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

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 (414) hide show
  1. package/CHANGELOG.md +413 -0
  2. package/README.md +402 -205
  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/contracts/public-api-baseline.json +57 -0
  31. package/docs/assurance-controls.md +49 -0
  32. package/docs/atelier-runtime.md +28 -2
  33. package/docs/atelier-sync.md +172 -0
  34. package/docs/blocks/audience-visibility.md +9 -0
  35. package/docs/blocks/claims.md +61 -0
  36. package/docs/blocks/conformance-admission.md +15 -0
  37. package/docs/blocks/will-not-do.md +18 -0
  38. package/docs/coauthor-session.md +57 -0
  39. package/docs/connected-composition.md +75 -0
  40. package/docs/continuity.md +3 -4
  41. package/docs/decisions.md +167 -0
  42. package/docs/design.md +167 -0
  43. package/docs/exact-upgrades.md +159 -0
  44. package/docs/extension-pack-lifecycle.md +33 -0
  45. package/docs/guided-upgrades.md +99 -0
  46. package/docs/install.md +99 -16
  47. package/docs/intake-and-guides.md +65 -0
  48. package/docs/integration-and-review-plan.md +409 -0
  49. package/docs/integration-contract-decisions.md +22 -0
  50. package/docs/knowledge-graph.md +8 -4
  51. package/docs/local-review.md +84 -0
  52. package/docs/local-services.md +328 -0
  53. package/docs/local-state.md +107 -0
  54. package/docs/obsidian-contract.md +698 -0
  55. package/docs/obsidian.md +484 -0
  56. package/docs/portable-presentation.md +328 -0
  57. package/docs/presentation-consumer-boundaries.md +75 -0
  58. package/docs/presentation-review-disposition.md +47 -0
  59. package/docs/project-command-inventory.md +38 -0
  60. package/docs/project-options.md +53 -0
  61. package/docs/release-engineering.md +174 -12
  62. package/docs/repo-boundary-guard.md +12 -2
  63. package/docs/review-portability.md +55 -0
  64. package/docs/skill-steward.md +156 -0
  65. package/docs/upgrade.md +155 -5
  66. package/docs/vault-service.md +292 -0
  67. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  68. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  69. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  70. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  71. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  72. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  73. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  74. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  75. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  76. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  77. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  78. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  79. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  80. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  81. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  82. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  83. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  84. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  85. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  86. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  87. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  88. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  89. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  90. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  91. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  92. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  93. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  94. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  95. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  96. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  97. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  98. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  99. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  100. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  101. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  102. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  103. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  104. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  105. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  106. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  107. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  108. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  109. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  110. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  111. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  112. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  113. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  114. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  115. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  116. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  117. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  118. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  119. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  120. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  121. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  122. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  123. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  124. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  125. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  126. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  127. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  128. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  129. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  130. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  131. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  132. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  133. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  134. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  135. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  136. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  137. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  138. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  139. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  140. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  141. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  142. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  143. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  144. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  145. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  146. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  147. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  148. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  149. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  150. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  151. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  152. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  153. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  154. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  155. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  156. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  157. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  158. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  159. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  160. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  161. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  162. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  163. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  164. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  165. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  166. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  167. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  168. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  169. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  170. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  171. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  172. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  173. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  174. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  175. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  176. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  177. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  178. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  179. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  180. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  181. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  182. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  183. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  184. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  185. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  186. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  187. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  188. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  189. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  190. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  191. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  192. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  193. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  194. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  195. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  196. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  197. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  198. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  199. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  200. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  201. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  202. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  203. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  204. package/fixtures/obsidian/edits/workspace.json +65 -0
  205. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  206. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  207. package/fixtures/obsidian/materialization/workspace.json +54 -0
  208. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  209. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  210. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  211. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  212. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  213. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  214. package/fixtures/ui/presentation/pane.v1.json +7 -0
  215. package/fixtures/ui/presentation/reference.v1.json +293 -0
  216. package/package.json +86 -10
  217. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  218. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  219. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  220. package/skills/claude/atelier-open/SKILL.md +11 -5
  221. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  222. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  223. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  224. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  225. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  226. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  227. package/skills/codex/atelier-open/SKILL.md +11 -5
  228. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  229. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  230. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  231. package/src/access/preview-enforcer.mjs +84 -0
  232. package/src/boundary/content-rules.mjs +283 -20
  233. package/src/boundary/policy.mjs +213 -91
  234. package/src/cli/execute-command.mjs +36 -0
  235. package/src/cli/project-options.mjs +58 -0
  236. package/src/cli/run.mjs +78 -10
  237. package/src/coauthor/session.mjs +161 -0
  238. package/src/coauthor/store.mjs +173 -0
  239. package/src/collaboration/event-ledger.mjs +348 -0
  240. package/src/collaboration/index.mjs +17 -0
  241. package/src/collaboration/inspection-bundle.mjs +223 -0
  242. package/src/collaboration/proposals.mjs +265 -65
  243. package/src/collaboration/review-contracts.mjs +25 -0
  244. package/src/collaboration/review-store.mjs +305 -0
  245. package/src/commands/attestation.mjs +20 -6
  246. package/src/commands/coauthor.mjs +26 -0
  247. package/src/commands/config.mjs +6 -1
  248. package/src/commands/disclosure.mjs +133 -0
  249. package/src/commands/distribution.mjs +2 -1
  250. package/src/commands/extension-pack.mjs +17 -9
  251. package/src/commands/init.mjs +46 -6
  252. package/src/commands/obsidian.mjs +371 -0
  253. package/src/commands/review.mjs +147 -0
  254. package/src/commands/server.mjs +1 -4
  255. package/src/commands/setup.mjs +51 -14
  256. package/src/commands/skills.mjs +199 -0
  257. package/src/commands/sync.mjs +107 -0
  258. package/src/composition/registry.mjs +27 -0
  259. package/src/composition/wire.mjs +122 -0
  260. package/src/contracts/corpus.mjs +35 -0
  261. package/src/decisions/contracts.d.mts +96 -0
  262. package/src/decisions/contracts.mjs +321 -0
  263. package/src/disclosure/content-scan.mjs +206 -0
  264. package/src/egress/check.mjs +7 -38
  265. package/src/egress/forbidden-egress.mjs +32 -18
  266. package/src/export/atelier-export-contract.mjs +16 -1
  267. package/src/extension-packs/lifecycle.mjs +154 -0
  268. package/src/graph/graph.mjs +176 -316
  269. package/src/graph/knowledge-graph.mjs +643 -43
  270. package/src/guides/contracts.mjs +54 -0
  271. package/src/harness/context-client.mjs +9 -1
  272. package/src/index.mjs +41 -0
  273. package/src/intake/store.mjs +119 -0
  274. package/src/preview/channel.mjs +89 -0
  275. package/src/project/config.mjs +112 -52
  276. package/src/project/durable-state.mjs +161 -0
  277. package/src/project/file-class.mjs +180 -0
  278. package/src/project/package-root.mjs +10 -0
  279. package/src/project/path-match.mjs +38 -15
  280. package/src/project/private-state.mjs +120 -0
  281. package/src/projection/obsidian/contracts.mjs +571 -0
  282. package/src/projection/obsidian/edits/align.mjs +169 -0
  283. package/src/projection/obsidian/edits/apply.mjs +889 -0
  284. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  285. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  286. package/src/projection/obsidian/edits/index.mjs +19 -0
  287. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  288. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  289. package/src/projection/obsidian/edits/observe.mjs +123 -0
  290. package/src/projection/obsidian/edits/policy.mjs +123 -0
  291. package/src/projection/obsidian/edits/regions.mjs +648 -0
  292. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  293. package/src/projection/obsidian/materialize/index.mjs +7 -0
  294. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  295. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  296. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  297. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  298. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  299. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  300. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  301. package/src/projection/obsidian/proposals/index.mjs +23 -0
  302. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  303. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  304. package/src/projection/obsidian/proposals/router.mjs +168 -0
  305. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  306. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  307. package/src/projection/obsidian/publication/index.mjs +5 -0
  308. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  309. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  310. package/src/projection/obsidian/publication/transport.mjs +212 -0
  311. package/src/projection/obsidian/recovery/index.mjs +6 -0
  312. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  313. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  314. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  315. package/src/projection/obsidian/recovery/store.mjs +234 -0
  316. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  317. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  318. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  319. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  320. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  321. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  322. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  323. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  324. package/src/projection/project.mjs +28 -19
  325. package/src/readiness-protocols/evidence.mjs +282 -0
  326. package/src/readiness-protocols/runtime.mjs +4 -3
  327. package/src/readiness-protocols/source-read.mjs +39 -0
  328. package/src/runtime/git-adapter.mjs +189 -0
  329. package/src/runtime/local-state.mjs +439 -0
  330. package/src/runtime/obsidian/app-capability.mjs +138 -0
  331. package/src/runtime/obsidian/app-production-seams.mjs +107 -0
  332. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  333. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  334. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  335. package/src/runtime/obsidian/contributions.mjs +27 -0
  336. package/src/runtime/obsidian/documents.mjs +37 -0
  337. package/src/runtime/obsidian/enablement.mjs +53 -0
  338. package/src/runtime/obsidian/engine.mjs +532 -0
  339. package/src/runtime/obsidian/errors.mjs +14 -0
  340. package/src/runtime/obsidian/extension-points.mjs +103 -0
  341. package/src/runtime/obsidian/index.mjs +34 -0
  342. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  343. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  344. package/src/runtime/obsidian/observation.mjs +158 -0
  345. package/src/runtime/obsidian/opening.mjs +280 -0
  346. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  347. package/src/runtime/obsidian/pipeline.mjs +122 -0
  348. package/src/runtime/obsidian/private-lock.mjs +156 -0
  349. package/src/runtime/obsidian/process-identity.mjs +58 -0
  350. package/src/runtime/obsidian/service-client.mjs +66 -0
  351. package/src/runtime/obsidian/service-entry-path.mjs +9 -0
  352. package/src/runtime/obsidian/service-main.mjs +93 -0
  353. package/src/runtime/obsidian/service-record.mjs +169 -0
  354. package/src/runtime/obsidian/service-server.mjs +121 -0
  355. package/src/runtime/obsidian/service.mjs +216 -0
  356. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  357. package/src/runtime/obsidian/state-store.mjs +180 -0
  358. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  359. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  360. package/src/runtime/obsidian/watchers.mjs +36 -0
  361. package/src/runtime/repository-observation.mjs +491 -0
  362. package/src/runtime/supervisor.mjs +803 -0
  363. package/src/server/local-sidecar.mjs +125 -59
  364. package/src/server/security.mjs +89 -4
  365. package/src/server/server.mjs +4 -2
  366. package/src/skills/steward.mjs +780 -0
  367. package/src/support/feedback-report.mjs +4 -3
  368. package/src/ui/presentation/browser.mjs +278 -0
  369. package/src/ui/presentation/contract.mjs +116 -0
  370. package/src/ui/presentation/index.mjs +7 -0
  371. package/src/ui/presentation/native.mjs +153 -0
  372. package/src/ui/presentation/proof.mjs +57 -0
  373. package/src/ui/presentation/schema-check.mjs +30 -0
  374. package/src/ui/presentation/schema.generated.mjs +1371 -0
  375. package/src/ui/presentation/state.mjs +43 -0
  376. package/src/ui/presentation/styles.mjs +67 -0
  377. package/src/ui/presentation/tokens.mjs +74 -0
  378. package/src/ui/presentation/web.mjs +85 -0
  379. package/src/ui/review-page.mjs +32 -0
  380. package/src/upgrade/explanation.mjs +33 -0
  381. package/src/upgrade/provenance.mjs +338 -0
  382. package/src/upgrade/transaction-files.mjs +103 -0
  383. package/src/upgrade/transaction.mjs +475 -0
  384. package/src/upgrade/upgrade.mjs +65 -29
  385. package/src/vault/hosts.mjs +21 -0
  386. package/src/vault/identity.mjs +19 -0
  387. package/src/vault/index.mjs +7 -0
  388. package/src/vault/interface.mjs +11 -0
  389. package/src/vault/metadata.mjs +31 -0
  390. package/src/vault/privacy.mjs +148 -0
  391. package/src/vault/probe.mjs +89 -0
  392. package/src/vault/service.mjs +131 -0
  393. package/src/vault/source.mjs +45 -0
  394. package/src/vault/storage.mjs +23 -0
  395. package/templates/distribution-workspace/.claude/launch.json +6 -2
  396. package/templates/distribution-workspace/gitignore +1 -0
  397. package/templates/distribution-workspace/project/README.md +2 -2
  398. package/templates/external-project-workspace/README.md +45 -0
  399. package/templates/external-project-workspace/answers.example.json +4 -0
  400. package/templates/external-project-workspace/atelier.project.json +40 -0
  401. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  402. package/templates/external-project-workspace/gitignore +8 -0
  403. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  404. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  405. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  406. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  407. package/templates/external-project-workspace/source/README.md +11 -0
  408. package/templates/launch.json +6 -2
  409. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  410. package/templates/private-domain-workspace/domain/README.md +2 -2
  411. package/templates/private-domain-workspace/gitignore +1 -0
  412. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  413. package/templates/shared-project-workspace/gitignore +1 -0
  414. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,316 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { acquirePrivateLock, syncPrivateDirectory } from '../../../project/durable-state.mjs'
4
+ import { journalDetail, listJournals } from './journal.mjs'
5
+ import { readFileBytes, readFileDigest, refuse, sha256Digest } from './store.mjs'
6
+
7
+ // Restart recovery. Every decision here is made from what is on disk, never
8
+ // from what a lost reply might have said, and every action is a move, a
9
+ // non-overwriting link, the removal of a file proven to be our own candidate,
10
+ // or an appended journal entry. Running it again changes nothing.
11
+ //
12
+ // Interrupted states of one note, identified by its write-ahead `capture`
13
+ // entry (base, candidate, candidate path, recovery path) with no outcome
14
+ // entry. The candidate path of a replacement is in the unit's recovery
15
+ // directory, so whatever it holds is never in a discardable area:
16
+ //
17
+ // candidate path == candidate digest nothing was exchanged. The file is a
18
+ // generated candidate and is retired;
19
+ // the note is as it was.
20
+ // candidate path, any other bytes the exchange happened and the move to
21
+ // the recovery name did not: the path
22
+ // holds the displaced bytes. They are
23
+ // moved to the recovery name and
24
+ // compared with base.
25
+ // path absent, recovery name present exchange and move both happened.
26
+ // neither nothing happened.
27
+ //
28
+ // The journal names every exchange candidate, path and digest, before the file
29
+ // is at that path (the header for candidates known when the run began, a
30
+ // write-ahead entry for a late one), and only complete files are moved there.
31
+ // classifyCandidateFile is the only place that decides between the two kinds.
32
+ //
33
+ // For the whole journal: a pointer that already names this journal gets its
34
+ // missing `manifest-commit` entry; a journal that reached `verifying` with
35
+ // every note settled commits its stored manifest; anything else is left as
36
+ // `updating` for the next publication to converge, with the digests it did
37
+ // publish still trusted as bases.
38
+
39
+ const exists = (file) => { try { fs.lstatSync(file); return true } catch (error) { if (error.code === 'ENOENT') return false; throw error } }
40
+
41
+ const RETIRING_NAME = 'retiring.bin'
42
+
43
+ // What a file at a candidate path is. `generated-candidate` is claimed only
44
+ // for bytes whose digest the journal recorded for that candidate; those are
45
+ // ours and may be deleted. Everything else, including a file whose candidate
46
+ // digest the journal does not give, is `displaced-bytes`: somebody's content,
47
+ // which is moved or kept and never deleted.
48
+ export function classifyCandidateFile({ file, candidateDigest }) {
49
+ const digest = file ? readFileDigest(file) : null
50
+ if (digest === null) return 'absent'
51
+ return candidateDigest && digest === candidateDigest ? 'generated-candidate' : 'displaced-bytes'
52
+ }
53
+
54
+ // Every candidate a journal names: from its header, and from the write-ahead
55
+ // entries of late exchange candidates. `preparedRef` is where the bytes were
56
+ // written, in staging, before they were moved to `stagedRef`.
57
+ export function namedCandidates(document) {
58
+ const named = (journalDetail(document).staged ?? []).map(({ unit, path: notePath, candidateDigest, stagedRef, preparedRef }) => ({ unit, path: notePath, candidateDigest, stagedRef, preparedRef: preparedRef ?? null, late: false }))
59
+ for (const entry of document.entries) {
60
+ const detail = journalDetail(entry)
61
+ if (entry.step === 'capture' && detail.code === 'late-candidate' && detail.stagedRef) {
62
+ named.push({ unit: detail.unit, path: entry.notePath, candidateDigest: entry.afterDigest ?? null, stagedRef: detail.stagedRef, preparedRef: detail.preparedRef ?? null, late: true })
63
+ }
64
+ }
65
+ return named
66
+ }
67
+
68
+ function freeName(store, journalId, unit, stem = 'displaced') {
69
+ for (let index = 0; ; index += 1) {
70
+ const candidate = store.displacedPath(journalId, unit, index === 0 ? `${stem}.bin` : `${stem}-${index}.bin`)
71
+ if (!exists(candidate)) return candidate
72
+ }
73
+ }
74
+
75
+ // Judges a file already moved to the unit's retiring name: our own candidate
76
+ // is deleted; anything else is somebody's bytes and stays in recovery under a
77
+ // name of its own. This is both the second half of retireStagedFile and what
78
+ // restart recovery runs for a retirement that was interrupted.
79
+ function judgeRetiring({ store, journalId, unit, candidateDigest }) {
80
+ const retiring = store.displacedPath(journalId, unit, RETIRING_NAME)
81
+ if (!exists(retiring)) return { retired: false, capturedPaths: [] }
82
+ if (classifyCandidateFile({ file: retiring, candidateDigest }) === 'generated-candidate') {
83
+ fs.unlinkSync(retiring)
84
+ return { retired: true, capturedPaths: [] }
85
+ }
86
+ const capturedPath = freeName(store, journalId, unit, 'unexpected-at-staged-path')
87
+ fs.renameSync(retiring, capturedPath)
88
+ syncPrivateDirectory(path.dirname(capturedPath))
89
+ return { retired: false, capturedPaths: [capturedPath] }
90
+ }
91
+
92
+ // A candidate path is never unlinked in place: between a digest check and an
93
+ // unlink, a late exchange could put displaced bytes there. It is first moved
94
+ // to a private name, which no payload refers to, and only then judged. That
95
+ // name lives in the unit's recovery directory, beside the exchange candidate,
96
+ // never in staging: between the two steps the file may be the only copy of
97
+ // bytes an exchange displaced. A process that dies between the steps leaves
98
+ // `retiring.bin` there, and restart recovery finishes the judgement.
99
+ // Returns { retired, capturedPaths }: every path in `capturedPaths` holds
100
+ // bytes that were not the candidate and still needs a receipt.
101
+ export function retireStagedFile({ store, journalId, unit, stagedPath, candidateDigest, crash = () => {} }) {
102
+ // An earlier interrupted retirement of this unit is finished first, so the move below never lands on it.
103
+ const earlier = judgeRetiring({ store, journalId, unit, candidateDigest })
104
+ if (!stagedPath || !exists(stagedPath)) return earlier
105
+ fs.renameSync(stagedPath, store.displacedPath(journalId, unit, RETIRING_NAME))
106
+ crash('after-retire-move')
107
+ const result = judgeRetiring({ store, journalId, unit, candidateDigest })
108
+ return { retired: result.retired, capturedPaths: [...earlier.capturedPaths, ...result.capturedPaths] }
109
+ }
110
+
111
+ // Units of one journal that still hold a `retiring.bin`.
112
+ function interruptedRetirements(store, journalId) {
113
+ const base = path.join(store.workspaceRoot, 'recovery', journalId.replaceAll(':', '_'))
114
+ if (!exists(base)) return []
115
+ return fs.readdirSync(base).filter((name) => /^\d{6}$/.test(name) && exists(path.join(base, name, RETIRING_NAME))).map((name) => Number(name)).sort((a, b) => a - b)
116
+ }
117
+
118
+ // `digestAtMove` is the digest taken inside the critical section, right after
119
+ // the move, when the caller has it. A holder of the old file may already have
120
+ // written into it by the time this process reads it; that difference is left
121
+ // for the late-writer check to report.
122
+ export function recordDisplaced({ store, journalId, unit, notePath, displacedPath, baseDigest, at, digestAtMove: observedAtMove = null }) {
123
+ const bytes = readFileBytes(displacedPath)
124
+ const digestAtMove = observedAtMove ?? sha256Digest(bytes)
125
+ const externalCaptured = digestAtMove !== baseDigest
126
+ // The displaced file stays where it is. Bytes that are not the known base get an immutable copy as well.
127
+ const object = sha256Digest(bytes) !== baseDigest ? store.retainObject(bytes) : null
128
+ store.writeReceipt(journalId, unit, { role: 'displaced', notePath, displacedRef: store.ref(displacedPath), digestAtMove, baseDigest, externalCaptured, ...(object ? { objectRef: object.ref } : {}), at })
129
+ return { digestAtMove, externalCaptured, displacedRef: store.ref(displacedPath) }
130
+ }
131
+
132
+ // Settles one note whose outcome is unknown. Returns the entry to append.
133
+ export function reconcileUnit({ store, journalId, capture, at }) {
134
+ const detail = journalDetail(capture)
135
+ const { unit, op } = detail
136
+ const notePath = capture.notePath
137
+ const note = path.join(store.vaultRoot, notePath)
138
+ const base = capture.beforeDigest ?? null
139
+ const candidate = capture.afterDigest ?? null
140
+ const staged = detail.stagedRef ? store.resolve(detail.stagedRef) : null
141
+ const displaced = capture.recoveryRef ? store.resolve(capture.recoveryRef) : null
142
+ const settle = (outcome, code, extra = {}) => ({ step: 'conditional-update', outcome, notePath, ...(base ? { beforeDigest: base } : {}), ...(outcome === 'ok' && candidate ? { afterDigest: candidate } : {}),
143
+ ...(extra.recoveryRef ? { recoveryRef: extra.recoveryRef } : {}), detail: { unit, op, kind: detail.kind, code, reconciled: true, ...(extra.externalCaptured ? { externalCaptured: true } : {}) } })
144
+
145
+ const keepUnexpected = (paths) => { for (const capturedPath of paths) recordDisplaced({ store, journalId, unit, notePath, displacedPath: capturedPath, baseDigest: base, at }) }
146
+ const completeAfterExchange = (holder) => {
147
+ const target = !displaced || exists(displaced) ? freeName(store, journalId, unit) : displaced
148
+ fs.renameSync(holder, target)
149
+ syncPrivateDirectory(path.dirname(target))
150
+ const recorded = recordDisplaced({ store, journalId, unit, notePath, displacedPath: target, baseDigest: base, at })
151
+ return settle('ok', 'completed-after-exchange', { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured })
152
+ }
153
+
154
+ if (op === 'replace') {
155
+ // A retirement interrupted between its two moves: the file that was at the
156
+ // staged path is at the retiring name. Bytes that are not the candidate
157
+ // can only have come from the exchange, exactly as if they were still staged.
158
+ const retiring = store.displacedPath(journalId, unit, RETIRING_NAME)
159
+ if (classifyCandidateFile({ file: retiring, candidateDigest: candidate }) === 'displaced-bytes') {
160
+ const entry = completeAfterExchange(retiring)
161
+ if (staged) keepUnexpected(retireStagedFile({ store, journalId, unit, stagedPath: staged, candidateDigest: candidate }).capturedPaths)
162
+ return entry
163
+ }
164
+ const atCandidatePath = classifyCandidateFile({ file: staged, candidateDigest: candidate })
165
+ if (atCandidatePath === 'generated-candidate') {
166
+ keepUnexpected(retireStagedFile({ store, journalId, unit, stagedPath: staged, candidateDigest: candidate }).capturedPaths)
167
+ return settle('skipped', 'interrupted-before-exchange')
168
+ }
169
+ if (atCandidatePath === 'displaced-bytes') return completeAfterExchange(staged)
170
+ keepUnexpected(judgeRetiring({ store, journalId, unit, candidateDigest: candidate }).capturedPaths)
171
+ if (displaced && exists(displaced)) {
172
+ const known = store.listReceipts(journalId).find((receipt) => receipt.role === 'displaced' && receipt.unit === unit && receipt.displacedRef === store.ref(displaced))
173
+ const recorded = known ?? recordDisplaced({ store, journalId, unit, notePath, displacedPath: displaced, baseDigest: base, at })
174
+ return settle('ok', 'completed-after-recovery-move', { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured })
175
+ }
176
+ return settle('skipped', 'nothing-happened')
177
+ }
178
+ if (op === 'create') {
179
+ const created = readFileDigest(note) === candidate
180
+ keepUnexpected(staged ? retireStagedFile({ store, journalId, unit, stagedPath: staged, candidateDigest: candidate }).capturedPaths : [])
181
+ return created ? settle('ok', 'completed-after-create') : settle('skipped', 'interrupted-before-create')
182
+ }
183
+ if (op === 'remove') {
184
+ if (!displaced || !exists(displaced)) return settle('skipped', 'interrupted-before-removal')
185
+ const noteStat = fs.lstatSync(note, { throwIfNoEntry: false })
186
+ const movedStat = fs.lstatSync(displaced)
187
+ if (noteStat && noteStat.ino === movedStat.ino && noteStat.dev === movedStat.dev) {
188
+ // An interrupted put-back: the note path already names these bytes again.
189
+ fs.unlinkSync(displaced)
190
+ return settle('skipped', 'remove-reverted')
191
+ }
192
+ const recorded = recordDisplaced({ store, journalId, unit, notePath, displacedPath: displaced, baseDigest: base, at })
193
+ return settle('ok', recorded.externalCaptured ? 'removed-external-captured' : 'completed-after-removal', { recoveryRef: recorded.displacedRef, externalCaptured: recorded.externalCaptured })
194
+ }
195
+ return refuse('journal-corrupt', 'a capture entry names an unknown operation')
196
+ }
197
+
198
+ // Late candidates in staging that no entry names. Every one counts: a journal
199
+ // may hold a named late candidate and an orphaned one side by side. This looks
200
+ // in staging only. Nothing in a unit's recovery directory is ever an orphan:
201
+ // a late exchange candidate gets there after its write-ahead entry, and a
202
+ // file there that the journal does not account for is kept.
203
+ function strays(store, document) {
204
+ const directory = path.join(store.stagingRoot, document.journalId.replaceAll(':', '_'))
205
+ if (!exists(directory)) return []
206
+ const named = new Set(document.entries.flatMap((entry) => [journalDetail(entry).stagedRef, journalDetail(entry).preparedRef]).filter(Boolean))
207
+ return fs.readdirSync(directory).filter((name) => name.endsWith('.late.candidate')).map((name) => path.join(directory, name)).filter((file) => !named.has(store.ref(file)))
208
+ }
209
+
210
+ const iso = (clock) => { const value = clock(); return (value instanceof Date ? value : new Date(value)).toISOString() }
211
+
212
+ // Caller holds the view's lock.
213
+ export function recoverPublicationsLocked({ store, clock = () => new Date() }) {
214
+ const report = []
215
+ for (const journal of listJournals(store, { clock, openOnly: true })) {
216
+ const document = journal.document()
217
+ if (document.state === 'committed') { journal.close(); continue }
218
+ const header = journalDetail(document)
219
+ const settled = new Set(document.entries.filter((entry) => entry.step === 'conditional-update').map((entry) => journalDetail(entry).unit))
220
+ const pending = document.entries.filter((entry) => entry.step === 'capture' && journalDetail(entry).intent === true && !settled.has(journalDetail(entry).unit))
221
+ const pendingUnits = new Set(pending.map((entry) => journalDetail(entry).unit))
222
+ // Candidates the journal names that are still on disk. At the candidate path of a pending unit the file is
223
+ // reconcileUnit's to judge. A file still in staging was never moved to an exchange path, whatever its unit.
224
+ const leftovers = namedCandidates(document).flatMap((item) => [...(pendingUnits.has(item.unit) ? [] : [item.stagedRef]), item.preparedRef].filter(Boolean).map((ref) => ({ ...item, file: store.resolve(ref) })))
225
+ .filter((item) => exists(item.file))
226
+ // Retirements interrupted between their two moves, for notes that are otherwise settled.
227
+ const candidateOf = new Map((header.staged ?? []).map((item) => [item.unit, item.candidateDigest]))
228
+ for (const entry of document.entries) if (entry.step === 'capture' && journalDetail(entry).stagedRef && entry.afterDigest) candidateOf.set(journalDetail(entry).unit, entry.afterDigest)
229
+ const pathOf = new Map((header.units ?? []).map((item) => [item.unit, item.path]))
230
+ const retiring = interruptedRetirements(store, document.journalId).filter((unit) => !pendingUnits.has(unit))
231
+ const orphans = strays(store, document)
232
+ const pointer = store.readCurrent()
233
+ const hasCommitEntry = document.entries.some((entry) => entry.step === 'manifest-commit' && entry.outcome === 'ok')
234
+ const pointerNamesJournal = pointer?.journalId === document.journalId
235
+ const verifying = document.entries.findLast((entry) => entry.step === 'verify' && journalDetail(entry).settled === true)
236
+ const current = (pointer?.generationId ?? null) === document.expectedGeneration
237
+ const canCommit = Boolean(verifying) && !hasCommitEntry && !pointerNamesJournal && current
238
+ const stale = !current && !pointerNamesJournal
239
+ const atRest = document.state === 'failed' || document.state === 'updating' || document.state === 'captured' || document.state === 'prepared'
240
+ const work = pending.length > 0 || leftovers.length > 0 || retiring.length > 0 || orphans.length > 0 || (pointerNamesJournal && !hasCommitEntry) || canCommit || document.state === 'recovering' || (stale && document.state !== 'failed')
241
+ if (!work && atRest) { if (stale) journal.close(); continue }
242
+
243
+ const actions = []
244
+ journal.append({ step: 'restart', outcome: 'ok', state: 'recovering', detail: { phase: 'begin', pendingUnits: pending.length } })
245
+ for (const capture of pending) {
246
+ const entry = reconcileUnit({ store, journalId: document.journalId, capture, at: iso(clock) })
247
+ journal.append({ ...entry, state: 'recovering' })
248
+ actions.push({ path: capture.notePath, outcome: entry.outcome, code: entry.detail.code })
249
+ if (entry.outcome === 'ok' && entry.detail.op !== 'remove') {
250
+ const verified = readFileDigest(path.join(store.vaultRoot, capture.notePath)) === capture.afterDigest
251
+ journal.append({ step: 'verify', outcome: verified ? 'ok' : 'conflict', state: 'recovering', notePath: capture.notePath, afterDigest: capture.afterDigest,
252
+ detail: { unit: entry.detail.unit, code: verified ? 'verified' : 'changed-after-publication' } })
253
+ }
254
+ }
255
+ const keepUnexpected = (unit, notePath, capturedPaths) => {
256
+ for (const capturedPath of capturedPaths) {
257
+ // Not the candidate, and nothing says what it is: it is kept and surfaced as an outside writer's bytes.
258
+ recordDisplaced({ store, journalId: document.journalId, unit, notePath, displacedPath: capturedPath, baseDigest: null, at: iso(clock) })
259
+ actions.push({ path: notePath, outcome: 'ok', code: 'unexpected-bytes-at-staged-path-kept' })
260
+ }
261
+ }
262
+ for (const unit of retiring) {
263
+ const result = retireStagedFile({ store, journalId: document.journalId, unit, stagedPath: null, candidateDigest: candidateOf.get(unit) ?? null })
264
+ keepUnexpected(unit, pathOf.get(unit) ?? null, result.capturedPaths)
265
+ if (result.retired) actions.push({ path: pathOf.get(unit) ?? null, outcome: 'ok', code: 'interrupted-retirement-finished' })
266
+ }
267
+ for (const item of leftovers) {
268
+ const result = retireStagedFile({ store, journalId: document.journalId, unit: item.unit, stagedPath: item.file, candidateDigest: item.candidateDigest })
269
+ keepUnexpected(item.unit, item.path, result.capturedPaths)
270
+ }
271
+ // A file in staging that no entry names was never put in a payload, so nothing can have exchanged it.
272
+ for (const orphan of orphans) fs.rmSync(orphan, { force: true })
273
+ const stagingDir = store.stagingDir(document.journalId)
274
+ try { fs.rmdirSync(stagingDir) } catch { /* not empty, or already gone */ }
275
+
276
+ let committed = false
277
+ if (pointerNamesJournal && !hasCommitEntry) {
278
+ journal.append({ step: 'manifest-commit', outcome: 'ok', state: 'committed', detail: { code: 'pointer-already-committed', reconciled: true } })
279
+ committed = true
280
+ } else if (canCommit) {
281
+ const manifestBytes = readFileBytes(store.resolve(header.manifestRef))
282
+ if (sha256Digest(manifestBytes) !== header.manifestDigest) refuse('journal-corrupt', 'the manifest stored with a journal no longer matches its digest')
283
+ store.commitManifest({ manifestBytes, generationId: document.targetGeneration, journalId: document.journalId, retained: journalDetail(verifying).retained ?? [], committedAt: iso(clock) })
284
+ journal.append({ step: 'manifest-commit', outcome: 'ok', state: 'committed', detail: { code: 'committed-on-restart', reconciled: true } })
285
+ committed = true
286
+ } else {
287
+ journal.append({ step: 'restart', outcome: 'ok', state: stale ? 'failed' : 'updating', detail: { phase: 'complete', ...(stale ? { code: 'superseded' } : {}) } })
288
+ }
289
+ if (committed || stale) journal.close()
290
+ report.push({ journalId: document.journalId, actions, committed, state: journal.document().state })
291
+ }
292
+ return { journals: report }
293
+ }
294
+
295
+ export function recoverPublications({ store, clock = () => new Date() }) {
296
+ const release = acquirePrivateLock(store.lockPath)
297
+ try { return recoverPublicationsLocked({ store, clock }) } finally { release() }
298
+ }
299
+
300
+ // Digests this view has published since its trusted manifest was committed,
301
+ // by journals that did not reach a commit. They are valid bases: without them
302
+ // a note published by an interrupted run would look like an outside edit.
303
+ export function publishedSinceCommit({ store }) {
304
+ const pointer = store.readCurrent()
305
+ const ledger = new Map()
306
+ for (const journal of listJournals(store, { openOnly: true })) {
307
+ const document = journal.document()
308
+ if (document.state === 'committed' || document.expectedGeneration !== (pointer?.generationId ?? null)) continue
309
+ for (const entry of document.entries) {
310
+ const detail = journalDetail(entry)
311
+ if (entry.step !== 'conditional-update' || entry.outcome !== 'ok' || detail.kind === 'settings') continue
312
+ ledger.set(entry.notePath, detail.op === 'remove' ? null : entry.afterDigest)
313
+ }
314
+ }
315
+ return ledger
316
+ }
@@ -0,0 +1,234 @@
1
+ import { createHash } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { acquirePrivateLock, publishPrivateFile } from '../../../project/durable-state.mjs'
5
+ import { checkManagedRoots } from '../../../project/file-class.mjs'
6
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow } from '../../../project/private-state.mjs'
7
+
8
+ // Private per-workspace state for one Obsidian view:
9
+ //
10
+ // <workspaceRoot>/vaults/<scopeId>/ the editable vault (or an explicit vaultRoot)
11
+ // <workspaceRoot>/state/manifests/<scopeId>/ trusted manifests and the current pointer
12
+ // <workspaceRoot>/state/journals/<scopeId>/<id>/ publication journals
13
+ // <workspaceRoot>/recovery/objects/<sha256>.bin immutable content-addressed bytes
14
+ // <workspaceRoot>/recovery/<journalId>/<unit>/ displaced files, receipts, and the unit's exchange candidate
15
+ // <workspaceRoot>/recovery/.exchange-probe/ scratch files of the exchange self-test, nothing else
16
+ // <workspaceRoot>/staging/<journalId>/ disposable generated bytes that no exchange ever names
17
+ // <vault>/.atelier-publication/ the vault lock, shared by every view of the vault
18
+ //
19
+ // Immutable records are published with publishPrivateFile: a complete file
20
+ // appears atomically under a name that is never overwritten. What that gives
21
+ // is an all-or-nothing record that survives a process crash; it is fsynced,
22
+ // which on some platforms is weaker than a full device flush. A displaced
23
+ // file is different: it is the very file that occupied a note path, it is
24
+ // moved and never copied over, and a program that still holds it open may
25
+ // write into it later. It is therefore never treated as immutable; its digest
26
+ // at the time of the move is recorded in a receipt and re-checked.
27
+ //
28
+ // A candidate that replaces an existing file is exchanged with it, so after
29
+ // the exchange the candidate's path holds the displaced bytes. That path is
30
+ // therefore in the unit's recovery directory, never in staging: staging is
31
+ // discardable, and displaced bytes must not sit in a discardable area for an
32
+ // instant. The candidate is written and fsynced in staging, where it is only
33
+ // generated bytes, named with its digest by the journal, and only then moved
34
+ // to its exchange path. What a file at an exchange path is, is decided from
35
+ // the journal: bytes with the recorded candidate digest are generated and
36
+ // ours; anything else is somebody's bytes and is kept. The candidate names
37
+ // end in `.candidate`; everything else the store puts in a unit directory
38
+ // ends in `.bin` or `.json`, so the names cannot collide.
39
+
40
+ export const EXCHANGE_CANDIDATE_NAME = 'exchange.candidate'
41
+ export const LATE_EXCHANGE_CANDIDATE_NAME = 'exchange.late.candidate'
42
+
43
+ export const VAULT_LOCK_DIRECTORY = '.atelier-publication'
44
+
45
+ // The vault lock, with the semantics of every private lock: the newest ticket
46
+ // owns it, a release marker frees it, and a ticket whose process is gone on
47
+ // this host is stale and is taken over. Nothing else is ever written here.
48
+ // Tickets of superseded generations are removed while the lock is held, the
49
+ // marker before its ticket, so the directory does not grow inside a person's
50
+ // vault and an interrupted removal never leaves a marker without its ticket.
51
+ export function acquireVaultLock(store) {
52
+ const directory = ensureContainedPrivateDirectory({ workspaceRoot: store.vaultRoot, directory: path.dirname(store.vaultLockPath), label: 'vault publication lock' })
53
+ const release = acquirePrivateLock(path.join(directory, path.basename(store.vaultLockPath)))
54
+ try {
55
+ const owners = `${store.vaultLockPath}.owners`
56
+ const names = fs.readdirSync(owners).filter((name) => /^\d{12}\.(json|released)$/.test(name)).sort()
57
+ const newest = names.filter((name) => name.endsWith('.json')).at(-1)?.slice(0, 12)
58
+ for (const name of names.filter((item) => item.slice(0, 12) < newest).sort((left, right) => (left.endsWith('.released') ? -1 : 1) - (right.endsWith('.released') ? -1 : 1))) {
59
+ fs.rmSync(path.join(owners, name), { force: true })
60
+ }
61
+ } catch { /* history that cannot be pruned is harmless */ }
62
+ return release
63
+ }
64
+
65
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
66
+ const DIGEST = /^sha256:[0-9a-f]{64}$/
67
+
68
+ export const sha256Digest = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`
69
+
70
+ export class PublicationRefusal extends Error {
71
+ constructor(code, message, detail = {}) {
72
+ super(`${code}: ${message}`)
73
+ this.name = 'PublicationRefusal'
74
+ this.code = code
75
+ this.detail = detail
76
+ }
77
+ }
78
+
79
+ export function refuse(code, message, detail) {
80
+ throw new PublicationRefusal(code, message, detail)
81
+ }
82
+
83
+ // Identifiers become directory names. ':' is legal in an identifier and is not
84
+ // legal in a file name everywhere.
85
+ const segment = (identifier) => identifier.replaceAll(':', '_')
86
+
87
+ export function readFileDigest(file) {
88
+ let descriptor
89
+ try {
90
+ descriptor = openRegularFileNoFollow(file)
91
+ } catch (error) {
92
+ if (error.code === 'ENOENT') return null
93
+ throw error
94
+ }
95
+ try { return sha256Digest(fs.readFileSync(descriptor)) } finally { fs.closeSync(descriptor) }
96
+ }
97
+
98
+ export function readFileBytes(file) {
99
+ const descriptor = openRegularFileNoFollow(file)
100
+ try { return fs.readFileSync(descriptor) } finally { fs.closeSync(descriptor) }
101
+ }
102
+
103
+ function publishOnce(file, bytes) {
104
+ try {
105
+ publishPrivateFile(file, bytes)
106
+ } catch (error) {
107
+ // The name is taken by different bytes. An immutable record is never replaced.
108
+ if (error.code !== 'EEXIST') throw error
109
+ return false
110
+ }
111
+ return true
112
+ }
113
+
114
+ // `repositoryRoots` is required: every enrolled repository root of the loaded
115
+ // project. Neither the workspace state nor the vault may overlap one of them,
116
+ // in either direction, lexically or by real path; the store refuses before it
117
+ // creates anything. A caller with no enrolled repository (a test in a
118
+ // temporary directory) says so with an explicit empty list.
119
+ export function createRecoveryStore({ workspaceRoot, workspaceId, scopeId, vaultRoot, repositoryRoots } = {}) {
120
+ if (typeof workspaceRoot !== 'string' || !path.isAbsolute(workspaceRoot)) throw new TypeError('workspaceRoot must be an absolute path')
121
+ for (const [label, value] of [['workspaceId', workspaceId], ['scopeId', scopeId]]) {
122
+ if (typeof value !== 'string' || !IDENTIFIER.test(value)) throw new TypeError(`${label} must be a contract identifier`)
123
+ }
124
+ if (!Array.isArray(repositoryRoots) || repositoryRoots.some((item) => typeof item !== 'string' || !path.isAbsolute(item))) {
125
+ throw new TypeError('repositoryRoots must list the absolute root of every enrolled repository; pass an explicit empty list when there is none')
126
+ }
127
+ if (vaultRoot !== undefined && (typeof vaultRoot !== 'string' || !path.isAbsolute(vaultRoot))) throw new TypeError('vaultRoot must be an absolute path')
128
+ const guard = checkManagedRoots({ managedRoots: [workspaceRoot, ...(vaultRoot === undefined ? [] : [vaultRoot])], repositoryRoots })
129
+ if (!guard.ok) refuse(guard.refusals[0].code, guard.refusals[0].message, { refusals: guard.refusals })
130
+ fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
131
+ const root = fs.realpathSync(workspaceRoot)
132
+ const privateDir = (...parts) => ensureContainedPrivateDirectory({ workspaceRoot: root, directory: path.join(root, ...parts), label: 'Obsidian publication state' })
133
+ const requestedVault = vaultRoot ?? path.join(root, 'vaults', segment(scopeId))
134
+ fs.mkdirSync(requestedVault, { recursive: true })
135
+ const vault = fs.realpathSync(requestedVault)
136
+ const inside = (parent, child) => { const relative = path.relative(parent, child); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) }
137
+ for (const area of ['state', 'recovery', 'staging']) {
138
+ if (inside(path.join(root, area), vault) || inside(vault, path.join(root, area))) throw new TypeError('the vault may not overlap private publication state')
139
+ }
140
+ const manifests = privateDir('state', 'manifests', segment(scopeId))
141
+ const journals = privateDir('state', 'journals', segment(scopeId))
142
+ const locks = privateDir('state', 'locks')
143
+ const objects = privateDir('recovery', 'objects')
144
+ const staging = privateDir('staging')
145
+ const pointerFile = path.join(manifests, 'current.json')
146
+
147
+ const store = {
148
+ workspaceRoot: root,
149
+ workspaceId,
150
+ scopeId,
151
+ vaultRoot: vault,
152
+ journalsRoot: journals,
153
+ lockPath: path.join(locks, `${segment(scopeId)}.lock`),
154
+ // One publisher per vault, whichever view or workspace state it belongs to.
155
+ // Two stores share nothing but the vault, so this lock lives under the
156
+ // vault's real path, in a dot-directory no note path can name.
157
+ vaultLockPath: path.join(vault, VAULT_LOCK_DIRECTORY, 'vault.lock'),
158
+ ref: (absolute) => path.relative(root, absolute).split(path.sep).join('/'),
159
+ resolve(ref) {
160
+ const absolute = path.resolve(root, ref)
161
+ if (!inside(root, absolute) || absolute === root) throw new TypeError('a store reference must stay inside the workspace state')
162
+ return absolute
163
+ },
164
+ journalDir: (journalId) => privateDir('state', 'journals', segment(scopeId), segment(journalId)),
165
+ stagingDir: (journalId) => privateDir('staging', segment(journalId)),
166
+ stagingRoot: staging,
167
+ // Where the exchange self-test runs: inside recovery, because that is where exchange candidates live.
168
+ exchangeProbeDir: () => privateDir('recovery', '.exchange-probe'),
169
+ // Generated bytes on their way to a path: written here, and for a created file linked from here.
170
+ preparedPath: (journalId, unit, { late = false } = {}) => path.join(store.stagingDir(journalId), `${String(unit).padStart(6, '0')}${late ? '.late' : ''}.candidate`),
171
+ // The path handed to the exchange. Computed without creating anything; unitDir creates the directory.
172
+ exchangeCandidatePath: (journalId, unit, { late = false } = {}) => path.join(root, 'recovery', segment(journalId), String(unit).padStart(6, '0'), late ? LATE_EXCHANGE_CANDIDATE_NAME : EXCHANGE_CANDIDATE_NAME),
173
+ unitsRoot: (journalId) => path.join(root, 'recovery', segment(journalId)),
174
+ unitDir: (journalId, unit) => privateDir('recovery', segment(journalId), String(unit).padStart(6, '0')),
175
+ displacedPath: (journalId, unit, name = 'displaced.bin') => path.join(store.unitDir(journalId, unit), name),
176
+
177
+ // Immutable, content-addressed bytes (a base, an observed edit, an outside writer's bytes).
178
+ retainObject(bytes) {
179
+ const digest = sha256Digest(bytes)
180
+ const file = path.join(objects, `${digest.slice('sha256:'.length)}.bin`)
181
+ publishPrivateFile(file, bytes)
182
+ return { digest, ref: store.ref(file) }
183
+ },
184
+ readObject(digest) {
185
+ if (!DIGEST.test(digest)) throw new TypeError('digest required')
186
+ const bytes = readFileBytes(path.join(objects, `${digest.slice('sha256:'.length)}.bin`))
187
+ if (sha256Digest(bytes) !== digest) refuse('recovery-object-corrupt', 'a retained object no longer matches its name')
188
+ return bytes
189
+ },
190
+ writeReceipt(journalId, unit, receipt) {
191
+ const body = Buffer.from(`${JSON.stringify({ schema: 'atelier-obsidian-recovery-receipt/v1', journalId, unit, ...receipt }, null, 2)}\n`, 'utf8')
192
+ const file = path.join(store.unitDir(journalId, unit), `receipt-${receipt.role}-${sha256Digest(body).slice(7, 19)}.json`)
193
+ publishOnce(file, body)
194
+ return store.ref(file)
195
+ },
196
+ listReceipts(journalId) {
197
+ const base = path.join(root, 'recovery', segment(journalId))
198
+ if (!fs.existsSync(base)) return []
199
+ const receipts = []
200
+ for (const unit of fs.readdirSync(base).sort()) {
201
+ for (const name of fs.readdirSync(path.join(base, unit)).filter((entry) => /^receipt-.*\.json$/.test(entry)).sort()) {
202
+ receipts.push(JSON.parse(readRegularTextNoFollow(path.join(base, unit, name))))
203
+ }
204
+ }
205
+ return receipts
206
+ },
207
+
208
+ // Trusted manifest. The manifest file is immutable; only the small pointer
209
+ // is replaced, and only as the last step of a publication.
210
+ readCurrent() {
211
+ let text
212
+ try { text = readRegularTextNoFollow(pointerFile) } catch (error) { if (error.code === 'ENOENT') return null; throw error }
213
+ const pointer = JSON.parse(text)
214
+ if (pointer.scopeId !== scopeId || pointer.workspaceId !== workspaceId) refuse('state-mismatch', 'the manifest pointer belongs to another view')
215
+ return pointer
216
+ },
217
+ readCurrentManifest() {
218
+ const pointer = store.readCurrent()
219
+ if (!pointer) return null
220
+ const bytes = readFileBytes(path.join(manifests, pointer.manifestFile))
221
+ if (sha256Digest(bytes) !== pointer.manifestDigest) refuse('state-mismatch', 'the trusted manifest no longer matches its pointer')
222
+ return JSON.parse(bytes.toString('utf8'))
223
+ },
224
+ commitManifest({ manifestBytes, generationId, journalId, retained = [], committedAt }) {
225
+ const manifestDigest = sha256Digest(manifestBytes)
226
+ const manifestFile = `${segment(generationId)}--${manifestDigest.slice(7, 19)}.json`
227
+ publishPrivateFile(path.join(manifests, manifestFile), manifestBytes)
228
+ const pointer = { schema: 'atelier-obsidian-current-manifest/v1', workspaceId, scopeId, generationId, manifestFile, manifestDigest, journalId, committedAt, retained }
229
+ atomicReplacePrivateText(pointerFile, `${JSON.stringify(pointer, null, 2)}\n`)
230
+ return pointer
231
+ },
232
+ }
233
+ return store
234
+ }
@@ -0,0 +1,91 @@
1
+ import { compareText } from '../../../runtime/obsidian/documents.mjs'
2
+ import { OPEN_EDIT_STATES, createMaintenanceStateStore } from '../../../runtime/obsidian/state-store.mjs'
3
+ import { openObjectStore } from '../edits/object-store.mjs'
4
+
5
+ // A read-only, typed view of the shared conflict state AOP-2's arbitration
6
+ // keeps per object: which objects are conflicted, pending, settled or
7
+ // unreadable, which operations are involved and why, and which pending edit
8
+ // records of which views feed them. Identities, states, codes and digests
9
+ // only: no note text, title or path.
10
+ //
11
+ // `conflictView` is pure: it is a function of the object entries (the
12
+ // object store's `list()` or an `arbitrateEvents` summary) and the pending
13
+ // edit records. `readConflictView` reads both from the workspace's private
14
+ // state. Neither decides anything, writes an event or touches a source file
15
+ // or a vault.
16
+
17
+ export const CONFLICT_VIEW_SCHEMA = 'atelier-obsidian-conflict-view/v1'
18
+ export const OBJECT_VIEW_STATES = Object.freeze(['conflicted', 'pending', 'settled', 'empty', 'inconsistent', 'unreadable'])
19
+
20
+ // What a person or an agent does next. A conflict is resolved by a new
21
+ // operation that names the ones it resolves; nothing here chooses a winner.
22
+ const NEXT = Object.freeze({
23
+ conflicted: 'offer a resolution that names every conflicted operation; no operation is dropped and no winner is chosen',
24
+ pending: 'apply explicitly, or let an active automatic policy dispatch it',
25
+ settled: 'nothing: every recorded operation is applied, superseded or refused',
26
+ empty: 'nothing: no operation is recorded for this object',
27
+ inconsistent: 'a person looks at the object log; an event the log did not allow is recorded',
28
+ unreadable: 'a person looks at the object directory; nothing is decided until then',
29
+ })
30
+
31
+ const NEEDS_PERSON = new Set(['conflicted', 'inconsistent', 'unreadable'])
32
+
33
+ function operationView(operation) {
34
+ return {
35
+ idempotencyKey: operation.idempotencyKey,
36
+ kind: operation.kind ?? null,
37
+ state: operation.state,
38
+ reason: operation.reason ?? null,
39
+ by: operation.by ?? null,
40
+ }
41
+ }
42
+
43
+ export function conflictView({ objects, edits = [], scopeId } = {}) {
44
+ if (!Array.isArray(objects)) throw new TypeError('conflictView needs the list of object entries')
45
+ const openEdits = edits.filter((edit) => OPEN_EDIT_STATES.includes(edit.state) && (scopeId === undefined || edit.scopeId === scopeId))
46
+ const editsOf = (repoId, nodeId) => openEdits
47
+ .filter((edit) => edit.identity?.repoId === repoId && edit.identity?.nodeId === nodeId)
48
+ .map((edit) => ({ editId: edit.editId, scopeId: edit.scopeId, state: edit.state, lastCode: edit.lastResult?.code ?? null }))
49
+ .sort((left, right) => compareText(left.scopeId, right.scopeId) || compareText(left.editId, right.editId))
50
+
51
+ const entries = objects.map((object) => {
52
+ const state = OBJECT_VIEW_STATES.includes(object.state) ? object.state : 'unreadable'
53
+ const identity = object.identity ?? object
54
+ const repoId = identity.repoId ?? null
55
+ const nodeId = identity.nodeId ?? null
56
+ const operations = (object.operations ?? []).map(operationView)
57
+ const intentOutcomeUnknown = object.intentOutcomeUnknown === true || (object.intent !== undefined && object.intent !== null)
58
+ const needsPerson = NEEDS_PERSON.has(state) || intentOutcomeUnknown
59
+ return {
60
+ repoId, nodeId, state,
61
+ code: object.code ?? null,
62
+ sourceDigest: object.sourceDigest ?? null,
63
+ leaseHeld: object.leaseHeld === true || (object.lease !== undefined && object.lease !== null),
64
+ intentOutcomeUnknown,
65
+ operations,
66
+ conflictedOperations: operations.filter((operation) => operation.state === 'conflicted').map((operation) => operation.idempotencyKey),
67
+ pendingEdits: repoId === null ? [] : editsOf(repoId, nodeId),
68
+ needsPerson,
69
+ next: intentOutcomeUnknown && !NEEDS_PERSON.has(state) ? 'an apply was started and its outcome is not recorded; the next lease holder reads the source and decides' : NEXT[state],
70
+ }
71
+ }).sort((left, right) => compareText(left.repoId ?? '', right.repoId ?? '') || compareText(left.nodeId ?? '', right.nodeId ?? ''))
72
+
73
+ const byState = Object.fromEntries(OBJECT_VIEW_STATES.map((state) => [state, entries.filter((entry) => entry.state === state).length]))
74
+ return {
75
+ schema: CONFLICT_VIEW_SCHEMA,
76
+ scopeId: scopeId ?? null,
77
+ objects: entries,
78
+ summary: { total: entries.length, byState, needsPerson: entries.filter((entry) => entry.needsPerson).length, openEdits: openEdits.length },
79
+ needsPerson: entries.some((entry) => entry.needsPerson),
80
+ }
81
+ }
82
+
83
+ // Reads the object index (a cache the store rebuilds from the events where
84
+ // it is not current) and the pending edit records of the workspace.
85
+ export function readConflictView({ workspaceRoot, workspaceId, repositoryRoots, clock, scopeId, objectStore = openObjectStore }) {
86
+ const store = objectStore({ stateRoot: workspaceRoot, workspaceId, repositoryRoots, clock })
87
+ const { objects, foreign } = store.list(scopeId === undefined ? {} : { scopeId })
88
+ const edits = createMaintenanceStateStore({ workspaceRoot, workspaceId }).readPendingEdits().edits
89
+ const view = conflictView({ objects, edits, scopeId })
90
+ return { ...view, foreign }
91
+ }