@mnstry/atelier 0.2.0-alpha.5 → 0.2.0-alpha.8

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 (383) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +86 -25
  3. package/SECURITY.md +6 -1
  4. package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
  5. package/contracts/atelier-coauthor.v1.schema.json +163 -0
  6. package/contracts/atelier-decision-request.v1.schema.json +476 -0
  7. package/contracts/atelier-decision-result.v1.schema.json +500 -0
  8. package/contracts/atelier-guide.v1.schema.json +227 -0
  9. package/contracts/atelier-intake.v1.schema.json +151 -0
  10. package/contracts/atelier-migration.v2.schema.json +61 -0
  11. package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
  12. package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
  13. package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
  14. package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
  15. package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
  16. package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
  17. package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
  18. package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
  19. package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
  20. package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
  21. package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
  22. package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
  23. package/contracts/atelier-presentation.v1.schema.json +1293 -0
  24. package/contracts/atelier-repository-observation.v1.schema.json +163 -0
  25. package/contracts/atelier-review.v1.schema.json +1601 -0
  26. package/contracts/atelier-skill-steward.v1.schema.json +692 -0
  27. package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
  28. package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
  29. package/contracts/atelier-vault-publication.v1.schema.json +58 -0
  30. package/docs/assurance-controls.md +12 -2
  31. package/docs/atelier-runtime.md +13 -2
  32. package/docs/atelier-sync.md +172 -0
  33. package/docs/blocks/claims.md +28 -18
  34. package/docs/blocks/will-not-do.md +12 -3
  35. package/docs/coauthor-session.md +57 -0
  36. package/docs/connected-composition.md +75 -0
  37. package/docs/decisions.md +167 -0
  38. package/docs/exact-upgrades.md +159 -0
  39. package/docs/extension-pack-lifecycle.md +33 -0
  40. package/docs/guided-upgrades.md +99 -0
  41. package/docs/install.md +65 -9
  42. package/docs/intake-and-guides.md +65 -0
  43. package/docs/integration-and-review-plan.md +409 -0
  44. package/docs/integration-contract-decisions.md +22 -0
  45. package/docs/local-review.md +84 -0
  46. package/docs/local-services.md +227 -0
  47. package/docs/local-state.md +107 -0
  48. package/docs/obsidian-contract.md +687 -0
  49. package/docs/obsidian.md +387 -0
  50. package/docs/portable-presentation.md +328 -0
  51. package/docs/presentation-consumer-boundaries.md +75 -0
  52. package/docs/presentation-review-disposition.md +47 -0
  53. package/docs/project-command-inventory.md +38 -0
  54. package/docs/project-options.md +53 -0
  55. package/docs/release-engineering.md +99 -2
  56. package/docs/review-portability.md +55 -0
  57. package/docs/skill-steward.md +156 -0
  58. package/docs/upgrade.md +70 -2
  59. package/docs/vault-service.md +292 -0
  60. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  61. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  62. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  63. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  64. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  65. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  66. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  67. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  68. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  69. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  70. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  71. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  72. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  73. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  74. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  75. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  76. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  77. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  78. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  79. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  80. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  81. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  82. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  83. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  84. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  85. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  86. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  87. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  88. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  89. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  90. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  91. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  92. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  93. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  94. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  95. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  96. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  97. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  98. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  99. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  100. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  101. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  102. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  103. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  104. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  105. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  106. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  107. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  108. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  109. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  110. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  111. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  112. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  113. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  114. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  115. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  116. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  117. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  118. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  119. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  120. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  121. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  122. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  123. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  124. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  125. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  126. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  127. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  128. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  129. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  130. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  131. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  132. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  133. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  134. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  135. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  136. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  137. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  138. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  139. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  140. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  141. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  142. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  143. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  144. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  145. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  146. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  147. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  148. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  149. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  150. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  151. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  152. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  153. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  154. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  155. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  156. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  157. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  158. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  159. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  160. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  161. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  162. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  163. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  164. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  165. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  166. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  167. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  168. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  169. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  170. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  171. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  172. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  173. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  174. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  175. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  176. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  177. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  178. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  179. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  180. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  181. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  182. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  183. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  184. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  185. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  186. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  187. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  188. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  189. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  190. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  191. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  192. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  193. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  194. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  195. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  196. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  197. package/fixtures/obsidian/edits/workspace.json +65 -0
  198. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  199. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  200. package/fixtures/obsidian/materialization/workspace.json +54 -0
  201. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  202. package/fixtures/ui/presentation/pane.v1.json +7 -0
  203. package/fixtures/ui/presentation/reference.v1.json +293 -0
  204. package/package.json +61 -6
  205. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  206. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  207. package/skills/claude/atelier-open/SKILL.md +5 -0
  208. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  209. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  210. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  211. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  212. package/skills/codex/atelier-open/SKILL.md +5 -0
  213. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  214. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  215. package/src/access/preview-enforcer.mjs +84 -0
  216. package/src/boundary/content-rules.mjs +9 -4
  217. package/src/boundary/policy.mjs +74 -42
  218. package/src/cli/project-options.mjs +58 -0
  219. package/src/cli/run.mjs +61 -3
  220. package/src/coauthor/session.mjs +161 -0
  221. package/src/coauthor/store.mjs +173 -0
  222. package/src/collaboration/event-ledger.mjs +16 -33
  223. package/src/collaboration/inspection-bundle.mjs +223 -0
  224. package/src/collaboration/review-contracts.mjs +25 -0
  225. package/src/collaboration/review-store.mjs +305 -0
  226. package/src/commands/coauthor.mjs +26 -0
  227. package/src/commands/config.mjs +6 -1
  228. package/src/commands/extension-pack.mjs +15 -8
  229. package/src/commands/init.mjs +44 -5
  230. package/src/commands/obsidian.mjs +368 -0
  231. package/src/commands/review.mjs +147 -0
  232. package/src/commands/setup.mjs +51 -14
  233. package/src/commands/skills.mjs +199 -0
  234. package/src/commands/sync.mjs +107 -0
  235. package/src/composition/registry.mjs +27 -0
  236. package/src/composition/wire.mjs +122 -0
  237. package/src/contracts/corpus.mjs +35 -0
  238. package/src/decisions/contracts.d.mts +96 -0
  239. package/src/decisions/contracts.mjs +321 -0
  240. package/src/disclosure/content-scan.mjs +13 -0
  241. package/src/export/atelier-export-contract.mjs +16 -1
  242. package/src/extension-packs/lifecycle.mjs +154 -0
  243. package/src/graph/graph.mjs +64 -2
  244. package/src/graph/knowledge-graph.mjs +548 -24
  245. package/src/guides/contracts.mjs +54 -0
  246. package/src/index.mjs +29 -0
  247. package/src/intake/store.mjs +119 -0
  248. package/src/preview/channel.mjs +89 -0
  249. package/src/project/config.mjs +50 -49
  250. package/src/project/durable-state.mjs +161 -0
  251. package/src/project/file-class.mjs +166 -0
  252. package/src/project/private-state.mjs +11 -1
  253. package/src/projection/obsidian/contracts.mjs +571 -0
  254. package/src/projection/obsidian/edits/align.mjs +169 -0
  255. package/src/projection/obsidian/edits/apply.mjs +889 -0
  256. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  257. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  258. package/src/projection/obsidian/edits/index.mjs +19 -0
  259. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  260. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  261. package/src/projection/obsidian/edits/observe.mjs +123 -0
  262. package/src/projection/obsidian/edits/policy.mjs +123 -0
  263. package/src/projection/obsidian/edits/regions.mjs +648 -0
  264. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  265. package/src/projection/obsidian/materialize/index.mjs +7 -0
  266. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  267. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  268. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  269. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  270. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  271. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  272. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  273. package/src/projection/obsidian/proposals/index.mjs +23 -0
  274. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  275. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  276. package/src/projection/obsidian/proposals/router.mjs +168 -0
  277. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  278. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  279. package/src/projection/obsidian/publication/index.mjs +5 -0
  280. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  281. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  282. package/src/projection/obsidian/publication/transport.mjs +139 -0
  283. package/src/projection/obsidian/recovery/index.mjs +6 -0
  284. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  285. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  286. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  287. package/src/projection/obsidian/recovery/store.mjs +234 -0
  288. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  289. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  290. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  291. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  292. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  293. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  294. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  295. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  296. package/src/projection/project.mjs +28 -19
  297. package/src/readiness-protocols/evidence.mjs +282 -0
  298. package/src/readiness-protocols/runtime.mjs +4 -3
  299. package/src/readiness-protocols/source-read.mjs +39 -0
  300. package/src/runtime/git-adapter.mjs +189 -0
  301. package/src/runtime/local-state.mjs +439 -0
  302. package/src/runtime/obsidian/app-capability.mjs +110 -0
  303. package/src/runtime/obsidian/app-production-seams.mjs +96 -0
  304. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  305. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  306. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  307. package/src/runtime/obsidian/contributions.mjs +27 -0
  308. package/src/runtime/obsidian/documents.mjs +37 -0
  309. package/src/runtime/obsidian/enablement.mjs +53 -0
  310. package/src/runtime/obsidian/engine.mjs +532 -0
  311. package/src/runtime/obsidian/errors.mjs +14 -0
  312. package/src/runtime/obsidian/extension-points.mjs +103 -0
  313. package/src/runtime/obsidian/index.mjs +34 -0
  314. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  315. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  316. package/src/runtime/obsidian/observation.mjs +158 -0
  317. package/src/runtime/obsidian/opening.mjs +243 -0
  318. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  319. package/src/runtime/obsidian/pipeline.mjs +122 -0
  320. package/src/runtime/obsidian/private-lock.mjs +156 -0
  321. package/src/runtime/obsidian/process-identity.mjs +58 -0
  322. package/src/runtime/obsidian/service-client.mjs +66 -0
  323. package/src/runtime/obsidian/service-main.mjs +89 -0
  324. package/src/runtime/obsidian/service-record.mjs +169 -0
  325. package/src/runtime/obsidian/service-server.mjs +121 -0
  326. package/src/runtime/obsidian/service.mjs +216 -0
  327. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  328. package/src/runtime/obsidian/state-store.mjs +180 -0
  329. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  330. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  331. package/src/runtime/obsidian/watchers.mjs +36 -0
  332. package/src/runtime/repository-observation.mjs +491 -0
  333. package/src/runtime/supervisor.mjs +803 -0
  334. package/src/server/local-sidecar.mjs +44 -0
  335. package/src/server/server.mjs +1 -0
  336. package/src/skills/steward.mjs +780 -0
  337. package/src/ui/presentation/browser.mjs +278 -0
  338. package/src/ui/presentation/contract.mjs +116 -0
  339. package/src/ui/presentation/index.mjs +7 -0
  340. package/src/ui/presentation/native.mjs +153 -0
  341. package/src/ui/presentation/proof.mjs +57 -0
  342. package/src/ui/presentation/schema-check.mjs +30 -0
  343. package/src/ui/presentation/schema.generated.mjs +1371 -0
  344. package/src/ui/presentation/state.mjs +43 -0
  345. package/src/ui/presentation/styles.mjs +67 -0
  346. package/src/ui/presentation/tokens.mjs +74 -0
  347. package/src/ui/presentation/web.mjs +85 -0
  348. package/src/ui/review-page.mjs +32 -0
  349. package/src/upgrade/explanation.mjs +33 -0
  350. package/src/upgrade/provenance.mjs +338 -0
  351. package/src/upgrade/transaction-files.mjs +103 -0
  352. package/src/upgrade/transaction.mjs +475 -0
  353. package/src/upgrade/upgrade.mjs +63 -28
  354. package/src/vault/hosts.mjs +21 -0
  355. package/src/vault/identity.mjs +19 -0
  356. package/src/vault/index.mjs +7 -0
  357. package/src/vault/interface.mjs +11 -0
  358. package/src/vault/metadata.mjs +31 -0
  359. package/src/vault/privacy.mjs +148 -0
  360. package/src/vault/probe.mjs +89 -0
  361. package/src/vault/service.mjs +131 -0
  362. package/src/vault/source.mjs +45 -0
  363. package/src/vault/storage.mjs +23 -0
  364. package/templates/distribution-workspace/.claude/launch.json +6 -2
  365. package/templates/distribution-workspace/gitignore +1 -0
  366. package/templates/distribution-workspace/project/README.md +2 -2
  367. package/templates/external-project-workspace/README.md +45 -0
  368. package/templates/external-project-workspace/answers.example.json +4 -0
  369. package/templates/external-project-workspace/atelier.project.json +40 -0
  370. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  371. package/templates/external-project-workspace/gitignore +8 -0
  372. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  373. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  374. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  375. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  376. package/templates/external-project-workspace/source/README.md +11 -0
  377. package/templates/launch.json +6 -2
  378. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  379. package/templates/private-domain-workspace/domain/README.md +2 -2
  380. package/templates/private-domain-workspace/gitignore +1 -0
  381. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  382. package/templates/shared-project-workspace/gitignore +1 -0
  383. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,27 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
+ import { refuse } from './errors.mjs'
5
+
6
+ // Where later work plugs in without touching a shared file: every module in
7
+ // `contributions/` beside this file is a contribution. Its default export is
8
+ // `{ id, register({ extensions, operations }) }`. The command and the service
9
+ // entry both load the directory, so an apply operation registered there is
10
+ // the one the service dispatches to and the one `status` reports.
11
+ //
12
+ // Only regular `.mjs` files directly in that directory are loaded, in name
13
+ // order. The directory ships with the package; nothing outside it is read.
14
+
15
+ export const CONTRIBUTIONS_DIRECTORY = path.join(path.dirname(fileURLToPath(import.meta.url)), 'contributions')
16
+
17
+ export async function loadContributions({ directory = CONTRIBUTIONS_DIRECTORY } = {}) {
18
+ let entries
19
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }) } catch (error) { if (error.code === 'ENOENT') return []; throw error }
20
+ const contributions = []
21
+ for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith('.mjs')).sort((left, right) => (left.name < right.name ? -1 : 1))) {
22
+ const loaded = await import(pathToFileURL(path.join(directory, entry.name)).href)
23
+ if (loaded.default === null || typeof loaded.default !== 'object' || typeof loaded.default.register !== 'function') refuse('invalid-extension', 'a contribution module must export { id, register } by default', { file: entry.name })
24
+ contributions.push(loaded.default)
25
+ }
26
+ return contributions
27
+ }
@@ -0,0 +1,37 @@
1
+ import { refuse } from './errors.mjs'
2
+
3
+ // Helpers shared by this module's private documents. Each document is
4
+ // versioned by its `schema` string, closed (an unknown key refuses) and
5
+ // written in one canonical form, so equal state is equal bytes.
6
+
7
+ export const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
8
+
9
+ function sortKeys(value) {
10
+ if (Array.isArray(value)) return value.map(sortKeys)
11
+ if (!isPlainObject(value)) return value
12
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortKeys(value[key])]))
13
+ }
14
+
15
+ // Keys sorted at every depth, two-space indentation, one final newline.
16
+ export function canonicalJson(value) {
17
+ return `${JSON.stringify(sortKeys(value), null, 2)}\n`
18
+ }
19
+
20
+ export function closedObject(value, { required = [], optional = [] }, code, label) {
21
+ if (!isPlainObject(value)) refuse(code, `${label} must be an object`)
22
+ const allowed = new Set([...required, ...optional])
23
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key))
24
+ if (unknown.length > 0) refuse(code, `${label} carries an unknown key`, { keys: unknown.sort() })
25
+ const missing = required.filter((key) => !Object.hasOwn(value, key))
26
+ if (missing.length > 0) refuse(code, `${label} lacks a required key`, { keys: missing })
27
+ return value
28
+ }
29
+
30
+ export const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0)
31
+
32
+ export function isoTime(clock) {
33
+ const value = clock()
34
+ const date = value instanceof Date ? value : new Date(value)
35
+ if (Number.isNaN(date.getTime())) refuse('missing-clock', 'the injected clock did not return a time')
36
+ return date.toISOString()
37
+ }
@@ -0,0 +1,53 @@
1
+ import { projectExtMember } from '../../project/config.mjs'
2
+ import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, assertObsidianContract } from '../../projection/obsidian/contracts.mjs'
3
+ import { refuse } from './errors.mjs'
4
+
5
+ // Typed enablement. The project v1 schema is unchanged: a project without the
6
+ // ext member is valid and means "not configured", which is disabled. A member
7
+ // that is present must satisfy the closed ext-settings contract exactly. An
8
+ // unknown key or value refuses here; it is never ignored and never read as
9
+ // permission, so a newer or mistyped setting cannot switch anything on.
10
+
11
+ export const ENABLEMENT_STATES = Object.freeze(['disabled', 'enabled'])
12
+ export const DISABLED_REASONS = Object.freeze(['not-configured', 'disabled-in-settings'])
13
+
14
+ const SCOPE_SCHEMA = 'atelier-obsidian-scope/v1'
15
+
16
+ function scopeDocument({ scopeId, mode, selector, expansion, ext }) {
17
+ return {
18
+ schema: SCOPE_SCHEMA,
19
+ scopeId,
20
+ mode,
21
+ selector,
22
+ ...(expansion === undefined ? {} : { expansion }),
23
+ ...(ext === undefined ? {} : { ext }),
24
+ }
25
+ }
26
+
27
+ function assertOrRefuse(shape, document, code, message) {
28
+ try {
29
+ return assertObsidianContract(shape, document)
30
+ } catch (error) {
31
+ if (error instanceof ObsidianContractRefusal) refuse(code, message, { errors: error.detail?.errors ?? [] })
32
+ throw error
33
+ }
34
+ }
35
+
36
+ // Returns a frozen { state, reason, settings, scopes, defaultScopeId }.
37
+ // `scopes` are complete scope contract documents, in declared order, and are
38
+ // listed even when the integration is disabled so a caller can name them.
39
+ export function readObsidianEnablement(project) {
40
+ const member = projectExtMember(project, OBSIDIAN_EXT_KEY)
41
+ if (!member.present) {
42
+ return Object.freeze({ state: 'disabled', reason: 'not-configured', settings: null, scopes: Object.freeze([]), defaultScopeId: null })
43
+ }
44
+ const settings = assertOrRefuse('ext-settings', member.value, 'invalid-ext-settings', 'the project Obsidian settings do not satisfy their contract; nothing is enabled')
45
+ const scopes = settings.scopes.map((scope) => assertOrRefuse('scope', scopeDocument(scope), 'invalid-ext-settings', 'a declared scope does not satisfy the scope contract; nothing is enabled'))
46
+ return Object.freeze({
47
+ state: settings.enabled === true ? 'enabled' : 'disabled',
48
+ reason: settings.enabled === true ? 'enabled' : 'disabled-in-settings',
49
+ settings,
50
+ scopes: Object.freeze(scopes),
51
+ defaultScopeId: settings.defaultScopeId ?? null,
52
+ })
53
+ }
@@ -0,0 +1,532 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { AtelierDiagnosticError } from '../../project/config.mjs'
4
+ import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../../projection/obsidian/contracts.mjs'
5
+ import { PROTOCOL_ID } from '../../projection/obsidian/publication/bridge-script.mjs'
6
+ import { PublicationRefusal } from '../../projection/obsidian/recovery/store.mjs'
7
+ import { canonicalJson, compareText, isoTime } from './documents.mjs'
8
+ import { readObsidianEnablement } from './enablement.mjs'
9
+ import { ObsidianMaintenanceRefusal } from './errors.mjs'
10
+ import { createMaintenanceExtensions } from './extension-points.mjs'
11
+ import {
12
+ assertOutsideRepositories, authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, protectedRoots, readLocalPointer,
13
+ readMachineSettings, resolveDataRoot, workspaceStateRoot, writeMachineSettings,
14
+ } from './machine-settings.mjs'
15
+ import { configKey, listConfigFiles, listSourceFiles, listVaultNotes, readFileFacts, reconcile, sha256Digest, sourceKey, vaultKey } from './observation.mjs'
16
+ import { dispatchAutomaticApply, heldPaths, observeVaultEdits, preserveInRecoveryStore, trustedNoteBases } from './pending-edits.mjs'
17
+ import { DEFAULT_ELIGIBILITY, createProductionSeams } from './pipeline.mjs'
18
+ import { ENGINE_LOCK_DIRECTORY, acquirePrivateGenerationLock, createAbandonmentProof } from './private-lock.mjs'
19
+ import { probeHealth } from './service-client.mjs'
20
+ import { FRESHNESS_SCHEMA, LATE_WRITERS_SCHEMA, createMaintenanceStateStore } from './state-store.mjs'
21
+ import { createNullWatcherFactory } from './watchers.mjs'
22
+
23
+ // The maintenance engine. One explicit `tick()`; no timer, no process, no
24
+ // listener. Whoever owns the lifecycle calls it.
25
+ //
26
+ // A tick, in order:
27
+ //
28
+ // 1. load the project when its configuration changed; read typed enablement
29
+ // 2. look at every vault note; preserve and queue what somebody edited
30
+ // 3. when a proposal adapter is registered: it observes the open edits the
31
+ // object store does not know yet and records what each is, in manual and
32
+ // in automatic mode alike, writing no source; automatic mode only:
33
+ // dispatch queued edits through the apply operation; then hand the
34
+ // pending edits to the proposal adapter
35
+ // 4. look at displaced files again for writes that arrived late
36
+ // 5. look at sources, configuration, scopes and eligibility; decide by digest
37
+ // 6. for each invalidated view: canonical graph -> source snapshot ->
38
+ // prepareView -> publishView, unless that would replace a held note
39
+ // 7. persist the freshness of every view
40
+ //
41
+ // From the moment a workspace is resolved until the tick ends, the tick holds
42
+ // the workspace's private engine lock, so two engines (a service and a
43
+ // foreground command, or two services) never tick one workspace together. An
44
+ // engine that finds the lock held writes nothing and reports `busy`.
45
+ //
46
+ // The engine writes private state and recovery objects. It writes into a
47
+ // vault only by calling publishView, never touches `staging/` or a journal's
48
+ // recovery directory, and never writes a source file: in manual mode nothing
49
+ // on a tick can, and in automatic mode only the registered apply operation.
50
+
51
+ export const DEFAULT_FULL_RECONCILIATION_INTERVAL_MS = 5 * 60 * 1000
52
+ export const DEFAULT_RETRY_INTERVAL_MS = 60 * 1000
53
+ export const DEFAULT_LATE_WRITER_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
54
+ export const LATE_WRITER_EVERY_TICK_WINDOW_MS = 60 * 60 * 1000
55
+
56
+ // A refusal that means "someone else is publishing or editing here" rather
57
+ // than "this machine cannot publish".
58
+ const CONFLICT_REFUSALS = new Set(['publication-in-progress', 'generation-mismatch', 'editor-uncoordinated', 'state-mismatch'])
59
+ const EDIT_OUTCOMES = new Set(['disk-changed', 'editor-edit'])
60
+ const RETRIED_STATES = new Set(['stale', 'updating', 'publisher-conflict'])
61
+ // A file changed or went away under a tick: everything is hashed again on the next one.
62
+ const REREAD_CODES = new Set(['mixed-read', 'source-not-in-snapshot'])
63
+ const SOURCE_PREFIX = 'source\u0000'
64
+ const CONFIG_PREFIX = configKey('')
65
+
66
+ // The decisions the oracles in test/obsidian-maintenance.test.mjs are
67
+ // sensitive to. Production always uses these; the tests substitute
68
+ // deliberately broken ones through createMaintenanceEngineForOracleTests to
69
+ // prove that each oracle can fail.
70
+ export const ENGINE_PRIMITIVES = Object.freeze({
71
+ // Edited bytes reach the recovery object store before a record is queued.
72
+ preserve: preserveInRecoveryStore,
73
+ // Read from disk on every call, immediately before each dispatch.
74
+ authorize: authorizeAutomaticApply,
75
+ // Only automatic mode ever reaches the apply operation.
76
+ dispatchAllowed: (maintenanceMode) => maintenanceMode === 'automatic',
77
+ // Every file is hashed, whatever its stat says, at least this often.
78
+ isFullReconciliationDue: ({ nowMs, lastFullMs, intervalMs }) => lastFullMs === null || nowMs - lastFullMs >= intervalMs,
79
+ // The embedded assets observed beside the walk: those the last built graph lets a view copy, and no withheld one.
80
+ observedAssetsOf: (graph) => (graph.assets ?? []).filter((asset) => asset.eligible === true).map(({ repo, path: assetPath }) => ({ repo, path: assetPath })),
81
+ // One engine per workspace at a time, across processes.
82
+ acquireEngineLock: acquirePrivateGenerationLock,
83
+ // The registered proposal adapter observes the open edits on every tick, so a structural edit is recorded as
84
+ // proposed and routed without anybody asking apply to look at it.
85
+ observeEdits: (proposalAdapter) => typeof proposalAdapter.observe === 'function',
86
+ // Held notes that this prepared view would replace, create over or remove. A held note that already holds exactly
87
+ // the bytes this view would publish is none of those: its edit reached the source, the source was prepared again,
88
+ // and publishing changes nothing of the person's. The publisher reads the note again and settles it as already
89
+ // current only while it still holds those bytes; the next look then finds the note at its base and closes the edit.
90
+ // `observed` holds what `heldNoteDigest` answered for each held note immediately before this call.
91
+ publicationConflicts({ prepared, held, bases, observed = new Map() }) {
92
+ const candidates = new Map(prepared.files.map((file) => [file.path, file.digest]))
93
+ return held.filter((notePath) => {
94
+ if (candidates.has(notePath) && observed.get(notePath) === candidates.get(notePath)) return false
95
+ return bases.has(notePath) ? candidates.get(notePath) !== bases.get(notePath).digest : candidates.has(notePath)
96
+ })
97
+ },
98
+ // What a held note holds at the moment of that decision: one read of the file, now. The observation index is not
99
+ // asked (`indexed` is its answer, for the mutation control): its entry was taken earlier in the tick, behind a stat
100
+ // comparison, and the person may have typed since. A note that cannot be read answers null, and the hold stays.
101
+ heldNoteDigest({ file }) {
102
+ try { return readFileFacts(file)?.digest ?? null } catch { return null }
103
+ },
104
+ })
105
+
106
+ const isTypedRefusal = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof AtelierDiagnosticError || error instanceof ObsidianContractRefusal || error instanceof PublicationRefusal
107
+ const digestOfJson = (value) => sha256Digest(Buffer.from(canonicalJson(value)))
108
+
109
+ function journalTime(journalId) {
110
+ const match = /^journal-(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{3})-/.exec(journalId)
111
+ return match ? Date.UTC(+match[1], +match[2] - 1, +match[3], +match[4], +match[5], +match[6], +match[7]) : null
112
+ }
113
+
114
+ // Unfinished journals always; finished ones while they are recent. A journal
115
+ // whose time cannot be read is treated as recent.
116
+ function journalsToRecheck(store, { nowMs, full, windowMs }) {
117
+ let names
118
+ try { names = fs.readdirSync(store.journalsRoot).sort() } catch (error) { if (error.code === 'ENOENT') return []; throw error }
119
+ return names.filter((name) => {
120
+ if (!fs.existsSync(path.join(store.journalsRoot, name, 'header.json'))) return false
121
+ if (!fs.existsSync(path.join(store.journalsRoot, name, 'closed.json'))) return true
122
+ const at = journalTime(name)
123
+ return at === null || nowMs - at <= (full ? windowMs : Math.min(windowMs, LATE_WRITER_EVERY_TICK_WINDOW_MS))
124
+ })
125
+ }
126
+
127
+ export function createMaintenanceEngineForOracleTests(options = {}, primitives = ENGINE_PRIMITIVES) {
128
+ const {
129
+ loadProject, dataRoot, adapterFactory, clock,
130
+ watcherFactory = createNullWatcherFactory(), extensions = createMaintenanceExtensions(), eligibility = DEFAULT_ELIGIBILITY,
131
+ fullReconciliationIntervalMs = DEFAULT_FULL_RECONCILIATION_INTERVAL_MS, retryIntervalMs = DEFAULT_RETRY_INTERVAL_MS, lateWriterWindowMs = DEFAULT_LATE_WRITER_WINDOW_MS,
132
+ quietPeriodMs, lstat = fs.lstatSync, randomBytes, env = process.env, platform = process.platform,
133
+ // A service names where it answers health, so a lock it leaves behind can be proven abandoned.
134
+ lockOwner = null, lockProbe = probeHealth,
135
+ } = options
136
+ if (typeof loadProject !== 'function') throw new TypeError('the engine needs loadProject')
137
+ if (typeof clock !== 'function') throw new TypeError('the engine needs an injected clock')
138
+ // No default: the production adapter reaches a running app, and only the owner of the lifecycle may decide that.
139
+ if (typeof adapterFactory !== 'function') throw new TypeError('the engine needs an adapterFactory')
140
+ const seams = { ...createProductionSeams(), ...(options.seams ?? {}) }
141
+ const rules = { ...ENGINE_PRIMITIVES, ...primitives }
142
+
143
+ const index = new Map()
144
+ const hintedKeys = new Set()
145
+ const hintedPrefixes = new Set()
146
+ const stores = new Map()
147
+ // One graph file cache, and one preparation cache per scope, kept for the engine's lifetime. Derived state only:
148
+ // each is reused under a content digest or dependency key that proves the cached value equal to a fresh one, and a
149
+ // dropped cache costs a full build or preparation, never a wrong one.
150
+ const graphCache = seams.createGraphCache?.() ?? null
151
+ const preparationCaches = new Map()
152
+ const preparationCacheFor = (scopeId) => {
153
+ if (!preparationCaches.has(scopeId)) preparationCaches.set(scopeId, seams.createPreparationCache?.() ?? null)
154
+ return preparationCaches.get(scopeId)
155
+ }
156
+ let project = null
157
+ let observedAssets = []
158
+ let semantic = null
159
+ let lastFullMs = null
160
+ let forceFull = false
161
+ let firstTick = true
162
+ let running = false
163
+ let watching = { signature: null, handle: null }
164
+ let known = null // { stateStore, maintenanceMode } once a workspace has been resolved
165
+ let heldLock = null
166
+ const proveAbandoned = createAbandonmentProof({ probe: lockProbe })
167
+
168
+ async function takeLock(workspaceRoot, workspaceId) {
169
+ if (heldLock) return { acquired: true }
170
+ const lock = await rules.acquireEngineLock({
171
+ workspaceRoot, directory: path.join(workspaceRoot, ENGINE_LOCK_DIRECTORY), workspaceId, purpose: 'maintenance-engine', service: lockOwner, clock, proveAbandoned,
172
+ })
173
+ if (lock.acquired) heldLock = lock
174
+ return lock
175
+ }
176
+
177
+ function dropLock() {
178
+ const lock = heldLock
179
+ heldLock = null
180
+ lock?.release()
181
+ }
182
+
183
+ function onWatcherEvent(roots, { rootId, relative }) {
184
+ const root = roots.find((item) => item.id === rootId)
185
+ if (!root) return
186
+ if (relative === null || relative === undefined) hintedPrefixes.add(root.keyPrefix)
187
+ else hintedKeys.add(root.keyOf(relative))
188
+ }
189
+
190
+ function watch(roots) {
191
+ const signature = JSON.stringify(roots.map((root) => [root.id, root.path]))
192
+ if (watching.signature === signature) return
193
+ stopWatching()
194
+ watching = { signature, handle: watcherFactory({ roots: roots.map(({ id, path: rootPath, recursive }) => ({ id, path: rootPath, recursive })), onEvent: (event) => onWatcherEvent(roots, event) }) }
195
+ }
196
+
197
+ function stopWatching() {
198
+ try { watching.handle?.close() } finally { watching = { signature: null, handle: null } }
199
+ }
200
+
201
+ function storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) {
202
+ const signature = JSON.stringify([workspaceRoot, workspaceId, repositoryRoots])
203
+ const cached = stores.get(scope.scopeId)
204
+ if (cached?.signature === signature) return cached.store
205
+ const store = seams.createRecoveryStore({ workspaceRoot, workspaceId, scopeId: scope.scopeId, repositoryRoots })
206
+ stores.set(scope.scopeId, { signature, store })
207
+ return store
208
+ }
209
+
210
+ function freshnessDocument({ workspaceId, enablement, maintenanceMode, now, entries }) {
211
+ return {
212
+ schema: FRESHNESS_SCHEMA, workspaceId, enablement, maintenanceMode, lastTickAt: now,
213
+ lastFullReconciliationAt: lastFullMs === null ? null : new Date(lastFullMs).toISOString(), scopes: [...entries.values()],
214
+ }
215
+ }
216
+
217
+ function readPreviousFreshness(stateStore) {
218
+ // Derived state: a document that does not validate is rebuilt, not trusted.
219
+ try { return stateStore.readFreshness() } catch (error) { if (isTypedRefusal(error)) return null; throw error }
220
+ }
221
+
222
+ const blankEntry = (scopeId, now) => ({ scopeId, state: 'stale', reason: 'not-yet-published', generationId: null, preparedGenerationId: null, verified: false, heldNotes: [], changeClasses: [], retainedEdits: 0, checkedAt: now })
223
+ const demote = (entry, state, reason, now) => ({ ...entry, state, reason, verified: false, checkedAt: now })
224
+
225
+ // Persists "refused" or "disabled" over whatever is known, and only when a workspace already has state.
226
+ async function persistOutcome({ enablement, state, reason, now, scopeIds = [] }) {
227
+ if (!known) {
228
+ let pointer = null
229
+ try { pointer = project ? readLocalPointer(project) : null } catch { pointer = null }
230
+ if (!pointer) return
231
+ let root
232
+ try { root = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), pointer.workspaceId) } catch { return }
233
+ const stateStore = createMaintenanceStateStore({ workspaceRoot: root, workspaceId: pointer.workspaceId })
234
+ if (!stateStore.exists()) return
235
+ known = { stateStore: createMaintenanceStateStore({ workspaceRoot: fs.realpathSync(root), workspaceId: pointer.workspaceId }), maintenanceMode: 'manual' }
236
+ }
237
+ // Another engine is ticking this workspace: its state is not ours to write.
238
+ if (!(await takeLock(known.stateStore.workspaceRoot, known.stateStore.workspaceId)).acquired) return
239
+ const previous = readPreviousFreshness(known.stateStore)
240
+ const entries = new Map()
241
+ for (const entry of previous?.scopes ?? []) entries.set(entry.scopeId, demote(entry, state, reason, now))
242
+ for (const scopeId of scopeIds) if (!entries.has(scopeId)) entries.set(scopeId, demote(blankEntry(scopeId, now), state, reason, now))
243
+ known.stateStore.writeFreshness(freshnessDocument({ workspaceId: known.stateStore.workspaceId, enablement, maintenanceMode: known.maintenanceMode, now, entries }))
244
+ }
245
+
246
+ async function runTick() {
247
+ const now = isoTime(clock)
248
+ const nowMs = Date.parse(now)
249
+ const full = firstTick || forceFull || rules.isFullReconciliationDue({ nowMs, lastFullMs, intervalMs: fullReconciliationIntervalMs })
250
+ const changes = []
251
+ // The hints collected so far belong to this tick; one that arrives while it runs belongs to the next.
252
+ const tickKeys = new Set(hintedKeys)
253
+ const tickPrefixes = [...hintedPrefixes]
254
+ hintedKeys.clear()
255
+ hintedPrefixes.clear()
256
+ const hinted = (key) => tickKeys.has(key) || tickPrefixes.some((prefix) => key.startsWith(prefix))
257
+
258
+ // 1. Project and enablement. The digests are seeded before the project is
259
+ // loaded for good, so the loaded project is never older than the index.
260
+ if (project === null) {
261
+ project = loadProject()
262
+ reconcile({ index, files: listConfigFiles(project), prefix: CONFIG_PREFIX, full: true, lstat })
263
+ project = loadProject()
264
+ } else {
265
+ const config = reconcile({ index, files: listConfigFiles(project), prefix: CONFIG_PREFIX, full, hinted, lstat })
266
+ if (config.changes.length > 0) {
267
+ project = loadProject()
268
+ reconcile({ index, files: listConfigFiles(project), prefix: CONFIG_PREFIX, full: true, lstat })
269
+ }
270
+ }
271
+ const enablement = readObsidianEnablement(project)
272
+ if (enablement.state === 'disabled') {
273
+ stopWatching()
274
+ await persistOutcome({ enablement: 'disabled', state: 'disabled', reason: enablement.reason, now, scopeIds: enablement.scopes.map((scope) => scope.scopeId) })
275
+ semantic = null
276
+ firstTick = true
277
+ return { state: 'disabled', reason: enablement.reason, full, changes: [], scopes: [], pendingEdits: [], dispatched: [], lateWriters: [] }
278
+ }
279
+
280
+ // 2. Identity and machine settings, outside every repository.
281
+ const pointer = ensureWorkspaceIdentity({ project, ...(randomBytes ? { randomBytes } : {}) })
282
+ const { workspaceId } = pointer
283
+ const requestedRoot = workspaceStateRoot(resolveDataRoot({ dataRoot, pointer, project, env, platform }), workspaceId)
284
+ const repositoryRoots = protectedRoots(project)
285
+ assertOutsideRepositories({ managedRoot: requestedRoot, repositoryRoots })
286
+ fs.mkdirSync(requestedRoot, { recursive: true, mode: 0o700 })
287
+ const workspaceRoot = fs.realpathSync(requestedRoot)
288
+ const lock = await takeLock(workspaceRoot, workspaceId)
289
+ if (!lock.acquired) return { state: 'busy', reason: 'engine-lock-held', lock: { reason: lock.reason, holder: lock.holder ?? null }, changes: [], scopes: [], pendingEdits: [], dispatched: [], lateWriters: [] }
290
+ const machine = readMachineSettings({ workspaceRoot, workspaceId })
291
+ ?? writeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, settings: defaultMachineSettings({ workspaceId, updatedAt: now }) })
292
+ const stateStore = createMaintenanceStateStore({ workspaceRoot, workspaceId })
293
+ known = { stateStore, maintenanceMode: machine.maintenanceMode }
294
+ const scopes = enablement.scopes.map((scope) => ({ scope, store: storeFor(scope, workspaceRoot, workspaceId, repositoryRoots) }))
295
+
296
+ watch([
297
+ ...(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) })),
298
+ ...scopes.map(({ scope, store }) => ({ id: `vault:${scope.scopeId}`, path: store.vaultRoot, recursive: true, keyPrefix: vaultKey(scope.scopeId, ''), keyOf: (relative) => vaultKey(scope.scopeId, relative) })),
299
+ ...[...new Set(listConfigFiles(project).map((file) => path.dirname(file.absolute)))].filter((directory) => fs.existsSync(directory))
300
+ .map((directory) => ({ id: `config:${directory}`, path: directory, recursive: false, keyPrefix: CONFIG_PREFIX, keyOf: (relative) => configKey(path.join(directory, relative)) })),
301
+ ])
302
+
303
+ // 3. Vault notes: preserve, then queue. Compared with the bytes each note
304
+ // was generated with, not with the previous look.
305
+ const pending = stateStore.readPendingEdits()
306
+ let edits = pending.edits
307
+ const basesOf = new Map()
308
+ for (const { scope, store } of scopes) {
309
+ const { manifest, bases } = trustedNoteBases(store)
310
+ basesOf.set(scope.scopeId, bases)
311
+ if (!manifest) continue
312
+ const vault = reconcile({ index, files: listVaultNotes({ scopeId: scope.scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scope.scopeId, ''), full, hinted, lstat })
313
+ const digestOf = (notePath) => index.get(vaultKey(scope.scopeId, notePath))?.digest ?? null
314
+ const observed = observeVaultEdits({ store, workspaceId, scopeId: scope.scopeId, manifest, bases, digestOf, edits, now, preserve: rules.preserve })
315
+ edits = observed.edits
316
+ // A note that has gone is restored by the publisher; its disappearance is a change, once.
317
+ if (observed.events.length > 0 || vault.changes.some((change) => change.kind === 'removed')) changes.push({ changeClass: 'vault-note', scopeId: scope.scopeId })
318
+ }
319
+ stateStore.writePendingEdits({ ...pending, edits })
320
+
321
+ // Observation. The registered proposal adapter is handed a copy of the pending edits and observes the open ones
322
+ // the object store does not know yet, a bounded number per tick: each is classified from its preserved bytes
323
+ // against the source as it is now and recorded as what it is, in manual and in automatic mode alike, and no
324
+ // source is written. What refused before anything was recorded is offered again on a full reconciliation only.
325
+ const proposalAdapter = extensions.get('proposal-adapter')
326
+ const adapterContext = () => ({ project, workspaceRoot, workspaceId, repositoryRoots, edits: structuredClone(edits), clock, env })
327
+ let observed = null
328
+ if (proposalAdapter !== null && rules.observeEdits(proposalAdapter)) {
329
+ try { observed = await proposalAdapter.observe(adapterContext(), { retryRefused: full }) } catch { observed = { adapterId: proposalAdapter.id, failed: 'proposal-adapter-threw' } }
330
+ }
331
+
332
+ // 4. Automatic apply, through the registered operation only.
333
+ let dispatched = []
334
+ if (rules.dispatchAllowed(machine.maintenanceMode)) {
335
+ const outcome = await dispatchAutomaticApply({
336
+ edits, applyOperation: extensions.applyOperation(), now, nowMs, retryIntervalMs,
337
+ authorize: () => rules.authorize({ workspaceRoot, workspaceId }),
338
+ })
339
+ edits = outcome.edits
340
+ dispatched = outcome.dispatched
341
+ stateStore.writePendingEdits({ ...pending, edits })
342
+ }
343
+
344
+ // Structural edits. The registered proposal adapter is handed the pending edits once per tick and decides by its
345
+ // own record which of them it has not settled; it creates copy-only proposals and writes no source and no vault.
346
+ // Without one registered nothing is handed anywhere, and an adapter that throws changes nothing else of the tick.
347
+ let proposals = null
348
+ if (proposalAdapter !== null) {
349
+ try { proposals = await proposalAdapter.propose(adapterContext()) } catch { proposals = { adapterId: proposalAdapter.id, failed: 'proposal-adapter-threw' } }
350
+ }
351
+
352
+ // 5. Late writers. The publisher looks twice per publication; a holder can write later still.
353
+ const lateWriters = []
354
+ const lateDocument = stateStore.readLateWriters()
355
+ const knownFindings = new Set(lateDocument.findings.map((item) => `${item.scopeId}\u0000${item.journalId}\u0000${item.unit}\u0000${item.observedDigest}`))
356
+ for (const { scope, store } of scopes) {
357
+ const journalIds = journalsToRecheck(store, { nowMs, full, windowMs: lateWriterWindowMs })
358
+ if (journalIds.length === 0) continue
359
+ for (const finding of seams.recheckDisplacedFiles({ store, journalIds, clock })) {
360
+ if (finding.code !== 'late-writer-captured') continue
361
+ const key = `${scope.scopeId}\u0000${finding.journalId}\u0000${finding.unit}\u0000${finding.observedDigest}`
362
+ if (knownFindings.has(key)) continue
363
+ knownFindings.add(key)
364
+ 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 })
365
+ }
366
+ }
367
+ if (lateWriters.length > 0) {
368
+ 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))
369
+ stateStore.writeLateWriters({ schema: LATE_WRITERS_SCHEMA, workspaceId, findings })
370
+ }
371
+
372
+ // 6. Sources, settings, scopes, eligibility.
373
+ const sources = reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full, hinted, lstat })
374
+ for (const change of sources.changes) changes.push({ changeClass: change.changeClass })
375
+ const { scopes: _declared, ...settingsWithoutScopes } = enablement.settings
376
+ // What the configuration says apart from this integration's own member: a change to the member alone is an
377
+ // ext-settings or a scope change, and a scope change invalidates that scope only.
378
+ const { [OBSIDIAN_EXT_KEY]: _member, ...otherExt } = project.config.ext ?? {}
379
+ const nextSemantic = {
380
+ config: digestOfJson({ document: { ...project.config, ext: otherExt }, files: [...index].filter(([key]) => key.startsWith(CONFIG_PREFIX) && key !== configKey(project.configPath)).map(([key, entry]) => [key.slice(CONFIG_PREFIX.length), entry.digest]) }),
381
+ settings: digestOfJson(settingsWithoutScopes),
382
+ scopes: new Map(enablement.scopes.map((scope) => [scope.scopeId, digestOfJson(scope)])),
383
+ eligibility: digestOfJson({ audienceAllow: [...machine.audienceAllow].sort(), revision: String(eligibility.revision()) }),
384
+ }
385
+ if (semantic) {
386
+ if (semantic.config !== nextSemantic.config) changes.push({ changeClass: 'config' })
387
+ if (semantic.settings !== nextSemantic.settings) changes.push({ changeClass: 'ext-settings' })
388
+ if (semantic.eligibility !== nextSemantic.eligibility) changes.push({ changeClass: 'eligibility' })
389
+ for (const [scopeId, digest] of nextSemantic.scopes) if (semantic.scopes.get(scopeId) !== digest) changes.push({ changeClass: 'scope', scopeId })
390
+ }
391
+ if (full) { lastFullMs = nowMs; forceFull = false }
392
+
393
+ // 7. Which views are invalid.
394
+ const previous = readPreviousFreshness(stateStore)
395
+ const entries = new Map()
396
+ const unseen = new Set()
397
+ for (const { scope } of scopes) {
398
+ const carried = previous?.scopes.find((entry) => entry.scopeId === scope.scopeId && entry.state !== 'disabled')
399
+ if (!carried) unseen.add(scope.scopeId)
400
+ entries.set(scope.scopeId, carried ?? blankEntry(scope.scopeId, now))
401
+ }
402
+ const attempt = new Map()
403
+ const invalidate = (scopeId, changeClass) => attempt.set(scopeId, new Set([...(attempt.get(scopeId) ?? []), ...(changeClass ? [changeClass] : [])]))
404
+ for (const { scope } of scopes) {
405
+ const entry = entries.get(scope.scopeId)
406
+ // A view that did not settle is tried again at the full reconciliation cadence, not on every tick.
407
+ if (firstTick || unseen.has(scope.scopeId) || (full && RETRIED_STATES.has(entry.state))) invalidate(scope.scopeId, null)
408
+ }
409
+ for (const change of changes) for (const { scope } of scopes) if (change.scopeId === undefined || change.scopeId === scope.scopeId) invalidate(scope.scopeId, change.changeClass)
410
+
411
+ // Invalidation is durable before any work: a view is not reported current while it is being rebuilt.
412
+ for (const [scopeId, classes] of attempt) entries.set(scopeId, { ...demote(entries.get(scopeId), 'stale', 'invalidated', now), changeClasses: [...classes].sort() })
413
+ for (const entry of previous?.scopes ?? []) if (!entries.has(entry.scopeId)) entries.set(entry.scopeId, demote(entry, 'disabled', 'scope-not-configured', now))
414
+ const persist = () => stateStore.writeFreshness(freshnessDocument({ workspaceId, enablement: 'enabled', maintenanceMode: machine.maintenanceMode, now, entries }))
415
+ persist()
416
+
417
+ // 8. Rebuild and publish.
418
+ if (attempt.size > 0) {
419
+ let built = null
420
+ try {
421
+ const graph = seams.buildGraph({ project, eligibility, cache: graphCache, index })
422
+ // The assets this graph lets a view copy are observed from now on, and hashed now so the snapshot pins
423
+ // what observation saw. A withheld asset is not observed: its bytes can change no view.
424
+ const formerAssetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
425
+ observedAssets = rules.observedAssetsOf(graph)
426
+ const assetKeys = new Set(observedAssets.map((asset) => sourceKey(asset.repo, asset.path)))
427
+ const settled = reconcile({ index, files: listSourceFiles(project, { assets: observedAssets }), prefix: SOURCE_PREFIX, full: false, lstat })
428
+ // 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.
429
+ 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')
430
+ const configDigest = digestOfJson([...index].filter(([key]) => key.startsWith(CONFIG_PREFIX)).map(([key, entry]) => [path.basename(key.slice(CONFIG_PREFIX.length)), entry.digest]))
431
+ built = {
432
+ snapshot: seams.captureSnapshot({ project, graph, workspaceId, index, configDigest, capturedAt: now }),
433
+ profile: seams.profileFor({ project, workspaceId, audienceAllow: machine.audienceAllow }),
434
+ }
435
+ } catch (error) {
436
+ if (!isTypedRefusal(error)) throw error
437
+ for (const scopeId of attempt.keys()) entries.set(scopeId, demote(entries.get(scopeId), 'stale', error.code, now))
438
+ if (REREAD_CODES.has(error.code)) forceFull = true
439
+ }
440
+ for (const { scope, store } of built ? scopes.filter((item) => attempt.has(item.scope.scopeId)) : []) {
441
+ const { scopeId } = scope
442
+ const entry = entries.get(scopeId)
443
+ const settle = (state, reason, extra = {}) => entries.set(scopeId, { ...entry, ...extra, state, reason, verified: extra.verified === true, checkedAt: now })
444
+ try {
445
+ const prepared = seams.prepareView({
446
+ snapshot: built.snapshot, profile: built.profile, scope, persistentPathRegistry: stateStore.readPathRegistry(), priorManifest: store.readCurrentManifest(),
447
+ existingSettings: null, clock, vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8'), cache: preparationCacheFor(scopeId),
448
+ })
449
+ stateStore.writePathRegistry(prepared.persistentPathRegistry)
450
+ const preparedGenerationId = prepared.manifest.generationId
451
+ const trusted = () => store.readCurrent()
452
+ const held = heldPaths(edits, scopeId)
453
+ const observed = new Map(held.map((notePath) => [notePath, rules.heldNoteDigest({ file: path.join(store.vaultRoot, notePath), indexed: index.get(vaultKey(scopeId, notePath))?.digest ?? null })]))
454
+ const conflicts = rules.publicationConflicts({ prepared, held, bases: basesOf.get(scopeId) ?? new Map(), observed })
455
+ if (conflicts.length > 0) {
456
+ // Not even attempted: the prepared view would replace a note somebody edited.
457
+ settle('held-for-your-edit', 'publication-withheld-for-your-edit', { generationId: trusted()?.generationId ?? null, preparedGenerationId, heldNotes: held })
458
+ continue
459
+ }
460
+ settle('updating', 'publishing', { generationId: trusted()?.generationId ?? null, preparedGenerationId, heldNotes: held })
461
+ persist()
462
+ const result = await seams.publishView({
463
+ preparedView: prepared, protocolId: PROTOCOL_ID, expectedGeneration: trusted()?.generationId ?? null, recoveryStore: store, adapter: adapterFactory({ store, scope }), clock,
464
+ ...(quietPeriodMs === undefined ? {} : { quietPeriodMs }),
465
+ })
466
+ const pointerNow = trusted()
467
+ const common = { generationId: pointerNow?.generationId ?? null, preparedGenerationId, retainedEdits: pointerNow?.retained?.length ?? 0 }
468
+ if (result.state === 'refused') {
469
+ settle(CONFLICT_REFUSALS.has(result.refusal.code) ? 'publisher-conflict' : 'stale', result.refusal.code, { ...common, heldNotes: held })
470
+ } else if (result.state === 'updating') {
471
+ const blocking = result.notes.filter((note) => note.blocking)
472
+ const byEdit = blocking.length > 0 && blocking.every((note) => EDIT_OUTCOMES.has(note.outcome))
473
+ 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) })
474
+ } else {
475
+ // Read back: every note of the committed generation, by digest.
476
+ const { manifest, bases } = trustedNoteBases(store)
477
+ basesOf.set(scopeId, bases)
478
+ reconcile({ index, files: listVaultNotes({ scopeId, vaultRoot: store.vaultRoot, manifest }), prefix: vaultKey(scopeId, ''), full: true, lstat })
479
+ const differing = [...bases].filter(([notePath, base]) => index.get(vaultKey(scopeId, notePath))?.digest !== base.digest).map(([notePath]) => notePath).sort(compareText)
480
+ const editedUnderPublisher = result.notes.some((note) => note.outcome === 'edit-kept' || note.changedAfterPublication === true)
481
+ if (held.length > 0) settle('held-for-your-edit', 'edit-pending', { ...common, heldNotes: held })
482
+ else if (differing.some((notePath) => (index.get(vaultKey(scopeId, notePath))?.digest ?? null) === null)) settle('stale', 'vault-note-missing', common)
483
+ // Somebody wrote during the publication. The next tick preserves and queues it.
484
+ else if (differing.length > 0 || editedUnderPublisher) settle('updating', 'read-back-differs', common)
485
+ else if (common.generationId !== preparedGenerationId) settle('stale', 'committed-generation-differs', common)
486
+ else settle('current', result.alreadyCommitted ? 'verified-by-read-back' : 'published-and-verified', { ...common, heldNotes: [], verified: true })
487
+ }
488
+ } catch (error) {
489
+ if (!isTypedRefusal(error)) { settle('stale', 'publisher-error'); persist(); throw error }
490
+ settle('stale', error.code)
491
+ if (REREAD_CODES.has(error.code)) forceFull = true
492
+ }
493
+ }
494
+ }
495
+ persist()
496
+ semantic = nextSemantic
497
+ firstTick = false
498
+ return {
499
+ state: 'ticked', full, workspaceId, maintenanceMode: machine.maintenanceMode,
500
+ changes: changes.map((change) => ({ ...change })), scopes: [...entries.values()].sort((left, right) => compareText(left.scopeId, right.scopeId)),
501
+ pendingEdits: edits.filter((edit) => edit.closedAt === null).map(({ editId, scopeId, path: notePath, state }) => ({ editId, scopeId, path: notePath, state })),
502
+ dispatched, lateWriters, ...(observed === null ? {} : { observed }), ...(proposals === null ? {} : { proposals }),
503
+ }
504
+ }
505
+
506
+ return {
507
+ extensions,
508
+ async tick() {
509
+ if (running) return { state: 'busy' }
510
+ running = true
511
+ try {
512
+ return await runTick()
513
+ } catch (error) {
514
+ if (!isTypedRefusal(error)) throw error
515
+ // Fail closed: nothing is published under a refusal, and every known view says why.
516
+ try { await persistOutcome({ enablement: 'refused', state: 'stale', reason: error.code, now: isoTime(clock) }) } catch { /* the refusal itself is still reported */ }
517
+ project = null
518
+ semantic = null
519
+ firstTick = true
520
+ return { state: 'refused', refusal: { code: error.code, message: error.message, detail: error.detail ?? {} }, changes: [], scopes: [], pendingEdits: [], dispatched: [], lateWriters: [] }
521
+ } finally {
522
+ try { dropLock() } finally { running = false }
523
+ }
524
+ },
525
+ stop() { stopWatching() },
526
+ }
527
+ }
528
+
529
+ // Production entry point: the production primitives, always.
530
+ export function createMaintenanceEngine(options = {}) {
531
+ return createMaintenanceEngineForOracleTests(options, ENGINE_PRIMITIVES)
532
+ }