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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (516) hide show
  1. package/CHANGELOG.md +661 -0
  2. package/README.md +142 -6
  3. package/contracts/atelier-adoption-policy.v2.schema.json +55 -0
  4. package/contracts/atelier-architecture.v1.schema.json +258 -0
  5. package/contracts/atelier-boundary-delegations.v1.schema.json +161 -0
  6. package/contracts/atelier-boundary-owner-keys.v1.schema.json +98 -0
  7. package/contracts/atelier-build.v1.schema.json +1076 -0
  8. package/contracts/atelier-capability.v1.schema.json +1252 -0
  9. package/contracts/atelier-coordination.v1.schema.json +1050 -0
  10. package/contracts/atelier-evidence-navigation.v1.schema.json +603 -0
  11. package/contracts/atelier-harness.v1.schema.json +234 -0
  12. package/contracts/atelier-ingestion.v1.schema.json +806 -0
  13. package/contracts/atelier-inquiry.v1.schema.json +1772 -0
  14. package/contracts/atelier-instruction-adoption.v1.schema.json +490 -0
  15. package/contracts/atelier-interaction.v1.schema.json +553 -0
  16. package/contracts/atelier-judgment.v1.schema.json +690 -0
  17. package/contracts/atelier-knowledge.v1.schema.json +1512 -0
  18. package/contracts/atelier-learning.v1.schema.json +1095 -0
  19. package/contracts/atelier-migration.v3.schema.json +64 -0
  20. package/contracts/atelier-personal-workspace-manifest.v1.schema.json +87 -0
  21. package/contracts/atelier-personal-workspace-overlay.v1.schema.json +299 -0
  22. package/contracts/atelier-template-adoption.v1.schema.json +223 -0
  23. package/contracts/atelier-template-profile.v1.schema.json +1168 -0
  24. package/contracts/atelier-trackable.v1.schema.json +781 -0
  25. package/contracts/atelier-upgrade-plan.v3.schema.json +532 -0
  26. package/docs/architecture.md +89 -0
  27. package/docs/assurance-controls.md +2 -2
  28. package/docs/blocks/capability-stewardship.md +17 -0
  29. package/docs/blocks/claims.md +16 -3
  30. package/docs/blocks/inquiry-harnesses.md +19 -0
  31. package/docs/blocks/learning-harnesses.md +17 -0
  32. package/docs/capability-stewardship.md +269 -0
  33. package/docs/coordination.md +51 -0
  34. package/docs/decision-practices.md +111 -0
  35. package/docs/decisions.md +29 -0
  36. package/docs/evidence-navigation.md +237 -0
  37. package/docs/exact-upgrades.md +3 -2
  38. package/docs/ingestion.md +181 -0
  39. package/docs/inquiry-harnesses.md +211 -0
  40. package/docs/install.md +80 -3
  41. package/docs/intake-and-guides.md +5 -0
  42. package/docs/integration-contract-decisions.md +24 -2
  43. package/docs/interaction-and-reflection.md +72 -0
  44. package/docs/knowledge-graph.md +20 -0
  45. package/docs/knowledge-health/api.md +30 -0
  46. package/docs/knowledge-health/assessment/api.md +96 -0
  47. package/docs/knowledge-health/assessment/capabilities.md +40 -0
  48. package/docs/knowledge-health/assessment/evaluation.md +27 -0
  49. package/docs/knowledge-health/assessment/facilitator.md +30 -0
  50. package/docs/knowledge-health/assessment/quickstart.md +61 -0
  51. package/docs/knowledge-health/assessment/troubleshooting.md +23 -0
  52. package/docs/knowledge-health/devday/facilitator.md +16 -0
  53. package/docs/knowledge-health/devday/participant-response.md +22 -0
  54. package/docs/knowledge-health/devday/public-default.md +107 -0
  55. package/docs/knowledge-health/devday/quickstart.md +18 -0
  56. package/docs/knowledge-health/devday/support.md +22 -0
  57. package/docs/knowledge-health/exercise.md +17 -0
  58. package/docs/knowledge-health/facilitator.md +20 -0
  59. package/docs/knowledge-health/quickstart.md +94 -0
  60. package/docs/knowledge-health/readme.md +15 -0
  61. package/docs/knowledge-health/support.md +34 -0
  62. package/docs/knowledge-setup.md +248 -0
  63. package/docs/knowledge-workspace.md +264 -0
  64. package/docs/learning-harnesses.md +238 -0
  65. package/docs/learning.md +215 -0
  66. package/docs/local-services.md +181 -16
  67. package/docs/obsidian-contract.md +223 -15
  68. package/docs/obsidian-plugin.md +38 -4
  69. package/docs/obsidian.md +613 -51
  70. package/docs/optional-ingestion.md +83 -0
  71. package/docs/personal-workspace.md +257 -0
  72. package/docs/practical-judgment.md +92 -0
  73. package/docs/progressive-ingestion-acceptance.md +89 -0
  74. package/docs/progressive-ingestion.md +235 -0
  75. package/docs/project-command-inventory.md +1 -0
  76. package/docs/release-engineering.md +49 -0
  77. package/docs/repo-boundary-guard.md +94 -0
  78. package/docs/research-starter-quickstart.md +66 -0
  79. package/docs/research-starter.md +114 -0
  80. package/docs/semantic-ingestion.md +198 -0
  81. package/docs/semantic-operation.md +317 -0
  82. package/docs/skill-steward.md +6 -0
  83. package/docs/template-profile.md +164 -0
  84. package/docs/template-upgrades.md +114 -0
  85. package/docs/trackables.md +111 -0
  86. package/docs/upgrade.md +47 -2
  87. package/fixtures/atelier-architecture/binding/invalid/document.json +19 -0
  88. package/fixtures/atelier-architecture/binding/valid/document.json +19 -0
  89. package/fixtures/atelier-architecture/catalog/invalid/document.json +290 -0
  90. package/fixtures/atelier-architecture/catalog/valid/document.json +343 -0
  91. package/fixtures/atelier-build-contract/attempt/invalid/document.json +20 -0
  92. package/fixtures/atelier-build-contract/attempt/valid/document.json +19 -0
  93. package/fixtures/atelier-build-contract/candidate/invalid/document.json +24 -0
  94. package/fixtures/atelier-build-contract/candidate/valid/document.json +23 -0
  95. package/fixtures/atelier-build-contract/decision/invalid/document.json +31 -0
  96. package/fixtures/atelier-build-contract/decision/valid/document.json +30 -0
  97. package/fixtures/atelier-build-contract/delivery/invalid/document.json +18 -0
  98. package/fixtures/atelier-build-contract/delivery/valid/document.json +17 -0
  99. package/fixtures/atelier-build-contract/gate/invalid/document.json +22 -0
  100. package/fixtures/atelier-build-contract/gate/valid/document.json +21 -0
  101. package/fixtures/atelier-build-contract/ledger/invalid/document.json +270 -0
  102. package/fixtures/atelier-build-contract/ledger/valid/document.json +269 -0
  103. package/fixtures/atelier-build-contract/objective/invalid/document.json +73 -0
  104. package/fixtures/atelier-build-contract/objective/valid/document.json +72 -0
  105. package/fixtures/atelier-build-contract/progress/invalid/document.json +18 -0
  106. package/fixtures/atelier-build-contract/progress/valid/document.json +17 -0
  107. package/fixtures/atelier-capability-contract/adoption/invalid/document.json +71 -0
  108. package/fixtures/atelier-capability-contract/adoption/valid/document.json +70 -0
  109. package/fixtures/atelier-capability-contract/event/invalid/document.json +18 -0
  110. package/fixtures/atelier-capability-contract/event/valid/document.json +17 -0
  111. package/fixtures/atelier-capability-contract/journal/invalid/document.json +382 -0
  112. package/fixtures/atelier-capability-contract/journal/valid/document.json +381 -0
  113. package/fixtures/atelier-capability-contract/notice/invalid/document.json +8 -0
  114. package/fixtures/atelier-capability-contract/notice/valid/document.json +7 -0
  115. package/fixtures/atelier-capability-contract/package/invalid/document.json +59 -0
  116. package/fixtures/atelier-capability-contract/package/valid/document.json +58 -0
  117. package/fixtures/atelier-capability-contract/plan/invalid/document.json +107 -0
  118. package/fixtures/atelier-capability-contract/plan/valid/document.json +106 -0
  119. package/fixtures/atelier-capability-contract/release/invalid/document.json +85 -0
  120. package/fixtures/atelier-capability-contract/release/valid/document.json +84 -0
  121. package/fixtures/atelier-capability-contract/state/invalid/document.json +316 -0
  122. package/fixtures/atelier-capability-contract/state/valid/document.json +315 -0
  123. package/fixtures/atelier-coordination/dependency/invalid/document.json +37 -0
  124. package/fixtures/atelier-coordination/dependency/valid/document.json +36 -0
  125. package/fixtures/atelier-coordination/directive/invalid/document.json +25 -0
  126. package/fixtures/atelier-coordination/directive/valid/document.json +24 -0
  127. package/fixtures/atelier-coordination/disposition/invalid/document.json +34 -0
  128. package/fixtures/atelier-coordination/disposition/valid/document.json +33 -0
  129. package/fixtures/atelier-coordination/outcome/invalid/document.json +39 -0
  130. package/fixtures/atelier-coordination/outcome/valid/document.json +38 -0
  131. package/fixtures/atelier-coordination/recovery/invalid/document.json +34 -0
  132. package/fixtures/atelier-coordination/recovery/valid/document.json +33 -0
  133. package/fixtures/atelier-harness-contract/handoff/invalid/document.json +25 -0
  134. package/fixtures/atelier-harness-contract/handoff/valid/document.json +24 -0
  135. package/fixtures/atelier-ingestion/invalid/authority.json +19 -0
  136. package/fixtures/atelier-ingestion/semantic-baseline.json +26 -0
  137. package/fixtures/atelier-ingestion/semantic-operation.json +75 -0
  138. package/fixtures/atelier-ingestion/valid/plan-input.json +18 -0
  139. package/fixtures/atelier-inquiry-contract/assessment/invalid/document.json +41 -0
  140. package/fixtures/atelier-inquiry-contract/assessment/valid/document.json +40 -0
  141. package/fixtures/atelier-inquiry-contract/bundle/invalid/document.json +51 -0
  142. package/fixtures/atelier-inquiry-contract/bundle/valid/document.json +50 -0
  143. package/fixtures/atelier-inquiry-contract/campaign/invalid/document.json +17 -0
  144. package/fixtures/atelier-inquiry-contract/campaign/valid/document.json +16 -0
  145. package/fixtures/atelier-inquiry-contract/decision/invalid/document.json +22 -0
  146. package/fixtures/atelier-inquiry-contract/decision/valid/document.json +21 -0
  147. package/fixtures/atelier-inquiry-contract/feedback/invalid/document.json +19 -0
  148. package/fixtures/atelier-inquiry-contract/feedback/valid/document.json +18 -0
  149. package/fixtures/atelier-inquiry-contract/handoff/invalid/document.json +92 -0
  150. package/fixtures/atelier-inquiry-contract/handoff/valid/document.json +91 -0
  151. package/fixtures/atelier-inquiry-contract/hypothesis/invalid/document.json +20 -0
  152. package/fixtures/atelier-inquiry-contract/hypothesis/valid/document.json +19 -0
  153. package/fixtures/atelier-inquiry-contract/ledger/invalid/document.json +680 -0
  154. package/fixtures/atelier-inquiry-contract/ledger/valid/document.json +679 -0
  155. package/fixtures/atelier-inquiry-contract/legacy-assessment/invalid/document.json +19 -0
  156. package/fixtures/atelier-inquiry-contract/legacy-assessment/valid/document.json +18 -0
  157. package/fixtures/atelier-inquiry-contract/request/invalid/document.json +50 -0
  158. package/fixtures/atelier-inquiry-contract/request/valid/document.json +49 -0
  159. package/fixtures/atelier-inquiry-contract/source/invalid/document.json +18 -0
  160. package/fixtures/atelier-inquiry-contract/source/valid/document.json +17 -0
  161. package/fixtures/atelier-inquiry-contract/withdrawal/invalid/document.json +16 -0
  162. package/fixtures/atelier-inquiry-contract/withdrawal/valid/document.json +15 -0
  163. package/fixtures/atelier-instruction-adoption/context-receipt/invalid/document.json +18 -0
  164. package/fixtures/atelier-instruction-adoption/context-receipt/valid/document.json +17 -0
  165. package/fixtures/atelier-instruction-adoption/plan/invalid/document.json +63 -0
  166. package/fixtures/atelier-instruction-adoption/plan/valid/document.json +62 -0
  167. package/fixtures/atelier-instruction-adoption/receipt/invalid/document.json +22 -0
  168. package/fixtures/atelier-instruction-adoption/receipt/valid/document.json +21 -0
  169. package/fixtures/atelier-instruction-adoption/state/invalid/document.json +30 -0
  170. package/fixtures/atelier-instruction-adoption/state/valid/document.json +29 -0
  171. package/fixtures/atelier-interaction/act/invalid/document.json +25 -0
  172. package/fixtures/atelier-interaction/act/valid/document.json +24 -0
  173. package/fixtures/atelier-interaction/assessment/invalid/document.json +32 -0
  174. package/fixtures/atelier-interaction/assessment/valid/document.json +31 -0
  175. package/fixtures/atelier-interaction/observation/invalid/document.json +22 -0
  176. package/fixtures/atelier-interaction/observation/valid/document.json +21 -0
  177. package/fixtures/atelier-interaction/policy/invalid/document.json +20 -0
  178. package/fixtures/atelier-interaction/policy/valid/document.json +19 -0
  179. package/fixtures/atelier-judgment/case/invalid/document.json +76 -0
  180. package/fixtures/atelier-judgment/case/valid/document.json +75 -0
  181. package/fixtures/atelier-judgment/decision/invalid/document.json +38 -0
  182. package/fixtures/atelier-judgment/decision/valid/document.json +37 -0
  183. package/fixtures/atelier-judgment/native-decision.md +3 -0
  184. package/fixtures/atelier-judgment/practice-reconsideration/definition.json +92 -0
  185. package/fixtures/atelier-judgment/practice-reconsideration/scenario.json +435 -0
  186. package/fixtures/atelier-knowledge-contract/activation/invalid/document.json +26 -0
  187. package/fixtures/atelier-knowledge-contract/activation/valid/document.json +25 -0
  188. package/fixtures/atelier-knowledge-contract/contribution/invalid/document.json +34 -0
  189. package/fixtures/atelier-knowledge-contract/contribution/valid/document.json +33 -0
  190. package/fixtures/atelier-knowledge-contract/domain/invalid/document.json +41 -0
  191. package/fixtures/atelier-knowledge-contract/domain/valid/document.json +40 -0
  192. package/fixtures/atelier-knowledge-contract/domain-revision/invalid/document.json +46 -0
  193. package/fixtures/atelier-knowledge-contract/domain-revision/valid/document.json +45 -0
  194. package/fixtures/atelier-knowledge-contract/evaluation/invalid/document.json +21 -0
  195. package/fixtures/atelier-knowledge-contract/evaluation/valid/document.json +20 -0
  196. package/fixtures/atelier-knowledge-contract/ledger/invalid/document.json +318 -0
  197. package/fixtures/atelier-knowledge-contract/ledger/valid/document.json +317 -0
  198. package/fixtures/atelier-knowledge-contract/relation/invalid/document.json +25 -0
  199. package/fixtures/atelier-knowledge-contract/relation/valid/document.json +24 -0
  200. package/fixtures/atelier-knowledge-contract/review/invalid/document.json +18 -0
  201. package/fixtures/atelier-knowledge-contract/review/valid/document.json +17 -0
  202. package/fixtures/atelier-knowledge-contract/withdrawal/invalid/document.json +16 -0
  203. package/fixtures/atelier-knowledge-contract/withdrawal/valid/document.json +15 -0
  204. package/fixtures/atelier-learning/invalid/authority.json +14 -0
  205. package/fixtures/atelier-learning/valid/capture.json +13 -0
  206. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/authority-key.json +17 -0
  207. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/nonempty-ext.json +25 -0
  208. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/wrong-schema.json +22 -0
  209. package/fixtures/atelier-personal-workspace-contract/manifest/valid/manifest.json +22 -0
  210. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/authority-key.json +25 -0
  211. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/unknown-top-level-field.json +27 -0
  212. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/wrong-schema.json +24 -0
  213. package/fixtures/atelier-trackable/definition/invalid/document.json +15 -0
  214. package/fixtures/atelier-trackable/definition/valid/document.json +14 -0
  215. package/fixtures/atelier-trackable/request/invalid/document.json +23 -0
  216. package/fixtures/atelier-trackable/request/valid/document.json +22 -0
  217. package/fixtures/boundary-delegations/invalid/authority-key.v1.json +30 -0
  218. package/fixtures/boundary-delegations/invalid/push-operation.v1.json +28 -0
  219. package/fixtures/boundary-delegations/invalid/unsigned.v1.json +25 -0
  220. package/fixtures/boundary-delegations/valid/empty.v1.json +4 -0
  221. package/fixtures/boundary-delegations/valid/signed-operator.v1.json +30 -0
  222. package/fixtures/boundary-owner-keys/invalid/wrong-algorithm.v1.json +15 -0
  223. package/fixtures/boundary-owner-keys/invalid/wrong-curve.v1.json +15 -0
  224. package/fixtures/boundary-owner-keys/valid/one-owner.v1.json +17 -0
  225. package/fixtures/capability-packages/adoption.example.json +70 -0
  226. package/fixtures/capability-packages/evidence-review/capability-package.json +58 -0
  227. package/fixtures/capability-packages/evidence-review/capability-release.json +84 -0
  228. package/fixtures/capability-packages/evidence-review/evaluation.md +15 -0
  229. package/fixtures/capability-packages/evidence-review/skills/evidence-review/SKILL.md +22 -0
  230. package/fixtures/capability-packages/evidence-review/skills/evidence-review/references/ledger.md +7 -0
  231. package/fixtures/capability-packages/research/capability-package.json +88 -0
  232. package/fixtures/capability-packages/research/capability-release.json +134 -0
  233. package/fixtures/capability-packages/research/evaluation.md +15 -0
  234. package/fixtures/capability-packages/research/skills/research-integrate/SKILL.md +29 -0
  235. package/fixtures/capability-packages/research/skills/research-integrate/references/integration.md +10 -0
  236. package/fixtures/capability-packages/research/skills/research-plan/SKILL.md +27 -0
  237. package/fixtures/capability-packages/research/skills/research-plan/references/inquiries.md +26 -0
  238. package/fixtures/capability-packages/research/skills/research-synthesize/SKILL.md +23 -0
  239. package/fixtures/capability-packages/research/skills/research-synthesize/references/synthesis.md +12 -0
  240. package/fixtures/evidence-navigation/invalid/duplicate-operation.json +16 -0
  241. package/fixtures/evidence-navigation/invalid/instruction-kind.json +36 -0
  242. package/fixtures/evidence-navigation/invalid/invalid-digest.json +36 -0
  243. package/fixtures/evidence-navigation/invalid/invalid-time.json +36 -0
  244. package/fixtures/evidence-navigation/invalid/missing-attribution.json +35 -0
  245. package/fixtures/evidence-navigation/invalid/reserved-extension.json +19 -0
  246. package/fixtures/evidence-navigation/invalid/unattributed-acceptance.json +36 -0
  247. package/fixtures/evidence-navigation/invalid/uncoerced-version.json +16 -0
  248. package/fixtures/evidence-navigation/invalid/unknown-currency.json +17 -0
  249. package/fixtures/evidence-navigation/invalid/unknown-selector-field.json +37 -0
  250. package/fixtures/evidence-navigation/invalid/unsupported-operation.json +15 -0
  251. package/fixtures/evidence-navigation/valid/claim.json +36 -0
  252. package/fixtures/evidence-navigation/valid/host.json +16 -0
  253. package/fixtures/evidence-navigation/valid/profile.json +16 -0
  254. package/fixtures/evidence-navigation/valid/snapshot.json +17 -0
  255. package/fixtures/harness-packages/build-harness/capability-package.json +61 -0
  256. package/fixtures/harness-packages/build-harness/capability-release.json +82 -0
  257. package/fixtures/harness-packages/build-harness/evaluation.md +3 -0
  258. package/fixtures/harness-packages/build-harness/skills/atelier-build-harness/SKILL.md +59 -0
  259. package/fixtures/harness-packages/knowledge-harness/capability-package.json +61 -0
  260. package/fixtures/harness-packages/knowledge-harness/capability-release.json +82 -0
  261. package/fixtures/harness-packages/knowledge-harness/evaluation.md +3 -0
  262. package/fixtures/harness-packages/knowledge-harness/skills/atelier-knowledge-harness/SKILL.md +60 -0
  263. package/fixtures/harnesses/learning-cycle.json +780 -0
  264. package/fixtures/inquiry/workshop.json +675 -0
  265. package/fixtures/inquiry-packages/discovery-harness/capability-package.json +61 -0
  266. package/fixtures/inquiry-packages/discovery-harness/capability-release.json +82 -0
  267. package/fixtures/inquiry-packages/discovery-harness/evaluation.md +3 -0
  268. package/fixtures/inquiry-packages/discovery-harness/skills/atelier-discovery-harness/SKILL.md +48 -0
  269. package/fixtures/inquiry-packages/research-harness/capability-package.json +61 -0
  270. package/fixtures/inquiry-packages/research-harness/capability-release.json +82 -0
  271. package/fixtures/inquiry-packages/research-harness/evaluation.md +3 -0
  272. package/fixtures/inquiry-packages/research-harness/skills/atelier-research-harness/SKILL.md +43 -0
  273. package/fixtures/knowledge-health/calibration-suite.json +26 -0
  274. package/fixtures/knowledge-health/calibration-trial.json +37 -0
  275. package/fixtures/knowledge-health/corrected/checklist.md +18 -0
  276. package/fixtures/knowledge-health/devday/participant-record.json +19 -0
  277. package/fixtures/knowledge-health/expected-summary.json +27 -0
  278. package/fixtures/knowledge-health/workshop/README.md +20 -0
  279. package/fixtures/knowledge-health/workshop/atelier.project.json +7 -0
  280. package/fixtures/knowledge-health/workshop/knowledge-plan.json +65 -0
  281. package/fixtures/knowledge-health/workshop/records/checklist.md +18 -0
  282. package/fixtures/knowledge-health/workshop/records/workshop.md +18 -0
  283. package/fixtures/knowledge-health/workshop/repo-access.v1.json +5 -0
  284. package/fixtures/knowledge-health/workspace/atelier.project.json +18 -0
  285. package/fixtures/knowledge-health/workspace/knowledge-plan.json +74 -0
  286. package/fixtures/knowledge-health/workspace/records/checklist.md +18 -0
  287. package/fixtures/knowledge-health/workspace/records/pilot.md +16 -0
  288. package/fixtures/knowledge-health/workspace/repo-access.v1.json +9 -0
  289. package/fixtures/personal-workspace/overlay.json +24 -0
  290. package/fixtures/templates/local-library.v1.json +75 -0
  291. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/invalid.json +16 -0
  292. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/valid.json +13 -0
  293. package/fixtures/templates/upgrade-contracts/migration.v3/invalid.json +24 -0
  294. package/fixtures/templates/upgrade-contracts/migration.v3/valid.json +21 -0
  295. package/fixtures/templates/upgrade-contracts/template-adoption.v1/invalid.json +60 -0
  296. package/fixtures/templates/upgrade-contracts/template-adoption.v1/valid.json +57 -0
  297. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/invalid.json +85 -0
  298. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/valid.json +82 -0
  299. package/package.json +59 -5
  300. package/skills/claude/atelier-build-harness/SKILL.md +59 -0
  301. package/skills/claude/atelier-coordination/SKILL.md +32 -0
  302. package/skills/claude/atelier-discovery-harness/SKILL.md +48 -0
  303. package/skills/claude/atelier-guided-upgrade/SKILL.md +29 -0
  304. package/skills/claude/atelier-interaction/SKILL.md +34 -0
  305. package/skills/claude/atelier-knowledge-harness/SKILL.md +60 -0
  306. package/skills/claude/atelier-knowledge-setup/SKILL.md +72 -0
  307. package/skills/claude/atelier-practical-judgment/SKILL.md +64 -0
  308. package/skills/claude/atelier-research-harness/SKILL.md +43 -0
  309. package/skills/claude/atelier-skill-steward/SKILL.md +53 -0
  310. package/skills/claude/atelier-trackables/SKILL.md +40 -0
  311. package/skills/codex/atelier-build-harness/SKILL.md +59 -0
  312. package/skills/codex/atelier-coordination/SKILL.md +32 -0
  313. package/skills/codex/atelier-discovery-harness/SKILL.md +48 -0
  314. package/skills/codex/atelier-guided-upgrade/SKILL.md +29 -0
  315. package/skills/codex/atelier-interaction/SKILL.md +34 -0
  316. package/skills/codex/atelier-knowledge-harness/SKILL.md +60 -0
  317. package/skills/codex/atelier-knowledge-setup/SKILL.md +72 -0
  318. package/skills/codex/atelier-practical-judgment/SKILL.md +64 -0
  319. package/skills/codex/atelier-research-harness/SKILL.md +43 -0
  320. package/skills/codex/atelier-skill-steward/SKILL.md +53 -0
  321. package/skills/codex/atelier-trackables/SKILL.md +40 -0
  322. package/src/architecture/catalog.mjs +355 -0
  323. package/src/architecture/index.mjs +93 -0
  324. package/src/boundary/content-rules.mjs +4 -2
  325. package/src/boundary/delegation-command.mjs +158 -0
  326. package/src/boundary/delegation.mjs +396 -0
  327. package/src/boundary/policy.mjs +52 -10
  328. package/src/build/git.mjs +18 -0
  329. package/src/build/index.mjs +3 -0
  330. package/src/build/ledger.mjs +88 -0
  331. package/src/capabilities/adoption.mjs +381 -0
  332. package/src/capabilities/evidence.mjs +140 -0
  333. package/src/capabilities/files.mjs +124 -0
  334. package/src/capabilities/index.mjs +4 -0
  335. package/src/capabilities/instructions.mjs +250 -0
  336. package/src/capabilities/package.mjs +136 -0
  337. package/src/cli/command-failure.mjs +36 -0
  338. package/src/cli/execute-command.mjs +16 -2
  339. package/src/cli/run.mjs +65 -9
  340. package/src/coauthor/store.mjs +43 -2
  341. package/src/collaboration/proposals.mjs +189 -0
  342. package/src/commands/architecture.mjs +27 -0
  343. package/src/commands/boundary.mjs +3 -0
  344. package/src/commands/capability.mjs +73 -0
  345. package/src/commands/enroll.mjs +3 -0
  346. package/src/commands/harness.mjs +79 -0
  347. package/src/commands/ingest.mjs +39 -0
  348. package/src/commands/init.mjs +4 -1
  349. package/src/commands/inquiry.mjs +66 -0
  350. package/src/commands/knowledge-semantic.mjs +128 -0
  351. package/src/commands/knowledge.mjs +119 -0
  352. package/src/commands/learn.mjs +61 -0
  353. package/src/commands/obsidian.mjs +754 -63
  354. package/src/commands/practice.mjs +24 -0
  355. package/src/commands/setup.mjs +22 -2
  356. package/src/commands/trackable.mjs +19 -0
  357. package/src/contracts/corpus.mjs +30 -0
  358. package/src/coordination/index.mjs +110 -0
  359. package/src/evidence-navigation/contracts.mjs +60 -0
  360. package/src/evidence-navigation/evaluate.mjs +69 -0
  361. package/src/evidence-navigation/generation.mjs +22 -0
  362. package/src/evidence-navigation/index.mjs +2 -0
  363. package/src/evidence-navigation/local.mjs +167 -0
  364. package/src/evidence-navigation/schema.generated.mjs +604 -0
  365. package/src/evidence-navigation/types.mjs +12 -0
  366. package/src/export/atelier-export-contract.mjs +35 -8
  367. package/src/graph/enroll.mjs +248 -0
  368. package/src/graph/graph.mjs +17 -1
  369. package/src/graph/knowledge-graph.mjs +58 -8
  370. package/src/harnesses/contracts.mjs +41 -0
  371. package/src/harnesses/exchange.mjs +154 -0
  372. package/src/harnesses/feedback.mjs +16 -0
  373. package/src/harnesses/history.mjs +37 -0
  374. package/src/harnesses/index.mjs +4 -0
  375. package/src/harnesses/store.mjs +29 -0
  376. package/src/ingestion/contracts.mjs +41 -0
  377. package/src/ingestion/evaluation.mjs +119 -0
  378. package/src/ingestion/optional/composition/index.mjs +171 -0
  379. package/src/ingestion/optional/evaluation/index.mjs +219 -0
  380. package/src/ingestion/optional/graphify/composition-binding.mjs +60 -0
  381. package/src/ingestion/optional/graphify/composition_receiving.py +185 -0
  382. package/src/ingestion/optional/graphify/graphify_supplier.py +933 -0
  383. package/src/ingestion/optional/graphify/json_bridge.py +163 -0
  384. package/src/ingestion/optional/graphify/query_receiving.py +209 -0
  385. package/src/ingestion/optional/index.mjs +9 -0
  386. package/src/ingestion/optional/jev/bridge.mjs +166 -0
  387. package/src/ingestion/optional/jev/composition-binding.mjs +116 -0
  388. package/src/ingestion/optional/jev/judgments.mjs +174 -0
  389. package/src/ingestion/optional/jev/native-translation.mjs +32 -0
  390. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/__init__.py +7 -0
  391. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/adapter.py +966 -0
  392. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/artifacts.py +101 -0
  393. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/projection.py +146 -0
  394. package/src/ingestion/optional/lightrag/composition-binding.mjs +67 -0
  395. package/src/ingestion/optional/vanilla/composition-binding.mjs +102 -0
  396. package/src/ingestion/optional/vanilla/index.mjs +218 -0
  397. package/src/ingestion/optional/vanilla/native-normalization.mjs +187 -0
  398. package/src/ingestion/processors.mjs +218 -0
  399. package/src/ingestion/semantic.mjs +203 -0
  400. package/src/ingestion/store.mjs +372 -0
  401. package/src/inquiry/beliefs.mjs +32 -0
  402. package/src/inquiry/contracts.mjs +23 -0
  403. package/src/inquiry/index.mjs +5 -0
  404. package/src/inquiry/ledger.mjs +135 -0
  405. package/src/inquiry/lenses.mjs +15 -0
  406. package/src/inquiry/projection.mjs +77 -0
  407. package/src/inquiry/store.mjs +35 -0
  408. package/src/intake/read-scope.mjs +12 -0
  409. package/src/intake/store.mjs +69 -6
  410. package/src/interaction/index.mjs +91 -0
  411. package/src/judgment/index.mjs +82 -0
  412. package/src/judgment/practice-evaluation.mjs +70 -0
  413. package/src/judgment/practice.mjs +144 -0
  414. package/src/knowledge/context.mjs +217 -0
  415. package/src/knowledge/index.mjs +5 -0
  416. package/src/knowledge/ingestion.mjs +79 -0
  417. package/src/knowledge/intake.mjs +33 -0
  418. package/src/knowledge/ledger.mjs +110 -0
  419. package/src/knowledge/participatory/guidance.mjs +36 -0
  420. package/src/knowledge/participatory/guided-contribution.mjs +44 -0
  421. package/src/knowledge/participatory/index.mjs +2 -0
  422. package/src/knowledge/participatory/installed-workshop.mjs +108 -0
  423. package/src/knowledge/plan.mjs +67 -0
  424. package/src/knowledge/projection.mjs +43 -0
  425. package/src/knowledge/semantic-operation.mjs +567 -0
  426. package/src/knowledge/sessions.mjs +467 -0
  427. package/src/knowledge/workspace.mjs +295 -0
  428. package/src/knowledge-health/assessment.mjs +165 -0
  429. package/src/knowledge-health/capabilities.mjs +350 -0
  430. package/src/knowledge-health/context.mjs +62 -0
  431. package/src/knowledge-health/example.mjs +47 -0
  432. package/src/knowledge-health/index.mjs +10 -0
  433. package/src/knowledge-health/owner-join.mjs +88 -0
  434. package/src/knowledge-health/proof/common.mjs +24 -0
  435. package/src/knowledge-health/proof/installed-check.mjs +100 -0
  436. package/src/knowledge-health/proof/native-readback.mjs +37 -0
  437. package/src/knowledge-health/proof/rehearse.mjs +68 -0
  438. package/src/knowledge-health/proof/validate-participant.mjs +17 -0
  439. package/src/knowledge-health/proof/verify-native-capture.mjs +14 -0
  440. package/src/knowledge-health/proof/workshop-check.mjs +36 -0
  441. package/src/knowledge-health/rule-identity.mjs +44 -0
  442. package/src/knowledge-health/workshop-composition.mjs +19 -0
  443. package/src/knowledge-health/workshop-profile.mjs +176 -0
  444. package/src/knowledge-health/workshop.mjs +99 -0
  445. package/src/learning/adapters.mjs +68 -0
  446. package/src/learning/assistance.mjs +127 -0
  447. package/src/learning/contracts.mjs +65 -0
  448. package/src/learning/store.mjs +272 -0
  449. package/src/personal-workspace/index.mjs +391 -0
  450. package/src/personal-workspace/selection.mjs +385 -0
  451. package/src/project/config.mjs +8 -0
  452. package/src/project/durable-state.mjs +3 -1
  453. package/src/project/file-class.mjs +1 -1
  454. package/src/projection/obsidian/edits/apply.mjs +55 -16
  455. package/src/projection/obsidian/personal-workspace.mjs +436 -0
  456. package/src/projection/obsidian/proposals/adapter.mjs +47 -8
  457. package/src/projection/obsidian/proposals/observation.mjs +10 -2
  458. package/src/projection/obsidian/publication/publisher.mjs +142 -13
  459. package/src/projection/obsidian/publication/vault-list.mjs +4 -3
  460. package/src/projection/obsidian/recovery/index.mjs +4 -1
  461. package/src/projection/obsidian/recovery/store.mjs +160 -7
  462. package/src/projection/obsidian/selection-ui/contribution.mjs +6 -5
  463. package/src/readiness/readiness.mjs +10 -3
  464. package/src/reflection/index.mjs +56 -0
  465. package/src/runtime/obsidian/app-production-seams.mjs +48 -16
  466. package/src/runtime/obsidian/app-registration.mjs +175 -28
  467. package/src/runtime/obsidian/app-restart.mjs +154 -0
  468. package/src/runtime/obsidian/engine.mjs +163 -20
  469. package/src/runtime/obsidian/extension-points.mjs +1 -1
  470. package/src/runtime/obsidian/index.mjs +10 -7
  471. package/src/runtime/obsidian/launch-plan.mjs +7 -4
  472. package/src/runtime/obsidian/lifecycle.mjs +92 -12
  473. package/src/runtime/obsidian/login-item.mjs +332 -0
  474. package/src/runtime/obsidian/machine-settings.mjs +119 -12
  475. package/src/runtime/obsidian/opening.mjs +171 -37
  476. package/src/runtime/obsidian/pipeline.mjs +71 -3
  477. package/src/runtime/obsidian/plugin-channel.mjs +11 -3
  478. package/src/runtime/obsidian/plugin-choice.mjs +16 -4
  479. package/src/runtime/obsidian/plugin-drift.mjs +59 -11
  480. package/src/runtime/obsidian/plugin-presence.mjs +14 -1
  481. package/src/runtime/obsidian/process-identity.mjs +20 -10
  482. package/src/runtime/obsidian/project-views.mjs +282 -0
  483. package/src/runtime/obsidian/questions.mjs +48 -0
  484. package/src/runtime/obsidian/release-watch.mjs +56 -0
  485. package/src/runtime/obsidian/service-main.mjs +99 -16
  486. package/src/runtime/obsidian/service-manager-production.mjs +61 -0
  487. package/src/runtime/obsidian/service-managers.mjs +275 -0
  488. package/src/runtime/obsidian/service-record.mjs +87 -23
  489. package/src/runtime/obsidian/service.mjs +36 -3
  490. package/src/runtime/obsidian/startup-adapters.mjs +59 -21
  491. package/src/runtime/obsidian/unheld-evidence.mjs +84 -0
  492. package/src/runtime/obsidian/vault-location.mjs +300 -0
  493. package/src/runtime/obsidian/view-counts.mjs +34 -0
  494. package/src/server/local-sidecar.mjs +169 -2
  495. package/src/server/server.mjs +28 -2
  496. package/src/template-bindings/project.mjs +92 -0
  497. package/src/templates/profile.mjs +225 -0
  498. package/src/trackables/domain.mjs +175 -0
  499. package/src/trackables/store.mjs +114 -0
  500. package/src/ui/knowledge-page.mjs +1388 -0
  501. package/src/upgrade/explanation.mjs +1 -1
  502. package/src/upgrade/template-participant.mjs +171 -0
  503. package/src/upgrade/transaction.mjs +72 -25
  504. package/src/upgrade/upgrade.mjs +1 -0
  505. package/templates/external-project-workspace/pack-lifecycle.json +6 -1
  506. package/templates/knowledge-workspace/.gitattributes +2 -0
  507. package/templates/knowledge-workspace/AGENTS.md +27 -0
  508. package/templates/knowledge-workspace/README.md +44 -0
  509. package/templates/knowledge-workspace/atelier.project.json +22 -0
  510. package/templates/knowledge-workspace/gitignore +6 -0
  511. package/templates/knowledge-workspace/knowledge-plan.json +111 -0
  512. package/templates/knowledge-workspace/knowledge-plan.schema.json +281 -0
  513. package/templates/knowledge-workspace/records/forecast.md +22 -0
  514. package/templates/knowledge-workspace/records/inspection.md +25 -0
  515. package/templates/knowledge-workspace/records/telescope.md +24 -0
  516. package/templates/knowledge-workspace/repo-access.v1.json +9 -0
@@ -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,56 @@ 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] [--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
+ Reaching the installed app needs --adapter=${PRODUCTION_ADAPTER} once for a workspace; it is remembered after that.
115
+ Obsidian's own settings file (obsidian.json) is written only while no Obsidian runs: open adds the view's vault to its
116
+ list, turns its command line on (cli: true), and creates the file for an Obsidian that never started; every write is
117
+ shown in open's answer, with the backup of the file as it was.
118
+ The first start of the maintenance service records who allows it: --consent-actor ID, or, for a person at a terminal,
119
+ their account's name; service unit --install records who allows it to start at login the same way (at a terminal, the
120
+ actor already recorded is kept). --no-input, --json, a CI environment or ATELIER_NONINTERACTIVE=1 mean no person is at a terminal.
121
+
70
122
  Contributed operations, registered by the modules shipped under src/runtime/obsidian/contributions/:
71
123
  apply list | show EDIT | run EDIT [--actor ID] | recover
72
124
  Pending edits, and the explicit apply of one of them to its source file.
@@ -83,7 +135,77 @@ Pending edits additionally report ${APPLY_UNAVAILABLE} while no apply operation
83
135
  Minimum Obsidian version: ${MINIMUM_APP_VERSION}.
84
136
  Exit codes: 0 done; 1 internal error; 2 refusal or usage; 3 ran, and the answer is not success.`
85
137
 
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' })
138
+ // Every option of the command. `atelier obsidian --help` names each one but `help` and `project-config` (the older
139
+ // spelling of `project`); a test holds it to that.
140
+ export const FLAGS = Object.freeze({
141
+ 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',
142
+ project: 'value', 'project-config': 'value', 'data-root': 'value', scope: 'value', 'consent-actor': 'value', actor: 'value', adapter: 'value', 'wait-ms': 'value', repo: 'value', tag: 'value',
143
+ expand: 'value', folder: 'values',
144
+ })
145
+
146
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
147
+
148
+ // Which adapter this run may use, or a typed refusal. Pure. `flag` is --adapter as given; `remembered` is the adapter
149
+ // this workspace's machine settings remember (or null); `production` says this module runs as the real command-line
150
+ // entry. A remembered adapter is used only by the real entry, and never under the test runner: a test that reaches an
151
+ // app names the adapter, and runs where no app of the developer's can be reached.
152
+ export function selectAdapter({ flag, remembered = null, production = false, env = process.env } = {}) {
153
+ if (flag !== undefined) {
154
+ 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}`)
155
+ return { adapter: PRODUCTION_ADAPTER, source: 'flag' }
156
+ }
157
+ if (remembered === PRODUCTION_ADAPTER && production === true) {
158
+ 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')
159
+ return { adapter: PRODUCTION_ADAPTER, source: 'remembered' }
160
+ }
161
+ return refuse('app-adapter-not-selected', 'no editor adapter was selected; reaching the installed app is never a default')
162
+ }
163
+
164
+ // Whether a person is at a terminal: nothing says otherwise, and both standard input and output are one. Pure.
165
+ // `terminal` is `{ stdin, stdout }`, each true when that stream is a terminal.
166
+ export function isInteractive({ flags = {}, env = process.env, terminal = { stdin: false, stdout: false } } = {}) {
167
+ if (flags.json === true || flags['no-input'] === true) return false
168
+ if (env.ATELIER_NONINTERACTIVE === '1') return false
169
+ if (typeof env.CI === 'string' && env.CI !== '' && env.CI !== 'false' && env.CI !== '0') return false
170
+ return terminal.stdin === true && terminal.stdout === true
171
+ }
172
+
173
+ // The account's name as an actor identifier, or null when it is not one.
174
+ export const accountActor = (username) => (typeof username === 'string' && IDENTIFIER.test(username) ? username : null)
175
+
176
+ // How each remembered answer is changed; null where no command changes it.
177
+ const DECISION_CHANGES = Object.freeze({
178
+ audience: '`atelier obsidian audience set me|A,B` decides who may see the vaults again',
179
+ location: '`atelier obsidian location set DIR` decides where the vaults of views not published yet live',
180
+ loginItem: '`atelier obsidian service unit --install` starts maintenance at login; `service unit --remove` stops that',
181
+ adapter: null,
182
+ consent: '`atelier obsidian service stop`, then `service start --consent-actor ID`, records another actor',
183
+ })
184
+ const SOURCE_WORDS = Object.freeze({ question: 'answered at a terminal', command: 'given on the command line', defaults: 'the defaults', v1: 'set before this release' })
185
+
186
+ // One remembered answer in words, with when and how it was given.
187
+ function decisionWords(name, decision, audienceAllow = []) {
188
+ if (decision === null) return name === 'audience' && audienceAllow.length === 0 ? 'not decided; no audience is allowed, so every view is empty' : 'not decided'
189
+ const what = name === 'audience'
190
+ ? `${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}`
191
+ : name === 'location' ? decision.parent : decision.choice
192
+ return `${what} (${SOURCE_WORDS[decision.via]}, ${decision.decidedAt}${decision.decidedBy === null ? '' : `, by ${decision.decidedBy}`})`
193
+ }
194
+
195
+ function decisionSummary(decisions) {
196
+ const words = {
197
+ audience: (decision) => `who may see ${decision.choice === 'only-you' ? 'only you' : 'a list of audiences'}`,
198
+ location: (decision) => `vaults in ${decision.parent}`,
199
+ loginItem: (decision) => `start at login ${decision.choice}`,
200
+ adapter: (decision) => `app through ${decision.choice}`,
201
+ }
202
+ const decided = DECISIONS.filter((name) => decisions[name] !== null)
203
+ return decided.length === 0 ? 'nothing yet' : decided.map((name) => words[name](decisions[name])).join('; ')
204
+ }
205
+
206
+ function accountName() {
207
+ try { return os.userInfo().username } catch { return null }
208
+ }
87
209
 
88
210
  function parse(argv) {
89
211
  const positionals = []
@@ -97,24 +219,119 @@ function parse(argv) {
97
219
  if (FLAGS[name] === 'flag') { if (inline !== undefined) refuse('usage', `--${name} takes no value`); flags[name] = true; continue }
98
220
  const value = inline ?? argv[index += 1]
99
221
  if (value === undefined || value === '' || (inline === undefined && value.startsWith('--'))) refuse('usage', `--${name} needs a value`)
100
- flags[name] = value
222
+ // An option that takes several values is given once for each.
223
+ flags[name] = FLAGS[name] === 'values' ? [...(flags[name] ?? []), value] : value
101
224
  }
102
225
  return { positionals, flags }
103
226
  }
104
227
 
105
- const isTyped = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal
228
+ // What `open` did to the person's Obsidian, in words: a restart they asked for, and every write to its settings file.
229
+ const STATE_WORDS = Object.freeze({ 'cli-turned-off': 'its command line was turned off', 'no-vault-open': 'it had no vault open' })
230
+ function restartLines(restart) {
231
+ if (restart === undefined || restart === null) return []
232
+ const why = STATE_WORDS[restart.state] ?? restart.state
233
+ const copy = restart.settingsCopy && restart.signalled ? [` A copy of Obsidian's settings, taken just before the first signal, is kept in ${restart.settingsCopy}.`] : []
234
+ 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`
235
+ if (restart.quit !== true) return [`Obsidian: not restarted (${restart.reason}${restart.detail ? `: ${restart.detail}` : ''}); ${restart.signalled ? asked : 'it was not asked to quit'}.`, ...copy]
236
+ const quit = !restart.signalled ? 'already gone'
237
+ : 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`
238
+ : `quit (SIGTERM to its main process, ${restart.pid})`
239
+ 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]
240
+ }
241
+ function obsidianSettingsLines(settings) {
242
+ if (settings === undefined || settings === null) return []
243
+ const file = settings.file ?? 'obsidian.json'
244
+ const what = settings.created
245
+ ? `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)`
246
+ : [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('; ')
247
+ return [`Obsidian's settings, written while it was quit: ${what}.`, ...(settings.backupPath ? [` The file as it was is kept in ${settings.backupPath}.`] : [])]
248
+ }
249
+
250
+ const isTyped = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal || error instanceof PublicationRefusal
106
251
  const plainMessage = (error) => String(error.message ?? '').replace(new RegExp(`^${error.code}: `), '')
107
252
  const NEXT = Object.freeze({
108
253
  usage: 'run `atelier obsidian --help`',
109
254
  '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',
255
+ 'app-adapter-not-selected': `pass --adapter=${PRODUCTION_ADAPTER} to let this command reach the installed app; it is remembered for this workspace`,
256
+ '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`,
257
+ '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
258
  'automatic-mode-refused': 'install an active automatic policy with `obsidian policy install FILE`',
113
259
  [APPLY_UNAVAILABLE]: 'no apply operation is registered on this command; edits stay preserved and queued',
114
260
  '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',
261
+ disabled: 'declare the Obsidian settings in the project configuration: `atelier obsidian view add everything --all` does it',
262
+ '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',
263
+ 'view-exists': 'choose another name; `atelier obsidian view list` shows the views declared',
264
+ 'view-repository-ambiguous': 'name the repository the folders are in with --repo',
265
+ 'project-config-format-unknown': 'add the member shown under "detail" to the project configuration by hand, under "ext"',
266
+ 'project-config-changed': 'run the command again',
267
+ 'canonical-graph-invalid': '`atelier graph --check` names the errors in the project\'s notes; fix them, then run the command again',
268
+ unanswered: 'run the command again at a terminal, or pass --yes',
269
+ 'startup-platform-unqualified': 'a login item is offered on macOS and Linux; `atelier obsidian open` starts maintenance when you open a view',
270
+ 'startup-platform-unsupported': 'a login item is offered on macOS and Linux; `atelier obsidian open` starts maintenance when you open a view',
271
+ 'login-item-needs-installed-package': 'install @mnstry/atelier in the project (`npm i -D @mnstry/atelier`), then run the command there',
272
+ 'login-item-home-mismatch': 'run the command in your own session, with HOME set to your home directory',
273
+ 'real-login-item-under-test': 'a test passes its own service manager',
274
+ 'login-item-unavailable': 'no service manager of your session answered (on Linux: systemd --user); `atelier obsidian open` starts maintenance when you open a view',
275
+ 'login-item-install-failed': 'see `atelier obsidian status`; `atelier obsidian service unit --install` tries again',
276
+ 'login-item-remove-failed': 'see `atelier obsidian status`; `atelier obsidian service unit --remove` tries again',
277
+ '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',
278
+ 'invalid-login-item': 'the login item record in private state does not validate; inspect it, remove it, then install the login item again',
279
+ })
280
+
281
+ // The login item of `status`, in words.
282
+ function loginItemWords(item, platform) {
283
+ if (item.installed === null) return `unreadable (${item.reason})`
284
+ if (item.installed === false) return 'off'
285
+ const where = item.file
286
+ 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}`
287
+ return {
288
+ loaded: `on (${item.running ? 'running' : 'loaded'}); ${where}`,
289
+ 'switched-off': `installed, ${platform === 'darwin' ? 'switched off in System Settings' : 'disabled'}; it does not start at login; ${where}`,
290
+ 'file-missing': `installed, but its file is gone: it does not start at login; \`atelier obsidian service unit --install\` puts it back; ${where}`,
291
+ 'not-loaded': `installed, not loaded by the service manager; it starts at the next login; ${where}`,
292
+ }[item.state] ?? `installed; ${where}`
293
+ }
294
+
295
+ // What a start through the login item adds to the answer of `service start` and `open`.
296
+ function loginItemLines(item) {
297
+ if (item === undefined || item === null) return []
298
+ return [
299
+ ...(item.refreshed === true ? ['login item refreshed: its unit is written as it would be now, and loaded again'] : []),
300
+ ...(item.via === 'child' ? [`the login item did not start the service (${item.reason}); it was started for this session only`] : []),
301
+ ]
302
+ }
303
+
304
+ // A start's answer in words: started, replaced, or already running, and which release when that is why it was kept.
305
+ function serviceWords(result) {
306
+ if (result.replaced === 'outdated' && result.started) return `${result.state} (restarted: the service of an earlier release was replaced)`
307
+ if (result.started) return `${result.state} (started)`
308
+ if (result.alreadyRunning) {
309
+ if (result.release === 'later') return `${result.state} (already running, a later release; it is not replaced by this one)`
310
+ if (result.release === 'outdated') return `${result.state} (already running, an earlier release, not replaced: ${result.reason})`
311
+ return `${result.state} (already running)`
312
+ }
313
+ return `${result.state} (${result.reason})`
314
+ }
315
+
316
+ // What `status` suggests after the login item did not start the service.
317
+ const LOGIN_ITEM_NEXT = Object.freeze({
318
+ 'startup-consent-absent': 'install it again with `atelier obsidian service unit --install --consent-actor ID`',
319
+ 'service-port-occupied': 'something else answers on the recorded port; see `atelier obsidian service status`',
320
+ 'service-settings-absent': 'install it again with `atelier obsidian service unit --install --consent-actor ID`',
116
321
  })
117
322
 
323
+ // What a view would show, in words: `counts` of viewCounts, and who they were counted for.
324
+ function viewCountWords(scopeId, counts, audience) {
325
+ const who = audience.decided ? (audience.allow.length === 0 ? 'no audience' : audience.allow.join(', ')) : 'only you, until you decide'
326
+ const reasons = [
327
+ ...(counts.withheld.unclassified > 0 ? [`${counts.withheld.unclassified} carry no classification`] : []),
328
+ ...(counts.withheld.audience > 0 ? [`${counts.withheld.audience} an audience not admitted`] : []),
329
+ ]
330
+ const shown = `view ${scopeId} would show ${counts.shown === 0 ? 'no note' : `${counts.shown} note(s)`} (who may see: ${who})`
331
+ if (counts.named === 0) return `${shown}: it names no note`
332
+ return reasons.length === 0 ? shown : `${shown}; of the ${counts.named} note(s) it names, ${reasons.join(' and ')}`
333
+ }
334
+
118
335
  // The decisions the command's oracles are sensitive to are those of opening and of the lifecycle; tests substitute
119
336
  // broken ones here to prove the oracles can fail. `runObsidianCommand` always uses the production ones.
120
337
  export async function runObsidianCommandForOracleTests(options = {}, rules = {}) {
@@ -123,9 +340,22 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
123
340
  const {
124
341
  argv = [], seams = null, production = false, env = process.env, cwd = process.cwd(), platform = process.platform, clock = () => new Date(),
125
342
  stdout = (text) => process.stdout.write(`${text}\n`), stderr = (text) => process.stderr.write(`${text}\n`), contributions = null, contributionsDirectory,
343
+ // Whether standard input and output are terminals, and the name of the account this runs as. A test injects both;
344
+ // under the test runner the process's own terminal is never looked at.
345
+ terminal = env.NODE_TEST_CONTEXT === undefined ? { stdin: tty.isatty(0), stdout: tty.isatty(1) } : { stdin: false, stdout: false },
346
+ account = accountName,
347
+ // The home folder a location is read against (`~/`) and checked for sync clients; a test names its own.
348
+ homedir = env.NODE_TEST_CONTEXT === undefined ? os.homedir() : null,
349
+ // Where a person at a terminal is asked, and answers; a test types into its own.
350
+ input = env.NODE_TEST_CONTEXT === undefined ? process.stdin : null, output = env.NODE_TEST_CONTEXT === undefined ? process.stdout : null,
126
351
  } = options
127
352
  let json = argv.includes('--json')
128
353
  let operationName = null
354
+ let questions = null
355
+ const questioner = () => {
356
+ if (input === null || output === null) throw new Error('a question needs an input and an output')
357
+ return (questions ??= createQuestioner({ input, output }))
358
+ }
129
359
  const emit = ({ exit, document, human }) => {
130
360
  const complete = { schema: COMMAND_SCHEMA, ok: exit === EXIT.ok, operation: operationName, ...document }
131
361
  if (json) stdout(JSON.stringify(complete, null, 2))
@@ -147,18 +377,77 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
147
377
  const applyAvailable = registry.extensions.applyOperation() !== UNAVAILABLE_APPLY_OPERATION
148
378
  const lifecycle = { loadProject, dataRoot, env, platform, ...(options.probeTimeoutMs === undefined ? {} : { probeTimeoutMs: options.probeTimeoutMs }) }
149
379
 
150
- const serviceSeam = async () => {
380
+ // The adapter of this run: given now, or remembered by this workspace (read only when the real entry has no flag).
381
+ const chooseAdapter = () => selectAdapter({ flag: flags.adapter, remembered: flags.adapter === undefined && production === true ? rememberedAdapter() : null, production, env })
382
+ // The entry this command may start, and its arguments.
383
+ const entrySeam = async () => {
151
384
  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')
385
+ chooseAdapter()
153
386
  const { SERVICE_ENTRY_PATH } = await import('../runtime/obsidian/service-entry-path.mjs')
154
387
  return { entryPath: SERVICE_ENTRY_PATH, entryArgs: [`--adapter=${PRODUCTION_ADAPTER}`] }
155
388
  }
389
+ // The service manager of a login item: the caller's (`serviceManager`, or `seams.serviceManager`), or, for the real
390
+ // command-line entry only, the production one, which refuses under the test runner and for a HOME that is not the
391
+ // account's own. Without `required`, null where none can be had.
392
+ const managerSeam = async ({ required = true } = {}) => {
393
+ const given = options.serviceManager ?? seams?.serviceManager ?? null
394
+ if (given !== null) return given
395
+ if (seams !== null || production !== true) { if (required) refuse('seams-required', 'no service manager was passed for the login item'); return null }
396
+ try {
397
+ const { createProductionServiceManager } = await import('../runtime/obsidian/service-manager-production.mjs')
398
+ return createProductionServiceManager({ platform, env })
399
+ } catch (error) {
400
+ if (!required && isTyped(error)) return null
401
+ throw error
402
+ }
403
+ }
404
+ const nodePath = () => options.nodePath ?? realNodePath()
405
+ // The service seam: once this workspace has a login item, the service is started through its manager, running the
406
+ // entry the item names; a unit that differs from what would be written now is written again on the way.
407
+ const serviceSeam = async () => {
408
+ const entry = await entrySeam()
409
+ const { project, workspace } = readable()
410
+ let record = null
411
+ try { record = workspace === null ? null : readLoginItemRecord(workspace) } catch (error) { if (!isTyped(error)) throw error }
412
+ if (record === null) return entry
413
+ const manager = await managerSeam({ required: false })
414
+ if (manager === null) return entry
415
+ const plan = currentLoginItemPlan({ record, project, workspaceRoot: workspace.workspaceRoot, dataRoot, env, platform, ownEntry: entry.entryPath, entryArgs: entry.entryArgs ?? [], nodePath: nodePath() })
416
+ // An item that names an entry that is gone, with nothing to refresh it to from here, cannot start anything.
417
+ if (plan === null && !fs.existsSync(record.program.entry)) return entry
418
+ return { ...entry, entryPath: plan?.program.entry ?? record.program.entry, loginItem: loginItemStarter({ workspace, manager, record, plan, clock }) }
419
+ }
156
420
  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')
421
+ if (seams !== null) return { appProbe: seams.appProbe, launcher: seams.launcher, registry: seams.registry, quitter: seams.quitter ?? null }
422
+ chooseAdapter()
159
423
  const { createProductionAppSeams } = await import('../runtime/obsidian/app-production-seams.mjs')
160
424
  return createProductionAppSeams({ env, platform })
161
425
  }
426
+ // The app's vault list, read only, for a check of where vaults may live: through the app when it runs and answers,
427
+ // else from its settings file. { source: 'app' | 'file' | 'none', vaults } ('none': the app never ran here), or
428
+ // { source: 'unread', vaults: null, reason }. The app is reached only with the adapter (given or remembered, or the
429
+ // caller's seams); without it, only the file is read, and never under the test runner.
430
+ const appVaultList = async () => {
431
+ const quietly = async (read) => { try { return await read() } catch { return null } }
432
+ const reach = seams !== null ? seams : adapterSelected() ? await appSeams() : null
433
+ const registry = reach?.registry ?? null
434
+ if (registry !== null && reach.appProbe !== undefined) {
435
+ const seen = await inspectApp(reach.appProbe)
436
+ if (seen.running === true && typeof seen.version === 'string') {
437
+ const listed = await quietly(() => registry.listThroughApp())
438
+ if (listed?.answered === true) return { source: 'app', vaults: listed.vaults }
439
+ }
440
+ }
441
+ const settings = registry !== null ? await quietly(() => registry.readSettings())
442
+ : env.NODE_TEST_CONTEXT !== undefined ? { ok: false, code: 'app-vault-list-under-test' }
443
+ : obsidianSandboxedBuild({ platform, env }) !== null ? { ok: false, code: 'obsidian-sandboxed' }
444
+ : readObsidianSettings({ userDataDir: obsidianUserDataDir({ platform, env }) })
445
+ if (settings?.ok === true) return { source: 'file', vaults: settings.vaults ?? {} }
446
+ if (settings?.code === 'obsidian-settings-missing') return { source: 'none', vaults: {} }
447
+ return { source: 'unread', vaults: null, reason: typeof settings?.code === 'string' ? settings.code : 'obsidian-settings-unreadable' }
448
+ }
449
+ // Whether this run may reach the installed app: --adapter given, or remembered by the workspace for the real entry.
450
+ const adapterSelected = () => { try { chooseAdapter(); return true } catch (error) { if (isTyped(error)) return false; throw error } }
162
451
  // 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
452
  // and the private record too, whether or not the service runs.
164
453
  const pluginView = (running, workspace, scopeId) => {
@@ -167,8 +456,11 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
167
456
  return { present: false, reason: 'turned-off-in-this-vault', next: turnPluginOnNext(scopeId) }
168
457
  }
169
458
  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})`)
459
+ 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
460
 
461
+ // A project bound to a personal workspace takes its views from the overlay, and has no place for these decisions
462
+ // yet: the operations that would write them refuse before anything is resolved or written.
463
+ const refuseOnPersonalBinding = async (project, operation) => (await import('../projection/obsidian/personal-workspace.mjs')).refuseOnPersonalBinding(project, operation)
172
464
  const configured = () => {
173
465
  const project = loadProject()
174
466
  const enablement = readObsidianEnablement(project)
@@ -189,15 +481,225 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
189
481
  return { project, enablement, workspace: workspace?.workspaceRoot ? workspace : null, workspaceId: workspace?.workspaceId ?? null }
190
482
  }
191
483
  const machineOf = (workspace) => (workspace === null ? null : readMachineSettings(workspace))
192
- const consent = flags['consent-actor'] === undefined ? undefined : { actor: flags['consent-actor'], coverage: 'service' }
484
+ const rememberedAdapter = () => machineOf(readable().workspace)?.decisions.adapter?.choice ?? null
485
+ const interactive = isInteractive({ flags, env, terminal })
486
+ // Who allows the maintenance service to run, for a start this run may make: --consent-actor; else, for a person at a
487
+ // terminal and only while this workspace has no consent recorded, their account's name. A derived consent never
488
+ // replaces a recorded one. `source` says which.
489
+ // With a login item installed, the service is started through it, so the consent covers startup too, or the item's
490
+ // own start would be refused (`seam.loginItem`).
491
+ const consentOfThisRun = (seam = null) => {
492
+ const coverage = seam?.loginItem ? 'service-and-startup' : 'service'
493
+ if (flags['consent-actor'] !== undefined) return { consent: { actor: flags['consent-actor'], coverage }, source: 'flag' }
494
+ if (!interactive) return { consent: undefined, source: null }
495
+ const { workspace } = readable()
496
+ if (workspace !== null && readServiceSettings(workspace) !== null) return { consent: undefined, source: null }
497
+ const actor = accountActor(account())
498
+ // `derived`: the start records it only while no consent is recorded, checked again under the start lock.
499
+ return actor === null ? { consent: undefined, source: null } : { consent: { actor, coverage, derived: true }, source: 'account' }
500
+ }
501
+ // --adapter, given to an operation that starts or reaches the service of an enabled project, is remembered the first
502
+ // time: the workspace is prepared and the decision written before anything starts, so no later run needs the flag.
503
+ // Answers whether this run remembered it.
504
+ // Remembering never stands in the way of the operation: a workspace that cannot be prepared refuses the operation
505
+ // itself, which says so in its own terms.
506
+ const rememberAdapter = (decidedBy) => {
507
+ if (flags.adapter !== PRODUCTION_ADAPTER) return false
508
+ try {
509
+ if (readable().enablement.state !== 'enabled') return false
510
+ const { workspace, repositoryRoots, now } = writable()
511
+ const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
512
+ if (current.decisions.adapter?.choice === PRODUCTION_ADAPTER) return false
513
+ const decided = withDecision(current, 'adapter', { choice: PRODUCTION_ADAPTER }, { decidedAt: now, decidedBy: decidedBy ?? accountActor(account()), via: 'command' })
514
+ writeMachineSettings({ ...workspace, repositoryRoots, settings: { ...decided, updatedAt: now } })
515
+ return true
516
+ } catch (error) {
517
+ if (isTyped(error)) return false
518
+ throw error
519
+ }
520
+ }
521
+ // What a run that starts or reaches the service says about what it remembered, in the document and in words.
522
+ const rememberedLines = ({ adapterRemembered, consent }) => [
523
+ ...(consent.source === 'account' ? [`The maintenance service is allowed by ${consent.consent.actor}, this account; recorded for this workspace.`] : []),
524
+ ...(adapterRemembered ? [`Remembered for this workspace: the app is reached through ${PRODUCTION_ADAPTER}; --adapter is not needed again.`] : []),
525
+ ]
193
526
  const shownMachine = (machine, workspace) => {
194
- const settings = machine ?? { maintenanceMode: 'manual', audienceAllow: [], applyPolicy: null }
527
+ const settings = machine ?? defaultMachineSettings({ workspaceId: workspace?.workspaceId ?? 'ws-unprepared', updatedAt: isoTime(clock) })
195
528
  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 } }
529
+ return {
530
+ maintenanceMode: settings.maintenanceMode, audienceAllow: settings.audienceAllow, applyPolicy: settings.applyPolicy, automaticApply: { authorized: automatic.authorized, reason: automatic.reason },
531
+ decisions: settings.decisions,
532
+ }
533
+ }
534
+ // After a run that may have started the service: whether the consent it derived is the one now recorded.
535
+ const consentRecorded = (consent) => {
536
+ if (consent.source !== 'account') return consent
537
+ const { workspace } = readable()
538
+ const recorded = workspace === null ? null : readServiceSettings(workspace)
539
+ return recorded?.consent.actor === consent.consent.actor ? consent : { consent: undefined, source: null }
197
540
  }
198
541
  const applyShown = { available: applyAvailable, state: applyAvailable ? 'available' : APPLY_UNAVAILABLE, operationId: registry.extensions.applyOperation().id }
199
542
  const [, sub, value] = positionals
200
543
 
544
+ // The login item's text as `--install` would write it now. Writes nothing and asks no service manager.
545
+ const printUnit = async () => {
546
+ const { project, workspace } = readable()
547
+ if (workspace === null) refuse('service-workspace-not-prepared', 'this workspace has no private state yet; `service start` prepares it')
548
+ const entry = await entrySeam()
549
+ const record = readLoginItemRecord(workspace)
550
+ const temporary = temporaryRoots()
551
+ const { entryPath } = resolveLoginItemEntry({ project, ownEntry: entry.entryPath, temporary })
552
+ const label = record?.label ?? loginItemLabel({ platform, projectName: projectNameOf(project), workspaceId: workspace.workspaceId })
553
+ const { platform: unitPlatform, kind, fileName, text } = planLoginItem({
554
+ platform, project, workspaceRoot: workspace.workspaceRoot, workspaceId: workspace.workspaceId, dataRoot: loginItemDataRoot({ project, dataRoot, env, platform }), label, entryPath,
555
+ entryArgs: entry.entryArgs ?? [], nodePath: nodePath(),
556
+ searchPath: record?.searchPath ?? startupSearchPath(env.PATH, { temporary }),
557
+ })
558
+ 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] }
559
+ }
560
+ // Who allows the service to run at login, for `--install`: --consent-actor; else, for a person at a terminal, the
561
+ // actor this workspace already recorded (its coverage is raised to startup), or the account's name when none is.
562
+ // Asking for the login item at a terminal is that person's consent to it. A program names its actor.
563
+ const startupConsent = () => {
564
+ if (flags['consent-actor'] !== undefined) return { actor: flags['consent-actor'] }
565
+ if (!interactive) return undefined
566
+ const { workspace } = readable()
567
+ const recorded = workspace === null ? null : readServiceSettings(workspace)
568
+ const actor = recorded?.consent.actor ?? accountActor(account())
569
+ return actor === null ? undefined : { actor }
570
+ }
571
+ // Installs the login item, then starts the service through it (launchd has usually started it already).
572
+ const installUnit = async () => {
573
+ configured()
574
+ if (!STARTUP_PLATFORMS.includes(platform)) buildStartupAdapter({ platform })
575
+ const entry = await entrySeam()
576
+ const manager = await managerSeam()
577
+ const consent = startupConsent()
578
+ // A service of an earlier release still running would make the unit's own first start refuse (another runtime
579
+ // answers): it is stopped as its owner stops it once the consent is recorded, just before the unit is loaded.
580
+ let replaced = null
581
+ const beforeInstall = async ({ entryPath }) => {
582
+ if (runtimeRelease(await serviceStatus(lifecycle, lifecycleRules), entryPath) !== 'outdated') return
583
+ if ((await stopService(lifecycle, lifecycleRules)).stopped) replaced = 'outdated'
584
+ }
585
+ const installed = await installLoginItem({
586
+ loadProject, dataRoot, env, platform, manager, ownEntry: entry.entryPath, entryArgs: entry.entryArgs ?? [], nodePath: nodePath(), pathValue: env.PATH, clock, beforeInstall,
587
+ ...(consent === undefined ? {} : { consent }),
588
+ })
589
+ if (!installed.installed) {
590
+ // The earlier release was stopped for nothing: the installed one runs in its place for this session.
591
+ const service = replaced === null ? null : (({ child: _child, ...result }) => result)(await startService({ ...lifecycle, detached: true, ...entry, replaceOutdated: true }, lifecycleRules))
592
+ return {
593
+ exit: EXIT.notSuccess, document: { loginItem: installed, ...(service === null ? {} : { service: { ...service, replaced } }) },
594
+ 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 })}`])],
595
+ }
596
+ }
597
+ const remembered = rememberLoginItem('on')
598
+ const adapterRemembered = rememberAdapter(consent?.actor)
599
+ const seam = await serviceSeam()
600
+ const started = (({ child: _child, ...result }) => result)(await startService({ ...lifecycle, detached: true, ...seam, replaceOutdated: true }, lifecycleRules))
601
+ const service = replaced === null || started.replaced !== undefined ? started : { ...started, replaced }
602
+ const running = service.state === 'healthy' || service.state === 'busy'
603
+ return {
604
+ exit: running ? EXIT.ok : EXIT.notSuccess, document: { loginItem: installed, service, rememberedNow: { loginItem: remembered ? 'on' : null, adapter: adapterRemembered } },
605
+ human: [
606
+ `login item installed: ${installed.file}`, `it runs ${installed.entry.path}${installed.entry.source === 'project' ? ', the package installed in this project' : ''}`,
607
+ ...(platform === 'darwin' ? ['macOS lists it as "node" under Login Items & Extensions, and may say "Background Items Added"'] : []),
608
+ `service: ${serviceWords(service)}`, ...loginItemLines(service.loginItem),
609
+ ...(consent !== undefined && flags['consent-actor'] === undefined ? [`Maintenance at login is allowed by ${consent.actor}; recorded for this workspace.`] : []),
610
+ ...rememberedLines({ adapterRemembered, consent: { source: null } }),
611
+ ],
612
+ }
613
+ }
614
+ // Removes the login item. A service it ran is stopped as its manager unloads it, and is started again at once as a
615
+ // process of its own for this session, under the consent now recorded (the service alone), so the vaults stay fresh
616
+ // until the person logs out. That start needs the adapter, given or remembered; without it, `open` starts it.
617
+ const removeUnit = async () => {
618
+ if (!STARTUP_PLATFORMS.includes(platform)) buildStartupAdapter({ platform })
619
+ const manager = await managerSeam()
620
+ const removed = await removeLoginItem({ loadProject, dataRoot, env, platform, manager, clock })
621
+ const failed = removed.removed !== true && removed.reason !== undefined && removed.reason !== 'workspace-not-prepared'
622
+ 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`'}`] }
623
+ const remembered = removed.reason === 'workspace-not-prepared' ? false : rememberLoginItem('off')
624
+ let service = null
625
+ if (removed.removed === true) {
626
+ let entry = null
627
+ try { entry = await entrySeam() } catch (error) { if (!isTyped(error)) throw error; service = { state: 'stopped', started: false, reason: error.code } }
628
+ if (entry !== null) {
629
+ try { service = (({ child: _child, ...result }) => result)(await startService({ ...lifecycle, detached: true, ...entry, replaceOutdated: true }, lifecycleRules)) } catch (error) {
630
+ if (!isTyped(error)) throw error
631
+ service = { state: 'stopped', started: false, reason: error.code }
632
+ }
633
+ }
634
+ }
635
+ service ??= await serviceStatus(lifecycle, lifecycleRules)
636
+ const running = ['healthy', 'busy'].includes(service.state)
637
+ return {
638
+ exit: EXIT.ok, document: { loginItem: removed, service: { state: service.state, reason: service.reason ?? null, started: service.started === true }, rememberedNow: { loginItem: remembered ? 'off' : null } },
639
+ human: [
640
+ removed.removed ? `login item removed: ${removed.file}` : 'no login item was installed', 'the consent now covers the service alone',
641
+ `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`}`,
642
+ ],
643
+ }
644
+ }
645
+ // The person's answer to "start at login?", remembered for this workspace as the `loginItem` decision of its machine
646
+ // settings, given on the command line. By whom: the actor named, else a person at a terminal by the account's name,
647
+ // else nobody known. Answers whether it wrote; an answer already remembered is not written again.
648
+ const rememberLoginItem = (choice) => {
649
+ if (readable().enablement.reason === 'not-configured') return false
650
+ const { workspace, repositoryRoots, now } = writable()
651
+ const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
652
+ if (current.decisions.loginItem?.choice === choice) return false
653
+ const decidedBy = flags['consent-actor'] ?? (interactive ? accountActor(account()) : null)
654
+ const decided = withDecision(current, 'loginItem', { choice }, { decidedAt: now, decidedBy, via: 'command' })
655
+ writeMachineSettings({ ...workspace, repositoryRoots, settings: { ...decided, updatedAt: now } })
656
+ return true
657
+ }
658
+ // Where what `uninstall` keeps is: the vaults, the private state, the project file and Obsidian's vault list. The
659
+ // vaults are those under the data root and every folder allocated to a view where the workspace decided its vaults
660
+ // live, a view no longer declared included; a record that cannot be read names none.
661
+ const keptLocations = (project, workspace) => {
662
+ const vaultsDirectory = workspace === null ? null : path.join(workspace.workspaceRoot, 'vaults')
663
+ let underDataRoot = []
664
+ try { underDataRoot = vaultsDirectory === null ? [] : fs.readdirSync(vaultsDirectory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(vaultsDirectory, entry.name)) } catch { underDataRoot = [] }
665
+ const allocated = []
666
+ if (workspace !== null) {
667
+ const records = path.join(workspace.workspaceRoot, 'state', 'allocations')
668
+ let names = []
669
+ try { names = fs.readdirSync(records).filter((name) => name.endsWith('.json')) } catch { names = [] }
670
+ for (const name of names) {
671
+ // Whatever the file is or holds (a link, a FIFO, not JSON, `null`, another view's record), it names a folder only
672
+ // when it is a valid record of its own view, which the store would read too; nothing here can stop `uninstall`.
673
+ try {
674
+ // Read as the store reads a record: a regular file only, never through a link, never a FIFO that would block.
675
+ const scopeId = JSON.parse(readRegularTextNoFollow(path.join(records, name)))?.scopeId
676
+ if (typeof scopeId !== 'string' || path.basename(allocationFile(workspace.workspaceRoot, scopeId)) !== name) continue
677
+ const allocation = readVaultAllocation({ ...workspace, scopeId })
678
+ if (allocation !== null) allocated.push(allocation.path)
679
+ } catch { /* a record that cannot be read names no folder */ }
680
+ }
681
+ }
682
+ const vaults = [...new Set([...underDataRoot, ...allocated])].sort()
683
+ const userDataDir = obsidianUserDataDir({ platform, env })
684
+ 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) }
685
+ }
686
+
687
+ // Where a view's vault is, or will be: { path, origin }. A view the next tick will place is shown where it would go,
688
+ // if that name is still free then (`to-be-allocated`).
689
+ // A view whose record cannot be read, or was lost, says why, as `status` does, and the other views are still shown.
690
+ const vaultWhere = (workspace, scopeId, decided, projectName) => {
691
+ if (workspace === null) return { path: null, origin: 'workspace-not-prepared' }
692
+ let found
693
+ try { found = vaultRootFor({ ...workspace, scopeId }) } catch (error) {
694
+ if (!isTyped(error)) throw error
695
+ return { path: null, origin: 'unreadable', reason: error.code }
696
+ }
697
+ if (found.origin === 'legacy-data-root' && decided !== null && !hasCommittedGeneration({ ...workspace, scopeId })) {
698
+ return { path: path.join(decided.parent, vaultFolderName({ projectName, scopeId })), origin: 'to-be-allocated' }
699
+ }
700
+ return { path: found.path, origin: found.origin }
701
+ }
702
+
201
703
  const operations = {
202
704
  async status() {
203
705
  const { project, enablement, workspace, workspaceId } = readable()
@@ -205,25 +707,34 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
205
707
  const running = service.state === 'healthy' ? (await readServiceStatusDocument(lifecycle, lifecycleRules)).document : null
206
708
  // What the service last learned about the app, and what a person can do about it.
207
709
  const app = running?.app ? { ...running.app, next: running.app.outcome === 'qualified' ? null : nextStep(running.app.outcome, running.app.reason) } : null
710
+ // The login item: its record, what its manager says (asked only when one is recorded), and how its last start ended.
711
+ const item = workspace === null ? { installed: false, lastStartup: null } : await loginItemStatus({ workspace, manager: () => managerSeam({ required: false }) })
712
+ const refusedAtLogin = item.lastStartup?.outcome === 'refused' && item.lastStartup.code !== 'service-already-running' && !['healthy', 'busy'].includes(service.state) ? item.lastStartup.code : null
713
+ 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
714
  const scopes = workspace === null
209
715
  ? enablement.scopes.map(({ scopeId }) => ({ scopeId, outcome: enablement.state === 'disabled' ? 'disabled' : 'not-prepared', reason: enablement.state === 'disabled' ? enablement.reason : 'workspace-not-prepared' }))
210
716
  : 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)
717
+ const { vaultRoot: _vault, summary: _summary, ...found } = scopeReport({ workspace, scopeId, repositoryRoots: protectedRoots(project), serviceState: service.state, applyAvailable }, openingRules)
718
+ // A view whose vault the next tick allocates elsewhere says where, not the data root it will not use.
719
+ const report = found.vault?.origin === 'legacy-data-root' ? { ...found, vault: vaultWhere(workspace, scopeId, machineOf(workspace)?.decisions.location ?? null, projectDisplayName(project)) } : found
720
+ const plugin = withPluginFilesPending(pluginView(running, workspace, scopeId), report.freshness)
213
721
  return enablement.state === 'disabled' ? { ...report, outcome: 'disabled', reason: enablement.reason, next: OPENING_OUTCOMES.disabled.next, plugin } : { ...report, plugin }
214
722
  })
215
723
  const document = {
216
724
  enablement: { state: enablement.state, reason: enablement.reason, defaultScopeId: enablement.defaultScopeId }, workspace: { workspaceId, prepared: workspace !== null },
217
725
  machine: shownMachine(machineOf(workspace), workspace), apply: applyShown,
218
726
  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(),
727
+ loginItem, app: { probed: false, minimumVersion: MINIMUM_APP_VERSION }, scopes, extensions: registry.extensions.describe(), operations: registry.operations.describe(),
220
728
  }
221
729
  return {
222
730
  exit: EXIT.ok, document,
223
731
  human: [
224
732
  `obsidian: ${enablement.state} (${enablement.reason}); mode ${document.machine.maintenanceMode}; audiences ${document.machine.audienceAllow.join(', ') || 'none'}`,
733
+ `remembered: ${decisionSummary(document.machine.decisions)}`,
225
734
  `service: ${service.state} (${service.reason ?? 'no reason'})${app ? `; app ${app.outcome} (${app.reason})` : ''}`,
226
735
  ...(app?.next ? [`Next for the app: ${app.next}`] : []),
736
+ `login item: ${loginItemWords(loginItem, platform)}`,
737
+ ...(refusedAtLogin === null ? [] : [`the login item did not start the service: ${refusedAtLogin}`, `Next: ${loginItem.next}`]),
227
738
  `apply: ${applyShown.state}`,
228
739
  ...scopes.flatMap((scope) => [
229
740
  `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 +745,125 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
234
745
  }
235
746
  },
236
747
 
748
+ // What this machine remembers for this workspace, and how each answer is changed. Read-only.
749
+ async settings() {
750
+ const { workspace, workspaceId } = readable()
751
+ const { audienceAllow, decisions } = shownMachine(machineOf(workspace), workspace)
752
+ const service = workspace === null ? null : readServiceSettings(workspace)
753
+ const consent = service === null ? null : service.consent
754
+ const document = { workspace: { workspaceId, prepared: workspace !== null }, decisions, audienceAllow, consent, change: DECISION_CHANGES }
755
+ return {
756
+ exit: EXIT.ok, document,
757
+ human: [
758
+ `who may see: ${decisionWords('audience', decisions.audience, audienceAllow)}`,
759
+ `where vaults live: ${decisionWords('location', decisions.location)}`,
760
+ `start at login: ${decisionWords('loginItem', decisions.loginItem)}`,
761
+ `reaching the app: ${decisionWords('adapter', decisions.adapter)}`,
762
+ `maintenance allowed by: ${consent === null ? 'nobody yet' : `${consent.actor} (${consent.coverage}, since ${consent.grantedAt})`}`,
763
+ ...Object.values(DECISION_CHANGES).filter((change) => change !== null).map((change) => `Change: ${change}`),
764
+ ],
765
+ }
766
+ },
767
+
768
+ // Where this workspace's vaults live. Deciding it places the vaults of views not published yet; a vault published
769
+ // already, under the data root or where an earlier decision placed it, stays where it is.
770
+ async location() {
771
+ const where = vaultWhere
772
+ const views = (project, enablement, workspace, decided) => enablement.scopes.map(({ scopeId }) => ({ scopeId, ...where(workspace, scopeId, decided, projectDisplayName(project)) }))
773
+ const lines = (list) => list.map((view) => `view ${view.scopeId}: ${view.path ?? 'no vault yet'} (${view.origin}${view.reason === undefined ? '' : `: ${view.reason}`})`)
774
+ if (sub === 'show' || sub === undefined) {
775
+ const { project, enablement, workspace } = readable()
776
+ const { decisions } = shownMachine(machineOf(workspace), workspace)
777
+ const shown = views(project, enablement, workspace, decisions.location)
778
+ return { exit: EXIT.ok, document: { location: decisions.location, views: shown }, human: [`where vaults live: ${decisionWords('location', decisions.location)}`, ...lines(shown)] }
779
+ }
780
+ if (sub !== 'set' || value === undefined) refuse('usage', 'location show | location set DIR [--allow-synced-location]')
781
+ // `~/` is read against the home folder, which a shell does not do after `--x=`; under the test runner a home
782
+ // folder is only ever one the test named.
783
+ const tilde = value === '~' || value.startsWith('~/')
784
+ 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')
785
+ // Resolved, so a trailing separator (as tab completion leaves it) is no refusal.
786
+ const parent = tilde ? path.resolve(path.join(homedir, value.slice(1))) : path.resolve(cwd, value)
787
+ await refuseOnPersonalBinding(loadProject(), 'location set')
788
+ const { project, enablement, workspace, repositoryRoots, now } = writable()
789
+ // A record that cannot be read names no folder here; the engine refuses its view on its own.
790
+ const allocatedPaths = enablement.scopes.map(({ scopeId }) => vaultWhere(workspace, scopeId, null, null)).filter((found) => found.origin === 'allocated').map((found) => found.path)
791
+ // A folder inside, or holding, a vault the app lists is refused now; when the list cannot be read, it is checked
792
+ // again, and has to be read, before any vault is allocated there.
793
+ const list = await appVaultList()
794
+ const warnings = checkVaultParent({ parent, workspaceRoot: workspace.workspaceRoot, repositoryRoots, vaults: list.vaults, allocatedPaths, allowSynced: flags['allow-synced-location'] === true, homedir: homedir ?? undefined, platform })
795
+ const appVaultListShown = { source: list.source, ...(list.reason === undefined ? {} : { reason: list.reason }) }
796
+ const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
797
+ const decided = withDecision(current, 'location', { parent }, { decidedAt: now, decidedBy: accountActor(account()), via: 'command' })
798
+ writeMachineSettings({ ...workspace, repositoryRoots, settings: { ...decided, updatedAt: now } })
799
+ const placed = views(project, enablement, workspace, decided.decisions.location)
800
+ return {
801
+ exit: EXIT.ok, document: { location: decided.decisions.location, warnings, appVaultList: appVaultListShown, views: placed, takesEffect: 'next-tick' },
802
+ human: [
803
+ `where vaults live: ${parent}; a view's vault is allocated there at its first publication, as "${projectDisplayName(project)} (<view>)"`,
804
+ ...(warnings.synced === null ? [] : [`Warning: ${warnings.synced} keeps this folder in step with other machines.`]),
805
+ ...(warnings.protected === null ? [] : [`Warning: macOS asks before Obsidian or the maintenance service may read your ${warnings.protected} folder.`]),
806
+ ...(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.`] : []),
807
+ ...lines(placed),
808
+ ],
809
+ }
810
+ },
811
+
812
+ // One more view in the project's configuration. A person at a terminal sees the change and how many notes the view
813
+ // would show, and is asked; --yes answers beforehand. Anyone else gets the change made, and shown.
814
+ async view() {
815
+ if (sub === 'list' || sub === 'show' || sub === undefined) return operations.scope()
816
+ 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]')
817
+ await refuseOnPersonalBinding(loadProject(), 'view add')
818
+ const { project, workspace, workspaceId } = readable()
819
+ const repositories = project.repos.filter((repo) => !repo.external && typeof repo.name === 'string').map((repo) => repo.name)
820
+ const scope = viewFromRequest({ scopeId: value, all: flags.all === true, folders: flags.folder ?? [], repo: flags.repo, tag: flags.tag, expand: flags.expand, repositories })
821
+ const plan = planViewAdd(project, { scope, makeDefault: flags.default === true })
822
+ // Counted as this machine decided who may see; while nobody decided, for "only you", the answer offered first,
823
+ // which shows the notes without a classification too.
824
+ const machine = machineOf(workspace)
825
+ const decided = machine?.decisions.audience ?? null
826
+ const audience = { allow: decided === null ? ONLY_YOU_AUDIENCES : machine.audienceAllow, decided: decided !== null }
827
+ const { eligibilityFor, onlyYouEligibility } = await import('../runtime/obsidian/pipeline.mjs')
828
+ const { viewCounts } = await import('../runtime/obsidian/view-counts.mjs')
829
+ const eligibility = decided === null ? onlyYouEligibility({ project }) : eligibilityFor({ machine, project })
830
+ const counts = viewCounts({ project, audienceAllow: audience.allow, scope, eligibility, ...(workspaceId === null ? {} : { workspaceId }) })
831
+ const countLine = viewCountWords(scope.scopeId, counts, audience)
832
+ // No audience admitted on this machine (`audience clear`): the view is empty whatever it selects, and the way out is
833
+ // deciding who may see, not classifying notes.
834
+ const nobody = audience.decided && audience.allow.length === 0 ? '; no audience is admitted on this machine: `atelier obsidian audience set me` admits yours' : ''
835
+ if (counts.shown === 0 && flags['allow-empty'] !== true) refuse('view-would-be-empty', `${countLine}${nobody}; nothing was written`, { scopeId: scope.scopeId, counts, audience })
836
+ const file = path.basename(project.configPath)
837
+ const shownPlan = [
838
+ `Atelier will add the view "${scope.scopeId}" to ${file}${plan.ignore.needed ? ', and .atelier-local/ to .gitignore' : ''} (you commit it):`,
839
+ ...plan.diff.trimEnd().split('\n').map((line) => ` ${line}`),
840
+ countLine,
841
+ ]
842
+ const asked = interactive && flags.yes !== true
843
+ if (asked) {
844
+ for (const line of shownPlan) stdout(line)
845
+ const yes = await askGoAhead(questioner(), 'Write this change? [Y/n] ')
846
+ if (yes === null) refuse('unanswered', 'no answer came; nothing was written')
847
+ if (!yes) return { exit: EXIT.notSuccess, document: { scope, declined: true, written: [], diff: plan.diff, counts, audience }, human: ['Nothing was written.'] }
848
+ }
849
+ const { written } = writeViewPlan(plan)
850
+ const committed = written.map((item) => path.relative(project.configDir, item).split(path.sep).join('/'))
851
+ // The counts are for "only you" while nobody decided, but the maintenance service publishes nothing until someone
852
+ // does: deciding comes first.
853
+ const openStep = `\`atelier obsidian open${plan.settings.defaultScopeId === scope.scopeId ? '' : ` --scope ${scope.scopeId}`}\` shows it in Obsidian`
854
+ const next = audience.decided ? [openStep] : ['`atelier obsidian audience set me` lets only you see its notes; until someone decides, the view publishes none', openStep]
855
+ return {
856
+ exit: EXIT.ok,
857
+ document: { scope, defaultScopeId: plan.settings.defaultScopeId ?? null, declined: false, written: committed, diff: plan.diff, counts, audience, next },
858
+ human: [
859
+ ...(asked ? [] : shownPlan),
860
+ `Added the view ${scope.scopeId}${plan.settings.defaultScopeId === scope.scopeId ? ', the default' : ''}. Commit ${committed.join(' and ')} when you are ready; Atelier commits nothing.`,
861
+ ...(plan.settings.enabled === true ? [] : ['The projection is turned off in this project ("enabled": false), so nothing is published until it is turned on.']),
862
+ `Next: ${next[0]}`, ...next.slice(1).map((step) => `Then: ${step}`),
863
+ ],
864
+ }
865
+ },
866
+
237
867
  async scope() {
238
868
  const { enablement } = readable()
239
869
  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 +875,32 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
245
875
  async audience() {
246
876
  if (sub === 'show' || sub === undefined) {
247
877
  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'] }
878
+ const { audienceAllow, decisions } = shownMachine(machineOf(workspace), workspace)
879
+ return { exit: EXIT.ok, document: { audienceAllow, choice: decisions.audience?.choice ?? null, unclassified: decisions.audience?.unclassified ?? 'withheld' }, human: [decisionWords('audience', decisions.audience, audienceAllow)] }
250
880
  }
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')
881
+ if (sub !== 'set' && sub !== 'clear') refuse('usage', 'audience show | audience set me|A,B | audience clear')
882
+ const named = sub === 'clear' ? [] : String(value ?? '').split(',').map((item) => item.trim()).filter((item) => item !== '')
883
+ if (sub === 'set' && named.length === 0) refuse('usage', 'audience set needs at least one audience; `audience clear` allows none')
884
+ 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')
885
+ // `me` stands for "only you". Named beside other audiences, it adds its own to theirs, and the list is the person's own choice.
886
+ const audienceAllow = [...new Set(named.flatMap((item) => (item === 'me' ? ONLY_YOU_AUDIENCES : [item])))]
887
+ const choice = named.length === 1 && named[0] === 'me' ? 'only-you' : 'custom'
888
+ await refuseOnPersonalBinding(loadProject(), `audience ${sub}`)
255
889
  const { workspace, repositoryRoots, now } = writable()
256
890
  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 } })
891
+ // A vault that is only yours shows the notes that carry no classification too; any other list withholds them.
892
+ const unclassified = choice === 'only-you' ? 'shown' : 'withheld'
893
+ const changed = JSON.stringify(current.audienceAllow) !== JSON.stringify(audienceAllow) || (current.decisions.audience?.unclassified ?? 'withheld') !== unclassified
894
+ const decided = withDecision({ ...current, audienceAllow }, 'audience', { choice, unclassified }, { decidedAt: now, decidedBy: accountActor(account()), via: 'command' })
895
+ writeMachineSettings({ ...workspace, repositoryRoots, settings: { ...decided, updatedAt: now } })
259
896
  // 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`] }
897
+ return {
898
+ exit: EXIT.ok, document: { audienceAllow, choice, unclassified, changed, takesEffect: 'next-tick' },
899
+ human: [
900
+ `audiences: ${choice === 'only-you' ? `only you (${audienceAllow.join(', ')})` : audienceAllow.join(', ') || 'none'}; notes without a classification ${unclassified}; views are rebuilt at the next tick`,
901
+ ...(choice === 'only-you' ? [] : ['Notes without a classification are shown only in a vault that is only yours: `atelier obsidian audience set me`.']),
902
+ ],
903
+ }
261
904
  },
262
905
 
263
906
  async mode() {
@@ -325,44 +968,58 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
325
968
  if (sub === 'start') {
326
969
  configured()
327
970
  const seam = await serviceSeam()
328
- const result = shown(await startService({ ...lifecycle, detached: true, ...seam, ...(consent === undefined ? {} : { consent }) }, lifecycleRules))
971
+ const consent = consentOfThisRun(seam)
972
+ const adapterRemembered = rememberAdapter(consent.consent?.actor)
973
+ const result = shown(await startService({ ...lifecycle, detached: true, ...seam, replaceOutdated: true, ...(consent.consent === undefined ? {} : { consent: consent.consent }) }, lifecycleRules))
974
+ const recorded = consentRecorded(consent)
329
975
  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})`}`] }
976
+ return {
977
+ exit: running ? EXIT.ok : EXIT.notSuccess,
978
+ document: { service: result, rememberedNow: { adapter: adapterRemembered, consentActor: recorded.source === 'account' ? recorded.consent.actor : null } },
979
+ human: [
980
+ result.state === 'busy' && !result.started ? 'running, and busy in a long tick; nothing was started beside it' : serviceWords(result),
981
+ ...(result.release === 'later' ? [`Next: ${REASON_NEXT['service-other-release']}`] : []),
982
+ ...rememberedLines({ adapterRemembered, consent: recorded }), ...loginItemLines(result.loginItem),
983
+ ],
984
+ }
331
985
  }
332
986
  if (sub === 'stop') {
333
987
  const result = await stopService(lifecycle, lifecycleRules)
334
988
  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
989
  }
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] }
990
+ 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')
991
+ if (flags.install === true) return installUnit()
992
+ if (flags.remove === true) return removeUnit()
993
+ return printUnit()
346
994
  },
347
995
 
348
996
  async open() {
349
997
  const seam = await serviceSeam()
350
998
  const app = await appSeams()
351
999
  const { appProbe } = app
1000
+ const consent = consentOfThisRun(seam)
1001
+ const adapterRemembered = rememberAdapter(consent.consent?.actor)
352
1002
  // The view whose plugin may report the app version; an unknown one is refused by open itself.
353
1003
  const requested = () => { try { return resolveScope(readObsidianEnablement(loadProject()), flags.scope) } catch { return null } }
354
1004
  const result = await openScopeForOracleTests({
355
1005
  ...lifecycle, ...app,
356
1006
  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,
1007
+ service: seam, scopeId: flags.scope, consent: consent.consent, allowStale: flags['allow-stale'] === true, restartApp: flags['restart-obsidian'] === true, extensions: registry.extensions,
358
1008
  ...(flags['wait-ms'] === undefined ? {} : { tickTimeoutMs: Number(flags['wait-ms']) || undefined }), ...(options.open ?? {}),
359
1009
  }, openingRules, lifecycleRules)
1010
+ const recorded = consentRecorded(consent)
360
1011
  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 }
1012
+ const plugin = typeof result.scopeId === 'string' ? withPluginFilesPending(await pluginOf(result.scopeId), result.freshness) : null
1013
+ const rememberedNow = { adapter: adapterRemembered, consentActor: recorded.source === 'account' ? recorded.consent.actor : null }
1014
+ const document = plugin === null ? { ...opened, rememberedNow } : { ...opened, plugin, rememberedNow }
363
1015
  return {
364
1016
  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}`] : [])],
1017
+ human: [
1018
+ `${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})`] : []),
1019
+ ...loginItemLines(result.service?.loginItem), ...restartLines(result.restart), ...obsidianSettingsLines(result.obsidianSettings),
1020
+ ...(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}`] : []),
1021
+ ...rememberedLines({ adapterRemembered, consent: recorded }),
1022
+ ],
366
1023
  }
367
1024
  },
368
1025
 
@@ -377,9 +1034,9 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
377
1034
  const scopeId = resolveScope(enablement, flags.scope)
378
1035
  const choice = writePluginChoice({ ...workspace, scopeId, state: 'requested', reason: 'requested-by-command', clock })
379
1036
  // 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
1037
+ // With the installed entry to start (`--adapter`, given or remembered), a service of an earlier release still running after an upgrade
381
1038
  // 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
1039
+ const replaceable = seams !== null || adapterSelected()
383
1040
  let asked
384
1041
  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
1042
  const view = asked.tick?.scopes?.find((scope) => scope.scopeId === scopeId) ?? null
@@ -413,6 +1070,36 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
413
1070
  }
414
1071
  },
415
1072
 
1073
+ // Maintenance of this workspace ends here: the login item goes first, so its manager does not start the service
1074
+ // again, then the proven service stops. Everything a person made or may want back stays, and is named.
1075
+ async uninstall() {
1076
+ const project = loadProject()
1077
+ let workspace = null
1078
+ try { const found = resolveServiceWorkspace({ project, dataRoot, env, platform }); workspace = found?.workspaceRoot ? found : null } catch (error) { if (!isTyped(error)) throw error }
1079
+ const record = workspace === null ? null : readLoginItemRecord(workspace)
1080
+ // What is kept is read before anything is stopped or removed, so nothing it finds can stop the answer after that.
1081
+ const kept = keptLocations(project, workspace)
1082
+ const item = record === null ? { removed: false, reason: 'not-installed' } : await removeLoginItem({ loadProject, dataRoot, env, platform, manager: await managerSeam(), clock })
1083
+ const service = workspace === null ? { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' } : await stopService(lifecycle, lifecycleRules)
1084
+ const itemGone = item.removed === true || item.reason === 'not-installed' || item.reason === 'workspace-not-prepared'
1085
+ const serviceGone = service.stopped === true || service.state === 'stopped'
1086
+ const remembered = workspace !== null && itemGone ? rememberLoginItem('off') : false
1087
+ return {
1088
+ exit: itemGone && serviceGone ? EXIT.ok : EXIT.notSuccess,
1089
+ document: { loginItem: item, service, kept, rememberedNow: { loginItem: remembered ? 'off' : null } },
1090
+ human: [
1091
+ `login item: ${item.removed ? `removed (${item.file})` : item.reason === 'not-installed' || item.reason === 'workspace-not-prepared' ? 'none was installed' : `not removed (${item.reason})`}`,
1092
+ `service: ${service.stopped ? 'stopped' : service.refused ? `not stopped: ${service.state} (${service.reason})` : `not running (${service.reason})`}`,
1093
+ ...(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`'}`]),
1094
+ 'Kept as they are:',
1095
+ ...kept.vaults.map((vault) => ` vault ${vault}`),
1096
+ ...(kept.privateState === null ? [] : [` private state ${kept.privateState}`]),
1097
+ ` project file ${kept.projectFile ?? '(none)'}; its Obsidian settings stay`,
1098
+ ...(kept.obsidianList === null ? [] : [` Obsidian's list ${kept.obsidianList}; the vaults stay in it`]),
1099
+ ],
1100
+ }
1101
+ },
1102
+
416
1103
  // The placeholder that answers when no contribution registered an apply operation; the shipped source-apply contribution replaces it.
417
1104
  async apply() {
418
1105
  return refuse(APPLY_UNAVAILABLE, 'no apply operation is registered; pending edits stay preserved and queued', { operationId: registry.extensions.applyOperation().id })
@@ -424,6 +1111,8 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
424
1111
  // One option table serves every operation. No built-in operation has a use for an actor, and a contributed one takes
425
1112
  // it only when it declares so (`options: ['actor']`); the apply operation then decides which of its verbs takes it.
426
1113
  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')
1114
+ // Stopping the person's app is asked for one run of `open`, by name, and by nothing else.
1115
+ if (flags['restart-obsidian'] === true && operationName !== 'open') refuse('usage', '--restart-obsidian belongs to `open`; this operation does not take it')
427
1116
  const result = contributed !== null
428
1117
  ? await contributed.run({ args: positionals.slice(1), flags: { ...flags }, registry, loadProject, dataRoot, env, platform, clock, readable, writable })
429
1118
  : await operations[operationName]()
@@ -436,6 +1125,8 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
436
1125
  }
437
1126
  const next = NEXT[error.code] ?? error.hint ?? 'see `atelier obsidian status`'
438
1127
  return emit({ exit: EXIT.refused, document: { error: { code: error.code, message: plainMessage(error), next, detail: error.detail ?? {} } }, human: [`[${error.code}] ${plainMessage(error)}`, `Next: ${next}`] })
1128
+ } finally {
1129
+ questions?.close()
439
1130
  }
440
1131
  }
441
1132