@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,571 @@
1
+ import fs from 'node:fs'
2
+ import { createHash } from 'node:crypto'
3
+ import { validateJsonSchema } from '../../export/atelier-export-contract.mjs'
4
+
5
+ // Obsidian projection contracts: closed v1 document shapes, the checks a JSON
6
+ // schema cannot express, and the scope selector algebra.
7
+ //
8
+ // Nothing here publishes, applies or reads a vault. selectScope and the path
9
+ // helpers are pure functions of their arguments; only schema loading touches
10
+ // the filesystem, and only to read the shipped contract files.
11
+
12
+ export const OBSIDIAN_EXT_KEY = 'mnstry.atelier.obsidian'
13
+
14
+ export const DECLARED_RELATION_TYPES = Object.freeze([
15
+ 'related', 'supports', 'supersedes', 'implements', 'depends_on', 'evidences', 'contradicts', 'belongs_to',
16
+ ])
17
+ export const DERIVED_RELATION_TYPE = 'links_to'
18
+ export const RELATION_TYPES = Object.freeze([...DECLARED_RELATION_TYPES, DERIVED_RELATION_TYPE])
19
+
20
+ // An edit class is listed only once an apply implementation and its checks
21
+ // exist. Everything else refuses.
22
+ export const IMPLEMENTED_EDIT_CLASSES = Object.freeze(['body-replacement'])
23
+
24
+ export const SCOPE_MODES = Object.freeze(['full', 'focus', 'scoped'])
25
+ export const EXPANSION_DIRECTIONS = Object.freeze(['outgoing', 'incoming', 'both'])
26
+ export const EXPANSION_ORDERS = Object.freeze(['canonical-id'])
27
+ export const MAX_EXPANSION_DEPTH = 8
28
+ const MAX_SELECTOR_DEPTH = 64
29
+
30
+ // Portable shapes may be committed or shared and must never carry a
31
+ // machine-local absolute path, port or policy. Private shapes live only in
32
+ // ignored machine-private state.
33
+ export const OBSIDIAN_CONTRACTS = Object.freeze([
34
+ ['corpus-profile', 'portable'],
35
+ ['scope', 'portable'],
36
+ ['source-snapshot', 'portable'],
37
+ ['generation-manifest', 'portable'],
38
+ ['publication-journal', 'private'],
39
+ ['service-state', 'private'],
40
+ ['edit-operation', 'portable'],
41
+ ['apply-policy', 'private'],
42
+ ['proposal-receipt', 'portable'],
43
+ ['acceptance-receipt', 'portable'],
44
+ ['ext-settings', 'portable'],
45
+ ].map(([shape, portability]) => Object.freeze({
46
+ shape,
47
+ portability,
48
+ name: `atelier-obsidian-${shape}`,
49
+ schemaConst: `atelier-obsidian-${shape}/v1`,
50
+ contractFile: `contracts/atelier-obsidian-${shape}.v1.schema.json`,
51
+ fixtureRoot: `fixtures/obsidian/contracts/${shape}`,
52
+ })))
53
+
54
+ export class ObsidianContractRefusal extends Error {
55
+ constructor(code, message, detail = {}) {
56
+ super(`${code}: ${message}`)
57
+ this.name = 'ObsidianContractRefusal'
58
+ this.code = code
59
+ this.detail = detail
60
+ }
61
+ }
62
+
63
+ function refuse(code, message, detail) {
64
+ throw new ObsidianContractRefusal(code, message, detail)
65
+ }
66
+
67
+ function isPlainObject(value) {
68
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
69
+ }
70
+
71
+ // Code-unit comparison: locale-independent, so ordering is identical on every
72
+ // machine.
73
+ function compareIds(left, right) {
74
+ return left < right ? -1 : left > right ? 1 : 0
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Document validation
79
+ // ---------------------------------------------------------------------------
80
+
81
+ const schemaCache = new Map()
82
+
83
+ function contractFor(shape) {
84
+ const contract = OBSIDIAN_CONTRACTS.find((item) => item.shape === shape)
85
+ if (!contract) refuse('unknown-contract-shape', 'no Obsidian contract is registered for the requested shape')
86
+ return contract
87
+ }
88
+
89
+ function loadSchema(contract) {
90
+ if (!schemaCache.has(contract.shape)) {
91
+ const url = new URL(`../../../${contract.contractFile}`, import.meta.url)
92
+ schemaCache.set(contract.shape, JSON.parse(fs.readFileSync(url, 'utf8')))
93
+ }
94
+ return schemaCache.get(contract.shape)
95
+ }
96
+
97
+ const ABSOLUTE_PATH = /^(?:\/|~[\\/]|[A-Za-z]:[\\/]|\\\\|file:)/
98
+
99
+ export function isAbsolutePathLike(value) {
100
+ return typeof value === 'string' && ABSOLUTE_PATH.test(value)
101
+ }
102
+
103
+ // `pointer` is the JSON pointer of `value`, built only for a finding: a large
104
+ // document (a manifest of every note) holds millions of values and almost
105
+ // never a finding.
106
+ function collectAbsolutePaths(value, pointer, found, trail = null) {
107
+ if (typeof value === 'string') {
108
+ if (isAbsolutePathLike(value)) found.push(pointerOf(pointer, trail) || '/')
109
+ } else if (Array.isArray(value)) {
110
+ for (let index = 0; index < value.length; index += 1) collectAbsolutePaths(value[index], pointer, found, { key: index, up: trail })
111
+ } else if (isPlainObject(value)) {
112
+ for (const key of Object.keys(value)) collectAbsolutePaths(value[key], pointer, found, { key, up: trail })
113
+ }
114
+ }
115
+
116
+ function pointerOf(pointer, trail) {
117
+ const parts = []
118
+ for (let node = trail; node !== null; node = node.up) parts.push(node.key)
119
+ return `${pointer}${parts.reverse().map((part) => `/${part}`).join('')}`
120
+ }
121
+
122
+ function duplicates(values) {
123
+ const seen = new Set()
124
+ const repeated = new Set()
125
+ for (const value of values) {
126
+ if (seen.has(value)) repeated.add(value)
127
+ seen.add(value)
128
+ }
129
+ return [...repeated].sort(compareIds)
130
+ }
131
+
132
+ function pathsOverlap(left, right) {
133
+ const a = left.replace(/\/+$/, '')
134
+ const b = right.replace(/\/+$/, '')
135
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
136
+ }
137
+
138
+ function selectorDuplicateIds(selector, pointer, errors, depth = 0) {
139
+ if (!isPlainObject(selector) || depth > MAX_SELECTOR_DEPTH) return
140
+ if (Array.isArray(selector.ids) && duplicates(selector.ids).length > 0) {
141
+ errors.push({ code: 'duplicate-identity', message: `${pointer}/ids repeats an identity` })
142
+ }
143
+ for (const operator of ['union', 'intersection', 'exclude']) {
144
+ if (!Array.isArray(selector[operator])) continue
145
+ selector[operator].forEach((child, index) => selectorDuplicateIds(child, `${pointer}/${operator}/${index}`, errors, depth + 1))
146
+ }
147
+ }
148
+
149
+ const SEMANTIC_CHECKS = {
150
+ 'corpus-profile'(doc, errors) {
151
+ for (const repoId of duplicates(doc.repositories.map((repo) => repo.repoId))) {
152
+ errors.push({ code: 'duplicate-identity', message: `/repositories repeats repository identity ${repoId}` })
153
+ }
154
+ doc.repositories.forEach((repo, index) => {
155
+ for (let other = 0; other < index; other += 1) {
156
+ if (pathsOverlap(repo.root, doc.repositories[other].root)) {
157
+ errors.push({ code: 'overlapping-managed-roots', message: `/repositories/${index}/root overlaps /repositories/${other}/root` })
158
+ }
159
+ }
160
+ })
161
+ },
162
+ scope(doc, errors) {
163
+ selectorDuplicateIds(doc.selector, '/selector', errors)
164
+ },
165
+ 'source-snapshot'(doc, errors) {
166
+ for (const repoId of duplicates(doc.repositories.map((repo) => repo.repoId))) {
167
+ errors.push({ code: 'duplicate-identity', message: `/repositories repeats repository identity ${repoId}` })
168
+ }
169
+ doc.repositories.forEach((repo, index) => {
170
+ if (duplicates(repo.files.map((file) => file.path)).length > 0) {
171
+ errors.push({ code: 'duplicate-identity', message: `/repositories/${index}/files repeats a path` })
172
+ }
173
+ })
174
+ },
175
+ 'generation-manifest'(doc, errors) {
176
+ const identities = doc.notes.map((note) => `${note.repoId}\u0000${note.nodeId}`)
177
+ if (duplicates(identities).length > 0) errors.push({ code: 'duplicate-identity', message: '/notes repeats a canonical identity' })
178
+ if (duplicates(doc.notes.map((note) => note.path.toLowerCase())).length > 0) {
179
+ errors.push({ code: 'path-collision', message: '/notes allocates one path to more than one note' })
180
+ }
181
+ if (duplicates(doc.links.map((link) => link.edgeId)).length > 0) errors.push({ code: 'duplicate-identity', message: '/links repeats an edge identity' })
182
+ const nodeIds = new Set(doc.notes.map((note) => note.nodeId))
183
+ doc.links.forEach((link, index) => {
184
+ if (!nodeIds.has(link.sourceNodeId)) errors.push({ code: 'unknown-edge-endpoint', message: `/links/${index}/sourceNodeId is not a note in this generation` })
185
+ if (link.targetState === 'in-scope' && !nodeIds.has(link.targetNodeId)) {
186
+ errors.push({ code: 'unknown-edge-endpoint', message: `/links/${index}/targetNodeId is declared in-scope but is not a note in this generation` })
187
+ }
188
+ })
189
+ doc.notes.forEach((note, index) => {
190
+ const ranges = [note.regions.frontmatter, note.regions.body, ...note.regions.generated.map((region) => region.range)].filter(Boolean)
191
+ if (ranges.some((range) => range.end < range.start)) errors.push({ code: 'invalid-byte-range', message: `/notes/${index}/regions has a range that ends before it starts` })
192
+ })
193
+ if (doc.completeness.status === 'complete' && doc.completeness.writtenNotes !== doc.completeness.expectedNotes) {
194
+ errors.push({ code: 'incomplete-generation', message: '/completeness claims complete with unwritten notes' })
195
+ }
196
+ },
197
+ 'publication-journal'(doc, errors) {
198
+ const sequence = doc.entries.map((entry) => entry.seq)
199
+ if (duplicates(sequence).length > 0) errors.push({ code: 'duplicate-identity', message: '/entries repeats a sequence number' })
200
+ if (sequence.some((seq, index) => index > 0 && seq <= sequence[index - 1])) {
201
+ errors.push({ code: 'journal-out-of-order', message: '/entries sequence numbers must strictly increase' })
202
+ }
203
+ },
204
+ 'service-state'() {},
205
+ 'edit-operation'() {},
206
+ 'apply-policy'(doc, errors) {
207
+ selectorDuplicateIds(doc.selector, '/selector', errors)
208
+ for (const editClass of doc.allowedEditClasses) {
209
+ if (!IMPLEMENTED_EDIT_CLASSES.includes(editClass)) errors.push({ code: 'unimplemented-edit-class', message: '/allowedEditClasses names a class with no apply implementation' })
210
+ }
211
+ },
212
+ 'proposal-receipt'() {},
213
+ 'acceptance-receipt'(doc, errors) {
214
+ if (duplicates(doc.evidence.map((item) => item.name)).length > 0) errors.push({ code: 'duplicate-identity', message: '/evidence repeats an evidence name' })
215
+ },
216
+ 'ext-settings'(doc, errors) {
217
+ const scopeIds = doc.scopes.map((scope) => scope.scopeId)
218
+ for (const scopeId of duplicates(scopeIds)) errors.push({ code: 'duplicate-identity', message: `/scopes repeats scope identity ${scopeId}` })
219
+ if (doc.defaultScopeId !== undefined && !scopeIds.includes(doc.defaultScopeId)) {
220
+ errors.push({ code: 'unknown-scope', message: '/defaultScopeId does not name a declared scope' })
221
+ }
222
+ doc.scopes.forEach((scope, index) => selectorDuplicateIds(scope.selector, `/scopes/${index}/selector`, errors))
223
+ },
224
+ }
225
+
226
+ // Returns every reason a document is refused: [] means accepted. Each error is
227
+ // { layer: 'schema' | 'semantic', code, message }. Semantic checks run only on
228
+ // schema-valid documents, so they can rely on the shape.
229
+ export function validateObsidianContract(shape, doc) {
230
+ const contract = contractFor(shape)
231
+ const schemaErrors = validateJsonSchema(loadSchema(contract), doc)
232
+ if (schemaErrors.length > 0) return schemaErrors.map((message) => ({ layer: 'schema', code: 'schema-violation', message }))
233
+ const errors = []
234
+ SEMANTIC_CHECKS[shape](doc, errors)
235
+ if (contract.portability === 'portable') {
236
+ const found = []
237
+ collectAbsolutePaths(doc, '', found)
238
+ for (const pointer of found) errors.push({ code: 'absolute-path-in-portable-shape', message: `${pointer} carries a machine-local absolute path` })
239
+ }
240
+ return errors.map((error) => ({ layer: 'semantic', ...error }))
241
+ }
242
+
243
+ export function assertObsidianContract(shape, doc) {
244
+ const errors = validateObsidianContract(shape, doc)
245
+ if (errors.length > 0) refuse('invalid-contract-document', `${shape} document refused`, { shape, errors })
246
+ return doc
247
+ }
248
+
249
+ // Reads the settings object a project keeps under its ext container. Absent
250
+ // settings mean the integration is not configured; anything present must
251
+ // satisfy the closed ext-settings contract, so unknown keys refuse here rather
252
+ // than in the unchanged project v1 schema.
253
+ export function readObsidianExtSettings(projectConfigDoc) {
254
+ const ext = projectConfigDoc?.ext
255
+ if (!isPlainObject(ext) || !Object.hasOwn(ext, OBSIDIAN_EXT_KEY)) return null
256
+ const settings = ext[OBSIDIAN_EXT_KEY]
257
+ const errors = validateObsidianContract('ext-settings', settings)
258
+ if (errors.length > 0) refuse('invalid-ext-settings', 'project Obsidian settings refused', { errors })
259
+ return settings
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Note identity and readable paths
264
+ // ---------------------------------------------------------------------------
265
+
266
+ const RESERVED_BASENAMES = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i
267
+
268
+ // Stable identity suffix: a digest of repository identity and node identity,
269
+ // never of a title or a mutable checkout path.
270
+ export function identitySuffix(repoId, nodeId, length = 12) {
271
+ if (typeof repoId !== 'string' || repoId === '' || typeof nodeId !== 'string' || nodeId === '') {
272
+ refuse('invalid-identity', 'repository and node identity must be non-empty strings')
273
+ }
274
+ if (!Number.isInteger(length) || length < 12 || length > 64) refuse('invalid-identity', 'identity suffix length must be 12 to 64')
275
+ return createHash('sha256').update(`${repoId}\u0000${nodeId}`).digest('hex').slice(0, length)
276
+ }
277
+
278
+ export function readableTitle(title) {
279
+ const cleaned = String(title ?? '')
280
+ .normalize('NFC')
281
+ .replace(/[\u0000-\u001f\u007f/\\:*?"<>|#^[\]]/g, ' ')
282
+ .replace(/\s+/g, ' ')
283
+ .trim()
284
+ .replace(/[. ]+$/, '')
285
+ .slice(0, 120)
286
+ .trim()
287
+ if (cleaned === '') return 'Untitled'
288
+ return RESERVED_BASENAMES.test(cleaned) ? `_${cleaned}` : cleaned
289
+ }
290
+
291
+ export function notePath(node, length = 12) {
292
+ return `notes/${readableTitle(node.title)}--${identitySuffix(node.repo, node.id, length)}.md`
293
+ }
294
+
295
+ // Allocates one path per canonical identity. Duplicate titles are allowed and
296
+ // stay distinct through the suffix; a case-folded collision extends the suffix
297
+ // of the later identity (in canonical-id order) until the paths differ.
298
+ export function allocateNotePaths(nodes) {
299
+ const ordered = [...nodes].sort((left, right) => compareIds(left.id, right.id))
300
+ const taken = new Set()
301
+ const allocated = {}
302
+ for (const node of ordered) {
303
+ let length = 12
304
+ let candidate = notePath(node, length)
305
+ while (taken.has(candidate.toLowerCase())) {
306
+ length += 4
307
+ if (length > 64) refuse('path-collision', 'unable to allocate a distinct note path')
308
+ candidate = notePath(node, length)
309
+ }
310
+ taken.add(candidate.toLowerCase())
311
+ allocated[node.id] = candidate
312
+ }
313
+ return allocated
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Canonical snapshot and profile views
318
+ // ---------------------------------------------------------------------------
319
+
320
+ function profileView(profile) {
321
+ if (!isPlainObject(profile) || !Array.isArray(profile.repositories) || !Array.isArray(profile.audience?.allow)) {
322
+ refuse('invalid-profile', 'profile must declare repositories and an audience allow list')
323
+ }
324
+ const repositories = new Map()
325
+ for (const repo of profile.repositories) {
326
+ if (!isPlainObject(repo) || typeof repo.repoId !== 'string' || repo.repoId === '') refuse('invalid-profile', 'repository entries need a repoId')
327
+ if (repositories.has(repo.repoId)) refuse('duplicate-identity', 'profile repeats a repository identity')
328
+ repositories.set(repo.repoId, repo)
329
+ }
330
+ return { repositories, allowedAudiences: new Set(profile.audience.allow) }
331
+ }
332
+
333
+ function indexSnapshot(canonicalSnapshot) {
334
+ if (!isPlainObject(canonicalSnapshot) || !Array.isArray(canonicalSnapshot.nodes) || !Array.isArray(canonicalSnapshot.edges)) {
335
+ refuse('invalid-snapshot', 'canonical snapshot must carry nodes and edges arrays')
336
+ }
337
+ const nodes = new Map()
338
+ for (const node of canonicalSnapshot.nodes) {
339
+ if (!isPlainObject(node) || typeof node.id !== 'string' || node.id === '' || typeof node.repo !== 'string' || typeof node.path !== 'string') {
340
+ refuse('invalid-snapshot', 'every node needs string id, repo and path')
341
+ }
342
+ if (node.tags !== undefined && !Array.isArray(node.tags)) refuse('invalid-snapshot', 'node tags must be an array')
343
+ // Duplicate titles are fine; a repeated canonical identity is never merged.
344
+ if (nodes.has(node.id)) refuse('duplicate-identity', 'canonical snapshot repeats a node identity')
345
+ nodes.set(node.id, node)
346
+ }
347
+ const edges = new Map()
348
+ for (const edge of canonicalSnapshot.edges) {
349
+ if (!isPlainObject(edge) || typeof edge.id !== 'string' || edge.id === '') refuse('invalid-snapshot', 'every edge needs a string id')
350
+ if (edges.has(edge.id)) refuse('duplicate-identity', 'canonical snapshot repeats an edge identity')
351
+ if (!RELATION_TYPES.includes(edge.type)) refuse('unknown-relation-type', 'edge uses a relation type outside the canonical set', { edgeId: edge.id })
352
+ const derived = edge.type === DERIVED_RELATION_TYPE
353
+ if (derived ? edge.origin === 'declared' || typeof edge.origin !== 'string' : edge.origin !== 'declared') {
354
+ refuse('relation-origin-mismatch', 'declared relation types are declared; links_to is derived', { edgeId: edge.id })
355
+ }
356
+ if (!nodes.has(edge.source) || !nodes.has(edge.target)) refuse('unknown-edge-endpoint', 'edge endpoint is not a node in the snapshot', { edgeId: edge.id })
357
+ edges.set(edge.id, edge)
358
+ }
359
+ return { nodes, edges }
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Scope selection
364
+ // ---------------------------------------------------------------------------
365
+
366
+ // The three set primitives the literal oracles are sensitive to. Production
367
+ // always uses these; test/obsidian-contract.test.mjs substitutes deliberately
368
+ // broken ones through createScopeSelectorForOracleTests to prove the oracles can fail.
369
+ export const SCOPE_PRIMITIVES = Object.freeze({
370
+ // Eligibility and audience. Fails closed: only an explicit eligible: true in
371
+ // an enrolled repository with an allowed audience is visible.
372
+ isVisible(node, view) {
373
+ if (node.eligible !== true) return false
374
+ const repo = view.repositories.get(node.repo)
375
+ if (!repo || repo.enrollment !== 'enrolled') return false
376
+ const audience = node.audience ?? repo.audience
377
+ return audience === undefined || view.allowedAudiences.has(audience)
378
+ },
379
+ // exclude: [base, ...removed] keeps base members absent from every removed set.
380
+ difference(base, removed) {
381
+ return new Set([...base].filter((id) => !removed.some((set) => set.has(id))))
382
+ },
383
+ // Expansion may add a node only while the total stays within the node budget.
384
+ hasBudget(selectedCount, maxNodes) {
385
+ return selectedCount < maxNodes
386
+ },
387
+ })
388
+
389
+ const LEAF_OPERATORS = ['all', 'ids', 'repo', 'tag', 'type']
390
+ const SET_OPERATORS = ['union', 'intersection', 'exclude']
391
+
392
+ function selectorOperator(selector) {
393
+ if (!isPlainObject(selector)) refuse('unknown-selector', 'selector must be an object')
394
+ const keys = Object.keys(selector).filter((key) => key !== 'ext')
395
+ const operators = keys.filter((key) => LEAF_OPERATORS.includes(key) || SET_OPERATORS.includes(key))
396
+ const extras = keys.filter((key) => !operators.includes(key))
397
+ const allowedExtras = operators[0] === 'repo' ? ['pathPrefix'] : []
398
+ if (operators.length !== 1 || extras.some((key) => !allowedExtras.includes(key))) {
399
+ refuse('unknown-selector', 'selector must use exactly one known operator')
400
+ }
401
+ return operators[0]
402
+ }
403
+
404
+ function requestsMembers(selector) {
405
+ const operator = selectorOperator(selector)
406
+ if (operator === 'ids') return selector.ids.length > 0
407
+ if (SET_OPERATORS.includes(operator)) return selector[operator].some(requestsMembers)
408
+ return true
409
+ }
410
+
411
+ function normalizeExpansion(expansion) {
412
+ if (expansion === undefined || expansion === null) return null
413
+ if (!isPlainObject(expansion)) refuse('invalid-expansion', 'expansion must be an object')
414
+ const unknown = Object.keys(expansion).filter((key) => !['depth', 'maxNodes', 'direction', 'order', 'ext'].includes(key))
415
+ if (unknown.length > 0) refuse('invalid-expansion', 'expansion carries an unknown key')
416
+ // Expansion without both budgets would be unbounded; refuse, never default.
417
+ if (expansion.depth === undefined || expansion.maxNodes === undefined) {
418
+ refuse('missing-expansion-budget', 'expansion requires both a depth and a maxNodes budget')
419
+ }
420
+ const { depth, maxNodes, direction = 'outgoing', order = 'canonical-id' } = expansion
421
+ if (!Number.isInteger(depth) || depth < 1 || depth > MAX_EXPANSION_DEPTH) refuse('invalid-expansion', `depth must be an integer from 1 to ${MAX_EXPANSION_DEPTH}`)
422
+ if (!Number.isInteger(maxNodes) || maxNodes < 1) refuse('invalid-expansion', 'maxNodes must be a positive integer')
423
+ if (!EXPANSION_DIRECTIONS.includes(direction)) refuse('invalid-expansion', 'unknown expansion direction')
424
+ if (!EXPANSION_ORDERS.includes(order)) refuse('invalid-expansion', 'unknown expansion order')
425
+ return { depth, maxNodes, direction, order }
426
+ }
427
+
428
+ // Test seam only. Substituting isVisible removes the eligibility and audience
429
+ // boundary, so nothing outside test/obsidian-contract.test.mjs may call this;
430
+ // runtime code uses selectScope.
431
+ export function createScopeSelectorForOracleTests(primitives = SCOPE_PRIMITIVES) {
432
+ const { isVisible, difference, hasBudget } = { ...SCOPE_PRIMITIVES, ...primitives }
433
+
434
+ function evaluate(selector, context, depth) {
435
+ if (depth > MAX_SELECTOR_DEPTH) refuse('invalid-selector', 'selector nesting is too deep')
436
+ const operator = selectorOperator(selector)
437
+ const value = selector[operator]
438
+ const matching = (predicate) => new Set(context.visibleNodes.filter(predicate).map((node) => node.id))
439
+
440
+ if (operator === 'all') {
441
+ if (value !== true) refuse('invalid-selector', 'all must be true')
442
+ return matching(() => true)
443
+ }
444
+ if (operator === 'ids') {
445
+ if (!Array.isArray(value) || value.some((id) => typeof id !== 'string')) refuse('invalid-selector', 'ids must be an array of strings')
446
+ if (new Set(value).size !== value.length) refuse('duplicate-identity', 'ids selector repeats an identity')
447
+ // Absent and withheld identities are reported together, so the report
448
+ // cannot be used to confirm that a withheld object exists.
449
+ for (const id of value) if (!context.visible.has(id)) context.unresolvedIds.add(id)
450
+ return new Set(value.filter((id) => context.visible.has(id)))
451
+ }
452
+ if (operator === 'repo') {
453
+ if (typeof value !== 'string' || !context.view.repositories.has(value)) refuse('unknown-repo', 'selector names a repository outside the profile')
454
+ const prefix = selector.pathPrefix
455
+ if (prefix === undefined) return matching((node) => node.repo === value)
456
+ if (typeof prefix !== 'string' || prefix === '' || isAbsolutePathLike(prefix)) refuse('invalid-selector', 'pathPrefix must be a repository-relative path')
457
+ const directory = prefix.endsWith('/') ? prefix : `${prefix}/`
458
+ return matching((node) => node.repo === value && (node.path === prefix || node.path.startsWith(directory)))
459
+ }
460
+ if (operator === 'tag' || operator === 'type') {
461
+ if (typeof value !== 'string' || value === '') refuse('invalid-selector', `${operator} must be a non-empty string`)
462
+ return operator === 'tag'
463
+ ? matching((node) => (node.tags ?? []).includes(value))
464
+ : matching((node) => node.type === value)
465
+ }
466
+ if (!Array.isArray(value) || value.length === 0) refuse('invalid-selector', `${operator} needs at least one member selector`)
467
+ const sets = value.map((child) => evaluate(child, context, depth + 1))
468
+ if (operator === 'union') return new Set(sets.flatMap((set) => [...set]))
469
+ if (operator === 'intersection') return new Set([...sets[0]].filter((id) => sets.every((set) => set.has(id))))
470
+ return difference(sets[0], sets.slice(1))
471
+ }
472
+
473
+ function expand(selected, budget, visibleEdges) {
474
+ let truncated = false
475
+ let frontier = [...selected]
476
+ for (let hop = 0; hop < budget.depth && frontier.length > 0; hop += 1) {
477
+ const from = new Set(frontier)
478
+ const candidates = new Set()
479
+ for (const edge of visibleEdges) {
480
+ if (budget.direction !== 'incoming' && from.has(edge.source) && !selected.has(edge.target)) candidates.add(edge.target)
481
+ if (budget.direction !== 'outgoing' && from.has(edge.target) && !selected.has(edge.source)) candidates.add(edge.source)
482
+ }
483
+ frontier = []
484
+ for (const id of [...candidates].sort(compareIds)) {
485
+ if (!hasBudget(selected.size, budget.maxNodes)) {
486
+ truncated = true
487
+ continue
488
+ }
489
+ selected.add(id)
490
+ frontier.push(id)
491
+ }
492
+ }
493
+ return truncated
494
+ }
495
+
496
+ return function selectScope({ canonicalSnapshot, profile, selector, expansion, mode = 'scoped' } = {}) {
497
+ if (!SCOPE_MODES.includes(mode)) refuse('unknown-mode', 'scope mode must be full, focus or scoped')
498
+ const view = profileView(profile)
499
+ const { nodes, edges } = indexSnapshot(canonicalSnapshot)
500
+ const budget = normalizeExpansion(expansion)
501
+
502
+ const visibleNodes = [...nodes.values()].filter((node) => isVisible(node, view))
503
+ const visible = new Set(visibleNodes.map((node) => node.id))
504
+ // An edge with a withheld endpoint does not exist for selection purposes:
505
+ // it is not selected, not traversed and not reported as outside-selection.
506
+ const seenEdgeKeys = new Set()
507
+ const visibleEdges = [...edges.values()]
508
+ .filter((edge) => visible.has(edge.source) && visible.has(edge.target))
509
+ .sort((left, right) => compareIds(left.id, right.id))
510
+ .filter((edge) => {
511
+ // Identities and types are strings that never hold NUL; `origin` is whatever the edge carries.
512
+ const key = `${edge.source}\u0000${edge.target}\u0000${edge.type}\u0000${JSON.stringify(edge.origin) ?? 'null'}`
513
+ if (seenEdgeKeys.has(key)) return false
514
+ seenEdgeKeys.add(key)
515
+ return true
516
+ })
517
+
518
+ const context = { view, visibleNodes, visible, unresolvedIds: new Set() }
519
+ const selected = evaluate(selector, context, 0)
520
+ if (mode === 'full' && selectorOperator(selector) !== 'all') refuse('full-mode-requires-all', 'full mode selects the whole authorized corpus')
521
+ const truncated = budget ? expand(selected, budget, visibleEdges) : false
522
+
523
+ const inside = (edge) => selected.has(edge.source) && selected.has(edge.target)
524
+ const touching = (edge) => selected.has(edge.source) !== selected.has(edge.target)
525
+ const nodeIds = [...selected].sort(compareIds)
526
+ const diagnostics = []
527
+ if (nodeIds.length === 0 && requestsMembers(selector)) diagnostics.push('selection-has-no-visible-members')
528
+ if (truncated) diagnostics.push('expansion-truncated-at-node-budget')
529
+
530
+ // Focus keeps the full vault and filters the graph; it is not a
531
+ // confidentiality boundary. Scoped and full vaults hold the selection
532
+ // only, and that is a statement about which notes exist and about
533
+ // generated bytes, not about authored bytes: emission is byte-faithful, so
534
+ // a visible author's own references to withheld or out-of-selection
535
+ // documents (identities, repository-relative paths, link text) are emitted
536
+ // unchanged. No mode is a confidentiality boundary against what visible
537
+ // authors wrote. `outsideSelectionEdges` feeds a per-note count of
538
+ // relationships to visible-but-unselected notes; withheld endpoints were
539
+ // already dropped from `visibleEdges` and are never counted.
540
+ const vaultNodes = mode === 'focus' ? [...visible].sort(compareIds) : nodeIds
541
+ const vaultEdges = mode === 'focus' ? visibleEdges.map((edge) => edge.id) : visibleEdges.filter(inside).map((edge) => edge.id)
542
+
543
+ return {
544
+ mode,
545
+ nodes: nodeIds,
546
+ edges: visibleEdges.filter(inside).map((edge) => edge.id),
547
+ outsideSelectionEdges: visibleEdges.filter(touching).map((edge) => edge.id),
548
+ vaultNodes,
549
+ vaultEdges,
550
+ truncated,
551
+ unresolvedIds: [...context.unresolvedIds].sort(compareIds),
552
+ diagnostics,
553
+ }
554
+ }
555
+ }
556
+
557
+ export const selectScope = createScopeSelectorForOracleTests()
558
+
559
+ // Resolves a title-only wikilink among visible nodes. A title shared by more
560
+ // than one visible node refuses instead of choosing a target.
561
+ export function resolveTitleLink({ canonicalSnapshot, profile, input }) {
562
+ const match = typeof input === 'string' ? input.match(/^\[\[([^[\]|#^]+)\]\]$/) : null
563
+ if (!match) refuse('unsupported-link', 'only a plain title wikilink can be resolved by title')
564
+ const title = match[1].trim()
565
+ const view = profileView(profile)
566
+ const { nodes } = indexSnapshot(canonicalSnapshot)
567
+ const targets = [...nodes.values()].filter((node) => SCOPE_PRIMITIVES.isVisible(node, view) && node.title === title)
568
+ if (targets.length === 0) refuse('unresolved-target', 'no visible node carries that title')
569
+ if (targets.length > 1) refuse('ambiguous-target', 'more than one visible node carries that title')
570
+ return targets[0].id
571
+ }