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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (383) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +86 -25
  3. package/SECURITY.md +6 -1
  4. package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
  5. package/contracts/atelier-coauthor.v1.schema.json +163 -0
  6. package/contracts/atelier-decision-request.v1.schema.json +476 -0
  7. package/contracts/atelier-decision-result.v1.schema.json +500 -0
  8. package/contracts/atelier-guide.v1.schema.json +227 -0
  9. package/contracts/atelier-intake.v1.schema.json +151 -0
  10. package/contracts/atelier-migration.v2.schema.json +61 -0
  11. package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
  12. package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
  13. package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
  14. package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
  15. package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
  16. package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
  17. package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
  18. package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
  19. package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
  20. package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
  21. package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
  22. package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
  23. package/contracts/atelier-presentation.v1.schema.json +1293 -0
  24. package/contracts/atelier-repository-observation.v1.schema.json +163 -0
  25. package/contracts/atelier-review.v1.schema.json +1601 -0
  26. package/contracts/atelier-skill-steward.v1.schema.json +692 -0
  27. package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
  28. package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
  29. package/contracts/atelier-vault-publication.v1.schema.json +58 -0
  30. package/docs/assurance-controls.md +12 -2
  31. package/docs/atelier-runtime.md +13 -2
  32. package/docs/atelier-sync.md +172 -0
  33. package/docs/blocks/claims.md +28 -18
  34. package/docs/blocks/will-not-do.md +12 -3
  35. package/docs/coauthor-session.md +57 -0
  36. package/docs/connected-composition.md +75 -0
  37. package/docs/decisions.md +167 -0
  38. package/docs/exact-upgrades.md +159 -0
  39. package/docs/extension-pack-lifecycle.md +33 -0
  40. package/docs/guided-upgrades.md +99 -0
  41. package/docs/install.md +65 -9
  42. package/docs/intake-and-guides.md +65 -0
  43. package/docs/integration-and-review-plan.md +409 -0
  44. package/docs/integration-contract-decisions.md +22 -0
  45. package/docs/local-review.md +84 -0
  46. package/docs/local-services.md +227 -0
  47. package/docs/local-state.md +107 -0
  48. package/docs/obsidian-contract.md +687 -0
  49. package/docs/obsidian.md +387 -0
  50. package/docs/portable-presentation.md +328 -0
  51. package/docs/presentation-consumer-boundaries.md +75 -0
  52. package/docs/presentation-review-disposition.md +47 -0
  53. package/docs/project-command-inventory.md +38 -0
  54. package/docs/project-options.md +53 -0
  55. package/docs/release-engineering.md +99 -2
  56. package/docs/review-portability.md +55 -0
  57. package/docs/skill-steward.md +156 -0
  58. package/docs/upgrade.md +70 -2
  59. package/docs/vault-service.md +292 -0
  60. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  61. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  62. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  63. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  64. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  65. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  66. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  67. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  68. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  69. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  70. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  71. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  72. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  73. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  74. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  75. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  76. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  77. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  78. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  79. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  80. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  81. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  82. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  83. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  84. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  85. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  86. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  87. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  88. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  89. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  90. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  91. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  92. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  93. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  94. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  95. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  96. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  97. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  98. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  99. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  100. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  101. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  102. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  103. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  104. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  105. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  106. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  107. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  108. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  109. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  110. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  111. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  112. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  113. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  114. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  115. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  116. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  117. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  118. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  119. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  120. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  121. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  122. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  123. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  124. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  125. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  126. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  127. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  128. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  129. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  130. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  131. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  132. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  133. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  134. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  135. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  136. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  137. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  138. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  139. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  140. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  141. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  142. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  143. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  144. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  145. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  146. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  147. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  148. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  149. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  150. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  151. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  152. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  153. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  154. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  155. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  156. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  157. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  158. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  159. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  160. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  161. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  162. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  163. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  164. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  165. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  166. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  167. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  168. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  169. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  170. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  171. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  172. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  173. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  174. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  175. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  176. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  177. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  178. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  179. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  180. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  181. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  182. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  183. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  184. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  185. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  186. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  187. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  188. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  189. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  190. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  191. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  192. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  193. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  194. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  195. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  196. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  197. package/fixtures/obsidian/edits/workspace.json +65 -0
  198. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  199. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  200. package/fixtures/obsidian/materialization/workspace.json +54 -0
  201. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  202. package/fixtures/ui/presentation/pane.v1.json +7 -0
  203. package/fixtures/ui/presentation/reference.v1.json +293 -0
  204. package/package.json +61 -6
  205. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  206. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  207. package/skills/claude/atelier-open/SKILL.md +5 -0
  208. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  209. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  210. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  211. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  212. package/skills/codex/atelier-open/SKILL.md +5 -0
  213. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  214. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  215. package/src/access/preview-enforcer.mjs +84 -0
  216. package/src/boundary/content-rules.mjs +9 -4
  217. package/src/boundary/policy.mjs +74 -42
  218. package/src/cli/project-options.mjs +58 -0
  219. package/src/cli/run.mjs +61 -3
  220. package/src/coauthor/session.mjs +161 -0
  221. package/src/coauthor/store.mjs +173 -0
  222. package/src/collaboration/event-ledger.mjs +16 -33
  223. package/src/collaboration/inspection-bundle.mjs +223 -0
  224. package/src/collaboration/review-contracts.mjs +25 -0
  225. package/src/collaboration/review-store.mjs +305 -0
  226. package/src/commands/coauthor.mjs +26 -0
  227. package/src/commands/config.mjs +6 -1
  228. package/src/commands/extension-pack.mjs +15 -8
  229. package/src/commands/init.mjs +44 -5
  230. package/src/commands/obsidian.mjs +368 -0
  231. package/src/commands/review.mjs +147 -0
  232. package/src/commands/setup.mjs +51 -14
  233. package/src/commands/skills.mjs +199 -0
  234. package/src/commands/sync.mjs +107 -0
  235. package/src/composition/registry.mjs +27 -0
  236. package/src/composition/wire.mjs +122 -0
  237. package/src/contracts/corpus.mjs +35 -0
  238. package/src/decisions/contracts.d.mts +96 -0
  239. package/src/decisions/contracts.mjs +321 -0
  240. package/src/disclosure/content-scan.mjs +13 -0
  241. package/src/export/atelier-export-contract.mjs +16 -1
  242. package/src/extension-packs/lifecycle.mjs +154 -0
  243. package/src/graph/graph.mjs +64 -2
  244. package/src/graph/knowledge-graph.mjs +548 -24
  245. package/src/guides/contracts.mjs +54 -0
  246. package/src/index.mjs +29 -0
  247. package/src/intake/store.mjs +119 -0
  248. package/src/preview/channel.mjs +89 -0
  249. package/src/project/config.mjs +50 -49
  250. package/src/project/durable-state.mjs +161 -0
  251. package/src/project/file-class.mjs +166 -0
  252. package/src/project/private-state.mjs +11 -1
  253. package/src/projection/obsidian/contracts.mjs +571 -0
  254. package/src/projection/obsidian/edits/align.mjs +169 -0
  255. package/src/projection/obsidian/edits/apply.mjs +889 -0
  256. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  257. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  258. package/src/projection/obsidian/edits/index.mjs +19 -0
  259. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  260. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  261. package/src/projection/obsidian/edits/observe.mjs +123 -0
  262. package/src/projection/obsidian/edits/policy.mjs +123 -0
  263. package/src/projection/obsidian/edits/regions.mjs +648 -0
  264. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  265. package/src/projection/obsidian/materialize/index.mjs +7 -0
  266. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  267. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  268. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  269. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  270. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  271. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  272. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  273. package/src/projection/obsidian/proposals/index.mjs +23 -0
  274. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  275. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  276. package/src/projection/obsidian/proposals/router.mjs +168 -0
  277. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  278. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  279. package/src/projection/obsidian/publication/index.mjs +5 -0
  280. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  281. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  282. package/src/projection/obsidian/publication/transport.mjs +139 -0
  283. package/src/projection/obsidian/recovery/index.mjs +6 -0
  284. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  285. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  286. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  287. package/src/projection/obsidian/recovery/store.mjs +234 -0
  288. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  289. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  290. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  291. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  292. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  293. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  294. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  295. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  296. package/src/projection/project.mjs +28 -19
  297. package/src/readiness-protocols/evidence.mjs +282 -0
  298. package/src/readiness-protocols/runtime.mjs +4 -3
  299. package/src/readiness-protocols/source-read.mjs +39 -0
  300. package/src/runtime/git-adapter.mjs +189 -0
  301. package/src/runtime/local-state.mjs +439 -0
  302. package/src/runtime/obsidian/app-capability.mjs +110 -0
  303. package/src/runtime/obsidian/app-production-seams.mjs +96 -0
  304. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  305. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  306. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  307. package/src/runtime/obsidian/contributions.mjs +27 -0
  308. package/src/runtime/obsidian/documents.mjs +37 -0
  309. package/src/runtime/obsidian/enablement.mjs +53 -0
  310. package/src/runtime/obsidian/engine.mjs +532 -0
  311. package/src/runtime/obsidian/errors.mjs +14 -0
  312. package/src/runtime/obsidian/extension-points.mjs +103 -0
  313. package/src/runtime/obsidian/index.mjs +34 -0
  314. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  315. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  316. package/src/runtime/obsidian/observation.mjs +158 -0
  317. package/src/runtime/obsidian/opening.mjs +243 -0
  318. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  319. package/src/runtime/obsidian/pipeline.mjs +122 -0
  320. package/src/runtime/obsidian/private-lock.mjs +156 -0
  321. package/src/runtime/obsidian/process-identity.mjs +58 -0
  322. package/src/runtime/obsidian/service-client.mjs +66 -0
  323. package/src/runtime/obsidian/service-main.mjs +89 -0
  324. package/src/runtime/obsidian/service-record.mjs +169 -0
  325. package/src/runtime/obsidian/service-server.mjs +121 -0
  326. package/src/runtime/obsidian/service.mjs +216 -0
  327. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  328. package/src/runtime/obsidian/state-store.mjs +180 -0
  329. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  330. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  331. package/src/runtime/obsidian/watchers.mjs +36 -0
  332. package/src/runtime/repository-observation.mjs +491 -0
  333. package/src/runtime/supervisor.mjs +803 -0
  334. package/src/server/local-sidecar.mjs +44 -0
  335. package/src/server/server.mjs +1 -0
  336. package/src/skills/steward.mjs +780 -0
  337. package/src/ui/presentation/browser.mjs +278 -0
  338. package/src/ui/presentation/contract.mjs +116 -0
  339. package/src/ui/presentation/index.mjs +7 -0
  340. package/src/ui/presentation/native.mjs +153 -0
  341. package/src/ui/presentation/proof.mjs +57 -0
  342. package/src/ui/presentation/schema-check.mjs +30 -0
  343. package/src/ui/presentation/schema.generated.mjs +1371 -0
  344. package/src/ui/presentation/state.mjs +43 -0
  345. package/src/ui/presentation/styles.mjs +67 -0
  346. package/src/ui/presentation/tokens.mjs +74 -0
  347. package/src/ui/presentation/web.mjs +85 -0
  348. package/src/ui/review-page.mjs +32 -0
  349. package/src/upgrade/explanation.mjs +33 -0
  350. package/src/upgrade/provenance.mjs +338 -0
  351. package/src/upgrade/transaction-files.mjs +103 -0
  352. package/src/upgrade/transaction.mjs +475 -0
  353. package/src/upgrade/upgrade.mjs +63 -28
  354. package/src/vault/hosts.mjs +21 -0
  355. package/src/vault/identity.mjs +19 -0
  356. package/src/vault/index.mjs +7 -0
  357. package/src/vault/interface.mjs +11 -0
  358. package/src/vault/metadata.mjs +31 -0
  359. package/src/vault/privacy.mjs +148 -0
  360. package/src/vault/probe.mjs +89 -0
  361. package/src/vault/service.mjs +131 -0
  362. package/src/vault/source.mjs +45 -0
  363. package/src/vault/storage.mjs +23 -0
  364. package/templates/distribution-workspace/.claude/launch.json +6 -2
  365. package/templates/distribution-workspace/gitignore +1 -0
  366. package/templates/distribution-workspace/project/README.md +2 -2
  367. package/templates/external-project-workspace/README.md +45 -0
  368. package/templates/external-project-workspace/answers.example.json +4 -0
  369. package/templates/external-project-workspace/atelier.project.json +40 -0
  370. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  371. package/templates/external-project-workspace/gitignore +8 -0
  372. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  373. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  374. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  375. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  376. package/templates/external-project-workspace/source/README.md +11 -0
  377. package/templates/launch.json +6 -2
  378. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  379. package/templates/private-domain-workspace/domain/README.md +2 -2
  380. package/templates/private-domain-workspace/gitignore +1 -0
  381. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  382. package/templates/shared-project-workspace/gitignore +1 -0
  383. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,414 @@
1
+ import { canonicalJson } from '../../../runtime/obsidian/documents.mjs'
2
+ import { OBSIDIAN_EXT_KEY } from '../contracts.mjs'
3
+ import { isIdentifier } from './object-identity.mjs'
4
+
5
+ // Arbitration of the edits of one object, as a pure function of its event
6
+ // list. Nothing here reads a clock, a file or a process table: equal events
7
+ // give an equal result, which is what makes every index a cache.
8
+ //
9
+ // The rules, in the order they are applied to a new body replacement:
10
+ //
11
+ // 1. Equal idempotency key (equal base and equal edited bytes): one logical
12
+ // operation; every origin is recorded.
13
+ // 2. A base that is not the source digest the object is known to have now
14
+ // is stale. Its result already being the present source makes it
15
+ // `superseded` (nothing is left to apply); otherwise it is `conflicted`.
16
+ // The present digest is the last fact recorded under the object lease
17
+ // (`applied`, or a refusal that read the source); before any such fact it
18
+ // is what the observer said it read.
19
+ // 3. An operation that names the operations it resolves, whose base is the
20
+ // present digest and which names every conflicted operation, supersedes
21
+ // the ones it names and is pending. A resolution that cannot be checked
22
+ // against a present digest, or that leaves a conflict unnamed, is itself
23
+ // conflicted. Rule 2 is applied first and is never overridden.
24
+ // 4. While any operation is conflicted, a new one joins the conflict.
25
+ // 5. Open operations that would not produce the same source from the same
26
+ // base are all conflicted, whatever order, view or process they came
27
+ // from. Nothing is dropped and no winner is chosen.
28
+ //
29
+ // Operations that arrive as `proposed` or `refused` are recorded and take no
30
+ // part; one that arrives `conflicted` (the lens found its base stale) is
31
+ // retained as a conflict.
32
+
33
+ const EXT = OBSIDIAN_EXT_KEY
34
+ export const OBJECT_EVENT_SCHEMA = 'atelier-obsidian-object-event/v1'
35
+ export const OBJECT_EVENT_TYPES = Object.freeze(['observed', 'coalesced', 'conflict-declared', 'superseded', 'lease-acquired', 'lease-released', 'apply-intent', 'applied', 'apply-refused', 'siblings-stale'])
36
+ export const ARBITRATION_REASONS = Object.freeze(['stale-base', 'divergent-edits', 'divergent-bases', 'object-conflicted', 'resolution-incomplete', 'resolution-unverifiable', 'already-applied', 'resolved'])
37
+ export const REFUSED_DISPOSITIONS = Object.freeze(['retained', 'conflicted', 'refused'])
38
+
39
+ export class EditArbitrationRefusal extends Error {
40
+ constructor(code, message, detail = {}) {
41
+ super(`${code}: ${message}`)
42
+ this.name = 'EditArbitrationRefusal'
43
+ this.code = code
44
+ this.detail = detail
45
+ }
46
+ }
47
+
48
+ export function refuseArbitration(code, message, detail) {
49
+ throw new EditArbitrationRefusal(code, message, detail)
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Documents. Every shape is closed and every string is an identifier, a
54
+ // digest, a store reference, a code or a timestamp.
55
+
56
+ const DIGEST = /^sha256:[0-9a-f]{64}$/
57
+ const KEY = /^op-[0-9a-f]{64}$/
58
+ const NONCE = /^[0-9a-f]{32}$/
59
+ const MACHINE = /^[0-9a-f]{64}$/
60
+ const CODE = /^[a-z][a-z0-9-]{0,63}$/
61
+ const REFERENCE = /^recovery\/objects\/[0-9a-f]{64}\.bin$/
62
+ // A file of one source apply: the displaced source, or bytes found where they were not expected.
63
+ const APPLY_REFERENCE = /^recovery\/[A-Za-z0-9._-]{1,128}\/\d{6}\/[A-Za-z0-9._-]{1,64}\.bin$/
64
+ const TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/
65
+ const VERSION = /^\d+\.\d+\.\d+$/
66
+
67
+ const is = {
68
+ digest: (value) => typeof value === 'string' && DIGEST.test(value),
69
+ key: (value) => typeof value === 'string' && KEY.test(value),
70
+ nonce: (value) => typeof value === 'string' && NONCE.test(value),
71
+ machine: (value) => typeof value === 'string' && MACHINE.test(value),
72
+ code: (value) => typeof value === 'string' && CODE.test(value),
73
+ reference: (value) => typeof value === 'string' && REFERENCE.test(value),
74
+ retained: (value) => typeof value === 'string' && (REFERENCE.test(value) || APPLY_REFERENCE.test(value)),
75
+ time: (value) => typeof value === 'string' && TIME.test(value) && !Number.isNaN(Date.parse(value)),
76
+ version: (value) => typeof value === 'string' && VERSION.test(value),
77
+ identifier: isIdentifier,
78
+ count: (value) => Number.isSafeInteger(value) && value >= 0,
79
+ positive: (value) => Number.isSafeInteger(value) && value > 0,
80
+ }
81
+ const nullable = (check) => (value) => value === null || check(value)
82
+ const oneOf = (values) => (value) => values.includes(value)
83
+ const listOf = (check, { max = 4096, sortedBy = null } = {}) => (value) => Array.isArray(value) && value.length <= max && value.every(check)
84
+ && (sortedBy === null || value.every((item, index) => index === 0 || sortedBy(value[index - 1]) < sortedBy(item)))
85
+ const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
86
+ // A closed object: exactly the required keys plus any of the optional ones.
87
+ const shape = (required, optional = {}) => (value) => plain(value)
88
+ && Object.keys(value).every((key) => Object.hasOwn(required, key) || Object.hasOwn(optional, key))
89
+ && Object.entries(required).every(([key, check]) => Object.hasOwn(value, key) && check(value[key]))
90
+ && Object.entries(optional).every(([key, check]) => !Object.hasOwn(value, key) || check(value[key]))
91
+ const extOf = (check) => shape({ [EXT]: check })
92
+
93
+ const policy = (value) => shape({ mode: oneOf(['manual']) })(value) || shape({ mode: oneOf(['automatic']), policyDigest: is.digest }, { policyId: is.identifier })(value)
94
+
95
+ // The recorded form of an edit operation: the frozen contract's required
96
+ // members and, under the extension key, digests and references only. The
97
+ // vault-relative note path of the original document is readable text and is
98
+ // not recorded; the note is found again from the origin and the identity.
99
+ const recordedOperation = shape({
100
+ schema: oneOf(['atelier-obsidian-edit-operation/v1']),
101
+ editId: is.identifier,
102
+ workspaceId: is.identifier,
103
+ repoId: is.identifier,
104
+ nodeId: is.identifier,
105
+ origin: shape({ scopeId: is.identifier, generationId: is.identifier }, { ext: extOf(shape({}, { baseNoteDigest: is.digest, publishedNoteDigest: nullable(is.digest) })) }),
106
+ kind: oneOf(['body-replacement', 'semantic-proposal']),
107
+ baseSourceDigest: is.digest,
108
+ observed: shape({ digest: is.digest, byteLength: is.count, recoveryRef: is.reference }),
109
+ idempotencyKey: is.key,
110
+ state: oneOf(['pending', 'proposed', 'conflicted', 'refused']),
111
+ }, {
112
+ contractVersion: is.version,
113
+ observedAt: is.time,
114
+ ext: extOf(shape({}, { lensVersion: is.version, publishedNoteRef: is.reference, baseSourceRef: is.reference, currentSourceDigest: is.digest, newSourceDigest: is.digest, newByteLength: is.count, refusalCode: is.code })),
115
+ })
116
+
117
+ const observation = shape({ operation: recordedOperation, presentSourceDigest: nullable(is.digest), resolves: listOf(is.key, { max: 256, sortedBy: (key) => key }) })
118
+ // A reason is one of ARBITRATION_REASONS or the code of an apply refusal.
119
+ const declared = () => listOf(shape({ idempotencyKey: is.key, reason: is.code }, { by: nullable(is.key) }), { sortedBy: (item) => item.idempotencyKey })
120
+ const viewKey = (view) => `${view.scopeId}\u0000${view.generationId}`
121
+
122
+ const BODIES = {
123
+ observed: observation,
124
+ coalesced: observation,
125
+ 'conflict-declared': shape({ operations: declared() }),
126
+ superseded: shape({ operations: declared() }),
127
+ 'lease-acquired': shape({
128
+ lease: shape({ nonce: is.nonce, pid: is.positive, machine: is.machine, runtimeId: nullable(is.identifier), acquiredAt: is.time }),
129
+ takeover: nullable(shape({ nonce: is.nonce, reason: is.code })),
130
+ }),
131
+ 'lease-released': shape({ nonce: is.nonce }),
132
+ // `applyId` names the write-ahead record and the recovery directory of one source apply; `backupRef` the displaced
133
+ // source it retained; `recoveryRefs` every file or object a refused apply retained.
134
+ 'apply-intent': shape({ nonce: is.nonce, idempotencyKey: is.key, expectedSourceDigest: is.digest, newSourceDigest: is.digest, actor: is.identifier, policy }, { applyId: is.identifier }),
135
+ applied: shape({ nonce: is.nonce, idempotencyKey: is.key, oldSourceDigest: is.digest, newSourceDigest: is.digest, actor: is.identifier, policy }, { applyId: is.identifier, backupRef: is.retained }),
136
+ 'apply-refused': shape({ nonce: is.nonce, idempotencyKey: is.key, code: is.code, presentSourceDigest: nullable(is.digest), disposition: oneOf(REFUSED_DISPOSITIONS) },
137
+ { applyId: is.identifier, policy, recoveryRefs: listOf(is.retained, { max: 16 }) }),
138
+ 'siblings-stale': shape({
139
+ appliedSequence: is.positive,
140
+ newSourceDigest: is.digest,
141
+ views: listOf(shape({ scopeId: is.identifier, generationId: is.identifier, role: oneOf(['origin', 'sibling']) }), { sortedBy: viewKey }),
142
+ }),
143
+ }
144
+
145
+ const envelope = shape({
146
+ schema: oneOf([OBJECT_EVENT_SCHEMA]),
147
+ workspaceId: is.identifier,
148
+ repoId: is.identifier,
149
+ nodeId: is.identifier,
150
+ sequence: is.positive,
151
+ previous: nullable(is.digest),
152
+ type: oneOf(OBJECT_EVENT_TYPES),
153
+ recordedAt: is.time,
154
+ body: plain,
155
+ })
156
+
157
+ export function isObjectEvent(document) {
158
+ if (!envelope(document) || !BODIES[document.type](document.body)) return false
159
+ if (document.type !== 'observed' && document.type !== 'coalesced') return true
160
+ const { operation } = document.body
161
+ return operation.workspaceId === document.workspaceId && operation.repoId === document.repoId && operation.nodeId === document.nodeId
162
+ }
163
+
164
+ // What of an edit operation document is recorded. The caller has validated
165
+ // the document against the frozen contract.
166
+ export function recordableOperation(operation) {
167
+ const ext = operation.ext?.[EXT] ?? {}
168
+ const originExt = operation.origin.ext?.[EXT] ?? {}
169
+ const kept = {}
170
+ for (const name of ['lensVersion', 'publishedNoteRef', 'baseSourceRef', 'currentSourceDigest']) if (ext[name] !== undefined) kept[name] = ext[name]
171
+ if (ext.result?.newSourceDigest !== undefined) { kept.newSourceDigest = ext.result.newSourceDigest; kept.newByteLength = ext.result.newByteLength }
172
+ if (ext.refusal?.code !== undefined) kept.refusalCode = ext.refusal.code
173
+ const keptOrigin = {}
174
+ for (const name of ['baseNoteDigest', 'publishedNoteDigest']) if (originExt[name] !== undefined) keptOrigin[name] = originExt[name]
175
+ const recorded = {
176
+ schema: operation.schema,
177
+ ...(operation.contractVersion === undefined ? {} : { contractVersion: operation.contractVersion }),
178
+ editId: operation.editId,
179
+ workspaceId: operation.workspaceId,
180
+ repoId: operation.repoId,
181
+ nodeId: operation.nodeId,
182
+ origin: { scopeId: operation.origin.scopeId, generationId: operation.origin.generationId, ...(Object.keys(keptOrigin).length > 0 ? { ext: { [EXT]: keptOrigin } } : {}) },
183
+ kind: operation.kind,
184
+ baseSourceDigest: operation.baseSourceDigest,
185
+ observed: { digest: operation.observed.digest, byteLength: operation.observed.byteLength, recoveryRef: operation.observed.recoveryRef },
186
+ idempotencyKey: operation.idempotencyKey,
187
+ state: operation.state,
188
+ ...(operation.observedAt === undefined ? {} : { observedAt: operation.observedAt }),
189
+ ...(Object.keys(kept).length > 0 ? { ext: { [EXT]: kept } } : {}),
190
+ }
191
+ return recordedOperation(recorded) ? recorded : null
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // The rules that differ between a correct arbiter and the broken ones the
196
+ // tests substitute. Each returns what becomes of the operations involved.
197
+
198
+ export const ARBITRATION_RULES = Object.freeze({
199
+ // `incoming` would not produce the same source from the same base as `open`.
200
+ divergent: ({ incoming, open }) => {
201
+ const reason = open.every((other) => other.baseSourceDigest === incoming.baseSourceDigest) ? 'divergent-edits' : 'divergent-bases'
202
+ return [incoming, ...open].map((entry) => ({ entry, state: 'conflicted', reason }))
203
+ },
204
+ // The base of `incoming` is not the present source digest.
205
+ stale: ({ incoming }) => [{ entry: incoming, state: 'conflicted', reason: 'stale-base' }],
206
+ })
207
+
208
+ const OPEN = new Set(['pending', 'conflicted'])
209
+ // Equal whatever order the keys were written in.
210
+ const sameDocument = (left, right) => canonicalJson(left) === canonicalJson(right)
211
+ const sameList = (left, right) => left.length === right.length && left.every((item, index) => item === right[index])
212
+ const equivalent = (left, right) => left.baseSourceDigest === right.baseSourceDigest && left.newSourceDigest !== null && left.newSourceDigest === right.newSourceDigest
213
+
214
+ function createState() {
215
+ return { identity: null, sequence: 0, operations: new Map(), sourceDigest: null, sourceFrom: null, hint: null, lastAppliedKey: null, lease: null, intent: null, applies: [], outcomes: [], declared: new Map(), violations: [] }
216
+ }
217
+
218
+ function assign(state, { entry, state: next, reason = null, by = null, drop = false }) {
219
+ // Only a broken arbiter of the tests drops anything.
220
+ if (drop) { state.operations.delete(entry.idempotencyKey); return }
221
+ if (entry.state !== next) state.declared.delete(entry.idempotencyKey)
222
+ Object.assign(entry, { state: next, reason, by })
223
+ }
224
+
225
+ function place(state, entry, resolves, rules) {
226
+ const present = state.sourceDigest ?? state.hint
227
+ const others = [...state.operations.values()].filter((other) => other !== entry)
228
+ if (present !== null && entry.baseSourceDigest !== present) {
229
+ if (entry.newSourceDigest === present) return assign(state, { entry, state: 'superseded', reason: 'already-applied', by: state.sourceFrom })
230
+ for (const outcome of rules.stale({ incoming: entry, present })) assign(state, outcome)
231
+ return undefined
232
+ }
233
+ const conflicted = others.filter((other) => other.state === 'conflicted')
234
+ if (resolves.length > 0) {
235
+ if (present === null) return assign(state, { entry, state: 'conflicted', reason: 'resolution-unverifiable' })
236
+ const named = new Set(resolves)
237
+ if (named.has(entry.idempotencyKey) || resolves.some((key) => !state.operations.has(key)) || conflicted.some((other) => !named.has(other.idempotencyKey))) {
238
+ return assign(state, { entry, state: 'conflicted', reason: 'resolution-incomplete' })
239
+ }
240
+ for (const other of others) if (named.has(other.idempotencyKey) && OPEN.has(other.state)) assign(state, { entry: other, state: 'superseded', reason: 'resolved', by: entry.idempotencyKey })
241
+ } else if (conflicted.length > 0) {
242
+ return assign(state, { entry, state: 'conflicted', reason: 'object-conflicted' })
243
+ }
244
+ const open = others.filter((other) => other.state === 'pending' && !equivalent(other, entry))
245
+ if (open.length > 0) { for (const outcome of rules.divergent({ incoming: entry, open })) assign(state, outcome); return undefined }
246
+ return assign(state, { entry, state: 'pending' })
247
+ }
248
+
249
+ // The source is now `digest`: what was open against another base is settled.
250
+ function sourceMoved(state, digest, byKey, rules) {
251
+ state.sourceDigest = digest
252
+ state.sourceFrom = byKey
253
+ for (const entry of state.operations.values()) {
254
+ if (!OPEN.has(entry.state) || entry.baseSourceDigest === digest) continue
255
+ if (entry.newSourceDigest === digest) assign(state, { entry, state: 'superseded', reason: 'already-applied', by: byKey })
256
+ else if (entry.state === 'pending') for (const outcome of rules.stale({ incoming: entry, present: digest })) assign(state, outcome)
257
+ }
258
+ }
259
+
260
+ const undeclared = (state, which) => [...state.operations.values()]
261
+ .filter((entry) => entry.state === which && state.declared.get(entry.idempotencyKey) !== which)
262
+ .map((entry) => ({ idempotencyKey: entry.idempotencyKey, reason: entry.reason, ...(which === 'superseded' ? { by: entry.by } : {}) }))
263
+ .sort((left, right) => (left.idempotencyKey < right.idempotencyKey ? -1 : 1))
264
+
265
+ function viewsOf(state, appliedKey) {
266
+ const views = new Map()
267
+ for (const entry of state.operations.values()) {
268
+ for (const origin of entry.origins) {
269
+ const view = { scopeId: origin.scopeId, generationId: origin.generationId, role: entry.idempotencyKey === appliedKey ? 'origin' : 'sibling' }
270
+ if (views.get(viewKey(view))?.role !== 'origin') views.set(viewKey(view), view)
271
+ }
272
+ }
273
+ return [...views.values()].sort((left, right) => (viewKey(left) < viewKey(right) ? -1 : 1))
274
+ }
275
+
276
+ const HANDLERS = {
277
+ observed: observe,
278
+ coalesced: observe,
279
+ 'conflict-declared': (state, event) => declare(state, event, 'conflicted'),
280
+ superseded: (state, event) => declare(state, event, 'superseded'),
281
+ 'lease-acquired'(state, { body, sequence }) {
282
+ if (state.lease === null ? body.takeover !== null : body.takeover?.nonce !== state.lease.nonce) return state.lease === null ? 'takeover-without-holder' : 'lease-held'
283
+ state.lease = { ...body.lease, sequence, takeover: body.takeover }
284
+ return null
285
+ },
286
+ 'lease-released'(state, { body }) {
287
+ if (state.lease?.nonce !== body.nonce) return 'lease-not-held'
288
+ state.lease = null
289
+ return null
290
+ },
291
+ 'apply-intent'(state, { body, sequence }) {
292
+ if (state.lease?.nonce !== body.nonce) return 'lease-not-held'
293
+ if (state.intent !== null) return 'intent-unresolved'
294
+ const entry = state.operations.get(body.idempotencyKey)
295
+ if (entry === undefined) return 'unknown-operation'
296
+ if ([...state.operations.values()].some((other) => other.state === 'conflicted')) return 'object-conflicted'
297
+ if (entry.state !== 'pending' || entry.kind !== 'body-replacement') return 'operation-not-applicable'
298
+ if (body.expectedSourceDigest !== entry.baseSourceDigest || body.newSourceDigest !== entry.newSourceDigest) return 'digest-mismatch'
299
+ state.intent = { ...body, sequence }
300
+ return null
301
+ },
302
+ applied(state, { body, sequence }, rules) {
303
+ if (state.lease?.nonce !== body.nonce) return 'lease-not-held'
304
+ if (state.intent === null || state.intent.idempotencyKey !== body.idempotencyKey) return 'intent-missing'
305
+ if (body.oldSourceDigest !== state.intent.expectedSourceDigest || body.newSourceDigest !== state.intent.newSourceDigest) return 'digest-mismatch'
306
+ const entry = state.operations.get(body.idempotencyKey)
307
+ state.intent = null
308
+ assign(state, { entry, state: 'applied' })
309
+ state.lastAppliedKey = body.idempotencyKey
310
+ sourceMoved(state, body.newSourceDigest, body.idempotencyKey, rules)
311
+ state.applies.push({ appliedSequence: sequence, newSourceDigest: body.newSourceDigest, views: viewsOf(state, body.idempotencyKey), declared: false })
312
+ state.outcomes.push({ sequence, idempotencyKey: body.idempotencyKey, status: 'applied', code: 'applied', oldSourceDigest: body.oldSourceDigest, newSourceDigest: body.newSourceDigest, actor: body.actor, policy: body.policy, applyId: body.applyId ?? null, backupRef: body.backupRef ?? null })
313
+ return null
314
+ },
315
+ 'apply-refused'(state, { body, sequence }, rules) {
316
+ if (state.lease?.nonce !== body.nonce) return 'lease-not-held'
317
+ const entry = state.operations.get(body.idempotencyKey)
318
+ if (entry === undefined) return 'unknown-operation'
319
+ if (state.intent !== null && state.intent.idempotencyKey !== body.idempotencyKey) return 'intent-unresolved'
320
+ if (state.intent === null && !OPEN.has(entry.state)) return 'operation-not-applicable'
321
+ state.intent = null
322
+ state.outcomes.push({ sequence, idempotencyKey: body.idempotencyKey, status: 'refused', code: body.code, presentSourceDigest: body.presentSourceDigest, disposition: body.disposition, policy: body.policy ?? null, applyId: body.applyId ?? null, recoveryRefs: body.recoveryRefs ?? [] })
323
+ if (body.presentSourceDigest !== null) sourceMoved(state, body.presentSourceDigest, null, rules)
324
+ if (entry.state === 'pending' && body.disposition !== 'retained') assign(state, { entry, state: body.disposition, reason: body.code })
325
+ return null
326
+ },
327
+ 'siblings-stale'(state, { body }) {
328
+ const next = state.applies.find((apply) => !apply.declared)
329
+ if (next === undefined || next.appliedSequence !== body.appliedSequence || next.newSourceDigest !== body.newSourceDigest || !sameDocument(next.views, body.views)) return 'declaration-mismatch'
330
+ next.declared = true
331
+ return null
332
+ },
333
+ }
334
+
335
+ function observe(state, { type, body, sequence }, rules) {
336
+ const { operation, resolves } = body
337
+ const existing = state.operations.get(operation.idempotencyKey)
338
+ if ((type === 'observed') !== (existing === undefined)) return 'observation-kind-mismatch'
339
+ const origin = { scopeId: operation.origin.scopeId, generationId: operation.origin.generationId, editId: operation.editId, recoveryRef: operation.observed.recoveryRef, observedAt: operation.observedAt ?? null, sequence, resolves }
340
+ if (existing?.origins.some((known) => sameOrigin(known, origin))) return 'duplicate-origin'
341
+ const ext = operation.ext?.[EXT] ?? {}
342
+ if (operation.state === 'pending' && (operation.kind !== 'body-replacement' || ext.newSourceDigest === undefined)) return 'operation-unusable'
343
+ if (existing !== undefined && (existing.baseSourceDigest !== operation.baseSourceDigest || existing.observedDigest !== operation.observed.digest || existing.kind !== operation.kind)) return 'operation-identity-mismatch'
344
+ if (body.presentSourceDigest !== null) state.hint = body.presentSourceDigest
345
+ if (existing !== undefined) {
346
+ existing.origins.push(origin)
347
+ // A known operation offered again as the resolution of a conflict.
348
+ if (resolves.length > 0 && operation.state === 'pending' && OPEN.has(existing.state)) place(state, existing, resolves, rules)
349
+ return null
350
+ }
351
+ const entry = {
352
+ idempotencyKey: operation.idempotencyKey, kind: operation.kind, baseSourceDigest: operation.baseSourceDigest,
353
+ observedDigest: operation.observed.digest, observedByteLength: operation.observed.byteLength, newSourceDigest: ext.newSourceDigest ?? null,
354
+ state: operation.state, reason: null, by: null, firstSequence: sequence, origins: [origin],
355
+ }
356
+ state.operations.set(entry.idempotencyKey, entry)
357
+ if (operation.state === 'pending') place(state, entry, resolves, rules)
358
+ else if (operation.state === 'conflicted') entry.reason = 'stale-base'
359
+ else if (operation.state === 'refused') entry.reason = ext.refusalCode ?? null
360
+ return null
361
+ }
362
+
363
+ export const sameOrigin = (left, right) => left.scopeId === right.scopeId && left.generationId === right.generationId && left.editId === right.editId && sameList(left.resolves, right.resolves)
364
+
365
+ function declare(state, { body }, which) {
366
+ if (body.operations.length === 0 || !sameDocument(undeclared(state, which), body.operations)) return 'declaration-mismatch'
367
+ for (const item of body.operations) state.declared.set(item.idempotencyKey, which)
368
+ return null
369
+ }
370
+
371
+ function summarize(state) {
372
+ const operations = [...state.operations.values()].sort((left, right) => left.firstSequence - right.firstSequence)
373
+ const objectState = state.violations.length > 0 ? 'inconsistent'
374
+ : operations.some((entry) => entry.state === 'conflicted') ? 'conflicted'
375
+ : operations.some((entry) => entry.state === 'pending') ? 'pending'
376
+ : operations.length === 0 ? 'empty' : 'settled'
377
+ const pendingApply = state.applies.find((apply) => !apply.declared)
378
+ return {
379
+ identity: state.identity,
380
+ sequence: state.sequence,
381
+ state: objectState,
382
+ sourceDigest: state.sourceDigest ?? state.hint,
383
+ sourceDigestProven: state.sourceDigest !== null,
384
+ operations,
385
+ lease: state.lease,
386
+ // An intent with no outcome recorded: whether the source was written is
387
+ // not known here. Whoever holds the lease next reads the source and
388
+ // decides from its digest.
389
+ intent: state.intent === null ? null : { ...state.intent, status: 'outcome-unknown' },
390
+ siblingsStale: state.applies.map((apply) => ({ ...apply })),
391
+ // Every recorded outcome of an apply, oldest first: what a repeated request is answered from, and what a retry
392
+ // budget is counted over.
393
+ applyOutcomes: state.outcomes.map((outcome) => ({ ...outcome })),
394
+ undeclared: {
395
+ siblingsStale: pendingApply === undefined ? null : { appliedSequence: pendingApply.appliedSequence, newSourceDigest: pendingApply.newSourceDigest, views: pendingApply.views },
396
+ superseded: undeclared(state, 'superseded'),
397
+ conflicted: undeclared(state, 'conflicted'),
398
+ },
399
+ violations: state.violations,
400
+ }
401
+ }
402
+
403
+ // `events` are validated object events in sequence order. An event that the
404
+ // events before it do not allow changes nothing and is listed in `violations`.
405
+ export function arbitrateEvents(events, rules = ARBITRATION_RULES) {
406
+ const state = createState()
407
+ for (const event of events) {
408
+ state.identity ??= { workspaceId: event.workspaceId, repoId: event.repoId, nodeId: event.nodeId }
409
+ state.sequence = event.sequence
410
+ const violation = HANDLERS[event.type](state, event, rules)
411
+ if (violation) state.violations.push({ sequence: event.sequence, code: violation })
412
+ }
413
+ return summarize(state)
414
+ }
@@ -0,0 +1,108 @@
1
+ import path from 'node:path'
2
+ import { firstString, parseArgs, resolveProjectConfig } from '../../../project/config.mjs'
3
+ import { refuse } from '../../../runtime/obsidian/errors.mjs'
4
+ import { PublicationRefusal } from '../recovery/store.mjs'
5
+ import { SOURCE_APPLY_OPERATION_ID, SourceApplyRefusal, createSourceApply } from './apply.mjs'
6
+ import { EditArbitrationRefusal } from './arbitrate.mjs'
7
+
8
+ // Binds source apply to the two places that call it: the maintenance engine,
9
+ // which dispatches queued edits in automatic mode, and `atelier obsidian
10
+ // apply`, which is how a person or an agent acting for them asks for one edit
11
+ // by name. Both reach the same function; the engine says `automatic`, the
12
+ // command says `manual`.
13
+ //
14
+ // The engine hands an apply operation the edit and the policy and nothing
15
+ // about where it runs, so the operation finds its project the way the process
16
+ // that hosts the engine did: from `--project` and `--data-root` of that
17
+ // process, else from the working directory. A workspace other than the edit's
18
+ // own refuses.
19
+
20
+ const EXIT = Object.freeze({ ok: 0, notSuccess: 3 })
21
+ const EDIT_ID = /^edit-[0-9a-f]{32}$/
22
+ const ACTOR = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
23
+ const ENGINE_STATUSES = new Set(['applied', 'refused', 'conflict', 'failed'])
24
+
25
+ export function processApplyContext({ argv = process.argv.slice(2), env = process.env, cwd = process.cwd(), platform = process.platform } = {}) {
26
+ const args = parseArgs(argv)
27
+ const configPath = firstString(args.project)
28
+ const dataRoot = firstString(args['data-root']) ?? undefined
29
+ return {
30
+ loadProject: () => resolveProjectConfig({ argv: configPath ? [`--project=${path.resolve(cwd, configPath)}`] : [], cwd: configPath ? path.dirname(path.resolve(cwd, configPath)) : cwd, env, writeLocalState: false }),
31
+ ...(dataRoot === undefined ? {} : { dataRoot: path.resolve(cwd, dataRoot) }), env, platform,
32
+ }
33
+ }
34
+
35
+ // The engine looks at displaced files again on the tick of a dispatch and on later ones, and an applied source leaves
36
+ // a closed journal that names its backup, so the operation does not wait out a quiet period inside a tick.
37
+ export function createEngineApplyOperation({ context = processApplyContext, create = createSourceApply } = {}) {
38
+ return {
39
+ id: SOURCE_APPLY_OPERATION_ID,
40
+ async apply({ edit, policyDigest }) {
41
+ const sourceApply = create({ ...(typeof context === 'function' ? context() : context), quietPeriodMs: 0 })
42
+ const result = await sourceApply.apply({ editId: edit?.editId, mode: 'automatic', policyDigest })
43
+ return { status: ENGINE_STATUSES.has(result.status) ? result.status : 'failed', code: result.code }
44
+ },
45
+ }
46
+ }
47
+
48
+ const SUMMARY = 'list | show EDIT | run EDIT [--actor ID] | recover Pending edits and the explicit apply of one of them to its source file.'
49
+
50
+ export function createApplyCommandOperation({ create = createSourceApply } = {}) {
51
+ return {
52
+ name: 'apply',
53
+ summary: SUMMARY,
54
+ // The command's option table is shared; this declares that `apply` is the operation that takes `--actor`.
55
+ options: ['actor'],
56
+ async run({ args, flags, loadProject, dataRoot, env, platform, clock }) {
57
+ const [sub = 'list', editId, ...extra] = args
58
+ const sourceApply = create({ loadProject, ...(dataRoot === undefined ? {} : { dataRoot }), env, platform, clock })
59
+ // The refusals of the apply operation, of the object store and of the store of a view are typed refusals of
60
+ // this command too: an object log that cannot be read is an answer with a code, never an internal error.
61
+ const typed = async (operation) => {
62
+ try { return await operation() } catch (error) {
63
+ if (error instanceof SourceApplyRefusal) refuse(error.code, 'the apply operation refused', error.detail)
64
+ if (error instanceof EditArbitrationRefusal) refuse(error.code, 'the record of this object cannot be read; nothing was changed')
65
+ if (error instanceof PublicationRefusal) refuse(error.code, 'the store of this view refused; nothing was changed')
66
+ throw error
67
+ }
68
+ }
69
+ // The option table of the command is one table for every operation, so only `run` can say that it has no use for an actor.
70
+ if (sub !== 'run' && flags.actor !== undefined) refuse('usage', '--actor belongs to `apply run`')
71
+ const needsEdit = () => { if (typeof editId !== 'string' || !EDIT_ID.test(editId)) refuse('usage', 'name the edit: an identifier that `obsidian apply list` printed') }
72
+ if (sub === 'list') {
73
+ const edits = await typed(() => sourceApply.list())
74
+ return { exit: EXIT.ok, document: { edits }, human: edits.length === 0 ? ['no pending edit'] : edits.map((edit) => `${edit.editId}\t${edit.repoId}\t${edit.nodeId}\t${edit.state}\t${edit.object?.state ?? 'not-observed'}${edit.lastCode ? `\t${edit.lastCode}` : ''}`) }
75
+ }
76
+ if (sub === 'show') {
77
+ needsEdit()
78
+ const edit = await typed(() => sourceApply.show(editId))
79
+ return { exit: EXIT.ok, document: { edit }, human: [JSON.stringify(edit, null, 2)] }
80
+ }
81
+ if (sub === 'run') {
82
+ needsEdit()
83
+ if (extra.length > 0) refuse('usage', 'run takes one edit; name the actor with --actor ID')
84
+ const { actor } = flags
85
+ if (actor !== undefined && !ACTOR.test(actor)) refuse('usage', 'the actor must be an identifier')
86
+ const result = await typed(() => sourceApply.apply({ editId, mode: 'manual', ...(actor === undefined ? {} : { actor }) }))
87
+ const applied = result.status === 'applied'
88
+ return { exit: applied ? EXIT.ok : EXIT.notSuccess, document: { result }, human: [`${result.status}: ${result.code}`, ...(applied ? ['The source file changed; nothing was staged or committed.'] : ['Nothing was written to the source file.'])] }
89
+ }
90
+ if (sub === 'recover') {
91
+ const report = await typed(() => sourceApply.recover())
92
+ if (report.refusal) refuse(report.refusal.code, 'interrupted applies could not be looked at')
93
+ return { exit: EXIT.ok, document: report, human: report.recovered.length === 0 ? ['no interrupted apply'] : report.recovered.map((item) => `${item.applyId}\t${item.status}\t${item.code}`) }
94
+ }
95
+ return refuse('usage', 'apply takes list, show EDIT, run EDIT [--actor ID] or recover')
96
+ },
97
+ }
98
+ }
99
+
100
+ export function createSourceApplyContribution(options = {}) {
101
+ return {
102
+ id: 'atelier.source-apply',
103
+ register({ extensions, operations }) {
104
+ extensions.register('apply-operation', createEngineApplyOperation(options))
105
+ operations.register(createApplyCommandOperation(options))
106
+ },
107
+ }
108
+ }
@@ -0,0 +1,19 @@
1
+ // Edits made in a vault: the raw-byte lens that turns an edited note into
2
+ // exact source bytes or a typed refusal, and the observation that turns a
3
+ // pending edit record into an edit operation document, and the durable,
4
+ // object-keyed arbitration of those operations across views and processes.
5
+ // The lens, the observation and the arbitration write to no source file and to
6
+ // no vault. Source apply (apply.mjs) is the one operation that writes a source
7
+ // file, under the policy decision of policy.mjs.
8
+ export { ALIGN_LIMITS, alignBodies } from './align.mjs'
9
+ export {
10
+ DEFAULT_APPLY_QUIET_PERIOD_MS, MAX_MANUAL_BATCH_SIZE, SOURCE_APPLY_DIRECTORY, SOURCE_APPLY_OPERATION_ID, SOURCE_APPLY_PRIMITIVES, SOURCE_APPLY_PROTOCOL_ID, SOURCE_APPLY_RECORD_SCHEMA, SOURCE_APPLY_REFUSALS,
11
+ SOURCE_APPLY_STEPS, SourceApplyRefusal, createSourceApply, createSourceApplyForOracleTests,
12
+ } from './apply.mjs'
13
+ export { createApplyCommandOperation, createEngineApplyOperation, createSourceApplyContribution, processApplyContext } from './contribution.mjs'
14
+ export { APPLY_MODES, APPLY_POLICY_PRIMITIVES, APPLY_POLICY_REFUSALS, DEFAULT_MANUAL_ACTOR, applyPolicyDigest, canonicalApplyPolicy, createApplyPolicyForOracleTests, decideApply, withApplyPolicyDigest } from './policy.mjs'
15
+ export { ARBITRATION_REASONS, ARBITRATION_RULES, EditArbitrationRefusal, OBJECT_EVENT_SCHEMA, OBJECT_EVENT_TYPES, REFUSED_DISPOSITIONS, arbitrateEvents, isObjectEvent } from './arbitrate.mjs'
16
+ export { MAX_PLAIN_SEGMENT, decodeIdentitySegment, encodeIdentitySegment } from './object-identity.mjs'
17
+ export { EDIT_ACKNOWLEDGEMENT_SCHEMA, OBJECTS_DIRECTORY, OBJECT_INDEX_DIRECTORY, OBJECT_INDEX_SCHEMA, OBJECT_STORE_CRASH_STEPS, OBJECT_STORE_LIMITS, OBJECT_STORE_PRIMITIVES, createObjectStoreForOracleTests, openObjectStore } from './object-store.mjs'
18
+ export { EDIT_LENS_VERSION, EDIT_OBSERVATION_STEPS, createEditObserverForOracleTests, editIdempotencyKey, observeEdit } from './observe.mjs'
19
+ export { EDIT_LENS_PRIMITIVES, EDIT_LENS_REFUSALS, applyEditLens, assertRewritesAccounted, createEditLensForOracleTests, findStructuralLinks, placeUnits, splitGeneratedTail, splitPrefix } from './regions.mjs'
@@ -0,0 +1,61 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ // An object of the arbitration store is one source document of one repository
4
+ // of one workspace: (workspaceId, repoId, nodeId). The workspace is the state
5
+ // root; the repository and the node each become one directory name.
6
+ //
7
+ // An identifier may hold upper-case letters, '.', ':' and may spell a name a
8
+ // platform reserves, so it is never used as a name directly:
9
+ //
10
+ // k-<escaped> the whole identifier, reversible
11
+ // h-<escaped head>-<32 hex of sha256(identifier)> when the escaped form is long
12
+ //
13
+ // Escaping keeps [a-z0-9_-]; an upper-case letter becomes '~' and its lower
14
+ // case; any other character becomes '~' and two hex digits (every such code
15
+ // starts with a digit, so the two escapes cannot be confused). The result is
16
+ // lower case only, so two identifiers that differ by case never meet on a
17
+ // case-insensitive filesystem; the fixed prefix means no name is ever a
18
+ // reserved device name, and no name ends in a dot or a space. A hashed name is
19
+ // not reversible from the name alone: every event in the directory carries the
20
+ // identity, and the store checks that it encodes to the directory it is in.
21
+
22
+ export const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
23
+ export const MAX_PLAIN_SEGMENT = 64
24
+ const HEAD_LENGTH = 24
25
+
26
+ const escapeCharacter = (character) => {
27
+ if (/[a-z0-9_-]/.test(character)) return character
28
+ if (/[A-Z]/.test(character)) return `~${character.toLowerCase()}`
29
+ return `~${character.charCodeAt(0).toString(16).padStart(2, '0')}`
30
+ }
31
+
32
+ export const isIdentifier = (value) => typeof value === 'string' && IDENTIFIER_PATTERN.test(value)
33
+
34
+ export function encodeIdentitySegment(identifier) {
35
+ if (!isIdentifier(identifier)) throw new TypeError('an identity segment is made from an identifier')
36
+ const escaped = [...identifier].map(escapeCharacter).join('')
37
+ if (escaped.length <= MAX_PLAIN_SEGMENT) return `k-${escaped}`
38
+ const head = escaped.slice(0, HEAD_LENGTH).replace(/~[0-9a-f]?$/, '')
39
+ return `h-${head}-${createHash('sha256').update(identifier).digest('hex').slice(0, 32)}`
40
+ }
41
+
42
+ // The identifier of a `k-` name; null for a hashed name or for a name this
43
+ // module would not have produced.
44
+ export function decodeIdentitySegment(segment) {
45
+ if (typeof segment !== 'string' || !segment.startsWith('k-')) return null
46
+ const escaped = segment.slice(2)
47
+ let identifier = ''
48
+ for (let at = 0; at < escaped.length; at += 1) {
49
+ const character = escaped[at]
50
+ if (character !== '~') { identifier += character; continue }
51
+ const next = escaped[at + 1] ?? ''
52
+ if (/[a-z]/.test(next)) { identifier += next.toUpperCase(); at += 1; continue }
53
+ const code = escaped.slice(at + 1, at + 3)
54
+ if (!/^[0-9][0-9a-f]$/.test(code)) return null
55
+ identifier += String.fromCharCode(Number.parseInt(code, 16))
56
+ at += 2
57
+ }
58
+ return isIdentifier(identifier) && encodeIdentitySegment(identifier) === segment ? identifier : null
59
+ }
60
+
61
+ export const isIdentitySegment = (segment) => typeof segment === 'string' && (decodeIdentitySegment(segment) !== null || /^h-[a-z0-9_~-]{0,24}-[0-9a-f]{32}$/.test(segment))