@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,277 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { isPendingPrivateWrite, publishPrivateFile } from '../../../project/durable-state.mjs'
4
+ import { checkManagedRoots } from '../../../project/file-class.mjs'
5
+ import { ensureContainedPrivateDirectory, openRegularFileNoFollow } from '../../../project/private-state.mjs'
6
+ import { canonicalJson, closedObject, compareText, isoTime } from '../../../runtime/obsidian/documents.mjs'
7
+ import { validateObsidianContract } from '../contracts.mjs'
8
+ import { encodeIdentitySegment, isIdentifier } from '../edits/object-identity.mjs'
9
+ import { sha256Digest } from '../materialize/byte-lens.mjs'
10
+ import { adapterOperationId, isAdapterOperationId, isRoutableSourcePath, proposalStoreId } from './router.mjs'
11
+
12
+ // The memory of the proposal adapter: one record per adapter operation, in
13
+ // private workspace state, written BEFORE any proposal is created.
14
+ //
15
+ // <stateRoot>/state/proposals/<repo>/operations/<operation>--000001.json
16
+ // <stateRoot>/state/proposals/<repo>/lock/ (adapter.mjs)
17
+ //
18
+ // <repo> is an encoded repository identifier (edits/object-identity.mjs), so
19
+ // two repositories never share a directory. A record is one complete,
20
+ // canonical JSON file, owner-only, published with a non-overwriting hard link
21
+ // and never rewritten or removed. Each step of an operation is the next
22
+ // numbered file; it repeats the whole record and names the digest of the file
23
+ // before it. The state of an operation is its highest file.
24
+ //
25
+ // queued recorded; no proposal has been asked for yet, or the last attempt is to be made again
26
+ // submitted a proposal that carries this operation identity exists in the store of the repository
27
+ // acknowledged that proposal was read back and a receipt binds it to the edit; final
28
+ // backpressure the store could not take it now; tried again after `nextAttemptAt`, a bounded number of times
29
+ // refused it cannot be routed or stored as it is; final until a person asks for it again
30
+ //
31
+ // Bounds. A repository holds at most 4096 operations, 256 of them open, and an
32
+ // operation at most 64 files of at most 16 KiB. Nothing is rotated, compacted
33
+ // or dropped: a full queue refuses `queue-full` and records nothing, and the
34
+ // edit stays where it was, preserved and recorded, to be offered again.
35
+ //
36
+ // No record holds note or source text: identifiers, digests, a
37
+ // repository-relative path, codes, numbers and timestamps only.
38
+
39
+ export const PROPOSAL_QUEUE_DIRECTORY = path.join('state', 'proposals')
40
+ export const PROPOSAL_OPERATION_SCHEMA = 'atelier-obsidian-proposal-operation/v1'
41
+ export const PROPOSAL_QUEUE_STATES = Object.freeze(['queued', 'submitted', 'acknowledged', 'backpressure', 'refused'])
42
+ export const OPEN_QUEUE_STATES = Object.freeze(['queued', 'submitted', 'backpressure'])
43
+ export const PROPOSAL_QUEUE_LIMITS = Object.freeze({ maxOperationsPerRepository: 4096, maxOpenPerRepository: 256, maxRecordsPerOperation: 64, maxRecordBytes: 16 * 1024 })
44
+ export const PROPOSAL_QUEUE_CRASH_STEPS = Object.freeze(['record-published'])
45
+
46
+ const TRANSITIONS = Object.freeze({
47
+ queued: ['queued', 'submitted', 'backpressure', 'refused'],
48
+ backpressure: ['backpressure', 'submitted', 'refused', 'queued'],
49
+ // Back to `queued` only when the proposal it named is no longer in the store: it is then asked for again.
50
+ submitted: ['acknowledged', 'queued', 'submitted'],
51
+ refused: ['queued'],
52
+ acknowledged: [],
53
+ })
54
+ const RECORD_NAME = /^(pa-[0-9a-f]{64})--(\d{6})\.json$/
55
+ const recordName = (operationId, sequence) => `${operationId}--${String(sequence).padStart(6, '0')}.json`
56
+ const DIGEST = /^sha256:[0-9a-f]{64}$/
57
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
58
+ const CODE = /^[a-z][a-z0-9-]{0,63}$/
59
+ const EDIT_ID = /^edit-[0-9a-f]{32}$/
60
+ const PROPOSAL_ID = /^proposal-[a-z0-9]+(?:-[a-z0-9]+)*$/
61
+ const FIELDS = Object.freeze(['schema', 'adapterOperationId', 'sequence', 'previous', 'workspaceId', 'repoId', 'nodeId', 'editId', 'idempotencyKey', 'scopeId', 'generationId', 'sourcePath', 'storeId',
62
+ 'state', 'code', 'attempts', 'lastAttemptAt', 'nextAttemptAt', 'proposalId', 'dedupe', 'receipt', 'recordedAt'])
63
+ // What never changes from the first file of an operation to its last.
64
+ const FIXED = Object.freeze(['adapterOperationId', 'workspaceId', 'repoId', 'nodeId', 'editId', 'idempotencyKey', 'scopeId', 'generationId', 'storeId'])
65
+
66
+ export class ProposalQueueRefusal extends Error {
67
+ constructor(code, message, detail = {}) {
68
+ super(`${code}: ${message}`)
69
+ this.name = 'ProposalQueueRefusal'
70
+ this.code = code
71
+ this.detail = detail
72
+ }
73
+ }
74
+ const refuse = (code, message, detail) => { throw new ProposalQueueRefusal(code, message, detail) }
75
+
76
+ const isCount = (value) => Number.isInteger(value) && value >= 0
77
+ const isTimestamp = (value) => typeof value === 'string' && TIMESTAMP.test(value)
78
+
79
+ export function isProposalOperationRecord(record) {
80
+ try { closedObject(record, { required: FIELDS }, 'invalid', 'record') } catch { return false }
81
+ const identityOk = record.schema === PROPOSAL_OPERATION_SCHEMA && [record.workspaceId, record.repoId, record.nodeId, record.scopeId, record.generationId].every(isIdentifier)
82
+ && isAdapterOperationId(record.adapterOperationId) && typeof record.editId === 'string' && EDIT_ID.test(record.editId) && typeof record.idempotencyKey === 'string'
83
+ if (!identityOk) return false
84
+ let expected
85
+ try { expected = adapterOperationId(record) } catch { return false }
86
+ if (expected !== record.adapterOperationId || record.storeId !== proposalStoreId(record)) return false
87
+ if (!Number.isInteger(record.sequence) || record.sequence < 1 || !(record.previous === null ? record.sequence === 1 : DIGEST.test(record.previous) && record.sequence > 1)) return false
88
+ // The path a route refused for is unknown or unusable, and is then not recorded at all.
89
+ if (!(record.sourcePath === null || isRoutableSourcePath(record.sourcePath))) return false
90
+ if (!PROPOSAL_QUEUE_STATES.includes(record.state) || !(record.code === null || (typeof record.code === 'string' && CODE.test(record.code)))) return false
91
+ if (!isCount(record.attempts) || !(record.lastAttemptAt === null || isTimestamp(record.lastAttemptAt)) || !(record.nextAttemptAt === null || isTimestamp(record.nextAttemptAt)) || !isTimestamp(record.recordedAt)) return false
92
+ if (!(record.proposalId === null || (typeof record.proposalId === 'string' && record.proposalId.length <= 128 && PROPOSAL_ID.test(record.proposalId)))) return false
93
+ if (!(record.dedupe === null || ['new', 'recovered'].includes(record.dedupe))) return false
94
+ const settled = record.state === 'submitted' || record.state === 'acknowledged'
95
+ if (settled !== (record.proposalId !== null) || settled !== (record.dedupe !== null)) return false
96
+ if ((record.state === 'refused' || record.state === 'backpressure') && record.code === null) return false
97
+ if (record.state === 'acknowledged' || record.state === 'refused') {
98
+ const receipt = record.receipt
99
+ if (validateObsidianContract('proposal-receipt', receipt).length > 0) return false
100
+ if (receipt.adapterOperationId !== record.adapterOperationId || receipt.editId !== record.editId || receipt.repoId !== record.repoId || receipt.storeId !== record.storeId || receipt.proposalId !== record.proposalId) return false
101
+ if (receipt.backpressure !== (record.state === 'acknowledged' ? 'accepted' : 'refused')) return false
102
+ } else if (record.receipt !== null) return false
103
+ return true
104
+ }
105
+
106
+ function readBytesNoFollow(file) {
107
+ const descriptor = openRegularFileNoFollow(file)
108
+ try { return fs.readFileSync(descriptor) } finally { fs.closeSync(descriptor) }
109
+ }
110
+
111
+ export function openProposalQueue({ stateRoot, workspaceId, repositoryRoots, clock, crash = () => {}, limits = PROPOSAL_QUEUE_LIMITS } = {}) {
112
+ if (typeof stateRoot !== 'string' || !path.isAbsolute(stateRoot)) throw new TypeError('stateRoot must be an absolute path')
113
+ if (!isIdentifier(workspaceId)) throw new TypeError('workspaceId must be a contract identifier')
114
+ if (typeof clock !== 'function') throw new TypeError('a clock must be injected')
115
+ if (!Array.isArray(repositoryRoots) || repositoryRoots.some((item) => typeof item !== 'string' || !path.isAbsolute(item))) throw new TypeError('repositoryRoots must list the absolute root of every enrolled repository')
116
+ const guard = checkManagedRoots({ managedRoots: [stateRoot], repositoryRoots })
117
+ if (!guard.ok) refuse('state-root-overlaps-repository', 'the adapter queue may not overlap an enrolled repository', { codes: guard.refusals.map((item) => item.code) })
118
+ const root = fs.realpathSync.native(stateRoot)
119
+ const queueRoot = path.join(root, PROPOSAL_QUEUE_DIRECTORY)
120
+ // Two processes may create the same directory at once; the one that loses the mkdir finds it made.
121
+ const privateDir = (...parts) => {
122
+ for (let attempt = 0; ; attempt += 1) {
123
+ try { return ensureContainedPrivateDirectory({ workspaceRoot: root, directory: path.join(queueRoot, ...parts), label: 'proposal adapter state' }) } catch (error) {
124
+ if (error.code !== 'EEXIST' || attempt >= 8) throw error
125
+ }
126
+ }
127
+ }
128
+ const repositoryDirectory = (repoId) => path.join(queueRoot, encodeIdentitySegment(repoId))
129
+ const operationsDirectory = (repoId) => path.join(repositoryDirectory(repoId), 'operations')
130
+ // Records are immutable, so one that was read and verified stays verified under its name.
131
+ const verified = new Map()
132
+
133
+ function readRecord(directory, name) {
134
+ const file = path.join(directory, name)
135
+ const cached = verified.get(file)
136
+ if (cached) return cached
137
+ const [, operationId, digits] = RECORD_NAME.exec(name)
138
+ const corrupt = (code, message) => refuse(code, message, { adapterOperationId: operationId, sequence: Number(digits) })
139
+ let bytes
140
+ try {
141
+ if (fs.lstatSync(file).size > limits.maxRecordBytes) corrupt('queue-record-too-large', 'a record of the adapter queue is larger than a record can be')
142
+ bytes = readBytesNoFollow(file)
143
+ } catch (error) {
144
+ if (error instanceof ProposalQueueRefusal) throw error
145
+ return corrupt('queue-record-unreadable', 'a record of the adapter queue cannot be read')
146
+ }
147
+ let record
148
+ try { record = JSON.parse(bytes.toString('utf8')) } catch { return corrupt('queue-record-malformed', 'a record of the adapter queue is not a document') }
149
+ if (!isProposalOperationRecord(record) || canonicalJson(record) !== bytes.toString('utf8')) corrupt('queue-record-malformed', 'a record of the adapter queue is not a valid canonical record')
150
+ if (record.adapterOperationId !== operationId || record.sequence !== Number(digits) || record.workspaceId !== workspaceId) corrupt('queue-record-foreign', 'a record of the adapter queue is not where it belongs')
151
+ const entry = { record, digest: sha256Digest(bytes) }
152
+ verified.set(file, entry)
153
+ return entry
154
+ }
155
+
156
+ // Every operation of one repository by its files: Map(operationId -> highest sequence). Refuses a directory that
157
+ // holds anything this module did not write, since nothing may be decided over files nobody can account for.
158
+ function listNames(repoId) {
159
+ const directory = operationsDirectory(repoId)
160
+ let names
161
+ try { names = fs.readdirSync(directory) } catch (error) { if (error.code === 'ENOENT') return { directory, highest: new Map() }; throw error }
162
+ const highest = new Map()
163
+ for (const name of names) {
164
+ if (isPendingPrivateWrite(name)) continue
165
+ const match = RECORD_NAME.exec(name)
166
+ if (!match || Number(match[2]) < 1) refuse('queue-foreign-file', 'the adapter queue holds a file that is not a record; nothing is decided until a person has looked', { repoId })
167
+ highest.set(match[1], Math.max(highest.get(match[1]) ?? 0, Number(match[2])))
168
+ }
169
+ return { directory, highest }
170
+ }
171
+
172
+ // The whole, verified history of one operation, or null.
173
+ function readOperation(repoId, operationId) {
174
+ const { directory, highest } = listNames(repoId)
175
+ const last = highest.get(operationId) ?? 0
176
+ if (last === 0) return null
177
+ const records = []
178
+ let previous = null
179
+ for (let sequence = 1; sequence <= last; sequence += 1) {
180
+ let entry
181
+ try { entry = readRecord(directory, recordName(operationId, sequence)) } catch (error) {
182
+ if (error instanceof ProposalQueueRefusal && error.code === 'queue-record-unreadable') refuse('queue-record-gap', 'a record of the operation is missing', { adapterOperationId: operationId, sequence })
183
+ throw error
184
+ }
185
+ const before = records.at(-1)
186
+ if (entry.record.previous !== previous || entry.record.repoId !== repoId) refuse('queue-chain-broken', 'a record of the operation does not follow the one before it', { adapterOperationId: operationId, sequence })
187
+ if (before && (FIXED.some((field) => before[field] !== entry.record[field]) || !TRANSITIONS[before.state].includes(entry.record.state))) {
188
+ refuse('queue-history-invalid', 'the records of the operation are not a history this adapter could have written', { adapterOperationId: operationId, sequence })
189
+ }
190
+ records.push(entry.record)
191
+ previous = entry.digest
192
+ }
193
+ return { records, head: records.at(-1), headDigest: previous }
194
+ }
195
+
196
+ function publish(directory, record) {
197
+ const bytes = Buffer.from(canonicalJson(record), 'utf8')
198
+ if (bytes.length > limits.maxRecordBytes) refuse('queue-record-too-large', 'the record is larger than a record can be', { bytes: bytes.length })
199
+ const file = path.join(directory, recordName(record.adapterOperationId, record.sequence))
200
+ const context = { adapterOperationId: record.adapterOperationId, sequence: record.sequence, state: record.state }
201
+ // Non-overwriting: of two writers of one name exactly one succeeds, and the other is told.
202
+ try { publishPrivateFile(file, bytes) } catch (error) {
203
+ if (error.code === 'EEXIST') refuse('queue-record-taken', 'another writer recorded this step of the operation first', context)
204
+ throw error
205
+ }
206
+ crash('record-published', context)
207
+ }
208
+
209
+ return {
210
+ workspaceId,
211
+ stateRoot: root,
212
+ queueRoot,
213
+ lockDirectory: (repoId) => path.join(repositoryDirectory(repoId), 'lock'),
214
+ // Made on demand: a workspace that never sees a structural edit holds no adapter state at all.
215
+ ensureRepository(repoId) { privateDir(encodeIdentitySegment(repoId)); return privateDir(encodeIdentitySegment(repoId), 'operations') },
216
+
217
+ read: (repoId, operationId) => readOperation(repoId, operationId),
218
+
219
+ // The head of every operation of every repository. An operation (or a repository) that cannot be read is listed
220
+ // as such, with its code, and hides nothing else. { operations, unreadable }
221
+ heads({ repoId: only } = {}) {
222
+ const operations = []
223
+ const unreadable = []
224
+ let segments
225
+ try { segments = fs.readdirSync(queueRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort() } catch (error) { if (error.code === 'ENOENT') return { operations, unreadable }; throw error }
226
+ for (const segment of segments) {
227
+ if (only !== undefined && segment !== encodeIdentitySegment(only)) continue
228
+ const directory = path.join(queueRoot, segment, 'operations')
229
+ let names
230
+ try { names = fs.readdirSync(directory) } catch (error) { if (error.code === 'ENOENT') continue; throw error }
231
+ const highest = new Map()
232
+ let foreign = false
233
+ for (const name of names) {
234
+ if (isPendingPrivateWrite(name)) continue
235
+ const match = RECORD_NAME.exec(name)
236
+ if (!match) { foreign = true; continue }
237
+ highest.set(match[1], Math.max(highest.get(match[1]) ?? 0, Number(match[2])))
238
+ }
239
+ if (foreign) unreadable.push({ segment, adapterOperationId: null, code: 'queue-foreign-file' })
240
+ for (const [operationId, sequence] of [...highest].sort(([left], [right]) => compareText(left, right))) {
241
+ try {
242
+ const { record } = readRecord(directory, recordName(operationId, sequence))
243
+ if (encodeIdentitySegment(record.repoId) !== segment) refuse('queue-record-foreign', 'a record is kept under another repository', { adapterOperationId: operationId })
244
+ operations.push(record)
245
+ } catch (error) {
246
+ if (!(error instanceof ProposalQueueRefusal)) throw error
247
+ unreadable.push({ segment, adapterOperationId: operationId, code: error.code })
248
+ }
249
+ }
250
+ }
251
+ return { operations, unreadable }
252
+ },
253
+
254
+ // Records the next step of an operation. `fields` are the members that change; the first step names the whole
255
+ // operation. Returns the record written. Refuses `queue-full` before the first step of an operation the
256
+ // repository has no room for, and `queue-operation-full` when an operation holds as many files as it may.
257
+ append(repoId, operationId, fields) {
258
+ const existing = readOperation(repoId, operationId)
259
+ if (existing === null) {
260
+ const { highest } = listNames(repoId)
261
+ if (highest.size >= limits.maxOperationsPerRepository) refuse('queue-full', 'the adapter queue of the repository holds as many operations as it may; nothing is rotated or dropped', { repoId, operations: highest.size })
262
+ const open = this.heads({ repoId }).operations.filter((record) => OPEN_QUEUE_STATES.includes(record.state)).length
263
+ if (open >= limits.maxOpenPerRepository) refuse('queue-full', 'the adapter queue of the repository holds as many open operations as it may', { repoId, open })
264
+ } else if (existing.records.length >= limits.maxRecordsPerOperation) {
265
+ refuse('queue-operation-full', 'the operation holds as many records as an operation may', { adapterOperationId: operationId })
266
+ }
267
+ const base = existing?.head ?? { schema: PROPOSAL_OPERATION_SCHEMA, adapterOperationId: operationId, workspaceId, repoId, code: null, attempts: 0, lastAttemptAt: null, nextAttemptAt: null, proposalId: null, dedupe: null, receipt: null }
268
+ const record = { ...base, ...fields, sequence: (existing?.records.length ?? 0) + 1, previous: existing?.headDigest ?? null, recordedAt: isoTime(clock) }
269
+ if (!isProposalOperationRecord(record)) refuse('invalid-queue-record', 'the record is not one this adapter writes', { adapterOperationId: operationId, state: String(fields?.state) })
270
+ if (existing && (FIXED.some((field) => existing.head[field] !== record[field]) || !TRANSITIONS[existing.head.state].includes(record.state))) {
271
+ refuse('invalid-queue-transition', 'the operation cannot go from where it is to there', { adapterOperationId: operationId, from: existing.head.state, to: record.state })
272
+ }
273
+ publish(this.ensureRepository(repoId), record)
274
+ return record
275
+ },
276
+ }
277
+ }
@@ -0,0 +1,168 @@
1
+ import { createHash } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { isIdentifier } from '../edits/object-identity.mjs'
5
+
6
+ // Routing: which existing proposal store a structural edit belongs to, and the
7
+ // name the adapter knows that edit by.
8
+ //
9
+ // A structural edit is an edit operation of kind `semantic-proposal`. It is a
10
+ // request about ONE source document of ONE enrolled repository, so it goes to
11
+ // the proposal store of that repository and to no other. The store of a
12
+ // repository is the one every other writer of proposals already uses for a
13
+ // root: `<repository root>/.atelier-proposals`, the default of
14
+ // createProposalStore for that root. Nothing here invents another place, and
15
+ // a store is never resolved inside a vault, inside private Obsidian state, or
16
+ // for a repository the project does not enrol.
17
+ //
18
+ // The routing key is the whole identity (workspaceId, repoId, nodeId). Two
19
+ // repositories that hold the same relative path and the same local node id
20
+ // have different repository identifiers, so they have different roots,
21
+ // different stores and different adapter operation identities.
22
+ //
23
+ // A route that cannot be resolved refuses with a code. A refusal reads and
24
+ // writes nothing of the edit: the preserved bytes and the record of the object
25
+ // stay exactly as they are.
26
+
27
+ export const PROPOSAL_STORE_DIRECTORY = '.atelier-proposals'
28
+ export const PROPOSAL_STORE_LEDGER = 'events.ndjson'
29
+ export const MAX_ROUTED_SOURCE_PATH = 500
30
+
31
+ export const PROPOSAL_ROUTE_REFUSALS = Object.freeze([
32
+ 'invalid-operation',
33
+ 'foreign-workspace',
34
+ 'repository-not-enrolled',
35
+ 'repository-external',
36
+ 'repository-root-unreadable',
37
+ 'route-withheld',
38
+ 'route-visibility-unknown',
39
+ 'source-path-invalid',
40
+ 'source-path-not-preservable',
41
+ 'proposal-store-unsafe',
42
+ 'proposal-store-inside-managed-root',
43
+ 'proposal-store-not-ignored',
44
+ 'proposal-store-ignore-unknown',
45
+ ])
46
+ // Conditions of the machine at this moment rather than of the route: tried again later, within the retry bounds.
47
+ export const TRANSIENT_ROUTE_REFUSALS = Object.freeze(['route-visibility-unknown'])
48
+
49
+ const IDEMPOTENCY_KEY = /^[A-Za-z0-9._:-]{16,128}$/
50
+ const OPERATION_IDENTITY = /^pa-[0-9a-f]{64}$/
51
+ const DOMAIN = 'atelier-obsidian-proposal-adapter/v1'
52
+
53
+ const hashOf = (parts) => createHash('sha256').update(parts.join('\u0000')).digest('hex')
54
+
55
+ // The decisions the oracles of test/obsidian-proposals.test.mjs are sensitive
56
+ // to. Production always uses these; the tests substitute deliberately broken
57
+ // ones through createProposalRouterForOracleTests to prove each oracle can fail.
58
+ export const PROPOSAL_ROUTER_PRIMITIVES = Object.freeze({
59
+ // What an adapter operation identity is made from: the whole identity and the idempotency key, in this order.
60
+ identityParts: ({ workspaceId, repoId, nodeId, idempotencyKey }) => [workspaceId, repoId, nodeId, idempotencyKey],
61
+ // The enrolled repository a route goes to: the one the identity names, and no other.
62
+ repositoryOf: ({ project, repoId }) => (project?.repos ?? []).filter((item) => item?.name === repoId),
63
+ })
64
+
65
+ function validIdentityInput({ workspaceId, repoId, nodeId, idempotencyKey }) {
66
+ return [workspaceId, repoId, nodeId].every(isIdentifier) && typeof idempotencyKey === 'string' && IDEMPOTENCY_KEY.test(idempotencyKey)
67
+ }
68
+
69
+ // The adapter operation identity: a fixed-length, lower-case name derived from
70
+ // the whole identity and the idempotency key of the edit operation. The parts
71
+ // are joined by a character no identifier can hold, so moving a character from
72
+ // one part to the next gives another name. It holds only [a-z0-9-], is 67
73
+ // characters long, and is carried inside the payload object of a proposal,
74
+ // which the store writes and reads back as JSON without normalising it.
75
+ function operationIdWith(rules, input) {
76
+ if (!validIdentityInput(input)) throw new TypeError('an adapter operation identity is made from a whole identity and an idempotency key')
77
+ return `pa-${hashOf([DOMAIN, 'operation', ...rules.identityParts(input)])}`
78
+ }
79
+ export const adapterOperationId = (input) => operationIdWith(PROPOSAL_ROUTER_PRIMITIVES, input)
80
+
81
+ export const isAdapterOperationId = (value) => typeof value === 'string' && OPERATION_IDENTITY.test(value)
82
+
83
+ // The store of one repository as this workspace names it. No path is part of it.
84
+ export function proposalStoreId({ workspaceId, repoId }) {
85
+ if (![workspaceId, repoId].every(isIdentifier)) throw new TypeError('a proposal store identity is made from a workspace and a repository')
86
+ return `ps-${hashOf([DOMAIN, 'store', workspaceId, repoId, PROPOSAL_STORE_DIRECTORY])}`
87
+ }
88
+
89
+ const inside = (parent, candidate) => {
90
+ const relative = path.relative(parent, candidate)
91
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
92
+ }
93
+ const lstatOrNull = (file) => { try { return fs.lstatSync(file) } catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null; throw error } }
94
+ const realOrSelf = (directory) => { try { return fs.realpathSync.native(directory) } catch { return directory } }
95
+
96
+ export function isRoutableSourcePath(value) {
97
+ if (typeof value !== 'string' || value === '' || path.isAbsolute(value) || value.includes('\\') || value.includes('\u0000') || /^[A-Za-z]:/.test(value)) return false
98
+ return !value.split('/').some((part) => part === '' || part === '.' || part === '..')
99
+ }
100
+
101
+ // What the existing store does to the `path` of a proposal: trimmed, and cut
102
+ // at 500 characters. A path that would not come back as it went in refuses
103
+ // before anything is created; it is never recorded cut short.
104
+ export const survivesStoreNormalisation = (sourcePath) => sourcePath.trim().slice(0, MAX_ROUTED_SOURCE_PATH) === sourcePath
105
+
106
+ const refusal = (code, detail = {}) => ({ ok: false, code, transient: TRANSIENT_ROUTE_REFUSALS.includes(code), detail })
107
+
108
+ // `identity` is { workspaceId, repoId, nodeId }; `sourcePath` is the path of
109
+ // the source inside its repository, as the manifest of the view records it.
110
+ // `managedRoots` are the private state root and every vault. `isVisible` says
111
+ // whether this machine may see the object now: true, false, or null when that
112
+ // cannot be decided. `isGitIgnored` answers true, false or null.
113
+ //
114
+ // { ok: true, route } or { ok: false, code, transient, detail }. Never throws
115
+ // for a route that is merely wrong, and writes nothing.
116
+ function resolveWith(rules, { project, workspaceId, identity, sourcePath, managedRoots = [], isVisible, isGitIgnored, env = process.env }) {
117
+ if (identity === null || typeof identity !== 'object' || ![identity.workspaceId, identity.repoId, identity.nodeId].every(isIdentifier)) return refusal('invalid-operation', { member: 'identity' })
118
+ if (identity.workspaceId !== workspaceId) return refusal('foreign-workspace')
119
+ const { repoId, nodeId } = identity
120
+ const named = rules.repositoryOf({ project, repoId, nodeId })
121
+ if (named.length !== 1) return refusal('repository-not-enrolled')
122
+ const [repo] = named
123
+ if (repo.external || typeof repo.path !== 'string' || !path.isAbsolute(repo.path)) return refusal('repository-external')
124
+ let repositoryRoot
125
+ try {
126
+ repositoryRoot = fs.realpathSync.native(repo.path)
127
+ if (!fs.statSync(repositoryRoot).isDirectory()) return refusal('repository-root-unreadable')
128
+ } catch { return refusal('repository-root-unreadable') }
129
+
130
+ if (!isRoutableSourcePath(sourcePath)) return refusal('source-path-invalid')
131
+ if (!survivesStoreNormalisation(sourcePath)) return refusal('source-path-not-preservable', { length: sourcePath.length, limit: MAX_ROUTED_SOURCE_PATH })
132
+
133
+ const storeDirectory = path.join(repositoryRoot, PROPOSAL_STORE_DIRECTORY)
134
+ const present = lstatOrNull(storeDirectory)
135
+ if (present !== null && (present.isSymbolicLink() || !present.isDirectory())) return refusal('proposal-store-unsafe')
136
+ for (const managedRoot of managedRoots) {
137
+ const managed = realOrSelf(managedRoot)
138
+ if (inside(managed, storeDirectory) || inside(storeDirectory, managed)) return refusal('proposal-store-inside-managed-root')
139
+ }
140
+
141
+ const visible = isVisible({ workspaceId, repoId, nodeId })
142
+ if (visible === null || visible === undefined) return refusal('route-visibility-unknown')
143
+ if (visible !== true) return refusal('route-withheld')
144
+
145
+ // A store that is already there is whatever git already says it is. One that would be made here must not become
146
+ // something git reports: a repository that does not ignore the directory refuses, and nothing is made.
147
+ if (present === null) {
148
+ const ignored = isGitIgnored({ repositoryRoot, relative: `${PROPOSAL_STORE_DIRECTORY}/${PROPOSAL_STORE_LEDGER}`, env })
149
+ if (ignored === false) return refusal('proposal-store-not-ignored')
150
+ if (ignored !== true && lstatOrNull(path.join(repositoryRoot, '.git')) !== null) return refusal('proposal-store-ignore-unknown')
151
+ }
152
+
153
+ return {
154
+ ok: true,
155
+ route: Object.freeze({
156
+ workspaceId, repoId, nodeId, sourcePath, repositoryRoot, storeDirectory,
157
+ storeExists: present !== null,
158
+ storeId: proposalStoreId({ workspaceId, repoId }),
159
+ }),
160
+ }
161
+ }
162
+
163
+ export function createProposalRouterForOracleTests(primitives = PROPOSAL_ROUTER_PRIMITIVES) {
164
+ const rules = { ...PROPOSAL_ROUTER_PRIMITIVES, ...primitives }
165
+ return { adapterOperationId: (input) => operationIdWith(rules, input), resolveProposalRoute: (input) => resolveWith(rules, input) }
166
+ }
167
+
168
+ export const resolveProposalRoute = (input) => resolveWith(PROPOSAL_ROUTER_PRIMITIVES, input)