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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (560) hide show
  1. package/CHANGELOG.md +1005 -0
  2. package/README.md +149 -9
  3. package/contracts/atelier-adoption-policy.v2.schema.json +55 -0
  4. package/contracts/atelier-architecture.v1.schema.json +258 -0
  5. package/contracts/atelier-boundary-delegations.v1.schema.json +161 -0
  6. package/contracts/atelier-boundary-owner-keys.v1.schema.json +98 -0
  7. package/contracts/atelier-build.v1.schema.json +1076 -0
  8. package/contracts/atelier-capability.v1.schema.json +1252 -0
  9. package/contracts/atelier-coordination.v1.schema.json +1050 -0
  10. package/contracts/atelier-evidence-navigation.v1.schema.json +603 -0
  11. package/contracts/atelier-harness.v1.schema.json +234 -0
  12. package/contracts/atelier-ingestion.v1.schema.json +806 -0
  13. package/contracts/atelier-inquiry.v1.schema.json +1772 -0
  14. package/contracts/atelier-instruction-adoption.v1.schema.json +490 -0
  15. package/contracts/atelier-interaction.v1.schema.json +553 -0
  16. package/contracts/atelier-judgment.v1.schema.json +690 -0
  17. package/contracts/atelier-knowledge.v1.schema.json +1512 -0
  18. package/contracts/atelier-learning.v1.schema.json +1095 -0
  19. package/contracts/atelier-migration.v3.schema.json +64 -0
  20. package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
  21. package/contracts/atelier-personal-workspace-manifest.v1.schema.json +87 -0
  22. package/contracts/atelier-personal-workspace-overlay.v1.schema.json +299 -0
  23. package/contracts/atelier-template-adoption.v1.schema.json +223 -0
  24. package/contracts/atelier-template-profile.v1.schema.json +1168 -0
  25. package/contracts/atelier-trackable.v1.schema.json +781 -0
  26. package/contracts/atelier-upgrade-plan.v3.schema.json +532 -0
  27. package/contracts/compat-baseline.json +1 -1
  28. package/docs/architecture.md +89 -0
  29. package/docs/assurance-controls.md +4 -3
  30. package/docs/blocks/capability-stewardship.md +17 -0
  31. package/docs/blocks/claims.md +16 -3
  32. package/docs/blocks/inquiry-harnesses.md +19 -0
  33. package/docs/blocks/learning-harnesses.md +17 -0
  34. package/docs/capability-stewardship.md +269 -0
  35. package/docs/coordination.md +51 -0
  36. package/docs/decision-practices.md +111 -0
  37. package/docs/decisions.md +29 -0
  38. package/docs/evidence-navigation.md +237 -0
  39. package/docs/exact-upgrades.md +3 -2
  40. package/docs/ingestion.md +181 -0
  41. package/docs/inquiry-harnesses.md +211 -0
  42. package/docs/install.md +80 -3
  43. package/docs/intake-and-guides.md +5 -0
  44. package/docs/integration-contract-decisions.md +24 -2
  45. package/docs/interaction-and-reflection.md +72 -0
  46. package/docs/knowledge-graph.md +20 -0
  47. package/docs/knowledge-health/api.md +30 -0
  48. package/docs/knowledge-health/assessment/api.md +96 -0
  49. package/docs/knowledge-health/assessment/capabilities.md +40 -0
  50. package/docs/knowledge-health/assessment/evaluation.md +27 -0
  51. package/docs/knowledge-health/assessment/facilitator.md +30 -0
  52. package/docs/knowledge-health/assessment/quickstart.md +61 -0
  53. package/docs/knowledge-health/assessment/troubleshooting.md +23 -0
  54. package/docs/knowledge-health/devday/facilitator.md +16 -0
  55. package/docs/knowledge-health/devday/participant-response.md +22 -0
  56. package/docs/knowledge-health/devday/public-default.md +107 -0
  57. package/docs/knowledge-health/devday/quickstart.md +18 -0
  58. package/docs/knowledge-health/devday/support.md +22 -0
  59. package/docs/knowledge-health/exercise.md +17 -0
  60. package/docs/knowledge-health/facilitator.md +20 -0
  61. package/docs/knowledge-health/quickstart.md +94 -0
  62. package/docs/knowledge-health/readme.md +15 -0
  63. package/docs/knowledge-health/support.md +34 -0
  64. package/docs/knowledge-setup.md +248 -0
  65. package/docs/knowledge-workspace.md +264 -0
  66. package/docs/learning-harnesses.md +238 -0
  67. package/docs/learning.md +215 -0
  68. package/docs/local-services.md +242 -26
  69. package/docs/obsidian-contract.md +878 -36
  70. package/docs/obsidian-plugin.md +619 -0
  71. package/docs/obsidian.md +809 -64
  72. package/docs/optional-ingestion.md +83 -0
  73. package/docs/personal-workspace.md +257 -0
  74. package/docs/practical-judgment.md +92 -0
  75. package/docs/progressive-ingestion-acceptance.md +89 -0
  76. package/docs/progressive-ingestion.md +235 -0
  77. package/docs/project-command-inventory.md +1 -0
  78. package/docs/release-engineering.md +56 -1
  79. package/docs/repo-boundary-guard.md +94 -0
  80. package/docs/research-starter-quickstart.md +66 -0
  81. package/docs/research-starter.md +114 -0
  82. package/docs/semantic-ingestion.md +198 -0
  83. package/docs/semantic-operation.md +317 -0
  84. package/docs/skill-steward.md +6 -0
  85. package/docs/template-profile.md +164 -0
  86. package/docs/template-upgrades.md +114 -0
  87. package/docs/trackables.md +111 -0
  88. package/docs/upgrade.md +68 -3
  89. package/fixtures/atelier-architecture/binding/invalid/document.json +19 -0
  90. package/fixtures/atelier-architecture/binding/valid/document.json +19 -0
  91. package/fixtures/atelier-architecture/catalog/invalid/document.json +290 -0
  92. package/fixtures/atelier-architecture/catalog/valid/document.json +343 -0
  93. package/fixtures/atelier-build-contract/attempt/invalid/document.json +20 -0
  94. package/fixtures/atelier-build-contract/attempt/valid/document.json +19 -0
  95. package/fixtures/atelier-build-contract/candidate/invalid/document.json +24 -0
  96. package/fixtures/atelier-build-contract/candidate/valid/document.json +23 -0
  97. package/fixtures/atelier-build-contract/decision/invalid/document.json +31 -0
  98. package/fixtures/atelier-build-contract/decision/valid/document.json +30 -0
  99. package/fixtures/atelier-build-contract/delivery/invalid/document.json +18 -0
  100. package/fixtures/atelier-build-contract/delivery/valid/document.json +17 -0
  101. package/fixtures/atelier-build-contract/gate/invalid/document.json +22 -0
  102. package/fixtures/atelier-build-contract/gate/valid/document.json +21 -0
  103. package/fixtures/atelier-build-contract/ledger/invalid/document.json +270 -0
  104. package/fixtures/atelier-build-contract/ledger/valid/document.json +269 -0
  105. package/fixtures/atelier-build-contract/objective/invalid/document.json +73 -0
  106. package/fixtures/atelier-build-contract/objective/valid/document.json +72 -0
  107. package/fixtures/atelier-build-contract/progress/invalid/document.json +18 -0
  108. package/fixtures/atelier-build-contract/progress/valid/document.json +17 -0
  109. package/fixtures/atelier-capability-contract/adoption/invalid/document.json +71 -0
  110. package/fixtures/atelier-capability-contract/adoption/valid/document.json +70 -0
  111. package/fixtures/atelier-capability-contract/event/invalid/document.json +18 -0
  112. package/fixtures/atelier-capability-contract/event/valid/document.json +17 -0
  113. package/fixtures/atelier-capability-contract/journal/invalid/document.json +382 -0
  114. package/fixtures/atelier-capability-contract/journal/valid/document.json +381 -0
  115. package/fixtures/atelier-capability-contract/notice/invalid/document.json +8 -0
  116. package/fixtures/atelier-capability-contract/notice/valid/document.json +7 -0
  117. package/fixtures/atelier-capability-contract/package/invalid/document.json +59 -0
  118. package/fixtures/atelier-capability-contract/package/valid/document.json +58 -0
  119. package/fixtures/atelier-capability-contract/plan/invalid/document.json +107 -0
  120. package/fixtures/atelier-capability-contract/plan/valid/document.json +106 -0
  121. package/fixtures/atelier-capability-contract/release/invalid/document.json +85 -0
  122. package/fixtures/atelier-capability-contract/release/valid/document.json +84 -0
  123. package/fixtures/atelier-capability-contract/state/invalid/document.json +316 -0
  124. package/fixtures/atelier-capability-contract/state/valid/document.json +315 -0
  125. package/fixtures/atelier-coordination/dependency/invalid/document.json +37 -0
  126. package/fixtures/atelier-coordination/dependency/valid/document.json +36 -0
  127. package/fixtures/atelier-coordination/directive/invalid/document.json +25 -0
  128. package/fixtures/atelier-coordination/directive/valid/document.json +24 -0
  129. package/fixtures/atelier-coordination/disposition/invalid/document.json +34 -0
  130. package/fixtures/atelier-coordination/disposition/valid/document.json +33 -0
  131. package/fixtures/atelier-coordination/outcome/invalid/document.json +39 -0
  132. package/fixtures/atelier-coordination/outcome/valid/document.json +38 -0
  133. package/fixtures/atelier-coordination/recovery/invalid/document.json +34 -0
  134. package/fixtures/atelier-coordination/recovery/valid/document.json +33 -0
  135. package/fixtures/atelier-harness-contract/handoff/invalid/document.json +25 -0
  136. package/fixtures/atelier-harness-contract/handoff/valid/document.json +24 -0
  137. package/fixtures/atelier-ingestion/invalid/authority.json +19 -0
  138. package/fixtures/atelier-ingestion/semantic-baseline.json +26 -0
  139. package/fixtures/atelier-ingestion/semantic-operation.json +75 -0
  140. package/fixtures/atelier-ingestion/valid/plan-input.json +18 -0
  141. package/fixtures/atelier-inquiry-contract/assessment/invalid/document.json +41 -0
  142. package/fixtures/atelier-inquiry-contract/assessment/valid/document.json +40 -0
  143. package/fixtures/atelier-inquiry-contract/bundle/invalid/document.json +51 -0
  144. package/fixtures/atelier-inquiry-contract/bundle/valid/document.json +50 -0
  145. package/fixtures/atelier-inquiry-contract/campaign/invalid/document.json +17 -0
  146. package/fixtures/atelier-inquiry-contract/campaign/valid/document.json +16 -0
  147. package/fixtures/atelier-inquiry-contract/decision/invalid/document.json +22 -0
  148. package/fixtures/atelier-inquiry-contract/decision/valid/document.json +21 -0
  149. package/fixtures/atelier-inquiry-contract/feedback/invalid/document.json +19 -0
  150. package/fixtures/atelier-inquiry-contract/feedback/valid/document.json +18 -0
  151. package/fixtures/atelier-inquiry-contract/handoff/invalid/document.json +92 -0
  152. package/fixtures/atelier-inquiry-contract/handoff/valid/document.json +91 -0
  153. package/fixtures/atelier-inquiry-contract/hypothesis/invalid/document.json +20 -0
  154. package/fixtures/atelier-inquiry-contract/hypothesis/valid/document.json +19 -0
  155. package/fixtures/atelier-inquiry-contract/ledger/invalid/document.json +680 -0
  156. package/fixtures/atelier-inquiry-contract/ledger/valid/document.json +679 -0
  157. package/fixtures/atelier-inquiry-contract/legacy-assessment/invalid/document.json +19 -0
  158. package/fixtures/atelier-inquiry-contract/legacy-assessment/valid/document.json +18 -0
  159. package/fixtures/atelier-inquiry-contract/request/invalid/document.json +50 -0
  160. package/fixtures/atelier-inquiry-contract/request/valid/document.json +49 -0
  161. package/fixtures/atelier-inquiry-contract/source/invalid/document.json +18 -0
  162. package/fixtures/atelier-inquiry-contract/source/valid/document.json +17 -0
  163. package/fixtures/atelier-inquiry-contract/withdrawal/invalid/document.json +16 -0
  164. package/fixtures/atelier-inquiry-contract/withdrawal/valid/document.json +15 -0
  165. package/fixtures/atelier-instruction-adoption/context-receipt/invalid/document.json +18 -0
  166. package/fixtures/atelier-instruction-adoption/context-receipt/valid/document.json +17 -0
  167. package/fixtures/atelier-instruction-adoption/plan/invalid/document.json +63 -0
  168. package/fixtures/atelier-instruction-adoption/plan/valid/document.json +62 -0
  169. package/fixtures/atelier-instruction-adoption/receipt/invalid/document.json +22 -0
  170. package/fixtures/atelier-instruction-adoption/receipt/valid/document.json +21 -0
  171. package/fixtures/atelier-instruction-adoption/state/invalid/document.json +30 -0
  172. package/fixtures/atelier-instruction-adoption/state/valid/document.json +29 -0
  173. package/fixtures/atelier-interaction/act/invalid/document.json +25 -0
  174. package/fixtures/atelier-interaction/act/valid/document.json +24 -0
  175. package/fixtures/atelier-interaction/assessment/invalid/document.json +32 -0
  176. package/fixtures/atelier-interaction/assessment/valid/document.json +31 -0
  177. package/fixtures/atelier-interaction/observation/invalid/document.json +22 -0
  178. package/fixtures/atelier-interaction/observation/valid/document.json +21 -0
  179. package/fixtures/atelier-interaction/policy/invalid/document.json +20 -0
  180. package/fixtures/atelier-interaction/policy/valid/document.json +19 -0
  181. package/fixtures/atelier-judgment/case/invalid/document.json +76 -0
  182. package/fixtures/atelier-judgment/case/valid/document.json +75 -0
  183. package/fixtures/atelier-judgment/decision/invalid/document.json +38 -0
  184. package/fixtures/atelier-judgment/decision/valid/document.json +37 -0
  185. package/fixtures/atelier-judgment/native-decision.md +3 -0
  186. package/fixtures/atelier-judgment/practice-reconsideration/definition.json +92 -0
  187. package/fixtures/atelier-judgment/practice-reconsideration/scenario.json +435 -0
  188. package/fixtures/atelier-knowledge-contract/activation/invalid/document.json +26 -0
  189. package/fixtures/atelier-knowledge-contract/activation/valid/document.json +25 -0
  190. package/fixtures/atelier-knowledge-contract/contribution/invalid/document.json +34 -0
  191. package/fixtures/atelier-knowledge-contract/contribution/valid/document.json +33 -0
  192. package/fixtures/atelier-knowledge-contract/domain/invalid/document.json +41 -0
  193. package/fixtures/atelier-knowledge-contract/domain/valid/document.json +40 -0
  194. package/fixtures/atelier-knowledge-contract/domain-revision/invalid/document.json +46 -0
  195. package/fixtures/atelier-knowledge-contract/domain-revision/valid/document.json +45 -0
  196. package/fixtures/atelier-knowledge-contract/evaluation/invalid/document.json +21 -0
  197. package/fixtures/atelier-knowledge-contract/evaluation/valid/document.json +20 -0
  198. package/fixtures/atelier-knowledge-contract/ledger/invalid/document.json +318 -0
  199. package/fixtures/atelier-knowledge-contract/ledger/valid/document.json +317 -0
  200. package/fixtures/atelier-knowledge-contract/relation/invalid/document.json +25 -0
  201. package/fixtures/atelier-knowledge-contract/relation/valid/document.json +24 -0
  202. package/fixtures/atelier-knowledge-contract/review/invalid/document.json +18 -0
  203. package/fixtures/atelier-knowledge-contract/review/valid/document.json +17 -0
  204. package/fixtures/atelier-knowledge-contract/withdrawal/invalid/document.json +16 -0
  205. package/fixtures/atelier-knowledge-contract/withdrawal/valid/document.json +15 -0
  206. package/fixtures/atelier-learning/invalid/authority.json +14 -0
  207. package/fixtures/atelier-learning/valid/capture.json +13 -0
  208. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/authority-key.json +17 -0
  209. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/nonempty-ext.json +25 -0
  210. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/wrong-schema.json +22 -0
  211. package/fixtures/atelier-personal-workspace-contract/manifest/valid/manifest.json +22 -0
  212. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/authority-key.json +25 -0
  213. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/unknown-top-level-field.json +27 -0
  214. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/wrong-schema.json +24 -0
  215. package/fixtures/atelier-trackable/definition/invalid/document.json +15 -0
  216. package/fixtures/atelier-trackable/definition/valid/document.json +14 -0
  217. package/fixtures/atelier-trackable/request/invalid/document.json +23 -0
  218. package/fixtures/atelier-trackable/request/valid/document.json +22 -0
  219. package/fixtures/boundary-delegations/invalid/authority-key.v1.json +30 -0
  220. package/fixtures/boundary-delegations/invalid/push-operation.v1.json +28 -0
  221. package/fixtures/boundary-delegations/invalid/unsigned.v1.json +25 -0
  222. package/fixtures/boundary-delegations/valid/empty.v1.json +4 -0
  223. package/fixtures/boundary-delegations/valid/signed-operator.v1.json +30 -0
  224. package/fixtures/boundary-owner-keys/invalid/wrong-algorithm.v1.json +15 -0
  225. package/fixtures/boundary-owner-keys/invalid/wrong-curve.v1.json +15 -0
  226. package/fixtures/boundary-owner-keys/valid/one-owner.v1.json +17 -0
  227. package/fixtures/capability-packages/adoption.example.json +70 -0
  228. package/fixtures/capability-packages/evidence-review/capability-package.json +58 -0
  229. package/fixtures/capability-packages/evidence-review/capability-release.json +84 -0
  230. package/fixtures/capability-packages/evidence-review/evaluation.md +15 -0
  231. package/fixtures/capability-packages/evidence-review/skills/evidence-review/SKILL.md +22 -0
  232. package/fixtures/capability-packages/evidence-review/skills/evidence-review/references/ledger.md +7 -0
  233. package/fixtures/capability-packages/research/capability-package.json +88 -0
  234. package/fixtures/capability-packages/research/capability-release.json +134 -0
  235. package/fixtures/capability-packages/research/evaluation.md +15 -0
  236. package/fixtures/capability-packages/research/skills/research-integrate/SKILL.md +29 -0
  237. package/fixtures/capability-packages/research/skills/research-integrate/references/integration.md +10 -0
  238. package/fixtures/capability-packages/research/skills/research-plan/SKILL.md +27 -0
  239. package/fixtures/capability-packages/research/skills/research-plan/references/inquiries.md +26 -0
  240. package/fixtures/capability-packages/research/skills/research-synthesize/SKILL.md +23 -0
  241. package/fixtures/capability-packages/research/skills/research-synthesize/references/synthesis.md +12 -0
  242. package/fixtures/evidence-navigation/invalid/duplicate-operation.json +16 -0
  243. package/fixtures/evidence-navigation/invalid/instruction-kind.json +36 -0
  244. package/fixtures/evidence-navigation/invalid/invalid-digest.json +36 -0
  245. package/fixtures/evidence-navigation/invalid/invalid-time.json +36 -0
  246. package/fixtures/evidence-navigation/invalid/missing-attribution.json +35 -0
  247. package/fixtures/evidence-navigation/invalid/reserved-extension.json +19 -0
  248. package/fixtures/evidence-navigation/invalid/unattributed-acceptance.json +36 -0
  249. package/fixtures/evidence-navigation/invalid/uncoerced-version.json +16 -0
  250. package/fixtures/evidence-navigation/invalid/unknown-currency.json +17 -0
  251. package/fixtures/evidence-navigation/invalid/unknown-selector-field.json +37 -0
  252. package/fixtures/evidence-navigation/invalid/unsupported-operation.json +15 -0
  253. package/fixtures/evidence-navigation/valid/claim.json +36 -0
  254. package/fixtures/evidence-navigation/valid/host.json +16 -0
  255. package/fixtures/evidence-navigation/valid/profile.json +16 -0
  256. package/fixtures/evidence-navigation/valid/snapshot.json +17 -0
  257. package/fixtures/harness-packages/build-harness/capability-package.json +61 -0
  258. package/fixtures/harness-packages/build-harness/capability-release.json +82 -0
  259. package/fixtures/harness-packages/build-harness/evaluation.md +3 -0
  260. package/fixtures/harness-packages/build-harness/skills/atelier-build-harness/SKILL.md +59 -0
  261. package/fixtures/harness-packages/knowledge-harness/capability-package.json +61 -0
  262. package/fixtures/harness-packages/knowledge-harness/capability-release.json +82 -0
  263. package/fixtures/harness-packages/knowledge-harness/evaluation.md +3 -0
  264. package/fixtures/harness-packages/knowledge-harness/skills/atelier-knowledge-harness/SKILL.md +60 -0
  265. package/fixtures/harnesses/learning-cycle.json +780 -0
  266. package/fixtures/inquiry/workshop.json +675 -0
  267. package/fixtures/inquiry-packages/discovery-harness/capability-package.json +61 -0
  268. package/fixtures/inquiry-packages/discovery-harness/capability-release.json +82 -0
  269. package/fixtures/inquiry-packages/discovery-harness/evaluation.md +3 -0
  270. package/fixtures/inquiry-packages/discovery-harness/skills/atelier-discovery-harness/SKILL.md +48 -0
  271. package/fixtures/inquiry-packages/research-harness/capability-package.json +61 -0
  272. package/fixtures/inquiry-packages/research-harness/capability-release.json +82 -0
  273. package/fixtures/inquiry-packages/research-harness/evaluation.md +3 -0
  274. package/fixtures/inquiry-packages/research-harness/skills/atelier-research-harness/SKILL.md +43 -0
  275. package/fixtures/knowledge-health/calibration-suite.json +26 -0
  276. package/fixtures/knowledge-health/calibration-trial.json +37 -0
  277. package/fixtures/knowledge-health/corrected/checklist.md +18 -0
  278. package/fixtures/knowledge-health/devday/participant-record.json +19 -0
  279. package/fixtures/knowledge-health/expected-summary.json +27 -0
  280. package/fixtures/knowledge-health/workshop/README.md +20 -0
  281. package/fixtures/knowledge-health/workshop/atelier.project.json +7 -0
  282. package/fixtures/knowledge-health/workshop/knowledge-plan.json +65 -0
  283. package/fixtures/knowledge-health/workshop/records/checklist.md +18 -0
  284. package/fixtures/knowledge-health/workshop/records/workshop.md +18 -0
  285. package/fixtures/knowledge-health/workshop/repo-access.v1.json +5 -0
  286. package/fixtures/knowledge-health/workspace/atelier.project.json +18 -0
  287. package/fixtures/knowledge-health/workspace/knowledge-plan.json +74 -0
  288. package/fixtures/knowledge-health/workspace/records/checklist.md +18 -0
  289. package/fixtures/knowledge-health/workspace/records/pilot.md +16 -0
  290. package/fixtures/knowledge-health/workspace/repo-access.v1.json +9 -0
  291. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
  292. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
  293. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
  294. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
  295. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
  296. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
  297. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
  298. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
  299. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
  300. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
  301. package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
  302. package/fixtures/obsidian/edits/workspace.json +1 -1
  303. package/fixtures/obsidian/materialization/expected-full.json +21 -21
  304. package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
  305. package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
  306. package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
  307. package/fixtures/personal-workspace/overlay.json +24 -0
  308. package/fixtures/templates/local-library.v1.json +75 -0
  309. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/invalid.json +16 -0
  310. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/valid.json +13 -0
  311. package/fixtures/templates/upgrade-contracts/migration.v3/invalid.json +24 -0
  312. package/fixtures/templates/upgrade-contracts/migration.v3/valid.json +21 -0
  313. package/fixtures/templates/upgrade-contracts/template-adoption.v1/invalid.json +60 -0
  314. package/fixtures/templates/upgrade-contracts/template-adoption.v1/valid.json +57 -0
  315. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/invalid.json +85 -0
  316. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/valid.json +82 -0
  317. package/package.json +64 -6
  318. package/plugins/obsidian/main.js +444 -0
  319. package/plugins/obsidian/manifest.json +9 -0
  320. package/plugins/obsidian/styles.css +22 -0
  321. package/skills/claude/atelier-build-harness/SKILL.md +59 -0
  322. package/skills/claude/atelier-coordination/SKILL.md +32 -0
  323. package/skills/claude/atelier-discovery-harness/SKILL.md +48 -0
  324. package/skills/claude/atelier-guided-upgrade/SKILL.md +29 -0
  325. package/skills/claude/atelier-interaction/SKILL.md +34 -0
  326. package/skills/claude/atelier-knowledge-harness/SKILL.md +60 -0
  327. package/skills/claude/atelier-knowledge-setup/SKILL.md +72 -0
  328. package/skills/claude/atelier-practical-judgment/SKILL.md +64 -0
  329. package/skills/claude/atelier-research-harness/SKILL.md +43 -0
  330. package/skills/claude/atelier-skill-steward/SKILL.md +53 -0
  331. package/skills/claude/atelier-trackables/SKILL.md +40 -0
  332. package/skills/codex/atelier-build-harness/SKILL.md +59 -0
  333. package/skills/codex/atelier-coordination/SKILL.md +32 -0
  334. package/skills/codex/atelier-discovery-harness/SKILL.md +48 -0
  335. package/skills/codex/atelier-guided-upgrade/SKILL.md +29 -0
  336. package/skills/codex/atelier-interaction/SKILL.md +34 -0
  337. package/skills/codex/atelier-knowledge-harness/SKILL.md +60 -0
  338. package/skills/codex/atelier-knowledge-setup/SKILL.md +72 -0
  339. package/skills/codex/atelier-practical-judgment/SKILL.md +64 -0
  340. package/skills/codex/atelier-research-harness/SKILL.md +43 -0
  341. package/skills/codex/atelier-skill-steward/SKILL.md +53 -0
  342. package/skills/codex/atelier-trackables/SKILL.md +40 -0
  343. package/src/architecture/catalog.mjs +355 -0
  344. package/src/architecture/index.mjs +93 -0
  345. package/src/boundary/content-rules.mjs +4 -2
  346. package/src/boundary/delegation-command.mjs +158 -0
  347. package/src/boundary/delegation.mjs +396 -0
  348. package/src/boundary/policy.mjs +52 -10
  349. package/src/build/git.mjs +18 -0
  350. package/src/build/index.mjs +3 -0
  351. package/src/build/ledger.mjs +88 -0
  352. package/src/capabilities/adoption.mjs +381 -0
  353. package/src/capabilities/evidence.mjs +140 -0
  354. package/src/capabilities/files.mjs +124 -0
  355. package/src/capabilities/index.mjs +4 -0
  356. package/src/capabilities/instructions.mjs +250 -0
  357. package/src/capabilities/package.mjs +136 -0
  358. package/src/cli/command-failure.mjs +36 -0
  359. package/src/cli/execute-command.mjs +16 -2
  360. package/src/cli/run.mjs +65 -9
  361. package/src/coauthor/store.mjs +43 -2
  362. package/src/collaboration/proposals.mjs +189 -0
  363. package/src/commands/architecture.mjs +27 -0
  364. package/src/commands/boundary.mjs +3 -0
  365. package/src/commands/capability.mjs +73 -0
  366. package/src/commands/enroll.mjs +3 -0
  367. package/src/commands/harness.mjs +79 -0
  368. package/src/commands/ingest.mjs +39 -0
  369. package/src/commands/init.mjs +4 -1
  370. package/src/commands/inquiry.mjs +66 -0
  371. package/src/commands/knowledge-semantic.mjs +128 -0
  372. package/src/commands/knowledge.mjs +119 -0
  373. package/src/commands/learn.mjs +61 -0
  374. package/src/commands/obsidian.mjs +832 -62
  375. package/src/commands/practice.mjs +24 -0
  376. package/src/commands/setup.mjs +22 -2
  377. package/src/commands/trackable.mjs +19 -0
  378. package/src/contracts/corpus.mjs +36 -0
  379. package/src/coordination/index.mjs +110 -0
  380. package/src/egress/forbidden-egress.mjs +2 -0
  381. package/src/evidence-navigation/contracts.mjs +60 -0
  382. package/src/evidence-navigation/evaluate.mjs +69 -0
  383. package/src/evidence-navigation/generation.mjs +22 -0
  384. package/src/evidence-navigation/index.mjs +2 -0
  385. package/src/evidence-navigation/local.mjs +167 -0
  386. package/src/evidence-navigation/schema.generated.mjs +604 -0
  387. package/src/evidence-navigation/types.mjs +12 -0
  388. package/src/export/atelier-export-contract.mjs +35 -8
  389. package/src/graph/enroll.mjs +248 -0
  390. package/src/graph/graph.mjs +17 -1
  391. package/src/graph/knowledge-graph.mjs +58 -8
  392. package/src/harnesses/contracts.mjs +41 -0
  393. package/src/harnesses/exchange.mjs +154 -0
  394. package/src/harnesses/feedback.mjs +16 -0
  395. package/src/harnesses/history.mjs +37 -0
  396. package/src/harnesses/index.mjs +4 -0
  397. package/src/harnesses/store.mjs +29 -0
  398. package/src/ingestion/contracts.mjs +41 -0
  399. package/src/ingestion/evaluation.mjs +119 -0
  400. package/src/ingestion/optional/composition/index.mjs +171 -0
  401. package/src/ingestion/optional/evaluation/index.mjs +219 -0
  402. package/src/ingestion/optional/graphify/composition-binding.mjs +60 -0
  403. package/src/ingestion/optional/graphify/composition_receiving.py +185 -0
  404. package/src/ingestion/optional/graphify/graphify_supplier.py +933 -0
  405. package/src/ingestion/optional/graphify/json_bridge.py +163 -0
  406. package/src/ingestion/optional/graphify/query_receiving.py +209 -0
  407. package/src/ingestion/optional/index.mjs +9 -0
  408. package/src/ingestion/optional/jev/bridge.mjs +166 -0
  409. package/src/ingestion/optional/jev/composition-binding.mjs +116 -0
  410. package/src/ingestion/optional/jev/judgments.mjs +174 -0
  411. package/src/ingestion/optional/jev/native-translation.mjs +32 -0
  412. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/__init__.py +7 -0
  413. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/adapter.py +966 -0
  414. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/artifacts.py +101 -0
  415. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/projection.py +146 -0
  416. package/src/ingestion/optional/lightrag/composition-binding.mjs +67 -0
  417. package/src/ingestion/optional/vanilla/composition-binding.mjs +102 -0
  418. package/src/ingestion/optional/vanilla/index.mjs +218 -0
  419. package/src/ingestion/optional/vanilla/native-normalization.mjs +187 -0
  420. package/src/ingestion/processors.mjs +218 -0
  421. package/src/ingestion/semantic.mjs +203 -0
  422. package/src/ingestion/store.mjs +372 -0
  423. package/src/inquiry/beliefs.mjs +32 -0
  424. package/src/inquiry/contracts.mjs +23 -0
  425. package/src/inquiry/index.mjs +5 -0
  426. package/src/inquiry/ledger.mjs +135 -0
  427. package/src/inquiry/lenses.mjs +15 -0
  428. package/src/inquiry/projection.mjs +77 -0
  429. package/src/inquiry/store.mjs +35 -0
  430. package/src/intake/read-scope.mjs +12 -0
  431. package/src/intake/store.mjs +69 -6
  432. package/src/interaction/index.mjs +91 -0
  433. package/src/judgment/index.mjs +82 -0
  434. package/src/judgment/practice-evaluation.mjs +70 -0
  435. package/src/judgment/practice.mjs +144 -0
  436. package/src/knowledge/context.mjs +217 -0
  437. package/src/knowledge/index.mjs +5 -0
  438. package/src/knowledge/ingestion.mjs +79 -0
  439. package/src/knowledge/intake.mjs +33 -0
  440. package/src/knowledge/ledger.mjs +110 -0
  441. package/src/knowledge/participatory/guidance.mjs +36 -0
  442. package/src/knowledge/participatory/guided-contribution.mjs +44 -0
  443. package/src/knowledge/participatory/index.mjs +2 -0
  444. package/src/knowledge/participatory/installed-workshop.mjs +108 -0
  445. package/src/knowledge/plan.mjs +67 -0
  446. package/src/knowledge/projection.mjs +43 -0
  447. package/src/knowledge/semantic-operation.mjs +567 -0
  448. package/src/knowledge/sessions.mjs +467 -0
  449. package/src/knowledge/workspace.mjs +295 -0
  450. package/src/knowledge-health/assessment.mjs +165 -0
  451. package/src/knowledge-health/capabilities.mjs +350 -0
  452. package/src/knowledge-health/context.mjs +62 -0
  453. package/src/knowledge-health/example.mjs +47 -0
  454. package/src/knowledge-health/index.mjs +10 -0
  455. package/src/knowledge-health/owner-join.mjs +88 -0
  456. package/src/knowledge-health/proof/common.mjs +24 -0
  457. package/src/knowledge-health/proof/installed-check.mjs +100 -0
  458. package/src/knowledge-health/proof/native-readback.mjs +37 -0
  459. package/src/knowledge-health/proof/rehearse.mjs +68 -0
  460. package/src/knowledge-health/proof/validate-participant.mjs +17 -0
  461. package/src/knowledge-health/proof/verify-native-capture.mjs +14 -0
  462. package/src/knowledge-health/proof/workshop-check.mjs +36 -0
  463. package/src/knowledge-health/rule-identity.mjs +44 -0
  464. package/src/knowledge-health/workshop-composition.mjs +19 -0
  465. package/src/knowledge-health/workshop-profile.mjs +176 -0
  466. package/src/knowledge-health/workshop.mjs +99 -0
  467. package/src/learning/adapters.mjs +68 -0
  468. package/src/learning/assistance.mjs +127 -0
  469. package/src/learning/contracts.mjs +65 -0
  470. package/src/learning/store.mjs +272 -0
  471. package/src/personal-workspace/index.mjs +391 -0
  472. package/src/personal-workspace/selection.mjs +385 -0
  473. package/src/project/config.mjs +8 -0
  474. package/src/project/durable-state.mjs +3 -1
  475. package/src/project/file-class.mjs +1 -1
  476. package/src/project/private-state.mjs +10 -1
  477. package/src/projection/obsidian/contracts.mjs +224 -18
  478. package/src/projection/obsidian/edits/apply.mjs +63 -19
  479. package/src/projection/obsidian/edits/regions.mjs +137 -42
  480. package/src/projection/obsidian/materialize/index.mjs +6 -3
  481. package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
  482. package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
  483. package/src/projection/obsidian/materialize/redaction.mjs +327 -0
  484. package/src/projection/obsidian/materialize/settings.mjs +113 -34
  485. package/src/projection/obsidian/personal-workspace.mjs +436 -0
  486. package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
  487. package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
  488. package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
  489. package/src/projection/obsidian/proposals/adapter.mjs +47 -8
  490. package/src/projection/obsidian/proposals/observation.mjs +20 -7
  491. package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
  492. package/src/projection/obsidian/publication/index.mjs +2 -1
  493. package/src/projection/obsidian/publication/publisher.mjs +312 -36
  494. package/src/projection/obsidian/publication/transport.mjs +63 -8
  495. package/src/projection/obsidian/publication/vault-list.mjs +200 -0
  496. package/src/projection/obsidian/recovery/index.mjs +4 -1
  497. package/src/projection/obsidian/recovery/restart.mjs +3 -1
  498. package/src/projection/obsidian/recovery/store.mjs +168 -8
  499. package/src/projection/obsidian/selection-ui/contribution.mjs +6 -5
  500. package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
  501. package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
  502. package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
  503. package/src/readiness/readiness.mjs +10 -3
  504. package/src/reflection/index.mjs +56 -0
  505. package/src/runtime/obsidian/app-capability.mjs +104 -12
  506. package/src/runtime/obsidian/app-production-seams.mjs +161 -42
  507. package/src/runtime/obsidian/app-registration.mjs +313 -0
  508. package/src/runtime/obsidian/app-restart.mjs +154 -0
  509. package/src/runtime/obsidian/engine.mjs +288 -29
  510. package/src/runtime/obsidian/extension-points.mjs +1 -1
  511. package/src/runtime/obsidian/index.mjs +15 -8
  512. package/src/runtime/obsidian/launch-plan.mjs +91 -0
  513. package/src/runtime/obsidian/lifecycle.mjs +193 -22
  514. package/src/runtime/obsidian/login-item.mjs +332 -0
  515. package/src/runtime/obsidian/machine-settings.mjs +119 -12
  516. package/src/runtime/obsidian/opening.mjs +369 -63
  517. package/src/runtime/obsidian/pending-edits.mjs +10 -0
  518. package/src/runtime/obsidian/pipeline.mjs +71 -3
  519. package/src/runtime/obsidian/plugin-channel.mjs +289 -0
  520. package/src/runtime/obsidian/plugin-choice.mjs +182 -0
  521. package/src/runtime/obsidian/plugin-drift.mjs +124 -0
  522. package/src/runtime/obsidian/plugin-presence.mjs +61 -0
  523. package/src/runtime/obsidian/process-identity.mjs +20 -10
  524. package/src/runtime/obsidian/project-views.mjs +282 -0
  525. package/src/runtime/obsidian/questions.mjs +48 -0
  526. package/src/runtime/obsidian/release-watch.mjs +56 -0
  527. package/src/runtime/obsidian/service-main.mjs +113 -17
  528. package/src/runtime/obsidian/service-manager-production.mjs +61 -0
  529. package/src/runtime/obsidian/service-managers.mjs +275 -0
  530. package/src/runtime/obsidian/service-record.mjs +105 -5
  531. package/src/runtime/obsidian/service-server.mjs +50 -12
  532. package/src/runtime/obsidian/service.mjs +134 -7
  533. package/src/runtime/obsidian/startup-adapters.mjs +59 -21
  534. package/src/runtime/obsidian/state-store.mjs +13 -1
  535. package/src/runtime/obsidian/unheld-evidence.mjs +84 -0
  536. package/src/runtime/obsidian/vault-location.mjs +300 -0
  537. package/src/runtime/obsidian/view-counts.mjs +34 -0
  538. package/src/server/local-sidecar.mjs +193 -7
  539. package/src/server/server.mjs +45 -6
  540. package/src/template-bindings/project.mjs +92 -0
  541. package/src/templates/profile.mjs +225 -0
  542. package/src/trackables/domain.mjs +175 -0
  543. package/src/trackables/store.mjs +114 -0
  544. package/src/ui/knowledge-page.mjs +1388 -0
  545. package/src/upgrade/explanation.mjs +1 -1
  546. package/src/upgrade/template-participant.mjs +171 -0
  547. package/src/upgrade/transaction.mjs +72 -25
  548. package/src/upgrade/upgrade.mjs +2 -1
  549. package/templates/external-project-workspace/pack-lifecycle.json +6 -1
  550. package/templates/knowledge-workspace/.gitattributes +2 -0
  551. package/templates/knowledge-workspace/AGENTS.md +27 -0
  552. package/templates/knowledge-workspace/README.md +44 -0
  553. package/templates/knowledge-workspace/atelier.project.json +22 -0
  554. package/templates/knowledge-workspace/gitignore +6 -0
  555. package/templates/knowledge-workspace/knowledge-plan.json +111 -0
  556. package/templates/knowledge-workspace/knowledge-plan.schema.json +281 -0
  557. package/templates/knowledge-workspace/records/forecast.md +22 -0
  558. package/templates/knowledge-workspace/records/inspection.md +25 -0
  559. package/templates/knowledge-workspace/records/telescope.md +24 -0
  560. package/templates/knowledge-workspace/repo-access.v1.json +9 -0
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { readRegularTextNoFollow } from '../../project/private-state.mjs'
4
+ import { OBSIDIAN_EXT_KEY } from '../../projection/obsidian/contracts.mjs'
5
+ import { sha256Digest } from '../../projection/obsidian/materialize/byte-lens.mjs'
6
+ import { readFileBytes, readFileDigest } from '../../projection/obsidian/recovery/store.mjs'
7
+ import { viewVaultRoot } from './plugin-choice.mjs'
8
+
9
+ // Whether the plugin files a view's committed generation pins are on disk as
10
+ // pinned. A person may remove one, change one, or repair a path Atelier had to
11
+ // leave (a folder where a file goes, say). The publisher then publishes the
12
+ // same generation again (publisher.mjs), but only when the view is prepared
13
+ // again, which nothing else asks for while its sources are unchanged.
14
+ //
15
+ // `observe(scopeIds)` looks at each view once per tick and returns the views
16
+ // to prepare again: those whose files differ from their pins and look
17
+ // different from the last look. A drift Atelier leaves for the person (a link
18
+ // where a file goes) is asked about once, not at every tick; a change to it is
19
+ // asked about again. Where the plugin is turned off in the vault, a pinned
20
+ // file that is gone is no drift: the publisher never makes one again.
21
+ // `settle(scopeIds)` looks again after each tick, and takes as seen what a
22
+ // publication of that tick could not change, so a drift publishing cannot
23
+ // repair is not published a second time at the next look (at a service's
24
+ // start, say).
25
+
26
+ const segment = (identifier) => identifier.replaceAll(':', '_')
27
+ const TURNED_OFF = 'turned-off-in-this-vault'
28
+
29
+ // The plugin record of the view's committed manifest, read as the store reads
30
+ // it: the pointer names the manifest and its digest. Null when there is none.
31
+ function pinnedPlugin(workspaceRoot, workspaceId, scopeId) {
32
+ const manifests = path.join(workspaceRoot, 'state', 'manifests', segment(scopeId))
33
+ try {
34
+ const pointer = JSON.parse(readRegularTextNoFollow(path.join(manifests, 'current.json')))
35
+ if (pointer?.workspaceId !== workspaceId || pointer?.scopeId !== scopeId || typeof pointer.manifestFile !== 'string' || pointer.manifestFile.includes('/')) return null
36
+ const bytes = readFileBytes(path.join(manifests, pointer.manifestFile))
37
+ if (sha256Digest(bytes) !== pointer.manifestDigest) return null
38
+ const owned = JSON.parse(bytes.toString('utf8'))?.ext?.[OBSIDIAN_EXT_KEY]?.settings?.pluginOwned
39
+ if (!Array.isArray(owned?.files)) return null
40
+ return { manifest: pointer.manifestDigest, files: owned.files.filter((file) => typeof file?.path === 'string' && typeof file?.digest === 'string'), off: owned.withheldBecause === TURNED_OFF }
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ // What one pinned path holds: its digest, 'absent', or 'unreadable' (a link, a
47
+ // folder where a file goes, a path nobody may read).
48
+ function onDisk(vaultRoot, relativePath) {
49
+ let current = vaultRoot
50
+ for (const part of relativePath.split('/').slice(0, -1)) {
51
+ current = path.join(current, part)
52
+ let stat
53
+ try { stat = fs.lstatSync(current, { throwIfNoEntry: false }) } catch { return 'unreadable' }
54
+ if (!stat) return 'absent'
55
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return 'unreadable'
56
+ }
57
+ try { return readFileDigest(path.join(vaultRoot, relativePath)) ?? 'absent' } catch { return 'unreadable' }
58
+ }
59
+
60
+ // A look, as it is compared: the vault root, and each pinned file with its pin and what the disk holds.
61
+ const signatureOf = (record) => JSON.stringify([record.root, record.files.map((file) => file.digest), record.files.map((file) => file.state)])
62
+ // Never what a look finds on disk: a path recorded so is asked about at the next look whatever it holds then.
63
+ const NOT_SEEN = 'not-seen'
64
+
65
+ export function createPluginDriftObserver({ workspaceRoot, workspaceId }) {
66
+ // Per view: the last look ({ root, files: [{ path, digest, state }], manifest }).
67
+ const looked = new Map()
68
+ // The views the last observe asked to be prepared again: the tick that follows publishes them.
69
+ let asked = new Set()
70
+ // What the pinned plugin files of a view look like on disk now; null when its committed generation pins none.
71
+ const look = (scopeId) => {
72
+ const pinned = pinnedPlugin(workspaceRoot, workspaceId, scopeId)
73
+ if (pinned === null || pinned.files.length === 0) return null
74
+ // The view's vault wherever it is; one that cannot be found is refused by the engine, not observed here.
75
+ let vaultRoot
76
+ try { vaultRoot = viewVaultRoot(workspaceRoot, scopeId, workspaceId) } catch (error) { if (typeof error?.code !== 'string') throw error; return null }
77
+ let root = 'absent'
78
+ try { const stat = fs.lstatSync(vaultRoot); root = stat.isSymbolicLink() ? 'link' : (stat.mode & 0o777).toString(8) } catch { root = 'absent' }
79
+ const files = pinned.files.map((file) => ({ path: file.path, digest: file.digest, state: onDisk(vaultRoot, file.path) }))
80
+ const drifted = (file) => file.state !== file.digest && !(pinned.off && file.state === 'absent')
81
+ return { root, files, manifest: pinned.manifest, drifted, differs: files.some(drifted) }
82
+ }
83
+ return {
84
+ observe(scopeIds) {
85
+ const drifted = []
86
+ for (const scopeId of scopeIds) {
87
+ const seen = look(scopeId)
88
+ if (seen === null) { looked.delete(scopeId); continue }
89
+ const last = looked.get(scopeId)
90
+ if (seen.differs && (last === undefined || signatureOf(last) !== signatureOf(seen))) drifted.push(scopeId)
91
+ looked.set(scopeId, { root: seen.root, files: seen.files, manifest: seen.manifest })
92
+ }
93
+ asked = new Set(drifted)
94
+ return drifted
95
+ },
96
+ // After a tick. A view this tick published (one the last observe asked for, one whose committed generation
97
+ // changed since the last look, or one never looked at before) had its plugin files written or left for the person
98
+ // by its publisher. Of what the disk shows now, only what that publication could not change is taken as seen,
99
+ // without asking: a file as it pins it, or one that holds what it held before the tick under the same pin and the
100
+ // same vault root. So a drift publishing cannot repair (the data file of a vault root that is a link, say) is not
101
+ // asked about again once it was asked about under its pin. Anything else is not taken as seen, and the next look
102
+ // asks about it: a file another writer removed or changed after the publication, while the tick went on, or
103
+ // brought back to what it held under a pin the tick replaced; a file the publication left for the person, which
104
+ // the person may have repaired before the tick ended; and any file, when the vault root changed during the tick.
105
+ // Asking about a file that is still left writes no vault file: the publisher does not publish a committed
106
+ // generation again for it (pluginFilesDrifted). Any other view keeps its last look.
107
+ settle(scopeIds) {
108
+ for (const scopeId of scopeIds) {
109
+ const seen = look(scopeId)
110
+ if (seen === null) { looked.delete(scopeId); continue }
111
+ const last = looked.get(scopeId)
112
+ if (!asked.has(scopeId) && last !== undefined && last.manifest === seen.manifest) continue
113
+ // Keyed by path and pin: what a file held under a pin this tick replaced says nothing about the new one.
114
+ const key = (file) => `${file.path}\u0000${file.digest}`
115
+ const before = new Map((last?.files ?? []).map((file) => [key(file), file.state]))
116
+ // And only under the same vault root: a root made private during the tick changes what the publisher leaves.
117
+ const sameRoot = last !== undefined && last.root === seen.root
118
+ const files = seen.files.map((file) => (!seen.drifted(file) || (sameRoot && before.get(key(file)) === file.state) ? file : { ...file, state: NOT_SEEN }))
119
+ looked.set(scopeId, { root: seen.root, files, manifest: seen.manifest })
120
+ }
121
+ asked = new Set()
122
+ },
123
+ }
124
+ }
@@ -0,0 +1,61 @@
1
+ import { isPlainObject } from './documents.mjs'
2
+
3
+ // What `status` and `open` say about Atelier's plugin, read from the status
4
+ // document of the running service (`plugins`, see plugin-channel.mjs). The
5
+ // plugin is present for a view while it holds a live lease on it.
6
+
7
+ export const PLUGIN_PRESENCE_REASONS = Object.freeze(['live-lease', 'no-live-lease', 'turned-off-in-this-vault', 'service-not-running', 'service-reports-no-plugin-channel'])
8
+
9
+ // The two ways back, for a view whose vault turned the plugin off.
10
+ export const turnPluginOnNext = (scopeId) => `turn Atelier on in Obsidian (Settings, Community plugins), or run \`atelier obsidian plugin on --scope ${scopeId}\`, which restores it at the view's next publication`
11
+
12
+ // A view kept `current` while a plugin file its committed generation pins waits for an app that qualifies: the notes
13
+ // are verified, the plugin file (often `data.json`) is not written yet (engine.mjs). Its freshness reason says so.
14
+ export const PLUGIN_FILES_WAIT_FOR_APP = 'verified-by-read-back-plugin-waits-for-app'
15
+ export const PLUGIN_FILES_WAIT_NEXT = 'the view\'s notes are current, but a file of Atelier\'s plugin in this vault is not written yet, since Obsidian could not be coordinated with (it runs without its command line answering, say): it is written once Obsidian answers its command line or is quit, and it is retried automatically'
16
+
17
+ // The presence, with the plugin files that wait for the app when the view's freshness says so (`files`, and the next
18
+ // step unless the presence has its own).
19
+ export function withPluginFilesPending(presence, freshness) {
20
+ if (freshness?.state !== 'current' || freshness.reason !== PLUGIN_FILES_WAIT_FOR_APP) return presence
21
+ return { ...presence, files: 'waits-for-app', next: presence.next ?? PLUGIN_FILES_WAIT_NEXT }
22
+ }
23
+
24
+ export function pluginPresenceOf(statusDocument, scopeId) {
25
+ if (!isPlainObject(statusDocument)) return { present: false, reason: 'service-not-running' }
26
+ const scopes = isPlainObject(statusDocument.plugins) && Array.isArray(statusDocument.plugins.scopes) ? statusDocument.plugins.scopes : null
27
+ if (scopes === null) return { present: false, reason: 'service-reports-no-plugin-channel' }
28
+ const entry = scopes.find((item) => isPlainObject(item) && item.scopeId === scopeId)
29
+ if (entry?.present === true && typeof entry.appVersion === 'string') {
30
+ return { present: true, reason: 'live-lease', appVersion: entry.appVersion, pluginVersion: typeof entry.pluginVersion === 'string' ? entry.pluginVersion : null, sessions: Number.isInteger(entry.sessions) ? entry.sessions : 1 }
31
+ }
32
+ if (entry?.entry === 'off') return { present: false, reason: 'turned-off-in-this-vault', next: turnPluginOnNext(scopeId) }
33
+ return { present: false, reason: 'no-live-lease' }
34
+ }
35
+
36
+ // An app probe that takes the version from the plugin when one holds the view
37
+ // and the command-line tool gives none: that plugin runs inside the app, so its
38
+ // version is the app's. A version the tool did give decides, since the tool may
39
+ // reach another app that holds the same vault. Whether the app is installed and
40
+ // has its command-line capability is still the probe's answer; that capability
41
+ // opens and coordinates the vault in this phase. So is whether the app runs: a
42
+ // lease outlives a crashed app by up to its lease time.
43
+ // The presence is asked at most once per `maxAgeMs`: open's wait loop asks the
44
+ // app far more often, and each answer about the plugin costs a round trip to
45
+ // the service.
46
+ export function withPluginReportedVersion(appProbe, readPresence, { maxAgeMs = 1000, now = () => Date.now() } = {}) {
47
+ let last = null
48
+ const presenceNow = () => {
49
+ if (last === null || now() - last.at >= maxAgeMs) last = { at: now(), presence: Promise.resolve().then(readPresence).catch(() => null) }
50
+ return last.presence
51
+ }
52
+ return {
53
+ async inspect() {
54
+ const [observation, presence] = await Promise.all([appProbe.inspect(), presenceNow()])
55
+ // A command line that answered it is turned off stays that answer: a plugin's version never hides it.
56
+ if (presence?.present !== true || observation?.installed !== true || observation?.cli !== true || observation.running === false || typeof observation.version === 'string' || observation.cliOff === true) return observation
57
+ return { ...observation, running: true, version: presence.appVersion, noVaultOpen: false, versionSource: 'plugin' }
58
+ },
59
+ vaultState: (input) => appProbe.vaultState(input),
60
+ }
61
+ }
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process'
2
2
  import fs from 'node:fs'
3
+ import path from 'node:path'
3
4
  import { executableIdentity } from './service-record.mjs'
4
5
 
5
6
  // Whether a live PID provably runs the executable a service record names,
@@ -32,27 +33,36 @@ export function readProcessCommandLine(pid, { platform = process.platform, readF
32
33
  return null
33
34
  }
34
35
 
35
- // Pure. The command line must name the recorded entry module, and a runtime
36
- // identifier it carries must be the recorded one.
37
- export function commandLineNamesRecord(commandLine, record) {
38
- const entry = record?.executable?.path
39
- if (typeof entry !== 'string' || entry === '' || typeof record.runtimeId !== 'string') return false
36
+ // Pure. The command line must name the recorded entry module, by one of
37
+ // `entries` (its recorded path, and the path it was started by when that is
38
+ // another name for it), and a runtime identifier it carries must be the
39
+ // recorded one.
40
+ export function commandLineNamesRecord(commandLine, record, entries = [record?.executable?.path]) {
41
+ const names = entries.filter((entry) => typeof entry === 'string' && entry !== '')
42
+ if (names.length === 0 || typeof record?.runtimeId !== 'string') return false
40
43
  if (Array.isArray(commandLine)) {
41
- if (!commandLine.slice(1).includes(entry)) return false
44
+ if (!commandLine.slice(1).some((word) => names.includes(word))) return false
42
45
  const named = commandLine.filter((word) => word.startsWith(RUNTIME_ARGUMENT)).map((word) => word.slice(RUNTIME_ARGUMENT.length))
43
46
  return named.every((runtimeId) => runtimeId === record.runtimeId)
44
47
  }
45
48
  if (typeof commandLine !== 'string') return false
46
- if (!` ${commandLine} `.includes(` ${entry} `)) return false
49
+ if (!names.some((entry) => ` ${commandLine} `.includes(` ${entry} `))) return false
47
50
  const named = [...commandLine.matchAll(/(?:^|\s)--runtime-id=(\S+)/g)].map((match) => match[1])
48
51
  return named.every((runtimeId) => runtimeId === record.runtimeId)
49
52
  }
50
53
 
51
54
  // The recorded executable is still the bytes that were recorded, and the live
52
- // process was started on it.
53
- export function processRunsRecordedExecutable(record, { commandLineOf = readProcessCommandLine, identityOf = executableIdentity } = {}) {
55
+ // process was started on it: by its recorded path, or by the path the record
56
+ // says it was started by (a login item names the installed entry by its path),
57
+ // when that still leads to the recorded one.
58
+ export function processRunsRecordedExecutable(record, { commandLineOf = readProcessCommandLine, identityOf = executableIdentity, realpathOf = fs.realpathSync } = {}) {
54
59
  try {
55
60
  if (identityOf(record.executable.path).digest !== record.executable.digest) return false
56
61
  } catch { return false }
57
- return commandLineNamesRecord(commandLineOf(record.pid), record)
62
+ const entries = [record.executable.path]
63
+ const invokedAs = record.executable.ext?.invokedAs
64
+ if (typeof invokedAs === 'string' && path.isAbsolute(invokedAs)) {
65
+ try { if (realpathOf(invokedAs) === record.executable.path) entries.push(invokedAs) } catch { /* not another name for it */ }
66
+ }
67
+ return commandLineNamesRecord(commandLineOf(record.pid), record, entries)
58
68
  }
@@ -0,0 +1,282 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { validateProjectConfigDoc } from '../../project/config.mjs'
4
+ import { MAX_EXPANSION_DEPTH, OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../../projection/obsidian/contracts.mjs'
5
+ import { readObsidianEnablement } from './enablement.mjs'
6
+ import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
7
+
8
+ // The views a project declares, written into its `atelier.project.json` for a
9
+ // person, so nobody edits JSON by hand: `view add`, and the view a project
10
+ // that never used Obsidian gets on its first open.
11
+ //
12
+ // The project file is committed source. Atelier changes it only when asked,
13
+ // shows the exact change as a diff, and never commits. It rewrites a file only
14
+ // when the file is in the form Atelier writes JSON in (two-space indentation,
15
+ // with LF or CRLF line ends, with or without a final one), so the change is
16
+ // the Obsidian member and nothing else; and only over the bytes it read. The
17
+ // workspace pointer is only ever written into an ignored `.atelier-local/`, so
18
+ // a project where that folder is not ignored gets the ignore line in the same
19
+ // change.
20
+
21
+ export const EXT_SETTINGS_SCHEMA = 'atelier-obsidian-ext-settings/v1'
22
+ // Every note this machine may show, in one view that is the default. Its name reads well in a command
23
+ // (`--scope everything`) and in a vault's name (`harbor-notes (everything)`).
24
+ export const DEFAULT_VIEW = Object.freeze({ scopeId: 'everything', mode: 'full', selector: Object.freeze({ all: true }) })
25
+ const LOCAL_STATE_LINE = '.atelier-local/'
26
+ const SCOPE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
27
+ const MAX_EXPANSION_NODES = 100000
28
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
29
+
30
+ const linesOf = (text) => { const lines = text.split(/\r?\n/); if (lines.at(-1) === '') lines.pop(); return lines }
31
+ // A hunk's range: the first line and the count, or the line before an empty range.
32
+ const range = (start, count) => (count === 0 ? `${start},0` : `${start + 1},${count}`)
33
+
34
+ // The lines that differ between two texts, as one unified hunk with up to three lines of context on each side. Enough
35
+ // for a change that inserts or replaces one run of lines, which is every change made here.
36
+ export function unifiedDiff(before, after, { label }) {
37
+ if (before === after) return ''
38
+ const [left, right] = [linesOf(before), linesOf(after)]
39
+ let prefix = 0
40
+ while (prefix < left.length && prefix < right.length && left[prefix] === right[prefix]) prefix += 1
41
+ let suffix = 0
42
+ while (suffix < left.length - prefix && suffix < right.length - prefix && left[left.length - 1 - suffix] === right[right.length - 1 - suffix]) suffix += 1
43
+ const context = 3
44
+ const start = Math.max(0, prefix - context)
45
+ const leftEnd = Math.min(left.length, left.length - suffix + context)
46
+ const rightEnd = Math.min(right.length, right.length - suffix + context)
47
+ const lines = [
48
+ `--- ${label}`, `+++ ${label}`, `@@ -${range(start, leftEnd - start)} +${range(start, rightEnd - start)} @@`,
49
+ ...left.slice(start, prefix).map((line) => ` ${line}`),
50
+ ...left.slice(prefix, left.length - suffix).map((line) => `-${line}`),
51
+ ...right.slice(prefix, right.length - suffix).map((line) => `+${line}`),
52
+ ...right.slice(right.length - suffix, rightEnd).map((line) => ` ${line}`),
53
+ ]
54
+ return `${lines.join('\n')}\n`
55
+ }
56
+
57
+ const jsonText = (document, { eol, final }) => {
58
+ const text = JSON.stringify(document, null, 2)
59
+ return `${eol === '\n' ? text : text.replaceAll('\n', '\r\n')}${final ? eol : ''}`
60
+ }
61
+
62
+ // The form `text` is `document` written in, or null when it is in none Atelier writes.
63
+ function formOf(text, document) {
64
+ for (const eol of ['\n', '\r\n']) for (const final of [true, false]) if (text === jsonText(document, { eol, final })) return { eol, final }
65
+ return null
66
+ }
67
+
68
+ // A file Atelier may replace: a regular file, not a link to one elsewhere. `bytes` is null when there is none.
69
+ function readRegularFile(file, { code, what }) {
70
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false })
71
+ if (stat === undefined) return { bytes: null, mode: null }
72
+ if (!stat.isFile()) refuse('project-file-not-regular', `${what} is not a regular file, so Atelier does not rewrite it; make the change by hand`, { file: path.basename(file) })
73
+ try { return { bytes: fs.readFileSync(file), mode: stat.mode & 0o777 } } catch (error) { return refuse(code, `${what} cannot be read`, { cause: error.code ?? 'unreadable' }) }
74
+ }
75
+
76
+ // The text of a file's bytes, only when they are UTF-8 that decodes and encodes back to exactly them: a file in another
77
+ // encoding is never rewritten, since a byte that is not UTF-8 would be replaced without the diff showing it.
78
+ function textOf(bytes, refusal) {
79
+ const text = bytes.toString('utf8')
80
+ if (!Buffer.from(text, 'utf8').equals(bytes)) refusal()
81
+ return text
82
+ }
83
+
84
+ // The settings as enablement will read them, or a typed refusal naming what is wrong.
85
+ function checkedSettings(document, settings) {
86
+ try {
87
+ readObsidianEnablement({ config: { ...document, ext: { ...(isPlainObject(document.ext) ? document.ext : {}), [OBSIDIAN_EXT_KEY]: settings } } })
88
+ } catch (error) {
89
+ if (error instanceof ObsidianMaintenanceRefusal || error instanceof ObsidianContractRefusal) refuse('invalid-view', 'the views would not satisfy the Obsidian settings contract; nothing was written', { errors: error.detail?.errors ?? [] })
90
+ throw error
91
+ }
92
+ return settings
93
+ }
94
+
95
+ // The project file with `settings` as its Obsidian member: { file, bytes, after, mode, diff }, `bytes` being the ones
96
+ // it was made from. `document` is the file as read. Refuses a file that is not in a form Atelier writes, since
97
+ // rewriting it would change more than the member, and names the member to add by hand.
98
+ function planMember({ file, bytes, before, mode, document, settings }) {
99
+ const form = isPlainObject(document) && (document.ext === undefined || isPlainObject(document.ext)) ? formOf(before, document) : null
100
+ if (form === null) {
101
+ refuse('project-config-format-unknown', `${path.basename(file)} is not in the form Atelier writes (two-space JSON), so rewriting it would change more than the Obsidian member; nothing was written. Add the member by hand, under "ext"`, { member: { [OBSIDIAN_EXT_KEY]: settings } })
102
+ }
103
+ const next = { ...document, ext: { ...(document.ext ?? {}), [OBSIDIAN_EXT_KEY]: settings } }
104
+ const errors = validateProjectConfigDoc(next)
105
+ if (errors.length > 0) refuse('invalid-view', 'the project configuration would not be valid; nothing was written', { errors })
106
+ const after = jsonText(next, form)
107
+ return { file, what: path.basename(file), bytes, after, mode, diff: unifiedDiff(before, after, { label: path.basename(file) }) }
108
+ }
109
+
110
+ // The ignore line a project needs before the workspace pointer can be written:
111
+ // { needed, file, bytes, after, mode, diff }.
112
+ function planIgnore(project) {
113
+ if (project?.localState?.ignored === true) return { needed: false }
114
+ const file = path.join(project.configDir, '.gitignore')
115
+ const { bytes, mode } = readRegularFile(file, { code: 'gitignore-unreadable', what: 'the project\'s .gitignore' })
116
+ const before = bytes === null ? '' : textOf(bytes, () => refuse('gitignore-not-utf8', `the project's .gitignore is not UTF-8, so Atelier does not rewrite it; add the line ${LOCAL_STATE_LINE} to it by hand; nothing was written`, { line: LOCAL_STATE_LINE }))
117
+ const eol = before.includes('\r\n') ? '\r\n' : '\n'
118
+ const after = `${before}${before === '' || before.endsWith('\n') ? '' : eol}${LOCAL_STATE_LINE}${eol}`
119
+ return { needed: true, file, what: 'the project\'s .gitignore', bytes, after, mode, diff: unifiedDiff(before, after, { label: '.gitignore' }) }
120
+ }
121
+
122
+ // The project file as it is now: its bytes and their text, its mode, and its document (null when it is not JSON).
123
+ function readProjectFile(project) {
124
+ const file = project?.configPath
125
+ if (typeof file !== 'string') refuse('project-config-unreadable', 'this project has no configuration file to write the view into')
126
+ const { bytes, mode } = readRegularFile(file, { code: 'project-config-unreadable', what: path.basename(file) })
127
+ if (bytes === null) refuse('project-config-unreadable', `${path.basename(file)} is not there`)
128
+ // Not UTF-8: in no form Atelier writes. The member is named for the person when the rest of the file can be read.
129
+ let before
130
+ try { before = textOf(bytes, () => { throw new SyntaxError('not UTF-8') }) } catch {
131
+ return { file, bytes, before: null, mode, document: null }
132
+ }
133
+ let document = null
134
+ try { document = JSON.parse(before) } catch { document = null }
135
+ return { file, bytes, before, mode, document }
136
+ }
137
+
138
+ // A folder as the repository-relative prefix of the notes a view selects, `./` and a trailing `/` dropped; null for
139
+ // the whole repository.
140
+ function folderPrefix(text) {
141
+ const cleaned = String(text).replace(/^(?:\.\/)+/, '').replace(/\/+$/, '')
142
+ if (cleaned === '' || cleaned === '.') return null
143
+ if (cleaned.startsWith('/') || cleaned.startsWith('~') || /^[A-Za-z]:/.test(cleaned) || cleaned.includes('\\') || cleaned.includes('\u0000') || cleaned.split('/').includes('..')) {
144
+ refuse('invalid-view', 'a folder is written relative to its repository\'s root, with "/", and without ".." (docs/notes, say)', { folder: String(text).slice(0, 200) })
145
+ }
146
+ return cleaned
147
+ }
148
+
149
+ // `DEPTH:MAX`: follow outgoing links up to DEPTH steps from the selected notes, adding at most MAX notes in all.
150
+ function expansionOf(text) {
151
+ const match = /^([0-9]{1,2}):([0-9]{1,6})$/.exec(String(text))
152
+ const depth = match ? Number(match[1]) : NaN
153
+ const maxNodes = match ? Number(match[2]) : NaN
154
+ if (!(depth >= 1 && depth <= MAX_EXPANSION_DEPTH && maxNodes >= 1 && maxNodes <= MAX_EXPANSION_NODES)) {
155
+ refuse('invalid-view', `--expand is DEPTH:MAX, a depth from 1 to ${MAX_EXPANSION_DEPTH} and at most ${MAX_EXPANSION_NODES} notes in all (2:200, say)`)
156
+ }
157
+ return { depth, maxNodes, direction: 'outgoing', order: 'canonical-id' }
158
+ }
159
+
160
+ // The view `view add` asks for: every note (`all`, a full view), or a scoped view of the notes under folders of one
161
+ // repository or with a tag, optionally expanded along outgoing links. `repositories` are the names of the project's
162
+ // managed repositories; `repo` may be left out when there is one. (The contract's `type` selector matches a note's
163
+ // file type, not its `kg.type`, so it is not offered here.)
164
+ export function viewFromRequest({ scopeId, all = false, folders = [], repo, tag, expand, repositories = [] }) {
165
+ if (typeof scopeId !== 'string' || !SCOPE_ID.test(scopeId)) refuse('usage', 'a view is named by an identifier: a letter or digit, then letters, digits, ".", "_", ":" or "-", 128 at most')
166
+ const kinds = [all === true, folders.length > 0, tag !== undefined].filter(Boolean).length
167
+ if (kinds !== 1) refuse('usage', 'a view selects its notes with exactly one of --all, --folder PATH (as often as needed) or --tag T')
168
+ if (repo !== undefined && folders.length === 0) refuse('usage', '--repo names the repository of --folder')
169
+ if (all === true && expand !== undefined) refuse('usage', 'a view of every note has nothing to expand')
170
+ const expansion = expand === undefined ? undefined : expansionOf(expand)
171
+ let selector
172
+ if (all === true) selector = { all: true }
173
+ else if (tag !== undefined) selector = { tag }
174
+ else {
175
+ if (repo === undefined && repositories.length !== 1) refuse('view-repository-ambiguous', 'this project enrols more than one repository; name the one the folders are in with --repo', { repositories })
176
+ const repoId = repo ?? repositories[0]
177
+ if (!repositories.includes(repoId)) refuse('unknown-repo', 'the project enrols no repository of that name', { repositories })
178
+ const prefixes = [...new Set(folders.map(folderPrefix))]
179
+ const members = prefixes.includes(null) ? [{ repo: repoId }] : prefixes.map((pathPrefix) => ({ repo: repoId, pathPrefix }))
180
+ selector = members.length === 1 ? members[0] : { union: members }
181
+ }
182
+ return { scopeId, mode: all === true ? 'full' : 'scoped', selector, ...(expansion === undefined ? {} : { expansion }) }
183
+ }
184
+
185
+ // One more view: the project's Obsidian member with `scope` added (the member made, enabled, when there is none), and
186
+ // the default when asked or when it is the first view. Refuses a view whose name is taken. Writes nothing:
187
+ // { settings, member, ignore, diff }, for writeViewPlan.
188
+ export function planViewAdd(project, { scope, makeDefault = false }) {
189
+ const source = readProjectFile(project)
190
+ const document = isPlainObject(source.document) ? source.document : project.config
191
+ const present = isPlainObject(document?.ext) ? document.ext[OBSIDIAN_EXT_KEY] : undefined
192
+ const scopes = Array.isArray(present?.scopes) ? present.scopes : []
193
+ if (scopes.some((item) => item?.scopeId === scope.scopeId)) refuse('view-exists', 'the project declares a view of this name already', { scopeId: scope.scopeId })
194
+ // A member of its own keeps its keys in their order; a new one is written as the documentation shows it.
195
+ const next = present === undefined
196
+ ? { schema: EXT_SETTINGS_SCHEMA, enabled: true, defaultScopeId: scope.scopeId, scopes: [scope] }
197
+ : { ...present, scopes: [...scopes, scope], ...(makeDefault || scopes.length === 0 ? { defaultScopeId: scope.scopeId } : {}) }
198
+ const settings = checkedSettings(document ?? {}, next)
199
+ const member = planMember({ ...source, settings })
200
+ const ignore = planIgnore(project)
201
+ return { settings, member, ignore, diff: `${member.diff}${ignore.needed ? ignore.diff : ''}` }
202
+ }
203
+
204
+ // A planned file still holds exactly the bytes its plan was made from (a file that was not there is still not there).
205
+ function assertUnchanged({ file, what, bytes: planned }) {
206
+ const { bytes } = readRegularFile(file, { code: 'project-file-unreadable', what })
207
+ if (planned === null ? bytes !== null : bytes === null || !bytes.equals(planned)) {
208
+ refuse('project-config-changed', `${path.basename(file)} changed while the change was prepared; nothing was written`, { file: path.basename(file) })
209
+ }
210
+ }
211
+
212
+ // The temporary file a planned file's new text is written to, beside it: `.<name>.<pid>.<ms>.atelier.tmp`.
213
+ const temporaryOf = (file) => path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.atelier.tmp`)
214
+ const isAlive = (pid) => { try { process.kill(pid, 0); return true } catch (error) { return error.code === 'EPERM' } }
215
+ // Temporary files an earlier run left beside a planned file when it stopped between writing and renaming (a crash):
216
+ // removed, unless the run that made them still runs, so none is left to be committed.
217
+ function removeLeftovers(file) {
218
+ const pattern = new RegExp(`^\\.${path.basename(file).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.([0-9]+)\\.[0-9]+\\.atelier\\.tmp$`)
219
+ let names = []
220
+ try { names = fs.readdirSync(path.dirname(file)) } catch { return }
221
+ for (const name of names) {
222
+ const match = pattern.exec(name)
223
+ if (match !== null && (Number(match[1]) === process.pid || !isAlive(Number(match[1])))) fs.rmSync(path.join(path.dirname(file), name), { force: true })
224
+ }
225
+ }
226
+
227
+ // A planned file's new text in its temporary file, keeping its mode, and on the disk before any file is replaced.
228
+ function writeTemporary({ file, after, mode }) {
229
+ const temporary = temporaryOf(file)
230
+ const descriptor = fs.openSync(temporary, 'wx', mode ?? 0o666)
231
+ try {
232
+ fs.writeFileSync(descriptor, after)
233
+ if (mode !== null) fs.fchmodSync(descriptor, mode)
234
+ fs.fsyncSync(descriptor)
235
+ } catch (error) {
236
+ fs.closeSync(descriptor)
237
+ fs.rmSync(temporary, { force: true })
238
+ throw error
239
+ } finally {
240
+ try { fs.closeSync(descriptor) } catch { /* closed above */ }
241
+ }
242
+ return temporary
243
+ }
244
+
245
+ // The folder of a replaced file, on the disk, so the renames survive a crash. A system that cannot sync a folder
246
+ // (Windows) keeps the renames all the same.
247
+ function syncFolder(folder) {
248
+ let descriptor
249
+ try { descriptor = fs.openSync(folder, 'r'); fs.fsyncSync(descriptor) } catch { /* not supported here */ } finally { if (descriptor !== undefined) fs.closeSync(descriptor) }
250
+ }
251
+
252
+ // Writes a plan of planViewAdd when every file it changes still holds the bytes it was made from: every new text is
253
+ // written and synced to its temporary file first, then the files are replaced, the ignore line first, so a project
254
+ // file that names a view never waits on it. A failure before the first replacement changes nothing
255
+ // (`project-files-not-written`); one after it says which files changed (`project-files-partly-written`). No temporary
256
+ // file is left. Commits nothing. Answers the files written. `rename` is injectable for tests.
257
+ export function writeViewPlan(plan, { rename = fs.renameSync } = {}) {
258
+ const targets = [...(plan.ignore.needed ? [plan.ignore] : []), plan.member]
259
+ for (const target of targets) removeLeftovers(target.file)
260
+ for (const target of targets) assertUnchanged(target)
261
+ const prepared = []
262
+ const discard = () => { for (const { temporary } of prepared) fs.rmSync(temporary, { force: true }) }
263
+ try {
264
+ for (const target of targets) prepared.push({ target, temporary: writeTemporary(target) })
265
+ } catch (error) {
266
+ discard()
267
+ refuse('project-files-not-written', 'the change could not be written; nothing was changed', { cause: error.code ?? String(error.message) })
268
+ }
269
+ const written = []
270
+ for (const { target, temporary } of prepared) {
271
+ try { rename(temporary, target.file) } catch (error) {
272
+ discard()
273
+ const names = (list) => list.map((file) => path.basename(file))
274
+ const rest = targets.map((item) => item.file).filter((file) => !written.includes(file))
275
+ if (written.length === 0) refuse('project-files-not-written', 'the change could not be written; nothing was changed', { cause: error.code ?? String(error.message) })
276
+ refuse('project-files-partly-written', `${names(written).join(', ')} changed, and ${names(rest).join(', ')} could not be; finish the change by hand, or undo it`, { written: names(written), notWritten: names(rest), cause: error.code ?? String(error.message) })
277
+ }
278
+ written.push(target.file)
279
+ }
280
+ for (const folder of new Set(written.map((file) => path.dirname(file)))) syncFolder(folder)
281
+ return { written }
282
+ }
@@ -0,0 +1,48 @@
1
+ import readline from 'node:readline'
2
+
3
+ // Questions for a person at a terminal, one line each, over the streams the
4
+ // caller gives, so a test types the answers. A question never waits forever:
5
+ // no line within the timeout (10 minutes), or the end of the input, is no
6
+ // answer, so an agent that was handed a terminal still never hangs. Whether a
7
+ // person is there at all is decided before anything is asked
8
+ // (src/commands/obsidian.mjs `isInteractive`).
9
+
10
+ export const ANSWER_TIMEOUT_MS = 10 * 60 * 1000
11
+ const MAX_ANSWER_LENGTH = 4096
12
+ const clean = (line) => line.slice(0, MAX_ANSWER_LENGTH).trim()
13
+
14
+ // `ask(question)` writes the question and answers the next line typed, trimmed, or null when none came. A line typed
15
+ // before its question is kept for it. `close()` stops reading.
16
+ export function createQuestioner({ input, output, timeoutMs = ANSWER_TIMEOUT_MS }) {
17
+ const typed = []
18
+ const waiting = []
19
+ let ended = false
20
+ const lines = readline.createInterface({ input, terminal: false, crlfDelay: Infinity })
21
+ lines.on('line', (line) => { const next = waiting.shift(); if (next === undefined) typed.push(line); else next(line) })
22
+ lines.on('close', () => { ended = true; for (const next of waiting.splice(0)) next(null) })
23
+ return {
24
+ ask(question) {
25
+ output.write(question)
26
+ if (typed.length > 0) return Promise.resolve(clean(typed.shift()))
27
+ if (ended) return Promise.resolve(null)
28
+ return new Promise((resolve) => {
29
+ const answer = (line) => { clearTimeout(timer); resolve(line === null ? null : clean(line)) }
30
+ const timer = setTimeout(() => { waiting.splice(waiting.indexOf(answer), 1); output.write('\n'); resolve(null) }, timeoutMs)
31
+ waiting.push(answer)
32
+ })
33
+ },
34
+ close() { lines.close() },
35
+ }
36
+ }
37
+
38
+ // A go-ahead: Enter, y or yes is yes; n or no is no; anything else is asked again, three times in all. null: no answer.
39
+ export async function askGoAhead(questioner, question) {
40
+ for (let attempt = 0; attempt < 3; attempt += 1) {
41
+ const answer = await questioner.ask(question)
42
+ if (answer === null) return null
43
+ const word = answer.toLowerCase()
44
+ if (word === '' || word === 'y' || word === 'yes') return true
45
+ if (word === 'n' || word === 'no') return false
46
+ }
47
+ return null
48
+ }
@@ -0,0 +1,56 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { packageRootOfEntry, readReleaseIdentity, walkRelease } from './service-record.mjs'
4
+
5
+ // Whether the package a service runs from changed on disk since it started.
6
+ //
7
+ // A service started by a login item runs until the person logs out. When the
8
+ // package it was started from is upgraded meanwhile (`npm i -D
9
+ // @mnstry/atelier@<new>` in the project), it would go on running the release
10
+ // it loaded, and load any module it had not needed yet from the new one. So,
11
+ // under `--startup`, the service asks after every tick whether its package
12
+ // changed, finishes that tick and exits with a code its service manager
13
+ // restarts (service.mjs, service-main.mjs): the next process runs the new
14
+ // release.
15
+ //
16
+ // The package is read through the path the service was started by, not its
17
+ // real path: a linked install (`file:`, `npm link`) that is pointed elsewhere
18
+ // is then seen. What is compared is the release identity (readReleaseIdentity
19
+ // in service-record.mjs, the one a service records at its start): the package
20
+ // version and a digest of every file under `src/`, `contracts/` and
21
+ // `plugins/`, by relative path and content. It is cheap to ask: the files'
22
+ // status (inode, size, change and modification time) is compared first, and
23
+ // the digest is computed only when that differs. A package that cannot be read right now (an install in
24
+ // progress) is not a change yet; it is asked again after the next tick.
25
+
26
+ export { packageRootOfEntry, readReleaseIdentity }
27
+
28
+ // The status of every file the identity covers and of package.json, as one comparable text. A package replaced by an
29
+ // install has new inodes and change times even where the archive kept the modification times.
30
+ export function releaseStatSignature({ root }) {
31
+ const lines = []
32
+ const stat = (relative, absolute) => { const found = fs.lstatSync(absolute); lines.push(`${relative}\0${found.dev}\0${found.ino}\0${found.size}\0${found.mtimeMs}\0${found.ctimeMs}`) }
33
+ walkRelease(root, stat)
34
+ stat('package.json', path.join(root, 'package.json'))
35
+ return lines.join('\n')
36
+ }
37
+
38
+ // { root, started, changed() }. `started` is the identity the service runs, read when the watch is made; `changed()`
39
+ // answers true once the package on disk is another release, and false while it is the same or cannot be read.
40
+ export function createReleaseWatch({ root, identityOf = readReleaseIdentity, signatureOf = releaseStatSignature } = {}) {
41
+ if (typeof root !== 'string' || !path.isAbsolute(root)) throw new TypeError('a release watch needs the absolute root of a package')
42
+ let signature = signatureOf({ root })
43
+ const started = identityOf({ root })
44
+ return {
45
+ root, started,
46
+ changed() {
47
+ let now
48
+ try { now = signatureOf({ root }) } catch { return false }
49
+ if (now === signature) return false
50
+ let identity
51
+ try { identity = identityOf({ root }) } catch { return false }
52
+ if (identity.version === started.version && identity.digest === started.digest) { signature = now; return false }
53
+ return true
54
+ },
55
+ }
56
+ }