@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,475 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { createRequire } from 'node:module'
5
+ import { resolveGitExecutable, runGit, parseNullConfig } from '../runtime/git-adapter.mjs'
6
+ import { resolveProjectConfig } from '../project/config.mjs'
7
+ import { validateJsonSchema } from '../export/atelier-export-contract.mjs'
8
+ import { buildAtelierLock } from './upgrade.mjs'
9
+ import { checkBoundaryPolicy, loadBoundaryPolicy } from '../boundary/policy.mjs'
10
+ import { buildGraph } from '../graph/graph.mjs'
11
+ import { buildProjectProjection, buildProjectManifest } from '../projection/project.mjs'
12
+ import { buildReadiness } from '../readiness/readiness.mjs'
13
+ import { hashBytes, hashObject, same, jsonText, contained, readBytes, fileState, inventory, syncDirectory, publish, privateDirectory, replaceExpected, MAX_BYTES } from './transaction-files.mjs'
14
+
15
+ const packageRoot = fileURLToPath(new URL('../../', import.meta.url))
16
+ const PRIVATE = '.atelier-local/upgrades'
17
+ const POLICY = 'atelier.adoption-policy.json'
18
+ const SCHEMAS = { policy: 'atelier-adoption-policy.v1', plan: 'atelier-upgrade-plan.v2', migration: 'atelier-migration.v2', event: 'atelier-upgrade-receipt.v1', lock: 'atelier-lock.v1' }
19
+ const schemaDocs = Object.fromEntries(Object.entries(SCHEMAS).map(([key, name]) => [key, JSON.parse(fs.readFileSync(path.join(packageRoot, 'contracts', `${name}.schema.json`)))]))
20
+ export function validateUpgradeDocument(kind, doc) {
21
+ const errors = validateJsonSchema(schemaDocs[kind], doc)
22
+ if (errors.length) throw new Error(`invalid upgrade ${kind}: ${errors.join('; ')}`)
23
+ return doc
24
+ }
25
+ const readJson = (file) => JSON.parse(readBytes(file))
26
+ const git = (root, args, options = {}) => runGit(resolveGitExecutable(), root, ['-c', 'core.fsmonitor=false', ...(args[0] === 'check-ignore' ? [] : ['--literal-pathspecs']), ...args], options)
27
+ const text = (root, args) => git(root, args).stdout.trim()
28
+ const snapshot = (root) => inventory(root, { exclude: ['.git', '.atelier-local'] })
29
+
30
+ function privateRoot(root) {
31
+ // An ignored pathname alone is insufficient when its files were force-added.
32
+ if (text(root, ['ls-files', '--', '.atelier-local']).length || !git(root, ['check-ignore', '-q', '.atelier-local/upgrades/probe'], { allowFailure: true }).ok) throw new Error('private upgrade state must be ignored and untracked')
33
+ return privateDirectory(root, PRIVATE)
34
+ }
35
+ function policy(root) {
36
+ const result = validateUpgradeDocument('policy', readJson(contained(root, POLICY)))
37
+ if (!result.enabled) throw new Error('upgrade policy is disabled')
38
+ return result
39
+ }
40
+ function canonicalProject(project) {
41
+ const root = fs.realpathSync(project.configDir)
42
+ const remap = (value) => value ? path.resolve(root, path.relative(project.configDir, value)) : value
43
+ const copy = { ...project, configDir: root, repos: project.repos.map((repo) => ({ ...repo, path: remap(repo.path) })) }
44
+ for (const key of ['configPath', 'workspaceRoot', 'repoOpsRoot', 'repoAccessPath', 'boundaryPolicyPath', 'graphPath', 'readinessPath', 'outputRoot']) copy[key] = remap(project[key])
45
+ return copy
46
+ }
47
+ function checkProject(project) {
48
+ if (Object.keys(process.env).some((key) => /^GIT_(?!PAGER$|TERMINAL_PROMPT$|OPTIONAL_LOCKS$)/i.test(key))) throw new Error('Git environment overrides unsupported during exact preparation')
49
+ const root = fs.realpathSync(project.configDir)
50
+ if (text(root, ['rev-parse', '--show-toplevel']) !== root) throw new Error('configuration must be at repository root')
51
+ const gitDir = text(root, ['rev-parse', '--absolute-git-dir'])
52
+ const common = path.resolve(root, text(root, ['rev-parse', '--git-common-dir']))
53
+ if (gitDir === common) throw new Error('upgrade requires an isolated linked worktree')
54
+ if (!text(root, ['symbolic-ref', '--quiet', 'HEAD']).startsWith('refs/heads/')) throw new Error('upgrade requires a candidate branch')
55
+ if (project.repos.length !== 1 || project.repos[0].external || fs.realpathSync(project.repos[0].path) !== root || fs.realpathSync(project.workspaceRoot) !== root || fs.realpathSync(project.repoOpsRoot) !== root) throw new Error('slice 1 requires one repository with workspace and configuration at its root')
56
+ if (project.localOverlay.paths.length || project.config.runtime || project.config.alignment || project.governanceLedgerPath) throw new Error('local overlays and runtime/alignment participants are unsupported')
57
+ if (project.config.ext && Object.entries(project.config.ext).some(([key, value]) => key !== 'mnstry.atelier' || Object.keys(value).some((k) => k !== 'distribution'))) throw new Error('extension pack and custom extension participants are unsupported')
58
+ if (fs.existsSync(path.join(root, '.atelier-local', 'readiness'))) throw new Error('private readiness inputs require a later read-set participant')
59
+ for (const file of [project.configPath, project.repoAccessPath, project.boundaryPolicyPath, project.graphPath, project.readinessPath, path.join(project.outputRoot, 'index.html')]) contained(root, path.relative(root, file))
60
+ if (fs.existsSync(path.join(root, '.gitmodules'))) throw new Error('submodules unsupported')
61
+ return root
62
+ }
63
+ function gitAuxiliary(root) {
64
+ const result = git(root, ['config', '--path', '--get', 'core.excludesFile'], { allowFailure: true })
65
+ if (!result.ok && result.status !== 1) throw new Error('Git ignore configuration unavailable')
66
+ const globalIgnore = result.ok ? path.resolve(root, result.stdout.trim()) : path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'git', 'ignore')
67
+ const attributes = ['GIT_ATTR_GLOBAL', 'GIT_ATTR_SYSTEM'].map((variable) => {
68
+ const location = text(root, ['var', variable])
69
+ if (!path.isAbsolute(location)) throw new Error('Git attributes location unavailable')
70
+ return location
71
+ })
72
+ const files = [text(root, ['rev-parse', '--git-path', 'info/exclude']), text(root, ['rev-parse', '--git-path', 'info/attributes']), globalIgnore, ...attributes].map((file) => path.resolve(root, file))
73
+ return files.map((file) => ({ path: file, state: fileState(file) }))
74
+ }
75
+ function gitEvidence(root) {
76
+ const result = git(root, ['config', '--path', '--get', 'core.hooksPath'], { allowFailure: true })
77
+ if (!result.ok && result.status !== 1) throw new Error('hook configuration unavailable')
78
+ const hooksPath = path.resolve(root, result.ok ? result.stdout.trim() : text(root, ['rev-parse', '--git-path', 'hooks']))
79
+ const config = parseNullConfig(git(root, ['config', '--null', '--list']).stdout)
80
+ if (config.some(({ key, value }) => /^core\.attributesfile$|^filter\.|^extensions\.(partialclone|worktreeconfig)$|^remote\..*\.promisor$/.test(key) || (/^core\.(sparsecheckout|autocrlf|fsmonitor)$/.test(key) && !/^(false|no|off|0)$/.test(value)))) throw new Error('unsupported Git transformation or checkout configuration')
81
+ if (fs.existsSync(hooksPath) && (fs.lstatSync(hooksPath).isSymbolicLink() || fs.realpathSync(hooksPath) !== hooksPath)) throw new Error('redirected hook directory refused')
82
+ const auxiliary = gitAuxiliary(root)
83
+ for (const entry of [auxiliary[1], ...auxiliary.slice(3)]) {
84
+ if (entry.state && readBytes(entry.path).length) throw new Error('Git attribute transformations unsupported in slice 1')
85
+ }
86
+ return {
87
+ gitAuxDigest: hashObject(auxiliary),
88
+ gitDigest: hashBytes(readBytes(resolveGitExecutable(), { allowLinks: true })),
89
+ gitConfigDigest: hashBytes(git(root, ['config', '--null', '--list', '--show-origin']).stdout),
90
+ hooksPath,
91
+ hooksDigest: hashObject(fs.existsSync(hooksPath) ? inventory(hooksPath) : []),
92
+ }
93
+ }
94
+ function executorDigest() {
95
+ const inputs = ['src', 'contracts', 'bin'].map((dir) => ({ dir, files: inventory(path.join(packageRoot, dir)) }))
96
+ for (const file of ['package.json', 'package-lock.json']) inputs.push({ file, state: fileState(path.join(packageRoot, file)) })
97
+ // Resolve from the actual importing module, then follow each package's own
98
+ // dependency resolution. npm may hoist a dependency; nested copies must be
99
+ // bound to the importer that actually selects them, not a guessed layout.
100
+ const roots = new Map()
101
+ const edges = []
102
+ function dependency(specifier, importer, via) {
103
+ const entry = fs.realpathSync(createRequire(importer).resolve(specifier))
104
+ let dir = path.dirname(entry)
105
+ while (!fs.existsSync(path.join(dir, 'package.json'))) {
106
+ const parent = path.dirname(dir)
107
+ if (parent === dir) throw new Error('dependency package identity unavailable')
108
+ dir = parent
109
+ }
110
+ const metadata = readJson(path.join(dir, 'package.json'))
111
+ const id = hashObject({ name: metadata.name, version: metadata.version, files: inventory(dir, { exclude: ['node_modules'], allowLinks: true }) })
112
+ edges.push({ via, specifier, id })
113
+ if (roots.has(dir)) return
114
+ if (roots.size >= 64) throw new Error('executor dependency closure exceeds bounds')
115
+ roots.set(dir, id)
116
+ for (const child of Object.keys(metadata.dependencies ?? {}).sort()) dependency(child, path.join(dir, 'package.json'), id)
117
+ }
118
+ const importer = fileURLToPath(new URL('../export/atelier-export-contract.mjs', import.meta.url))
119
+ dependency('ajv/dist/2020.js', importer, 'schema-validator')
120
+ dependency('ajv-formats', importer, 'schema-validator')
121
+ inputs.push({ dependencies: edges })
122
+ return hashObject({ inputs, node: process.version })
123
+ }
124
+ function identity(root) {
125
+ return hashObject({ root, gitDir: text(root, ['rev-parse', '--absolute-git-dir']), common: text(root, ['rev-parse', '--git-common-dir']) })
126
+ }
127
+ function clean(root) {
128
+ if (git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']).stdout) throw new Error('upgrade requires a clean index and working tree')
129
+ const flags = git(root, ['ls-files', '-v', '-z']).stdout.split('\0').filter(Boolean)
130
+ if (flags.some((s) => !s.startsWith('H '))) throw new Error('special index flags unsupported')
131
+ for (const name of ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply']) {
132
+ if (fs.existsSync(path.resolve(root, text(root, ['rev-parse', '--git-path', name])))) throw new Error('Git operation already in progress')
133
+ }
134
+ }
135
+ function lease(root, body) {
136
+ const state = privateRoot(root)
137
+ if (fs.readdirSync(state).some((name) => name.startsWith('prepare-'))) throw new Error('stale preparation directory under .atelier-local/upgrades: verify its writer has stopped, then inspect and separately remove only the abandoned prepare-* directory before retrying; no automatic cleanup performed')
138
+ if (inventory(state).reduce((sum, x) => sum + fs.statSync(path.join(state, x.path)).size, 0) > MAX_BYTES / 2) throw new Error('local evidence capacity exhausted; export and verify before retention maintenance')
139
+ const lock = path.join(state, 'writer')
140
+ fs.mkdirSync(lock) // No time-based stealing, including after an interrupted run.
141
+ publish(path.join(lock, 'owner.json'), jsonText({ pid: process.pid, startedAt: new Date().toISOString() }))
142
+ syncDirectory(state)
143
+ try { return body() } finally { fs.rmSync(lock, { recursive: true }); syncDirectory(state) }
144
+ }
145
+ function allowedPaths(project) {
146
+ const paths = ['atelier.lock.json', project.graphPath, path.join(project.outputRoot, 'index.html'), path.join(project.outputRoot, 'atelier.manifest.json'), project.readinessPath].map((p) => path.isAbsolute(p) ? path.relative(project.configDir, p) : p)
147
+ if (new Set(paths).size !== 5 || paths.slice(1).some((p) => !p.startsWith('atelier-output/'))) throw new Error('slice 1 generated writes must be distinct files inside atelier-output')
148
+ paths.forEach((p) => contained(project.configDir, p))
149
+ return paths
150
+ }
151
+ function boundaryCheck(project, staged = false) {
152
+ const loaded = loadBoundaryPolicy(project)
153
+ if (!loaded.ok) throw new Error('boundary policy unavailable')
154
+ const report = checkBoundaryPolicy({ project, policy: loaded.policy, staged, gitExecutable: resolveGitExecutable(), allowNetworkActorResolution: false, forceActorErrors: true })
155
+ if (!report.ok) throw new Error(`boundary postcheck refused: ${report.errors.map((e) => e.code).join(', ')}`)
156
+ }
157
+ function preservedLock(project, generatedAt) {
158
+ const file = path.join(project.configDir, 'atelier.lock.json')
159
+ const previous = fs.existsSync(file) ? validateUpgradeDocument('lock', readJson(file)) : null
160
+ // Pack adoption and template changes are separate participants.
161
+ const next = buildAtelierLock({ project })
162
+ if (previous && !same(previous.boundaryPolicy, next.boundaryPolicy)) throw new Error('boundary policy adoption requires a separate participant')
163
+ if (previous && !same(previous.extensionPacks, next.extensionPacks)) throw new Error('pack adoption requires a separate participant')
164
+ next.generatedAt = generatedAt
165
+ if (previous) { next.appliedMigrations = previous.appliedMigrations; next.template = previous.template; next.lastSuccessfulUpgrade = previous.lastSuccessfulUpgrade }
166
+ return validateUpgradeDocument('lock', next)
167
+ }
168
+ function render(project, readSet, createdAt) {
169
+ const root = project.configDir
170
+ const scratch = fs.mkdtempSync(path.join(privateRoot(root), 'prepare-'))
171
+ const allowed = allowedPaths(project)
172
+ try {
173
+ for (const entry of readSet) {
174
+ if (entry.path.split('/').includes('.gitattributes')) throw new Error('Git attribute transformations unsupported in slice 1')
175
+ const dest = contained(scratch, entry.path)
176
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
177
+ fs.writeFileSync(dest, readBytes(contained(root, entry.path)), { mode: entry.state.mode === '100755' ? 0o755 : 0o644 })
178
+ }
179
+ // Private preparation has no commits or hooks. Keep Git ignore semantics
180
+ // for the existing graph builder without sharing the enrolled index.
181
+ const empty = path.join(scratch, '.atelier-local', 'empty-template')
182
+ fs.mkdirSync(empty, { recursive: true })
183
+ git(scratch, ['init', `--template=${empty}`])
184
+ const [exclude, , globalIgnore] = gitAuxiliary(root)
185
+ if (exclude.state) { fs.mkdirSync(path.join(scratch, '.git/info'), { recursive: true }); fs.writeFileSync(path.join(scratch, '.git/info/exclude'), readBytes(exclude.path)) }
186
+ git(scratch, ['config', 'core.excludesFile', globalIgnore.path])
187
+ const tracked = git(root, ['ls-files', '-z']).stdout.split('\0').filter(Boolean)
188
+ for (let start = 0; start < tracked.length; start += 100) git(scratch, ['add', '-f', '--', ...tracked.slice(start, start + 100)])
189
+ const clone = resolveProjectConfig({ cwd: scratch, argv: ['--project', path.join(scratch, path.basename(project.configPath))], env: {}, writeLocalState: false })
190
+ if (clone.workspaceRoot !== scratch || clone.repoOpsRoot !== scratch || clone.repos.length !== 1 || clone.repos[0].path !== scratch || clone.localOverlay.paths.length) throw new Error('preparation inputs escape isolated workspace')
191
+ for (const file of [clone.configPath, clone.repoAccessPath, clone.boundaryPolicyPath, clone.graphPath, clone.readinessPath, path.join(clone.outputRoot, 'index.html')]) contained(scratch, path.relative(scratch, file))
192
+ const before = snapshot(scratch)
193
+ const write = (rel, bytes) => {
194
+ if (!allowed.includes(rel)) throw new Error('undeclared preparation write')
195
+ const file = contained(scratch, rel)
196
+ fs.mkdirSync(path.dirname(file), { recursive: true })
197
+ fs.writeFileSync(file, bytes)
198
+ }
199
+ const lock = preservedLock(project, createdAt)
200
+ write('atelier.lock.json', jsonText(lock))
201
+ const graph = buildGraph(clone)
202
+ graph.project = path.basename(root)
203
+ if (graph.errors.length) throw new Error('graph postcheck failed')
204
+ write(path.relative(scratch, clone.graphPath), jsonText(graph))
205
+ const projection = buildProjectProjection(clone)
206
+ write(path.relative(scratch, projection.output), projection.html)
207
+ write(path.relative(scratch, path.join(clone.outputRoot, 'atelier.manifest.json')), jsonText(buildProjectManifest(clone, projection)))
208
+ const readiness = buildReadiness({ project: clone, graph })
209
+ readiness.graph.path = project.graphPath
210
+ readiness.projection.outputRoot = project.outputRoot
211
+ readiness.projection.entry = path.join(project.outputRoot, 'index.html')
212
+ if (!readiness.ready) throw new Error('readiness postcheck failed')
213
+ write(path.relative(scratch, clone.readinessPath), jsonText(readiness))
214
+ const after = snapshot(scratch)
215
+ const writes = after.filter((entry) => !same(entry.state, before.find((p) => p.path === entry.path)?.state ?? null)).map((entry) => {
216
+ if (!allowed.includes(entry.path)) throw new Error('unaccounted preparation write')
217
+ const previous = before.find((p) => p.path === entry.path)?.state ?? null
218
+ return { path: entry.path, owner: entry.path === 'atelier.lock.json' ? 'lock' : 'generated', action: previous ? 'update' : 'create', before: previous, after: entry.state, content: readBytes(contained(scratch, entry.path)).toString('base64') }
219
+ })
220
+ if (before.some((entry) => !after.some((p) => p.path === entry.path))) throw new Error('unaccounted preparation deletion')
221
+ return { writes, allowed }
222
+ } finally { fs.rmSync(scratch, { recursive: true }); syncDirectory(privateRoot(root)) }
223
+ }
224
+
225
+ function planDigest(plan) { const { digest, ...authority } = plan; return hashObject(authority) }
226
+ function requireDurableHost() {
227
+ if (!['linux', 'darwin'].includes(process.platform)) throw new Error('exact upgrade transactions require a qualified Linux or macOS filesystem; this host is unsupported')
228
+ }
229
+ export function prepareUpgrade({ project, now = new Date() }) {
230
+ requireDurableHost()
231
+ project = canonicalProject(project)
232
+ const root = checkProject(project)
233
+ return lease(root, () => {
234
+ clean(root)
235
+ const adopted = policy(root)
236
+ const readSet = snapshot(root)
237
+ boundaryCheck(project)
238
+ const createdAt = now.toISOString()
239
+ const evidence = gitEvidence(root)
240
+ const head = text(root, ['rev-parse', 'HEAD'])
241
+ const branch = text(root, ['symbolic-ref', 'HEAD'])
242
+ const executor = executorDigest()
243
+ const { writes, allowed } = render(project, readSet, createdAt)
244
+ if (!same(snapshot(root), readSet) || text(root, ['rev-parse', 'HEAD']) !== head || !same(gitEvidence(root), evidence)) throw new Error('inputs changed during preparation')
245
+ clean(root)
246
+ const plan = {
247
+ schema: 'mnstry.atelier-upgrade-plan@v2', digest: '', operation: 'prepare-local-candidate', workspace: root,
248
+ repositoryId: identity(root), configPath: path.basename(project.configPath), baseHead: head, baseTree: text(root, ['rev-parse', 'HEAD^{tree}']), branch,
249
+ createdAt, expiresAt: new Date(now.getTime() + adopted.maxAgeSeconds * 1000).toISOString(),
250
+ policy: adopted, policyDigest: hashObject(adopted), executorDigest: executor, ...evidence, readSet,
251
+ migration: { schema: 'mnstry.atelier-migration@v2', id: 'local-lock-projections@1', executorDigest: executor, readScope: 'whole-enrolled-repository', allowedWrites: allowed, sideEffects: ['git-index', 'git-commit'], postChecks: ['exact-inventory', 'lock-schema', 'exact-index', 'commit-parent-tree-message'] },
252
+ writes, releaseEvidence: null, dependencyInstallation: 'excluded', mode: 'manual-exact-plan', recoveryCoverage: 'local-only', message: 'Prepare Atelier local lock and projections',
253
+ }
254
+ plan.digest = planDigest(plan)
255
+ validateUpgradeDocument('plan', plan)
256
+ const dir = privateDirectory(root, `${PRIVATE}/plans`)
257
+ const savedPlan = path.join(dir, `${plan.digest.slice(7)}.json`)
258
+ if (Buffer.byteLength(jsonText(plan)) > 8 * 1024 * 1024) throw new Error('saved plan exceeds 8 MiB capacity')
259
+ publish(savedPlan, jsonText(plan))
260
+ return { ok: true, savedPlan, plan, confirmationIsAuthenticatedIdentity: false }
261
+ })
262
+ }
263
+
264
+ function loadPlan(root, file) {
265
+ const location = path.resolve(file)
266
+ const relative = path.relative(root, location)
267
+ if (!/^\.atelier-local\/upgrades\/plans\/[a-f0-9]{64}\.json$/.test(relative)) throw new Error('plan must be the saved local digest-addressed file')
268
+ const plan = validateUpgradeDocument('plan', readJson(contained(root, relative)))
269
+ if (planDigest(plan) !== plan.digest || path.basename(file) !== `${plan.digest.slice(7)}.json`) throw new Error('plan digest mismatch')
270
+ if (plan.workspace !== root || plan.repositoryId !== identity(root)) throw new Error('plan belongs to another workspace')
271
+ const allowed = plan.migration.allowedWrites
272
+ if (new Set(plan.writes.map((w) => w.path)).size !== plan.writes.length || new Set(plan.readSet.map((r) => r.path)).size !== plan.readSet.length) throw new Error('duplicate plan paths')
273
+ for (const entry of plan.readSet) contained(root, entry.path)
274
+ for (const entry of plan.writes) {
275
+ contained(root, entry.path)
276
+ if (!allowed.includes(entry.path) || (entry.owner === 'lock' ? entry.path !== 'atelier.lock.json' : !entry.path.startsWith('atelier-output/'))) throw new Error('unregistered write path')
277
+ const content = Buffer.from(entry.content, 'base64')
278
+ if (content.toString('base64') !== entry.content || !entry.after || hashBytes(content) !== entry.after.digest || !same(entry.before, plan.readSet.find((r) => r.path === entry.path)?.state ?? null) || entry.action !== (entry.before ? 'update' : 'create')) throw new Error('invalid write evidence')
279
+ }
280
+ return plan
281
+ }
282
+ function expectedSnapshot(plan, count) {
283
+ const entries = new Map(plan.readSet.map((entry) => [entry.path, entry.state]))
284
+ for (const write of plan.writes.slice(0, count)) entries.set(write.path, write.after)
285
+ return [...entries].map(([file, state]) => ({ path: file, state })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
286
+ }
287
+
288
+ // Read-only explanation of verified saved bytes. This never records consent or
289
+ // reserves a writer; application remains responsible for its full live checks.
290
+ export function explainSavedUpgrade({ project, planFile, now = new Date() }) {
291
+ requireDurableHost()
292
+ project = canonicalProject(project)
293
+ const root = checkProject(project)
294
+ const plan = loadPlan(root, planFile)
295
+ const blockers = []
296
+ try {
297
+ clean(root)
298
+ liveBindings(root, plan, 0, now)
299
+ if (!same(allowedPaths(project), plan.migration.allowedWrites) || path.basename(project.configPath) !== plan.configPath) throw new Error('project binding changed')
300
+ if (fs.existsSync(operationDirectory(root, plan.digest.slice(7)))) throw new Error('plan already consumed; inspect its operation status')
301
+ const state = contained(root, PRIVATE)
302
+ if (fs.existsSync(path.join(state, 'writer'))) throw new Error('writer lease exists; inspect its owner')
303
+ if (fs.readdirSync(state).some((name) => name.startsWith('prepare-'))) throw new Error('abandoned preparation requires inspection')
304
+ } catch (error) { blockers.push(error.message) }
305
+ return {
306
+ schema: 'mnstry.atelier-upgrade-explanation@v1', ok: true,
307
+ planDigest: plan.digest, baseHead: plan.baseHead, branch: plan.branch,
308
+ expiresAt: plan.expiresAt, bindingsCurrent: blockers.length === 0, blockers,
309
+ writes: plan.writes.map(({ path, owner, action, before, after }) => ({ path, owner, action, before, after })),
310
+ provenance: { executorDigest: plan.executorDigest, gitDigest: plan.gitDigest, gitConfigDigest: plan.gitConfigDigest, gitAuxDigest: plan.gitAuxDigest, hooksDigest: plan.hooksDigest, sourceFiles: plan.readSet.length, releaseEvidence: plan.releaseEvidence },
311
+ consent: { mode: plan.mode, requiredEffects: plan.policy.allowedEffects, humanApprovalAuthenticated: false, applicationAuthorized: false },
312
+ outcome: 'local candidate commit', dependencyInstallation: plan.dependencyInstallation,
313
+ activation: 'excluded', recovery: 'local backups and inspection only; no automatic rollback',
314
+ caveats: ['Generated readiness bytes can contain absolute host paths; inspect the saved writes before sharing.', 'Local receipts prove consistency, not publisher identity or an authenticated human decision.', 'Application rechecks current state; this report neither reserves the workspace nor grants permission.'],
315
+ }
316
+ }
317
+
318
+ function liveBindings(root, plan, count, now = new Date()) {
319
+ for (const rel of ['.atelier-local/readiness', '.atelier-local/atelier.local.json', 'atelier.local.json', 'atelier.workspace.local.json']) if (fs.existsSync(contained(root, rel))) throw new Error('unsupported local context appeared')
320
+ if (now < new Date(plan.createdAt) || now >= new Date(plan.expiresAt) || new Date(plan.expiresAt) - new Date(plan.createdAt) > plan.policy.maxAgeSeconds * 1000) throw new Error('plan expired or clock moved backward')
321
+ if (!same(policy(root), plan.policy) || hashObject(plan.policy) !== plan.policyDigest) throw new Error('policy changed or revoked')
322
+ if (executorDigest() !== plan.executorDigest || plan.migration.executorDigest !== plan.executorDigest) throw new Error('executor changed')
323
+ if (!same(gitEvidence(root), Object.fromEntries(['gitDigest', 'gitConfigDigest', 'gitAuxDigest', 'hooksPath', 'hooksDigest'].map((k) => [k, plan[k]])))) throw new Error('Git or hook identity changed')
324
+ if (text(root, ['rev-parse', 'HEAD']) !== plan.baseHead || text(root, ['symbolic-ref', 'HEAD']) !== plan.branch || !same(snapshot(root), expectedSnapshot(plan, count))) throw new Error('repository changed from saved plan')
325
+ }
326
+ function operationDirectory(root, id) {
327
+ if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('invalid operation ID')
328
+ return contained(root, `${PRIVATE}/operations/${id}`)
329
+ }
330
+ function events(root, id) {
331
+ const dir = contained(root, `${PRIVATE}/operations/${id}/events`)
332
+ const records = []
333
+ let previous = null
334
+ for (const name of fs.readdirSync(dir).sort()) {
335
+ const record = validateUpgradeDocument('event', readJson(contained(root, `${PRIVATE}/operations/${id}/events/${name}`)))
336
+ const digest = hashObject(record)
337
+ if (name !== `${String(records.length).padStart(6, '0')}-${digest.slice(7)}.json` || record.sequence !== records.length || record.previous !== previous || record.operationId !== id || record.planDigest !== `sha256:${id}` || record.confirmation !== record.planDigest || (records.length && new Date(record.time) < new Date(records.at(-1).time))) throw new Error('broken upgrade journal; recovery required')
338
+ if ((!records.length && record.phase !== 'accepted') || ['completed', 'commit-refused', 'recovery-required'].includes(records.at(-1)?.phase)) throw new Error('invalid journal transition')
339
+ records.push(record); previous = digest
340
+ }
341
+ return records
342
+ }
343
+ function append(root, plan, phase, { step = null, expected = null, observed = null } = {}) {
344
+ const id = plan.digest.slice(7)
345
+ const chain = events(root, id)
346
+ const event = {
347
+ schema: 'mnstry.atelier-upgrade-receipt@v1', kind: 'private-event', operationId: id, planDigest: plan.digest,
348
+ sequence: chain.length, previous: chain.length ? hashObject(chain.at(-1)) : null,
349
+ time: new Date().toISOString(), phase, step, expected, observed, confirmation: plan.digest,
350
+ }
351
+ if (chain.length && new Date(event.time) < new Date(chain.at(-1).time)) throw new Error('clock moved backward during operation')
352
+ validateUpgradeDocument('event', event)
353
+ publish(path.join(operationDirectory(root, id), 'events', `${String(chain.length).padStart(6, '0')}-${hashObject(event).slice(7)}.json`), jsonText(event))
354
+ events(root, id)
355
+ }
356
+ function indexManifest(root) {
357
+ return git(root, ['ls-files', '--stage', '-z']).stdout.split('\0').filter(Boolean).map((record) => {
358
+ const match = /^(\d+) ([a-f0-9]+) 0\t(.+)$/.exec(record)
359
+ if (!match) throw new Error('unmerged or malformed index')
360
+ return { path: match[3], mode: match[1], oid: match[2] }
361
+ }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
362
+ }
363
+ function plannedIndex(root, plan) {
364
+ const entries = git(root, ['ls-tree', '-r', '-z', plan.baseHead]).stdout.split('\0').filter(Boolean).map((record) => {
365
+ const match = /^(100644|100755) blob ([a-f0-9]+)\t(.+)$/.exec(record)
366
+ if (!match) throw new Error('unsupported base tree entry')
367
+ return { path: match[3], mode: match[1], oid: match[2] }
368
+ })
369
+ const byPath = new Map(entries.map((e) => [e.path, e]))
370
+ for (const entry of plan.writes) byPath.set(entry.path, { path: entry.path, mode: entry.after.mode, oid: git(root, ['hash-object', '--stdin', '--no-filters'], { input: Buffer.from(entry.content, 'base64') }).stdout.trim() })
371
+ return [...byPath.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
372
+ }
373
+ function verifyCommit(root, plan, commit, tree) {
374
+ if (!/^[a-f0-9]{40,64}$/.test(commit) || text(root, ['show', '-s', '--format=%P', commit]) !== plan.baseHead || text(root, ['show', '-s', '--format=%T', commit]) !== tree || text(root, ['show', '-s', '--format=%B', commit]) !== plan.message) throw new Error('commit parent/tree/message mismatch')
375
+ }
376
+ export function applySavedUpgrade({ project, planFile, confirm }) {
377
+ requireDurableHost()
378
+ project = canonicalProject(project)
379
+ const root = checkProject(project)
380
+ return lease(root, () => {
381
+ const plan = loadPlan(root, planFile)
382
+ if (confirm !== plan.digest) throw new Error('exact plan confirmation required')
383
+ clean(root)
384
+ liveBindings(root, plan, 0)
385
+ if (!same(allowedPaths(project), plan.migration.allowedWrites) || path.basename(project.configPath) !== plan.configPath) throw new Error('project binding changed')
386
+ const id = plan.digest.slice(7)
387
+ privateDirectory(root, `${PRIVATE}/operations`)
388
+ const operation = operationDirectory(root, id)
389
+ fs.mkdirSync(operation) // A prior attempt consumes this plan, even if interrupted.
390
+ syncDirectory(path.dirname(operation))
391
+ privateDirectory(root, `${PRIVATE}/operations/${id}/events`)
392
+ privateDirectory(root, `${PRIVATE}/operations/${id}/backups`)
393
+ append(root, plan, 'accepted', { expected: plan.digest, observed: 'manual content selection; local recovery only' })
394
+ try {
395
+ const baseIndex = indexManifest(root)
396
+ for (const [i, entry] of plan.writes.entries()) {
397
+ liveBindings(root, plan, i)
398
+ if (!same(indexManifest(root), baseIndex)) throw new Error('index changed before staging')
399
+ if (entry.before) publish(path.join(operation, 'backups', `${i}.bin`), readBytes(contained(root, entry.path)))
400
+ append(root, plan, 'write-intent', { step: entry.path, expected: hashObject({ before: entry.before, after: entry.after }) })
401
+ replaceExpected(root, entry)
402
+ append(root, plan, 'write-complete', { step: entry.path, observed: entry.after.digest })
403
+ }
404
+ liveBindings(root, plan, plan.writes.length)
405
+ validateUpgradeDocument('lock', readJson(path.join(root, 'atelier.lock.json')))
406
+ boundaryCheck(project)
407
+ const expectedIndex = plannedIndex(root, plan)
408
+ if (!same(indexManifest(root), baseIndex)) throw new Error('index changed before staging')
409
+ append(root, plan, 'stage-intent', { expected: hashObject(expectedIndex) })
410
+ // Literal, concrete paths only. Explicitly planned ignored outputs are
411
+ // admitted here; no stage-all, glob expansion or implicit authored files.
412
+ for (const entry of plan.writes) git(root, ['add', '-f', '--', entry.path])
413
+ if (!same(indexManifest(root), expectedIndex)) throw new Error('staged blobs differ from saved plan')
414
+ liveBindings(root, plan, plan.writes.length)
415
+ boundaryCheck(project, true)
416
+ const tree = text(root, ['write-tree'])
417
+ append(root, plan, 'commit-intent', { expected: tree })
418
+ const result = git(root, ['commit', '-m', plan.message], { allowFailure: true, timeout: 30_000 })
419
+ const head = text(root, ['rev-parse', 'HEAD'])
420
+ const unchanged = same(indexManifest(root), expectedIndex) && same(snapshot(root), expectedSnapshot(plan, plan.writes.length))
421
+ if (!result.ok) {
422
+ append(root, plan, head === plan.baseHead && unchanged && !result.error && !result.signal ? 'commit-refused' : 'recovery-required', { expected: tree, observed: `Git exit ${result.status}; ${result.error || result.stderr}` })
423
+ return { ok: false, operationId: id, status: events(root, id).at(-1).phase }
424
+ }
425
+ if (!unchanged || text(root, ['symbolic-ref', 'HEAD']) !== plan.branch) throw new Error('hook changed unreviewed state')
426
+ verifyCommit(root, plan, head, tree)
427
+ // Hooks may alter policy, their own bytes, or configuration too.
428
+ if (!same(gitEvidence(root), Object.fromEntries(['gitDigest', 'gitConfigDigest', 'gitAuxDigest', 'hooksPath', 'hooksDigest'].map((k) => [k, plan[k]])))) throw new Error('hook or Git configuration changed during commit')
429
+ clean(root)
430
+ append(root, plan, 'completed', { expected: tree, observed: head })
431
+ return { ok: true, operationId: id, status: 'completed', commit: head, activated: false, lockSuccessMetadata: 'historical; terminal receipt is authoritative' }
432
+ } catch (error) {
433
+ // Never reset user files or retry a commit. A failing journal write
434
+ // remains a nonterminal/broken chain and status refuses success.
435
+ if (!['completed', 'commit-refused', 'recovery-required'].includes(events(root, id).at(-1)?.phase)) append(root, plan, 'recovery-required', { observed: error.message })
436
+ return { ok: false, operationId: id, status: 'recovery-required', reason: error.message }
437
+ }
438
+ })
439
+ }
440
+ export function upgradeOperationStatus({ project, operationId }) {
441
+ const root = fs.realpathSync(project.configDir)
442
+ privateRoot(root)
443
+ const plan = loadPlan(root, path.join(root, PRIVATE, 'plans', `${operationId}.json`))
444
+ const chain = events(root, operationId)
445
+ const last = chain.at(-1)
446
+ const phase = ['completed', 'commit-refused', 'recovery-required'].includes(last?.phase) ? last.phase : 'recovery-required'
447
+ if (phase === 'completed') verifyCommit(root, plan, last.observed, last.expected)
448
+ return { ok: phase === 'completed', operationId, status: phase, lastEvent: last ?? null, events: chain.length, activated: false, recoveryCoverage: 'local-only', lockSuccessMetadata: 'historical; terminal receipt is authoritative', writerLeasePresent: fs.existsSync(path.join(root, PRIVATE, 'writer')) }
449
+ }
450
+ export function recoverUpgradeDryRun({ project, operationId }) {
451
+ const root = fs.realpathSync(project.configDir)
452
+ const status = upgradeOperationStatus({ project, operationId })
453
+ const plan = loadPlan(root, path.join(root, PRIVATE, 'plans', `${operationId}.json`))
454
+ const chain = events(root, operationId)
455
+ const head = text(root, ['rev-parse', 'HEAD'])
456
+ const index = indexManifest(root)
457
+ const planned = plannedIndex(root, plan)
458
+ const base = plannedIndex(root, { ...plan, writes: [] })
459
+ const entries = plan.writes.map((entry, i) => {
460
+ const current = fileState(contained(root, entry.path))
461
+ const began = chain.some((e) => e.phase === 'write-intent' && e.step === entry.path)
462
+ const backup = path.join(operationDirectory(root, operationId), 'backups', `${i}.bin`)
463
+ const validBackup = !entry.before || (fs.existsSync(backup) && hashBytes(readBytes(backup)) === entry.before.digest)
464
+ const staged = index.find((e) => e.path === entry.path)
465
+ const stagedExpected = planned.find((e) => e.path === entry.path)
466
+ const safe = began && head === plan.baseHead && same(current, entry.after) && validBackup && (same(staged ?? null, stagedExpected ?? null) || same(staged ?? null, base.find((e) => e.path === entry.path) ?? null))
467
+ return { path: entry.path, state: same(current, entry.before) ? 'already-original' : safe ? 'restorable-after-new-confirmation' : 'conflict', restore: safe ? entry.before ? 'backup' : 'remove-created-file' : null }
468
+ })
469
+ const current = snapshot(root)
470
+ const knownWrites = new Set(plan.writes.map((w) => w.path))
471
+ const observedPaths = new Set([...current, ...plan.readSet].map((e) => e.path))
472
+ const unexpected = [...observedPaths].filter((file) => !knownWrites.has(file) && !same(current.find((e) => e.path === file)?.state ?? null, plan.readSet.find((e) => e.path === file)?.state ?? null))
473
+ const proposal = { operationId, status: status.status, head, indexDigest: hashObject(index), entries, unexpected, mutation: false, requiresNewConfirmation: true }
474
+ return { ...proposal, digest: hashObject(proposal), note: head === plan.baseHead ? 'No files or index entries restored. Review conflicts and exact current state before a separately authorized recovery.' : 'HEAD moved; preserve committed history. No automatic reset or rollback is offered.' }
475
+ }
@@ -3,7 +3,7 @@ import crypto from 'node:crypto'
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { buildGraph } from '../graph/graph.mjs'
6
- import { buildProjectProjection } from '../projection/project.mjs'
6
+ import { buildProjectProjection, buildProjectManifest } from '../projection/project.mjs'
7
7
  import { buildReadiness } from '../readiness/readiness.mjs'
8
8
  import { validateJsonSchema } from '../export/atelier-export-contract.mjs'
9
9
  import {
@@ -23,6 +23,10 @@ import {
23
23
  } from '../boundary/policy.mjs'
24
24
  import { bundledMnstryReadinessPackV1 } from '../readiness-protocols/bundled-pack.mjs'
25
25
  import { loadExtensionPacks } from '../extension-packs/loader.mjs'
26
+ import { inspectPackageProvenance, legacyPackageSource } from './provenance.mjs'
27
+ import { prepareUpgrade, applySavedUpgrade, upgradeOperationStatus, recoverUpgradeDryRun, explainSavedUpgrade } from './transaction.mjs'
28
+ import { renderUpgradeExplanation } from './explanation.mjs'
29
+ export { prepareUpgrade, applySavedUpgrade, upgradeOperationStatus, recoverUpgradeDryRun, explainSavedUpgrade }
26
30
 
27
31
  export const ATELIER_LOCK_SCHEMA = 'mnstry.atelier-lock@v1'
28
32
  export const ATELIER_MIGRATION_SCHEMA = 'mnstry.atelier-migration@v1'
@@ -77,18 +81,7 @@ function packageGitSha() {
77
81
  }
78
82
 
79
83
  function packageSource() {
80
- const gitSha = packageGitSha()
81
- const repository = packageGitRoot()
82
- ? git(packageRoot, ['config', '--get', 'remote.origin.url']) || packageJson.repository?.url || null
83
- : packageJson.repository?.url || null
84
- if (repository && gitSha) return { type: 'git', repository, gitSha }
85
- if (repository) return { type: 'private_github', repository, gitSha: null }
86
- return {
87
- type: 'local_path',
88
- path: '.',
89
- gitSha,
90
- repository,
91
- }
84
+ return legacyPackageSource(inspectPackageProvenance(packageRoot, { includeInventory: false }))
92
85
  }
93
86
 
94
87
  export function lockPathForProject(project) {
@@ -172,7 +165,7 @@ export function buildAtelierLock({ project, templateId = 'existing-workspace', a
172
165
  }
173
166
  }
174
167
 
175
- export function writeAtelierLock({ project, templateId = 'existing-workspace' } = {}) {
168
+ export function writeAtelierLock({ project, templateId } = {}) {
176
169
  const lockPath = lockPathForProject(project)
177
170
  // Packs load in throwing mode on purpose: a broken extension pack cannot be
178
171
  // locked. Lock verification is disabled for this one load so re-pinning is
@@ -180,8 +173,18 @@ export function writeAtelierLock({ project, templateId = 'existing-workspace' }
180
173
  // moved or set aside, so it exists at every observable point, and writeJson
181
174
  // replaces it atomically (temp file + renameSync). A load failure throws
182
175
  // before any write and leaves the previous lock untouched.
176
+ const previous = readOptionalJson(lockPath)
177
+ if (previous) {
178
+ const errors = validateJsonSchema(lockSchema, previous)
179
+ if (errors.length) throw new Error(`cannot preserve invalid lock history: ${errors.join('; ')}`)
180
+ }
183
181
  const { packs } = loadExtensionPacks(project, { verifyLock: false })
184
- const lock = buildAtelierLock({ project, templateId, packs })
182
+ const lock = buildAtelierLock({ project, templateId: templateId || previous?.template?.id || 'existing-workspace', packs })
183
+ if (previous) {
184
+ lock.appliedMigrations = previous.appliedMigrations
185
+ lock.lastSuccessfulUpgrade = previous.lastSuccessfulUpgrade
186
+ lock.template = { ...previous.template, ...(templateId ? { id: templateId } : {}) }
187
+ }
185
188
  writeJson(lockPath, lock)
186
189
  return lock
187
190
  }
@@ -519,13 +522,7 @@ function runGeneratedRefresh(project) {
519
522
  const projection = buildProjectProjection(project)
520
523
  fs.mkdirSync(path.dirname(projection.output), { recursive: true })
521
524
  fs.writeFileSync(projection.output, projection.html)
522
- writeJson(path.join(project.outputRoot, 'atelier.manifest.json'), {
523
- schema: 'mnstry.atelier-manifest@v1',
524
- generatedAt: 'deterministic',
525
- graphPath: project.graphPath,
526
- entry: 'index.html',
527
- nodes: projection.graph.nodes.map((node) => ({ id: node.id, title: node.title, audience: node.audience, path: node.path })),
528
- })
525
+ writeJson(path.join(project.outputRoot, 'atelier.manifest.json'), buildProjectManifest(project, projection))
529
526
  writeJson(project.readinessPath, buildReadiness({ project, graph }))
530
527
  }
531
528
 
@@ -617,7 +614,7 @@ export function checkAtelierLock(project) {
617
614
  if (lock && lock.package?.version !== packageJson.version) errors.push(`lock package version ${lock.package?.version} does not match ${packageJson.version}`)
618
615
  if (lock && lock.contracts?.boundaryPolicy !== BOUNDARY_POLICY_SCHEMA) errors.push(`lock boundary policy contract must be ${BOUNDARY_POLICY_SCHEMA}`)
619
616
  const loadedPolicy = loadPolicyForLock(project)
620
- if (lock?.boundaryPolicy?.digest && loadedPolicy.digest && lock.boundaryPolicy.digest !== loadedPolicy.digest) {
617
+ if (lock && (lock.boundaryPolicy?.digest ?? null) !== loadedPolicy.digest) {
621
618
  errors.push('boundary policy digest has changed; run upgrade --dry-run before applying')
622
619
  }
623
620
  if (lock) {
@@ -656,24 +653,62 @@ export function checkAtelierLock(project) {
656
653
  export function runLockCommand(argv = process.argv.slice(2)) {
657
654
  const args = parseArgs(argv)
658
655
  const subcommand = args._[0] || 'check'
656
+ if (subcommand === 'provenance') {
657
+ const report = inspectPackageProvenance(packageRoot)
658
+ console.log(JSON.stringify(report, null, 2))
659
+ process.exitCode = args['exact-source-required'] && !report.sourceVerified ? 1 : 0
660
+ return
661
+ }
659
662
  const project = commandProject({ argv })
660
663
  if (subcommand === 'write') {
661
- const lock = writeAtelierLock({ project, templateId: firstString(args.template, args['template-id'], project.config?.template?.id) || 'existing-workspace' })
664
+ const lock = writeAtelierLock({ project, templateId: firstString(args.template, args['template-id'], project.config?.template?.id) || undefined })
662
665
  console.log(JSON.stringify({ ok: true, path: lockPathForProject(project), lock }, null, 2))
663
- process.exit(0)
666
+ process.exitCode = 0
667
+ return
664
668
  }
665
669
  if (subcommand === 'check') {
666
670
  const report = checkAtelierLock(project)
671
+ if (args['exact-source-required']) {
672
+ report.provenance = inspectPackageProvenance(packageRoot)
673
+ if (!report.provenance.sourceVerified) { report.ok = false; report.errors.push('installed package source is unverified') }
674
+ }
667
675
  console.log(JSON.stringify(report, null, 2))
668
- process.exit(report.ok ? 0 : 1)
676
+ process.exitCode = report.ok ? 0 : 1
677
+ return
669
678
  }
670
679
  console.error(`Unknown lock command: ${subcommand}`)
671
- process.exit(1)
680
+ process.exitCode = 1
672
681
  }
673
682
 
674
683
  export function runUpgradeCommand(argv = process.argv.slice(2)) {
675
684
  const args = parseArgs(argv)
676
- const project = commandProject({ argv })
685
+ const project = commandProject({ argv, writeLocalState: args._[0] !== 'explain' })
686
+ if (['plan', 'apply', 'status', 'recover', 'explain'].includes(args._[0])) {
687
+ try {
688
+ let result
689
+ if (args._[0] === 'explain') {
690
+ if (!firstString(args.plan)) throw new Error('--plan is required')
691
+ if (args.format != null && !['json', 'markdown'].includes(args.format)) throw new Error('--format must be json or markdown')
692
+ result = explainSavedUpgrade({ project, planFile: args.plan })
693
+ console.log(args.format === 'markdown' ? renderUpgradeExplanation(result) : JSON.stringify(result, null, 2))
694
+ return
695
+ } else if (args._[0] === 'plan') {
696
+ if (args.save !== true) throw new Error('exact planning requires --save')
697
+ result = prepareUpgrade({ project })
698
+ } else if (args._[0] === 'apply') {
699
+ if (!firstString(args.plan)) throw new Error('--plan is required')
700
+ result = applySavedUpgrade({ project, planFile: args.plan, confirm: args.confirm })
701
+ } else if (args._[0] === 'status') {
702
+ result = upgradeOperationStatus({ project, operationId: args.operation })
703
+ } else {
704
+ if (args['dry-run'] !== true) throw new Error('recovery supports --dry-run only; mutation requires a separately reviewed recovery plan')
705
+ result = recoverUpgradeDryRun({ project, operationId: args.operation })
706
+ }
707
+ console.log(JSON.stringify(result, null, 2))
708
+ process.exitCode = result.ok === false ? 1 : 0
709
+ } catch (error) { console.error(error.message); process.exitCode = 1 }
710
+ return
711
+ }
677
712
  const confirmBreaking = asArray(args['confirm-breaking']).concat(firstString(args['confirm-breaking']) ? [String(args['confirm-breaking'])] : [])
678
713
  const allowDirtyGenerated = Boolean(args['allow-dirty-generated'])
679
714
  if (args.check) {