@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
@@ -91,6 +91,16 @@ export function heldPaths(edits, scopeId) {
91
91
  return [...new Set(edits.filter((edit) => edit.scopeId === scopeId && isOpenEdit(edit)).map((edit) => edit.path))].sort(compareText)
92
92
  }
93
93
 
94
+ // The notes a view must keep in the vault layout they were published in: those
95
+ // held for an open edit, and those whose edit closed on this tick (automatic
96
+ // mode applies and closes an edit on the tick it observes it). The view is
97
+ // prepared in that layout once more, so the file the person edited becomes the
98
+ // published note, and a later tick lays the view out again and retires it.
99
+ export function layoutHeldPaths(edits, scopeId, now) {
100
+ const closedNow = edits.filter((edit) => edit.scopeId === scopeId && edit.closedAt !== null && edit.closedAt === now)
101
+ return [...new Set([...heldPaths(edits, scopeId), ...closedNow.map((edit) => edit.path)])].sort(compareText)
102
+ }
103
+
94
104
  // Automatic dispatch. `authorize()` reads the machine settings and the policy
95
105
  // from disk. It is called before the batch and again immediately before each
96
106
  // edit, so a revocation, a pause or a switch back to manual stops the very
@@ -1,7 +1,9 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import path from 'node:path'
3
3
  import { buildCanonicalGraph, createGraphFileCache } from '../../graph/graph.mjs'
4
- import { EMITTER_VERSION, createPreparationCache, prepareView, withEligibility } from '../../projection/obsidian/materialize/index.mjs'
4
+ import { markdownMetadata } from '../../graph/knowledge-graph.mjs'
5
+ import { EMITTER_VERSION, createPreparationCache, prepareView, readMarkdownLens, withEligibility } from '../../projection/obsidian/materialize/index.mjs'
6
+ import { personalWorkspaceGraph } from '../../projection/obsidian/personal-workspace.mjs'
5
7
  import { publishView } from '../../projection/obsidian/publication/publisher.mjs'
6
8
  import { recheckDisplacedFiles } from '../../projection/obsidian/recovery/late-writer.mjs'
7
9
  import { createRecoveryStore, readFileBytes } from '../../projection/obsidian/recovery/store.mjs'
@@ -31,6 +33,68 @@ export const DEFAULT_ELIGIBILITY = Object.freeze({
31
33
  isEligible: (node) => node.classification === 'classified',
32
34
  })
33
35
 
36
+ // A vault that is the person's own also shows the notes that carry no classification, when they decided so: the
37
+ // audience decision `only-you` with `unclassified: 'shown'`, which no other audience decision may carry. Those notes
38
+ // are the person's own files, in a vault only they see. One is admitted only when its bytes read as a note (the byte
39
+ // lens the emitter reads it with), so a file the emitter would refuse never stops the vault: it stays withheld, as does
40
+ // one that cannot be read. A note whose labels are unknown is never admitted: front matter Atelier could not read
41
+ // (`malformed-frontmatter`: a block scalar, a wrapped value), or any top-level `kg` key that is not a
42
+ // block, may carry an audience such as `sensitive` that "only you" leaves out. Only a note with no front matter, or
43
+ // front matter of plain top-level keys at column 0 none of which is `kg` (plainTopLevelKeys) and in which the graph
44
+ // reads no `kg` either, is the person's plain note. Assets follow the documents that
45
+ // embed them, as always.
46
+ export function onlyYouEligibility({ project }) {
47
+ const roots = new Map((project.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => [repo.name, repo.path]))
48
+ const readsAsNote = (node) => {
49
+ const root = roots.get(node.repo)
50
+ if (root === undefined || node.extension !== 'md' || typeof node.path !== 'string') return false
51
+ let bytes
52
+ let lens
53
+ try { bytes = readFileBytes(path.join(root, ...node.path.split('/'))); lens = readMarkdownLens(bytes) } catch { return false }
54
+ // Read now, not as the graph saw it: a file that gained front matter since is judged by what it holds now.
55
+ if (lens.frontmatter === null) return node.classificationReason === 'absent-frontmatter'
56
+ if (node.classificationReason !== 'missing-kg-block') return false
57
+ const text = bytes.toString('utf8')
58
+ const block = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
59
+ if (block === null || !plainTopLevelKeys(block[1])) return false
60
+ // A second layer: the graph's own reading of the front matter has no kg key either.
61
+ try { return !('kg' in markdownMetadata(text)) } catch { return false }
62
+ }
63
+ return Object.freeze({
64
+ revision: () => 'classified-documents/v2+unclassified-notes-read-as-notes-for-only-you/v4+assets-embedded-by-eligible-documents/v1',
65
+ isEligible: (node) => node.classification === 'classified' || (node.classification === 'unclassified' && readsAsNote(node)),
66
+ })
67
+ }
68
+
69
+ // Whether front matter is plain `key: value` lines at its top level, none of them `kg`: every line that is not blank,
70
+ // a comment, or indented (a nested value or a continuation) starts with a bare key. A quoted key, a flow or JSON
71
+ // mapping, a complex key (`? `), a top-level list or anything else unusual is not plain, so a label it may hold (a
72
+ // `kg.audience: sensitive` Atelier did not read) keeps the note withheld. An allow-list, never a pattern to find.
73
+ const PLAIN_KEY = /^([A-Za-z_][A-Za-z0-9_-]*)[ \t]*:(?:[ \t]|$)/
74
+ function plainTopLevelKeys(frontmatter) {
75
+ // Line breaks YAML 1.1 readers honour and this reading does not (NEL, LS, PS) could hide a kg from it.
76
+ if (/[\u0085\u2028\u2029]/.test(frontmatter)) return false
77
+ let rooted = false
78
+ for (const line of frontmatter.split(/\r?\n/)) {
79
+ const trimmed = line.trim()
80
+ if (trimmed === '' || trimmed.startsWith('#')) continue
81
+ // An indented line is a nested value or a continuation only below a key at column 0; YAML also lets a whole root
82
+ // mapping be indented, and then its keys (kg among them) are indented too.
83
+ if (/^[ \t]/.test(line)) { if (!rooted) return false; continue }
84
+ const key = PLAIN_KEY.exec(line)
85
+ if (key === null || key[1] === 'kg') return false
86
+ rooted = true
87
+ }
88
+ return true
89
+ }
90
+
91
+ // The eligibility a workspace's machine settings decide: the one above only for "only you" with unclassified notes
92
+ // shown, and the default, which withholds them, for every other decision and for none.
93
+ export function eligibilityFor({ machine, project }) {
94
+ const audience = machine?.decisions?.audience
95
+ return audience?.choice === 'only-you' && audience.unclassified === 'shown' ? onlyYouEligibility({ project }) : DEFAULT_ELIGIBILITY
96
+ }
97
+
34
98
  export function assetEligibilityFor({ graph, eligibility }) {
35
99
  const eligibleSources = new Set(graph.nodes.filter((node) => eligibility.isEligible(node) === true).map((node) => node.id))
36
100
  const embeds = Array.isArray(graph.embeds) ? graph.embeds : []
@@ -59,9 +123,13 @@ export function profileFor({ project, workspaceId, audienceAllow }) {
59
123
  // `index` is the engine's observation index. With both, a source whose observed digest is the cached one is not
60
124
  // opened: observation already decided by digest that it did not change, under the stat-hint bound observation
61
125
  // documents, and a full reconciliation hashes it again. Without an index every source is read and hashed here.
62
- export function buildGraph({ project, eligibility, cache = null, index = null }) {
126
+ // A project bound to a personal workspace (personal-workspace.mjs) is built by that route, for every caller, with the
127
+ // same caches: the composition's graph, served only under a validity key the composition confirmed. With
128
+ // `deferPersonalValidation` (the engine), an unconfirmed key refuses `personal-validation-pending` instead of composing
129
+ // on this thread.
130
+ export function buildGraph({ project, eligibility, cache = null, index = null, deferPersonalValidation = false }) {
63
131
  const observedDigest = cache && index ? (repoId, relative) => index.get(sourceKey(repoId, relative))?.digest ?? null : null
64
- const canonical = buildCanonicalGraph(project, { fileCache: cache, observedDigest })
132
+ const canonical = personalWorkspaceGraph(project, { fileCache: cache, observedDigest, defer: deferPersonalValidation }) ?? buildCanonicalGraph(project, { fileCache: cache, observedDigest })
65
133
  if (!canonical.ok) refuse('canonical-graph-invalid', 'the canonical graph has errors; no view is prepared from it', { errorCount: canonical.errors.length })
66
134
  return withEligibility(canonical, eligibility.isEligible, assetEligibilityFor({ graph: canonical, eligibility }))
67
135
  }
@@ -0,0 +1,289 @@
1
+ import { createHash, randomBytes as cryptoRandomBytes, timingSafeEqual } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, readRegularTextNoFollow, realPathAsStored } from '../../project/private-state.mjs'
5
+ import { assertAllocatedFolder, vaultRootFor } from '../../projection/obsidian/recovery/store.mjs'
6
+ import {
7
+ PLUGIN_BEARER, PLUGIN_CHALLENGE_WINDOW_MS, PLUGIN_CHANNEL_PROTOCOL, PLUGIN_HANDSHAKE_TTL_MS, PLUGIN_LEASE_TTL_MS, PLUGIN_MAX_PENDING_HANDSHAKES_PER_SCOPE, PLUGIN_MAX_SESSION_AGE_MS, PLUGIN_MAX_SESSIONS_PER_SCOPE,
8
+ PLUGIN_RENEW_INTERVAL_MS, PLUGIN_STATUS_SCHEMA, pluginClientProof, pluginKeyHint, pluginRequestMac, pluginResponseMac, pluginServerProof, pluginSessionKey, pluginVaultProof,
9
+ } from '../../projection/obsidian/plugin-bridge/channel.mjs'
10
+ import { canonicalJson, compareText, isPlainObject, isoTime } from './documents.mjs'
11
+
12
+ // The service's half of the plugin channel (plugin-bridge/channel.mjs).
13
+ //
14
+ // bearers one random bearer per view, minted when the view's vault first
15
+ // receives the plugin, kept owner-only under state/plugin/ and
16
+ // written into that vault's plugin data file, nowhere else; it
17
+ // is the key of the handshake and never crosses the wire
18
+ // sessions who holds a view open right now: a plugin that proved it holds
19
+ // the vault's key and renews its lease; in memory, gone when the
20
+ // service stops
21
+ // commands challenge, hello, lease, release and status, answered from
22
+ // state the service already keeps; nothing here writes a vault, a
23
+ // source, a manifest or an edit, and no command decides anything
24
+ //
25
+ // A live session also tells the app version the plugin runs in, and an id of
26
+ // that launch of the plugin. That version counts as checked for app
27
+ // qualification (app-capability.mjs) while one launch alone holds the view:
28
+ // the plugin runs inside exactly that app.
29
+
30
+ export const PLUGIN_BEARER_SCHEMA = 'atelier-obsidian-plugin-bearer/v1'
31
+ export const PLUGIN_PRESENCE_SCHEMA = 'atelier-obsidian-plugin-presence/v1'
32
+ const MAX_BEARER_FILES = 256
33
+ const segment = (identifier) => identifier.replaceAll(':', '_')
34
+
35
+ export function pluginBearerDirectory(workspaceRoot) {
36
+ return path.join(workspaceRoot, 'state', 'plugin')
37
+ }
38
+
39
+ function readBearerFile(file, workspaceId) {
40
+ let document
41
+ try { document = JSON.parse(readRegularTextNoFollow(file)) } catch { return null }
42
+ if (!isPlainObject(document) || Object.keys(document).sort().join(',') !== 'bearer,createdAt,schema,scopeId,workspaceId') return null
43
+ if (document.schema !== PLUGIN_BEARER_SCHEMA || document.workspaceId !== workspaceId || typeof document.scopeId !== 'string' || !PLUGIN_BEARER.test(document.bearer ?? '')) return null
44
+ return document
45
+ }
46
+
47
+ // The bearer of one view's vault, minted on first use. A file that does not
48
+ // read as this view's bearer is replaced: it cannot authenticate anything, and
49
+ // the vault receives the new bearer with its next publication.
50
+ export function ensurePluginBearer({ workspaceRoot, workspaceId, scopeId, randomBytes = cryptoRandomBytes, clock = () => new Date() }) {
51
+ const directory = ensureContainedPrivateDirectory({ workspaceRoot, directory: pluginBearerDirectory(workspaceRoot), label: 'Obsidian plugin state' })
52
+ const file = path.join(directory, `${segment(scopeId)}.json`)
53
+ const existing = readBearerFile(file, workspaceId)
54
+ if (existing?.scopeId === scopeId) return existing.bearer
55
+ const bearer = randomBytes(32).toString('base64url')
56
+ atomicReplacePrivateText(file, canonicalJson({ schema: PLUGIN_BEARER_SCHEMA, workspaceId, scopeId, bearer, createdAt: isoTime(clock) }))
57
+ return bearer
58
+ }
59
+
60
+ // Every view's bearer this workspace has minted: scopeId -> bearer.
61
+ export function readPluginBearers({ workspaceRoot, workspaceId }) {
62
+ const directory = pluginBearerDirectory(workspaceRoot)
63
+ let names
64
+ try { names = fs.readdirSync(directory) } catch (error) { if (error.code === 'ENOENT') return new Map(); throw error }
65
+ const bearers = new Map()
66
+ for (const name of names.filter((item) => item.endsWith('.json')).sort().slice(0, MAX_BEARER_FILES)) {
67
+ const document = readBearerFile(path.join(directory, name), workspaceId)
68
+ if (document !== null && name === `${segment(document.scopeId)}.json`) bearers.set(document.scopeId, document.bearer)
69
+ }
70
+ return bearers
71
+ }
72
+
73
+ // The bearers in memory. They are read again only when the directory changed
74
+ // (a bearer minted, replaced, or deleted to rotate it), so a request costs a
75
+ // stat and a listing of the directory, not a read of every file. The names
76
+ // are part of what is compared: not every platform changes a directory's times
77
+ // when an entry goes away. A bearer the service replaces under the same name
78
+ // is announced with `invalidate()`.
79
+ export function createPluginBearerCache({ workspaceRoot, workspaceId, read = readPluginBearers }) {
80
+ const directory = pluginBearerDirectory(workspaceRoot)
81
+ const signature = () => {
82
+ try {
83
+ const stat = fs.statSync(directory, { bigint: true })
84
+ return `${stat.ino}:${stat.mtimeNs}:${stat.ctimeNs}:${fs.readdirSync(directory).sort().join('/')}`
85
+ } catch (error) { if (error.code === 'ENOENT') return 'absent'; throw error }
86
+ }
87
+ let seen = null
88
+ let bearers = new Map()
89
+ return {
90
+ current() {
91
+ // Taken before the read: a change during the read is seen by the next call.
92
+ const now = signature()
93
+ if (now !== seen) { bearers = read({ workspaceRoot, workspaceId }); seen = now }
94
+ return bearers
95
+ },
96
+ invalidate() { seen = null },
97
+ }
98
+ }
99
+
100
+ // The decisions the channel's oracles are sensitive to; tests substitute broken ones to prove the oracles can fail.
101
+ export const PLUGIN_CHANNEL_PRIMITIVES = Object.freeze({
102
+ // Two HMACs in hex of validated shape, compared in constant time.
103
+ macMatches: (presented, expected) => typeof presented === 'string' && presented.length === expected.length && timingSafeEqual(Buffer.from(presented, 'hex'), Buffer.from(expected, 'hex')),
104
+ counterIsNew: (counter, last) => counter > last,
105
+ handshakeUsedOnce: true,
106
+ // A challenge is answered only within this long of the time it names, and each of its nonces once.
107
+ challengeWindowMs: PLUGIN_CHALLENGE_WINDOW_MS,
108
+ noncesAnsweredOnce: true,
109
+ })
110
+
111
+ // Sessions of the plugin, per view. A session lives as long as its lease: a
112
+ // hello grants one, each renewal extends it by `ttlMs`, and a release, a lapse
113
+ // or `maxAgeMs` ends it. At most `maxPerScope` live sessions per view. A
114
+ // session holds the key its handshake derived; nothing it hands out carries it.
115
+ export function createPluginSessions({ now = () => Date.now(), ttlMs = PLUGIN_LEASE_TTL_MS, maxPerScope = PLUGIN_MAX_SESSIONS_PER_SCOPE, maxAgeMs = PLUGIN_MAX_SESSION_AGE_MS, randomBytes = cryptoRandomBytes } = {}) {
116
+ const sessions = new Map()
117
+ const prune = () => { const at = now(); for (const [sessionId, session] of sessions) if (session.expiresAt <= at || at - session.openedAt >= maxAgeMs) sessions.delete(sessionId) }
118
+ const liveFor = (scopeId) => { prune(); return [...sessions.values()].filter((session) => session.scopeId === scopeId) }
119
+ const shown = ({ sessionKey: _key, keyDigest: _digest, ...session }) => ({ ...session })
120
+ return {
121
+ open({ scopeId, pluginVersion, appVersion, instanceId, sessionKey, keyDigest }) {
122
+ if (liveFor(scopeId).length >= maxPerScope) return null
123
+ const at = now()
124
+ const session = { sessionId: `ps-${randomBytes(16).toString('hex')}`, scopeId, pluginVersion, appVersion, instanceId, sessionKey, keyDigest, counter: 0, openedAt: at, renewedAt: at, expiresAt: at + ttlMs }
125
+ sessions.set(session.sessionId, session)
126
+ return shown(session)
127
+ },
128
+ // The live session with this id, key included, for the channel only; null once it lapsed or was released.
129
+ held(sessionId) { prune(); return sessions.get(sessionId) ?? null },
130
+ renew(sessionId) {
131
+ const session = sessions.get(sessionId)
132
+ if (!session) return null
133
+ session.renewedAt = now()
134
+ session.expiresAt = session.renewedAt + ttlMs
135
+ return shown(session)
136
+ },
137
+ release: (sessionId) => sessions.delete(sessionId),
138
+ live: (scopeId) => liveFor(scopeId).map(shown),
139
+ // What the most recently renewed live session reports, or null when no plugin holds the view. `instances` counts
140
+ // the launches of the plugin behind the live sessions: more than one means more than one app holds the vault.
141
+ report(scopeId) {
142
+ const live = liveFor(scopeId).sort((left, right) => right.renewedAt - left.renewedAt)
143
+ if (live.length === 0) return null
144
+ const [newest] = live
145
+ return { scopeId, appVersion: newest.appVersion, pluginVersion: newest.pluginVersion, sessions: live.length, instances: new Set(live.map((session) => session.instanceId)).size, renewedAt: new Date(newest.renewedAt).toISOString() }
146
+ },
147
+ scopeIds() { prune(); return [...new Set([...sessions.values()].map((session) => session.scopeId))] },
148
+ }
149
+ }
150
+
151
+ // The commands of the plugin channel (plugin-bridge/channel.mjs). `statusOf(scopeId)`
152
+ // returns the view's freshness entry as the service reports it and its open
153
+ // pending edits; the vault a hello must prove is the view's vault under the
154
+ // workspace state. `authority` is the exact address the request reached.
155
+ export function createPluginChannelForOracleTests({
156
+ workspaceRoot, workspaceId, runtimeId, sessions, statusOf, serviceStatus, now = () => Date.now(), randomBytes = cryptoRandomBytes,
157
+ bearers = createPluginBearerCache({ workspaceRoot, workspaceId }),
158
+ // Told each time a plugin opened a session for a view: the app it runs in has the view's entry.
159
+ onSessionOpened = () => {},
160
+ }, primitives = PLUGIN_CHANNEL_PRIMITIVES) {
161
+ const rules = { ...PLUGIN_CHANNEL_PRIMITIVES, ...primitives }
162
+ // The view's vault wherever it is (vaultRootFor: allocated for the view, else under the data root), and an allocated one
163
+ // only while it is the folder recorded; as the file system stores the path, as the store registers the vault in the app
164
+ // (realPathAsStored): a data root given in another letter case names the same vault.
165
+ const vaultRootOf = (scopeId) => {
166
+ try {
167
+ const found = vaultRootFor({ workspaceRoot, workspaceId, scopeId })
168
+ if (found.allocation !== null) assertAllocatedFolder(found.allocation)
169
+ return realPathAsStored(found.path)
170
+ } catch { return null }
171
+ }
172
+ const keyDigestOf = (bearer) => createHash('sha256').update(bearer, 'utf8').digest()
173
+ const handshakes = new Map()
174
+ const pruneHandshakes = () => { const at = now(); for (const [handshakeId, handshake] of handshakes) if (handshake.expiresAt <= at) handshakes.delete(handshakeId) }
175
+ // The nonces of the challenges each view's key answered, until their time has left the window: a challenge recorded
176
+ // by whoever squatted the address, sent later, is answered at most once and only while it is fresh.
177
+ const answered = new Map()
178
+ const seenBefore = (scopeId, clientNonce, issuedAt) => {
179
+ const at = now()
180
+ const nonces = answered.get(scopeId) ?? new Map()
181
+ for (const [nonce, until] of nonces) if (until <= at) nonces.delete(nonce)
182
+ const seen = nonces.has(clientNonce)
183
+ if (rules.noncesAnsweredOnce) nonces.set(clientNonce, issuedAt + rules.challengeWindowMs + 1)
184
+ answered.set(scopeId, nonces)
185
+ return seen
186
+ }
187
+ const answer = (statusCode, body) => ({ statusCode, body })
188
+ // An answer only the session's key can have made: the exact text of the document, and a MAC over it.
189
+ const sealed = ({ sessionKey, command, sessionId, counter }, document) => {
190
+ const payload = JSON.stringify(document)
191
+ return answer(200, { payload, mac: pluginResponseMac({ sessionKey, command, sessionId, counter, payload }) })
192
+ }
193
+ // A request of a session: its MAC verifies under the session's key and its counter was never seen. A session
194
+ // whose vault's bearer was rotated since its handshake ends: its key came from the old bearer.
195
+ const authenticated = (command, body) => {
196
+ const session = sessions.held(body.sessionId)
197
+ if (session === null) return { refusal: answer(409, { error: 'session-unknown' }) }
198
+ if (!rules.macMatches(body.mac, pluginRequestMac({ sessionKey: session.sessionKey, command, sessionId: session.sessionId, counter: body.counter }))) return { refusal: answer(401, { error: 'request-not-authenticated' }) }
199
+ if (!rules.counterIsNew(body.counter, session.counter)) return { refusal: answer(401, { error: 'request-replayed' }) }
200
+ session.counter = body.counter
201
+ const bearer = bearers.current().get(session.scopeId)
202
+ if (typeof bearer !== 'string' || !timingSafeEqual(keyDigestOf(bearer), session.keyDigest)) {
203
+ sessions.release(session.sessionId)
204
+ return { refusal: answer(409, { error: 'session-unknown' }) }
205
+ }
206
+ return { session, seal: (document) => sealed({ sessionKey: session.sessionKey, command, sessionId: session.sessionId, counter: body.counter }, document) }
207
+ }
208
+ const commands = {
209
+ challenge({ body, authority }) {
210
+ if (Math.abs(now() - body.issuedAt) > rules.challengeWindowMs) return answer(401, { error: 'challenge-stale' })
211
+ // The key the hint was made with: every key is tried, all of them, whichever matches.
212
+ let found = null
213
+ for (const [scopeId, bearer] of bearers.current()) {
214
+ if (rules.macMatches(body.keyHint, pluginKeyHint({ bearer, clientNonce: body.clientNonce, issuedAt: body.issuedAt })) && found === null) found = { scopeId, bearer }
215
+ }
216
+ if (found === null) return answer(401, { error: 'plugin-key-unknown' })
217
+ if (seenBefore(found.scopeId, body.clientNonce, body.issuedAt)) return answer(401, { error: 'challenge-replayed' })
218
+ pruneHandshakes()
219
+ // Bounded per view: one view's waiting handshakes never hold up another's.
220
+ if ([...handshakes.values()].filter((handshake) => handshake.scopeId === found.scopeId).length >= PLUGIN_MAX_PENDING_HANDSHAKES_PER_SCOPE) return answer(429, { error: 'too-many-handshakes' })
221
+ const handshake = { handshakeId: `ph-${randomBytes(16).toString('hex')}`, scopeId: found.scopeId, authority, clientNonce: body.clientNonce, serverNonce: randomBytes(32).toString('hex'), expiresAt: now() + PLUGIN_HANDSHAKE_TTL_MS }
222
+ handshakes.set(handshake.handshakeId, handshake)
223
+ return answer(200, { protocol: PLUGIN_CHANNEL_PROTOCOL, handshakeId: handshake.handshakeId, serverNonce: handshake.serverNonce, serverProof: pluginServerProof({ bearer: found.bearer, ...handshake }) })
224
+ },
225
+ hello({ body, authority }) {
226
+ pruneHandshakes()
227
+ const handshake = handshakes.get(body.handshakeId) ?? null
228
+ if (rules.handshakeUsedOnce) handshakes.delete(body.handshakeId)
229
+ const bearer = handshake === null ? undefined : bearers.current().get(handshake.scopeId)
230
+ if (handshake === null || handshake.authority !== authority || typeof bearer !== 'string') return answer(401, { error: 'handshake-unknown' })
231
+ const { scopeId } = handshake
232
+ const bound = { bearer, scopeId, authority, clientNonce: handshake.clientNonce, serverNonce: handshake.serverNonce, handshakeId: handshake.handshakeId }
233
+ const expected = pluginClientProof({ ...bound, pluginVersion: body.pluginVersion, appVersion: body.appVersion, instanceId: body.instanceId, vaultProof: body.vaultProof })
234
+ if (!rules.macMatches(body.clientProof, expected)) return answer(401, { error: 'plugin-not-authenticated' })
235
+ const sessionKey = pluginSessionKey(bound)
236
+ // Compared, never named: the vault the app has open, proven under this session's key.
237
+ const vaultRoot = vaultRootOf(scopeId)
238
+ if (vaultRoot === null || !rules.macMatches(body.vaultProof, pluginVaultProof({ sessionKey, vaultPath: vaultRoot }))) return answer(409, { error: 'wrong-vault' })
239
+ const session = sessions.open({ scopeId, pluginVersion: body.pluginVersion, appVersion: body.appVersion, instanceId: body.instanceId, sessionKey, keyDigest: keyDigestOf(bearer) })
240
+ if (session === null) return answer(429, { error: 'too-many-sessions' })
241
+ onSessionOpened(scopeId)
242
+ return sealed({ sessionKey, command: 'hello', sessionId: session.sessionId, counter: 0 }, {
243
+ schema: PLUGIN_CHANNEL_PROTOCOL, scopeId, sessionId: session.sessionId, runtimeId, leaseTtlMs: session.expiresAt - session.openedAt, renewEveryMs: PLUGIN_RENEW_INTERVAL_MS,
244
+ })
245
+ },
246
+ lease({ body }) {
247
+ const request = authenticated('lease', body)
248
+ if (request.refusal) return request.refusal
249
+ const session = sessions.renew(request.session.sessionId)
250
+ return request.seal({ schema: PLUGIN_CHANNEL_PROTOCOL, scopeId: session.scopeId, sessionId: session.sessionId, leaseTtlMs: session.expiresAt - session.renewedAt })
251
+ },
252
+ release({ body }) {
253
+ const request = authenticated('release', body)
254
+ if (request.refusal) return request.refusal
255
+ return request.seal({ schema: PLUGIN_CHANNEL_PROTOCOL, scopeId: request.session.scopeId, released: sessions.release(request.session.sessionId) })
256
+ },
257
+ status({ body }) {
258
+ const request = authenticated('status', body)
259
+ if (request.refusal) return request.refusal
260
+ const { scopeId } = request.session
261
+ const { view, pendingEdits } = statusOf(scopeId)
262
+ return request.seal({ schema: PLUGIN_STATUS_SCHEMA, scopeId, service: { status: serviceStatus() }, view, pendingEdits })
263
+ },
264
+ }
265
+ return {
266
+ bearers: () => bearers.current(),
267
+ // The service minted a bearer: whatever the directory's times say, the next lookup reads the bearers again.
268
+ bearersChanged: () => bearers.invalidate?.(),
269
+ handle: (command, request) => commands[command](request),
270
+ }
271
+ }
272
+
273
+ export function createPluginChannel(options) {
274
+ return createPluginChannelForOracleTests(options, PLUGIN_CHANNEL_PRIMITIVES)
275
+ }
276
+
277
+ // Which views a plugin holds open, for the service's status document, and
278
+ // whether the person wants the plugin in each vault (`entry`: undecided,
279
+ // requested, on or off; see plugin-choice.mjs). Counts, versions and states
280
+ // only: no vault path, no session identity.
281
+ export function pluginPresence({ sessions, scopeIds, entryOf = () => null }) {
282
+ const scopes = [...new Set([...scopeIds, ...sessions.scopeIds()])].sort(compareText).map((scopeId) => {
283
+ const report = sessions.report(scopeId)
284
+ const entry = entryOf(scopeId)
285
+ const presence = report === null ? { scopeId, present: false, sessions: 0 } : { scopeId, present: true, sessions: report.sessions, appVersion: report.appVersion, pluginVersion: report.pluginVersion, renewedAt: report.renewedAt }
286
+ return entry === null ? presence : { ...presence, entry }
287
+ })
288
+ return { schema: PLUGIN_PRESENCE_SCHEMA, leaseTtlMs: PLUGIN_LEASE_TTL_MS, scopes }
289
+ }
@@ -0,0 +1,182 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../project/private-state.mjs'
4
+ import { PLUGIN_ID } from '../../projection/obsidian/plugin-bridge/channel.mjs'
5
+ import { COMMUNITY_PLUGINS_PATH } from '../../projection/obsidian/materialize/settings.mjs'
6
+ import { sha256Digest } from '../../projection/obsidian/materialize/byte-lens.mjs'
7
+ import { vaultRootFor } from '../../projection/obsidian/recovery/store.mjs'
8
+ import { canonicalJson, isPlainObject, isoTime } from './documents.mjs'
9
+
10
+ // Whether the person wants Atelier's plugin in one vault, as the vault itself
11
+ // says it: the `atelier-projection` entry of .obsidian/community-plugins.json.
12
+ //
13
+ // (no record) nothing decided yet: Atelier offers the entry and the files
14
+ // requested `atelier obsidian plugin on` asked for it: offered again
15
+ // offered the entry is in place, but it was written while an app held
16
+ // the vault; that app keeps the list it read in memory and may
17
+ // write it back without the entry, so a list without it is no
18
+ // decision of the person's yet: the entry is offered again
19
+ // on the app has the entry: it was in place before an app next
20
+ // opened the vault (published while none ran), or the plugin
21
+ // ran in the app; from now on a list without it is the
22
+ // person's decision
23
+ // off the person removed the entry (turned the plugin off, or
24
+ // uninstalled it, in Obsidian): Atelier never adds it back and
25
+ // never creates a plugin file that is not there; it only keeps
26
+ // the files that are present current
27
+ //
28
+ // The decision is read before a view is prepared, from the bytes of the file
29
+ // on disk, and those bytes travel with the prepared view: the publisher writes
30
+ // the entry only over exactly those bytes, so a change the person makes while
31
+ // a publication runs is never written over, and the next preparation reads it.
32
+ // A file that is absent or is not a list decides nothing. A record that cannot
33
+ // be read counts as `off`: nothing is re-added on a guess. Status reads the
34
+ // same change without recording it (currentPluginChoice), so it says what the
35
+ // person did before the view's next preparation records it.
36
+
37
+ export const PLUGIN_CHOICE_SCHEMA = 'atelier-obsidian-plugin-choice/v1'
38
+ export const PLUGIN_CHOICE_STATES = Object.freeze(['requested', 'offered', 'on', 'off'])
39
+ const REASONS = Object.freeze(['requested-by-command', 'entry-offered-while-the-app-runs', 'entry-confirmed', 'entry-seen-by-the-app', 'entry-removed-by-person', 'entry-restored-by-person'])
40
+ // The states in which a list without the entry is not the person's decision: the entry is offered again.
41
+ const NOT_YET_CONFIRMED = Object.freeze(['undecided', 'requested', 'offered'])
42
+ const MAX_SETTINGS_BYTES = 256 * 1024
43
+ const segment = (identifier) => identifier.replaceAll(':', '_')
44
+
45
+ export const pluginChoiceDirectory = (workspaceRoot) => path.join(workspaceRoot, 'state', 'plugin', 'choices')
46
+ // Where a view's vault is, as the recovery store finds it (vaultRootFor): the folder allocated to the view where the
47
+ // workspace decided its vaults live, else its folder under the data root. A record that cannot be read, or was lost,
48
+ // refuses, typed. Pass `workspaceId`; every caller here does. The two-argument form of earlier releases still works:
49
+ // it takes the name of the workspace's private-state folder as the id, which a record of another workspace (or a
50
+ // folder reached through a link) does not match, and such a record refuses, typed, rather than answering another vault.
51
+ export function viewVaultRoot(workspaceRoot, scopeId, workspaceId = path.basename(String(workspaceRoot))) {
52
+ return vaultRootFor({ workspaceRoot, workspaceId, scopeId }).path
53
+ }
54
+ const choiceFile = (workspaceRoot, scopeId) => path.join(pluginChoiceDirectory(workspaceRoot), `${segment(scopeId)}.json`)
55
+
56
+ function validChoice(document, { workspaceId, scopeId }) {
57
+ return isPlainObject(document) && Object.keys(document).sort().join(',') === 'reason,schema,scopeId,since,state,workspaceId'
58
+ && document.schema === PLUGIN_CHOICE_SCHEMA && document.workspaceId === workspaceId && document.scopeId === scopeId
59
+ && PLUGIN_CHOICE_STATES.includes(document.state) && REASONS.includes(document.reason) && typeof document.since === 'string'
60
+ }
61
+
62
+ // { state: 'undecided' | 'requested' | 'offered' | 'on' | 'off', reason, since, unreadable? }
63
+ export function readPluginChoice({ workspaceRoot, workspaceId, scopeId }) {
64
+ let text
65
+ try { text = readRegularTextNoFollow(choiceFile(workspaceRoot, scopeId)) } catch (error) {
66
+ if (error.code === 'ENOENT') return { state: 'undecided', reason: null, since: null }
67
+ return { state: 'off', reason: 'choice-unreadable', since: null, unreadable: true }
68
+ }
69
+ let document = null
70
+ try { document = JSON.parse(text) } catch { document = null }
71
+ if (!validChoice(document, { workspaceId, scopeId })) return { state: 'off', reason: 'choice-unreadable', since: null, unreadable: true }
72
+ return { state: document.state, reason: document.reason, since: document.since }
73
+ }
74
+
75
+ export function writePluginChoice({ workspaceRoot, workspaceId, scopeId, state, reason, clock = () => new Date() }) {
76
+ const document = { schema: PLUGIN_CHOICE_SCHEMA, workspaceId, scopeId, state, reason, since: isoTime(clock) }
77
+ if (!validChoice(document, { workspaceId, scopeId })) throw new TypeError('a plugin choice names a known state and reason')
78
+ ensureContainedPrivateDirectory({ workspaceRoot, directory: pluginChoiceDirectory(workspaceRoot), label: 'Obsidian plugin state' })
79
+ atomicReplacePrivateText(choiceFile(workspaceRoot, scopeId), canonicalJson(document))
80
+ return { state, reason, since: document.since }
81
+ }
82
+
83
+ // 'absent', 'unreadable' (a parent is a link or not a directory) or 'reachable'.
84
+ function parentsOf(vaultRoot, relativePath) {
85
+ let current = vaultRoot
86
+ for (const part of relativePath.split('/').slice(0, -1)) {
87
+ current = path.join(current, part)
88
+ const stat = fs.lstatSync(current, { throwIfNoEntry: false })
89
+ if (!stat) return 'absent'
90
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return 'unreadable'
91
+ }
92
+ return 'reachable'
93
+ }
94
+
95
+ // Whether a vault path holds a regular file under real directories.
96
+ export function vaultFilePresent(vaultRoot, relativePath) {
97
+ return parentsOf(vaultRoot, relativePath) === 'reachable' && fs.lstatSync(path.join(vaultRoot, relativePath), { throwIfNoEntry: false })?.isFile() === true
98
+ }
99
+
100
+ // The bytes of a settings file of a vault, read without following a link and
101
+ // bounded: { state: 'absent' | 'present' | 'unreadable', bytes, digest }.
102
+ export function readVaultSettingsFile(vaultRoot, relativePath) {
103
+ const parents = parentsOf(vaultRoot, relativePath)
104
+ if (parents !== 'reachable') return { state: parents, bytes: null, digest: null }
105
+ let descriptor
106
+ try { descriptor = openRegularFileNoFollow(path.join(vaultRoot, relativePath)) } catch (error) {
107
+ return error.code === 'ENOENT' ? { state: 'absent', bytes: null, digest: null } : { state: 'unreadable', bytes: null, digest: null }
108
+ }
109
+ try {
110
+ if (fs.fstatSync(descriptor).size > MAX_SETTINGS_BYTES) return { state: 'unreadable', bytes: null, digest: null }
111
+ const bytes = fs.readFileSync(descriptor)
112
+ return { state: 'present', bytes, digest: sha256Digest(bytes) }
113
+ } finally {
114
+ fs.closeSync(descriptor)
115
+ }
116
+ }
117
+
118
+ // What the vault's community plugin list says about Atelier's entry:
119
+ // 'listed', 'not-listed' (a list without it), or 'no-list' (absent, unreadable
120
+ // or not a list), with the bytes read.
121
+ export function readCommunityEntry(vaultRoot) {
122
+ const file = readVaultSettingsFile(vaultRoot, COMMUNITY_PLUGINS_PATH)
123
+ if (file.state !== 'present') return { entry: 'no-list', file }
124
+ let list = null
125
+ try { list = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(file.bytes)) } catch { list = null }
126
+ if (!Array.isArray(list)) return { entry: 'no-list', file }
127
+ return { entry: list.includes(PLUGIN_ID) ? 'listed' : 'not-listed', file }
128
+ }
129
+
130
+ // A change of the decision the vault shows against the record, or null: an
131
+ // entry removed after it was confirmed is the person turning the plugin off; an
132
+ // entry the person put back turns it on again.
133
+ function changeShown(choice, entry) {
134
+ if (choice.state === 'on' && entry === 'not-listed') return { state: 'off', reason: 'entry-removed-by-person' }
135
+ if (choice.state === 'off' && !choice.unreadable && entry === 'listed') return { state: 'on', reason: 'entry-restored-by-person' }
136
+ return null
137
+ }
138
+
139
+ // The decision for one view, from its record and what its vault says now, as
140
+ // the view is prepared: a change is recorded here.
141
+ export function decidePluginChoice({ workspaceRoot, workspaceId, scopeId, vaultRoot, clock }) {
142
+ const recorded = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
143
+ const community = readCommunityEntry(vaultRoot)
144
+ const change = changeShown(recorded, community.entry)
145
+ return { choice: change === null ? recorded : writePluginChoice({ workspaceRoot, workspaceId, scopeId, ...change, clock }), community }
146
+ }
147
+
148
+ // The same decision for status, read only: a change not recorded yet is marked
149
+ // `pending` (the view's next preparation records it).
150
+ export function currentPluginChoice({ workspaceRoot, workspaceId, scopeId }) {
151
+ const recorded = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
152
+ // A view whose vault cannot be found (its record cannot be read) shows what was recorded; its view says why.
153
+ let vaultRoot
154
+ try { vaultRoot = viewVaultRoot(workspaceRoot, scopeId, workspaceId) } catch (error) { if (typeof error?.code !== 'string') throw error; return recorded }
155
+ const change = changeShown(recorded, readCommunityEntry(vaultRoot).entry)
156
+ return change === null ? recorded : { ...change, since: null, pending: true }
157
+ }
158
+
159
+ // After a publication: an offered entry that is now in place. Published while
160
+ // no app ran, it is confirmed: an app reads the list, entry and all, when it
161
+ // next opens the vault. Published through a running app, it is only offered:
162
+ // that app may not have read it, and the plugin running there confirms it
163
+ // (confirmPluginSeen). So is one published into a vault no Obsidian listed
164
+ // while one ran (`direct-unheld`): the evidence covers only the lists Atelier
165
+ // can read, not another profile's or another machine's.
166
+ const IN_PLACE = new Set(['policy-satisfied', 'created', 'published', 'published-external-captured', 'already-current'])
167
+ export function confirmPluginEntry({ workspaceRoot, workspaceId, scopeId, result, clock }) {
168
+ const unit = (result?.notes ?? []).find((entry) => entry.path === COMMUNITY_PLUGINS_PATH)
169
+ if (!unit || !IN_PLACE.has(unit.outcome) || unit.changedAfterPublication === true) return null
170
+ const choice = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
171
+ if (!NOT_YET_CONFIRMED.includes(choice.state)) return null
172
+ if (result.mode === 'direct') return writePluginChoice({ workspaceRoot, workspaceId, scopeId, state: 'on', reason: 'entry-confirmed', clock })
173
+ if (choice.state === 'offered') return null
174
+ return writePluginChoice({ workspaceRoot, workspaceId, scopeId, state: 'offered', reason: 'entry-offered-while-the-app-runs', clock })
175
+ }
176
+
177
+ // The plugin said hello from the vault: the app it runs in has the entry, so it is confirmed.
178
+ export function confirmPluginSeen({ workspaceRoot, workspaceId, scopeId, clock }) {
179
+ const choice = readPluginChoice({ workspaceRoot, workspaceId, scopeId })
180
+ if (!NOT_YET_CONFIRMED.includes(choice.state)) return null
181
+ return writePluginChoice({ workspaceRoot, workspaceId, scopeId, state: 'on', reason: 'entry-seen-by-the-app', clock })
182
+ }