@mnstry/atelier 0.2.0-alpha.12 → 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 (516) hide show
  1. package/CHANGELOG.md +661 -0
  2. package/README.md +142 -6
  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-personal-workspace-manifest.v1.schema.json +87 -0
  21. package/contracts/atelier-personal-workspace-overlay.v1.schema.json +299 -0
  22. package/contracts/atelier-template-adoption.v1.schema.json +223 -0
  23. package/contracts/atelier-template-profile.v1.schema.json +1168 -0
  24. package/contracts/atelier-trackable.v1.schema.json +781 -0
  25. package/contracts/atelier-upgrade-plan.v3.schema.json +532 -0
  26. package/docs/architecture.md +89 -0
  27. package/docs/assurance-controls.md +2 -2
  28. package/docs/blocks/capability-stewardship.md +17 -0
  29. package/docs/blocks/claims.md +16 -3
  30. package/docs/blocks/inquiry-harnesses.md +19 -0
  31. package/docs/blocks/learning-harnesses.md +17 -0
  32. package/docs/capability-stewardship.md +269 -0
  33. package/docs/coordination.md +51 -0
  34. package/docs/decision-practices.md +111 -0
  35. package/docs/decisions.md +29 -0
  36. package/docs/evidence-navigation.md +237 -0
  37. package/docs/exact-upgrades.md +3 -2
  38. package/docs/ingestion.md +181 -0
  39. package/docs/inquiry-harnesses.md +211 -0
  40. package/docs/install.md +80 -3
  41. package/docs/intake-and-guides.md +5 -0
  42. package/docs/integration-contract-decisions.md +24 -2
  43. package/docs/interaction-and-reflection.md +72 -0
  44. package/docs/knowledge-graph.md +20 -0
  45. package/docs/knowledge-health/api.md +30 -0
  46. package/docs/knowledge-health/assessment/api.md +96 -0
  47. package/docs/knowledge-health/assessment/capabilities.md +40 -0
  48. package/docs/knowledge-health/assessment/evaluation.md +27 -0
  49. package/docs/knowledge-health/assessment/facilitator.md +30 -0
  50. package/docs/knowledge-health/assessment/quickstart.md +61 -0
  51. package/docs/knowledge-health/assessment/troubleshooting.md +23 -0
  52. package/docs/knowledge-health/devday/facilitator.md +16 -0
  53. package/docs/knowledge-health/devday/participant-response.md +22 -0
  54. package/docs/knowledge-health/devday/public-default.md +107 -0
  55. package/docs/knowledge-health/devday/quickstart.md +18 -0
  56. package/docs/knowledge-health/devday/support.md +22 -0
  57. package/docs/knowledge-health/exercise.md +17 -0
  58. package/docs/knowledge-health/facilitator.md +20 -0
  59. package/docs/knowledge-health/quickstart.md +94 -0
  60. package/docs/knowledge-health/readme.md +15 -0
  61. package/docs/knowledge-health/support.md +34 -0
  62. package/docs/knowledge-setup.md +248 -0
  63. package/docs/knowledge-workspace.md +264 -0
  64. package/docs/learning-harnesses.md +238 -0
  65. package/docs/learning.md +215 -0
  66. package/docs/local-services.md +181 -16
  67. package/docs/obsidian-contract.md +223 -15
  68. package/docs/obsidian-plugin.md +38 -4
  69. package/docs/obsidian.md +613 -51
  70. package/docs/optional-ingestion.md +83 -0
  71. package/docs/personal-workspace.md +257 -0
  72. package/docs/practical-judgment.md +92 -0
  73. package/docs/progressive-ingestion-acceptance.md +89 -0
  74. package/docs/progressive-ingestion.md +235 -0
  75. package/docs/project-command-inventory.md +1 -0
  76. package/docs/release-engineering.md +49 -0
  77. package/docs/repo-boundary-guard.md +94 -0
  78. package/docs/research-starter-quickstart.md +66 -0
  79. package/docs/research-starter.md +114 -0
  80. package/docs/semantic-ingestion.md +198 -0
  81. package/docs/semantic-operation.md +317 -0
  82. package/docs/skill-steward.md +6 -0
  83. package/docs/template-profile.md +164 -0
  84. package/docs/template-upgrades.md +114 -0
  85. package/docs/trackables.md +111 -0
  86. package/docs/upgrade.md +47 -2
  87. package/fixtures/atelier-architecture/binding/invalid/document.json +19 -0
  88. package/fixtures/atelier-architecture/binding/valid/document.json +19 -0
  89. package/fixtures/atelier-architecture/catalog/invalid/document.json +290 -0
  90. package/fixtures/atelier-architecture/catalog/valid/document.json +343 -0
  91. package/fixtures/atelier-build-contract/attempt/invalid/document.json +20 -0
  92. package/fixtures/atelier-build-contract/attempt/valid/document.json +19 -0
  93. package/fixtures/atelier-build-contract/candidate/invalid/document.json +24 -0
  94. package/fixtures/atelier-build-contract/candidate/valid/document.json +23 -0
  95. package/fixtures/atelier-build-contract/decision/invalid/document.json +31 -0
  96. package/fixtures/atelier-build-contract/decision/valid/document.json +30 -0
  97. package/fixtures/atelier-build-contract/delivery/invalid/document.json +18 -0
  98. package/fixtures/atelier-build-contract/delivery/valid/document.json +17 -0
  99. package/fixtures/atelier-build-contract/gate/invalid/document.json +22 -0
  100. package/fixtures/atelier-build-contract/gate/valid/document.json +21 -0
  101. package/fixtures/atelier-build-contract/ledger/invalid/document.json +270 -0
  102. package/fixtures/atelier-build-contract/ledger/valid/document.json +269 -0
  103. package/fixtures/atelier-build-contract/objective/invalid/document.json +73 -0
  104. package/fixtures/atelier-build-contract/objective/valid/document.json +72 -0
  105. package/fixtures/atelier-build-contract/progress/invalid/document.json +18 -0
  106. package/fixtures/atelier-build-contract/progress/valid/document.json +17 -0
  107. package/fixtures/atelier-capability-contract/adoption/invalid/document.json +71 -0
  108. package/fixtures/atelier-capability-contract/adoption/valid/document.json +70 -0
  109. package/fixtures/atelier-capability-contract/event/invalid/document.json +18 -0
  110. package/fixtures/atelier-capability-contract/event/valid/document.json +17 -0
  111. package/fixtures/atelier-capability-contract/journal/invalid/document.json +382 -0
  112. package/fixtures/atelier-capability-contract/journal/valid/document.json +381 -0
  113. package/fixtures/atelier-capability-contract/notice/invalid/document.json +8 -0
  114. package/fixtures/atelier-capability-contract/notice/valid/document.json +7 -0
  115. package/fixtures/atelier-capability-contract/package/invalid/document.json +59 -0
  116. package/fixtures/atelier-capability-contract/package/valid/document.json +58 -0
  117. package/fixtures/atelier-capability-contract/plan/invalid/document.json +107 -0
  118. package/fixtures/atelier-capability-contract/plan/valid/document.json +106 -0
  119. package/fixtures/atelier-capability-contract/release/invalid/document.json +85 -0
  120. package/fixtures/atelier-capability-contract/release/valid/document.json +84 -0
  121. package/fixtures/atelier-capability-contract/state/invalid/document.json +316 -0
  122. package/fixtures/atelier-capability-contract/state/valid/document.json +315 -0
  123. package/fixtures/atelier-coordination/dependency/invalid/document.json +37 -0
  124. package/fixtures/atelier-coordination/dependency/valid/document.json +36 -0
  125. package/fixtures/atelier-coordination/directive/invalid/document.json +25 -0
  126. package/fixtures/atelier-coordination/directive/valid/document.json +24 -0
  127. package/fixtures/atelier-coordination/disposition/invalid/document.json +34 -0
  128. package/fixtures/atelier-coordination/disposition/valid/document.json +33 -0
  129. package/fixtures/atelier-coordination/outcome/invalid/document.json +39 -0
  130. package/fixtures/atelier-coordination/outcome/valid/document.json +38 -0
  131. package/fixtures/atelier-coordination/recovery/invalid/document.json +34 -0
  132. package/fixtures/atelier-coordination/recovery/valid/document.json +33 -0
  133. package/fixtures/atelier-harness-contract/handoff/invalid/document.json +25 -0
  134. package/fixtures/atelier-harness-contract/handoff/valid/document.json +24 -0
  135. package/fixtures/atelier-ingestion/invalid/authority.json +19 -0
  136. package/fixtures/atelier-ingestion/semantic-baseline.json +26 -0
  137. package/fixtures/atelier-ingestion/semantic-operation.json +75 -0
  138. package/fixtures/atelier-ingestion/valid/plan-input.json +18 -0
  139. package/fixtures/atelier-inquiry-contract/assessment/invalid/document.json +41 -0
  140. package/fixtures/atelier-inquiry-contract/assessment/valid/document.json +40 -0
  141. package/fixtures/atelier-inquiry-contract/bundle/invalid/document.json +51 -0
  142. package/fixtures/atelier-inquiry-contract/bundle/valid/document.json +50 -0
  143. package/fixtures/atelier-inquiry-contract/campaign/invalid/document.json +17 -0
  144. package/fixtures/atelier-inquiry-contract/campaign/valid/document.json +16 -0
  145. package/fixtures/atelier-inquiry-contract/decision/invalid/document.json +22 -0
  146. package/fixtures/atelier-inquiry-contract/decision/valid/document.json +21 -0
  147. package/fixtures/atelier-inquiry-contract/feedback/invalid/document.json +19 -0
  148. package/fixtures/atelier-inquiry-contract/feedback/valid/document.json +18 -0
  149. package/fixtures/atelier-inquiry-contract/handoff/invalid/document.json +92 -0
  150. package/fixtures/atelier-inquiry-contract/handoff/valid/document.json +91 -0
  151. package/fixtures/atelier-inquiry-contract/hypothesis/invalid/document.json +20 -0
  152. package/fixtures/atelier-inquiry-contract/hypothesis/valid/document.json +19 -0
  153. package/fixtures/atelier-inquiry-contract/ledger/invalid/document.json +680 -0
  154. package/fixtures/atelier-inquiry-contract/ledger/valid/document.json +679 -0
  155. package/fixtures/atelier-inquiry-contract/legacy-assessment/invalid/document.json +19 -0
  156. package/fixtures/atelier-inquiry-contract/legacy-assessment/valid/document.json +18 -0
  157. package/fixtures/atelier-inquiry-contract/request/invalid/document.json +50 -0
  158. package/fixtures/atelier-inquiry-contract/request/valid/document.json +49 -0
  159. package/fixtures/atelier-inquiry-contract/source/invalid/document.json +18 -0
  160. package/fixtures/atelier-inquiry-contract/source/valid/document.json +17 -0
  161. package/fixtures/atelier-inquiry-contract/withdrawal/invalid/document.json +16 -0
  162. package/fixtures/atelier-inquiry-contract/withdrawal/valid/document.json +15 -0
  163. package/fixtures/atelier-instruction-adoption/context-receipt/invalid/document.json +18 -0
  164. package/fixtures/atelier-instruction-adoption/context-receipt/valid/document.json +17 -0
  165. package/fixtures/atelier-instruction-adoption/plan/invalid/document.json +63 -0
  166. package/fixtures/atelier-instruction-adoption/plan/valid/document.json +62 -0
  167. package/fixtures/atelier-instruction-adoption/receipt/invalid/document.json +22 -0
  168. package/fixtures/atelier-instruction-adoption/receipt/valid/document.json +21 -0
  169. package/fixtures/atelier-instruction-adoption/state/invalid/document.json +30 -0
  170. package/fixtures/atelier-instruction-adoption/state/valid/document.json +29 -0
  171. package/fixtures/atelier-interaction/act/invalid/document.json +25 -0
  172. package/fixtures/atelier-interaction/act/valid/document.json +24 -0
  173. package/fixtures/atelier-interaction/assessment/invalid/document.json +32 -0
  174. package/fixtures/atelier-interaction/assessment/valid/document.json +31 -0
  175. package/fixtures/atelier-interaction/observation/invalid/document.json +22 -0
  176. package/fixtures/atelier-interaction/observation/valid/document.json +21 -0
  177. package/fixtures/atelier-interaction/policy/invalid/document.json +20 -0
  178. package/fixtures/atelier-interaction/policy/valid/document.json +19 -0
  179. package/fixtures/atelier-judgment/case/invalid/document.json +76 -0
  180. package/fixtures/atelier-judgment/case/valid/document.json +75 -0
  181. package/fixtures/atelier-judgment/decision/invalid/document.json +38 -0
  182. package/fixtures/atelier-judgment/decision/valid/document.json +37 -0
  183. package/fixtures/atelier-judgment/native-decision.md +3 -0
  184. package/fixtures/atelier-judgment/practice-reconsideration/definition.json +92 -0
  185. package/fixtures/atelier-judgment/practice-reconsideration/scenario.json +435 -0
  186. package/fixtures/atelier-knowledge-contract/activation/invalid/document.json +26 -0
  187. package/fixtures/atelier-knowledge-contract/activation/valid/document.json +25 -0
  188. package/fixtures/atelier-knowledge-contract/contribution/invalid/document.json +34 -0
  189. package/fixtures/atelier-knowledge-contract/contribution/valid/document.json +33 -0
  190. package/fixtures/atelier-knowledge-contract/domain/invalid/document.json +41 -0
  191. package/fixtures/atelier-knowledge-contract/domain/valid/document.json +40 -0
  192. package/fixtures/atelier-knowledge-contract/domain-revision/invalid/document.json +46 -0
  193. package/fixtures/atelier-knowledge-contract/domain-revision/valid/document.json +45 -0
  194. package/fixtures/atelier-knowledge-contract/evaluation/invalid/document.json +21 -0
  195. package/fixtures/atelier-knowledge-contract/evaluation/valid/document.json +20 -0
  196. package/fixtures/atelier-knowledge-contract/ledger/invalid/document.json +318 -0
  197. package/fixtures/atelier-knowledge-contract/ledger/valid/document.json +317 -0
  198. package/fixtures/atelier-knowledge-contract/relation/invalid/document.json +25 -0
  199. package/fixtures/atelier-knowledge-contract/relation/valid/document.json +24 -0
  200. package/fixtures/atelier-knowledge-contract/review/invalid/document.json +18 -0
  201. package/fixtures/atelier-knowledge-contract/review/valid/document.json +17 -0
  202. package/fixtures/atelier-knowledge-contract/withdrawal/invalid/document.json +16 -0
  203. package/fixtures/atelier-knowledge-contract/withdrawal/valid/document.json +15 -0
  204. package/fixtures/atelier-learning/invalid/authority.json +14 -0
  205. package/fixtures/atelier-learning/valid/capture.json +13 -0
  206. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/authority-key.json +17 -0
  207. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/nonempty-ext.json +25 -0
  208. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/wrong-schema.json +22 -0
  209. package/fixtures/atelier-personal-workspace-contract/manifest/valid/manifest.json +22 -0
  210. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/authority-key.json +25 -0
  211. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/unknown-top-level-field.json +27 -0
  212. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/wrong-schema.json +24 -0
  213. package/fixtures/atelier-trackable/definition/invalid/document.json +15 -0
  214. package/fixtures/atelier-trackable/definition/valid/document.json +14 -0
  215. package/fixtures/atelier-trackable/request/invalid/document.json +23 -0
  216. package/fixtures/atelier-trackable/request/valid/document.json +22 -0
  217. package/fixtures/boundary-delegations/invalid/authority-key.v1.json +30 -0
  218. package/fixtures/boundary-delegations/invalid/push-operation.v1.json +28 -0
  219. package/fixtures/boundary-delegations/invalid/unsigned.v1.json +25 -0
  220. package/fixtures/boundary-delegations/valid/empty.v1.json +4 -0
  221. package/fixtures/boundary-delegations/valid/signed-operator.v1.json +30 -0
  222. package/fixtures/boundary-owner-keys/invalid/wrong-algorithm.v1.json +15 -0
  223. package/fixtures/boundary-owner-keys/invalid/wrong-curve.v1.json +15 -0
  224. package/fixtures/boundary-owner-keys/valid/one-owner.v1.json +17 -0
  225. package/fixtures/capability-packages/adoption.example.json +70 -0
  226. package/fixtures/capability-packages/evidence-review/capability-package.json +58 -0
  227. package/fixtures/capability-packages/evidence-review/capability-release.json +84 -0
  228. package/fixtures/capability-packages/evidence-review/evaluation.md +15 -0
  229. package/fixtures/capability-packages/evidence-review/skills/evidence-review/SKILL.md +22 -0
  230. package/fixtures/capability-packages/evidence-review/skills/evidence-review/references/ledger.md +7 -0
  231. package/fixtures/capability-packages/research/capability-package.json +88 -0
  232. package/fixtures/capability-packages/research/capability-release.json +134 -0
  233. package/fixtures/capability-packages/research/evaluation.md +15 -0
  234. package/fixtures/capability-packages/research/skills/research-integrate/SKILL.md +29 -0
  235. package/fixtures/capability-packages/research/skills/research-integrate/references/integration.md +10 -0
  236. package/fixtures/capability-packages/research/skills/research-plan/SKILL.md +27 -0
  237. package/fixtures/capability-packages/research/skills/research-plan/references/inquiries.md +26 -0
  238. package/fixtures/capability-packages/research/skills/research-synthesize/SKILL.md +23 -0
  239. package/fixtures/capability-packages/research/skills/research-synthesize/references/synthesis.md +12 -0
  240. package/fixtures/evidence-navigation/invalid/duplicate-operation.json +16 -0
  241. package/fixtures/evidence-navigation/invalid/instruction-kind.json +36 -0
  242. package/fixtures/evidence-navigation/invalid/invalid-digest.json +36 -0
  243. package/fixtures/evidence-navigation/invalid/invalid-time.json +36 -0
  244. package/fixtures/evidence-navigation/invalid/missing-attribution.json +35 -0
  245. package/fixtures/evidence-navigation/invalid/reserved-extension.json +19 -0
  246. package/fixtures/evidence-navigation/invalid/unattributed-acceptance.json +36 -0
  247. package/fixtures/evidence-navigation/invalid/uncoerced-version.json +16 -0
  248. package/fixtures/evidence-navigation/invalid/unknown-currency.json +17 -0
  249. package/fixtures/evidence-navigation/invalid/unknown-selector-field.json +37 -0
  250. package/fixtures/evidence-navigation/invalid/unsupported-operation.json +15 -0
  251. package/fixtures/evidence-navigation/valid/claim.json +36 -0
  252. package/fixtures/evidence-navigation/valid/host.json +16 -0
  253. package/fixtures/evidence-navigation/valid/profile.json +16 -0
  254. package/fixtures/evidence-navigation/valid/snapshot.json +17 -0
  255. package/fixtures/harness-packages/build-harness/capability-package.json +61 -0
  256. package/fixtures/harness-packages/build-harness/capability-release.json +82 -0
  257. package/fixtures/harness-packages/build-harness/evaluation.md +3 -0
  258. package/fixtures/harness-packages/build-harness/skills/atelier-build-harness/SKILL.md +59 -0
  259. package/fixtures/harness-packages/knowledge-harness/capability-package.json +61 -0
  260. package/fixtures/harness-packages/knowledge-harness/capability-release.json +82 -0
  261. package/fixtures/harness-packages/knowledge-harness/evaluation.md +3 -0
  262. package/fixtures/harness-packages/knowledge-harness/skills/atelier-knowledge-harness/SKILL.md +60 -0
  263. package/fixtures/harnesses/learning-cycle.json +780 -0
  264. package/fixtures/inquiry/workshop.json +675 -0
  265. package/fixtures/inquiry-packages/discovery-harness/capability-package.json +61 -0
  266. package/fixtures/inquiry-packages/discovery-harness/capability-release.json +82 -0
  267. package/fixtures/inquiry-packages/discovery-harness/evaluation.md +3 -0
  268. package/fixtures/inquiry-packages/discovery-harness/skills/atelier-discovery-harness/SKILL.md +48 -0
  269. package/fixtures/inquiry-packages/research-harness/capability-package.json +61 -0
  270. package/fixtures/inquiry-packages/research-harness/capability-release.json +82 -0
  271. package/fixtures/inquiry-packages/research-harness/evaluation.md +3 -0
  272. package/fixtures/inquiry-packages/research-harness/skills/atelier-research-harness/SKILL.md +43 -0
  273. package/fixtures/knowledge-health/calibration-suite.json +26 -0
  274. package/fixtures/knowledge-health/calibration-trial.json +37 -0
  275. package/fixtures/knowledge-health/corrected/checklist.md +18 -0
  276. package/fixtures/knowledge-health/devday/participant-record.json +19 -0
  277. package/fixtures/knowledge-health/expected-summary.json +27 -0
  278. package/fixtures/knowledge-health/workshop/README.md +20 -0
  279. package/fixtures/knowledge-health/workshop/atelier.project.json +7 -0
  280. package/fixtures/knowledge-health/workshop/knowledge-plan.json +65 -0
  281. package/fixtures/knowledge-health/workshop/records/checklist.md +18 -0
  282. package/fixtures/knowledge-health/workshop/records/workshop.md +18 -0
  283. package/fixtures/knowledge-health/workshop/repo-access.v1.json +5 -0
  284. package/fixtures/knowledge-health/workspace/atelier.project.json +18 -0
  285. package/fixtures/knowledge-health/workspace/knowledge-plan.json +74 -0
  286. package/fixtures/knowledge-health/workspace/records/checklist.md +18 -0
  287. package/fixtures/knowledge-health/workspace/records/pilot.md +16 -0
  288. package/fixtures/knowledge-health/workspace/repo-access.v1.json +9 -0
  289. package/fixtures/personal-workspace/overlay.json +24 -0
  290. package/fixtures/templates/local-library.v1.json +75 -0
  291. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/invalid.json +16 -0
  292. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/valid.json +13 -0
  293. package/fixtures/templates/upgrade-contracts/migration.v3/invalid.json +24 -0
  294. package/fixtures/templates/upgrade-contracts/migration.v3/valid.json +21 -0
  295. package/fixtures/templates/upgrade-contracts/template-adoption.v1/invalid.json +60 -0
  296. package/fixtures/templates/upgrade-contracts/template-adoption.v1/valid.json +57 -0
  297. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/invalid.json +85 -0
  298. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/valid.json +82 -0
  299. package/package.json +59 -5
  300. package/skills/claude/atelier-build-harness/SKILL.md +59 -0
  301. package/skills/claude/atelier-coordination/SKILL.md +32 -0
  302. package/skills/claude/atelier-discovery-harness/SKILL.md +48 -0
  303. package/skills/claude/atelier-guided-upgrade/SKILL.md +29 -0
  304. package/skills/claude/atelier-interaction/SKILL.md +34 -0
  305. package/skills/claude/atelier-knowledge-harness/SKILL.md +60 -0
  306. package/skills/claude/atelier-knowledge-setup/SKILL.md +72 -0
  307. package/skills/claude/atelier-practical-judgment/SKILL.md +64 -0
  308. package/skills/claude/atelier-research-harness/SKILL.md +43 -0
  309. package/skills/claude/atelier-skill-steward/SKILL.md +53 -0
  310. package/skills/claude/atelier-trackables/SKILL.md +40 -0
  311. package/skills/codex/atelier-build-harness/SKILL.md +59 -0
  312. package/skills/codex/atelier-coordination/SKILL.md +32 -0
  313. package/skills/codex/atelier-discovery-harness/SKILL.md +48 -0
  314. package/skills/codex/atelier-guided-upgrade/SKILL.md +29 -0
  315. package/skills/codex/atelier-interaction/SKILL.md +34 -0
  316. package/skills/codex/atelier-knowledge-harness/SKILL.md +60 -0
  317. package/skills/codex/atelier-knowledge-setup/SKILL.md +72 -0
  318. package/skills/codex/atelier-practical-judgment/SKILL.md +64 -0
  319. package/skills/codex/atelier-research-harness/SKILL.md +43 -0
  320. package/skills/codex/atelier-skill-steward/SKILL.md +53 -0
  321. package/skills/codex/atelier-trackables/SKILL.md +40 -0
  322. package/src/architecture/catalog.mjs +355 -0
  323. package/src/architecture/index.mjs +93 -0
  324. package/src/boundary/content-rules.mjs +4 -2
  325. package/src/boundary/delegation-command.mjs +158 -0
  326. package/src/boundary/delegation.mjs +396 -0
  327. package/src/boundary/policy.mjs +52 -10
  328. package/src/build/git.mjs +18 -0
  329. package/src/build/index.mjs +3 -0
  330. package/src/build/ledger.mjs +88 -0
  331. package/src/capabilities/adoption.mjs +381 -0
  332. package/src/capabilities/evidence.mjs +140 -0
  333. package/src/capabilities/files.mjs +124 -0
  334. package/src/capabilities/index.mjs +4 -0
  335. package/src/capabilities/instructions.mjs +250 -0
  336. package/src/capabilities/package.mjs +136 -0
  337. package/src/cli/command-failure.mjs +36 -0
  338. package/src/cli/execute-command.mjs +16 -2
  339. package/src/cli/run.mjs +65 -9
  340. package/src/coauthor/store.mjs +43 -2
  341. package/src/collaboration/proposals.mjs +189 -0
  342. package/src/commands/architecture.mjs +27 -0
  343. package/src/commands/boundary.mjs +3 -0
  344. package/src/commands/capability.mjs +73 -0
  345. package/src/commands/enroll.mjs +3 -0
  346. package/src/commands/harness.mjs +79 -0
  347. package/src/commands/ingest.mjs +39 -0
  348. package/src/commands/init.mjs +4 -1
  349. package/src/commands/inquiry.mjs +66 -0
  350. package/src/commands/knowledge-semantic.mjs +128 -0
  351. package/src/commands/knowledge.mjs +119 -0
  352. package/src/commands/learn.mjs +61 -0
  353. package/src/commands/obsidian.mjs +754 -63
  354. package/src/commands/practice.mjs +24 -0
  355. package/src/commands/setup.mjs +22 -2
  356. package/src/commands/trackable.mjs +19 -0
  357. package/src/contracts/corpus.mjs +30 -0
  358. package/src/coordination/index.mjs +110 -0
  359. package/src/evidence-navigation/contracts.mjs +60 -0
  360. package/src/evidence-navigation/evaluate.mjs +69 -0
  361. package/src/evidence-navigation/generation.mjs +22 -0
  362. package/src/evidence-navigation/index.mjs +2 -0
  363. package/src/evidence-navigation/local.mjs +167 -0
  364. package/src/evidence-navigation/schema.generated.mjs +604 -0
  365. package/src/evidence-navigation/types.mjs +12 -0
  366. package/src/export/atelier-export-contract.mjs +35 -8
  367. package/src/graph/enroll.mjs +248 -0
  368. package/src/graph/graph.mjs +17 -1
  369. package/src/graph/knowledge-graph.mjs +58 -8
  370. package/src/harnesses/contracts.mjs +41 -0
  371. package/src/harnesses/exchange.mjs +154 -0
  372. package/src/harnesses/feedback.mjs +16 -0
  373. package/src/harnesses/history.mjs +37 -0
  374. package/src/harnesses/index.mjs +4 -0
  375. package/src/harnesses/store.mjs +29 -0
  376. package/src/ingestion/contracts.mjs +41 -0
  377. package/src/ingestion/evaluation.mjs +119 -0
  378. package/src/ingestion/optional/composition/index.mjs +171 -0
  379. package/src/ingestion/optional/evaluation/index.mjs +219 -0
  380. package/src/ingestion/optional/graphify/composition-binding.mjs +60 -0
  381. package/src/ingestion/optional/graphify/composition_receiving.py +185 -0
  382. package/src/ingestion/optional/graphify/graphify_supplier.py +933 -0
  383. package/src/ingestion/optional/graphify/json_bridge.py +163 -0
  384. package/src/ingestion/optional/graphify/query_receiving.py +209 -0
  385. package/src/ingestion/optional/index.mjs +9 -0
  386. package/src/ingestion/optional/jev/bridge.mjs +166 -0
  387. package/src/ingestion/optional/jev/composition-binding.mjs +116 -0
  388. package/src/ingestion/optional/jev/judgments.mjs +174 -0
  389. package/src/ingestion/optional/jev/native-translation.mjs +32 -0
  390. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/__init__.py +7 -0
  391. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/adapter.py +966 -0
  392. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/artifacts.py +101 -0
  393. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/projection.py +146 -0
  394. package/src/ingestion/optional/lightrag/composition-binding.mjs +67 -0
  395. package/src/ingestion/optional/vanilla/composition-binding.mjs +102 -0
  396. package/src/ingestion/optional/vanilla/index.mjs +218 -0
  397. package/src/ingestion/optional/vanilla/native-normalization.mjs +187 -0
  398. package/src/ingestion/processors.mjs +218 -0
  399. package/src/ingestion/semantic.mjs +203 -0
  400. package/src/ingestion/store.mjs +372 -0
  401. package/src/inquiry/beliefs.mjs +32 -0
  402. package/src/inquiry/contracts.mjs +23 -0
  403. package/src/inquiry/index.mjs +5 -0
  404. package/src/inquiry/ledger.mjs +135 -0
  405. package/src/inquiry/lenses.mjs +15 -0
  406. package/src/inquiry/projection.mjs +77 -0
  407. package/src/inquiry/store.mjs +35 -0
  408. package/src/intake/read-scope.mjs +12 -0
  409. package/src/intake/store.mjs +69 -6
  410. package/src/interaction/index.mjs +91 -0
  411. package/src/judgment/index.mjs +82 -0
  412. package/src/judgment/practice-evaluation.mjs +70 -0
  413. package/src/judgment/practice.mjs +144 -0
  414. package/src/knowledge/context.mjs +217 -0
  415. package/src/knowledge/index.mjs +5 -0
  416. package/src/knowledge/ingestion.mjs +79 -0
  417. package/src/knowledge/intake.mjs +33 -0
  418. package/src/knowledge/ledger.mjs +110 -0
  419. package/src/knowledge/participatory/guidance.mjs +36 -0
  420. package/src/knowledge/participatory/guided-contribution.mjs +44 -0
  421. package/src/knowledge/participatory/index.mjs +2 -0
  422. package/src/knowledge/participatory/installed-workshop.mjs +108 -0
  423. package/src/knowledge/plan.mjs +67 -0
  424. package/src/knowledge/projection.mjs +43 -0
  425. package/src/knowledge/semantic-operation.mjs +567 -0
  426. package/src/knowledge/sessions.mjs +467 -0
  427. package/src/knowledge/workspace.mjs +295 -0
  428. package/src/knowledge-health/assessment.mjs +165 -0
  429. package/src/knowledge-health/capabilities.mjs +350 -0
  430. package/src/knowledge-health/context.mjs +62 -0
  431. package/src/knowledge-health/example.mjs +47 -0
  432. package/src/knowledge-health/index.mjs +10 -0
  433. package/src/knowledge-health/owner-join.mjs +88 -0
  434. package/src/knowledge-health/proof/common.mjs +24 -0
  435. package/src/knowledge-health/proof/installed-check.mjs +100 -0
  436. package/src/knowledge-health/proof/native-readback.mjs +37 -0
  437. package/src/knowledge-health/proof/rehearse.mjs +68 -0
  438. package/src/knowledge-health/proof/validate-participant.mjs +17 -0
  439. package/src/knowledge-health/proof/verify-native-capture.mjs +14 -0
  440. package/src/knowledge-health/proof/workshop-check.mjs +36 -0
  441. package/src/knowledge-health/rule-identity.mjs +44 -0
  442. package/src/knowledge-health/workshop-composition.mjs +19 -0
  443. package/src/knowledge-health/workshop-profile.mjs +176 -0
  444. package/src/knowledge-health/workshop.mjs +99 -0
  445. package/src/learning/adapters.mjs +68 -0
  446. package/src/learning/assistance.mjs +127 -0
  447. package/src/learning/contracts.mjs +65 -0
  448. package/src/learning/store.mjs +272 -0
  449. package/src/personal-workspace/index.mjs +391 -0
  450. package/src/personal-workspace/selection.mjs +385 -0
  451. package/src/project/config.mjs +8 -0
  452. package/src/project/durable-state.mjs +3 -1
  453. package/src/project/file-class.mjs +1 -1
  454. package/src/projection/obsidian/edits/apply.mjs +55 -16
  455. package/src/projection/obsidian/personal-workspace.mjs +436 -0
  456. package/src/projection/obsidian/proposals/adapter.mjs +47 -8
  457. package/src/projection/obsidian/proposals/observation.mjs +10 -2
  458. package/src/projection/obsidian/publication/publisher.mjs +142 -13
  459. package/src/projection/obsidian/publication/vault-list.mjs +4 -3
  460. package/src/projection/obsidian/recovery/index.mjs +4 -1
  461. package/src/projection/obsidian/recovery/store.mjs +160 -7
  462. package/src/projection/obsidian/selection-ui/contribution.mjs +6 -5
  463. package/src/readiness/readiness.mjs +10 -3
  464. package/src/reflection/index.mjs +56 -0
  465. package/src/runtime/obsidian/app-production-seams.mjs +48 -16
  466. package/src/runtime/obsidian/app-registration.mjs +175 -28
  467. package/src/runtime/obsidian/app-restart.mjs +154 -0
  468. package/src/runtime/obsidian/engine.mjs +163 -20
  469. package/src/runtime/obsidian/extension-points.mjs +1 -1
  470. package/src/runtime/obsidian/index.mjs +10 -7
  471. package/src/runtime/obsidian/launch-plan.mjs +7 -4
  472. package/src/runtime/obsidian/lifecycle.mjs +92 -12
  473. package/src/runtime/obsidian/login-item.mjs +332 -0
  474. package/src/runtime/obsidian/machine-settings.mjs +119 -12
  475. package/src/runtime/obsidian/opening.mjs +171 -37
  476. package/src/runtime/obsidian/pipeline.mjs +71 -3
  477. package/src/runtime/obsidian/plugin-channel.mjs +11 -3
  478. package/src/runtime/obsidian/plugin-choice.mjs +16 -4
  479. package/src/runtime/obsidian/plugin-drift.mjs +59 -11
  480. package/src/runtime/obsidian/plugin-presence.mjs +14 -1
  481. package/src/runtime/obsidian/process-identity.mjs +20 -10
  482. package/src/runtime/obsidian/project-views.mjs +282 -0
  483. package/src/runtime/obsidian/questions.mjs +48 -0
  484. package/src/runtime/obsidian/release-watch.mjs +56 -0
  485. package/src/runtime/obsidian/service-main.mjs +99 -16
  486. package/src/runtime/obsidian/service-manager-production.mjs +61 -0
  487. package/src/runtime/obsidian/service-managers.mjs +275 -0
  488. package/src/runtime/obsidian/service-record.mjs +87 -23
  489. package/src/runtime/obsidian/service.mjs +36 -3
  490. package/src/runtime/obsidian/startup-adapters.mjs +59 -21
  491. package/src/runtime/obsidian/unheld-evidence.mjs +84 -0
  492. package/src/runtime/obsidian/vault-location.mjs +300 -0
  493. package/src/runtime/obsidian/view-counts.mjs +34 -0
  494. package/src/server/local-sidecar.mjs +169 -2
  495. package/src/server/server.mjs +28 -2
  496. package/src/template-bindings/project.mjs +92 -0
  497. package/src/templates/profile.mjs +225 -0
  498. package/src/trackables/domain.mjs +175 -0
  499. package/src/trackables/store.mjs +114 -0
  500. package/src/ui/knowledge-page.mjs +1388 -0
  501. package/src/upgrade/explanation.mjs +1 -1
  502. package/src/upgrade/template-participant.mjs +171 -0
  503. package/src/upgrade/transaction.mjs +72 -25
  504. package/src/upgrade/upgrade.mjs +1 -0
  505. package/templates/external-project-workspace/pack-lifecycle.json +6 -1
  506. package/templates/knowledge-workspace/.gitattributes +2 -0
  507. package/templates/knowledge-workspace/AGENTS.md +27 -0
  508. package/templates/knowledge-workspace/README.md +44 -0
  509. package/templates/knowledge-workspace/atelier.project.json +22 -0
  510. package/templates/knowledge-workspace/gitignore +6 -0
  511. package/templates/knowledge-workspace/knowledge-plan.json +111 -0
  512. package/templates/knowledge-workspace/knowledge-plan.schema.json +281 -0
  513. package/templates/knowledge-workspace/records/forecast.md +22 -0
  514. package/templates/knowledge-workspace/records/inspection.md +25 -0
  515. package/templates/knowledge-workspace/records/telescope.md +24 -0
  516. package/templates/knowledge-workspace/repo-access.v1.json +9 -0
@@ -4,9 +4,8 @@ import { refuse } from './errors.mjs'
4
4
  // Builds the text of an operating-system startup unit for the maintenance
5
5
  // service. Pure: it returns text and touches nothing. It writes no file,
6
6
  // installs nothing and runs no service manager. Installing a unit is a
7
- // separate system change that needs a person's explicit authorization and a
8
- // recorded consent that covers startup; the service refuses `--startup`
9
- // without one.
7
+ // separate step (login-item.mjs) that a person asks for, and the service
8
+ // refuses `--startup` without a recorded consent that covers startup.
10
9
  //
11
10
  // Every machine-specific value is an input. Nothing is looked up here: no
12
11
  // home directory, no environment, no current directory, no installed path.
@@ -15,17 +14,30 @@ import { refuse } from './errors.mjs'
15
14
  // Linux a systemd user unit
16
15
  // Windows refused, typed: startup there has not been qualified
17
16
  //
18
- // The unit runs the service entry in the foreground with `--startup`; the
19
- // service writes its own record once it listens, exactly as under `start`.
17
+ // The unit runs the service entry in the foreground with `--startup`, in the
18
+ // root directory (never one that could be a vault), with the search path it
19
+ // is given, so the service finds the same programs the person uses. It is
20
+ // restarted after a crash and after an exit with a code the service uses to
21
+ // ask for it (a new release on disk), never after a clean stop and never in a
22
+ // tight loop; a refusal under `--startup` exits cleanly. The service writes
23
+ // its own bounded log; the unit's output file receives only what happens
24
+ // before it can, such as a module that cannot be loaded.
20
25
 
21
26
  export const STARTUP_PLATFORMS = Object.freeze(['darwin', 'linux'])
22
27
  const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
28
+ const CONTROL = /[\u0000-\u001f\u007f]/
29
+ // Seconds: the least time between two starts, and how long a stop may take before the manager ends the process. The
30
+ // service gives a tick in flight 30 seconds to finish when it is asked to stop.
31
+ const THROTTLE_SECONDS = 60
32
+ const EXIT_TIMEOUT_SECONDS = 60
23
33
 
24
34
  const xml = (value) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;')
25
35
  // systemd: quoted, with its specifier and variable characters doubled.
26
36
  const unitWord = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('$', '$$$$')}"`
37
+ // systemd: one quoted assignment of Environment=, where `$` has no meaning and only specifiers are expanded.
38
+ const unitAssignment = (name, value) => `"${`${name}=${value}`.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`
27
39
 
28
- function launchdText({ label, words, logPath, workingDirectory }) {
40
+ function launchdText({ label, words, logPath, workingDirectory, searchPath }) {
29
41
  const lines = [
30
42
  '<?xml version="1.0" encoding="UTF-8"?>',
31
43
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -33,12 +45,15 @@ function launchdText({ label, words, logPath, workingDirectory }) {
33
45
  '<dict>',
34
46
  ' <key>Label</key>', ` <string>${xml(label)}</string>`,
35
47
  ' <key>ProgramArguments</key>', ' <array>', ...words.map((word) => ` <string>${xml(word)}</string>`), ' </array>',
48
+ ...(searchPath === null ? [] : [' <key>EnvironmentVariables</key>', ' <dict>', ' <key>PATH</key>', ` <string>${xml(searchPath)}</string>`, ' </dict>']),
49
+ ' <key>WorkingDirectory</key>', ` <string>${xml(workingDirectory)}</string>`,
36
50
  ' <key>RunAtLoad</key>', ' <true/>',
37
- // Restarted after a crash, not after a clean stop; a refusal is not retried in a tight loop.
51
+ // Restarted after a crash or a non-zero exit, not after a clean stop; a refusal exits cleanly and is not retried.
38
52
  ' <key>KeepAlive</key>', ' <dict>', ' <key>SuccessfulExit</key>', ' <false/>', ' </dict>',
39
- ' <key>ThrottleInterval</key>', ' <integer>60</integer>',
40
- ' <key>ProcessType</key>', ' <string>Background</string>',
41
- ...(workingDirectory === undefined ? [] : [' <key>WorkingDirectory</key>', ` <string>${xml(workingDirectory)}</string>`]),
53
+ ' <key>ThrottleInterval</key>', ` <integer>${THROTTLE_SECONDS}</integer>`,
54
+ ' <key>ExitTimeOut</key>', ` <integer>${EXIT_TIMEOUT_SECONDS}</integer>`,
55
+ // Standard, not Background: the service's budgets were measured without the I/O throttling of a background process.
56
+ ' <key>ProcessType</key>', ' <string>Standard</string>',
42
57
  ' <key>StandardOutPath</key>', ` <string>${xml(logPath)}</string>`,
43
58
  ' <key>StandardErrorPath</key>', ` <string>${xml(logPath)}</string>`,
44
59
  '</dict>',
@@ -47,7 +62,7 @@ function launchdText({ label, words, logPath, workingDirectory }) {
47
62
  return `${lines.join('\n')}\n`
48
63
  }
49
64
 
50
- function systemdText({ label, words, logPath, workingDirectory }) {
65
+ function systemdText({ label, words, logPath, workingDirectory, searchPath }) {
51
66
  const lines = [
52
67
  '[Unit]',
53
68
  `Description=Atelier Obsidian maintenance (${label.replaceAll(/[^A-Za-z0-9._-]/g, '')})`,
@@ -55,11 +70,13 @@ function systemdText({ label, words, logPath, workingDirectory }) {
55
70
  '[Service]',
56
71
  'Type=simple',
57
72
  `ExecStart=${words.map(unitWord).join(' ')}`,
58
- ...(workingDirectory === undefined ? [] : [`WorkingDirectory=${workingDirectory.replaceAll('%', '%%')}`]),
59
- // Restarted after a crash; not after a clean stop, and not after a refusal (exit status 2).
73
+ ...(searchPath === null ? [] : [`Environment=${unitAssignment('PATH', searchPath)}`]),
74
+ `WorkingDirectory=${workingDirectory.replaceAll('%', '%%')}`,
75
+ // Restarted after a crash or a non-zero exit; not after a clean stop, and not after a refusal (exit status 2).
60
76
  'Restart=on-failure',
61
- 'RestartSec=60',
77
+ `RestartSec=${THROTTLE_SECONDS}`,
62
78
  'RestartPreventExitStatus=2',
79
+ `TimeoutStopSec=${EXIT_TIMEOUT_SECONDS}`,
63
80
  `StandardOutput=append:${logPath.replaceAll('%', '%%')}`,
64
81
  `StandardError=append:${logPath.replaceAll('%', '%%')}`,
65
82
  '',
@@ -69,19 +86,40 @@ function systemdText({ label, words, logPath, workingDirectory }) {
69
86
  return `${lines.join('\n')}\n`
70
87
  }
71
88
 
72
- // { platform, kind, fileName, text }. `args` are the arguments of the entry after `--startup`.
73
- export function buildStartupAdapter({ platform, label, nodePath, entryPath, args = [], logPath, workingDirectory } = {}) {
89
+ // The search path a unit carries: the entries of `value` (a PATH as the environment holds it) that are absolute
90
+ // directories, in their order, each once, and none inside a temporary directory (`temporary`, absolute prefixes): a
91
+ // relative entry would be resolved in the unit's working directory, and a temporary one (an agent's session, say) is
92
+ // gone after a restart. Pure. Null when nothing is left.
93
+ export function startupSearchPath(value, { temporary = [] } = {}) {
94
+ if (typeof value !== 'string') return null
95
+ const inside = (entry) => temporary.some((prefix) => typeof prefix === 'string' && path.posix.isAbsolute(prefix) && (entry === prefix || entry.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`)))
96
+ const kept = []
97
+ for (const raw of value.split(':')) {
98
+ const entry = raw.length > 1 ? raw.replace(/\/+$/, '') : raw
99
+ if (!path.posix.isAbsolute(entry) || CONTROL.test(entry) || inside(entry) || kept.includes(entry)) continue
100
+ kept.push(entry)
101
+ }
102
+ return kept.length === 0 ? null : kept.join(':')
103
+ }
104
+
105
+ // { platform, kind, fileName, text }. `args` are the arguments of the entry after `--startup`; `searchPath` is the PATH
106
+ // the unit sets (absolute entries joined by `:`; see startupSearchPath), none when omitted.
107
+ export function buildStartupAdapter({ platform, label, nodePath, entryPath, args = [], logPath, workingDirectory = '/', searchPath = null } = {}) {
74
108
  if (platform === 'win32') refuse('startup-platform-unqualified', 'operating-system startup on Windows has not been qualified; no unit is generated for it', { platform })
75
109
  if (!STARTUP_PLATFORMS.includes(platform)) refuse('startup-platform-unsupported', 'no startup unit is known for this platform', { platform: String(platform) })
76
110
  if (typeof label !== 'string' || !LABEL.test(label)) refuse('startup-adapter-input-invalid', 'the unit label must be a plain identifier')
77
111
  const flavor = path.posix
78
- for (const [name, value] of [['nodePath', nodePath], ['entryPath', entryPath], ['logPath', logPath], ...(workingDirectory === undefined ? [] : [['workingDirectory', workingDirectory]])]) {
79
- if (typeof value !== 'string' || !flavor.isAbsolute(value) || /[\u0000-\u001f]/.test(value)) refuse('startup-adapter-input-invalid', `${name} must be an absolute path given by the caller`, { name })
112
+ for (const [name, value] of [['nodePath', nodePath], ['entryPath', entryPath], ['logPath', logPath], ['workingDirectory', workingDirectory]]) {
113
+ if (typeof value !== 'string' || !flavor.isAbsolute(value) || CONTROL.test(value)) refuse('startup-adapter-input-invalid', `${name} must be an absolute path given by the caller`, { name })
114
+ }
115
+ if (searchPath !== null && (typeof searchPath !== 'string' || searchPath.split(':').some((entry) => !flavor.isAbsolute(entry) || CONTROL.test(entry)))) {
116
+ refuse('startup-adapter-input-invalid', 'searchPath must be absolute directories joined by ":"', { name: 'searchPath' })
80
117
  }
81
- if (!Array.isArray(args) || args.some((word) => typeof word !== 'string' || word === '' || /[\u0000-\u001f]/.test(word))) refuse('startup-adapter-input-invalid', 'args must be a list of plain words')
118
+ if (!Array.isArray(args) || args.some((word) => typeof word !== 'string' || word === '' || CONTROL.test(word))) refuse('startup-adapter-input-invalid', 'args must be a list of plain words')
82
119
  if (args.includes('--startup')) refuse('startup-adapter-input-invalid', '--startup is added by the builder')
83
120
  const words = [nodePath, entryPath, '--startup', ...args]
121
+ const input = { label, words, logPath, workingDirectory, searchPath }
84
122
  return platform === 'darwin'
85
- ? { platform, kind: 'launchd-user-agent', fileName: `${label}.plist`, text: launchdText({ label, words, logPath, workingDirectory }) }
86
- : { platform, kind: 'systemd-user-unit', fileName: `${label}.service`, text: systemdText({ label, words, logPath, workingDirectory }) }
123
+ ? { platform, kind: 'launchd-user-agent', fileName: `${label}.plist`, text: launchdText(input) }
124
+ : { platform, kind: 'systemd-user-unit', fileName: `${label}.service`, text: systemdText(input) }
87
125
  }
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { setTimeout as delay } from 'node:timers/promises'
4
+ import { obsidianSandboxedBuild, obsidianUserDataDir, readObsidianSettings } from '../../projection/obsidian/publication/vault-list.mjs'
5
+ import { realPathOfLocation } from './vault-location.mjs'
6
+
7
+ // Whether the Obsidian this account runs lists a vault Atelier is filling for
8
+ // the first time, as the publisher's `direct-unheld` path needs to know
9
+ // (publisher.mjs; docs/obsidian-contract.md, "First publication into a vault
10
+ // no Obsidian lists"). Read from the app's settings file, never from the app:
11
+ //
12
+ // { unlisted: true } the file was read, and none of its entries is the vault root, a folder above
13
+ // it or a folder inside it
14
+ // { unlisted: false, reason, ... } anything else, which gives no evidence
15
+ //
16
+ // It errs towards "no evidence": a file that is missing (an app that has shown
17
+ // only its starter window writes none), that cannot be read after `attempts`
18
+ // reads `delayMs` apart (the app rewrites it with a plain write, so a reader
19
+ // can find it empty or cut short while it writes), that is not a JSON object,
20
+ // or that has an entry whose folder is not an absolute path or has a `..`
21
+ // segment (after a link, `..` leads where the text does not); and a Flatpak or
22
+ // snap build of the app anywhere on this account, whose list lives in its
23
+ // sandbox and not in this file. The root and every listed folder are compared
24
+ // as written and as their real paths, in any letter case, so a link on the way
25
+ // to either side, or another spelling, does not hide an entry.
26
+
27
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
28
+ const folded = (target) => target.normalize('NFC').toLowerCase()
29
+ // The real path of a folder that may not exist, and its lexical path when it cannot be resolved (a volume that is gone).
30
+ const realOrLexical = (target) => { try { return realPathOfLocation(target) } catch { return path.resolve(target) } }
31
+ const spellings = (target) => [...new Set([path.resolve(target), realOrLexical(target)].map(folded))]
32
+ // Strictly inside: `..x` is a folder name, not a way up.
33
+ const inside = (parent, child) => { const relative = path.relative(parent, child); return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) }
34
+
35
+ // How a listed folder stands to the vault root, or null when it is neither the root, above it, nor inside it.
36
+ function relationOf(entryPath, root) {
37
+ const listed = spellings(entryPath)
38
+ if (listed.some((folder) => root.includes(folder))) return 'vault-listed'
39
+ if (listed.some((folder) => root.some((candidate) => inside(folder, candidate)))) return 'vault-inside-listed-vault'
40
+ if (listed.some((folder) => root.some((candidate) => inside(candidate, folder)))) return 'listed-vault-inside-vault'
41
+ return null
42
+ }
43
+
44
+ const noEvidence = (reason, extra = {}) => ({ unlisted: false, reason, ...extra })
45
+
46
+ // `read` answers readObsidianSettings for the app's user-data directory; `sandboxed` answers whether a Flatpak or snap
47
+ // build is present. Either one that throws gives no evidence.
48
+ export async function readUnheldEvidence({ vaultRoot, read, sandboxed, attempts = 3, delayMs = 50, sleep = delay } = {}) {
49
+ if (typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot)) return noEvidence('vault-root-unknown')
50
+ if (typeof read !== 'function' || typeof sandboxed !== 'function') return noEvidence('obsidian-settings-location-unknown')
51
+ let present = true
52
+ try { present = sandboxed() !== false } catch { present = true }
53
+ if (present) return noEvidence('obsidian-sandboxed')
54
+ let answer = null
55
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
56
+ try { answer = read() } catch { answer = { ok: false, code: 'obsidian-settings-unreadable' } }
57
+ if (answer?.ok === true || answer?.code !== 'obsidian-settings-unreadable') break
58
+ if (attempt < attempts) await sleep(delayMs)
59
+ }
60
+ if (answer?.ok !== true) return noEvidence(typeof answer?.code === 'string' ? answer.code : 'obsidian-settings-unreadable')
61
+ const vaults = answer.vaults ?? {}
62
+ if (!isPlainObject(vaults)) return noEvidence('obsidian-settings-not-object')
63
+ const root = spellings(vaultRoot)
64
+ for (const [id, entry] of Object.entries(vaults)) {
65
+ if (!isPlainObject(entry) || typeof entry.path !== 'string' || !path.isAbsolute(entry.path) || entry.path.split(/[\\/]/).includes('..')) return noEvidence('obsidian-settings-entry-unreadable', { id })
66
+ const relation = relationOf(entry.path, root)
67
+ if (relation !== null) return noEvidence(relation, { id, path: entry.path })
68
+ }
69
+ return { unlisted: true }
70
+ }
71
+
72
+ // The reader the maintenance service gives its engine: the settings file of the app for this account (HOME, and on
73
+ // Linux XDG_CONFIG_HOME), and any Flatpak or snap build, installed or having left its list, whichever wrote last.
74
+ // `exists` answers whether a path exists: the build's system-wide places (/var/lib/flatpak, /snap) are looked up through
75
+ // it too.
76
+ export function createProductionUnheldEvidence({ platform = process.platform, env = process.env, exists = fs.existsSync } = {}) {
77
+ const userDataDir = obsidianUserDataDir({ platform, env })
78
+ return ({ vaultRoot }) => readUnheldEvidence({
79
+ vaultRoot,
80
+ read: () => (userDataDir === null ? { ok: false, code: 'obsidian-settings-location-unknown' } : readObsidianSettings({ userDataDir })),
81
+ // Present at all, not only when its list was written last: with no list time given, any installation or list counts.
82
+ sandboxed: () => obsidianSandboxedBuild({ platform, env, exists, modified: () => null }) !== null,
83
+ })
84
+ }
@@ -0,0 +1,300 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { checkManagedRoots, realLocation } from '../../project/file-class.mjs'
4
+ import { realPathAsStored } from '../../project/private-state.mjs'
5
+ import { VAULT_ALLOCATION_SCHEMA, VAULT_LOCK_DIRECTORY, allocatedFolderState, folderIdentity, hasCommittedGeneration, readVaultAllocation, writeVaultAllocation } from '../../projection/obsidian/recovery/store.mjs'
6
+ import { refuse } from './errors.mjs'
7
+
8
+ // Where the vaults of a workspace live when a person decided it (the
9
+ // `location` decision of its machine settings), and the folder each view
10
+ // gets there.
11
+ //
12
+ // <location>/<project> (<view>) e.g. ~/Atelier/harbor-notes (everything)
13
+ //
14
+ // The folder's name is the vault's name in Obsidian (its vault switcher, its
15
+ // window title, a `vault=` call), so it carries the project and the view:
16
+ // readable, the same in the file manager and in the app, and unique across
17
+ // projects and views. The view is the text in the last parentheses, up to
18
+ // its first space (a number follows it when the name was taken; view ids
19
+ // never contain a space), and no
20
+ // name of this form is the beginning of another one's path, which matters
21
+ // because the app matches an `obsidian://open?path=` by the longest string
22
+ // prefix among the folders it lists.
23
+ //
24
+ // A folder is allocated once per view, at its first publication, by the
25
+ // maintenance engine under its lock, and recorded (see
26
+ // src/projection/obsidian/recovery/store.mjs). It is never recomputed: a
27
+ // renamed project or a new location leaves an allocated vault where it is.
28
+ // A view published before, under the data root, stays there; a workspace
29
+ // that decided no location publishes there as before.
30
+
31
+ export const MAX_PROJECT_NAME_BYTES = 80
32
+
33
+ const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029/\\:*?"<>|#^[\]()]/g
34
+ // Bidirectional controls and marks, and characters that show nothing: a name reads as it is.
35
+ const INVISIBLE = /[\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/g
36
+ const WINDOWS_DEVICE = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)$/i
37
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
38
+
39
+ // The longest start of `text` of at most `maxBytes` bytes that ends between two characters as a person reads them: a
40
+ // letter and its accents, or an emoji sequence, stay together or go together.
41
+ function cutToBytes(text, maxBytes) {
42
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text
43
+ let cut = ''
44
+ for (const { segment } of GRAPHEMES.segment(text)) {
45
+ if (Buffer.byteLength(cut + segment, 'utf8') > maxBytes) break
46
+ cut += segment
47
+ }
48
+ return cut
49
+ }
50
+
51
+ // A piece of text as a folder name on macOS, Linux and Windows, and in the app: NFC, no control, separator, reserved
52
+ // or link character, no parentheses (they end the view's part), single spaces, no leading or trailing space or dot
53
+ // (a leading dot hides a folder from the app), cut to a number of bytes between two characters as a person reads them,
54
+ // and no Windows device name. May be empty.
55
+ export function safeFolderPart(text, maxBytes = MAX_PROJECT_NAME_BYTES) {
56
+ const cleaned = String(text ?? '').normalize('NFC').replace(INVISIBLE, '').replace(UNSAFE, ' ').replace(/\s+/gu, ' ').replace(/^[\s.]+|[\s.]+$/gu, '')
57
+ const cut = cutToBytes(cleaned, maxBytes).replace(/[\s.]+$/u, '')
58
+ return WINDOWS_DEVICE.test(cut.split('.')[0]) ? `_${cut}` : cut
59
+ }
60
+
61
+ // The name people and Obsidian read for a project: its configured `name`, else the name of its folder.
62
+ export function projectDisplayName(project) {
63
+ return safeFolderPart(project?.config?.name) || safeFolderPart(path.basename(String(project?.configDir ?? ''))) || 'project'
64
+ }
65
+
66
+ // `<project> (<view>)`, or `<project> (<view> <number>)` from the second.
67
+ export function vaultFolderName({ projectName, scopeId, number = 1 }) {
68
+ const view = String(scopeId).replaceAll(':', '_')
69
+ return `${projectName} (${view}${number > 1 ? ` ${number}` : ''})`
70
+ }
71
+
72
+ const isInside = (parent, child) => { const relative = path.relative(parent, child); return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative) }
73
+ const isSameOrInside = (parent, child) => parent === child || isInside(parent, child)
74
+
75
+ // The nearest folder that exists on the way up from `target` (itself included).
76
+ function nearestExisting(target) {
77
+ let current = path.resolve(target)
78
+ for (;;) {
79
+ if (fs.existsSync(current)) return current
80
+ const parent = path.dirname(current)
81
+ if (parent === current) return current
82
+ current = parent
83
+ }
84
+ }
85
+
86
+ // A path compared with another as a volume that folds letter case would: NFC, lower case. Used where a false match
87
+ // only refuses (an enclosing vault, a synced or protected folder, a listed name), so a case-sensitive volume is at worst
88
+ // refused a folder whose name differs from a listed vault's in letter case only.
89
+ const folded = (target) => target.normalize('NFC').toLowerCase()
90
+ const foldedInside = (parent, child) => isSameOrInside(folded(parent), folded(child))
91
+ // The real path of a folder that may not exist yet, as the file system stores it (realPathAsStored): the nearest
92
+ // existing folder on the way up resolved, links and letter case included, and the missing tail appended.
93
+ export const realPathOfLocation = (target) => realLocation(target, realPathAsStored)
94
+ // The same, for a folder read from somewhere else (the app's list, the home folder): its lexical path when it cannot be
95
+ // resolved, since it may be on a volume that is gone.
96
+ const realOrLexical = (target) => { try { return realPathOfLocation(target) } catch { return path.resolve(target) } }
97
+ // A path as written and as its real path, both.
98
+ const spellings = (target) => [...new Set([path.resolve(target), realOrLexical(target)])]
99
+
100
+ // A folder a sync client of this account keeps in step with other machines, by name; null for any other folder.
101
+ // `exists` answers whether a path exists. iCloud also syncs Desktop and Documents when "Desktop & Documents Folders"
102
+ // is on, which shows as their folders inside iCloud Drive.
103
+ export function syncedFolderOf(target, { homedir, exists = fs.existsSync } = {}) {
104
+ if (typeof homedir !== 'string' || !path.isAbsolute(homedir)) return null
105
+ const resolved = path.resolve(target)
106
+ const within = (...parts) => foldedInside(path.join(homedir, ...parts), resolved)
107
+ if (within('Library', 'Mobile Documents')) return 'iCloud Drive'
108
+ if (within('Library', 'CloudStorage')) return 'a cloud storage provider'
109
+ for (const [folder, name] of [['Dropbox', 'Dropbox'], ['OneDrive', 'OneDrive'], ['Google Drive', 'Google Drive'], ['Box', 'Box']]) if (within(folder)) return name
110
+ for (const folder of ['Desktop', 'Documents']) {
111
+ if (!within(folder)) continue
112
+ try { if (exists(path.join(homedir, 'Library', 'Mobile Documents', 'com~apple~CloudDocs', folder))) return `iCloud (${folder})` } catch { /* not known */ }
113
+ }
114
+ return null
115
+ }
116
+
117
+ // A folder for which macOS asks a person before an app, or a background service, may read it; null otherwise.
118
+ export function protectedFolderOf(target, { homedir, platform = process.platform } = {}) {
119
+ if (platform !== 'darwin' || typeof homedir !== 'string' || !path.isAbsolute(homedir)) return null
120
+ const resolved = path.resolve(target)
121
+ return ['Desktop', 'Documents', 'Downloads'].find((folder) => foldedInside(path.join(homedir, folder), resolved)) ?? null
122
+ }
123
+
124
+ // The folder of a vault Atelier publishes into that contains `target`, or null: the vault lock of a published vault,
125
+ // or a folder another view of this workspace was allocated.
126
+ function enclosingAtelierVault(target, allocatedPaths = []) {
127
+ const candidates = spellings(target)
128
+ for (const allocated of allocatedPaths) if (spellings(allocated).some((folder) => candidates.some((candidate) => foldedInside(folder, candidate)))) return allocated
129
+ for (const resolved of candidates) {
130
+ let current = resolved
131
+ for (;;) {
132
+ try { if (fs.statSync(path.join(current, VAULT_LOCK_DIRECTORY)).isDirectory()) return current } catch { /* not a published vault */ }
133
+ const parent = path.dirname(current)
134
+ if (parent === current) break
135
+ current = parent
136
+ }
137
+ }
138
+ return null
139
+ }
140
+
141
+ // The folder of a vault the app lists that contains `target` or is it; null when there is none or no list is known.
142
+ // `vaults` is the app's own map, id -> { path }. Both sides are compared as written and as their real paths, in any
143
+ // letter case, so neither a link nor another spelling of a listed folder hides it.
144
+ function enclosingListedVault(target, vaults) {
145
+ if (vaults === null || typeof vaults !== 'object') return null
146
+ const candidates = spellings(target)
147
+ for (const entry of Object.values(vaults)) {
148
+ if (entry === null || typeof entry !== 'object' || typeof entry.path !== 'string' || !path.isAbsolute(entry.path)) continue
149
+ if (spellings(entry.path).some((folder) => candidates.some((candidate) => foldedInside(folder, candidate)))) return entry.path
150
+ }
151
+ return null
152
+ }
153
+
154
+ // A folder a sync client keeps in step, or one macOS protects, for any spelling of the target and the home folder.
155
+ const anySpelling = (check, target, homedir) => {
156
+ if (typeof homedir !== 'string' || !path.isAbsolute(homedir)) return null
157
+ for (const home of spellings(homedir)) for (const candidate of spellings(target)) { const found = check(candidate, home); if (found !== null) return found }
158
+ return null
159
+ }
160
+
161
+ // Whether `parent` may hold this workspace's vaults, checked before it is decided and again before a folder is
162
+ // allocated there. Refuses, typed, a folder inside an enrolled repository or the project, inside a vault Atelier
163
+ // publishes, inside a vault the app lists (when `vaults` is known), or on another volume than the workspace's private
164
+ // state (the atomic exchange that publishes a note cannot cross volumes); and a folder a sync client keeps in step,
165
+ // unless `allowSynced`. Answers the warnings a person should hear: { synced, protected }. `deviceOf` answers the
166
+ // device a path is on.
167
+ export function checkVaultParent({ parent, workspaceRoot, repositoryRoots, vaults = null, allocatedPaths = [], allowSynced = false, homedir, platform = process.platform, deviceOf = (target) => fs.statSync(target).dev }) {
168
+ if (typeof parent !== 'string' || !path.isAbsolute(parent) || path.resolve(parent) !== parent) refuse('vault-location-not-absolute', 'where vaults live must be an absolute folder, written plainly')
169
+ // A repository beside the vaults, below the same folder (a home folder, say), is no overlap: each vault folder is
170
+ // checked again against every repository when its store is made.
171
+ const overlaps = checkManagedRoots({ managedRoots: [parent], repositoryRoots }).refusals.filter(({ code }) => code !== 'repository-inside-managed-root')
172
+ // A folder that is itself a link: the folder it leads to is named instead, so the decision says where vaults are.
173
+ if (overlaps.some(({ code }) => code === 'managed-root-symlink-alias')) refuse('vault-location-is-link', 'this folder is a link; name the folder it leads to', { refusals: overlaps.map(({ code }) => code) })
174
+ if (overlaps.length > 0) refuse('vault-location-inside-repository', 'vaults never live inside an enrolled repository or the project', { refusals: overlaps.map(({ code }) => code) })
175
+ // Atelier's own private state: publication state, recovery and staging live there, and a vault inside it would be
176
+ // published over them.
177
+ if (typeof workspaceRoot === 'string' && spellings(workspaceRoot).some((root) => spellings(parent).some((candidate) => foldedInside(root, candidate)))) {
178
+ refuse('vault-location-inside-private-state', 'vaults never live inside Atelier\'s private state for this workspace')
179
+ }
180
+ // A folder a vault can be made in: the nearest folder that exists on the way is a folder this user can write in.
181
+ const nearest = nearestExisting(parent)
182
+ try {
183
+ if (!fs.statSync(nearest).isDirectory()) refuse('vault-location-unusable', 'no folder can be made there: a file is in the way', { path: nearest })
184
+ fs.accessSync(nearest, fs.constants.W_OK | fs.constants.X_OK)
185
+ } catch (error) {
186
+ if (typeof error?.code === 'string' && !/^E[A-Z]+$/.test(error.code)) throw error
187
+ refuse('vault-location-unusable', 'no folder can be made there: this user cannot write in the nearest folder that exists', { path: nearest, cause: error.code ?? null })
188
+ }
189
+ const inAtelierVault = enclosingAtelierVault(parent, allocatedPaths)
190
+ if (inAtelierVault !== null) refuse('vault-location-inside-vault', 'vaults never live inside a vault Atelier publishes', { vault: inAtelierVault })
191
+ const inListedVault = enclosingListedVault(parent, vaults)
192
+ if (inListedVault !== null) refuse('vault-location-inside-vault', 'vaults never live inside a folder Obsidian lists as a vault: that vault would show their notes too, and a call run in their folders would reach it', { vault: inListedVault })
193
+ if (typeof workspaceRoot === 'string' && deviceOf(nearestExisting(parent)) !== deviceOf(nearestExisting(workspaceRoot))) {
194
+ refuse('vault-location-other-volume', 'vaults live on the volume of Atelier\'s private data, because a note is published by an exchange that cannot cross volumes; name another data root (--data-root) on that volume to use it')
195
+ }
196
+ const synced = anySpelling((candidate, home) => syncedFolderOf(candidate, { homedir: home }), parent, homedir)
197
+ if (synced !== null && !allowSynced) {
198
+ refuse('vault-location-synced', `this folder is kept in step by ${synced}: another machine's Obsidian could hold a vault there unseen, and a sync conflict is not a publication; pass --allow-synced-location to use it anyway`, { synced })
199
+ }
200
+ return { synced, protected: anySpelling((candidate, home) => protectedFolderOf(candidate, { homedir: home, platform }), parent, homedir) }
201
+ }
202
+
203
+ // The folder that holds the vaults, made when it is missing: private to this user, as each vault in it is. A folder on
204
+ // the way to it that is missing too is made as any other folder is, so naming ~/Projects/Atelier makes ~/Projects an
205
+ // ordinary folder.
206
+ function makeHoldingFolder(parent) {
207
+ fs.mkdirSync(path.dirname(parent), { recursive: true })
208
+ try { fs.mkdirSync(parent, { mode: 0o700 }) } catch (error) { if (error?.code !== 'EEXIST') throw error }
209
+ }
210
+
211
+ // The view's allocation, allocated now when this workspace decided where its vaults live and the view has none yet
212
+ // and was never published under the data root; null when the view's vault stays under the data root. The folder is
213
+ // made here, exclusively and private to this user, under the first name that is free: taken by nothing on the disk
214
+ // and, when the app's list is known, by no vault it lists with that name in any letter case.
215
+ // `storedPathOf` answers a folder's real path as the file system stores it (realPathAsStored).
216
+ export function ensureVaultAllocation({ workspaceRoot, workspaceId, scopeId, location, projectName, repositoryRoots, vaults = null, allocatedPaths = [], homedir, now, storedPathOf = realPathAsStored }) {
217
+ const existing = readVaultAllocation({ workspaceRoot, workspaceId, scopeId })
218
+ // An allocated folder that has gone is made again where it was, private, and recorded as the folder it now is; one
219
+ // another folder replaced is left alone, and the store refuses to publish into it. `vaults` is the app's list here
220
+ // too: a folder is made again only where one would be allocated.
221
+ if (existing !== null && allocatedFolderState(existing) === 'missing') {
222
+ // Only where the store would publish: a record that names a folder inside a repository, the project or the
223
+ // private state (edited, or copied from elsewhere) makes nothing there.
224
+ const guard = checkManagedRoots({ managedRoots: [workspaceRoot, existing.path], repositoryRoots })
225
+ if (!guard.ok) refuse(guard.refusals[0].code, guard.refusals[0].message, { refusals: guard.refusals.map(({ code }) => code) })
226
+ if (spellings(workspaceRoot).some((root) => spellings(existing.path).some((candidate) => foldedInside(root, candidate)))) {
227
+ refuse('vault-location-inside-private-state', 'vaults never live inside Atelier\'s private state for this workspace')
228
+ }
229
+ // Only where it was: the folder that held it leads where it did, through no link put on the way since (into a
230
+ // vault the app lists, a synced folder), and passes every check a location is decided with.
231
+ const moved = (leadsTo) => refuse('vault-allocation-moved', 'the folder that held this view\'s vault now leads somewhere else, through a link; its vault is not made again there', { parent: existing.parent, leadsTo })
232
+ let realParent = null
233
+ try { realParent = realPathOfLocation(existing.parent) } catch { /* not reachable */ }
234
+ if (realParent !== existing.parent) moved(realParent)
235
+ checkVaultParent({ parent: existing.parent, workspaceRoot, repositoryRoots, vaults, allocatedPaths: allocatedPaths.filter((allocated) => allocated !== existing.path), allowSynced: true, homedir })
236
+ try { makeHoldingFolder(existing.parent); fs.mkdirSync(existing.path, { mode: 0o700 }) } catch (error) {
237
+ if (error?.code === 'EEXIST') return existing
238
+ refuse('vault-location-unusable', 'the folder for this view\'s vault cannot be made again there', { parent: existing.parent, cause: error?.code ?? null })
239
+ }
240
+ // Made where the record says and nowhere else: a link put on the way between the check and the folder is found here.
241
+ let madeAt = null
242
+ try { madeAt = realPathAsStored(existing.path) } catch { /* not reachable */ }
243
+ if (madeAt !== existing.path) {
244
+ try { fs.rmdirSync(existing.path) } catch { /* not ours to remove */ }
245
+ moved(madeAt)
246
+ }
247
+ const made = folderIdentity(existing.path)
248
+ return writeVaultAllocation({ workspaceRoot, allocation: { ...existing, device: made.device, inode: made.inode } })
249
+ }
250
+ if (existing !== null) return existing
251
+ if (location === null || location === undefined || hasCommittedGeneration({ workspaceRoot, scopeId })) return null
252
+ const parent = location.parent
253
+ // A synced location was allowed when it was decided; it is checked again for everything else.
254
+ checkVaultParent({ parent, workspaceRoot, repositoryRoots, vaults, allocatedPaths, allowSynced: true, homedir })
255
+ const unusable = (error) => refuse('vault-location-unusable', 'the folder for this view\'s vault cannot be made there', { parent, cause: error?.code ?? String(error?.message ?? error) })
256
+ try { makeHoldingFolder(parent) } catch (error) { unusable(error) }
257
+ // The folder is recorded by its real path, as the file system stores it: a link on the way to the location decided
258
+ // then leads nowhere else later, and the path is the one the app and every check see.
259
+ const realParent = realPathAsStored(parent)
260
+ const listedNames = new Set(Object.values(vaults ?? {}).filter((entry) => typeof entry?.path === 'string').map((entry) => folded(path.basename(entry.path))))
261
+ for (let number = 1; number <= 99; number += 1) {
262
+ const name = vaultFolderName({ projectName, scopeId, number })
263
+ const folder = path.join(realParent, name)
264
+ if (listedNames.has(folded(name)) || fs.lstatSync(folder, { throwIfNoEntry: false }) !== undefined) continue
265
+ try { fs.mkdirSync(folder, { mode: 0o700 }) } catch (error) { if (error.code === 'EEXIST') continue; unusable(error) }
266
+ try { fs.chmodSync(folder, 0o700) } catch { /* a file system without modes */ }
267
+ const made = folderIdentity(folder)
268
+ // Recorded as the file system stores the folder's path, which is what every later check reads back: a volume that
269
+ // stores names in another Unicode form (HFS+ stores NFD) would otherwise never match the name as it was asked for.
270
+ let stored = null
271
+ try { stored = storedPathOf(folder) } catch { /* not reachable: refused below */ }
272
+ // Only the folder just made, under the parent that was checked: its name may come back in another Unicode form, and
273
+ // nothing else may differ. A link put on the way since would otherwise be recorded, and then trusted.
274
+ if (stored === null || path.dirname(stored) !== realParent || path.basename(stored).normalize('NFC') !== name.normalize('NFC')) {
275
+ try { fs.rmdirSync(folder) } catch { /* not ours to remove */ }
276
+ refuse('vault-allocation-moved', 'the folder that holds the vaults led somewhere else once this view\'s folder was made; nothing is recorded', { parent: realParent, leadsTo: stored })
277
+ }
278
+ return writeVaultAllocation({ workspaceRoot, allocation: { schema: VAULT_ALLOCATION_SCHEMA, workspaceId, scopeId, path: stored, name: path.basename(stored), parent: path.dirname(stored), device: made.device, inode: made.inode, allocatedAt: now } })
279
+ }
280
+ return refuse('vault-location-full', 'no free vault name is left for this view in that folder', { parent })
281
+ }
282
+
283
+ // The app's vault list for an allocation, from its settings file and never from the app: { ok: true, vaults } when it
284
+ // was read, or when the app never ran here (no file, and no Flatpak or snap build whose list lives elsewhere), and
285
+ // { ok: false, code } otherwise, so a folder is never allocated while a listed vault could enclose it or have its
286
+ // name. `read` answers readObsidianSettings; a file the app is writing can read as unreadable for a moment, so an
287
+ // unreadable file is read again, up to `attempts` times `delayMs` apart. `sandboxed` is true for such a build.
288
+ export async function readAppVaultListForAllocation({ read, sandboxed = false, attempts = 3, delayMs = 50, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
289
+ if (sandboxed) return { ok: false, code: 'obsidian-sandboxed' }
290
+ let answer = null
291
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
292
+ try { answer = read() } catch { answer = { ok: false, code: 'obsidian-settings-unreadable' } }
293
+ if (answer?.ok === true) return { ok: true, vaults: answer.vaults ?? {} }
294
+ if (answer?.code === 'obsidian-settings-missing') return { ok: true, vaults: {} }
295
+ if (answer?.code !== 'obsidian-settings-unreadable') break
296
+ if (attempt < attempts) await sleep(delayMs)
297
+ }
298
+ return { ok: false, code: typeof answer?.code === 'string' ? answer.code : 'obsidian-settings-unreadable' }
299
+ }
300
+
@@ -0,0 +1,34 @@
1
+ import { selectScope } from '../../projection/obsidian/contracts.mjs'
2
+ import { DEFAULT_ELIGIBILITY, buildGraph, profileFor } from './pipeline.mjs'
3
+
4
+ // How many notes a view would show on this machine, and why the other notes
5
+ // its selector names are withheld: the numbers a person sees before a view is
6
+ // declared or first opened. Read-only. The canonical graph is built as the
7
+ // engine builds it, with the eligibility the caller names (the one the machine
8
+ // settings decide, `eligibilityFor`), and the selection is the contract's own
9
+ // selectScope, so the count is the view's.
10
+
11
+ // { corpus, named, shown, withheld: { unclassified, audience }, truncated }: the notes of the project; those the
12
+ // view's selector names, whatever their classification and audience; those the view shows under `audienceAllow`
13
+ // and `eligibility` (with its expansion); and, of the named ones, how many are withheld because they are not
14
+ // eligible (they carry no classification, or, in a vault that is only yours, cannot be read as notes), or carry an
15
+ // audience that is not admitted.
16
+ export function viewCounts({ project, audienceAllow, scope, eligibility = DEFAULT_ELIGIBILITY, workspaceId = 'ws-unprepared' }) {
17
+ const graph = buildGraph({ project, eligibility })
18
+ const known = new Set(graph.nodes.map((node) => node.id))
19
+ const edges = graph.edges.filter((edge) => known.has(edge.source) && known.has(edge.target))
20
+ const profile = profileFor({ project, workspaceId, audienceAllow })
21
+ const selection = selectScope({ canonicalSnapshot: { nodes: graph.nodes, edges }, profile, selector: scope.selector, expansion: scope.expansion, mode: scope.mode })
22
+ // The same selector over every note made visible names what it would show if nothing were withheld.
23
+ const everyNote = graph.nodes.map((node) => ({ ...node, eligible: true, audience: undefined }))
24
+ const named = selectScope({ canonicalSnapshot: { nodes: everyNote, edges: [] }, profile, selector: scope.selector, mode: scope.mode === 'focus' ? 'scoped' : scope.mode }).nodes
25
+ const byId = new Map(graph.nodes.map((node) => [node.id, node]))
26
+ const allowed = new Set(audienceAllow)
27
+ const withheld = { unclassified: 0, audience: 0 }
28
+ for (const id of named) {
29
+ const node = byId.get(id)
30
+ if (node.eligible !== true) withheld.unclassified += 1
31
+ else if (node.audience !== undefined && !allowed.has(node.audience)) withheld.audience += 1
32
+ }
33
+ return { corpus: graph.nodes.length, named: named.length, shown: selection.nodes.length, withheld, truncated: selection.truncated }
34
+ }