@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
@@ -1,33 +1,48 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs'
3
+ import os from 'node:os'
3
4
  import path from 'node:path'
5
+ import tty from 'node:tty'
4
6
  import { fileURLToPath } from 'node:url'
5
7
  import { AtelierDiagnosticError, resolveProjectConfig } from '../project/config.mjs'
8
+ import { readRegularTextNoFollow } from '../project/private-state.mjs'
6
9
  import { ObsidianContractRefusal } from '../projection/obsidian/contracts.mjs'
7
10
  import { applyPolicyDigest } from '../projection/obsidian/edits/policy.mjs'
8
- import { MINIMUM_APP_VERSION } from '../runtime/obsidian/app-capability.mjs'
11
+ import { MINIMUM_APP_VERSION, inspectApp } from '../runtime/obsidian/app-capability.mjs'
9
12
  import { loadContributions } from '../runtime/obsidian/contributions.mjs'
10
13
  import { isoTime } from '../runtime/obsidian/documents.mjs'
11
14
  import { readObsidianEnablement } from '../runtime/obsidian/enablement.mjs'
12
15
  import { ObsidianMaintenanceRefusal, refuse } from '../runtime/obsidian/errors.mjs'
13
16
  import { BUILT_IN_OPERATIONS, UNAVAILABLE_APPLY_OPERATION, createObsidianRegistry } from '../runtime/obsidian/extension-points.mjs'
14
- import { LIFECYCLE_PRIMITIVES, readServiceStatusDocument, requestServiceTick, serviceStatus, startService, stopService } from '../runtime/obsidian/lifecycle.mjs'
17
+ import { LIFECYCLE_PRIMITIVES, readServiceStatusDocument, requestServiceTick, runtimeRelease, serviceStatus, startService, stopService } from '../runtime/obsidian/lifecycle.mjs'
15
18
  import {
16
- authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, installApplyPolicy, protectedRoots, readInstalledApplyPolicy, readMachineSettings,
17
- revokeApplyPolicy, writeMachineSettings,
19
+ DECISIONS, ONLY_YOU_AUDIENCES, authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, installApplyPolicy, localPointerPath, protectedRoots,
20
+ readInstalledApplyPolicy, readMachineSettings, revokeApplyPolicy, withDecision, writeMachineSettings,
18
21
  } from '../runtime/obsidian/machine-settings.mjs'
22
+ import {
23
+ currentLoginItemPlan, installLoginItem, loginItemDataRoot, loginItemLabel, loginItemStarter, loginItemStatus, planLoginItem, projectNameOf, readLoginItemRecord, realNodePath,
24
+ removeLoginItem, resolveLoginItemEntry, temporaryRoots,
25
+ } from '../runtime/obsidian/login-item.mjs'
19
26
  import { APPLY_UNAVAILABLE, OPENING_OUTCOMES, OPENING_PRIMITIVES, REASON_NEXT, nextStep, openScopeForOracleTests, resolveScope, scopeReport } from '../runtime/obsidian/opening.mjs'
20
27
  import { currentPluginChoice, writePluginChoice } from '../runtime/obsidian/plugin-choice.mjs'
21
- import { pluginPresenceOf, turnPluginOnNext, withPluginReportedVersion } from '../runtime/obsidian/plugin-presence.mjs'
22
- import { serviceNameFor, servicePaths } from '../runtime/obsidian/service-record.mjs'
28
+ import { pluginPresenceOf, turnPluginOnNext, withPluginFilesPending, withPluginReportedVersion } from '../runtime/obsidian/plugin-presence.mjs'
29
+ import { planViewAdd, viewFromRequest, writeViewPlan } from '../runtime/obsidian/project-views.mjs'
30
+ import { askGoAhead, createQuestioner } from '../runtime/obsidian/questions.mjs'
31
+ import { readServiceSettings } from '../runtime/obsidian/service-record.mjs'
23
32
  import { resolveServiceWorkspace } from '../runtime/obsidian/service.mjs'
24
- import { buildStartupAdapter } from '../runtime/obsidian/startup-adapters.mjs'
33
+ import { STARTUP_PLATFORMS, buildStartupAdapter, startupSearchPath } from '../runtime/obsidian/startup-adapters.mjs'
34
+ import { OBSIDIAN_SETTINGS_FILE, obsidianSandboxedBuild, obsidianUserDataDir, readObsidianSettings } from '../projection/obsidian/publication/vault-list.mjs'
35
+ import { checkVaultParent, projectDisplayName, vaultFolderName } from '../runtime/obsidian/vault-location.mjs'
36
+ import { PublicationRefusal, allocationFile, hasCommittedGeneration, readVaultAllocation, vaultRootFor } from '../projection/obsidian/recovery/store.mjs'
25
37
 
26
38
  // `atelier obsidian <operation>`: status, views, audiences, apply policy, the
27
39
  // owned maintenance service, and opening a view.
28
40
  //
29
- // Noninteractive: every input is an argument, nothing is ever asked. With
30
- // `--json` exactly one JSON document is printed on stdout, for a refusal too.
41
+ // Every input is an argument. A person at a terminal (`isInteractive`) is
42
+ // asked before a change to a file they commit, and can answer with --yes
43
+ // beforehand; anyone else is never asked, and their command is their consent.
44
+ // With `--json` exactly one JSON document is printed on stdout, for a refusal
45
+ // too.
31
46
  //
32
47
  // Exit codes: 0 done (for `open`: the view is current and open); 1 an error
33
48
  // nobody typed; 2 a typed refusal or a usage error; 3 the operation ran and
@@ -37,8 +52,15 @@ import { buildStartupAdapter } from '../runtime/obsidian/startup-adapters.mjs'
37
52
  // Reaching the installed app or the operating system goes through seams
38
53
  // (`appProbe`, `launcher`, `registry`, `service`). A caller passes them. The production
39
54
  // seams are constructed only when this module runs as the real command-line
40
- // entry (`production: true`) AND `--adapter=obsidian-cli` was given, the same
41
- // explicit rule the service entry has; there is no default.
55
+ // entry (`production: true`) AND the adapter was selected: `--adapter=obsidian-cli`
56
+ // given now, or given once before and remembered in this workspace's machine
57
+ // settings (`selectAdapter`). A remembered adapter is never used under the test
58
+ // runner. There is no default.
59
+ //
60
+ // What a person decided is remembered per workspace on this machine
61
+ // (`atelier obsidian settings` shows it). The first start of the maintenance
62
+ // service records who allowed it: `--consent-actor ID`, or, for a person at a
63
+ // terminal, their account's name (`isInteractive`).
42
64
 
43
65
  export const COMMAND_SCHEMA = 'atelier-obsidian-command/v1'
44
66
  export const EXIT = Object.freeze({ ok: 0, error: 1, refused: 2, notSuccess: 3 })
@@ -47,26 +69,61 @@ const PRODUCTION_ADAPTER = 'obsidian-cli'
47
69
  const MAX_POLICY_BYTES = 64 * 1024
48
70
  const AUDIENCE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
49
71
 
50
- export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.project.json] [--data-root DIR] [--json]
72
+ export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.project.json | --personal-home DIR] [--data-root DIR] [--json] [--no-input]
51
73
 
52
74
  status Enablement, machine settings, service and per-view freshness. Read-only.
53
- scope list | scope show ID The views this project declares. Read-only.
54
- audience show | set A,B | clear The audiences this machine lets into a view (private; none by default).
75
+ settings What this machine remembers for this workspace, and how to change it. Read-only.
76
+ scope list | scope show ID The views this project declares. Read-only. (\`view list | show ID\` too.)
77
+ view add ID (--all | --folder PATH [--folder PATH ...] [--repo R] | --tag T) [--expand DEPTH:MAX] [--default]
78
+ [--allow-empty] [--yes]
79
+ Add a view to the project's configuration, which you commit: every note, the
80
+ notes under folders of one repository, or those with a tag, optionally with the
81
+ notes they link to (DEPTH steps, MAX notes in all). Shows the change and how many
82
+ notes the view would show, and asks at a terminal.
83
+ audience show | set me|A,B | clear The audiences this machine lets into a view (private; none by default).
84
+ \`me\` is only you: every audience but sensitive, which is added by name, and
85
+ the notes without a classification, which no other list shows.
86
+ location show | set DIR [--allow-synced-location]
87
+ Where this workspace's vaults live, as "<project> (<view>)"; each is allocated
88
+ there at its first publication. A vault published already stays where it is.
55
89
  mode show | set manual|automatic Whether queued edits wait for a person or are applied under the policy.
56
90
  policy show | install FILE | revoke The private apply policy. Automatic mode needs an installed, active one.
57
91
  policy digest FILE The digest FILE has to carry to be installed. Reads FILE; writes nothing.
58
- service start [--consent-actor ID] --adapter=${PRODUCTION_ADAPTER}
59
- service status | stop The owned maintenance service of this workspace.
60
- service unit --print --adapter=${PRODUCTION_ADAPTER}
61
- Print an operating-system startup unit. Writes and installs nothing.
62
- open [--scope ID] [--consent-actor ID] [--allow-stale] --adapter=${PRODUCTION_ADAPTER}
92
+ service start [--consent-actor ID] [--adapter=${PRODUCTION_ADAPTER}]
93
+ service status | stop The owned maintenance service of this workspace. start replaces one of an
94
+ earlier release still running after an upgrade.
95
+ service unit --print [--adapter=${PRODUCTION_ADAPTER}]
96
+ Print the login item (launchd agent, systemd user unit). Writes nothing.
97
+ service unit --install [--consent-actor ID] [--adapter=${PRODUCTION_ADAPTER}]
98
+ Install the login item: maintenance starts when you log in. macOS and Linux.
99
+ service unit --remove Remove the login item; the consent goes back to the service alone, and the
100
+ service is started for this session only.
101
+ open [--scope ID] [--consent-actor ID] [--allow-stale] [--restart-obsidian] [--wait-ms MS] [--adapter=${PRODUCTION_ADAPTER}]
63
102
  Start or reconnect maintenance, verify the view, add it to Obsidian and open it.
103
+ --restart-obsidian lets open quit a running Obsidian it cannot reach (its command
104
+ line off, or no vault open), add the vault and start it again; never implied.
105
+ --wait-ms bounds the wait for maintenance's tick.
106
+ uninstall Stop maintenance and remove the login item. Vaults, private state, the project
107
+ file and Obsidian's vault list are kept, and where each is is printed.
64
108
  plugin show [--scope ID] Whether Atelier's plugin is on in a view's vault, and whether it holds it open.
65
109
  plugin on [--scope ID] [--adapter=${PRODUCTION_ADAPTER}]
66
110
  Offer Atelier's plugin again in a vault where it was turned off; a running
67
111
  service publishes the view at once (with --adapter, one of an earlier release
68
112
  is replaced first).
69
113
 
114
+ --personal-home DIR, given instead of --project, follows the generation last confirmed in that private home of a personal
115
+ workspace (the personal-workspace module's selection); its views come from the overlay. Nothing here confirms one: while
116
+ none is confirmed and current nothing is published, and status, open and service start refuse with the reason. service
117
+ status and stop, unit --remove and uninstall find the service from the home alone. Who may see its vaults stays this
118
+ machine's audience setting, none by default.
119
+ Reaching the installed app needs --adapter=${PRODUCTION_ADAPTER} once for a workspace; it is remembered after that.
120
+ Obsidian's own settings file (obsidian.json) is written only while no Obsidian runs: open adds the view's vault to its
121
+ list, turns its command line on (cli: true), and creates the file for an Obsidian that never started; every write is
122
+ shown in open's answer, with the backup of the file as it was.
123
+ The first start of the maintenance service records who allows it: --consent-actor ID, or, for a person at a terminal,
124
+ their account's name; service unit --install records who allows it to start at login the same way (at a terminal, the
125
+ actor already recorded is kept). --no-input, --json, a CI environment or ATELIER_NONINTERACTIVE=1 mean no person is at a terminal.
126
+
70
127
  Contributed operations, registered by the modules shipped under src/runtime/obsidian/contributions/:
71
128
  apply list | show EDIT | run EDIT [--actor ID] | recover
72
129
  Pending edits, and the explicit apply of one of them to its source file.
@@ -83,7 +140,77 @@ Pending edits additionally report ${APPLY_UNAVAILABLE} while no apply operation
83
140
  Minimum Obsidian version: ${MINIMUM_APP_VERSION}.
84
141
  Exit codes: 0 done; 1 internal error; 2 refusal or usage; 3 ran, and the answer is not success.`
85
142
 
86
- const FLAGS = Object.freeze({ json: 'flag', 'allow-stale': 'flag', print: 'flag', help: 'flag', project: 'value', 'project-config': 'value', 'data-root': 'value', scope: 'value', 'consent-actor': 'value', actor: 'value', adapter: 'value', 'wait-ms': 'value' })
143
+ // Every option of the command. `atelier obsidian --help` names each one but `help` and `project-config` (the older
144
+ // spelling of `project`); a test holds it to that.
145
+ export const FLAGS = Object.freeze({
146
+ json: 'flag', 'allow-stale': 'flag', 'restart-obsidian': 'flag', print: 'flag', install: 'flag', remove: 'flag', help: 'flag', 'no-input': 'flag', 'allow-synced-location': 'flag', all: 'flag', default: 'flag', 'allow-empty': 'flag', yes: 'flag',
147
+ project: 'value', 'project-config': 'value', 'personal-home': 'value', 'data-root': 'value', scope: 'value', 'consent-actor': 'value', actor: 'value', adapter: 'value', 'wait-ms': 'value', repo: 'value', tag: 'value',
148
+ expand: 'value', folder: 'values',
149
+ })
150
+
151
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
152
+
153
+ // Which adapter this run may use, or a typed refusal. Pure. `flag` is --adapter as given; `remembered` is the adapter
154
+ // this workspace's machine settings remember (or null); `production` says this module runs as the real command-line
155
+ // entry. A remembered adapter is used only by the real entry, and never under the test runner: a test that reaches an
156
+ // app names the adapter, and runs where no app of the developer's can be reached.
157
+ export function selectAdapter({ flag, remembered = null, production = false, env = process.env } = {}) {
158
+ if (flag !== undefined) {
159
+ if (flag !== PRODUCTION_ADAPTER) refuse('app-adapter-not-selected', `no editor adapter is called ${String(flag).slice(0, 40)}; the only one is ${PRODUCTION_ADAPTER}`)
160
+ return { adapter: PRODUCTION_ADAPTER, source: 'flag' }
161
+ }
162
+ if (remembered === PRODUCTION_ADAPTER && production === true) {
163
+ if (env.NODE_TEST_CONTEXT !== undefined) refuse('remembered-adapter-under-test', 'a remembered adapter is never used under the test runner; pass the seams, or the adapter explicitly')
164
+ return { adapter: PRODUCTION_ADAPTER, source: 'remembered' }
165
+ }
166
+ return refuse('app-adapter-not-selected', 'no editor adapter was selected; reaching the installed app is never a default')
167
+ }
168
+
169
+ // Whether a person is at a terminal: nothing says otherwise, and both standard input and output are one. Pure.
170
+ // `terminal` is `{ stdin, stdout }`, each true when that stream is a terminal.
171
+ export function isInteractive({ flags = {}, env = process.env, terminal = { stdin: false, stdout: false } } = {}) {
172
+ if (flags.json === true || flags['no-input'] === true) return false
173
+ if (env.ATELIER_NONINTERACTIVE === '1') return false
174
+ if (typeof env.CI === 'string' && env.CI !== '' && env.CI !== 'false' && env.CI !== '0') return false
175
+ return terminal.stdin === true && terminal.stdout === true
176
+ }
177
+
178
+ // The account's name as an actor identifier, or null when it is not one.
179
+ export const accountActor = (username) => (typeof username === 'string' && IDENTIFIER.test(username) ? username : null)
180
+
181
+ // How each remembered answer is changed; null where no command changes it.
182
+ const DECISION_CHANGES = Object.freeze({
183
+ audience: '`atelier obsidian audience set me|A,B` decides who may see the vaults again',
184
+ location: '`atelier obsidian location set DIR` decides where the vaults of views not published yet live',
185
+ loginItem: '`atelier obsidian service unit --install` starts maintenance at login; `service unit --remove` stops that',
186
+ adapter: null,
187
+ consent: '`atelier obsidian service stop`, then `service start --consent-actor ID`, records another actor',
188
+ })
189
+ const SOURCE_WORDS = Object.freeze({ question: 'answered at a terminal', command: 'given on the command line', defaults: 'the defaults', v1: 'set before this release' })
190
+
191
+ // One remembered answer in words, with when and how it was given.
192
+ function decisionWords(name, decision, audienceAllow = []) {
193
+ if (decision === null) return name === 'audience' && audienceAllow.length === 0 ? 'not decided; no audience is allowed, so every view is empty' : 'not decided'
194
+ const what = name === 'audience'
195
+ ? `${decision.choice === 'only-you' ? `only you (${audienceAllow.join(', ')})` : audienceAllow.length === 0 ? 'no audience: every view is empty' : audienceAllow.join(', ')}; notes without a classification ${decision.unclassified}`
196
+ : name === 'location' ? decision.parent : decision.choice
197
+ return `${what} (${SOURCE_WORDS[decision.via]}, ${decision.decidedAt}${decision.decidedBy === null ? '' : `, by ${decision.decidedBy}`})`
198
+ }
199
+
200
+ function decisionSummary(decisions) {
201
+ const words = {
202
+ audience: (decision) => `who may see ${decision.choice === 'only-you' ? 'only you' : 'a list of audiences'}`,
203
+ location: (decision) => `vaults in ${decision.parent}`,
204
+ loginItem: (decision) => `start at login ${decision.choice}`,
205
+ adapter: (decision) => `app through ${decision.choice}`,
206
+ }
207
+ const decided = DECISIONS.filter((name) => decisions[name] !== null)
208
+ return decided.length === 0 ? 'nothing yet' : decided.map((name) => words[name](decisions[name])).join('; ')
209
+ }
210
+
211
+ function accountName() {
212
+ try { return os.userInfo().username } catch { return null }
213
+ }
87
214
 
88
215
  function parse(argv) {
89
216
  const positionals = []
@@ -97,24 +224,120 @@ function parse(argv) {
97
224
  if (FLAGS[name] === 'flag') { if (inline !== undefined) refuse('usage', `--${name} takes no value`); flags[name] = true; continue }
98
225
  const value = inline ?? argv[index += 1]
99
226
  if (value === undefined || value === '' || (inline === undefined && value.startsWith('--'))) refuse('usage', `--${name} needs a value`)
100
- flags[name] = value
227
+ // An option that takes several values is given once for each.
228
+ flags[name] = FLAGS[name] === 'values' ? [...(flags[name] ?? []), value] : value
101
229
  }
102
230
  return { positionals, flags }
103
231
  }
104
232
 
105
- const isTyped = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal
233
+ // What `open` did to the person's Obsidian, in words: a restart they asked for, and every write to its settings file.
234
+ const STATE_WORDS = Object.freeze({ 'cli-turned-off': 'its command line was turned off', 'no-vault-open': 'it had no vault open' })
235
+ function restartLines(restart) {
236
+ if (restart === undefined || restart === null) return []
237
+ const why = STATE_WORDS[restart.state] ?? restart.state
238
+ const copy = restart.settingsCopy && restart.signalled ? [` A copy of Obsidian's settings, taken just before the first signal, is kept in ${restart.settingsCopy}.`] : []
239
+ const asked = restart.signals === 2 ? `it was sent SIGTERM twice (process ${restart.pid}), and has not gone` : `it was asked to quit (SIGTERM to process ${restart.pid}) and has not`
240
+ if (restart.quit !== true) return [`Obsidian: not restarted (${restart.reason}${restart.detail ? `: ${restart.detail}` : ''}); ${restart.signalled ? asked : 'it was not asked to quit'}.`, ...copy]
241
+ const quit = !restart.signalled ? 'already gone'
242
+ : restart.signals === 2 ? `quit: SIGTERM to its main process, ${restart.pid}, closed its windows, and a second SIGTERM ended that process, left without a window, at once, without its quit handlers`
243
+ : `quit (SIGTERM to its main process, ${restart.pid})`
244
+ return [`Obsidian: restarted as you asked, because ${why}: ${quit}; ${restart.startedAgain === true ? 'then started again plainly, which reopens the vaults it had open' : restart.startedAgain === false ? 'it could not be started again: start it yourself' : 'not started again yet'}.`, ...copy]
245
+ }
246
+ function obsidianSettingsLines(settings) {
247
+ if (settings === undefined || settings === null) return []
248
+ const file = settings.file ?? 'obsidian.json'
249
+ const what = settings.created
250
+ ? `created ${file}${settings.directoryCreated ? ' (and its folder, private to you)' : ''}, because Obsidian never started on this account: this vault flagged open, and the command line on (cli: true)`
251
+ : [settings.vaultAdded ? `added this vault to the vault list in ${file}, flagged open` : null, settings.cliTurnedOn ? `turned the command line on (cli: true)${settings.vaultAdded ? '' : ` in ${file}`}` : null].filter(Boolean).join('; ')
252
+ return [`Obsidian's settings, written while it was quit: ${what}.`, ...(settings.backupPath ? [` The file as it was is kept in ${settings.backupPath}.`] : [])]
253
+ }
254
+
255
+ const isTyped = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal || error instanceof PublicationRefusal
106
256
  const plainMessage = (error) => String(error.message ?? '').replace(new RegExp(`^${error.code}: `), '')
107
257
  const NEXT = Object.freeze({
108
258
  usage: 'run `atelier obsidian --help`',
109
259
  'seams-required': 'pass the seams, or run the real command-line entry',
110
- 'app-adapter-not-selected': `pass --adapter=${PRODUCTION_ADAPTER} to let this command reach the installed app`,
111
- 'startup-consent-required': 'pass --consent-actor ID to record who allows the maintenance service to run',
260
+ 'app-adapter-not-selected': `pass --adapter=${PRODUCTION_ADAPTER} to let this command reach the installed app; it is remembered for this workspace`,
261
+ 'remembered-adapter-under-test': `a test passes its own seams, or --adapter=${PRODUCTION_ADAPTER} with a HOME that leads to no app of the developer's`,
262
+ 'startup-consent-required': 'pass --consent-actor ID to record who allows the maintenance service to run (at a terminal, your account\'s name is recorded)',
112
263
  'automatic-mode-refused': 'install an active automatic policy with `obsidian policy install FILE`',
113
264
  [APPLY_UNAVAILABLE]: 'no apply operation is registered on this command; edits stay preserved and queued',
114
265
  'policy-digest-mismatch': 'set the "digest" member of the file to the expected digest (`obsidian policy digest FILE` prints it), then install again',
115
- disabled: 'declare the Obsidian settings in the project configuration',
266
+ disabled: 'declare the Obsidian settings in the project configuration: `atelier obsidian view add everything --all` does it',
267
+ 'view-would-be-empty': 'check the folders or the tag, and who may see them (`atelier obsidian audience show`; `audience set me` admits every note that is only yours, those without a classification included, and leaves out `sensitive`; a list such as `me,sensitive` shows no note without a classification); --allow-empty declares the view anyway',
268
+ 'view-exists': 'choose another name; `atelier obsidian view list` shows the views declared',
269
+ 'view-repository-ambiguous': 'name the repository the folders are in with --repo',
270
+ 'project-config-format-unknown': 'add the member shown under "detail" to the project configuration by hand, under "ext"',
271
+ 'project-config-changed': 'run the command again',
272
+ 'canonical-graph-invalid': '`atelier graph --check` names the errors in the project\'s notes; fix them, then run the command again',
273
+ unanswered: 'run the command again at a terminal, or pass --yes',
274
+ 'startup-platform-unqualified': 'a login item is offered on macOS and Linux; `atelier obsidian open` starts maintenance when you open a view',
275
+ 'startup-platform-unsupported': 'a login item is offered on macOS and Linux; `atelier obsidian open` starts maintenance when you open a view',
276
+ 'login-item-needs-installed-package': 'install @mnstry/atelier in the project (`npm i -D @mnstry/atelier`), then run the command there',
277
+ 'login-item-home-mismatch': 'run the command in your own session, with HOME set to your home directory',
278
+ 'real-login-item-under-test': 'a test passes its own service manager',
279
+ 'login-item-unavailable': 'no service manager of your session answered (on Linux: systemd --user); `atelier obsidian open` starts maintenance when you open a view',
280
+ 'login-item-install-failed': 'see `atelier obsidian status`; `atelier obsidian service unit --install` tries again',
281
+ 'login-item-remove-failed': 'see `atelier obsidian status`; `atelier obsidian service unit --remove` tries again',
282
+ 'login-item-file-unsafe': 'the login item\'s file is a link or not a regular file and is left alone; remove it by hand, then try again',
283
+ 'invalid-login-item': 'the login item record in private state does not validate; inspect it, remove it, then install the login item again',
284
+ 'selection-history-shortened': 'records of the selection history were removed after this process read them; put them back, or run the command in a process of its own, which reads the history as it now stands',
285
+ })
286
+
287
+ // The login item of `status`, in words.
288
+ function loginItemWords(item, platform) {
289
+ if (item.installed === null) return `unreadable (${item.reason})`
290
+ if (item.installed === false) return 'off'
291
+ const where = item.file
292
+ if (item.programPresent === false) return `installed, but the Node or the package it runs is gone, so it cannot start: install @mnstry/atelier in the project, then \`atelier obsidian service unit --install\`, or remove it with \`service unit --remove\`; ${where}`
293
+ return {
294
+ loaded: `on (${item.running ? 'running' : 'loaded'}); ${where}`,
295
+ 'switched-off': `installed, ${platform === 'darwin' ? 'switched off in System Settings' : 'disabled'}; it does not start at login; ${where}`,
296
+ 'file-missing': `installed, but its file is gone: it does not start at login; \`atelier obsidian service unit --install\` puts it back; ${where}`,
297
+ 'not-loaded': `installed, not loaded by the service manager; it starts at the next login; ${where}`,
298
+ }[item.state] ?? `installed; ${where}`
299
+ }
300
+
301
+ // What a start through the login item adds to the answer of `service start` and `open`.
302
+ function loginItemLines(item) {
303
+ if (item === undefined || item === null) return []
304
+ return [
305
+ ...(item.refreshed === true ? ['login item refreshed: its unit is written as it would be now, and loaded again'] : []),
306
+ ...(item.via === 'child' ? [`the login item did not start the service (${item.reason}); it was started for this session only`] : []),
307
+ ]
308
+ }
309
+
310
+ // A start's answer in words: started, replaced, or already running, and which release when that is why it was kept.
311
+ function serviceWords(result) {
312
+ if (result.replaced === 'outdated' && result.started) return `${result.state} (restarted: the service of an earlier release was replaced)`
313
+ if (result.started) return `${result.state} (started)`
314
+ if (result.alreadyRunning) {
315
+ if (result.release === 'later') return `${result.state} (already running, a later release; it is not replaced by this one)`
316
+ if (result.release === 'outdated') return `${result.state} (already running, an earlier release, not replaced: ${result.reason})`
317
+ return `${result.state} (already running)`
318
+ }
319
+ return `${result.state} (${result.reason})`
320
+ }
321
+
322
+ // What `status` suggests after the login item did not start the service.
323
+ const LOGIN_ITEM_NEXT = Object.freeze({
324
+ 'startup-consent-absent': 'install it again with `atelier obsidian service unit --install --consent-actor ID`',
325
+ 'service-port-occupied': 'something else answers on the recorded port; see `atelier obsidian service status`',
326
+ 'service-settings-absent': 'install it again with `atelier obsidian service unit --install --consent-actor ID`',
116
327
  })
117
328
 
329
+ // What a view would show, in words: `counts` of viewCounts, and who they were counted for.
330
+ function viewCountWords(scopeId, counts, audience) {
331
+ const who = audience.decided ? (audience.allow.length === 0 ? 'no audience' : audience.allow.join(', ')) : 'only you, until you decide'
332
+ const reasons = [
333
+ ...(counts.withheld.unclassified > 0 ? [`${counts.withheld.unclassified} carry no classification`] : []),
334
+ ...(counts.withheld.audience > 0 ? [`${counts.withheld.audience} an audience not admitted`] : []),
335
+ ]
336
+ const shown = `view ${scopeId} would show ${counts.shown === 0 ? 'no note' : `${counts.shown} note(s)`} (who may see: ${who})`
337
+ if (counts.named === 0) return `${shown}: it names no note`
338
+ return reasons.length === 0 ? shown : `${shown}; of the ${counts.named} note(s) it names, ${reasons.join(' and ')}`
339
+ }
340
+
118
341
  // The decisions the command's oracles are sensitive to are those of opening and of the lifecycle; tests substitute
119
342
  // broken ones here to prove the oracles can fail. `runObsidianCommand` always uses the production ones.
120
343
  export async function runObsidianCommandForOracleTests(options = {}, rules = {}) {
@@ -123,9 +346,22 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
123
346
  const {
124
347
  argv = [], seams = null, production = false, env = process.env, cwd = process.cwd(), platform = process.platform, clock = () => new Date(),
125
348
  stdout = (text) => process.stdout.write(`${text}\n`), stderr = (text) => process.stderr.write(`${text}\n`), contributions = null, contributionsDirectory,
349
+ // Whether standard input and output are terminals, and the name of the account this runs as. A test injects both;
350
+ // under the test runner the process's own terminal is never looked at.
351
+ terminal = env.NODE_TEST_CONTEXT === undefined ? { stdin: tty.isatty(0), stdout: tty.isatty(1) } : { stdin: false, stdout: false },
352
+ account = accountName,
353
+ // The home folder a location is read against (`~/`) and checked for sync clients; a test names its own.
354
+ homedir = env.NODE_TEST_CONTEXT === undefined ? os.homedir() : null,
355
+ // Where a person at a terminal is asked, and answers; a test types into its own.
356
+ input = env.NODE_TEST_CONTEXT === undefined ? process.stdin : null, output = env.NODE_TEST_CONTEXT === undefined ? process.stdout : null,
126
357
  } = options
127
358
  let json = argv.includes('--json')
128
359
  let operationName = null
360
+ let questions = null
361
+ const questioner = () => {
362
+ if (input === null || output === null) throw new Error('a question needs an input and an output')
363
+ return (questions ??= createQuestioner({ input, output }))
364
+ }
129
365
  const emit = ({ exit, document, human }) => {
130
366
  const complete = { schema: COMMAND_SCHEMA, ok: exit === EXIT.ok, operation: operationName, ...document }
131
367
  if (json) stdout(JSON.stringify(complete, null, 2))
@@ -142,23 +378,88 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
142
378
 
143
379
  const dataRoot = flags['data-root'] === undefined ? options.dataRoot : path.resolve(cwd, flags['data-root'])
144
380
  const configFlag = flags.project ?? flags['project-config']
145
- const loadProject = options.loadProject ?? (() => resolveProjectConfig({ argv: configFlag === undefined ? [] : [`--project=${path.resolve(cwd, configFlag)}`], cwd, env, writeLocalState: false }))
381
+ // A personal home is followed instead of a project: the generation confirmed there is loaded (composed in this
382
+ // thread, as for a command run once), and the service's workspace is located from the home alone.
383
+ const personalHome = flags['personal-home'] === undefined ? null : path.resolve(cwd, flags['personal-home'])
384
+ if (personalHome !== null && configFlag !== undefined) refuse('usage', '--personal-home is given instead of --project, not with it')
385
+ const selection = personalHome === null ? null : await import('../runtime/obsidian/personal-selection.mjs')
386
+ const loadProject = options.loadProject ?? (selection !== null ? () => selection.loadSelectedProject({ personalHome }) : () => resolveProjectConfig({ argv: configFlag === undefined ? [] : [`--project=${path.resolve(cwd, configFlag)}`], cwd, env, writeLocalState: false }))
387
+ const locateProject = selection === null ? null : () => selection.locatePersonalHome({ personalHome })
146
388
  const registry = createObsidianRegistry({ reservedOperations: BUILT_IN_OPERATIONS, contributions: contributions ?? await loadContributions(contributionsDirectory === undefined ? {} : { directory: contributionsDirectory }) })
147
389
  const applyAvailable = registry.extensions.applyOperation() !== UNAVAILABLE_APPLY_OPERATION
148
- const lifecycle = { loadProject, dataRoot, env, platform, ...(options.probeTimeoutMs === undefined ? {} : { probeTimeoutMs: options.probeTimeoutMs }) }
390
+ const lifecycle = { loadProject, ...(locateProject === null ? {} : { locateProject }), dataRoot, env, platform, ...(options.probeTimeoutMs === undefined ? {} : { probeTimeoutMs: options.probeTimeoutMs }) }
149
391
 
150
- const serviceSeam = async () => {
392
+ // The adapter of this run: given now, or remembered by this workspace (read only when the real entry has no flag).
393
+ const chooseAdapter = () => selectAdapter({ flag: flags.adapter, remembered: flags.adapter === undefined && production === true ? rememberedAdapter() : null, production, env })
394
+ // The entry this command may start, and its arguments.
395
+ const entrySeam = async () => {
151
396
  if (seams !== null) { if (typeof seams.service?.entryPath !== 'string') refuse('seams-required', 'the service seam names the entry this command may start'); return seams.service }
152
- if (flags.adapter !== PRODUCTION_ADAPTER) refuse('app-adapter-not-selected', 'no editor adapter was selected; a service that reaches the installed app is never a default')
397
+ chooseAdapter()
153
398
  const { SERVICE_ENTRY_PATH } = await import('../runtime/obsidian/service-entry-path.mjs')
154
399
  return { entryPath: SERVICE_ENTRY_PATH, entryArgs: [`--adapter=${PRODUCTION_ADAPTER}`] }
155
400
  }
401
+ // The service manager of a login item: the caller's (`serviceManager`, or `seams.serviceManager`), or, for the real
402
+ // command-line entry only, the production one, which refuses under the test runner and for a HOME that is not the
403
+ // account's own. Without `required`, null where none can be had.
404
+ const managerSeam = async ({ required = true } = {}) => {
405
+ const given = options.serviceManager ?? seams?.serviceManager ?? null
406
+ if (given !== null) return given
407
+ if (seams !== null || production !== true) { if (required) refuse('seams-required', 'no service manager was passed for the login item'); return null }
408
+ try {
409
+ const { createProductionServiceManager } = await import('../runtime/obsidian/service-manager-production.mjs')
410
+ return createProductionServiceManager({ platform, env })
411
+ } catch (error) {
412
+ if (!required && isTyped(error)) return null
413
+ throw error
414
+ }
415
+ }
416
+ const nodePath = () => options.nodePath ?? realNodePath()
417
+ // The service seam: once this workspace has a login item, the service is started through its manager, running the
418
+ // entry the item names; a unit that differs from what would be written now is written again on the way.
419
+ const serviceSeam = async () => {
420
+ const entry = await entrySeam()
421
+ const { project, workspace } = readable()
422
+ let record = null
423
+ try { record = workspace === null ? null : readLoginItemRecord(workspace) } catch (error) { if (!isTyped(error)) throw error }
424
+ if (record === null) return entry
425
+ const manager = await managerSeam({ required: false })
426
+ if (manager === null) return entry
427
+ const plan = currentLoginItemPlan({ record, project, workspaceRoot: workspace.workspaceRoot, dataRoot, env, platform, ownEntry: entry.entryPath, entryArgs: entry.entryArgs ?? [], nodePath: nodePath() })
428
+ // An item that names an entry that is gone, with nothing to refresh it to from here, cannot start anything.
429
+ if (plan === null && !fs.existsSync(record.program.entry)) return entry
430
+ return { ...entry, entryPath: plan?.program.entry ?? record.program.entry, loginItem: loginItemStarter({ workspace, manager, record, plan, clock }) }
431
+ }
156
432
  const appSeams = async () => {
157
- if (seams !== null) return { appProbe: seams.appProbe, launcher: seams.launcher, registry: seams.registry }
158
- if (flags.adapter !== PRODUCTION_ADAPTER) refuse('app-adapter-not-selected', 'no editor adapter was selected; reaching the installed app is never a default')
433
+ if (seams !== null) return { appProbe: seams.appProbe, launcher: seams.launcher, registry: seams.registry, quitter: seams.quitter ?? null }
434
+ chooseAdapter()
159
435
  const { createProductionAppSeams } = await import('../runtime/obsidian/app-production-seams.mjs')
160
436
  return createProductionAppSeams({ env, platform })
161
437
  }
438
+ // The app's vault list, read only, for a check of where vaults may live: through the app when it runs and answers,
439
+ // else from its settings file. { source: 'app' | 'file' | 'none', vaults } ('none': the app never ran here), or
440
+ // { source: 'unread', vaults: null, reason }. The app is reached only with the adapter (given or remembered, or the
441
+ // caller's seams); without it, only the file is read, and never under the test runner.
442
+ const appVaultList = async () => {
443
+ const quietly = async (read) => { try { return await read() } catch { return null } }
444
+ const reach = seams !== null ? seams : adapterSelected() ? await appSeams() : null
445
+ const registry = reach?.registry ?? null
446
+ if (registry !== null && reach.appProbe !== undefined) {
447
+ const seen = await inspectApp(reach.appProbe)
448
+ if (seen.running === true && typeof seen.version === 'string') {
449
+ const listed = await quietly(() => registry.listThroughApp())
450
+ if (listed?.answered === true) return { source: 'app', vaults: listed.vaults }
451
+ }
452
+ }
453
+ const settings = registry !== null ? await quietly(() => registry.readSettings())
454
+ : env.NODE_TEST_CONTEXT !== undefined ? { ok: false, code: 'app-vault-list-under-test' }
455
+ : obsidianSandboxedBuild({ platform, env }) !== null ? { ok: false, code: 'obsidian-sandboxed' }
456
+ : readObsidianSettings({ userDataDir: obsidianUserDataDir({ platform, env }) })
457
+ if (settings?.ok === true) return { source: 'file', vaults: settings.vaults ?? {} }
458
+ if (settings?.code === 'obsidian-settings-missing') return { source: 'none', vaults: {} }
459
+ return { source: 'unread', vaults: null, reason: typeof settings?.code === 'string' ? settings.code : 'obsidian-settings-unreadable' }
460
+ }
461
+ // Whether this run may reach the installed app: --adapter given, or remembered by the workspace for the real entry.
462
+ const adapterSelected = () => { try { chooseAdapter(); return true } catch (error) { if (isTyped(error)) return false; throw error } }
162
463
  // Atelier's plugin as the running service sees it, for one view. A vault that turned it off says so from its own list
163
464
  // and the private record too, whether or not the service runs.
164
465
  const pluginView = (running, workspace, scopeId) => {
@@ -167,8 +468,11 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
167
468
  return { present: false, reason: 'turned-off-in-this-vault', next: turnPluginOnNext(scopeId) }
168
469
  }
169
470
  const pluginOf = async (scopeId) => pluginPresenceOf((await readServiceStatusDocument(lifecycle, lifecycleRules)).document, scopeId)
170
- const pluginLine = (plugin) => (plugin.present ? `; plugin present (Obsidian ${plugin.appVersion})` : plugin.reason === 'turned-off-in-this-vault' ? '; plugin turned off in this vault' : `; plugin not present (${plugin.reason})`)
471
+ const pluginLine = (plugin) => `${plugin.present ? `; plugin present (Obsidian ${plugin.appVersion})` : plugin.reason === 'turned-off-in-this-vault' ? '; plugin turned off in this vault' : `; plugin not present (${plugin.reason})`}${plugin.files === 'waits-for-app' ? '; plugin files wait for the app' : ''}`
171
472
 
473
+ // A project bound to a personal workspace takes its views from the overlay, and has no place for these decisions
474
+ // yet: the operations that would write them refuse before anything is resolved or written.
475
+ const refuseOnPersonalBinding = async (project, operation) => (await import('../projection/obsidian/personal-workspace.mjs')).refuseOnPersonalBinding(project, operation)
172
476
  const configured = () => {
173
477
  const project = loadProject()
174
478
  const enablement = readObsidianEnablement(project)
@@ -189,15 +493,225 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
189
493
  return { project, enablement, workspace: workspace?.workspaceRoot ? workspace : null, workspaceId: workspace?.workspaceId ?? null }
190
494
  }
191
495
  const machineOf = (workspace) => (workspace === null ? null : readMachineSettings(workspace))
192
- const consent = flags['consent-actor'] === undefined ? undefined : { actor: flags['consent-actor'], coverage: 'service' }
496
+ const rememberedAdapter = () => machineOf(readable().workspace)?.decisions.adapter?.choice ?? null
497
+ const interactive = isInteractive({ flags, env, terminal })
498
+ // Who allows the maintenance service to run, for a start this run may make: --consent-actor; else, for a person at a
499
+ // terminal and only while this workspace has no consent recorded, their account's name. A derived consent never
500
+ // replaces a recorded one. `source` says which.
501
+ // With a login item installed, the service is started through it, so the consent covers startup too, or the item's
502
+ // own start would be refused (`seam.loginItem`).
503
+ const consentOfThisRun = (seam = null) => {
504
+ const coverage = seam?.loginItem ? 'service-and-startup' : 'service'
505
+ if (flags['consent-actor'] !== undefined) return { consent: { actor: flags['consent-actor'], coverage }, source: 'flag' }
506
+ if (!interactive) return { consent: undefined, source: null }
507
+ const { workspace } = readable()
508
+ if (workspace !== null && readServiceSettings(workspace) !== null) return { consent: undefined, source: null }
509
+ const actor = accountActor(account())
510
+ // `derived`: the start records it only while no consent is recorded, checked again under the start lock.
511
+ return actor === null ? { consent: undefined, source: null } : { consent: { actor, coverage, derived: true }, source: 'account' }
512
+ }
513
+ // --adapter, given to an operation that starts or reaches the service of an enabled project, is remembered the first
514
+ // time: the workspace is prepared and the decision written before anything starts, so no later run needs the flag.
515
+ // Answers whether this run remembered it.
516
+ // Remembering never stands in the way of the operation: a workspace that cannot be prepared refuses the operation
517
+ // itself, which says so in its own terms.
518
+ const rememberAdapter = (decidedBy) => {
519
+ if (flags.adapter !== PRODUCTION_ADAPTER) return false
520
+ try {
521
+ if (readable().enablement.state !== 'enabled') return false
522
+ const { workspace, repositoryRoots, now } = writable()
523
+ const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
524
+ if (current.decisions.adapter?.choice === PRODUCTION_ADAPTER) return false
525
+ const decided = withDecision(current, 'adapter', { choice: PRODUCTION_ADAPTER }, { decidedAt: now, decidedBy: decidedBy ?? accountActor(account()), via: 'command' })
526
+ writeMachineSettings({ ...workspace, repositoryRoots, settings: { ...decided, updatedAt: now } })
527
+ return true
528
+ } catch (error) {
529
+ if (isTyped(error)) return false
530
+ throw error
531
+ }
532
+ }
533
+ // What a run that starts or reaches the service says about what it remembered, in the document and in words.
534
+ const rememberedLines = ({ adapterRemembered, consent }) => [
535
+ ...(consent.source === 'account' ? [`The maintenance service is allowed by ${consent.consent.actor}, this account; recorded for this workspace.`] : []),
536
+ ...(adapterRemembered ? [`Remembered for this workspace: the app is reached through ${PRODUCTION_ADAPTER}; --adapter is not needed again.`] : []),
537
+ ]
193
538
  const shownMachine = (machine, workspace) => {
194
- const settings = machine ?? { maintenanceMode: 'manual', audienceAllow: [], applyPolicy: null }
539
+ const settings = machine ?? defaultMachineSettings({ workspaceId: workspace?.workspaceId ?? 'ws-unprepared', updatedAt: isoTime(clock) })
195
540
  const automatic = workspace === null ? { authorized: false, reason: 'machine-settings-absent' } : authorizeAutomaticApply(workspace)
196
- return { maintenanceMode: settings.maintenanceMode, audienceAllow: settings.audienceAllow, applyPolicy: settings.applyPolicy, automaticApply: { authorized: automatic.authorized, reason: automatic.reason } }
541
+ return {
542
+ maintenanceMode: settings.maintenanceMode, audienceAllow: settings.audienceAllow, applyPolicy: settings.applyPolicy, automaticApply: { authorized: automatic.authorized, reason: automatic.reason },
543
+ decisions: settings.decisions,
544
+ }
545
+ }
546
+ // After a run that may have started the service: whether the consent it derived is the one now recorded.
547
+ const consentRecorded = (consent) => {
548
+ if (consent.source !== 'account') return consent
549
+ const { workspace } = readable()
550
+ const recorded = workspace === null ? null : readServiceSettings(workspace)
551
+ return recorded?.consent.actor === consent.consent.actor ? consent : { consent: undefined, source: null }
197
552
  }
198
553
  const applyShown = { available: applyAvailable, state: applyAvailable ? 'available' : APPLY_UNAVAILABLE, operationId: registry.extensions.applyOperation().id }
199
554
  const [, sub, value] = positionals
200
555
 
556
+ // The login item's text as `--install` would write it now. Writes nothing and asks no service manager.
557
+ const printUnit = async () => {
558
+ const { project, workspace } = readable()
559
+ if (workspace === null) refuse('service-workspace-not-prepared', 'this workspace has no private state yet; `service start` prepares it')
560
+ const entry = await entrySeam()
561
+ const record = readLoginItemRecord(workspace)
562
+ const temporary = temporaryRoots()
563
+ const { entryPath } = resolveLoginItemEntry({ project, ownEntry: entry.entryPath, temporary })
564
+ const label = record?.label ?? loginItemLabel({ platform, projectName: projectNameOf(project), workspaceId: workspace.workspaceId })
565
+ const { platform: unitPlatform, kind, fileName, text } = planLoginItem({
566
+ platform, project, workspaceRoot: workspace.workspaceRoot, workspaceId: workspace.workspaceId, dataRoot: loginItemDataRoot({ project, dataRoot, env, platform }), label, entryPath,
567
+ entryArgs: entry.entryArgs ?? [], nodePath: nodePath(),
568
+ searchPath: record?.searchPath ?? startupSearchPath(env.PATH, { temporary }),
569
+ })
570
+ return { exit: EXIT.ok, document: { unit: { platform: unitPlatform, kind, fileName, text }, installed: record !== null, note: 'the service runs under this unit only with a recorded consent that covers startup; `service unit --install` records one' }, human: [text] }
571
+ }
572
+ // Who allows the service to run at login, for `--install`: --consent-actor; else, for a person at a terminal, the
573
+ // actor this workspace already recorded (its coverage is raised to startup), or the account's name when none is.
574
+ // Asking for the login item at a terminal is that person's consent to it. A program names its actor.
575
+ const startupConsent = () => {
576
+ if (flags['consent-actor'] !== undefined) return { actor: flags['consent-actor'] }
577
+ if (!interactive) return undefined
578
+ const { workspace } = readable()
579
+ const recorded = workspace === null ? null : readServiceSettings(workspace)
580
+ const actor = recorded?.consent.actor ?? accountActor(account())
581
+ return actor === null ? undefined : { actor }
582
+ }
583
+ // Installs the login item, then starts the service through it (launchd has usually started it already).
584
+ const installUnit = async () => {
585
+ configured()
586
+ if (!STARTUP_PLATFORMS.includes(platform)) buildStartupAdapter({ platform })
587
+ const entry = await entrySeam()
588
+ const manager = await managerSeam()
589
+ const consent = startupConsent()
590
+ // A service of an earlier release still running would make the unit's own first start refuse (another runtime
591
+ // answers): it is stopped as its owner stops it once the consent is recorded, just before the unit is loaded.
592
+ let replaced = null
593
+ const beforeInstall = async ({ entryPath }) => {
594
+ if (runtimeRelease(await serviceStatus(lifecycle, lifecycleRules), entryPath) !== 'outdated') return
595
+ if ((await stopService(lifecycle, lifecycleRules)).stopped) replaced = 'outdated'
596
+ }
597
+ const installed = await installLoginItem({
598
+ loadProject, dataRoot, env, platform, manager, ownEntry: entry.entryPath, entryArgs: entry.entryArgs ?? [], nodePath: nodePath(), pathValue: env.PATH, clock, beforeInstall,
599
+ ...(consent === undefined ? {} : { consent }),
600
+ })
601
+ if (!installed.installed) {
602
+ // The earlier release was stopped for nothing: the installed one runs in its place for this session.
603
+ const service = replaced === null ? null : (({ child: _child, ...result }) => result)(await startService({ ...lifecycle, detached: true, ...entry, replaceOutdated: true }, lifecycleRules))
604
+ return {
605
+ exit: EXIT.notSuccess, document: { loginItem: installed, ...(service === null ? {} : { service: { ...service, replaced } }) },
606
+ human: [`login item not installed: ${installed.reason}${installed.message ? ` (${installed.message})` : ''}`, `Next: ${NEXT[installed.reason] ?? 'see `atelier obsidian status`'}`, ...(service === null ? [] : [`service: ${serviceWords({ ...service, replaced })}`])],
607
+ }
608
+ }
609
+ const remembered = rememberLoginItem('on')
610
+ const adapterRemembered = rememberAdapter(consent?.actor)
611
+ const seam = await serviceSeam()
612
+ const started = (({ child: _child, ...result }) => result)(await startService({ ...lifecycle, detached: true, ...seam, replaceOutdated: true }, lifecycleRules))
613
+ const service = replaced === null || started.replaced !== undefined ? started : { ...started, replaced }
614
+ const running = service.state === 'healthy' || service.state === 'busy'
615
+ return {
616
+ exit: running ? EXIT.ok : EXIT.notSuccess, document: { loginItem: installed, service, rememberedNow: { loginItem: remembered ? 'on' : null, adapter: adapterRemembered } },
617
+ human: [
618
+ `login item installed: ${installed.file}`, `it runs ${installed.entry.path}${installed.entry.source === 'project' ? ', the package installed in this project' : ''}`,
619
+ ...(platform === 'darwin' ? ['macOS lists it as "node" under Login Items & Extensions, and may say "Background Items Added"'] : []),
620
+ `service: ${serviceWords(service)}`, ...loginItemLines(service.loginItem),
621
+ ...(consent !== undefined && flags['consent-actor'] === undefined ? [`Maintenance at login is allowed by ${consent.actor}; recorded for this workspace.`] : []),
622
+ ...rememberedLines({ adapterRemembered, consent: { source: null } }),
623
+ ],
624
+ }
625
+ }
626
+ // Removes the login item. A service it ran is stopped as its manager unloads it, and is started again at once as a
627
+ // process of its own for this session, under the consent now recorded (the service alone), so the vaults stay fresh
628
+ // until the person logs out. That start needs the adapter, given or remembered; without it, `open` starts it.
629
+ const removeUnit = async () => {
630
+ if (!STARTUP_PLATFORMS.includes(platform)) buildStartupAdapter({ platform })
631
+ const manager = await managerSeam()
632
+ const removed = await removeLoginItem({ loadProject: locateProject ?? loadProject, dataRoot, env, platform, manager, clock })
633
+ const failed = removed.removed !== true && removed.reason !== undefined && removed.reason !== 'workspace-not-prepared'
634
+ if (failed) return { exit: EXIT.notSuccess, document: { loginItem: removed }, human: [`login item not removed: ${removed.reason}${removed.message ? ` (${removed.message})` : ''}`, `Next: ${NEXT[removed.reason] ?? 'see `atelier obsidian status`'}`] }
635
+ const remembered = removed.reason === 'workspace-not-prepared' ? false : rememberLoginItem('off')
636
+ let service = null
637
+ if (removed.removed === true) {
638
+ let entry = null
639
+ try { entry = await entrySeam() } catch (error) { if (!isTyped(error)) throw error; service = { state: 'stopped', started: false, reason: error.code } }
640
+ if (entry !== null) {
641
+ try { service = (({ child: _child, ...result }) => result)(await startService({ ...lifecycle, detached: true, ...entry, replaceOutdated: true }, lifecycleRules)) } catch (error) {
642
+ if (!isTyped(error)) throw error
643
+ service = { state: 'stopped', started: false, reason: error.code }
644
+ }
645
+ }
646
+ }
647
+ service ??= await serviceStatus(lifecycle, lifecycleRules)
648
+ const running = ['healthy', 'busy'].includes(service.state)
649
+ return {
650
+ exit: EXIT.ok, document: { loginItem: removed, service: { state: service.state, reason: service.reason ?? null, started: service.started === true }, rememberedNow: { loginItem: remembered ? 'off' : null } },
651
+ human: [
652
+ removed.removed ? `login item removed: ${removed.file}` : 'no login item was installed', 'the consent now covers the service alone',
653
+ `service: ${service.state}${running ? (service.started ? ' (started for this session only; it does not start at login)' : '') : `${service.reason ? ` (${service.reason})` : ''}; \`atelier obsidian open\` starts it again`}`,
654
+ ],
655
+ }
656
+ }
657
+ // The person's answer to "start at login?", remembered for this workspace as the `loginItem` decision of its machine
658
+ // settings, given on the command line. By whom: the actor named, else a person at a terminal by the account's name,
659
+ // else nobody known. Answers whether it wrote; an answer already remembered is not written again.
660
+ const rememberLoginItem = (choice) => {
661
+ if (readable().enablement.reason === 'not-configured') return false
662
+ const { workspace, repositoryRoots, now } = writable()
663
+ const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
664
+ if (current.decisions.loginItem?.choice === choice) return false
665
+ const decidedBy = flags['consent-actor'] ?? (interactive ? accountActor(account()) : null)
666
+ const decided = withDecision(current, 'loginItem', { choice }, { decidedAt: now, decidedBy, via: 'command' })
667
+ writeMachineSettings({ ...workspace, repositoryRoots, settings: { ...decided, updatedAt: now } })
668
+ return true
669
+ }
670
+ // Where what `uninstall` keeps is: the vaults, the private state, the project file and Obsidian's vault list. The
671
+ // vaults are those under the data root and every folder allocated to a view where the workspace decided its vaults
672
+ // live, a view no longer declared included; a record that cannot be read names none.
673
+ const keptLocations = (project, workspace) => {
674
+ const vaultsDirectory = workspace === null ? null : path.join(workspace.workspaceRoot, 'vaults')
675
+ let underDataRoot = []
676
+ try { underDataRoot = vaultsDirectory === null ? [] : fs.readdirSync(vaultsDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(vaultsDirectory, entry.name)) } catch { underDataRoot = [] }
677
+ const allocated = []
678
+ if (workspace !== null) {
679
+ const records = path.join(workspace.workspaceRoot, 'state', 'allocations')
680
+ let names = []
681
+ try { names = fs.readdirSync(records).filter((name) => name.endsWith('.json')) } catch { names = [] }
682
+ for (const name of names) {
683
+ // Whatever the file is or holds (a link, a FIFO, not JSON, `null`, another view's record), it names a folder only
684
+ // when it is a valid record of its own view, which the store would read too; nothing here can stop `uninstall`.
685
+ try {
686
+ // Read as the store reads a record: a regular file only, never through a link, never a FIFO that would block.
687
+ const scopeId = JSON.parse(readRegularTextNoFollow(path.join(records, name)))?.scopeId
688
+ if (typeof scopeId !== 'string' || path.basename(allocationFile(workspace.workspaceRoot, scopeId)) !== name) continue
689
+ const allocation = readVaultAllocation({ ...workspace, scopeId })
690
+ if (allocation !== null) allocated.push(allocation.path)
691
+ } catch { /* a record that cannot be read names no folder */ }
692
+ }
693
+ }
694
+ const vaults = [...new Set([...underDataRoot, ...allocated])].sort()
695
+ const userDataDir = obsidianUserDataDir({ platform, env })
696
+ return { vaults, privateState: workspace?.workspaceRoot ?? null, projectFile: project.configPath ?? null, pointer: project.configPath ? localPointerPath(project) : null, obsidianList: userDataDir === null ? null : path.join(userDataDir, OBSIDIAN_SETTINGS_FILE) }
697
+ }
698
+
699
+ // Where a view's vault is, or will be: { path, origin }. A view the next tick will place is shown where it would go,
700
+ // if that name is still free then (`to-be-allocated`).
701
+ // A view whose record cannot be read, or was lost, says why, as `status` does, and the other views are still shown.
702
+ const vaultWhere = (workspace, scopeId, decided, projectName) => {
703
+ if (workspace === null) return { path: null, origin: 'workspace-not-prepared' }
704
+ let found
705
+ try { found = vaultRootFor({ ...workspace, scopeId }) } catch (error) {
706
+ if (!isTyped(error)) throw error
707
+ return { path: null, origin: 'unreadable', reason: error.code }
708
+ }
709
+ if (found.origin === 'legacy-data-root' && decided !== null && !hasCommittedGeneration({ ...workspace, scopeId })) {
710
+ return { path: path.join(decided.parent, vaultFolderName({ projectName, scopeId })), origin: 'to-be-allocated' }
711
+ }
712
+ return { path: found.path, origin: found.origin }
713
+ }
714
+
201
715
  const operations = {
202
716
  async status() {
203
717
  const { project, enablement, workspace, workspaceId } = readable()
@@ -205,25 +719,38 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
205
719
  const running = service.state === 'healthy' ? (await readServiceStatusDocument(lifecycle, lifecycleRules)).document : null
206
720
  // What the service last learned about the app, and what a person can do about it.
207
721
  const app = running?.app ? { ...running.app, next: running.app.outcome === 'qualified' ? null : nextStep(running.app.outcome, running.app.reason) } : null
722
+ // The login item: its record, what its manager says (asked only when one is recorded), and how its last start ended.
723
+ const item = workspace === null ? { installed: false, lastStartup: null } : await loginItemStatus({ workspace, manager: () => managerSeam({ required: false }) })
724
+ const refusedAtLogin = item.lastStartup?.outcome === 'refused' && item.lastStartup.code !== 'service-already-running' && !['healthy', 'busy'].includes(service.state) ? item.lastStartup.code : null
725
+ const loginItem = { ...item, didNotStart: refusedAtLogin, next: refusedAtLogin === null ? null : LOGIN_ITEM_NEXT[refusedAtLogin] ?? NEXT[refusedAtLogin] ?? 'see `atelier obsidian service status`, and the private log it names' }
208
726
  const scopes = workspace === null
209
727
  ? enablement.scopes.map(({ scopeId }) => ({ scopeId, outcome: enablement.state === 'disabled' ? 'disabled' : 'not-prepared', reason: enablement.state === 'disabled' ? enablement.reason : 'workspace-not-prepared' }))
210
728
  : enablement.scopes.map(({ scopeId }) => {
211
- const { vaultRoot: _vault, summary: _summary, ...report } = scopeReport({ workspace, scopeId, repositoryRoots: protectedRoots(project), serviceState: service.state, applyAvailable }, openingRules)
212
- const plugin = pluginView(running, workspace, scopeId)
729
+ const { vaultRoot: _vault, summary: _summary, ...found } = scopeReport({ workspace, scopeId, repositoryRoots: protectedRoots(project), serviceState: service.state, applyAvailable }, openingRules)
730
+ // A view whose vault the next tick allocates elsewhere says where, not the data root it will not use.
731
+ const report = found.vault?.origin === 'legacy-data-root' ? { ...found, vault: vaultWhere(workspace, scopeId, machineOf(workspace)?.decisions.location ?? null, projectDisplayName(project)) } : found
732
+ const plugin = withPluginFilesPending(pluginView(running, workspace, scopeId), report.freshness)
213
733
  return enablement.state === 'disabled' ? { ...report, outcome: 'disabled', reason: enablement.reason, next: OPENING_OUTCOMES.disabled.next, plugin } : { ...report, plugin }
214
734
  })
735
+ // The confirmed selection a personal home's project was loaded under; reported, never confirmed here.
736
+ const pinned = selection === null ? null : selection.personalSelectionOf(project)
215
737
  const document = {
738
+ ...(pinned === null ? {} : { selection: { generationId: pinned.generationId, sequence: pinned.sequence } }),
216
739
  enablement: { state: enablement.state, reason: enablement.reason, defaultScopeId: enablement.defaultScopeId }, workspace: { workspaceId, prepared: workspace !== null },
217
740
  machine: shownMachine(machineOf(workspace), workspace), apply: applyShown,
218
741
  service: { state: service.state, reason: service.reason ?? null, address: service.address ?? null, runtimeId: service.record?.runtimeId ?? null, pid: service.record?.pid ?? null, lastTick: running?.lastTick ?? null, lastError: running?.lastError ?? null, app },
219
- app: { probed: false, minimumVersion: MINIMUM_APP_VERSION }, scopes, extensions: registry.extensions.describe(), operations: registry.operations.describe(),
742
+ loginItem, app: { probed: false, minimumVersion: MINIMUM_APP_VERSION }, scopes, extensions: registry.extensions.describe(), operations: registry.operations.describe(),
220
743
  }
221
744
  return {
222
745
  exit: EXIT.ok, document,
223
746
  human: [
747
+ ...(pinned === null ? [] : [`personal workspace: generation ${pinned.generationId}, confirmed as selection ${pinned.sequence}`]),
224
748
  `obsidian: ${enablement.state} (${enablement.reason}); mode ${document.machine.maintenanceMode}; audiences ${document.machine.audienceAllow.join(', ') || 'none'}`,
749
+ `remembered: ${decisionSummary(document.machine.decisions)}`,
225
750
  `service: ${service.state} (${service.reason ?? 'no reason'})${app ? `; app ${app.outcome} (${app.reason})` : ''}`,
226
751
  ...(app?.next ? [`Next for the app: ${app.next}`] : []),
752
+ `login item: ${loginItemWords(loginItem, platform)}`,
753
+ ...(refusedAtLogin === null ? [] : [`the login item did not start the service: ${refusedAtLogin}`, `Next: ${loginItem.next}`]),
227
754
  `apply: ${applyShown.state}`,
228
755
  ...scopes.flatMap((scope) => [
229
756
  `view ${scope.scopeId}: ${scope.outcome} (${scope.reason})${scope.pendingEdits?.open ? `; ${scope.pendingEdits.open} pending edit(s), apply ${scope.pendingEdits.apply}` : ''}${scope.plugin ? pluginLine(scope.plugin) : ''}`,
@@ -234,6 +761,125 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
234
761
  }
235
762
  },
236
763
 
764
+ // What this machine remembers for this workspace, and how each answer is changed. Read-only.
765
+ async settings() {
766
+ const { workspace, workspaceId } = readable()
767
+ const { audienceAllow, decisions } = shownMachine(machineOf(workspace), workspace)
768
+ const service = workspace === null ? null : readServiceSettings(workspace)
769
+ const consent = service === null ? null : service.consent
770
+ const document = { workspace: { workspaceId, prepared: workspace !== null }, decisions, audienceAllow, consent, change: DECISION_CHANGES }
771
+ return {
772
+ exit: EXIT.ok, document,
773
+ human: [
774
+ `who may see: ${decisionWords('audience', decisions.audience, audienceAllow)}`,
775
+ `where vaults live: ${decisionWords('location', decisions.location)}`,
776
+ `start at login: ${decisionWords('loginItem', decisions.loginItem)}`,
777
+ `reaching the app: ${decisionWords('adapter', decisions.adapter)}`,
778
+ `maintenance allowed by: ${consent === null ? 'nobody yet' : `${consent.actor} (${consent.coverage}, since ${consent.grantedAt})`}`,
779
+ ...Object.values(DECISION_CHANGES).filter((change) => change !== null).map((change) => `Change: ${change}`),
780
+ ],
781
+ }
782
+ },
783
+
784
+ // Where this workspace's vaults live. Deciding it places the vaults of views not published yet; a vault published
785
+ // already, under the data root or where an earlier decision placed it, stays where it is.
786
+ async location() {
787
+ const where = vaultWhere
788
+ const views = (project, enablement, workspace, decided) => enablement.scopes.map(({ scopeId }) => ({ scopeId, ...where(workspace, scopeId, decided, projectDisplayName(project)) }))
789
+ const lines = (list) => list.map((view) => `view ${view.scopeId}: ${view.path ?? 'no vault yet'} (${view.origin}${view.reason === undefined ? '' : `: ${view.reason}`})`)
790
+ if (sub === 'show' || sub === undefined) {
791
+ const { project, enablement, workspace } = readable()
792
+ const { decisions } = shownMachine(machineOf(workspace), workspace)
793
+ const shown = views(project, enablement, workspace, decisions.location)
794
+ return { exit: EXIT.ok, document: { location: decisions.location, views: shown }, human: [`where vaults live: ${decisionWords('location', decisions.location)}`, ...lines(shown)] }
795
+ }
796
+ if (sub !== 'set' || value === undefined) refuse('usage', 'location show | location set DIR [--allow-synced-location]')
797
+ // `~/` is read against the home folder, which a shell does not do after `--x=`; under the test runner a home
798
+ // folder is only ever one the test named.
799
+ const tilde = value === '~' || value.startsWith('~/')
800
+ if (tilde && typeof homedir !== 'string') refuse('real-vault-location-under-test', 'a location under the home folder is never used under the test runner; name an absolute folder')
801
+ // Resolved, so a trailing separator (as tab completion leaves it) is no refusal.
802
+ const parent = tilde ? path.resolve(path.join(homedir, value.slice(1))) : path.resolve(cwd, value)
803
+ await refuseOnPersonalBinding(loadProject(), 'location set')
804
+ const { project, enablement, workspace, repositoryRoots, now } = writable()
805
+ // A record that cannot be read names no folder here; the engine refuses its view on its own.
806
+ const allocatedPaths = enablement.scopes.map(({ scopeId }) => vaultWhere(workspace, scopeId, null, null)).filter((found) => found.origin === 'allocated').map((found) => found.path)
807
+ // A folder inside, or holding, a vault the app lists is refused now; when the list cannot be read, it is checked
808
+ // again, and has to be read, before any vault is allocated there.
809
+ const list = await appVaultList()
810
+ const warnings = checkVaultParent({ parent, workspaceRoot: workspace.workspaceRoot, repositoryRoots, vaults: list.vaults, allocatedPaths, allowSynced: flags['allow-synced-location'] === true, homedir: homedir ?? undefined, platform })
811
+ const appVaultListShown = { source: list.source, ...(list.reason === undefined ? {} : { reason: list.reason }) }
812
+ const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
813
+ const decided = withDecision(current, 'location', { parent }, { decidedAt: now, decidedBy: accountActor(account()), via: 'command' })
814
+ writeMachineSettings({ ...workspace, repositoryRoots, settings: { ...decided, updatedAt: now } })
815
+ const placed = views(project, enablement, workspace, decided.decisions.location)
816
+ return {
817
+ exit: EXIT.ok, document: { location: decided.decisions.location, warnings, appVaultList: appVaultListShown, views: placed, takesEffect: 'next-tick' },
818
+ human: [
819
+ `where vaults live: ${parent}; a view's vault is allocated there at its first publication, as "${projectDisplayName(project)} (<view>)"`,
820
+ ...(warnings.synced === null ? [] : [`Warning: ${warnings.synced} keeps this folder in step with other machines.`]),
821
+ ...(warnings.protected === null ? [] : [`Warning: macOS asks before Obsidian or the maintenance service may read your ${warnings.protected} folder.`]),
822
+ ...(list.source === 'unread' ? [`Obsidian's vault list could not be read (${list.reason}); no vault is allocated there until it can be, and it is checked again then.`] : []),
823
+ ...lines(placed),
824
+ ],
825
+ }
826
+ },
827
+
828
+ // One more view in the project's configuration. A person at a terminal sees the change and how many notes the view
829
+ // would show, and is asked; --yes answers beforehand. Anyone else gets the change made, and shown.
830
+ async view() {
831
+ if (sub === 'list' || sub === 'show' || sub === undefined) return operations.scope()
832
+ if (sub !== 'add' || value === undefined) refuse('usage', 'view add ID (--all | --folder PATH [--folder PATH ...] [--repo R] | --tag T) [--expand DEPTH:MAX] [--default] [--allow-empty] [--yes]')
833
+ await refuseOnPersonalBinding(loadProject(), 'view add')
834
+ const { project, workspace, workspaceId } = readable()
835
+ const repositories = project.repos.filter((repo) => !repo.external && typeof repo.name === 'string').map((repo) => repo.name)
836
+ const scope = viewFromRequest({ scopeId: value, all: flags.all === true, folders: flags.folder ?? [], repo: flags.repo, tag: flags.tag, expand: flags.expand, repositories })
837
+ const plan = planViewAdd(project, { scope, makeDefault: flags.default === true })
838
+ // Counted as this machine decided who may see; while nobody decided, for "only you", the answer offered first,
839
+ // which shows the notes without a classification too.
840
+ const machine = machineOf(workspace)
841
+ const decided = machine?.decisions.audience ?? null
842
+ const audience = { allow: decided === null ? ONLY_YOU_AUDIENCES : machine.audienceAllow, decided: decided !== null }
843
+ const { eligibilityFor, onlyYouEligibility } = await import('../runtime/obsidian/pipeline.mjs')
844
+ const { viewCounts } = await import('../runtime/obsidian/view-counts.mjs')
845
+ const eligibility = decided === null ? onlyYouEligibility({ project }) : eligibilityFor({ machine, project })
846
+ const counts = viewCounts({ project, audienceAllow: audience.allow, scope, eligibility, ...(workspaceId === null ? {} : { workspaceId }) })
847
+ const countLine = viewCountWords(scope.scopeId, counts, audience)
848
+ // No audience admitted on this machine (`audience clear`): the view is empty whatever it selects, and the way out is
849
+ // deciding who may see, not classifying notes.
850
+ const nobody = audience.decided && audience.allow.length === 0 ? '; no audience is admitted on this machine: `atelier obsidian audience set me` admits yours' : ''
851
+ if (counts.shown === 0 && flags['allow-empty'] !== true) refuse('view-would-be-empty', `${countLine}${nobody}; nothing was written`, { scopeId: scope.scopeId, counts, audience })
852
+ const file = path.basename(project.configPath)
853
+ const shownPlan = [
854
+ `Atelier will add the view "${scope.scopeId}" to ${file}${plan.ignore.needed ? ', and .atelier-local/ to .gitignore' : ''} (you commit it):`,
855
+ ...plan.diff.trimEnd().split('\n').map((line) => ` ${line}`),
856
+ countLine,
857
+ ]
858
+ const asked = interactive && flags.yes !== true
859
+ if (asked) {
860
+ for (const line of shownPlan) stdout(line)
861
+ const yes = await askGoAhead(questioner(), 'Write this change? [Y/n] ')
862
+ if (yes === null) refuse('unanswered', 'no answer came; nothing was written')
863
+ if (!yes) return { exit: EXIT.notSuccess, document: { scope, declined: true, written: [], diff: plan.diff, counts, audience }, human: ['Nothing was written.'] }
864
+ }
865
+ const { written } = writeViewPlan(plan)
866
+ const committed = written.map((item) => path.relative(project.configDir, item).split(path.sep).join('/'))
867
+ // The counts are for "only you" while nobody decided, but the maintenance service publishes nothing until someone
868
+ // does: deciding comes first.
869
+ const openStep = `\`atelier obsidian open${plan.settings.defaultScopeId === scope.scopeId ? '' : ` --scope ${scope.scopeId}`}\` shows it in Obsidian`
870
+ const next = audience.decided ? [openStep] : ['`atelier obsidian audience set me` lets only you see its notes; until someone decides, the view publishes none', openStep]
871
+ return {
872
+ exit: EXIT.ok,
873
+ document: { scope, defaultScopeId: plan.settings.defaultScopeId ?? null, declined: false, written: committed, diff: plan.diff, counts, audience, next },
874
+ human: [
875
+ ...(asked ? [] : shownPlan),
876
+ `Added the view ${scope.scopeId}${plan.settings.defaultScopeId === scope.scopeId ? ', the default' : ''}. Commit ${committed.join(' and ')} when you are ready; Atelier commits nothing.`,
877
+ ...(plan.settings.enabled === true ? [] : ['The projection is turned off in this project ("enabled": false), so nothing is published until it is turned on.']),
878
+ `Next: ${next[0]}`, ...next.slice(1).map((step) => `Then: ${step}`),
879
+ ],
880
+ }
881
+ },
882
+
237
883
  async scope() {
238
884
  const { enablement } = readable()
239
885
  if (sub === 'list' || sub === undefined) return { exit: EXIT.ok, document: { defaultScopeId: enablement.defaultScopeId, scopes: enablement.scopes }, human: enablement.scopes.map((scope) => `${scope.scopeId}\t${scope.mode}${scope.scopeId === enablement.defaultScopeId ? '\tdefault' : ''}`) }
@@ -245,19 +891,32 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
245
891
  async audience() {
246
892
  if (sub === 'show' || sub === undefined) {
247
893
  const { workspace } = readable()
248
- const { audienceAllow } = shownMachine(machineOf(workspace), workspace)
249
- return { exit: EXIT.ok, document: { audienceAllow }, human: [audienceAllow.join(', ') || 'no audience is allowed: every view is empty'] }
894
+ const { audienceAllow, decisions } = shownMachine(machineOf(workspace), workspace)
895
+ return { exit: EXIT.ok, document: { audienceAllow, choice: decisions.audience?.choice ?? null, unclassified: decisions.audience?.unclassified ?? 'withheld' }, human: [decisionWords('audience', decisions.audience, audienceAllow)] }
250
896
  }
251
- if (sub !== 'set' && sub !== 'clear') refuse('usage', 'audience show | audience set A,B | audience clear')
252
- const audienceAllow = sub === 'clear' ? [] : String(value ?? '').split(',').map((item) => item.trim()).filter((item) => item !== '')
253
- if (sub === 'set' && audienceAllow.length === 0) refuse('usage', 'audience set needs at least one audience; `audience clear` allows none')
254
- if (audienceAllow.some((item) => !AUDIENCE.test(item)) || new Set(audienceAllow).size !== audienceAllow.length || audienceAllow.length > 64) refuse('invalid-audience', 'audiences are distinct identifiers, at most 64')
897
+ if (sub !== 'set' && sub !== 'clear') refuse('usage', 'audience show | audience set me|A,B | audience clear')
898
+ const named = sub === 'clear' ? [] : String(value ?? '').split(',').map((item) => item.trim()).filter((item) => item !== '')
899
+ if (sub === 'set' && named.length === 0) refuse('usage', 'audience set needs at least one audience; `audience clear` allows none')
900
+ if (named.some((item) => !AUDIENCE.test(item)) || new Set(named).size !== named.length || named.length > 64) refuse('invalid-audience', 'audiences are distinct identifiers, at most 64')
901
+ // `me` stands for "only you". Named beside other audiences, it adds its own to theirs, and the list is the person's own choice.
902
+ const audienceAllow = [...new Set(named.flatMap((item) => (item === 'me' ? ONLY_YOU_AUDIENCES : [item])))]
903
+ const choice = named.length === 1 && named[0] === 'me' ? 'only-you' : 'custom'
904
+ await refuseOnPersonalBinding(loadProject(), `audience ${sub}`)
255
905
  const { workspace, repositoryRoots, now } = writable()
256
906
  const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
257
- const changed = JSON.stringify(current.audienceAllow) !== JSON.stringify(audienceAllow)
258
- writeMachineSettings({ ...workspace, repositoryRoots, settings: { ...current, audienceAllow, updatedAt: now } })
907
+ // A vault that is only yours shows the notes that carry no classification too; any other list withholds them.
908
+ const unclassified = choice === 'only-you' ? 'shown' : 'withheld'
909
+ const changed = JSON.stringify(current.audienceAllow) !== JSON.stringify(audienceAllow) || (current.decisions.audience?.unclassified ?? 'withheld') !== unclassified
910
+ const decided = withDecision({ ...current, audienceAllow }, 'audience', { choice, unclassified }, { decidedAt: now, decidedBy: accountActor(account()), via: 'command' })
911
+ writeMachineSettings({ ...workspace, repositoryRoots, settings: { ...decided, updatedAt: now } })
259
912
  // The engine compares a digest of these on every tick: a change invalidates every view at the next one.
260
- return { exit: EXIT.ok, document: { audienceAllow, changed, takesEffect: 'next-tick' }, human: [`audiences: ${audienceAllow.join(', ') || 'none'}; views are rebuilt at the next tick`] }
913
+ return {
914
+ exit: EXIT.ok, document: { audienceAllow, choice, unclassified, changed, takesEffect: 'next-tick' },
915
+ human: [
916
+ `audiences: ${choice === 'only-you' ? `only you (${audienceAllow.join(', ')})` : audienceAllow.join(', ') || 'none'}; notes without a classification ${unclassified}; views are rebuilt at the next tick`,
917
+ ...(choice === 'only-you' ? [] : ['Notes without a classification are shown only in a vault that is only yours: `atelier obsidian audience set me`.']),
918
+ ],
919
+ }
261
920
  },
262
921
 
263
922
  async mode() {
@@ -325,44 +984,58 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
325
984
  if (sub === 'start') {
326
985
  configured()
327
986
  const seam = await serviceSeam()
328
- const result = shown(await startService({ ...lifecycle, detached: true, ...seam, ...(consent === undefined ? {} : { consent }) }, lifecycleRules))
987
+ const consent = consentOfThisRun(seam)
988
+ const adapterRemembered = rememberAdapter(consent.consent?.actor)
989
+ const result = shown(await startService({ ...lifecycle, detached: true, ...seam, replaceOutdated: true, ...(consent.consent === undefined ? {} : { consent: consent.consent }) }, lifecycleRules))
990
+ const recorded = consentRecorded(consent)
329
991
  const running = result.state === 'healthy' || result.state === 'busy'
330
- return { exit: running ? EXIT.ok : EXIT.notSuccess, document: { service: result }, human: [result.state === 'busy' ? 'running, and busy in a long tick; nothing was started beside it' : `${result.state}${result.started ? ' (started)' : result.alreadyRunning ? ' (already running)' : ` (${result.reason})`}`] }
992
+ return {
993
+ exit: running ? EXIT.ok : EXIT.notSuccess,
994
+ document: { service: result, rememberedNow: { adapter: adapterRemembered, consentActor: recorded.source === 'account' ? recorded.consent.actor : null } },
995
+ human: [
996
+ result.state === 'busy' && !result.started ? 'running, and busy in a long tick; nothing was started beside it' : serviceWords(result),
997
+ ...(result.release === 'later' ? [`Next: ${REASON_NEXT['service-other-release']}`] : []),
998
+ ...rememberedLines({ adapterRemembered, consent: recorded }), ...loginItemLines(result.loginItem),
999
+ ],
1000
+ }
331
1001
  }
332
1002
  if (sub === 'stop') {
333
1003
  const result = await stopService(lifecycle, lifecycleRules)
334
1004
  return { exit: result.refused ? EXIT.notSuccess : EXIT.ok, document: { service: result }, human: [result.refused ? `not stopped: ${result.state} (${result.reason})${result.retry ? '; ask again in a moment' : ''}` : result.stopped ? 'stopped' : `nothing to stop (${result.reason})`] }
335
1005
  }
336
- if (sub !== 'unit' || flags.print !== true) refuse('usage', 'service start | status | stop | unit --print (installing a startup unit is not offered here)')
337
- const { project, workspace } = readable()
338
- if (workspace === null) refuse('service-workspace-not-prepared', 'this workspace has no private state yet; `service start` prepares it')
339
- const seam = await serviceSeam()
340
- // Pure text from the builder. Nothing is written, installed or handed to a service manager.
341
- const unit = buildStartupAdapter({
342
- platform, label: serviceNameFor(workspace.workspaceId), nodePath: process.execPath, entryPath: seam.entryPath, logPath: servicePaths(workspace.workspaceRoot).log,
343
- args: [`--project=${project.configPath}`, ...(dataRoot === undefined ? [] : [`--data-root=${dataRoot}`]), ...(seam.entryArgs ?? [])],
344
- })
345
- return { exit: EXIT.ok, document: { unit, installed: false, note: 'running this unit needs a recorded consent that covers operating-system startup' }, human: [unit.text] }
1006
+ if (sub !== 'unit' || [flags.print, flags.install, flags.remove].filter((flag) => flag === true).length !== 1) refuse('usage', 'service start | status | stop | unit --print | unit --install | unit --remove')
1007
+ if (flags.install === true) return installUnit()
1008
+ if (flags.remove === true) return removeUnit()
1009
+ return printUnit()
346
1010
  },
347
1011
 
348
1012
  async open() {
349
1013
  const seam = await serviceSeam()
350
1014
  const app = await appSeams()
351
1015
  const { appProbe } = app
1016
+ const consent = consentOfThisRun(seam)
1017
+ const adapterRemembered = rememberAdapter(consent.consent?.actor)
352
1018
  // The view whose plugin may report the app version; an unknown one is refused by open itself.
353
1019
  const requested = () => { try { return resolveScope(readObsidianEnablement(loadProject()), flags.scope) } catch { return null } }
354
1020
  const result = await openScopeForOracleTests({
355
1021
  ...lifecycle, ...app,
356
1022
  appProbe: typeof appProbe?.inspect === 'function' && typeof appProbe?.vaultState === 'function' ? withPluginReportedVersion(appProbe, async () => { const scopeId = requested(); return scopeId === null ? null : pluginOf(scopeId) }) : appProbe,
357
- service: seam, scopeId: flags.scope, consent, allowStale: flags['allow-stale'] === true, extensions: registry.extensions,
1023
+ service: seam, scopeId: flags.scope, consent: consent.consent, allowStale: flags['allow-stale'] === true, restartApp: flags['restart-obsidian'] === true, extensions: registry.extensions,
358
1024
  ...(flags['wait-ms'] === undefined ? {} : { tickTimeoutMs: Number(flags['wait-ms']) || undefined }), ...(options.open ?? {}),
359
1025
  }, openingRules, lifecycleRules)
1026
+ const recorded = consentRecorded(consent)
360
1027
  const { ok: _ok, ...opened } = result
361
- const plugin = typeof result.scopeId === 'string' ? await pluginOf(result.scopeId) : null
362
- const document = plugin === null ? opened : { ...opened, plugin }
1028
+ const plugin = typeof result.scopeId === 'string' ? withPluginFilesPending(await pluginOf(result.scopeId), result.freshness) : null
1029
+ const rememberedNow = { adapter: adapterRemembered, consentActor: recorded.source === 'account' ? recorded.consent.actor : null }
1030
+ const document = plugin === null ? { ...opened, rememberedNow } : { ...opened, plugin, rememberedNow }
363
1031
  return {
364
1032
  exit: result.ok ? EXIT.ok : EXIT.notSuccess, document,
365
- human: [`${result.outcome}: ${result.summary}${result.reason ? ` (${result.reason})` : ''}${plugin ? pluginLine(plugin) : ''}`, `Next: ${result.next}`, ...(result.service?.restarted ? [`service: restarted (${result.service.restarted})`] : []), ...(result.duplicates ? [`open in Obsidian as: ${result.duplicates.map((entry) => entry.path).join(', ')}`] : []), ...(result.pendingEdits?.open ? [`${result.pendingEdits.open} pending edit(s); apply ${result.pendingEdits.apply}`] : [])],
1033
+ human: [
1034
+ `${result.outcome}: ${result.summary}${result.reason ? ` (${result.reason})` : ''}${plugin ? pluginLine(plugin) : ''}`, `Next: ${result.next}`, ...(plugin?.files === 'waits-for-app' ? [`Next for the plugin: ${plugin.next}`] : []), ...(result.service?.restarted ? [`service: restarted (${result.service.restarted})`] : []),
1035
+ ...loginItemLines(result.service?.loginItem), ...restartLines(result.restart), ...obsidianSettingsLines(result.obsidianSettings),
1036
+ ...(result.duplicates ? [`open in Obsidian as: ${result.duplicates.map((entry) => entry.path).join(', ')}`] : []), ...(result.pendingEdits?.open ? [`${result.pendingEdits.open} pending edit(s); apply ${result.pendingEdits.apply}`] : []),
1037
+ ...rememberedLines({ adapterRemembered, consent: recorded }),
1038
+ ],
366
1039
  }
367
1040
  },
368
1041
 
@@ -377,9 +1050,9 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
377
1050
  const scopeId = resolveScope(enablement, flags.scope)
378
1051
  const choice = writePluginChoice({ ...workspace, scopeId, state: 'requested', reason: 'requested-by-command', clock })
379
1052
  // The request is recorded either way; a service that cannot be asked leaves it to the view's next publication.
380
- // With the installed entry to start (`--adapter`), a service of an earlier release still running after an upgrade
1053
+ // With the installed entry to start (`--adapter`, given or remembered), a service of an earlier release still running after an upgrade
381
1054
  // is replaced before its tick, under the consent already recorded, as `open` does; without it, it is reported.
382
- const replaceable = seams !== null || flags.adapter === PRODUCTION_ADAPTER
1055
+ const replaceable = seams !== null || adapterSelected()
383
1056
  let asked
384
1057
  try { asked = await requestServiceTick({ ...lifecycle, scopeId, ...(replaceable ? { service: await serviceSeam() } : {}) }, lifecycleRules) } catch (error) { if (!isTyped(error)) throw error; asked = { requested: false, reason: error.code } }
385
1058
  const view = asked.tick?.scopes?.find((scope) => scope.scopeId === scopeId) ?? null
@@ -413,6 +1086,36 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
413
1086
  }
414
1087
  },
415
1088
 
1089
+ // Maintenance of this workspace ends here: the login item goes first, so its manager does not start the service
1090
+ // again, then the proven service stops. Everything a person made or may want back stays, and is named.
1091
+ async uninstall() {
1092
+ const project = (locateProject ?? loadProject)()
1093
+ let workspace = null
1094
+ try { const found = resolveServiceWorkspace({ project, dataRoot, env, platform }); workspace = found?.workspaceRoot ? found : null } catch (error) { if (!isTyped(error)) throw error }
1095
+ const record = workspace === null ? null : readLoginItemRecord(workspace)
1096
+ // What is kept is read before anything is stopped or removed, so nothing it finds can stop the answer after that.
1097
+ const kept = keptLocations(project, workspace)
1098
+ const item = record === null ? { removed: false, reason: 'not-installed' } : await removeLoginItem({ loadProject: locateProject ?? loadProject, dataRoot, env, platform, manager: await managerSeam(), clock })
1099
+ const service = workspace === null ? { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' } : await stopService(lifecycle, lifecycleRules)
1100
+ const itemGone = item.removed === true || item.reason === 'not-installed' || item.reason === 'workspace-not-prepared'
1101
+ const serviceGone = service.stopped === true || service.state === 'stopped'
1102
+ const remembered = workspace !== null && itemGone ? rememberLoginItem('off') : false
1103
+ return {
1104
+ exit: itemGone && serviceGone ? EXIT.ok : EXIT.notSuccess,
1105
+ document: { loginItem: item, service, kept, rememberedNow: { loginItem: remembered ? 'off' : null } },
1106
+ human: [
1107
+ `login item: ${item.removed ? `removed (${item.file})` : item.reason === 'not-installed' || item.reason === 'workspace-not-prepared' ? 'none was installed' : `not removed (${item.reason})`}`,
1108
+ `service: ${service.stopped ? 'stopped' : service.refused ? `not stopped: ${service.state} (${service.reason})` : `not running (${service.reason})`}`,
1109
+ ...(itemGone && serviceGone ? [] : [`Next: ${!itemGone ? NEXT[item.reason] ?? 'run `atelier obsidian uninstall` again' : service.retry ? 'run `atelier obsidian uninstall` again in a moment' : 'see `atelier obsidian service status`'}`]),
1110
+ 'Kept as they are:',
1111
+ ...kept.vaults.map((vault) => ` vault ${vault}`),
1112
+ ...(kept.privateState === null ? [] : [` private state ${kept.privateState}`]),
1113
+ ` project file ${kept.projectFile ?? '(none)'}; its Obsidian settings stay`,
1114
+ ...(kept.obsidianList === null ? [] : [` Obsidian's list ${kept.obsidianList}; the vaults stay in it`]),
1115
+ ],
1116
+ }
1117
+ },
1118
+
416
1119
  // The placeholder that answers when no contribution registered an apply operation; the shipped source-apply contribution replaces it.
417
1120
  async apply() {
418
1121
  return refuse(APPLY_UNAVAILABLE, 'no apply operation is registered; pending edits stay preserved and queued', { operationId: registry.extensions.applyOperation().id })
@@ -424,6 +1127,8 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
424
1127
  // One option table serves every operation. No built-in operation has a use for an actor, and a contributed one takes
425
1128
  // it only when it declares so (`options: ['actor']`); the apply operation then decides which of its verbs takes it.
426
1129
  if (flags.actor !== undefined && !(Array.isArray(contributed?.options) && contributed.options.includes('actor'))) refuse('usage', '--actor belongs to `apply run`; this operation does not take it')
1130
+ // Stopping the person's app is asked for one run of `open`, by name, and by nothing else.
1131
+ if (flags['restart-obsidian'] === true && operationName !== 'open') refuse('usage', '--restart-obsidian belongs to `open`; this operation does not take it')
427
1132
  const result = contributed !== null
428
1133
  ? await contributed.run({ args: positionals.slice(1), flags: { ...flags }, registry, loadProject, dataRoot, env, platform, clock, readable, writable })
429
1134
  : await operations[operationName]()
@@ -436,6 +1141,8 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
436
1141
  }
437
1142
  const next = NEXT[error.code] ?? error.hint ?? 'see `atelier obsidian status`'
438
1143
  return emit({ exit: EXIT.refused, document: { error: { code: error.code, message: plainMessage(error), next, detail: error.detail ?? {} } }, human: [`[${error.code}] ${plainMessage(error)}`, `Next: ${next}`] })
1144
+ } finally {
1145
+ questions?.close()
439
1146
  }
440
1147
  }
441
1148