@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
@@ -3,12 +3,13 @@ import fsp from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { setTimeout as sleep } from 'node:timers/promises'
5
5
  import { acquirePrivateLock, publishPrivateFile, syncPrivateDirectory } from '../../../project/durable-state.mjs'
6
- import { ObsidianContractRefusal, assertObsidianContract } from '../contracts.mjs'
7
- import { isPolicySettingsPath, isUserOwnedSettingsPath, prepareSettings } from '../materialize/settings.mjs'
6
+ import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, assertObsidianContract, manifestLayoutVersion } from '../contracts.mjs'
7
+ import { isPluginOwnedPath, isPolicySettingsPath, isUserOwnedSettingsPath, preparePolicySettingsFile } from '../materialize/settings.mjs'
8
+ import { PLUGIN_DATA_MODE, PLUGIN_DATA_PATH, PLUGIN_SOURCE_MODE } from '../plugin-bridge/channel.mjs'
8
9
  import { createJournal, newJournalId } from '../recovery/journal.mjs'
9
10
  import { recheckDisplacedFiles } from '../recovery/late-writer.mjs'
10
11
  import { publishedSinceCommit, reconcileUnit, recordDisplaced, recoverPublicationsLocked, retireStagedFile } from '../recovery/restart.mjs'
11
- import { PublicationRefusal, acquireVaultLock, readFileBytes, readFileDigest, refuse, sha256Digest } from '../recovery/store.mjs'
12
+ import { PublicationRefusal, acquireVaultLock, allocatedFolderState, readFileBytes, readFileDigest, refuse, sha256Digest } from '../recovery/store.mjs'
12
13
  import { PROTOCOL_ID, isAddressableVaultPath } from './bridge-script.mjs'
13
14
  import { probeExchange } from './exchange.mjs'
14
15
  import { CRASH_INJECTION_TEST_SEAM } from './test-seam.mjs'
@@ -36,14 +37,53 @@ import { createDirectAdapter } from './transport.mjs'
36
37
  // from `recovery/<journalId>/<unit>/`, never from staging. Staging holds only
37
38
  // generated bytes that no exchange names: every candidate while it is written,
38
39
  // and the candidates of created files until they are linked into the vault.
40
+ //
41
+ // Atelier's own plugin files (kind `plugin`) go through the same units, with
42
+ // two differences: each is compared with what the disk holds when the run
43
+ // begins rather than with an earlier generation, so whatever is there is
44
+ // displaced to recovery and never lost, and a plugin path that a person has to
45
+ // repair never holds the view back. The plugin's data file names a bearer and
46
+ // is written only into a vault root that is private to this user.
47
+ //
48
+ // First publication into a vault no Obsidian lists (`direct-unheld`). A running
49
+ // or unknowable app that cannot be coordinated with still refuses the whole
50
+ // publication, as ever, except in one case: the view has never been committed,
51
+ // its vault is the folder Atelier allocated for it (the record's device and
52
+ // inode), and the evidence (`unheldEvidence`, read by the caller from the app's
53
+ // own settings file) says no list this account's Obsidian keeps names that
54
+ // folder, a folder above it or one inside it, on macOS or Linux. The vault is
55
+ // then filled in this process, with no editor, by creating files only: every
56
+ // file appears through the exclusive link, where nothing is, or not at all. A
57
+ // unit that would replace or remove a file, or write a settings file over one
58
+ // that is there, refuses as `editor-uncoordinated` (blocking) and writes
59
+ // nothing. No editor can hold an unsaved buffer of a file that does not exist,
60
+ // so nothing a person wrote can be lost, whoever holds the vault. The evidence
61
+ // is read again immediately before the first unit and whenever two seconds
62
+ // have passed since the last reading; once a reading gives no evidence, every
63
+ // remaining unit refuses and nothing is committed. A run that completes is
64
+ // committed before `open` tells the app about the vault; a run that stops
65
+ // partway reports why, and what it left is published through the app.
39
66
 
40
67
  const hex = (digest) => digest.slice('sha256:'.length)
41
68
  const iso = (clock) => { const value = clock(); return (value instanceof Date ? value : new Date(value)).toISOString() }
42
69
  const REFUSED_BY_EDIT = new Set(['editor-edit', 'disk-changed'])
43
70
  const unreleased = new Map()
44
-
71
+ // A release kept earlier whose lock folder is gone when it is tried again (the vault deleted, replaced, or made again
72
+ // empty at its path) holds nothing there, and is dropped rather than holding every later publication of that vault back.
73
+ // A vault put back holds its ticket again, and the release kept for it then succeeds.
74
+ const lockFolderGone = (error) => error?.code === 'ENOENT' || error?.code === 'ENOTDIR'
75
+
76
+ // A note another program replaces by rename can change between the check of its leaf and the open
77
+ // (ELEAFCHANGED). Every rename leaves a complete file, so it is read again; one that keeps changing is left to
78
+ // publishView, which reports it for that unit only.
79
+ const READ_ATTEMPTS = 5
45
80
  function readNote(file) {
46
- try { return readFileBytes(file) } catch (error) { if (error.code === 'ENOENT') return null; throw error }
81
+ for (let attempt = 1; ; attempt += 1) {
82
+ try { return readFileBytes(file) } catch (error) {
83
+ if (error.code === 'ENOENT') return null
84
+ if (error.code !== 'ELEAFCHANGED' || attempt >= READ_ATTEMPTS) throw error
85
+ }
86
+ }
47
87
  }
48
88
 
49
89
  function validatePreparedView(preparedView, store) {
@@ -61,22 +101,35 @@ function validatePreparedView(preparedView, store) {
61
101
  if (expected.has(declared)) refuse('invalid-prepared-view', 'the manifest declares one path more than once')
62
102
  expected.set(declared, fileDigest)
63
103
  }
104
+ // Atelier's plugin files are pinned by the settings unit's record in the manifest.
105
+ const pinnedPlugin = manifest.ext?.[OBSIDIAN_EXT_KEY]?.settings?.pluginOwned?.files
106
+ const pinned = new Map(Array.isArray(pinnedPlugin) ? pinnedPlugin.map((file) => [file?.path, file?.digest]) : [])
64
107
  const seen = new Set()
65
108
  for (const file of preparedView.files) {
66
109
  if (!isAddressableVaultPath(file.path) || isUserOwnedSettingsPath(file.path)) refuse('invalid-prepared-view', 'a prepared path is outside what the publisher may write')
67
110
  if (seen.has(file.path)) refuse('invalid-prepared-view', 'a prepared path appears twice')
68
111
  seen.add(file.path)
69
112
  if (file.kind === 'settings') {
70
- if (!isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'only the policy settings file may be prepared as settings')
113
+ if (!isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'only a policy settings file may be prepared as settings')
71
114
  continue
72
115
  }
73
- if (!['note', 'attachment'].includes(file.kind) || isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'a prepared file has an unknown kind')
116
+ if (file.kind === 'plugin') {
117
+ if (!isPluginOwnedPath(file.path) || !Buffer.isBuffer(file.bytes) || sha256Digest(file.bytes) !== file.digest || pinned.get(file.path) !== file.digest) {
118
+ refuse('invalid-prepared-view', 'a plugin file, its digest and the digest the manifest pins disagree')
119
+ }
120
+ // Read by the app, never run by anyone: the data file owner-only, the others readable.
121
+ if (file.mode !== (file.path === PLUGIN_DATA_PATH ? PLUGIN_DATA_MODE : PLUGIN_SOURCE_MODE)) refuse('invalid-prepared-view', 'a plugin file has a mode Atelier never gives one')
122
+ pinned.delete(file.path)
123
+ continue
124
+ }
125
+ if (!['note', 'attachment'].includes(file.kind) || isPolicySettingsPath(file.path) || isPluginOwnedPath(file.path)) refuse('invalid-prepared-view', 'a prepared file has an unknown kind')
74
126
  if (!Buffer.isBuffer(file.bytes) || sha256Digest(file.bytes) !== file.digest || expected.get(file.path) !== file.digest) {
75
127
  refuse('invalid-prepared-view', 'prepared bytes, their digest and the manifest disagree')
76
128
  }
77
129
  expected.delete(file.path)
78
130
  }
79
131
  if (expected.size > 0) refuse('invalid-prepared-view', 'the manifest names a file the prepared view does not carry')
132
+ if (pinned.size > 0) refuse('invalid-prepared-view', 'the manifest pins a plugin file the prepared view does not carry')
80
133
  let parsedBytes = null
81
134
  if (Buffer.isBuffer(preparedView.manifestBytes)) {
82
135
  try { parsedBytes = JSON.parse(preparedView.manifestBytes.toString('utf8')) } catch { refuse('invalid-prepared-view', 'manifest bytes are not JSON') }
@@ -169,7 +222,65 @@ async function holdsCandidateBytes(vaultRoot, relativePath, candidateDigest) {
169
222
  }
170
223
  }
171
224
 
172
- function planUnits({ files, priorManifest, pointer, ledger }) {
225
+ // The digest of what a vault path holds now: null when it is absent, sits
226
+ // under a parent that is not a real directory, is not a regular file or cannot
227
+ // be read. Only Atelier's plugin files are planned from it.
228
+ function currentDigestOf(vaultRoot, relativePath) {
229
+ let current = vaultRoot
230
+ for (const part of relativePath.split('/').slice(0, -1)) {
231
+ current = path.join(current, part)
232
+ const stat = fs.lstatSync(current, { throwIfNoEntry: false })
233
+ if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return null
234
+ }
235
+ try { return readFileDigest(path.join(vaultRoot, relativePath)) } catch { return null }
236
+ }
237
+
238
+ // Whether publishing would only leave this plugin path for the person, as it
239
+ // stands: a parent that is a link or not a directory, a leaf that is not a
240
+ // regular file (path-unsafe), or the data file where the vault root cannot hold
241
+ // the bearer and is not one Atelier makes private itself (vault-not-private;
242
+ // see ensurePrivateVaultRoot). Read-only. Conservative: a leaf nobody may
243
+ // read, a parent that cannot be looked into, and a vault root Atelier keeps
244
+ // whose mode cannot be changed are left for the person too, but count here as
245
+ // a drift, as before.
246
+ function leftForThePerson(store, relativePath) {
247
+ let current = store.vaultRoot
248
+ for (const part of relativePath.split('/').slice(0, -1)) {
249
+ current = path.join(current, part)
250
+ const stat = fs.lstatSync(current, { throwIfNoEntry: false })
251
+ if (!stat) break
252
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return true
253
+ }
254
+ const leaf = fs.lstatSync(path.join(store.vaultRoot, relativePath), { throwIfNoEntry: false })
255
+ if (leaf && !leaf.isFile()) return true
256
+ if (relativePath !== PLUGIN_DATA_PATH) return false
257
+ if (store.linkedVaultRoot === true) return true
258
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null
259
+ const root = fs.lstatSync(store.vaultRoot, { throwIfNoEntry: false })
260
+ if (!root?.isDirectory() || uid === null || root.uid !== uid) return true
261
+ return (root.mode & 0o077) !== 0 && store.managedVaultRoot !== true
262
+ }
263
+
264
+ // A generation that is already committed is published again, as it is, when a
265
+ // plugin file it carries is not on disk as it pins it: the person repaired a
266
+ // path Atelier had to leave, or removed or changed a file. The bytes are
267
+ // Atelier's and nothing else of the view changed, so the generation stays the
268
+ // same; its notes are kept, and its plugin units are planned from the disk as
269
+ // ever. A file prepared only where present that is gone is not a drift, and
270
+ // neither is one that publishing would only leave for the person again: it
271
+ // would change nothing, and it would need the app for nothing (a view that
272
+ // waits for an app for it would wait for a write that never comes).
273
+ function pluginFilesDrifted(preparedView, store) {
274
+ return preparedView.files.some((file) => {
275
+ if (file.kind !== 'plugin') return false
276
+ const current = currentDigestOf(store.vaultRoot, file.path)
277
+ if (current === file.digest || (current === null && file.onlyIfPresent === true)) return false
278
+ try { return !leftForThePerson(store, file.path) } catch { return true }
279
+ })
280
+ }
281
+
282
+ // One unit per file the publication creates, keeps, replaces or removes. Exported for the oracle tests.
283
+ export function planUnits({ files, priorManifest, pointer, ledger, pluginDisk = new Map() }) {
173
284
  const trusted = new Map()
174
285
  for (const note of priorManifest?.notes ?? []) trusted.set(note.path, note.noteDigest)
175
286
  for (const attachment of priorManifest?.attachments ?? []) trusted.set(attachment.path, attachment.digest)
@@ -177,24 +288,67 @@ function planUnits({ files, priorManifest, pointer, ledger }) {
177
288
  for (const item of pointer?.retained ?? []) if (item.priorDigest && !item.movedToRecovery) trusted.set(item.path, item.priorDigest)
178
289
  for (const [notePath, digest] of ledger) { if (digest === null) trusted.delete(notePath); else trusted.set(notePath, digest) }
179
290
 
291
+ // A file leaves as an attachment when the prior generation listed it as one. Layout 1 kept every file under
292
+ // `attachments/`, so there that folder says it too (of a file an interrupted run published); in layout 2 it is only
293
+ // a repository's folder.
294
+ const priorAttachments = new Set((priorManifest?.attachments ?? []).map((attachment) => attachment.path))
295
+ const underAttachments = !priorManifest || manifestLayoutVersion(priorManifest) === 1
180
296
  const units = []
181
297
  const present = new Set()
182
298
  for (const file of files) {
183
299
  present.add(file.path)
184
- if (file.kind === 'settings') { units.push({ path: file.path, kind: 'settings', op: 'settings' }); continue }
300
+ // A settings file prepared from the bytes it held when the view was prepared carries their digest: it is written only over them.
301
+ if (file.kind === 'settings') { units.push({ path: file.path, kind: 'settings', op: 'settings', ...(Object.hasOwn(file, 'expectedDigest') ? { expectedDigest: file.expectedDigest } : {}) }); continue }
302
+ if (file.kind === 'plugin') {
303
+ const current = pluginDisk.get(file.path) ?? null
304
+ // A plugin file the person removed while the plugin is off in this vault stays removed.
305
+ if (current === null && file.onlyIfPresent === true) { units.push({ path: file.path, kind: 'plugin', op: 'leave-absent' }); continue }
306
+ units.push({ path: file.path, kind: 'plugin', op: current === null ? 'create' : current === file.digest ? 'keep' : 'replace', baseDigest: current, candidateDigest: file.digest, bytes: file.bytes, mode: file.mode })
307
+ continue
308
+ }
185
309
  const base = trusted.get(file.path)
186
310
  const op = base === undefined ? 'create' : base === file.digest ? 'keep' : 'replace'
187
311
  units.push({ path: file.path, kind: file.kind, op, baseDigest: base ?? null, candidateDigest: file.digest, bytes: file.bytes })
188
312
  }
189
313
  for (const [notePath, digest] of [...trusted].sort(([left], [right]) => (left < right ? -1 : 1))) {
190
- if (!present.has(notePath)) units.push({ path: notePath, kind: notePath.startsWith('attachments/') ? 'attachment' : 'note', op: 'remove', baseDigest: digest, candidateDigest: null })
314
+ if (!present.has(notePath)) units.push({ path: notePath, kind: priorAttachments.has(notePath) || (underAttachments && notePath.startsWith('attachments/')) ? 'attachment' : 'note', op: 'remove', baseDigest: digest, candidateDigest: null })
191
315
  }
192
316
  units.forEach((unit, index) => { unit.unit = index })
193
317
  return units
194
318
  }
195
319
 
196
- export async function publishView(options = {}) {
320
+ // The rules the oracle tests are sensitive to. Production always uses these; the tests substitute a deliberately
321
+ // broken one through publishViewForOracleTests to prove the oracle can fail.
322
+ export const PUBLICATION_PRIMITIVES = Object.freeze({
323
+ // The one operation the `direct-unheld` path writes into the vault: a file created where nothing is.
324
+ unheldWrites: (op) => op === 'create',
325
+ })
326
+
327
+ // Whether the `direct-unheld` path applies, after the adapter found an app it cannot coordinate with (see the head of
328
+ // this file). Every condition must hold; an evidence reader that is absent, throws or answers anything but
329
+ // `unlisted: true` gives none.
330
+ async function unheldPathApplies({ probe, pointer, store, unheldEvidence, platform }) {
331
+ if (probe.code !== undefined || pointer !== null) return false
332
+ if (platform !== 'darwin' && platform !== 'linux') return false
333
+ const { allocation } = store
334
+ if (store.vaultOrigin !== 'allocated' || store.managedVaultRoot !== true || !allocation || store.vaultRoot !== allocation.path || allocatedFolderState(allocation) !== 'same') return false
335
+ return readsUnlisted(unheldEvidence, store)
336
+ }
337
+
338
+ async function readsUnlisted(unheldEvidence, store) {
339
+ if (typeof unheldEvidence !== 'function') return false
340
+ try { return (await unheldEvidence({ vaultRoot: store.vaultRoot }))?.unlisted === true } catch { return false }
341
+ }
342
+
343
+ export function publishView(options = {}) {
344
+ return publishViewForOracleTests(options, PUBLICATION_PRIMITIVES)
345
+ }
346
+
347
+ export async function publishViewForOracleTests(options = {}, primitives = PUBLICATION_PRIMITIVES) {
197
348
  const { preparedView, protocolId, expectedGeneration, recoveryStore: store, adapter, clock = () => new Date(), quietPeriodMs = 1500, exchangeOptions = {} } = options
349
+ // `unheldEvidence({ vaultRoot })` answers whether the app's own list names this vault (see the head of this file).
350
+ const { unheldEvidence = null, platform = process.platform } = options
351
+ const rules = { ...PUBLICATION_PRIMITIVES, ...primitives }
198
352
  const seam = options[CRASH_INJECTION_TEST_SEAM] ?? null
199
353
  const crash = (point) => { if (seam && seam.at === point) seam.halt(point) }
200
354
  if (!store || typeof store.commitManifest !== 'function') throw new TypeError('publishView needs a recoveryStore')
@@ -209,30 +363,43 @@ export async function publishView(options = {}) {
209
363
  // A release that could not be written (a full disk) is finished first.
210
364
  for (const lockPath of [store.vaultLockPath, store.lockPath]) {
211
365
  if (!unreleased.has(lockPath)) continue
212
- try { unreleased.get(lockPath)() } catch (error) { refuse('state-unwritable', 'private publication state cannot be written; nothing in the vault was touched', { cause: error.code ?? String(error.message) }) }
366
+ try { unreleased.get(lockPath)() } catch (error) { if (!lockFolderGone(error)) refuse('state-unwritable', 'private publication state cannot be written; nothing in the vault was touched', { cause: error.code ?? String(error.message) }) }
213
367
  unreleased.delete(lockPath)
214
368
  }
215
369
  // Two locks, the view's and then the vault's: a second view or a second
216
370
  // workspace state pointed at the same vault refuses instead of racing.
217
371
  const acquire = (lockPath, take, held) => {
218
372
  try { releases.push([lockPath, take()]) } catch (error) {
373
+ if (error instanceof PublicationRefusal) throw error
219
374
  if (error.code === 'EEXIST') refuse('publication-in-progress', `another publication ${held} holds the lock`)
220
375
  refuse('state-unwritable', 'private publication state cannot be written; nothing in the vault was touched', { cause: error.code ?? String(error.message) })
221
376
  }
222
377
  }
223
378
  acquire(store.lockPath, () => acquirePrivateLock(store.lockPath), 'of this view')
379
+ // A vault allocated for the view is still the folder it made, before anything is written in it (the vault lock
380
+ // included) and again once the lock is held: a store kept by a long-running service may outlive its folder. It is
381
+ // checked again after every wait on the app, before each unit and before the commit, so a folder replaced during a
382
+ // publication refuses it and is never reported current. (Only this account can replace it; the check and the
383
+ // write that follows it are still two steps.)
384
+ store.checkAllocatedVault?.()
224
385
  acquire(store.vaultLockPath, () => acquireVaultLock(store), 'into this vault')
386
+ store.checkAllocatedVault?.()
225
387
  const recovered = recoverPublicationsLocked({ store, clock })
226
388
  const pointer = store.readCurrent()
227
- if (pointer?.generationId === manifest.generationId) {
389
+ if (pointer?.generationId === manifest.generationId && !pluginFilesDrifted(preparedView, store)) {
228
390
  return { state: 'committed', alreadyCommitted: true, generationId: manifest.generationId, journalId: pointer.journalId, notes: [], retainedEdits: pointer.retained ?? [], lateWriters: [], recovered }
229
391
  }
230
392
  if ((pointer?.generationId ?? null) !== expectedGeneration) refuse('generation-mismatch', 'the committed generation is not the one this publication expects', { committed: pointer?.generationId ?? null })
231
393
 
232
- // Path selection.
394
+ // Path selection. An app with this vault open in several windows is refused as such: publishing through one of
395
+ // them would leave the others uncoordinated.
233
396
  const probe = await adapter.probe({ vaultRoot: store.vaultRoot })
234
- if (probe.state !== 'coordinated' && probe.state !== 'absent') refuse('editor-uncoordinated', `an Obsidian process may have this vault open and cannot be coordinated with: ${probe.reason}`)
235
- const mode = probe.state === 'coordinated' ? 'in-app' : 'direct'
397
+ store.checkAllocatedVault?.()
398
+ const unheld = probe.state !== 'coordinated' && probe.state !== 'absent' && await unheldPathApplies({ probe, pointer, store, unheldEvidence, platform })
399
+ // The evidence took time to read: the folder is checked again.
400
+ if (unheld) store.checkAllocatedVault?.()
401
+ if (probe.state !== 'coordinated' && probe.state !== 'absent' && !unheld) refuse(probe.code === 'vault-open-in-several-windows' ? probe.code : 'editor-uncoordinated', `an Obsidian process may have this vault open and cannot be coordinated with: ${probe.reason}`)
402
+ const mode = probe.state === 'coordinated' ? 'in-app' : unheld ? 'direct-unheld' : 'direct'
236
403
  const channel = mode === 'in-app' ? adapter : createDirectAdapter({ crashSeam: seam })
237
404
 
238
405
  // Same volume, and an exchange that works on it.
@@ -247,7 +414,8 @@ export async function publishView(options = {}) {
247
414
  const exchange = probeExchange({ directory: probeDir, ...exchangeOptions })
248
415
  if (!exchange.supported) refuse(exchange.code, exchange.message)
249
416
 
250
- const units = planUnits({ files: preparedView.files, priorManifest: store.readCurrentManifest(), pointer, ledger: publishedSinceCommit({ store }) })
417
+ const pluginDisk = new Map(preparedView.files.filter((file) => file.kind === 'plugin').map((file) => [file.path, currentDigestOf(store.vaultRoot, file.path)]))
418
+ const units = planUnits({ files: preparedView.files, priorManifest: store.readCurrentManifest(), pointer, ledger: publishedSinceCommit({ store }), pluginDisk })
251
419
 
252
420
  // Stage every known candidate before anything is touched.
253
421
  const journalId = newJournalId(clock)
@@ -256,10 +424,11 @@ export async function publishView(options = {}) {
256
424
  let stagingDir = null
257
425
  try {
258
426
  stagingDir = store.stagingDir(journalId)
259
- for (const unit of units.filter((item) => item.op === 'replace' || item.op === 'create')) {
427
+ // On the `direct-unheld` path a replacement is never written, so no candidate is staged for one.
428
+ for (const unit of units.filter((item) => item.op === 'create' || (item.op === 'replace' && (mode !== 'direct-unheld' || rules.unheldWrites(item.op))))) {
260
429
  const existing = fs.lstatSync(path.join(store.vaultRoot, unit.path), { throwIfNoEntry: false })
261
430
  unit.preparedPath = store.preparedPath(journalId, unit.unit)
262
- stageCandidate(unit.preparedPath, unit.bytes, existing?.isFile() ? existing.mode & 0o777 : 0o644, created)
431
+ stageCandidate(unit.preparedPath, unit.bytes, unit.mode ?? (existing?.isFile() ? existing.mode & 0o777 : 0o644), created)
263
432
  // A replacement is exchanged, and the exchange leaves the displaced bytes at this path: it is in recovery.
264
433
  unit.stagedPath = unit.op === 'replace' ? store.exchangeCandidatePath(journalId, unit.unit) : unit.preparedPath
265
434
  }
@@ -270,7 +439,7 @@ export async function publishView(options = {}) {
270
439
  detail: { mode, manifestRef: store.ref(manifestFile), manifestDigest: sha256Digest(manifestBytes),
271
440
  // The units this run may write. A kept note is compared and never written; the one case that writes
272
441
  // it after all (a kept note gone missing) is a late candidate with its own write-ahead entry.
273
- units: units.filter((item) => item.op !== 'keep').map(({ unit, path: unitPath, kind, op }) => ({ unit, path: unitPath, kind, op })),
442
+ units: units.filter((item) => item.op !== 'keep' && item.op !== 'leave-absent').map(({ unit, path: unitPath, kind, op }) => ({ unit, path: unitPath, kind, op })),
274
443
  staged: units.filter((item) => item.stagedPath).map((item) => ({ unit: item.unit, path: item.path, candidateDigest: item.candidateDigest, stagedRef: store.ref(item.stagedPath),
275
444
  ...(item.stagedPath === item.preparedPath ? {} : { preparedRef: store.ref(item.preparedPath) }) })) } })
276
445
  crash('before-candidate-move')
@@ -295,7 +464,7 @@ export async function publishView(options = {}) {
295
464
  }
296
465
  crash('after-staging')
297
466
 
298
- const context = { store, journal, journalId, channel, clock, crash, mode }
467
+ const context = { store, journal, journalId, channel, clock, crash, mode, rules }
299
468
  const results = []
300
469
  // Kept notes are compared first, several at a time; one that holds exactly its candidate bytes is unchanged.
301
470
  const unchanged = await unchangedKeepUnits(units, store)
@@ -306,6 +475,8 @@ export async function publishView(options = {}) {
306
475
  // passed since the last reading. An app that starts after a reading and
307
476
  // before the next is not seen; that window is at most two seconds plus
308
477
  // one note's publication.
478
+ // The `direct-unheld` path reads its evidence on the same schedule, and once a reading gives none, every remaining
479
+ // unit refuses, a kept one included, so nothing is committed.
309
480
  let lastProbe = null
310
481
  for (const unit of units) {
311
482
  if (mode === 'direct' && (lastProbe === null || Date.now() - lastProbe > 2000)) {
@@ -313,18 +484,28 @@ export async function publishView(options = {}) {
313
484
  const again = await adapter.probe({ vaultRoot: store.vaultRoot })
314
485
  if (again.state !== 'absent') context.uncoordinated = again.reason
315
486
  }
487
+ if (mode === 'direct-unheld' && !context.uncoordinated && (lastProbe === null || Date.now() - lastProbe > 2000)) {
488
+ lastProbe = Date.now()
489
+ if (!(await readsUnlisted(unheldEvidence, store))) context.uncoordinated = 'the vault list of the app no longer shows, positively, that no Obsidian lists this vault'
490
+ }
491
+ store.checkAllocatedVault?.()
316
492
  if (unit.op === 'keep' && unchanged.has(unit.path)) {
317
- results.push({ path: unit.path, kind: unit.kind, op: unit.op, outcome: context.uncoordinated ? 'editor-uncoordinated' : 'unchanged', blocking: false })
493
+ results.push({ path: unit.path, kind: unit.kind, op: unit.op, outcome: context.uncoordinated ? 'editor-uncoordinated' : 'unchanged', blocking: mode === 'direct-unheld' && Boolean(context.uncoordinated) })
318
494
  continue
319
495
  }
320
- results.push(await publishUnit(unit, context))
496
+ try { results.push(await publishUnit(unit, context)) } catch (error) {
497
+ if (error.code !== 'ELEAFCHANGED') throw error
498
+ // Another program kept replacing this file while it was read: its bytes are left as they are, the unit's
499
+ // journal stays open for recovery like an interrupted one, and the other units go on.
500
+ results.push({ path: unit.path, kind: unit.kind, op: unit.op, outcome: 'disk-changed', blocking: true })
501
+ }
321
502
  }
322
503
 
323
504
  const blocking = results.filter((result) => result.blocking)
324
505
  const retainedEdits = results.filter((result) => result.retained).map((result) => result.retained)
325
506
  // Notes kept earlier stay surfaced until they leave the vault or come back into a view.
326
507
  const base = { journalId, generationId: manifest.generationId, mode, notes: results.map(({ retained, ...rest }) => rest), retainedEdits, recovered }
327
- if (blocking.length > 0) {
508
+ if (blocking.length > 0 || (mode === 'direct-unheld' && context.uncoordinated)) {
328
509
  const lateWriters = recheckDisplacedFiles({ store, journalIds: [journalId], clock })
329
510
  return { ...base, state: 'updating', lateWriters }
330
511
  }
@@ -338,6 +519,7 @@ export async function publishView(options = {}) {
338
519
  }
339
520
  journal.append({ step: 'verify', outcome: 'ok', state: 'verifying', detail: { settled: true, retained: retainedEdits } })
340
521
  crash('before-manifest-commit')
522
+ store.checkAllocatedVault?.()
341
523
  store.commitManifest({ manifestBytes, generationId: manifest.generationId, journalId, retained: retainedEdits, committedAt: iso(clock) })
342
524
  crash('after-manifest-pointer')
343
525
  journal.append({ step: 'manifest-commit', outcome: 'ok', state: 'committed', detail: { manifestDigest: sha256Digest(manifestBytes) } })
@@ -351,26 +533,85 @@ export async function publishView(options = {}) {
351
533
  }
352
534
  return { state: 'refused', refusal: { code: error.code, message: error.message, detail: error.detail }, notes: [], retainedEdits: [], lateWriters: [] }
353
535
  } finally {
536
+ // A release that fails is kept and tried again before the next publication of that lock, even when the lock's folder
537
+ // is not there now: a vault moved away keeps its ticket and may be put back.
354
538
  for (const [lockPath, release] of releases.reverse()) try { release() } catch { unreleased.set(lockPath, release) }
355
539
  }
356
540
  }
357
541
 
358
542
  // ---------------------------------------------------------------------------
359
- // One note, attachment or settings file
543
+ // One note, attachment, settings file or plugin file
360
544
  // ---------------------------------------------------------------------------
361
545
 
546
+ // Atelier's own plugin never waits on a person: a plugin path that cannot be
547
+ // written safely, a vault root not private enough for the bearer, or a file
548
+ // that could not be made or replaced there (a plugin folder the person can read
549
+ // but not write, say) is reported and left for the person while every note
550
+ // converges, and its candidate is retired at once. A plugin file that another
551
+ // writer changed under this run is a race: it blocks like a note, so the view
552
+ // is tried again and the file is planned from the disk anew. Nothing reads a
553
+ // plugin file as a person's edit of a note: its outcomes carry their own names.
554
+ const PLUGIN_LEFT_FOR_A_PERSON = new Set(['path-unsafe', 'vault-not-private', 'create-failed', 'exchange-failed'])
555
+ // A leaf or a parent nobody may look into or read (another owner, mode 000) or a link loop: the person's to repair,
556
+ // like a leaf that is not a file. It is reported, never thrown, so the rest of the view and every later view go on.
557
+ const UNREADABLE_PATH = new Set(['EACCES', 'EPERM', 'ELOOP'])
558
+ const PLUGIN_FILE_CHANGED = new Set(['edit-kept', 'disk-changed', 'editor-edit'])
559
+
362
560
  async function publishUnit(unit, context) {
561
+ let result
562
+ try { result = await publishOneUnit(unit, context) } catch (error) {
563
+ // A plugin or settings file another program kept replacing while it was opened (see readNote) is a race of that
564
+ // file, like one that changed under the run: blocking, tried again, and never taken for a person's edit of a note.
565
+ if (error?.code !== 'ELEAFCHANGED' || (unit.kind !== 'plugin' && unit.kind !== 'settings')) throw error
566
+ // Its candidate is left as #83 leaves a note's: the unit's journal stays open for recovery like an interrupted one.
567
+ result = { path: unit.path, kind: unit.kind, op: unit.op, outcome: unit.kind === 'settings' ? 'settings-changed' : 'disk-changed', blocking: true }
568
+ }
569
+ if (unit.kind !== 'plugin') return result
570
+ const { changedAfterPublication, retained: _retained, ...rest } = result
571
+ if (rest.outcome === 'path-unsafe' && unit.stagedPath) retire(unit, context)
572
+ const changed = PLUGIN_FILE_CHANGED.has(rest.outcome)
573
+ return { ...rest, outcome: changed ? 'plugin-file-changed' : rest.outcome, blocking: changed || (rest.blocking && !PLUGIN_LEFT_FOR_A_PERSON.has(rest.outcome)), ...(changedAfterPublication ? { pluginFileChanged: true } : {}) }
574
+ }
575
+
576
+ // The vault root holds the plugin's bearer once its data file is there, so it
577
+ // must be private to this user. A vault root Atelier placed under its own data
578
+ // root is made private here; any other one is only looked at, and a vault path
579
+ // under the data root that is a link to somewhere else never receives it.
580
+ function ensurePrivateVaultRoot(store) {
581
+ if (store.linkedVaultRoot === true) return { private: false, reason: 'vault-root-is-a-link' }
582
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null
583
+ let stat = fs.lstatSync(store.vaultRoot, { throwIfNoEntry: false })
584
+ if (!stat?.isDirectory() || uid === null || stat.uid !== uid) return { private: false, reason: 'vault-root-not-owned' }
585
+ if ((stat.mode & 0o077) !== 0 && store.managedVaultRoot === true) {
586
+ try { fs.chmodSync(store.vaultRoot, stat.mode & 0o700) } catch { /* looked at again below */ }
587
+ stat = fs.lstatSync(store.vaultRoot, { throwIfNoEntry: false })
588
+ }
589
+ return stat?.isDirectory() && (stat.mode & 0o077) === 0 ? { private: true } : { private: false, reason: 'vault-root-not-private' }
590
+ }
591
+
592
+ async function publishOneUnit(unit, context) {
363
593
  const { store, journal, journalId, clock } = context
364
594
  const note = path.join(store.vaultRoot, unit.path)
365
595
  const outcome = (code, extra = {}) => ({ path: unit.path, kind: unit.kind, op: unit.op, outcome: code, blocking: false, ...extra })
596
+ // On the `direct-unheld` path a unit after a failed reading refuses, whatever it would have done, so the run says why
597
+ // it stopped.
598
+ if (unit.op === 'leave-absent') return context.mode === 'direct-unheld' && context.uncoordinated ? outcome('editor-uncoordinated', { blocking: true }) : outcome('left-absent')
366
599
  const observe = (bytes) => store.retainObject(bytes)
367
- if (context.uncoordinated) return outcome('editor-uncoordinated', { blocking: unit.op !== 'keep' })
600
+ if (context.uncoordinated) return outcome('editor-uncoordinated', { blocking: unit.op !== 'keep' || context.mode === 'direct-unheld' })
601
+ // The `direct-unheld` path creates files and nothing else. A unit planned as a replacement or a removal has no
602
+ // candidate staged, and is refused before its file is even read. (A kept unit may still become a create; a settings
603
+ // unit is decided in planSettings.)
604
+ if (context.mode === 'direct-unheld' && unit.op !== 'keep' && unit.op !== 'settings' && !context.rules.unheldWrites(unit.op)) return outcome('editor-uncoordinated', { blocking: true })
368
605
  // A settings path that cannot be written safely (a symlinked `.obsidian`,
369
606
  // say) is reported and left alone; it must not keep every note from
370
607
  // converging while the person repairs it. Notes and attachments block.
371
608
  const unsafeBlocks = unit.op !== 'settings'
372
- if (!ensureParents(store.vaultRoot, unit.path)) return outcome('path-unsafe', { blocking: unsafeBlocks })
373
- const leaf = fs.lstatSync(note, { throwIfNoEntry: false })
609
+ const unreadable = (error) => { if (!UNREADABLE_PATH.has(error?.code)) throw error; return outcome('path-unsafe', { blocking: unsafeBlocks, errorCode: error.code }) }
610
+ let leaf
611
+ try {
612
+ if (!ensureParents(store.vaultRoot, unit.path)) return outcome('path-unsafe', { blocking: unsafeBlocks })
613
+ leaf = fs.lstatSync(note, { throwIfNoEntry: false })
614
+ } catch (error) { return unreadable(error) }
374
615
  if (leaf && !leaf.isFile()) return outcome('path-unsafe', { blocking: unsafeBlocks })
375
616
 
376
617
  let plan = unit
@@ -378,7 +619,8 @@ async function publishUnit(unit, context) {
378
619
  plan = planSettings(unit, context)
379
620
  if (plan.outcome) return plan
380
621
  }
381
- const current = readNote(note)
622
+ let current
623
+ try { current = readNote(note) } catch (error) { return unreadable(error) }
382
624
  const currentDigest = current === null ? null : sha256Digest(current)
383
625
 
384
626
  if (plan.op === 'keep') {
@@ -402,6 +644,19 @@ async function publishUnit(unit, context) {
402
644
  if (plan.stagedPath) retire(plan, context)
403
645
  return outcome('already-current')
404
646
  }
647
+ // Whatever a unit became on the way here, the `direct-unheld` path writes nothing below this point but a create.
648
+ if (context.mode === 'direct-unheld' && !context.rules.unheldWrites(plan.op)) {
649
+ if (plan.stagedPath) retire(plan, context)
650
+ return outcome('editor-uncoordinated', { blocking: true })
651
+ }
652
+
653
+ if (unit.path === PLUGIN_DATA_PATH && plan.op !== 'remove') {
654
+ const privacy = ensurePrivateVaultRoot(store)
655
+ if (!privacy.private) {
656
+ if (plan.stagedPath) retire(plan, context)
657
+ return outcome('vault-not-private', { reason: privacy.reason })
658
+ }
659
+ }
405
660
 
406
661
  const operationId = `${journalId}:${unit.unit}`
407
662
  const displaced = plan.op === 'create' ? null : store.displacedPath(journalId, unit.unit)
@@ -552,18 +807,32 @@ function stageLate(plan, { store, journal, journalId, crash, updating }) {
552
807
  return unit.stagedPath
553
808
  }
554
809
 
555
- // Policy settings are owned per key. The candidate is the file on disk now
556
- // with only the owned keys set; the base is that same file. A file that is
557
- // not a JSON object or array is the person's to repair and is left alone.
810
+ // Policy settings are owned per key or entry. The candidate is the file on
811
+ // disk now with only what Atelier owns set; the base is that same file. A file
812
+ // whose shape the policy does not know is the person's to repair and is left
813
+ // alone.
558
814
  function planSettings(unit, context) {
559
815
  const note = path.join(context.store.vaultRoot, unit.path)
560
- const existing = readNote(note)
561
- let prepared
562
- try { prepared = prepareSettings({ existing }) } catch (error) {
816
+ let existing
817
+ try { existing = readNote(note) } catch (error) {
818
+ if (!UNREADABLE_PATH.has(error?.code)) throw error
819
+ return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'path-unsafe', blocking: false, errorCode: error.code }
820
+ }
821
+ // Prepared from other bytes than these: the person changed the file since, and whatever Atelier decided from the old
822
+ // bytes is not written over the change. The view is tried again, and the next preparation reads the file as it is.
823
+ if (Object.hasOwn(unit, 'expectedDigest') && (existing === null ? null : sha256Digest(existing)) !== unit.expectedDigest) {
824
+ return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'settings-changed', blocking: true }
825
+ }
826
+ // On the `direct-unheld` path a settings file that is there is never written over, and no candidate is staged for it.
827
+ // Only one that already holds exactly what the policy asks (Atelier's own, from an interrupted run) is left as it is.
828
+ if (context.mode === 'direct-unheld' && existing !== null && !context.rules.unheldWrites('replace') && !holdsPolicy(unit.path, existing)) {
829
+ return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'editor-uncoordinated', blocking: true }
830
+ }
831
+ let file
832
+ try { file = preparePolicySettingsFile(unit.path, existing) } catch (error) {
563
833
  if (error instanceof ObsidianContractRefusal) return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'settings-invalid', blocking: false }
564
834
  throw error
565
835
  }
566
- const [file] = prepared.files
567
836
  if (existing !== null && file.bytes.equals(existing)) return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'policy-satisfied', blocking: false }
568
837
  const plan = { ...unit, op: existing === null ? 'create' : 'replace', baseDigest: existing === null ? null : sha256Digest(existing), candidateDigest: file.digest, bytes: file.bytes,
569
838
  mode: existing === null ? 0o644 : fs.lstatSync(note).mode & 0o777 }
@@ -573,6 +842,13 @@ function planSettings(unit, context) {
573
842
  return plan
574
843
  }
575
844
 
845
+ function holdsPolicy(settingsPath, bytes) {
846
+ try { return preparePolicySettingsFile(settingsPath, bytes).bytes.equals(bytes) } catch (error) {
847
+ if (error instanceof ObsidianContractRefusal) return false
848
+ throw error
849
+ }
850
+ }
851
+
576
852
  function verify(unit, plan, { store, journal }, result) {
577
853
  const bytes = readNote(path.join(store.vaultRoot, unit.path))
578
854
  const digest = bytes === null ? null : sha256Digest(bytes)