@mnstry/atelier 0.2.0-alpha.1 → 0.2.0-alpha.11

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 (414) hide show
  1. package/CHANGELOG.md +413 -0
  2. package/README.md +402 -205
  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/contracts/public-api-baseline.json +57 -0
  31. package/docs/assurance-controls.md +49 -0
  32. package/docs/atelier-runtime.md +28 -2
  33. package/docs/atelier-sync.md +172 -0
  34. package/docs/blocks/audience-visibility.md +9 -0
  35. package/docs/blocks/claims.md +61 -0
  36. package/docs/blocks/conformance-admission.md +15 -0
  37. package/docs/blocks/will-not-do.md +18 -0
  38. package/docs/coauthor-session.md +57 -0
  39. package/docs/connected-composition.md +75 -0
  40. package/docs/continuity.md +3 -4
  41. package/docs/decisions.md +167 -0
  42. package/docs/design.md +167 -0
  43. package/docs/exact-upgrades.md +159 -0
  44. package/docs/extension-pack-lifecycle.md +33 -0
  45. package/docs/guided-upgrades.md +99 -0
  46. package/docs/install.md +99 -16
  47. package/docs/intake-and-guides.md +65 -0
  48. package/docs/integration-and-review-plan.md +409 -0
  49. package/docs/integration-contract-decisions.md +22 -0
  50. package/docs/knowledge-graph.md +8 -4
  51. package/docs/local-review.md +84 -0
  52. package/docs/local-services.md +328 -0
  53. package/docs/local-state.md +107 -0
  54. package/docs/obsidian-contract.md +698 -0
  55. package/docs/obsidian.md +484 -0
  56. package/docs/portable-presentation.md +328 -0
  57. package/docs/presentation-consumer-boundaries.md +75 -0
  58. package/docs/presentation-review-disposition.md +47 -0
  59. package/docs/project-command-inventory.md +38 -0
  60. package/docs/project-options.md +53 -0
  61. package/docs/release-engineering.md +174 -12
  62. package/docs/repo-boundary-guard.md +12 -2
  63. package/docs/review-portability.md +55 -0
  64. package/docs/skill-steward.md +156 -0
  65. package/docs/upgrade.md +155 -5
  66. package/docs/vault-service.md +292 -0
  67. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  68. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  69. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  70. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  71. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  72. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  73. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  74. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  75. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  76. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  77. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  78. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  79. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  80. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  81. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  82. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  83. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  84. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  85. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  86. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  87. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  88. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  89. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  90. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  91. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  92. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  93. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  94. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  95. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  96. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  97. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  98. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  99. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  100. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  101. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  102. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  103. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  104. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  105. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  106. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  107. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  108. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  109. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  110. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  111. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  112. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  113. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  114. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  115. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  116. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  117. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  118. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  119. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  120. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  121. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  122. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  123. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  124. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  125. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  126. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  127. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  128. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  129. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  130. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  131. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  132. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  133. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  134. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  135. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  136. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  137. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  138. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  139. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  140. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  141. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  142. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  143. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  144. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  145. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  146. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  147. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  148. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  149. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  150. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  151. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  152. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  153. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  154. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  155. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  156. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  157. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  158. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  159. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  160. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  161. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  162. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  163. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  164. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  165. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  166. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  167. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  168. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  169. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  170. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  171. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  172. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  173. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  174. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  175. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  176. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  177. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  178. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  179. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  180. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  181. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  182. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  183. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  184. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  185. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  186. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  187. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  188. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  189. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  190. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  191. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  192. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  193. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  194. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  195. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  196. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  197. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  198. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  199. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  200. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  201. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  202. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  203. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  204. package/fixtures/obsidian/edits/workspace.json +65 -0
  205. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  206. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  207. package/fixtures/obsidian/materialization/workspace.json +54 -0
  208. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  209. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  210. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  211. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  212. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  213. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  214. package/fixtures/ui/presentation/pane.v1.json +7 -0
  215. package/fixtures/ui/presentation/reference.v1.json +293 -0
  216. package/package.json +86 -10
  217. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  218. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  219. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  220. package/skills/claude/atelier-open/SKILL.md +11 -5
  221. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  222. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  223. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  224. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  225. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  226. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  227. package/skills/codex/atelier-open/SKILL.md +11 -5
  228. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  229. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  230. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  231. package/src/access/preview-enforcer.mjs +84 -0
  232. package/src/boundary/content-rules.mjs +283 -20
  233. package/src/boundary/policy.mjs +213 -91
  234. package/src/cli/execute-command.mjs +36 -0
  235. package/src/cli/project-options.mjs +58 -0
  236. package/src/cli/run.mjs +78 -10
  237. package/src/coauthor/session.mjs +161 -0
  238. package/src/coauthor/store.mjs +173 -0
  239. package/src/collaboration/event-ledger.mjs +348 -0
  240. package/src/collaboration/index.mjs +17 -0
  241. package/src/collaboration/inspection-bundle.mjs +223 -0
  242. package/src/collaboration/proposals.mjs +265 -65
  243. package/src/collaboration/review-contracts.mjs +25 -0
  244. package/src/collaboration/review-store.mjs +305 -0
  245. package/src/commands/attestation.mjs +20 -6
  246. package/src/commands/coauthor.mjs +26 -0
  247. package/src/commands/config.mjs +6 -1
  248. package/src/commands/disclosure.mjs +133 -0
  249. package/src/commands/distribution.mjs +2 -1
  250. package/src/commands/extension-pack.mjs +17 -9
  251. package/src/commands/init.mjs +46 -6
  252. package/src/commands/obsidian.mjs +371 -0
  253. package/src/commands/review.mjs +147 -0
  254. package/src/commands/server.mjs +1 -4
  255. package/src/commands/setup.mjs +51 -14
  256. package/src/commands/skills.mjs +199 -0
  257. package/src/commands/sync.mjs +107 -0
  258. package/src/composition/registry.mjs +27 -0
  259. package/src/composition/wire.mjs +122 -0
  260. package/src/contracts/corpus.mjs +35 -0
  261. package/src/decisions/contracts.d.mts +96 -0
  262. package/src/decisions/contracts.mjs +321 -0
  263. package/src/disclosure/content-scan.mjs +206 -0
  264. package/src/egress/check.mjs +7 -38
  265. package/src/egress/forbidden-egress.mjs +32 -18
  266. package/src/export/atelier-export-contract.mjs +16 -1
  267. package/src/extension-packs/lifecycle.mjs +154 -0
  268. package/src/graph/graph.mjs +176 -316
  269. package/src/graph/knowledge-graph.mjs +643 -43
  270. package/src/guides/contracts.mjs +54 -0
  271. package/src/harness/context-client.mjs +9 -1
  272. package/src/index.mjs +41 -0
  273. package/src/intake/store.mjs +119 -0
  274. package/src/preview/channel.mjs +89 -0
  275. package/src/project/config.mjs +112 -52
  276. package/src/project/durable-state.mjs +161 -0
  277. package/src/project/file-class.mjs +180 -0
  278. package/src/project/package-root.mjs +10 -0
  279. package/src/project/path-match.mjs +38 -15
  280. package/src/project/private-state.mjs +120 -0
  281. package/src/projection/obsidian/contracts.mjs +571 -0
  282. package/src/projection/obsidian/edits/align.mjs +169 -0
  283. package/src/projection/obsidian/edits/apply.mjs +889 -0
  284. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  285. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  286. package/src/projection/obsidian/edits/index.mjs +19 -0
  287. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  288. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  289. package/src/projection/obsidian/edits/observe.mjs +123 -0
  290. package/src/projection/obsidian/edits/policy.mjs +123 -0
  291. package/src/projection/obsidian/edits/regions.mjs +648 -0
  292. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  293. package/src/projection/obsidian/materialize/index.mjs +7 -0
  294. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  295. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  296. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  297. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  298. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  299. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  300. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  301. package/src/projection/obsidian/proposals/index.mjs +23 -0
  302. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  303. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  304. package/src/projection/obsidian/proposals/router.mjs +168 -0
  305. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  306. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  307. package/src/projection/obsidian/publication/index.mjs +5 -0
  308. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  309. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  310. package/src/projection/obsidian/publication/transport.mjs +212 -0
  311. package/src/projection/obsidian/recovery/index.mjs +6 -0
  312. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  313. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  314. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  315. package/src/projection/obsidian/recovery/store.mjs +234 -0
  316. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  317. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  318. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  319. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  320. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  321. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  322. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  323. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  324. package/src/projection/project.mjs +28 -19
  325. package/src/readiness-protocols/evidence.mjs +282 -0
  326. package/src/readiness-protocols/runtime.mjs +4 -3
  327. package/src/readiness-protocols/source-read.mjs +39 -0
  328. package/src/runtime/git-adapter.mjs +189 -0
  329. package/src/runtime/local-state.mjs +439 -0
  330. package/src/runtime/obsidian/app-capability.mjs +138 -0
  331. package/src/runtime/obsidian/app-production-seams.mjs +107 -0
  332. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  333. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  334. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  335. package/src/runtime/obsidian/contributions.mjs +27 -0
  336. package/src/runtime/obsidian/documents.mjs +37 -0
  337. package/src/runtime/obsidian/enablement.mjs +53 -0
  338. package/src/runtime/obsidian/engine.mjs +532 -0
  339. package/src/runtime/obsidian/errors.mjs +14 -0
  340. package/src/runtime/obsidian/extension-points.mjs +103 -0
  341. package/src/runtime/obsidian/index.mjs +34 -0
  342. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  343. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  344. package/src/runtime/obsidian/observation.mjs +158 -0
  345. package/src/runtime/obsidian/opening.mjs +280 -0
  346. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  347. package/src/runtime/obsidian/pipeline.mjs +122 -0
  348. package/src/runtime/obsidian/private-lock.mjs +156 -0
  349. package/src/runtime/obsidian/process-identity.mjs +58 -0
  350. package/src/runtime/obsidian/service-client.mjs +66 -0
  351. package/src/runtime/obsidian/service-entry-path.mjs +9 -0
  352. package/src/runtime/obsidian/service-main.mjs +93 -0
  353. package/src/runtime/obsidian/service-record.mjs +169 -0
  354. package/src/runtime/obsidian/service-server.mjs +121 -0
  355. package/src/runtime/obsidian/service.mjs +216 -0
  356. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  357. package/src/runtime/obsidian/state-store.mjs +180 -0
  358. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  359. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  360. package/src/runtime/obsidian/watchers.mjs +36 -0
  361. package/src/runtime/repository-observation.mjs +491 -0
  362. package/src/runtime/supervisor.mjs +803 -0
  363. package/src/server/local-sidecar.mjs +125 -59
  364. package/src/server/security.mjs +89 -4
  365. package/src/server/server.mjs +4 -2
  366. package/src/skills/steward.mjs +780 -0
  367. package/src/support/feedback-report.mjs +4 -3
  368. package/src/ui/presentation/browser.mjs +278 -0
  369. package/src/ui/presentation/contract.mjs +116 -0
  370. package/src/ui/presentation/index.mjs +7 -0
  371. package/src/ui/presentation/native.mjs +153 -0
  372. package/src/ui/presentation/proof.mjs +57 -0
  373. package/src/ui/presentation/schema-check.mjs +30 -0
  374. package/src/ui/presentation/schema.generated.mjs +1371 -0
  375. package/src/ui/presentation/state.mjs +43 -0
  376. package/src/ui/presentation/styles.mjs +67 -0
  377. package/src/ui/presentation/tokens.mjs +74 -0
  378. package/src/ui/presentation/web.mjs +85 -0
  379. package/src/ui/review-page.mjs +32 -0
  380. package/src/upgrade/explanation.mjs +33 -0
  381. package/src/upgrade/provenance.mjs +338 -0
  382. package/src/upgrade/transaction-files.mjs +103 -0
  383. package/src/upgrade/transaction.mjs +475 -0
  384. package/src/upgrade/upgrade.mjs +65 -29
  385. package/src/vault/hosts.mjs +21 -0
  386. package/src/vault/identity.mjs +19 -0
  387. package/src/vault/index.mjs +7 -0
  388. package/src/vault/interface.mjs +11 -0
  389. package/src/vault/metadata.mjs +31 -0
  390. package/src/vault/privacy.mjs +148 -0
  391. package/src/vault/probe.mjs +89 -0
  392. package/src/vault/service.mjs +131 -0
  393. package/src/vault/source.mjs +45 -0
  394. package/src/vault/storage.mjs +23 -0
  395. package/templates/distribution-workspace/.claude/launch.json +6 -2
  396. package/templates/distribution-workspace/gitignore +1 -0
  397. package/templates/distribution-workspace/project/README.md +2 -2
  398. package/templates/external-project-workspace/README.md +45 -0
  399. package/templates/external-project-workspace/answers.example.json +4 -0
  400. package/templates/external-project-workspace/atelier.project.json +40 -0
  401. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  402. package/templates/external-project-workspace/gitignore +8 -0
  403. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  404. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  405. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  406. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  407. package/templates/external-project-workspace/source/README.md +11 -0
  408. package/templates/launch.json +6 -2
  409. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  410. package/templates/private-domain-workspace/domain/README.md +2 -2
  411. package/templates/private-domain-workspace/gitignore +1 -0
  412. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  413. package/templates/shared-project-workspace/gitignore +1 -0
  414. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,212 @@
1
+ import { execFile, execFileSync } from 'node:child_process'
2
+ import { readlinkSync } from 'node:fs'
3
+ import { POLICY_SETTINGS_PATH, buildEvalCode, createInProcessHost, runInProcess, validatePayload } from './bridge-script.mjs'
4
+
5
+ // Editor coordination adapter.
6
+ //
7
+ // adapter.probe({ vaultRoot }) -> { state: 'coordinated' | 'absent' | 'uncoordinated', reason }
8
+ // adapter.inspect(payload) -> reply read-only, may retry
9
+ // adapter.collect(payload) -> reply read-only, may retry
10
+ // adapter.publish(payload) -> reply sent at most once
11
+ //
12
+ // A raw `call(payload)` sends one payload once and resolves with the parsed
13
+ // reply. It throws TransportTimeout when the reply did not arrive in time and
14
+ // any other error when the call could not be made. createEditorAdapter adds
15
+ // the rules every transport must follow: calls are serialized, read-only calls
16
+ // may retry, and a publish is never resent. When a publish reply is lost the
17
+ // recorded outcome is re-read from the app; when none is recorded the caller
18
+ // is told so and reconciles from the files themselves.
19
+
20
+ export class TransportTimeout extends Error {
21
+ constructor(message) {
22
+ super(message)
23
+ this.name = 'TransportTimeout'
24
+ this.code = 'transport-timeout'
25
+ }
26
+ }
27
+
28
+ const READ_RETRIES = 2
29
+
30
+ // `qualification`, when given, is the app qualification this adapter was
31
+ // constructed under (`qualifyApp`). An adapter qualified while no app ran was
32
+ // never shown a version, so it never coordinates with an app found running
33
+ // later, whose version nobody checked: it is `uncoordinated` until an adapter
34
+ // qualified with a checked version replaces it. Without a qualification the
35
+ // adapter coordinates as before.
36
+ export function createEditorAdapter({ call, processProbe, kind = 'custom', qualification }) {
37
+ if (typeof call !== 'function') throw new TypeError('createEditorAdapter needs a call function')
38
+ if (typeof processProbe !== 'function') throw new TypeError('createEditorAdapter needs a processProbe function')
39
+ const versionUnchecked = qualification !== undefined && qualification?.versionChecked !== true
40
+ const retries = []
41
+ let tail = Promise.resolve()
42
+ const serialized = (operation) => {
43
+ const next = tail.then(operation, operation)
44
+ tail = next.catch(() => {})
45
+ return next
46
+ }
47
+ const readOnly = async (payload) => {
48
+ for (let attempt = 0; ; attempt += 1) {
49
+ try { return await call(validatePayload(payload)) } catch (error) {
50
+ if (attempt >= READ_RETRIES || !(error instanceof TransportTimeout)) throw error
51
+ retries.push(payload.op)
52
+ }
53
+ }
54
+ }
55
+ return {
56
+ kind,
57
+ transportRetries: retries,
58
+ inspect: (payload) => serialized(() => readOnly({ ...payload, op: 'inspect' })),
59
+ collect: (payload) => serialized(() => readOnly({ ...payload, op: 'collect' })),
60
+ publish: (payload) => serialized(async () => {
61
+ validatePayload(payload)
62
+ if (payload.op !== 'publish') throw new TypeError('publish needs a publish payload')
63
+ try { return await call(payload) } catch (error) {
64
+ retries.push(error instanceof TransportTimeout ? 'publish-reply-lost' : 'publish-call-failed')
65
+ let record = null
66
+ try { record = await readOnly({ op: 'collect', vaultRoot: payload.vaultRoot, path: payload.path }) } catch { /* the app is not answering */ }
67
+ if (record && record.operationId === payload.operationId && typeof record.outcome === 'string') return { ...record, status: record.outcome, replyLost: true }
68
+ return { status: 'outcome-unknown', operationId: payload.operationId, wrote: null, replyLost: true, transportError: String(error.message || error).slice(0, 300) }
69
+ }
70
+ }),
71
+ // Path selection. `absent` is returned only when the process probe says,
72
+ // positively, that no Obsidian is running. A running or unknowable app
73
+ // that does not answer for this exact vault is `uncoordinated`, and so is
74
+ // any app while this adapter's qualification checked no version.
75
+ probe: ({ vaultRoot }) => serialized(async () => {
76
+ let processes
77
+ try { processes = await processProbe() } catch { processes = 'unknown' }
78
+ if (processes === 'absent') return { state: 'absent', reason: 'no Obsidian process is running' }
79
+ if (versionUnchecked) return { state: 'uncoordinated', reason: 'app-version-unchecked: an Obsidian process may be running, and this adapter was qualified while none ran, so its version was never checked' }
80
+ try {
81
+ const reply = await readOnly({ op: 'inspect', vaultRoot, path: POLICY_SETTINGS_PATH })
82
+ // `path-unsafe` for the probe path still proves the app answered for this vault; the settings unit reports the path.
83
+ if (['inspected', 'path-unsafe'].includes(reply.status) && reply.vaultBasePath === vaultRoot) return { state: 'coordinated', reason: 'the app answered for this vault' }
84
+ return { state: 'uncoordinated', reason: reply.status === 'vault-mismatch' ? 'the app answered for another vault' : `the app answered ${reply.status}` }
85
+ } catch (error) {
86
+ return { state: 'uncoordinated', reason: `an Obsidian process may be running and the bridge did not answer: ${String(error.message || error).slice(0, 200)}` }
87
+ }
88
+ }),
89
+ }
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // In-process transport: the same script, run here. Used for the path with no
94
+ // app, and by tests that supply a host modelling an app.
95
+ // ---------------------------------------------------------------------------
96
+
97
+ export function createInProcessCall(host = createInProcessHost()) {
98
+ return async (payload) => runInProcess(payload, host)
99
+ }
100
+
101
+ export function createDirectAdapter({ crashSeam } = {}) {
102
+ return createEditorAdapter({ call: createInProcessCall(createInProcessHost({ crashSeam })), processProbe: () => 'absent', kind: 'direct' })
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Obsidian CLI transport
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export function defaultCliPath(platform = process.platform) {
110
+ if (platform === 'darwin') return '/Applications/Obsidian.app/Contents/MacOS/obsidian-cli'
111
+ return 'obsidian-cli'
112
+ }
113
+
114
+ // The CLI reaches the app through a socket under $HOME, so `env` selects the
115
+ // app instance. It is passed explicitly and never edited here. A call that
116
+ // outlives its timeout is killed with SIGKILL: the CLI ignores SIGTERM while
117
+ // it waits on the app.
118
+ export function createObsidianCliCall({ cliPath = defaultCliPath(), env = process.env, timeoutMs = 20000 } = {}) {
119
+ return (payload) => new Promise((resolve, reject) => {
120
+ execFile(cliPath, ['eval', `code=${buildEvalCode(payload)}`], { env, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
121
+ 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)}`))
122
+ const out = stdout || stderr || ''
123
+ const start = out.indexOf('=> ')
124
+ if (start < 0) return reject(new Error(`bridge returned no value: ${JSON.stringify(out.slice(0, 300))}`))
125
+ try { return resolve(JSON.parse(out.slice(start + 3))) } catch { return reject(new Error('bridge returned a value that is not JSON')) }
126
+ })
127
+ })
128
+ }
129
+
130
+ // The app is recognised by the executable a process runs, never by its
131
+ // arguments: a path argument with an `obsidian` segment (this package's own
132
+ // maintenance service, its private data root) is not the app. `ps -o comm=`
133
+ // names the executable as it was started on macOS, usually its full path, and
134
+ // by its command name of at most 15 characters on Linux.
135
+ //
136
+ // macOS `Obsidian` and the bundle's `Obsidian Helper…` processes, in any
137
+ // directory: a moved or renamed bundle still counts, and so does a
138
+ // process whose path ps cannot read and names by its short name.
139
+ // Linux `obsidian`, the executable of the .deb, snap, AppImage and Flatpak
140
+ // builds, by name or, for a name that is not recognised, by the
141
+ // executable it resolves to. A distribution that runs the app under
142
+ // a system Electron shows only `electron`, and which app it hosts is
143
+ // not known.
144
+ //
145
+ // The command-line tool is a client of the app, not the app: counting it would
146
+ // make every call this package makes through it look like a running app.
147
+ const APP_EXECUTABLE = Object.freeze({ darwin: /^obsidian(?: helper\b.*)?$/i, linux: /^obsidian$/i })
148
+ const ELECTRON_HOST = /^\.?electron(?:\d+|-wrap.*)?$/i
149
+ // A `ps` that has not answered by then is killed, and the reading is unknown.
150
+ const PS_TIMEOUT_MS = 5000
151
+ // A process that exited since the table was read, or whose executable this
152
+ // user may not read: another user's process, or one of this user's that hides
153
+ // it from tracing (ssh-agent, a sandboxed renderer). Such a process is judged
154
+ // by its name alone.
155
+ const EXECUTABLE_UNREADABLE = new Set(['ENOENT', 'ESRCH', 'EACCES', 'EPERM'])
156
+
157
+ const baseName = (text) => text.slice(text.lastIndexOf('/') + 1)
158
+ const readProcessExecutable = (pid) => readlinkSync(`/proc/${pid}/exe`)
159
+
160
+ // Linux names a process after the path it was started through, so an app
161
+ // started through a differently named link or launcher shows another name.
162
+ // The executable `/proc/<pid>/exe` resolves to does not change with the name.
163
+ // Any failure other than the expected ones is unknown, and so is a table in
164
+ // which no executable could be resolved at all: without `/proc`, nothing
165
+ // below the names can be established.
166
+ function resolveLinuxExecutables(rows, readExe) {
167
+ let resolved = 0
168
+ let unknown = false
169
+ for (const { pid } of rows) {
170
+ let target
171
+ try { target = String(readExe(pid)) } catch (error) {
172
+ if (!EXECUTABLE_UNREADABLE.has(error?.code)) unknown = true
173
+ continue
174
+ }
175
+ resolved += 1
176
+ const name = baseName(target.replace(/ \(deleted\)$/, ''))
177
+ if (APP_EXECUTABLE.linux.test(name)) return 'running'
178
+ if (ELECTRON_HOST.test(name)) unknown = true
179
+ }
180
+ return unknown || resolved === 0 ? 'unknown' : 'absent'
181
+ }
182
+
183
+ // Default process probe: 'absent' only when the process table was read and
184
+ // holds no Obsidian. Limits: it sees this machine's processes as this user
185
+ // can list them; it cannot see an app on another machine that reaches the
186
+ // vault through a shared or synchronized folder, an app packaged under another
187
+ // executable name, or an app that starts after the probe. Anything it cannot
188
+ // establish is 'unknown', which the adapter treats as a running app: an empty
189
+ // table, a failed or slow read, and on Linux a system Electron process or an
190
+ // executable that cannot be resolved for an unexpected reason.
191
+ export function defaultObsidianProcessProbe({ platform = process.platform, run = execFileSync, readExe = readProcessExecutable } = {}) {
192
+ if (!Object.hasOwn(APP_EXECUTABLE, platform)) return 'unknown'
193
+ const linux = platform === 'linux'
194
+ try {
195
+ const table = run('/bin/ps', ['-A', '-o', linux ? 'pid=,comm=' : 'comm='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024, timeout: PS_TIMEOUT_MS, killSignal: 'SIGKILL' })
196
+ if (typeof table !== 'string' || table.trim() === '') return 'unknown'
197
+ const rows = table.split('\n').map((line) => line.trim()).filter((line) => line !== '').map((line) => {
198
+ const match = linux ? /^(\d+)\s+(.*)$/.exec(line) : null
199
+ return match ? { pid: Number(match[1]), comm: match[2] } : { pid: null, comm: line }
200
+ })
201
+ if (rows.some((row) => APP_EXECUTABLE[platform].test(baseName(row.comm)))) return 'running'
202
+ if (!linux) return 'absent'
203
+ if (rows.some((row) => row.pid === null || ELECTRON_HOST.test(baseName(row.comm)))) return 'unknown'
204
+ return resolveLinuxExecutables(rows, readExe)
205
+ } catch {
206
+ return 'unknown'
207
+ }
208
+ }
209
+
210
+ export function createObsidianCliAdapter({ cliPath, env, timeoutMs, processProbe = () => defaultObsidianProcessProbe(), qualification } = {}) {
211
+ return createEditorAdapter({ call: createObsidianCliCall({ cliPath, env, timeoutMs }), processProbe, kind: 'obsidian-cli', qualification })
212
+ }
@@ -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
+ }