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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (383) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +86 -25
  3. package/SECURITY.md +6 -1
  4. package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
  5. package/contracts/atelier-coauthor.v1.schema.json +163 -0
  6. package/contracts/atelier-decision-request.v1.schema.json +476 -0
  7. package/contracts/atelier-decision-result.v1.schema.json +500 -0
  8. package/contracts/atelier-guide.v1.schema.json +227 -0
  9. package/contracts/atelier-intake.v1.schema.json +151 -0
  10. package/contracts/atelier-migration.v2.schema.json +61 -0
  11. package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
  12. package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
  13. package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
  14. package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
  15. package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
  16. package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
  17. package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
  18. package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
  19. package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
  20. package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
  21. package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
  22. package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
  23. package/contracts/atelier-presentation.v1.schema.json +1293 -0
  24. package/contracts/atelier-repository-observation.v1.schema.json +163 -0
  25. package/contracts/atelier-review.v1.schema.json +1601 -0
  26. package/contracts/atelier-skill-steward.v1.schema.json +692 -0
  27. package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
  28. package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
  29. package/contracts/atelier-vault-publication.v1.schema.json +58 -0
  30. package/docs/assurance-controls.md +12 -2
  31. package/docs/atelier-runtime.md +13 -2
  32. package/docs/atelier-sync.md +172 -0
  33. package/docs/blocks/claims.md +28 -18
  34. package/docs/blocks/will-not-do.md +12 -3
  35. package/docs/coauthor-session.md +57 -0
  36. package/docs/connected-composition.md +75 -0
  37. package/docs/decisions.md +167 -0
  38. package/docs/exact-upgrades.md +159 -0
  39. package/docs/extension-pack-lifecycle.md +33 -0
  40. package/docs/guided-upgrades.md +99 -0
  41. package/docs/install.md +65 -9
  42. package/docs/intake-and-guides.md +65 -0
  43. package/docs/integration-and-review-plan.md +409 -0
  44. package/docs/integration-contract-decisions.md +22 -0
  45. package/docs/local-review.md +84 -0
  46. package/docs/local-services.md +227 -0
  47. package/docs/local-state.md +107 -0
  48. package/docs/obsidian-contract.md +687 -0
  49. package/docs/obsidian.md +387 -0
  50. package/docs/portable-presentation.md +328 -0
  51. package/docs/presentation-consumer-boundaries.md +75 -0
  52. package/docs/presentation-review-disposition.md +47 -0
  53. package/docs/project-command-inventory.md +38 -0
  54. package/docs/project-options.md +53 -0
  55. package/docs/release-engineering.md +99 -2
  56. package/docs/review-portability.md +55 -0
  57. package/docs/skill-steward.md +156 -0
  58. package/docs/upgrade.md +70 -2
  59. package/docs/vault-service.md +292 -0
  60. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  61. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  62. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  63. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  64. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  65. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  66. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  67. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  68. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  69. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  70. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  71. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  72. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  73. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  74. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  75. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  76. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  77. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  78. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  79. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  80. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  81. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  82. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  83. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  84. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  85. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  86. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  87. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  88. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  89. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  90. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  91. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  92. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  93. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  94. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  95. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  96. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  97. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  98. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  99. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  100. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  101. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  102. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  103. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  104. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  105. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  106. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  107. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  108. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  109. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  110. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  111. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  112. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  113. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  114. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  115. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  116. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  117. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  118. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  119. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  120. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  121. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  122. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  123. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  124. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  125. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  126. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  127. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  128. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  129. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  130. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  131. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  132. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  133. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  134. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  135. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  136. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  137. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  138. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  139. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  140. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  141. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  142. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  143. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  144. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  145. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  146. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  147. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  148. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  149. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  150. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  151. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  152. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  153. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  154. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  155. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  156. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  157. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  158. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  159. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  160. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  161. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  162. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  163. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  164. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  165. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  166. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  167. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  168. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  169. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  170. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  171. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  172. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  173. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  174. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  175. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  176. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  177. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  178. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  179. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  180. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  181. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  182. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  183. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  184. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  185. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  186. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  187. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  188. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  189. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  190. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  191. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  192. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  193. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  194. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  195. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  196. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  197. package/fixtures/obsidian/edits/workspace.json +65 -0
  198. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  199. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  200. package/fixtures/obsidian/materialization/workspace.json +54 -0
  201. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  202. package/fixtures/ui/presentation/pane.v1.json +7 -0
  203. package/fixtures/ui/presentation/reference.v1.json +293 -0
  204. package/package.json +61 -6
  205. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  206. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  207. package/skills/claude/atelier-open/SKILL.md +5 -0
  208. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  209. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  210. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  211. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  212. package/skills/codex/atelier-open/SKILL.md +5 -0
  213. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  214. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  215. package/src/access/preview-enforcer.mjs +84 -0
  216. package/src/boundary/content-rules.mjs +9 -4
  217. package/src/boundary/policy.mjs +74 -42
  218. package/src/cli/project-options.mjs +58 -0
  219. package/src/cli/run.mjs +61 -3
  220. package/src/coauthor/session.mjs +161 -0
  221. package/src/coauthor/store.mjs +173 -0
  222. package/src/collaboration/event-ledger.mjs +16 -33
  223. package/src/collaboration/inspection-bundle.mjs +223 -0
  224. package/src/collaboration/review-contracts.mjs +25 -0
  225. package/src/collaboration/review-store.mjs +305 -0
  226. package/src/commands/coauthor.mjs +26 -0
  227. package/src/commands/config.mjs +6 -1
  228. package/src/commands/extension-pack.mjs +15 -8
  229. package/src/commands/init.mjs +44 -5
  230. package/src/commands/obsidian.mjs +368 -0
  231. package/src/commands/review.mjs +147 -0
  232. package/src/commands/setup.mjs +51 -14
  233. package/src/commands/skills.mjs +199 -0
  234. package/src/commands/sync.mjs +107 -0
  235. package/src/composition/registry.mjs +27 -0
  236. package/src/composition/wire.mjs +122 -0
  237. package/src/contracts/corpus.mjs +35 -0
  238. package/src/decisions/contracts.d.mts +96 -0
  239. package/src/decisions/contracts.mjs +321 -0
  240. package/src/disclosure/content-scan.mjs +13 -0
  241. package/src/export/atelier-export-contract.mjs +16 -1
  242. package/src/extension-packs/lifecycle.mjs +154 -0
  243. package/src/graph/graph.mjs +64 -2
  244. package/src/graph/knowledge-graph.mjs +548 -24
  245. package/src/guides/contracts.mjs +54 -0
  246. package/src/index.mjs +29 -0
  247. package/src/intake/store.mjs +119 -0
  248. package/src/preview/channel.mjs +89 -0
  249. package/src/project/config.mjs +50 -49
  250. package/src/project/durable-state.mjs +161 -0
  251. package/src/project/file-class.mjs +166 -0
  252. package/src/project/private-state.mjs +11 -1
  253. package/src/projection/obsidian/contracts.mjs +571 -0
  254. package/src/projection/obsidian/edits/align.mjs +169 -0
  255. package/src/projection/obsidian/edits/apply.mjs +889 -0
  256. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  257. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  258. package/src/projection/obsidian/edits/index.mjs +19 -0
  259. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  260. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  261. package/src/projection/obsidian/edits/observe.mjs +123 -0
  262. package/src/projection/obsidian/edits/policy.mjs +123 -0
  263. package/src/projection/obsidian/edits/regions.mjs +648 -0
  264. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  265. package/src/projection/obsidian/materialize/index.mjs +7 -0
  266. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  267. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  268. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  269. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  270. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  271. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  272. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  273. package/src/projection/obsidian/proposals/index.mjs +23 -0
  274. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  275. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  276. package/src/projection/obsidian/proposals/router.mjs +168 -0
  277. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  278. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  279. package/src/projection/obsidian/publication/index.mjs +5 -0
  280. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  281. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  282. package/src/projection/obsidian/publication/transport.mjs +139 -0
  283. package/src/projection/obsidian/recovery/index.mjs +6 -0
  284. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  285. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  286. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  287. package/src/projection/obsidian/recovery/store.mjs +234 -0
  288. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  289. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  290. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  291. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  292. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  293. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  294. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  295. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  296. package/src/projection/project.mjs +28 -19
  297. package/src/readiness-protocols/evidence.mjs +282 -0
  298. package/src/readiness-protocols/runtime.mjs +4 -3
  299. package/src/readiness-protocols/source-read.mjs +39 -0
  300. package/src/runtime/git-adapter.mjs +189 -0
  301. package/src/runtime/local-state.mjs +439 -0
  302. package/src/runtime/obsidian/app-capability.mjs +110 -0
  303. package/src/runtime/obsidian/app-production-seams.mjs +96 -0
  304. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  305. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  306. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  307. package/src/runtime/obsidian/contributions.mjs +27 -0
  308. package/src/runtime/obsidian/documents.mjs +37 -0
  309. package/src/runtime/obsidian/enablement.mjs +53 -0
  310. package/src/runtime/obsidian/engine.mjs +532 -0
  311. package/src/runtime/obsidian/errors.mjs +14 -0
  312. package/src/runtime/obsidian/extension-points.mjs +103 -0
  313. package/src/runtime/obsidian/index.mjs +34 -0
  314. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  315. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  316. package/src/runtime/obsidian/observation.mjs +158 -0
  317. package/src/runtime/obsidian/opening.mjs +243 -0
  318. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  319. package/src/runtime/obsidian/pipeline.mjs +122 -0
  320. package/src/runtime/obsidian/private-lock.mjs +156 -0
  321. package/src/runtime/obsidian/process-identity.mjs +58 -0
  322. package/src/runtime/obsidian/service-client.mjs +66 -0
  323. package/src/runtime/obsidian/service-main.mjs +89 -0
  324. package/src/runtime/obsidian/service-record.mjs +169 -0
  325. package/src/runtime/obsidian/service-server.mjs +121 -0
  326. package/src/runtime/obsidian/service.mjs +216 -0
  327. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  328. package/src/runtime/obsidian/state-store.mjs +180 -0
  329. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  330. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  331. package/src/runtime/obsidian/watchers.mjs +36 -0
  332. package/src/runtime/repository-observation.mjs +491 -0
  333. package/src/runtime/supervisor.mjs +803 -0
  334. package/src/server/local-sidecar.mjs +44 -0
  335. package/src/server/server.mjs +1 -0
  336. package/src/skills/steward.mjs +780 -0
  337. package/src/ui/presentation/browser.mjs +278 -0
  338. package/src/ui/presentation/contract.mjs +116 -0
  339. package/src/ui/presentation/index.mjs +7 -0
  340. package/src/ui/presentation/native.mjs +153 -0
  341. package/src/ui/presentation/proof.mjs +57 -0
  342. package/src/ui/presentation/schema-check.mjs +30 -0
  343. package/src/ui/presentation/schema.generated.mjs +1371 -0
  344. package/src/ui/presentation/state.mjs +43 -0
  345. package/src/ui/presentation/styles.mjs +67 -0
  346. package/src/ui/presentation/tokens.mjs +74 -0
  347. package/src/ui/presentation/web.mjs +85 -0
  348. package/src/ui/review-page.mjs +32 -0
  349. package/src/upgrade/explanation.mjs +33 -0
  350. package/src/upgrade/provenance.mjs +338 -0
  351. package/src/upgrade/transaction-files.mjs +103 -0
  352. package/src/upgrade/transaction.mjs +475 -0
  353. package/src/upgrade/upgrade.mjs +63 -28
  354. package/src/vault/hosts.mjs +21 -0
  355. package/src/vault/identity.mjs +19 -0
  356. package/src/vault/index.mjs +7 -0
  357. package/src/vault/interface.mjs +11 -0
  358. package/src/vault/metadata.mjs +31 -0
  359. package/src/vault/privacy.mjs +148 -0
  360. package/src/vault/probe.mjs +89 -0
  361. package/src/vault/service.mjs +131 -0
  362. package/src/vault/source.mjs +45 -0
  363. package/src/vault/storage.mjs +23 -0
  364. package/templates/distribution-workspace/.claude/launch.json +6 -2
  365. package/templates/distribution-workspace/gitignore +1 -0
  366. package/templates/distribution-workspace/project/README.md +2 -2
  367. package/templates/external-project-workspace/README.md +45 -0
  368. package/templates/external-project-workspace/answers.example.json +4 -0
  369. package/templates/external-project-workspace/atelier.project.json +40 -0
  370. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  371. package/templates/external-project-workspace/gitignore +8 -0
  372. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  373. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  374. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  375. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  376. package/templates/external-project-workspace/source/README.md +11 -0
  377. package/templates/launch.json +6 -2
  378. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  379. package/templates/private-domain-workspace/domain/README.md +2 -2
  380. package/templates/private-domain-workspace/gitignore +1 -0
  381. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  382. package/templates/shared-project-workspace/gitignore +1 -0
  383. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID, createHash } from 'node:crypto';
4
+ import { hostname } from 'node:os';
5
+ import { ensureContainedPrivateDirectory, openRegularFileNoFollow, readRegularTextNoFollow, syncPrivateDirectory } from './private-state.mjs';
6
+
7
+ const host = createHash('sha256').update(hostname()).digest('hex');
8
+ const pendingPattern = /^\.atelier-write-[a-f0-9-]{36}\.tmp$/;
9
+ export const isPendingPrivateWrite = name => pendingPattern.test(name);
10
+
11
+ export { syncPrivateDirectory };
12
+
13
+ // Publish a complete immutable file with an atomic, non-overwriting hard link.
14
+ // A process interruption can leave a private staging file, never a partial final.
15
+ export function publishPrivateFile(file, bytes) {
16
+ const directory = path.dirname(file);
17
+ const pending = path.join(directory, `.atelier-write-${randomUUID()}.tmp`);
18
+ let fd;
19
+ try {
20
+ fd = openRegularFileNoFollow(pending, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
21
+ fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
22
+ try { fs.linkSync(pending, file); }
23
+ catch (error) {
24
+ if (error.code !== 'EEXIST') throw error;
25
+ const existing = openRegularFileNoFollow(file);
26
+ try { if (!fs.readFileSync(existing).equals(Buffer.from(bytes))) throw error; }
27
+ finally { fs.closeSync(existing); }
28
+ }
29
+ syncPrivateDirectory(directory);
30
+ const actual = openRegularFileNoFollow(file);
31
+ try { if (!fs.readFileSync(actual).equals(Buffer.from(bytes))) throw new Error('published file readback mismatch'); }
32
+ finally { fs.closeSync(actual); }
33
+ } finally {
34
+ if (fd !== undefined) fs.closeSync(fd);
35
+ try { fs.unlinkSync(pending); } catch (error) { if (error.code !== 'ENOENT') throw error; }
36
+ }
37
+ }
38
+
39
+ function unavailable(diagnostic = { reason: 'ownership changed' }) {
40
+ return Object.assign(new Error(`EEXIST: private state is locked (${diagnostic.reason}${diagnostic.file ? `: ${diagnostic.file}` : ''}); inspectPrivateLock and preserve ownership records before offline recovery`), { code: 'EEXIST', diagnostic });
41
+ }
42
+ function ownerState(record, legacy = false) {
43
+ const pid = typeof record === 'number' ? record : record?.pid;
44
+ if (!Number.isSafeInteger(pid) || pid <= 0) return 'invalid owner';
45
+ if (!legacy && record?.host !== host) return 'host identity differs';
46
+ try { process.kill(pid, 0); return 'process exists'; }
47
+ catch (error) { return error.code === 'ESRCH' ? 'absent' : 'process identity unavailable'; }
48
+ }
49
+
50
+ // Read-only diagnosis. Older tickets were superseded by a successful successor;
51
+ // only the newest ticket owns this lock. A release marker prevents a completed
52
+ // successor from disappearing and resurrecting an obsolete owner on PID reuse.
53
+ export function inspectPrivateLock(lockPath) {
54
+ const directory = `${lockPath}.owners`;
55
+ let names;
56
+ try {
57
+ const stat = fs.lstatSync(directory);
58
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return { available: false, reason: 'unsafe ownership directory' };
59
+ names = fs.readdirSync(directory).filter(name => !isPendingPrivateWrite(name)).sort();
60
+ } catch (error) { if (error.code !== 'ENOENT') throw error; names = []; }
61
+ const unexpected = names.find(name => !/^\d{12}\.(json|released)$/.test(name));
62
+ if (unexpected) return { available: false, reason: 'unknown ownership file', file: unexpected };
63
+ const nameSet = new Set(names);
64
+ const tickets = names.filter(name => name.endsWith('.json'));
65
+ const orphan = names.find(name => name.endsWith('.released') && !nameSet.has(name.replace(/\.released$/, '.json')));
66
+ if (orphan) return { available: false, reason: 'release without owner', file: orphan };
67
+ let legacy, legacyDigest = null;
68
+ try {
69
+ const bytes = readRegularTextNoFollow(lockPath);
70
+ legacy = JSON.parse(bytes); legacyDigest = createHash('sha256').update(bytes).digest('hex');
71
+ } catch (error) {
72
+ if (error.code !== 'ENOENT') return { available: false, reason: 'unreadable legacy owner', file: path.basename(lockPath) };
73
+ }
74
+ if (tickets.length) {
75
+ const file = tickets.at(-1), generation = Number(file.slice(0, 12));
76
+ let body, record;
77
+ try { body = readRegularTextNoFollow(path.join(directory, file)); record = JSON.parse(body); }
78
+ catch { return { available: false, reason: 'unreadable owner', file }; }
79
+ if (!Number.isSafeInteger(record?.pid) || record.pid <= 0 || !/^[a-f0-9]{64}$/.test(record?.host) || !/^[a-f0-9-]{36}$/.test(record?.nonce)) return { available: false, reason: 'invalid owner', file };
80
+ if (legacyDigest !== null && record.legacyDigest !== legacyDigest) {
81
+ const reason = ownerState(legacy, true);
82
+ if (reason !== 'absent') return { available: false, reason: `legacy ${reason}`, file: path.basename(lockPath) };
83
+ }
84
+ const marker = file.replace(/\.json$/, '.released');
85
+ if (names.includes(marker)) {
86
+ try {
87
+ if (readRegularTextNoFollow(path.join(directory, marker)) !== body) return { available: false, reason: 'release identity differs', file: marker };
88
+ } catch { return { available: false, reason: 'unreadable release', file: marker }; }
89
+ return { available: true, reason: 'released', generation, file, legacyDigest };
90
+ }
91
+ const reason = ownerState(record);
92
+ return { available: reason === 'absent', reason, generation, file, legacyDigest };
93
+ }
94
+ if (legacyDigest === null) return { available: true, reason: 'unused', generation: 0, legacyDigest };
95
+ const reason = ownerState(legacy, true);
96
+ return { available: reason === 'absent', reason, generation: 0, file: path.basename(lockPath), legacyDigest };
97
+ }
98
+
99
+ export function acquirePrivateLock(lockPath) {
100
+ const parent = path.dirname(lockPath);
101
+ const directory = ensureContainedPrivateDirectory({ workspaceRoot: parent, directory: `${lockPath}.owners`, label: 'private lock' });
102
+ let ticket, body;
103
+ try {
104
+ const status = inspectPrivateLock(lockPath);
105
+ if (!status.available) throw unavailable(status);
106
+ const generation = status.generation;
107
+ if (generation >= 999999999999) throw unavailable({ reason: 'ownership generation exhausted' });
108
+ ticket = path.join(directory, `${String(generation + 1).padStart(12, '0')}.json`);
109
+ body = JSON.stringify({ pid: process.pid, host, nonce: randomUUID(), legacyDigest: status.legacyDigest });
110
+ publishPrivateFile(ticket, body);
111
+ let released = false;
112
+ return () => {
113
+ if (released) return;
114
+ if (readRegularTextNoFollow(ticket) !== body) throw unavailable();
115
+ publishPrivateFile(ticket.replace(/\.json$/, '.released'), body);
116
+ released = true;
117
+ };
118
+ } catch (error) {
119
+ // No operation has started: clean up only this exact attempted owner.
120
+ if (ticket && body) {
121
+ try { if (readRegularTextNoFollow(ticket) === body) fs.unlinkSync(ticket); }
122
+ catch { /* Preserve uncertain ownership for offline diagnosis. */ }
123
+ }
124
+ if (error.code === 'EEXIST' && !error.diagnostic) throw unavailable(); throw error;
125
+ }
126
+ }
127
+
128
+ export function withPrivateLock(lockPath, operation) {
129
+ const release = acquirePrivateLock(lockPath);
130
+ try { return operation(); } finally { release(); }
131
+ }
132
+
133
+ // Revalidate file identities on every read; unchanged prefixes reuse their
134
+ // verified result. A changed existing file forces replay, a missing tail is
135
+ // refused for the lifetime of this reader. Startup always verifies all history.
136
+ export function createVerifiedFileSequence({ directory, initial, apply, ignoreFiles = [] }) {
137
+ let names = [], identities = [], value = initial();
138
+ const identity = file => {
139
+ const s = fs.lstatSync(file, { bigint: true });
140
+ if (!s.isFile() || s.isSymbolicLink()) throw new Error('non-regular history refused');
141
+ return [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs].join(':');
142
+ };
143
+ return () => {
144
+ const listed = fs.readdirSync(directory).filter(n => !isPendingPrivateWrite(n)).sort();
145
+ // Explicit adapter metadata exceptions must still be regular files. Never
146
+ // hide unknown filenames or malformed committed records behind a glob.
147
+ for (const name of listed.filter(n => ignoreFiles.includes(n))) identity(path.join(directory, name));
148
+ const next = listed.filter(n => !ignoreFiles.includes(n));
149
+ if (next.length < names.length) throw new Error('history shortened; preserve and inspect');
150
+ const ids = next.map(n => identity(path.join(directory, n)));
151
+ const reuse = names.every((n, i) => n === next[i] && identities[i] === ids[i]);
152
+ let result = reuse ? value : initial();
153
+ for (let i = reuse ? names.length : 0; i < next.length; i++) {
154
+ const file = path.join(directory, next[i]);
155
+ result = apply(readRegularTextNoFollow(file), result, i + 1, next[i]);
156
+ if (identity(file) !== ids[i]) throw new Error('history changed during verification');
157
+ }
158
+ names = next; identities = ids; value = result;
159
+ return structuredClone(result);
160
+ };
161
+ }
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
1
3
  import { basenameOf, matchesPathPattern, normalizeRelPath } from './path-match.mjs'
2
4
 
3
5
  export const FILE_CLASS_SCHEMA = 'mnstry.atelier-file-classes@v1'
@@ -6,8 +8,13 @@ export const SOURCE = 'source'
6
8
  export const GENERATED_PROJECTION = 'generated-projection'
7
9
  export const DISTRIBUTED_RUNTIME_COPY = 'distributed-runtime-copy'
8
10
 
11
+ // Declarable classes for tracked files. The kit manifest schema closes this set.
9
12
  export const FILE_CLASSES = Object.freeze([SOURCE, GENERATED_PROJECTION, DISTRIBUTED_RUNTIME_COPY])
10
13
 
14
+ // A machine-local file that git never tracks. It is not declarable: nothing
15
+ // tracked can be ignored-local, so it stays out of FILE_CLASSES.
16
+ export const IGNORED_LOCAL = 'ignored-local'
17
+
11
18
  // What each class means for automated handling. Sync loops, merge policies, upgrade
12
19
  // tooling, and CI guards all read this instead of each keeping a list of filenames
13
20
  // that drifts out of step with the others.
@@ -15,8 +22,17 @@ export const FILE_CLASS_HANDLING = Object.freeze({
15
22
  [SOURCE]: Object.freeze({ rederivable: false, discardable: false, conflictsNeedHuman: true }),
16
23
  [GENERATED_PROJECTION]: Object.freeze({ rederivable: true, discardable: true, conflictsNeedHuman: false }),
17
24
  [DISTRIBUTED_RUNTIME_COPY]: Object.freeze({ rederivable: true, discardable: true, conflictsNeedHuman: false }),
25
+ // Holds this machine's only reference to external editable state. Never
26
+ // swept, never synced, never a merge conflict.
27
+ [IGNORED_LOCAL]: Object.freeze({ rederivable: false, discardable: false, conflictsNeedHuman: false }),
18
28
  })
19
29
 
30
+ // The one repo-local file the Obsidian view leaves in a repository or
31
+ // workspace: a pointer to the external data directory and its local settings.
32
+ // It lives under the existing ignored local-state directory.
33
+ export const OBSIDIAN_LOCAL_POINTER = '.atelier-local/obsidian.json'
34
+ export const IGNORED_LOCAL_PATTERNS = Object.freeze([`**/${OBSIDIAN_LOCAL_POINTER}`])
35
+
20
36
  // The kit's own declaration — the single place these paths are classified. A
21
37
  // distributed-runtime-copy is canonical in exactly one repo role and rederivable
22
38
  // everywhere else; that is why the class carries `canonicalRepoRole` rather than
@@ -75,6 +91,12 @@ export function validateFileClasses(entries, { label = 'fileClasses' } = {}) {
75
91
  */
76
92
  export function classifyPath(filePath, { repoRole = null, fileClasses = KIT_FILE_CLASSES } = {}) {
77
93
  const rel = normalizeRelPath(filePath)
94
+ // Not overridable: a declaration cannot turn a local pointer into something
95
+ // a sync loop may discard or commit.
96
+ const localPattern = IGNORED_LOCAL_PATTERNS.find((pattern) => matchesPathPattern(pattern, rel))
97
+ if (localPattern) {
98
+ return { path: rel, class: IGNORED_LOCAL, pattern: localPattern, canonicalRepoRole: null, canonicalHere: true, declared: false, handling: FILE_CLASS_HANDLING[IGNORED_LOCAL] }
99
+ }
78
100
  let matched = null
79
101
  for (const entry of fileClasses) {
80
102
  if (matchesPathPattern(entry.pattern, rel)) matched = entry
@@ -125,3 +147,147 @@ export function generatedProjectionDirectoryBasenames(fileClasses = KIT_FILE_CLA
125
147
  }
126
148
  return names
127
149
  }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // External managed data root: <data>/obsidian/<workspace-id>/
153
+ // ---------------------------------------------------------------------------
154
+
155
+ export const EDITABLE_VAULT = 'editable-vault'
156
+ export const TRUSTED_STATE = 'trusted-state'
157
+ export const RECOVERY_RECORD = 'recovery-record'
158
+ export const DISPOSABLE_STAGING = 'disposable-staging'
159
+ export const UNKNOWN_MANAGED = 'unknown-managed'
160
+
161
+ // Only staging is discardable. A vault holds a person's edits and recovery
162
+ // holds the only other copy of them, so neither is ever generated output, and
163
+ // anything unrecognized is kept.
164
+ export const MANAGED_AREA_HANDLING = Object.freeze({
165
+ [EDITABLE_VAULT]: Object.freeze({ rederivable: false, discardable: false, conflictsNeedHuman: true }),
166
+ [TRUSTED_STATE]: Object.freeze({ rederivable: false, discardable: false, conflictsNeedHuman: true }),
167
+ [RECOVERY_RECORD]: Object.freeze({ rederivable: false, discardable: false, conflictsNeedHuman: true }),
168
+ [DISPOSABLE_STAGING]: Object.freeze({ rederivable: true, discardable: true, conflictsNeedHuman: false }),
169
+ [UNKNOWN_MANAGED]: Object.freeze({ rederivable: false, discardable: false, conflictsNeedHuman: true }),
170
+ })
171
+
172
+ const MANAGED_AREAS = Object.freeze({ vaults: EDITABLE_VAULT, state: TRUSTED_STATE, recovery: RECOVERY_RECORD, staging: DISPOSABLE_STAGING })
173
+
174
+ // Classify a path relative to one workspace's managed data root. Matching is
175
+ // exact and case-sensitive, and a path that climbs, is absolute or names only
176
+ // the area directory itself is never discardable.
177
+ export function classifyManagedPath(relativePath) {
178
+ const rel = normalizeRelPath(relativePath).replace(/\/+$/, '')
179
+ const segments = rel.split('/')
180
+ const contained = rel !== '' && !rel.startsWith('/') && !/^[A-Za-z]:/.test(rel) && !segments.some((part) => part === '..' || part === '.' || part === '')
181
+ const area = contained && segments.length > 1 && Object.hasOwn(MANAGED_AREAS, segments[0]) ? segments[0] : null
182
+ const resolved = area ? MANAGED_AREAS[area] : UNKNOWN_MANAGED
183
+ return { path: rel, area, class: resolved, handling: MANAGED_AREA_HANDLING[resolved] }
184
+ }
185
+
186
+ // The real path of a target that may not exist yet: the deepest existing
187
+ // ancestor is resolved and the missing tail is appended. Only "does not exist"
188
+ // moves up a level. Any other failure (a directory that cannot be searched,
189
+ // a link loop) is thrown: a lexical path standing in for a real one would
190
+ // weaken the alias check exactly where it matters.
191
+ function realLocation(target, realpath) {
192
+ const missing = []
193
+ let current = path.resolve(target)
194
+ for (;;) {
195
+ try {
196
+ return path.join(realpath(current), ...missing.reverse())
197
+ } catch (error) {
198
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error
199
+ const parent = path.dirname(current)
200
+ if (parent === current) return path.resolve(target)
201
+ missing.push(path.basename(current))
202
+ current = parent
203
+ }
204
+ }
205
+ }
206
+
207
+ const containsPath = (outer, inner) => inner === outer || inner.startsWith(outer.endsWith(path.sep) ? outer : `${outer}${path.sep}`)
208
+
209
+ function isSymbolicLink(target, lstat) {
210
+ try {
211
+ return lstat(target).isSymbolicLink()
212
+ } catch {
213
+ return false
214
+ }
215
+ }
216
+
217
+ // True when the path or any of its ancestors is a symbolic link.
218
+ function crossesSymbolicLink(target, lstat) {
219
+ for (let current = path.resolve(target); ; current = path.dirname(current)) {
220
+ if (isSymbolicLink(current, lstat)) return true
221
+ if (path.dirname(current) === current) return false
222
+ }
223
+ }
224
+
225
+ // Refuse a managed root that overlaps an enrolled repository in either
226
+ // direction, under lexical or real paths. `realpath` and `lstat` are the only
227
+ // filesystem reads, and both can be supplied; nothing is created or changed.
228
+ // A real path that cannot be established refuses; it is never replaced by the
229
+ // lexical path.
230
+ export function checkManagedRoots({ managedRoots = [], repositoryRoots = [], realpath = fs.realpathSync.native, lstat = fs.lstatSync } = {}) {
231
+ const refusals = []
232
+ const refuse = (code, managedRoot, repositoryRoot, message) => refusals.push({ code, managedRoot, repositoryRoot, message })
233
+ const locate = (root) => {
234
+ try {
235
+ return { given: root, lexical: path.resolve(root), real: realLocation(root, realpath) }
236
+ } catch (error) {
237
+ return { given: root, lexical: path.resolve(root), real: null, errorCode: error?.code ?? 'unknown' }
238
+ }
239
+ }
240
+ const repositories = repositoryRoots.map(locate)
241
+
242
+ for (const managedRoot of managedRoots) {
243
+ if (typeof managedRoot !== 'string' || !path.isAbsolute(managedRoot)) {
244
+ refuse('managed-root-not-absolute', managedRoot, null, 'a managed root must be an absolute path')
245
+ continue
246
+ }
247
+ const managed = locate(managedRoot)
248
+ if (managed.real === null) {
249
+ refuse('managed-root-realpath-failed', managedRoot, null, `the real path of a managed root cannot be established (${managed.errorCode})`)
250
+ continue
251
+ }
252
+ if (isSymbolicLink(managed.lexical, lstat)) {
253
+ refuse('managed-root-symlink-alias', managedRoot, null, 'a managed root must not be a symbolic link')
254
+ }
255
+ for (const repository of repositories) {
256
+ const inside = (kind) => containsPath(repository[kind], managed[kind])
257
+ const around = (kind) => containsPath(managed[kind], repository[kind])
258
+ if (inside('lexical')) refuse('managed-root-inside-repository', managedRoot, repository.given, 'a managed root must sit outside every enrolled repository')
259
+ else if (around('lexical')) refuse('repository-inside-managed-root', managedRoot, repository.given, 'an enrolled repository must not sit inside a managed root')
260
+ else if (repository.real === null) {
261
+ refuse('managed-root-realpath-failed', managedRoot, repository.given, `the real path of an enrolled repository cannot be established (${repository.errorCode})`)
262
+ } else if (inside('real') || around('real')) {
263
+ // Name the cause that is actually there. Folding letter case and
264
+ // Unicode normalization alone may explain the overlap; otherwise a
265
+ // symbolic link on either path does; otherwise something else presents
266
+ // one location under two names (a mount, for example).
267
+ const folded = (value) => value.normalize('NFC').toLowerCase()
268
+ const byFolding = containsPath(folded(repository.lexical), folded(managed.lexical)) || containsPath(folded(managed.lexical), folded(repository.lexical))
269
+ if (!byFolding && (crossesSymbolicLink(managed.lexical, lstat) || crossesSymbolicLink(repository.lexical, lstat))) {
270
+ refuse('managed-root-symlink-alias', managedRoot, repository.given, 'a symbolic link makes the managed root and an enrolled repository overlap')
271
+ } else {
272
+ refuse('managed-root-realpath-overlap', managedRoot, repository.given,
273
+ 'the managed root and an enrolled repository are written differently but name overlapping locations, and no symbolic link explains it: look for a difference in letter case or Unicode normalization on a filesystem that folds them, or a mount that presents one location under two names')
274
+ }
275
+ }
276
+ }
277
+ }
278
+ return { ok: refusals.length === 0, refusals }
279
+ }
280
+
281
+ // The same guard at enrolment time: a repository that would contain, sit
282
+ // inside or alias an existing managed root cannot be enrolled.
283
+ export function checkRepositoryEnrollment({ repositoryRoot, managedRoots = [], realpath, lstat } = {}) {
284
+ const { refusals } = checkManagedRoots({ managedRoots, repositoryRoots: [repositoryRoot], ...(realpath ? { realpath } : {}), ...(lstat ? { lstat } : {}) })
285
+ const codes = {
286
+ 'managed-root-inside-repository': 'enrollment-contains-managed-root',
287
+ 'repository-inside-managed-root': 'enrollment-inside-managed-root',
288
+ 'managed-root-symlink-alias': 'enrollment-aliases-managed-root',
289
+ 'managed-root-realpath-overlap': 'enrollment-realpath-overlaps-managed-root',
290
+ }
291
+ const blocking = refusals.filter((item) => item.repositoryRoot !== null).map((item) => ({ ...item, code: codes[item.code] ?? item.code }))
292
+ return { ok: blocking.length === 0, refusals: blocking }
293
+ }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
3
4
 
4
5
  function escapes(root, candidate) {
5
6
  const relative = path.relative(root, candidate)
@@ -79,12 +80,20 @@ export function readRegularTextNoFollow(file) {
79
80
  }
80
81
  }
81
82
 
83
+ export function syncPrivateDirectory(directory) {
84
+ let fd;
85
+ try { fd = fs.openSync(directory, fs.constants.O_RDONLY); fs.fsyncSync(fd); }
86
+ catch (error) {
87
+ if (process.platform !== 'win32' || !['EPERM', 'EACCES', 'EINVAL', 'EISDIR'].includes(error.code)) throw error;
88
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
89
+ }
90
+
82
91
  export function atomicReplacePrivateText(file, text, mode = 0o600) {
83
92
  const existing = lstatIfPresent(file)
84
93
  if (existing && (existing.isSymbolicLink() || !existing.isFile())) {
85
94
  throw new Error('state leaf is not a regular file')
86
95
  }
87
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
96
+ const tmp = `${file}.${randomUUID()}.tmp`
88
97
  let descriptor
89
98
  try {
90
99
  descriptor = openRegularFileNoFollow(
@@ -98,6 +107,7 @@ export function atomicReplacePrivateText(file, text, mode = 0o600) {
98
107
  fs.closeSync(descriptor)
99
108
  descriptor = null
100
109
  fs.renameSync(tmp, file)
110
+ syncPrivateDirectory(path.dirname(file))
101
111
  } catch (error) {
102
112
  if (descriptor != null) fs.closeSync(descriptor)
103
113
  try {