@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,107 @@
1
+ import { ObsidianMaintenanceRefusal, refuse } from '../../../runtime/obsidian/errors.mjs'
2
+ import { authorizeAutomaticApply, installApplyPolicy, readInstalledApplyPolicy, readMachineSettings, revokeApplyPolicy } from '../../../runtime/obsidian/machine-settings.mjs'
3
+ import { IMPLEMENTED_EDIT_CLASSES, ObsidianContractRefusal, assertObsidianContract } from '../contracts.mjs'
4
+ import { APPLY_MODES, applyPolicyDigest, withApplyPolicyDigest } from '../edits/policy.mjs'
5
+
6
+ // Apply policy setup, noninteractive: a structured request in, a structured
7
+ // answer out. Three actions:
8
+ //
9
+ // create build a manual or automatic policy document from a request,
10
+ // digest it, validate it against the frozen contract and install
11
+ // it through AOP-1's installer. Only the implemented edit class
12
+ // can be allowed; the conflict disposition is `hold`, the only
13
+ // one that exists.
14
+ // show the installed policy, the reference machine settings hold, and
15
+ // whether an automatic apply would be authorized right now.
16
+ // revoke AOP-1's revocation: the stored policy is written `revoked`
17
+ // first, then maintenance goes back to manual. Every later
18
+ // authorization read denies, and the engine reads authorization
19
+ // again immediately before each queued dispatch, so a revocation
20
+ // is durable and wins over anything queued behind it.
21
+ //
22
+ // Nothing here changes the maintenance mode to automatic: that is the
23
+ // person's separate `mode set automatic`, which itself checks that an active
24
+ // automatic policy is installed.
25
+
26
+ export const POLICY_SETUP_ACTIONS = Object.freeze(['create', 'show', 'revoke'])
27
+ export const POLICY_SETUP_SCHEMA = 'atelier-obsidian-policy-setup/v1'
28
+ export const DEFAULT_MAX_BATCH_SIZE = 10
29
+ export const DEFAULT_RETRY_BUDGET = 0
30
+ export const CONFLICT_DISPOSITION = 'hold'
31
+
32
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
33
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
34
+ const isTyped = (error) => error instanceof ObsidianMaintenanceRefusal || error instanceof ObsidianContractRefusal
35
+
36
+ // The policy document a request describes, with the digest of its content.
37
+ // `version` is taken from the request, else one above the installed policy of
38
+ // the same identity, else 1.
39
+ export function buildApplyPolicy({ workspaceId, policyId, mode, actor, selector, allowedEditClasses, maxBatchSize = DEFAULT_MAX_BATCH_SIZE, retryBudget = DEFAULT_RETRY_BUDGET, version = 1, status = 'active', ext } = {}) {
40
+ if (!APPLY_MODES.includes(mode)) refuse('invalid-apply-policy', 'policy mode must be manual or automatic', { mode: String(mode).slice(0, 32) })
41
+ if (!isPlainObject(actor) || !['user', 'agent'].includes(actor.kind) || typeof actor.id !== 'string' || !IDENTIFIER.test(actor.id)) refuse('invalid-apply-policy', 'the actor is { kind: user|agent, id }')
42
+ const classes = allowedEditClasses === undefined ? (mode === 'automatic' ? [...IMPLEMENTED_EDIT_CLASSES] : []) : allowedEditClasses
43
+ if (!Array.isArray(classes) || classes.some((item) => typeof item !== 'string')) refuse('invalid-apply-policy', 'allowedEditClasses is a list of edit classes')
44
+ const unimplemented = classes.filter((item) => !IMPLEMENTED_EDIT_CLASSES.includes(item))
45
+ if (unimplemented.length > 0) refuse('unimplemented-edit-class', 'only an edit class with an apply implementation can be allowed', { editClasses: unimplemented.map((item) => item.slice(0, 64)) })
46
+ const content = {
47
+ schema: 'atelier-obsidian-apply-policy/v1',
48
+ policyId, workspaceId, mode, status,
49
+ actor: { kind: actor.kind, id: actor.id },
50
+ version, allowedEditClasses: classes, selector, maxBatchSize, retryBudget,
51
+ conflictDisposition: CONFLICT_DISPOSITION,
52
+ ...(ext === undefined ? {} : { ext }),
53
+ }
54
+ const policy = withApplyPolicyDigest(content)
55
+ try { assertObsidianContract('apply-policy', policy) } catch (error) {
56
+ if (error instanceof ObsidianContractRefusal) refuse('invalid-apply-policy', 'the policy does not satisfy its contract', { errors: error.detail?.errors ?? [] })
57
+ throw error
58
+ }
59
+ return policy
60
+ }
61
+
62
+ const reference = (policy) => (policy === null ? null : { policyId: policy.policyId, version: policy.version, digest: policy.digest, mode: policy.mode, status: policy.status })
63
+ const authorizationOf = (workspace) => {
64
+ const { authorized, reason } = authorizeAutomaticApply(workspace)
65
+ return { authorized, reason }
66
+ }
67
+
68
+ // The authorization the engine reads before each queued dispatch, in the
69
+ // form a caller sees: read from disk now, never cached.
70
+ export function dispatchGate(workspace) {
71
+ const authorization = authorizeAutomaticApply(workspace)
72
+ return { authorized: authorization.authorized, reason: authorization.reason, policyDigest: authorization.policy?.digest ?? null }
73
+ }
74
+
75
+ function showDocument(workspace) {
76
+ const policy = readInstalledApplyPolicy(workspace)
77
+ let machine = null
78
+ try { machine = readMachineSettings(workspace) } catch (error) { if (!isTyped(error)) throw error }
79
+ return { installed: policy !== null, policy, reference: machine?.applyPolicy ?? null, maintenanceMode: machine?.maintenanceMode ?? null, automaticApply: authorizationOf(workspace) }
80
+ }
81
+
82
+ // `workspace` is { workspaceRoot, workspaceId }; `repositoryRoots` the roots
83
+ // private state may not overlap; `now` the timestamp to record.
84
+ export function runPolicySetup({ action, input = {}, workspace, repositoryRoots, now }) {
85
+ const answer = (document) => ({ schema: POLICY_SETUP_SCHEMA, ok: true, action, ...document })
86
+ try {
87
+ if (!POLICY_SETUP_ACTIONS.includes(action)) refuse('usage', 'policy setup takes create, show or revoke', { action: String(action).slice(0, 32) })
88
+ if (!isPlainObject(workspace) || typeof workspace.workspaceRoot !== 'string' || typeof workspace.workspaceId !== 'string') refuse('usage', 'policy setup needs the workspace')
89
+ if (action === 'show') return answer(showDocument(workspace))
90
+ if (action === 'revoke') {
91
+ const result = revokeApplyPolicy({ ...workspace, repositoryRoots, updatedAt: now })
92
+ return answer({ revoked: result.revoked, reason: result.reason, policy: reference(result.policy), maintenanceMode: 'manual', automaticApply: authorizationOf(workspace) })
93
+ }
94
+ if (!isPlainObject(input)) refuse('usage', 'create takes a request object')
95
+ const installed = readInstalledApplyPolicy(workspace)
96
+ const version = input.version ?? (installed !== null && installed.policyId === input.policyId ? installed.version + 1 : 1)
97
+ if (installed !== null && installed.policyId === input.policyId && installed.version >= version) {
98
+ refuse('policy-version-not-newer', 'a policy of this identity is installed with an equal or higher version', { installed: installed.version, requested: version })
99
+ }
100
+ const policy = buildApplyPolicy({ ...input, workspaceId: workspace.workspaceId, version })
101
+ installApplyPolicy({ ...workspace, policy, repositoryRoots, updatedAt: now, digestOf: applyPolicyDigest })
102
+ return answer({ installed: true, policy, reference: reference(policy), maintenanceMode: readMachineSettings(workspace)?.maintenanceMode ?? 'manual', automaticApply: authorizationOf(workspace) })
103
+ } catch (error) {
104
+ if (!isTyped(error)) throw error
105
+ return { schema: POLICY_SETUP_SCHEMA, ok: false, action, refusal: { code: error.code, message: String(error.message).replace(`${error.code}: `, ''), detail: error.detail ?? {} } }
106
+ }
107
+ }
@@ -0,0 +1,173 @@
1
+ import { OBSIDIAN_EXT_KEY, validateObsidianContract } from '../contracts.mjs'
2
+
3
+ // Acceptance receipt validation for the gates AOP-4 owns. This is schema
4
+ // validation and required-field checking of a document. It proves that a
5
+ // receipt is shaped as a receipt of that gate: exact candidate, pinned
6
+ // versions, host and operator identity, hashed raw evidence in every role
7
+ // the procedure calls for, and the separately recorded human or adopter
8
+ // acceptance where the gate needs one.
9
+ //
10
+ // It proves nothing about what happened in the app, on the host or with the
11
+ // adopter. `gateClosed` is always false. A gate is closed by its owner
12
+ // inspecting the actual evidence and recording that in the parent matrix; a
13
+ // document that passes here is an input to that inspection, never its result.
14
+
15
+ export const RECEIPT_VALIDATION_LABEL = 'schema-validation-only'
16
+ export const RECEIPT_EXT_KEY = OBSIDIAN_EXT_KEY
17
+ export const ACCEPTANCE_KINDS = Object.freeze(['human', 'adopter'])
18
+
19
+ // Per gate: the procedure of ACCEPTANCE.md that produces it, the evidence
20
+ // type the receipt carries, the evidence roles that must each name a hashed
21
+ // raw-evidence entry, and what is separately recorded.
22
+ export const RECEIPT_GATES = Object.freeze({
23
+ G07: Object.freeze({ procedure: 'AP-01', evidenceType: 'real-app', roles: Object.freeze(['cli-link-inspection', 'app-observation']), acceptance: 'human', wallClock: false, dataset: false, tarball: false }),
24
+ G13: Object.freeze({ procedure: 'AP-02', evidenceType: 'real-app', roles: Object.freeze(['on-disk-membership', 'app-index-membership', 'graph-filter-observation']), acceptance: 'human', wallClock: false, dataset: false, tarball: false }),
25
+ G14: Object.freeze({ procedure: 'AP-03', evidenceType: 'host', roles: Object.freeze(['source-refresh-trace', 'dropped-event-recovery', 'sleep-wake-clock']), acceptance: null, wallClock: true, dataset: false, tarball: false }),
26
+ G15: Object.freeze({ procedure: 'AP-03', evidenceType: 'host', roles: Object.freeze(['ownership-health', 'terminal-closure']), acceptance: null, wallClock: true, dataset: false, tarball: false }),
27
+ G16: Object.freeze({ procedure: 'AP-04', evidenceType: 'real-app', roles: Object.freeze(['dataset-manifest', 'resource-samples', 'app-indexing-timings', 'warm-update-latencies']), acceptance: null, wallClock: true, dataset: true, tarball: false }),
28
+ G17: Object.freeze({ procedure: 'AP-05', evidenceType: 'real-app', roles: Object.freeze(['multi-vault-edit-trace', 'manual-apply-trace', 'automatic-apply-trace', 'uninstall-retention']), acceptance: 'human', wallClock: false, dataset: false, tarball: false }),
29
+ G18: Object.freeze({ procedure: 'AP-06', evidenceType: 'human', roles: Object.freeze(['tarball-audit', 'adopter-acceptance']), acceptance: 'adopter', wallClock: false, dataset: false, tarball: true }),
30
+ })
31
+ export const RECEIPT_GATE_IDS = Object.freeze(Object.keys(RECEIPT_GATES))
32
+
33
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
34
+ const DIGEST = /^sha256:[0-9a-f]{64}$/
35
+ const PLACEHOLDER = /^(?:unknown|n\/a|na|none|null|tbd|todo|latest|-+|\?+|0)$/i
36
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
37
+ const isIdentifier = (value) => typeof value === 'string' && IDENTIFIER.test(value)
38
+ const isTime = (value) => typeof value === 'string' && !Number.isNaN(Date.parse(value))
39
+ const pinned = (value) => typeof value === 'string' && value.trim() !== '' && !PLACEHOLDER.test(value.trim())
40
+
41
+ // Every check answers a list of { code, pointer, message }. Each is a
42
+ // separate rule so the mutation controls of the test suite can switch one
43
+ // off and prove the negative case that depends on it then passes.
44
+ export const RECEIPT_RULES = Object.freeze({
45
+ gate(receipt, { gateId }) {
46
+ return receipt.gate === gateId ? [] : [{ code: 'gate-mismatch', pointer: '/gate', message: `the receipt is for ${receipt.gate}, not ${gateId}` }]
47
+ },
48
+ procedure(receipt, { gate }) {
49
+ const expected = new RegExp(`^${gate.procedure}(?:[.:-][A-Za-z0-9._:-]+)?$`)
50
+ return expected.test(receipt.procedureId) ? [] : [{ code: 'procedure-mismatch', pointer: '/procedureId', message: `this gate is produced by ${gate.procedure}` }]
51
+ },
52
+ evidenceType(receipt, { gate }) {
53
+ return receipt.evidenceType === gate.evidenceType ? [] : [{ code: 'evidence-type-mismatch', pointer: '/evidenceType', message: `this gate needs ${gate.evidenceType} evidence; a node result cannot stand for it` }]
54
+ },
55
+ versionPins(receipt) {
56
+ const missing = []
57
+ for (const [pointer, value] of [['/environment/os/name', receipt.environment.os.name], ['/environment/os/version', receipt.environment.os.version], ['/environment/app/name', receipt.environment.app.name], ['/environment/app/version', receipt.environment.app.version], ['/environment/cli/version', receipt.environment.cli.version]]) {
58
+ if (!pinned(value)) missing.push({ code: 'version-not-pinned', pointer, message: 'an exact installed version is recorded, never a placeholder' })
59
+ }
60
+ return missing
61
+ },
62
+ candidateIdentity(receipt, { gate }) {
63
+ const missing = []
64
+ if (/^0{40}$/.test(receipt.candidate.commit)) missing.push({ code: 'candidate-not-pinned', pointer: '/candidate/commit', message: 'the candidate commit is a placeholder' })
65
+ if (!DIGEST.test(String(receipt.candidate.treeDigest))) missing.push({ code: 'candidate-not-pinned', pointer: '/candidate/treeDigest', message: 'the candidate tree digest is missing' })
66
+ if (gate.tarball && !DIGEST.test(String(receipt.candidate.tarballDigest ?? ''))) missing.push({ code: 'candidate-not-pinned', pointer: '/candidate/tarballDigest', message: 'this gate binds the exact tarball' })
67
+ return missing
68
+ },
69
+ hostIdentity(receipt, { ext }) {
70
+ const missing = []
71
+ if (!isPlainObject(ext.host) || !isIdentifier(ext.host.id)) missing.push({ code: 'host-identity-missing', pointer: `/ext/${RECEIPT_EXT_KEY}/host/id`, message: 'the host the procedure ran on is identified' })
72
+ if (!isPlainObject(ext.operator) || !isIdentifier(ext.operator.id)) missing.push({ code: 'operator-identity-missing', pointer: `/ext/${RECEIPT_EXT_KEY}/operator/id`, message: 'the operator who ran the procedure is identified' })
73
+ return missing
74
+ },
75
+ evidenceHashes(receipt, { gate, ext }) {
76
+ const missing = []
77
+ const names = new Map(receipt.evidence.map((item) => [item.name, item]))
78
+ receipt.evidence.forEach((item, index) => {
79
+ if (!DIGEST.test(String(item.digest)) || !Number.isInteger(item.byteLength) || item.byteLength < 1) missing.push({ code: 'evidence-not-hashed', pointer: `/evidence/${index}`, message: 'every raw evidence file is hashed and non-empty' })
80
+ })
81
+ const roles = isPlainObject(ext.evidenceRoles) ? ext.evidenceRoles : {}
82
+ for (const role of gate.roles) {
83
+ const name = roles[role]
84
+ if (typeof name !== 'string' || !names.has(name)) missing.push({ code: 'evidence-role-missing', pointer: `/ext/${RECEIPT_EXT_KEY}/evidenceRoles/${role}`, message: `the procedure records ${role} as hashed raw evidence` })
85
+ }
86
+ for (const role of Object.keys(roles)) if (!gate.roles.includes(role)) missing.push({ code: 'evidence-role-unknown', pointer: `/ext/${RECEIPT_EXT_KEY}/evidenceRoles/${role}`, message: 'this gate has no such evidence role' })
87
+ return missing
88
+ },
89
+ acceptance(receipt, { gate, ext }) {
90
+ const pointer = `/ext/${RECEIPT_EXT_KEY}/acceptance`
91
+ if (gate.acceptance === null) return ext.acceptance === undefined ? [] : [{ code: 'acceptance-not-expected', pointer, message: 'this gate records automation and host evidence; a separate acceptance belongs to another gate' }]
92
+ const acceptance = ext.acceptance
93
+ if (!isPlainObject(acceptance)) return [{ code: 'acceptance-missing', pointer, message: `this gate needs the separately recorded ${gate.acceptance} acceptance` }]
94
+ const missing = []
95
+ if (acceptance.kind !== gate.acceptance) missing.push({ code: 'acceptance-kind-mismatch', pointer: `${pointer}/kind`, message: `this gate needs a ${gate.acceptance} acceptance` })
96
+ if (!isIdentifier(acceptance.actor)) missing.push({ code: 'acceptance-missing', pointer: `${pointer}/actor`, message: 'the person who accepted is identified' })
97
+ if (!isTime(acceptance.recordedAt)) missing.push({ code: 'acceptance-missing', pointer: `${pointer}/recordedAt`, message: 'when the acceptance was recorded' })
98
+ if (typeof acceptance.evidenceName !== 'string' || !receipt.evidence.some((item) => item.name === acceptance.evidenceName)) missing.push({ code: 'acceptance-missing', pointer: `${pointer}/evidenceName`, message: 'the acceptance names its own hashed evidence entry' })
99
+ if (isPlainObject(ext.operator) && isIdentifier(acceptance.actor) && acceptance.actor === ext.operator.id && gate.acceptance === 'adopter') missing.push({ code: 'acceptance-not-separate', pointer: `${pointer}/actor`, message: 'the adopter acceptance is recorded by the adopter, not the operator' })
100
+ return missing
101
+ },
102
+ wallClock(receipt, { gate, ext }) {
103
+ if (!gate.wallClock) return []
104
+ const clock = ext.wallClock
105
+ const pointer = `/ext/${RECEIPT_EXT_KEY}/wallClock`
106
+ if (!isPlainObject(clock) || !isTime(clock.startedAt) || !isTime(clock.endedAt)) return [{ code: 'wall-clock-missing', pointer, message: 'host procedures record the wall clock before and after' }]
107
+ return Date.parse(clock.endedAt) >= Date.parse(clock.startedAt) ? [] : [{ code: 'wall-clock-missing', pointer, message: 'the wall clock ends after it starts' }]
108
+ },
109
+ dataset(receipt, { gate, ext }) {
110
+ if (!gate.dataset) return []
111
+ const dataset = ext.dataset
112
+ const pointer = `/ext/${RECEIPT_EXT_KEY}/dataset`
113
+ if (!isPlainObject(dataset) || !Number.isInteger(dataset.nodes) || dataset.nodes < 1 || !Number.isInteger(dataset.edges) || dataset.edges < 0 || !DIGEST.test(String(dataset.fixtureDigest))) {
114
+ return [{ code: 'dataset-missing', pointer, message: 'the generated dataset is described by node count, edge count and fixture digest' }]
115
+ }
116
+ return []
117
+ },
118
+ })
119
+
120
+ const EXT_KEYS = new Set(['host', 'operator', 'evidenceRoles', 'acceptance', 'wallClock', 'dataset'])
121
+
122
+ function extensionOf(receipt) {
123
+ const ext = receipt.ext?.[RECEIPT_EXT_KEY]
124
+ if (ext === undefined) return { ext: {}, missing: [{ code: 'extension-missing', pointer: `/ext/${RECEIPT_EXT_KEY}`, message: 'host, operator, evidence roles and acceptance are recorded under the extension key' }] }
125
+ if (!isPlainObject(ext)) return { ext: {}, missing: [{ code: 'extension-missing', pointer: `/ext/${RECEIPT_EXT_KEY}`, message: 'the extension member is an object' }] }
126
+ const unknown = Object.keys(ext).filter((key) => !EXT_KEYS.has(key)).sort()
127
+ return { ext, missing: unknown.map((key) => ({ code: 'extension-key-unknown', pointer: `/ext/${RECEIPT_EXT_KEY}/${key}`, message: 'the receipt extension is a closed shape' })) }
128
+ }
129
+
130
+ // Test seam: the mutation controls substitute a rule table with one rule
131
+ // switched off. Production uses validateAcceptanceReceipt.
132
+ export function createReceiptValidatorForOracleTests(rules = RECEIPT_RULES) {
133
+ const table = { ...RECEIPT_RULES, ...rules }
134
+ return function validateAcceptanceReceipt(receipt, { gate: gateId } = {}) {
135
+ const constant = { label: RECEIPT_VALIDATION_LABEL, gateClosed: false, closes: 'nothing', note: 'A schema-valid receipt closes no gate. The gate owner inspects the actual app, host or adopter evidence and records the closing in the parent matrix.' }
136
+ const requested = gateId ?? (isPlainObject(receipt) ? receipt.gate : undefined)
137
+ if (!RECEIPT_GATE_IDS.includes(requested)) {
138
+ return { ...constant, gate: typeof requested === 'string' ? requested.slice(0, 8) : null, schemaValid: false, requirementsMet: false, valid: false, outcome: null, evidenceType: null, missing: [{ code: 'gate-not-owned', pointer: '/gate', message: `this validator knows the gates ${RECEIPT_GATE_IDS.join(', ')}` }] }
139
+ }
140
+ const gate = RECEIPT_GATES[requested]
141
+ const schemaErrors = validateObsidianContract('acceptance-receipt', receipt)
142
+ if (schemaErrors.length > 0) {
143
+ return { ...constant, gate: requested, schemaValid: false, requirementsMet: false, valid: false, outcome: null, evidenceType: null, missing: schemaErrors.map((error) => ({ code: error.code, pointer: null, message: error.message })) }
144
+ }
145
+ const { ext, missing: extensionShape } = extensionOf(receipt)
146
+ const context = { gateId: requested, gate, ext }
147
+ // The rules run in table order, gate identity first, so a receipt of another gate is told that before anything about its extension.
148
+ const missing = []
149
+ for (const name of Object.keys(RECEIPT_RULES)) missing.push(...table[name](receipt, context))
150
+ missing.push(...extensionShape)
151
+ const requirementsMet = missing.length === 0
152
+ return { ...constant, gate: requested, schemaValid: true, requirementsMet, valid: requirementsMet, outcome: receipt.outcome, evidenceType: receipt.evidenceType, missing }
153
+ }
154
+ }
155
+
156
+ export const validateAcceptanceReceipt = createReceiptValidatorForOracleTests()
157
+
158
+ // The field table, for documentation and for the test that pins it.
159
+ export function receiptRequirementsFor(gateId) {
160
+ const gate = RECEIPT_GATES[gateId]
161
+ if (!gate) return null
162
+ return {
163
+ gate: gateId,
164
+ procedure: gate.procedure,
165
+ evidenceType: gate.evidenceType,
166
+ always: ['candidate.commit', 'candidate.treeDigest', 'environment.os.{name,version}', 'environment.app.{name,version}', 'environment.cli.version', 'evidence[].{name,digest,byteLength>0}', `ext.${RECEIPT_EXT_KEY}.host.id`, `ext.${RECEIPT_EXT_KEY}.operator.id`],
167
+ evidenceRoles: [...gate.roles],
168
+ acceptance: gate.acceptance,
169
+ wallClock: gate.wallClock,
170
+ dataset: gate.dataset,
171
+ tarballDigest: gate.tarball,
172
+ }
173
+ }
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { atomicReplacePrivateText, ensureContainedPrivateDirectory, readRegularTextNoFollow } from '../../../project/private-state.mjs'
4
+ import { canonicalJson, closedObject, compareText } from '../../../runtime/obsidian/documents.mjs'
5
+ import { refuse } from '../../../runtime/obsidian/errors.mjs'
6
+ import { assertOutsideRepositories } from '../../../runtime/obsidian/machine-settings.mjs'
7
+ import { SCOPE_MODES, assertObsidianContract } from '../contracts.mjs'
8
+ import { SETTINGS_ROOT } from '../materialize/settings.mjs'
9
+ import { FOCUS_BOOKMARK_TYPE, FOCUS_QUERY_VERSION } from './focus.mjs'
10
+ import { SELECTION_SCHEMA } from './selection.mjs'
11
+
12
+ // Where a selector is kept: Atelier's own private state of the workspace,
13
+ //
14
+ // <data>/obsidian/<workspace-id>/state/selection/<scope-id>.json
15
+ //
16
+ // beside the maintenance and settings documents AOP-1 keeps there, through
17
+ // the same private-state primitives (owner-only, atomic replace, no symlink
18
+ // following) and the same guard that keeps private state outside every
19
+ // enrolled repository. It is not under any vault, and it is never one of the
20
+ // files the app's UI owns under `.obsidian/`.
21
+
22
+ export const SELECTION_STATE_SCHEMA = 'atelier-obsidian-selection-state/v1'
23
+ export const SELECTION_DIRECTORY = path.join('state', 'selection')
24
+
25
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
26
+ const DIGEST = /^sha256:[0-9a-f]{64}$/
27
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/
28
+ const segment = (identifier) => identifier.replaceAll(':', '_')
29
+
30
+ export function validateSelectionState(document, workspaceId) {
31
+ const code = 'invalid-selection-state'
32
+ closedObject(document, { required: ['schema', 'workspaceId', 'scopeId', 'mode', 'selector', 'expansion', 'focus', 'updatedAt'] }, code, 'the selection state')
33
+ if (document.schema !== SELECTION_STATE_SCHEMA) refuse(code, 'the selection state names an unknown schema')
34
+ if (document.workspaceId !== workspaceId) refuse(code, 'the selection state belongs to another workspace')
35
+ if (typeof document.scopeId !== 'string' || !IDENTIFIER.test(document.scopeId)) refuse(code, 'the selection state names no scope')
36
+ if (!SCOPE_MODES.includes(document.mode)) refuse(code, 'the selection state carries an unknown mode')
37
+ // The selector and the expansion are exactly a scope document's: the contract decides whether they are well formed.
38
+ assertObsidianContract('scope', { schema: 'atelier-obsidian-scope/v1', scopeId: document.scopeId, mode: document.mode, selector: document.selector, ...(document.expansion === null ? {} : { expansion: document.expansion }) })
39
+ if (document.focus !== null) {
40
+ closedObject(document.focus, { required: ['version', 'query', 'queryDigest', 'paths', 'bookmark'] }, code, 'the persisted focus')
41
+ if (document.focus.version !== FOCUS_QUERY_VERSION || typeof document.focus.query !== 'string' || document.focus.query === '' || !DIGEST.test(String(document.focus.queryDigest))) refuse(code, 'the persisted focus is malformed')
42
+ if (!Array.isArray(document.focus.paths) || document.focus.paths.length === 0 || document.focus.paths.some((item) => typeof item !== 'string')) refuse(code, 'the persisted focus names no path')
43
+ closedObject(document.focus.bookmark, { required: ['type', 'title', 'options'] }, code, 'the persisted bookmark payload')
44
+ if (document.focus.bookmark.type !== FOCUS_BOOKMARK_TYPE) refuse(code, 'the persisted bookmark payload is not a graph bookmark')
45
+ }
46
+ if (document.mode === 'focus' ? document.focus === null : document.focus !== null) refuse(code, 'a focus is persisted exactly for a focus scope')
47
+ if (typeof document.updatedAt !== 'string' || !TIMESTAMP.test(document.updatedAt)) refuse(code, 'updatedAt must be a UTC timestamp')
48
+ return document
49
+ }
50
+
51
+ // A path Atelier may write: under the workspace's private state and never under a vault's `.obsidian/`.
52
+ export function assertWritableSelectionPath({ workspaceRoot, file }) {
53
+ const relative = path.relative(workspaceRoot, file)
54
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) refuse('selection-state-outside-workspace', 'the selection state is written under the workspace private state only')
55
+ const parts = relative.split(path.sep)
56
+ if (parts.includes(SETTINGS_ROOT) || parts[0] === 'vaults') refuse('ui-owned-file-refused', 'Atelier never writes a vault or a file under .obsidian/; the app owns those')
57
+ if (parts.length !== 3 || parts[0] !== 'state' || parts[1] !== 'selection') refuse('selection-state-outside-workspace', 'the selection state lives in state/selection')
58
+ return file
59
+ }
60
+
61
+ export function selectionStateDocument({ workspaceId, selection, now }) {
62
+ if (selection?.schema !== SELECTION_SCHEMA) refuse('invalid-selection', 'a resolved selection is persisted, nothing else')
63
+ return {
64
+ schema: SELECTION_STATE_SCHEMA,
65
+ workspaceId,
66
+ scopeId: selection.scope.scopeId,
67
+ mode: selection.scope.mode,
68
+ selector: selection.scope.selector,
69
+ expansion: selection.scope.expansion ?? null,
70
+ focus: selection.focus === null ? null : { version: selection.focus.version, query: selection.focus.query, queryDigest: selection.focus.queryDigest, paths: selection.focus.paths, bookmark: selection.focus.bookmark },
71
+ updatedAt: now,
72
+ }
73
+ }
74
+
75
+ const selectionFile = (workspaceRoot, scopeId) => path.join(workspaceRoot, SELECTION_DIRECTORY, `${segment(scopeId)}.json`)
76
+
77
+ // Writes only when the bytes change. Returns { file, changed, document }.
78
+ export function writeSelectionState({ workspaceRoot, workspaceId, repositoryRoots, selection, now }) {
79
+ const document = validateSelectionState(selectionStateDocument({ workspaceId, selection, now }), workspaceId)
80
+ assertOutsideRepositories({ managedRoot: workspaceRoot, repositoryRoots })
81
+ const file = assertWritableSelectionPath({ workspaceRoot, file: selectionFile(workspaceRoot, document.scopeId) })
82
+ const text = canonicalJson(document)
83
+ let existing = null
84
+ try { existing = readRegularTextNoFollow(file) } catch (error) { if (error.code !== 'ENOENT') throw error }
85
+ // A rewrite that changes only the time is not a change to the selection.
86
+ if (existing !== null) {
87
+ let previous = null
88
+ try { previous = JSON.parse(existing) } catch { previous = null }
89
+ if (previous !== null && canonicalJson({ ...previous, updatedAt: document.updatedAt }) === text) return { file, changed: false, document: previous }
90
+ }
91
+ fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 })
92
+ const directory = ensureContainedPrivateDirectory({ workspaceRoot, directory: path.join(workspaceRoot, SELECTION_DIRECTORY), label: 'Obsidian selection state' })
93
+ atomicReplacePrivateText(path.join(directory, path.basename(file)), text)
94
+ return { file, changed: true, document }
95
+ }
96
+
97
+ export function readSelectionState({ workspaceRoot, workspaceId, scopeId }) {
98
+ if (typeof scopeId !== 'string' || !IDENTIFIER.test(scopeId)) refuse('invalid-scope', 'name the scope whose selection to read')
99
+ let text
100
+ try { text = readRegularTextNoFollow(selectionFile(workspaceRoot, scopeId)) } catch (error) {
101
+ if (error.code === 'ENOENT') return null
102
+ refuse('invalid-selection-state', 'the selection state cannot be read', { cause: error.code ?? String(error.message) })
103
+ }
104
+ let document
105
+ try { document = JSON.parse(text) } catch { refuse('invalid-selection-state', 'the selection state is not JSON') }
106
+ const validated = validateSelectionState(document, workspaceId)
107
+ if (validated.scopeId !== scopeId) refuse('invalid-selection-state', 'the selection state names another scope')
108
+ return validated
109
+ }
110
+
111
+ export function listSelectionStates({ workspaceRoot, workspaceId }) {
112
+ let names
113
+ try { names = fs.readdirSync(path.join(workspaceRoot, SELECTION_DIRECTORY)) } catch (error) { if (error.code === 'ENOENT') return []; throw error }
114
+ const documents = []
115
+ for (const name of names.filter((item) => item.endsWith('.json')).sort(compareText)) {
116
+ const text = readRegularTextNoFollow(path.join(workspaceRoot, SELECTION_DIRECTORY, name))
117
+ let document
118
+ try { document = JSON.parse(text) } catch { refuse('invalid-selection-state', 'a selection state is not JSON', { file: name }) }
119
+ documents.push(validateSelectionState(document, workspaceId))
120
+ }
121
+ return documents.sort((left, right) => compareText(left.scopeId, right.scopeId))
122
+ }
@@ -0,0 +1,109 @@
1
+ import { refuse } from '../../../runtime/obsidian/errors.mjs'
2
+ import { EXPANSION_DIRECTIONS, EXPANSION_ORDERS, MAX_EXPANSION_DEPTH, ObsidianContractRefusal, SCOPE_MODES, assertObsidianContract, selectScope } from '../contracts.mjs'
3
+ import { allocateWorkspacePaths } from '../materialize/path-registry.mjs'
4
+ import { buildFocusQuery, focusBookmarkPayload } from './focus.mjs'
5
+
6
+ // Selection: a requested scope (full, scoped or focus, with an optional
7
+ // bounded expansion) becomes an exact scope document, the exact selected set
8
+ // and, for a focus, the graph query and bookmark payload derived from it.
9
+ //
10
+ // Selection is the contract's selectScope, unchanged: this module adds no
11
+ // selector, no default and no fallback. An empty result is reported as
12
+ // empty, or refused when the caller did not say empty is acceptable. It is
13
+ // never widened. The note paths are the ones the workspace path registry
14
+ // allocates, so a focus names the files a full vault of this workspace holds.
15
+
16
+ export const SELECTION_SCHEMA = 'atelier-obsidian-selection/v1'
17
+ export const SCOPE_CONTRACT_SCHEMA = 'atelier-obsidian-scope/v1'
18
+ export const EMPTY_SELECTION_REASONS = Object.freeze(['explicit-empty', 'no-visible-members'])
19
+
20
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
21
+
22
+ // The exact document: the caller's members, the two expansion defaults the
23
+ // contract allows filled in, and nothing else. A budget is never defaulted.
24
+ export function scopeDocumentOf({ scopeId, mode, selector, expansion, ext } = {}) {
25
+ if (typeof scopeId !== 'string' || scopeId === '') refuse('invalid-scope', 'a scope names itself with a scopeId')
26
+ if (!SCOPE_MODES.includes(mode)) refuse('invalid-scope', 'scope mode must be full, focus or scoped', { mode: String(mode).slice(0, 32) })
27
+ if (!isPlainObject(selector)) refuse('invalid-scope', 'a scope carries a selector object')
28
+ let normalizedExpansion
29
+ if (expansion !== undefined && expansion !== null) {
30
+ if (!isPlainObject(expansion)) refuse('invalid-expansion', 'expansion must be an object')
31
+ if (expansion.depth === undefined || expansion.maxNodes === undefined) refuse('missing-expansion-budget', 'expansion requires both a depth and a maxNodes budget; neither is defaulted')
32
+ // The budgets are checked here, before the contract, so a bad budget is an expansion fault and not a generic scope fault.
33
+ if (!Number.isInteger(expansion.depth) || expansion.depth < 1 || expansion.depth > MAX_EXPANSION_DEPTH) refuse('invalid-expansion', `depth must be an integer from 1 to ${MAX_EXPANSION_DEPTH}`)
34
+ if (!Number.isInteger(expansion.maxNodes) || expansion.maxNodes < 1) refuse('invalid-expansion', 'maxNodes must be a positive integer')
35
+ const direction = expansion.direction ?? 'outgoing'
36
+ const order = expansion.order ?? 'canonical-id'
37
+ if (!EXPANSION_DIRECTIONS.includes(direction)) refuse('invalid-expansion', 'unknown expansion direction')
38
+ if (!EXPANSION_ORDERS.includes(order)) refuse('invalid-expansion', 'unknown expansion order')
39
+ normalizedExpansion = { depth: expansion.depth, maxNodes: expansion.maxNodes, direction, order, ...(expansion.ext === undefined ? {} : { ext: expansion.ext }) }
40
+ }
41
+ const document = {
42
+ schema: SCOPE_CONTRACT_SCHEMA,
43
+ scopeId,
44
+ mode,
45
+ selector,
46
+ ...(normalizedExpansion === undefined ? {} : { expansion: normalizedExpansion }),
47
+ ...(ext === undefined ? {} : { ext }),
48
+ }
49
+ try { assertObsidianContract('scope', document) } catch (error) {
50
+ if (error instanceof ObsidianContractRefusal) refuse('invalid-scope', 'the requested scope does not satisfy the scope contract', { errors: error.detail?.errors ?? [] })
51
+ throw error
52
+ }
53
+ return document
54
+ }
55
+
56
+ function contractRefusalToTyped(error) {
57
+ if (error instanceof ObsidianContractRefusal) refuse(error.code, error.message.replace(`${error.code}: `, ''), error.detail ?? {})
58
+ throw error
59
+ }
60
+
61
+ // `pathRegistry` is the workspace's persisted path registry when the caller
62
+ // has one (AOP-1 keeps it in the maintenance state store); without it the
63
+ // paths are those a fresh workspace would allocate. Both are deterministic.
64
+ export function resolveSelection({ canonicalSnapshot, profile, scope, allowEmpty = false, pathRegistry = null, select = selectScope } = {}) {
65
+ const document = scopeDocumentOf(scope)
66
+ let result
67
+ try {
68
+ result = select({ canonicalSnapshot, profile, selector: document.selector, expansion: document.expansion, mode: document.mode })
69
+ } catch (error) { contractRefusalToTyped(error) }
70
+
71
+ const empty = result.nodes.length === 0
72
+ const emptyReason = empty ? (result.diagnostics.includes('selection-has-no-visible-members') ? 'no-visible-members' : 'explicit-empty') : null
73
+ if (empty && document.mode === 'focus') {
74
+ // A graph filter naming no note would show the whole vault or nothing, depending on the app. Neither is the request.
75
+ refuse('focus-selection-empty', 'the focus selects no visible note; a focus is never widened', { reason: emptyReason, unresolvedIds: result.unresolvedIds })
76
+ }
77
+ if (empty && allowEmpty !== true) refuse('selection-empty', 'the selection has no visible member; pass allowEmpty to produce an honest empty scope', { reason: emptyReason, unresolvedIds: result.unresolvedIds })
78
+
79
+ const workspaceId = typeof profile?.workspaceId === 'string' ? profile.workspaceId : 'ws-unnamed'
80
+ const nodeById = new Map(canonicalSnapshot.nodes.map((node) => [node.id, node]))
81
+ const vaultNodes = result.vaultNodes.map((id) => nodeById.get(id))
82
+ let pathOf
83
+ try { ({ pathOf } = allocateWorkspacePaths({ registry: pathRegistry, workspaceId, nodes: vaultNodes })) } catch (error) { contractRefusalToTyped(error) }
84
+ const notePaths = Object.fromEntries(result.nodes.map((id) => [id, pathOf(nodeById.get(id).repo, id)]))
85
+
86
+ let focus = null
87
+ if (document.mode === 'focus') {
88
+ const built = buildFocusQuery(result.nodes.map((id) => notePaths[id]))
89
+ focus = { ...built, bookmark: focusBookmarkPayload({ scopeId: document.scopeId, query: built.query }) }
90
+ }
91
+
92
+ return {
93
+ schema: SELECTION_SCHEMA,
94
+ scope: document,
95
+ mode: document.mode,
96
+ nodes: result.nodes,
97
+ edges: result.edges,
98
+ outsideSelectionEdges: result.outsideSelectionEdges,
99
+ vaultNodes: result.vaultNodes,
100
+ vaultEdges: result.vaultEdges,
101
+ truncated: result.truncated,
102
+ unresolvedIds: result.unresolvedIds,
103
+ diagnostics: result.diagnostics,
104
+ empty,
105
+ emptyReason,
106
+ notePaths,
107
+ focus,
108
+ }
109
+ }
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs'
2
+ import { isDeepStrictEqual } from 'node:util'
2
3
  import path from 'node:path'
3
4
  import { commandProject, readJson, writeJson } from '../project/config.mjs'
4
5
  import { summarizeReadinessJourney } from '../readiness-protocols/runtime.mjs'
@@ -50,7 +51,7 @@ export function buildProjectProjection(project) {
50
51
  const dimensionCards = readinessJourney.dimensions.map((item) => `<article class="readiness-card" data-protocol="${esc(item.protocolId)}">
51
52
  <p class="eyebrow">${esc(item.label)}</p>
52
53
  <h3>${esc(item.title)}</h3>
53
- <p><strong>${esc(item.status)}</strong> · ${esc(item.score)} / 100</p>
54
+ <p><strong>${esc(item.status)}</strong> · ${esc(item.score)} / 100 answers complete</p>
54
55
  <p>${esc(item.blockers[0] || 'review-ready')}</p>
55
56
  <button type="button" data-copy="${esc(item.agentPrompt)}">Copy protocol prompt</button>
56
57
  </article>`).join('\n')
@@ -93,6 +94,7 @@ ${branding.themeCss}</style>
93
94
  <div>
94
95
  <p class="eyebrow">MNSTRY Tenant Readiness</p>
95
96
  <h2 id="tenant-readiness-title">Readiness Journey</h2>
97
+ <p>Scores measure answer completeness, not evidence confidence, approval or runtime readiness.</p>
96
98
  <p>${esc(readinessJourney.dimensions.length)} dimensions · next protocol: ${esc(readinessJourney.nextProtocol || 'none')}</p>
97
99
  </div>
98
100
  <div class="readiness-score">${esc(readinessJourney.score)}%</div>
@@ -117,36 +119,43 @@ document.addEventListener('click', async (event) => {
117
119
  return { output, html, graph, readinessJourney }
118
120
  }
119
121
 
122
+ export function buildProjectManifest(project, projection) {
123
+ return {
124
+ schema: 'mnstry.atelier-manifest@v1',
125
+ generatedAt: 'deterministic',
126
+ graphPath: path.relative(project.outputRoot, project.graphPath).split(path.sep).join('/'),
127
+ entry: 'index.html',
128
+ tenantReadiness: {
129
+ score: projection.readinessJourney.score,
130
+ ready: projection.readinessJourney.ready,
131
+ dimensions: projection.readinessJourney.dimensions.map((item) => ({
132
+ key: item.key,
133
+ protocolId: item.protocolId,
134
+ status: item.status,
135
+ score: item.score,
136
+ })),
137
+ },
138
+ nodes: projection.graph.nodes.map((node) => ({ id: node.id, title: node.title, audience: node.audience, path: node.path })),
139
+ }
140
+ }
141
+
120
142
  export function runProjectCommand(argv = process.argv.slice(2)) {
121
143
  const check = argv.includes('--check')
122
144
  const project = commandProject({ argv })
123
145
  const projection = buildProjectProjection(project)
124
146
  if (check) {
125
147
  const current = fs.existsSync(projection.output) ? fs.readFileSync(projection.output, 'utf8') : null
126
- if (current !== projection.html) {
148
+ const manifestPath = path.join(project.outputRoot, 'atelier.manifest.json')
149
+ let manifest = null
150
+ try { manifest = readJson(manifestPath) } catch { /* Missing or invalid artifacts are stale. */ }
151
+ if (current !== projection.html || !isDeepStrictEqual(manifest, JSON.parse(JSON.stringify(buildProjectManifest(project, projection))))) {
127
152
  console.error(`project projection is stale: ${projection.output}`)
128
153
  process.exit(1)
129
154
  }
130
155
  } else {
131
156
  fs.mkdirSync(path.dirname(projection.output), { recursive: true })
132
157
  fs.writeFileSync(projection.output, projection.html)
133
- writeJson(path.join(project.outputRoot, 'atelier.manifest.json'), {
134
- schema: 'mnstry.atelier-manifest@v1',
135
- generatedAt: 'deterministic',
136
- graphPath: project.graphPath,
137
- entry: 'index.html',
138
- tenantReadiness: {
139
- score: projection.readinessJourney.score,
140
- ready: projection.readinessJourney.ready,
141
- dimensions: projection.readinessJourney.dimensions.map((item) => ({
142
- key: item.key,
143
- protocolId: item.protocolId,
144
- status: item.status,
145
- score: item.score,
146
- })),
147
- },
148
- nodes: projection.graph.nodes.map((node) => ({ id: node.id, title: node.title, audience: node.audience, path: node.path })),
149
- })
158
+ writeJson(path.join(project.outputRoot, 'atelier.manifest.json'), buildProjectManifest(project, projection))
150
159
  }
151
160
  console.log(`project projection: ${projection.graph.counts.nodes} nodes -> ${projection.output}`)
152
161
  }