@mnstry/atelier 0.2.0-alpha.6 → 0.2.0-alpha.8

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 (374) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/README.md +59 -15
  3. package/SECURITY.md +6 -1
  4. package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
  5. package/contracts/atelier-coauthor.v1.schema.json +163 -0
  6. package/contracts/atelier-decision-request.v1.schema.json +476 -0
  7. package/contracts/atelier-decision-result.v1.schema.json +500 -0
  8. package/contracts/atelier-guide.v1.schema.json +227 -0
  9. package/contracts/atelier-intake.v1.schema.json +151 -0
  10. package/contracts/atelier-migration.v2.schema.json +61 -0
  11. package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
  12. package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
  13. package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
  14. package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
  15. package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
  16. package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
  17. package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
  18. package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
  19. package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
  20. package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
  21. package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
  22. package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
  23. package/contracts/atelier-presentation.v1.schema.json +1293 -0
  24. package/contracts/atelier-review.v1.schema.json +1601 -0
  25. package/contracts/atelier-skill-steward.v1.schema.json +692 -0
  26. package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
  27. package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
  28. package/contracts/atelier-vault-publication.v1.schema.json +58 -0
  29. package/docs/assurance-controls.md +9 -1
  30. package/docs/atelier-sync.md +5 -4
  31. package/docs/blocks/claims.md +15 -10
  32. package/docs/blocks/will-not-do.md +4 -1
  33. package/docs/coauthor-session.md +57 -0
  34. package/docs/connected-composition.md +75 -0
  35. package/docs/decisions.md +167 -0
  36. package/docs/exact-upgrades.md +159 -0
  37. package/docs/extension-pack-lifecycle.md +33 -0
  38. package/docs/guided-upgrades.md +99 -0
  39. package/docs/install.md +65 -9
  40. package/docs/intake-and-guides.md +65 -0
  41. package/docs/integration-and-review-plan.md +409 -0
  42. package/docs/integration-contract-decisions.md +22 -0
  43. package/docs/local-review.md +84 -0
  44. package/docs/local-services.md +227 -0
  45. package/docs/local-state.md +107 -0
  46. package/docs/obsidian-contract.md +687 -0
  47. package/docs/obsidian.md +387 -0
  48. package/docs/portable-presentation.md +328 -0
  49. package/docs/presentation-consumer-boundaries.md +75 -0
  50. package/docs/presentation-review-disposition.md +47 -0
  51. package/docs/project-command-inventory.md +38 -0
  52. package/docs/project-options.md +53 -0
  53. package/docs/release-engineering.md +88 -0
  54. package/docs/review-portability.md +55 -0
  55. package/docs/skill-steward.md +156 -0
  56. package/docs/upgrade.md +55 -2
  57. package/docs/vault-service.md +292 -0
  58. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  59. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  60. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  61. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  62. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  63. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  64. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  65. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  66. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  67. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  68. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  69. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  70. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  71. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  72. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  73. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  74. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  75. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  76. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  77. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  78. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  79. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  80. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  81. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  82. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  83. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  84. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  85. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  86. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  87. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  88. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  89. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  90. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  91. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  92. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  93. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  94. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  95. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  96. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  97. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  98. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  99. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  100. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  101. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  102. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  103. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  104. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  105. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  106. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  107. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  108. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  109. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  110. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  111. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  112. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  113. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  114. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  115. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  116. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  117. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  118. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  119. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  120. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  121. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  122. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  123. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  124. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  125. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  126. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  127. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  128. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  129. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  130. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  131. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  132. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  133. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  134. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  135. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  136. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  137. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  138. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  139. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  140. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  141. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  142. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  143. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  144. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  145. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  146. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  147. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  148. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  149. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  150. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  151. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  152. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  153. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  154. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  155. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  156. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  157. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  158. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  159. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  160. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  161. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  162. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  163. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  164. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  165. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  166. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  167. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  168. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  169. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  170. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  171. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  172. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  173. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  174. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  175. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  176. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  177. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  178. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  179. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  180. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  181. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  182. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  183. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  184. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  185. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  186. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  187. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  188. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  189. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  190. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  191. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  192. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  193. package/fixtures/obsidian/edits/workspace.json +65 -0
  194. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  195. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  196. package/fixtures/obsidian/materialization/workspace.json +54 -0
  197. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  198. package/fixtures/ui/presentation/pane.v1.json +7 -0
  199. package/fixtures/ui/presentation/reference.v1.json +293 -0
  200. package/package.json +57 -6
  201. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  202. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  203. package/skills/claude/atelier-open/SKILL.md +5 -0
  204. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  205. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  206. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  207. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  208. package/skills/codex/atelier-open/SKILL.md +5 -0
  209. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  210. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  211. package/src/access/preview-enforcer.mjs +84 -0
  212. package/src/boundary/policy.mjs +57 -25
  213. package/src/cli/project-options.mjs +58 -0
  214. package/src/cli/run.mjs +43 -3
  215. package/src/coauthor/session.mjs +161 -0
  216. package/src/coauthor/store.mjs +173 -0
  217. package/src/collaboration/event-ledger.mjs +16 -33
  218. package/src/collaboration/inspection-bundle.mjs +223 -0
  219. package/src/collaboration/review-contracts.mjs +25 -0
  220. package/src/collaboration/review-store.mjs +305 -0
  221. package/src/commands/coauthor.mjs +26 -0
  222. package/src/commands/config.mjs +6 -1
  223. package/src/commands/extension-pack.mjs +15 -8
  224. package/src/commands/init.mjs +44 -5
  225. package/src/commands/obsidian.mjs +368 -0
  226. package/src/commands/review.mjs +147 -0
  227. package/src/commands/setup.mjs +51 -14
  228. package/src/commands/skills.mjs +199 -0
  229. package/src/commands/sync.mjs +8 -1
  230. package/src/composition/registry.mjs +27 -0
  231. package/src/composition/wire.mjs +122 -0
  232. package/src/contracts/corpus.mjs +29 -0
  233. package/src/decisions/contracts.d.mts +96 -0
  234. package/src/decisions/contracts.mjs +321 -0
  235. package/src/disclosure/content-scan.mjs +13 -0
  236. package/src/export/atelier-export-contract.mjs +16 -1
  237. package/src/extension-packs/lifecycle.mjs +154 -0
  238. package/src/graph/graph.mjs +64 -2
  239. package/src/graph/knowledge-graph.mjs +548 -24
  240. package/src/guides/contracts.mjs +54 -0
  241. package/src/intake/store.mjs +119 -0
  242. package/src/preview/channel.mjs +89 -0
  243. package/src/project/config.mjs +26 -27
  244. package/src/project/durable-state.mjs +161 -0
  245. package/src/project/file-class.mjs +166 -0
  246. package/src/project/private-state.mjs +11 -1
  247. package/src/projection/obsidian/contracts.mjs +571 -0
  248. package/src/projection/obsidian/edits/align.mjs +169 -0
  249. package/src/projection/obsidian/edits/apply.mjs +889 -0
  250. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  251. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  252. package/src/projection/obsidian/edits/index.mjs +19 -0
  253. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  254. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  255. package/src/projection/obsidian/edits/observe.mjs +123 -0
  256. package/src/projection/obsidian/edits/policy.mjs +123 -0
  257. package/src/projection/obsidian/edits/regions.mjs +648 -0
  258. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  259. package/src/projection/obsidian/materialize/index.mjs +7 -0
  260. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  261. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  262. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  263. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  264. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  265. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  266. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  267. package/src/projection/obsidian/proposals/index.mjs +23 -0
  268. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  269. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  270. package/src/projection/obsidian/proposals/router.mjs +168 -0
  271. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  272. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  273. package/src/projection/obsidian/publication/index.mjs +5 -0
  274. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  275. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  276. package/src/projection/obsidian/publication/transport.mjs +139 -0
  277. package/src/projection/obsidian/recovery/index.mjs +6 -0
  278. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  279. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  280. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  281. package/src/projection/obsidian/recovery/store.mjs +234 -0
  282. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  283. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  284. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  285. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  286. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  287. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  288. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  289. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  290. package/src/projection/project.mjs +28 -19
  291. package/src/readiness-protocols/evidence.mjs +282 -0
  292. package/src/readiness-protocols/runtime.mjs +4 -3
  293. package/src/readiness-protocols/source-read.mjs +39 -0
  294. package/src/runtime/obsidian/app-capability.mjs +110 -0
  295. package/src/runtime/obsidian/app-production-seams.mjs +96 -0
  296. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  297. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  298. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  299. package/src/runtime/obsidian/contributions.mjs +27 -0
  300. package/src/runtime/obsidian/documents.mjs +37 -0
  301. package/src/runtime/obsidian/enablement.mjs +53 -0
  302. package/src/runtime/obsidian/engine.mjs +532 -0
  303. package/src/runtime/obsidian/errors.mjs +14 -0
  304. package/src/runtime/obsidian/extension-points.mjs +103 -0
  305. package/src/runtime/obsidian/index.mjs +34 -0
  306. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  307. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  308. package/src/runtime/obsidian/observation.mjs +158 -0
  309. package/src/runtime/obsidian/opening.mjs +243 -0
  310. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  311. package/src/runtime/obsidian/pipeline.mjs +122 -0
  312. package/src/runtime/obsidian/private-lock.mjs +156 -0
  313. package/src/runtime/obsidian/process-identity.mjs +58 -0
  314. package/src/runtime/obsidian/service-client.mjs +66 -0
  315. package/src/runtime/obsidian/service-main.mjs +89 -0
  316. package/src/runtime/obsidian/service-record.mjs +169 -0
  317. package/src/runtime/obsidian/service-server.mjs +121 -0
  318. package/src/runtime/obsidian/service.mjs +216 -0
  319. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  320. package/src/runtime/obsidian/state-store.mjs +180 -0
  321. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  322. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  323. package/src/runtime/obsidian/watchers.mjs +36 -0
  324. package/src/runtime/supervisor.mjs +16 -1
  325. package/src/server/local-sidecar.mjs +44 -0
  326. package/src/server/server.mjs +1 -0
  327. package/src/skills/steward.mjs +780 -0
  328. package/src/ui/presentation/browser.mjs +278 -0
  329. package/src/ui/presentation/contract.mjs +116 -0
  330. package/src/ui/presentation/index.mjs +7 -0
  331. package/src/ui/presentation/native.mjs +153 -0
  332. package/src/ui/presentation/proof.mjs +57 -0
  333. package/src/ui/presentation/schema-check.mjs +30 -0
  334. package/src/ui/presentation/schema.generated.mjs +1371 -0
  335. package/src/ui/presentation/state.mjs +43 -0
  336. package/src/ui/presentation/styles.mjs +67 -0
  337. package/src/ui/presentation/tokens.mjs +74 -0
  338. package/src/ui/presentation/web.mjs +85 -0
  339. package/src/ui/review-page.mjs +32 -0
  340. package/src/upgrade/explanation.mjs +33 -0
  341. package/src/upgrade/provenance.mjs +338 -0
  342. package/src/upgrade/transaction-files.mjs +103 -0
  343. package/src/upgrade/transaction.mjs +475 -0
  344. package/src/upgrade/upgrade.mjs +63 -28
  345. package/src/vault/hosts.mjs +21 -0
  346. package/src/vault/identity.mjs +19 -0
  347. package/src/vault/index.mjs +7 -0
  348. package/src/vault/interface.mjs +11 -0
  349. package/src/vault/metadata.mjs +31 -0
  350. package/src/vault/privacy.mjs +148 -0
  351. package/src/vault/probe.mjs +89 -0
  352. package/src/vault/service.mjs +131 -0
  353. package/src/vault/source.mjs +45 -0
  354. package/src/vault/storage.mjs +23 -0
  355. package/templates/distribution-workspace/.claude/launch.json +6 -2
  356. package/templates/distribution-workspace/gitignore +1 -0
  357. package/templates/distribution-workspace/project/README.md +2 -2
  358. package/templates/external-project-workspace/README.md +45 -0
  359. package/templates/external-project-workspace/answers.example.json +4 -0
  360. package/templates/external-project-workspace/atelier.project.json +40 -0
  361. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  362. package/templates/external-project-workspace/gitignore +8 -0
  363. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  364. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  365. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  366. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  367. package/templates/external-project-workspace/source/README.md +11 -0
  368. package/templates/launch.json +6 -2
  369. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  370. package/templates/private-domain-workspace/domain/README.md +2 -2
  371. package/templates/private-domain-workspace/gitignore +1 -0
  372. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  373. package/templates/shared-project-workspace/gitignore +1 -0
  374. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,648 @@
1
+ import { scanMarkdownLinks } from '../../../graph/knowledge-graph.mjs'
2
+ import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../contracts.mjs'
3
+ import { readMarkdownLens, sha256Digest } from '../materialize/byte-lens.mjs'
4
+ import { alignBodies, commonPrefixLength, commonSubsequenceLength, commonSuffixLength } from './align.mjs'
5
+
6
+ // The raw-byte edit lens. A person edits a generated NOTE; the lens turns the
7
+ // edited note bytes, the manifest entry the note was generated under, the
8
+ // published note bytes and the base source bytes into the exact new source
9
+ // bytes, or a typed refusal. Everything is a Buffer from start to finish: no
10
+ // newline is normalized, no byte outside the authored body is touched and
11
+ // generated text is never attributed to the author. Nothing is written.
12
+ //
13
+ // A note is three byte classes, in order:
14
+ //
15
+ // prefix bytes before regions.body.start: the source's byte order prefix
16
+ // or front matter, verbatim
17
+ // authored the body, with canonical link and embed targets rewritten; each
18
+ // rewrite is recorded as an inversion
19
+ // generated relations, outside-selection rows and, before them, the closing
20
+ // fence of a body that ends inside a code fence; every generated
21
+ // region begins with its own separator bytes
22
+ //
23
+ // Refusals carry codes, digests and byte offsets only: never note or source
24
+ // text, titles or machine paths.
25
+
26
+ const EXT = OBSIDIAN_EXT_KEY
27
+ const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf])
28
+ const LF = 0x0a
29
+ const CR = 0x0d
30
+ const PIPE = 0x7c
31
+ const LINK_CLOSE = Buffer.from(']]')
32
+
33
+ export const EDIT_LENS_REFUSALS = Object.freeze([
34
+ 'unknown-note',
35
+ 'unsupported-wrapper-edit',
36
+ 'baseline-mismatch',
37
+ 'stale-base',
38
+ 'manifest-mismatch',
39
+ 'invalid-utf8',
40
+ 'generated-region-edited',
41
+ 'unsupported-frontmatter-edit',
42
+ 'link-rewrite-edited',
43
+ 'unsupported-structural-edit',
44
+ 'ambiguous-link-alignment',
45
+ 'edit-too-large-to-align',
46
+ ])
47
+
48
+ function refuse(code, message, detail = {}) {
49
+ throw new ObsidianContractRefusal(code, message, detail)
50
+ }
51
+
52
+ const endsWith = (buffer, tail) => tail.length <= buffer.length && buffer.subarray(buffer.length - tail.length).equals(tail)
53
+ const startsWith = (buffer, head) => head.length <= buffer.length && buffer.subarray(0, head.length).equals(head)
54
+ function countOccurrences(haystack, needle) {
55
+ let count = 0
56
+ for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) count += 1
57
+ return count
58
+ }
59
+
60
+ function isStrictUtf8(buffer) {
61
+ try {
62
+ new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(buffer)
63
+ return true
64
+ } catch {
65
+ return false
66
+ }
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Generated regions
71
+ // ---------------------------------------------------------------------------
72
+
73
+ // What the published note says about its generated tail: the exact bytes, the
74
+ // leading bytes that are separator or fence closure (`lead`) and the text lines
75
+ // that follow them. The expected bytes are read from the published note under
76
+ // the manifest's ranges; nothing about their wording is restated here.
77
+ function publishedGenerated(noteEntry, published) {
78
+ const regions = noteEntry.regions.generated
79
+ const bodyEnd = noteEntry.regions.body.end
80
+ let cursor = bodyEnd
81
+ for (const region of regions) {
82
+ if (region.range.start !== cursor || region.range.end < region.range.start) refuse('manifest-mismatch', 'generated regions do not tile the end of the published note')
83
+ cursor = region.range.end
84
+ }
85
+ if (cursor !== published.length) refuse('manifest-mismatch', 'generated regions do not tile the end of the published note')
86
+ const bytes = published.subarray(bodyEnd)
87
+ const closureLength = regions[0]?.ext?.[EXT]?.fenceClosure?.byteLength ?? 0
88
+ let lead = closureLength
89
+ while (lead < bytes.length && (bytes[lead] === LF || bytes[lead] === CR)) lead += 1
90
+ const lines = []
91
+ let offset = lead
92
+ while (offset < bytes.length) {
93
+ const newline = bytes.indexOf(LF, offset)
94
+ const next = newline === -1 ? bytes.length : newline + 1
95
+ let end = newline === -1 ? bytes.length : newline
96
+ if (end > offset && bytes[end - 1] === CR) end -= 1
97
+ if (end > offset) lines.push(bytes.subarray(offset, end))
98
+ offset = next
99
+ }
100
+ return { bytes, lead: bytes.subarray(0, lead), lines }
101
+ }
102
+
103
+ // Splits the edited note into its authored part and the state of its generated
104
+ // tail. Returns { authored, generated } with `authored` a view of the edited
105
+ // bytes (prefix included) and `generated` one of:
106
+ //
107
+ // 'none' the published note had no generated region
108
+ // 'intact' the edited note still ends with the exact generated bytes
109
+ // 'removed' the tail is gone and where the authored text ends is PROVEN
110
+ //
111
+ // Every other state (edited, moved, duplicated, cut in the middle of a line
112
+ // from either side) refuses: generated text is never guessed at and never
113
+ // reaches a source. A refusal that a person could have avoided is accepted; a
114
+ // generated byte in a source is not.
115
+ //
116
+ // Intact. The authored part is what precedes the tail. It must hold no
117
+ // complete generated line more often than the published authored part did
118
+ // (an author whose own text repeats a generated line is not refused for it),
119
+ // and it must not end in the beginning of the tail (a second, cut copy).
120
+ //
121
+ // Removed. With G the generated bytes, `lead` their leading separator and
122
+ // fence-closure bytes and P the published authored part, the edited note E is
123
+ // read as A + r, r = G[0, k) for one k <= |lead|, only under one of:
124
+ //
125
+ // unedited E = P + r. What is left of the separator is not the author's:
126
+ // the edit is no authored edit at all.
127
+ // anchored A ends with the anchor of P: the line ending before P's last
128
+ // non-empty line, that line, and P's trailing line endings, byte
129
+ // for byte; E does not begin with all of P; and exactly ONE k
130
+ // qualifies. The end of the authored text is then the published
131
+ // end, so nothing after it can be the author's, and a fragment f
132
+ // of the tail left before r would have to end in the anchor
133
+ // itself, which makes it a complete generated line (refused
134
+ // below) or empty. A P without such a line ending has no anchor.
135
+ //
136
+ // and only when, in addition:
137
+ //
138
+ // - E does not end with G[0, k) for any |lead| < k < |G| (cut from the right)
139
+ // - no complete generated line is left anywhere in E beyond P's own count
140
+ // - between the first and the last byte in which A differs from P, no run of
141
+ // FRAGMENT bytes of G that reaches past `lead` occurs more often than in
142
+ // the same span of P (a fragment left in the middle of the body)
143
+ //
144
+ // So text appended at the very end of the body together with a removed tail
145
+ // refuses, and so does an edit of the last authored line together with one.
146
+ const FRAGMENT = 8
147
+
148
+ function fragmentRuns(generated) {
149
+ const heads = new Set()
150
+ const runs = new Set()
151
+ for (let at = Math.max(0, generated.lead.length - FRAGMENT + 1); at + FRAGMENT <= generated.bytes.length; at += 1) {
152
+ heads.add(generated.bytes.readUInt32BE(at))
153
+ runs.add(generated.bytes.toString('latin1', at, at + FRAGMENT))
154
+ }
155
+ return { heads, runs }
156
+ }
157
+
158
+ function countRuns(bytes, from, to, { heads, runs }) {
159
+ const counts = new Map()
160
+ for (let at = Math.max(0, from); at + FRAGMENT <= Math.min(bytes.length, to); at += 1) {
161
+ if (!heads.has(bytes.readUInt32BE(at))) continue
162
+ const run = bytes.toString('latin1', at, at + FRAGMENT)
163
+ if (runs.has(run)) counts.set(run, (counts.get(run) ?? 0) + 1)
164
+ }
165
+ return counts
166
+ }
167
+
168
+ // The anchor of the published authored part, or null when it has none.
169
+ function authoredAnchor(publishedAuthored) {
170
+ let last = publishedAuthored.length
171
+ while (last > 0 && (publishedAuthored[last - 1] === LF || publishedAuthored[last - 1] === CR)) last -= 1
172
+ if (last === 0) return null
173
+ const lineStart = publishedAuthored.lastIndexOf(LF, last - 1)
174
+ return lineStart === -1 ? null : publishedAuthored.subarray(lineStart)
175
+ }
176
+
177
+ export function splitGeneratedTail({ noteEntry, publishedNoteBytes, editedNoteBytes }) {
178
+ const generated = publishedGenerated(noteEntry, publishedNoteBytes)
179
+ const publishedAuthored = publishedNoteBytes.subarray(0, noteEntry.regions.body.end)
180
+ if (generated.bytes.length === 0) return { authored: editedNoteBytes, generated: 'none' }
181
+ const surplus = (haystack) => generated.lines.findIndex((line) => countOccurrences(haystack, line) > countOccurrences(publishedAuthored, line))
182
+ const endsInCutTail = (haystack) => {
183
+ for (let kept = generated.lead.length + 1; kept < generated.bytes.length; kept += 1) if (endsWith(haystack, generated.bytes.subarray(0, kept))) return true
184
+ return false
185
+ }
186
+
187
+ if (endsWith(editedNoteBytes, generated.bytes)) {
188
+ const authored = editedNoteBytes.subarray(0, editedNoteBytes.length - generated.bytes.length)
189
+ if (surplus(authored) !== -1) refuse('generated-region-edited', 'generated text also appears inside the authored part of the edited note', { generatedLine: surplus(authored) })
190
+ if (endsInCutTail(authored)) refuse('generated-region-edited', 'the beginning of a generated region was copied in front of it')
191
+ return { authored, generated: 'intact' }
192
+ }
193
+ if (surplus(editedNoteBytes) !== -1) {
194
+ refuse('generated-region-edited', 'a generated region was edited, moved or partially deleted', { generatedLine: surplus(editedNoteBytes) })
195
+ }
196
+ if (endsInCutTail(editedNoteBytes)) refuse('generated-region-edited', 'a generated region was cut in the middle; what is left of it is not authored text')
197
+ if (startsWith(editedNoteBytes, publishedAuthored)) {
198
+ if (startsWith(generated.lead, editedNoteBytes.subarray(publishedAuthored.length))) return { authored: editedNoteBytes.subarray(0, publishedAuthored.length), generated: 'removed' }
199
+ refuse('generated-region-edited', 'a generated region was removed and the note no longer ends where its authored text ended')
200
+ }
201
+ const anchor = authoredAnchor(publishedAuthored)
202
+ const proven = []
203
+ for (let keep = 0; anchor !== null && keep <= generated.lead.length; keep += 1) {
204
+ if (!endsWith(editedNoteBytes, generated.lead.subarray(0, keep))) continue
205
+ const authored = editedNoteBytes.subarray(0, editedNoteBytes.length - keep)
206
+ if (endsWith(authored, anchor)) proven.push(authored)
207
+ }
208
+ if (proven.length !== 1) refuse('generated-region-edited', 'a generated region was removed and where the authored text ends cannot be proven', { readings: proven.length })
209
+ const [authored] = proven
210
+ const head = commonPrefixLength(authored, publishedAuthored)
211
+ const tail = commonSuffixLength(authored, publishedAuthored, authored.length, publishedAuthored.length, Math.min(authored.length, publishedAuthored.length) - head)
212
+ const runs = fragmentRuns(generated)
213
+ const before = countRuns(publishedAuthored, head - FRAGMENT + 1, publishedAuthored.length - tail + FRAGMENT - 1, runs)
214
+ for (const [run, count] of countRuns(authored, head - FRAGMENT + 1, authored.length - tail + FRAGMENT - 1, runs)) {
215
+ if (count > (before.get(run) ?? 0)) refuse('generated-region-edited', 'a fragment of a generated region was left inside the authored text')
216
+ }
217
+ return { authored, generated: 'removed' }
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Prefix
222
+ // ---------------------------------------------------------------------------
223
+
224
+ // Returns the edited authored BODY. The prefix must be byte-identical. A byte
225
+ // order prefix is the one exception: it belongs to the source and is kept from
226
+ // the source whether or not the editor kept it in the note.
227
+ export function splitPrefix({ noteEntry, publishedNoteBytes, editedAuthored }) {
228
+ const prefix = publishedNoteBytes.subarray(0, noteEntry.regions.body.start)
229
+ const frontmatter = noteEntry.regions.frontmatter ?? null
230
+ let body
231
+ if (frontmatter === null) {
232
+ body = startsWith(editedAuthored, UTF8_BOM) ? editedAuthored.subarray(UTF8_BOM.length) : editedAuthored
233
+ } else {
234
+ if (!startsWith(editedAuthored, prefix)) {
235
+ refuse('unsupported-frontmatter-edit', 'the front matter of the note was edited; only the body can be replaced', { firstDifference: commonPrefixLength(editedAuthored, prefix) })
236
+ }
237
+ body = editedAuthored.subarray(prefix.length)
238
+ }
239
+ // The body must not read as front matter of its own, and the front matter
240
+ // must still end where it did, as the source lens reads the result.
241
+ let lens = null
242
+ try { lens = readMarkdownLens(Buffer.concat([frontmatter === null ? Buffer.alloc(0) : prefix, body])) } catch { /* refused below */ }
243
+ const expectedEnd = frontmatter === null ? null : frontmatter.end
244
+ if (lens === null || (lens.frontmatter?.end ?? null) !== expectedEnd) {
245
+ refuse('unsupported-frontmatter-edit', 'the edit changes where the front matter of the note begins or ends; only the body can be replaced')
246
+ }
247
+ return body
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Rewritten links and embeds
252
+ // ---------------------------------------------------------------------------
253
+
254
+ function inversionUnits({ manifest, noteEntry, publishedNoteBytes, baseSourceBytes }) {
255
+ const bodyStart = noteEntry.regions.body.start
256
+ const bodyEnd = noteEntry.regions.body.end
257
+ const records = [
258
+ ...manifest.links.filter((link) => link.sourceNodeId === noteEntry.nodeId).flatMap((link) => link.inversions ?? []),
259
+ ...(noteEntry.ext?.[EXT]?.assetEmbeds ?? []).flatMap((embed) => embed.inversions),
260
+ ]
261
+ const units = records.map((record) => {
262
+ const ext = record.ext?.[EXT]
263
+ if (!ext || ext.encoding !== 'base64url') refuse('manifest-mismatch', 'an inversion record does not carry its byte strings')
264
+ const unit = {
265
+ pStart: record.note.start - bodyStart,
266
+ pEnd: record.note.end - bodyStart,
267
+ sourceStart: record.source.start,
268
+ sourceEnd: record.source.end,
269
+ original: Buffer.from(ext.original, 'base64url'),
270
+ emitted: Buffer.from(ext.emitted, 'base64url'),
271
+ }
272
+ const consistent = record.note.start >= bodyStart && record.note.end <= bodyEnd && unit.emitted.length > 0
273
+ && publishedNoteBytes.subarray(record.note.start, record.note.end).equals(unit.emitted)
274
+ && baseSourceBytes.subarray(record.source.start, record.source.end).equals(unit.original)
275
+ if (!consistent) refuse('manifest-mismatch', 'an inversion record does not describe the published note and the base source')
276
+ return unit
277
+ }).sort((left, right) => left.pStart - right.pStart || left.sourceStart - right.sourceStart)
278
+ units.forEach((unit, index) => {
279
+ const previous = units[index - 1]
280
+ if (previous && (unit.pStart < previous.pEnd || unit.sourceStart < previous.sourceEnd)) refuse('manifest-mismatch', 'inversion records overlap')
281
+ // An alias the emitter appended: nothing in the source, `|…` in the note,
282
+ // directly tied to the target rewrite before it.
283
+ unit.aliasOf = unit.original.length === 0 && unit.emitted[0] === PIPE && previous ? index - 1 : null
284
+ if (unit.original.length === 0 && unit.aliasOf === null) refuse('manifest-mismatch', 'an inserted alias has no target rewrite before it')
285
+ })
286
+ return units
287
+ }
288
+
289
+ // Where each rewrite is in the edited body. Every unit ends as
290
+ // { state: 'intact', eStart, eEnd } or { state: 'deleted' }; anything else
291
+ // refuses.
292
+ //
293
+ // intact the emitted bytes lie whole inside one matched run of the
294
+ // alignment, or, failing that, occur exactly once between the
295
+ // edited positions of the unit's matched neighbours
296
+ // or, when no byte of the unit is matched at all (the alignment
297
+ // paired its surroundings with other text), are found between the
298
+ // previous placement and the next unit the alignment did place
299
+ // deleted no byte of the unit is matched and its emitted bytes are not
300
+ // found by either search
301
+ // refused part of the unit survives (it was modified), it occurs more than
302
+ // once between its neighbours, or no search could align its range
303
+ export function placeUnits({ units, publishedBody, editedBody, align = alignBodies, wideSearch = true }) {
304
+ if (units.length === 0) return []
305
+ const wanted = (from, to) => units.some((unit) => unit.pStart < to && unit.pEnd > from)
306
+ const { runs, coarse } = publishedBody.equals(editedBody) ? { runs: [{ p: 0, e: 0, length: publishedBody.length }], coarse: [] } : align(publishedBody, editedBody, { wanted })
307
+ const aligned = units.map((unit) => {
308
+ const run = runs.find((item) => item.p <= unit.pStart && item.p + item.length >= unit.pEnd)
309
+ return run ? run.e + (unit.pStart - run.p) : null
310
+ })
311
+ let placedEnd = 0
312
+ return units.map((unit, index) => {
313
+ if (aligned[index] !== null) {
314
+ const eStart = aligned[index]
315
+ placedEnd = eStart + unit.emitted.length
316
+ return { state: 'intact', eStart, eEnd: placedEnd }
317
+ }
318
+ let lower = 0
319
+ let upper = editedBody.length
320
+ let matched = 0
321
+ for (const run of runs) {
322
+ const runEnd = run.p + run.length
323
+ if (run.p < unit.pStart) lower = run.e + (Math.min(runEnd, unit.pStart) - run.p)
324
+ matched += Math.max(0, Math.min(runEnd, unit.pEnd) - Math.max(run.p, unit.pStart))
325
+ if (runEnd > unit.pEnd) { upper = run.e + (Math.max(run.p, unit.pEnd) - run.p); break }
326
+ }
327
+ lower = Math.max(lower, placedEnd)
328
+ const gap = editedBody.subarray(lower, Math.max(lower, upper))
329
+ const first = gap.indexOf(unit.emitted)
330
+ const unique = first !== -1 && gap.indexOf(unit.emitted, first + 1) === -1
331
+ if (!unique && coarse.some((range) => unit.pStart < range.end && unit.pEnd > range.start)) {
332
+ refuse('edit-too-large-to-align', 'the edit around a rewritten link is too large to align with the published note', { unit: index })
333
+ }
334
+ if (first !== -1 && !unique) refuse('ambiguous-link-alignment', 'a rewritten link cannot be aligned with the published note unambiguously', { unit: index })
335
+ if (first !== -1) {
336
+ placedEnd = lower + first + unit.emitted.length
337
+ return { state: 'intact', eStart: lower + first, eEnd: placedEnd }
338
+ }
339
+ if (matched === 0 && wideSearch) {
340
+ // A shortest edit script may pair the text around an untouched rewrite
341
+ // with other text and call the rewrite itself inserted. Its bytes are
342
+ // then still in the edited body, before the next rewrite that was
343
+ // aligned. The first occurrence is taken only when every rewrite that
344
+ // could claim it inverts to the same source bytes.
345
+ let next = index + 1
346
+ while (next < units.length && aligned[next] === null) next += 1
347
+ const from = Math.max(lower, placedEnd)
348
+ const wide = editedBody.subarray(from, Math.max(from, next < units.length ? aligned[next] : editedBody.length))
349
+ const found = wide.indexOf(unit.emitted)
350
+ if (found !== -1) {
351
+ const rivals = units.slice(index + 1, next).some((other) => other.emitted.equals(unit.emitted) && !other.original.equals(unit.original))
352
+ if (rivals && wide.indexOf(unit.emitted, found + 1) !== -1) refuse('ambiguous-link-alignment', 'a rewritten link cannot be aligned with the published note unambiguously', { unit: index })
353
+ placedEnd = from + found + unit.emitted.length
354
+ return { state: 'intact', eStart: from + found, eEnd: placedEnd }
355
+ }
356
+ }
357
+ if (matched > 0) {
358
+ if (unit.aliasOf !== null) refuse('link-rewrite-edited', 'the alias the emitter appended to a link was edited', { publishedStart: unit.pStart, publishedEnd: unit.pEnd })
359
+ refuse('unsupported-structural-edit', 'the target of a rewritten link was edited', { reason: 'rewritten-target-edited', publishedStart: unit.pStart, publishedEnd: unit.pEnd })
360
+ }
361
+ return { state: 'deleted' }
362
+ })
363
+ }
364
+
365
+ function assertCoherentPlacements({ units, placements, publishedBody, editedBody }) {
366
+ const intactBefore = (index) => { for (let at = index - 1; at >= 0; at -= 1) if (placements[at].state === 'intact') return at; return -1 }
367
+ const intactAfter = (index) => { for (let at = index + 1; at < units.length; at += 1) if (placements[at].state === 'intact') return at; return -1 }
368
+ units.forEach((unit, index) => {
369
+ const state = placements[index].state
370
+ if (unit.aliasOf !== null && placements[unit.aliasOf].state !== state) {
371
+ if (state === 'deleted') refuse('link-rewrite-edited', 'the alias the emitter appended to a link was removed while its link was kept', { publishedStart: unit.pStart, publishedEnd: unit.pEnd })
372
+ refuse('unsupported-structural-edit', 'the target of a rewritten link was removed while the rest of the link was kept', { reason: 'rewritten-target-edited', publishedStart: unit.pStart, publishedEnd: unit.pEnd })
373
+ }
374
+ if (state !== 'deleted') return
375
+ // The same emitted bytes can stand for different source spellings of one
376
+ // target. When one of two such neighbours is deleted, which one survives
377
+ // decides the source bytes. The pairing the alignment chose must keep
378
+ // strictly more of the published bytes around them than the other
379
+ // pairing would; a tie, or a comparison too large to make, refuses.
380
+ for (const kept of [intactBefore(index), intactAfter(index)]) {
381
+ if (kept === -1 || !units[kept].emitted.equals(unit.emitted) || units[kept].original.equals(unit.original)) continue
382
+ const first = units[Math.min(index, kept)]
383
+ const second = units[Math.max(index, kept)]
384
+ const before = intactBefore(Math.min(index, kept))
385
+ const after = intactAfter(Math.max(index, kept))
386
+ const pFrom = before === -1 ? 0 : units[before].pEnd
387
+ const pTo = after === -1 ? publishedBody.length : units[after].pStart
388
+ const editedBefore = editedBody.subarray(before === -1 ? 0 : placements[before].eEnd, placements[kept].eStart)
389
+ const editedAfter = editedBody.subarray(placements[kept].eEnd, after === -1 ? editedBody.length : placements[after].eStart)
390
+ const score = (survivor) => {
391
+ const left = commonSubsequenceLength(publishedBody.subarray(pFrom, survivor.pStart), editedBefore)
392
+ const right = commonSubsequenceLength(publishedBody.subarray(survivor.pEnd, pTo), editedAfter)
393
+ return left === null || right === null ? null : left + right
394
+ }
395
+ const chosen = score(units[kept])
396
+ const other = score(units[kept] === first ? second : first)
397
+ if (chosen === null || other === null || chosen <= other) {
398
+ refuse('ambiguous-link-alignment', 'a deleted link cannot be told apart from a neighbouring link to the same note that the source spells differently', { unit: index })
399
+ }
400
+ }
401
+ })
402
+ }
403
+
404
+ // The final invariant, independent of how the alignment went: every rewrite
405
+ // of the published body is either substituted (intact) or gone (deleted), and
406
+ // what is left of the edited body, which becomes source bytes verbatim, holds
407
+ // no vault identity that the published authored text did not already hold.
408
+ // A vault identity is the `--<hex>` suffix of a note or attachment path of
409
+ // this view, wherever it appears: in a link, in code or in plain prose.
410
+ const IDENTITY = /--[0-9a-f]{12,64}(?![0-9a-f])/g
411
+
412
+ export function assertRewritesAccounted({ manifest, units, placements, publishedBody, editedBody }) {
413
+ if (placements.length !== units.length || placements.some((placement) => placement.state !== 'intact' && placement.state !== 'deleted')) {
414
+ refuse('unsupported-structural-edit', 'a rewritten link is neither kept nor deleted', { reason: 'rewrite-unaccounted' })
415
+ }
416
+ placements.forEach((placement, index) => {
417
+ if (placement.state === 'intact' && !editedBody.subarray(placement.eStart, placement.eEnd).equals(units[index].emitted)) {
418
+ refuse('unsupported-structural-edit', 'a rewritten link is not where it was placed', { reason: 'rewrite-unaccounted' })
419
+ }
420
+ })
421
+ const known = new Set()
422
+ for (const item of [...manifest.notes, ...manifest.attachments]) for (const match of item.path.matchAll(IDENTITY)) known.add(match[0])
423
+ const identities = (body, holes) => {
424
+ const found = []
425
+ let cursor = 0
426
+ for (const [start, end] of [...holes, [body.length, body.length]]) {
427
+ for (const match of body.toString('latin1', cursor, start).matchAll(IDENTITY)) if (known.has(match[0])) found.push({ identity: match[0], start: cursor + match.index, end: cursor + match.index + match[0].length })
428
+ cursor = end
429
+ }
430
+ return found
431
+ }
432
+ const allowance = new Map()
433
+ for (const item of identities(publishedBody, units.map((unit) => [unit.pStart, unit.pEnd]))) allowance.set(item.identity, (allowance.get(item.identity) ?? 0) + 1)
434
+ const surplus = []
435
+ for (const item of identities(editedBody, placements.filter((placement) => placement.state === 'intact').map((placement) => [placement.eStart, placement.eEnd]))) {
436
+ if ((allowance.get(item.identity) ?? 0) > 0) allowance.set(item.identity, allowance.get(item.identity) - 1)
437
+ else surplus.push({ bodyStart: item.start, bodyEnd: item.end })
438
+ }
439
+ return surplus
440
+ }
441
+
442
+ const folded = (value) => value.normalize('NFC').toLowerCase()
443
+
444
+ // Every spelling under which the view's notes and attachments can be linked
445
+ // from inside the vault.
446
+ function vaultTargets(manifest) {
447
+ const targets = new Set()
448
+ const paths = [...manifest.notes.map((note) => note.path), ...manifest.attachments.map((attachment) => attachment.path)]
449
+ for (const filePath of paths) {
450
+ const base = filePath.split('/').at(-1)
451
+ for (const spelling of [filePath, base]) {
452
+ targets.add(folded(spelling))
453
+ if (spelling.endsWith('.md')) targets.add(folded(spelling.slice(0, -'.md'.length)))
454
+ }
455
+ }
456
+ return targets
457
+ }
458
+
459
+ function namesVaultFile(href, targets) {
460
+ const spellings = [href]
461
+ try { spellings.push(decodeURIComponent(href)) } catch { /* not percent-encoded */ }
462
+ return spellings.some((spelling) => {
463
+ const target = folded(spelling.replace(/^<|>$/g, '').replace(/^(?:\.\/)+/, '').trim())
464
+ return targets.has(target) || target.startsWith('notes/') || target.startsWith('attachments/')
465
+ })
466
+ }
467
+
468
+ // Links and embeds of `bytes` (a whole authored note, so front matter is
469
+ // skipped as the canonical scanner skips it) that name a vault file, with byte
470
+ // ranges relative to `bodyStart`. The canonical scanner is the only reader of
471
+ // link syntax.
472
+ function vaultLinkOccurrences(bytes, bodyStart, targets) {
473
+ const text = bytes.toString('utf8')
474
+ const found = scanMarkdownLinks(text).filter((occurrence) => namesVaultFile(occurrence.href, targets))
475
+ if (found.length === 0) return []
476
+ const offsets = [...new Set(found.flatMap((item) => [item.range.start, item.range.end, item.targetRange.start, item.targetRange.end]))].sort((a, b) => a - b)
477
+ const byteAt = new Map()
478
+ let previous = 0
479
+ let total = 0
480
+ for (const offset of offsets) {
481
+ total += Buffer.byteLength(text.slice(previous, offset), 'utf8')
482
+ byteAt.set(offset, total - bodyStart)
483
+ previous = offset
484
+ }
485
+ return found.map((item) => ({
486
+ start: byteAt.get(item.range.start), end: byteAt.get(item.range.end), targetStart: byteAt.get(item.targetRange.start), targetEnd: byteAt.get(item.targetRange.end),
487
+ }))
488
+ }
489
+
490
+ // A link or embed in the edited body that names a vault file must be one the
491
+ // emitter wrote and the person left alone. The one other thing it may be is a
492
+ // target the author's own source already contained, byte for byte and no more
493
+ // often (a source may link to a directory of its own called notes/). Anything else is a
494
+ // structural change: it is never written to a source as a vault-internal path.
495
+ //
496
+ // The reverse holds too. A rewritten target that was the whole target of a
497
+ // link in the published body must still be the whole target of a link, and an
498
+ // appended alias must still be what closes its link. Text typed against
499
+ // either one changes what the link points at once the rewrite is inverted.
500
+ export function findStructuralLinks({ manifest, notePrefix, publishedBody, editedBody, units, placements }) {
501
+ const targets = vaultTargets(manifest)
502
+ const within = (occurrence, start, end) => start >= occurrence.targetStart && end <= occurrence.targetEnd
503
+ const publishedLinks = vaultLinkOccurrences(Buffer.concat([notePrefix, publishedBody]), notePrefix.length, targets)
504
+ const editedLinks = vaultLinkOccurrences(Buffer.concat([notePrefix, editedBody]), notePrefix.length, targets)
505
+ const authoredAlready = new Map()
506
+ for (const occurrence of publishedLinks) {
507
+ if (units.some((unit) => within(occurrence, unit.pStart, unit.pEnd))) continue
508
+ const key = publishedBody.toString('latin1', occurrence.targetStart, occurrence.targetEnd)
509
+ authoredAlready.set(key, (authoredAlready.get(key) ?? 0) + 1)
510
+ }
511
+ const structural = []
512
+ for (const occurrence of editedLinks) {
513
+ if (placements.some((placement) => placement.state === 'intact' && within(occurrence, placement.eStart, placement.eEnd))) continue
514
+ const key = editedBody.toString('latin1', occurrence.targetStart, occurrence.targetEnd)
515
+ if ((authoredAlready.get(key) ?? 0) > 0) { authoredAlready.set(key, authoredAlready.get(key) - 1); continue }
516
+ structural.push({ bodyStart: occurrence.start, bodyEnd: occurrence.end })
517
+ }
518
+ units.forEach((unit, index) => {
519
+ const placement = placements[index]
520
+ if (placement.state !== 'intact') return
521
+ if (unit.aliasOf !== null) {
522
+ if (!editedBody.subarray(placement.eEnd, placement.eEnd + 2).equals(LINK_CLOSE)) structural.push({ bodyStart: placement.eStart, bodyEnd: placement.eEnd, alias: true })
523
+ return
524
+ }
525
+ const wasWholeTarget = publishedLinks.some((occurrence) => occurrence.targetStart === unit.pStart && occurrence.targetEnd === unit.pEnd)
526
+ const isWholeTarget = editedLinks.some((occurrence) => occurrence.targetStart === placement.eStart && occurrence.targetEnd === placement.eEnd)
527
+ if (wasWholeTarget && !isWholeTarget) structural.push({ bodyStart: placement.eStart, bodyEnd: placement.eEnd })
528
+ })
529
+ return structural.sort((left, right) => left.bodyStart - right.bodyStart)
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // The lens
534
+ // ---------------------------------------------------------------------------
535
+
536
+ export const EDIT_LENS_PRIMITIVES = Object.freeze({
537
+ splitGeneratedTail,
538
+ splitPrefix,
539
+ placeUnits,
540
+ findStructuralLinks,
541
+ assertRewritesAccounted,
542
+ originalOf: (unit) => unit.original,
543
+ digestOf: sha256Digest,
544
+ isStrictUtf8,
545
+ isEditableSource: (source) => source?.kind === 'markdown',
546
+ })
547
+
548
+ function lensWith(primitives, { manifest, repoId, nodeId, publishedNoteBytes, editedNoteBytes, baseSourceBytes }) {
549
+ for (const bytes of [publishedNoteBytes, editedNoteBytes, baseSourceBytes]) if (!Buffer.isBuffer(bytes)) throw new TypeError('the edit lens reads Buffers only')
550
+ const noteEntry = manifest.notes.find((note) => note.repoId === repoId && note.nodeId === nodeId)
551
+ if (!noteEntry) refuse('unknown-note', 'the manifest holds no note for this identity')
552
+ const source = noteEntry.ext?.[EXT]?.source
553
+ if (!primitives.isEditableSource(source)) refuse('unsupported-wrapper-edit', 'the note represents a source that is not Markdown; it has no editable body')
554
+ const digests = { publishedNoteDigest: primitives.digestOf(publishedNoteBytes), editedNoteDigest: primitives.digestOf(editedNoteBytes), baseSourceDigest: primitives.digestOf(baseSourceBytes) }
555
+ if (digests.publishedNoteDigest !== noteEntry.noteDigest) refuse('baseline-mismatch', 'the published note bytes are not the bytes the manifest recorded', { expected: noteEntry.noteDigest, actual: digests.publishedNoteDigest })
556
+ if (digests.baseSourceDigest !== source.rawDigest) refuse('stale-base', 'the source is no longer the source the note was generated from', { expected: source.rawDigest, actual: digests.baseSourceDigest })
557
+
558
+ const bodyStart = noteEntry.regions.body.start
559
+ const notePrefix = publishedNoteBytes.subarray(0, bodyStart)
560
+ const sourceLens = readMarkdownLens(baseSourceBytes)
561
+ if (sourceLens.body.start !== bodyStart || sourceLens.body.end !== baseSourceBytes.length || !baseSourceBytes.subarray(0, bodyStart).equals(notePrefix)) {
562
+ refuse('manifest-mismatch', 'the recorded body does not begin where the body of the base source begins')
563
+ }
564
+ const units = inversionUnits({ manifest, noteEntry, publishedNoteBytes, baseSourceBytes })
565
+ if (!primitives.isStrictUtf8(editedNoteBytes)) refuse('invalid-utf8', 'the edited note is not valid UTF-8', { editedNoteDigest: digests.editedNoteDigest })
566
+
567
+ const tail = primitives.splitGeneratedTail({ noteEntry, publishedNoteBytes, editedNoteBytes })
568
+ const editedBody = primitives.splitPrefix({ noteEntry, publishedNoteBytes, editedAuthored: tail.authored })
569
+ const publishedBody = publishedNoteBytes.subarray(bodyStart, noteEntry.regions.body.end)
570
+ const placements = primitives.placeUnits({ units, publishedBody, editedBody })
571
+ assertCoherentPlacements({ units, placements, publishedBody, editedBody })
572
+ // Offsets in a refusal are offsets into the edited note.
573
+ const shift = tail.authored.length - editedBody.length
574
+ const structural = primitives.findStructuralLinks({ manifest, notePrefix: noteEntry.regions.frontmatter ? notePrefix : Buffer.alloc(0), publishedBody, editedBody, units, placements })
575
+ const aliasTouched = structural.find((item) => item.alias)
576
+ if (aliasTouched) {
577
+ refuse('link-rewrite-edited', 'text was added to the alias the emitter appended to a link', { noteStart: aliasTouched.bodyStart + shift, noteEnd: aliasTouched.bodyEnd + shift })
578
+ }
579
+ if (structural.length > 0) {
580
+ refuse('unsupported-structural-edit', 'the edit adds or changes a link to a file of the vault', {
581
+ reason: 'vault-link-changed', occurrences: structural.map((item) => ({ noteStart: item.bodyStart + shift, noteEnd: item.bodyEnd + shift })),
582
+ })
583
+ }
584
+
585
+ const unaccounted = primitives.assertRewritesAccounted({ manifest, units, placements, publishedBody, editedBody }) ?? []
586
+ if (unaccounted.length > 0) {
587
+ refuse('unsupported-structural-edit', 'the edited body names a file of the vault outside the links the emitter rewrote', {
588
+ reason: 'vault-identity-in-authored-text', occurrences: unaccounted.map((item) => ({ noteStart: item.bodyStart + shift, noteEnd: item.bodyEnd + shift })),
589
+ })
590
+ }
591
+
592
+ // Between two surviving rewrites the edited bytes ARE the new source bytes.
593
+ const parts = [baseSourceBytes.subarray(0, bodyStart)]
594
+ const changedRanges = []
595
+ let written = bodyStart
596
+ let sourceCursor = bodyStart
597
+ let editedCursor = 0
598
+ const segment = (editedTo, sourceTo) => {
599
+ const next = editedBody.subarray(editedCursor, editedTo)
600
+ const base = baseSourceBytes.subarray(sourceCursor, sourceTo)
601
+ if (!next.equals(base)) {
602
+ const head = commonPrefixLength(next, base)
603
+ const end = commonSuffixLength(next, base, next.length, base.length, Math.min(next.length, base.length) - head)
604
+ changedRanges.push({ start: sourceCursor + head, end: sourceTo - end, newStart: written + head, newEnd: written + next.length - end })
605
+ }
606
+ parts.push(next)
607
+ written += next.length
608
+ }
609
+ units.forEach((unit, index) => {
610
+ const placement = placements[index]
611
+ if (placement.state !== 'intact') return
612
+ segment(placement.eStart, unit.sourceStart)
613
+ const original = primitives.originalOf(unit)
614
+ parts.push(original)
615
+ written += original.length
616
+ sourceCursor = unit.sourceEnd
617
+ editedCursor = placement.eEnd
618
+ })
619
+ segment(editedBody.length, baseSourceBytes.length)
620
+ const newSourceBytes = Buffer.concat(parts)
621
+ return {
622
+ kind: 'body-replacement',
623
+ newSourceBytes,
624
+ baseSourceDigest: digests.baseSourceDigest,
625
+ newSourceDigest: sha256Digest(newSourceBytes),
626
+ changedRanges,
627
+ unchanged: changedRanges.length === 0,
628
+ generated: tail.generated,
629
+ publishedNoteDigest: digests.publishedNoteDigest,
630
+ editedNoteDigest: digests.editedNoteDigest,
631
+ }
632
+ }
633
+
634
+ // `applyEditLens` returns the body replacement or { kind: 'refusal', code,
635
+ // detail }. It never throws for something a person did to a note; it throws a
636
+ // TypeError for a caller that does not pass Buffers.
637
+ export function createEditLensForOracleTests(primitives = EDIT_LENS_PRIMITIVES) {
638
+ return function applyEditLens(input) {
639
+ try {
640
+ return lensWith(primitives, input)
641
+ } catch (error) {
642
+ if (!(error instanceof ObsidianContractRefusal)) throw error
643
+ return { kind: 'refusal', code: error.code, detail: error.detail ?? {} }
644
+ }
645
+ }
646
+ }
647
+
648
+ export const applyEditLens = createEditLensForOracleTests()