@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,266 @@
1
+ import { createRequire } from 'node:module'
2
+ import { EXCHANGE_CONSTANTS } from './exchange.mjs'
3
+
4
+ // The fixed script that performs one conditional publication. It runs in two
5
+ // places with the same body: inside the Obsidian renderer, sent through the
6
+ // official CLI's `eval`, and in this process when no Obsidian is running.
7
+ //
8
+ // The body is a constant. The only variable input is a closed, validated JSON
9
+ // payload that travels base64-encoded, so note text and paths never become
10
+ // code. Candidate and recovery bytes travel as files bound by SHA-256.
11
+
12
+ export const PROTOCOL_ID = 'obsidian-cli-critical-section/v1'
13
+ export const POLICY_SETTINGS_PATH = '.obsidian/core-plugins.json'
14
+
15
+ const OPS = new Set(['inspect', 'publish', 'collect'])
16
+ const MODES = new Set(['replace', 'remove'])
17
+ const SHA = /^[0-9a-f]{64}$/
18
+ const OPERATION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
19
+ const KEYS = {
20
+ inspect: ['op', 'vaultRoot', 'path'],
21
+ collect: ['op', 'vaultRoot', 'path'],
22
+ replace: ['op', 'mode', 'vaultRoot', 'path', 'operationId', 'baseSha256', 'candidateSha256', 'stagedPath', 'recoveryPath'],
23
+ remove: ['op', 'mode', 'vaultRoot', 'path', 'operationId', 'baseSha256', 'recoveryPath'],
24
+ }
25
+
26
+ const isAbsolute = (value) => typeof value === 'string' && value.startsWith('/') && !value.includes('\u0000')
27
+
28
+ // A vault-relative path the publisher may address: visible files only, plus
29
+ // the one policy-owned settings file. Every other settings path is the
30
+ // person's and cannot be named in a payload at all.
31
+ export function isAddressableVaultPath(value) {
32
+ if (typeof value !== 'string' || value.length === 0 || value.length > 1024 || value.startsWith('/') || value.includes('\\') || value.includes('\u0000')) return false
33
+ if (value === POLICY_SETTINGS_PATH) return true
34
+ return value.split('/').every((part) => part !== '' && part !== '.' && part !== '..' && !part.startsWith('.'))
35
+ }
36
+
37
+ export function validatePayload(payload) {
38
+ const fail = (message) => { throw new TypeError(`Invalid bridge payload: ${message}`) }
39
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) fail('object required')
40
+ if (!OPS.has(payload.op)) fail('unknown op')
41
+ if (payload.op === 'publish' && !MODES.has(payload.mode)) fail('unknown mode')
42
+ const allowed = KEYS[payload.op === 'publish' ? payload.mode : payload.op]
43
+ for (const key of Object.keys(payload)) if (!allowed.includes(key)) fail(`unknown key ${key}`)
44
+ for (const key of allowed) if (payload[key] === undefined) fail(`missing key ${key}`)
45
+ if (!isAbsolute(payload.vaultRoot)) fail('vaultRoot must be absolute')
46
+ if (!isAddressableVaultPath(payload.path)) fail('path must be a relative visible path or the policy settings file')
47
+ if (payload.op === 'publish') {
48
+ if (!OPERATION_ID.test(payload.operationId)) fail('operationId required')
49
+ if (!SHA.test(payload.baseSha256)) fail('baseSha256 required')
50
+ if (!isAbsolute(payload.recoveryPath)) fail('recoveryPath must be absolute')
51
+ if (payload.mode === 'replace') {
52
+ if (!SHA.test(payload.candidateSha256)) fail('candidateSha256 required')
53
+ if (!isAbsolute(payload.stagedPath)) fail('stagedPath must be absolute')
54
+ if (payload.baseSha256 === payload.candidateSha256) fail('candidate equals base; nothing to publish')
55
+ }
56
+ }
57
+ return payload
58
+ }
59
+
60
+ // Everything between "critical section begins" and "critical section ends" is
61
+ // synchronous. Inside the app that means no editor input event, in any window
62
+ // of the vault, can interleave with it. The function is serialized with
63
+ // toString(), so it may not close over anything in this module: all it uses
64
+ // arrives through `host`.
65
+ //
66
+ // host: { app, window, document, require, process, performance, exchange }
67
+ // `host.crashSeam` is a test seam for crash injection. It is a function-valued
68
+ // host property: no payload can carry it and buildEvalCode never sets it.
69
+ export function criticalSection(P, host) {
70
+ const fs = host.require('fs')
71
+ const nodePath = host.require('path')
72
+ const crypto = host.require('crypto')
73
+ const app = host.app || null
74
+ const sha = (value) => crypto.createHash('sha256').update(value).digest('hex')
75
+ const t0 = host.performance.now()
76
+ const trace = []
77
+ const step = (event, detail) => trace.push({ ms: Number((host.performance.now() - t0).toFixed(3)), event, ...(detail || {}) })
78
+ const crash = (point) => { if (host.crashSeam && host.crashSeam.at === point) host.crashSeam.halt(point) }
79
+ const real = (target) => { try { return fs.realpathSync(target) } catch (error) { return null } }
80
+ const vaultBasePath = app ? real(app.vault.adapter.getBasePath()) : real(P.vaultRoot)
81
+ const full = nodePath.join(P.vaultRoot, P.path)
82
+ const views = () => {
83
+ const found = []
84
+ if (!app) return found
85
+ app.workspace.iterateAllLeaves((leaf) => {
86
+ const view = leaf.view
87
+ if (view && view.file && view.file.path === P.path && view.editor && typeof view.getViewData === 'function') found.push(view)
88
+ })
89
+ return found
90
+ }
91
+ const describe = (view) => ({ dirty: Boolean(view.dirty), bufferSha256: sha(view.getViewData()), popout: view.containerEl.ownerDocument !== host.document,
92
+ savedMarker: typeof view.lastSavedData === 'string' })
93
+ const lstat = (target) => { try { return fs.lstatSync(target) } catch (error) { if (error.code === 'ENOENT') return null; throw error } }
94
+ // No write-through links: every directory on the way is a real directory and the leaf, when present, is a regular file.
95
+ const unsafe = () => {
96
+ let current = P.vaultRoot
97
+ const parts = P.path.split('/')
98
+ for (let index = 0; index < parts.length; index += 1) {
99
+ current = nodePath.join(current, parts[index])
100
+ const stat = lstat(current)
101
+ if (!stat) return false
102
+ if (stat.isSymbolicLink() || (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile())) return true
103
+ }
104
+ return false
105
+ }
106
+ const disk = () => (lstat(full) ? sha(fs.readFileSync(full)) : null)
107
+ const snapshot = () => ({ vaultBasePath, diskSha256: disk(), views: views().map(describe) })
108
+ const store = (host.window.__atelierObsidianPublication = host.window.__atelierObsidianPublication || {})
109
+ // Every outcome is kept here so a caller whose reply was lost re-reads it and never resends.
110
+ const done = (status, extra) => {
111
+ if (P.op === 'publish') store[P.path] = { ...(extra || {}), operationId: P.operationId, outcome: status }
112
+ return JSON.stringify({ status, operationId: P.operationId, ...(extra || {}), trace })
113
+ }
114
+
115
+ if (vaultBasePath === null || vaultBasePath !== P.vaultRoot) return done('vault-mismatch', { vaultBasePath, wrote: false })
116
+ // The app answered for this vault; the path itself is the problem.
117
+ if (unsafe()) return done('path-unsafe', { vaultBasePath, wrote: false })
118
+ if (P.op === 'inspect') return done('inspected', snapshot())
119
+ if (P.op === 'collect') return JSON.stringify({ status: 'collected', ...(store[P.path] || { missing: true }), ...snapshot() })
120
+
121
+ // ---- critical section begins (synchronous) ----
122
+ step('enter')
123
+ if (!lstat(full)) return done('note-missing', { ...snapshot(), wrote: false })
124
+ const open = views()
125
+ // Capability floor: the no-write editor update relies on the view's saved-content field. Refuse open notes on an app build that lacks it.
126
+ if (P.mode === 'replace' && open.some((view) => typeof view.lastSavedData !== 'string')) return done('unsupported-app', { wrote: false })
127
+ if (open.some((view) => view.dirty || sha(view.getViewData()) !== P.baseSha256)) return done('editor-edit', { ...snapshot(), wrote: false })
128
+ if (disk() !== P.baseSha256) return done('disk-changed', { ...snapshot(), wrote: false })
129
+ if (lstat(P.recoveryPath)) return done('recovery-path-occupied', { wrote: false })
130
+
131
+ if (P.mode === 'remove') {
132
+ // A move, never a delete: whatever occupies the path at this instant is kept at the recovery path.
133
+ try { fs.renameSync(full, P.recoveryPath) } catch (error) { return done('move-failed', { ...snapshot(), wrote: false, errorCode: error.code || null }) }
134
+ step('moved')
135
+ crash('after-removal-move')
136
+ const movedSha256 = sha(fs.readFileSync(P.recoveryPath))
137
+ if (movedSha256 !== P.baseSha256) {
138
+ // Something replaced the note between the check and the move. Put it back without overwriting anything.
139
+ try { fs.linkSync(P.recoveryPath, full) } catch (error) {
140
+ // The path is taken again, or cannot be linked: the moved bytes stay in recovery.
141
+ return done('removed-external-captured', { ...snapshot(), wrote: true, externalCaptured: true, recoveredSha256: movedSha256 })
142
+ }
143
+ // The note is live again from here on, whatever happens to the second name.
144
+ let recoveryNameLeft = false
145
+ try { fs.unlinkSync(P.recoveryPath) } catch (error) { recoveryNameLeft = true }
146
+ return done('remove-reverted', { ...snapshot(), wrote: false, observedSha256: movedSha256, recoveryNameLeft })
147
+ }
148
+ return done('removed', { ...snapshot(), wrote: true, externalCaptured: false, recoveredSha256: movedSha256 })
149
+ }
150
+
151
+ let candidate
152
+ try { candidate = fs.readFileSync(P.stagedPath) } catch (error) { return done('staged-missing', { wrote: false, errorCode: error.code || null }) }
153
+ if (sha(candidate) !== P.candidateSha256) return done('staged-mismatch', { wrote: false })
154
+ const call = host.exchange.calls[`${host.process.platform}/${host.process.arch}`]
155
+ const perl = host.exchange.perlCandidates.find((candidatePath) => fs.existsSync(candidatePath))
156
+ if (!call || !perl) return done('exchange-unavailable', { wrote: false })
157
+ // The interpreter runs inside the app: only a root-owned binary that neither group nor others can write.
158
+ let perlStat = null
159
+ try { perlStat = fs.statSync(perl) } catch (error) { perlStat = null }
160
+ if (!perlStat || perlStat.uid !== 0 || (perlStat.mode & 0o022) !== 0) return done('exchange-interpreter-untrusted', { wrote: false })
161
+ // Atomic exchange: whatever occupied the note path at this instant lands at
162
+ // the staged path, so no concurrent replacement of the note can be lost.
163
+ let exitStatus = 0
164
+ try {
165
+ host.require('child_process').execFileSync(perl, ['-e', host.exchange.script, '--', String(call.number), String(call.cwd), P.stagedPath, full], { stdio: 'ignore' })
166
+ } catch (error) {
167
+ exitStatus = error.status == null ? null : error.status
168
+ }
169
+ // What happened is read from the files, not from the exit status: a helper
170
+ // killed after the call returned reports failure although the exchange took
171
+ // place. The staged path is private, so bytes there that are not the
172
+ // candidate can only be what the exchange displaced from the note path.
173
+ let stagedNow = null
174
+ try { stagedNow = sha(fs.readFileSync(P.stagedPath)) } catch (error) { stagedNow = null }
175
+ if (stagedNow === null || stagedNow === P.candidateSha256) {
176
+ // All-or-nothing: a failure (for example a full disk) leaves the note as it was.
177
+ const seen = snapshot()
178
+ return done('exchange-failed', { ...seen, wrote: stagedNow === null && seen.diskSha256 === P.candidateSha256, exitStatus })
179
+ }
180
+ step('exchanged', exitStatus === 0 ? undefined : { exitStatus })
181
+ crash('after-exchange')
182
+ fs.renameSync(P.stagedPath, P.recoveryPath)
183
+ const recoveredSha256 = sha(fs.readFileSync(P.recoveryPath))
184
+ const externalCaptured = recoveredSha256 !== P.baseSha256
185
+ step('recovered', { externalCaptured })
186
+ crash('after-recovery-move')
187
+
188
+ const candidateText = candidate.toString('utf8')
189
+ let editorUpdateFailed = false
190
+ open.forEach((view) => {
191
+ // Route the change through the editor so the app never takes its lossy
192
+ // external-modification merge path for this view, and never writes the
193
+ // file itself afterwards: an in-place save here would overwrite an outside
194
+ // writer that replaced the note after the exchange. Minimal line hunks in
195
+ // one transaction keep carets and selections in untouched text.
196
+ try {
197
+ const a = view.getViewData().split(/(?<=\n)/)
198
+ const b = candidateText.split(/(?<=\n)/)
199
+ let head = 0
200
+ while (head < a.length && head < b.length && a[head] === b[head]) head += 1
201
+ let tail = 0
202
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1
203
+ const starts = [0]
204
+ for (const line of a) starts.push(starts[starts.length - 1] + line.length)
205
+ const am = a.slice(head, a.length - tail)
206
+ const bm = b.slice(head, b.length - tail)
207
+ const changes = []
208
+ if (am.length * bm.length > 4000000) {
209
+ // Too large for a line table inside a synchronous section: one hunk over the differing middle.
210
+ changes.push({ from: starts[head], to: starts[a.length - tail], old: am.join(''), text: bm.join('') })
211
+ } else {
212
+ const lcs = Array.from({ length: am.length + 1 }, () => new Uint32Array(bm.length + 1))
213
+ for (let i = am.length - 1; i >= 0; i -= 1) {
214
+ for (let j = bm.length - 1; j >= 0; j -= 1) lcs[i][j] = am[i] === bm[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
215
+ }
216
+ let current = null
217
+ for (let i = 0, j = 0; i < am.length || j < bm.length;) {
218
+ if (i < am.length && j < bm.length && am[i] === bm[j]) { current = null; i += 1; j += 1; continue }
219
+ if (!current) { current = { from: starts[head + i], to: starts[head + i], old: '', text: '' }; changes.push(current) }
220
+ if (j < bm.length && (i >= am.length || lcs[i][j + 1] >= lcs[i + 1][j])) { current.text += bm[j]; j += 1 } else { current.old += am[i]; i += 1; current.to = starts[head + i] }
221
+ }
222
+ }
223
+ for (const change of changes) {
224
+ let lead = 0
225
+ while (lead < change.old.length && lead < change.text.length && change.old[lead] === change.text[lead]) lead += 1
226
+ let trail = 0
227
+ while (trail < change.old.length - lead && trail < change.text.length - lead
228
+ && change.old[change.old.length - 1 - trail] === change.text[change.text.length - 1 - trail]) trail += 1
229
+ change.from += lead; change.to -= trail; change.text = change.text.slice(lead, change.text.length - trail)
230
+ }
231
+ view.editor.transaction({ changes: changes.map((change) => ({ from: view.editor.offsetToPos(change.from), to: view.editor.offsetToPos(change.to), text: change.text })) })
232
+ if (view.getViewData() !== candidateText) { step('hunk-fallback'); view.editor.setValue(candidateText) }
233
+ view.lastSavedData = candidateText // the app's delayed save now sees nothing to write
234
+ } catch (error) {
235
+ // The note is already published and the buffer was clean: the app reloads a clean buffer from disk without loss.
236
+ editorUpdateFailed = true
237
+ step('editor-update-failed')
238
+ }
239
+ })
240
+ step('editor-routed', { openViews: open.length })
241
+ crash('after-editor-update')
242
+ // ---- critical section ends ----
243
+ return done(externalCaptured ? 'published-external-captured' : 'published', { ...snapshot(), wrote: true, openViews: open.length, externalCaptured, recoveredSha256, editorUpdateFailed })
244
+ }
245
+
246
+ const encodePayload = (payload) => Buffer.from(JSON.stringify(validatePayload(payload)), 'utf8').toString('base64')
247
+
248
+ // The code sent to the app. Only the base64 literal varies between calls.
249
+ export function buildEvalCode(payload) {
250
+ return `(${criticalSection.toString()})(JSON.parse(new TextDecoder().decode(Uint8Array.from(atob('${encodePayload(payload)}'),c=>c.charCodeAt(0)))),`
251
+ + `{app,window,document,require,process,performance,exchange:${JSON.stringify(EXCHANGE_CONSTANTS)}})`
252
+ }
253
+
254
+ const nodeRequire = createRequire(import.meta.url)
255
+
256
+ // A host for running the same script in this process. With no `app` the
257
+ // script sees no editors; that is only correct when no Obsidian has the vault
258
+ // open, which the publisher establishes before choosing this path.
259
+ export function createInProcessHost({ app = null, window = {}, document = {}, crashSeam } = {}) {
260
+ return { app, window, document, require: nodeRequire, process, performance, exchange: EXCHANGE_CONSTANTS, ...(crashSeam ? { crashSeam } : {}) }
261
+ }
262
+
263
+ export function runInProcess(payload, host) {
264
+ // The payload takes the same JSON round trip it takes on its way into the app.
265
+ return JSON.parse(criticalSection(JSON.parse(JSON.stringify(validatePayload(payload))), host))
266
+ }
@@ -0,0 +1,117 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { randomUUID } from 'node:crypto'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+
6
+ // Atomic exchange of two paths on one volume. After a successful call each
7
+ // path names the file the other path named before it; a failed call changes
8
+ // nothing. This is the only primitive the publisher uses to put generated
9
+ // bytes at an editable path, because whatever occupied that path at the
10
+ // instant of the call is kept, under the other name, instead of destroyed.
11
+ //
12
+ // The call is reached through the system perl as a raw syscall: no perl
13
+ // module, no compiler and no package dependency.
14
+ // macOS: renameatx_np(AT_FDCWD, from, AT_FDCWD, to, RENAME_SWAP)
15
+ // Linux: renameat2(AT_FDCWD, from, AT_FDCWD, to, RENAME_EXCHANGE)
16
+ // The perl binary must be owned by uid 0 and writable by neither group nor
17
+ // others, here and inside the app; otherwise the caller refuses.
18
+ // Link-then-rename is not a substitute and is not offered. Where the exchange
19
+ // is unavailable the caller refuses and publishes nothing.
20
+
21
+ export const EXCHANGE_CONSTANTS = Object.freeze({
22
+ perlCandidates: Object.freeze(['/usr/bin/perl', '/bin/perl', '/usr/local/bin/perl']),
23
+ // argv: syscall number, AT_FDCWD, from, to. The exit status is errno.
24
+ script: 'my ($n,$d,$from,$to)=@ARGV; my $r = syscall($n+0, $d+0, $from, $d+0, $to, 2); exit($r == 0 ? 0 : ($!+0 || 1));',
25
+ calls: Object.freeze({
26
+ 'darwin/arm64': Object.freeze({ number: 488, cwd: -2 }),
27
+ 'darwin/x64': Object.freeze({ number: 488, cwd: -2 }),
28
+ 'linux/x64': Object.freeze({ number: 316, cwd: -100 }),
29
+ 'linux/arm64': Object.freeze({ number: 276, cwd: -100 }),
30
+ }),
31
+ })
32
+
33
+ export class ExchangeRefusal extends Error {
34
+ constructor(code, message, detail = {}) {
35
+ super(`${code}: ${message}`)
36
+ this.name = 'ExchangeRefusal'
37
+ this.code = code
38
+ this.detail = detail
39
+ }
40
+ }
41
+
42
+ // The interpreter runs inside the publisher and, through the fixed script,
43
+ // inside the app. It is used only when it belongs to the system: owned by
44
+ // uid 0 and writable by neither group nor others.
45
+ export const isTrustedInterpreter = (stat) => Boolean(stat) && stat.uid === 0 && (stat.mode & 0o022) === 0
46
+
47
+ export function resolveExchange({ platform = process.platform, arch = process.arch, perlPath, existsSync = fs.existsSync, statSync = fs.statSync } = {}) {
48
+ if (platform !== 'darwin' && platform !== 'linux') {
49
+ throw new ExchangeRefusal('exchange-unsupported-platform', 'no atomic exchange is known for this platform; nothing is published', { platform })
50
+ }
51
+ const call = EXCHANGE_CONSTANTS.calls[`${platform}/${arch}`]
52
+ if (!call) throw new ExchangeRefusal('exchange-unsupported-architecture', 'the exchange syscall number is not known for this architecture', { platform, arch })
53
+ const perl = perlPath ?? EXCHANGE_CONSTANTS.perlCandidates.find((candidate) => existsSync(candidate))
54
+ if (!perl || !existsSync(perl)) throw new ExchangeRefusal('exchange-interpreter-missing', 'the system perl used to reach the exchange syscall is absent', { platform })
55
+ let stat = null
56
+ try { stat = statSync(perl) } catch { /* unreadable is untrusted */ }
57
+ if (!isTrustedInterpreter(stat)) {
58
+ throw new ExchangeRefusal('exchange-interpreter-untrusted', 'the perl used to reach the exchange syscall is not owned by root, or is writable by group or others; nothing is published', { perl })
59
+ }
60
+ return { perl, number: call.number, cwd: call.cwd }
61
+ }
62
+
63
+ export function exchangeFiles(from, to, options = {}) {
64
+ if (!path.isAbsolute(from) || !path.isAbsolute(to)) throw new TypeError('exchangeFiles needs absolute paths')
65
+ const { perl, number, cwd } = resolveExchange(options)
66
+ try {
67
+ execFileSync(perl, ['-e', EXCHANGE_CONSTANTS.script, '--', String(number), String(cwd), from, to], { stdio: 'ignore' })
68
+ } catch (error) {
69
+ throw new ExchangeRefusal('exchange-failed', 'the atomic exchange did not happen; nothing changed', { exitStatus: error.status ?? null })
70
+ }
71
+ }
72
+
73
+ const probed = new Map()
74
+
75
+ export function resetExchangeProbeCache() {
76
+ probed.clear()
77
+ }
78
+
79
+ // Self-test on two scratch files inside `directory`, which must be on the
80
+ // volume that holds the vault. The publisher passes a directory inside the
81
+ // recovery area, where exchange candidates live. The verdict is cached per volume. A refusal is
82
+ // returned, not thrown: { supported: false, code, message }.
83
+ export function probeExchange({ directory, ...options } = {}) {
84
+ if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw new TypeError('probeExchange needs an absolute directory')
85
+ let key
86
+ try {
87
+ key = `${options.platform ?? process.platform}/${options.arch ?? process.arch}/${options.perlPath ?? ''}/${fs.statSync(directory).dev}`
88
+ } catch (error) {
89
+ return { supported: false, code: 'exchange-probe-failed', message: `the exchange directory cannot be read: ${error.code ?? error.message}` }
90
+ }
91
+ if (probed.has(key)) return probed.get(key)
92
+ const id = randomUUID()
93
+ const first = path.join(directory, `.atelier-exchange-probe-${id}.a`)
94
+ const second = path.join(directory, `.atelier-exchange-probe-${id}.b`)
95
+ let verdict
96
+ try {
97
+ const resolved = resolveExchange(options)
98
+ fs.writeFileSync(first, 'a', { flag: 'wx', mode: 0o600 })
99
+ fs.writeFileSync(second, 'b', { flag: 'wx', mode: 0o600 })
100
+ exchangeFiles(first, second, options)
101
+ if (fs.readFileSync(first, 'utf8') !== 'b' || fs.readFileSync(second, 'utf8') !== 'a') {
102
+ throw new ExchangeRefusal('exchange-unsupported-filesystem', 'the exchange call returned success without exchanging the files')
103
+ }
104
+ verdict = { supported: true, perl: resolved.perl }
105
+ } catch (error) {
106
+ if (!(error instanceof ExchangeRefusal)) {
107
+ // A scratch file could not even be written (for example a full disk). That says nothing durable about the volume.
108
+ return { supported: false, code: 'exchange-probe-failed', message: `the exchange self-test could not run: ${error.code ?? error.message}` }
109
+ }
110
+ const code = error.code === 'exchange-failed' ? 'exchange-unsupported-filesystem' : error.code
111
+ verdict = { supported: false, code, message: error.message, detail: error.detail }
112
+ } finally {
113
+ for (const file of [first, second]) fs.rmSync(file, { force: true })
114
+ }
115
+ probed.set(key, verdict)
116
+ return verdict
117
+ }
@@ -0,0 +1,5 @@
1
+ // Conditional publication of a prepared view into an editable Obsidian vault.
2
+ export { PROTOCOL_ID, buildEvalCode, createInProcessHost, criticalSection, isAddressableVaultPath, runInProcess, validatePayload } from './bridge-script.mjs'
3
+ export { EXCHANGE_CONSTANTS, ExchangeRefusal, exchangeFiles, probeExchange, resetExchangeProbeCache, resolveExchange } from './exchange.mjs'
4
+ export { TransportTimeout, createDirectAdapter, createEditorAdapter, createInProcessCall, createObsidianCliAdapter, createObsidianCliCall, defaultCliPath, defaultObsidianProcessProbe } from './transport.mjs'
5
+ export { publishView } from './publisher.mjs'