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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (374) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/README.md +59 -15
  3. package/SECURITY.md +6 -1
  4. package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
  5. package/contracts/atelier-coauthor.v1.schema.json +163 -0
  6. package/contracts/atelier-decision-request.v1.schema.json +476 -0
  7. package/contracts/atelier-decision-result.v1.schema.json +500 -0
  8. package/contracts/atelier-guide.v1.schema.json +227 -0
  9. package/contracts/atelier-intake.v1.schema.json +151 -0
  10. package/contracts/atelier-migration.v2.schema.json +61 -0
  11. package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
  12. package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
  13. package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
  14. package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
  15. package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
  16. package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
  17. package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
  18. package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
  19. package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
  20. package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
  21. package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
  22. package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
  23. package/contracts/atelier-presentation.v1.schema.json +1293 -0
  24. package/contracts/atelier-review.v1.schema.json +1601 -0
  25. package/contracts/atelier-skill-steward.v1.schema.json +692 -0
  26. package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
  27. package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
  28. package/contracts/atelier-vault-publication.v1.schema.json +58 -0
  29. package/docs/assurance-controls.md +9 -1
  30. package/docs/atelier-sync.md +5 -4
  31. package/docs/blocks/claims.md +15 -10
  32. package/docs/blocks/will-not-do.md +4 -1
  33. package/docs/coauthor-session.md +57 -0
  34. package/docs/connected-composition.md +75 -0
  35. package/docs/decisions.md +167 -0
  36. package/docs/exact-upgrades.md +159 -0
  37. package/docs/extension-pack-lifecycle.md +33 -0
  38. package/docs/guided-upgrades.md +99 -0
  39. package/docs/install.md +65 -9
  40. package/docs/intake-and-guides.md +65 -0
  41. package/docs/integration-and-review-plan.md +409 -0
  42. package/docs/integration-contract-decisions.md +22 -0
  43. package/docs/local-review.md +84 -0
  44. package/docs/local-services.md +227 -0
  45. package/docs/local-state.md +107 -0
  46. package/docs/obsidian-contract.md +687 -0
  47. package/docs/obsidian.md +387 -0
  48. package/docs/portable-presentation.md +328 -0
  49. package/docs/presentation-consumer-boundaries.md +75 -0
  50. package/docs/presentation-review-disposition.md +47 -0
  51. package/docs/project-command-inventory.md +38 -0
  52. package/docs/project-options.md +53 -0
  53. package/docs/release-engineering.md +88 -0
  54. package/docs/review-portability.md +55 -0
  55. package/docs/skill-steward.md +156 -0
  56. package/docs/upgrade.md +55 -2
  57. package/docs/vault-service.md +292 -0
  58. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  59. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  60. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  61. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  62. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  63. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  64. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  65. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  66. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  67. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  68. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  69. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  70. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  71. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  72. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  73. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  74. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  75. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  76. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  77. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  78. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  79. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  80. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  81. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  82. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  83. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  84. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  85. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  86. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  87. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  88. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  89. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  90. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  91. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  92. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  93. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  94. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  95. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  96. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  97. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  98. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  99. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  100. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  101. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  102. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  103. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  104. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  105. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  106. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  107. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  108. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  109. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  110. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  111. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  112. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  113. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  114. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  115. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  116. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  117. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  118. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  119. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  120. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  121. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  122. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  123. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  124. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  125. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  126. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  127. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  128. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  129. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  130. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  131. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  132. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  133. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  134. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  135. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  136. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  137. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  138. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  139. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  140. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  141. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  142. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  143. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  144. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  145. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  146. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  147. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  148. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  149. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  150. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  151. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  152. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  153. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  154. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  155. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  156. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  157. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  158. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  159. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  160. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  161. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  162. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  163. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  164. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  165. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  166. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  167. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  168. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  169. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  170. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  171. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  172. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  173. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  174. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  175. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  176. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  177. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  178. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  179. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  180. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  181. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  182. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  183. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  184. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  185. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  186. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  187. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  188. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  189. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  190. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  191. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  192. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  193. package/fixtures/obsidian/edits/workspace.json +65 -0
  194. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  195. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  196. package/fixtures/obsidian/materialization/workspace.json +54 -0
  197. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  198. package/fixtures/ui/presentation/pane.v1.json +7 -0
  199. package/fixtures/ui/presentation/reference.v1.json +293 -0
  200. package/package.json +57 -6
  201. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  202. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  203. package/skills/claude/atelier-open/SKILL.md +5 -0
  204. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  205. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  206. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  207. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  208. package/skills/codex/atelier-open/SKILL.md +5 -0
  209. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  210. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  211. package/src/access/preview-enforcer.mjs +84 -0
  212. package/src/boundary/policy.mjs +57 -25
  213. package/src/cli/project-options.mjs +58 -0
  214. package/src/cli/run.mjs +43 -3
  215. package/src/coauthor/session.mjs +161 -0
  216. package/src/coauthor/store.mjs +173 -0
  217. package/src/collaboration/event-ledger.mjs +16 -33
  218. package/src/collaboration/inspection-bundle.mjs +223 -0
  219. package/src/collaboration/review-contracts.mjs +25 -0
  220. package/src/collaboration/review-store.mjs +305 -0
  221. package/src/commands/coauthor.mjs +26 -0
  222. package/src/commands/config.mjs +6 -1
  223. package/src/commands/extension-pack.mjs +15 -8
  224. package/src/commands/init.mjs +44 -5
  225. package/src/commands/obsidian.mjs +368 -0
  226. package/src/commands/review.mjs +147 -0
  227. package/src/commands/setup.mjs +51 -14
  228. package/src/commands/skills.mjs +199 -0
  229. package/src/commands/sync.mjs +8 -1
  230. package/src/composition/registry.mjs +27 -0
  231. package/src/composition/wire.mjs +122 -0
  232. package/src/contracts/corpus.mjs +29 -0
  233. package/src/decisions/contracts.d.mts +96 -0
  234. package/src/decisions/contracts.mjs +321 -0
  235. package/src/disclosure/content-scan.mjs +13 -0
  236. package/src/export/atelier-export-contract.mjs +16 -1
  237. package/src/extension-packs/lifecycle.mjs +154 -0
  238. package/src/graph/graph.mjs +64 -2
  239. package/src/graph/knowledge-graph.mjs +548 -24
  240. package/src/guides/contracts.mjs +54 -0
  241. package/src/intake/store.mjs +119 -0
  242. package/src/preview/channel.mjs +89 -0
  243. package/src/project/config.mjs +26 -27
  244. package/src/project/durable-state.mjs +161 -0
  245. package/src/project/file-class.mjs +166 -0
  246. package/src/project/private-state.mjs +11 -1
  247. package/src/projection/obsidian/contracts.mjs +571 -0
  248. package/src/projection/obsidian/edits/align.mjs +169 -0
  249. package/src/projection/obsidian/edits/apply.mjs +889 -0
  250. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  251. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  252. package/src/projection/obsidian/edits/index.mjs +19 -0
  253. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  254. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  255. package/src/projection/obsidian/edits/observe.mjs +123 -0
  256. package/src/projection/obsidian/edits/policy.mjs +123 -0
  257. package/src/projection/obsidian/edits/regions.mjs +648 -0
  258. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  259. package/src/projection/obsidian/materialize/index.mjs +7 -0
  260. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  261. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  262. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  263. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  264. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  265. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  266. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  267. package/src/projection/obsidian/proposals/index.mjs +23 -0
  268. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  269. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  270. package/src/projection/obsidian/proposals/router.mjs +168 -0
  271. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  272. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  273. package/src/projection/obsidian/publication/index.mjs +5 -0
  274. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  275. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  276. package/src/projection/obsidian/publication/transport.mjs +139 -0
  277. package/src/projection/obsidian/recovery/index.mjs +6 -0
  278. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  279. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  280. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  281. package/src/projection/obsidian/recovery/store.mjs +234 -0
  282. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  283. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  284. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  285. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  286. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  287. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  288. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  289. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  290. package/src/projection/project.mjs +28 -19
  291. package/src/readiness-protocols/evidence.mjs +282 -0
  292. package/src/readiness-protocols/runtime.mjs +4 -3
  293. package/src/readiness-protocols/source-read.mjs +39 -0
  294. package/src/runtime/obsidian/app-capability.mjs +110 -0
  295. package/src/runtime/obsidian/app-production-seams.mjs +96 -0
  296. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  297. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  298. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  299. package/src/runtime/obsidian/contributions.mjs +27 -0
  300. package/src/runtime/obsidian/documents.mjs +37 -0
  301. package/src/runtime/obsidian/enablement.mjs +53 -0
  302. package/src/runtime/obsidian/engine.mjs +532 -0
  303. package/src/runtime/obsidian/errors.mjs +14 -0
  304. package/src/runtime/obsidian/extension-points.mjs +103 -0
  305. package/src/runtime/obsidian/index.mjs +34 -0
  306. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  307. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  308. package/src/runtime/obsidian/observation.mjs +158 -0
  309. package/src/runtime/obsidian/opening.mjs +243 -0
  310. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  311. package/src/runtime/obsidian/pipeline.mjs +122 -0
  312. package/src/runtime/obsidian/private-lock.mjs +156 -0
  313. package/src/runtime/obsidian/process-identity.mjs +58 -0
  314. package/src/runtime/obsidian/service-client.mjs +66 -0
  315. package/src/runtime/obsidian/service-main.mjs +89 -0
  316. package/src/runtime/obsidian/service-record.mjs +169 -0
  317. package/src/runtime/obsidian/service-server.mjs +121 -0
  318. package/src/runtime/obsidian/service.mjs +216 -0
  319. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  320. package/src/runtime/obsidian/state-store.mjs +180 -0
  321. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  322. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  323. package/src/runtime/obsidian/watchers.mjs +36 -0
  324. package/src/runtime/supervisor.mjs +16 -1
  325. package/src/server/local-sidecar.mjs +44 -0
  326. package/src/server/server.mjs +1 -0
  327. package/src/skills/steward.mjs +780 -0
  328. package/src/ui/presentation/browser.mjs +278 -0
  329. package/src/ui/presentation/contract.mjs +116 -0
  330. package/src/ui/presentation/index.mjs +7 -0
  331. package/src/ui/presentation/native.mjs +153 -0
  332. package/src/ui/presentation/proof.mjs +57 -0
  333. package/src/ui/presentation/schema-check.mjs +30 -0
  334. package/src/ui/presentation/schema.generated.mjs +1371 -0
  335. package/src/ui/presentation/state.mjs +43 -0
  336. package/src/ui/presentation/styles.mjs +67 -0
  337. package/src/ui/presentation/tokens.mjs +74 -0
  338. package/src/ui/presentation/web.mjs +85 -0
  339. package/src/ui/review-page.mjs +32 -0
  340. package/src/upgrade/explanation.mjs +33 -0
  341. package/src/upgrade/provenance.mjs +338 -0
  342. package/src/upgrade/transaction-files.mjs +103 -0
  343. package/src/upgrade/transaction.mjs +475 -0
  344. package/src/upgrade/upgrade.mjs +63 -28
  345. package/src/vault/hosts.mjs +21 -0
  346. package/src/vault/identity.mjs +19 -0
  347. package/src/vault/index.mjs +7 -0
  348. package/src/vault/interface.mjs +11 -0
  349. package/src/vault/metadata.mjs +31 -0
  350. package/src/vault/privacy.mjs +148 -0
  351. package/src/vault/probe.mjs +89 -0
  352. package/src/vault/service.mjs +131 -0
  353. package/src/vault/source.mjs +45 -0
  354. package/src/vault/storage.mjs +23 -0
  355. package/templates/distribution-workspace/.claude/launch.json +6 -2
  356. package/templates/distribution-workspace/gitignore +1 -0
  357. package/templates/distribution-workspace/project/README.md +2 -2
  358. package/templates/external-project-workspace/README.md +45 -0
  359. package/templates/external-project-workspace/answers.example.json +4 -0
  360. package/templates/external-project-workspace/atelier.project.json +40 -0
  361. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  362. package/templates/external-project-workspace/gitignore +8 -0
  363. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  364. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  365. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  366. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  367. package/templates/external-project-workspace/source/README.md +11 -0
  368. package/templates/launch.json +6 -2
  369. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  370. package/templates/private-domain-workspace/domain/README.md +2 -2
  371. package/templates/private-domain-workspace/gitignore +1 -0
  372. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  373. package/templates/shared-project-workspace/gitignore +1 -0
  374. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,243 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { createRecoveryStore as createStore, readFileBytes, sha256Digest } from '../../projection/obsidian/recovery/store.mjs'
4
+ import { inspectApp, qualifyApp } from './app-capability.mjs'
5
+ import { readObsidianEnablement } from './enablement.mjs'
6
+ import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
7
+ import { UNAVAILABLE_APPLY_OPERATION } from './extension-points.mjs'
8
+ import { LIFECYCLE_PRIMITIVES, requestServiceTick, serviceStatus, startService } from './lifecycle.mjs'
9
+ import { protectedRoots } from './machine-settings.mjs'
10
+ import { trustedNoteBases } from './pending-edits.mjs'
11
+ import { resolveServiceWorkspace } from './service.mjs'
12
+ import { OPEN_EDIT_STATES, createMaintenanceStateStore } from './state-store.mjs'
13
+
14
+ // Opening a view, and saying honestly how fresh it is.
15
+ //
16
+ // Every answer is one of the typed outcomes below. Only `current` is success,
17
+ // and it means all of:
18
+ //
19
+ // - the owned maintenance service is healthy and ran a tick after this
20
+ // request, so the generation it prepared comes from the present sources;
21
+ // - the persisted freshness of the view says `current`, which the state
22
+ // store only accepts for a verified generation equal to the prepared one;
23
+ // - read back here, independently: the trusted pointer of the view names
24
+ // that same generation and every note in the vault has the bytes that
25
+ // generation was published with;
26
+ // - the installed app is present, has its command-line capability, meets
27
+ // the minimum version, was asked to open the vault, answers for exactly
28
+ // this vault and has finished reading it.
29
+ //
30
+ // Nothing here talks to an app or an operating system itself: `appProbe` and
31
+ // `launcher` are injected, and there is no default for either.
32
+
33
+ export const OPENING_OUTCOMES = Object.freeze({
34
+ current: { summary: 'the vault is the present generation, verified by read-back, and the app has it open', next: 'nothing to do' },
35
+ updating: { summary: 'maintenance is publishing or has not finished; the vault is not confirmed current', next: 'run `obsidian open` again in a moment' },
36
+ 'held-for-your-edit': { summary: 'a note you edited is preserved and held; the view is not republished over it', next: 'apply or withdraw the pending edit, then open again' },
37
+ 'stale-readable': { summary: 'a last good vault exists and can be read, but it is not proven to be the present generation', next: 'see `obsidian status` for the reason; `obsidian open --allow-stale` opens it as it is' },
38
+ 'not-prepared': { summary: 'no generation of this view has been published yet', next: 'see `obsidian status` for the reason' },
39
+ 'app-missing': { summary: 'no Obsidian installation was found', next: 'install Obsidian, then open again' },
40
+ 'app-version-unsupported': { summary: 'the installed Obsidian is below the minimum supported version, or its version cannot be read', next: 'update Obsidian, then open again' },
41
+ 'app-cli-unavailable': { summary: 'the installed Obsidian has no usable command-line capability', next: 'enable the command-line interface in Obsidian, then open again' },
42
+ indexing: { summary: 'the app answered for this vault but has not finished reading it', next: 'wait for Obsidian to finish indexing, then open again' },
43
+ 'publisher-conflict': { summary: 'another publisher or an uncoordinated editor holds this vault', next: 'close the other publisher or let it finish; it is retried automatically' },
44
+ 'launch-failed': { summary: 'the operating system or the app did not open this vault', next: 'open the vault folder in Obsidian once by hand, then open again' },
45
+ 'service-unavailable': { summary: 'the maintenance service of this workspace could not be started or reached as ours', next: 'see `obsidian service status`' },
46
+ busy: { summary: 'the maintenance service runs but is in a long tick and did not answer in time', next: 'run `obsidian open` again in a moment; nothing was stopped or restarted' },
47
+ disabled: { summary: 'the Obsidian integration is not enabled for this project, or this view is not configured', next: 'enable it in the project configuration' },
48
+ })
49
+
50
+ // A sub-state of pending edits, never an opening failure.
51
+ export const APPLY_UNAVAILABLE = 'apply-unavailable'
52
+
53
+ export function describeOutcome(code) {
54
+ if (!Object.hasOwn(OPENING_OUTCOMES, code)) throw new TypeError(`unknown opening outcome: ${String(code)}`)
55
+ return { outcome: code, ...OPENING_OUTCOMES[code] }
56
+ }
57
+
58
+ // The decisions the opening oracles are sensitive to; tests substitute broken ones to prove the oracles can fail.
59
+ export const OPENING_PRIMITIVES = Object.freeze({
60
+ // The freshness states that are their own outcome.
61
+ outcomeOfState: (state) => (['updating', 'held-for-your-edit', 'publisher-conflict', 'disabled'].includes(state) ? state : null),
62
+ // Whether a persisted `current` may be believed after reading the vault back.
63
+ readBackAgrees: ({ entry, verification }) => verification.readable && verification.generationId === entry.preparedGenerationId && verification.intact,
64
+ // Whether the app may be shown this vault at all.
65
+ appQualifies: (qualification) => qualification.outcome === 'qualified',
66
+ })
67
+
68
+ const segment = (identifier) => identifier.replaceAll(':', '_')
69
+ const STORE_AREAS = (scopeId) => [['vaults', segment(scopeId)], ['state', 'manifests', segment(scopeId)], ['state', 'journals', segment(scopeId)], ['state', 'locks'], ['recovery', 'objects'], ['staging']]
70
+
71
+ // Reads the trusted pointer of a view and every note of its vault. Read-only:
72
+ // the store is only constructed when everything it would create already exists.
73
+ export function readBackTrustedGeneration({ workspaceRoot, workspaceId, scopeId, repositoryRoots, createRecoveryStore = createStore }) {
74
+ const unreadable = (reason) => ({ readable: false, reason, generationId: null, intact: false, noteCount: 0, differing: 0, missing: 0 })
75
+ if (!STORE_AREAS(scopeId).every((parts) => fs.existsSync(path.join(workspaceRoot, ...parts)))) return unreadable('no-published-vault')
76
+ let store
77
+ let trusted
78
+ try {
79
+ store = createRecoveryStore({ workspaceRoot, workspaceId, scopeId, repositoryRoots })
80
+ trusted = { pointer: store.readCurrent(), ...trustedNoteBases(store) }
81
+ } catch (error) {
82
+ if (typeof error?.code === 'string') return unreadable(error.code)
83
+ throw error
84
+ }
85
+ if (!trusted.pointer || !trusted.manifest) return unreadable('no-trusted-generation')
86
+ let differing = 0
87
+ let missing = 0
88
+ for (const [notePath, base] of trusted.bases) {
89
+ let bytes
90
+ try { bytes = readFileBytes(path.join(store.vaultRoot, ...notePath.split('/'))) } catch (error) {
91
+ if (error.code === 'ENOENT') { missing += 1; continue }
92
+ return unreadable(error.code ?? 'vault-unreadable')
93
+ }
94
+ if (sha256Digest(bytes) !== base.digest) differing += 1
95
+ }
96
+ return { readable: true, reason: 'read-back', generationId: trusted.pointer.generationId, intact: differing === 0 && missing === 0, noteCount: trusted.bases.size, differing, missing, vaultRoot: store.vaultRoot }
97
+ }
98
+
99
+ function pendingSummary(stateStore, scopeId, applyAvailable) {
100
+ const open = stateStore.readPendingEdits().edits.filter((edit) => edit.scopeId === scopeId && OPEN_EDIT_STATES.includes(edit.state))
101
+ const byState = Object.fromEntries(OPEN_EDIT_STATES.map((state) => [state, open.filter((edit) => edit.state === state).length]))
102
+ // Said whenever no apply operation exists, not only after a dispatch found that out.
103
+ return { open: open.length, byState, apply: applyAvailable ? 'available' : APPLY_UNAVAILABLE }
104
+ }
105
+
106
+ // The honest freshness of one view, from persisted state plus a read-back.
107
+ // `serviceState` is the proven lifecycle state; a persisted `current` is not
108
+ // repeated as `current` while nothing proves maintenance is looking.
109
+ export function scopeReport({ workspace, scopeId, repositoryRoots, serviceState, applyAvailable = false, freshness = undefined, createRecoveryStore }, rules = OPENING_PRIMITIVES) {
110
+ const stateStore = createMaintenanceStateStore(workspace)
111
+ let document = freshness
112
+ let unreadableCode = null
113
+ if (document === undefined) {
114
+ try { document = stateStore.readFreshness() } catch (error) { if (!(error instanceof ObsidianMaintenanceRefusal)) throw error; document = null; unreadableCode = error.code }
115
+ }
116
+ const entry = document?.scopes.find((item) => item.scopeId === scopeId) ?? null
117
+ const verification = readBackTrustedGeneration({ ...workspace, scopeId, repositoryRoots, ...(createRecoveryStore ? { createRecoveryStore } : {}) })
118
+ const lastGood = verification.readable ? 'stale-readable' : 'not-prepared'
119
+ let outcome
120
+ let reason
121
+ if (entry === null) { outcome = lastGood; reason = unreadableCode ?? 'no-freshness-recorded' }
122
+ else if (rules.outcomeOfState(entry.state) !== null) { outcome = rules.outcomeOfState(entry.state); reason = entry.reason }
123
+ else if (entry.state !== 'current') { outcome = lastGood; reason = entry.reason }
124
+ else if (!rules.readBackAgrees({ entry, verification })) {
125
+ // Notes that differ are an edit the engine has not looked at yet; a pointer that differs is another generation.
126
+ const edited = verification.readable && verification.generationId === entry.preparedGenerationId
127
+ outcome = edited ? 'updating' : lastGood
128
+ reason = edited ? 'vault-differs-from-trusted-generation' : verification.readable ? 'trusted-generation-differs' : verification.reason
129
+ } else if (serviceState !== 'healthy') { outcome = 'stale-readable'; reason = serviceState === 'busy' ? 'maintenance-busy-not-rechecked' : 'maintenance-not-running' }
130
+ else { outcome = 'current'; reason = entry.reason }
131
+ return {
132
+ scopeId, ...describeOutcome(outcome), reason,
133
+ freshness: entry === null ? null : { state: entry.state, reason: entry.reason, verified: entry.verified, generationId: entry.generationId, preparedGenerationId: entry.preparedGenerationId, heldNoteCount: entry.heldNotes.length, retainedEdits: entry.retainedEdits, checkedAt: entry.checkedAt },
134
+ readBack: { readable: verification.readable, reason: verification.reason, generationId: verification.generationId, intact: verification.intact, noteCount: verification.noteCount, differing: verification.differing, missing: verification.missing },
135
+ pendingEdits: pendingSummary(stateStore, scopeId, applyAvailable),
136
+ vaultRoot: verification.vaultRoot ?? null,
137
+ }
138
+ }
139
+
140
+ export function resolveScope(enablement, requested) {
141
+ const scopeId = requested ?? enablement.defaultScopeId ?? enablement.scopes[0]?.scopeId ?? null
142
+ if (scopeId === null) refuse('unknown-scope', 'this project declares no view to open')
143
+ if (!enablement.scopes.some((scope) => scope.scopeId === scopeId)) refuse('unknown-scope', 'no declared view has this identity', { scopeId: String(scopeId).slice(0, 128) })
144
+ return scopeId
145
+ }
146
+
147
+ const defaultSleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms) })
148
+ const READABLE = new Set(['stale-readable', 'held-for-your-edit', 'updating', 'publisher-conflict'])
149
+
150
+ // Starts or reconnects the owned service, asks it for a tick, reads the view
151
+ // back, qualifies the app, has the vault opened and reports one outcome.
152
+ export async function openScopeForOracleTests(options = {}, rules = OPENING_PRIMITIVES, lifecycleRules = LIFECYCLE_PRIMITIVES) {
153
+ const {
154
+ loadProject, dataRoot, scopeId: requestedScope, appProbe, launcher, service = {}, consent, allowStale = false, extensions = null,
155
+ tickTimeoutMs = 120 * 1000, appWaitMs = 30 * 1000, appPollMs = 500, sleep = defaultSleep, monotonic = () => Date.now(),
156
+ env = process.env, platform = process.platform, probeTimeoutMs,
157
+ } = options
158
+ if (typeof loadProject !== 'function') throw new TypeError('open needs loadProject')
159
+ // No defaults: reaching an app or the operating system is a decision of the entry that composes this.
160
+ if (typeof appProbe?.inspect !== 'function' || typeof appProbe?.vaultState !== 'function') throw new TypeError('open needs an injected appProbe')
161
+ if (typeof launcher?.open !== 'function') throw new TypeError('open needs an injected launcher')
162
+ if (typeof service.entryPath !== 'string') throw new TypeError('open needs the service entry it may start')
163
+
164
+ const project = loadProject()
165
+ const enablement = readObsidianEnablement(project)
166
+ const applyAvailable = extensions !== null && extensions.applyOperation() !== UNAVAILABLE_APPLY_OPERATION
167
+ const finish = (outcome, extra = {}) => ({ ok: outcome === 'current', ...describeOutcome(outcome), launched: false, ...extra })
168
+ if (enablement.state === 'disabled') return finish('disabled', { reason: enablement.reason, scopeId: requestedScope ?? null })
169
+ const scopeId = resolveScope(enablement, requestedScope)
170
+ const lifecycle = { loadProject, dataRoot, env, platform, ...(probeTimeoutMs === undefined ? {} : { probeTimeoutMs }) }
171
+
172
+ // 1. The owned service: reconnect, or start. Never adopt, never replace.
173
+ let status = await serviceStatus(lifecycle, lifecycleRules)
174
+ if (status.state === 'busy') return finish('busy', { scopeId, reason: status.reason, service: { state: status.state } })
175
+ if (status.state !== 'healthy') {
176
+ let started
177
+ try {
178
+ started = await startService({ ...lifecycle, detached: true, ...service, ...(consent === undefined ? {} : { consent }) }, lifecycleRules)
179
+ } catch (error) {
180
+ if (!(error instanceof ObsidianMaintenanceRefusal)) throw error
181
+ return finish('service-unavailable', { scopeId, reason: error.code, service: { state: status.state } })
182
+ }
183
+ if (started.state === 'busy') return finish('busy', { scopeId, reason: started.reason, service: { state: 'busy', started: started.started } })
184
+ if (started.state !== 'healthy') return finish('service-unavailable', { scopeId, reason: started.reason ?? started.state, service: { state: started.state } })
185
+ status = started
186
+ }
187
+
188
+ // 2. A tick that starts after this request, bounded.
189
+ const asked = await requestServiceTick({ ...lifecycle, tickTimeoutMs }, lifecycleRules)
190
+ const serviceShown = { state: asked.state, runtimeId: status.record?.runtimeId ?? null }
191
+ if (asked.state === 'busy') return finish('busy', { scopeId, reason: asked.reason, service: serviceShown })
192
+ if (!asked.requested) return finish('service-unavailable', { scopeId, reason: asked.reason, service: serviceShown })
193
+ const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform })
194
+ const report = (freshnessOverride) => scopeReport({ workspace, scopeId, repositoryRoots: protectedRoots(project), serviceState: 'healthy', applyAvailable, ...(freshnessOverride === undefined ? {} : { freshness: freshnessOverride }) }, rules)
195
+ let view = report()
196
+ const tick = asked.pending ? null : asked.tick
197
+ if (asked.pending && view.outcome === 'current') view = { ...view, ...describeOutcome('updating'), reason: 'tick-still-running' }
198
+ else if (!asked.pending && tick?.state === 'busy') return finish('busy', { scopeId, reason: 'engine-lock-held', service: serviceShown })
199
+ else if (!asked.pending && (tick === null || tick.ok !== true || tick.state !== 'ticked') && view.outcome === 'current') {
200
+ // The tick after this request did not complete: what was current before is not proven current now.
201
+ view = { ...view, ...describeOutcome('stale-readable'), reason: `tick-${tick?.state ?? tick?.error?.code ?? 'unanswered'}` }
202
+ }
203
+ const common = { scopeId, reason: view.reason, service: serviceShown, freshness: view.freshness, readBack: view.readBack, pendingEdits: view.pendingEdits }
204
+ if (view.outcome !== 'current' && !(allowStale && READABLE.has(view.outcome) && view.readBack.readable)) return finish(view.outcome, common)
205
+
206
+ // 3. The app, before anything is launched.
207
+ const before = qualifyApp(await inspectApp(appProbe), { requireVersion: false })
208
+ const app = (qualification) => ({ outcome: qualification.outcome, reason: qualification.reason, version: qualification.version, floor: qualification.floor })
209
+ if (!rules.appQualifies(before)) return finish(before.outcome, { ...common, reason: before.reason, app: app(before) })
210
+
211
+ // 4. Launch, then wait, bounded, for the app to answer for exactly this vault.
212
+ let launch
213
+ try { launch = await launcher.open({ vaultRoot: view.vaultRoot }) } catch { launch = { launched: false, reason: 'launcher-threw' } }
214
+ if (launch?.launched !== true) return finish('launch-failed', { ...common, reason: typeof launch?.reason === 'string' ? launch.reason : 'launcher-refused', app: app(before) })
215
+ const deadline = monotonic() + appWaitMs
216
+ let after = before
217
+ let vault = { answered: false, indexReady: false }
218
+ for (;;) {
219
+ after = qualifyApp(await inspectApp(appProbe), { requireVersion: true })
220
+ // A running app below the floor is final; an app that has not come up yet is asked again.
221
+ if (!rules.appQualifies(after) && after.reason !== 'version-unknown') return finish(after.outcome, { ...common, launched: true, reason: after.reason, app: app(after) })
222
+ if (rules.appQualifies(after)) {
223
+ try { vault = await appProbe.vaultState({ vaultRoot: view.vaultRoot }) } catch { vault = { answered: false, indexReady: false } }
224
+ if (vault?.answered === true && vault.indexReady === true) break
225
+ }
226
+ if (monotonic() >= deadline) break
227
+ await sleep(appPollMs)
228
+ }
229
+ if (!rules.appQualifies(after)) return finish(after.outcome, { ...common, launched: true, reason: after.reason, app: app(after) })
230
+ if (vault?.answered !== true) return finish('launch-failed', { ...common, launched: true, reason: 'app-did-not-answer-for-this-vault', app: app(after) })
231
+ if (vault.indexReady !== true) return finish('indexing', { ...common, launched: true, reason: 'metadata-cache-not-ready', app: app(after) })
232
+
233
+ // 5. Still what was verified? The launch took time.
234
+ if (view.outcome !== 'current') return finish(view.outcome, { ...common, launched: true, app: app(after) })
235
+ const again = report()
236
+ if (again.outcome !== 'current') return finish(again.outcome, { ...common, launched: true, reason: again.reason, freshness: again.freshness, readBack: again.readBack, app: app(after) })
237
+ return finish('current', { ...common, launched: true, app: app(after) })
238
+ }
239
+
240
+ // Production entry point: the production decisions, always.
241
+ export function openScope(options = {}) {
242
+ return openScopeForOracleTests(options, OPENING_PRIMITIVES, LIFECYCLE_PRIMITIVES)
243
+ }
@@ -0,0 +1,141 @@
1
+ import { createHash } from 'node:crypto'
2
+ import path from 'node:path'
3
+ import { publishedSinceCommit } from '../../projection/obsidian/recovery/restart.mjs'
4
+ import { readFileBytes, sha256Digest } from '../../projection/obsidian/recovery/store.mjs'
5
+ import { compareText } from './documents.mjs'
6
+ import { normalizeApplyResult } from './extension-points.mjs'
7
+ import { OPEN_EDIT_STATES } from './state-store.mjs'
8
+
9
+ // Edits a person (or another program) made in a vault.
10
+ //
11
+ // A note whose bytes differ from the digest its view was generated with has
12
+ // been edited. The order is fixed: the edited bytes are copied into the
13
+ // recovery object store first, and only then is a record queued. The engine
14
+ // never writes such a note. Whether an edit reaches a source file is a
15
+ // separate, explicit operation: never on a tick in manual mode, and in
16
+ // automatic mode only through the registered apply operation under an
17
+ // installed policy that is read again immediately before every dispatch.
18
+
19
+ const DISPATCHABLE = new Set(['queued', 'apply-unavailable', 'apply-failed'])
20
+ export const isOpenEdit = (edit) => OPEN_EDIT_STATES.includes(edit.state)
21
+
22
+ export function editIdentifier({ workspaceId, repoId, nodeId, scopeId, generationId, observedDigest }) {
23
+ return `edit-${createHash('sha256').update([workspaceId, repoId, nodeId, scopeId, generationId, observedDigest].join('\u0000')).digest('hex').slice(0, 32)}`
24
+ }
25
+
26
+ // The digest each note of a view is bound to, exactly as the publisher binds
27
+ // it: the trusted manifest, then whatever an unfinished publication of this
28
+ // view has verifiably published since. Without the second part a note written
29
+ // by an interrupted run would look like somebody's edit.
30
+ export function trustedNoteBases(store) {
31
+ const manifest = store.readCurrentManifest()
32
+ const bases = new Map()
33
+ if (!manifest) return { manifest: null, bases }
34
+ const published = publishedSinceCommit({ store })
35
+ for (const note of manifest.notes) {
36
+ const digest = published.has(note.path) ? published.get(note.path) : note.noteDigest
37
+ if (digest !== null) bases.set(note.path, { digest, repoId: note.repoId, nodeId: note.nodeId })
38
+ }
39
+ return { manifest, bases }
40
+ }
41
+
42
+ // Production preservation: an immutable, content-addressed copy in the
43
+ // recovery object store, through the store's own primitive.
44
+ export const preserveInRecoveryStore = (store, bytes) => store.retainObject(bytes)
45
+
46
+ // Compares each note with its base and returns the next list of edit records.
47
+ // `digestOf(path)` is the digest observation found; the bytes are read again
48
+ // here and hashed, and that second reading is what gets preserved and recorded.
49
+ export function observeVaultEdits({ store, workspaceId, scopeId, manifest, bases, digestOf, edits, now, preserve = preserveInRecoveryStore }) {
50
+ const next = edits.map((edit) => ({ ...edit }))
51
+ const events = []
52
+ const openFor = (notePath) => next.filter((edit) => edit.scopeId === scopeId && edit.path === notePath && isOpenEdit(edit))
53
+ const close = (edit, state) => { edit.state = state; edit.closedAt = now }
54
+
55
+ for (const [notePath, base] of bases) {
56
+ const observed = digestOf(notePath)
57
+ // A missing note holds no bytes to keep; the publisher restores it.
58
+ if (observed === null || observed === undefined) continue
59
+ if (observed === base.digest) {
60
+ for (const edit of openFor(notePath)) { close(edit, 'withdrawn'); events.push({ kind: 'withdrawn', scopeId, path: notePath, editId: edit.editId }) }
61
+ continue
62
+ }
63
+ if (openFor(notePath).some((edit) => edit.observedDigest === observed)) continue
64
+ let bytes
65
+ try { bytes = readFileBytes(path.join(store.vaultRoot, ...notePath.split('/'))) } catch (error) {
66
+ if (error.code === 'ENOENT') continue
67
+ throw error
68
+ }
69
+ const observedDigest = sha256Digest(bytes)
70
+ if (observedDigest === base.digest) continue
71
+ // Preserved first. If this throws, nothing has been recorded.
72
+ const object = preserve(store, bytes)
73
+ if (openFor(notePath).some((edit) => edit.observedDigest === observedDigest)) continue
74
+ for (const edit of openFor(notePath)) { close(edit, 'superseded'); events.push({ kind: 'superseded', scopeId, path: notePath, editId: edit.editId }) }
75
+ const identity = { workspaceId, repoId: base.repoId, nodeId: base.nodeId }
76
+ const editId = editIdentifier({ ...identity, scopeId, generationId: manifest.generationId, observedDigest })
77
+ // The same bytes over the same generation, seen again after being closed: reopened, not duplicated.
78
+ const index = next.findIndex((edit) => edit.editId === editId)
79
+ const record = {
80
+ editId, identity, scopeId, generationId: manifest.generationId, path: notePath, baseNoteDigest: base.digest, observedDigest, objectRef: object.ref,
81
+ observedAt: now, state: 'queued', attempts: 0, retryBudget: null, lastAttemptAt: null, lastResult: null, closedAt: null,
82
+ }
83
+ if (index === -1) next.push(record); else next[index] = record
84
+ events.push({ kind: 'queued', scopeId, path: notePath, editId })
85
+ }
86
+ // A note that left the view is no longer compared; its record stays open and its bytes stay kept.
87
+ return { edits: next, events }
88
+ }
89
+
90
+ export function heldPaths(edits, scopeId) {
91
+ return [...new Set(edits.filter((edit) => edit.scopeId === scopeId && isOpenEdit(edit)).map((edit) => edit.path))].sort(compareText)
92
+ }
93
+
94
+ // Automatic dispatch. `authorize()` reads the machine settings and the policy
95
+ // from disk. It is called before the batch and again immediately before each
96
+ // edit, so a revocation, a pause or a switch back to manual stops the very
97
+ // next dispatch. Attempts are bounded by the policy's retry budget and spaced
98
+ // by `retryIntervalMs`; an exhausted edit stays pending and is not dispatched
99
+ // again until the operation or the policy changes.
100
+ export async function dispatchAutomaticApply({ edits, authorize, applyOperation, now, nowMs, retryIntervalMs }) {
101
+ const next = edits.map((edit) => ({ ...edit }))
102
+ const dispatched = []
103
+ const first = authorize()
104
+ if (!first.authorized) return { edits: next, dispatched, authorization: first.reason }
105
+ let authorization = first.reason
106
+ let remaining = first.policy.maxBatchSize
107
+ const ordered = next.filter((edit) => isOpenEdit(edit)).sort((left, right) => compareText(left.observedAt, right.observedAt) || compareText(left.editId, right.editId))
108
+ for (const edit of ordered) {
109
+ if (remaining <= 0) break
110
+ const current = authorize()
111
+ if (!current.authorized) { authorization = current.reason; break }
112
+ const { policy } = current
113
+ if (!policy.allowedEditClasses.includes('body-replacement')) { authorization = 'edit-class-not-allowed'; break }
114
+ // A new operation or a new policy revision is a new situation: the budget starts again.
115
+ if (edit.lastResult && (edit.lastResult.operationId !== applyOperation.id || edit.lastResult.policyDigest !== policy.digest)) {
116
+ edit.attempts = 0
117
+ if (edit.state === 'retry-exhausted') edit.state = 'queued'
118
+ }
119
+ edit.retryBudget = policy.retryBudget
120
+ if (edit.attempts >= policy.retryBudget + 1) { edit.state = 'retry-exhausted'; continue }
121
+ if (!DISPATCHABLE.has(edit.state)) continue
122
+ if (edit.lastAttemptAt !== null && nowMs - Date.parse(edit.lastAttemptAt) < retryIntervalMs) continue
123
+ remaining -= 1
124
+ let result
125
+ try {
126
+ result = normalizeApplyResult(await applyOperation.apply({
127
+ edit: structuredClone(edit), policy: structuredClone(policy), policyDigest: policy.digest, idempotencyKey: edit.editId,
128
+ }))
129
+ } catch {
130
+ result = { status: 'failed', code: 'apply-operation-threw' }
131
+ }
132
+ edit.attempts += 1
133
+ edit.lastAttemptAt = now
134
+ edit.lastResult = { ...result, operationId: applyOperation.id, policyDigest: policy.digest }
135
+ if (result.status === 'applied') { edit.state = 'applied'; edit.closedAt = now }
136
+ else if (edit.attempts >= policy.retryBudget + 1) edit.state = 'retry-exhausted'
137
+ else edit.state = result.status === 'apply-unavailable' ? 'apply-unavailable' : 'apply-failed'
138
+ dispatched.push({ editId: edit.editId, status: result.status, code: result.code, state: edit.state })
139
+ }
140
+ return { edits: next, dispatched, authorization }
141
+ }
@@ -0,0 +1,122 @@
1
+ import { createHash } from 'node:crypto'
2
+ import path from 'node:path'
3
+ import { buildCanonicalGraph, createGraphFileCache } from '../../graph/graph.mjs'
4
+ import { EMITTER_VERSION, createPreparationCache, prepareView, withEligibility } from '../../projection/obsidian/materialize/index.mjs'
5
+ import { publishView } from '../../projection/obsidian/publication/publisher.mjs'
6
+ import { recheckDisplacedFiles } from '../../projection/obsidian/recovery/late-writer.mjs'
7
+ import { createRecoveryStore, readFileBytes } from '../../projection/obsidian/recovery/store.mjs'
8
+ import { compareText } from './documents.mjs'
9
+ import { refuse } from './errors.mjs'
10
+ import { readFileFacts, sha256Digest, sourceKey } from './observation.mjs'
11
+
12
+ // The production seams between the engine and the projection modules:
13
+ //
14
+ // canonical graph -> source snapshot -> prepareView -> publishView
15
+ //
16
+ // The engine receives these as one object and tests replace single members.
17
+ // Nothing here talks to a network, and nothing here asks git anything; the
18
+ // canonical graph builder reads local ignore rules through the local git
19
+ // executable and that is all.
20
+
21
+ // Fails closed twice. A document is eligible only when the census classified
22
+ // it: an unclassified one, or one that carries no classification at all, is
23
+ // withheld. An embedded asset has no audience of its own, so it is eligible
24
+ // only when at least one note that embeds it is itself eligible; a file that
25
+ // only withheld notes embed never reaches a view, a snapshot or a state
26
+ // document. `isAssetEligible(asset, { embeds, isEligibleSource })` may be
27
+ // supplied to replace the asset rule; only an exact `true` admits an asset, and
28
+ // `revision()` must change whenever either rule does.
29
+ export const DEFAULT_ELIGIBILITY = Object.freeze({
30
+ revision: () => 'classified-documents/v2+assets-embedded-by-eligible-documents/v1',
31
+ isEligible: (node) => node.classification === 'classified',
32
+ })
33
+
34
+ export function assetEligibilityFor({ graph, eligibility }) {
35
+ const eligibleSources = new Set(graph.nodes.filter((node) => eligibility.isEligible(node) === true).map((node) => node.id))
36
+ const embeds = Array.isArray(graph.embeds) ? graph.embeds : []
37
+ if (typeof eligibility.isAssetEligible === 'function') {
38
+ return (asset) => eligibility.isAssetEligible(asset, { embeds: embeds.filter((embed) => embed?.asset?.id === asset.id), isEligibleSource: (id) => eligibleSources.has(id) }) === true
39
+ }
40
+ const embedded = new Set(embeds.filter((embed) => eligibleSources.has(embed?.source)).map((embed) => embed?.asset?.id))
41
+ return (asset) => embedded.has(asset.id)
42
+ }
43
+
44
+ const posix = (value) => value.split(path.sep).join('/')
45
+
46
+ // The corpus profile of a loaded project. Repository roots are recorded
47
+ // relative to the workspace root, since a profile is a portable shape; a
48
+ // repository kept elsewhere on this machine is recorded by name only.
49
+ export function profileFor({ project, workspaceId, audienceAllow }) {
50
+ const repositories = (project.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => {
51
+ const relative = posix(path.relative(project.workspaceRoot, repo.path))
52
+ const inside = relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
53
+ return { repoId: repo.name, root: relative === '' ? '.' : inside ? relative : `elsewhere/${repo.name}`, enrollment: 'enrolled' }
54
+ })
55
+ return { schema: 'atelier-obsidian-corpus-profile/v1', workspaceId, repositories, audience: { allow: [...audienceAllow] } }
56
+ }
57
+
58
+ // `cache` (createGraphFileCache) is the engine's per-file census cache; without it every source is parsed again.
59
+ // `index` is the engine's observation index. With both, a source whose observed digest is the cached one is not
60
+ // opened: observation already decided by digest that it did not change, under the stat-hint bound observation
61
+ // documents, and a full reconciliation hashes it again. Without an index every source is read and hashed here.
62
+ export function buildGraph({ project, eligibility, cache = null, index = null }) {
63
+ const observedDigest = cache && index ? (repoId, relative) => index.get(sourceKey(repoId, relative))?.digest ?? null : null
64
+ const canonical = buildCanonicalGraph(project, { fileCache: cache, observedDigest })
65
+ if (!canonical.ok) refuse('canonical-graph-invalid', 'the canonical graph has errors; no view is prepared from it', { errorCount: canonical.errors.length })
66
+ return withEligibility(canonical, eligibility.isEligible, assetEligibilityFor({ graph: canonical, eligibility }))
67
+ }
68
+
69
+ // A source snapshot pinned to the digests observation decided on. prepareView
70
+ // reads every source it emits again and refuses with `mixed-read` when the
71
+ // bytes it gets are not the pinned ones, so an emitted note never carries a
72
+ // stale digest. A note reused from the preparation cache is not read again:
73
+ // its pinned digest is unchanged and the cached bytes are the ones that digest
74
+ // describes, so bytes that drift under an unchanged pin are not consulted
75
+ // until observation hashes the file again (the same bound as the graph stage).
76
+ export function captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt }) {
77
+ const roots = new Map((project.repos ?? []).filter((repo) => !repo.external).map((repo) => [repo.name, repo.path]))
78
+ const absolute = (repoId, relative) => {
79
+ const root = roots.get(repoId)
80
+ if (!root) refuse('source-not-in-snapshot', 'a node names a repository the project does not enrol')
81
+ return path.join(root, ...relative.split('/'))
82
+ }
83
+ const byRepo = new Map([...roots.keys()].map((repoId) => [repoId, new Map()]))
84
+ // Every node, and every asset a view may copy. A withheld asset is never pinned: it is in no snapshot document.
85
+ const pinned = [...graph.nodes, ...(Array.isArray(graph.assets) ? graph.assets.filter((asset) => asset.eligible === true) : [])]
86
+ for (const item of pinned) {
87
+ if (!byRepo.has(item.repo)) refuse('source-not-in-snapshot', 'the graph names a repository the project does not enrol')
88
+ const observed = index.get(sourceKey(item.repo, item.path))
89
+ const facts = observed?.digest ? observed : readFileFacts(absolute(item.repo, item.path))
90
+ if (!facts) refuse('mixed-read', 'a file the graph read is no longer there')
91
+ byRepo.get(item.repo).set(item.path, { path: item.path, rawDigest: facts.digest, byteLength: facts.byteLength })
92
+ }
93
+ const repositories = [...byRepo].sort(([left], [right]) => compareText(left, right))
94
+ .map(([repoId, files]) => ({ repoId, head: null, dirty: true, files: [...files.values()].sort((left, right) => compareText(left.path, right.path)) }))
95
+ // Embeds and assets are part of what was read: an embed that changes alone yields another snapshot.
96
+ const graphDigest = sha256Digest(Buffer.from(JSON.stringify([graph.nodes, graph.edges, graph.embeds ?? [], graph.assets ?? []])))
97
+ const identity = createHash('sha256').update(JSON.stringify([workspaceId, graphDigest, configDigest, repositories])).digest('hex').slice(0, 32)
98
+ return {
99
+ document: {
100
+ schema: 'atelier-obsidian-source-snapshot/v1',
101
+ contractVersion: '1.0.0',
102
+ snapshotId: `snap-${identity}`,
103
+ workspaceId,
104
+ capturedAt,
105
+ readConsistency: 'single-read',
106
+ graphPin: { graphDigest, nodeCount: graph.nodes.length, edgeCount: graph.edges.length },
107
+ versions: { source: '1', config: configDigest.slice('sha256:'.length, 'sha256:'.length + 16), emitter: EMITTER_VERSION },
108
+ repositories,
109
+ },
110
+ graph,
111
+ readSource: (repoId, relative) => readFileBytes(absolute(repoId, relative)),
112
+ }
113
+ }
114
+
115
+ // The engine keeps one graph file cache and one preparation cache per scope
116
+ // for as long as it runs, and hands them to buildGraph and prepareView on
117
+ // every tick, so a tick after a one-note change parses that source and emits
118
+ // that note and reuses the rest. Both caches are derived, in-memory state: a
119
+ // test may replace either seam with `() => null` to build or prepare in full.
120
+ export function createProductionSeams() {
121
+ return { buildGraph, captureSnapshot, profileFor, prepareView, publishView, createRecoveryStore, recheckDisplacedFiles, createGraphCache: createGraphFileCache, createPreparationCache }
122
+ }