@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,234 @@
1
+ import { createHash } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { acquirePrivateLock, publishPrivateFile } from '../../../project/durable-state.mjs'
5
+ import { checkManagedRoots } from '../../../project/file-class.mjs'
6
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../../project/private-state.mjs'
7
+
8
+ // Private per-workspace state for one Obsidian view:
9
+ //
10
+ // <workspaceRoot>/vaults/<scopeId>/ the editable vault (or an explicit vaultRoot)
11
+ // <workspaceRoot>/state/manifests/<scopeId>/ trusted manifests and the current pointer
12
+ // <workspaceRoot>/state/journals/<scopeId>/<id>/ publication journals
13
+ // <workspaceRoot>/recovery/objects/<sha256>.bin immutable content-addressed bytes
14
+ // <workspaceRoot>/recovery/<journalId>/<unit>/ displaced files, receipts, and the unit's exchange candidate
15
+ // <workspaceRoot>/recovery/.exchange-probe/ scratch files of the exchange self-test, nothing else
16
+ // <workspaceRoot>/staging/<journalId>/ disposable generated bytes that no exchange ever names
17
+ // <vault>/.atelier-publication/ the vault lock, shared by every view of the vault
18
+ //
19
+ // Immutable records are published with publishPrivateFile: a complete file
20
+ // appears atomically under a name that is never overwritten. What that gives
21
+ // is an all-or-nothing record that survives a process crash; it is fsynced,
22
+ // which on some platforms is weaker than a full device flush. A displaced
23
+ // file is different: it is the very file that occupied a note path, it is
24
+ // moved and never copied over, and a program that still holds it open may
25
+ // write into it later. It is therefore never treated as immutable; its digest
26
+ // at the time of the move is recorded in a receipt and re-checked.
27
+ //
28
+ // A candidate that replaces an existing file is exchanged with it, so after
29
+ // the exchange the candidate's path holds the displaced bytes. That path is
30
+ // therefore in the unit's recovery directory, never in staging: staging is
31
+ // discardable, and displaced bytes must not sit in a discardable area for an
32
+ // instant. The candidate is written and fsynced in staging, where it is only
33
+ // generated bytes, named with its digest by the journal, and only then moved
34
+ // to its exchange path. What a file at an exchange path is, is decided from
35
+ // the journal: bytes with the recorded candidate digest are generated and
36
+ // ours; anything else is somebody's bytes and is kept. The candidate names
37
+ // end in `.candidate`; everything else the store puts in a unit directory
38
+ // ends in `.bin` or `.json`, so the names cannot collide.
39
+
40
+ export const EXCHANGE_CANDIDATE_NAME = 'exchange.candidate'
41
+ export const LATE_EXCHANGE_CANDIDATE_NAME = 'exchange.late.candidate'
42
+
43
+ export const VAULT_LOCK_DIRECTORY = '.atelier-publication'
44
+
45
+ // The vault lock, with the semantics of every private lock: the newest ticket
46
+ // owns it, a release marker frees it, and a ticket whose process is gone on
47
+ // this host is stale and is taken over. Nothing else is ever written here.
48
+ // Tickets of superseded generations are removed while the lock is held, the
49
+ // marker before its ticket, so the directory does not grow inside a person's
50
+ // vault and an interrupted removal never leaves a marker without its ticket.
51
+ export function acquireVaultLock(store) {
52
+ const directory = ensureContainedPrivateDirectory({ workspaceRoot: store.vaultRoot, directory: path.dirname(store.vaultLockPath), label: 'vault publication lock' })
53
+ const release = acquirePrivateLock(path.join(directory, path.basename(store.vaultLockPath)))
54
+ try {
55
+ const owners = `${store.vaultLockPath}.owners`
56
+ const names = fs.readdirSync(owners).filter((name) => /^\d{12}\.(json|released)$/.test(name)).sort()
57
+ const newest = names.filter((name) => name.endsWith('.json')).at(-1)?.slice(0, 12)
58
+ for (const name of names.filter((item) => item.slice(0, 12) < newest).sort((left, right) => (left.endsWith('.released') ? -1 : 1) - (right.endsWith('.released') ? -1 : 1))) {
59
+ fs.rmSync(path.join(owners, name), { force: true })
60
+ }
61
+ } catch { /* history that cannot be pruned is harmless */ }
62
+ return release
63
+ }
64
+
65
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
66
+ const DIGEST = /^sha256:[0-9a-f]{64}$/
67
+
68
+ export const sha256Digest = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`
69
+
70
+ export class PublicationRefusal extends Error {
71
+ constructor(code, message, detail = {}) {
72
+ super(`${code}: ${message}`)
73
+ this.name = 'PublicationRefusal'
74
+ this.code = code
75
+ this.detail = detail
76
+ }
77
+ }
78
+
79
+ export function refuse(code, message, detail) {
80
+ throw new PublicationRefusal(code, message, detail)
81
+ }
82
+
83
+ // Identifiers become directory names. ':' is legal in an identifier and is not
84
+ // legal in a file name everywhere.
85
+ const segment = (identifier) => identifier.replaceAll(':', '_')
86
+
87
+ export function readFileDigest(file) {
88
+ let descriptor
89
+ try {
90
+ descriptor = openRegularFileNoFollow(file)
91
+ } catch (error) {
92
+ if (error.code === 'ENOENT') return null
93
+ throw error
94
+ }
95
+ try { return sha256Digest(fs.readFileSync(descriptor)) } finally { fs.closeSync(descriptor) }
96
+ }
97
+
98
+ export function readFileBytes(file) {
99
+ const descriptor = openRegularFileNoFollow(file)
100
+ try { return fs.readFileSync(descriptor) } finally { fs.closeSync(descriptor) }
101
+ }
102
+
103
+ function publishOnce(file, bytes) {
104
+ try {
105
+ publishPrivateFile(file, bytes)
106
+ } catch (error) {
107
+ // The name is taken by different bytes. An immutable record is never replaced.
108
+ if (error.code !== 'EEXIST') throw error
109
+ return false
110
+ }
111
+ return true
112
+ }
113
+
114
+ // `repositoryRoots` is required: every enrolled repository root of the loaded
115
+ // project. Neither the workspace state nor the vault may overlap one of them,
116
+ // in either direction, lexically or by real path; the store refuses before it
117
+ // creates anything. A caller with no enrolled repository (a test in a
118
+ // temporary directory) says so with an explicit empty list.
119
+ export function createRecoveryStore({ workspaceRoot, workspaceId, scopeId, vaultRoot, repositoryRoots } = {}) {
120
+ if (typeof workspaceRoot !== 'string' || !path.isAbsolute(workspaceRoot)) throw new TypeError('workspaceRoot must be an absolute path')
121
+ for (const [label, value] of [['workspaceId', workspaceId], ['scopeId', scopeId]]) {
122
+ if (typeof value !== 'string' || !IDENTIFIER.test(value)) throw new TypeError(`${label} must be a contract identifier`)
123
+ }
124
+ if (!Array.isArray(repositoryRoots) || repositoryRoots.some((item) => typeof item !== 'string' || !path.isAbsolute(item))) {
125
+ throw new TypeError('repositoryRoots must list the absolute root of every enrolled repository; pass an explicit empty list when there is none')
126
+ }
127
+ if (vaultRoot !== undefined && (typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot))) throw new TypeError('vaultRoot must be an absolute path')
128
+ const guard = checkManagedRoots({ managedRoots: [workspaceRoot, ...(vaultRoot === undefined ? [] : [vaultRoot])], repositoryRoots })
129
+ if (!guard.ok) refuse(guard.refusals[0].code, guard.refusals[0].message, { refusals: guard.refusals })
130
+ fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
131
+ const root = fs.realpathSync(workspaceRoot)
132
+ const privateDir = (...parts) => ensureContainedPrivateDirectory({ workspaceRoot: root, directory: path.join(root, ...parts), label: 'Obsidian publication state' })
133
+ const requestedVault = vaultRoot ?? path.join(root, 'vaults', segment(scopeId))
134
+ fs.mkdirSync(requestedVault, { recursive: true })
135
+ const vault = fs.realpathSync(requestedVault)
136
+ const inside = (parent, child) => { const relative = path.relative(parent, child); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) }
137
+ for (const area of ['state', 'recovery', 'staging']) {
138
+ if (inside(path.join(root, area), vault) || inside(vault, path.join(root, area))) throw new TypeError('the vault may not overlap private publication state')
139
+ }
140
+ const manifests = privateDir('state', 'manifests', segment(scopeId))
141
+ const journals = privateDir('state', 'journals', segment(scopeId))
142
+ const locks = privateDir('state', 'locks')
143
+ const objects = privateDir('recovery', 'objects')
144
+ const staging = privateDir('staging')
145
+ const pointerFile = path.join(manifests, 'current.json')
146
+
147
+ const store = {
148
+ workspaceRoot: root,
149
+ workspaceId,
150
+ scopeId,
151
+ vaultRoot: vault,
152
+ journalsRoot: journals,
153
+ lockPath: path.join(locks, `${segment(scopeId)}.lock`),
154
+ // One publisher per vault, whichever view or workspace state it belongs to.
155
+ // Two stores share nothing but the vault, so this lock lives under the
156
+ // vault's real path, in a dot-directory no note path can name.
157
+ vaultLockPath: path.join(vault, VAULT_LOCK_DIRECTORY, 'vault.lock'),
158
+ ref: (absolute) => path.relative(root, absolute).split(path.sep).join('/'),
159
+ resolve(ref) {
160
+ const absolute = path.resolve(root, ref)
161
+ if (!inside(root, absolute) || absolute === root) throw new TypeError('a store reference must stay inside the workspace state')
162
+ return absolute
163
+ },
164
+ journalDir: (journalId) => privateDir('state', 'journals', segment(scopeId), segment(journalId)),
165
+ stagingDir: (journalId) => privateDir('staging', segment(journalId)),
166
+ stagingRoot: staging,
167
+ // Where the exchange self-test runs: inside recovery, because that is where exchange candidates live.
168
+ exchangeProbeDir: () => privateDir('recovery', '.exchange-probe'),
169
+ // Generated bytes on their way to a path: written here, and for a created file linked from here.
170
+ preparedPath: (journalId, unit, { late = false } = {}) => path.join(store.stagingDir(journalId), `${String(unit).padStart(6, '0')}${late ? '.late' : ''}.candidate`),
171
+ // The path handed to the exchange. Computed without creating anything; unitDir creates the directory.
172
+ exchangeCandidatePath: (journalId, unit, { late = false } = {}) => path.join(root, 'recovery', segment(journalId), String(unit).padStart(6, '0'), late ? LATE_EXCHANGE_CANDIDATE_NAME : EXCHANGE_CANDIDATE_NAME),
173
+ unitsRoot: (journalId) => path.join(root, 'recovery', segment(journalId)),
174
+ unitDir: (journalId, unit) => privateDir('recovery', segment(journalId), String(unit).padStart(6, '0')),
175
+ displacedPath: (journalId, unit, name = 'displaced.bin') => path.join(store.unitDir(journalId, unit), name),
176
+
177
+ // Immutable, content-addressed bytes (a base, an observed edit, an outside writer's bytes).
178
+ retainObject(bytes) {
179
+ const digest = sha256Digest(bytes)
180
+ const file = path.join(objects, `${digest.slice('sha256:'.length)}.bin`)
181
+ publishPrivateFile(file, bytes)
182
+ return { digest, ref: store.ref(file) }
183
+ },
184
+ readObject(digest) {
185
+ if (!DIGEST.test(digest)) throw new TypeError('digest required')
186
+ const bytes = readFileBytes(path.join(objects, `${digest.slice('sha256:'.length)}.bin`))
187
+ if (sha256Digest(bytes) !== digest) refuse('recovery-object-corrupt', 'a retained object no longer matches its name')
188
+ return bytes
189
+ },
190
+ writeReceipt(journalId, unit, receipt) {
191
+ const body = Buffer.from(`${JSON.stringify({ schema: 'atelier-obsidian-recovery-receipt/v1', journalId, unit, ...receipt }, null, 2)}\n`, 'utf8')
192
+ const file = path.join(store.unitDir(journalId, unit), `receipt-${receipt.role}-${sha256Digest(body).slice(7, 19)}.json`)
193
+ publishOnce(file, body)
194
+ return store.ref(file)
195
+ },
196
+ listReceipts(journalId) {
197
+ const base = path.join(root, 'recovery', segment(journalId))
198
+ if (!fs.existsSync(base)) return []
199
+ const receipts = []
200
+ for (const unit of fs.readdirSync(base).sort()) {
201
+ for (const name of fs.readdirSync(path.join(base, unit)).filter((entry) => /^receipt-.*\.json$/.test(entry)).sort()) {
202
+ receipts.push(JSON.parse(readRegularTextNoFollow(path.join(base, unit, name))))
203
+ }
204
+ }
205
+ return receipts
206
+ },
207
+
208
+ // Trusted manifest. The manifest file is immutable; only the small pointer
209
+ // is replaced, and only as the last step of a publication.
210
+ readCurrent() {
211
+ let text
212
+ try { text = readRegularTextNoFollow(pointerFile) } catch (error) { if (error.code === 'ENOENT') return null; throw error }
213
+ const pointer = JSON.parse(text)
214
+ if (pointer.scopeId !== scopeId || pointer.workspaceId !== workspaceId) refuse('state-mismatch', 'the manifest pointer belongs to another view')
215
+ return pointer
216
+ },
217
+ readCurrentManifest() {
218
+ const pointer = store.readCurrent()
219
+ if (!pointer) return null
220
+ const bytes = readFileBytes(path.join(manifests, pointer.manifestFile))
221
+ if (sha256Digest(bytes) !== pointer.manifestDigest) refuse('state-mismatch', 'the trusted manifest no longer matches its pointer')
222
+ return JSON.parse(bytes.toString('utf8'))
223
+ },
224
+ commitManifest({ manifestBytes, generationId, journalId, retained = [], committedAt }) {
225
+ const manifestDigest = sha256Digest(manifestBytes)
226
+ const manifestFile = `${segment(generationId)}--${manifestDigest.slice(7, 19)}.json`
227
+ publishPrivateFile(path.join(manifests, manifestFile), manifestBytes)
228
+ const pointer = { schema: 'atelier-obsidian-current-manifest/v1', workspaceId, scopeId, generationId, manifestFile, manifestDigest, journalId, committedAt, retained }
229
+ atomicReplacePrivateText(pointerFile, `${JSON.stringify(pointer, null, 2)}\n`)
230
+ return pointer
231
+ },
232
+ }
233
+ return store
234
+ }
@@ -0,0 +1,91 @@
1
+ import { compareText } from '../../../runtime/obsidian/documents.mjs'
2
+ import { OPEN_EDIT_STATES, createMaintenanceStateStore } from '../../../runtime/obsidian/state-store.mjs'
3
+ import { openObjectStore } from '../edits/object-store.mjs'
4
+
5
+ // A read-only, typed view of the shared conflict state AOP-2's arbitration
6
+ // keeps per object: which objects are conflicted, pending, settled or
7
+ // unreadable, which operations are involved and why, and which pending edit
8
+ // records of which views feed them. Identities, states, codes and digests
9
+ // only: no note text, title or path.
10
+ //
11
+ // `conflictView` is pure: it is a function of the object entries (the
12
+ // object store's `list()` or an `arbitrateEvents` summary) and the pending
13
+ // edit records. `readConflictView` reads both from the workspace's private
14
+ // state. Neither decides anything, writes an event or touches a source file
15
+ // or a vault.
16
+
17
+ export const CONFLICT_VIEW_SCHEMA = 'atelier-obsidian-conflict-view/v1'
18
+ export const OBJECT_VIEW_STATES = Object.freeze(['conflicted', 'pending', 'settled', 'empty', 'inconsistent', 'unreadable'])
19
+
20
+ // What a person or an agent does next. A conflict is resolved by a new
21
+ // operation that names the ones it resolves; nothing here chooses a winner.
22
+ const NEXT = Object.freeze({
23
+ conflicted: 'offer a resolution that names every conflicted operation; no operation is dropped and no winner is chosen',
24
+ pending: 'apply explicitly, or let an active automatic policy dispatch it',
25
+ settled: 'nothing: every recorded operation is applied, superseded or refused',
26
+ empty: 'nothing: no operation is recorded for this object',
27
+ inconsistent: 'a person looks at the object log; an event the log did not allow is recorded',
28
+ unreadable: 'a person looks at the object directory; nothing is decided until then',
29
+ })
30
+
31
+ const NEEDS_PERSON = new Set(['conflicted', 'inconsistent', 'unreadable'])
32
+
33
+ function operationView(operation) {
34
+ return {
35
+ idempotencyKey: operation.idempotencyKey,
36
+ kind: operation.kind ?? null,
37
+ state: operation.state,
38
+ reason: operation.reason ?? null,
39
+ by: operation.by ?? null,
40
+ }
41
+ }
42
+
43
+ export function conflictView({ objects, edits = [], scopeId } = {}) {
44
+ if (!Array.isArray(objects)) throw new TypeError('conflictView needs the list of object entries')
45
+ const openEdits = edits.filter((edit) => OPEN_EDIT_STATES.includes(edit.state) && (scopeId === undefined || edit.scopeId === scopeId))
46
+ const editsOf = (repoId, nodeId) => openEdits
47
+ .filter((edit) => edit.identity?.repoId === repoId && edit.identity?.nodeId === nodeId)
48
+ .map((edit) => ({ editId: edit.editId, scopeId: edit.scopeId, state: edit.state, lastCode: edit.lastResult?.code ?? null }))
49
+ .sort((left, right) => compareText(left.scopeId, right.scopeId) || compareText(left.editId, right.editId))
50
+
51
+ const entries = objects.map((object) => {
52
+ const state = OBJECT_VIEW_STATES.includes(object.state) ? object.state : 'unreadable'
53
+ const identity = object.identity ?? object
54
+ const repoId = identity.repoId ?? null
55
+ const nodeId = identity.nodeId ?? null
56
+ const operations = (object.operations ?? []).map(operationView)
57
+ const intentOutcomeUnknown = object.intentOutcomeUnknown === true || (object.intent !== undefined && object.intent !== null)
58
+ const needsPerson = NEEDS_PERSON.has(state) || intentOutcomeUnknown
59
+ return {
60
+ repoId, nodeId, state,
61
+ code: object.code ?? null,
62
+ sourceDigest: object.sourceDigest ?? null,
63
+ leaseHeld: object.leaseHeld === true || (object.lease !== undefined && object.lease !== null),
64
+ intentOutcomeUnknown,
65
+ operations,
66
+ conflictedOperations: operations.filter((operation) => operation.state === 'conflicted').map((operation) => operation.idempotencyKey),
67
+ pendingEdits: repoId === null ? [] : editsOf(repoId, nodeId),
68
+ needsPerson,
69
+ next: intentOutcomeUnknown && !NEEDS_PERSON.has(state) ? 'an apply was started and its outcome is not recorded; the next lease holder reads the source and decides' : NEXT[state],
70
+ }
71
+ }).sort((left, right) => compareText(left.repoId ?? '', right.repoId ?? '') || compareText(left.nodeId ?? '', right.nodeId ?? ''))
72
+
73
+ const byState = Object.fromEntries(OBJECT_VIEW_STATES.map((state) => [state, entries.filter((entry) => entry.state === state).length]))
74
+ return {
75
+ schema: CONFLICT_VIEW_SCHEMA,
76
+ scopeId: scopeId ?? null,
77
+ objects: entries,
78
+ summary: { total: entries.length, byState, needsPerson: entries.filter((entry) => entry.needsPerson).length, openEdits: openEdits.length },
79
+ needsPerson: entries.some((entry) => entry.needsPerson),
80
+ }
81
+ }
82
+
83
+ // Reads the object index (a cache the store rebuilds from the events where
84
+ // it is not current) and the pending edit records of the workspace.
85
+ export function readConflictView({ workspaceRoot, workspaceId, repositoryRoots, clock, scopeId, objectStore = openObjectStore }) {
86
+ const store = objectStore({ stateRoot: workspaceRoot, workspaceId, repositoryRoots, clock })
87
+ const { objects, foreign } = store.list(scopeId === undefined ? {} : { scopeId })
88
+ const edits = createMaintenanceStateStore({ workspaceRoot, workspaceId }).readPendingEdits().edits
89
+ const view = conflictView({ objects, edits, scopeId })
90
+ return { ...view, foreign }
91
+ }
@@ -0,0 +1,141 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { isoTime } from '../../../runtime/obsidian/documents.mjs'
4
+ import { refuse } from '../../../runtime/obsidian/errors.mjs'
5
+ import { protectedRoots, readMachineSettings } from '../../../runtime/obsidian/machine-settings.mjs'
6
+ import { resolveScope } from '../../../runtime/obsidian/opening.mjs'
7
+ import { DEFAULT_ELIGIBILITY, createProductionSeams } from '../../../runtime/obsidian/pipeline.mjs'
8
+ import { createMaintenanceStateStore } from '../../../runtime/obsidian/state-store.mjs'
9
+ import { readConflictView } from './conflict-view.mjs'
10
+ import { runPolicySetup } from './policy-setup.mjs'
11
+ import { listSelectionStates, readSelectionState, writeSelectionState } from './selection-state.mjs'
12
+ import { resolveSelection } from './selection.mjs'
13
+
14
+ // The `obsidian` command operations of selection, conflict state and policy
15
+ // setup, registered on the command's operation registry the way AOP-1 laid
16
+ // out: a contribution `{ id, register({ operations }) }`. Nothing in the
17
+ // command dispatcher is edited. The built-in `scope`, `policy` and `mode`
18
+ // names are reserved, so these are `selection`, `conflicts` and
19
+ // `apply-policy`.
20
+ //
21
+ // Every operation is noninteractive and read-only towards sources, vaults
22
+ // and the app: `selection persist` writes the workspace's private selection
23
+ // state, `apply-policy create|revoke` write the private policy files through
24
+ // AOP-1's installer and revocation, and nothing else writes at all.
25
+
26
+ export const SELECTION_CONTRIBUTION_ID = 'atelier.selection-ui'
27
+ const EXIT = Object.freeze({ ok: 0, notSuccess: 3 })
28
+ const MAX_REQUEST_BYTES = 64 * 1024
29
+
30
+ // The canonical snapshot selectScope reads, built exactly as the engine
31
+ // builds it: the canonical graph with fail-closed eligibility, and the
32
+ // declared relations whose both endpoints the census knows.
33
+ function snapshotNow({ project, seams, eligibility }) {
34
+ const graph = seams.buildGraph({ project, eligibility })
35
+ const known = new Set(graph.nodes.map((node) => node.id))
36
+ return { nodes: graph.nodes, edges: graph.edges.filter((edge) => known.has(edge.source) && known.has(edge.target)) }
37
+ }
38
+
39
+ // FILE is resolved from the working directory of the process, as the built-in `policy install FILE` resolves its file.
40
+ function readRequest(value) {
41
+ if (value === undefined) refuse('usage', 'apply-policy create FILE')
42
+ const file = path.resolve(value)
43
+ try {
44
+ if (fs.statSync(file).size > MAX_REQUEST_BYTES) refuse('invalid-apply-policy', 'the request file is larger than a request can be')
45
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
46
+ } catch (error) {
47
+ if (typeof error?.code === 'string' && error.name === 'ObsidianMaintenanceRefusal') throw error
48
+ return refuse('invalid-apply-policy', 'the request file cannot be read as JSON', { cause: error.code ?? 'not-json' })
49
+ }
50
+ }
51
+
52
+ export function createSelectionOperation({ seams = createProductionSeams(), eligibility = DEFAULT_ELIGIBILITY } = {}) {
53
+ return {
54
+ name: 'selection',
55
+ summary: 'resolve ID | persist ID [allow-empty] | show ID | list The exact selected set of a declared view, its note paths and, for a focus, the graph query; persisted in Atelier state.',
56
+ async run({ args, readable, writable, clock }) {
57
+ const [sub = 'list', requested, option] = args
58
+ if (option !== undefined && !(sub === 'persist' && option === 'allow-empty')) refuse('usage', 'selection persist ID [allow-empty]')
59
+ if (sub === 'list') {
60
+ const { workspace, workspaceId } = readable()
61
+ const selections = workspace === null ? [] : listSelectionStates({ workspaceRoot: workspace.workspaceRoot, workspaceId })
62
+ return { exit: EXIT.ok, document: { selections }, human: selections.length === 0 ? ['no persisted selection'] : selections.map((item) => `${item.scopeId}\t${item.mode}\t${item.updatedAt}${item.focus ? `\t${item.focus.paths.length} focused note(s)` : ''}`) }
63
+ }
64
+ if (!['resolve', 'persist', 'show'].includes(sub)) refuse('usage', 'selection resolve ID | persist ID | show ID | list')
65
+ if (sub === 'show') {
66
+ const { enablement, workspace, workspaceId } = readable()
67
+ const scopeId = resolveScope(enablement, requested)
68
+ const selection = workspace === null ? null : readSelectionState({ workspaceRoot: workspace.workspaceRoot, workspaceId, scopeId })
69
+ return { exit: selection === null ? EXIT.notSuccess : EXIT.ok, document: { scopeId, persisted: selection !== null, selection }, human: [selection === null ? `no persisted selection for ${scopeId}` : JSON.stringify(selection, null, 2)] }
70
+ }
71
+ const { project, enablement, workspace, repositoryRoots, now } = sub === 'persist' ? writable() : { ...readable(), repositoryRoots: null, now: isoTime(clock) }
72
+ const scopeId = resolveScope(enablement, requested)
73
+ const scope = enablement.scopes.find((item) => item.scopeId === scopeId)
74
+ const usable = workspace !== null && workspace.workspaceRoot !== null
75
+ const machine = usable ? readMachineSettings(workspace) : null
76
+ const profile = seams.profileFor({ project, workspaceId: workspace?.workspaceId ?? 'ws-unprepared', audienceAllow: machine?.audienceAllow ?? [] })
77
+ const canonicalSnapshot = snapshotNow({ project, seams, eligibility })
78
+ const pathRegistry = usable ? createMaintenanceStateStore(workspace).readPathRegistry() : null
79
+ // Explicit empty is an honest answer of `resolve`; `persist` of an empty selection is refused and asks for it by name.
80
+ const selection = resolveSelection({ canonicalSnapshot, profile, scope, pathRegistry, allowEmpty: sub === 'resolve' || option === 'allow-empty' })
81
+ if (sub === 'resolve') {
82
+ return { exit: EXIT.ok, document: { scopeId, selection, persisted: false }, human: [`${scopeId}: ${selection.mode}, ${selection.nodes.length} note(s), ${selection.edges.length} edge(s)${selection.truncated ? ', expansion truncated' : ''}${selection.focus ? `; focus query ${selection.focus.query}` : ''}`] }
83
+ }
84
+ const written = writeSelectionState({ workspaceRoot: workspace.workspaceRoot, workspaceId: workspace.workspaceId, repositoryRoots, selection, now })
85
+ return { exit: EXIT.ok, document: { scopeId, selection, persisted: true, changed: written.changed, file: path.relative(workspace.workspaceRoot, written.file).split(path.sep).join('/') }, human: [`${scopeId}: persisted${written.changed ? '' : ' (unchanged)'}; ${selection.nodes.length} note(s)${selection.focus ? `; focus query ${selection.focus.query}` : ''}`, 'No file under .obsidian/ was written; apply a focus in the app.'] }
86
+ },
87
+ }
88
+ }
89
+
90
+ export function createConflictsOperation() {
91
+ return {
92
+ name: 'conflicts',
93
+ summary: '[ID] The shared conflict state of edited objects, read from the arbitration record: what is conflicted, pending or unreadable, and what a person does next. Read-only.',
94
+ async run({ args, readable, clock }) {
95
+ const [requested] = args
96
+ const { project, enablement, workspace, workspaceId } = readable()
97
+ const scopeId = requested === undefined ? undefined : resolveScope(enablement, requested)
98
+ if (workspace === null) return { exit: EXIT.ok, document: { scopeId: scopeId ?? null, objects: [], summary: { total: 0 }, needsPerson: false, workspace: 'not-prepared' }, human: ['no private state yet: no edit was ever recorded'] }
99
+ const view = readConflictView({ workspaceRoot: workspace.workspaceRoot, workspaceId, repositoryRoots: protectedRoots(project), clock, scopeId })
100
+ return {
101
+ exit: EXIT.ok, document: view,
102
+ human: view.objects.length === 0 ? ['no recorded object'] : view.objects.map((object) => `${object.repoId}\t${object.nodeId}\t${object.state}${object.needsPerson ? '\tneeds a person' : ''}\t${object.next}`),
103
+ }
104
+ },
105
+ }
106
+ }
107
+
108
+ export function createApplyPolicyOperation() {
109
+ return {
110
+ name: 'apply-policy',
111
+ summary: 'create FILE | show | revoke Build, digest and install a manual or automatic apply policy from a JSON request; show it; revoke it durably.',
112
+ async run({ args, readable, writable }) {
113
+ const [action, value] = args
114
+ if (action === undefined || action === 'show') {
115
+ const { workspace } = readable()
116
+ if (workspace === null) return { exit: EXIT.ok, document: { ok: true, action: 'show', installed: false, policy: null, reference: null, automaticApply: { authorized: false, reason: 'machine-settings-absent' } }, human: ['no apply policy is installed'] }
117
+ const result = runPolicySetup({ action: 'show', workspace })
118
+ return { exit: result.ok ? EXIT.ok : EXIT.notSuccess, document: result, human: [result.ok ? (result.installed ? `${result.policy.policyId} v${result.policy.version} ${result.policy.mode} ${result.policy.status}; automatic apply ${result.automaticApply.authorized ? 'authorized' : `not authorized (${result.automaticApply.reason})`}` : 'no apply policy is installed') : `[${result.refusal.code}] ${result.refusal.message}`] }
119
+ }
120
+ if (action !== 'create' && action !== 'revoke') refuse('usage', 'apply-policy create FILE | show | revoke')
121
+ const input = action === 'create' ? readRequest(value) : {}
122
+ const { workspace, repositoryRoots, now } = writable()
123
+ const result = runPolicySetup({ action, input, workspace, repositoryRoots, now })
124
+ const human = result.ok
125
+ ? [action === 'create' ? `installed ${result.policy.policyId} v${result.policy.version} (${result.policy.mode}, ${result.policy.status}); maintenance mode ${result.maintenanceMode}` : (result.revoked ? `revoked (${result.reason}); no queued edit is applied from now on, and the mode is manual` : 'no apply policy was installed')]
126
+ : [`[${result.refusal.code}] ${result.refusal.message}`]
127
+ return { exit: result.ok ? EXIT.ok : EXIT.notSuccess, document: result, human }
128
+ },
129
+ }
130
+ }
131
+
132
+ export function createSelectionContribution(options = {}) {
133
+ return {
134
+ id: SELECTION_CONTRIBUTION_ID,
135
+ register({ operations }) {
136
+ operations.register(createSelectionOperation(options))
137
+ operations.register(createConflictsOperation())
138
+ operations.register(createApplyPolicyOperation())
139
+ },
140
+ }
141
+ }
@@ -0,0 +1,82 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { refuse } from '../../../runtime/obsidian/errors.mjs'
3
+
4
+ // Focus: the full vault stays on disk and the native Graph view is filtered
5
+ // with a "Search files" query derived from the selected set. The query names
6
+ // every selected note by its exact vault-relative path, quoted and escaped
7
+ // the way the app's search syntax reads a quoted term, joined with OR.
8
+ //
9
+ // Nothing here writes a file. The query and the core-Bookmark payload are
10
+ // values a person, an agent or the acceptance procedure applies in the app;
11
+ // `.obsidian/workspace.json`, `.obsidian/graph.json` and
12
+ // `.obsidian/bookmarks.json` belong to the person and are never written by
13
+ // Atelier. Whether the installed app applies the query as built is an
14
+ // acceptance question (AP-02), not something this module can answer.
15
+
16
+ export const FOCUS_QUERY_VERSION = 'obsidian-graph-search-paths/v1'
17
+ export const FOCUS_BOOKMARK_TYPE = 'graph'
18
+ // The files under .obsidian/ the app's UI owns. Atelier reads none of them
19
+ // and writes none of them; a focus is applied through the app, never by
20
+ // editing these.
21
+ export const UI_OWNED_SETTINGS_FILES = Object.freeze(['workspace.json', 'graph.json', 'bookmarks.json'])
22
+ export const MAX_FOCUS_PATHS = 100000
23
+
24
+ // A quoted search term: backslash and double quote are escaped; anything
25
+ // that is not printable text cannot be typed into a search box and refuses.
26
+ export const FOCUS_QUERY_PRIMITIVES = Object.freeze({
27
+ escape(value) {
28
+ return value.replace(/[\\"]/g, (character) => `\\${character}`)
29
+ },
30
+ term(path) {
31
+ return `path:"${path}"`
32
+ },
33
+ join(terms) {
34
+ return terms.join(' OR ')
35
+ },
36
+ })
37
+
38
+ function normalizePath(value, index) {
39
+ if (typeof value !== 'string' || value === '') refuse('focus-path-unrepresentable', 'a focus path must be a non-empty string', { index })
40
+ const normalized = value.normalize('NFC')
41
+ // eslint-disable-next-line no-control-regex
42
+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(normalized)) refuse('focus-path-unrepresentable', 'a focus path carries a control character and cannot be a search term', { index })
43
+ if (normalized.startsWith('/') || normalized.includes('\\') || normalized.split('/').some((part) => part === '' || part === '.' || part === '..')) {
44
+ refuse('focus-path-unrepresentable', 'a focus path must be a normalized vault-relative path', { index })
45
+ }
46
+ return normalized
47
+ }
48
+
49
+ // Test seam: the oracles in test/obsidian-acceptance.test.mjs substitute a
50
+ // builder that drops escaping to prove the escaping oracle can fail.
51
+ // Production uses buildFocusQuery.
52
+ export function createFocusQueryBuilderForOracleTests(primitives = FOCUS_QUERY_PRIMITIVES) {
53
+ const { escape, term, join } = { ...FOCUS_QUERY_PRIMITIVES, ...primitives }
54
+ return function buildFocusQuery(paths) {
55
+ if (!Array.isArray(paths)) refuse('focus-selection-empty', 'a focus needs the list of selected note paths')
56
+ if (paths.length === 0) refuse('focus-selection-empty', 'a focus with no selected note would filter nothing; it is refused, never widened to the whole vault')
57
+ if (paths.length > MAX_FOCUS_PATHS) refuse('focus-too-large', `a focus names at most ${MAX_FOCUS_PATHS} notes`, { count: paths.length })
58
+ const normalized = paths.map(normalizePath)
59
+ if (new Set(normalized).size !== normalized.length) refuse('duplicate-identity', 'a focus names a note path more than once')
60
+ // The order is the order given (canonical-id order from the selection), so the same selection is the same query.
61
+ const terms = normalized.map((path) => term(escape(path)))
62
+ const query = join(terms)
63
+ return {
64
+ version: FOCUS_QUERY_VERSION,
65
+ query,
66
+ queryDigest: `sha256:${createHash('sha256').update(query, 'utf8').digest('hex')}`,
67
+ paths: normalized,
68
+ }
69
+ }
70
+ }
71
+
72
+ export const buildFocusQuery = createFocusQueryBuilderForOracleTests()
73
+
74
+ // The payload of a core-plugin graph bookmark that retains this focus. The
75
+ // app assigns the creation time when the bookmark is made there; nothing
76
+ // nondeterministic is part of the payload. Only the graph type is supported:
77
+ // core Bookmarks retain graph views, not local-graph views.
78
+ export function focusBookmarkPayload({ scopeId, query }) {
79
+ if (typeof scopeId !== 'string' || scopeId === '') refuse('invalid-scope', 'a focus bookmark names its scope')
80
+ if (typeof query !== 'string' || query === '') refuse('focus-selection-empty', 'a focus bookmark carries a non-empty query')
81
+ return { type: FOCUS_BOOKMARK_TYPE, title: `Atelier focus ${scopeId}`, options: { search: query } }
82
+ }
@@ -0,0 +1,12 @@
1
+ // Selection, focus, apply policy setup, conflict state and acceptance receipt
2
+ // validation for the Obsidian projection. Selection and focus are pure;
3
+ // selection state, policy setup and the conflict view read and write the
4
+ // workspace's private state through the runtime's own primitives; the receipt
5
+ // validator is schema validation only and closes no gate.
6
+ export { FOCUS_BOOKMARK_TYPE, FOCUS_QUERY_PRIMITIVES, FOCUS_QUERY_VERSION, MAX_FOCUS_PATHS, UI_OWNED_SETTINGS_FILES, buildFocusQuery, createFocusQueryBuilderForOracleTests, focusBookmarkPayload } from './focus.mjs'
7
+ export { EMPTY_SELECTION_REASONS, SCOPE_CONTRACT_SCHEMA, SELECTION_SCHEMA, resolveSelection, scopeDocumentOf } from './selection.mjs'
8
+ export { SELECTION_DIRECTORY, SELECTION_STATE_SCHEMA, assertWritableSelectionPath, listSelectionStates, readSelectionState, selectionStateDocument, validateSelectionState, writeSelectionState } from './selection-state.mjs'
9
+ export { CONFLICT_DISPOSITION, DEFAULT_MAX_BATCH_SIZE, DEFAULT_RETRY_BUDGET, POLICY_SETUP_ACTIONS, POLICY_SETUP_SCHEMA, buildApplyPolicy, dispatchGate, runPolicySetup } from './policy-setup.mjs'
10
+ export { CONFLICT_VIEW_SCHEMA, OBJECT_VIEW_STATES, conflictView, readConflictView } from './conflict-view.mjs'
11
+ export { ACCEPTANCE_KINDS, RECEIPT_EXT_KEY, RECEIPT_GATES, RECEIPT_GATE_IDS, RECEIPT_RULES, RECEIPT_VALIDATION_LABEL, createReceiptValidatorForOracleTests, receiptRequirementsFor, validateAcceptanceReceipt } from './receipt.mjs'
12
+ export { SELECTION_CONTRIBUTION_ID, createApplyPolicyOperation, createConflictsOperation, createSelectionContribution, createSelectionOperation } from './contribution.mjs'