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

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 (560) hide show
  1. package/CHANGELOG.md +1005 -0
  2. package/README.md +149 -9
  3. package/contracts/atelier-adoption-policy.v2.schema.json +55 -0
  4. package/contracts/atelier-architecture.v1.schema.json +258 -0
  5. package/contracts/atelier-boundary-delegations.v1.schema.json +161 -0
  6. package/contracts/atelier-boundary-owner-keys.v1.schema.json +98 -0
  7. package/contracts/atelier-build.v1.schema.json +1076 -0
  8. package/contracts/atelier-capability.v1.schema.json +1252 -0
  9. package/contracts/atelier-coordination.v1.schema.json +1050 -0
  10. package/contracts/atelier-evidence-navigation.v1.schema.json +603 -0
  11. package/contracts/atelier-harness.v1.schema.json +234 -0
  12. package/contracts/atelier-ingestion.v1.schema.json +806 -0
  13. package/contracts/atelier-inquiry.v1.schema.json +1772 -0
  14. package/contracts/atelier-instruction-adoption.v1.schema.json +490 -0
  15. package/contracts/atelier-interaction.v1.schema.json +553 -0
  16. package/contracts/atelier-judgment.v1.schema.json +690 -0
  17. package/contracts/atelier-knowledge.v1.schema.json +1512 -0
  18. package/contracts/atelier-learning.v1.schema.json +1095 -0
  19. package/contracts/atelier-migration.v3.schema.json +64 -0
  20. package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
  21. package/contracts/atelier-personal-workspace-manifest.v1.schema.json +87 -0
  22. package/contracts/atelier-personal-workspace-overlay.v1.schema.json +299 -0
  23. package/contracts/atelier-template-adoption.v1.schema.json +223 -0
  24. package/contracts/atelier-template-profile.v1.schema.json +1168 -0
  25. package/contracts/atelier-trackable.v1.schema.json +781 -0
  26. package/contracts/atelier-upgrade-plan.v3.schema.json +532 -0
  27. package/contracts/compat-baseline.json +1 -1
  28. package/docs/architecture.md +89 -0
  29. package/docs/assurance-controls.md +4 -3
  30. package/docs/blocks/capability-stewardship.md +17 -0
  31. package/docs/blocks/claims.md +16 -3
  32. package/docs/blocks/inquiry-harnesses.md +19 -0
  33. package/docs/blocks/learning-harnesses.md +17 -0
  34. package/docs/capability-stewardship.md +269 -0
  35. package/docs/coordination.md +51 -0
  36. package/docs/decision-practices.md +111 -0
  37. package/docs/decisions.md +29 -0
  38. package/docs/evidence-navigation.md +237 -0
  39. package/docs/exact-upgrades.md +3 -2
  40. package/docs/ingestion.md +181 -0
  41. package/docs/inquiry-harnesses.md +211 -0
  42. package/docs/install.md +80 -3
  43. package/docs/intake-and-guides.md +5 -0
  44. package/docs/integration-contract-decisions.md +24 -2
  45. package/docs/interaction-and-reflection.md +72 -0
  46. package/docs/knowledge-graph.md +20 -0
  47. package/docs/knowledge-health/api.md +30 -0
  48. package/docs/knowledge-health/assessment/api.md +96 -0
  49. package/docs/knowledge-health/assessment/capabilities.md +40 -0
  50. package/docs/knowledge-health/assessment/evaluation.md +27 -0
  51. package/docs/knowledge-health/assessment/facilitator.md +30 -0
  52. package/docs/knowledge-health/assessment/quickstart.md +61 -0
  53. package/docs/knowledge-health/assessment/troubleshooting.md +23 -0
  54. package/docs/knowledge-health/devday/facilitator.md +16 -0
  55. package/docs/knowledge-health/devday/participant-response.md +22 -0
  56. package/docs/knowledge-health/devday/public-default.md +107 -0
  57. package/docs/knowledge-health/devday/quickstart.md +18 -0
  58. package/docs/knowledge-health/devday/support.md +22 -0
  59. package/docs/knowledge-health/exercise.md +17 -0
  60. package/docs/knowledge-health/facilitator.md +20 -0
  61. package/docs/knowledge-health/quickstart.md +94 -0
  62. package/docs/knowledge-health/readme.md +15 -0
  63. package/docs/knowledge-health/support.md +34 -0
  64. package/docs/knowledge-setup.md +248 -0
  65. package/docs/knowledge-workspace.md +264 -0
  66. package/docs/learning-harnesses.md +238 -0
  67. package/docs/learning.md +215 -0
  68. package/docs/local-services.md +242 -26
  69. package/docs/obsidian-contract.md +878 -36
  70. package/docs/obsidian-plugin.md +619 -0
  71. package/docs/obsidian.md +809 -64
  72. package/docs/optional-ingestion.md +83 -0
  73. package/docs/personal-workspace.md +257 -0
  74. package/docs/practical-judgment.md +92 -0
  75. package/docs/progressive-ingestion-acceptance.md +89 -0
  76. package/docs/progressive-ingestion.md +235 -0
  77. package/docs/project-command-inventory.md +1 -0
  78. package/docs/release-engineering.md +56 -1
  79. package/docs/repo-boundary-guard.md +94 -0
  80. package/docs/research-starter-quickstart.md +66 -0
  81. package/docs/research-starter.md +114 -0
  82. package/docs/semantic-ingestion.md +198 -0
  83. package/docs/semantic-operation.md +317 -0
  84. package/docs/skill-steward.md +6 -0
  85. package/docs/template-profile.md +164 -0
  86. package/docs/template-upgrades.md +114 -0
  87. package/docs/trackables.md +111 -0
  88. package/docs/upgrade.md +68 -3
  89. package/fixtures/atelier-architecture/binding/invalid/document.json +19 -0
  90. package/fixtures/atelier-architecture/binding/valid/document.json +19 -0
  91. package/fixtures/atelier-architecture/catalog/invalid/document.json +290 -0
  92. package/fixtures/atelier-architecture/catalog/valid/document.json +343 -0
  93. package/fixtures/atelier-build-contract/attempt/invalid/document.json +20 -0
  94. package/fixtures/atelier-build-contract/attempt/valid/document.json +19 -0
  95. package/fixtures/atelier-build-contract/candidate/invalid/document.json +24 -0
  96. package/fixtures/atelier-build-contract/candidate/valid/document.json +23 -0
  97. package/fixtures/atelier-build-contract/decision/invalid/document.json +31 -0
  98. package/fixtures/atelier-build-contract/decision/valid/document.json +30 -0
  99. package/fixtures/atelier-build-contract/delivery/invalid/document.json +18 -0
  100. package/fixtures/atelier-build-contract/delivery/valid/document.json +17 -0
  101. package/fixtures/atelier-build-contract/gate/invalid/document.json +22 -0
  102. package/fixtures/atelier-build-contract/gate/valid/document.json +21 -0
  103. package/fixtures/atelier-build-contract/ledger/invalid/document.json +270 -0
  104. package/fixtures/atelier-build-contract/ledger/valid/document.json +269 -0
  105. package/fixtures/atelier-build-contract/objective/invalid/document.json +73 -0
  106. package/fixtures/atelier-build-contract/objective/valid/document.json +72 -0
  107. package/fixtures/atelier-build-contract/progress/invalid/document.json +18 -0
  108. package/fixtures/atelier-build-contract/progress/valid/document.json +17 -0
  109. package/fixtures/atelier-capability-contract/adoption/invalid/document.json +71 -0
  110. package/fixtures/atelier-capability-contract/adoption/valid/document.json +70 -0
  111. package/fixtures/atelier-capability-contract/event/invalid/document.json +18 -0
  112. package/fixtures/atelier-capability-contract/event/valid/document.json +17 -0
  113. package/fixtures/atelier-capability-contract/journal/invalid/document.json +382 -0
  114. package/fixtures/atelier-capability-contract/journal/valid/document.json +381 -0
  115. package/fixtures/atelier-capability-contract/notice/invalid/document.json +8 -0
  116. package/fixtures/atelier-capability-contract/notice/valid/document.json +7 -0
  117. package/fixtures/atelier-capability-contract/package/invalid/document.json +59 -0
  118. package/fixtures/atelier-capability-contract/package/valid/document.json +58 -0
  119. package/fixtures/atelier-capability-contract/plan/invalid/document.json +107 -0
  120. package/fixtures/atelier-capability-contract/plan/valid/document.json +106 -0
  121. package/fixtures/atelier-capability-contract/release/invalid/document.json +85 -0
  122. package/fixtures/atelier-capability-contract/release/valid/document.json +84 -0
  123. package/fixtures/atelier-capability-contract/state/invalid/document.json +316 -0
  124. package/fixtures/atelier-capability-contract/state/valid/document.json +315 -0
  125. package/fixtures/atelier-coordination/dependency/invalid/document.json +37 -0
  126. package/fixtures/atelier-coordination/dependency/valid/document.json +36 -0
  127. package/fixtures/atelier-coordination/directive/invalid/document.json +25 -0
  128. package/fixtures/atelier-coordination/directive/valid/document.json +24 -0
  129. package/fixtures/atelier-coordination/disposition/invalid/document.json +34 -0
  130. package/fixtures/atelier-coordination/disposition/valid/document.json +33 -0
  131. package/fixtures/atelier-coordination/outcome/invalid/document.json +39 -0
  132. package/fixtures/atelier-coordination/outcome/valid/document.json +38 -0
  133. package/fixtures/atelier-coordination/recovery/invalid/document.json +34 -0
  134. package/fixtures/atelier-coordination/recovery/valid/document.json +33 -0
  135. package/fixtures/atelier-harness-contract/handoff/invalid/document.json +25 -0
  136. package/fixtures/atelier-harness-contract/handoff/valid/document.json +24 -0
  137. package/fixtures/atelier-ingestion/invalid/authority.json +19 -0
  138. package/fixtures/atelier-ingestion/semantic-baseline.json +26 -0
  139. package/fixtures/atelier-ingestion/semantic-operation.json +75 -0
  140. package/fixtures/atelier-ingestion/valid/plan-input.json +18 -0
  141. package/fixtures/atelier-inquiry-contract/assessment/invalid/document.json +41 -0
  142. package/fixtures/atelier-inquiry-contract/assessment/valid/document.json +40 -0
  143. package/fixtures/atelier-inquiry-contract/bundle/invalid/document.json +51 -0
  144. package/fixtures/atelier-inquiry-contract/bundle/valid/document.json +50 -0
  145. package/fixtures/atelier-inquiry-contract/campaign/invalid/document.json +17 -0
  146. package/fixtures/atelier-inquiry-contract/campaign/valid/document.json +16 -0
  147. package/fixtures/atelier-inquiry-contract/decision/invalid/document.json +22 -0
  148. package/fixtures/atelier-inquiry-contract/decision/valid/document.json +21 -0
  149. package/fixtures/atelier-inquiry-contract/feedback/invalid/document.json +19 -0
  150. package/fixtures/atelier-inquiry-contract/feedback/valid/document.json +18 -0
  151. package/fixtures/atelier-inquiry-contract/handoff/invalid/document.json +92 -0
  152. package/fixtures/atelier-inquiry-contract/handoff/valid/document.json +91 -0
  153. package/fixtures/atelier-inquiry-contract/hypothesis/invalid/document.json +20 -0
  154. package/fixtures/atelier-inquiry-contract/hypothesis/valid/document.json +19 -0
  155. package/fixtures/atelier-inquiry-contract/ledger/invalid/document.json +680 -0
  156. package/fixtures/atelier-inquiry-contract/ledger/valid/document.json +679 -0
  157. package/fixtures/atelier-inquiry-contract/legacy-assessment/invalid/document.json +19 -0
  158. package/fixtures/atelier-inquiry-contract/legacy-assessment/valid/document.json +18 -0
  159. package/fixtures/atelier-inquiry-contract/request/invalid/document.json +50 -0
  160. package/fixtures/atelier-inquiry-contract/request/valid/document.json +49 -0
  161. package/fixtures/atelier-inquiry-contract/source/invalid/document.json +18 -0
  162. package/fixtures/atelier-inquiry-contract/source/valid/document.json +17 -0
  163. package/fixtures/atelier-inquiry-contract/withdrawal/invalid/document.json +16 -0
  164. package/fixtures/atelier-inquiry-contract/withdrawal/valid/document.json +15 -0
  165. package/fixtures/atelier-instruction-adoption/context-receipt/invalid/document.json +18 -0
  166. package/fixtures/atelier-instruction-adoption/context-receipt/valid/document.json +17 -0
  167. package/fixtures/atelier-instruction-adoption/plan/invalid/document.json +63 -0
  168. package/fixtures/atelier-instruction-adoption/plan/valid/document.json +62 -0
  169. package/fixtures/atelier-instruction-adoption/receipt/invalid/document.json +22 -0
  170. package/fixtures/atelier-instruction-adoption/receipt/valid/document.json +21 -0
  171. package/fixtures/atelier-instruction-adoption/state/invalid/document.json +30 -0
  172. package/fixtures/atelier-instruction-adoption/state/valid/document.json +29 -0
  173. package/fixtures/atelier-interaction/act/invalid/document.json +25 -0
  174. package/fixtures/atelier-interaction/act/valid/document.json +24 -0
  175. package/fixtures/atelier-interaction/assessment/invalid/document.json +32 -0
  176. package/fixtures/atelier-interaction/assessment/valid/document.json +31 -0
  177. package/fixtures/atelier-interaction/observation/invalid/document.json +22 -0
  178. package/fixtures/atelier-interaction/observation/valid/document.json +21 -0
  179. package/fixtures/atelier-interaction/policy/invalid/document.json +20 -0
  180. package/fixtures/atelier-interaction/policy/valid/document.json +19 -0
  181. package/fixtures/atelier-judgment/case/invalid/document.json +76 -0
  182. package/fixtures/atelier-judgment/case/valid/document.json +75 -0
  183. package/fixtures/atelier-judgment/decision/invalid/document.json +38 -0
  184. package/fixtures/atelier-judgment/decision/valid/document.json +37 -0
  185. package/fixtures/atelier-judgment/native-decision.md +3 -0
  186. package/fixtures/atelier-judgment/practice-reconsideration/definition.json +92 -0
  187. package/fixtures/atelier-judgment/practice-reconsideration/scenario.json +435 -0
  188. package/fixtures/atelier-knowledge-contract/activation/invalid/document.json +26 -0
  189. package/fixtures/atelier-knowledge-contract/activation/valid/document.json +25 -0
  190. package/fixtures/atelier-knowledge-contract/contribution/invalid/document.json +34 -0
  191. package/fixtures/atelier-knowledge-contract/contribution/valid/document.json +33 -0
  192. package/fixtures/atelier-knowledge-contract/domain/invalid/document.json +41 -0
  193. package/fixtures/atelier-knowledge-contract/domain/valid/document.json +40 -0
  194. package/fixtures/atelier-knowledge-contract/domain-revision/invalid/document.json +46 -0
  195. package/fixtures/atelier-knowledge-contract/domain-revision/valid/document.json +45 -0
  196. package/fixtures/atelier-knowledge-contract/evaluation/invalid/document.json +21 -0
  197. package/fixtures/atelier-knowledge-contract/evaluation/valid/document.json +20 -0
  198. package/fixtures/atelier-knowledge-contract/ledger/invalid/document.json +318 -0
  199. package/fixtures/atelier-knowledge-contract/ledger/valid/document.json +317 -0
  200. package/fixtures/atelier-knowledge-contract/relation/invalid/document.json +25 -0
  201. package/fixtures/atelier-knowledge-contract/relation/valid/document.json +24 -0
  202. package/fixtures/atelier-knowledge-contract/review/invalid/document.json +18 -0
  203. package/fixtures/atelier-knowledge-contract/review/valid/document.json +17 -0
  204. package/fixtures/atelier-knowledge-contract/withdrawal/invalid/document.json +16 -0
  205. package/fixtures/atelier-knowledge-contract/withdrawal/valid/document.json +15 -0
  206. package/fixtures/atelier-learning/invalid/authority.json +14 -0
  207. package/fixtures/atelier-learning/valid/capture.json +13 -0
  208. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/authority-key.json +17 -0
  209. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/nonempty-ext.json +25 -0
  210. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/wrong-schema.json +22 -0
  211. package/fixtures/atelier-personal-workspace-contract/manifest/valid/manifest.json +22 -0
  212. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/authority-key.json +25 -0
  213. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/unknown-top-level-field.json +27 -0
  214. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/wrong-schema.json +24 -0
  215. package/fixtures/atelier-trackable/definition/invalid/document.json +15 -0
  216. package/fixtures/atelier-trackable/definition/valid/document.json +14 -0
  217. package/fixtures/atelier-trackable/request/invalid/document.json +23 -0
  218. package/fixtures/atelier-trackable/request/valid/document.json +22 -0
  219. package/fixtures/boundary-delegations/invalid/authority-key.v1.json +30 -0
  220. package/fixtures/boundary-delegations/invalid/push-operation.v1.json +28 -0
  221. package/fixtures/boundary-delegations/invalid/unsigned.v1.json +25 -0
  222. package/fixtures/boundary-delegations/valid/empty.v1.json +4 -0
  223. package/fixtures/boundary-delegations/valid/signed-operator.v1.json +30 -0
  224. package/fixtures/boundary-owner-keys/invalid/wrong-algorithm.v1.json +15 -0
  225. package/fixtures/boundary-owner-keys/invalid/wrong-curve.v1.json +15 -0
  226. package/fixtures/boundary-owner-keys/valid/one-owner.v1.json +17 -0
  227. package/fixtures/capability-packages/adoption.example.json +70 -0
  228. package/fixtures/capability-packages/evidence-review/capability-package.json +58 -0
  229. package/fixtures/capability-packages/evidence-review/capability-release.json +84 -0
  230. package/fixtures/capability-packages/evidence-review/evaluation.md +15 -0
  231. package/fixtures/capability-packages/evidence-review/skills/evidence-review/SKILL.md +22 -0
  232. package/fixtures/capability-packages/evidence-review/skills/evidence-review/references/ledger.md +7 -0
  233. package/fixtures/capability-packages/research/capability-package.json +88 -0
  234. package/fixtures/capability-packages/research/capability-release.json +134 -0
  235. package/fixtures/capability-packages/research/evaluation.md +15 -0
  236. package/fixtures/capability-packages/research/skills/research-integrate/SKILL.md +29 -0
  237. package/fixtures/capability-packages/research/skills/research-integrate/references/integration.md +10 -0
  238. package/fixtures/capability-packages/research/skills/research-plan/SKILL.md +27 -0
  239. package/fixtures/capability-packages/research/skills/research-plan/references/inquiries.md +26 -0
  240. package/fixtures/capability-packages/research/skills/research-synthesize/SKILL.md +23 -0
  241. package/fixtures/capability-packages/research/skills/research-synthesize/references/synthesis.md +12 -0
  242. package/fixtures/evidence-navigation/invalid/duplicate-operation.json +16 -0
  243. package/fixtures/evidence-navigation/invalid/instruction-kind.json +36 -0
  244. package/fixtures/evidence-navigation/invalid/invalid-digest.json +36 -0
  245. package/fixtures/evidence-navigation/invalid/invalid-time.json +36 -0
  246. package/fixtures/evidence-navigation/invalid/missing-attribution.json +35 -0
  247. package/fixtures/evidence-navigation/invalid/reserved-extension.json +19 -0
  248. package/fixtures/evidence-navigation/invalid/unattributed-acceptance.json +36 -0
  249. package/fixtures/evidence-navigation/invalid/uncoerced-version.json +16 -0
  250. package/fixtures/evidence-navigation/invalid/unknown-currency.json +17 -0
  251. package/fixtures/evidence-navigation/invalid/unknown-selector-field.json +37 -0
  252. package/fixtures/evidence-navigation/invalid/unsupported-operation.json +15 -0
  253. package/fixtures/evidence-navigation/valid/claim.json +36 -0
  254. package/fixtures/evidence-navigation/valid/host.json +16 -0
  255. package/fixtures/evidence-navigation/valid/profile.json +16 -0
  256. package/fixtures/evidence-navigation/valid/snapshot.json +17 -0
  257. package/fixtures/harness-packages/build-harness/capability-package.json +61 -0
  258. package/fixtures/harness-packages/build-harness/capability-release.json +82 -0
  259. package/fixtures/harness-packages/build-harness/evaluation.md +3 -0
  260. package/fixtures/harness-packages/build-harness/skills/atelier-build-harness/SKILL.md +59 -0
  261. package/fixtures/harness-packages/knowledge-harness/capability-package.json +61 -0
  262. package/fixtures/harness-packages/knowledge-harness/capability-release.json +82 -0
  263. package/fixtures/harness-packages/knowledge-harness/evaluation.md +3 -0
  264. package/fixtures/harness-packages/knowledge-harness/skills/atelier-knowledge-harness/SKILL.md +60 -0
  265. package/fixtures/harnesses/learning-cycle.json +780 -0
  266. package/fixtures/inquiry/workshop.json +675 -0
  267. package/fixtures/inquiry-packages/discovery-harness/capability-package.json +61 -0
  268. package/fixtures/inquiry-packages/discovery-harness/capability-release.json +82 -0
  269. package/fixtures/inquiry-packages/discovery-harness/evaluation.md +3 -0
  270. package/fixtures/inquiry-packages/discovery-harness/skills/atelier-discovery-harness/SKILL.md +48 -0
  271. package/fixtures/inquiry-packages/research-harness/capability-package.json +61 -0
  272. package/fixtures/inquiry-packages/research-harness/capability-release.json +82 -0
  273. package/fixtures/inquiry-packages/research-harness/evaluation.md +3 -0
  274. package/fixtures/inquiry-packages/research-harness/skills/atelier-research-harness/SKILL.md +43 -0
  275. package/fixtures/knowledge-health/calibration-suite.json +26 -0
  276. package/fixtures/knowledge-health/calibration-trial.json +37 -0
  277. package/fixtures/knowledge-health/corrected/checklist.md +18 -0
  278. package/fixtures/knowledge-health/devday/participant-record.json +19 -0
  279. package/fixtures/knowledge-health/expected-summary.json +27 -0
  280. package/fixtures/knowledge-health/workshop/README.md +20 -0
  281. package/fixtures/knowledge-health/workshop/atelier.project.json +7 -0
  282. package/fixtures/knowledge-health/workshop/knowledge-plan.json +65 -0
  283. package/fixtures/knowledge-health/workshop/records/checklist.md +18 -0
  284. package/fixtures/knowledge-health/workshop/records/workshop.md +18 -0
  285. package/fixtures/knowledge-health/workshop/repo-access.v1.json +5 -0
  286. package/fixtures/knowledge-health/workspace/atelier.project.json +18 -0
  287. package/fixtures/knowledge-health/workspace/knowledge-plan.json +74 -0
  288. package/fixtures/knowledge-health/workspace/records/checklist.md +18 -0
  289. package/fixtures/knowledge-health/workspace/records/pilot.md +16 -0
  290. package/fixtures/knowledge-health/workspace/repo-access.v1.json +9 -0
  291. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
  292. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
  293. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
  294. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
  295. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
  296. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
  297. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
  298. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
  299. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
  300. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
  301. package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
  302. package/fixtures/obsidian/edits/workspace.json +1 -1
  303. package/fixtures/obsidian/materialization/expected-full.json +21 -21
  304. package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
  305. package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
  306. package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
  307. package/fixtures/personal-workspace/overlay.json +24 -0
  308. package/fixtures/templates/local-library.v1.json +75 -0
  309. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/invalid.json +16 -0
  310. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/valid.json +13 -0
  311. package/fixtures/templates/upgrade-contracts/migration.v3/invalid.json +24 -0
  312. package/fixtures/templates/upgrade-contracts/migration.v3/valid.json +21 -0
  313. package/fixtures/templates/upgrade-contracts/template-adoption.v1/invalid.json +60 -0
  314. package/fixtures/templates/upgrade-contracts/template-adoption.v1/valid.json +57 -0
  315. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/invalid.json +85 -0
  316. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/valid.json +82 -0
  317. package/package.json +64 -6
  318. package/plugins/obsidian/main.js +444 -0
  319. package/plugins/obsidian/manifest.json +9 -0
  320. package/plugins/obsidian/styles.css +22 -0
  321. package/skills/claude/atelier-build-harness/SKILL.md +59 -0
  322. package/skills/claude/atelier-coordination/SKILL.md +32 -0
  323. package/skills/claude/atelier-discovery-harness/SKILL.md +48 -0
  324. package/skills/claude/atelier-guided-upgrade/SKILL.md +29 -0
  325. package/skills/claude/atelier-interaction/SKILL.md +34 -0
  326. package/skills/claude/atelier-knowledge-harness/SKILL.md +60 -0
  327. package/skills/claude/atelier-knowledge-setup/SKILL.md +72 -0
  328. package/skills/claude/atelier-practical-judgment/SKILL.md +64 -0
  329. package/skills/claude/atelier-research-harness/SKILL.md +43 -0
  330. package/skills/claude/atelier-skill-steward/SKILL.md +53 -0
  331. package/skills/claude/atelier-trackables/SKILL.md +40 -0
  332. package/skills/codex/atelier-build-harness/SKILL.md +59 -0
  333. package/skills/codex/atelier-coordination/SKILL.md +32 -0
  334. package/skills/codex/atelier-discovery-harness/SKILL.md +48 -0
  335. package/skills/codex/atelier-guided-upgrade/SKILL.md +29 -0
  336. package/skills/codex/atelier-interaction/SKILL.md +34 -0
  337. package/skills/codex/atelier-knowledge-harness/SKILL.md +60 -0
  338. package/skills/codex/atelier-knowledge-setup/SKILL.md +72 -0
  339. package/skills/codex/atelier-practical-judgment/SKILL.md +64 -0
  340. package/skills/codex/atelier-research-harness/SKILL.md +43 -0
  341. package/skills/codex/atelier-skill-steward/SKILL.md +53 -0
  342. package/skills/codex/atelier-trackables/SKILL.md +40 -0
  343. package/src/architecture/catalog.mjs +355 -0
  344. package/src/architecture/index.mjs +93 -0
  345. package/src/boundary/content-rules.mjs +4 -2
  346. package/src/boundary/delegation-command.mjs +158 -0
  347. package/src/boundary/delegation.mjs +396 -0
  348. package/src/boundary/policy.mjs +52 -10
  349. package/src/build/git.mjs +18 -0
  350. package/src/build/index.mjs +3 -0
  351. package/src/build/ledger.mjs +88 -0
  352. package/src/capabilities/adoption.mjs +381 -0
  353. package/src/capabilities/evidence.mjs +140 -0
  354. package/src/capabilities/files.mjs +124 -0
  355. package/src/capabilities/index.mjs +4 -0
  356. package/src/capabilities/instructions.mjs +250 -0
  357. package/src/capabilities/package.mjs +136 -0
  358. package/src/cli/command-failure.mjs +36 -0
  359. package/src/cli/execute-command.mjs +16 -2
  360. package/src/cli/run.mjs +65 -9
  361. package/src/coauthor/store.mjs +43 -2
  362. package/src/collaboration/proposals.mjs +189 -0
  363. package/src/commands/architecture.mjs +27 -0
  364. package/src/commands/boundary.mjs +3 -0
  365. package/src/commands/capability.mjs +73 -0
  366. package/src/commands/enroll.mjs +3 -0
  367. package/src/commands/harness.mjs +79 -0
  368. package/src/commands/ingest.mjs +39 -0
  369. package/src/commands/init.mjs +4 -1
  370. package/src/commands/inquiry.mjs +66 -0
  371. package/src/commands/knowledge-semantic.mjs +128 -0
  372. package/src/commands/knowledge.mjs +119 -0
  373. package/src/commands/learn.mjs +61 -0
  374. package/src/commands/obsidian.mjs +832 -62
  375. package/src/commands/practice.mjs +24 -0
  376. package/src/commands/setup.mjs +22 -2
  377. package/src/commands/trackable.mjs +19 -0
  378. package/src/contracts/corpus.mjs +36 -0
  379. package/src/coordination/index.mjs +110 -0
  380. package/src/egress/forbidden-egress.mjs +2 -0
  381. package/src/evidence-navigation/contracts.mjs +60 -0
  382. package/src/evidence-navigation/evaluate.mjs +69 -0
  383. package/src/evidence-navigation/generation.mjs +22 -0
  384. package/src/evidence-navigation/index.mjs +2 -0
  385. package/src/evidence-navigation/local.mjs +167 -0
  386. package/src/evidence-navigation/schema.generated.mjs +604 -0
  387. package/src/evidence-navigation/types.mjs +12 -0
  388. package/src/export/atelier-export-contract.mjs +35 -8
  389. package/src/graph/enroll.mjs +248 -0
  390. package/src/graph/graph.mjs +17 -1
  391. package/src/graph/knowledge-graph.mjs +58 -8
  392. package/src/harnesses/contracts.mjs +41 -0
  393. package/src/harnesses/exchange.mjs +154 -0
  394. package/src/harnesses/feedback.mjs +16 -0
  395. package/src/harnesses/history.mjs +37 -0
  396. package/src/harnesses/index.mjs +4 -0
  397. package/src/harnesses/store.mjs +29 -0
  398. package/src/ingestion/contracts.mjs +41 -0
  399. package/src/ingestion/evaluation.mjs +119 -0
  400. package/src/ingestion/optional/composition/index.mjs +171 -0
  401. package/src/ingestion/optional/evaluation/index.mjs +219 -0
  402. package/src/ingestion/optional/graphify/composition-binding.mjs +60 -0
  403. package/src/ingestion/optional/graphify/composition_receiving.py +185 -0
  404. package/src/ingestion/optional/graphify/graphify_supplier.py +933 -0
  405. package/src/ingestion/optional/graphify/json_bridge.py +163 -0
  406. package/src/ingestion/optional/graphify/query_receiving.py +209 -0
  407. package/src/ingestion/optional/index.mjs +9 -0
  408. package/src/ingestion/optional/jev/bridge.mjs +166 -0
  409. package/src/ingestion/optional/jev/composition-binding.mjs +116 -0
  410. package/src/ingestion/optional/jev/judgments.mjs +174 -0
  411. package/src/ingestion/optional/jev/native-translation.mjs +32 -0
  412. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/__init__.py +7 -0
  413. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/adapter.py +966 -0
  414. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/artifacts.py +101 -0
  415. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/projection.py +146 -0
  416. package/src/ingestion/optional/lightrag/composition-binding.mjs +67 -0
  417. package/src/ingestion/optional/vanilla/composition-binding.mjs +102 -0
  418. package/src/ingestion/optional/vanilla/index.mjs +218 -0
  419. package/src/ingestion/optional/vanilla/native-normalization.mjs +187 -0
  420. package/src/ingestion/processors.mjs +218 -0
  421. package/src/ingestion/semantic.mjs +203 -0
  422. package/src/ingestion/store.mjs +372 -0
  423. package/src/inquiry/beliefs.mjs +32 -0
  424. package/src/inquiry/contracts.mjs +23 -0
  425. package/src/inquiry/index.mjs +5 -0
  426. package/src/inquiry/ledger.mjs +135 -0
  427. package/src/inquiry/lenses.mjs +15 -0
  428. package/src/inquiry/projection.mjs +77 -0
  429. package/src/inquiry/store.mjs +35 -0
  430. package/src/intake/read-scope.mjs +12 -0
  431. package/src/intake/store.mjs +69 -6
  432. package/src/interaction/index.mjs +91 -0
  433. package/src/judgment/index.mjs +82 -0
  434. package/src/judgment/practice-evaluation.mjs +70 -0
  435. package/src/judgment/practice.mjs +144 -0
  436. package/src/knowledge/context.mjs +217 -0
  437. package/src/knowledge/index.mjs +5 -0
  438. package/src/knowledge/ingestion.mjs +79 -0
  439. package/src/knowledge/intake.mjs +33 -0
  440. package/src/knowledge/ledger.mjs +110 -0
  441. package/src/knowledge/participatory/guidance.mjs +36 -0
  442. package/src/knowledge/participatory/guided-contribution.mjs +44 -0
  443. package/src/knowledge/participatory/index.mjs +2 -0
  444. package/src/knowledge/participatory/installed-workshop.mjs +108 -0
  445. package/src/knowledge/plan.mjs +67 -0
  446. package/src/knowledge/projection.mjs +43 -0
  447. package/src/knowledge/semantic-operation.mjs +567 -0
  448. package/src/knowledge/sessions.mjs +467 -0
  449. package/src/knowledge/workspace.mjs +295 -0
  450. package/src/knowledge-health/assessment.mjs +165 -0
  451. package/src/knowledge-health/capabilities.mjs +350 -0
  452. package/src/knowledge-health/context.mjs +62 -0
  453. package/src/knowledge-health/example.mjs +47 -0
  454. package/src/knowledge-health/index.mjs +10 -0
  455. package/src/knowledge-health/owner-join.mjs +88 -0
  456. package/src/knowledge-health/proof/common.mjs +24 -0
  457. package/src/knowledge-health/proof/installed-check.mjs +100 -0
  458. package/src/knowledge-health/proof/native-readback.mjs +37 -0
  459. package/src/knowledge-health/proof/rehearse.mjs +68 -0
  460. package/src/knowledge-health/proof/validate-participant.mjs +17 -0
  461. package/src/knowledge-health/proof/verify-native-capture.mjs +14 -0
  462. package/src/knowledge-health/proof/workshop-check.mjs +36 -0
  463. package/src/knowledge-health/rule-identity.mjs +44 -0
  464. package/src/knowledge-health/workshop-composition.mjs +19 -0
  465. package/src/knowledge-health/workshop-profile.mjs +176 -0
  466. package/src/knowledge-health/workshop.mjs +99 -0
  467. package/src/learning/adapters.mjs +68 -0
  468. package/src/learning/assistance.mjs +127 -0
  469. package/src/learning/contracts.mjs +65 -0
  470. package/src/learning/store.mjs +272 -0
  471. package/src/personal-workspace/index.mjs +391 -0
  472. package/src/personal-workspace/selection.mjs +385 -0
  473. package/src/project/config.mjs +8 -0
  474. package/src/project/durable-state.mjs +3 -1
  475. package/src/project/file-class.mjs +1 -1
  476. package/src/project/private-state.mjs +10 -1
  477. package/src/projection/obsidian/contracts.mjs +224 -18
  478. package/src/projection/obsidian/edits/apply.mjs +63 -19
  479. package/src/projection/obsidian/edits/regions.mjs +137 -42
  480. package/src/projection/obsidian/materialize/index.mjs +6 -3
  481. package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
  482. package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
  483. package/src/projection/obsidian/materialize/redaction.mjs +327 -0
  484. package/src/projection/obsidian/materialize/settings.mjs +113 -34
  485. package/src/projection/obsidian/personal-workspace.mjs +436 -0
  486. package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
  487. package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
  488. package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
  489. package/src/projection/obsidian/proposals/adapter.mjs +47 -8
  490. package/src/projection/obsidian/proposals/observation.mjs +20 -7
  491. package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
  492. package/src/projection/obsidian/publication/index.mjs +2 -1
  493. package/src/projection/obsidian/publication/publisher.mjs +312 -36
  494. package/src/projection/obsidian/publication/transport.mjs +63 -8
  495. package/src/projection/obsidian/publication/vault-list.mjs +200 -0
  496. package/src/projection/obsidian/recovery/index.mjs +4 -1
  497. package/src/projection/obsidian/recovery/restart.mjs +3 -1
  498. package/src/projection/obsidian/recovery/store.mjs +168 -8
  499. package/src/projection/obsidian/selection-ui/contribution.mjs +6 -5
  500. package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
  501. package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
  502. package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
  503. package/src/readiness/readiness.mjs +10 -3
  504. package/src/reflection/index.mjs +56 -0
  505. package/src/runtime/obsidian/app-capability.mjs +104 -12
  506. package/src/runtime/obsidian/app-production-seams.mjs +161 -42
  507. package/src/runtime/obsidian/app-registration.mjs +313 -0
  508. package/src/runtime/obsidian/app-restart.mjs +154 -0
  509. package/src/runtime/obsidian/engine.mjs +288 -29
  510. package/src/runtime/obsidian/extension-points.mjs +1 -1
  511. package/src/runtime/obsidian/index.mjs +15 -8
  512. package/src/runtime/obsidian/launch-plan.mjs +91 -0
  513. package/src/runtime/obsidian/lifecycle.mjs +193 -22
  514. package/src/runtime/obsidian/login-item.mjs +332 -0
  515. package/src/runtime/obsidian/machine-settings.mjs +119 -12
  516. package/src/runtime/obsidian/opening.mjs +369 -63
  517. package/src/runtime/obsidian/pending-edits.mjs +10 -0
  518. package/src/runtime/obsidian/pipeline.mjs +71 -3
  519. package/src/runtime/obsidian/plugin-channel.mjs +289 -0
  520. package/src/runtime/obsidian/plugin-choice.mjs +182 -0
  521. package/src/runtime/obsidian/plugin-drift.mjs +124 -0
  522. package/src/runtime/obsidian/plugin-presence.mjs +61 -0
  523. package/src/runtime/obsidian/process-identity.mjs +20 -10
  524. package/src/runtime/obsidian/project-views.mjs +282 -0
  525. package/src/runtime/obsidian/questions.mjs +48 -0
  526. package/src/runtime/obsidian/release-watch.mjs +56 -0
  527. package/src/runtime/obsidian/service-main.mjs +113 -17
  528. package/src/runtime/obsidian/service-manager-production.mjs +61 -0
  529. package/src/runtime/obsidian/service-managers.mjs +275 -0
  530. package/src/runtime/obsidian/service-record.mjs +105 -5
  531. package/src/runtime/obsidian/service-server.mjs +50 -12
  532. package/src/runtime/obsidian/service.mjs +134 -7
  533. package/src/runtime/obsidian/startup-adapters.mjs +59 -21
  534. package/src/runtime/obsidian/state-store.mjs +13 -1
  535. package/src/runtime/obsidian/unheld-evidence.mjs +84 -0
  536. package/src/runtime/obsidian/vault-location.mjs +300 -0
  537. package/src/runtime/obsidian/view-counts.mjs +34 -0
  538. package/src/server/local-sidecar.mjs +193 -7
  539. package/src/server/server.mjs +45 -6
  540. package/src/template-bindings/project.mjs +92 -0
  541. package/src/templates/profile.mjs +225 -0
  542. package/src/trackables/domain.mjs +175 -0
  543. package/src/trackables/store.mjs +114 -0
  544. package/src/ui/knowledge-page.mjs +1388 -0
  545. package/src/upgrade/explanation.mjs +1 -1
  546. package/src/upgrade/template-participant.mjs +171 -0
  547. package/src/upgrade/transaction.mjs +72 -25
  548. package/src/upgrade/upgrade.mjs +2 -1
  549. package/templates/external-project-workspace/pack-lifecycle.json +6 -1
  550. package/templates/knowledge-workspace/.gitattributes +2 -0
  551. package/templates/knowledge-workspace/AGENTS.md +27 -0
  552. package/templates/knowledge-workspace/README.md +44 -0
  553. package/templates/knowledge-workspace/atelier.project.json +22 -0
  554. package/templates/knowledge-workspace/gitignore +6 -0
  555. package/templates/knowledge-workspace/knowledge-plan.json +111 -0
  556. package/templates/knowledge-workspace/knowledge-plan.schema.json +281 -0
  557. package/templates/knowledge-workspace/records/forecast.md +22 -0
  558. package/templates/knowledge-workspace/records/inspection.md +25 -0
  559. package/templates/knowledge-workspace/records/telescope.md +24 -0
  560. package/templates/knowledge-workspace/repo-access.v1.json +9 -0
@@ -1,10 +1,12 @@
1
1
  import { execFile, execFileSync } from 'node:child_process'
2
2
  import { readlinkSync } from 'node:fs'
3
+ import path from 'node:path'
3
4
  import { POLICY_SETTINGS_PATH, buildEvalCode, createInProcessHost, runInProcess, validatePayload } from './bridge-script.mjs'
5
+ import { obsidianUserDataDir, readObsidianSettings, vaultRoute } from './vault-list.mjs'
4
6
 
5
7
  // Editor coordination adapter.
6
8
  //
7
- // adapter.probe({ vaultRoot }) -> { state: 'coordinated' | 'absent' | 'uncoordinated', reason }
9
+ // adapter.probe({ vaultRoot }) -> { state: 'coordinated' | 'absent' | 'uncoordinated', reason, code? }
8
10
  // adapter.inspect(payload) -> reply read-only, may retry
9
11
  // adapter.collect(payload) -> reply read-only, may retry
10
12
  // adapter.publish(payload) -> reply sent at most once
@@ -25,6 +27,16 @@ export class TransportTimeout extends Error {
25
27
  }
26
28
  }
27
29
 
30
+ // A call that no route can take: the app's list says no call reaches only
31
+ // this vault. `code` says why; the call is never made.
32
+ export class RouteRefusal extends Error {
33
+ constructor(code, message) {
34
+ super(`${code}: ${message}`)
35
+ this.name = 'RouteRefusal'
36
+ this.code = code
37
+ }
38
+ }
39
+
28
40
  const READ_RETRIES = 2
29
41
 
30
42
  // `qualification`, when given, is the app qualification this adapter was
@@ -71,7 +83,8 @@ export function createEditorAdapter({ call, processProbe, kind = 'custom', quali
71
83
  // Path selection. `absent` is returned only when the process probe says,
72
84
  // positively, that no Obsidian is running. A running or unknowable app
73
85
  // that does not answer for this exact vault is `uncoordinated`, and so is
74
- // any app while this adapter's qualification checked no version.
86
+ // any app while this adapter's qualification checked no version. When no
87
+ // call could be routed to only this vault, `code` names the route's refusal.
75
88
  probe: ({ vaultRoot }) => serialized(async () => {
76
89
  let processes
77
90
  try { processes = await processProbe() } catch { processes = 'unknown' }
@@ -83,7 +96,7 @@ export function createEditorAdapter({ call, processProbe, kind = 'custom', quali
83
96
  if (['inspected', 'path-unsafe'].includes(reply.status) && reply.vaultBasePath === vaultRoot) return { state: 'coordinated', reason: 'the app answered for this vault' }
84
97
  return { state: 'uncoordinated', reason: reply.status === 'vault-mismatch' ? 'the app answered for another vault' : `the app answered ${reply.status}` }
85
98
  } catch (error) {
86
- return { state: 'uncoordinated', reason: `an Obsidian process may be running and the bridge did not answer: ${String(error.message || error).slice(0, 200)}` }
99
+ return { state: 'uncoordinated', ...(error instanceof RouteRefusal ? { code: error.code } : {}), reason: `an Obsidian process may be running and the bridge did not answer: ${String(error.message || error).slice(0, 200)}` }
87
100
  }
88
101
  }),
89
102
  }
@@ -111,13 +124,55 @@ export function defaultCliPath(platform = process.platform) {
111
124
  return 'obsidian-cli'
112
125
  }
113
126
 
127
+ // A directory that is no vault, for a call that must not choose one by where it runs.
128
+ export const NEUTRAL_DIRECTORY = (() => { try { return path.parse(process.cwd()).root } catch { return path.sep } })()
129
+
130
+ // The working directory and leading arguments of a call that goes where a
131
+ // `vaultRoute` says: into the vault's folder, or naming the vault's id first
132
+ // from a directory that is no vault (`neutral`), or, for any other route, from
133
+ // that directory naming no vault.
134
+ export function routedCall(route, neutral = NEUTRAL_DIRECTORY) {
135
+ if (route?.how === 'folder') return { cwd: route.cwd, args: [] }
136
+ if (route?.how === 'id') return { cwd: neutral, args: [`vault=${route.id}`] }
137
+ return { cwd: neutral, args: [] }
138
+ }
139
+
140
+ // Which window a publication call reaches. The app answers a command in the
141
+ // window of the vault its list routes the call to (see vault-list.mjs),
142
+ // opening that vault first when it is closed, and otherwise in the vault
143
+ // window that had focus last, which may be any vault the person uses. While
144
+ // the app lists this vault as open, a call therefore runs in its folder, or,
145
+ // when a vault listed at a folder above it would take a call run there, names
146
+ // its id; when neither reaches only this vault, no call is made (the error
147
+ // says `vault-inside-another-vault`), nor when the app has the vault open in
148
+ // more than one window, one per entry of its list that names this folder
149
+ // (`vault-open-in-several-windows`): a call would coordinate with one of them
150
+ // only. Otherwise a call runs in a directory
151
+ // that is no vault and names none: a closed vault is never reopened by
152
+ // maintenance, no other vault is reached through this one's folder, and the
153
+ // working directory of whoever started the service never picks a vault. The
154
+ // script still checks that it answered for exactly this vault.
155
+ export function publicationRoute({ env = process.env, platform = process.platform } = {}) {
156
+ const userDataDir = obsidianUserDataDir({ platform, env })
157
+ return (payload) => {
158
+ const settings = readObsidianSettings({ userDataDir })
159
+ const route = settings.ok ? vaultRoute({ vaults: settings.vaults, vaultRoot: payload.vaultRoot, open: true }) : { how: 'unlisted' }
160
+ if (route.how === 'ambiguous') throw new RouteRefusal('vault-inside-another-vault', 'Obsidian lists a vault at a folder above this one, and no call reaches only this vault; no call was made')
161
+ if (route.how === 'duplicated') throw new RouteRefusal('vault-open-in-several-windows', `Obsidian has this vault open in ${route.entries.length} windows (${route.entries.map((entry) => entry.id).join(', ')}), and a call reaches one of them only; no call was made`)
162
+ return routedCall(route)
163
+ }
164
+ }
165
+
114
166
  // The CLI reaches the app through a socket under $HOME, so `env` selects the
115
167
  // app instance. It is passed explicitly and never edited here. A call that
116
168
  // outlives its timeout is killed with SIGKILL: the CLI ignores SIGTERM while
117
- // it waits on the app.
118
- export function createObsidianCliCall({ cliPath = defaultCliPath(), env = process.env, timeoutMs = 20000 } = {}) {
169
+ // it waits on the app. `route(payload)` says where a call runs and what it
170
+ // names first ({ cwd, args }); a route that throws makes no call.
171
+ export function createObsidianCliCall({ cliPath = defaultCliPath(), env = process.env, timeoutMs = 20000, route = () => routedCall(null) } = {}) {
119
172
  return (payload) => new Promise((resolve, reject) => {
120
- execFile(cliPath, ['eval', `code=${buildEvalCode(payload)}`], { env, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
173
+ let where
174
+ try { where = route(payload) } catch (error) { return reject(error) }
175
+ execFile(cliPath, [...where.args, 'eval', `code=${buildEvalCode(payload)}`], { env, cwd: where.cwd, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
121
176
  if (error) return reject(error.killed ? new TransportTimeout(`CLI call timed out: ${payload.op}`) : new Error(`CLI call failed: ${String(error.message).slice(0, 300)}`))
122
177
  const out = stdout || stderr || ''
123
178
  const start = out.indexOf('=> ')
@@ -207,6 +262,6 @@ export function defaultObsidianProcessProbe({ platform = process.platform, run =
207
262
  }
208
263
  }
209
264
 
210
- export function createObsidianCliAdapter({ cliPath, env, timeoutMs, processProbe = () => defaultObsidianProcessProbe(), qualification } = {}) {
211
- return createEditorAdapter({ call: createObsidianCliCall({ cliPath, env, timeoutMs }), processProbe, kind: 'obsidian-cli', qualification })
265
+ export function createObsidianCliAdapter({ cliPath, env = process.env, timeoutMs, processProbe = () => defaultObsidianProcessProbe(), qualification, route = publicationRoute({ env }) } = {}) {
266
+ return createEditorAdapter({ call: createObsidianCliCall({ cliPath, env, timeoutMs, route }), processProbe, kind: 'obsidian-cli', qualification })
212
267
  }
@@ -0,0 +1,200 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { openRegularFileNoFollow, realPathAsStored } from '../../../project/private-state.mjs'
4
+
5
+ // Obsidian's own list of the folders it knows as vaults, read only.
6
+ //
7
+ // The app keeps the list in `obsidian.json` in its user-data directory, as
8
+ // `{ "vaults": { "<id>": { "path", "ts", "open"? } }, ...other settings }`.
9
+ // It reads the file when it starts and rewrites the whole file whenever the
10
+ // list changes. `open` is true while a window of that vault is open, and stays
11
+ // true for the vaults that were open when the app quit, which it reopens on its
12
+ // next start. `obsidian://open?path=` finds a vault only through this list.
13
+ //
14
+ // Nothing here writes the file. The one write Atelier makes to it is in
15
+ // src/runtime/obsidian/app-registration.mjs, and only while no Obsidian runs.
16
+ //
17
+ // The list also decides which window answers a command-line call (1.13.7): a
18
+ // first argument `vault=<value>` names the first listed vault whose id is the
19
+ // value, or whose folder's name is the value in any letter case; without one,
20
+ // the first listed vault whose folder is the tool's working directory or
21
+ // contains it takes the call, the folders compared as written; failing both,
22
+ // the vault window that had focus last. The first in list order, not the
23
+ // deepest: a vault listed at a folder above another one takes the calls run
24
+ // inside it. The app opens a vault that takes a call when it is closed.
25
+
26
+ export const OBSIDIAN_SETTINGS_FILE = 'obsidian.json'
27
+ // Far more than a list of vaults and a few settings; a larger file is not read.
28
+ export const MAX_OBSIDIAN_SETTINGS_BYTES = 4 * 1024 * 1024
29
+
30
+ // The user-data directory of the app for this account: Electron's, for an app
31
+ // named `obsidian`. Null where it is not known (another platform, no HOME). A
32
+ // Flatpak or snap build keeps its own elsewhere and is not found here (see
33
+ // obsidianSandboxedBuild). Both known platforms have POSIX paths, whatever
34
+ // platform asks.
35
+ export function obsidianUserDataDir({ platform = process.platform, env = process.env } = {}) {
36
+ const home = env.HOME
37
+ if (platform !== 'darwin' && platform !== 'linux') return null
38
+ if (typeof home !== 'string' || !path.posix.isAbsolute(home)) return null
39
+ if (platform === 'darwin') return path.posix.join(home, 'Library', 'Application Support', 'obsidian')
40
+ const config = typeof env.XDG_CONFIG_HOME === 'string' && path.posix.isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : path.posix.join(home, '.config')
41
+ return path.posix.join(config, 'obsidian')
42
+ }
43
+
44
+ // A Flatpak or snap build of Obsidian on Linux keeps its user-data directory
45
+ // inside its sandbox (`~/.var/app/md.obsidian.Obsidian/config/obsidian`,
46
+ // `~/snap/obsidian/current/.config/obsidian`), where Atelier does not write,
47
+ // and reads no file outside it. Which build this account uses: the one whose
48
+ // vault list was written last, since every build rewrites its own whenever a
49
+ // vault window opens or closes ('flatpak', 'snap', or null for the native
50
+ // build); only when no build wrote one, a Flatpak or snap installation, or a
51
+ // Flatpak or snap left by one. Null on any other platform. `exists` answers
52
+ // whether a path exists, `modified` when a file was last written (or null).
53
+ const modifiedAt = (file) => { try { return fs.statSync(file).mtimeMs } catch { return null } }
54
+ export function obsidianSandboxedBuild({ platform = process.platform, env = process.env, exists = fs.existsSync, modified = modifiedAt } = {}) {
55
+ if (platform !== 'linux') return null
56
+ const home = typeof env.HOME === 'string' && path.posix.isAbsolute(env.HOME) ? env.HOME : null
57
+ const under = (...parts) => (home === null ? [] : [path.posix.join(home, ...parts)])
58
+ const native = obsidianUserDataDir({ platform, env })
59
+ const lists = [
60
+ [null, native === null ? [] : [path.posix.join(native, OBSIDIAN_SETTINGS_FILE)]],
61
+ ['flatpak', under('.var', 'app', 'md.obsidian.Obsidian', 'config', 'obsidian', OBSIDIAN_SETTINGS_FILE)],
62
+ ['snap', under('snap', 'obsidian', 'current', '.config', 'obsidian', OBSIDIAN_SETTINGS_FILE)],
63
+ ].flatMap(([build, files]) => files.map((file) => { let at; try { at = modified(file) } catch { at = null } return { build, at } })).filter(({ at }) => typeof at === 'number')
64
+ if (lists.length > 0) return lists.reduce((latest, list) => (list.at > latest.at ? list : latest)).build
65
+ const found = (paths) => paths.some((candidate) => { try { return exists(candidate) } catch { return false } })
66
+ if (found([...under('.var', 'app', 'md.obsidian.Obsidian'), ...under('.local', 'share', 'flatpak', 'app', 'md.obsidian.Obsidian'), '/var/lib/flatpak/app/md.obsidian.Obsidian'])) return 'flatpak'
67
+ if (found([...under('snap', 'obsidian'), '/snap/obsidian', '/var/lib/snapd/snap/obsidian'])) return 'snap'
68
+ return null
69
+ }
70
+
71
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
72
+ // The real path as stored (realPathAsStored): what the operating system reports as a working directory.
73
+ const realOrResolved = (target) => { try { return realPathAsStored(target) } catch { return path.resolve(target) } }
74
+ const listedFolders = (vaults) => (isPlainObject(vaults) ? Object.entries(vaults).filter(([, entry]) => isPlainObject(entry) && typeof entry.path === 'string' && path.isAbsolute(entry.path)) : [])
75
+
76
+ // A folder the list names, against a vault root spelled as given (`spelled`)
77
+ // and as its real path (`target`). Compared as written first; the real path of
78
+ // a listed folder is read only when its last component is the vault root's,
79
+ // as it is for the root reached through a linked parent. So a vault on a
80
+ // mount that does not answer is never waited on; a link to the vault root
81
+ // under another name is not recognised.
82
+ const namesFolder = (entryPath, { spelled, target }) => {
83
+ const written = path.resolve(entryPath)
84
+ if (written === spelled || written === target) return true
85
+ return path.basename(written) === path.basename(target) && realOrResolved(entryPath) === target
86
+ }
87
+ const rootOf = (vaultRoot) => ({ spelled: path.resolve(vaultRoot), target: realOrResolved(vaultRoot) })
88
+
89
+ // The listed entries (listedFolders) whose folder is this vault root (see
90
+ // namesFolder), in list order. One folder can be listed more than once, under
91
+ // another letter case or through a link, say.
92
+ const entriesOf = (listed, root) => listed.filter(([, entry]) => namesFolder(entry.path, root))
93
+
94
+ // The entry of the app's vault map whose folder is this vault root (see
95
+ // namesFolder), one the app lists open first; null when there is none. `vaults`
96
+ // is the map as the app keeps it, from its file or from the app itself.
97
+ export function findVaultEntry(vaults, vaultRoot) {
98
+ if (!isPlainObject(vaults) || typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot)) return null
99
+ const entries = entriesOf(listedFolders(vaults), rootOf(vaultRoot))
100
+ const found = entries.find(([, entry]) => entry.open === true) ?? entries[0]
101
+ return found === undefined ? null : { id: found[0], path: found[1].path, open: found[1].open === true }
102
+ }
103
+
104
+ const refused = (code, message) => ({ ok: false, code, message })
105
+ const currentUid = () => (typeof process.getuid === 'function' ? process.getuid() : null)
106
+
107
+ // Reads the settings file without following a link and without writing:
108
+ // { ok: true, file, document, vaults, bytes, mode } or a typed refusal
109
+ // { ok: false, code, message }. The user-data directory and the file must be
110
+ // this user's own, a real directory and a regular file; the file must be a
111
+ // JSON object whose `vaults`, when present, is an object. A missing directory
112
+ // or file means Obsidian has not run on this account, or has shown only its
113
+ // starter window, which writes no list: that refusal names `userDataDir`.
114
+ export function readObsidianSettings({ userDataDir, uid = currentUid() } = {}) {
115
+ if (typeof userDataDir !== 'string' || !path.isAbsolute(userDataDir)) return refused('obsidian-settings-location-unknown', 'where Obsidian keeps its settings on this system is not known')
116
+ const owned = (stat) => uid === null || stat.uid === uid
117
+ let directory
118
+ try { directory = fs.lstatSync(userDataDir) } catch (error) {
119
+ if (error.code === 'ENOENT') return { ...refused('obsidian-settings-missing', 'Obsidian has no settings directory on this account; it has not run here'), userDataDir }
120
+ return refused('obsidian-settings-unreadable', 'the Obsidian settings directory cannot be read')
121
+ }
122
+ if (directory.isSymbolicLink() || !directory.isDirectory()) return refused('obsidian-settings-unsafe', 'the Obsidian settings directory is a link or not a directory')
123
+ if (!owned(directory)) return refused('obsidian-settings-not-owned', 'the Obsidian settings directory belongs to another user')
124
+ const file = path.join(userDataDir, OBSIDIAN_SETTINGS_FILE)
125
+ let leaf
126
+ try { leaf = fs.lstatSync(file) } catch (error) {
127
+ if (error.code === 'ENOENT') return { ...refused('obsidian-settings-missing', 'Obsidian has no settings file on this account; it has not run here'), userDataDir }
128
+ return refused('obsidian-settings-unreadable', 'the Obsidian settings file cannot be read')
129
+ }
130
+ if (leaf.isSymbolicLink() || !leaf.isFile()) return refused('obsidian-settings-unsafe', 'the Obsidian settings file is a link or not a regular file')
131
+ if (!owned(leaf)) return refused('obsidian-settings-not-owned', 'the Obsidian settings file belongs to another user')
132
+ if (leaf.size > MAX_OBSIDIAN_SETTINGS_BYTES) return refused('obsidian-settings-unreadable', 'the Obsidian settings file is larger than a settings file can be')
133
+ let bytes
134
+ let descriptor
135
+ try {
136
+ descriptor = openRegularFileNoFollow(file)
137
+ const opened = fs.fstatSync(descriptor)
138
+ if (!owned(opened)) return refused('obsidian-settings-not-owned', 'the Obsidian settings file belongs to another user')
139
+ bytes = fs.readFileSync(descriptor)
140
+ } catch {
141
+ return refused('obsidian-settings-unreadable', 'the Obsidian settings file cannot be read')
142
+ } finally {
143
+ if (descriptor !== undefined) try { fs.closeSync(descriptor) } catch { /* read already, or refused */ }
144
+ }
145
+ let document
146
+ try { document = JSON.parse(bytes.toString('utf8')) } catch { return refused('obsidian-settings-unreadable', 'the Obsidian settings file is not JSON') }
147
+ if (!isPlainObject(document)) return refused('obsidian-settings-not-object', 'the Obsidian settings file is not a JSON object')
148
+ if (document.vaults !== undefined && !isPlainObject(document.vaults)) return refused('obsidian-settings-not-object', 'the vault list in the Obsidian settings file is not an object')
149
+ return { ok: true, file, document, vaults: document.vaults ?? {}, bytes, mode: leaf.mode & 0o777, links: leaf.nlink }
150
+ }
151
+
152
+ // Where a command-line call about this vault reaches the app, predicted from
153
+ // the app's list as the app routes a call (see above):
154
+ //
155
+ // { how: 'id', id } run in a directory that is no vault, with `vault=<id>` first: the id names this vault first.
156
+ // Preferred: it does not depend on how a working directory is spelled;
157
+ // { how: 'folder', cwd } run in the vault's folder (its real path as stored, which the tool reports), when the id
158
+ // names another vault first and the first listed vault that is or contains the folder is this one;
159
+ // { how: 'unlisted' } the list has no entry for this folder (with `open`, none that the app lists open);
160
+ // { how: 'ambiguous' } the id names another vault first, and a vault listed above it takes a call run in its folder;
161
+ // { how: 'duplicated', entries }
162
+ // the list has this folder open more than once, one window per entry ({ id, path }): each
163
+ // window holds the vault, and a call reaches one of them only.
164
+ //
165
+ // With `open`, only an entry the app lists open may take the call, so a
166
+ // closed vault window is never reopened. Without it, so does an entry the app
167
+ // lists open when there is one: another entry of the same folder is not opened
168
+ // beside it.
169
+ export function vaultRoute({ vaults, vaultRoot, open = false } = {}) {
170
+ if (typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot)) return { how: 'unlisted' }
171
+ const root = rootOf(vaultRoot)
172
+ const { target } = root
173
+ const listed = listedFolders(vaults)
174
+ const entries = entriesOf(listed, root)
175
+ const windows = entries.filter(([, entry]) => entry.open === true)
176
+ if (windows.length > 1) return { how: 'duplicated', entries: windows.map(([id, entry]) => ({ id, path: entry.path })) }
177
+ const own = open || windows.length > 0 ? windows : entries
178
+ if (own.length === 0) return { how: 'unlisted' }
179
+ const takes = ([id]) => own.some(([ownId]) => ownId === id)
180
+ const byName = (value) => listed.find(([id, entry]) => id === value || path.basename(entry.path).toUpperCase() === value.toUpperCase())
181
+ const named = own.find(([id]) => byName(id)?.[0] === id)
182
+ if (named !== undefined) return { how: 'id', id: named[0] }
183
+ const byFolder = listed.find(([, entry]) => { const folder = path.resolve(entry.path); return target === folder || target.startsWith(folder + path.sep) })
184
+ return byFolder !== undefined && takes(byFolder) ? { how: 'folder', cwd: target } : { how: 'ambiguous' }
185
+ }
186
+
187
+ // The listed vaults whose folder contains this vault root, as written, against
188
+ // the root as given and as its real path: the app would show this vault's
189
+ // notes in their windows too, and a call run in this vault's folder may reach
190
+ // them. No vault is added inside another one. No listed folder's real path is
191
+ // read, so a vault above the root only through a link is not found.
192
+ export function enclosingVaults({ vaults, vaultRoot } = {}) {
193
+ if (typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot)) return []
194
+ const { spelled, target } = rootOf(vaultRoot)
195
+ const inside = (folder, root) => folder !== root && root.startsWith(folder.endsWith(path.sep) ? folder : folder + path.sep)
196
+ return listedFolders(vaults).filter(([, entry]) => {
197
+ const folder = path.resolve(entry.path)
198
+ return !namesFolder(entry.path, { spelled, target }) && (inside(folder, target) || inside(folder, spelled))
199
+ }).map(([id, entry]) => ({ id, path: entry.path }))
200
+ }
@@ -1,6 +1,9 @@
1
1
  // Recovery state for Obsidian publication: the immutable store, the durable
2
2
  // journal, restart recovery and the late-writer re-check.
3
- export { EXCHANGE_CANDIDATE_NAME, LATE_EXCHANGE_CANDIDATE_NAME, PublicationRefusal, VAULT_LOCK_DIRECTORY, acquireVaultLock, createRecoveryStore, readFileDigest, sha256Digest } from './store.mjs'
3
+ export {
4
+ EXCHANGE_CANDIDATE_NAME, LATE_EXCHANGE_CANDIDATE_NAME, PublicationRefusal, VAULT_ALLOCATION_SCHEMA, VAULT_LOCK_DIRECTORY, VAULT_ORIGINS, acquireVaultLock, allocatedFolderState, assertAllocatedFolder, allocationFile, folderIdentity,
5
+ createRecoveryStore, hasCommittedGeneration, legacyVaultRoot, readFileDigest, readVaultAllocation, sha256Digest, validateVaultAllocation, vaultRootFor, writeVaultAllocation,
6
+ } from './store.mjs'
4
7
  export { JOURNAL_SCHEMA, createJournal, journalDetail, listJournals, newJournalId } from './journal.mjs'
5
8
  export { classifyCandidateFile, namedCandidates, publishedSinceCommit, reconcileUnit, recoverPublications, retireStagedFile } from './restart.mjs'
6
9
  export { recheckDisplacedFiles } from './late-writer.mjs'
@@ -300,6 +300,8 @@ export function recoverPublications({ store, clock = () => new Date() }) {
300
300
  // Digests this view has published since its trusted manifest was committed,
301
301
  // by journals that did not reach a commit. They are valid bases: without them
302
302
  // a note published by an interrupted run would look like an outside edit.
303
+ // Settings and Atelier's plugin files are planned from the disk, never from a
304
+ // base, so they have no place here.
303
305
  export function publishedSinceCommit({ store }) {
304
306
  const pointer = store.readCurrent()
305
307
  const ledger = new Map()
@@ -308,7 +310,7 @@ export function publishedSinceCommit({ store }) {
308
310
  if (document.state === 'committed' || document.expectedGeneration !== (pointer?.generationId ?? null)) continue
309
311
  for (const entry of document.entries) {
310
312
  const detail = journalDetail(entry)
311
- if (entry.step !== 'conditional-update' || entry.outcome !== 'ok' || detail.kind === 'settings') continue
313
+ if (entry.step !== 'conditional-update' || entry.outcome !== 'ok' || detail.kind === 'settings' || detail.kind === 'plugin') continue
312
314
  ledger.set(entry.notePath, detail.op === 'remove' ? null : entry.afterDigest)
313
315
  }
314
316
  }
@@ -3,11 +3,12 @@ import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { acquirePrivateLock, publishPrivateFile } from '../../../project/durable-state.mjs'
5
5
  import { checkManagedRoots } from '../../../project/file-class.mjs'
6
- import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../../project/private-state.mjs'
6
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow, realPathAsStored } from '../../../project/private-state.mjs'
7
7
 
8
8
  // Private per-workspace state for one Obsidian view:
9
9
  //
10
- // <workspaceRoot>/vaults/<scopeId>/ the editable vault (or an explicit vaultRoot)
10
+ // <workspaceRoot>/vaults/<scopeId>/ the editable vault, unless it was allocated elsewhere or a vaultRoot is named
11
+ // <workspaceRoot>/state/allocations/<scopeId>.json where the view's vault was allocated, when it was (see below)
11
12
  // <workspaceRoot>/state/manifests/<scopeId>/ trusted manifests and the current pointer
12
13
  // <workspaceRoot>/state/journals/<scopeId>/<id>/ publication journals
13
14
  // <workspaceRoot>/recovery/objects/<sha256>.bin immutable content-addressed bytes
@@ -49,7 +50,15 @@ export const VAULT_LOCK_DIRECTORY = '.atelier-publication'
49
50
  // marker before its ticket, so the directory does not grow inside a person's
50
51
  // vault and an interrupted removal never leaves a marker without its ticket.
51
52
  export function acquireVaultLock(store) {
53
+ // Only in the vault the store was made for, reached through no link: the lock's folder is made inside whatever the
54
+ // vault root leads to.
55
+ let real = null
56
+ try { real = realPathAsStored(store.vaultRoot) } catch { /* not reachable: refused below */ }
57
+ if (real !== store.vaultRoot) refuse(store.allocation ? 'vault-allocation-moved' : 'vault-root-moved', 'the vault is no longer the folder this publication was prepared for: its path now leads somewhere else; nothing was written', { path: store.vaultRoot, leadsTo: real })
52
58
  const directory = ensureContainedPrivateDirectory({ workspaceRoot: store.vaultRoot, directory: path.dirname(store.vaultLockPath), label: 'vault publication lock' })
59
+ // The lock's folder resolved, again, to the one inside the vault: a link put there after the check above is refused
60
+ // before any ticket is written (only the empty folder may have been made through it).
61
+ if (directory !== path.dirname(store.vaultLockPath)) refuse(store.allocation ? 'vault-allocation-moved' : 'vault-root-moved', 'the vault is no longer the folder this publication was prepared for: its path now leads somewhere else; no lock was taken', { path: store.vaultRoot, leadsTo: path.dirname(directory) })
53
62
  const release = acquirePrivateLock(path.join(directory, path.basename(store.vaultLockPath)))
54
63
  try {
55
64
  const owners = `${store.vaultLockPath}.owners`
@@ -111,11 +120,135 @@ function publishOnce(file, bytes) {
111
120
  return true
112
121
  }
113
122
 
123
+ // ---------------------------------------------------------------------------
124
+ // Where a view's vault is
125
+ // ---------------------------------------------------------------------------
126
+
127
+ // A view of a workspace that decided where its vaults live gets a folder
128
+ // there, allocated once at its first publication (by the maintenance engine)
129
+ // and recorded, owner only, in the workspace's private state:
130
+ //
131
+ // <workspaceRoot>/state/allocations/<scopeId>.json atelier-obsidian-vault-allocation/v1
132
+ //
133
+ // A view without a record has its vault where every earlier release put it,
134
+ // `<workspaceRoot>/vaults/<scopeId>`: a vault published there stays there
135
+ // until it is moved, and a workspace that decided no place publishes there as
136
+ // before. The record is the one thing that names another folder, and the
137
+ // store reads it, so every reader of a view's vault (the engine, source apply,
138
+ // the proposal adapter, `open`) finds the same one.
139
+
140
+ export const VAULT_ALLOCATION_SCHEMA = 'atelier-obsidian-vault-allocation/v1'
141
+ export const VAULT_ORIGINS = Object.freeze(['allocated', 'legacy-data-root'])
142
+
143
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/
144
+ const CONTROL = /[\u0000-\u001f\u007f]/
145
+ // `device` and `inode` name the folder that was made, as decimal strings (a 64-bit number does not fit a JSON number):
146
+ // a folder at the recorded path that is another one (restored, moved in, made again by someone else) is never
147
+ // published into.
148
+ const ALLOCATION_KEYS = Object.freeze(['schema', 'workspaceId', 'scopeId', 'path', 'name', 'parent', 'device', 'inode', 'allocatedAt'])
149
+ const DECIMAL = /^(0|[1-9][0-9]{0,19})$/
150
+ const isPlainPath = (value) => typeof value === 'string' && value.length <= 4096 && !CONTROL.test(value) && path.isAbsolute(value) && path.resolve(value) === value
151
+
152
+ export const allocationFile = (workspaceRoot, scopeId) => path.join(workspaceRoot, 'state', 'allocations', `${segment(scopeId)}.json`)
153
+ export const legacyVaultRoot = (workspaceRoot, scopeId) => path.join(workspaceRoot, 'vaults', segment(scopeId))
154
+
155
+ export function validateVaultAllocation(document, { workspaceId, scopeId }) {
156
+ const code = 'invalid-vault-allocation'
157
+ if (document === null || typeof document !== 'object' || Array.isArray(document)) refuse(code, 'a vault allocation is an object')
158
+ const unknown = Object.keys(document).filter((key) => !ALLOCATION_KEYS.includes(key))
159
+ if (unknown.length > 0 || ALLOCATION_KEYS.some((key) => !Object.hasOwn(document, key))) refuse(code, 'a vault allocation carries exactly its own members', { unknown: unknown.sort() })
160
+ if (document.schema !== VAULT_ALLOCATION_SCHEMA) refuse(code, 'a vault allocation names an unknown schema')
161
+ if (document.workspaceId !== workspaceId || document.scopeId !== scopeId || !IDENTIFIER.test(String(scopeId))) refuse(code, 'the vault allocation belongs to another workspace or view')
162
+ if (!isPlainPath(document.path) || !isPlainPath(document.parent)) refuse(code, 'the vault allocation names a folder that is not an absolute, plainly written path')
163
+ if (path.dirname(document.path) !== document.parent || path.basename(document.path) !== document.name) refuse(code, 'the vault allocation names its folder inconsistently')
164
+ if (typeof document.device !== 'string' || !DECIMAL.test(document.device) || typeof document.inode !== 'string' || !DECIMAL.test(document.inode)) refuse(code, 'the vault allocation names its folder\'s device and inode as decimal strings')
165
+ if (typeof document.allocatedAt !== 'string' || !TIMESTAMP.test(document.allocatedAt)) refuse(code, 'the vault allocation needs a UTC time')
166
+ return document
167
+ }
168
+
169
+ // The record of a view, validated, or null when it has none. Reads only.
170
+ export function readVaultAllocation({ workspaceRoot, workspaceId, scopeId }) {
171
+ let text
172
+ try { text = readRegularTextNoFollow(allocationFile(workspaceRoot, scopeId)) } catch (error) {
173
+ if (error.code === 'ENOENT') return null
174
+ return refuse('invalid-vault-allocation', 'the vault allocation cannot be read', { cause: error.code ?? String(error.message) })
175
+ }
176
+ let document
177
+ try { document = JSON.parse(text) } catch { return refuse('invalid-vault-allocation', 'the vault allocation is not JSON') }
178
+ return validateVaultAllocation(document, { workspaceId, scopeId })
179
+ }
180
+
181
+ // The device and inode of a folder, as an allocation records them; null when nothing is there.
182
+ export function folderIdentity(folder) {
183
+ const stat = fs.lstatSync(folder, { bigint: true, throwIfNoEntry: false })
184
+ return stat === undefined ? null : { device: String(stat.dev), inode: String(stat.ino), directory: stat.isDirectory(), link: stat.isSymbolicLink() }
185
+ }
186
+
187
+ // Whether the folder at an allocation's path is the one it recorded: 'same', 'missing', or 'replaced'.
188
+ export function allocatedFolderState(allocation) {
189
+ const found = folderIdentity(allocation.path)
190
+ if (found === null) return 'missing'
191
+ return found.directory && !found.link && found.device === allocation.device && found.inode === allocation.inode ? 'same' : 'replaced'
192
+ }
193
+
194
+ // Refuses, typed, unless the folder at an allocation's path is the one it recorded, at that real path: checked when a store is made, by
195
+ // the maintenance engine before it uses a store again, and by the publisher before and after it takes the vault lock,
196
+ // so a folder replaced while a service runs is never published into.
197
+ export function assertAllocatedFolder(allocation) {
198
+ const found = allocatedFolderState(allocation)
199
+ if (found === 'missing') refuse('vault-allocation-missing', 'the folder allocated to this view is gone; the maintenance service makes it again at its next tick', { path: allocation.path })
200
+ if (found === 'replaced') refuse('vault-allocation-replaced', 'another folder is where this view\'s vault was allocated; Atelier publishes only into the folder it made', { path: allocation.path })
201
+ // Reached through no link: the record holds the folder's real path, so the same folder moved elsewhere (into a
202
+ // vault the app lists, say) and linked back, which keeps its device and inode, is not published into either.
203
+ let real = null
204
+ try { real = realPathAsStored(allocation.path) } catch { /* not reachable: refused below */ }
205
+ if (real !== allocation.path) refuse('vault-allocation-moved', 'this view\'s vault is now reached through a link, somewhere else than where it was allocated; Atelier publishes only into the folder at the path it recorded', { path: allocation.path, leadsTo: real })
206
+ return allocation
207
+ }
208
+
209
+ // Written only by the maintenance engine, under its lock, and by what moves a vault under the same lock.
210
+ export function writeVaultAllocation({ workspaceRoot, allocation }) {
211
+ validateVaultAllocation(allocation, allocation)
212
+ const directory = ensureContainedPrivateDirectory({ workspaceRoot, directory: path.dirname(allocationFile(workspaceRoot, allocation.scopeId)), label: 'Obsidian vault allocations' })
213
+ atomicReplacePrivateText(path.join(directory, path.basename(allocationFile(workspaceRoot, allocation.scopeId))), `${JSON.stringify(allocation, null, 2)}\n`)
214
+ return allocation
215
+ }
216
+
217
+ // A view without a record that was published, and has no vault under the data root, where a view published there
218
+ // always has its folder: its record was lost (removed, or a backup restored without it), or, for a view that was never
219
+ // allocated a folder, its folder under the data root was removed. Refused, typed, rather than published again into a
220
+ // new, empty vault under the data root while a vault it had elsewhere stays orphaned.
221
+ function refuseLostAllocation({ workspaceRoot, scopeId }) {
222
+ const legacy = legacyVaultRoot(workspaceRoot, scopeId)
223
+ if (!hasCommittedGeneration({ workspaceRoot, scopeId }) || fs.lstatSync(legacy, { throwIfNoEntry: false }) !== undefined) return
224
+ refuse('vault-allocation-lost', `this view was published, and its vault is not where Atelier can find it: there is no record of a folder allocated for it, and no folder under the data root. If its vault was allocated elsewhere, restore ${allocationFile(workspaceRoot, scopeId)} from a backup; otherwise, or to publish the view under the data root from now on, make the folder ${legacy}: the view is published there at the next change at its sources`, { record: allocationFile(workspaceRoot, scopeId), path: legacy })
225
+ }
226
+
227
+ // Where a view's vault is, without creating anything: { path, origin, allocation }.
228
+ export function vaultRootFor({ workspaceRoot, workspaceId, scopeId }) {
229
+ const allocation = readVaultAllocation({ workspaceRoot, workspaceId, scopeId })
230
+ if (allocation === null) refuseLostAllocation({ workspaceRoot, scopeId })
231
+ return allocation === null
232
+ ? { path: legacyVaultRoot(workspaceRoot, scopeId), origin: 'legacy-data-root', allocation: null }
233
+ : { path: allocation.path, origin: 'allocated', allocation }
234
+ }
235
+
236
+ // Whether a view has a committed generation, whichever folder it was published into.
237
+ export const hasCommittedGeneration = ({ workspaceRoot, scopeId }) => fs.existsSync(path.join(workspaceRoot, 'state', 'manifests', segment(scopeId), 'current.json'))
238
+
114
239
  // `repositoryRoots` is required: every enrolled repository root of the loaded
115
240
  // project. Neither the workspace state nor the vault may overlap one of them,
116
241
  // in either direction, lexically or by real path; the store refuses before it
117
242
  // creates anything. A caller with no enrolled repository (a test in a
118
243
  // temporary directory) says so with an explicit empty list.
244
+ //
245
+ // Without `vaultRoot` the vault is where the view's allocation says, else in
246
+ // the workspace's `vaults/` (vaultRootFor). An allocated folder must be the
247
+ // very folder the record names (device and inode): one that has gone is made
248
+ // again by the maintenance engine under its lock (ensureVaultAllocation), and
249
+ // until then, like one that another folder replaced, the store refuses. A
250
+ // published view whose record was lost is refused too, never published again
251
+ // into a new vault under the data root.
119
252
  export function createRecoveryStore({ workspaceRoot, workspaceId, scopeId, vaultRoot, repositoryRoots } = {}) {
120
253
  if (typeof workspaceRoot !== 'string' || !path.isAbsolute(workspaceRoot)) throw new TypeError('workspaceRoot must be an absolute path')
121
254
  for (const [label, value] of [['workspaceId', workspaceId], ['scopeId', scopeId]]) {
@@ -125,17 +258,28 @@ export function createRecoveryStore({ workspaceRoot, workspaceId, scopeId, vault
125
258
  throw new TypeError('repositoryRoots must list the absolute root of every enrolled repository; pass an explicit empty list when there is none')
126
259
  }
127
260
  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 })
261
+ const allocation = vaultRoot === undefined ? readVaultAllocation({ workspaceRoot, workspaceId, scopeId }) : null
262
+ const namedVault = vaultRoot ?? allocation?.path
263
+ // A folder that is there but is not the one allocated (a link put where it was, say) is refused as such first, with
264
+ // its own next step; the checks below only read. One that has gone is checked against the repositories first.
265
+ if (allocation !== null && allocatedFolderState(allocation) !== 'missing') assertAllocatedFolder(allocation)
266
+ const guard = checkManagedRoots({ managedRoots: [workspaceRoot, ...(namedVault === undefined ? [] : [namedVault])], repositoryRoots })
129
267
  if (!guard.ok) refuse(guard.refusals[0].code, guard.refusals[0].message, { refusals: guard.refusals })
268
+ if (allocation !== null) assertAllocatedFolder(allocation)
269
+ else if (vaultRoot === undefined) refuseLostAllocation({ workspaceRoot, scopeId })
130
270
  fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
131
- const root = fs.realpathSync(workspaceRoot)
271
+ // Both as the file system stores them: the vault root is what the app is told, and what it compares its own
272
+ // working-directory spelling with.
273
+ const root = realPathAsStored(workspaceRoot)
132
274
  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)
275
+ const requestedVault = namedVault ?? legacyVaultRoot(root, scopeId)
276
+ // A vault this store places itself, under the data root or where the view's vault was allocated, is private to this
277
+ // user from the start: it will hold the plugin's bearer.
278
+ fs.mkdirSync(requestedVault, { recursive: true, ...(vaultRoot === undefined ? { mode: 0o700 } : {}) })
279
+ const vault = realPathAsStored(requestedVault)
136
280
  const inside = (parent, child) => { const relative = path.relative(parent, child); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) }
137
281
  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')
282
+ if (inside(path.join(root, area), vault) || inside(vault, path.join(root, area))) refuse('vault-overlaps-private-state', 'the vault may not overlap private publication state')
139
283
  }
140
284
  const manifests = privateDir('state', 'manifests', segment(scopeId))
141
285
  const journals = privateDir('state', 'journals', segment(scopeId))
@@ -149,6 +293,22 @@ export function createRecoveryStore({ workspaceRoot, workspaceId, scopeId, vault
149
293
  workspaceId,
150
294
  scopeId,
151
295
  vaultRoot: vault,
296
+ // Placed by this store under the workspace state or where the view's vault was allocated, rather than named by the
297
+ // caller, and really there: a vault path that is a link leads to a folder someone else chose, which is only looked
298
+ // at and never receives the bearer.
299
+ managedVaultRoot: vaultRoot === undefined && vault === requestedVault,
300
+ linkedVaultRoot: vaultRoot === undefined && vault !== requestedVault,
301
+ // Where the vault is and why: named by the caller, allocated for the view, or under the data root.
302
+ vaultOrigin: vaultRoot !== undefined ? 'explicit' : allocation === null ? 'legacy-data-root' : 'allocated',
303
+ allocation,
304
+ // Refuses, typed, once the folder of an allocated vault is no longer the one this store was made for (see
305
+ // assertAllocatedFolder), and once any other vault root is gone or leads elsewhere (vault-root-moved).
306
+ checkAllocatedVault() {
307
+ if (allocation !== null) { assertAllocatedFolder(allocation); return }
308
+ let real = null
309
+ try { real = realPathAsStored(vault) } catch { /* gone: refused below */ }
310
+ if (real !== vault) refuse('vault-root-moved', 'this view\'s vault is gone, or its path now leads somewhere else; nothing more is written into it', { path: vault, leadsTo: real })
311
+ },
152
312
  journalsRoot: journals,
153
313
  lockPath: path.join(locks, `${segment(scopeId)}.lock`),
154
314
  // One publisher per vault, whichever view or workspace state it belongs to.
@@ -4,7 +4,7 @@ import { isoTime } from '../../../runtime/obsidian/documents.mjs'
4
4
  import { refuse } from '../../../runtime/obsidian/errors.mjs'
5
5
  import { protectedRoots, readMachineSettings } from '../../../runtime/obsidian/machine-settings.mjs'
6
6
  import { resolveScope } from '../../../runtime/obsidian/opening.mjs'
7
- import { DEFAULT_ELIGIBILITY, createProductionSeams } from '../../../runtime/obsidian/pipeline.mjs'
7
+ import { createProductionSeams, eligibilityFor } from '../../../runtime/obsidian/pipeline.mjs'
8
8
  import { createMaintenanceStateStore } from '../../../runtime/obsidian/state-store.mjs'
9
9
  import { readConflictView } from './conflict-view.mjs'
10
10
  import { runPolicySetup } from './policy-setup.mjs'
@@ -28,8 +28,9 @@ const EXIT = Object.freeze({ ok: 0, notSuccess: 3 })
28
28
  const MAX_REQUEST_BYTES = 64 * 1024
29
29
 
30
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.
31
+ // builds it: the canonical graph with the eligibility the machine settings
32
+ // decide (fail closed), and the declared relations whose both endpoints the
33
+ // census knows.
33
34
  function snapshotNow({ project, seams, eligibility }) {
34
35
  const graph = seams.buildGraph({ project, eligibility })
35
36
  const known = new Set(graph.nodes.map((node) => node.id))
@@ -49,7 +50,7 @@ function readRequest(value) {
49
50
  }
50
51
  }
51
52
 
52
- export function createSelectionOperation({ seams = createProductionSeams(), eligibility = DEFAULT_ELIGIBILITY } = {}) {
53
+ export function createSelectionOperation({ seams = createProductionSeams(), eligibility: fixedEligibility = null } = {}) {
53
54
  return {
54
55
  name: 'selection',
55
56
  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.',
@@ -74,7 +75,7 @@ export function createSelectionOperation({ seams = createProductionSeams(), elig
74
75
  const usable = workspace !== null && workspace.workspaceRoot !== null
75
76
  const machine = usable ? readMachineSettings(workspace) : null
76
77
  const profile = seams.profileFor({ project, workspaceId: workspace?.workspaceId ?? 'ws-unprepared', audienceAllow: machine?.audienceAllow ?? [] })
77
- const canonicalSnapshot = snapshotNow({ project, seams, eligibility })
78
+ const canonicalSnapshot = snapshotNow({ project, seams, eligibility: fixedEligibility ?? eligibilityFor({ machine, project }) })
78
79
  const pathRegistry = usable ? createMaintenanceStateStore(workspace).readPathRegistry() : null
79
80
  // Explicit empty is an honest answer of `resolve`; `persist` of an empty selection is refused and asks for it by name.
80
81
  const selection = resolveSelection({ canonicalSnapshot, profile, scope, pathRegistry, allowEmpty: sub === 'resolve' || option === 'allow-empty' })