@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,139 @@
1
+ import { execFile, execFileSync } from 'node:child_process'
2
+ import { POLICY_SETTINGS_PATH, buildEvalCode, createInProcessHost, runInProcess, validatePayload } from './bridge-script.mjs'
3
+
4
+ // Editor coordination adapter.
5
+ //
6
+ // adapter.probe({ vaultRoot }) -> { state: 'coordinated' | 'absent' | 'uncoordinated', reason }
7
+ // adapter.inspect(payload) -> reply read-only, may retry
8
+ // adapter.collect(payload) -> reply read-only, may retry
9
+ // adapter.publish(payload) -> reply sent at most once
10
+ //
11
+ // A raw `call(payload)` sends one payload once and resolves with the parsed
12
+ // reply. It throws TransportTimeout when the reply did not arrive in time and
13
+ // any other error when the call could not be made. createEditorAdapter adds
14
+ // the rules every transport must follow: calls are serialized, read-only calls
15
+ // may retry, and a publish is never resent. When a publish reply is lost the
16
+ // recorded outcome is re-read from the app; when none is recorded the caller
17
+ // is told so and reconciles from the files themselves.
18
+
19
+ export class TransportTimeout extends Error {
20
+ constructor(message) {
21
+ super(message)
22
+ this.name = 'TransportTimeout'
23
+ this.code = 'transport-timeout'
24
+ }
25
+ }
26
+
27
+ const READ_RETRIES = 2
28
+
29
+ export function createEditorAdapter({ call, processProbe, kind = 'custom' }) {
30
+ if (typeof call !== 'function') throw new TypeError('createEditorAdapter needs a call function')
31
+ if (typeof processProbe !== 'function') throw new TypeError('createEditorAdapter needs a processProbe function')
32
+ const retries = []
33
+ let tail = Promise.resolve()
34
+ const serialized = (operation) => {
35
+ const next = tail.then(operation, operation)
36
+ tail = next.catch(() => {})
37
+ return next
38
+ }
39
+ const readOnly = async (payload) => {
40
+ for (let attempt = 0; ; attempt += 1) {
41
+ try { return await call(validatePayload(payload)) } catch (error) {
42
+ if (attempt >= READ_RETRIES || !(error instanceof TransportTimeout)) throw error
43
+ retries.push(payload.op)
44
+ }
45
+ }
46
+ }
47
+ return {
48
+ kind,
49
+ transportRetries: retries,
50
+ inspect: (payload) => serialized(() => readOnly({ ...payload, op: 'inspect' })),
51
+ collect: (payload) => serialized(() => readOnly({ ...payload, op: 'collect' })),
52
+ publish: (payload) => serialized(async () => {
53
+ validatePayload(payload)
54
+ if (payload.op !== 'publish') throw new TypeError('publish needs a publish payload')
55
+ try { return await call(payload) } catch (error) {
56
+ retries.push(error instanceof TransportTimeout ? 'publish-reply-lost' : 'publish-call-failed')
57
+ let record = null
58
+ try { record = await readOnly({ op: 'collect', vaultRoot: payload.vaultRoot, path: payload.path }) } catch { /* the app is not answering */ }
59
+ if (record && record.operationId === payload.operationId && typeof record.outcome === 'string') return { ...record, status: record.outcome, replyLost: true }
60
+ return { status: 'outcome-unknown', operationId: payload.operationId, wrote: null, replyLost: true, transportError: String(error.message || error).slice(0, 300) }
61
+ }
62
+ }),
63
+ // Path selection. `absent` is returned only when the process probe says,
64
+ // positively, that no Obsidian is running. A running or unknowable app
65
+ // that does not answer for this exact vault is `uncoordinated`.
66
+ probe: ({ vaultRoot }) => serialized(async () => {
67
+ let processes
68
+ try { processes = await processProbe() } catch { processes = 'unknown' }
69
+ if (processes === 'absent') return { state: 'absent', reason: 'no Obsidian process is running' }
70
+ try {
71
+ const reply = await readOnly({ op: 'inspect', vaultRoot, path: POLICY_SETTINGS_PATH })
72
+ // `path-unsafe` for the probe path still proves the app answered for this vault; the settings unit reports the path.
73
+ if (['inspected', 'path-unsafe'].includes(reply.status) && reply.vaultBasePath === vaultRoot) return { state: 'coordinated', reason: 'the app answered for this vault' }
74
+ return { state: 'uncoordinated', reason: reply.status === 'vault-mismatch' ? 'the app answered for another vault' : `the app answered ${reply.status}` }
75
+ } catch (error) {
76
+ return { state: 'uncoordinated', reason: `an Obsidian process may be running and the bridge did not answer: ${String(error.message || error).slice(0, 200)}` }
77
+ }
78
+ }),
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // In-process transport: the same script, run here. Used for the path with no
84
+ // app, and by tests that supply a host modelling an app.
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export function createInProcessCall(host = createInProcessHost()) {
88
+ return async (payload) => runInProcess(payload, host)
89
+ }
90
+
91
+ export function createDirectAdapter({ crashSeam } = {}) {
92
+ return createEditorAdapter({ call: createInProcessCall(createInProcessHost({ crashSeam })), processProbe: () => 'absent', kind: 'direct' })
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Obsidian CLI transport
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export function defaultCliPath(platform = process.platform) {
100
+ if (platform === 'darwin') return '/Applications/Obsidian.app/Contents/MacOS/obsidian-cli'
101
+ return 'obsidian-cli'
102
+ }
103
+
104
+ // The CLI reaches the app through a socket under $HOME, so `env` selects the
105
+ // app instance. It is passed explicitly and never edited here. A call that
106
+ // outlives its timeout is killed with SIGKILL: the CLI ignores SIGTERM while
107
+ // it waits on the app.
108
+ export function createObsidianCliCall({ cliPath = defaultCliPath(), env = process.env, timeoutMs = 20000 } = {}) {
109
+ return (payload) => new Promise((resolve, reject) => {
110
+ execFile(cliPath, ['eval', `code=${buildEvalCode(payload)}`], { env, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
111
+ if (error) return reject(error.killed ? new TransportTimeout(`CLI call timed out: ${payload.op}`) : new Error(`CLI call failed: ${String(error.message).slice(0, 300)}`))
112
+ const out = stdout || stderr || ''
113
+ const start = out.indexOf('=> ')
114
+ if (start < 0) return reject(new Error(`bridge returned no value: ${JSON.stringify(out.slice(0, 300))}`))
115
+ try { return resolve(JSON.parse(out.slice(start + 3))) } catch { return reject(new Error('bridge returned a value that is not JSON')) }
116
+ })
117
+ })
118
+ }
119
+
120
+ // Default process probe: 'absent' only when the process table was read and
121
+ // holds no Obsidian. Limits: it sees this machine's processes as this user
122
+ // can list them; it cannot see an app on another machine that reaches the
123
+ // vault through a shared or synchronized folder, an app packaged under another
124
+ // executable name, or an app that starts after the probe. Anything it cannot
125
+ // establish is 'unknown', which the adapter treats as a running app.
126
+ export function defaultObsidianProcessProbe({ platform = process.platform, run = execFileSync } = {}) {
127
+ if (platform !== 'darwin' && platform !== 'linux') return 'unknown'
128
+ try {
129
+ const table = run('/bin/ps', ['-A', '-o', 'args='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 })
130
+ if (typeof table !== 'string' || table.trim() === '') return 'unknown'
131
+ return /(^|[\\/ ])obsidian(\.app|\.exe|-cli)?([\\/ ]|$)/im.test(table.split('\n').filter((line) => !line.includes('/bin/ps')).join('\n')) ? 'running' : 'absent'
132
+ } catch {
133
+ return 'unknown'
134
+ }
135
+ }
136
+
137
+ export function createObsidianCliAdapter({ cliPath, env, timeoutMs, processProbe = () => defaultObsidianProcessProbe() } = {}) {
138
+ return createEditorAdapter({ call: createObsidianCliCall({ cliPath, env, timeoutMs }), processProbe, kind: 'obsidian-cli' })
139
+ }
@@ -0,0 +1,6 @@
1
+ // Recovery state for Obsidian publication: the immutable store, the durable
2
+ // journal, restart recovery and the late-writer re-check.
3
+ export { EXCHANGE_CANDIDATE_NAME, LATE_EXCHANGE_CANDIDATE_NAME, PublicationRefusal, VAULT_LOCK_DIRECTORY, acquireVaultLock, createRecoveryStore, readFileDigest, sha256Digest } from './store.mjs'
4
+ export { JOURNAL_SCHEMA, createJournal, journalDetail, listJournals, newJournalId } from './journal.mjs'
5
+ export { classifyCandidateFile, namedCandidates, publishedSinceCommit, reconcileUnit, recoverPublications, retireStagedFile } from './restart.mjs'
6
+ export { recheckDisplacedFiles } from './late-writer.mjs'
@@ -0,0 +1,134 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { isPendingPrivateWrite, publishPrivateFile } from '../../../project/durable-state.mjs'
5
+ import { readRegularTextNoFollow } from '../../../project/private-state.mjs'
6
+ import { OBSIDIAN_EXT_KEY, assertObsidianContract } from '../contracts.mjs'
7
+ import { refuse } from './store.mjs'
8
+
9
+ // Durable publication journal. The registered shape is one document; on disk
10
+ // it is a header plus one immutable file per entry, each published atomically
11
+ // under a name that is never overwritten, so an entry is either wholly present
12
+ // or absent and history cannot be rewritten in place. The document is
13
+ // assembled from those files and validated against the registered schema every
14
+ // time it is read or extended.
15
+ //
16
+ // Entry steps follow the schema: `capture` (the write-ahead record binding a
17
+ // note to its base, candidate, staged file and recovery path, written before
18
+ // anything is exchanged), `conditional-update` (the exchange, exclusive
19
+ // create or conditional removal and its outcome), `verify`, `manifest-commit`
20
+ // and `restart`. Each entry file also carries the journal state that holds
21
+ // after it.
22
+
23
+ export const JOURNAL_SCHEMA = 'atelier-obsidian-publication-journal/v1'
24
+ const ENTRY_FILE = /^\d{8}\.json$/
25
+ const CLOSED_FILE = 'closed.json'
26
+
27
+ const isoTime = (clock) => {
28
+ const value = clock()
29
+ const date = value instanceof Date ? value : new Date(value)
30
+ if (Number.isNaN(date.getTime())) throw new TypeError('the clock did not return a time')
31
+ return date.toISOString()
32
+ }
33
+
34
+ export function newJournalId(clock = () => new Date()) {
35
+ return `journal-${isoTime(clock).replace(/[-:.TZ]/g, '')}-${randomBytes(4).toString('hex')}`
36
+ }
37
+
38
+ function load(directory) {
39
+ const header = JSON.parse(readRegularTextNoFollow(path.join(directory, 'header.json')))
40
+ const entriesDir = path.join(directory, 'entries')
41
+ const names = fs.existsSync(entriesDir) ? fs.readdirSync(entriesDir).filter((name) => !isPendingPrivateWrite(name)).sort() : []
42
+ const records = names.map((name, index) => {
43
+ if (!ENTRY_FILE.test(name) || Number(name.slice(0, 8)) !== index) refuse('journal-corrupt', 'journal entries are not a contiguous sequence; preserve and inspect')
44
+ const record = JSON.parse(readRegularTextNoFollow(path.join(entriesDir, name)))
45
+ if (record.entry?.seq !== index) refuse('journal-corrupt', 'a journal entry does not carry its own sequence number')
46
+ return record
47
+ })
48
+ return { header, records }
49
+ }
50
+
51
+ function assemble({ header, records }) {
52
+ const restarts = records.filter((record) => record.entry.step === 'restart' && record.entry.ext?.[OBSIDIAN_EXT_KEY]?.phase === 'begin')
53
+ const document = {
54
+ ...header,
55
+ state: records.length > 0 ? records.at(-1).state : 'prepared',
56
+ entries: records.map((record) => record.entry),
57
+ restart: { count: restarts.length, ...(restarts.length > 0 ? { lastRecoveredAt: restarts.at(-1).entry.at } : {}) },
58
+ }
59
+ return assertObsidianContract('publication-journal', document)
60
+ }
61
+
62
+ function handle(store, directory, clock) {
63
+ // One writer at a time extends a journal (the publisher holds the view's
64
+ // lock), so the entries read once stay the entries on disk.
65
+ let cached = null
66
+ const current = () => (cached ??= load(directory))
67
+ const journal = {
68
+ directory,
69
+ document: () => assemble(current()),
70
+ // detail lands under the entry's ext container; the rest are schema fields.
71
+ append({ step, outcome, state, notePath, beforeDigest, afterDigest, recoveryRef, detail }) {
72
+ const loaded = current()
73
+ const entry = {
74
+ seq: loaded.records.length,
75
+ at: isoTime(clock),
76
+ step,
77
+ outcome,
78
+ ...(notePath === undefined ? {} : { notePath }),
79
+ ...(beforeDigest ? { beforeDigest } : {}),
80
+ ...(afterDigest ? { afterDigest } : {}),
81
+ ...(recoveryRef ? { recoveryRef } : {}),
82
+ ...(detail ? { ext: { [OBSIDIAN_EXT_KEY]: detail } } : {}),
83
+ }
84
+ const record = { entry, state: state ?? (loaded.records.at(-1)?.state ?? 'prepared') }
85
+ assemble({ header: loaded.header, records: [record] })
86
+ try {
87
+ publishPrivateFile(path.join(directory, 'entries', `${String(entry.seq).padStart(8, '0')}.json`), `${JSON.stringify(record, null, 2)}\n`)
88
+ } catch (error) {
89
+ cached = null
90
+ throw error
91
+ }
92
+ loaded.records.push(record)
93
+ return entry
94
+ },
95
+ // A journal that can no longer matter (committed, or superseded by a later
96
+ // commit) is closed so that later runs do not read it again. The flag file
97
+ // is advisory: a journal without one is simply read and judged again.
98
+ close() {
99
+ try { publishPrivateFile(path.join(directory, CLOSED_FILE), `${JSON.stringify({ state: assemble(current()).state })}\n`) } catch (error) { if (error.code !== 'EEXIST') throw error }
100
+ },
101
+ }
102
+ return journal
103
+ }
104
+
105
+ export function createJournal(store, { journalId, protocolId, expectedGeneration, targetGeneration, detail, clock = () => new Date() }) {
106
+ const directory = store.journalDir(journalId)
107
+ fs.mkdirSync(path.join(directory, 'entries'), { recursive: true, mode: 0o700 })
108
+ const header = {
109
+ schema: JOURNAL_SCHEMA,
110
+ contractVersion: '1.0.0',
111
+ journalId,
112
+ workspaceId: store.workspaceId,
113
+ scopeId: store.scopeId,
114
+ protocolId,
115
+ expectedGeneration,
116
+ targetGeneration,
117
+ ...(detail ? { ext: { [OBSIDIAN_EXT_KEY]: detail } } : {}),
118
+ }
119
+ assemble({ header, records: [] })
120
+ publishPrivateFile(path.join(directory, 'header.json'), `${JSON.stringify(header, null, 2)}\n`)
121
+ return handle(store, directory, clock)
122
+ }
123
+
124
+ // Journals of this view, oldest first. A directory without a header is a
125
+ // journal that never began: nothing can refer to it.
126
+ export function listJournals(store, { clock = () => new Date(), openOnly = false } = {}) {
127
+ if (!fs.existsSync(store.journalsRoot)) return []
128
+ return fs.readdirSync(store.journalsRoot).sort()
129
+ .filter((name) => fs.existsSync(path.join(store.journalsRoot, name, 'header.json')))
130
+ .filter((name) => !openOnly || !fs.existsSync(path.join(store.journalsRoot, name, CLOSED_FILE)))
131
+ .map((name) => handle(store, path.join(store.journalsRoot, name), clock))
132
+ }
133
+
134
+ export const journalDetail = (value) => value?.ext?.[OBSIDIAN_EXT_KEY] ?? {}
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { listJournals } from './journal.mjs'
4
+ import { readFileBytes, sha256Digest } from './store.mjs'
5
+
6
+ // A program that opened a note before it was exchanged still holds the
7
+ // displaced file, and may write into it at any later time. Those bytes land in
8
+ // the recovery area, not in the vault, so nothing shows them to the person
9
+ // unless the displaced files are looked at again. This compares every
10
+ // displaced file with the digest recorded when it was moved. A difference is
11
+ // kept as an immutable object and reported as `late-writer-captured`.
12
+ //
13
+ // The check is meaningful only after a quiet period, and one pass does not end
14
+ // the obligation: a holder can write later still. Callers repeat it.
15
+ export function recheckDisplacedFiles({ store, journalIds, clock = () => new Date() } = {}) {
16
+ const ids = journalIds ?? listJournals(store).map((journal) => journal.document().journalId)
17
+ const findings = []
18
+ for (const journalId of ids) {
19
+ const receipts = store.listReceipts(journalId)
20
+ const known = new Set(receipts.filter((receipt) => receipt.role === 'late-writer').map((receipt) => `${receipt.unit}\u0000${receipt.observedDigest}`))
21
+ for (const receipt of receipts.filter((item) => item.role === 'displaced')) {
22
+ // A recovery name that is a hard link to the live note (an interrupted
23
+ // put-back) is the note itself: edits to it are not a late writer's.
24
+ const live = receipt.notePath ? fs.lstatSync(path.join(store.vaultRoot, receipt.notePath), { throwIfNoEntry: false }) : null
25
+ const held = fs.lstatSync(store.resolve(receipt.displacedRef), { throwIfNoEntry: false })
26
+ if (live && held && live.ino === held.ino && live.dev === held.dev) continue
27
+ let bytes
28
+ try { bytes = readFileBytes(store.resolve(receipt.displacedRef)) } catch (error) {
29
+ if (error.code !== 'ENOENT') throw error
30
+ findings.push({ code: 'displaced-file-missing', journalId, unit: receipt.unit, notePath: receipt.notePath, displacedRef: receipt.displacedRef })
31
+ continue
32
+ }
33
+ const observedDigest = sha256Digest(bytes)
34
+ if (observedDigest === receipt.digestAtMove) continue
35
+ const object = store.retainObject(bytes)
36
+ const finding = { code: 'late-writer-captured', journalId, unit: receipt.unit, notePath: receipt.notePath, displacedRef: receipt.displacedRef, digestAtMove: receipt.digestAtMove, observedDigest, objectRef: object.ref }
37
+ if (!known.has(`${receipt.unit}\u0000${observedDigest}`)) {
38
+ const value = clock()
39
+ store.writeReceipt(journalId, receipt.unit, { role: 'late-writer', notePath: receipt.notePath, displacedRef: receipt.displacedRef, digestAtMove: receipt.digestAtMove, observedDigest, objectRef: object.ref,
40
+ at: (value instanceof Date ? value : new Date(value)).toISOString() })
41
+ }
42
+ findings.push(finding)
43
+ }
44
+ }
45
+ return findings
46
+ }
@@ -0,0 +1,316 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { acquirePrivateLock, syncPrivateDirectory } from '../../../project/durable-state.mjs'
4
+ import { journalDetail, listJournals } from './journal.mjs'
5
+ import { readFileBytes, readFileDigest, refuse, sha256Digest } from './store.mjs'
6
+
7
+ // Restart recovery. Every decision here is made from what is on disk, never
8
+ // from what a lost reply might have said, and every action is a move, a
9
+ // non-overwriting link, the removal of a file proven to be our own candidate,
10
+ // or an appended journal entry. Running it again changes nothing.
11
+ //
12
+ // Interrupted states of one note, identified by its write-ahead `capture`
13
+ // entry (base, candidate, candidate path, recovery path) with no outcome
14
+ // entry. The candidate path of a replacement is in the unit's recovery
15
+ // directory, so whatever it holds is never in a discardable area:
16
+ //
17
+ // candidate path == candidate digest nothing was exchanged. The file is a
18
+ // generated candidate and is retired;
19
+ // the note is as it was.
20
+ // candidate path, any other bytes the exchange happened and the move to
21
+ // the recovery name did not: the path
22
+ // holds the displaced bytes. They are
23
+ // moved to the recovery name and
24
+ // compared with base.
25
+ // path absent, recovery name present exchange and move both happened.
26
+ // neither nothing happened.
27
+ //
28
+ // The journal names every exchange candidate, path and digest, before the file
29
+ // is at that path (the header for candidates known when the run began, a
30
+ // write-ahead entry for a late one), and only complete files are moved there.
31
+ // classifyCandidateFile is the only place that decides between the two kinds.
32
+ //
33
+ // For the whole journal: a pointer that already names this journal gets its
34
+ // missing `manifest-commit` entry; a journal that reached `verifying` with
35
+ // every note settled commits its stored manifest; anything else is left as
36
+ // `updating` for the next publication to converge, with the digests it did
37
+ // publish still trusted as bases.
38
+
39
+ const exists = (file) => { try { fs.lstatSync(file); return true } catch (error) { if (error.code === 'ENOENT') return false; throw error } }
40
+
41
+ const RETIRING_NAME = 'retiring.bin'
42
+
43
+ // What a file at a candidate path is. `generated-candidate` is claimed only
44
+ // for bytes whose digest the journal recorded for that candidate; those are
45
+ // ours and may be deleted. Everything else, including a file whose candidate
46
+ // digest the journal does not give, is `displaced-bytes`: somebody's content,
47
+ // which is moved or kept and never deleted.
48
+ export function classifyCandidateFile({ file, candidateDigest }) {
49
+ const digest = file ? readFileDigest(file) : null
50
+ if (digest === null) return 'absent'
51
+ return candidateDigest && digest === candidateDigest ? 'generated-candidate' : 'displaced-bytes'
52
+ }
53
+
54
+ // Every candidate a journal names: from its header, and from the write-ahead
55
+ // entries of late exchange candidates. `preparedRef` is where the bytes were
56
+ // written, in staging, before they were moved to `stagedRef`.
57
+ export function namedCandidates(document) {
58
+ const named = (journalDetail(document).staged ?? []).map(({ unit, path: notePath, candidateDigest, stagedRef, preparedRef }) => ({ unit, path: notePath, candidateDigest, stagedRef, preparedRef: preparedRef ?? null, late: false }))
59
+ for (const entry of document.entries) {
60
+ const detail = journalDetail(entry)
61
+ if (entry.step === 'capture' && detail.code === 'late-candidate' && detail.stagedRef) {
62
+ named.push({ unit: detail.unit, path: entry.notePath, candidateDigest: entry.afterDigest ?? null, stagedRef: detail.stagedRef, preparedRef: detail.preparedRef ?? null, late: true })
63
+ }
64
+ }
65
+ return named
66
+ }
67
+
68
+ function freeName(store, journalId, unit, stem = 'displaced') {
69
+ for (let index = 0; ; index += 1) {
70
+ const candidate = store.displacedPath(journalId, unit, index === 0 ? `${stem}.bin` : `${stem}-${index}.bin`)
71
+ if (!exists(candidate)) return candidate
72
+ }
73
+ }
74
+
75
+ // Judges a file already moved to the unit's retiring name: our own candidate
76
+ // is deleted; anything else is somebody's bytes and stays in recovery under a
77
+ // name of its own. This is both the second half of retireStagedFile and what
78
+ // restart recovery runs for a retirement that was interrupted.
79
+ function judgeRetiring({ store, journalId, unit, candidateDigest }) {
80
+ const retiring = store.displacedPath(journalId, unit, RETIRING_NAME)
81
+ if (!exists(retiring)) return { retired: false, capturedPaths: [] }
82
+ if (classifyCandidateFile({ file: retiring, candidateDigest }) === 'generated-candidate') {
83
+ fs.unlinkSync(retiring)
84
+ return { retired: true, capturedPaths: [] }
85
+ }
86
+ const capturedPath = freeName(store, journalId, unit, 'unexpected-at-staged-path')
87
+ fs.renameSync(retiring, capturedPath)
88
+ syncPrivateDirectory(path.dirname(capturedPath))
89
+ return { retired: false, capturedPaths: [capturedPath] }
90
+ }
91
+
92
+ // A candidate path is never unlinked in place: between a digest check and an
93
+ // unlink, a late exchange could put displaced bytes there. It is first moved
94
+ // to a private name, which no payload refers to, and only then judged. That
95
+ // name lives in the unit's recovery directory, beside the exchange candidate,
96
+ // never in staging: between the two steps the file may be the only copy of
97
+ // bytes an exchange displaced. A process that dies between the steps leaves
98
+ // `retiring.bin` there, and restart recovery finishes the judgement.
99
+ // Returns { retired, capturedPaths }: every path in `capturedPaths` holds
100
+ // bytes that were not the candidate and still needs a receipt.
101
+ export function retireStagedFile({ store, journalId, unit, stagedPath, candidateDigest, crash = () => {} }) {
102
+ // An earlier interrupted retirement of this unit is finished first, so the move below never lands on it.
103
+ const earlier = judgeRetiring({ store, journalId, unit, candidateDigest })
104
+ if (!stagedPath || !exists(stagedPath)) return earlier
105
+ fs.renameSync(stagedPath, store.displacedPath(journalId, unit, RETIRING_NAME))
106
+ crash('after-retire-move')
107
+ const result = judgeRetiring({ store, journalId, unit, candidateDigest })
108
+ return { retired: result.retired, capturedPaths: [...earlier.capturedPaths, ...result.capturedPaths] }
109
+ }
110
+
111
+ // Units of one journal that still hold a `retiring.bin`.
112
+ function interruptedRetirements(store, journalId) {
113
+ const base = path.join(store.workspaceRoot, 'recovery', journalId.replaceAll(':', '_'))
114
+ if (!exists(base)) return []
115
+ return fs.readdirSync(base).filter((name) => /^\d{6}$/.test(name) && exists(path.join(base, name, RETIRING_NAME))).map((name) => Number(name)).sort((a, b) => a - b)
116
+ }
117
+
118
+ // `digestAtMove` is the digest taken inside the critical section, right after
119
+ // the move, when the caller has it. A holder of the old file may already have
120
+ // written into it by the time this process reads it; that difference is left
121
+ // for the late-writer check to report.
122
+ export function recordDisplaced({ store, journalId, unit, notePath, displacedPath, baseDigest, at, digestAtMove: observedAtMove = null }) {
123
+ const bytes = readFileBytes(displacedPath)
124
+ const digestAtMove = observedAtMove ?? sha256Digest(bytes)
125
+ const externalCaptured = digestAtMove !== baseDigest
126
+ // The displaced file stays where it is. Bytes that are not the known base get an immutable copy as well.
127
+ const object = sha256Digest(bytes) !== baseDigest ? store.retainObject(bytes) : null
128
+ store.writeReceipt(journalId, unit, { role: 'displaced', notePath, displacedRef: store.ref(displacedPath), digestAtMove, baseDigest, externalCaptured, ...(object ? { objectRef: object.ref } : {}), at })
129
+ return { digestAtMove, externalCaptured, displacedRef: store.ref(displacedPath) }
130
+ }
131
+
132
+ // Settles one note whose outcome is unknown. Returns the entry to append.
133
+ export function reconcileUnit({ store, journalId, capture, at }) {
134
+ const detail = journalDetail(capture)
135
+ const { unit, op } = detail
136
+ const notePath = capture.notePath
137
+ const note = path.join(store.vaultRoot, notePath)
138
+ const base = capture.beforeDigest ?? null
139
+ const candidate = capture.afterDigest ?? null
140
+ const staged = detail.stagedRef ? store.resolve(detail.stagedRef) : null
141
+ const displaced = capture.recoveryRef ? store.resolve(capture.recoveryRef) : null
142
+ const settle = (outcome, code, extra = {}) => ({ step: 'conditional-update', outcome, notePath, ...(base ? { beforeDigest: base } : {}), ...(outcome === 'ok' && candidate ? { afterDigest: candidate } : {}),
143
+ ...(extra.recoveryRef ? { recoveryRef: extra.recoveryRef } : {}), detail: { unit, op, kind: detail.kind, code, reconciled: true, ...(extra.externalCaptured ? { externalCaptured: true } : {}) } })
144
+
145
+ const keepUnexpected = (paths) => { for (const capturedPath of paths) recordDisplaced({ store, journalId, unit, notePath, displacedPath: capturedPath, baseDigest: base, at }) }
146
+ const completeAfterExchange = (holder) => {
147
+ const target = !displaced || exists(displaced) ? freeName(store, journalId, unit) : displaced
148
+ fs.renameSync(holder, target)
149
+ syncPrivateDirectory(path.dirname(target))
150
+ const recorded = recordDisplaced({ store, journalId, unit, notePath, displacedPath: target, baseDigest: base, at })
151
+ return settle('ok', 'completed-after-exchange', { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured })
152
+ }
153
+
154
+ if (op === 'replace') {
155
+ // A retirement interrupted between its two moves: the file that was at the
156
+ // staged path is at the retiring name. Bytes that are not the candidate
157
+ // can only have come from the exchange, exactly as if they were still staged.
158
+ const retiring = store.displacedPath(journalId, unit, RETIRING_NAME)
159
+ if (classifyCandidateFile({ file: retiring, candidateDigest: candidate }) === 'displaced-bytes') {
160
+ const entry = completeAfterExchange(retiring)
161
+ if (staged) keepUnexpected(retireStagedFile({ store, journalId, unit, stagedPath: staged, candidateDigest: candidate }).capturedPaths)
162
+ return entry
163
+ }
164
+ const atCandidatePath = classifyCandidateFile({ file: staged, candidateDigest: candidate })
165
+ if (atCandidatePath === 'generated-candidate') {
166
+ keepUnexpected(retireStagedFile({ store, journalId, unit, stagedPath: staged, candidateDigest: candidate }).capturedPaths)
167
+ return settle('skipped', 'interrupted-before-exchange')
168
+ }
169
+ if (atCandidatePath === 'displaced-bytes') return completeAfterExchange(staged)
170
+ keepUnexpected(judgeRetiring({ store, journalId, unit, candidateDigest: candidate }).capturedPaths)
171
+ if (displaced && exists(displaced)) {
172
+ const known = store.listReceipts(journalId).find((receipt) => receipt.role === 'displaced' && receipt.unit === unit && receipt.displacedRef === store.ref(displaced))
173
+ const recorded = known ?? recordDisplaced({ store, journalId, unit, notePath, displacedPath: displaced, baseDigest: base, at })
174
+ return settle('ok', 'completed-after-recovery-move', { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured })
175
+ }
176
+ return settle('skipped', 'nothing-happened')
177
+ }
178
+ if (op === 'create') {
179
+ const created = readFileDigest(note) === candidate
180
+ keepUnexpected(staged ? retireStagedFile({ store, journalId, unit, stagedPath: staged, candidateDigest: candidate }).capturedPaths : [])
181
+ return created ? settle('ok', 'completed-after-create') : settle('skipped', 'interrupted-before-create')
182
+ }
183
+ if (op === 'remove') {
184
+ if (!displaced || !exists(displaced)) return settle('skipped', 'interrupted-before-removal')
185
+ const noteStat = fs.lstatSync(note, { throwIfNoEntry: false })
186
+ const movedStat = fs.lstatSync(displaced)
187
+ if (noteStat && noteStat.ino === movedStat.ino && noteStat.dev === movedStat.dev) {
188
+ // An interrupted put-back: the note path already names these bytes again.
189
+ fs.unlinkSync(displaced)
190
+ return settle('skipped', 'remove-reverted')
191
+ }
192
+ const recorded = recordDisplaced({ store, journalId, unit, notePath, displacedPath: displaced, baseDigest: base, at })
193
+ return settle('ok', recorded.externalCaptured ? 'removed-external-captured' : 'completed-after-removal', { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured })
194
+ }
195
+ return refuse('journal-corrupt', 'a capture entry names an unknown operation')
196
+ }
197
+
198
+ // Late candidates in staging that no entry names. Every one counts: a journal
199
+ // may hold a named late candidate and an orphaned one side by side. This looks
200
+ // in staging only. Nothing in a unit's recovery directory is ever an orphan:
201
+ // a late exchange candidate gets there after its write-ahead entry, and a
202
+ // file there that the journal does not account for is kept.
203
+ function strays(store, document) {
204
+ const directory = path.join(store.stagingRoot, document.journalId.replaceAll(':', '_'))
205
+ if (!exists(directory)) return []
206
+ const named = new Set(document.entries.flatMap((entry) => [journalDetail(entry).stagedRef, journalDetail(entry).preparedRef]).filter(Boolean))
207
+ return fs.readdirSync(directory).filter((name) => name.endsWith('.late.candidate')).map((name) => path.join(directory, name)).filter((file) => !named.has(store.ref(file)))
208
+ }
209
+
210
+ const iso = (clock) => { const value = clock(); return (value instanceof Date ? value : new Date(value)).toISOString() }
211
+
212
+ // Caller holds the view's lock.
213
+ export function recoverPublicationsLocked({ store, clock = () => new Date() }) {
214
+ const report = []
215
+ for (const journal of listJournals(store, { clock, openOnly: true })) {
216
+ const document = journal.document()
217
+ if (document.state === 'committed') { journal.close(); continue }
218
+ const header = journalDetail(document)
219
+ const settled = new Set(document.entries.filter((entry) => entry.step === 'conditional-update').map((entry) => journalDetail(entry).unit))
220
+ const pending = document.entries.filter((entry) => entry.step === 'capture' && journalDetail(entry).intent === true && !settled.has(journalDetail(entry).unit))
221
+ const pendingUnits = new Set(pending.map((entry) => journalDetail(entry).unit))
222
+ // Candidates the journal names that are still on disk. At the candidate path of a pending unit the file is
223
+ // reconcileUnit's to judge. A file still in staging was never moved to an exchange path, whatever its unit.
224
+ const leftovers = namedCandidates(document).flatMap((item) => [...(pendingUnits.has(item.unit) ? [] : [item.stagedRef]), item.preparedRef].filter(Boolean).map((ref) => ({ ...item, file: store.resolve(ref) })))
225
+ .filter((item) => exists(item.file))
226
+ // Retirements interrupted between their two moves, for notes that are otherwise settled.
227
+ const candidateOf = new Map((header.staged ?? []).map((item) => [item.unit, item.candidateDigest]))
228
+ for (const entry of document.entries) if (entry.step === 'capture' && journalDetail(entry).stagedRef && entry.afterDigest) candidateOf.set(journalDetail(entry).unit, entry.afterDigest)
229
+ const pathOf = new Map((header.units ?? []).map((item) => [item.unit, item.path]))
230
+ const retiring = interruptedRetirements(store, document.journalId).filter((unit) => !pendingUnits.has(unit))
231
+ const orphans = strays(store, document)
232
+ const pointer = store.readCurrent()
233
+ const hasCommitEntry = document.entries.some((entry) => entry.step === 'manifest-commit' && entry.outcome === 'ok')
234
+ const pointerNamesJournal = pointer?.journalId === document.journalId
235
+ const verifying = document.entries.findLast((entry) => entry.step === 'verify' && journalDetail(entry).settled === true)
236
+ const current = (pointer?.generationId ?? null) === document.expectedGeneration
237
+ const canCommit = Boolean(verifying) && !hasCommitEntry && !pointerNamesJournal && current
238
+ const stale = !current && !pointerNamesJournal
239
+ const atRest = document.state === 'failed' || document.state === 'updating' || document.state === 'captured' || document.state === 'prepared'
240
+ const work = pending.length > 0 || leftovers.length > 0 || retiring.length > 0 || orphans.length > 0 || (pointerNamesJournal && !hasCommitEntry) || canCommit || document.state === 'recovering' || (stale && document.state !== 'failed')
241
+ if (!work && atRest) { if (stale) journal.close(); continue }
242
+
243
+ const actions = []
244
+ journal.append({ step: 'restart', outcome: 'ok', state: 'recovering', detail: { phase: 'begin', pendingUnits: pending.length } })
245
+ for (const capture of pending) {
246
+ const entry = reconcileUnit({ store, journalId: document.journalId, capture, at: iso(clock) })
247
+ journal.append({ ...entry, state: 'recovering' })
248
+ actions.push({ path: capture.notePath, outcome: entry.outcome, code: entry.detail.code })
249
+ if (entry.outcome === 'ok' && entry.detail.op !== 'remove') {
250
+ const verified = readFileDigest(path.join(store.vaultRoot, capture.notePath)) === capture.afterDigest
251
+ journal.append({ step: 'verify', outcome: verified ? 'ok' : 'conflict', state: 'recovering', notePath: capture.notePath, afterDigest: capture.afterDigest,
252
+ detail: { unit: entry.detail.unit, code: verified ? 'verified' : 'changed-after-publication' } })
253
+ }
254
+ }
255
+ const keepUnexpected = (unit, notePath, capturedPaths) => {
256
+ for (const capturedPath of capturedPaths) {
257
+ // Not the candidate, and nothing says what it is: it is kept and surfaced as an outside writer's bytes.
258
+ recordDisplaced({ store, journalId: document.journalId, unit, notePath, displacedPath: capturedPath, baseDigest: null, at: iso(clock) })
259
+ actions.push({ path: notePath, outcome: 'ok', code: 'unexpected-bytes-at-staged-path-kept' })
260
+ }
261
+ }
262
+ for (const unit of retiring) {
263
+ const result = retireStagedFile({ store, journalId: document.journalId, unit, stagedPath: null, candidateDigest: candidateOf.get(unit) ?? null })
264
+ keepUnexpected(unit, pathOf.get(unit) ?? null, result.capturedPaths)
265
+ if (result.retired) actions.push({ path: pathOf.get(unit) ?? null, outcome: 'ok', code: 'interrupted-retirement-finished' })
266
+ }
267
+ for (const item of leftovers) {
268
+ const result = retireStagedFile({ store, journalId: document.journalId, unit: item.unit, stagedPath: item.file, candidateDigest: item.candidateDigest })
269
+ keepUnexpected(item.unit, item.path, result.capturedPaths)
270
+ }
271
+ // A file in staging that no entry names was never put in a payload, so nothing can have exchanged it.
272
+ for (const orphan of orphans) fs.rmSync(orphan, { force: true })
273
+ const stagingDir = store.stagingDir(document.journalId)
274
+ try { fs.rmdirSync(stagingDir) } catch { /* not empty, or already gone */ }
275
+
276
+ let committed = false
277
+ if (pointerNamesJournal && !hasCommitEntry) {
278
+ journal.append({ step: 'manifest-commit', outcome: 'ok', state: 'committed', detail: { code: 'pointer-already-committed', reconciled: true } })
279
+ committed = true
280
+ } else if (canCommit) {
281
+ const manifestBytes = readFileBytes(store.resolve(header.manifestRef))
282
+ if (sha256Digest(manifestBytes) !== header.manifestDigest) refuse('journal-corrupt', 'the manifest stored with a journal no longer matches its digest')
283
+ store.commitManifest({ manifestBytes, generationId: document.targetGeneration, journalId: document.journalId, retained: journalDetail(verifying).retained ?? [], committedAt: iso(clock) })
284
+ journal.append({ step: 'manifest-commit', outcome: 'ok', state: 'committed', detail: { code: 'committed-on-restart', reconciled: true } })
285
+ committed = true
286
+ } else {
287
+ journal.append({ step: 'restart', outcome: 'ok', state: stale ? 'failed' : 'updating', detail: { phase: 'complete', ...(stale ? { code: 'superseded' } : {}) } })
288
+ }
289
+ if (committed || stale) journal.close()
290
+ report.push({ journalId: document.journalId, actions, committed, state: journal.document().state })
291
+ }
292
+ return { journals: report }
293
+ }
294
+
295
+ export function recoverPublications({ store, clock = () => new Date() }) {
296
+ const release = acquirePrivateLock(store.lockPath)
297
+ try { return recoverPublicationsLocked({ store, clock }) } finally { release() }
298
+ }
299
+
300
+ // Digests this view has published since its trusted manifest was committed,
301
+ // by journals that did not reach a commit. They are valid bases: without them
302
+ // a note published by an interrupted run would look like an outside edit.
303
+ export function publishedSinceCommit({ store }) {
304
+ const pointer = store.readCurrent()
305
+ const ledger = new Map()
306
+ for (const journal of listJournals(store, { openOnly: true })) {
307
+ const document = journal.document()
308
+ if (document.state === 'committed' || document.expectedGeneration !== (pointer?.generationId ?? null)) continue
309
+ for (const entry of document.entries) {
310
+ const detail = journalDetail(entry)
311
+ if (entry.step !== 'conditional-update' || entry.outcome !== 'ok' || detail.kind === 'settings') continue
312
+ ledger.set(entry.notePath, detail.op === 'remove' ? null : entry.afterDigest)
313
+ }
314
+ }
315
+ return ledger
316
+ }