@mnstry/atelier 0.2.0-alpha.5 → 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 (383) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +86 -25
  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-repository-observation.v1.schema.json +163 -0
  25. package/contracts/atelier-review.v1.schema.json +1601 -0
  26. package/contracts/atelier-skill-steward.v1.schema.json +692 -0
  27. package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
  28. package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
  29. package/contracts/atelier-vault-publication.v1.schema.json +58 -0
  30. package/docs/assurance-controls.md +12 -2
  31. package/docs/atelier-runtime.md +13 -2
  32. package/docs/atelier-sync.md +172 -0
  33. package/docs/blocks/claims.md +28 -18
  34. package/docs/blocks/will-not-do.md +12 -3
  35. package/docs/coauthor-session.md +57 -0
  36. package/docs/connected-composition.md +75 -0
  37. package/docs/decisions.md +167 -0
  38. package/docs/exact-upgrades.md +159 -0
  39. package/docs/extension-pack-lifecycle.md +33 -0
  40. package/docs/guided-upgrades.md +99 -0
  41. package/docs/install.md +65 -9
  42. package/docs/intake-and-guides.md +65 -0
  43. package/docs/integration-and-review-plan.md +409 -0
  44. package/docs/integration-contract-decisions.md +22 -0
  45. package/docs/local-review.md +84 -0
  46. package/docs/local-services.md +227 -0
  47. package/docs/local-state.md +107 -0
  48. package/docs/obsidian-contract.md +687 -0
  49. package/docs/obsidian.md +387 -0
  50. package/docs/portable-presentation.md +328 -0
  51. package/docs/presentation-consumer-boundaries.md +75 -0
  52. package/docs/presentation-review-disposition.md +47 -0
  53. package/docs/project-command-inventory.md +38 -0
  54. package/docs/project-options.md +53 -0
  55. package/docs/release-engineering.md +99 -2
  56. package/docs/review-portability.md +55 -0
  57. package/docs/skill-steward.md +156 -0
  58. package/docs/upgrade.md +70 -2
  59. package/docs/vault-service.md +292 -0
  60. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  61. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  62. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  63. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  64. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  65. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  66. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  67. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  68. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  69. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  70. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  71. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  72. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  73. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  74. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  75. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  76. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  77. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  78. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  79. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  80. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  81. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  82. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  83. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  84. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  85. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  86. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  87. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  88. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  89. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  90. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  91. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  92. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  93. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  94. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  95. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  96. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  97. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  98. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  99. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  100. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  101. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  102. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  103. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  104. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  105. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  106. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  107. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  108. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  109. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  110. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  111. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  112. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  113. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  114. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  115. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  116. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  117. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  118. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  119. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  120. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  121. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  122. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  123. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  124. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  125. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  126. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  127. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  128. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  129. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  130. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  131. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  132. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  133. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  134. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  135. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  136. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  137. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  138. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  139. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  140. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  141. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  142. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  143. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  144. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  145. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  146. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  147. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  148. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  149. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  150. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  151. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  152. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  153. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  154. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  155. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  156. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  157. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  158. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  159. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  160. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  161. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  162. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  163. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  164. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  165. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  166. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  167. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  168. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  169. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  170. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  171. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  172. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  173. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  174. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  175. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  176. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  177. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  178. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  179. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  180. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  181. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  182. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  183. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  184. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  185. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  186. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  187. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  188. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  189. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  190. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  191. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  192. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  193. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  194. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  195. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  196. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  197. package/fixtures/obsidian/edits/workspace.json +65 -0
  198. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  199. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  200. package/fixtures/obsidian/materialization/workspace.json +54 -0
  201. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  202. package/fixtures/ui/presentation/pane.v1.json +7 -0
  203. package/fixtures/ui/presentation/reference.v1.json +293 -0
  204. package/package.json +61 -6
  205. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  206. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  207. package/skills/claude/atelier-open/SKILL.md +5 -0
  208. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  209. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  210. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  211. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  212. package/skills/codex/atelier-open/SKILL.md +5 -0
  213. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  214. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  215. package/src/access/preview-enforcer.mjs +84 -0
  216. package/src/boundary/content-rules.mjs +9 -4
  217. package/src/boundary/policy.mjs +74 -42
  218. package/src/cli/project-options.mjs +58 -0
  219. package/src/cli/run.mjs +61 -3
  220. package/src/coauthor/session.mjs +161 -0
  221. package/src/coauthor/store.mjs +173 -0
  222. package/src/collaboration/event-ledger.mjs +16 -33
  223. package/src/collaboration/inspection-bundle.mjs +223 -0
  224. package/src/collaboration/review-contracts.mjs +25 -0
  225. package/src/collaboration/review-store.mjs +305 -0
  226. package/src/commands/coauthor.mjs +26 -0
  227. package/src/commands/config.mjs +6 -1
  228. package/src/commands/extension-pack.mjs +15 -8
  229. package/src/commands/init.mjs +44 -5
  230. package/src/commands/obsidian.mjs +368 -0
  231. package/src/commands/review.mjs +147 -0
  232. package/src/commands/setup.mjs +51 -14
  233. package/src/commands/skills.mjs +199 -0
  234. package/src/commands/sync.mjs +107 -0
  235. package/src/composition/registry.mjs +27 -0
  236. package/src/composition/wire.mjs +122 -0
  237. package/src/contracts/corpus.mjs +35 -0
  238. package/src/decisions/contracts.d.mts +96 -0
  239. package/src/decisions/contracts.mjs +321 -0
  240. package/src/disclosure/content-scan.mjs +13 -0
  241. package/src/export/atelier-export-contract.mjs +16 -1
  242. package/src/extension-packs/lifecycle.mjs +154 -0
  243. package/src/graph/graph.mjs +64 -2
  244. package/src/graph/knowledge-graph.mjs +548 -24
  245. package/src/guides/contracts.mjs +54 -0
  246. package/src/index.mjs +29 -0
  247. package/src/intake/store.mjs +119 -0
  248. package/src/preview/channel.mjs +89 -0
  249. package/src/project/config.mjs +50 -49
  250. package/src/project/durable-state.mjs +161 -0
  251. package/src/project/file-class.mjs +166 -0
  252. package/src/project/private-state.mjs +11 -1
  253. package/src/projection/obsidian/contracts.mjs +571 -0
  254. package/src/projection/obsidian/edits/align.mjs +169 -0
  255. package/src/projection/obsidian/edits/apply.mjs +889 -0
  256. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  257. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  258. package/src/projection/obsidian/edits/index.mjs +19 -0
  259. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  260. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  261. package/src/projection/obsidian/edits/observe.mjs +123 -0
  262. package/src/projection/obsidian/edits/policy.mjs +123 -0
  263. package/src/projection/obsidian/edits/regions.mjs +648 -0
  264. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  265. package/src/projection/obsidian/materialize/index.mjs +7 -0
  266. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  267. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  268. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  269. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  270. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  271. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  272. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  273. package/src/projection/obsidian/proposals/index.mjs +23 -0
  274. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  275. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  276. package/src/projection/obsidian/proposals/router.mjs +168 -0
  277. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  278. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  279. package/src/projection/obsidian/publication/index.mjs +5 -0
  280. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  281. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  282. package/src/projection/obsidian/publication/transport.mjs +139 -0
  283. package/src/projection/obsidian/recovery/index.mjs +6 -0
  284. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  285. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  286. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  287. package/src/projection/obsidian/recovery/store.mjs +234 -0
  288. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  289. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  290. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  291. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  292. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  293. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  294. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  295. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  296. package/src/projection/project.mjs +28 -19
  297. package/src/readiness-protocols/evidence.mjs +282 -0
  298. package/src/readiness-protocols/runtime.mjs +4 -3
  299. package/src/readiness-protocols/source-read.mjs +39 -0
  300. package/src/runtime/git-adapter.mjs +189 -0
  301. package/src/runtime/local-state.mjs +439 -0
  302. package/src/runtime/obsidian/app-capability.mjs +110 -0
  303. package/src/runtime/obsidian/app-production-seams.mjs +96 -0
  304. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  305. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  306. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  307. package/src/runtime/obsidian/contributions.mjs +27 -0
  308. package/src/runtime/obsidian/documents.mjs +37 -0
  309. package/src/runtime/obsidian/enablement.mjs +53 -0
  310. package/src/runtime/obsidian/engine.mjs +532 -0
  311. package/src/runtime/obsidian/errors.mjs +14 -0
  312. package/src/runtime/obsidian/extension-points.mjs +103 -0
  313. package/src/runtime/obsidian/index.mjs +34 -0
  314. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  315. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  316. package/src/runtime/obsidian/observation.mjs +158 -0
  317. package/src/runtime/obsidian/opening.mjs +243 -0
  318. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  319. package/src/runtime/obsidian/pipeline.mjs +122 -0
  320. package/src/runtime/obsidian/private-lock.mjs +156 -0
  321. package/src/runtime/obsidian/process-identity.mjs +58 -0
  322. package/src/runtime/obsidian/service-client.mjs +66 -0
  323. package/src/runtime/obsidian/service-main.mjs +89 -0
  324. package/src/runtime/obsidian/service-record.mjs +169 -0
  325. package/src/runtime/obsidian/service-server.mjs +121 -0
  326. package/src/runtime/obsidian/service.mjs +216 -0
  327. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  328. package/src/runtime/obsidian/state-store.mjs +180 -0
  329. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  330. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  331. package/src/runtime/obsidian/watchers.mjs +36 -0
  332. package/src/runtime/repository-observation.mjs +491 -0
  333. package/src/runtime/supervisor.mjs +803 -0
  334. package/src/server/local-sidecar.mjs +44 -0
  335. package/src/server/server.mjs +1 -0
  336. package/src/skills/steward.mjs +780 -0
  337. package/src/ui/presentation/browser.mjs +278 -0
  338. package/src/ui/presentation/contract.mjs +116 -0
  339. package/src/ui/presentation/index.mjs +7 -0
  340. package/src/ui/presentation/native.mjs +153 -0
  341. package/src/ui/presentation/proof.mjs +57 -0
  342. package/src/ui/presentation/schema-check.mjs +30 -0
  343. package/src/ui/presentation/schema.generated.mjs +1371 -0
  344. package/src/ui/presentation/state.mjs +43 -0
  345. package/src/ui/presentation/styles.mjs +67 -0
  346. package/src/ui/presentation/tokens.mjs +74 -0
  347. package/src/ui/presentation/web.mjs +85 -0
  348. package/src/ui/review-page.mjs +32 -0
  349. package/src/upgrade/explanation.mjs +33 -0
  350. package/src/upgrade/provenance.mjs +338 -0
  351. package/src/upgrade/transaction-files.mjs +103 -0
  352. package/src/upgrade/transaction.mjs +475 -0
  353. package/src/upgrade/upgrade.mjs +63 -28
  354. package/src/vault/hosts.mjs +21 -0
  355. package/src/vault/identity.mjs +19 -0
  356. package/src/vault/index.mjs +7 -0
  357. package/src/vault/interface.mjs +11 -0
  358. package/src/vault/metadata.mjs +31 -0
  359. package/src/vault/privacy.mjs +148 -0
  360. package/src/vault/probe.mjs +89 -0
  361. package/src/vault/service.mjs +131 -0
  362. package/src/vault/source.mjs +45 -0
  363. package/src/vault/storage.mjs +23 -0
  364. package/templates/distribution-workspace/.claude/launch.json +6 -2
  365. package/templates/distribution-workspace/gitignore +1 -0
  366. package/templates/distribution-workspace/project/README.md +2 -2
  367. package/templates/external-project-workspace/README.md +45 -0
  368. package/templates/external-project-workspace/answers.example.json +4 -0
  369. package/templates/external-project-workspace/atelier.project.json +40 -0
  370. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  371. package/templates/external-project-workspace/gitignore +8 -0
  372. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  373. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  374. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  375. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  376. package/templates/external-project-workspace/source/README.md +11 -0
  377. package/templates/launch.json +6 -2
  378. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  379. package/templates/private-domain-workspace/domain/README.md +2 -2
  380. package/templates/private-domain-workspace/gitignore +1 -0
  381. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  382. package/templates/shared-project-workspace/gitignore +1 -0
  383. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,588 @@
1
+ import fs from 'node:fs'
2
+ import fsp from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { setTimeout as sleep } from 'node:timers/promises'
5
+ import { acquirePrivateLock, publishPrivateFile, syncPrivateDirectory } from '../../../project/durable-state.mjs'
6
+ import { ObsidianContractRefusal, assertObsidianContract } from '../contracts.mjs'
7
+ import { isPolicySettingsPath, isUserOwnedSettingsPath, prepareSettings } from '../materialize/settings.mjs'
8
+ import { createJournal, newJournalId } from '../recovery/journal.mjs'
9
+ import { recheckDisplacedFiles } from '../recovery/late-writer.mjs'
10
+ import { publishedSinceCommit, reconcileUnit, recordDisplaced, recoverPublicationsLocked, retireStagedFile } from '../recovery/restart.mjs'
11
+ import { PublicationRefusal, acquireVaultLock, readFileBytes, readFileDigest, refuse, sha256Digest } from '../recovery/store.mjs'
12
+ import { PROTOCOL_ID, isAddressableVaultPath } from './bridge-script.mjs'
13
+ import { probeExchange } from './exchange.mjs'
14
+ import { CRASH_INJECTION_TEST_SEAM } from './test-seam.mjs'
15
+ import { createDirectAdapter } from './transport.mjs'
16
+
17
+ // publishView: conditional publication of one prepared view into an editable
18
+ // vault. The order is the contract's:
19
+ //
20
+ // recover interrupted publications -> write every candidate in staging
21
+ // (same volume, fsync), store the manifest, open the journal, which names
22
+ // every candidate path and digest, and move each candidate that will be
23
+ // exchanged into its unit's recovery directory -> per note: retain the comparison baseline,
24
+ // write the capture entry, publish conditionally, verify bytes -> re-check
25
+ // displaced files after a quiet period -> commit the trusted manifest last.
26
+ //
27
+ // Until the manifest is committed the view is `updating`. A refusal for one
28
+ // note does not stop the others; a refused note stays one generation behind
29
+ // and the view converges on a later run. No editable path is ever replaced
30
+ // unconditionally: an existing file changes only through the atomic exchange
31
+ // inside the critical section, a new file appears only through an exclusive
32
+ // link, and a file leaves only by being moved to recovery.
33
+ //
34
+ // Where candidates are: an exchange leaves the displaced bytes at the
35
+ // candidate's path, so a candidate that replaces an existing file is exchanged
36
+ // from `recovery/<journalId>/<unit>/`, never from staging. Staging holds only
37
+ // generated bytes that no exchange names: every candidate while it is written,
38
+ // and the candidates of created files until they are linked into the vault.
39
+
40
+ const hex = (digest) => digest.slice('sha256:'.length)
41
+ const iso = (clock) => { const value = clock(); return (value instanceof Date ? value : new Date(value)).toISOString() }
42
+ const REFUSED_BY_EDIT = new Set(['editor-edit', 'disk-changed'])
43
+ const unreleased = new Map()
44
+
45
+ function readNote(file) {
46
+ try { return readFileBytes(file) } catch (error) { if (error.code === 'ENOENT') return null; throw error }
47
+ }
48
+
49
+ function validatePreparedView(preparedView, store) {
50
+ if (!preparedView || !Array.isArray(preparedView.files) || !preparedView.manifest) throw new TypeError('publishView needs a prepared view')
51
+ try { assertObsidianContract('generation-manifest', preparedView.manifest) } catch (error) {
52
+ if (error instanceof ObsidianContractRefusal) refuse('invalid-prepared-view', 'the prepared manifest does not satisfy its contract', error.detail)
53
+ throw error
54
+ }
55
+ const { manifest } = preparedView
56
+ if (manifest.scopeId !== store.scopeId) refuse('invalid-prepared-view', 'the prepared view belongs to another scope')
57
+ if (manifest.completeness.status !== 'complete') refuse('invalid-prepared-view', 'a partial generation is not published')
58
+ // Closed over the manifest itself: one path names one file, as a note or as an attachment, once.
59
+ const expected = new Map()
60
+ for (const [declared, fileDigest] of [...manifest.notes.map((note) => [note.path, note.noteDigest]), ...manifest.attachments.map((item) => [item.path, item.digest])]) {
61
+ if (expected.has(declared)) refuse('invalid-prepared-view', 'the manifest declares one path more than once')
62
+ expected.set(declared, fileDigest)
63
+ }
64
+ const seen = new Set()
65
+ for (const file of preparedView.files) {
66
+ if (!isAddressableVaultPath(file.path) || isUserOwnedSettingsPath(file.path)) refuse('invalid-prepared-view', 'a prepared path is outside what the publisher may write')
67
+ if (seen.has(file.path)) refuse('invalid-prepared-view', 'a prepared path appears twice')
68
+ seen.add(file.path)
69
+ if (file.kind === 'settings') {
70
+ if (!isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'only the policy settings file may be prepared as settings')
71
+ continue
72
+ }
73
+ if (!['note', 'attachment'].includes(file.kind) || isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'a prepared file has an unknown kind')
74
+ if (!Buffer.isBuffer(file.bytes) || sha256Digest(file.bytes) !== file.digest || expected.get(file.path) !== file.digest) {
75
+ refuse('invalid-prepared-view', 'prepared bytes, their digest and the manifest disagree')
76
+ }
77
+ expected.delete(file.path)
78
+ }
79
+ if (expected.size > 0) refuse('invalid-prepared-view', 'the manifest names a file the prepared view does not carry')
80
+ let parsedBytes = null
81
+ if (Buffer.isBuffer(preparedView.manifestBytes)) {
82
+ try { parsedBytes = JSON.parse(preparedView.manifestBytes.toString('utf8')) } catch { refuse('invalid-prepared-view', 'manifest bytes are not JSON') }
83
+ }
84
+ const manifestBytes = parsedBytes !== null && JSON.stringify(parsedBytes) === JSON.stringify(manifest)
85
+ ? preparedView.manifestBytes
86
+ : Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
87
+ return { manifest, manifestBytes }
88
+ }
89
+
90
+ // Every directory on the way to a vault path is a real directory. Missing
91
+ // ones are created; nothing existing is changed.
92
+ function ensureParents(vaultRoot, relativePath) {
93
+ let current = vaultRoot
94
+ for (const part of relativePath.split('/').slice(0, -1)) {
95
+ current = path.join(current, part)
96
+ let stat = fs.lstatSync(current, { throwIfNoEntry: false })
97
+ if (!stat) {
98
+ try { fs.mkdirSync(current) } catch (error) { if (error.code !== 'EEXIST') throw error }
99
+ stat = fs.lstatSync(current)
100
+ }
101
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return false
102
+ }
103
+ return true
104
+ }
105
+
106
+ // `created` receives the path once this call has created the file, and not before: cleanup removes only those.
107
+ function stageCandidate(file, bytes, mode, created = [], crash = () => {}) {
108
+ const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, mode)
109
+ created.push(file)
110
+ try {
111
+ crash('after-late-candidate-open')
112
+ fs.writeFileSync(descriptor, bytes)
113
+ fs.fchmodSync(descriptor, mode)
114
+ fs.fsyncSync(descriptor)
115
+ } finally {
116
+ fs.closeSync(descriptor)
117
+ }
118
+ if (sha256Digest(readFileBytes(file)) !== sha256Digest(bytes)) throw Object.assign(new Error('staged bytes differ from the candidate'), { code: 'ESTAGE' })
119
+ }
120
+
121
+ // Keep units, compared ahead of the per-unit loop: each vault file is read
122
+ // and hashed once, several at a time through the thread pool, and a unit whose
123
+ // bytes are exactly the candidate's is settled as unchanged. Nothing else is
124
+ // decided here. A keep unit that is absent, holds other bytes, sits under an
125
+ // unsafe parent, is not a regular file or cannot be read goes through
126
+ // publishUnit as before, which reads it again. The checks are the ones the
127
+ // synchronous path makes (every parent a real directory, the leaf a regular
128
+ // file opened without following a link and bound to the file that was
129
+ // inspected); only the ordering and the concurrency differ, and a kept note
130
+ // is never written, so its comparison needs no place in the unit order.
131
+ const KEEP_COMPARISON_WIDTH = 16
132
+
133
+ async function unchangedKeepUnits(units, store) {
134
+ const settled = new Set()
135
+ const pending = units.filter((unit) => unit.op === 'keep')
136
+ let next = 0
137
+ const worker = async () => {
138
+ while (next < pending.length) {
139
+ const unit = pending[next]
140
+ next += 1
141
+ if (await holdsCandidateBytes(store.vaultRoot, unit.path, unit.candidateDigest)) settled.add(unit.path)
142
+ }
143
+ }
144
+ await Promise.all(Array.from({ length: Math.min(KEEP_COMPARISON_WIDTH, pending.length) }, worker))
145
+ return settled
146
+ }
147
+
148
+ async function holdsCandidateBytes(vaultRoot, relativePath, candidateDigest) {
149
+ try {
150
+ let current = vaultRoot
151
+ for (const part of relativePath.split('/').slice(0, -1)) {
152
+ current = path.join(current, part)
153
+ const stat = await fsp.lstat(current)
154
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return false
155
+ }
156
+ const file = path.join(vaultRoot, relativePath)
157
+ const before = await fsp.lstat(file)
158
+ if (before.isSymbolicLink() || !before.isFile()) return false
159
+ const handle = await fsp.open(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0))
160
+ try {
161
+ const opened = await handle.stat()
162
+ if (!opened.isFile() || (before.ino !== 0 && (before.dev !== opened.dev || before.ino !== opened.ino))) return false
163
+ return sha256Digest(await handle.readFile()) === candidateDigest
164
+ } finally {
165
+ await handle.close()
166
+ }
167
+ } catch {
168
+ return false
169
+ }
170
+ }
171
+
172
+ function planUnits({ files, priorManifest, pointer, ledger }) {
173
+ const trusted = new Map()
174
+ for (const note of priorManifest?.notes ?? []) trusted.set(note.path, note.noteDigest)
175
+ for (const attachment of priorManifest?.attachments ?? []) trusted.set(attachment.path, attachment.digest)
176
+ // A note kept because it was edited is still bound to the digest it was generated with.
177
+ for (const item of pointer?.retained ?? []) if (item.priorDigest && !item.movedToRecovery) trusted.set(item.path, item.priorDigest)
178
+ for (const [notePath, digest] of ledger) { if (digest === null) trusted.delete(notePath); else trusted.set(notePath, digest) }
179
+
180
+ const units = []
181
+ const present = new Set()
182
+ for (const file of files) {
183
+ present.add(file.path)
184
+ if (file.kind === 'settings') { units.push({ path: file.path, kind: 'settings', op: 'settings' }); continue }
185
+ const base = trusted.get(file.path)
186
+ const op = base === undefined ? 'create' : base === file.digest ? 'keep' : 'replace'
187
+ units.push({ path: file.path, kind: file.kind, op, baseDigest: base ?? null, candidateDigest: file.digest, bytes: file.bytes })
188
+ }
189
+ for (const [notePath, digest] of [...trusted].sort(([left], [right]) => (left < right ? -1 : 1))) {
190
+ if (!present.has(notePath)) units.push({ path: notePath, kind: notePath.startsWith('attachments/') ? 'attachment' : 'note', op: 'remove', baseDigest: digest, candidateDigest: null })
191
+ }
192
+ units.forEach((unit, index) => { unit.unit = index })
193
+ return units
194
+ }
195
+
196
+ export async function publishView(options = {}) {
197
+ const { preparedView, protocolId, expectedGeneration, recoveryStore: store, adapter, clock = () => new Date(), quietPeriodMs = 1500, exchangeOptions = {} } = options
198
+ const seam = options[CRASH_INJECTION_TEST_SEAM] ?? null
199
+ const crash = (point) => { if (seam && seam.at === point) seam.halt(point) }
200
+ if (!store || typeof store.commitManifest !== 'function') throw new TypeError('publishView needs a recoveryStore')
201
+ if (!adapter || typeof adapter.probe !== 'function') throw new TypeError('publishView needs an editor coordination adapter')
202
+ if (expectedGeneration !== null && typeof expectedGeneration !== 'string') throw new TypeError('expectedGeneration must be a generation identity or null')
203
+
204
+ const releases = []
205
+ let journal = null
206
+ try {
207
+ if (protocolId !== PROTOCOL_ID) refuse('unknown-protocol', 'the publisher implements exactly one publication protocol')
208
+ const { manifest, manifestBytes } = validatePreparedView(preparedView, store)
209
+ // A release that could not be written (a full disk) is finished first.
210
+ for (const lockPath of [store.vaultLockPath, store.lockPath]) {
211
+ if (!unreleased.has(lockPath)) continue
212
+ try { unreleased.get(lockPath)() } catch (error) { refuse('state-unwritable', 'private publication state cannot be written; nothing in the vault was touched', { cause: error.code ?? String(error.message) }) }
213
+ unreleased.delete(lockPath)
214
+ }
215
+ // Two locks, the view's and then the vault's: a second view or a second
216
+ // workspace state pointed at the same vault refuses instead of racing.
217
+ const acquire = (lockPath, take, held) => {
218
+ try { releases.push([lockPath, take()]) } catch (error) {
219
+ if (error.code === 'EEXIST') refuse('publication-in-progress', `another publication ${held} holds the lock`)
220
+ refuse('state-unwritable', 'private publication state cannot be written; nothing in the vault was touched', { cause: error.code ?? String(error.message) })
221
+ }
222
+ }
223
+ acquire(store.lockPath, () => acquirePrivateLock(store.lockPath), 'of this view')
224
+ acquire(store.vaultLockPath, () => acquireVaultLock(store), 'into this vault')
225
+ const recovered = recoverPublicationsLocked({ store, clock })
226
+ const pointer = store.readCurrent()
227
+ if (pointer?.generationId === manifest.generationId) {
228
+ return { state: 'committed', alreadyCommitted: true, generationId: manifest.generationId, journalId: pointer.journalId, notes: [], retainedEdits: pointer.retained ?? [], lateWriters: [], recovered }
229
+ }
230
+ if ((pointer?.generationId ?? null) !== expectedGeneration) refuse('generation-mismatch', 'the committed generation is not the one this publication expects', { committed: pointer?.generationId ?? null })
231
+
232
+ // Path selection.
233
+ const probe = await adapter.probe({ vaultRoot: store.vaultRoot })
234
+ if (probe.state !== 'coordinated' && probe.state !== 'absent') refuse('editor-uncoordinated', `an Obsidian process may have this vault open and cannot be coordinated with: ${probe.reason}`)
235
+ const mode = probe.state === 'coordinated' ? 'in-app' : 'direct'
236
+ const channel = mode === 'in-app' ? adapter : createDirectAdapter({ crashSeam: seam })
237
+
238
+ // Same volume, and an exchange that works on it.
239
+ const vaultDevice = fs.statSync(store.vaultRoot).dev
240
+ // Candidates are exchanged from the recovery area, so that is where the
241
+ // exchange is proven; staging must share the volume because candidates are moved from it.
242
+ let probeDir
243
+ try { probeDir = store.exchangeProbeDir() } catch (error) { refuse('state-unwritable', 'private publication state cannot be written; nothing in the vault was touched', { cause: error.code ?? String(error.message) }) }
244
+ if ([store.stagingRoot, store.resolve('recovery'), probeDir].some((directory) => fs.statSync(directory).dev !== vaultDevice)) {
245
+ refuse('staging-volume-mismatch', 'staging and recovery must be on the volume that holds the vault')
246
+ }
247
+ const exchange = probeExchange({ directory: probeDir, ...exchangeOptions })
248
+ if (!exchange.supported) refuse(exchange.code, exchange.message)
249
+
250
+ const units = planUnits({ files: preparedView.files, priorManifest: store.readCurrentManifest(), pointer, ledger: publishedSinceCommit({ store }) })
251
+
252
+ // Stage every known candidate before anything is touched.
253
+ const journalId = newJournalId(clock)
254
+ const created = []
255
+ const moved = []
256
+ let stagingDir = null
257
+ try {
258
+ stagingDir = store.stagingDir(journalId)
259
+ for (const unit of units.filter((item) => item.op === 'replace' || item.op === 'create')) {
260
+ const existing = fs.lstatSync(path.join(store.vaultRoot, unit.path), { throwIfNoEntry: false })
261
+ unit.preparedPath = store.preparedPath(journalId, unit.unit)
262
+ stageCandidate(unit.preparedPath, unit.bytes, existing?.isFile() ? existing.mode & 0o777 : 0o644, created)
263
+ // A replacement is exchanged, and the exchange leaves the displaced bytes at this path: it is in recovery.
264
+ unit.stagedPath = unit.op === 'replace' ? store.exchangeCandidatePath(journalId, unit.unit) : unit.preparedPath
265
+ }
266
+ syncPrivateDirectory(stagingDir)
267
+ const manifestFile = path.join(store.journalDir(journalId), 'manifest.json')
268
+ publishPrivateFile(manifestFile, manifestBytes)
269
+ journal = createJournal(store, { journalId, protocolId, expectedGeneration, targetGeneration: manifest.generationId, clock,
270
+ detail: { mode, manifestRef: store.ref(manifestFile), manifestDigest: sha256Digest(manifestBytes),
271
+ // The units this run may write. A kept note is compared and never written; the one case that writes
272
+ // it after all (a kept note gone missing) is a late candidate with its own write-ahead entry.
273
+ units: units.filter((item) => item.op !== 'keep').map(({ unit, path: unitPath, kind, op }) => ({ unit, path: unitPath, kind, op })),
274
+ staged: units.filter((item) => item.stagedPath).map((item) => ({ unit: item.unit, path: item.path, candidateDigest: item.candidateDigest, stagedRef: store.ref(item.stagedPath),
275
+ ...(item.stagedPath === item.preparedPath ? {} : { preparedRef: store.ref(item.preparedPath) }) })) } })
276
+ crash('before-candidate-move')
277
+ // The journal now names every exchange candidate by path and digest. Only complete, fsynced files are moved
278
+ // in, so a file at an exchange path is never a partial candidate.
279
+ for (const unit of units.filter((item) => item.stagedPath && item.stagedPath !== item.preparedPath)) {
280
+ moveToExchangePath(store, journalId, unit, moved)
281
+ }
282
+ } catch (error) {
283
+ if (error instanceof PublicationRefusal) throw error
284
+ // No payload has named any of these paths, so nothing was exchanged and they can only hold our own
285
+ // candidates. In staging they are removed by name. In recovery a file is removed only when its bytes are
286
+ // the candidate's; anything else stays for restart recovery, which the journal header points at it. Only
287
+ // empty directories are removed, so a failed staging leaves nothing that looks like an unfinished unit.
288
+ for (const unit of moved) {
289
+ if (readFileDigest(unit.stagedPath) === unit.candidateDigest) fs.rmSync(unit.stagedPath, { force: true })
290
+ try { fs.rmdirSync(path.dirname(unit.stagedPath)) } catch { /* not empty: left for restart recovery */ }
291
+ }
292
+ for (const file of created) fs.rmSync(file, { force: true })
293
+ for (const directory of [stagingDir, store.unitsRoot(journalId)]) if (directory) try { fs.rmdirSync(directory) } catch { /* absent, or not empty */ }
294
+ refuse('staging-failed', 'the candidates could not be staged; nothing in the vault was touched', { cause: error.code ?? String(error.message) })
295
+ }
296
+ crash('after-staging')
297
+
298
+ const context = { store, journal, journalId, channel, clock, crash, mode }
299
+ const results = []
300
+ // Kept notes are compared first, several at a time; one that holds exactly its candidate bytes is unchanged.
301
+ const unchanged = await unchangedKeepUnits(units, store)
302
+ // The direct path has no editor coordination, so it is only right while no
303
+ // Obsidian runs. Staging takes time and an app may have started since path
304
+ // selection: the process table is read again immediately before the first
305
+ // note, whatever time has passed, and again whenever two seconds have
306
+ // passed since the last reading. An app that starts after a reading and
307
+ // before the next is not seen; that window is at most two seconds plus
308
+ // one note's publication.
309
+ let lastProbe = null
310
+ for (const unit of units) {
311
+ if (mode === 'direct' && (lastProbe === null || Date.now() - lastProbe > 2000)) {
312
+ lastProbe = Date.now()
313
+ const again = await adapter.probe({ vaultRoot: store.vaultRoot })
314
+ if (again.state !== 'absent') context.uncoordinated = again.reason
315
+ }
316
+ if (unit.op === 'keep' && unchanged.has(unit.path)) {
317
+ results.push({ path: unit.path, kind: unit.kind, op: unit.op, outcome: context.uncoordinated ? 'editor-uncoordinated' : 'unchanged', blocking: false })
318
+ continue
319
+ }
320
+ results.push(await publishUnit(unit, context))
321
+ }
322
+
323
+ const blocking = results.filter((result) => result.blocking)
324
+ const retainedEdits = results.filter((result) => result.retained).map((result) => result.retained)
325
+ // Notes kept earlier stay surfaced until they leave the vault or come back into a view.
326
+ const base = { journalId, generationId: manifest.generationId, mode, notes: results.map(({ retained, ...rest }) => rest), retainedEdits, recovered }
327
+ if (blocking.length > 0) {
328
+ const lateWriters = recheckDisplacedFiles({ store, journalIds: [journalId], clock })
329
+ return { ...base, state: 'updating', lateWriters }
330
+ }
331
+
332
+ // Quiet period, then look at the displaced files again.
333
+ if (quietPeriodMs > 0 && results.some((result) => result.recoveryRef)) await sleep(quietPeriodMs)
334
+ const lateWriters = recheckDisplacedFiles({ store, journalIds: [journalId], clock })
335
+ for (const finding of lateWriters.filter((item) => item.code === 'late-writer-captured')) {
336
+ journal.append({ step: 'verify', outcome: 'conflict', state: 'verifying', notePath: finding.notePath, beforeDigest: finding.digestAtMove, afterDigest: finding.observedDigest, recoveryRef: finding.displacedRef,
337
+ detail: { unit: finding.unit, code: 'late-writer-captured', objectRef: finding.objectRef } })
338
+ }
339
+ journal.append({ step: 'verify', outcome: 'ok', state: 'verifying', detail: { settled: true, retained: retainedEdits } })
340
+ crash('before-manifest-commit')
341
+ store.commitManifest({ manifestBytes, generationId: manifest.generationId, journalId, retained: retainedEdits, committedAt: iso(clock) })
342
+ crash('after-manifest-pointer')
343
+ journal.append({ step: 'manifest-commit', outcome: 'ok', state: 'committed', detail: { manifestDigest: sha256Digest(manifestBytes) } })
344
+ journal.close()
345
+ try { fs.rmdirSync(store.stagingDir(journalId)) } catch { /* something is still staged; restart recovery looks at it */ }
346
+ return { ...base, state: 'committed', lateWriters }
347
+ } catch (error) {
348
+ if (!(error instanceof PublicationRefusal)) {
349
+ if (journal) try { journal.append({ step: 'verify', outcome: 'failed', state: 'failed', detail: { code: 'publisher-error', message: String(error.code ?? error.message).slice(0, 200) } }) } catch { /* the journal itself cannot be written */ }
350
+ throw error
351
+ }
352
+ return { state: 'refused', refusal: { code: error.code, message: error.message, detail: error.detail }, notes: [], retainedEdits: [], lateWriters: [] }
353
+ } finally {
354
+ for (const [lockPath, release] of releases.reverse()) try { release() } catch { unreleased.set(lockPath, release) }
355
+ }
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // One note, attachment or settings file
360
+ // ---------------------------------------------------------------------------
361
+
362
+ async function publishUnit(unit, context) {
363
+ const { store, journal, journalId, clock } = context
364
+ const note = path.join(store.vaultRoot, unit.path)
365
+ const outcome = (code, extra = {}) => ({ path: unit.path, kind: unit.kind, op: unit.op, outcome: code, blocking: false, ...extra })
366
+ const observe = (bytes) => store.retainObject(bytes)
367
+ if (context.uncoordinated) return outcome('editor-uncoordinated', { blocking: unit.op !== 'keep' })
368
+ // A settings path that cannot be written safely (a symlinked `.obsidian`,
369
+ // say) is reported and left alone; it must not keep every note from
370
+ // converging while the person repairs it. Notes and attachments block.
371
+ const unsafeBlocks = unit.op !== 'settings'
372
+ if (!ensureParents(store.vaultRoot, unit.path)) return outcome('path-unsafe', { blocking: unsafeBlocks })
373
+ const leaf = fs.lstatSync(note, { throwIfNoEntry: false })
374
+ if (leaf && !leaf.isFile()) return outcome('path-unsafe', { blocking: unsafeBlocks })
375
+
376
+ let plan = unit
377
+ if (unit.op === 'settings') {
378
+ plan = planSettings(unit, context)
379
+ if (plan.outcome) return plan
380
+ }
381
+ const current = readNote(note)
382
+ const currentDigest = current === null ? null : sha256Digest(current)
383
+
384
+ if (plan.op === 'keep') {
385
+ if (currentDigest === plan.candidateDigest) return outcome('unchanged')
386
+ if (current === null) {
387
+ let stagedPath
388
+ try { stagedPath = stageLate(plan, context) } catch (error) {
389
+ return outcome('staging-failed', { blocking: true, errorCode: error.code ?? null })
390
+ }
391
+ plan = { ...plan, op: 'create', stagedPath }
392
+ }
393
+ else {
394
+ // Generated bytes did not change and the person's did: nothing to write, nothing to lose.
395
+ const object = observe(current)
396
+ return outcome('edit-kept', { observedDigest: currentDigest, objectRef: object.ref })
397
+ }
398
+ }
399
+ if (plan.op === 'replace' && current === null) plan = { ...plan, op: 'create' }
400
+ if (plan.op === 'remove' && current === null) return outcome('already-absent')
401
+ if (plan.op !== 'remove' && currentDigest !== null && currentDigest === plan.candidateDigest) {
402
+ if (plan.stagedPath) retire(plan, context)
403
+ return outcome('already-current')
404
+ }
405
+
406
+ const operationId = `${journalId}:${unit.unit}`
407
+ const displaced = plan.op === 'create' ? null : store.displacedPath(journalId, unit.unit)
408
+ // The journal state never goes backwards: once a note has been published the transition is `updating`.
409
+ const capture = (result, code, extra = {}) => journal.append({ step: 'capture', outcome: result, state: context.updating ? 'updating' : 'captured', notePath: unit.path,
410
+ ...(extra.beforeDigest ? { beforeDigest: extra.beforeDigest } : {}), ...(result === 'ok' && plan.candidateDigest ? { afterDigest: plan.candidateDigest } : {}),
411
+ ...(extra.recoveryRef ? { recoveryRef: extra.recoveryRef } : {}), detail: { unit: unit.unit, op: plan.op, kind: unit.kind, code, ...(extra.detail ?? {}) } })
412
+ const settle = (result, code, extra = {}) => journal.append({ step: 'conditional-update', outcome: result, state: (context.updating ||= result === 'ok') ? 'updating' : 'captured', notePath: unit.path,
413
+ ...(plan.baseDigest ? { beforeDigest: plan.baseDigest } : {}), ...(result === 'ok' && plan.candidateDigest ? { afterDigest: plan.candidateDigest } : {}),
414
+ ...(extra.recoveryRef ? { recoveryRef: extra.recoveryRef } : {}), detail: { unit: unit.unit, op: plan.op, kind: unit.kind, code, ...(extra.externalCaptured ? { externalCaptured: true } : {}) } })
415
+
416
+ if (plan.op === 'create') {
417
+ if (current !== null) {
418
+ // A file appeared where a new note would go. It is not ours: keep it.
419
+ const object = observe(current)
420
+ capture('conflict', 'create-conflict', { beforeDigest: currentDigest, recoveryRef: object.ref })
421
+ retire(plan, context)
422
+ return outcome('create-conflict', { blocking: true, observedDigest: currentDigest, objectRef: object.ref })
423
+ }
424
+ capture('ok', 'intent', { detail: { intent: true, operationId, stagedRef: store.ref(plan.stagedPath) } })
425
+ context.crash('after-capture')
426
+ try {
427
+ // Exclusive create: a hard link either makes the complete file appear or fails because something is there.
428
+ fs.linkSync(plan.stagedPath, note)
429
+ } catch (error) {
430
+ retire(plan, context)
431
+ settle(error.code === 'EEXIST' ? 'conflict' : 'failed', error.code === 'EEXIST' ? 'create-conflict' : 'create-failed')
432
+ return outcome(error.code === 'EEXIST' ? 'create-conflict' : 'create-failed', { blocking: true, errorCode: error.code })
433
+ }
434
+ context.crash('after-publish')
435
+ fs.unlinkSync(plan.stagedPath)
436
+ syncPrivateDirectory(path.dirname(note))
437
+ settle('ok', 'created')
438
+ return verify(unit, plan, context, outcome('created'))
439
+ }
440
+
441
+ // replace and remove: the comparison baseline is retained before anything moves.
442
+ if (currentDigest !== plan.baseDigest) {
443
+ const object = observe(current)
444
+ capture('conflict', 'disk-changed', { beforeDigest: currentDigest, recoveryRef: object.ref })
445
+ if (plan.stagedPath) retire(plan, context)
446
+ const retained = { path: unit.path, priorDigest: plan.baseDigest, observedDigest: currentDigest, objectRef: object.ref }
447
+ // An edited note that left the scope is never deleted. It stays in the vault and stays surfaced.
448
+ if (plan.op === 'remove') return outcome('retained-edit', { retained, observedDigest: currentDigest, objectRef: object.ref })
449
+ return outcome('disk-changed', { blocking: true, observedDigest: currentDigest, objectRef: object.ref })
450
+ }
451
+ const baseObject = observe(current)
452
+ const inspected = await context.channel.inspect({ vaultRoot: store.vaultRoot, path: unit.path })
453
+ if (inspected.status !== 'inspected') {
454
+ capture('failed', inspected.status, { beforeDigest: currentDigest })
455
+ if (plan.stagedPath) retire(plan, context)
456
+ return outcome(inspected.status, { blocking: true })
457
+ }
458
+ if (inspected.views.some((view) => view.dirty || view.bufferSha256 !== hex(plan.baseDigest))) {
459
+ capture('conflict', 'editor-edit', { beforeDigest: currentDigest, recoveryRef: baseObject.ref })
460
+ if (plan.stagedPath) retire(plan, context)
461
+ return outcome('editor-edit', { blocking: true, openViews: inspected.views.length })
462
+ }
463
+ capture('ok', 'intent', { beforeDigest: plan.baseDigest, recoveryRef: store.ref(displaced),
464
+ detail: { intent: true, operationId, baseObjectRef: baseObject.ref, openViews: inspected.views.length, ...(plan.stagedPath ? { stagedRef: store.ref(plan.stagedPath) } : {}) } })
465
+ context.crash('after-capture')
466
+
467
+ const reply = await context.channel.publish(plan.op === 'remove'
468
+ ? { op: 'publish', mode: 'remove', vaultRoot: store.vaultRoot, path: unit.path, operationId, baseSha256: hex(plan.baseDigest), recoveryPath: displaced }
469
+ : { op: 'publish', mode: 'replace', vaultRoot: store.vaultRoot, path: unit.path, operationId, baseSha256: hex(plan.baseDigest), candidateSha256: hex(plan.candidateDigest), stagedPath: plan.stagedPath, recoveryPath: displaced })
470
+ context.crash('after-publish')
471
+
472
+ if (reply.status === 'published' || reply.status === 'published-external-captured' || reply.status === 'removed' || reply.status === 'removed-external-captured') {
473
+ syncPrivateDirectory(path.dirname(displaced))
474
+ const recorded = recordDisplaced({ store, journalId, unit: unit.unit, notePath: unit.path, displacedPath: displaced, baseDigest: plan.baseDigest, at: iso(clock),
475
+ digestAtMove: /^[0-9a-f]{64}$/.test(reply.recoveredSha256 ?? '') ? `sha256:${reply.recoveredSha256}` : null })
476
+ const code = plan.op === 'remove' ? (recorded.externalCaptured ? 'removed-external-captured' : 'removed') : (recorded.externalCaptured ? 'published-external-captured' : 'published')
477
+ settle('ok', code, { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured })
478
+ const result = outcome(code, { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured, openViews: reply.openViews ?? 0, replyLost: reply.replyLost === true,
479
+ ...(plan.op === 'remove' && recorded.externalCaptured ? { retained: { path: unit.path, priorDigest: plan.baseDigest, observedDigest: recorded.digestAtMove, recoveryRef: recorded.displacedRef, movedToRecovery: true } } : {}) })
480
+ return plan.op === 'remove' ? result : verify(unit, plan, context, result)
481
+ }
482
+
483
+ if (reply.status === 'outcome-unknown') {
484
+ // No reply and no recorded outcome. The files say what happened; the publish is not sent again.
485
+ const captureEntry = journal.document().entries.findLast((entry) => entry.step === 'capture' && entry.notePath === unit.path)
486
+ const entry = reconcileUnit({ store, journalId, capture: captureEntry, at: iso(clock) })
487
+ journal.append({ ...entry, state: (context.updating ||= entry.outcome === 'ok') ? 'updating' : 'captured' })
488
+ if (entry.outcome === 'ok') {
489
+ const result = outcome(entry.detail.code, { recoveryRef: entry.recoveryRef, externalCaptured: entry.detail.externalCaptured === true, replyLost: true })
490
+ return plan.op === 'remove' ? result : verify(unit, plan, context, result)
491
+ }
492
+ return outcome('outcome-unknown-nothing-published', { blocking: true, replyLost: true })
493
+ }
494
+
495
+ if (plan.stagedPath) retire(plan, context)
496
+ if (reply.status === 'remove-reverted') {
497
+ // The put-back may have left its second name behind. It names the live note, not displaced bytes: it gets no
498
+ // receipt, and it is removed here when it still is that very file.
499
+ const [live, left] = [fs.lstatSync(note, { throwIfNoEntry: false }), fs.lstatSync(displaced, { throwIfNoEntry: false })]
500
+ if (live && left && live.ino === left.ino && live.dev === left.dev) try { fs.unlinkSync(displaced) } catch { /* the late-writer check skips a name that is the live note */ }
501
+ settle('conflict', 'remove-reverted')
502
+ const bytes = readNote(note)
503
+ const object = bytes === null ? null : observe(bytes)
504
+ return outcome('retained-edit', { retained: { path: unit.path, priorDigest: plan.baseDigest, observedDigest: bytes === null ? null : sha256Digest(bytes), ...(object ? { objectRef: object.ref } : {}) } })
505
+ }
506
+ if (reply.status === 'note-missing' && plan.op === 'remove') {
507
+ settle('ok', 'already-absent')
508
+ return outcome('already-absent')
509
+ }
510
+ const conflict = REFUSED_BY_EDIT.has(reply.status) || reply.status === 'note-missing'
511
+ // Whatever is at the path now is somebody's edit: keep a copy before reporting the refusal.
512
+ const seen = REFUSED_BY_EDIT.has(reply.status) ? readNote(note) : null
513
+ const seenObject = seen === null || sha256Digest(seen) === plan.baseDigest ? null : observe(seen)
514
+ settle(conflict ? 'conflict' : 'failed', reply.status, seenObject ? { recoveryRef: seenObject.ref } : {})
515
+ return outcome(reply.status, { blocking: true, ...(seenObject ? { observedDigest: seenObject.digest, objectRef: seenObject.ref } : {}), ...(reply.exitStatus === undefined ? {} : { exitStatus: reply.exitStatus }) })
516
+ }
517
+
518
+ // Moves a complete candidate from staging to the path it will be exchanged from.
519
+ function moveToExchangePath(store, journalId, unit, moved = []) {
520
+ store.unitDir(journalId, unit.unit)
521
+ moved.push(unit)
522
+ fs.renameSync(unit.preparedPath, unit.stagedPath)
523
+ syncPrivateDirectory(path.dirname(unit.stagedPath))
524
+ }
525
+
526
+ function retire(plan, { store, journalId, crash }) {
527
+ const result = retireStagedFile({ store, journalId, unit: plan.unit, stagedPath: plan.stagedPath, candidateDigest: plan.candidateDigest, crash })
528
+ for (const capturedPath of result.capturedPaths) recordDisplaced({ store, journalId, unit: plan.unit, notePath: plan.path, displacedPath: capturedPath, baseDigest: plan.baseDigest ?? null, at: new Date().toISOString() })
529
+ }
530
+
531
+ // A candidate that could not be known when the run began (settings merged from
532
+ // the bytes on disk now; a kept note that has gone missing). One that will be
533
+ // created stays in staging. One that will be exchanged goes to the unit's
534
+ // recovery directory like any other, and because the header could not name
535
+ // it, a write-ahead entry names its path and digest before it is moved there:
536
+ // restart recovery can then tell this generated file from displaced bytes.
537
+ // A late candidate that fails part-way is removed before the failure is
538
+ // reported: nothing names it, so nothing would ever clean it up.
539
+ function stageLate(plan, { store, journal, journalId, crash, updating }) {
540
+ const preparedPath = store.preparedPath(journalId, plan.unit, { late: true })
541
+ const created = []
542
+ try { stageCandidate(preparedPath, plan.bytes, plan.mode ?? 0o644, created, crash) } catch (error) {
543
+ for (const file of created) fs.rmSync(file, { force: true })
544
+ throw error
545
+ }
546
+ if (plan.op !== 'replace') return preparedPath
547
+ const unit = { unit: plan.unit, preparedPath, stagedPath: store.exchangeCandidatePath(journalId, plan.unit, { late: true }) }
548
+ journal.append({ step: 'capture', outcome: 'ok', state: updating ? 'updating' : 'captured', notePath: plan.path, afterDigest: plan.candidateDigest,
549
+ detail: { unit: plan.unit, op: plan.op, kind: plan.kind, code: 'late-candidate', intent: false, stagedRef: store.ref(unit.stagedPath), preparedRef: store.ref(preparedPath) } })
550
+ crash('after-late-write-ahead')
551
+ moveToExchangePath(store, journalId, unit)
552
+ return unit.stagedPath
553
+ }
554
+
555
+ // Policy settings are owned per key. The candidate is the file on disk now
556
+ // with only the owned keys set; the base is that same file. A file that is
557
+ // not a JSON object or array is the person's to repair and is left alone.
558
+ function planSettings(unit, context) {
559
+ const note = path.join(context.store.vaultRoot, unit.path)
560
+ const existing = readNote(note)
561
+ let prepared
562
+ try { prepared = prepareSettings({ existing }) } catch (error) {
563
+ if (error instanceof ObsidianContractRefusal) return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'settings-invalid', blocking: false }
564
+ throw error
565
+ }
566
+ const [file] = prepared.files
567
+ if (existing !== null && file.bytes.equals(existing)) return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'policy-satisfied', blocking: false }
568
+ const plan = { ...unit, op: existing === null ? 'create' : 'replace', baseDigest: existing === null ? null : sha256Digest(existing), candidateDigest: file.digest, bytes: file.bytes,
569
+ mode: existing === null ? 0o644 : fs.lstatSync(note).mode & 0o777 }
570
+ try { plan.stagedPath = stageLate(plan, context) } catch (error) {
571
+ return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'staging-failed', blocking: true, errorCode: error.code }
572
+ }
573
+ return plan
574
+ }
575
+
576
+ function verify(unit, plan, { store, journal }, result) {
577
+ const bytes = readNote(path.join(store.vaultRoot, unit.path))
578
+ const digest = bytes === null ? null : sha256Digest(bytes)
579
+ if (digest === plan.candidateDigest) {
580
+ journal.append({ step: 'verify', outcome: 'ok', state: 'updating', notePath: unit.path, afterDigest: digest, detail: { unit: unit.unit, code: 'verified' } })
581
+ return result
582
+ }
583
+ // Published, then changed by someone else. That is an edit on top of this generation, kept where it is.
584
+ const object = bytes === null ? null : store.retainObject(bytes)
585
+ journal.append({ step: 'verify', outcome: 'conflict', state: 'updating', notePath: unit.path, ...(digest ? { afterDigest: digest } : {}), ...(object ? { recoveryRef: object.ref } : {}),
586
+ detail: { unit: unit.unit, code: 'changed-after-publication' } })
587
+ return { ...result, changedAfterPublication: true, observedDigest: digest, ...(object ? { objectRef: object.ref } : {}) }
588
+ }
@@ -0,0 +1,16 @@
1
+ // Crash injection for tests. The seam is a symbol-keyed option holding a
2
+ // function, so it cannot arrive through JSON, a bridge payload, a CLI argument
3
+ // or a configuration file, and the code sent to a real app never carries it.
4
+ //
5
+ // publishView({ ..., [CRASH_INJECTION_TEST_SEAM]: { at: 'after-exchange', halt: () => process.kill(process.pid, 'SIGKILL') } })
6
+ //
7
+ // Points inside the critical section: after-exchange, after-recovery-move,
8
+ // after-editor-update, after-removal-move. Points in the publisher:
9
+ // before-candidate-move (the journal header is written, the exchange
10
+ // candidates are still in staging), after-staging, after-late-write-ahead (a
11
+ // late exchange candidate is named by the journal and still in staging),
12
+ // after-late-candidate-open (a late candidate file exists and is still
13
+ // empty), after-capture, after-publish, before-manifest-commit,
14
+ // after-manifest-pointer. Inside the retirement of a staged file, between its
15
+ // move to the unit's recovery directory and the judgement: after-retire-move.
16
+ export const CRASH_INJECTION_TEST_SEAM = Symbol('atelier.obsidian.crash-injection-test-seam')