@1agh/maude 1.0.11 → 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 (567) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/bridge.ts +18 -13
  3. package/apps/studio/acp/index.ts +43 -7
  4. package/apps/studio/acp/transcript-io.ts +394 -0
  5. package/apps/studio/acp/transcript.ts +103 -38
  6. package/apps/studio/annotations-layer.tsx +191 -116
  7. package/apps/studio/annotations-model.ts +39 -0
  8. package/apps/studio/annotations-sync.ts +50 -0
  9. package/apps/studio/api.ts +821 -55
  10. package/apps/studio/bin/_import-figma.mjs +57 -11
  11. package/apps/studio/bin/_seed-fixture.mjs +163 -0
  12. package/apps/studio/bin/annotate.mjs +3 -1
  13. package/apps/studio/bin/server-up.sh +17 -2
  14. package/apps/studio/canvas-edit.ts +391 -7
  15. package/apps/studio/canvas-imports.ts +89 -0
  16. package/apps/studio/canvas-lib.tsx +82 -18
  17. package/apps/studio/canvas-notice-message.ts +16 -0
  18. package/apps/studio/canvas-notifications.tsx +19 -0
  19. package/apps/studio/canvas-shell.tsx +57 -6
  20. package/apps/studio/client/app.jsx +943 -248
  21. package/apps/studio/client/apply-edit-request.ts +55 -0
  22. package/apps/studio/client/export-center.jsx +125 -67
  23. package/apps/studio/client/github.js +38 -1
  24. package/apps/studio/client/index-loader.ts +76 -0
  25. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  26. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  27. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  28. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  29. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  30. package/apps/studio/client/panels/SyncPanel.jsx +238 -4
  31. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  32. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  33. package/apps/studio/client/photo-knobs.jsx +3 -0
  34. package/apps/studio/client/report-bug.jsx +19 -1
  35. package/apps/studio/client/share-dialog.jsx +91 -0
  36. package/apps/studio/client/share-link.js +94 -0
  37. package/apps/studio/client/styles/3-shell-maude.css +69 -18
  38. package/apps/studio/client/styles/4-components.css +44 -1
  39. package/apps/studio/client/tree-row-menu.jsx +48 -3
  40. package/apps/studio/client/whats-new.jsx +26 -32
  41. package/apps/studio/cloud/endpoints.ts +132 -0
  42. package/apps/studio/collab/index.ts +4 -0
  43. package/apps/studio/collab/persistence.ts +137 -17
  44. package/apps/studio/collab/registry.ts +19 -21
  45. package/apps/studio/collab/room.ts +13 -2
  46. package/apps/studio/context.ts +59 -0
  47. package/apps/studio/debug-bundle.ts +13 -0
  48. package/apps/studio/dist/client.bundle.js +1911 -1879
  49. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  50. package/apps/studio/dist/runtime/sonner.js +1 -0
  51. package/apps/studio/dist/styles.css +1 -1
  52. package/apps/studio/export-dialog.tsx +54 -0
  53. package/apps/studio/exporters/degraded.ts +19 -3
  54. package/apps/studio/exporters/ghostscript.ts +194 -0
  55. package/apps/studio/exporters/pdf-fonts.ts +261 -0
  56. package/apps/studio/exporters/pdf.ts +152 -3
  57. package/apps/studio/exporters/remote.ts +36 -1
  58. package/apps/studio/fs-watch.ts +45 -1
  59. package/apps/studio/http.ts +296 -19
  60. package/apps/studio/inspect.ts +19 -0
  61. package/apps/studio/managed-projects.ts +150 -0
  62. package/apps/studio/mem.ts +45 -9
  63. package/apps/studio/notifications.tsx +292 -0
  64. package/apps/studio/runtime-bundle.ts +2 -0
  65. package/apps/studio/server.ts +69 -6
  66. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  67. package/apps/studio/sync/accepted-link.ts +320 -0
  68. package/apps/studio/sync/action-stage.ts +343 -0
  69. package/apps/studio/sync/agent.ts +104 -45
  70. package/apps/studio/sync/asset-push.ts +1 -47
  71. package/apps/studio/sync/cell-file-events.ts +3 -0
  72. package/apps/studio/sync/codec.ts +192 -34
  73. package/apps/studio/sync/cold-start-apply.ts +7 -0
  74. package/apps/studio/sync/cold-start.ts +118 -22
  75. package/apps/studio/sync/comment-identity.ts +68 -0
  76. package/apps/studio/sync/connection-state.ts +15 -1
  77. package/apps/studio/sync/ctl-provider.ts +15 -2
  78. package/apps/studio/sync/document-discovery.ts +81 -0
  79. package/apps/studio/sync/file-ledger.ts +117 -0
  80. package/apps/studio/sync/file-membership.ts +10 -2
  81. package/apps/studio/sync/file-plane.ts +926 -47
  82. package/apps/studio/sync/index.ts +1786 -36
  83. package/apps/studio/sync/journal-client.ts +13 -1
  84. package/apps/studio/sync/migrate-seed.ts +150 -22
  85. package/apps/studio/sync/poke.ts +4 -2
  86. package/apps/studio/sync/presentation.ts +311 -5
  87. package/apps/studio/sync/projection.ts +820 -23
  88. package/apps/studio/sync/repeated-module.ts +73 -0
  89. package/apps/studio/sync/retry-after.ts +169 -0
  90. package/apps/studio/sync/revision-barrier.ts +129 -0
  91. package/apps/studio/sync/seed-progress.ts +218 -0
  92. package/apps/studio/sync/seed-repair.ts +46 -0
  93. package/apps/studio/sync/source-merge.ts +100 -0
  94. package/apps/studio/sync/source-ops.ts +289 -0
  95. package/apps/studio/sync/source-recovery.ts +70 -0
  96. package/apps/studio/sync/source-validation.ts +56 -0
  97. package/apps/studio/sync/status.ts +201 -6
  98. package/apps/studio/sync/transaction-client.ts +460 -0
  99. package/apps/studio/sync/transport-error.ts +111 -0
  100. package/apps/studio/sync/writer-registry.ts +236 -0
  101. package/apps/studio/text-caret.ts +35 -0
  102. package/apps/studio/undo-hud.tsx +9 -87
  103. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  104. package/apps/studio/whats-new.json +173 -0
  105. package/cli/bin/maude.mjs +2 -0
  106. package/cli/commands/codex.mjs +191 -0
  107. package/cli/commands/harness.mjs +1051 -0
  108. package/cli/commands/help.mjs +13 -0
  109. package/cli/lib/design-link.mjs +190 -2
  110. package/cli/lib/harness/capabilities.mjs +302 -0
  111. package/cli/lib/harness/codex-runtime.mjs +892 -0
  112. package/cli/lib/harness/compatibility.mjs +27 -0
  113. package/cli/lib/harness/discover-claude.mjs +1393 -0
  114. package/cli/lib/harness/managed-state.mjs +1296 -0
  115. package/cli/lib/harness/model.mjs +86 -0
  116. package/cli/lib/harness/secrets.mjs +390 -0
  117. package/cli/lib/harness/targets/codex.mjs +1141 -0
  118. package/cli/lib/harness/targets/opencode.mjs +807 -0
  119. package/cli/lib/harness/transaction.mjs +1099 -0
  120. package/cli/lib/hubs-config.mjs +35 -8
  121. package/cli/templates/harness/opencode/maude-projector.ts +58 -0
  122. package/docs/harness-capability-matrix.md +125 -0
  123. package/docs/harness-environment-projection.md +95 -0
  124. package/package.json +33 -9
  125. package/plugins/design/dependencies.json +3 -3
  126. package/plugins/design/templates/_shell.html +47 -0
  127. package/plugins/flow/.claude-plugin/config.schema.json +8 -0
  128. package/plugins/flow/dependencies.json +3 -3
  129. package/apps/studio/.ai/cache/_stats.json +0 -7
  130. package/apps/studio/bin/_agent-browser-safe.test.mjs +0 -165
  131. package/apps/studio/bin/_curl-local.test.mjs +0 -280
  132. package/apps/studio/bin/_pw-launch.test.mjs +0 -90
  133. package/apps/studio/bin/_smart-frames.test.mjs +0 -195
  134. package/apps/studio/bin/_transcribe.test.mjs +0 -80
  135. package/apps/studio/bun.lock +0 -928
  136. package/apps/studio/draw/test/animate.test.ts +0 -172
  137. package/apps/studio/draw/test/brush.test.ts +0 -213
  138. package/apps/studio/draw/test/composition.test.ts +0 -141
  139. package/apps/studio/draw/test/geometry.test.ts +0 -199
  140. package/apps/studio/draw/test/gradient.test.ts +0 -167
  141. package/apps/studio/draw/test/layout.test.ts +0 -120
  142. package/apps/studio/draw/test/morph.test.ts +0 -107
  143. package/apps/studio/draw/test/optimize.test.ts +0 -40
  144. package/apps/studio/draw/test/palette.test.ts +0 -123
  145. package/apps/studio/draw/test/primitives.test.ts +0 -129
  146. package/apps/studio/draw/test/serialize-animate.test.ts +0 -142
  147. package/apps/studio/draw/test/serialize.test.ts +0 -105
  148. package/apps/studio/figma/assets.test.ts +0 -464
  149. package/apps/studio/figma/client.test.ts +0 -395
  150. package/apps/studio/figma/codegen-client.test.ts +0 -276
  151. package/apps/studio/figma/codegen-fonts.test.ts +0 -103
  152. package/apps/studio/figma/codegen-values.test.ts +0 -179
  153. package/apps/studio/figma/comments-to-strokes.test.ts +0 -194
  154. package/apps/studio/figma/fig-decode.test.ts +0 -788
  155. package/apps/studio/figma/fig-differential.test.ts +0 -182
  156. package/apps/studio/figma/fig-translator.test.ts +0 -192
  157. package/apps/studio/figma/fig-vector.test.ts +0 -113
  158. package/apps/studio/figma/from-codegen.test.ts +0 -408
  159. package/apps/studio/figma/sanitize.test.ts +0 -325
  160. package/apps/studio/figma/tailwind-map.test.ts +0 -142
  161. package/apps/studio/figma/to-artboard.test.ts +0 -808
  162. package/apps/studio/figma/to-render.test.ts +0 -180
  163. package/apps/studio/figma/to-strokes-roundtrip.test.ts +0 -152
  164. package/apps/studio/figma/to-strokes.test.ts +0 -705
  165. package/apps/studio/figma/to-tokens.test.ts +0 -321
  166. package/apps/studio/figma/url.test.ts +0 -167
  167. package/apps/studio/footage/schema.test.ts +0 -287
  168. package/apps/studio/generation/adapters/groq.test.ts +0 -80
  169. package/apps/studio/generation/audio-library.test.ts +0 -95
  170. package/apps/studio/generation/captions.test.ts +0 -78
  171. package/apps/studio/generation/elevenlabs.test.ts +0 -223
  172. package/apps/studio/generation/gemini.test.ts +0 -350
  173. package/apps/studio/generation/jobs.test.ts +0 -128
  174. package/apps/studio/generation/keys.test.ts +0 -75
  175. package/apps/studio/generation/prefs.test.ts +0 -95
  176. package/apps/studio/generation/types.test.ts +0 -123
  177. package/apps/studio/generation/whisper-models.test.ts +0 -179
  178. package/apps/studio/test/_helpers.ts +0 -108
  179. package/apps/studio/test/acp-activity-endpoint.test.ts +0 -183
  180. package/apps/studio/test/acp-activity.test.ts +0 -154
  181. package/apps/studio/test/acp-ai-activity.test.ts +0 -182
  182. package/apps/studio/test/acp-attachment-serve.test.ts +0 -74
  183. package/apps/studio/test/acp-bootstrap-brief.test.ts +0 -178
  184. package/apps/studio/test/acp-branch-guard.test.ts +0 -123
  185. package/apps/studio/test/acp-bridge-lifetime.test.ts +0 -475
  186. package/apps/studio/test/acp-bridge.test.ts +0 -545
  187. package/apps/studio/test/acp-capabilities.test.ts +0 -123
  188. package/apps/studio/test/acp-caps-bridge.test.ts +0 -279
  189. package/apps/studio/test/acp-commands.test.ts +0 -119
  190. package/apps/studio/test/acp-elicitation-bridge.test.ts +0 -480
  191. package/apps/studio/test/acp-elicitation.test.ts +0 -251
  192. package/apps/studio/test/acp-env.test.ts +0 -95
  193. package/apps/studio/test/acp-mode-banner.test.ts +0 -45
  194. package/apps/studio/test/acp-origin-gate.test.ts +0 -160
  195. package/apps/studio/test/acp-permission-prompt.test.ts +0 -251
  196. package/apps/studio/test/acp-permission.test.ts +0 -278
  197. package/apps/studio/test/acp-plugin-bootstrap.test.ts +0 -89
  198. package/apps/studio/test/acp-session-allowed-tools.test.ts +0 -235
  199. package/apps/studio/test/acp-session-plugins.test.ts +0 -209
  200. package/apps/studio/test/acp-toolgroup.test.ts +0 -76
  201. package/apps/studio/test/acp-transcript-view.test.ts +0 -71
  202. package/apps/studio/test/acp-transcript.test.ts +0 -238
  203. package/apps/studio/test/acp-usage-bridge.test.ts +0 -141
  204. package/apps/studio/test/acp-usage.test.ts +0 -143
  205. package/apps/studio/test/acp-write-gate.test.ts +0 -323
  206. package/apps/studio/test/acp-write-scope.test.ts +0 -533
  207. package/apps/studio/test/active-state.test.ts +0 -186
  208. package/apps/studio/test/activity.test.ts +0 -189
  209. package/apps/studio/test/ai-activity.test.ts +0 -113
  210. package/apps/studio/test/annotate-write.test.ts +0 -1074
  211. package/apps/studio/test/annotation-strokes-command.test.ts +0 -166
  212. package/apps/studio/test/annotations-align.test.ts +0 -88
  213. package/apps/studio/test/annotations-api.test.ts +0 -145
  214. package/apps/studio/test/annotations-bindings.test.ts +0 -262
  215. package/apps/studio/test/annotations-draw-modifiers.test.ts +0 -206
  216. package/apps/studio/test/annotations-groups.test.ts +0 -231
  217. package/apps/studio/test/annotations-layer.test.ts +0 -1011
  218. package/apps/studio/test/annotations-roundtrip.test.ts +0 -663
  219. package/apps/studio/test/annotations-snap.test.ts +0 -135
  220. package/apps/studio/test/annotations-text-format.test.ts +0 -396
  221. package/apps/studio/test/artboard-activity-overlay.test.tsx +0 -56
  222. package/apps/studio/test/artboard-guides-overlay.test.tsx +0 -152
  223. package/apps/studio/test/artboard-hug-commit.test.ts +0 -36
  224. package/apps/studio/test/artboard-kinds.test.tsx +0 -83
  225. package/apps/studio/test/artboard-selection-attrs.test.ts +0 -69
  226. package/apps/studio/test/asset-api.test.ts +0 -149
  227. package/apps/studio/test/assets-s3.test.ts +0 -249
  228. package/apps/studio/test/binary-smoke.test.ts +0 -47
  229. package/apps/studio/test/boot-self-heal.test.ts +0 -87
  230. package/apps/studio/test/browse-posture.test.tsx +0 -107
  231. package/apps/studio/test/bundle-smoke.test.ts +0 -46
  232. package/apps/studio/test/camera-reveal.test.tsx +0 -173
  233. package/apps/studio/test/canvas-artifacts.test.ts +0 -125
  234. package/apps/studio/test/canvas-build-sandbox.test.ts +0 -69
  235. package/apps/studio/test/canvas-build.test.ts +0 -141
  236. package/apps/studio/test/canvas-create-api.test.ts +0 -676
  237. package/apps/studio/test/canvas-cursors.test.ts +0 -164
  238. package/apps/studio/test/canvas-edit.test.ts +0 -470
  239. package/apps/studio/test/canvas-freshness-deps.test.ts +0 -64
  240. package/apps/studio/test/canvas-header.test.ts +0 -127
  241. package/apps/studio/test/canvas-hide-chrome.test.ts +0 -88
  242. package/apps/studio/test/canvas-hmr-runtime.test.tsx +0 -114
  243. package/apps/studio/test/canvas-lib-inline.test.ts +0 -146
  244. package/apps/studio/test/canvas-lib-motion.test.ts +0 -131
  245. package/apps/studio/test/canvas-lib-resolver.test.ts +0 -111
  246. package/apps/studio/test/canvas-list-watch.test.ts +0 -388
  247. package/apps/studio/test/canvas-media-drop.test.ts +0 -187
  248. package/apps/studio/test/canvas-meta-api.test.ts +0 -707
  249. package/apps/studio/test/canvas-move-api.test.ts +0 -587
  250. package/apps/studio/test/canvas-origin-gate.test.ts +0 -417
  251. package/apps/studio/test/canvas-pipeline.test.ts +0 -180
  252. package/apps/studio/test/canvas-rects.test.ts +0 -198
  253. package/apps/studio/test/canvas-reorder.test.ts +0 -211
  254. package/apps/studio/test/canvas-route.test.ts +0 -179
  255. package/apps/studio/test/canvas-shell-base.test.ts +0 -107
  256. package/apps/studio/test/canvas-shell-build-error.test.ts +0 -52
  257. package/apps/studio/test/canvas-url.test.ts +0 -138
  258. package/apps/studio/test/canvas-wheel-delta.test.ts +0 -42
  259. package/apps/studio/test/canvas-zoom-floor.test.ts +0 -73
  260. package/apps/studio/test/capture-core.test.ts +0 -32
  261. package/apps/studio/test/capture-determinism-shape.test.ts +0 -135
  262. package/apps/studio/test/chat-attachments.test.ts +0 -117
  263. package/apps/studio/test/chat-context.test.ts +0 -129
  264. package/apps/studio/test/chat-markdown.test.tsx +0 -58
  265. package/apps/studio/test/clip-addressing.test.ts +0 -738
  266. package/apps/studio/test/clip-ops.test.ts +0 -922
  267. package/apps/studio/test/cloud-build.test.ts +0 -95
  268. package/apps/studio/test/cloud-connect-note.test.ts +0 -135
  269. package/apps/studio/test/cloud-endpoints.test.ts +0 -590
  270. package/apps/studio/test/cloud-history-hardening.test.ts +0 -165
  271. package/apps/studio/test/cloud-history-posture.test.ts +0 -230
  272. package/apps/studio/test/cloud-managed-save-surfaces.test.ts +0 -90
  273. package/apps/studio/test/cloud-renew.test.ts +0 -205
  274. package/apps/studio/test/cloud-session-role.test.ts +0 -161
  275. package/apps/studio/test/cloud-shell-surfaces.test.ts +0 -170
  276. package/apps/studio/test/cold-start-apply.test.ts +0 -303
  277. package/apps/studio/test/collab-annotations-bridge.test.ts +0 -55
  278. package/apps/studio/test/collab-awareness-bridge.test.ts +0 -223
  279. package/apps/studio/test/collab-bridge.test.ts +0 -79
  280. package/apps/studio/test/collab-loopback.test.ts +0 -63
  281. package/apps/studio/test/collab-origin-gate.test.ts +0 -323
  282. package/apps/studio/test/collab-protocol.test.ts +0 -146
  283. package/apps/studio/test/collab-readonly-gate.test.ts +0 -194
  284. package/apps/studio/test/collab-reseed-guard.test.ts +0 -78
  285. package/apps/studio/test/collab-room.test.ts +0 -188
  286. package/apps/studio/test/collab-session-survival.test.tsx +0 -176
  287. package/apps/studio/test/collab-stress.test.ts +0 -129
  288. package/apps/studio/test/comment-mount.test.ts +0 -97
  289. package/apps/studio/test/comment-relay-origin-gate.test.ts +0 -117
  290. package/apps/studio/test/comments-api.test.ts +0 -277
  291. package/apps/studio/test/comments-fs-rebroadcast.test.ts +0 -155
  292. package/apps/studio/test/comments-overlay.test.ts +0 -117
  293. package/apps/studio/test/compile-entry.test.ts +0 -133
  294. package/apps/studio/test/component-map.test.ts +0 -48
  295. package/apps/studio/test/config-projection.test.ts +0 -104
  296. package/apps/studio/test/config-reload.test.ts +0 -230
  297. package/apps/studio/test/config-version.test.ts +0 -88
  298. package/apps/studio/test/context-resolve-tokens.test.ts +0 -97
  299. package/apps/studio/test/convert-to-absolute.test.ts +0 -333
  300. package/apps/studio/test/credential-grammar.test.ts +0 -107
  301. package/apps/studio/test/csp-canvas-shell.test.ts +0 -62
  302. package/apps/studio/test/csrf-write-guard.test.ts +0 -141
  303. package/apps/studio/test/debug-bundle.test.ts +0 -105
  304. package/apps/studio/test/design-setup-readiness.test.ts +0 -155
  305. package/apps/studio/test/detach-component.test.ts +0 -94
  306. package/apps/studio/test/dns-rebinding-guard.test.ts +0 -101
  307. package/apps/studio/test/dom-selection.test.ts +0 -198
  308. package/apps/studio/test/dynamic-text-edit.test.ts +0 -162
  309. package/apps/studio/test/edit-css-occurrence.test.ts +0 -81
  310. package/apps/studio/test/edit-persistence.test.ts +0 -91
  311. package/apps/studio/test/edit-scope-api.test.ts +0 -119
  312. package/apps/studio/test/edit-source-command.test.ts +0 -142
  313. package/apps/studio/test/edit-suppress.test.ts +0 -170
  314. package/apps/studio/test/editing-presence.test.ts +0 -103
  315. package/apps/studio/test/element-resize.test.ts +0 -136
  316. package/apps/studio/test/element-structural-api.test.ts +0 -434
  317. package/apps/studio/test/element-structural-edit.test.ts +0 -750
  318. package/apps/studio/test/ensure-browser.test.ts +0 -92
  319. package/apps/studio/test/equal-spacing-detector.test.ts +0 -257
  320. package/apps/studio/test/export-assemble.test.ts +0 -53
  321. package/apps/studio/test/export-browser-lane.test.ts +0 -126
  322. package/apps/studio/test/export-capture-fidelity.test.ts +0 -146
  323. package/apps/studio/test/export-capture-hygiene.test.ts +0 -675
  324. package/apps/studio/test/export-center.test.tsx +0 -287
  325. package/apps/studio/test/export-e2e-lanes.test.ts +0 -857
  326. package/apps/studio/test/export-format-scope-coherence.test.ts +0 -89
  327. package/apps/studio/test/export-lane.test.ts +0 -245
  328. package/apps/studio/test/export-shim-multi-capture.test.ts +0 -83
  329. package/apps/studio/test/exporters/canva.test.ts +0 -63
  330. package/apps/studio/test/exporters/degraded-propagation.test.ts +0 -123
  331. package/apps/studio/test/exporters/endpoint.test.ts +0 -121
  332. package/apps/studio/test/exporters/history.test.ts +0 -107
  333. package/apps/studio/test/exporters/html.test.ts +0 -26
  334. package/apps/studio/test/exporters/jobs.test.ts +0 -342
  335. package/apps/studio/test/exporters/pdf.test.ts +0 -85
  336. package/apps/studio/test/exporters/png.test.ts +0 -103
  337. package/apps/studio/test/exporters/pptx-deck.test.ts +0 -112
  338. package/apps/studio/test/exporters/pptx.test.ts +0 -31
  339. package/apps/studio/test/exporters/pw-launch.test.ts +0 -49
  340. package/apps/studio/test/exporters/runtime.test.ts +0 -59
  341. package/apps/studio/test/exporters/scope.test.ts +0 -292
  342. package/apps/studio/test/exporters/svg.test.ts +0 -29
  343. package/apps/studio/test/exporters/unsupported-media.test.ts +0 -123
  344. package/apps/studio/test/exporters/zip.test.ts +0 -104
  345. package/apps/studio/test/fetch-asset-gate.test.ts +0 -189
  346. package/apps/studio/test/figjam-v3-model.test.ts +0 -342
  347. package/apps/studio/test/figma-explode.test.ts +0 -438
  348. package/apps/studio/test/figma-provenance.test.ts +0 -108
  349. package/apps/studio/test/figma-routes.test.ts +0 -294
  350. package/apps/studio/test/file-lock.test.ts +0 -84
  351. package/apps/studio/test/fixtures/backgrounding-claude-auth.sh +0 -12
  352. package/apps/studio/test/fixtures/fake-claude-auth.mjs +0 -23
  353. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +0 -1
  354. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +0 -158
  355. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +0 -44
  356. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +0 -46
  357. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +0 -42
  358. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +0 -63
  359. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +0 -51
  360. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +0 -50
  361. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +0 -45
  362. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +0 -56
  363. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +0 -37
  364. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +0 -118
  365. package/apps/studio/test/fixtures/mock-acp-agent.mjs +0 -69
  366. package/apps/studio/test/fixtures/noisy-claude-auth.mjs +0 -27
  367. package/apps/studio/test/fixtures/perf-canvas.mjs +0 -201
  368. package/apps/studio/test/fixtures/phase-20-annotations.svg +0 -1
  369. package/apps/studio/test/fixtures/phase-21-annotations.svg +0 -1
  370. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  371. package/apps/studio/test/fixtures/video-comp-fixture.tsx +0 -82
  372. package/apps/studio/test/fixtures/wedged-claude-auth.sh +0 -11
  373. package/apps/studio/test/footage-store.test.ts +0 -100
  374. package/apps/studio/test/fs-mkdir-api.test.ts +0 -248
  375. package/apps/studio/test/fs-watch.test.ts +0 -41
  376. package/apps/studio/test/gemma-models.test.ts +0 -245
  377. package/apps/studio/test/generate-route.test.ts +0 -106
  378. package/apps/studio/test/git-api.test.ts +0 -0
  379. package/apps/studio/test/git-branches.test.ts +0 -341
  380. package/apps/studio/test/git-cloud-posture.test.ts +0 -59
  381. package/apps/studio/test/git-grouping.test.ts +0 -106
  382. package/apps/studio/test/git-lifecycle.test.ts +0 -100
  383. package/apps/studio/test/git-log-format.test.ts +0 -95
  384. package/apps/studio/test/git-safe-rel.test.ts +0 -132
  385. package/apps/studio/test/git-system-engine.test.ts +0 -135
  386. package/apps/studio/test/git-watch.test.ts +0 -97
  387. package/apps/studio/test/github-api.test.ts +0 -577
  388. package/apps/studio/test/grid-track-handles.test.ts +0 -160
  389. package/apps/studio/test/handoff-static-frames.test.ts +0 -215
  390. package/apps/studio/test/handoff.test.ts +0 -329
  391. package/apps/studio/test/history-rollback.test.ts +0 -88
  392. package/apps/studio/test/hmr-broadcast.test.ts +0 -236
  393. package/apps/studio/test/hmr-classify.test.ts +0 -87
  394. package/apps/studio/test/hub-link.test.ts +0 -80
  395. package/apps/studio/test/identity-cache.test.ts +0 -83
  396. package/apps/studio/test/import-asset-browser.test.ts +0 -64
  397. package/apps/studio/test/import-asset-route.test.ts +0 -71
  398. package/apps/studio/test/import-asset.test.ts +0 -427
  399. package/apps/studio/test/import-brand-browser.test.ts +0 -88
  400. package/apps/studio/test/import-brand-route.test.ts +0 -120
  401. package/apps/studio/test/import-brand.test.ts +0 -206
  402. package/apps/studio/test/import-figma.test.ts +0 -667
  403. package/apps/studio/test/import-tokens.test.ts +0 -722
  404. package/apps/studio/test/index-data-canvas-ds.test.ts +0 -97
  405. package/apps/studio/test/input-router.test.ts +0 -757
  406. package/apps/studio/test/inspect-script-syntax.test.ts +0 -53
  407. package/apps/studio/test/inspect-selections.test.ts +0 -219
  408. package/apps/studio/test/knob-props-authored.test.ts +0 -87
  409. package/apps/studio/test/layers-synthetic-groups.test.ts +0 -96
  410. package/apps/studio/test/locator.test.ts +0 -214
  411. package/apps/studio/test/marquee-overlay.test.ts +0 -94
  412. package/apps/studio/test/media-commit-chain.test.ts +0 -210
  413. package/apps/studio/test/mode-toggle.test.tsx +0 -226
  414. package/apps/studio/test/motion-sample.test.ts +0 -35
  415. package/apps/studio/test/move-artboards-command.test.ts +0 -108
  416. package/apps/studio/test/participants-chrome.test.ts +0 -65
  417. package/apps/studio/test/paths.test.ts +0 -88
  418. package/apps/studio/test/pdf-print-boxes.test.ts +0 -359
  419. package/apps/studio/test/peer-selection-follows-camera.test.tsx +0 -131
  420. package/apps/studio/test/perf-harness.ts +0 -193
  421. package/apps/studio/test/phase-3.6-smoke.test.ts +0 -79
  422. package/apps/studio/test/photo-bg-remove-validation.test.ts +0 -71
  423. package/apps/studio/test/photo-canvas-bundle.test.ts +0 -79
  424. package/apps/studio/test/photo-edit-api.test.ts +0 -201
  425. package/apps/studio/test/photo-filters.test.ts +0 -149
  426. package/apps/studio/test/photo-pipeline.test.ts +0 -106
  427. package/apps/studio/test/photo-taxonomy.test.ts +0 -96
  428. package/apps/studio/test/print-marks.test.ts +0 -113
  429. package/apps/studio/test/print-units.test.ts +0 -173
  430. package/apps/studio/test/read-annotations.test.ts +0 -727
  431. package/apps/studio/test/read-only-gate.test.ts +0 -188
  432. package/apps/studio/test/read-only-role.test.ts +0 -95
  433. package/apps/studio/test/readiness.test.ts +0 -337
  434. package/apps/studio/test/remote-ahead-behind-cache.test.ts +0 -89
  435. package/apps/studio/test/reorder-api.test.ts +0 -210
  436. package/apps/studio/test/repo-concurrency.test.ts +0 -146
  437. package/apps/studio/test/repo-lock.test.ts +0 -350
  438. package/apps/studio/test/report-proxy.test.ts +0 -125
  439. package/apps/studio/test/ripple.test.ts +0 -197
  440. package/apps/studio/test/runtime-bundle-error-mapping.test.ts +0 -43
  441. package/apps/studio/test/runtime-bundle.test.ts +0 -69
  442. package/apps/studio/test/sanitize-annotation-svg.test.ts +0 -238
  443. package/apps/studio/test/scaffold-design.test.ts +0 -122
  444. package/apps/studio/test/server-lifecycle.test.ts +0 -38
  445. package/apps/studio/test/session-runtime-state.test.ts +0 -226
  446. package/apps/studio/test/session-scope.test.ts +0 -100
  447. package/apps/studio/test/shared-doc-cell-pairing.test.ts +0 -642
  448. package/apps/studio/test/shared-doc-convergence.test.ts +0 -265
  449. package/apps/studio/test/shared-doc-foundation.test.ts +0 -63
  450. package/apps/studio/test/shared-doc-migrate.test.ts +0 -369
  451. package/apps/studio/test/shared-doc-projection.test.ts +0 -238
  452. package/apps/studio/test/shell-importmap.test.ts +0 -56
  453. package/apps/studio/test/sizing-mode.test.ts +0 -102
  454. package/apps/studio/test/slash-commands.test.ts +0 -117
  455. package/apps/studio/test/snap-distance-pill.test.ts +0 -68
  456. package/apps/studio/test/spacing-handles.test.ts +0 -138
  457. package/apps/studio/test/specimen-select.test.ts +0 -88
  458. package/apps/studio/test/sync-agent.test.ts +0 -660
  459. package/apps/studio/test/sync-annotations-cold-start.test.ts +0 -302
  460. package/apps/studio/test/sync-asset-push.test.ts +0 -931
  461. package/apps/studio/test/sync-atomic-write.test.ts +0 -62
  462. package/apps/studio/test/sync-attach-incremental.test.ts +0 -527
  463. package/apps/studio/test/sync-autocommit.test.ts +0 -537
  464. package/apps/studio/test/sync-canvas-path.test.ts +0 -200
  465. package/apps/studio/test/sync-cell-write-nudge.test.ts +0 -346
  466. package/apps/studio/test/sync-codec.test.ts +0 -230
  467. package/apps/studio/test/sync-cold-seed-dedup.test.ts +0 -187
  468. package/apps/studio/test/sync-cold-start.test.ts +0 -304
  469. package/apps/studio/test/sync-connect-honesty.test.ts +0 -114
  470. package/apps/studio/test/sync-connection-state.test.ts +0 -337
  471. package/apps/studio/test/sync-ctl-channel.test.ts +0 -508
  472. package/apps/studio/test/sync-decide-file.test.ts +0 -420
  473. package/apps/studio/test/sync-doc-name.test.ts +0 -281
  474. package/apps/studio/test/sync-echo-guard.test.ts +0 -96
  475. package/apps/studio/test/sync-file-ledger.test.ts +0 -334
  476. package/apps/studio/test/sync-file-membership.test.ts +0 -347
  477. package/apps/studio/test/sync-file-plane.test.ts +0 -976
  478. package/apps/studio/test/sync-file-pull.test.ts +0 -333
  479. package/apps/studio/test/sync-fresh-link-parity.test.ts +0 -267
  480. package/apps/studio/test/sync-fs-mirror.test.ts +0 -181
  481. package/apps/studio/test/sync-hardening.test.ts +0 -586
  482. package/apps/studio/test/sync-hub-listing.test.ts +0 -46
  483. package/apps/studio/test/sync-hubs-config.test.ts +0 -133
  484. package/apps/studio/test/sync-incident-replay.test.ts +0 -211
  485. package/apps/studio/test/sync-journal.test.ts +0 -176
  486. package/apps/studio/test/sync-meta-codec.test.ts +0 -199
  487. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +0 -98
  488. package/apps/studio/test/sync-move-retirement.test.ts +0 -231
  489. package/apps/studio/test/sync-panel-surface.test.ts +0 -153
  490. package/apps/studio/test/sync-path-pull.test.ts +0 -531
  491. package/apps/studio/test/sync-presentation.test.ts +0 -262
  492. package/apps/studio/test/sync-pull-budget.test.ts +0 -169
  493. package/apps/studio/test/sync-remote-docs.test.ts +0 -333
  494. package/apps/studio/test/sync-resync-routes.test.ts +0 -168
  495. package/apps/studio/test/sync-runtime.test.ts +0 -1675
  496. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +0 -83
  497. package/apps/studio/test/sync-settings-routes.test.ts +0 -195
  498. package/apps/studio/test/sync-settings.test.ts +0 -151
  499. package/apps/studio/test/sync-status.test.ts +0 -261
  500. package/apps/studio/test/sync-supervisor.test.ts +0 -262
  501. package/apps/studio/test/sync-tombstone-apply.test.ts +0 -111
  502. package/apps/studio/test/sync-trash.test.ts +0 -132
  503. package/apps/studio/test/sync-two-peer-discovery.test.ts +0 -343
  504. package/apps/studio/test/sync-untrusted.test.ts +0 -103
  505. package/apps/studio/test/system-endpoint.test.ts +0 -144
  506. package/apps/studio/test/text-editability-stamp.test.ts +0 -131
  507. package/apps/studio/test/timeline-comp-target.test.ts +0 -139
  508. package/apps/studio/test/timeline-media-cache.test.ts +0 -41
  509. package/apps/studio/test/timeline-parse.test.ts +0 -184
  510. package/apps/studio/test/timeline-scale.test.ts +0 -65
  511. package/apps/studio/test/timeline-snap.test.ts +0 -85
  512. package/apps/studio/test/tool-palette-insert-anchor.test.ts +0 -86
  513. package/apps/studio/test/tool-palette.test.tsx +0 -55
  514. package/apps/studio/test/tour-overlay.test.tsx +0 -90
  515. package/apps/studio/test/trusted-request-host.test.ts +0 -66
  516. package/apps/studio/test/undo-sequence-byte-compare.test.ts +0 -211
  517. package/apps/studio/test/undo-stack.test.ts +0 -244
  518. package/apps/studio/test/use-agent-presence.test.tsx +0 -117
  519. package/apps/studio/test/use-annotation-resize.test.ts +0 -402
  520. package/apps/studio/test/use-annotation-selection.test.tsx +0 -77
  521. package/apps/studio/test/use-artboard-drag.test.ts +0 -324
  522. package/apps/studio/test/use-canvas-activity.test.tsx +0 -206
  523. package/apps/studio/test/use-collab.test.ts +0 -0
  524. package/apps/studio/test/use-cursor-modifiers.test.ts +0 -59
  525. package/apps/studio/test/use-keyboard-discipline.test.ts +0 -27
  526. package/apps/studio/test/use-selection-set.test.tsx +0 -166
  527. package/apps/studio/test/use-snap-guides.test.ts +0 -271
  528. package/apps/studio/test/use-tool-mode.test.tsx +0 -186
  529. package/apps/studio/test/use-undo-stack.test.tsx +0 -223
  530. package/apps/studio/test/video-asset.test.ts +0 -163
  531. package/apps/studio/test/video-comp-fixture.test.ts +0 -50
  532. package/apps/studio/test/video-comp.test.ts +0 -270
  533. package/apps/studio/test/video-encode-lib.test.ts +0 -63
  534. package/apps/studio/test/video-render-bridge.test.ts +0 -171
  535. package/apps/studio/test/whats-new.test.ts +0 -113
  536. package/apps/studio/test/whisper-setup.test.ts +0 -97
  537. package/apps/studio/test/workspace-containment.test.ts +0 -382
  538. package/apps/studio/test/workspace-signin.test.ts +0 -292
  539. package/apps/studio/test/ws-handshake.test.ts +0 -33
  540. package/cli/commands/cache.test.mjs +0 -166
  541. package/cli/commands/design-link.test.mjs +0 -419
  542. package/cli/commands/design.test.mjs +0 -240
  543. package/cli/commands/doctor.test.mjs +0 -237
  544. package/cli/commands/hub-workspace.test.mjs +0 -275
  545. package/cli/commands/hub.test.mjs +0 -225
  546. package/cli/commands/kg.test.mjs +0 -142
  547. package/cli/commands/preflight.test.mjs +0 -46
  548. package/cli/lib/argv.test.mjs +0 -46
  549. package/cli/lib/cache.test.mjs +0 -303
  550. package/cli/lib/cell-plan.test.mjs +0 -225
  551. package/cli/lib/config-lint.test.mjs +0 -117
  552. package/cli/lib/ddr-to-kgai.test.mjs +0 -118
  553. package/cli/lib/design-ownership.test.mjs +0 -329
  554. package/cli/lib/fetch-asset.test.mjs +0 -213
  555. package/cli/lib/figma-codegen-reachability.test.mjs +0 -104
  556. package/cli/lib/figma-import-controls.test.mjs +0 -70
  557. package/cli/lib/flow-design-integration.test.mjs +0 -165
  558. package/cli/lib/gitignore-block.test.mjs +0 -136
  559. package/cli/lib/gitignore-drift.test.mjs +0 -156
  560. package/cli/lib/hubs-config.test.mjs +0 -165
  561. package/cli/lib/pkg-root.test.mjs +0 -111
  562. package/cli/lib/plugin-cli-reachability.test.mjs +0 -57
  563. package/cli/lib/plugin-name-namespace.test.mjs +0 -71
  564. package/cli/lib/reconstruct-toolset.test.mjs +0 -194
  565. package/cli/lib/stack-detect.test.mjs +0 -121
  566. package/cli/lib/update-check.test.mjs +0 -32
  567. package/cli/lib/workspace-plan.test.mjs +0 -699
@@ -37,9 +37,11 @@ import {
37
37
  applyMetaToDoc,
38
38
  cssFromDoc,
39
39
  htmlFromDoc,
40
+ laneValueFromFile,
40
41
  mergeSharedMetaIntoLocal,
41
42
  metaFromDoc,
42
43
  movedToFromDoc,
44
+ readLaneFromDoc,
43
45
  stampAnnotationsEdit,
44
46
  stampBodyEdit,
45
47
  } from './codec.ts';
@@ -47,8 +49,29 @@ import { type EchoGuard, hashBytes } from './echo-guard.ts';
47
49
  import type { SyncJournal } from './journal.ts';
48
50
  import { MAX_CSS_BYTES, MAX_HTML_BYTES, MAX_META_BYTES, withinByteCap } from './limits.ts';
49
51
  import { ORIGINS } from './origins.ts';
52
+ import { collapseRepeatedModule, collapseRepeatedText } from './repeated-module.ts';
53
+ import type { RevisionBarrier } from './revision-barrier.ts';
54
+ import { repairSeedDuplication } from './seed-repair.ts';
55
+ import { mergeSource } from './source-merge.ts';
56
+ import type { SourceOp } from './source-ops.ts';
57
+ import { saveRecoveryBody } from './source-recovery.ts';
58
+ import { sourceError } from './source-validation.ts';
59
+ import { laneHash } from './transaction-client.ts';
50
60
 
51
61
  export const PROJECT_FLUSH_MS = 800;
62
+ /**
63
+ * Accepted revisions arrive as whole, validated states — not a keystroke
64
+ * stream from a peer's editor that the 800 ms debounce exists to coalesce — so
65
+ * the receiving disk (and the canvas that renders from it) follows almost at
66
+ * once.
67
+ */
68
+ export const ACCEPTED_FLUSH_MS = 30;
69
+ /** T24 — how many times one UI operation is re-applied after losing races. */
70
+ export const MAX_OP_REPLAYS = 3;
71
+ /** How long an announced API write may hold the doc→file writer at most. */
72
+ export const LOCAL_WRITE_HOLD_MS = 3_000;
73
+ /** `MAUDE_SYNC_DEBUG=1` — one line per proposal and per disk write (diagnosis). */
74
+ const SYNC_DEBUG = process.env.MAUDE_SYNC_DEBUG === '1';
52
75
  export const CIRCUIT_MAX_STRIKES = 3;
53
76
 
54
77
  export interface ProjectionPaths {
@@ -64,6 +87,13 @@ export interface ProjectionPaths {
64
87
  css?: string;
65
88
  }
66
89
 
90
+ export interface BodyRejection {
91
+ slug: string;
92
+ kind: 'body-rejected';
93
+ reason: 'invalid-source' | 'local-edit' | 'history-failed' | 'merge-budget';
94
+ snapshotFailed: boolean;
95
+ }
96
+
67
97
  export interface DocProjectionOptions {
68
98
  slug: string;
69
99
  doc: Y.Doc;
@@ -97,6 +127,77 @@ export interface DocProjectionOptions {
97
127
  /** DDR-102 — per-machine sync journal; every successful disk↔doc body/css
98
128
  * traversal checkpoints here (same discipline as the agent). Optional. */
99
129
  journal?: SyncJournal;
130
+ /** Bounded recovery slots, separate from rolling history. */
131
+ historyDir?: string;
132
+ onConflict?: (info: BodyRejection) => void;
133
+ onRecovered?: () => void;
134
+ /** Runtime waits for cold-start snapshots before allowing any projection. */
135
+ waitForReconcile?: boolean;
136
+ /**
137
+ * ACCEPTED-REVISIONS MODE (DDR-241). While `accepted.on()` holds, a local
138
+ * change is never written into the shared document: it is PROPOSED with the
139
+ * value it was derived from, and the document changes only when the hub
140
+ * publishes the accepted (possibly merged) revision. The file is the
141
+ * candidate until then; a rejection keeps it and reports a conflict.
142
+ */
143
+ accepted?: AcceptedLaneLink;
144
+ /**
145
+ * Plan T14 — the revision barrier shared by every projection of this
146
+ * runtime: a document stamped with a multi-document revision is written to
147
+ * disk together with the rest of that revision (see revision-barrier.ts).
148
+ */
149
+ revisionBarrier?: RevisionBarrier;
150
+ /** T24 — re-apply a UI operation onto the version that won (sync/source-ops). */
151
+ replayOp?: (
152
+ op: SourceOp,
153
+ head: string
154
+ ) => { ok: true; source: string } | { ok: false; reason: string };
155
+ /** T29 — this checkout now holds a document at this accepted revision. */
156
+ onRevisionApplied?: (revision: number) => void;
157
+ /** T26 — a lane value of ours was accepted as this action. */
158
+ onAccepted?: (info: { lane: ProposalLane; value: string; actionId: string }) => void;
159
+ /**
160
+ * May a write to the shared document reach the hub right now? When false a
161
+ * file change is HELD (not imported) and `onWriteBlocked` fires; the runtime
162
+ * re-delivers it via `retryDeferred()` once the connection is writable or
163
+ * the project turns out to be in accepted-revisions mode.
164
+ */
165
+ canWriteDoc?: () => boolean;
166
+ onWriteBlocked?: () => void;
167
+ }
168
+
169
+ export type ProposalLane = 'html' | 'css' | 'meta' | 'annotations' | 'comments';
170
+ export interface ProposalOutcome {
171
+ status: 'accepted' | 'rejected';
172
+ code?: string;
173
+ /** On a base conflict: the hash of the accepted value that won. */
174
+ head?: string;
175
+ /** On acceptance: the project action that carries it (T26 — Cmd+Z binds to it). */
176
+ actionId?: string;
177
+ }
178
+
179
+ export interface LaneProposal {
180
+ lane: ProposalLane;
181
+ content: string;
182
+ /** The value this edit was derived from — the hub merges three-way from it. */
183
+ baseContent: string;
184
+ writeId?: string;
185
+ transactionId: string;
186
+ /** An earlier proposal of the same lane this one was authored on top of. */
187
+ dependsOn?: string[];
188
+ /**
189
+ * T16 — a change a TOOL wrote (watcher import, or a cold-start difference):
190
+ * joins an open AI action instead of going out on its own. A change the
191
+ * person made through the UI is never stageable.
192
+ */
193
+ stageable?: boolean;
194
+ }
195
+
196
+ export interface AcceptedLaneLink {
197
+ /** Is the project in accepted-revisions mode right now? */
198
+ on(): boolean;
199
+ newTransactionId(): string;
200
+ propose(p: LaneProposal): Promise<ProposalOutcome>;
100
201
  }
101
202
 
102
203
  export interface DocProjection {
@@ -115,6 +216,58 @@ export interface DocProjection {
115
216
  reconcile(): void;
116
217
  /** Force the pending doc→file flush immediately. */
117
218
  flush(): Promise<void>;
219
+ /**
220
+ * Adopt `body` as the shared base before `reconcile()`. A restart that could
221
+ * not merge a local candidate (cold start) hands the base back here, so the
222
+ * write stays blocked with a visible conflict and a later save can merge.
223
+ */
224
+ adoptBase(body: string): void;
225
+ /**
226
+ * Accepted-revisions mode: propose one lane value that did not come through
227
+ * the watcher (a comment/annotation API write). Resolves with the outcome;
228
+ * `null` when the projection is not in accepted mode.
229
+ */
230
+ proposeLane(
231
+ lane: ProposalLane,
232
+ value: string,
233
+ opts?: { baseContent?: string; writeId?: string; stageable?: boolean }
234
+ ): Promise<ProposalOutcome> | null;
235
+ /** Lanes with an unresolved proposal (status surfaces). */
236
+ pendingCount(): number;
237
+ /**
238
+ * T28 — resolve a held source conflict by taking the project's version:
239
+ * the accepted body returns to disk; the local candidate is already in the
240
+ * recovery slots. Returns false when nothing is held.
241
+ */
242
+ takeAccepted(): boolean;
243
+ /** T28 — the two sides of a held source conflict (null when none). */
244
+ conflictSides(): { mine: string | null; theirs: string } | null;
245
+ /** Re-deliver file changes held while the document was not writable. */
246
+ retryDeferred(): void;
247
+ /**
248
+ * Accepted mode: a privileged API route is about to rewrite the canvas
249
+ * source (it announces this with `activity:suppress`). Captures the exact
250
+ * bytes that edit is based on, and holds the doc→file writer until the
251
+ * edit's own file event has been proposed — so a peer revision landing in
252
+ * the watcher's quiet window can neither overwrite the edit nor read it as
253
+ * a conflicting stale local change.
254
+ */
255
+ noteLocalWrite(): void;
256
+ /**
257
+ * T24 — the API write in flight is this UI operation (see sync/source-ops):
258
+ * if its proposal loses a race, the operation is re-applied onto the
259
+ * version that won and proposed again, instead of becoming a conflict.
260
+ */
261
+ noteSourceOp(op: SourceOp): void;
262
+ /** The announced write did not happen (no-op or failure). */
263
+ cancelLocalWrite(): void;
264
+ /**
265
+ * Accepted-revisions cold start: disk differs from the accepted value and
266
+ * nothing proves what it was derived from. Keep both — block the lane's
267
+ * writer, report the conflict, and let the next save (based on `base`, the
268
+ * accepted value shown in the conflict) resolve it.
269
+ */
270
+ hold(lane: ProposalLane, base: string, local: string): void;
118
271
  /** Stop the doc listener + timers. */
119
272
  stop(): void;
120
273
  /** Test/inspection — the origin used on file→doc imports. */
@@ -131,6 +284,66 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
131
284
  let started = false;
132
285
  let stopped = false;
133
286
  let dirty = false;
287
+ let ready = !opts.waitForReconcile;
288
+ let observedBody = readLocal(paths.html);
289
+ let rejectedKey: string | null = null;
290
+ let validationCache: { body: string; error: string | null } | null = null;
291
+
292
+ function recovered(): void {
293
+ if (rejectedKey !== null) opts.onRecovered?.();
294
+ rejectedKey = null;
295
+ }
296
+
297
+ function validation(body: string): string | null {
298
+ if (validationCache?.body === body) return validationCache.error;
299
+ const error = sourceError(paths.html, body);
300
+ validationCache = { body, error };
301
+ return error;
302
+ }
303
+
304
+ /** Persist the agreed body beside the journal checkpoint (best-effort). */
305
+ function rememberBase(body: string): void {
306
+ if (!opts.historyDir) return;
307
+ try {
308
+ saveRecoveryBody(opts.historyDir, paths.html, 'base', body);
309
+ } catch {
310
+ /* a restart then falls back to newest-wins — the pre-existing behaviour */
311
+ }
312
+ }
313
+
314
+ function preserveLocal(body: string | null): void {
315
+ if (opts.historyDir && body?.trim()) {
316
+ // An invalid local draft can still contain authored work. Keep its raw
317
+ // bytes too before accepting a valid remote replacement.
318
+ saveRecoveryBody(opts.historyDir, paths.html, 'local', body);
319
+ if (validation(body) === null)
320
+ saveRecoveryBody(opts.historyDir, paths.html, 'last-valid', body);
321
+ }
322
+ }
323
+
324
+ function reject(
325
+ reason: BodyRejection['reason'],
326
+ local: string | null,
327
+ incoming: string,
328
+ file = paths.html
329
+ ): void {
330
+ const key = `${file}:${reason}:${hashBytes(local ?? '')}:${hashBytes(incoming)}`;
331
+ if (key === rejectedKey) return;
332
+ rejectedKey = key;
333
+ let snapshotFailed = false;
334
+ try {
335
+ if (file === paths.html) preserveLocal(local);
336
+ if (opts.historyDir) {
337
+ if (local !== null) saveRecoveryBody(opts.historyDir, file, 'local', local);
338
+ saveRecoveryBody(opts.historyDir, file, 'incoming', incoming);
339
+ }
340
+ } catch {
341
+ snapshotFailed = true;
342
+ }
343
+ console.warn(`[projection/${slug}] source sync blocked (${reason}); local file kept.`);
344
+ opts.onConflict?.({ slug, kind: 'body-rejected', reason, snapshotFailed });
345
+ }
346
+
134
347
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
135
348
 
136
349
  // doc→file last-written hashes (skip redundant writes).
@@ -157,6 +370,9 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
157
370
  ) {
158
371
  return;
159
372
  }
373
+ // The accepted replica is the hub's to change — a local repair would be a
374
+ // write the fenced connection drops, leaving this replica diverged.
375
+ if (!acceptedOn()) repairSeedDuplication(doc, ORIGINS.DISK_PROJECTION);
160
376
  scheduleFlush();
161
377
  }
162
378
 
@@ -167,10 +383,13 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
167
383
  return;
168
384
  }
169
385
  if (flushTimer) clearTimeout(flushTimer);
170
- flushTimer = setTimeout(() => {
171
- flushTimer = null;
172
- void flush();
173
- }, flushMs);
386
+ flushTimer = setTimeout(
387
+ () => {
388
+ flushTimer = null;
389
+ void flush();
390
+ },
391
+ acceptedOn() ? Math.min(flushMs, ACCEPTED_FLUSH_MS) : flushMs
392
+ );
174
393
  }
175
394
 
176
395
  function recordEcho(path: string, value: string): void {
@@ -216,25 +435,84 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
216
435
 
217
436
  // ----- doc → file (html / css / meta only; room owns comments/annotations)
218
437
 
219
- function writeHtmlIfChanged(): void {
220
- const next = htmlFromDoc(doc);
221
- if (next === lastHtml) return;
438
+ function writeHtmlIfChanged(): boolean {
439
+ // A proposal for this lane is in flight: the file is our candidate and the
440
+ // accepted value is about to be republished. Writing the document's
441
+ // current value now would briefly undo the user's own edit.
442
+ if (pending.has('html') || localWriteActive()) return false;
443
+ let next = htmlFromDoc(doc);
444
+ // The same module written twice or more in a row is a merge of two
445
+ // independent seeds, never an edit (repeated-module.ts). Keep one copy —
446
+ // in the shared document too, so every peer and the hub are repaired,
447
+ // not just this disk. (Accepted revisions: the replica is the hub's; the
448
+ // import there collapses it instead.)
449
+ if (!acceptedOn()) {
450
+ const repeated = collapseRepeatedModule(next);
451
+ if (repeated) {
452
+ console.warn(
453
+ `[projection/${slug}] the shared document held this canvas ${repeated.times}× over${
454
+ repeated.partialTail ? ' (the last copy cut short)' : ''
455
+ } — keeping one copy.`
456
+ );
457
+ doc.transact(() => {
458
+ applyHtmlToDoc(doc, repeated.unit, ORIGINS.DISK_PROJECTION);
459
+ stampBodyEdit(doc, ORIGINS.DISK_PROJECTION);
460
+ }, ORIGINS.DISK_PROJECTION);
461
+ next = repeated.unit;
462
+ }
463
+ }
464
+ if (next === lastHtml) return true;
222
465
  // Don't clobber a non-empty local body with an empty doc (cold-start before
223
466
  // the doc is seeded — the safe-reconcile invariant; full adopt is Phase E).
224
467
  if (next === '') {
225
468
  lastHtml = next;
226
- return;
469
+ return true;
470
+ }
471
+ if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return false;
472
+ const local = readLocal(paths.html);
473
+ if (validation(next) !== null) {
474
+ reject('invalid-source', local, next);
475
+ return false;
476
+ }
477
+ if (local !== observedBody && local !== next) {
478
+ reject('local-edit', local, next);
479
+ return false;
480
+ }
481
+ try {
482
+ preserveLocal(local);
483
+ } catch {
484
+ reject('history-failed', local, next);
485
+ return false;
486
+ }
487
+ if (SYNC_DEBUG) {
488
+ console.log(
489
+ `[projection/${slug}] write html ${hashBytes(next).slice(0, 8)} (disk was ${local === null ? '-' : hashBytes(local).slice(0, 8)})`
490
+ );
227
491
  }
228
- if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return;
229
- recordEcho(paths.html, next);
230
492
  writeAndAnnounce(paths.html, next);
493
+ recordEcho(paths.html, next);
494
+ observedBody = next;
495
+ recovered();
231
496
  lastHtml = next;
232
497
  opts.journal?.record(slug, { bodyHash: hashBytes(next) }); // DDR-102 checkpoint
498
+ rememberBase(next);
499
+ return true;
233
500
  }
234
501
 
235
502
  function writeCssIfChanged(): void {
236
503
  if (!paths.css) return;
237
- const next = cssFromDoc(doc);
504
+ if (pending.has('css') || held.has('css')) return;
505
+ let next = cssFromDoc(doc);
506
+ if (next !== null && !acceptedOn()) {
507
+ const repeated = collapseRepeatedText(next);
508
+ if (repeated) {
509
+ console.warn(
510
+ `[projection/${slug}] the shared stylesheet held itself ${repeated.times}× over — keeping one copy.`
511
+ );
512
+ applyCssToDoc(doc, repeated.unit, ORIGINS.DISK_PROJECTION);
513
+ next = repeated.unit;
514
+ }
515
+ }
238
516
  if (next === lastCss) return;
239
517
  lastCss = next;
240
518
  if (next === null) return; // doc carries no css yet — nothing to write
@@ -242,10 +520,18 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
242
520
  recordEcho(paths.css, next);
243
521
  writeAndAnnounce(paths.css, next);
244
522
  opts.journal?.record(slug, { cssHash: hashBytes(next) }); // DDR-102 checkpoint
523
+ if (opts.historyDir) {
524
+ try {
525
+ saveRecoveryBody(opts.historyDir, paths.css, 'base', next);
526
+ } catch {
527
+ /* the journal hash still covers the common case */
528
+ }
529
+ }
245
530
  }
246
531
 
247
532
  function writeMetaIfChanged(): void {
248
533
  if (!paths.meta) return;
534
+ if (pending.has('meta') || held.has('meta')) return;
249
535
  const shared = metaFromDoc(doc);
250
536
  if (shared === lastMeta) return;
251
537
  lastMeta = shared;
@@ -258,8 +544,31 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
258
544
  writeAndAnnounce(paths.meta, merged);
259
545
  }
260
546
 
547
+ /** The newest multi-document revision this projection has been released for. */
548
+ let releasedRevision = 0;
549
+ /**
550
+ * The first stamp a projection sees is the state it started from (boot, or
551
+ * a document that arrived by pull) — already whole, never held. Only a
552
+ * revision that arrives WHILE it runs waits for its cohort.
553
+ */
554
+ let seenStamp = false;
555
+ function observeStamp(stamped: [number, number]): void {
556
+ seenStamp = true;
557
+ releasedRevision = Math.max(releasedRevision, stamped[0]);
558
+ opts.revisionBarrier?.present(stamped[0], stamped[1], slug);
559
+ }
560
+
561
+ /** `[revision, cohort]` the hub stamped on this document (cohort ≥ 1). */
562
+ function stampedCohort(): [number, number] | null {
563
+ const meta = doc.getMap('syncMeta');
564
+ const rev = meta.get('acceptedRevision');
565
+ const cohort = meta.get('acceptedCohort');
566
+ if (typeof rev !== 'number') return null;
567
+ return [rev, typeof cohort === 'number' && cohort > 1 ? cohort : 1];
568
+ }
569
+
261
570
  async function flush(): Promise<void> {
262
- if (!dirty || stopped) return;
571
+ if (!dirty || stopped || !ready) return;
263
572
  // A RETIRED document is write-inert — its canvas moved to a new path in a
264
573
  // new document, and materialising this one is how a moved canvas
265
574
  // resurrected itself at its old path (see codec stampMovedTo).
@@ -267,15 +576,30 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
267
576
  dirty = false;
268
577
  return;
269
578
  }
579
+ // T14 — part of a multi-document revision: wait for the rest of it, so
580
+ // the checkout never shows half an action.
581
+ const stamped = acceptedOn() && opts.revisionBarrier ? stampedCohort() : null;
582
+ if (stamped && !seenStamp) observeStamp(stamped);
583
+ else if (stamped && stamped[1] > 1 && stamped[0] > releasedRevision) {
584
+ const [rev, cohort] = stamped;
585
+ const held = opts.revisionBarrier?.arrive(rev, cohort, slug, () => {
586
+ releasedRevision = Math.max(releasedRevision, rev);
587
+ dirty = true;
588
+ void flush();
589
+ });
590
+ if (held) return;
591
+ }
270
592
  dirty = false;
271
593
  if (flushTimer) {
272
594
  clearTimeout(flushTimer);
273
595
  flushTimer = null;
274
596
  }
275
597
  try {
276
- writeHtmlIfChanged();
277
- writeCssIfChanged();
598
+ if (writeHtmlIfChanged()) writeCssIfChanged();
278
599
  writeMetaIfChanged();
600
+ const applied = acceptedOn() ? stampedCohort() : null;
601
+ if (applied && !held.has('html') && !pending.has('html'))
602
+ opts.onRevisionApplied?.(applied[0]);
279
603
  } catch (err) {
280
604
  dirty = true;
281
605
  console.error(`[projection/${slug}] flush failed:`, err);
@@ -307,6 +631,308 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
307
631
  quarantine.delete(path);
308
632
  }
309
633
 
634
+ // ----- accepted-revisions mode (DDR-241)
635
+
636
+ const acceptedOn = (): boolean => !!opts.accepted?.on();
637
+ /** Per lane: proposals not yet answered, and the newest one's id + value. */
638
+ const pending = new Map<ProposalLane, { count: number; lastTx: string; lastValue: string }>();
639
+ /** Lanes whose last answer was a rejection — the local file is the held candidate. */
640
+ const held = new Set<ProposalLane>();
641
+ /** Accepted mode: file events that arrived before the cold start decided. */
642
+ const deferredBeforeReady = new Map<string, string>();
643
+ /** Legacy mode: file events held while the connection was not writable. */
644
+ const heldWhileReadOnly = new Map<string, string>();
645
+ /** Accepted mode: an API source write announced but not yet seen by the watcher. */
646
+ let localWrite: { base: string | null; at: number; op?: SourceOp } | null = null;
647
+ let localWriteTimer: ReturnType<typeof setTimeout> | null = null;
648
+ const localWriteActive = (): boolean => {
649
+ if (!localWrite) return false;
650
+ if (Date.now() - localWrite.at < LOCAL_WRITE_HOLD_MS) return true;
651
+ localWrite = null;
652
+ return false;
653
+ };
654
+
655
+ const REJECTION_REASON: Record<string, BodyRejection['reason']> = {
656
+ 'source-invalid': 'invalid-source',
657
+ capacity: 'merge-budget',
658
+ // The change could not even be saved to this machine's outbox.
659
+ 'local-persistence': 'history-failed',
660
+ };
661
+
662
+ function laneOfPath(p: string): ProposalLane | null {
663
+ if (p === paths.html) return 'html';
664
+ if (paths.css && p === paths.css) return 'css';
665
+ if (paths.meta && p === paths.meta) return 'meta';
666
+ if (p === paths.comments) return 'comments';
667
+ if (p === paths.annotations) return 'annotations';
668
+ return null;
669
+ }
670
+
671
+ function pathOfLane(lane: ProposalLane): string {
672
+ if (lane === 'html') return paths.html;
673
+ if (lane === 'css') return paths.css ?? paths.html;
674
+ if (lane === 'meta') return paths.meta ?? paths.html;
675
+ return lane === 'comments' ? paths.comments : paths.annotations;
676
+ }
677
+
678
+ /** The value disk and the accepted replica last agreed on for `lane`. */
679
+ function agreedValue(lane: ProposalLane): string {
680
+ if (lane === 'html') return lastHtml ?? htmlFromDoc(doc);
681
+ if (lane === 'css') return lastCss ?? cssFromDoc(doc) ?? '';
682
+ return readLaneFromDoc(doc, lane);
683
+ }
684
+
685
+ function onRejected(lane: ProposalLane, local: string, outcome: ProposalOutcome): void {
686
+ held.add(lane);
687
+ // The candidate stays on disk: the projection's local-edit guard protects
688
+ // it (html), `held` blocks the lane writer (css/meta), and the recovery
689
+ // slots keep the bytes whatever happens next.
690
+ //
691
+ // The NEXT save is the resolution, and it is made looking at the version
692
+ // the conflict reports as incoming — so that version becomes its base.
693
+ // Keeping the old base would make every resolution that touches the same
694
+ // region conflict again, forever (there is no way out but typing the
695
+ // other side's bytes back exactly).
696
+ const incoming = readLaneFromDoc(doc, lane);
697
+ if (lane === 'html') {
698
+ observedBody = null;
699
+ lastHtml = incoming;
700
+ } else if (lane === 'css') {
701
+ lastCss = incoming;
702
+ }
703
+ reject(
704
+ REJECTION_REASON[outcome.code ?? ''] ?? 'local-edit',
705
+ local,
706
+ readLaneFromDoc(doc, lane),
707
+ pathOfLane(lane)
708
+ );
709
+ }
710
+
711
+ /**
712
+ * Send one lane proposal. A proposal made while an earlier one of the same
713
+ * lane is unanswered was authored ON TOP of it: it is based on that value and
714
+ * DEPENDS on it, so a rejected U1 can never be bypassed by an accepted U2
715
+ * that silently carries half of it (plan T7/T13).
716
+ */
717
+ function submit(
718
+ lane: ProposalLane,
719
+ value: string,
720
+ baseContent: string,
721
+ local: string,
722
+ writeId?: string,
723
+ stageable = false,
724
+ op?: SourceOp,
725
+ replays = 0
726
+ ): Promise<ProposalOutcome> {
727
+ const link = opts.accepted as AcceptedLaneLink;
728
+ const prior = pending.get(lane);
729
+ const transactionId = link.newTransactionId();
730
+ if (SYNC_DEBUG) {
731
+ console.log(
732
+ `[projection/${slug}] propose ${lane} tx=${transactionId.slice(0, 11)} value=${hashBytes(value).slice(0, 8)} base=${hashBytes(baseContent).slice(0, 8)} doc=${hashBytes(readLaneFromDoc(doc, lane)).slice(0, 8)} last=${lastHtml === null ? '-' : hashBytes(lastHtml).slice(0, 8)} via=${new Error().stack?.split('\n')[3]?.trim().slice(0, 60)}`
733
+ );
734
+ }
735
+ pending.set(lane, {
736
+ count: (prior?.count ?? 0) + 1,
737
+ lastTx: transactionId,
738
+ lastValue: value,
739
+ });
740
+ const settle = async (outcome: ProposalOutcome): Promise<ProposalOutcome> => {
741
+ // A rejection can outrun the publication of the version that beat it
742
+ // (HTTP answer vs. WebSocket update). The conflict must report — and the
743
+ // resolution be based on — THAT version, so wait briefly for it.
744
+ if (outcome.status === 'rejected' && outcome.head) await untilLaneHash(lane, outcome.head);
745
+ const p = pending.get(lane);
746
+ if (p) {
747
+ p.count -= 1;
748
+ if (p.count <= 0) pending.delete(lane);
749
+ }
750
+ // T24 — a UI operation that lost a race is re-applied onto the version
751
+ // that won (same property: the later acceptance wins; everything else
752
+ // of both people survives) and proposed again, instead of a conflict.
753
+ if (
754
+ outcome.status === 'rejected' &&
755
+ outcome.code === 'base-conflict' &&
756
+ lane === 'html' &&
757
+ op &&
758
+ opts.replayOp &&
759
+ replays < MAX_OP_REPLAYS &&
760
+ !pending.has(lane) &&
761
+ readLocal(paths.html) === local
762
+ ) {
763
+ const head = readLaneFromDoc(doc, 'html');
764
+ const r = opts.replayOp(op, head);
765
+ if (r.ok && r.source !== head && validation(r.source) === null) {
766
+ if (SYNC_DEBUG) console.log(`[projection/${slug}] replay ${op.kind} onto the winner`);
767
+ observedBody = r.source;
768
+ recordEcho(paths.html, r.source);
769
+ writeAndAnnounce(paths.html, r.source);
770
+ return submit('html', r.source, head, r.source, undefined, false, op, replays + 1);
771
+ }
772
+ }
773
+ if (outcome.status === 'accepted') {
774
+ if (!pending.has(lane)) held.delete(lane);
775
+ if (lane === 'html') recovered();
776
+ if (outcome.actionId) {
777
+ try {
778
+ opts.onAccepted?.({ lane, value, actionId: outcome.actionId });
779
+ } catch {
780
+ /* bookkeeping only */
781
+ }
782
+ }
783
+ } else if (outcome.code === 'discarded') {
784
+ // T16 — the person discarded an unfinished AI edit: the accepted
785
+ // version returns to disk (the candidate is in the recovery slots).
786
+ held.delete(lane);
787
+ if (lane === 'html') {
788
+ observedBody = readLocal(paths.html);
789
+ lastHtml = null;
790
+ } else if (lane === 'css') {
791
+ lastCss = null;
792
+ } else if (lane === 'meta') {
793
+ lastMeta = null;
794
+ }
795
+ } else {
796
+ onRejected(lane, local, outcome);
797
+ }
798
+ scheduleFlush();
799
+ return outcome;
800
+ };
801
+ return link
802
+ .propose({
803
+ lane,
804
+ content: value,
805
+ baseContent,
806
+ transactionId,
807
+ ...(prior ? { dependsOn: [prior.lastTx] } : {}),
808
+ ...(writeId ? { writeId } : {}),
809
+ ...(stageable ? { stageable: true } : {}),
810
+ })
811
+ .then(settle, (err: unknown) => {
812
+ const code = (err as { code?: unknown })?.code;
813
+ if (code === 'stopped') {
814
+ // The runtime is going away with this proposal unanswered. It is
815
+ // still in the durable outbox and the next runtime resends it — so
816
+ // it is neither a failure nor a rejection, and nothing is reported.
817
+ const p = pending.get(lane);
818
+ if (p) {
819
+ p.count -= 1;
820
+ if (p.count <= 0) pending.delete(lane);
821
+ }
822
+ return { status: 'rejected', code: 'stopped' } as ProposalOutcome;
823
+ }
824
+ console.error(`[projection/${slug}] proposing ${lane} failed:`, err);
825
+ return settle({
826
+ status: 'rejected',
827
+ code: typeof code === 'string' ? code : 'client-error',
828
+ });
829
+ });
830
+ }
831
+
832
+ function untilLaneHash(lane: ProposalLane, head: string, ms = 3_000): Promise<void> {
833
+ const matches = () => laneHash(readLaneFromDoc(doc, lane)) === head;
834
+ if (matches()) return Promise.resolve();
835
+ return new Promise((resolve) => {
836
+ const done = () => {
837
+ clearTimeout(timer);
838
+ doc.off('update', onUpdate);
839
+ resolve();
840
+ };
841
+ const onUpdate = () => {
842
+ if (matches()) done();
843
+ };
844
+ const timer = setTimeout(done, ms);
845
+ doc.on('update', onUpdate);
846
+ });
847
+ }
848
+
849
+ /** Accepted-revisions import: propose the file's lane value; never touch the doc. */
850
+ function proposeFromFs(evt: { path: string; hash: string }, str: string): boolean {
851
+ const lane = laneOfPath(evt.path);
852
+ if (!lane) return false;
853
+ if (SYNC_DEBUG && lane === 'html') {
854
+ console.log(
855
+ `[projection/${slug}] fs event html ${hashBytes(str).slice(0, 8)} evt=${evt.hash.slice(0, 8)} doc=${hashBytes(htmlFromDoc(doc)).slice(0, 8)} last=${lastHtml === null ? '-' : hashBytes(lastHtml).slice(0, 8)} held=${held.has('html')} pending=${pending.has('html')}`
856
+ );
857
+ }
858
+ // COMMENTS AND ANNOTATIONS ARE NEVER PROPOSED FROM A FILE EVENT. Their
859
+ // files have a second writer — the collab room projects the accepted
860
+ // document onto them — so a file change here is as likely the room
861
+ // writing a value the document has since moved past as it is an edit.
862
+ // Proposing that with the current head as its base REPLACES the head:
863
+ // a deleted shape resurrected by a stale projection (surface run
864
+ // 2026-09-15, L09 delete). Every edit to these lanes arrives through the
865
+ // API with the base it was made from (`proposeLane`).
866
+ if (lane === 'comments' || lane === 'annotations') return false;
867
+ if (lane === 'html') {
868
+ if (str === lastHtml && !held.has('html')) return false; // a redelivered projection
869
+ if (!withinCap(paths.html, str, MAX_HTML_BYTES)) return false;
870
+ if (validation(str) !== null) {
871
+ strike(evt.path, evt.hash);
872
+ reject('invalid-source', str, htmlFromDoc(doc));
873
+ return false;
874
+ }
875
+ } else if (lane === 'css' && str === lastCss && !held.has('css')) {
876
+ return false;
877
+ }
878
+ const value = laneValueFromFile(lane, str);
879
+ if (value === null) {
880
+ strike(evt.path, evt.hash);
881
+ return false;
882
+ }
883
+ clearStrike(evt.path);
884
+ const inFlight = pending.get(lane);
885
+ // The watcher redelivering what we already proposed is not a new edit.
886
+ if (inFlight?.lastValue === value) return false;
887
+ const current = readLaneFromDoc(doc, lane);
888
+ if (value === current) {
889
+ if (inFlight) return false;
890
+ held.delete(lane);
891
+ if (lane === 'html') {
892
+ observedBody = str;
893
+ lastHtml = str;
894
+ recovered();
895
+ } else if (lane === 'css') {
896
+ lastCss = str;
897
+ }
898
+ return false;
899
+ }
900
+ // NOT "skip a value proposed before": the reader reads the file as it is
901
+ // NOW, so an event carrying an earlier proposal's bytes after that
902
+ // proposal was answered is the user going BACK to them (A→B→A, every
903
+ // undo/redo) — skipping it turned a real edit into a false conflict
904
+ // (surface run 2026-09-15, L18).
905
+ if (lane === 'html') {
906
+ try {
907
+ preserveLocal(str);
908
+ } catch {
909
+ reject('history-failed', str, current);
910
+ return false;
911
+ }
912
+ observedBody = str;
913
+ }
914
+ // An API edit announced its base (noteLocalWrite): that is exactly what
915
+ // it was derived from — better than the last agreed body, which a peer
916
+ // revision may have advanced inside the watcher's quiet window.
917
+ const apiBase = lane === 'html' ? (localWrite?.base ?? null) : null;
918
+ // T16 — a change the person made through the UI announced itself; any
919
+ // other file change is a tool's, and may belong to an open AI action.
920
+ const fromUi = localWrite !== null;
921
+ // T24 — the UI operation this write was, when it said.
922
+ const op = lane === 'html' ? localWrite?.op : undefined;
923
+ if (lane === 'html') localWrite = null;
924
+ void submit(
925
+ lane,
926
+ value,
927
+ inFlight ? inFlight.lastValue : (apiBase ?? agreedValue(lane)),
928
+ str,
929
+ undefined,
930
+ !fromUi,
931
+ op
932
+ );
933
+ return true;
934
+ }
935
+
310
936
  function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
311
937
  if (stopped) return false;
312
938
  // Write-inert both ways — a local edit to a stale pre-move file must not
@@ -318,25 +944,100 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
318
944
  if (isQuarantined(evt.path, evt.hash)) return false;
319
945
 
320
946
  const str = bytesToString(evt.bytes);
947
+ if (acceptedOn()) {
948
+ // Before the cold start has decided, a proposal would race it (and a
949
+ // brand-new canvas is not in the project yet). Remember the path; the
950
+ // file is re-read once the decision is made.
951
+ if (!ready) {
952
+ deferredBeforeReady.set(evt.path, evt.hash);
953
+ return false;
954
+ }
955
+ return proposeFromFs(evt, str);
956
+ }
957
+ // Legacy import: a write the hub would drop must not be made at all.
958
+ if (opts.canWriteDoc && !opts.canWriteDoc()) {
959
+ heldWhileReadOnly.set(evt.path, evt.hash);
960
+ opts.onWriteBlocked?.();
961
+ return false;
962
+ }
321
963
 
322
964
  if (evt.path === paths.html) {
965
+ // Watchers can deliver the same projection more than once, including
966
+ // after the one-shot echo token was consumed or expired. These bytes
967
+ // contain no new local edit; diffing them against a newer remote body
968
+ // would turn a delayed notification into a rollback of peer work.
969
+ // Compare only the current baseline, so a deliberate A → B → A edit
970
+ // still imports after B advances lastHtml.
971
+ if (str === lastHtml) return false;
972
+ if (!withinCap(paths.html, str, MAX_HTML_BYTES)) return false;
973
+ if (validation(str) !== null) {
974
+ strike(evt.path, evt.hash);
975
+ reject('invalid-source', str, htmlFromDoc(doc));
976
+ return false;
977
+ }
323
978
  clearStrike(evt.path);
979
+ // A PEER CHANGED THE DOC SINCE DISK AND DOC LAST AGREED (`lastHtml`), and
980
+ // this save was authored against that older body. Importing it as a
981
+ // whole-file diff against the doc turned every stale byte into an edit
982
+ // and reverted the peer's work (audit 2026-09-13 P0 #1). Merge from the
983
+ // shared base instead; what the merge cannot prove independent — or a
984
+ // merged body that no longer validates — is preserved and blocked, and
985
+ // the conflict stays until a later save actually resolves it.
986
+ const current = htmlFromDoc(doc);
987
+ let next = str;
988
+ if (lastHtml !== null && current !== lastHtml && current !== str) {
989
+ const merged = mergeSource(lastHtml, str, current);
990
+ if (!merged.ok) {
991
+ reject(merged.reason === 'budget' ? 'merge-budget' : 'local-edit', str, current);
992
+ return false;
993
+ }
994
+ if (validation(merged.merged) !== null) {
995
+ reject('local-edit', str, current);
996
+ return false;
997
+ }
998
+ next = merged.merged;
999
+ }
1000
+ try {
1001
+ preserveLocal(str);
1002
+ } catch {
1003
+ reject('history-failed', str, htmlFromDoc(doc));
1004
+ return false;
1005
+ }
324
1006
  // Body import + syncMeta stamp in ONE transaction (same FILE_IMPORT
325
1007
  // origin) — peers get a single update carrying the newest-wins stamp.
326
1008
  let changed = false;
327
- doc.transact(() => {
328
- changed = applyHtmlToDoc(doc, str, importOrigin);
329
- if (changed) stampBodyEdit(doc, importOrigin);
330
- }, importOrigin);
331
- if (changed) {
332
- lastHtml = htmlFromDoc(doc);
1009
+ try {
1010
+ doc.transact(() => {
1011
+ changed = applyHtmlToDoc(doc, next, importOrigin);
1012
+ if (changed) stampBodyEdit(doc, importOrigin);
1013
+ }, importOrigin);
1014
+ } catch {
1015
+ reject('merge-budget', str, htmlFromDoc(doc));
1016
+ return false;
1017
+ }
1018
+ observedBody = str;
1019
+ lastHtml = str;
1020
+ recovered();
1021
+ if (htmlFromDoc(doc) !== str) {
1022
+ // A synchronous peer update can land during the import transaction.
1023
+ // Only bytes actually on disk count as the projection's baseline.
1024
+ scheduleFlush();
1025
+ } else if (changed) {
333
1026
  opts.journal?.record(slug, { bodyHash: evt.hash }); // DDR-102 checkpoint
1027
+ rememberBase(str);
334
1028
  }
335
1029
  return changed;
336
1030
  }
337
1031
  if (paths.css && evt.path === paths.css) {
1032
+ let changed: boolean;
1033
+ try {
1034
+ changed = applyCssToDoc(doc, str, importOrigin);
1035
+ } catch {
1036
+ strike(evt.path, evt.hash);
1037
+ reject('merge-budget', str, cssFromDoc(doc) ?? '', paths.css);
1038
+ return false;
1039
+ }
338
1040
  clearStrike(evt.path);
339
- const changed = applyCssToDoc(doc, str, importOrigin);
340
1041
  if (changed) {
341
1042
  lastCss = str;
342
1043
  opts.journal?.record(slug, { cssHash: evt.hash }); // DDR-102 checkpoint
@@ -383,13 +1084,25 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
383
1084
  if (stopped) return;
384
1085
  // A retired doc materialises NOTHING (see codec stampMovedTo).
385
1086
  if (movedToFromDoc(doc) !== null) return;
1087
+ ready = true;
1088
+ if (deferredBeforeReady.size && acceptedOn()) {
1089
+ const owed = [...deferredBeforeReady];
1090
+ deferredBeforeReady.clear();
1091
+ for (const [p, hash] of owed) {
1092
+ const text = readLocal(p);
1093
+ if (text !== null) proposeFromFs({ path: p, hash }, text);
1094
+ }
1095
+ }
386
1096
  // Materialize the converged doc to disk (html/css/meta). The *IfChanged
387
1097
  // writers already guard against clobbering non-empty local with empty doc
388
1098
  // values, so this is safe to run at cold start before the authoritative
389
1099
  // seed (Phase E) — it only writes what the doc actually holds.
390
- writeHtmlIfChanged();
391
- writeCssIfChanged();
1100
+ if (writeHtmlIfChanged()) writeCssIfChanged();
392
1101
  writeMetaIfChanged();
1102
+ // A document that arrives by pull as part of a multi-document revision is
1103
+ // on disk now: the rest of its revision may show.
1104
+ const stamped = acceptedOn() ? stampedCohort() : null;
1105
+ if (stamped && !seenStamp) observeStamp(stamped);
393
1106
  }
394
1107
 
395
1108
  return {
@@ -397,14 +1110,98 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
397
1110
  importOrigin,
398
1111
  start() {
399
1112
  if (started) return;
1113
+ // Snapshot before a provider can replace disk. Failure remains fail-closed
1114
+ // at the write boundary and is surfaced when a write is attempted.
1115
+ try {
1116
+ preserveLocal(observedBody);
1117
+ } catch {
1118
+ /* checked before writes */
1119
+ }
400
1120
  doc.on('update', onDocUpdate);
401
1121
  started = true;
402
1122
  },
403
1123
  applyFromFs,
404
1124
  reconcile,
405
1125
  flush,
1126
+ adoptBase(body: string) {
1127
+ lastHtml = body;
1128
+ observedBody = body;
1129
+ },
1130
+ hold(lane, base, local) {
1131
+ held.add(lane);
1132
+ if (lane === 'html') {
1133
+ lastHtml = base;
1134
+ observedBody = null;
1135
+ } else if (lane === 'css') {
1136
+ lastCss = base;
1137
+ }
1138
+ reject('local-edit', local, readLaneFromDoc(doc, lane), pathOfLane(lane));
1139
+ },
1140
+ takeAccepted() {
1141
+ if (!held.has('html') && rejectedKey === null) return false;
1142
+ held.delete('html');
1143
+ // The local-edit guard compares disk with `observedBody`; declaring the
1144
+ // current disk bytes observed lets the writer replace them.
1145
+ observedBody = readLocal(paths.html);
1146
+ lastHtml = null;
1147
+ dirty = true;
1148
+ void flush();
1149
+ recovered();
1150
+ return true;
1151
+ },
1152
+ conflictSides() {
1153
+ if (!held.has('html') && rejectedKey === null) return null;
1154
+ return { mine: readLocal(paths.html), theirs: htmlFromDoc(doc) };
1155
+ },
1156
+ proposeLane(lane, value, o) {
1157
+ if (!acceptedOn() || stopped) return null;
1158
+ if (lane === 'html') observedBody = value;
1159
+ const inFlight = pending.get(lane);
1160
+ if (inFlight?.lastValue === value) return Promise.resolve({ status: 'accepted' });
1161
+ if (!inFlight && value === readLaneFromDoc(doc, lane)) {
1162
+ held.delete(lane);
1163
+ return Promise.resolve({ status: 'accepted' });
1164
+ }
1165
+ const base = o?.baseContent ?? (inFlight ? inFlight.lastValue : agreedValue(lane));
1166
+ return submit(lane, value, base, value, o?.writeId, o?.stageable === true);
1167
+ },
1168
+ noteSourceOp(op) {
1169
+ if (localWrite) localWrite.op = op;
1170
+ },
1171
+ noteLocalWrite() {
1172
+ if (!acceptedOn() || stopped) return;
1173
+ localWrite = { base: readLocal(paths.html), at: Date.now() };
1174
+ if (localWriteTimer) clearTimeout(localWriteTimer);
1175
+ // The watcher event normally clears this long before; if it never comes
1176
+ // (a write that changed nothing), the writer resumes on its own.
1177
+ localWriteTimer = setTimeout(() => {
1178
+ localWriteTimer = null;
1179
+ scheduleFlush();
1180
+ }, LOCAL_WRITE_HOLD_MS + 10);
1181
+ localWriteTimer.unref?.();
1182
+ },
1183
+ cancelLocalWrite() {
1184
+ localWrite = null;
1185
+ scheduleFlush();
1186
+ },
1187
+ retryDeferred() {
1188
+ if (stopped || heldWhileReadOnly.size === 0) return;
1189
+ if (!acceptedOn() && opts.canWriteDoc && !opts.canWriteDoc()) return;
1190
+ const owed = [...heldWhileReadOnly];
1191
+ heldWhileReadOnly.clear();
1192
+ for (const [p, hash] of owed) {
1193
+ const text = readLocal(p);
1194
+ if (text !== null) applyFromFs({ path: p, bytes: new TextEncoder().encode(text), hash });
1195
+ }
1196
+ },
1197
+ pendingCount() {
1198
+ let n = 0;
1199
+ for (const p of pending.values()) n += p.count;
1200
+ return n;
1201
+ },
406
1202
  stop() {
407
1203
  stopped = true;
1204
+ if (localWriteTimer) clearTimeout(localWriteTimer);
408
1205
  doc.off('update', onDocUpdate);
409
1206
  if (flushTimer) {
410
1207
  clearTimeout(flushTimer);