@mnstry/atelier 0.2.0-alpha.6 → 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 (374) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/README.md +59 -15
  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-review.v1.schema.json +1601 -0
  25. package/contracts/atelier-skill-steward.v1.schema.json +692 -0
  26. package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
  27. package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
  28. package/contracts/atelier-vault-publication.v1.schema.json +58 -0
  29. package/docs/assurance-controls.md +9 -1
  30. package/docs/atelier-sync.md +5 -4
  31. package/docs/blocks/claims.md +15 -10
  32. package/docs/blocks/will-not-do.md +4 -1
  33. package/docs/coauthor-session.md +57 -0
  34. package/docs/connected-composition.md +75 -0
  35. package/docs/decisions.md +167 -0
  36. package/docs/exact-upgrades.md +159 -0
  37. package/docs/extension-pack-lifecycle.md +33 -0
  38. package/docs/guided-upgrades.md +99 -0
  39. package/docs/install.md +65 -9
  40. package/docs/intake-and-guides.md +65 -0
  41. package/docs/integration-and-review-plan.md +409 -0
  42. package/docs/integration-contract-decisions.md +22 -0
  43. package/docs/local-review.md +84 -0
  44. package/docs/local-services.md +227 -0
  45. package/docs/local-state.md +107 -0
  46. package/docs/obsidian-contract.md +687 -0
  47. package/docs/obsidian.md +387 -0
  48. package/docs/portable-presentation.md +328 -0
  49. package/docs/presentation-consumer-boundaries.md +75 -0
  50. package/docs/presentation-review-disposition.md +47 -0
  51. package/docs/project-command-inventory.md +38 -0
  52. package/docs/project-options.md +53 -0
  53. package/docs/release-engineering.md +88 -0
  54. package/docs/review-portability.md +55 -0
  55. package/docs/skill-steward.md +156 -0
  56. package/docs/upgrade.md +55 -2
  57. package/docs/vault-service.md +292 -0
  58. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  59. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  60. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  61. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  62. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  63. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  64. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  65. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  66. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  67. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  68. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  69. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  70. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  71. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  72. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  73. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  74. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  75. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  76. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  77. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  78. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  79. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  80. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  81. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  82. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  83. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  84. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  85. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  86. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  87. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  88. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  89. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  90. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  91. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  92. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  93. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  94. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  95. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  96. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  97. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  98. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  99. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  100. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  101. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  102. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  103. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  104. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  105. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  106. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  107. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  108. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  109. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  110. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  111. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  112. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  113. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  114. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  115. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  116. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  117. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  118. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  119. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  120. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  121. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  122. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  123. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  124. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  125. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  126. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  127. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  128. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  129. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  130. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  131. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  132. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  133. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  134. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  135. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  136. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  137. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  138. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  139. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  140. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  141. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  142. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  143. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  144. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  145. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  146. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  147. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  148. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  149. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  150. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  151. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  152. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  153. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  154. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  155. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  156. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  157. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  158. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  159. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  160. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  161. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  162. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  163. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  164. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  165. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  166. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  167. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  168. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  169. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  170. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  171. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  172. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  173. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  174. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  175. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  176. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  177. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  178. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  179. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  180. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  181. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  182. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  183. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  184. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  185. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  186. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  187. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  188. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  189. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  190. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  191. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  192. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  193. package/fixtures/obsidian/edits/workspace.json +65 -0
  194. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  195. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  196. package/fixtures/obsidian/materialization/workspace.json +54 -0
  197. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  198. package/fixtures/ui/presentation/pane.v1.json +7 -0
  199. package/fixtures/ui/presentation/reference.v1.json +293 -0
  200. package/package.json +57 -6
  201. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  202. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  203. package/skills/claude/atelier-open/SKILL.md +5 -0
  204. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  205. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  206. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  207. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  208. package/skills/codex/atelier-open/SKILL.md +5 -0
  209. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  210. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  211. package/src/access/preview-enforcer.mjs +84 -0
  212. package/src/boundary/policy.mjs +57 -25
  213. package/src/cli/project-options.mjs +58 -0
  214. package/src/cli/run.mjs +43 -3
  215. package/src/coauthor/session.mjs +161 -0
  216. package/src/coauthor/store.mjs +173 -0
  217. package/src/collaboration/event-ledger.mjs +16 -33
  218. package/src/collaboration/inspection-bundle.mjs +223 -0
  219. package/src/collaboration/review-contracts.mjs +25 -0
  220. package/src/collaboration/review-store.mjs +305 -0
  221. package/src/commands/coauthor.mjs +26 -0
  222. package/src/commands/config.mjs +6 -1
  223. package/src/commands/extension-pack.mjs +15 -8
  224. package/src/commands/init.mjs +44 -5
  225. package/src/commands/obsidian.mjs +368 -0
  226. package/src/commands/review.mjs +147 -0
  227. package/src/commands/setup.mjs +51 -14
  228. package/src/commands/skills.mjs +199 -0
  229. package/src/commands/sync.mjs +8 -1
  230. package/src/composition/registry.mjs +27 -0
  231. package/src/composition/wire.mjs +122 -0
  232. package/src/contracts/corpus.mjs +29 -0
  233. package/src/decisions/contracts.d.mts +96 -0
  234. package/src/decisions/contracts.mjs +321 -0
  235. package/src/disclosure/content-scan.mjs +13 -0
  236. package/src/export/atelier-export-contract.mjs +16 -1
  237. package/src/extension-packs/lifecycle.mjs +154 -0
  238. package/src/graph/graph.mjs +64 -2
  239. package/src/graph/knowledge-graph.mjs +548 -24
  240. package/src/guides/contracts.mjs +54 -0
  241. package/src/intake/store.mjs +119 -0
  242. package/src/preview/channel.mjs +89 -0
  243. package/src/project/config.mjs +26 -27
  244. package/src/project/durable-state.mjs +161 -0
  245. package/src/project/file-class.mjs +166 -0
  246. package/src/project/private-state.mjs +11 -1
  247. package/src/projection/obsidian/contracts.mjs +571 -0
  248. package/src/projection/obsidian/edits/align.mjs +169 -0
  249. package/src/projection/obsidian/edits/apply.mjs +889 -0
  250. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  251. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  252. package/src/projection/obsidian/edits/index.mjs +19 -0
  253. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  254. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  255. package/src/projection/obsidian/edits/observe.mjs +123 -0
  256. package/src/projection/obsidian/edits/policy.mjs +123 -0
  257. package/src/projection/obsidian/edits/regions.mjs +648 -0
  258. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  259. package/src/projection/obsidian/materialize/index.mjs +7 -0
  260. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  261. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  262. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  263. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  264. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  265. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  266. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  267. package/src/projection/obsidian/proposals/index.mjs +23 -0
  268. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  269. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  270. package/src/projection/obsidian/proposals/router.mjs +168 -0
  271. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  272. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  273. package/src/projection/obsidian/publication/index.mjs +5 -0
  274. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  275. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  276. package/src/projection/obsidian/publication/transport.mjs +139 -0
  277. package/src/projection/obsidian/recovery/index.mjs +6 -0
  278. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  279. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  280. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  281. package/src/projection/obsidian/recovery/store.mjs +234 -0
  282. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  283. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  284. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  285. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  286. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  287. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  288. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  289. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  290. package/src/projection/project.mjs +28 -19
  291. package/src/readiness-protocols/evidence.mjs +282 -0
  292. package/src/readiness-protocols/runtime.mjs +4 -3
  293. package/src/readiness-protocols/source-read.mjs +39 -0
  294. package/src/runtime/obsidian/app-capability.mjs +110 -0
  295. package/src/runtime/obsidian/app-production-seams.mjs +96 -0
  296. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  297. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  298. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  299. package/src/runtime/obsidian/contributions.mjs +27 -0
  300. package/src/runtime/obsidian/documents.mjs +37 -0
  301. package/src/runtime/obsidian/enablement.mjs +53 -0
  302. package/src/runtime/obsidian/engine.mjs +532 -0
  303. package/src/runtime/obsidian/errors.mjs +14 -0
  304. package/src/runtime/obsidian/extension-points.mjs +103 -0
  305. package/src/runtime/obsidian/index.mjs +34 -0
  306. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  307. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  308. package/src/runtime/obsidian/observation.mjs +158 -0
  309. package/src/runtime/obsidian/opening.mjs +243 -0
  310. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  311. package/src/runtime/obsidian/pipeline.mjs +122 -0
  312. package/src/runtime/obsidian/private-lock.mjs +156 -0
  313. package/src/runtime/obsidian/process-identity.mjs +58 -0
  314. package/src/runtime/obsidian/service-client.mjs +66 -0
  315. package/src/runtime/obsidian/service-main.mjs +89 -0
  316. package/src/runtime/obsidian/service-record.mjs +169 -0
  317. package/src/runtime/obsidian/service-server.mjs +121 -0
  318. package/src/runtime/obsidian/service.mjs +216 -0
  319. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  320. package/src/runtime/obsidian/state-store.mjs +180 -0
  321. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  322. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  323. package/src/runtime/obsidian/watchers.mjs +36 -0
  324. package/src/runtime/supervisor.mjs +16 -1
  325. package/src/server/local-sidecar.mjs +44 -0
  326. package/src/server/server.mjs +1 -0
  327. package/src/skills/steward.mjs +780 -0
  328. package/src/ui/presentation/browser.mjs +278 -0
  329. package/src/ui/presentation/contract.mjs +116 -0
  330. package/src/ui/presentation/index.mjs +7 -0
  331. package/src/ui/presentation/native.mjs +153 -0
  332. package/src/ui/presentation/proof.mjs +57 -0
  333. package/src/ui/presentation/schema-check.mjs +30 -0
  334. package/src/ui/presentation/schema.generated.mjs +1371 -0
  335. package/src/ui/presentation/state.mjs +43 -0
  336. package/src/ui/presentation/styles.mjs +67 -0
  337. package/src/ui/presentation/tokens.mjs +74 -0
  338. package/src/ui/presentation/web.mjs +85 -0
  339. package/src/ui/review-page.mjs +32 -0
  340. package/src/upgrade/explanation.mjs +33 -0
  341. package/src/upgrade/provenance.mjs +338 -0
  342. package/src/upgrade/transaction-files.mjs +103 -0
  343. package/src/upgrade/transaction.mjs +475 -0
  344. package/src/upgrade/upgrade.mjs +63 -28
  345. package/src/vault/hosts.mjs +21 -0
  346. package/src/vault/identity.mjs +19 -0
  347. package/src/vault/index.mjs +7 -0
  348. package/src/vault/interface.mjs +11 -0
  349. package/src/vault/metadata.mjs +31 -0
  350. package/src/vault/privacy.mjs +148 -0
  351. package/src/vault/probe.mjs +89 -0
  352. package/src/vault/service.mjs +131 -0
  353. package/src/vault/source.mjs +45 -0
  354. package/src/vault/storage.mjs +23 -0
  355. package/templates/distribution-workspace/.claude/launch.json +6 -2
  356. package/templates/distribution-workspace/gitignore +1 -0
  357. package/templates/distribution-workspace/project/README.md +2 -2
  358. package/templates/external-project-workspace/README.md +45 -0
  359. package/templates/external-project-workspace/answers.example.json +4 -0
  360. package/templates/external-project-workspace/atelier.project.json +40 -0
  361. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  362. package/templates/external-project-workspace/gitignore +8 -0
  363. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  364. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  365. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  366. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  367. package/templates/external-project-workspace/source/README.md +11 -0
  368. package/templates/launch.json +6 -2
  369. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  370. package/templates/private-domain-workspace/domain/README.md +2 -2
  371. package/templates/private-domain-workspace/gitignore +1 -0
  372. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  373. package/templates/shared-project-workspace/gitignore +1 -0
  374. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,260 @@
1
+ import { randomBytes as cryptoRandomBytes } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { LOCAL_STATE_DIR, localStateRoot } from '../../project/config.mjs'
6
+ import { checkManagedRoots } from '../../project/file-class.mjs'
7
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, readRegularTextNoFollow } from '../../project/private-state.mjs'
8
+ import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, assertObsidianContract } from '../../projection/obsidian/contracts.mjs'
9
+ import { refuse } from './errors.mjs'
10
+ import { canonicalJson, closedObject, isPlainObject } from './documents.mjs'
11
+
12
+ // Machine-private settings of the Obsidian maintenance runtime.
13
+ //
14
+ // <data>/obsidian/<workspace-id>/state/settings/machine.json mode, audiences, policy reference
15
+ // <data>/obsidian/<workspace-id>/state/settings/apply-policy.json the installed apply policy
16
+ // <project>/.atelier-local/obsidian.json pointer: workspace id, optional data root
17
+ //
18
+ // Nothing here is project configuration and nothing here is ever serialized
19
+ // into a note. The data directory sits outside every enrolled repository. The
20
+ // pointer is the one ignored file a project gains: it holds the persisted
21
+ // random workspace identity (never a hash of a checkout path, so moving a
22
+ // checkout keeps its vaults) and, optionally, where this machine keeps the
23
+ // data directory.
24
+
25
+ export const MACHINE_SETTINGS_SCHEMA = 'atelier-obsidian-machine-settings/v1'
26
+ export const LOCAL_POINTER_SCHEMA = 'atelier-obsidian-local-pointer/v1'
27
+ export const LOCAL_POINTER_FILE = 'obsidian.json'
28
+ export const MAINTENANCE_MODES = Object.freeze(['manual', 'automatic'])
29
+
30
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
31
+ const DIGEST = /^sha256:[0-9a-f]{64}$/
32
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/
33
+
34
+ const segment = (identifier) => identifier.replaceAll(':', '_')
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Where the data directory is
38
+ // ---------------------------------------------------------------------------
39
+
40
+ // Pure: computes a path from its arguments and touches nothing.
41
+ export function defaultDataRoot({ platform = process.platform, env = process.env, homedir = os.homedir() } = {}) {
42
+ const flavor = platform === 'win32' ? path.win32 : path.posix
43
+ const absolute = (value) => typeof value === 'string' && value !== '' && flavor.isAbsolute(value)
44
+ if (platform === 'win32') {
45
+ if (!absolute(env.LOCALAPPDATA)) refuse('data-root-unresolvable', 'LOCALAPPDATA does not name an absolute directory')
46
+ return flavor.join(env.LOCALAPPDATA, 'Atelier')
47
+ }
48
+ if (!absolute(homedir)) refuse('data-root-unresolvable', 'the home directory is not an absolute path')
49
+ if (platform === 'darwin') return flavor.join(homedir, 'Library', 'Application Support', 'Atelier')
50
+ // A relative XDG_DATA_HOME is invalid by its specification and is ignored.
51
+ return flavor.join(absolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : flavor.join(homedir, '.local', 'share'), 'atelier')
52
+ }
53
+
54
+ // The data root for this run: an injected root, else the project's pointer,
55
+ // else the local overlay preference, else the platform default. Pure. Under
56
+ // the Node test runner the platform default refuses, so a test that forgot to
57
+ // inject a root can never reach a person's real data directory.
58
+ export function resolveDataRoot({ dataRoot, pointer = null, project = null, platform = process.platform, env = process.env, homedir } = {}) {
59
+ const preference = project?.localOverlay?.overlay?.preferences?.[OBSIDIAN_EXT_KEY]
60
+ const configured = dataRoot ?? pointer?.dataRoot ?? (isPlainObject(preference) ? preference.dataRoot : undefined)
61
+ if (configured !== undefined) {
62
+ if (typeof configured !== 'string' || !path.isAbsolute(configured)) refuse('data-root-not-absolute', 'a configured data root must be an absolute path')
63
+ return path.resolve(configured)
64
+ }
65
+ if (env.NODE_TEST_CONTEXT !== undefined) refuse('real-data-root-under-test', 'the platform data directory is never used under the test runner; inject a data root')
66
+ return defaultDataRoot({ platform, env, ...(homedir === undefined ? {} : { homedir }) })
67
+ }
68
+
69
+ export function workspaceStateRoot(dataRoot, workspaceId) {
70
+ if (typeof workspaceId !== 'string' || !IDENTIFIER.test(workspaceId)) refuse('invalid-workspace-identity', 'a workspace identity must be a contract identifier')
71
+ return path.join(dataRoot, 'obsidian', segment(workspaceId))
72
+ }
73
+
74
+ // Every enrolled repository root plus the directories the project itself
75
+ // occupies. Private state may overlap none of them, in either direction.
76
+ export function protectedRoots(project) {
77
+ const roots = new Set()
78
+ for (const repo of project?.repos ?? []) if (!repo.external && typeof repo.path === 'string') roots.add(path.resolve(repo.path))
79
+ if (typeof project?.configDir === 'string') roots.add(path.resolve(project.configDir))
80
+ return [...roots].sort()
81
+ }
82
+
83
+ export function assertOutsideRepositories({ managedRoot, repositoryRoots }) {
84
+ const guard = checkManagedRoots({ managedRoots: [managedRoot], repositoryRoots })
85
+ if (!guard.ok) refuse(guard.refusals[0].code, guard.refusals[0].message, { refusals: guard.refusals.map(({ code }) => code) })
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // The pointer a project keeps in its ignored local state
90
+ // ---------------------------------------------------------------------------
91
+
92
+ function validatePointer(document) {
93
+ closedObject(document, { required: ['schema', 'workspaceId'], optional: ['dataRoot'] }, 'invalid-local-pointer', 'the Obsidian pointer')
94
+ if (document.schema !== LOCAL_POINTER_SCHEMA) refuse('invalid-local-pointer', 'the Obsidian pointer names an unknown schema')
95
+ if (typeof document.workspaceId !== 'string' || !IDENTIFIER.test(document.workspaceId)) refuse('invalid-local-pointer', 'the Obsidian pointer carries no usable workspace identity')
96
+ if (document.dataRoot !== undefined && (typeof document.dataRoot !== 'string' || !path.isAbsolute(document.dataRoot))) {
97
+ refuse('invalid-local-pointer', 'the Obsidian pointer names a data root that is not an absolute path')
98
+ }
99
+ return document
100
+ }
101
+
102
+ export const localPointerPath = (project) => path.join(localStateRoot(project), LOCAL_POINTER_FILE)
103
+
104
+ function readJsonFile(file, code, label) {
105
+ let text
106
+ try { text = readRegularTextNoFollow(file) } catch (error) {
107
+ if (error.code === 'ENOENT') return null
108
+ refuse(code, `${label} cannot be read`, { cause: error.code ?? String(error.message) })
109
+ }
110
+ try { return JSON.parse(text) } catch { return refuse(code, `${label} is not JSON`) }
111
+ }
112
+
113
+ export function readLocalPointer(project) {
114
+ const document = readJsonFile(localPointerPath(project), 'invalid-local-pointer', 'the Obsidian pointer')
115
+ return document === null ? null : validatePointer(document)
116
+ }
117
+
118
+ // The pointer is written only where the project's local state is proven
119
+ // ignored, so a machine-local value can never be committed by accident.
120
+ export function writeLocalPointer(project, pointer) {
121
+ validatePointer(pointer)
122
+ if (project?.localState?.ignored !== true) refuse('local-state-not-ignored', `${LOCAL_STATE_DIR}/ is not ignored in this project; the Obsidian pointer is not written`)
123
+ const directory = ensureContainedPrivateDirectory({ workspaceRoot: project.configDir, directory: localStateRoot(project), label: 'project local state' })
124
+ atomicReplacePrivateText(path.join(directory, LOCAL_POINTER_FILE), canonicalJson(pointer))
125
+ return pointer
126
+ }
127
+
128
+ // Reads the persisted workspace identity, or issues a random one and persists
129
+ // it. `randomBytes` is injectable so tests are deterministic.
130
+ export function ensureWorkspaceIdentity({ project, dataRoot, randomBytes = cryptoRandomBytes } = {}) {
131
+ const existing = readLocalPointer(project)
132
+ if (existing) return existing
133
+ const pointer = { schema: LOCAL_POINTER_SCHEMA, workspaceId: `ws-${randomBytes(12).toString('hex')}`, ...(dataRoot === undefined ? {} : { dataRoot: path.resolve(dataRoot) }) }
134
+ return writeLocalPointer(project, pointer)
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Machine settings
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function validateMachineSettings(document, workspaceId) {
142
+ closedObject(document, { required: ['schema', 'workspaceId', 'maintenanceMode', 'audienceAllow', 'applyPolicy', 'updatedAt'] }, 'invalid-machine-settings', 'machine settings')
143
+ if (document.schema !== MACHINE_SETTINGS_SCHEMA) refuse('invalid-machine-settings', 'machine settings name an unknown schema')
144
+ if (document.workspaceId !== workspaceId) refuse('invalid-machine-settings', 'machine settings belong to another workspace')
145
+ if (!MAINTENANCE_MODES.includes(document.maintenanceMode)) refuse('invalid-machine-settings', 'maintenanceMode must be manual or automatic')
146
+ if (!Array.isArray(document.audienceAllow) || document.audienceAllow.length > 64 || document.audienceAllow.some((item) => typeof item !== 'string' || !IDENTIFIER.test(item))
147
+ || new Set(document.audienceAllow).size !== document.audienceAllow.length) {
148
+ refuse('invalid-machine-settings', 'audienceAllow must be a list of distinct audience identifiers')
149
+ }
150
+ if (document.applyPolicy !== null) {
151
+ closedObject(document.applyPolicy, { required: ['policyId', 'version', 'digest'] }, 'invalid-machine-settings', 'the apply policy reference')
152
+ const { policyId, version, digest } = document.applyPolicy
153
+ if (typeof policyId !== 'string' || !IDENTIFIER.test(policyId) || !Number.isInteger(version) || version < 1 || typeof digest !== 'string' || !DIGEST.test(digest)) {
154
+ refuse('invalid-machine-settings', 'the apply policy reference is malformed')
155
+ }
156
+ }
157
+ if (typeof document.updatedAt !== 'string' || !TIMESTAMP.test(document.updatedAt)) refuse('invalid-machine-settings', 'updatedAt must be a UTC timestamp')
158
+ return document
159
+ }
160
+
161
+ // The defaults fail closed: edits are only queued, no audience is visible and
162
+ // no apply policy is installed until the person says otherwise.
163
+ export function defaultMachineSettings({ workspaceId, updatedAt }) {
164
+ return { schema: MACHINE_SETTINGS_SCHEMA, workspaceId, maintenanceMode: 'manual', audienceAllow: [], applyPolicy: null, updatedAt }
165
+ }
166
+
167
+ const settingsDirectory = (workspaceRoot) => ensureContainedPrivateDirectory({ workspaceRoot, directory: path.join(workspaceRoot, 'state', 'settings'), label: 'Obsidian machine settings' })
168
+ const settingsFile = (workspaceRoot, name) => path.join(workspaceRoot, 'state', 'settings', name)
169
+
170
+ export function readMachineSettings({ workspaceRoot, workspaceId }) {
171
+ const document = readJsonFile(settingsFile(workspaceRoot, 'machine.json'), 'invalid-machine-settings', 'machine settings')
172
+ return document === null ? null : validateMachineSettings(document, workspaceId)
173
+ }
174
+
175
+ export function writeMachineSettings({ workspaceRoot, workspaceId, settings, repositoryRoots }) {
176
+ validateMachineSettings(settings, workspaceId)
177
+ assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
178
+ fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
179
+ atomicReplacePrivateText(path.join(settingsDirectory(workspaceRoot), 'machine.json'), canonicalJson(settings))
180
+ return settings
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Installed apply policy
185
+ // ---------------------------------------------------------------------------
186
+
187
+ // `digestOf` is the canonical digest of a policy, handed in by the caller that installs for a person (the command):
188
+ // the decision that reads the policy recomputes that digest on every apply, so a policy that carries another one
189
+ // could be installed and would then refuse every automatic apply. With it, such a policy is refused here, naming the
190
+ // digest it has to carry. A digest is not a secret. The file of the person is never rewritten.
191
+ export function installApplyPolicy({ workspaceRoot, workspaceId, policy, repositoryRoots, updatedAt, digestOf = null }) {
192
+ try { assertObsidianContract('apply-policy', policy) } catch (error) {
193
+ if (error instanceof ObsidianContractRefusal) refuse('invalid-apply-policy', 'the apply policy does not satisfy its contract', { errors: error.detail?.errors ?? [] })
194
+ throw error
195
+ }
196
+ if (policy.workspaceId !== workspaceId) refuse('invalid-apply-policy', 'the apply policy belongs to another workspace')
197
+ if (digestOf !== null) {
198
+ const expected = digestOf(policy)
199
+ if (policy.digest !== expected) refuse('policy-digest-mismatch', `the policy does not carry the digest of its content; it has to carry ${expected}`, { expected, carried: policy.digest })
200
+ }
201
+ const current = readMachineSettings({ workspaceRoot, workspaceId }) ?? defaultMachineSettings({ workspaceId, updatedAt })
202
+ assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
203
+ fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
204
+ atomicReplacePrivateText(path.join(settingsDirectory(workspaceRoot), 'apply-policy.json'), canonicalJson(policy))
205
+ return writeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, settings: { ...current, applyPolicy: { policyId: policy.policyId, version: policy.version, digest: policy.digest }, updatedAt } })
206
+ }
207
+
208
+ // Whether an automatic dispatch is authorized right now. Reads both files
209
+ // from disk on every call; the engine calls this immediately before each
210
+ // dispatch, so a revocation or a pause takes effect for the very next edit.
211
+ // Anything missing, malformed or mismatched is "not authorized", never an
212
+ // error that could be mistaken for permission.
213
+ // `assumeAutomatic` answers "would automatic mode be authorized": the check `mode set automatic` makes before it writes.
214
+ export function authorizeAutomaticApply({ workspaceRoot, workspaceId, assumeAutomatic = false }) {
215
+ const denied = (reason) => ({ authorized: false, reason, policy: null })
216
+ let settings
217
+ try { settings = readMachineSettings({ workspaceRoot, workspaceId }) } catch { return denied('machine-settings-invalid') }
218
+ if (!settings) return denied('machine-settings-absent')
219
+ if (settings.maintenanceMode !== 'automatic' && assumeAutomatic !== true) return denied('maintenance-mode-manual')
220
+ if (settings.applyPolicy === null) return denied('no-apply-policy-installed')
221
+ let policy
222
+ try {
223
+ policy = readJsonFile(settingsFile(workspaceRoot, 'apply-policy.json'), 'invalid-apply-policy', 'the apply policy')
224
+ if (policy !== null) assertObsidianContract('apply-policy', policy)
225
+ } catch { return denied('apply-policy-invalid') }
226
+ if (policy === null) return denied('apply-policy-absent')
227
+ const reference = settings.applyPolicy
228
+ if (policy.workspaceId !== workspaceId || policy.policyId !== reference.policyId || policy.version !== reference.version || policy.digest !== reference.digest) {
229
+ return denied('apply-policy-reference-mismatch')
230
+ }
231
+ if (policy.mode !== 'automatic') return denied('apply-policy-manual')
232
+ if (policy.status !== 'active') return denied(`apply-policy-${policy.status}`)
233
+ return { authorized: true, reason: 'apply-policy-active', policy }
234
+ }
235
+
236
+ // The installed policy as stored, validated, or null. A stored policy that does not validate refuses.
237
+ export function readInstalledApplyPolicy({ workspaceRoot, workspaceId }) {
238
+ const policy = readJsonFile(settingsFile(workspaceRoot, 'apply-policy.json'), 'invalid-apply-policy', 'the apply policy')
239
+ if (policy === null) return null
240
+ try { assertObsidianContract('apply-policy', policy) } catch (error) {
241
+ if (error instanceof ObsidianContractRefusal) refuse('invalid-apply-policy', 'the stored apply policy does not satisfy its contract', { errors: error.detail?.errors ?? [] })
242
+ throw error
243
+ }
244
+ if (policy.workspaceId !== workspaceId) refuse('invalid-apply-policy', 'the stored apply policy belongs to another workspace')
245
+ return policy
246
+ }
247
+
248
+ // Revocation. The stored policy is marked revoked first: from that write on, every authorization read denies, so an
249
+ // apply that is queued behind it is never dispatched. Then maintenance goes back to manual, so installing a policy
250
+ // later does not resume automatic apply by itself.
251
+ export function revokeApplyPolicy({ workspaceRoot, workspaceId, repositoryRoots, updatedAt }) {
252
+ const policy = readInstalledApplyPolicy({ workspaceRoot, workspaceId })
253
+ const current = readMachineSettings({ workspaceRoot, workspaceId })
254
+ if (policy === null && (current === null || current.applyPolicy === null)) return { revoked: false, reason: 'no-apply-policy-installed', policy: null }
255
+ assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
256
+ const revoked = policy === null ? null : { ...policy, status: 'revoked' }
257
+ if (revoked !== null) atomicReplacePrivateText(path.join(settingsDirectory(workspaceRoot), 'apply-policy.json'), canonicalJson(revoked))
258
+ if (current !== null) writeMachineSettings({ workspaceRoot, workspaceId, repositoryRoots, settings: { ...current, maintenanceMode: 'manual', updatedAt } })
259
+ return { revoked: true, reason: policy?.status === 'revoked' ? 'already-revoked' : 'revoked', policy: revoked }
260
+ }
@@ -0,0 +1,158 @@
1
+ import { createHash } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { DOC_EXTENSIONS } from '../../graph/knowledge-graph.mjs'
5
+ import { localOverlayCandidatePaths } from '../../project/config.mjs'
6
+ import { openRegularFileNoFollow } from '../../project/private-state.mjs'
7
+ import { compareText } from './documents.mjs'
8
+
9
+ // Observation by digest. The git-based observation of the synchronization
10
+ // loop sees only what git tracks inside a repository; a vault is outside every
11
+ // repository and ignored by design, so this module looks at files directly and
12
+ // never asks git anything.
13
+ //
14
+ // Three layers, each only ever an accelerator for the next:
15
+ //
16
+ // watcher event names a path to look at now. Events may be lost, repeated
17
+ // or late on every platform; nothing is decided from one.
18
+ // stat hint size, modification time, change time and inode. A
19
+ // difference means "hash this file"; equality means "skip it
20
+ // on this tick". A hint never reports a change by itself.
21
+ // digest the only thing that decides. A full reconciliation hashes
22
+ // every observed file whatever its stat says, so a change
23
+ // the first two layers missed converges there.
24
+
25
+ export const sha256Digest = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`
26
+
27
+ const SKIPPED_DIRECTORIES = new Set(['node_modules'])
28
+ const SIDECAR_SUFFIX = '.kg.json'
29
+
30
+ export const sourceKey = (repoId, relative) => `source\u0000${repoId}\u0000${relative}`
31
+ export const configKey = (absolute) => `config\u0000${absolute}`
32
+ export const vaultKey = (scopeId, relative) => `vault\u0000${scopeId}\u0000${relative}`
33
+
34
+ function walk(root, relative, found) {
35
+ let entries
36
+ try { entries = fs.readdirSync(path.join(root, relative), { withFileTypes: true }) } catch (error) {
37
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return found
38
+ throw error
39
+ }
40
+ for (const entry of entries.sort((left, right) => compareText(left.name, right.name))) {
41
+ if (entry.name.startsWith('.') || SKIPPED_DIRECTORIES.has(entry.name)) continue
42
+ const child = relative === '' ? entry.name : `${relative}/${entry.name}`
43
+ // Links are not followed: a link could lead outside the repository.
44
+ if (entry.isDirectory()) walk(root, child, found)
45
+ else if (entry.isFile()) found.push(child)
46
+ }
47
+ return found
48
+ }
49
+
50
+ function sourceClass(relative, names) {
51
+ if (relative.endsWith(SIDECAR_SUFFIX)) return 'sidecar'
52
+ const extension = path.posix.extname(relative).toLowerCase()
53
+ if (extension === '.md') return 'source-body'
54
+ // Any other file is a source only when the census could enrol it: a document
55
+ // extension, or a file with a sidecar beside it.
56
+ return DOC_EXTENSIONS.has(extension) || names.has(`${relative}${SIDECAR_SUFFIX}`) ? 'asset' : null
57
+ }
58
+
59
+ // Every file whose bytes can change a view: source bodies, sidecars and the
60
+ // assets they enrol, in every enrolled repository. Deliberately a superset of
61
+ // the census (it does not apply ignore rules): observing one file too many
62
+ // costs a rebuild that produces the same generation, observing one too few
63
+ // would leave a view stale.
64
+ export function listSourceFiles(project, { assets = [] } = {}) {
65
+ const files = []
66
+ const roots = new Map()
67
+ for (const repo of project.repos ?? []) {
68
+ if (repo.external || typeof repo.path !== 'string' || typeof repo.name !== 'string') continue
69
+ roots.set(repo.name, repo.path)
70
+ const relatives = walk(repo.path, '', [])
71
+ const names = new Set(relatives)
72
+ for (const relative of relatives) {
73
+ const changeClass = sourceClass(relative, names)
74
+ if (changeClass) files.push({ key: sourceKey(repo.name, relative), changeClass, absolute: path.join(repo.path, relative), repoId: repo.name, relative })
75
+ }
76
+ }
77
+ // Embedded assets of the last built graph. The resolver accepts a file in a
78
+ // dot-directory, which the walk skips, and an image has no sidecar, so these
79
+ // are listed by name. Only assets a view may copy are passed in.
80
+ const listed = new Set(files.map((file) => file.key))
81
+ for (const asset of assets) {
82
+ const key = sourceKey(asset.repo, asset.path)
83
+ if (!roots.has(asset.repo) || listed.has(key)) continue
84
+ listed.add(key)
85
+ files.push({ key, changeClass: 'asset', absolute: path.join(roots.get(asset.repo), ...asset.path.split('/')), repoId: asset.repo, relative: asset.path })
86
+ }
87
+ return files
88
+ }
89
+
90
+ // The files that decide what a project is: its configuration, its repository
91
+ // access file and every place a local overlay may appear. A path that does
92
+ // not exist yet is observed too, so its appearance is a change.
93
+ export function listConfigFiles(project) {
94
+ const paths = new Set([project.configPath, project.repoAccessPath, ...localOverlayCandidatePaths({ configDir: project.configDir, env: {} }), ...(project.localOverlay?.paths ?? [])].filter((item) => typeof item === 'string'))
95
+ return [...paths].sort().map((absolute) => ({ key: configKey(absolute), changeClass: 'config', absolute }))
96
+ }
97
+
98
+ export function listVaultNotes({ scopeId, vaultRoot, manifest }) {
99
+ return (manifest?.notes ?? []).map((note) => ({ key: vaultKey(scopeId, note.path), changeClass: 'vault-note', absolute: path.join(vaultRoot, ...note.path.split('/')), scopeId, relative: note.path }))
100
+ }
101
+
102
+ function statHint(file, lstat) {
103
+ let stat
104
+ try { stat = lstat(file) } catch (error) {
105
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null
106
+ throw error
107
+ }
108
+ return stat.isFile() ? { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, ino: stat.ino } : null
109
+ }
110
+
111
+ // One read: the digest and the length are of the same bytes.
112
+ export function readFileFacts(file) {
113
+ let descriptor
114
+ try { descriptor = openRegularFileNoFollow(file) } catch (error) {
115
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'ELOOP' || error.message === 'state leaf is not a regular file') return null
116
+ throw error
117
+ }
118
+ try {
119
+ const bytes = fs.readFileSync(descriptor)
120
+ return { digest: sha256Digest(bytes), byteLength: bytes.length }
121
+ } finally { fs.closeSync(descriptor) }
122
+ }
123
+
124
+ const sameHint = (left, right) => left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs && left.ino === right.ino
125
+
126
+ // Reconciles `files` against `index` (a Map owned by the caller) and returns
127
+ // the changes, decided by digest alone. `full` hashes everything; otherwise a
128
+ // file is hashed when it is new, when its stat hint differs or when a watcher
129
+ // named it. Entries under `prefix` that are no longer listed are removals.
130
+ export function reconcile({ index, files, prefix, full, hinted = () => false, lstat = fs.lstatSync }) {
131
+ const changes = []
132
+ const present = new Set()
133
+ let hashed = 0
134
+ for (const file of files) {
135
+ present.add(file.key)
136
+ const previous = index.get(file.key)
137
+ const hint = statHint(file.absolute, lstat)
138
+ if (hint === null) {
139
+ if (previous && previous.digest !== null) changes.push({ key: file.key, changeClass: previous.changeClass, kind: 'removed' })
140
+ index.set(file.key, { changeClass: file.changeClass, hint: null, digest: null, byteLength: null })
141
+ continue
142
+ }
143
+ if (!full && previous && previous.digest !== null && previous.hint && sameHint(previous.hint, hint) && !hinted(file.key)) continue
144
+ const facts = readFileFacts(file.absolute)
145
+ const digest = facts?.digest ?? null
146
+ hashed += 1
147
+ if (!previous || previous.digest !== digest || previous.changeClass !== file.changeClass) {
148
+ if (digest !== null || (previous && previous.digest !== null)) changes.push({ key: file.key, changeClass: file.changeClass, kind: !previous || previous.digest === null ? 'added' : digest === null ? 'removed' : 'changed' })
149
+ }
150
+ index.set(file.key, { changeClass: file.changeClass, hint: digest === null ? null : hint, digest, byteLength: facts?.byteLength ?? null })
151
+ }
152
+ for (const [key, previous] of index) {
153
+ if (!key.startsWith(prefix) || present.has(key)) continue
154
+ if (previous.digest !== null) changes.push({ key, changeClass: previous.changeClass, kind: 'removed' })
155
+ index.delete(key)
156
+ }
157
+ return { changes, hashed }
158
+ }