@mnstry/atelier 0.2.0-alpha.12 → 0.2.0-alpha.14

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 (531) hide show
  1. package/CHANGELOG.md +894 -0
  2. package/README.md +159 -6
  3. package/contracts/atelier-adoption-policy.v2.schema.json +55 -0
  4. package/contracts/atelier-architecture.v1.schema.json +258 -0
  5. package/contracts/atelier-boundary-delegations.v1.schema.json +161 -0
  6. package/contracts/atelier-boundary-owner-keys.v1.schema.json +98 -0
  7. package/contracts/atelier-build.v1.schema.json +1076 -0
  8. package/contracts/atelier-capability.v1.schema.json +1252 -0
  9. package/contracts/atelier-coordination.v1.schema.json +1050 -0
  10. package/contracts/atelier-evidence-navigation.v1.schema.json +603 -0
  11. package/contracts/atelier-harness.v1.schema.json +234 -0
  12. package/contracts/atelier-ingestion.v1.schema.json +806 -0
  13. package/contracts/atelier-inquiry.v1.schema.json +1772 -0
  14. package/contracts/atelier-instruction-adoption.v1.schema.json +490 -0
  15. package/contracts/atelier-interaction.v1.schema.json +553 -0
  16. package/contracts/atelier-judgment.v1.schema.json +690 -0
  17. package/contracts/atelier-knowledge.v1.schema.json +1512 -0
  18. package/contracts/atelier-learning.v1.schema.json +1095 -0
  19. package/contracts/atelier-migration.v3.schema.json +64 -0
  20. package/contracts/atelier-personal-workspace-manifest.v1.schema.json +87 -0
  21. package/contracts/atelier-personal-workspace-overlay.v1.schema.json +299 -0
  22. package/contracts/atelier-template-adoption.v1.schema.json +223 -0
  23. package/contracts/atelier-template-profile.v1.schema.json +1168 -0
  24. package/contracts/atelier-trackable.v1.schema.json +781 -0
  25. package/contracts/atelier-upgrade-plan.v3.schema.json +532 -0
  26. package/docs/architecture.md +89 -0
  27. package/docs/assurance-controls.md +2 -2
  28. package/docs/blocks/capability-stewardship.md +17 -0
  29. package/docs/blocks/claims.md +16 -3
  30. package/docs/blocks/inquiry-harnesses.md +19 -0
  31. package/docs/blocks/learning-harnesses.md +17 -0
  32. package/docs/capability-stewardship.md +267 -0
  33. package/docs/coordination.md +51 -0
  34. package/docs/decision-practices.md +111 -0
  35. package/docs/decisions.md +29 -0
  36. package/docs/evidence-navigation.md +237 -0
  37. package/docs/exact-upgrades.md +3 -2
  38. package/docs/ingestion.md +181 -0
  39. package/docs/inquiry-harnesses.md +210 -0
  40. package/docs/install.md +80 -3
  41. package/docs/intake-and-guides.md +5 -0
  42. package/docs/integration-contract-decisions.md +24 -2
  43. package/docs/interaction-and-reflection.md +72 -0
  44. package/docs/knowledge-graph.md +20 -0
  45. package/docs/knowledge-health/api.md +30 -0
  46. package/docs/knowledge-health/assessment/api.md +96 -0
  47. package/docs/knowledge-health/assessment/capabilities.md +40 -0
  48. package/docs/knowledge-health/assessment/evaluation.md +27 -0
  49. package/docs/knowledge-health/assessment/facilitator.md +30 -0
  50. package/docs/knowledge-health/assessment/quickstart.md +61 -0
  51. package/docs/knowledge-health/assessment/troubleshooting.md +23 -0
  52. package/docs/knowledge-health/devday/facilitator.md +16 -0
  53. package/docs/knowledge-health/devday/participant-response.md +22 -0
  54. package/docs/knowledge-health/devday/public-default.md +107 -0
  55. package/docs/knowledge-health/devday/quickstart.md +18 -0
  56. package/docs/knowledge-health/devday/support.md +22 -0
  57. package/docs/knowledge-health/exercise.md +17 -0
  58. package/docs/knowledge-health/facilitator.md +20 -0
  59. package/docs/knowledge-health/quickstart.md +94 -0
  60. package/docs/knowledge-health/readme.md +15 -0
  61. package/docs/knowledge-health/support.md +34 -0
  62. package/docs/knowledge-setup.md +248 -0
  63. package/docs/knowledge-workspace.md +264 -0
  64. package/docs/learning-harnesses.md +237 -0
  65. package/docs/learning.md +215 -0
  66. package/docs/local-services.md +223 -19
  67. package/docs/obsidian-contract.md +246 -15
  68. package/docs/obsidian-plugin.md +252 -7
  69. package/docs/obsidian.md +874 -52
  70. package/docs/optional-ingestion.md +83 -0
  71. package/docs/personal-workspace.md +266 -0
  72. package/docs/practical-judgment.md +92 -0
  73. package/docs/progressive-ingestion-acceptance.md +89 -0
  74. package/docs/progressive-ingestion.md +235 -0
  75. package/docs/project-command-inventory.md +1 -0
  76. package/docs/release-engineering.md +50 -0
  77. package/docs/repo-boundary-guard.md +120 -3
  78. package/docs/research-starter-quickstart.md +66 -0
  79. package/docs/research-starter.md +114 -0
  80. package/docs/semantic-ingestion.md +198 -0
  81. package/docs/semantic-operation.md +318 -0
  82. package/docs/skill-steward.md +6 -0
  83. package/docs/template-profile.md +164 -0
  84. package/docs/template-upgrades.md +114 -0
  85. package/docs/trackables.md +111 -0
  86. package/docs/upgrade.md +219 -2
  87. package/fixtures/atelier-architecture/binding/invalid/document.json +19 -0
  88. package/fixtures/atelier-architecture/binding/valid/document.json +19 -0
  89. package/fixtures/atelier-architecture/catalog/invalid/document.json +290 -0
  90. package/fixtures/atelier-architecture/catalog/valid/document.json +343 -0
  91. package/fixtures/atelier-build-contract/attempt/invalid/document.json +20 -0
  92. package/fixtures/atelier-build-contract/attempt/valid/document.json +19 -0
  93. package/fixtures/atelier-build-contract/candidate/invalid/document.json +24 -0
  94. package/fixtures/atelier-build-contract/candidate/valid/document.json +23 -0
  95. package/fixtures/atelier-build-contract/decision/invalid/document.json +31 -0
  96. package/fixtures/atelier-build-contract/decision/valid/document.json +30 -0
  97. package/fixtures/atelier-build-contract/delivery/invalid/document.json +18 -0
  98. package/fixtures/atelier-build-contract/delivery/valid/document.json +17 -0
  99. package/fixtures/atelier-build-contract/gate/invalid/document.json +22 -0
  100. package/fixtures/atelier-build-contract/gate/valid/document.json +21 -0
  101. package/fixtures/atelier-build-contract/ledger/invalid/document.json +270 -0
  102. package/fixtures/atelier-build-contract/ledger/valid/document.json +269 -0
  103. package/fixtures/atelier-build-contract/objective/invalid/document.json +73 -0
  104. package/fixtures/atelier-build-contract/objective/valid/document.json +72 -0
  105. package/fixtures/atelier-build-contract/progress/invalid/document.json +18 -0
  106. package/fixtures/atelier-build-contract/progress/valid/document.json +17 -0
  107. package/fixtures/atelier-capability-contract/adoption/invalid/document.json +71 -0
  108. package/fixtures/atelier-capability-contract/adoption/valid/document.json +70 -0
  109. package/fixtures/atelier-capability-contract/event/invalid/document.json +18 -0
  110. package/fixtures/atelier-capability-contract/event/valid/document.json +17 -0
  111. package/fixtures/atelier-capability-contract/journal/invalid/document.json +382 -0
  112. package/fixtures/atelier-capability-contract/journal/valid/document.json +381 -0
  113. package/fixtures/atelier-capability-contract/notice/invalid/document.json +8 -0
  114. package/fixtures/atelier-capability-contract/notice/valid/document.json +7 -0
  115. package/fixtures/atelier-capability-contract/package/invalid/document.json +59 -0
  116. package/fixtures/atelier-capability-contract/package/valid/document.json +58 -0
  117. package/fixtures/atelier-capability-contract/plan/invalid/document.json +107 -0
  118. package/fixtures/atelier-capability-contract/plan/valid/document.json +106 -0
  119. package/fixtures/atelier-capability-contract/release/invalid/document.json +85 -0
  120. package/fixtures/atelier-capability-contract/release/valid/document.json +84 -0
  121. package/fixtures/atelier-capability-contract/state/invalid/document.json +316 -0
  122. package/fixtures/atelier-capability-contract/state/valid/document.json +315 -0
  123. package/fixtures/atelier-coordination/dependency/invalid/document.json +37 -0
  124. package/fixtures/atelier-coordination/dependency/valid/document.json +36 -0
  125. package/fixtures/atelier-coordination/directive/invalid/document.json +25 -0
  126. package/fixtures/atelier-coordination/directive/valid/document.json +24 -0
  127. package/fixtures/atelier-coordination/disposition/invalid/document.json +34 -0
  128. package/fixtures/atelier-coordination/disposition/valid/document.json +33 -0
  129. package/fixtures/atelier-coordination/outcome/invalid/document.json +39 -0
  130. package/fixtures/atelier-coordination/outcome/valid/document.json +38 -0
  131. package/fixtures/atelier-coordination/recovery/invalid/document.json +34 -0
  132. package/fixtures/atelier-coordination/recovery/valid/document.json +33 -0
  133. package/fixtures/atelier-harness-contract/handoff/invalid/document.json +25 -0
  134. package/fixtures/atelier-harness-contract/handoff/valid/document.json +24 -0
  135. package/fixtures/atelier-ingestion/invalid/authority.json +19 -0
  136. package/fixtures/atelier-ingestion/semantic-baseline.json +26 -0
  137. package/fixtures/atelier-ingestion/semantic-operation.json +75 -0
  138. package/fixtures/atelier-ingestion/valid/plan-input.json +18 -0
  139. package/fixtures/atelier-inquiry-contract/assessment/invalid/document.json +41 -0
  140. package/fixtures/atelier-inquiry-contract/assessment/valid/document.json +40 -0
  141. package/fixtures/atelier-inquiry-contract/bundle/invalid/document.json +51 -0
  142. package/fixtures/atelier-inquiry-contract/bundle/valid/document.json +50 -0
  143. package/fixtures/atelier-inquiry-contract/campaign/invalid/document.json +17 -0
  144. package/fixtures/atelier-inquiry-contract/campaign/valid/document.json +16 -0
  145. package/fixtures/atelier-inquiry-contract/decision/invalid/document.json +22 -0
  146. package/fixtures/atelier-inquiry-contract/decision/valid/document.json +21 -0
  147. package/fixtures/atelier-inquiry-contract/feedback/invalid/document.json +19 -0
  148. package/fixtures/atelier-inquiry-contract/feedback/valid/document.json +18 -0
  149. package/fixtures/atelier-inquiry-contract/handoff/invalid/document.json +92 -0
  150. package/fixtures/atelier-inquiry-contract/handoff/valid/document.json +91 -0
  151. package/fixtures/atelier-inquiry-contract/hypothesis/invalid/document.json +20 -0
  152. package/fixtures/atelier-inquiry-contract/hypothesis/valid/document.json +19 -0
  153. package/fixtures/atelier-inquiry-contract/ledger/invalid/document.json +680 -0
  154. package/fixtures/atelier-inquiry-contract/ledger/valid/document.json +679 -0
  155. package/fixtures/atelier-inquiry-contract/legacy-assessment/invalid/document.json +19 -0
  156. package/fixtures/atelier-inquiry-contract/legacy-assessment/valid/document.json +18 -0
  157. package/fixtures/atelier-inquiry-contract/request/invalid/document.json +50 -0
  158. package/fixtures/atelier-inquiry-contract/request/valid/document.json +49 -0
  159. package/fixtures/atelier-inquiry-contract/source/invalid/document.json +18 -0
  160. package/fixtures/atelier-inquiry-contract/source/valid/document.json +17 -0
  161. package/fixtures/atelier-inquiry-contract/withdrawal/invalid/document.json +16 -0
  162. package/fixtures/atelier-inquiry-contract/withdrawal/valid/document.json +15 -0
  163. package/fixtures/atelier-instruction-adoption/context-receipt/invalid/document.json +18 -0
  164. package/fixtures/atelier-instruction-adoption/context-receipt/valid/document.json +17 -0
  165. package/fixtures/atelier-instruction-adoption/plan/invalid/document.json +63 -0
  166. package/fixtures/atelier-instruction-adoption/plan/valid/document.json +62 -0
  167. package/fixtures/atelier-instruction-adoption/receipt/invalid/document.json +22 -0
  168. package/fixtures/atelier-instruction-adoption/receipt/valid/document.json +21 -0
  169. package/fixtures/atelier-instruction-adoption/state/invalid/document.json +30 -0
  170. package/fixtures/atelier-instruction-adoption/state/valid/document.json +29 -0
  171. package/fixtures/atelier-interaction/act/invalid/document.json +25 -0
  172. package/fixtures/atelier-interaction/act/valid/document.json +24 -0
  173. package/fixtures/atelier-interaction/assessment/invalid/document.json +32 -0
  174. package/fixtures/atelier-interaction/assessment/valid/document.json +31 -0
  175. package/fixtures/atelier-interaction/observation/invalid/document.json +22 -0
  176. package/fixtures/atelier-interaction/observation/valid/document.json +21 -0
  177. package/fixtures/atelier-interaction/policy/invalid/document.json +20 -0
  178. package/fixtures/atelier-interaction/policy/valid/document.json +19 -0
  179. package/fixtures/atelier-judgment/case/invalid/document.json +76 -0
  180. package/fixtures/atelier-judgment/case/valid/document.json +75 -0
  181. package/fixtures/atelier-judgment/decision/invalid/document.json +38 -0
  182. package/fixtures/atelier-judgment/decision/valid/document.json +37 -0
  183. package/fixtures/atelier-judgment/native-decision.md +3 -0
  184. package/fixtures/atelier-judgment/practice-reconsideration/definition.json +92 -0
  185. package/fixtures/atelier-judgment/practice-reconsideration/scenario.json +435 -0
  186. package/fixtures/atelier-knowledge-contract/activation/invalid/document.json +26 -0
  187. package/fixtures/atelier-knowledge-contract/activation/valid/document.json +25 -0
  188. package/fixtures/atelier-knowledge-contract/contribution/invalid/document.json +34 -0
  189. package/fixtures/atelier-knowledge-contract/contribution/valid/document.json +33 -0
  190. package/fixtures/atelier-knowledge-contract/domain/invalid/document.json +41 -0
  191. package/fixtures/atelier-knowledge-contract/domain/valid/document.json +40 -0
  192. package/fixtures/atelier-knowledge-contract/domain-revision/invalid/document.json +46 -0
  193. package/fixtures/atelier-knowledge-contract/domain-revision/valid/document.json +45 -0
  194. package/fixtures/atelier-knowledge-contract/evaluation/invalid/document.json +21 -0
  195. package/fixtures/atelier-knowledge-contract/evaluation/valid/document.json +20 -0
  196. package/fixtures/atelier-knowledge-contract/ledger/invalid/document.json +318 -0
  197. package/fixtures/atelier-knowledge-contract/ledger/valid/document.json +317 -0
  198. package/fixtures/atelier-knowledge-contract/relation/invalid/document.json +25 -0
  199. package/fixtures/atelier-knowledge-contract/relation/valid/document.json +24 -0
  200. package/fixtures/atelier-knowledge-contract/review/invalid/document.json +18 -0
  201. package/fixtures/atelier-knowledge-contract/review/valid/document.json +17 -0
  202. package/fixtures/atelier-knowledge-contract/withdrawal/invalid/document.json +16 -0
  203. package/fixtures/atelier-knowledge-contract/withdrawal/valid/document.json +15 -0
  204. package/fixtures/atelier-learning/invalid/authority.json +14 -0
  205. package/fixtures/atelier-learning/valid/capture.json +13 -0
  206. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/authority-key.json +17 -0
  207. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/nonempty-ext.json +25 -0
  208. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/wrong-schema.json +22 -0
  209. package/fixtures/atelier-personal-workspace-contract/manifest/valid/manifest.json +22 -0
  210. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/authority-key.json +25 -0
  211. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/unknown-top-level-field.json +27 -0
  212. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/wrong-schema.json +24 -0
  213. package/fixtures/atelier-trackable/definition/invalid/document.json +15 -0
  214. package/fixtures/atelier-trackable/definition/valid/document.json +14 -0
  215. package/fixtures/atelier-trackable/request/invalid/document.json +23 -0
  216. package/fixtures/atelier-trackable/request/valid/document.json +22 -0
  217. package/fixtures/boundary-delegations/invalid/authority-key.v1.json +30 -0
  218. package/fixtures/boundary-delegations/invalid/push-operation.v1.json +28 -0
  219. package/fixtures/boundary-delegations/invalid/unsigned.v1.json +25 -0
  220. package/fixtures/boundary-delegations/valid/empty.v1.json +4 -0
  221. package/fixtures/boundary-delegations/valid/signed-operator.v1.json +30 -0
  222. package/fixtures/boundary-owner-keys/invalid/wrong-algorithm.v1.json +15 -0
  223. package/fixtures/boundary-owner-keys/invalid/wrong-curve.v1.json +15 -0
  224. package/fixtures/boundary-owner-keys/valid/one-owner.v1.json +17 -0
  225. package/fixtures/capability-packages/adoption.example.json +70 -0
  226. package/fixtures/capability-packages/evidence-review/capability-package.json +58 -0
  227. package/fixtures/capability-packages/evidence-review/capability-release.json +84 -0
  228. package/fixtures/capability-packages/evidence-review/evaluation.md +15 -0
  229. package/fixtures/capability-packages/evidence-review/skills/evidence-review/SKILL.md +22 -0
  230. package/fixtures/capability-packages/evidence-review/skills/evidence-review/references/ledger.md +7 -0
  231. package/fixtures/capability-packages/research/capability-package.json +88 -0
  232. package/fixtures/capability-packages/research/capability-release.json +134 -0
  233. package/fixtures/capability-packages/research/evaluation.md +15 -0
  234. package/fixtures/capability-packages/research/skills/research-integrate/SKILL.md +29 -0
  235. package/fixtures/capability-packages/research/skills/research-integrate/references/integration.md +10 -0
  236. package/fixtures/capability-packages/research/skills/research-plan/SKILL.md +27 -0
  237. package/fixtures/capability-packages/research/skills/research-plan/references/inquiries.md +26 -0
  238. package/fixtures/capability-packages/research/skills/research-synthesize/SKILL.md +23 -0
  239. package/fixtures/capability-packages/research/skills/research-synthesize/references/synthesis.md +12 -0
  240. package/fixtures/evidence-navigation/invalid/duplicate-operation.json +16 -0
  241. package/fixtures/evidence-navigation/invalid/instruction-kind.json +36 -0
  242. package/fixtures/evidence-navigation/invalid/invalid-digest.json +36 -0
  243. package/fixtures/evidence-navigation/invalid/invalid-time.json +36 -0
  244. package/fixtures/evidence-navigation/invalid/missing-attribution.json +35 -0
  245. package/fixtures/evidence-navigation/invalid/reserved-extension.json +19 -0
  246. package/fixtures/evidence-navigation/invalid/unattributed-acceptance.json +36 -0
  247. package/fixtures/evidence-navigation/invalid/uncoerced-version.json +16 -0
  248. package/fixtures/evidence-navigation/invalid/unknown-currency.json +17 -0
  249. package/fixtures/evidence-navigation/invalid/unknown-selector-field.json +37 -0
  250. package/fixtures/evidence-navigation/invalid/unsupported-operation.json +15 -0
  251. package/fixtures/evidence-navigation/valid/claim.json +36 -0
  252. package/fixtures/evidence-navigation/valid/host.json +16 -0
  253. package/fixtures/evidence-navigation/valid/profile.json +16 -0
  254. package/fixtures/evidence-navigation/valid/snapshot.json +17 -0
  255. package/fixtures/harness-packages/build-harness/capability-package.json +61 -0
  256. package/fixtures/harness-packages/build-harness/capability-release.json +82 -0
  257. package/fixtures/harness-packages/build-harness/evaluation.md +3 -0
  258. package/fixtures/harness-packages/build-harness/skills/atelier-build-harness/SKILL.md +59 -0
  259. package/fixtures/harness-packages/knowledge-harness/capability-package.json +61 -0
  260. package/fixtures/harness-packages/knowledge-harness/capability-release.json +82 -0
  261. package/fixtures/harness-packages/knowledge-harness/evaluation.md +3 -0
  262. package/fixtures/harness-packages/knowledge-harness/skills/atelier-knowledge-harness/SKILL.md +60 -0
  263. package/fixtures/harnesses/learning-cycle.json +780 -0
  264. package/fixtures/inquiry/workshop.json +675 -0
  265. package/fixtures/inquiry-packages/discovery-harness/capability-package.json +61 -0
  266. package/fixtures/inquiry-packages/discovery-harness/capability-release.json +82 -0
  267. package/fixtures/inquiry-packages/discovery-harness/evaluation.md +3 -0
  268. package/fixtures/inquiry-packages/discovery-harness/skills/atelier-discovery-harness/SKILL.md +48 -0
  269. package/fixtures/inquiry-packages/research-harness/capability-package.json +61 -0
  270. package/fixtures/inquiry-packages/research-harness/capability-release.json +82 -0
  271. package/fixtures/inquiry-packages/research-harness/evaluation.md +3 -0
  272. package/fixtures/inquiry-packages/research-harness/skills/atelier-research-harness/SKILL.md +43 -0
  273. package/fixtures/knowledge-health/calibration-suite.json +26 -0
  274. package/fixtures/knowledge-health/calibration-trial.json +37 -0
  275. package/fixtures/knowledge-health/corrected/checklist.md +18 -0
  276. package/fixtures/knowledge-health/devday/participant-record.json +19 -0
  277. package/fixtures/knowledge-health/expected-summary.json +27 -0
  278. package/fixtures/knowledge-health/workshop/README.md +20 -0
  279. package/fixtures/knowledge-health/workshop/atelier.project.json +7 -0
  280. package/fixtures/knowledge-health/workshop/knowledge-plan.json +65 -0
  281. package/fixtures/knowledge-health/workshop/records/checklist.md +18 -0
  282. package/fixtures/knowledge-health/workshop/records/workshop.md +18 -0
  283. package/fixtures/knowledge-health/workshop/repo-access.v1.json +5 -0
  284. package/fixtures/knowledge-health/workspace/atelier.project.json +18 -0
  285. package/fixtures/knowledge-health/workspace/knowledge-plan.json +74 -0
  286. package/fixtures/knowledge-health/workspace/records/checklist.md +18 -0
  287. package/fixtures/knowledge-health/workspace/records/pilot.md +16 -0
  288. package/fixtures/knowledge-health/workspace/repo-access.v1.json +9 -0
  289. package/fixtures/personal-workspace/overlay.json +24 -0
  290. package/fixtures/templates/local-library.v1.json +76 -0
  291. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/invalid.json +16 -0
  292. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/valid.json +13 -0
  293. package/fixtures/templates/upgrade-contracts/migration.v3/invalid.json +24 -0
  294. package/fixtures/templates/upgrade-contracts/migration.v3/valid.json +21 -0
  295. package/fixtures/templates/upgrade-contracts/template-adoption.v1/invalid.json +60 -0
  296. package/fixtures/templates/upgrade-contracts/template-adoption.v1/valid.json +57 -0
  297. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/invalid.json +85 -0
  298. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/valid.json +82 -0
  299. package/package.json +60 -5
  300. package/plugins/obsidian/main.js +57 -8
  301. package/plugins/obsidian/manifest.json +1 -1
  302. package/skills/claude/atelier-build-harness/SKILL.md +59 -0
  303. package/skills/claude/atelier-coordination/SKILL.md +32 -0
  304. package/skills/claude/atelier-discovery-harness/SKILL.md +48 -0
  305. package/skills/claude/atelier-guided-upgrade/SKILL.md +29 -0
  306. package/skills/claude/atelier-interaction/SKILL.md +34 -0
  307. package/skills/claude/atelier-knowledge-harness/SKILL.md +60 -0
  308. package/skills/claude/atelier-knowledge-setup/SKILL.md +72 -0
  309. package/skills/claude/atelier-practical-judgment/SKILL.md +64 -0
  310. package/skills/claude/atelier-research-harness/SKILL.md +43 -0
  311. package/skills/claude/atelier-skill-steward/SKILL.md +53 -0
  312. package/skills/claude/atelier-trackables/SKILL.md +40 -0
  313. package/skills/codex/atelier-build-harness/SKILL.md +59 -0
  314. package/skills/codex/atelier-coordination/SKILL.md +32 -0
  315. package/skills/codex/atelier-discovery-harness/SKILL.md +48 -0
  316. package/skills/codex/atelier-guided-upgrade/SKILL.md +29 -0
  317. package/skills/codex/atelier-interaction/SKILL.md +34 -0
  318. package/skills/codex/atelier-knowledge-harness/SKILL.md +60 -0
  319. package/skills/codex/atelier-knowledge-setup/SKILL.md +72 -0
  320. package/skills/codex/atelier-practical-judgment/SKILL.md +64 -0
  321. package/skills/codex/atelier-research-harness/SKILL.md +43 -0
  322. package/skills/codex/atelier-skill-steward/SKILL.md +53 -0
  323. package/skills/codex/atelier-trackables/SKILL.md +40 -0
  324. package/src/architecture/catalog.mjs +355 -0
  325. package/src/architecture/index.mjs +93 -0
  326. package/src/boundary/content-rules.mjs +536 -69
  327. package/src/boundary/delegation-command.mjs +158 -0
  328. package/src/boundary/delegation.mjs +396 -0
  329. package/src/boundary/policy.mjs +185 -64
  330. package/src/build/git.mjs +18 -0
  331. package/src/build/index.mjs +3 -0
  332. package/src/build/ledger.mjs +88 -0
  333. package/src/capabilities/adoption.mjs +381 -0
  334. package/src/capabilities/evidence.mjs +140 -0
  335. package/src/capabilities/files.mjs +124 -0
  336. package/src/capabilities/index.mjs +4 -0
  337. package/src/capabilities/instructions.mjs +250 -0
  338. package/src/capabilities/package.mjs +136 -0
  339. package/src/cli/command-failure.mjs +36 -0
  340. package/src/cli/execute-command.mjs +16 -2
  341. package/src/cli/run.mjs +66 -9
  342. package/src/coauthor/store.mjs +43 -2
  343. package/src/collaboration/proposals.mjs +189 -0
  344. package/src/commands/architecture.mjs +27 -0
  345. package/src/commands/boundary.mjs +3 -0
  346. package/src/commands/capability.mjs +73 -0
  347. package/src/commands/enroll.mjs +3 -0
  348. package/src/commands/harness.mjs +79 -0
  349. package/src/commands/ingest.mjs +39 -0
  350. package/src/commands/init.mjs +4 -1
  351. package/src/commands/inquiry.mjs +66 -0
  352. package/src/commands/knowledge-semantic.mjs +128 -0
  353. package/src/commands/knowledge.mjs +119 -0
  354. package/src/commands/learn.mjs +61 -0
  355. package/src/commands/obsidian.mjs +772 -65
  356. package/src/commands/practice.mjs +24 -0
  357. package/src/commands/setup.mjs +22 -2
  358. package/src/commands/trackable.mjs +19 -0
  359. package/src/contracts/corpus.mjs +30 -0
  360. package/src/coordination/index.mjs +110 -0
  361. package/src/evidence-navigation/contracts.mjs +60 -0
  362. package/src/evidence-navigation/evaluate.mjs +69 -0
  363. package/src/evidence-navigation/generation.mjs +22 -0
  364. package/src/evidence-navigation/index.mjs +2 -0
  365. package/src/evidence-navigation/local.mjs +167 -0
  366. package/src/evidence-navigation/schema.generated.mjs +604 -0
  367. package/src/evidence-navigation/types.mjs +12 -0
  368. package/src/export/atelier-export-contract.mjs +35 -8
  369. package/src/graph/enroll.mjs +248 -0
  370. package/src/graph/graph.mjs +30 -6
  371. package/src/graph/knowledge-graph.mjs +77 -9
  372. package/src/harnesses/contracts.mjs +41 -0
  373. package/src/harnesses/exchange.mjs +154 -0
  374. package/src/harnesses/feedback.mjs +16 -0
  375. package/src/harnesses/history.mjs +37 -0
  376. package/src/harnesses/index.mjs +4 -0
  377. package/src/harnesses/store.mjs +29 -0
  378. package/src/ingestion/contracts.mjs +41 -0
  379. package/src/ingestion/evaluation.mjs +119 -0
  380. package/src/ingestion/optional/composition/index.mjs +171 -0
  381. package/src/ingestion/optional/evaluation/index.mjs +219 -0
  382. package/src/ingestion/optional/graphify/composition-binding.mjs +60 -0
  383. package/src/ingestion/optional/graphify/composition_receiving.py +185 -0
  384. package/src/ingestion/optional/graphify/graphify_supplier.py +938 -0
  385. package/src/ingestion/optional/graphify/json_bridge.py +163 -0
  386. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  387. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  388. package/src/ingestion/optional/graphify/query_receiving.py +209 -0
  389. package/src/ingestion/optional/index.mjs +9 -0
  390. package/src/ingestion/optional/jev/bridge.mjs +166 -0
  391. package/src/ingestion/optional/jev/composition-binding.mjs +116 -0
  392. package/src/ingestion/optional/jev/judgments.mjs +174 -0
  393. package/src/ingestion/optional/jev/native-translation.mjs +32 -0
  394. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/__init__.py +7 -0
  395. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/adapter.py +966 -0
  396. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/artifacts.py +101 -0
  397. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/projection.py +146 -0
  398. package/src/ingestion/optional/lightrag/composition-binding.mjs +67 -0
  399. package/src/ingestion/optional/vanilla/composition-binding.mjs +102 -0
  400. package/src/ingestion/optional/vanilla/index.mjs +218 -0
  401. package/src/ingestion/optional/vanilla/native-normalization.mjs +187 -0
  402. package/src/ingestion/processors.mjs +218 -0
  403. package/src/ingestion/semantic.mjs +203 -0
  404. package/src/ingestion/store.mjs +372 -0
  405. package/src/inquiry/beliefs.mjs +32 -0
  406. package/src/inquiry/contracts.mjs +23 -0
  407. package/src/inquiry/index.mjs +5 -0
  408. package/src/inquiry/ledger.mjs +135 -0
  409. package/src/inquiry/lenses.mjs +15 -0
  410. package/src/inquiry/projection.mjs +77 -0
  411. package/src/inquiry/store.mjs +35 -0
  412. package/src/intake/read-scope.mjs +12 -0
  413. package/src/intake/store.mjs +69 -6
  414. package/src/interaction/index.mjs +91 -0
  415. package/src/judgment/index.mjs +82 -0
  416. package/src/judgment/practice-evaluation.mjs +70 -0
  417. package/src/judgment/practice.mjs +144 -0
  418. package/src/knowledge/context.mjs +217 -0
  419. package/src/knowledge/index.mjs +5 -0
  420. package/src/knowledge/ingestion.mjs +79 -0
  421. package/src/knowledge/intake.mjs +33 -0
  422. package/src/knowledge/ledger.mjs +110 -0
  423. package/src/knowledge/participatory/guidance.mjs +36 -0
  424. package/src/knowledge/participatory/guided-contribution.mjs +44 -0
  425. package/src/knowledge/participatory/index.mjs +2 -0
  426. package/src/knowledge/participatory/installed-workshop.mjs +108 -0
  427. package/src/knowledge/plan.mjs +67 -0
  428. package/src/knowledge/projection.mjs +43 -0
  429. package/src/knowledge/semantic-operation.mjs +567 -0
  430. package/src/knowledge/sessions.mjs +467 -0
  431. package/src/knowledge/workspace.mjs +295 -0
  432. package/src/knowledge-health/assessment.mjs +165 -0
  433. package/src/knowledge-health/capabilities.mjs +350 -0
  434. package/src/knowledge-health/context.mjs +62 -0
  435. package/src/knowledge-health/example.mjs +47 -0
  436. package/src/knowledge-health/index.mjs +10 -0
  437. package/src/knowledge-health/owner-join.mjs +88 -0
  438. package/src/knowledge-health/proof/common.mjs +24 -0
  439. package/src/knowledge-health/proof/installed-check.mjs +100 -0
  440. package/src/knowledge-health/proof/native-readback.mjs +37 -0
  441. package/src/knowledge-health/proof/rehearse.mjs +68 -0
  442. package/src/knowledge-health/proof/validate-participant.mjs +17 -0
  443. package/src/knowledge-health/proof/verify-native-capture.mjs +14 -0
  444. package/src/knowledge-health/proof/workshop-check.mjs +36 -0
  445. package/src/knowledge-health/rule-identity.mjs +49 -0
  446. package/src/knowledge-health/workshop-composition.mjs +19 -0
  447. package/src/knowledge-health/workshop-profile.mjs +176 -0
  448. package/src/knowledge-health/workshop.mjs +99 -0
  449. package/src/learning/adapters.mjs +68 -0
  450. package/src/learning/assistance.mjs +127 -0
  451. package/src/learning/contracts.mjs +65 -0
  452. package/src/learning/store.mjs +272 -0
  453. package/src/personal-workspace/index.mjs +391 -0
  454. package/src/personal-workspace/selection.mjs +401 -0
  455. package/src/project/config.mjs +8 -0
  456. package/src/project/durable-state.mjs +3 -1
  457. package/src/project/file-class.mjs +1 -1
  458. package/src/project/path-match.mjs +2 -1
  459. package/src/projection/obsidian/edits/apply.mjs +55 -16
  460. package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
  461. package/src/projection/obsidian/personal-workspace.mjs +519 -0
  462. package/src/projection/obsidian/proposals/adapter.mjs +79 -17
  463. package/src/projection/obsidian/proposals/observation.mjs +50 -4
  464. package/src/projection/obsidian/publication/publisher.mjs +214 -18
  465. package/src/projection/obsidian/publication/vault-list.mjs +4 -3
  466. package/src/projection/obsidian/recovery/index.mjs +4 -1
  467. package/src/projection/obsidian/recovery/store.mjs +160 -7
  468. package/src/projection/obsidian/selection-ui/contribution.mjs +6 -5
  469. package/src/projection/project.mjs +3 -2
  470. package/src/readiness/readiness.mjs +10 -3
  471. package/src/reflection/index.mjs +56 -0
  472. package/src/runtime/obsidian/app-production-seams.mjs +48 -16
  473. package/src/runtime/obsidian/app-registration.mjs +175 -28
  474. package/src/runtime/obsidian/app-restart.mjs +154 -0
  475. package/src/runtime/obsidian/engine.mjs +437 -56
  476. package/src/runtime/obsidian/extension-points.mjs +1 -1
  477. package/src/runtime/obsidian/index.mjs +10 -7
  478. package/src/runtime/obsidian/launch-plan.mjs +7 -4
  479. package/src/runtime/obsidian/lifecycle.mjs +108 -23
  480. package/src/runtime/obsidian/login-item.mjs +335 -0
  481. package/src/runtime/obsidian/machine-settings.mjs +119 -12
  482. package/src/runtime/obsidian/observation.mjs +36 -4
  483. package/src/runtime/obsidian/opening.mjs +173 -38
  484. package/src/runtime/obsidian/personal-selection.mjs +125 -0
  485. package/src/runtime/obsidian/pipeline.mjs +115 -6
  486. package/src/runtime/obsidian/plugin-channel.mjs +11 -3
  487. package/src/runtime/obsidian/plugin-choice.mjs +16 -4
  488. package/src/runtime/obsidian/plugin-drift.mjs +59 -11
  489. package/src/runtime/obsidian/plugin-presence.mjs +14 -1
  490. package/src/runtime/obsidian/private-lock.mjs +39 -4
  491. package/src/runtime/obsidian/process-identity.mjs +20 -10
  492. package/src/runtime/obsidian/project-views.mjs +282 -0
  493. package/src/runtime/obsidian/questions.mjs +48 -0
  494. package/src/runtime/obsidian/release-watch.mjs +56 -0
  495. package/src/runtime/obsidian/service-main.mjs +148 -19
  496. package/src/runtime/obsidian/service-manager-production.mjs +61 -0
  497. package/src/runtime/obsidian/service-managers.mjs +275 -0
  498. package/src/runtime/obsidian/service-record.mjs +87 -23
  499. package/src/runtime/obsidian/service.mjs +102 -9
  500. package/src/runtime/obsidian/startup-adapters.mjs +59 -21
  501. package/src/runtime/obsidian/unheld-evidence.mjs +84 -0
  502. package/src/runtime/obsidian/vault-location.mjs +300 -0
  503. package/src/runtime/obsidian/view-counts.mjs +34 -0
  504. package/src/runtime/obsidian/view-permission.mjs +334 -0
  505. package/src/server/local-sidecar.mjs +169 -2
  506. package/src/server/server.mjs +28 -2
  507. package/src/template-bindings/project.mjs +92 -0
  508. package/src/templates/profile.mjs +225 -0
  509. package/src/trackables/domain.mjs +175 -0
  510. package/src/trackables/store.mjs +114 -0
  511. package/src/ui/control-system.mjs +201 -0
  512. package/src/ui/html-primitives.mjs +3 -0
  513. package/src/ui/knowledge-page.mjs +1388 -0
  514. package/src/upgrade/closure-diagnostics.mjs +135 -0
  515. package/src/upgrade/closure.mjs +649 -0
  516. package/src/upgrade/explanation.mjs +1 -1
  517. package/src/upgrade/template-participant.mjs +171 -0
  518. package/src/upgrade/transaction.mjs +72 -25
  519. package/src/upgrade/upgrade.mjs +20 -7
  520. package/templates/external-project-workspace/pack-lifecycle.json +7 -1
  521. package/templates/knowledge-workspace/.gitattributes +2 -0
  522. package/templates/knowledge-workspace/AGENTS.md +27 -0
  523. package/templates/knowledge-workspace/README.md +44 -0
  524. package/templates/knowledge-workspace/atelier.project.json +22 -0
  525. package/templates/knowledge-workspace/gitignore +6 -0
  526. package/templates/knowledge-workspace/knowledge-plan.json +111 -0
  527. package/templates/knowledge-workspace/knowledge-plan.schema.json +281 -0
  528. package/templates/knowledge-workspace/records/forecast.md +22 -0
  529. package/templates/knowledge-workspace/records/inspection.md +25 -0
  530. package/templates/knowledge-workspace/records/telescope.md +24 -0
  531. package/templates/knowledge-workspace/repo-access.v1.json +9 -0
@@ -0,0 +1,282 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { validateProjectConfigDoc } from '../../project/config.mjs'
4
+ import { MAX_EXPANSION_DEPTH, OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../../projection/obsidian/contracts.mjs'
5
+ import { readObsidianEnablement } from './enablement.mjs'
6
+ import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
7
+
8
+ // The views a project declares, written into its `atelier.project.json` for a
9
+ // person, so nobody edits JSON by hand: `view add`, and the view a project
10
+ // that never used Obsidian gets on its first open.
11
+ //
12
+ // The project file is committed source. Atelier changes it only when asked,
13
+ // shows the exact change as a diff, and never commits. It rewrites a file only
14
+ // when the file is in the form Atelier writes JSON in (two-space indentation,
15
+ // with LF or CRLF line ends, with or without a final one), so the change is
16
+ // the Obsidian member and nothing else; and only over the bytes it read. The
17
+ // workspace pointer is only ever written into an ignored `.atelier-local/`, so
18
+ // a project where that folder is not ignored gets the ignore line in the same
19
+ // change.
20
+
21
+ export const EXT_SETTINGS_SCHEMA = 'atelier-obsidian-ext-settings/v1'
22
+ // Every note this machine may show, in one view that is the default. Its name reads well in a command
23
+ // (`--scope everything`) and in a vault's name (`harbor-notes (everything)`).
24
+ export const DEFAULT_VIEW = Object.freeze({ scopeId: 'everything', mode: 'full', selector: Object.freeze({ all: true }) })
25
+ const LOCAL_STATE_LINE = '.atelier-local/'
26
+ const SCOPE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
27
+ const MAX_EXPANSION_NODES = 100000
28
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
29
+
30
+ const linesOf = (text) => { const lines = text.split(/\r?\n/); if (lines.at(-1) === '') lines.pop(); return lines }
31
+ // A hunk's range: the first line and the count, or the line before an empty range.
32
+ const range = (start, count) => (count === 0 ? `${start},0` : `${start + 1},${count}`)
33
+
34
+ // The lines that differ between two texts, as one unified hunk with up to three lines of context on each side. Enough
35
+ // for a change that inserts or replaces one run of lines, which is every change made here.
36
+ export function unifiedDiff(before, after, { label }) {
37
+ if (before === after) return ''
38
+ const [left, right] = [linesOf(before), linesOf(after)]
39
+ let prefix = 0
40
+ while (prefix < left.length && prefix < right.length && left[prefix] === right[prefix]) prefix += 1
41
+ let suffix = 0
42
+ while (suffix < left.length - prefix && suffix < right.length - prefix && left[left.length - 1 - suffix] === right[right.length - 1 - suffix]) suffix += 1
43
+ const context = 3
44
+ const start = Math.max(0, prefix - context)
45
+ const leftEnd = Math.min(left.length, left.length - suffix + context)
46
+ const rightEnd = Math.min(right.length, right.length - suffix + context)
47
+ const lines = [
48
+ `--- ${label}`, `+++ ${label}`, `@@ -${range(start, leftEnd - start)} +${range(start, rightEnd - start)} @@`,
49
+ ...left.slice(start, prefix).map((line) => ` ${line}`),
50
+ ...left.slice(prefix, left.length - suffix).map((line) => `-${line}`),
51
+ ...right.slice(prefix, right.length - suffix).map((line) => `+${line}`),
52
+ ...right.slice(right.length - suffix, rightEnd).map((line) => ` ${line}`),
53
+ ]
54
+ return `${lines.join('\n')}\n`
55
+ }
56
+
57
+ const jsonText = (document, { eol, final }) => {
58
+ const text = JSON.stringify(document, null, 2)
59
+ return `${eol === '\n' ? text : text.replaceAll('\n', '\r\n')}${final ? eol : ''}`
60
+ }
61
+
62
+ // The form `text` is `document` written in, or null when it is in none Atelier writes.
63
+ function formOf(text, document) {
64
+ for (const eol of ['\n', '\r\n']) for (const final of [true, false]) if (text === jsonText(document, { eol, final })) return { eol, final }
65
+ return null
66
+ }
67
+
68
+ // A file Atelier may replace: a regular file, not a link to one elsewhere. `bytes` is null when there is none.
69
+ function readRegularFile(file, { code, what }) {
70
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false })
71
+ if (stat === undefined) return { bytes: null, mode: null }
72
+ if (!stat.isFile()) refuse('project-file-not-regular', `${what} is not a regular file, so Atelier does not rewrite it; make the change by hand`, { file: path.basename(file) })
73
+ try { return { bytes: fs.readFileSync(file), mode: stat.mode & 0o777 } } catch (error) { return refuse(code, `${what} cannot be read`, { cause: error.code ?? 'unreadable' }) }
74
+ }
75
+
76
+ // The text of a file's bytes, only when they are UTF-8 that decodes and encodes back to exactly them: a file in another
77
+ // encoding is never rewritten, since a byte that is not UTF-8 would be replaced without the diff showing it.
78
+ function textOf(bytes, refusal) {
79
+ const text = bytes.toString('utf8')
80
+ if (!Buffer.from(text, 'utf8').equals(bytes)) refusal()
81
+ return text
82
+ }
83
+
84
+ // The settings as enablement will read them, or a typed refusal naming what is wrong.
85
+ function checkedSettings(document, settings) {
86
+ try {
87
+ readObsidianEnablement({ config: { ...document, ext: { ...(isPlainObject(document.ext) ? document.ext : {}), [OBSIDIAN_EXT_KEY]: settings } } })
88
+ } catch (error) {
89
+ if (error instanceof ObsidianMaintenanceRefusal || error instanceof ObsidianContractRefusal) refuse('invalid-view', 'the views would not satisfy the Obsidian settings contract; nothing was written', { errors: error.detail?.errors ?? [] })
90
+ throw error
91
+ }
92
+ return settings
93
+ }
94
+
95
+ // The project file with `settings` as its Obsidian member: { file, bytes, after, mode, diff }, `bytes` being the ones
96
+ // it was made from. `document` is the file as read. Refuses a file that is not in a form Atelier writes, since
97
+ // rewriting it would change more than the member, and names the member to add by hand.
98
+ function planMember({ file, bytes, before, mode, document, settings }) {
99
+ const form = isPlainObject(document) && (document.ext === undefined || isPlainObject(document.ext)) ? formOf(before, document) : null
100
+ if (form === null) {
101
+ refuse('project-config-format-unknown', `${path.basename(file)} is not in the form Atelier writes (two-space JSON), so rewriting it would change more than the Obsidian member; nothing was written. Add the member by hand, under "ext"`, { member: { [OBSIDIAN_EXT_KEY]: settings } })
102
+ }
103
+ const next = { ...document, ext: { ...(document.ext ?? {}), [OBSIDIAN_EXT_KEY]: settings } }
104
+ const errors = validateProjectConfigDoc(next)
105
+ if (errors.length > 0) refuse('invalid-view', 'the project configuration would not be valid; nothing was written', { errors })
106
+ const after = jsonText(next, form)
107
+ return { file, what: path.basename(file), bytes, after, mode, diff: unifiedDiff(before, after, { label: path.basename(file) }) }
108
+ }
109
+
110
+ // The ignore line a project needs before the workspace pointer can be written:
111
+ // { needed, file, bytes, after, mode, diff }.
112
+ function planIgnore(project) {
113
+ if (project?.localState?.ignored === true) return { needed: false }
114
+ const file = path.join(project.configDir, '.gitignore')
115
+ const { bytes, mode } = readRegularFile(file, { code: 'gitignore-unreadable', what: 'the project\'s .gitignore' })
116
+ const before = bytes === null ? '' : textOf(bytes, () => refuse('gitignore-not-utf8', `the project's .gitignore is not UTF-8, so Atelier does not rewrite it; add the line ${LOCAL_STATE_LINE} to it by hand; nothing was written`, { line: LOCAL_STATE_LINE }))
117
+ const eol = before.includes('\r\n') ? '\r\n' : '\n'
118
+ const after = `${before}${before === '' || before.endsWith('\n') ? '' : eol}${LOCAL_STATE_LINE}${eol}`
119
+ return { needed: true, file, what: 'the project\'s .gitignore', bytes, after, mode, diff: unifiedDiff(before, after, { label: '.gitignore' }) }
120
+ }
121
+
122
+ // The project file as it is now: its bytes and their text, its mode, and its document (null when it is not JSON).
123
+ function readProjectFile(project) {
124
+ const file = project?.configPath
125
+ if (typeof file !== 'string') refuse('project-config-unreadable', 'this project has no configuration file to write the view into')
126
+ const { bytes, mode } = readRegularFile(file, { code: 'project-config-unreadable', what: path.basename(file) })
127
+ if (bytes === null) refuse('project-config-unreadable', `${path.basename(file)} is not there`)
128
+ // Not UTF-8: in no form Atelier writes. The member is named for the person when the rest of the file can be read.
129
+ let before
130
+ try { before = textOf(bytes, () => { throw new SyntaxError('not UTF-8') }) } catch {
131
+ return { file, bytes, before: null, mode, document: null }
132
+ }
133
+ let document = null
134
+ try { document = JSON.parse(before) } catch { document = null }
135
+ return { file, bytes, before, mode, document }
136
+ }
137
+
138
+ // A folder as the repository-relative prefix of the notes a view selects, `./` and a trailing `/` dropped; null for
139
+ // the whole repository.
140
+ function folderPrefix(text) {
141
+ const cleaned = String(text).replace(/^(?:\.\/)+/, '').replace(/\/+$/, '')
142
+ if (cleaned === '' || cleaned === '.') return null
143
+ if (cleaned.startsWith('/') || cleaned.startsWith('~') || /^[A-Za-z]:/.test(cleaned) || cleaned.includes('\\') || cleaned.includes('\u0000') || cleaned.split('/').includes('..')) {
144
+ refuse('invalid-view', 'a folder is written relative to its repository\'s root, with "/", and without ".." (docs/notes, say)', { folder: String(text).slice(0, 200) })
145
+ }
146
+ return cleaned
147
+ }
148
+
149
+ // `DEPTH:MAX`: follow outgoing links up to DEPTH steps from the selected notes, adding at most MAX notes in all.
150
+ function expansionOf(text) {
151
+ const match = /^([0-9]{1,2}):([0-9]{1,6})$/.exec(String(text))
152
+ const depth = match ? Number(match[1]) : NaN
153
+ const maxNodes = match ? Number(match[2]) : NaN
154
+ if (!(depth >= 1 && depth <= MAX_EXPANSION_DEPTH && maxNodes >= 1 && maxNodes <= MAX_EXPANSION_NODES)) {
155
+ refuse('invalid-view', `--expand is DEPTH:MAX, a depth from 1 to ${MAX_EXPANSION_DEPTH} and at most ${MAX_EXPANSION_NODES} notes in all (2:200, say)`)
156
+ }
157
+ return { depth, maxNodes, direction: 'outgoing', order: 'canonical-id' }
158
+ }
159
+
160
+ // The view `view add` asks for: every note (`all`, a full view), or a scoped view of the notes under folders of one
161
+ // repository or with a tag, optionally expanded along outgoing links. `repositories` are the names of the project's
162
+ // managed repositories; `repo` may be left out when there is one. (The contract's `type` selector matches a note's
163
+ // file type, not its `kg.type`, so it is not offered here.)
164
+ export function viewFromRequest({ scopeId, all = false, folders = [], repo, tag, expand, repositories = [] }) {
165
+ if (typeof scopeId !== 'string' || !SCOPE_ID.test(scopeId)) refuse('usage', 'a view is named by an identifier: a letter or digit, then letters, digits, ".", "_", ":" or "-", 128 at most')
166
+ const kinds = [all === true, folders.length > 0, tag !== undefined].filter(Boolean).length
167
+ if (kinds !== 1) refuse('usage', 'a view selects its notes with exactly one of --all, --folder PATH (as often as needed) or --tag T')
168
+ if (repo !== undefined && folders.length === 0) refuse('usage', '--repo names the repository of --folder')
169
+ if (all === true && expand !== undefined) refuse('usage', 'a view of every note has nothing to expand')
170
+ const expansion = expand === undefined ? undefined : expansionOf(expand)
171
+ let selector
172
+ if (all === true) selector = { all: true }
173
+ else if (tag !== undefined) selector = { tag }
174
+ else {
175
+ if (repo === undefined && repositories.length !== 1) refuse('view-repository-ambiguous', 'this project enrols more than one repository; name the one the folders are in with --repo', { repositories })
176
+ const repoId = repo ?? repositories[0]
177
+ if (!repositories.includes(repoId)) refuse('unknown-repo', 'the project enrols no repository of that name', { repositories })
178
+ const prefixes = [...new Set(folders.map(folderPrefix))]
179
+ const members = prefixes.includes(null) ? [{ repo: repoId }] : prefixes.map((pathPrefix) => ({ repo: repoId, pathPrefix }))
180
+ selector = members.length === 1 ? members[0] : { union: members }
181
+ }
182
+ return { scopeId, mode: all === true ? 'full' : 'scoped', selector, ...(expansion === undefined ? {} : { expansion }) }
183
+ }
184
+
185
+ // One more view: the project's Obsidian member with `scope` added (the member made, enabled, when there is none), and
186
+ // the default when asked or when it is the first view. Refuses a view whose name is taken. Writes nothing:
187
+ // { settings, member, ignore, diff }, for writeViewPlan.
188
+ export function planViewAdd(project, { scope, makeDefault = false }) {
189
+ const source = readProjectFile(project)
190
+ const document = isPlainObject(source.document) ? source.document : project.config
191
+ const present = isPlainObject(document?.ext) ? document.ext[OBSIDIAN_EXT_KEY] : undefined
192
+ const scopes = Array.isArray(present?.scopes) ? present.scopes : []
193
+ if (scopes.some((item) => item?.scopeId === scope.scopeId)) refuse('view-exists', 'the project declares a view of this name already', { scopeId: scope.scopeId })
194
+ // A member of its own keeps its keys in their order; a new one is written as the documentation shows it.
195
+ const next = present === undefined
196
+ ? { schema: EXT_SETTINGS_SCHEMA, enabled: true, defaultScopeId: scope.scopeId, scopes: [scope] }
197
+ : { ...present, scopes: [...scopes, scope], ...(makeDefault || scopes.length === 0 ? { defaultScopeId: scope.scopeId } : {}) }
198
+ const settings = checkedSettings(document ?? {}, next)
199
+ const member = planMember({ ...source, settings })
200
+ const ignore = planIgnore(project)
201
+ return { settings, member, ignore, diff: `${member.diff}${ignore.needed ? ignore.diff : ''}` }
202
+ }
203
+
204
+ // A planned file still holds exactly the bytes its plan was made from (a file that was not there is still not there).
205
+ function assertUnchanged({ file, what, bytes: planned }) {
206
+ const { bytes } = readRegularFile(file, { code: 'project-file-unreadable', what })
207
+ if (planned === null ? bytes !== null : bytes === null || !bytes.equals(planned)) {
208
+ refuse('project-config-changed', `${path.basename(file)} changed while the change was prepared; nothing was written`, { file: path.basename(file) })
209
+ }
210
+ }
211
+
212
+ // The temporary file a planned file's new text is written to, beside it: `.<name>.<pid>.<ms>.atelier.tmp`.
213
+ const temporaryOf = (file) => path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.atelier.tmp`)
214
+ const isAlive = (pid) => { try { process.kill(pid, 0); return true } catch (error) { return error.code === 'EPERM' } }
215
+ // Temporary files an earlier run left beside a planned file when it stopped between writing and renaming (a crash):
216
+ // removed, unless the run that made them still runs, so none is left to be committed.
217
+ function removeLeftovers(file) {
218
+ const pattern = new RegExp(`^\\.${path.basename(file).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.([0-9]+)\\.[0-9]+\\.atelier\\.tmp$`)
219
+ let names = []
220
+ try { names = fs.readdirSync(path.dirname(file)) } catch { return }
221
+ for (const name of names) {
222
+ const match = pattern.exec(name)
223
+ if (match !== null && (Number(match[1]) === process.pid || !isAlive(Number(match[1])))) fs.rmSync(path.join(path.dirname(file), name), { force: true })
224
+ }
225
+ }
226
+
227
+ // A planned file's new text in its temporary file, keeping its mode, and on the disk before any file is replaced.
228
+ function writeTemporary({ file, after, mode }) {
229
+ const temporary = temporaryOf(file)
230
+ const descriptor = fs.openSync(temporary, 'wx', mode ?? 0o666)
231
+ try {
232
+ fs.writeFileSync(descriptor, after)
233
+ if (mode !== null) fs.fchmodSync(descriptor, mode)
234
+ fs.fsyncSync(descriptor)
235
+ } catch (error) {
236
+ fs.closeSync(descriptor)
237
+ fs.rmSync(temporary, { force: true })
238
+ throw error
239
+ } finally {
240
+ try { fs.closeSync(descriptor) } catch { /* closed above */ }
241
+ }
242
+ return temporary
243
+ }
244
+
245
+ // The folder of a replaced file, on the disk, so the renames survive a crash. A system that cannot sync a folder
246
+ // (Windows) keeps the renames all the same.
247
+ function syncFolder(folder) {
248
+ let descriptor
249
+ try { descriptor = fs.openSync(folder, 'r'); fs.fsyncSync(descriptor) } catch { /* not supported here */ } finally { if (descriptor !== undefined) fs.closeSync(descriptor) }
250
+ }
251
+
252
+ // Writes a plan of planViewAdd when every file it changes still holds the bytes it was made from: every new text is
253
+ // written and synced to its temporary file first, then the files are replaced, the ignore line first, so a project
254
+ // file that names a view never waits on it. A failure before the first replacement changes nothing
255
+ // (`project-files-not-written`); one after it says which files changed (`project-files-partly-written`). No temporary
256
+ // file is left. Commits nothing. Answers the files written. `rename` is injectable for tests.
257
+ export function writeViewPlan(plan, { rename = fs.renameSync } = {}) {
258
+ const targets = [...(plan.ignore.needed ? [plan.ignore] : []), plan.member]
259
+ for (const target of targets) removeLeftovers(target.file)
260
+ for (const target of targets) assertUnchanged(target)
261
+ const prepared = []
262
+ const discard = () => { for (const { temporary } of prepared) fs.rmSync(temporary, { force: true }) }
263
+ try {
264
+ for (const target of targets) prepared.push({ target, temporary: writeTemporary(target) })
265
+ } catch (error) {
266
+ discard()
267
+ refuse('project-files-not-written', 'the change could not be written; nothing was changed', { cause: error.code ?? String(error.message) })
268
+ }
269
+ const written = []
270
+ for (const { target, temporary } of prepared) {
271
+ try { rename(temporary, target.file) } catch (error) {
272
+ discard()
273
+ const names = (list) => list.map((file) => path.basename(file))
274
+ const rest = targets.map((item) => item.file).filter((file) => !written.includes(file))
275
+ if (written.length === 0) refuse('project-files-not-written', 'the change could not be written; nothing was changed', { cause: error.code ?? String(error.message) })
276
+ refuse('project-files-partly-written', `${names(written).join(', ')} changed, and ${names(rest).join(', ')} could not be; finish the change by hand, or undo it`, { written: names(written), notWritten: names(rest), cause: error.code ?? String(error.message) })
277
+ }
278
+ written.push(target.file)
279
+ }
280
+ for (const folder of new Set(written.map((file) => path.dirname(file)))) syncFolder(folder)
281
+ return { written }
282
+ }
@@ -0,0 +1,48 @@
1
+ import readline from 'node:readline'
2
+
3
+ // Questions for a person at a terminal, one line each, over the streams the
4
+ // caller gives, so a test types the answers. A question never waits forever:
5
+ // no line within the timeout (10 minutes), or the end of the input, is no
6
+ // answer, so an agent that was handed a terminal still never hangs. Whether a
7
+ // person is there at all is decided before anything is asked
8
+ // (src/commands/obsidian.mjs `isInteractive`).
9
+
10
+ export const ANSWER_TIMEOUT_MS = 10 * 60 * 1000
11
+ const MAX_ANSWER_LENGTH = 4096
12
+ const clean = (line) => line.slice(0, MAX_ANSWER_LENGTH).trim()
13
+
14
+ // `ask(question)` writes the question and answers the next line typed, trimmed, or null when none came. A line typed
15
+ // before its question is kept for it. `close()` stops reading.
16
+ export function createQuestioner({ input, output, timeoutMs = ANSWER_TIMEOUT_MS }) {
17
+ const typed = []
18
+ const waiting = []
19
+ let ended = false
20
+ const lines = readline.createInterface({ input, terminal: false, crlfDelay: Infinity })
21
+ lines.on('line', (line) => { const next = waiting.shift(); if (next === undefined) typed.push(line); else next(line) })
22
+ lines.on('close', () => { ended = true; for (const next of waiting.splice(0)) next(null) })
23
+ return {
24
+ ask(question) {
25
+ output.write(question)
26
+ if (typed.length > 0) return Promise.resolve(clean(typed.shift()))
27
+ if (ended) return Promise.resolve(null)
28
+ return new Promise((resolve) => {
29
+ const answer = (line) => { clearTimeout(timer); resolve(line === null ? null : clean(line)) }
30
+ const timer = setTimeout(() => { waiting.splice(waiting.indexOf(answer), 1); output.write('\n'); resolve(null) }, timeoutMs)
31
+ waiting.push(answer)
32
+ })
33
+ },
34
+ close() { lines.close() },
35
+ }
36
+ }
37
+
38
+ // A go-ahead: Enter, y or yes is yes; n or no is no; anything else is asked again, three times in all. null: no answer.
39
+ export async function askGoAhead(questioner, question) {
40
+ for (let attempt = 0; attempt < 3; attempt += 1) {
41
+ const answer = await questioner.ask(question)
42
+ if (answer === null) return null
43
+ const word = answer.toLowerCase()
44
+ if (word === '' || word === 'y' || word === 'yes') return true
45
+ if (word === 'n' || word === 'no') return false
46
+ }
47
+ return null
48
+ }
@@ -0,0 +1,56 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { packageRootOfEntry, readReleaseIdentity, walkRelease } from './service-record.mjs'
4
+
5
+ // Whether the package a service runs from changed on disk since it started.
6
+ //
7
+ // A service started by a login item runs until the person logs out. When the
8
+ // package it was started from is upgraded meanwhile (`npm i -D
9
+ // @mnstry/atelier@<new>` in the project), it would go on running the release
10
+ // it loaded, and load any module it had not needed yet from the new one. So,
11
+ // under `--startup`, the service asks after every tick whether its package
12
+ // changed, finishes that tick and exits with a code its service manager
13
+ // restarts (service.mjs, service-main.mjs): the next process runs the new
14
+ // release.
15
+ //
16
+ // The package is read through the path the service was started by, not its
17
+ // real path: a linked install (`file:`, `npm link`) that is pointed elsewhere
18
+ // is then seen. What is compared is the release identity (readReleaseIdentity
19
+ // in service-record.mjs, the one a service records at its start): the package
20
+ // version and a digest of every file under `src/`, `contracts/` and
21
+ // `plugins/`, by relative path and content. It is cheap to ask: the files'
22
+ // status (inode, size, change and modification time) is compared first, and
23
+ // the digest is computed only when that differs. A package that cannot be read right now (an install in
24
+ // progress) is not a change yet; it is asked again after the next tick.
25
+
26
+ export { packageRootOfEntry, readReleaseIdentity }
27
+
28
+ // The status of every file the identity covers and of package.json, as one comparable text. A package replaced by an
29
+ // install has new inodes and change times even where the archive kept the modification times.
30
+ export function releaseStatSignature({ root }) {
31
+ const lines = []
32
+ const stat = (relative, absolute) => { const found = fs.lstatSync(absolute); lines.push(`${relative}\0${found.dev}\0${found.ino}\0${found.size}\0${found.mtimeMs}\0${found.ctimeMs}`) }
33
+ walkRelease(root, stat)
34
+ stat('package.json', path.join(root, 'package.json'))
35
+ return lines.join('\n')
36
+ }
37
+
38
+ // { root, started, changed() }. `started` is the identity the service runs, read when the watch is made; `changed()`
39
+ // answers true once the package on disk is another release, and false while it is the same or cannot be read.
40
+ export function createReleaseWatch({ root, identityOf = readReleaseIdentity, signatureOf = releaseStatSignature } = {}) {
41
+ if (typeof root !== 'string' || !path.isAbsolute(root)) throw new TypeError('a release watch needs the absolute root of a package')
42
+ let signature = signatureOf({ root })
43
+ const started = identityOf({ root })
44
+ return {
45
+ root, started,
46
+ changed() {
47
+ let now
48
+ try { now = signatureOf({ root }) } catch { return false }
49
+ if (now === signature) return false
50
+ let identity
51
+ try { identity = identityOf({ root }) } catch { return false }
52
+ if (identity.version === started.version && identity.digest === started.digest) { signature = now; return false }
53
+ return true
54
+ },
55
+ }
56
+ }
@@ -3,20 +3,34 @@ import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { firstString, parseArgs, resolveProjectConfig } from '../../project/config.mjs'
6
- import { runMaintenanceService } from './service.mjs'
6
+ import { workspaceStateRoot } from './machine-settings.mjs'
7
+ import { loadSelectedProjectOffThread, locatePersonalHome } from './personal-selection.mjs'
8
+ import { RELEASE_CHANGED, resolveServiceWorkspace, runMaintenanceService } from './service.mjs'
7
9
 
8
10
  // The process of the maintenance service. `start` runs it as a child; a unit
9
11
  // installed at the operating-system level runs it with `--startup`.
10
12
  //
11
- // --project=<absolute atelier.project.json> required
13
+ // --project=<absolute atelier.project.json> required, unless a personal home is named instead
14
+ // --personal-home=<absolute directory> instead of --project: the private home of a personal workspace. The
15
+ // service follows the generation its person confirmed there, composed off
16
+ // the event loop, and starts whether or not one is confirmed: it locates
17
+ // its workspace from the home alone (personal-selection.mjs)
12
18
  // --data-root=<absolute directory> optional; otherwise the pointer, the overlay or the platform default
19
+ // --workspace-id=<identifier> named by a login item: under `--startup`, where a refusal is recorded
20
+ // when the project no longer leads to a workspace
13
21
  // --runtime-id=<identifier> given by `start`, so it can recognise the child it created
14
22
  // --interval-ms=<milliseconds> optional
15
- // --startup run by an operating-system unit; needs a consent that covers startup
23
+ // --startup run by a login item; needs a consent that covers startup
16
24
  // --adapter=obsidian-cli the editor adapter; there is no default
17
25
  //
18
26
  // Without `--adapter` the process refuses before it listens or ticks: reaching
19
27
  // a running app is a decision of whoever starts the service, never a default.
28
+ //
29
+ // Exit codes: 0 after a clean stop, and under `--startup` after a refusal too;
30
+ // 2 (EXIT_REFUSED) after a refusal otherwise; 75 (EXIT_RELEASE_CHANGED) under
31
+ // `--startup` once the package changed on disk; 1 after an error without a
32
+ // code. A refusal is any error that carries a string code, Node's system
33
+ // errors (EACCES, EADDRNOTAVAIL, EPERM) included.
20
34
 
21
35
  // Reaching a real app happens here and nowhere else in the service: the
22
36
  // production probe and the CLI transport are imported only once `--adapter`
@@ -33,52 +47,162 @@ import { runMaintenanceService } from './service.mjs'
33
47
  // only read here.
34
48
  const ADAPTERS = Object.freeze({
35
49
  'obsidian-cli': async () => {
36
- const [{ createObsidianCliAdapter, defaultObsidianProcessProbe }, { createProductionAppProbe }, { createQualifiedAdapterFactory }, { obsidianUserDataDir, readObsidianSettings }, { appStateSignature }] = await Promise.all([
50
+ const [{ createObsidianCliAdapter, defaultObsidianProcessProbe }, { createProductionAppProbe }, { createQualifiedAdapterFactory }, { obsidianSandboxedBuild, obsidianUserDataDir, readObsidianSettings }, { appStateSignature }, { readAppVaultListForAllocation }, { createProductionUnheldEvidence }] = await Promise.all([
37
51
  import('../../projection/obsidian/publication/transport.mjs'), import('./app-production-seams.mjs'), import('./app-capability.mjs'),
38
- import('../../projection/obsidian/publication/vault-list.mjs'), import('./app-registration.mjs'),
52
+ import('../../projection/obsidian/publication/vault-list.mjs'), import('./app-registration.mjs'), import('./vault-location.mjs'), import('./unheld-evidence.mjs'),
39
53
  ])
40
54
  const adapterFactory = createQualifiedAdapterFactory({ appProbe: createProductionAppProbe(), createAdapter: ({ qualification }) => createObsidianCliAdapter({ qualification }) })
41
55
  const userDataDir = obsidianUserDataDir()
42
56
  const observeApp = () => appStateSignature({ processes: defaultObsidianProcessProbe(), settings: userDataDir === null ? null : readObsidianSettings({ userDataDir }) })
57
+ // The app's own vault list, read from its file and never written, so a new vault's folder is never allocated inside
58
+ // a vault it lists nor under a name one of its vaults has; a list that cannot be read allocates nothing.
59
+ const readAppVaultList = () => readAppVaultListForAllocation({
60
+ read: () => (userDataDir === null ? { ok: false, code: 'obsidian-settings-location-unknown' } : readObsidianSettings({ userDataDir })),
61
+ sandboxed: obsidianSandboxedBuild() !== null,
62
+ })
43
63
  return {
44
- adapterFactory, engineOptions: { observeApp },
64
+ // The same file tells the publisher whether a view's first publication may go into a vault no Obsidian lists yet.
65
+ adapterFactory, engineOptions: { observeApp, readAppVaultList, readUnheldEvidence: createProductionUnheldEvidence() },
45
66
  appStatus: () => { const known = adapterFactory.lastQualification(); return known === null ? null : { outcome: known.outcome, reason: known.reason, version: known.version, floor: known.floor } },
46
67
  }
47
68
  },
48
69
  })
49
70
 
71
+ // What the tests read: which reader each adapter hands its engine. Calling one builds the production seams, and
72
+ // reaches no app and no file until the engine uses them.
73
+ export { ADAPTERS as SERVICE_ADAPTERS }
74
+
75
+ import { createReleaseWatch, packageRootOfEntry } from './release-watch.mjs'
50
76
  import { SERVICE_ENTRY_PATH } from './service-entry-path.mjs'
77
+ import { LAST_STARTUP_SCHEMA, openServiceLog, writeLastStartup } from './service-record.mjs'
51
78
 
52
79
  export { SERVICE_ENTRY_PATH }
53
80
  export const EXIT_REFUSED = 2
81
+ // A service a login item started exits with this code when the package it runs from changed on disk: its service
82
+ // manager restarts it (a non-zero exit), on the release now installed. EX_TEMPFAIL.
83
+ export const EXIT_RELEASE_CHANGED = 75
84
+
85
+ const readOr = (read, otherwise) => { try { return read() } catch { return otherwise } }
86
+ const printed = (entry) => { try { process.stdout.write(`${JSON.stringify(entry)}\n`) } catch { /* a closed log never ends the service */ } }
87
+
88
+ // The first answer of the loader, or of the locator of a personal home, asked for once and handed to both who need it
89
+ // at a start under a login item: the lookup of the workspace below, and the service's own start, which takes it as the
90
+ // answer to its first call. One that answers a promise or another thenable is not waited for by either. A promise whose
91
+ // `constructor` reads as this realm's `Promise` is kept as it is; any other thenable (as a rule another realm's promise
92
+ // or a subclass's) is adopted here, once, as a native promise (so its `then` is called once in all). That promise is let go, handled
93
+ // without calling a `then` of its own: a refusal of it never goes unhandled and ends the process with an error where
94
+ // the service's typed refusal should end it cleanly. The service is handed that promise. Whatever is thrown,
95
+ // `undefined` included, and a `then` that cannot be read, is thrown again at the service's first call, so it ends the
96
+ // start as a failure of the loader would.
97
+ function firstAnswerOf(loadProject) {
98
+ let first
99
+ try {
100
+ const project = loadProject()
101
+ if (typeof project?.then === 'function') {
102
+ const promised = Promise.resolve(project)
103
+ Promise.prototype.then.call(promised, undefined, () => {})
104
+ first = { promised }
105
+ } else first = { project }
106
+ } catch (error) { first = { threw: true, error } }
107
+ let taken = false
108
+ return {
109
+ // The project, when the loader answered one at once; null when it threw or answered a promise.
110
+ project: () => (first.threw || first.promised ? null : first.project),
111
+ loadProject() {
112
+ if (taken) return loadProject()
113
+ taken = true
114
+ if (first.threw) throw first.error
115
+ return first.promised ?? first.project
116
+ },
117
+ }
118
+ }
119
+
120
+ // The workspace of a service a login item started, when it can be found: where it keeps its log and records how its
121
+ // start ended. When the project no longer leads to one (it moved, its pointer is gone, its loader answered no project
122
+ // at once), the workspace the unit names by its data root and identity, if that exists; it is never created here. Null
123
+ // otherwise; a refusal is then printed only, into the unit's own output file.
124
+ function startupWorkspace({ project, dataRoot, workspaceId, env = process.env, platform = process.platform }) {
125
+ try {
126
+ const workspace = project === null ? null : resolveServiceWorkspace({ project, dataRoot, env, platform })
127
+ if (workspace?.workspaceRoot) return workspace
128
+ } catch { /* the workspace the unit names, below */ }
129
+ if (typeof dataRoot !== 'string' || !path.isAbsolute(dataRoot) || typeof workspaceId !== 'string') return null
130
+ try {
131
+ const root = workspaceStateRoot(path.resolve(dataRoot), workspaceId)
132
+ return fs.lstatSync(root).isDirectory() ? { workspaceId, workspaceRoot: fs.realpathSync(root) } : null
133
+ } catch { return null }
134
+ }
54
135
 
55
- const log = (entry) => { try { process.stdout.write(`${JSON.stringify(entry)}\n`) } catch { /* a closed log never ends the service */ } }
136
+ // The service's own log, bounded as under `start`, which hands a child that same file. Under a login item the output
137
+ // goes to the unit's file instead, which nothing bounds; it keeps only what happens before this log is open.
138
+ function workspaceLog(workspaceRoot) {
139
+ let descriptor
140
+ try { descriptor = openServiceLog(workspaceRoot).descriptor } catch { return printed }
141
+ return (entry) => { try { fs.writeSync(descriptor, `${JSON.stringify(entry)}\n`) } catch { /* a closed log never ends the service */ } }
142
+ }
143
+
144
+ function recordStartup(workspace, { at, outcome, code }) {
145
+ if (workspace === null) return
146
+ try { writeLastStartup({ ...workspace, document: { schema: LAST_STARTUP_SCHEMA, workspaceId: workspace.workspaceId, at, outcome, code } }) } catch { /* reported by the log only */ }
147
+ }
56
148
 
57
149
  // The common body of a service process: run, end cleanly on a signal, exit 0
58
150
  // after a clean stop and EXIT_REFUSED when the service refused to run.
151
+ //
152
+ // Started by a login item (`startup`), it logs into its workspace's bounded
153
+ // service log, records how the start ended (`last-startup.json`), exits 0 on
154
+ // a refusal as well, so the service manager does not start it again every
155
+ // minute over something that does not change by itself, and exits
156
+ // EXIT_RELEASE_CHANGED after the tick that found another release on disk.
157
+ // Anything else that ends it (an error nobody typed, a signal) is a crash,
158
+ // and the manager restarts it after its throttle.
59
159
  export async function runServiceProcess(options) {
160
+ const startup = options.startup === true
161
+ // A located personal home is asked instead of the loader: the loader is then not asked before the service's engine.
162
+ const located = typeof options.locateProject === 'function'
163
+ const asked = located ? options.locateProject : options.loadProject
164
+ const first = startup && typeof asked === 'function' ? firstAnswerOf(asked) : null
165
+ const workspace = startup ? startupWorkspace({ ...options, project: first === null ? null : first.project() }) : null
166
+ const log = workspace === null ? printed : workspaceLog(workspace.workspaceRoot)
60
167
  try {
61
- const service = await runMaintenanceService({ log, ...options })
168
+ const service = await runMaintenanceService({ log, invokedAs: process.argv[1], ...options, ...(first === null ? {} : { [located ? 'locateProject' : 'loadProject']: first.loadProject }) })
169
+ if (startup) recordStartup(workspace, { at: new Date().toISOString(), outcome: 'started', code: null })
62
170
  for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { void service.shutdown(`signal-${signal}`) })
63
- await service.done
171
+ const { reason } = await service.done
64
172
  // Everything durable is on disk by now; nothing left over may keep a stopped service's PID alive.
65
- process.exit(0)
173
+ process.exit(reason === RELEASE_CHANGED ? EXIT_RELEASE_CHANGED : 0)
66
174
  } catch (error) {
67
- log({ at: new Date().toISOString(), event: 'refused', code: error?.code ?? 'untyped-error', message: String(error?.message ?? error) })
68
- process.exitCode = typeof error?.code === 'string' ? EXIT_REFUSED : 1
175
+ const at = new Date().toISOString()
176
+ // What was thrown is read without trusting it: a value with no prototype, or a getter that throws, is still logged.
177
+ const code = readOr(() => (typeof error?.code === 'string' ? error.code : null), null)
178
+ log({ at, event: 'refused', code: code ?? 'untyped-error', message: readOr(() => String(error?.message ?? error), 'unprintable') })
179
+ if (startup && code !== null) { recordStartup(workspace, { at, outcome: 'refused', code }); process.exitCode = 0; return }
180
+ process.exitCode = code !== null ? EXIT_REFUSED : 1
69
181
  }
70
182
  }
71
183
 
184
+ // The release a service a login item started runs, read through the path it was started by (`argv[1]`), so an
185
+ // upgrade, or a linked install pointed elsewhere, is seen. Null when that package cannot be read.
186
+ function watchRelease(invokedAs) {
187
+ const root = packageRootOfEntry(invokedAs) ?? packageRootOfEntry(SERVICE_ENTRY_PATH)
188
+ if (root === null) return null
189
+ try { return createReleaseWatch({ root }) } catch { return null }
190
+ }
191
+
72
192
  export function serviceOptionsFromArgv(argv, { env = process.env } = {}) {
73
193
  const args = parseArgs(argv)
74
194
  const configPath = firstString(args.project)
75
- if (!configPath || !path.isAbsolute(configPath)) throw Object.assign(new Error('--project must be the absolute path of a project configuration'), { code: 'service-arguments-invalid' })
195
+ const personalHome = firstString(args['personal-home'])
196
+ if (personalHome !== null && (configPath !== null || !path.isAbsolute(personalHome))) throw Object.assign(new Error('--personal-home must be the absolute path of a private home, given instead of --project'), { code: 'service-arguments-invalid' })
197
+ if (personalHome === null && (!configPath || !path.isAbsolute(configPath))) throw Object.assign(new Error('--project must be the absolute path of a project configuration'), { code: 'service-arguments-invalid' })
76
198
  const dataRoot = firstString(args['data-root']) ?? undefined
199
+ const workspaceId = firstString(args['workspace-id']) ?? undefined
77
200
  const intervalMs = args['interval-ms'] === undefined ? undefined : Number(args['interval-ms'])
78
201
  if (intervalMs !== undefined && (!Number.isInteger(intervalMs) || intervalMs < 1)) throw Object.assign(new Error('--interval-ms must be a positive integer'), { code: 'service-arguments-invalid' })
79
202
  return {
80
- loadProject: () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
81
- ...(dataRoot === undefined ? {} : { dataRoot }), ...(intervalMs === undefined ? {} : { intervalMs }),
203
+ loadProject: personalHome !== null ? () => loadSelectedProjectOffThread({ personalHome }) : () => resolveProjectConfig({ argv: [`--project=${configPath}`], cwd: path.dirname(configPath), env, writeLocalState: false }),
204
+ ...(personalHome === null ? {} : { locateProject: () => locatePersonalHome({ personalHome }) }),
205
+ ...(dataRoot === undefined ? {} : { dataRoot }), ...(workspaceId === undefined ? {} : { workspaceId }), ...(intervalMs === undefined ? {} : { intervalMs }),
82
206
  ...(firstString(args['runtime-id']) ? { runtimeId: firstString(args['runtime-id']) } : {}),
83
207
  startup: args.startup === true, adapter: firstString(args.adapter),
84
208
  }
@@ -87,13 +211,18 @@ export function serviceOptionsFromArgv(argv, { env = process.env } = {}) {
87
211
  const invokedDirectly = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)) } catch { return false } })()
88
212
 
89
213
  if (invokedDirectly) {
214
+ const argv = process.argv.slice(2)
215
+ // As early as this entry can: its static imports have already loaded most of the modules, so a release that changes
216
+ // between this process's start and this reading is not noticed until the next change (docs/obsidian.md says so).
217
+ const releaseWatch = argv.includes('--startup') ? watchRelease(process.argv[1]) : null
90
218
  let options = null
91
219
  try {
92
- options = serviceOptionsFromArgv(process.argv.slice(2))
220
+ options = serviceOptionsFromArgv(argv)
93
221
  if (!options.adapter || !Object.hasOwn(ADAPTERS, options.adapter)) throw Object.assign(new Error('no editor adapter was selected; pass --adapter explicitly'), { code: 'service-adapter-not-selected' })
94
222
  } catch (error) {
95
- log({ at: new Date().toISOString(), event: 'refused', code: error.code ?? 'untyped-error', message: error.message })
96
- process.exitCode = EXIT_REFUSED
223
+ printed({ at: new Date().toISOString(), event: 'refused', code: error.code ?? 'untyped-error', message: error.message })
224
+ // Under a login item arguments that do not work never will: it ends cleanly, and is not started again every minute.
225
+ process.exitCode = argv.includes('--startup') ? 0 : EXIT_REFUSED
97
226
  options = null
98
227
  }
99
228
  if (options) {
@@ -101,6 +230,6 @@ if (invokedDirectly) {
101
230
  const [{ loadContributions }, { createObsidianRegistry }] = await Promise.all([import('./contributions.mjs'), import('./extension-points.mjs')])
102
231
  const registry = createObsidianRegistry({ contributions: await loadContributions() })
103
232
  const { engineOptions, ...seams } = await ADAPTERS[adapter]()
104
- await runServiceProcess({ ...rest, entryPath: SERVICE_ENTRY_PATH, ...seams, engineOptions: { ...engineOptions, extensions: registry.extensions } })
233
+ await runServiceProcess({ ...rest, entryPath: SERVICE_ENTRY_PATH, ...seams, engineOptions: { ...engineOptions, extensions: registry.extensions }, releaseWatch })
105
234
  }
106
235
  }