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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (531) hide show
  1. package/CHANGELOG.md +894 -0
  2. package/README.md +159 -6
  3. package/contracts/atelier-adoption-policy.v2.schema.json +55 -0
  4. package/contracts/atelier-architecture.v1.schema.json +258 -0
  5. package/contracts/atelier-boundary-delegations.v1.schema.json +161 -0
  6. package/contracts/atelier-boundary-owner-keys.v1.schema.json +98 -0
  7. package/contracts/atelier-build.v1.schema.json +1076 -0
  8. package/contracts/atelier-capability.v1.schema.json +1252 -0
  9. package/contracts/atelier-coordination.v1.schema.json +1050 -0
  10. package/contracts/atelier-evidence-navigation.v1.schema.json +603 -0
  11. package/contracts/atelier-harness.v1.schema.json +234 -0
  12. package/contracts/atelier-ingestion.v1.schema.json +806 -0
  13. package/contracts/atelier-inquiry.v1.schema.json +1772 -0
  14. package/contracts/atelier-instruction-adoption.v1.schema.json +490 -0
  15. package/contracts/atelier-interaction.v1.schema.json +553 -0
  16. package/contracts/atelier-judgment.v1.schema.json +690 -0
  17. package/contracts/atelier-knowledge.v1.schema.json +1512 -0
  18. package/contracts/atelier-learning.v1.schema.json +1095 -0
  19. package/contracts/atelier-migration.v3.schema.json +64 -0
  20. package/contracts/atelier-personal-workspace-manifest.v1.schema.json +87 -0
  21. package/contracts/atelier-personal-workspace-overlay.v1.schema.json +299 -0
  22. package/contracts/atelier-template-adoption.v1.schema.json +223 -0
  23. package/contracts/atelier-template-profile.v1.schema.json +1168 -0
  24. package/contracts/atelier-trackable.v1.schema.json +781 -0
  25. package/contracts/atelier-upgrade-plan.v3.schema.json +532 -0
  26. package/docs/architecture.md +89 -0
  27. package/docs/assurance-controls.md +2 -2
  28. package/docs/blocks/capability-stewardship.md +17 -0
  29. package/docs/blocks/claims.md +16 -3
  30. package/docs/blocks/inquiry-harnesses.md +19 -0
  31. package/docs/blocks/learning-harnesses.md +17 -0
  32. package/docs/capability-stewardship.md +267 -0
  33. package/docs/coordination.md +51 -0
  34. package/docs/decision-practices.md +111 -0
  35. package/docs/decisions.md +29 -0
  36. package/docs/evidence-navigation.md +237 -0
  37. package/docs/exact-upgrades.md +3 -2
  38. package/docs/ingestion.md +181 -0
  39. package/docs/inquiry-harnesses.md +210 -0
  40. package/docs/install.md +80 -3
  41. package/docs/intake-and-guides.md +5 -0
  42. package/docs/integration-contract-decisions.md +24 -2
  43. package/docs/interaction-and-reflection.md +72 -0
  44. package/docs/knowledge-graph.md +20 -0
  45. package/docs/knowledge-health/api.md +30 -0
  46. package/docs/knowledge-health/assessment/api.md +96 -0
  47. package/docs/knowledge-health/assessment/capabilities.md +40 -0
  48. package/docs/knowledge-health/assessment/evaluation.md +27 -0
  49. package/docs/knowledge-health/assessment/facilitator.md +30 -0
  50. package/docs/knowledge-health/assessment/quickstart.md +61 -0
  51. package/docs/knowledge-health/assessment/troubleshooting.md +23 -0
  52. package/docs/knowledge-health/devday/facilitator.md +16 -0
  53. package/docs/knowledge-health/devday/participant-response.md +22 -0
  54. package/docs/knowledge-health/devday/public-default.md +107 -0
  55. package/docs/knowledge-health/devday/quickstart.md +18 -0
  56. package/docs/knowledge-health/devday/support.md +22 -0
  57. package/docs/knowledge-health/exercise.md +17 -0
  58. package/docs/knowledge-health/facilitator.md +20 -0
  59. package/docs/knowledge-health/quickstart.md +94 -0
  60. package/docs/knowledge-health/readme.md +15 -0
  61. package/docs/knowledge-health/support.md +34 -0
  62. package/docs/knowledge-setup.md +248 -0
  63. package/docs/knowledge-workspace.md +264 -0
  64. package/docs/learning-harnesses.md +237 -0
  65. package/docs/learning.md +215 -0
  66. package/docs/local-services.md +223 -19
  67. package/docs/obsidian-contract.md +246 -15
  68. package/docs/obsidian-plugin.md +252 -7
  69. package/docs/obsidian.md +874 -52
  70. package/docs/optional-ingestion.md +83 -0
  71. package/docs/personal-workspace.md +266 -0
  72. package/docs/practical-judgment.md +92 -0
  73. package/docs/progressive-ingestion-acceptance.md +89 -0
  74. package/docs/progressive-ingestion.md +235 -0
  75. package/docs/project-command-inventory.md +1 -0
  76. package/docs/release-engineering.md +50 -0
  77. package/docs/repo-boundary-guard.md +120 -3
  78. package/docs/research-starter-quickstart.md +66 -0
  79. package/docs/research-starter.md +114 -0
  80. package/docs/semantic-ingestion.md +198 -0
  81. package/docs/semantic-operation.md +318 -0
  82. package/docs/skill-steward.md +6 -0
  83. package/docs/template-profile.md +164 -0
  84. package/docs/template-upgrades.md +114 -0
  85. package/docs/trackables.md +111 -0
  86. package/docs/upgrade.md +219 -2
  87. package/fixtures/atelier-architecture/binding/invalid/document.json +19 -0
  88. package/fixtures/atelier-architecture/binding/valid/document.json +19 -0
  89. package/fixtures/atelier-architecture/catalog/invalid/document.json +290 -0
  90. package/fixtures/atelier-architecture/catalog/valid/document.json +343 -0
  91. package/fixtures/atelier-build-contract/attempt/invalid/document.json +20 -0
  92. package/fixtures/atelier-build-contract/attempt/valid/document.json +19 -0
  93. package/fixtures/atelier-build-contract/candidate/invalid/document.json +24 -0
  94. package/fixtures/atelier-build-contract/candidate/valid/document.json +23 -0
  95. package/fixtures/atelier-build-contract/decision/invalid/document.json +31 -0
  96. package/fixtures/atelier-build-contract/decision/valid/document.json +30 -0
  97. package/fixtures/atelier-build-contract/delivery/invalid/document.json +18 -0
  98. package/fixtures/atelier-build-contract/delivery/valid/document.json +17 -0
  99. package/fixtures/atelier-build-contract/gate/invalid/document.json +22 -0
  100. package/fixtures/atelier-build-contract/gate/valid/document.json +21 -0
  101. package/fixtures/atelier-build-contract/ledger/invalid/document.json +270 -0
  102. package/fixtures/atelier-build-contract/ledger/valid/document.json +269 -0
  103. package/fixtures/atelier-build-contract/objective/invalid/document.json +73 -0
  104. package/fixtures/atelier-build-contract/objective/valid/document.json +72 -0
  105. package/fixtures/atelier-build-contract/progress/invalid/document.json +18 -0
  106. package/fixtures/atelier-build-contract/progress/valid/document.json +17 -0
  107. package/fixtures/atelier-capability-contract/adoption/invalid/document.json +71 -0
  108. package/fixtures/atelier-capability-contract/adoption/valid/document.json +70 -0
  109. package/fixtures/atelier-capability-contract/event/invalid/document.json +18 -0
  110. package/fixtures/atelier-capability-contract/event/valid/document.json +17 -0
  111. package/fixtures/atelier-capability-contract/journal/invalid/document.json +382 -0
  112. package/fixtures/atelier-capability-contract/journal/valid/document.json +381 -0
  113. package/fixtures/atelier-capability-contract/notice/invalid/document.json +8 -0
  114. package/fixtures/atelier-capability-contract/notice/valid/document.json +7 -0
  115. package/fixtures/atelier-capability-contract/package/invalid/document.json +59 -0
  116. package/fixtures/atelier-capability-contract/package/valid/document.json +58 -0
  117. package/fixtures/atelier-capability-contract/plan/invalid/document.json +107 -0
  118. package/fixtures/atelier-capability-contract/plan/valid/document.json +106 -0
  119. package/fixtures/atelier-capability-contract/release/invalid/document.json +85 -0
  120. package/fixtures/atelier-capability-contract/release/valid/document.json +84 -0
  121. package/fixtures/atelier-capability-contract/state/invalid/document.json +316 -0
  122. package/fixtures/atelier-capability-contract/state/valid/document.json +315 -0
  123. package/fixtures/atelier-coordination/dependency/invalid/document.json +37 -0
  124. package/fixtures/atelier-coordination/dependency/valid/document.json +36 -0
  125. package/fixtures/atelier-coordination/directive/invalid/document.json +25 -0
  126. package/fixtures/atelier-coordination/directive/valid/document.json +24 -0
  127. package/fixtures/atelier-coordination/disposition/invalid/document.json +34 -0
  128. package/fixtures/atelier-coordination/disposition/valid/document.json +33 -0
  129. package/fixtures/atelier-coordination/outcome/invalid/document.json +39 -0
  130. package/fixtures/atelier-coordination/outcome/valid/document.json +38 -0
  131. package/fixtures/atelier-coordination/recovery/invalid/document.json +34 -0
  132. package/fixtures/atelier-coordination/recovery/valid/document.json +33 -0
  133. package/fixtures/atelier-harness-contract/handoff/invalid/document.json +25 -0
  134. package/fixtures/atelier-harness-contract/handoff/valid/document.json +24 -0
  135. package/fixtures/atelier-ingestion/invalid/authority.json +19 -0
  136. package/fixtures/atelier-ingestion/semantic-baseline.json +26 -0
  137. package/fixtures/atelier-ingestion/semantic-operation.json +75 -0
  138. package/fixtures/atelier-ingestion/valid/plan-input.json +18 -0
  139. package/fixtures/atelier-inquiry-contract/assessment/invalid/document.json +41 -0
  140. package/fixtures/atelier-inquiry-contract/assessment/valid/document.json +40 -0
  141. package/fixtures/atelier-inquiry-contract/bundle/invalid/document.json +51 -0
  142. package/fixtures/atelier-inquiry-contract/bundle/valid/document.json +50 -0
  143. package/fixtures/atelier-inquiry-contract/campaign/invalid/document.json +17 -0
  144. package/fixtures/atelier-inquiry-contract/campaign/valid/document.json +16 -0
  145. package/fixtures/atelier-inquiry-contract/decision/invalid/document.json +22 -0
  146. package/fixtures/atelier-inquiry-contract/decision/valid/document.json +21 -0
  147. package/fixtures/atelier-inquiry-contract/feedback/invalid/document.json +19 -0
  148. package/fixtures/atelier-inquiry-contract/feedback/valid/document.json +18 -0
  149. package/fixtures/atelier-inquiry-contract/handoff/invalid/document.json +92 -0
  150. package/fixtures/atelier-inquiry-contract/handoff/valid/document.json +91 -0
  151. package/fixtures/atelier-inquiry-contract/hypothesis/invalid/document.json +20 -0
  152. package/fixtures/atelier-inquiry-contract/hypothesis/valid/document.json +19 -0
  153. package/fixtures/atelier-inquiry-contract/ledger/invalid/document.json +680 -0
  154. package/fixtures/atelier-inquiry-contract/ledger/valid/document.json +679 -0
  155. package/fixtures/atelier-inquiry-contract/legacy-assessment/invalid/document.json +19 -0
  156. package/fixtures/atelier-inquiry-contract/legacy-assessment/valid/document.json +18 -0
  157. package/fixtures/atelier-inquiry-contract/request/invalid/document.json +50 -0
  158. package/fixtures/atelier-inquiry-contract/request/valid/document.json +49 -0
  159. package/fixtures/atelier-inquiry-contract/source/invalid/document.json +18 -0
  160. package/fixtures/atelier-inquiry-contract/source/valid/document.json +17 -0
  161. package/fixtures/atelier-inquiry-contract/withdrawal/invalid/document.json +16 -0
  162. package/fixtures/atelier-inquiry-contract/withdrawal/valid/document.json +15 -0
  163. package/fixtures/atelier-instruction-adoption/context-receipt/invalid/document.json +18 -0
  164. package/fixtures/atelier-instruction-adoption/context-receipt/valid/document.json +17 -0
  165. package/fixtures/atelier-instruction-adoption/plan/invalid/document.json +63 -0
  166. package/fixtures/atelier-instruction-adoption/plan/valid/document.json +62 -0
  167. package/fixtures/atelier-instruction-adoption/receipt/invalid/document.json +22 -0
  168. package/fixtures/atelier-instruction-adoption/receipt/valid/document.json +21 -0
  169. package/fixtures/atelier-instruction-adoption/state/invalid/document.json +30 -0
  170. package/fixtures/atelier-instruction-adoption/state/valid/document.json +29 -0
  171. package/fixtures/atelier-interaction/act/invalid/document.json +25 -0
  172. package/fixtures/atelier-interaction/act/valid/document.json +24 -0
  173. package/fixtures/atelier-interaction/assessment/invalid/document.json +32 -0
  174. package/fixtures/atelier-interaction/assessment/valid/document.json +31 -0
  175. package/fixtures/atelier-interaction/observation/invalid/document.json +22 -0
  176. package/fixtures/atelier-interaction/observation/valid/document.json +21 -0
  177. package/fixtures/atelier-interaction/policy/invalid/document.json +20 -0
  178. package/fixtures/atelier-interaction/policy/valid/document.json +19 -0
  179. package/fixtures/atelier-judgment/case/invalid/document.json +76 -0
  180. package/fixtures/atelier-judgment/case/valid/document.json +75 -0
  181. package/fixtures/atelier-judgment/decision/invalid/document.json +38 -0
  182. package/fixtures/atelier-judgment/decision/valid/document.json +37 -0
  183. package/fixtures/atelier-judgment/native-decision.md +3 -0
  184. package/fixtures/atelier-judgment/practice-reconsideration/definition.json +92 -0
  185. package/fixtures/atelier-judgment/practice-reconsideration/scenario.json +435 -0
  186. package/fixtures/atelier-knowledge-contract/activation/invalid/document.json +26 -0
  187. package/fixtures/atelier-knowledge-contract/activation/valid/document.json +25 -0
  188. package/fixtures/atelier-knowledge-contract/contribution/invalid/document.json +34 -0
  189. package/fixtures/atelier-knowledge-contract/contribution/valid/document.json +33 -0
  190. package/fixtures/atelier-knowledge-contract/domain/invalid/document.json +41 -0
  191. package/fixtures/atelier-knowledge-contract/domain/valid/document.json +40 -0
  192. package/fixtures/atelier-knowledge-contract/domain-revision/invalid/document.json +46 -0
  193. package/fixtures/atelier-knowledge-contract/domain-revision/valid/document.json +45 -0
  194. package/fixtures/atelier-knowledge-contract/evaluation/invalid/document.json +21 -0
  195. package/fixtures/atelier-knowledge-contract/evaluation/valid/document.json +20 -0
  196. package/fixtures/atelier-knowledge-contract/ledger/invalid/document.json +318 -0
  197. package/fixtures/atelier-knowledge-contract/ledger/valid/document.json +317 -0
  198. package/fixtures/atelier-knowledge-contract/relation/invalid/document.json +25 -0
  199. package/fixtures/atelier-knowledge-contract/relation/valid/document.json +24 -0
  200. package/fixtures/atelier-knowledge-contract/review/invalid/document.json +18 -0
  201. package/fixtures/atelier-knowledge-contract/review/valid/document.json +17 -0
  202. package/fixtures/atelier-knowledge-contract/withdrawal/invalid/document.json +16 -0
  203. package/fixtures/atelier-knowledge-contract/withdrawal/valid/document.json +15 -0
  204. package/fixtures/atelier-learning/invalid/authority.json +14 -0
  205. package/fixtures/atelier-learning/valid/capture.json +13 -0
  206. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/authority-key.json +17 -0
  207. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/nonempty-ext.json +25 -0
  208. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/wrong-schema.json +22 -0
  209. package/fixtures/atelier-personal-workspace-contract/manifest/valid/manifest.json +22 -0
  210. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/authority-key.json +25 -0
  211. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/unknown-top-level-field.json +27 -0
  212. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/wrong-schema.json +24 -0
  213. package/fixtures/atelier-trackable/definition/invalid/document.json +15 -0
  214. package/fixtures/atelier-trackable/definition/valid/document.json +14 -0
  215. package/fixtures/atelier-trackable/request/invalid/document.json +23 -0
  216. package/fixtures/atelier-trackable/request/valid/document.json +22 -0
  217. package/fixtures/boundary-delegations/invalid/authority-key.v1.json +30 -0
  218. package/fixtures/boundary-delegations/invalid/push-operation.v1.json +28 -0
  219. package/fixtures/boundary-delegations/invalid/unsigned.v1.json +25 -0
  220. package/fixtures/boundary-delegations/valid/empty.v1.json +4 -0
  221. package/fixtures/boundary-delegations/valid/signed-operator.v1.json +30 -0
  222. package/fixtures/boundary-owner-keys/invalid/wrong-algorithm.v1.json +15 -0
  223. package/fixtures/boundary-owner-keys/invalid/wrong-curve.v1.json +15 -0
  224. package/fixtures/boundary-owner-keys/valid/one-owner.v1.json +17 -0
  225. package/fixtures/capability-packages/adoption.example.json +70 -0
  226. package/fixtures/capability-packages/evidence-review/capability-package.json +58 -0
  227. package/fixtures/capability-packages/evidence-review/capability-release.json +84 -0
  228. package/fixtures/capability-packages/evidence-review/evaluation.md +15 -0
  229. package/fixtures/capability-packages/evidence-review/skills/evidence-review/SKILL.md +22 -0
  230. package/fixtures/capability-packages/evidence-review/skills/evidence-review/references/ledger.md +7 -0
  231. package/fixtures/capability-packages/research/capability-package.json +88 -0
  232. package/fixtures/capability-packages/research/capability-release.json +134 -0
  233. package/fixtures/capability-packages/research/evaluation.md +15 -0
  234. package/fixtures/capability-packages/research/skills/research-integrate/SKILL.md +29 -0
  235. package/fixtures/capability-packages/research/skills/research-integrate/references/integration.md +10 -0
  236. package/fixtures/capability-packages/research/skills/research-plan/SKILL.md +27 -0
  237. package/fixtures/capability-packages/research/skills/research-plan/references/inquiries.md +26 -0
  238. package/fixtures/capability-packages/research/skills/research-synthesize/SKILL.md +23 -0
  239. package/fixtures/capability-packages/research/skills/research-synthesize/references/synthesis.md +12 -0
  240. package/fixtures/evidence-navigation/invalid/duplicate-operation.json +16 -0
  241. package/fixtures/evidence-navigation/invalid/instruction-kind.json +36 -0
  242. package/fixtures/evidence-navigation/invalid/invalid-digest.json +36 -0
  243. package/fixtures/evidence-navigation/invalid/invalid-time.json +36 -0
  244. package/fixtures/evidence-navigation/invalid/missing-attribution.json +35 -0
  245. package/fixtures/evidence-navigation/invalid/reserved-extension.json +19 -0
  246. package/fixtures/evidence-navigation/invalid/unattributed-acceptance.json +36 -0
  247. package/fixtures/evidence-navigation/invalid/uncoerced-version.json +16 -0
  248. package/fixtures/evidence-navigation/invalid/unknown-currency.json +17 -0
  249. package/fixtures/evidence-navigation/invalid/unknown-selector-field.json +37 -0
  250. package/fixtures/evidence-navigation/invalid/unsupported-operation.json +15 -0
  251. package/fixtures/evidence-navigation/valid/claim.json +36 -0
  252. package/fixtures/evidence-navigation/valid/host.json +16 -0
  253. package/fixtures/evidence-navigation/valid/profile.json +16 -0
  254. package/fixtures/evidence-navigation/valid/snapshot.json +17 -0
  255. package/fixtures/harness-packages/build-harness/capability-package.json +61 -0
  256. package/fixtures/harness-packages/build-harness/capability-release.json +82 -0
  257. package/fixtures/harness-packages/build-harness/evaluation.md +3 -0
  258. package/fixtures/harness-packages/build-harness/skills/atelier-build-harness/SKILL.md +59 -0
  259. package/fixtures/harness-packages/knowledge-harness/capability-package.json +61 -0
  260. package/fixtures/harness-packages/knowledge-harness/capability-release.json +82 -0
  261. package/fixtures/harness-packages/knowledge-harness/evaluation.md +3 -0
  262. package/fixtures/harness-packages/knowledge-harness/skills/atelier-knowledge-harness/SKILL.md +60 -0
  263. package/fixtures/harnesses/learning-cycle.json +780 -0
  264. package/fixtures/inquiry/workshop.json +675 -0
  265. package/fixtures/inquiry-packages/discovery-harness/capability-package.json +61 -0
  266. package/fixtures/inquiry-packages/discovery-harness/capability-release.json +82 -0
  267. package/fixtures/inquiry-packages/discovery-harness/evaluation.md +3 -0
  268. package/fixtures/inquiry-packages/discovery-harness/skills/atelier-discovery-harness/SKILL.md +48 -0
  269. package/fixtures/inquiry-packages/research-harness/capability-package.json +61 -0
  270. package/fixtures/inquiry-packages/research-harness/capability-release.json +82 -0
  271. package/fixtures/inquiry-packages/research-harness/evaluation.md +3 -0
  272. package/fixtures/inquiry-packages/research-harness/skills/atelier-research-harness/SKILL.md +43 -0
  273. package/fixtures/knowledge-health/calibration-suite.json +26 -0
  274. package/fixtures/knowledge-health/calibration-trial.json +37 -0
  275. package/fixtures/knowledge-health/corrected/checklist.md +18 -0
  276. package/fixtures/knowledge-health/devday/participant-record.json +19 -0
  277. package/fixtures/knowledge-health/expected-summary.json +27 -0
  278. package/fixtures/knowledge-health/workshop/README.md +20 -0
  279. package/fixtures/knowledge-health/workshop/atelier.project.json +7 -0
  280. package/fixtures/knowledge-health/workshop/knowledge-plan.json +65 -0
  281. package/fixtures/knowledge-health/workshop/records/checklist.md +18 -0
  282. package/fixtures/knowledge-health/workshop/records/workshop.md +18 -0
  283. package/fixtures/knowledge-health/workshop/repo-access.v1.json +5 -0
  284. package/fixtures/knowledge-health/workspace/atelier.project.json +18 -0
  285. package/fixtures/knowledge-health/workspace/knowledge-plan.json +74 -0
  286. package/fixtures/knowledge-health/workspace/records/checklist.md +18 -0
  287. package/fixtures/knowledge-health/workspace/records/pilot.md +16 -0
  288. package/fixtures/knowledge-health/workspace/repo-access.v1.json +9 -0
  289. package/fixtures/personal-workspace/overlay.json +24 -0
  290. package/fixtures/templates/local-library.v1.json +76 -0
  291. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/invalid.json +16 -0
  292. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/valid.json +13 -0
  293. package/fixtures/templates/upgrade-contracts/migration.v3/invalid.json +24 -0
  294. package/fixtures/templates/upgrade-contracts/migration.v3/valid.json +21 -0
  295. package/fixtures/templates/upgrade-contracts/template-adoption.v1/invalid.json +60 -0
  296. package/fixtures/templates/upgrade-contracts/template-adoption.v1/valid.json +57 -0
  297. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/invalid.json +85 -0
  298. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/valid.json +82 -0
  299. package/package.json +60 -5
  300. package/plugins/obsidian/main.js +57 -8
  301. package/plugins/obsidian/manifest.json +1 -1
  302. package/skills/claude/atelier-build-harness/SKILL.md +59 -0
  303. package/skills/claude/atelier-coordination/SKILL.md +32 -0
  304. package/skills/claude/atelier-discovery-harness/SKILL.md +48 -0
  305. package/skills/claude/atelier-guided-upgrade/SKILL.md +29 -0
  306. package/skills/claude/atelier-interaction/SKILL.md +34 -0
  307. package/skills/claude/atelier-knowledge-harness/SKILL.md +60 -0
  308. package/skills/claude/atelier-knowledge-setup/SKILL.md +72 -0
  309. package/skills/claude/atelier-practical-judgment/SKILL.md +64 -0
  310. package/skills/claude/atelier-research-harness/SKILL.md +43 -0
  311. package/skills/claude/atelier-skill-steward/SKILL.md +53 -0
  312. package/skills/claude/atelier-trackables/SKILL.md +40 -0
  313. package/skills/codex/atelier-build-harness/SKILL.md +59 -0
  314. package/skills/codex/atelier-coordination/SKILL.md +32 -0
  315. package/skills/codex/atelier-discovery-harness/SKILL.md +48 -0
  316. package/skills/codex/atelier-guided-upgrade/SKILL.md +29 -0
  317. package/skills/codex/atelier-interaction/SKILL.md +34 -0
  318. package/skills/codex/atelier-knowledge-harness/SKILL.md +60 -0
  319. package/skills/codex/atelier-knowledge-setup/SKILL.md +72 -0
  320. package/skills/codex/atelier-practical-judgment/SKILL.md +64 -0
  321. package/skills/codex/atelier-research-harness/SKILL.md +43 -0
  322. package/skills/codex/atelier-skill-steward/SKILL.md +53 -0
  323. package/skills/codex/atelier-trackables/SKILL.md +40 -0
  324. package/src/architecture/catalog.mjs +355 -0
  325. package/src/architecture/index.mjs +93 -0
  326. package/src/boundary/content-rules.mjs +536 -69
  327. package/src/boundary/delegation-command.mjs +158 -0
  328. package/src/boundary/delegation.mjs +396 -0
  329. package/src/boundary/policy.mjs +185 -64
  330. package/src/build/git.mjs +18 -0
  331. package/src/build/index.mjs +3 -0
  332. package/src/build/ledger.mjs +88 -0
  333. package/src/capabilities/adoption.mjs +381 -0
  334. package/src/capabilities/evidence.mjs +140 -0
  335. package/src/capabilities/files.mjs +124 -0
  336. package/src/capabilities/index.mjs +4 -0
  337. package/src/capabilities/instructions.mjs +250 -0
  338. package/src/capabilities/package.mjs +136 -0
  339. package/src/cli/command-failure.mjs +36 -0
  340. package/src/cli/execute-command.mjs +16 -2
  341. package/src/cli/run.mjs +66 -9
  342. package/src/coauthor/store.mjs +43 -2
  343. package/src/collaboration/proposals.mjs +189 -0
  344. package/src/commands/architecture.mjs +27 -0
  345. package/src/commands/boundary.mjs +3 -0
  346. package/src/commands/capability.mjs +73 -0
  347. package/src/commands/enroll.mjs +3 -0
  348. package/src/commands/harness.mjs +79 -0
  349. package/src/commands/ingest.mjs +39 -0
  350. package/src/commands/init.mjs +4 -1
  351. package/src/commands/inquiry.mjs +66 -0
  352. package/src/commands/knowledge-semantic.mjs +128 -0
  353. package/src/commands/knowledge.mjs +119 -0
  354. package/src/commands/learn.mjs +61 -0
  355. package/src/commands/obsidian.mjs +772 -65
  356. package/src/commands/practice.mjs +24 -0
  357. package/src/commands/setup.mjs +22 -2
  358. package/src/commands/trackable.mjs +19 -0
  359. package/src/contracts/corpus.mjs +30 -0
  360. package/src/coordination/index.mjs +110 -0
  361. package/src/evidence-navigation/contracts.mjs +60 -0
  362. package/src/evidence-navigation/evaluate.mjs +69 -0
  363. package/src/evidence-navigation/generation.mjs +22 -0
  364. package/src/evidence-navigation/index.mjs +2 -0
  365. package/src/evidence-navigation/local.mjs +167 -0
  366. package/src/evidence-navigation/schema.generated.mjs +604 -0
  367. package/src/evidence-navigation/types.mjs +12 -0
  368. package/src/export/atelier-export-contract.mjs +35 -8
  369. package/src/graph/enroll.mjs +248 -0
  370. package/src/graph/graph.mjs +30 -6
  371. package/src/graph/knowledge-graph.mjs +77 -9
  372. package/src/harnesses/contracts.mjs +41 -0
  373. package/src/harnesses/exchange.mjs +154 -0
  374. package/src/harnesses/feedback.mjs +16 -0
  375. package/src/harnesses/history.mjs +37 -0
  376. package/src/harnesses/index.mjs +4 -0
  377. package/src/harnesses/store.mjs +29 -0
  378. package/src/ingestion/contracts.mjs +41 -0
  379. package/src/ingestion/evaluation.mjs +119 -0
  380. package/src/ingestion/optional/composition/index.mjs +171 -0
  381. package/src/ingestion/optional/evaluation/index.mjs +219 -0
  382. package/src/ingestion/optional/graphify/composition-binding.mjs +60 -0
  383. package/src/ingestion/optional/graphify/composition_receiving.py +185 -0
  384. package/src/ingestion/optional/graphify/graphify_supplier.py +938 -0
  385. package/src/ingestion/optional/graphify/json_bridge.py +163 -0
  386. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  387. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  388. package/src/ingestion/optional/graphify/query_receiving.py +209 -0
  389. package/src/ingestion/optional/index.mjs +9 -0
  390. package/src/ingestion/optional/jev/bridge.mjs +166 -0
  391. package/src/ingestion/optional/jev/composition-binding.mjs +116 -0
  392. package/src/ingestion/optional/jev/judgments.mjs +174 -0
  393. package/src/ingestion/optional/jev/native-translation.mjs +32 -0
  394. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/__init__.py +7 -0
  395. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/adapter.py +966 -0
  396. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/artifacts.py +101 -0
  397. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/projection.py +146 -0
  398. package/src/ingestion/optional/lightrag/composition-binding.mjs +67 -0
  399. package/src/ingestion/optional/vanilla/composition-binding.mjs +102 -0
  400. package/src/ingestion/optional/vanilla/index.mjs +218 -0
  401. package/src/ingestion/optional/vanilla/native-normalization.mjs +187 -0
  402. package/src/ingestion/processors.mjs +218 -0
  403. package/src/ingestion/semantic.mjs +203 -0
  404. package/src/ingestion/store.mjs +372 -0
  405. package/src/inquiry/beliefs.mjs +32 -0
  406. package/src/inquiry/contracts.mjs +23 -0
  407. package/src/inquiry/index.mjs +5 -0
  408. package/src/inquiry/ledger.mjs +135 -0
  409. package/src/inquiry/lenses.mjs +15 -0
  410. package/src/inquiry/projection.mjs +77 -0
  411. package/src/inquiry/store.mjs +35 -0
  412. package/src/intake/read-scope.mjs +12 -0
  413. package/src/intake/store.mjs +69 -6
  414. package/src/interaction/index.mjs +91 -0
  415. package/src/judgment/index.mjs +82 -0
  416. package/src/judgment/practice-evaluation.mjs +70 -0
  417. package/src/judgment/practice.mjs +144 -0
  418. package/src/knowledge/context.mjs +217 -0
  419. package/src/knowledge/index.mjs +5 -0
  420. package/src/knowledge/ingestion.mjs +79 -0
  421. package/src/knowledge/intake.mjs +33 -0
  422. package/src/knowledge/ledger.mjs +110 -0
  423. package/src/knowledge/participatory/guidance.mjs +36 -0
  424. package/src/knowledge/participatory/guided-contribution.mjs +44 -0
  425. package/src/knowledge/participatory/index.mjs +2 -0
  426. package/src/knowledge/participatory/installed-workshop.mjs +108 -0
  427. package/src/knowledge/plan.mjs +67 -0
  428. package/src/knowledge/projection.mjs +43 -0
  429. package/src/knowledge/semantic-operation.mjs +567 -0
  430. package/src/knowledge/sessions.mjs +467 -0
  431. package/src/knowledge/workspace.mjs +295 -0
  432. package/src/knowledge-health/assessment.mjs +165 -0
  433. package/src/knowledge-health/capabilities.mjs +350 -0
  434. package/src/knowledge-health/context.mjs +62 -0
  435. package/src/knowledge-health/example.mjs +47 -0
  436. package/src/knowledge-health/index.mjs +10 -0
  437. package/src/knowledge-health/owner-join.mjs +88 -0
  438. package/src/knowledge-health/proof/common.mjs +24 -0
  439. package/src/knowledge-health/proof/installed-check.mjs +100 -0
  440. package/src/knowledge-health/proof/native-readback.mjs +37 -0
  441. package/src/knowledge-health/proof/rehearse.mjs +68 -0
  442. package/src/knowledge-health/proof/validate-participant.mjs +17 -0
  443. package/src/knowledge-health/proof/verify-native-capture.mjs +14 -0
  444. package/src/knowledge-health/proof/workshop-check.mjs +36 -0
  445. package/src/knowledge-health/rule-identity.mjs +49 -0
  446. package/src/knowledge-health/workshop-composition.mjs +19 -0
  447. package/src/knowledge-health/workshop-profile.mjs +176 -0
  448. package/src/knowledge-health/workshop.mjs +99 -0
  449. package/src/learning/adapters.mjs +68 -0
  450. package/src/learning/assistance.mjs +127 -0
  451. package/src/learning/contracts.mjs +65 -0
  452. package/src/learning/store.mjs +272 -0
  453. package/src/personal-workspace/index.mjs +391 -0
  454. package/src/personal-workspace/selection.mjs +401 -0
  455. package/src/project/config.mjs +8 -0
  456. package/src/project/durable-state.mjs +3 -1
  457. package/src/project/file-class.mjs +1 -1
  458. package/src/project/path-match.mjs +2 -1
  459. package/src/projection/obsidian/edits/apply.mjs +55 -16
  460. package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
  461. package/src/projection/obsidian/personal-workspace.mjs +519 -0
  462. package/src/projection/obsidian/proposals/adapter.mjs +79 -17
  463. package/src/projection/obsidian/proposals/observation.mjs +50 -4
  464. package/src/projection/obsidian/publication/publisher.mjs +214 -18
  465. package/src/projection/obsidian/publication/vault-list.mjs +4 -3
  466. package/src/projection/obsidian/recovery/index.mjs +4 -1
  467. package/src/projection/obsidian/recovery/store.mjs +160 -7
  468. package/src/projection/obsidian/selection-ui/contribution.mjs +6 -5
  469. package/src/projection/project.mjs +3 -2
  470. package/src/readiness/readiness.mjs +10 -3
  471. package/src/reflection/index.mjs +56 -0
  472. package/src/runtime/obsidian/app-production-seams.mjs +48 -16
  473. package/src/runtime/obsidian/app-registration.mjs +175 -28
  474. package/src/runtime/obsidian/app-restart.mjs +154 -0
  475. package/src/runtime/obsidian/engine.mjs +437 -56
  476. package/src/runtime/obsidian/extension-points.mjs +1 -1
  477. package/src/runtime/obsidian/index.mjs +10 -7
  478. package/src/runtime/obsidian/launch-plan.mjs +7 -4
  479. package/src/runtime/obsidian/lifecycle.mjs +108 -23
  480. package/src/runtime/obsidian/login-item.mjs +335 -0
  481. package/src/runtime/obsidian/machine-settings.mjs +119 -12
  482. package/src/runtime/obsidian/observation.mjs +36 -4
  483. package/src/runtime/obsidian/opening.mjs +173 -38
  484. package/src/runtime/obsidian/personal-selection.mjs +125 -0
  485. package/src/runtime/obsidian/pipeline.mjs +115 -6
  486. package/src/runtime/obsidian/plugin-channel.mjs +11 -3
  487. package/src/runtime/obsidian/plugin-choice.mjs +16 -4
  488. package/src/runtime/obsidian/plugin-drift.mjs +59 -11
  489. package/src/runtime/obsidian/plugin-presence.mjs +14 -1
  490. package/src/runtime/obsidian/private-lock.mjs +39 -4
  491. package/src/runtime/obsidian/process-identity.mjs +20 -10
  492. package/src/runtime/obsidian/project-views.mjs +282 -0
  493. package/src/runtime/obsidian/questions.mjs +48 -0
  494. package/src/runtime/obsidian/release-watch.mjs +56 -0
  495. package/src/runtime/obsidian/service-main.mjs +148 -19
  496. package/src/runtime/obsidian/service-manager-production.mjs +61 -0
  497. package/src/runtime/obsidian/service-managers.mjs +275 -0
  498. package/src/runtime/obsidian/service-record.mjs +87 -23
  499. package/src/runtime/obsidian/service.mjs +102 -9
  500. package/src/runtime/obsidian/startup-adapters.mjs +59 -21
  501. package/src/runtime/obsidian/unheld-evidence.mjs +84 -0
  502. package/src/runtime/obsidian/vault-location.mjs +300 -0
  503. package/src/runtime/obsidian/view-counts.mjs +34 -0
  504. package/src/runtime/obsidian/view-permission.mjs +334 -0
  505. package/src/server/local-sidecar.mjs +169 -2
  506. package/src/server/server.mjs +28 -2
  507. package/src/template-bindings/project.mjs +92 -0
  508. package/src/templates/profile.mjs +225 -0
  509. package/src/trackables/domain.mjs +175 -0
  510. package/src/trackables/store.mjs +114 -0
  511. package/src/ui/control-system.mjs +201 -0
  512. package/src/ui/html-primitives.mjs +3 -0
  513. package/src/ui/knowledge-page.mjs +1388 -0
  514. package/src/upgrade/closure-diagnostics.mjs +135 -0
  515. package/src/upgrade/closure.mjs +649 -0
  516. package/src/upgrade/explanation.mjs +1 -1
  517. package/src/upgrade/template-participant.mjs +171 -0
  518. package/src/upgrade/transaction.mjs +72 -25
  519. package/src/upgrade/upgrade.mjs +20 -7
  520. package/templates/external-project-workspace/pack-lifecycle.json +7 -1
  521. package/templates/knowledge-workspace/.gitattributes +2 -0
  522. package/templates/knowledge-workspace/AGENTS.md +27 -0
  523. package/templates/knowledge-workspace/README.md +44 -0
  524. package/templates/knowledge-workspace/atelier.project.json +22 -0
  525. package/templates/knowledge-workspace/gitignore +6 -0
  526. package/templates/knowledge-workspace/knowledge-plan.json +111 -0
  527. package/templates/knowledge-workspace/knowledge-plan.schema.json +281 -0
  528. package/templates/knowledge-workspace/records/forecast.md +22 -0
  529. package/templates/knowledge-workspace/records/inspection.md +25 -0
  530. package/templates/knowledge-workspace/records/telescope.md +24 -0
  531. package/templates/knowledge-workspace/repo-access.v1.json +9 -0
@@ -1,23 +1,28 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { performance } from 'node:perf_hooks'
3
4
  import { AtelierDiagnosticError } from '../../project/config.mjs'
4
5
  import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, manifestLayoutVersion } from '../../projection/obsidian/contracts.mjs'
6
+ import { createCooperativeBudget } from '../../projection/obsidian/materialize/prepare-view.mjs'
7
+ import { PERSONAL_VALIDATION_PENDING, notePersonalSelectionNow, personalSelectionBehind, personalWorkspaceBindingOf, personalWorkspaceInputs, validatePersonalWorkspace } from '../../projection/obsidian/personal-workspace.mjs'
5
8
  import { PROTOCOL_ID } from '../../projection/obsidian/publication/bridge-script.mjs'
6
- import { PublicationRefusal } from '../../projection/obsidian/recovery/store.mjs'
9
+ import { PublicationRefusal, allocatedFolderState, hasCommittedGeneration, readVaultAllocation } from '../../projection/obsidian/recovery/store.mjs'
7
10
  import { canonicalJson, compareText, isoTime } from './documents.mjs'
8
11
  import { readObsidianEnablement } from './enablement.mjs'
9
- import { ObsidianMaintenanceRefusal } from './errors.mjs'
12
+ import { ObsidianMaintenanceRefusal, refuse } from './errors.mjs'
10
13
  import { createMaintenanceExtensions } from './extension-points.mjs'
11
14
  import {
12
15
  assertOutsideRepositories, authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, protectedRoots, readLocalPointer,
13
16
  readMachineSettings, resolveDataRoot, workspaceStateRoot, writeMachineSettings,
14
17
  } from './machine-settings.mjs'
15
- import { configKey, listConfigFiles, listSourceFiles, listVaultNotes, readFileFacts, reconcile, sha256Digest, sourceKey, vaultKey } from './observation.mjs'
18
+ import { configKey, hasIndexedHint, listConfigFiles, listSourceFiles, listVaultNotes, readFileFacts, reconcile, reconcileCooperatively, sha256Digest, sourceKey, vaultKey } from './observation.mjs'
16
19
  import { dispatchAutomaticApply, heldPaths, layoutHeldPaths, observeVaultEdits, preserveInRecoveryStore, trustedNoteBases } from './pending-edits.mjs'
17
- import { DEFAULT_ELIGIBILITY, createProductionSeams } from './pipeline.mjs'
20
+ import { createProductionSeams, eligibilityFor } from './pipeline.mjs'
21
+ import { PLUGIN_FILES_WAIT_FOR_APP } from './plugin-presence.mjs'
18
22
  import { ENGINE_LOCK_DIRECTORY, acquirePrivateGenerationLock, createAbandonmentProof } from './private-lock.mjs'
19
23
  import { probeHealth } from './service-client.mjs'
20
24
  import { FRESHNESS_SCHEMA, LATE_WRITERS_SCHEMA, createMaintenanceStateStore } from './state-store.mjs'
25
+ import { ensureVaultAllocation, projectDisplayName } from './vault-location.mjs'
21
26
  import { createNullWatcherFactory } from './watchers.mjs'
22
27
 
23
28
  // The maintenance engine. One explicit `tick()`; no timer, no process, no
@@ -100,6 +105,15 @@ export const ENGINE_PRIMITIVES = Object.freeze({
100
105
  },
101
106
  // The embedded assets observed beside the walk: those the last built graph lets a view copy, and no withheld one.
102
107
  observedAssetsOf: (graph) => (graph.assets ?? []).filter((asset) => asset.eligible === true).map(({ repo, path: assetPath }) => ({ repo, path: assetPath })),
108
+ // A change the guard sees in the middle of a tick is read, where it may be a confirmed selection, while it is there.
109
+ noteSelectionAtGuard: notePersonalSelectionNow,
110
+ // A loaded project pinned to a selection record below the highest record this process has read the history ending at
111
+ // is loaded again at the next tick, whatever the files say: another reader in the process may have read a
112
+ // confirmation that is gone again before this engine looked (personalSelectionBehind).
113
+ reloadsBehindSelection: personalSelectionBehind,
114
+ // After a tick that failed without a typed reason, the next one looks at every view again, as the first tick of a
115
+ // process does: see tick().
116
+ rechecksEveryViewAfterFailure: true,
103
117
  // One engine per workspace at a time, across processes.
104
118
  acquireEngineLock: acquirePrivateGenerationLock,
105
119
  // The registered proposal adapter observes the open edits on every tick, so a structural edit is recorded as
@@ -123,10 +137,45 @@ export const ENGINE_PRIMITIVES = Object.freeze({
123
137
  heldNoteDigest({ file }) {
124
138
  try { return readFileFacts(file)?.digest ?? null } catch { return null }
125
139
  },
140
+ // Whether an error met by the check of a tick's settings, at a wait, is a failure to read them: a file-system error
141
+ // (no permission, an I/O error, no descriptor left), recognised as one is where a vault is placed. Anything else is a
142
+ // fault of the engine and stops the tick.
143
+ guardReadFailed: (error) => typeof error?.code === 'string' && /^E[A-Z]+$/.test(error.code),
144
+ // Whether an error met when the graph build, a view's preparation or the look again before its commit reads a
145
+ // source is a source, or a folder of sources, removed after this tick observed it: no such file (or a parent that is
146
+ // no longer a folder), at a path that is gone now and that this tick's walks over the sources (before the build, and
147
+ // the look again after it) recorded with a digest (a source of a repository the project observes), or a folder that
148
+ // held such a source. A source changed during the tick is refused as `mixed-read`; one removed is refused the same,
149
+ // and the next tick reads everything again. Anything else stops the tick, as before: a path still there, a path the
150
+ // walk listed and could not read (it was recorded without a digest), or one it never listed, so a path that can never
151
+ // be read still fails every tick.
152
+ sourceRemovedSinceObserved(error, { project, index }) {
153
+ if ((error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') || typeof error.path !== 'string' || !(index instanceof Map)) return false
154
+ const missing = path.resolve(error.path)
155
+ if (fs.existsSync(missing)) return false
156
+ for (const repo of project?.repos ?? []) {
157
+ if (repo?.external || typeof repo?.path !== 'string' || typeof repo?.name !== 'string') continue
158
+ const relative = path.relative(path.resolve(repo.path), missing)
159
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) continue
160
+ const posix = relative.split(path.sep).join('/')
161
+ if (typeof index.get(sourceKey(repo.name, posix))?.digest === 'string') return true
162
+ const folder = sourceKey(repo.name, `${posix}/`)
163
+ for (const [key, entry] of index) if (key.startsWith(folder) && typeof entry?.digest === 'string') return true
164
+ }
165
+ return false
166
+ },
126
167
  })
127
168
 
128
169
  const isTypedRefusal = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal || error instanceof PublicationRefusal
170
+ // The code a view is refused with when its own vault cannot be placed: a typed refusal's, or `vault-location-unusable`
171
+ // for a file-system error there (a file in the way, no permission, a read-only volume). Null for anything else, which
172
+ // is a fault of the engine and stops the tick.
173
+ const viewRefusalCode = (error) => (isTypedRefusal(error) ? error.code : typeof error?.code === 'string' && /^E[A-Z]+$/.test(error.code) ? 'vault-location-unusable' : null)
129
174
  const digestOfJson = (value) => sha256Digest(Buffer.from(canonicalJson(value)))
175
+ // The configuration a tick observes. A project bound to a personal workspace adds the person's authored manifest and
176
+ // overlay and its generation's record: a change to any of them is a configuration change, so every view is prepared
177
+ // again, asks the composition, and is refused at once when the generation no longer holds.
178
+ export const configFilesOf = (project) => [...listConfigFiles(project), ...personalWorkspaceInputs(project).map((absolute) => ({ key: configKey(absolute), changeClass: 'config', absolute }))]
130
179
 
131
180
  // An editor adapter that `build` makes, synchronously or not, the first time the publisher calls it; a refusal of
132
181
  // `build` is thrown from that call, and `refusal()` answers it afterwards.
@@ -142,8 +191,16 @@ function builtOnFirstUse(build) {
142
191
  publish: async (payload) => (await built()).publish(payload),
143
192
  }
144
193
  }
194
+ // The adapter a publication gets when the factory refused the app: it coordinates with nothing and never calls it.
195
+ function uncoordinatedAdapter(refusal) {
196
+ const never = async () => { throw refusal }
197
+ return { probe: async () => ({ state: 'uncoordinated', reason: `${refusal.code}: the installed Obsidian does not qualify, and nothing is published through it` }), inspect: never, collect: never, publish: never }
198
+ }
145
199
  // Views a tick may be asked to prepare again, at most, before it runs.
146
200
  const MAX_PREPARATION_REQUESTS = 64
201
+ // At most this many views a tick refuses for a file-system error are named with that error's code (`refusalCauses`); the
202
+ // others of that tick are not named.
203
+ const MAX_REFUSAL_CAUSES = 64
147
204
 
148
205
  function journalTime(journalId) {
149
206
  const match = /^journal-(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{3})-/.exec(journalId)
@@ -166,19 +223,48 @@ function journalsToRecheck(store, { nowMs, full, windowMs }) {
166
223
  export function createMaintenanceEngineForOracleTests(options = {}, primitives = ENGINE_PRIMITIVES) {
167
224
  const {
168
225
  loadProject, dataRoot, adapterFactory, clock,
169
- watcherFactory = createNullWatcherFactory(), extensions = createMaintenanceExtensions(), eligibility = DEFAULT_ELIGIBILITY,
226
+ // Which notes may enter a view: what the machine settings decide at each tick (eligibilityFor), unless a test fixes it.
227
+ watcherFactory = createNullWatcherFactory(), extensions = createMaintenanceExtensions(), eligibility: fixedEligibility = null,
170
228
  fullReconciliationIntervalMs = DEFAULT_FULL_RECONCILIATION_INTERVAL_MS, retryIntervalMs = DEFAULT_RETRY_INTERVAL_MS, lateWriterWindowMs = DEFAULT_LATE_WRITER_WINDOW_MS,
171
229
  publicationRetryMs = DEFAULT_PUBLICATION_RETRY_MS, observeApp = null,
230
+ // Reads the app's own vault list without asking the app (readAppVaultListForAllocation): { ok: true, vaults } with
231
+ // the map id -> { path }, or { ok: false, code }. A folder for a new vault is never allocated inside a vault it
232
+ // lists, nor under a name a vault it lists already has, nor while the list cannot be read.
233
+ readAppVaultList = null,
234
+ // Reads the app's own vault list from its file, without asking the app (unheld-evidence.mjs): { unlisted: true } only
235
+ // when no list names a view's vault, a folder above it or one inside it. A view never published into its allocated
236
+ // folder is then published even while an app runs that cannot be coordinated with, creating files only (the
237
+ // publisher's `direct-unheld` path). An engine given no reader never takes that path.
238
+ readUnheldEvidence = null,
172
239
  quietPeriodMs, lstat = fs.lstatSync, randomBytes, env = process.env, platform = process.platform,
173
240
  // A service names where it answers health, so a lock it leaves behind can be proven abandoned.
174
241
  lockOwner = null, lockProbe = probeHealth,
242
+ // Answers at once, without loading, what the workspace is resolved from (locatePersonalHome), for a loader that can
243
+ // refuse before any project was loaded: the views published before are then told why nothing is published now.
244
+ locateProject = null,
175
245
  } = options
176
246
  if (typeof loadProject !== 'function') throw new TypeError('the engine needs loadProject')
177
247
  if (typeof clock !== 'function') throw new TypeError('the engine needs an injected clock')
178
248
  // No default: the production adapter reaches a running app, and only the owner of the lifecycle may decide that.
179
249
  if (typeof adapterFactory !== 'function') throw new TypeError('the engine needs an adapterFactory')
180
- const seams = { ...createProductionSeams(), ...(options.seams ?? {}) }
250
+ const production = createProductionSeams()
251
+ const seams = { ...production, ...(options.seams ?? {}) }
252
+ // A caller that replaced prepareView and left the cooperative preparation as it ships prepares views its own way: its
253
+ // seam is used, synchronously, whether it handed in that one member or the production seams with that member replaced.
254
+ if (seams.prepareView !== production.prepareView && seams.prepareViewCooperatively === production.prepareViewCooperatively) seams.prepareViewCooperatively = null
255
+ // The same for the graph: a caller that replaced buildGraph and left the cooperative build as it ships builds its own way.
256
+ if (seams.buildGraph !== production.buildGraph && seams.buildGraphCooperatively === production.buildGraphCooperatively) seams.buildGraphCooperatively = null
181
257
  const rules = { ...ENGINE_PRIMITIVES, ...primitives }
258
+ // Optional local qualification telemetry. It has no authority to change a
259
+ // tick's result, and records elapsed boundaries rather than a budget claim.
260
+ const phaseTime = () => performance.timeOrigin + performance.now()
261
+ const reportPhase = (phase, startedMs) => {
262
+ try { seams.onPhase?.({ phase, startedMs, endedMs: phaseTime() }) } catch { /* telemetry never changes custody */ }
263
+ }
264
+ const measured = (phase, operation) => {
265
+ const startedMs = phaseTime()
266
+ try { return operation() } finally { reportPhase(phase, startedMs) }
267
+ }
182
268
 
183
269
  const index = new Map()
184
270
  const hintedKeys = new Set()
@@ -203,6 +289,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
203
289
  let watching = { signature: null, handle: null }
204
290
  let known = null // { stateStore, maintenanceMode } once a workspace has been resolved
205
291
  let heldLock = null
292
+ let releaseOwed = false
206
293
  // Views somebody asked to have prepared and published once more, at the next tick.
207
294
  const preparationRequests = new Set()
208
295
  // The views the project declared at the last tick; null before the first.
@@ -215,7 +302,23 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
215
302
  const waitingForApp = new Map()
216
303
  const proveAbandoned = createAbandonmentProof({ probe: lockProbe })
217
304
 
305
+ // Whether a view kept from publication by an app that does not qualify may still be published on the publisher's
306
+ // `direct-unheld` path: never committed, its vault the folder allocated for it, and the app's list naming no folder
307
+ // on the way. The publisher checks every condition again, under its locks.
308
+ async function unheldMayApply(store, pointer) {
309
+ if (typeof readUnheldEvidence !== 'function' || pointer !== null || store.vaultOrigin !== 'allocated') return false
310
+ try { return (await readUnheldEvidence({ vaultRoot: store.vaultRoot }))?.unlisted === true } catch { return false }
311
+ }
312
+
218
313
  async function takeLock(workspaceRoot, workspaceId) {
314
+ // A lock an earlier tick could not give back is still this process's, and its ticket on disk unreleased: a tick
315
+ // that tried to take it anew would find its own ticket and answer `busy` until the process ends. It is given back
316
+ // first. A release that fails again fails this tick, and is owed to the next.
317
+ if (heldLock && releaseOwed) {
318
+ heldLock.release()
319
+ heldLock = null
320
+ releaseOwed = false
321
+ }
219
322
  if (heldLock) return { acquired: true }
220
323
  const lock = await rules.acquireEngineLock({
221
324
  workspaceRoot, directory: path.join(workspaceRoot, ENGINE_LOCK_DIRECTORY), workspaceId, purpose: 'maintenance-engine', service: lockOwner, clock, proveAbandoned,
@@ -224,10 +327,18 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
224
327
  return lock
225
328
  }
226
329
 
330
+ // Gives the lock back at the end of a tick. One that cannot be given back (its released ticket could not be written)
331
+ // stays held, and the failure is the tick's: the next tick gives it back before anything else. One given back owes
332
+ // nothing more, whether this tick took it or an earlier one could not give it back.
227
333
  function dropLock() {
228
334
  const lock = heldLock
335
+ if (lock === null) return
336
+ try { lock.release() } catch (error) {
337
+ releaseOwed = true
338
+ throw error
339
+ }
229
340
  heldLock = null
230
- lock?.release()
341
+ releaseOwed = false
231
342
  }
232
343
 
233
344
  function onWatcherEvent(roots, { rootId, relative }) {
@@ -249,9 +360,18 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
249
360
  }
250
361
 
251
362
  function storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) {
252
- const signature = JSON.stringify([workspaceRoot, workspaceId, repositoryRoots])
363
+ // A view's vault can be allocated, made again or moved while the engine runs: the store follows its record. A
364
+ // store is used again only while its allocated folder is still the one the record names; otherwise it is dropped,
365
+ // and the store made in its place refuses, typed (vault-allocation-replaced, say), so a folder replaced while the
366
+ // service runs, or a link put where it was, is never published into.
367
+ const signature = JSON.stringify([workspaceRoot, workspaceId, repositoryRoots, readVaultAllocation({ workspaceRoot, workspaceId, scopeId: scope.scopeId })])
253
368
  const cached = stores.get(scope.scopeId)
254
- if (cached?.signature === signature) return cached.store
369
+ // A vault root that is gone (a folder under the data root the person removed, say) is looked for again too, so the
370
+ // store made in its place says why rather than failing on the folder that is not there.
371
+ if (cached?.signature === signature && fs.lstatSync(cached.store.vaultRoot, { throwIfNoEntry: false }) !== undefined) {
372
+ try { cached.store.checkAllocatedVault?.(); return cached.store } catch (error) { if (!isTypedRefusal(error)) throw error }
373
+ }
374
+ stores.delete(scope.scopeId)
255
375
  const store = seams.createRecoveryStore({ workspaceRoot, workspaceId, scopeId: scope.scopeId, repositoryRoots })
256
376
  stores.set(scope.scopeId, { signature, store })
257
377
  return store
@@ -275,11 +395,13 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
275
395
  // Persists "refused" or "disabled" over whatever is known, and only when a workspace already has state.
276
396
  async function persistOutcome({ enablement, state, reason, now, scopeIds = [] }) {
277
397
  if (!known) {
398
+ let located = project
399
+ try { located ??= locateProject === null ? null : locateProject() } catch { located = null }
278
400
  let pointer = null
279
- try { pointer = project ? readLocalPointer(project) : null } catch { pointer = null }
401
+ try { pointer = located ? readLocalPointer(located) : null } catch { pointer = null }
280
402
  if (!pointer) return
281
403
  let root
282
- try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), pointer.workspaceId) } catch { return }
404
+ try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project: located, env, platform }), pointer.workspaceId) } catch { return }
283
405
  const stateStore = createMaintenanceStateStore({ workspaceRoot: root, workspaceId: pointer.workspaceId })
284
406
  if (!stateStore.exists()) return
285
407
  known = { stateStore: createMaintenanceStateStore({ workspaceRoot: fs.realpathSync(root), workspaceId: pointer.workspaceId }), maintenanceMode: 'manual' }
@@ -318,15 +440,19 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
318
440
 
319
441
  // 1. Project and enablement. The digests are seeded before the project is
320
442
  // loaded for good, so the loaded project is never older than the index.
443
+ // A loader may answer a promise (one that composes a personal workspace off
444
+ // the event loop). A project bound to a personal workspace is loaded once:
445
+ // what a generation holds is fixed, and an input that changed after the load
446
+ // changes the validity key every build is checked against.
321
447
  if (project === null) {
322
- project = loadProject()
323
- reconcile({ index, files: listConfigFiles(project), prefix: CONFIG_PREFIX, full: true, lstat })
324
- project = loadProject()
448
+ project = await loadProject()
449
+ reconcile({ index, files: configFilesOf(project), prefix: CONFIG_PREFIX, full: true, lstat })
450
+ if (personalWorkspaceBindingOf(project) === null) project = await loadProject()
325
451
  } else {
326
- const config = reconcile({ index, files: listConfigFiles(project), prefix: CONFIG_PREFIX, full, hinted, lstat })
327
- if (config.changes.length > 0) {
328
- project = loadProject()
329
- reconcile({ index, files: listConfigFiles(project), prefix: CONFIG_PREFIX, full: true, lstat })
452
+ const config = reconcile({ index, files: configFilesOf(project), prefix: CONFIG_PREFIX, full, hinted, lstat })
453
+ if (config.changes.length > 0 || rules.reloadsBehindSelection(project)) {
454
+ project = await loadProject()
455
+ reconcile({ index, files: configFilesOf(project), prefix: CONFIG_PREFIX, full: true, lstat })
330
456
  }
331
457
  }
332
458
  const enablement = readObsidianEnablement(project)
@@ -351,33 +477,112 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
351
477
  if (!lock.acquired) return { state: 'busy', reason: 'engine-lock-held', lock: { reason: lock.reason, holder: lock.holder ?? null }, changes: [], scopes: [], pendingEdits: [], dispatched: [], lateWriters: [] }
352
478
  const machine = readMachineSettings({ workspaceRoot, workspaceId })
353
479
  ?? writeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, settings: defaultMachineSettings({ workspaceId, updatedAt: now }) })
480
+ const eligibility = fixedEligibility ?? eligibilityFor({ machine, project })
354
481
  const stateStore = createMaintenanceStateStore({ workspaceRoot, workspaceId })
355
482
  known = { stateStore, maintenanceMode: machine.maintenanceMode }
356
- const scopes = enablement.scopes.map((scope) => ({ scope, store: storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) }))
483
+ // A view refused for a file-system error (a vault folder that could not be allocated or placed, a configuration
484
+ // file that could not be read again, a source removed while it was read, candidates that could not be staged) is
485
+ // settled under the refusal's code alone, and such a tick ends without a failure. The error's code (`EACCES`, `EIO`,
486
+ // `ENOENT`), and that of a cleanup after it that failed too, are reported beside it for the service's log. Only
487
+ // codes shaped as a file-system error's are kept: never a message, which may name a path.
488
+ const refusalCauses = []
489
+ const errnoOf = (value) => (typeof value === 'string' && /^E[A-Z]+$/.test(value) ? value : null)
490
+ const noteRefusalCause = (scopeId, refusal) => {
491
+ const cause = errnoOf(refusal?.detail?.cause)
492
+ const cleanupCause = errnoOf(refusal?.detail?.cleanupCause)
493
+ if ((cause === null && cleanupCause === null) || refusalCauses.length >= MAX_REFUSAL_CAUSES) return
494
+ refusalCauses.push({ scopeId, code: refusal.code, ...(cause === null ? {} : { cause }), ...(cleanupCause === null ? {} : { cleanupCause }) })
495
+ }
496
+ // A view whose own vault cannot be placed: a typed refusal's code and detail, or `vault-location-unusable` for a
497
+ // file-system error there, whose code is its cause.
498
+ const refuseViewAlone = (scopeId, error) => {
499
+ const code = viewRefusalCode(error)
500
+ if (code === null) throw error
501
+ allocationRefusals.set(scopeId, code)
502
+ noteRefusalCause(scopeId, isTypedRefusal(error) ? error : { code, detail: { cause: error.code } })
503
+ }
504
+ // Each view's vault: allocated now where this workspace decided its vaults live, when it has none and was never
505
+ // published under the data root (vault-location.mjs). A view whose folder cannot be allocated is not prepared on
506
+ // this tick, and its freshness says why; the other views go on.
507
+ const allocationRefusals = new Map()
508
+ if (machine.decisions.location !== null) {
509
+ // The app's list, read once per tick and only when a view is about to be allocated. An engine given no reader
510
+ // coordinates with no app, and knows no list. A list that cannot be read allocates nothing: the view says why,
511
+ // and its folder is allocated at a later tick, once the list reads.
512
+ let list = null
513
+ const appList = async () => {
514
+ if (typeof readAppVaultList !== 'function') return { ok: true, vaults: null }
515
+ if (list === null) {
516
+ let answer
517
+ try { answer = await readAppVaultList() } catch { answer = null }
518
+ list = answer?.ok === true ? { ok: true, vaults: answer.vaults ?? {} } : { ok: false, code: typeof answer?.code === 'string' ? answer.code : 'obsidian-settings-unreadable' }
519
+ }
520
+ return list
521
+ }
522
+ // The folders allocated to the views; a record that cannot be read refuses only its own view, below.
523
+ const allocatedPaths = enablement.scopes.map((scope) => { try { return readVaultAllocation({ workspaceRoot, workspaceId, scopeId: scope.scopeId })?.path } catch { return undefined } }).filter((item) => typeof item === 'string')
524
+ for (const scope of enablement.scopes) {
525
+ try {
526
+ let vaults = null
527
+ // The list is read where a folder is about to be made: a first allocation, or an allocated folder that has gone.
528
+ const record = readVaultAllocation({ workspaceRoot, workspaceId, scopeId: scope.scopeId })
529
+ if (record === null ? !hasCommittedGeneration({ workspaceRoot, scopeId: scope.scopeId }) : allocatedFolderState(record) === 'missing') {
530
+ const known = await appList()
531
+ if (!known.ok) refuse('app-vault-list-unreadable', 'Obsidian\'s vault list could not be read, so no folder is allocated for this view yet; it is tried again at the next tick', { cause: known.code })
532
+ vaults = known.vaults
533
+ }
534
+ ensureVaultAllocation({ workspaceRoot, workspaceId, scopeId: scope.scopeId, location: machine.decisions.location, projectName: projectDisplayName(project), repositoryRoots, vaults, allocatedPaths, now })
535
+ } catch (error) { refuseViewAlone(scope.scopeId, error) }
536
+ }
537
+ }
538
+ // Each view's store, on its own: one whose vault cannot be placed (a record naming a folder inside a repository,
539
+ // say) is refused alone, and the other views go on.
540
+ const scopes = []
541
+ for (const scope of enablement.scopes) {
542
+ if (allocationRefusals.has(scope.scopeId)) continue
543
+ try { scopes.push({ scope, store: storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) }) } catch (error) { refuseViewAlone(scope.scopeId, error) }
544
+ }
357
545
 
358
546
  watch([
359
547
  ...(project.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => ({ id: `repo:${repo.name}`, path: repo.path, recursive: true, keyPrefix: sourceKey(repo.name, ''), keyOf: (relative) => sourceKey(repo.name, relative) })),
360
548
  ...scopes.map(({ scope, store }) => ({ id: `vault:${scope.scopeId}`, path: store.vaultRoot, recursive: true, keyPrefix: vaultKey(scope.scopeId, ''), keyOf: (relative) => vaultKey(scope.scopeId, relative) })),
361
- ...[...new Set(listConfigFiles(project).map((file) => path.dirname(file.absolute)))].filter((directory) => fs.existsSync(directory))
549
+ ...[...new Set(configFilesOf(project).map((file) => path.dirname(file.absolute)))].filter((directory) => fs.existsSync(directory))
362
550
  .map((directory) => ({ id: `config:${directory}`, path: directory, recursive: false, keyPrefix: CONFIG_PREFIX, keyOf: (relative) => configKey(path.join(directory, relative)) })),
363
551
  ])
364
552
 
365
553
  // 3. Vault notes: preserve, then queue. Compared with the bytes each note
366
554
  // was generated with, not with the previous look.
367
- const pending = stateStore.readPendingEdits()
368
- let edits = pending.edits
555
+ //
556
+ // Every view's notes are looked at first, and the walk waits between notes, so a large vault does not hold the
557
+ // listener for its whole length (at a full reconciliation every note is hashed, and after a publication the
558
+ // watcher has named each note it wrote). Nothing is checked at those waits and nothing is written: within
559
+ // Atelier only this engine commits a manifest or writes the pending edits, under the lock this tick holds, so the
560
+ // manifest read before the walk is the one after it. A note changed after its turn is the next tick's to see, by
561
+ // its stat hint or the watcher, as one changed just after a walk that ran through is. The pending edits are then
562
+ // read, compared and written in one stretch, as before; the bytes an edit keeps are read there, not at the walk.
369
563
  const basesOf = new Map()
370
564
  const earlierLayout = new Set()
371
- for (const { scope, store } of scopes) {
372
- const { manifest, bases } = trustedNoteBases(store)
373
- basesOf.set(scope.scopeId, bases)
374
- if (!manifest) continue
375
- if (manifestLayoutVersion(manifest) === 1) earlierLayout.add(scope.scopeId)
376
- const vault = reconcile({ index, files: listVaultNotes({ scopeId: scope.scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scope.scopeId, ''), full, hinted, lstat })
565
+ const looked = []
566
+ const vaultStartedMs = phaseTime()
567
+ try {
568
+ const budget = createCooperativeBudget()
569
+ for (const { scope, store } of scopes) {
570
+ const { manifest, bases } = trustedNoteBases(store)
571
+ basesOf.set(scope.scopeId, bases)
572
+ if (!manifest) continue
573
+ if (manifestLayoutVersion(manifest) === 1) earlierLayout.add(scope.scopeId)
574
+ const vault = await reconcileCooperatively({ index, files: listVaultNotes({ scopeId: scope.scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scope.scopeId, ''), full, hinted, lstat }, budget)
575
+ looked.push({ scope, store, manifest, bases, vault })
576
+ }
577
+ } finally { reportPhase('vault-observation', vaultStartedMs) }
578
+ const pending = stateStore.readPendingEdits()
579
+ let edits = pending.edits
580
+ for (const { scope, store, manifest, bases, vault } of looked) {
377
581
  const digestOf = (notePath) => index.get(vaultKey(scope.scopeId, notePath))?.digest ?? null
378
582
  const observed = observeVaultEdits({ store, workspaceId, scopeId: scope.scopeId, manifest, bases, digestOf, edits, now, preserve: rules.preserve })
379
583
  edits = observed.edits
380
- // A note that has gone is restored by the publisher; its disappearance is a change, once.
584
+ // A note that has gone is a change, once: the view is prepared again and its read-back says the note is missing.
585
+ // The publisher writes it back when it next publishes a generation of the view.
381
586
  if (observed.events.length > 0 || vault.changes.some((change) => change.kind === 'removed')) changes.push({ changeClass: 'vault-note', scopeId: scope.scopeId })
382
587
  }
383
588
  stateStore.writePendingEdits({ ...pending, edits })
@@ -385,9 +590,12 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
385
590
  // Observation. The registered proposal adapter is handed a copy of the pending edits and observes the open ones
386
591
  // the object store does not know yet, a bounded number per tick: each is classified from its preserved bytes
387
592
  // against the source as it is now and recorded as what it is, in manual and in automatic mode alike, and no
388
- // source is written. What refused before anything was recorded is offered again on a full reconciliation only.
593
+ // source is written. What refused before anything was recorded is offered again on a full reconciliation only;
594
+ // for a personal workspace, also on the tick after one whose graph was pending, which offers every earlier
595
+ // refusal again.
389
596
  const proposalAdapter = extensions.get('proposal-adapter')
390
- const adapterContext = () => ({ project, workspaceRoot, workspaceId, repositoryRoots, edits: structuredClone(edits), clock, env })
597
+ // A personal workspace is never composed on this event loop by the adapter: its builds defer, as the engine's do.
598
+ const adapterContext = () => ({ project, workspaceRoot, workspaceId, repositoryRoots, edits: structuredClone(edits), clock, env, deferPersonalValidation: true })
391
599
  let observed = null
392
600
  if (proposalAdapter !== null && rules.observeEdits(proposalAdapter)) {
393
601
  try { observed = await proposalAdapter.observe(adapterContext(), { retryRefused: full }) } catch { observed = { adapterId: proposalAdapter.id, failed: 'proposal-adapter-threw' } }
@@ -417,24 +625,33 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
417
625
  const lateWriters = []
418
626
  const lateDocument = stateStore.readLateWriters()
419
627
  const knownFindings = new Set(lateDocument.findings.map((item) => `${item.scopeId}\u0000${item.journalId}\u0000${item.unit}\u0000${item.observedDigest}`))
420
- for (const { scope, store } of scopes) {
421
- const journalIds = journalsToRecheck(store, { nowMs, full, windowMs: lateWriterWindowMs })
422
- if (journalIds.length === 0) continue
423
- for (const finding of seams.recheckDisplacedFiles({ store, journalIds, clock })) {
424
- if (finding.code !== 'late-writer-captured') continue
425
- const key = `${scope.scopeId}\u0000${finding.journalId}\u0000${finding.unit}\u0000${finding.observedDigest}`
426
- if (knownFindings.has(key)) continue
427
- knownFindings.add(key)
428
- lateWriters.push({ scopeId: scope.scopeId, journalId: finding.journalId, unit: finding.unit, notePath: finding.notePath ?? null, digestAtMove: finding.digestAtMove ?? null, observedDigest: finding.observedDigest, objectRef: finding.objectRef, detectedAt: now })
628
+ const lateStartedMs = phaseTime()
629
+ try {
630
+ for (const { scope, store } of scopes) {
631
+ const journalIds = journalsToRecheck(store, { nowMs, full, windowMs: lateWriterWindowMs })
632
+ if (journalIds.length === 0) continue
633
+ for (const finding of seams.recheckDisplacedFiles({ store, journalIds, clock })) {
634
+ if (finding.code !== 'late-writer-captured') continue
635
+ const key = `${scope.scopeId}\u0000${finding.journalId}\u0000${finding.unit}\u0000${finding.observedDigest}`
636
+ if (knownFindings.has(key)) continue
637
+ knownFindings.add(key)
638
+ lateWriters.push({ scopeId: scope.scopeId, journalId: finding.journalId, unit: finding.unit, notePath: finding.notePath ?? null, digestAtMove: finding.digestAtMove ?? null, observedDigest: finding.observedDigest, objectRef: finding.objectRef, detectedAt: now })
639
+ }
429
640
  }
430
- }
641
+ } finally { reportPhase('late-writer-recheck', lateStartedMs) }
431
642
  if (lateWriters.length > 0) {
432
643
  const findings = [...lateDocument.findings, ...lateWriters].sort((left, right) => compareText(left.detectedAt, right.detectedAt) || compareText(left.journalId, right.journalId) || left.unit - right.unit || compareText(left.observedDigest, right.observedDigest))
433
644
  stateStore.writeLateWriters({ schema: LATE_WRITERS_SCHEMA, workspaceId, findings })
434
645
  }
435
646
 
436
- // 6. Sources, settings, scopes, eligibility.
437
- const sources = reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full, hinted, lstat })
647
+ // 6. Sources, settings, scopes, eligibility. The walk over the sources waits between files, as the walk over the
648
+ // vault does; at a full reconciliation it hashes every one. A source changed after its turn is the next tick's to
649
+ // see, by its stat hint or the watcher, or at the latest by the next full reconciliation. When this tick builds the
650
+ // graph, the look again at the sources after the build (`graph-source-recheck`) refuses such a change as
651
+ // `mixed-read`.
652
+ const sourcesStartedMs = phaseTime()
653
+ let sources
654
+ try { sources = await reconcileCooperatively({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full, hinted, lstat }, createCooperativeBudget()) } finally { reportPhase('source-observation', sourcesStartedMs) }
438
655
  for (const change of sources.changes) changes.push({ changeClass: change.changeClass })
439
656
  const { scopes: _declared, ...settingsWithoutScopes } = enablement.settings
440
657
  // What the configuration says apart from this integration's own member: a change to the member alone is an
@@ -455,7 +672,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
455
672
  if (full) { lastFullMs = nowMs; forceFull = false }
456
673
 
457
674
  // 7. Which views are invalid.
458
- declaredScopes = new Set(scopes.map(({ scope }) => scope.scopeId))
675
+ declaredScopes = new Set(enablement.scopes.map((scope) => scope.scopeId))
459
676
  const previous = readPreviousFreshness(stateStore)
460
677
  const entries = new Map()
461
678
  const unseen = new Set()
@@ -479,35 +696,151 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
479
696
  if (earlierLayout.has(scope.scopeId) && entry.state === 'current' && layoutHeldOf(scope.scopeId).length === 0) invalidate(scope.scopeId, null)
480
697
  }
481
698
  for (const change of changes) for (const { scope } of scopes) if (change.scopeId === undefined || change.scopeId === scope.scopeId) invalidate(scope.scopeId, change.changeClass)
699
+ // A bound personal workspace is composed again, off the event loop, at every full reconciliation, whatever else is
700
+ // being prepared: a cause outside its validity key can stop a generation holding without a file this engine
701
+ // observes. Its views are prepared again only when the composition refuses, or confirms another key.
702
+ let personalRefusal = null
703
+ if (full && personalWorkspaceBindingOf(project) !== null) {
704
+ const verdict = await validatePersonalWorkspace(project)
705
+ if (verdict.ok !== true) personalRefusal = verdict.code
706
+ if (verdict.ok !== true || verdict.changed) for (const { scope } of scopes) invalidate(scope.scopeId, null)
707
+ }
482
708
 
483
709
  // Invalidation is durable before any work: a view is not reported current while it is being rebuilt.
484
710
  for (const [scopeId, classes] of attempt) entries.set(scopeId, { ...demote(entries.get(scopeId), 'stale', 'invalidated', now), changeClasses: [...classes].sort() })
711
+ for (const [scopeId, code] of allocationRefusals) {
712
+ entries.set(scopeId, demote(previous?.scopes.find((entry) => entry.scopeId === scopeId && entry.state !== 'disabled') ?? blankEntry(scopeId, now), 'stale', code, now))
713
+ }
485
714
  for (const entry of previous?.scopes ?? []) if (!entries.has(entry.scopeId)) entries.set(entry.scopeId, demote(entry, 'disabled', 'scope-not-configured', now))
486
715
  const persist = () => stateStore.writeFreshness(freshnessDocument({ workspaceId, enablement: 'enabled', maintenanceMode: machine.maintenanceMode, now, entries }))
487
716
  persist()
488
717
 
489
718
  // 8. Rebuild and publish.
490
719
  if (attempt.size > 0) {
720
+ // Cooperative preparation/publication can outlive the settings that
721
+ // selected this tick. Reuse the existing configuration/personal-input
722
+ // observation keys, machine settings and eligibility revision as the
723
+ // fence; do not invent another selection authority.
724
+ const bindings = configFilesOf(project).map(file => ({ ...file, digest: index.get(file.key)?.digest ?? null }))
725
+ const machineDigest = digestOfJson(machine)
726
+ const eligibilityRevision = String(eligibility.revision())
727
+ // The history's end is read once for a run of stopped views, however many views one change stops; a check that
728
+ // passes ends the run, so a change that stops a later view is read again.
729
+ let selectionNoted = false
730
+ const noteSelectionOnce = () => { if (!selectionNoted) { selectionNoted = true; rules.noteSelectionAtGuard(project) } }
731
+ const assertBindings = () => {
732
+ let changed
733
+ try {
734
+ changed = bindings.some(file => (readFileFacts(file.absolute)?.digest ?? null) !== file.digest)
735
+ || digestOfJson(readMachineSettings({ workspaceRoot, workspaceId })) !== machineDigest
736
+ || String(eligibility.revision()) !== eligibilityRevision
737
+ } catch (error) {
738
+ // A file that cannot be read now is not known to hold what this tick observed. The view is refused as one
739
+ // whose settings changed, and the next tick reads everything again: what an unreadable configuration file
740
+ // means is decided there, by observation and the project loader, as before.
741
+ if (!rules.guardReadFailed(error)) throw error
742
+ forceFull = true
743
+ // The file may be a selection record that cannot be read now (no permission, an I/O error, a file replaced while
744
+ // it was read): the history's end is read for the note.
745
+ noteSelectionOnce()
746
+ refuse('mixed-read', 'a file that decides source selection could not be read again while a view was being prepared or published', { cause: error.code })
747
+ }
748
+ if (changed) {
749
+ forceFull = true
750
+ // The change may be a confirmation in a personal home. The guard compares files and reads no history, so the
751
+ // history's end is read here for the note: a record that stopped this view counts from now on, also when it
752
+ // is gone again before the next tick looks.
753
+ noteSelectionOnce()
754
+ refuse('mixed-read', 'source selection or eligibility changed while a view was being prepared or published')
755
+ }
756
+ selectionNoted = false
757
+ }
491
758
  let built = null
759
+ // Whether a selected source of a prepared view moved since this tick observed it, decided as observation
760
+ // decides (reconcile): a source that still has the stat hint it was indexed with is taken as unchanged, and one
761
+ // whose hint moved is read and must still be the pinned bytes. Bytes that drift under an unchanged hint stay
762
+ // within observation's own bound, as they do for the graph and for a reused note: the next full reconciliation
763
+ // sees them. It waits between sources, and the settings that selected the view are checked at each wait.
764
+ const assertSourcesUnmoved = async (prepared) => {
765
+ const startedMs = phaseTime()
766
+ try {
767
+ const budget = createCooperativeBudget({ guard: assertBindings })
768
+ budget.check()
769
+ const roots = new Map((project.repos ?? []).filter((repo) => !repo.external && typeof repo.path === 'string').map((repo) => [repo.name, repo.path]))
770
+ const selected = new Set(prepared.manifest.notes.map((note) => note.nodeId))
771
+ const attachments = new Set(prepared.manifest.attachments.map((item) => {
772
+ const source = item.ext?.[OBSIDIAN_EXT_KEY]
773
+ return source?.kind === 'embedded-asset' ? sourceKey(source.repoId, source.assetPath) : null
774
+ }))
775
+ const nodes = built.snapshot.graph.nodes.filter((node) => selected.has(node.id))
776
+ const assets = (built.snapshot.graph.assets ?? []).filter((asset) => attachments.has(sourceKey(asset.repo, asset.path)))
777
+ const pins = new Map(built.snapshot.document.repositories.flatMap((repo) => repo.files.map((file) => [sourceKey(repo.repoId, file.path), file])))
778
+ for (const source of [...nodes, ...assets]) {
779
+ await budget.checkpoint('commit-source-recheck')
780
+ const key = sourceKey(source.repo, source.path)
781
+ const root = roots.get(source.repo)
782
+ if (root !== undefined && hasIndexedHint(index.get(key), path.join(root, ...source.path.split('/')), lstat)) continue
783
+ const bytes = built.snapshot.readSource(source.repo, source.path)
784
+ const pin = pins.get(key)
785
+ if (!pin || !Buffer.isBuffer(bytes) || bytes.length !== pin.byteLength || sha256Digest(bytes) !== pin.rawDigest) refuse('mixed-read', 'a selected source changed while its view was being prepared or published; its generation is not committed')
786
+ }
787
+ budget.check()
788
+ } finally { reportPhase('commit-source-recheck', startedMs) }
789
+ }
492
790
  try {
493
- const graph = seams.buildGraph({ project, eligibility, cache: graphCache, index })
791
+ if (personalRefusal !== null) refuse(personalRefusal, 'the personal workspace refused this generation; nothing is prepared from it', { source: 'personal-workspace' })
792
+ // A personal workspace whose validity key is not confirmed yet is composed off the event loop, then built again.
793
+ const build = () => seams.buildGraph({ project, eligibility, cache: graphCache, index, deferPersonalValidation: true })
794
+ let graph
795
+ try {
796
+ // Any other project is built with the event loop given a turn between the Markdown sources of the census: a
797
+ // first tick reads every source. The settings that select the views are checked at each wait, as during a
798
+ // preparation, and the look again at the sources after the build sees a source that moved during it.
799
+ if (typeof seams.buildGraphCooperatively === 'function' && personalWorkspaceBindingOf(project) === null) {
800
+ graph = await seams.buildGraphCooperatively({ project, eligibility, cache: graphCache, index, scheduling: { guard: assertBindings } })
801
+ } else try { graph = build() } catch (error) {
802
+ if (error?.code !== PERSONAL_VALIDATION_PENDING) throw error
803
+ const verdict = await validatePersonalWorkspace(project)
804
+ if (verdict?.ok !== true) refuse(verdict?.code ?? 'personal-binding-unrecognized', 'the personal workspace refused this generation; nothing is prepared from it', { source: 'personal-workspace' })
805
+ try { graph = build() } catch (again) {
806
+ if (again?.code !== PERSONAL_VALIDATION_PENDING) throw again
807
+ refuse('personal-graph-unconfirmed', 'the graph built for the personal workspace is not the one its composition built; nothing is prepared from it', { source: 'personal-workspace' })
808
+ }
809
+ }
810
+ } catch (error) {
811
+ if (!isTypedRefusal(error) && rules.sourceRemovedSinceObserved(error, { project, index })) refuse('mixed-read', 'a source was removed while the canonical graph was being built', { cause: error.code })
812
+ throw error
813
+ }
494
814
  // The assets this graph lets a view copy are observed from now on, and hashed now so the snapshot pins
495
815
  // what observation saw. A withheld asset is not observed: its bytes can change no view.
496
816
  const formerAssetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
497
817
  observedAssets = rules.observedAssetsOf(graph)
498
818
  const assetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
499
- const settled = reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full: false, lstat })
819
+ const settled = measured('graph-source-recheck', () => reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full: false, lstat }))
500
820
  // An asset newly observed, or one no longer embedded, is the list changing. Anything else that moved since this tick looked at the sources moved while the graph was being read.
501
821
  if (settled.changes.some((change) => !(change.kind === 'added' && assetKeys.has(change.key)) && !(change.kind === 'removed' && formerAssetKeys.has(change.key) && !assetKeys.has(change.key)))) throw new ObsidianMaintenanceRefusal('mixed-read', 'a source changed while the canonical graph was being built')
502
822
  const configDigest = digestOfJson([...index].filter(([key]) => key.startsWith(CONFIG_PREFIX)).map(([key, entry]) => [path.basename(key.slice(CONFIG_PREFIX.length)), entry.digest]))
823
+ const snapshot = seams.captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt: now })
824
+ const observedProject = project
503
825
  built = {
504
- snapshot: seams.captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt: now }),
826
+ // A view's preparation, and the look again at its sources before the commit, read a source through the
827
+ // snapshot. A selected source removed after this tick observed it, read there, refuses the view as
828
+ // `mixed-read`, as a removal during the build does; the other views of the tick are still prepared.
829
+ snapshot: { ...snapshot, readSource: (repoId, relative) => {
830
+ try { return snapshot.readSource(repoId, relative) } catch (error) {
831
+ if (!isTypedRefusal(error) && rules.sourceRemovedSinceObserved(error, { project: observedProject, index })) refuse('mixed-read', 'a selected source was removed while its view was being prepared or published', { cause: error.code })
832
+ throw error
833
+ }
834
+ } },
505
835
  profile: seams.profileFor({ project, workspaceId, audienceAllow: machine.audienceAllow }),
506
836
  }
507
837
  } catch (error) {
508
838
  if (!isTypedRefusal(error)) throw error
509
- for (const scopeId of attempt.keys()) entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now))
839
+ for (const scopeId of attempt.keys()) { entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now)); noteRefusalCause(scopeId, error) }
510
840
  if (REREAD_CODES.has(error.code)) forceFull = true
841
+ // The personal workspace refused the bound generation: the project is loaded again at the next tick, so a
842
+ // loader that now binds another generation is followed.
843
+ if (error.detail?.source === 'personal-workspace') project = null
511
844
  }
512
845
  for (const { scope, store } of built ? scopes.filter((item) => attempt.has(item.scope.scopeId)) : []) {
513
846
  const { scopeId } = scope
@@ -520,11 +853,17 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
520
853
  }
521
854
  try {
522
855
  const held = heldPaths(edits, scopeId)
523
- const prepared = seams.prepareView({
856
+ const prepared = await (seams.prepareViewCooperatively ?? seams.prepareView)({
524
857
  snapshot: built.snapshot, profile: built.profile, scope, persistentPathRegistry: stateStore.readPathRegistry(), priorManifest: store.readCurrentManifest(),
525
858
  existingSettings: null, clock, vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8'), cache: preparationCacheFor(scopeId),
526
859
  heldNotePaths: held, layoutHeldNotePaths: layoutHeldOf(scopeId), viewScopeIds: scopes.map((item) => item.scope.scopeId),
860
+ // The cooperative preparation would read every selected source once more before it returns. The engine
861
+ // checks them itself, below and again before the commit, without opening a source observation would not.
862
+ scheduling: { guard: assertBindings, recheckSources: false },
527
863
  })
864
+ assertBindings()
865
+ // Before anything is written: a source that moved while the view was being prepared refuses it here.
866
+ await assertSourcesUnmoved(prepared)
528
867
  stateStore.writePathRegistry(prepared.persistentPathRegistry)
529
868
  diagnostics = (prepared.manifest.ext?.[OBSIDIAN_EXT_KEY]?.diagnostics ?? []).slice(0, 100)
530
869
  const preparedGenerationId = prepared.manifest.generationId
@@ -543,13 +882,32 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
543
882
  // that cannot be qualified never makes a current view stale. Any other publication builds the adapter first,
544
883
  // and an app that does not qualify keeps the publisher from being reached at all.
545
884
  const lazy = trusted()?.generationId === preparedGenerationId ? builtOnFirstUse(() => adapterFactory({ store, scope })) : null
546
- const adapter = lazy ?? await adapterFactory({ store, scope })
885
+ // A view never published into its allocated folder is not kept from its first publication by an app that
886
+ // does not qualify, while that app's list shows no entry for the folder: the publisher is reached with an
887
+ // adapter that coordinates with nothing, and may take its `direct-unheld` path. Any other outcome is the
888
+ // factory's refusal, as before.
889
+ let heldBackBy = null
890
+ let adapter = lazy
891
+ if (adapter === null) {
892
+ try { adapter = await adapterFactory({ store, scope }) } catch (error) {
893
+ if (!isTypedRefusal(error) || !(await unheldMayApply(store, trusted()))) throw error
894
+ heldBackBy = error
895
+ adapter = uncoordinatedAdapter(error)
896
+ }
897
+ }
547
898
  let result
548
899
  try {
900
+ // Once more while the publisher holds its locks, before it says the publication settled.
901
+ const beforeCommit = () => assertSourcesUnmoved(prepared)
549
902
  result = await seams.publishView({
550
903
  preparedView: prepared, protocolId: PROTOCOL_ID, expectedGeneration: trusted()?.generationId ?? null, recoveryStore: store, adapter, clock,
551
904
  ...(quietPeriodMs === undefined ? {} : { quietPeriodMs }),
905
+ ...(typeof readUnheldEvidence === 'function' ? { unheldEvidence: readUnheldEvidence, platform } : {}),
906
+ scheduling: { guard: assertBindings }, beforeCommit,
552
907
  })
908
+ // Stopped before its commit for want of an app that coordinates: the app's refusal is what the person has to
909
+ // act on, and `open` answers it before it would add the vault to the app.
910
+ if (heldBackBy !== null && (result.state === 'refused' || (result.state === 'updating' && result.notes.some((note) => note.blocking && note.outcome === 'editor-uncoordinated')))) throw heldBackBy
553
911
  } catch (error) {
554
912
  // The publisher asked for the app only to publish the committed generation again (a plugin file drifted),
555
913
  // and the app did not qualify: nothing was written, and the committed generation is read back as it is.
@@ -563,15 +921,24 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
563
921
  const common = { generationId: pointerNow?.generationId ?? null, preparedGenerationId, retainedEdits: pointerNow?.retained?.length ?? 0 }
564
922
  if (result.state === 'refused') {
565
923
  settle(CONFLICT_REFUSALS.has(result.refusal.code) ? 'publisher-conflict' : 'stale', result.refusal.code, { ...common, heldNotes: held })
924
+ noteRefusalCause(scopeId, result.refusal)
566
925
  } else if (result.state === 'updating') {
567
926
  const blocking = result.notes.filter((note) => note.blocking)
568
927
  const byEdit = blocking.length > 0 && blocking.every((note) => EDIT_OUTCOMES.has(note.outcome))
569
928
  settle(byEdit ? 'held-for-your-edit' : 'publisher-conflict', blocking[0]?.outcome ?? 'publication-incomplete', { ...common, heldNotes: [...new Set([...held, ...blocking.filter((note) => EDIT_OUTCOMES.has(note.outcome)).map((note) => note.path)])].sort(compareText) })
570
929
  } else {
571
- // Read back: every note of the committed generation, by digest.
572
- const { manifest, bases } = trustedNoteBases(store)
930
+ // Read back: every note of the committed generation, by digest. The walk waits between notes, so a large
931
+ // view does not hold the service's listener for its whole length. Nothing is checked at those waits: the
932
+ // generation is committed, the view is reported as it was read, and what changes from here on is the next
933
+ // tick's to see, as it is after a read-back that ran through.
934
+ const readBackStartedMs = phaseTime()
935
+ let bases
936
+ try {
937
+ const committed = trustedNoteBases(store)
938
+ bases = committed.bases
939
+ await reconcileCooperatively({ index, files: listVaultNotes({ scopeId, vaultRoot: store.vaultRoot, manifest: committed.manifest }), prefix: vaultKey(scopeId, ''), full: true, lstat }, createCooperativeBudget())
940
+ } finally { reportPhase('publication-read-back', readBackStartedMs) }
573
941
  basesOf.set(scopeId, bases)
574
- reconcile({ index, files: listVaultNotes({ scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scopeId, ''), full: true, lstat })
575
942
  const differing = [...bases].filter(([notePath, base]) => index.get(vaultKey(scopeId, notePath))?.digest !== base.digest).map(([notePath]) => notePath).sort(compareText)
576
943
  const editedUnderPublisher = result.notes.some((note) => note.outcome === 'edit-kept' || note.changedAfterPublication === true)
577
944
  if (held.length > 0) settle('held-for-your-edit', 'edit-pending', { ...common, heldNotes: held })
@@ -579,7 +946,8 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
579
946
  // Somebody wrote during the publication. The next tick preserves and queues it.
580
947
  else if (differing.length > 0 || editedUnderPublisher) settle('updating', 'read-back-differs', common)
581
948
  else if (common.generationId !== preparedGenerationId) settle('stale', 'committed-generation-differs', common)
582
- else settle('current', result.alreadyCommitted ? 'verified-by-read-back' : 'published-and-verified', { ...common, heldNotes: [], verified: true })
949
+ // Kept current while a plugin file waits for the app: the reason says so, and status shows it on the plugin line.
950
+ else settle('current', result.waitsForApp === true ? PLUGIN_FILES_WAIT_FOR_APP : result.alreadyCommitted ? 'verified-by-read-back' : 'published-and-verified', { ...common, heldNotes: [], verified: true })
583
951
  }
584
952
  } catch (error) {
585
953
  if (!isTypedRefusal(error)) { settle('stale', 'publisher-error'); persist(); throw error }
@@ -587,6 +955,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
587
955
  const where = error.code === 'redaction-failure' ? error.detail ?? {} : {}
588
956
  diagnostics = typeof where.rule === 'string' ? [{ code: error.code, rule: where.rule, ...(typeof where.notePath === 'string' ? { notePath: where.notePath } : {}), ...(typeof where.filePath === 'string' ? { filePath: where.filePath } : {}) }] : []
589
957
  settle('stale', error.code)
958
+ noteRefusalCause(scopeId, error)
590
959
  if (REREAD_CODES.has(error.code)) forceFull = true
591
960
  }
592
961
  }
@@ -605,7 +974,7 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
605
974
  state: 'ticked', full, workspaceId, maintenanceMode: machine.maintenanceMode,
606
975
  changes: changes.map((change) => ({ ...change })), scopes: [...entries.values()].sort((left, right) => compareText(left.scopeId, right.scopeId)),
607
976
  pendingEdits: edits.filter((edit) => edit.closedAt === null).map(({ editId, scopeId, path: notePath, state }) => ({ editId, scopeId, path: notePath, state })),
608
- dispatched, lateWriters, ...(observed === null ? {} : { observed }), ...(proposals === null ? {} : { proposals }),
977
+ dispatched, lateWriters, ...(observed === null ? {} : { observed }), ...(proposals === null ? {} : { proposals }), ...(refusalCauses.length === 0 ? {} : { refusalCauses }),
609
978
  }
610
979
  }
611
980
 
@@ -627,7 +996,19 @@ export function createMaintenanceEngineForOracleTests(options = {}, primitives =
627
996
  try {
628
997
  return await runTick()
629
998
  } catch (error) {
630
- if (!isTypedRefusal(error)) throw error
999
+ if (!isTypedRefusal(error)) {
1000
+ // A tick that failed without a typed reason may have looked at files and recorded what it saw in the index
1001
+ // without acting on it: a note gone from a vault, a source that changed, or a configuration file that changed
1002
+ // before the project could be loaded again, is a change once, and the next look would find nothing to change.
1003
+ // The next tick therefore starts as the first tick of a process does: it loads the project again and looks
1004
+ // at every view, so whatever is missing or changed makes its view stale, or is published, not left current.
1005
+ if (rules.rechecksEveryViewAfterFailure) {
1006
+ project = null
1007
+ semantic = null
1008
+ firstTick = true
1009
+ }
1010
+ throw error
1011
+ }
631
1012
  // Fail closed: nothing is published under a refusal, and every known view says why.
632
1013
  try { await persistOutcome({ enablement: 'refused', state: 'stale', reason: error.code, now: isoTime(clock) }) } catch { /* the refusal itself is still reported */ }
633
1014
  project = null