@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,746 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { unclosedFenceAtEnd } from '../../../graph/knowledge-graph.mjs'
3
+ import { DERIVED_RELATION_TYPE, RELATION_TYPES, SCOPE_PRIMITIVES, assertObsidianContract, identitySuffix, readableTitle, selectScope } from '../contracts.mjs'
4
+ import { assertStrictUtf8, readMarkdownLens, refuse, sha256Digest } from './byte-lens.mjs'
5
+ import { allocateWorkspacePaths, collisionKey, emptyPathRegistry, titleWithinBudget } from './path-registry.mjs'
6
+ import { isUserOwnedSettingsPath, prepareSettings } from './settings.mjs'
7
+
8
+ // prepareView: a pure preparation of one Obsidian view. It reads sources
9
+ // through the caller, returns note, attachment and settings bytes plus a
10
+ // generation manifest, and writes nothing. The canonical graph is the only
11
+ // graph authority: notes and relation rows serialize canonical nodes and
12
+ // edges, and only canonical resolved link and embed occurrences are rewritten.
13
+ // An embedded asset is copied only when the canonical graph resolved it; the
14
+ // emitter never infers one from authored bytes.
15
+ //
16
+ // Redaction happens where bytes are made. Every title, path and identity that
17
+ // reaches an output is looked up through `vault`, the set selectScope returned
18
+ // for this view, so a node outside it cannot be serialized by any branch.
19
+ //
20
+ // Preparation is incremental when the caller passes a preparation cache (see
21
+ // createPreparationCache). Every note's bytes and manifest entry are a pure
22
+ // function of a small set of inputs: the pinned source digest, the node
23
+ // record, the allocated path, the generated rows, the outside-selection count
24
+ // and the rewritten occurrences with their emitted targets. Those inputs are
25
+ // serialized into a dependency key per note; a note whose key matches the
26
+ // cached one reuses the cached bytes and manifest entry instead of being
27
+ // emitted again, so the result is byte-identical to a full preparation by
28
+ // construction. The cache holds derived state only and can be dropped at any
29
+ // time; the redaction guard still runs over the whole result.
30
+
31
+ export const EMITTER_VERSION = '1.0.0'
32
+ const EXT_KEY = 'mnstry.atelier.obsidian'
33
+ const EMBEDDABLE = new Set(['pdf', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'])
34
+ // Bytes left for an asset's readable stem beside the longest suffix and extension.
35
+ const ASSET_STEM_BYTE_BUDGET = 255 - '--'.length - 64 - '.'.length - 16
36
+ const RELATIONS_HEADING = '## Relations (generated)\n\n%% Generated from the canonical graph. Edits to this section are not applied to any source. %%\n\n'
37
+
38
+ const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0)
39
+ const utf8 = (text) => Buffer.from(text, 'utf8')
40
+ const base64url = (bytes) => Buffer.from(bytes).toString('base64url')
41
+
42
+ // Text placed in generated prose. Anything that could start a link, a tag, an
43
+ // embed or a comment is removed or escaped, so generated prose can never add a
44
+ // native graph edge that the canonical graph does not hold.
45
+ function plainText(value) {
46
+ return String(value ?? '')
47
+ .normalize('NFC')
48
+ .replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, ' ')
49
+ .replace(/\s+/g, ' ')
50
+ .trim()
51
+ .replace(/[\\`*_[\]<>#|^$%~=!&]/g, (character) => `\\${character}`)
52
+ }
53
+
54
+ function encodeHref(fileName) {
55
+ return encodeURIComponent(fileName).replace(/[()']/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`)
56
+ }
57
+
58
+ const noteBasename = (notePathValue) => notePathValue.slice('notes/'.length, -'.md'.length)
59
+
60
+ function edgeIdentifier(edge) {
61
+ return `e-${createHash('sha256').update(`${edge.source}\u0000${edge.type}\u0000${edge.target}`).digest('hex').slice(0, 32)}`
62
+ }
63
+
64
+ function timestampFrom(clock) {
65
+ if (typeof clock !== 'function') refuse('missing-clock', 'prepareView needs an injected clock for the manifest freshness timestamp')
66
+ const value = clock()
67
+ const date = value instanceof Date ? value : new Date(value)
68
+ if (Number.isNaN(date.getTime())) refuse('missing-clock', 'the injected clock did not return a time')
69
+ return date.toISOString()
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Sources
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function sourceReader(snapshot) {
77
+ const expected = new Map()
78
+ for (const repo of snapshot.document.repositories) {
79
+ for (const file of repo.files) expected.set(`${repo.repoId}\u0000${file.path}`, file)
80
+ }
81
+ const pinnedFor = (repoId, relativePath) => {
82
+ const pinned = expected.get(`${repoId}\u0000${relativePath}`)
83
+ if (!pinned) refuse('source-not-in-snapshot', 'a selected source is not pinned by the source snapshot')
84
+ return pinned
85
+ }
86
+ const read = (repoId, relativePath) => {
87
+ const pinned = pinnedFor(repoId, relativePath)
88
+ const bytes = snapshot.readSource(repoId, relativePath)
89
+ if (!Buffer.isBuffer(bytes)) refuse('invalid-source', 'readSource must return a Buffer')
90
+ if (bytes.length !== pinned.byteLength || sha256Digest(bytes) !== pinned.rawDigest) {
91
+ refuse('mixed-read', 'source bytes differ from the digest pinned by the source snapshot')
92
+ }
93
+ return { bytes, rawDigest: pinned.rawDigest }
94
+ }
95
+ read.pinned = pinnedFor
96
+ return read
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Preparation cache
101
+ // ---------------------------------------------------------------------------
102
+
103
+ // Derived, droppable state for incremental preparation: one entry per note
104
+ // path holding the dependency key the note was emitted under and everything
105
+ // the note contributed to the result. It is private to the process that made
106
+ // it and is never written anywhere.
107
+ export function createPreparationCache() {
108
+ return { notes: new Map() }
109
+ }
110
+
111
+ function isPreparationCache(cache) {
112
+ return cache !== null && typeof cache === 'object' && cache.notes instanceof Map
113
+ }
114
+
115
+ // Everything the emitted bytes and the manifest entry of one note depend on,
116
+ // in a canonical serialization. A cached note is reused only under an equal
117
+ // key. The emitter version is part of the key so an entry states which emitter
118
+ // produced it, should a cache ever outlive this module.
119
+ function dependencyKey({ node, notePathValue, pinned, rows, outsideCount, occurrences, emittedTarget }) {
120
+ // A wrapper note serializes the record's summary and tags; a Markdown note never does.
121
+ const wrapper = node.extension === 'md' ? null : JSON.stringify([String(node.summary ?? ''), Array.isArray(node.tags) ? node.tags : null])
122
+ const parts = [
123
+ EMITTER_VERSION, notePathValue, node.repo, node.id, node.path, String(node.extension), String(node.title ?? ''), String(wrapper),
124
+ pinned.rawDigest, String(pinned.byteLength), String(outsideCount), rows.join(''),
125
+ ]
126
+ for (const occurrence of occurrences) {
127
+ const target = emittedTarget(occurrence)
128
+ parts.push(JSON.stringify([
129
+ occurrence.type ?? null, occurrence.syntax ?? null, occurrence.embed === true, occurrence.href ?? null, occurrence.target ?? null,
130
+ occurrence.range?.byteStart ?? null, occurrence.range?.byteEnd ?? null, occurrence.targetRange?.byteStart ?? null, occurrence.targetRange?.byteEnd ?? null,
131
+ target.key, target.markdown, target.wikilink, target.alias,
132
+ ]))
133
+ }
134
+ // JSON escapes every control character, so no field can shift into its neighbour.
135
+ return createHash('sha256').update(JSON.stringify(parts)).digest('hex')
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Link rewriting
140
+ // ---------------------------------------------------------------------------
141
+
142
+ // The byte edits for one source: each is { start, end, emitted } over the
143
+ // source Buffer. Offsets come from the canonical occurrence; the bytes under
144
+ // them are compared with what the graph read, so a graph built from different
145
+ // bytes refuses instead of rewriting the wrong place. `emittedTarget` names
146
+ // what an occurrence is rewritten to: { key, markdown, wikilink, alias }.
147
+ // Links and asset embeds go through here together, so one overlap check covers
148
+ // both.
149
+ function linkEdits({ source, lens, occurrences, emittedTarget }) {
150
+ const edits = []
151
+ for (const occurrence of occurrences) {
152
+ const { range, targetRange } = occurrence
153
+ const offsets = [range?.byteStart, targetRange?.byteStart, targetRange?.byteEnd, range?.byteEnd]
154
+ if (offsets.some((offset) => !Number.isInteger(offset)) || offsets.some((offset, index) => index > 0 && offset < offsets[index - 1])) {
155
+ refuse('link-offsets-unavailable', 'a canonical link occurrence carries no usable byte offsets')
156
+ }
157
+ if (range.byteStart < lens.body.start || range.byteEnd > lens.body.end) refuse('link-outside-body', 'a canonical link occurrence lies outside the authored body')
158
+ const written = source.subarray(targetRange.byteStart, targetRange.byteEnd).toString('utf8')
159
+ const target = emittedTarget(occurrence)
160
+ if (occurrence.syntax === 'markdown') {
161
+ if (written !== occurrence.href) refuse('mixed-read', 'source bytes under a canonical link differ from the link the graph read')
162
+ edits.push({ ...target.key, start: targetRange.byteStart, end: targetRange.byteEnd, emitted: utf8(target.markdown) })
163
+ } else if (occurrence.syntax === 'wikilink') {
164
+ const whole = source.subarray(range.byteStart, range.byteEnd).toString('utf8')
165
+ if (written.trim() !== occurrence.href || !whole.startsWith('[[') || !whole.endsWith(']]')) {
166
+ refuse('mixed-read', 'source bytes under a canonical link differ from the link the graph read')
167
+ }
168
+ edits.push({ ...target.key, start: targetRange.byteStart, end: targetRange.byteEnd, emitted: utf8(target.wikilink) })
169
+ // Keep the words the author chose visible: without an alias the editor
170
+ // would display the allocated file name. An embed has no label: what
171
+ // follows its target (a size, a fragment) stays as authored.
172
+ const tail = source.subarray(targetRange.byteEnd, range.byteEnd - 2).toString('utf8')
173
+ if (target.alias && !occurrence.embed && !tail.includes('|')) {
174
+ edits.push({ ...target.key, start: range.byteEnd - 2, end: range.byteEnd - 2, emitted: utf8(`|${occurrence.href}`) })
175
+ }
176
+ } else {
177
+ refuse('unsupported-link', 'a canonical link occurrence uses an unknown syntax')
178
+ }
179
+ }
180
+ edits.sort((left, right) => left.start - right.start || left.end - right.end)
181
+ for (let index = 1; index < edits.length; index += 1) {
182
+ if (edits[index].start < edits[index - 1].end) refuse('link-overlap', 'canonical link occurrences overlap')
183
+ }
184
+ return edits
185
+ }
186
+
187
+ // Applies edits to the body and returns the emitted bytes plus, for each edit,
188
+ // its source range, its range in `prefixLength`-offset note space and both
189
+ // byte strings.
190
+ function applyEdits({ source, from, to, edits, noteOffset }) {
191
+ const parts = []
192
+ const inversions = []
193
+ let cursor = from
194
+ let written = noteOffset
195
+ for (const edit of edits) {
196
+ const kept = source.subarray(cursor, edit.start)
197
+ parts.push(kept)
198
+ written += kept.length
199
+ parts.push(edit.emitted)
200
+ inversions.push({
201
+ edgeKey: edit.edgeKey,
202
+ assetKey: edit.assetKey,
203
+ source: { start: edit.start, end: edit.end },
204
+ note: { start: written, end: written + edit.emitted.length },
205
+ ext: { [EXT_KEY]: { original: base64url(source.subarray(edit.start, edit.end)), emitted: base64url(edit.emitted), encoding: 'base64url' } },
206
+ })
207
+ written += edit.emitted.length
208
+ cursor = edit.end
209
+ }
210
+ parts.push(source.subarray(cursor, to))
211
+ return { bytes: Buffer.concat(parts), inversions }
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Generated sections
216
+ // ---------------------------------------------------------------------------
217
+
218
+ function separatorAfter(bytes) {
219
+ if (bytes.length === 0) return ''
220
+ return bytes[bytes.length - 1] === 0x0a ? '\n' : '\n\n'
221
+ }
222
+
223
+ // `titles` memoizes the two renderings of a title per node for one
224
+ // preparation: a node's title is rendered once however many rows name it.
225
+ function titleRenderings() {
226
+ const readable = new Map()
227
+ const plain = new Map()
228
+ return {
229
+ readableOf: (target) => { if (!readable.has(target.id)) readable.set(target.id, readableTitle(target.title)); return readable.get(target.id) },
230
+ plainOf: (origin) => { if (!plain.has(origin.id)) plain.set(origin.id, plainText(origin.title)); return plain.get(origin.id) },
231
+ }
232
+ }
233
+
234
+ function relationRows({ node, outgoing, incoming, vaultNode, pathOf, titles }) {
235
+ const rows = []
236
+ for (const type of RELATION_TYPES) {
237
+ for (const edge of outgoing) {
238
+ if (edge.type !== type) continue
239
+ const target = vaultNode(edge.target)
240
+ if (!target) continue
241
+ rows.push(`- ${type} → [[${noteBasename(pathOf(target))}|${titles.readableOf(target)}]]\n`)
242
+ }
243
+ // An incoming row is plain text: a link here would give this note an
244
+ // outgoing native link that the canonical graph does not hold.
245
+ if (type === DERIVED_RELATION_TYPE) continue
246
+ for (const edge of incoming) {
247
+ if (edge.type !== type) continue
248
+ const origin = vaultNode(edge.source)
249
+ if (!origin || origin.id === node.id) continue
250
+ rows.push(`- ${type} ← ${titles.plainOf(origin)}\n`)
251
+ }
252
+ }
253
+ return rows
254
+ }
255
+
256
+ // An authored body that ends inside a fenced code block would swallow whatever
257
+ // follows it. The closing fence is generated, never authored: it is the first
258
+ // bytes of the first generated region, so authored ranges and inversion stay
259
+ // exact. It repeats the opener's indentation, character and length, starts on
260
+ // its own line and uses the source's line ending. The fence rules are the
261
+ // canonical graph scanner's, not restated here.
262
+ function fenceClosureFor(source, finalNewline) {
263
+ const fence = unclosedFenceAtEnd(source.toString('utf8'))
264
+ if (!fence) return null
265
+ const firstBreak = source.indexOf(0x0a)
266
+ const crlf = finalNewline === 'crlf' || (finalNewline === 'none' && firstBreak > 0 && source[firstBreak - 1] === 0x0d)
267
+ const eol = crlf ? '\r\n' : '\n'
268
+ const line = `${' '.repeat(fence.indent)}${fence.char.repeat(fence.length)}`
269
+ return { fence: line, text: `${finalNewline === 'none' ? eol : ''}${line}${eol}` }
270
+ }
271
+
272
+ function generatedSections({ precedingBytes, offset, rows, outsideCount, closure = null }) {
273
+ const regions = []
274
+ const parts = []
275
+ let cursor = offset
276
+ const push = (kind, text) => {
277
+ const closes = closure && regions.length === 0
278
+ const bytes = utf8(closes ? `${closure.text}${text}` : text)
279
+ parts.push(bytes)
280
+ regions.push({
281
+ kind,
282
+ range: { start: cursor, end: cursor + bytes.length },
283
+ ...(closes ? { ext: { [EXT_KEY]: { fenceClosure: { fence: closure.fence, byteLength: Buffer.byteLength(closure.text, 'utf8') } } } } : {}),
284
+ })
285
+ cursor += bytes.length
286
+ }
287
+ if (closure) precedingBytes = utf8(closure.text)
288
+ if (rows.length > 0) push('relations', `${separatorAfter(precedingBytes)}${RELATIONS_HEADING}${rows.join('')}`)
289
+ if (outsideCount > 0) {
290
+ const lead = rows.length > 0 ? '\n' : `${separatorAfter(precedingBytes)}${RELATIONS_HEADING}`
291
+ push('outside-selection', `${lead}Relationships leading outside this view: ${outsideCount}\n`)
292
+ }
293
+ return { bytes: Buffer.concat(parts), regions }
294
+ }
295
+
296
+ function wrapperRepresentation({ node, attachmentPath }) {
297
+ const lines = [`# ${plainText(node.title)}\n`]
298
+ if (node.summary) lines.push(`\n${plainText(node.summary)}\n`)
299
+ lines.push(`\n- Format: ${plainText(node.extension)}\n`)
300
+ lines.push(`- Source: ${plainText(node.repo)} · ${node.path.split('/').map(plainText).join(' / ')}\n`)
301
+ if (Array.isArray(node.tags) && node.tags.length > 0) lines.push(`- Tags: ${node.tags.map(plainText).join(', ')}\n`)
302
+ lines.push(`- Original: [[${attachmentPath}|Open the original file]]\n`)
303
+ if (EMBEDDABLE.has(String(node.extension).toLowerCase())) lines.push(`\n![[${attachmentPath}]]\n`)
304
+ return utf8(lines.join(''))
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // prepareView
309
+ // ---------------------------------------------------------------------------
310
+
311
+ function assertContained(relativePath) {
312
+ const parts = relativePath.split('/')
313
+ if (relativePath.startsWith('/') || relativePath.includes('\\') || relativePath.includes('\u0000') || parts.some((part) => part === '' || part === '.' || part === '..')) {
314
+ refuse('path-escapes-vault', 'a prepared path would leave the vault root')
315
+ }
316
+ }
317
+
318
+ // One note, from its inputs alone: the prepared files (the note and, for a
319
+ // wrapper, its attachment), the manifest entry, the attachment record, the
320
+ // inversions it contributes per rewritten edge, and whether it closed a fence.
321
+ // Nothing outside the arguments is read, which is what lets the result be
322
+ // cached under the dependency key.
323
+ function emitNote({ node, notePathValue, source: { bytes: source, rawDigest }, rows, outsideCount, occurrences, emittedTarget, key }) {
324
+ const sourceRecord = { path: node.path, rawDigest, byteLength: source.length }
325
+ const assetInversions = new Map()
326
+ const edgeInversions = new Map()
327
+ const files = []
328
+ let attachment = null
329
+ let authored
330
+ let regions
331
+ let closure = null
332
+
333
+ if (node.extension === 'md') {
334
+ const lens = readMarkdownLens(source, { repoId: node.repo, nodeId: node.id })
335
+ const edits = linkEdits({ source, lens, occurrences, emittedTarget })
336
+ const prefix = source.subarray(0, lens.body.start)
337
+ const body = applyEdits({ source, from: lens.body.start, to: lens.body.end, edits, noteOffset: prefix.length })
338
+ authored = Buffer.concat([prefix, body.bytes])
339
+ for (const { edgeKey, assetKey, ...inversion } of body.inversions) {
340
+ if (assetKey !== undefined) {
341
+ if (!assetInversions.has(assetKey)) assetInversions.set(assetKey, [])
342
+ assetInversions.get(assetKey).push(inversion)
343
+ continue
344
+ }
345
+ if (!edgeInversions.has(edgeKey)) edgeInversions.set(edgeKey, [])
346
+ edgeInversions.get(edgeKey).push(inversion)
347
+ }
348
+ regions = {
349
+ ...(lens.frontmatter ? { frontmatter: lens.frontmatter } : {}),
350
+ body: { start: lens.body.start, end: authored.length },
351
+ }
352
+ closure = fenceClosureFor(source, lens.finalNewline)
353
+ Object.assign(sourceRecord, { kind: 'markdown', finalNewline: lens.finalNewline, ...(lens.bom ? { bom: lens.bom } : {}) })
354
+ } else {
355
+ const extension = /^[a-z0-9]{1,16}$/.test(String(node.extension).toLowerCase()) ? String(node.extension).toLowerCase() : 'bin'
356
+ const attachmentPath = `attachments/${noteBasename(notePathValue)}.${extension}`
357
+ assertContained(attachmentPath)
358
+ attachment = { path: attachmentPath, digest: rawDigest, byteLength: source.length }
359
+ files.push({ path: attachmentPath, kind: 'attachment', bytes: source, digest: rawDigest })
360
+ authored = wrapperRepresentation({ node, attachmentPath })
361
+ regions = { body: { start: authored.length, end: authored.length }, representation: { start: 0, end: authored.length } }
362
+ Object.assign(sourceRecord, { kind: 'wrapper', attachment: attachmentPath })
363
+ }
364
+
365
+ const generated = generatedSections({ precedingBytes: authored, offset: authored.length, rows, outsideCount, closure })
366
+ const bytes = Buffer.concat([authored, generated.bytes])
367
+ assertStrictUtf8(bytes)
368
+ assertContained(notePathValue)
369
+ const { representation, ...authoredRegions } = regions
370
+ files.push({ path: notePathValue, kind: 'note', bytes, digest: sha256Digest(bytes) })
371
+ const note = {
372
+ repoId: node.repo,
373
+ nodeId: node.id,
374
+ path: notePathValue,
375
+ title: String(node.title || 'Untitled').slice(0, 512),
376
+ noteDigest: sha256Digest(bytes),
377
+ regions: {
378
+ ...authoredRegions,
379
+ generated: [...(representation ? [{ kind: 'representation', range: representation }] : []), ...generated.regions],
380
+ },
381
+ ext: {
382
+ [EXT_KEY]: {
383
+ source: sourceRecord,
384
+ ...(assetInversions.size > 0
385
+ ? { assetEmbeds: [...assetInversions].map(([attachment, inversions]) => ({ attachment, inversions })).sort((left, right) => compare(left.attachment, right.attachment)) }
386
+ : {}),
387
+ },
388
+ },
389
+ }
390
+ return { key, files, attachment, note, edgeInversions: [...edgeInversions], fenceClosed: Boolean(closure && generated.regions.length > 0) }
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // Embedded assets
395
+ // ---------------------------------------------------------------------------
396
+
397
+ // The assets this view may copy, by id. Fails closed like a node: only an
398
+ // explicit eligible: true, in a repository the profile enrols, under the
399
+ // repository's audience rule. A malformed or repeated record is withheld.
400
+ function visibleAssets(graph, profile) {
401
+ const view = { repositories: new Map(profile.repositories.map((repo) => [repo.repoId, repo])), allowedAudiences: new Set(profile.audience.allow) }
402
+ const seen = new Set()
403
+ const visible = new Map()
404
+ for (const asset of Array.isArray(graph.assets) ? graph.assets : []) {
405
+ if (!asset || typeof asset.id !== 'string' || asset.id === '' || typeof asset.repo !== 'string' || asset.repo === '' || typeof asset.path !== 'string' || asset.path === '') continue
406
+ if (seen.has(asset.id)) {
407
+ visible.delete(asset.id)
408
+ continue
409
+ }
410
+ seen.add(asset.id)
411
+ if (SCOPE_PRIMITIVES.isVisible({ eligible: asset.eligible, repo: asset.repo }, view)) visible.set(asset.id, asset)
412
+ }
413
+ return visible
414
+ }
415
+
416
+ // attachments/<readable stem>--<identity suffix>.<ext>, from the repository and
417
+ // asset identity alone. The suffix lengthens only when two assets of this view
418
+ // would share a name on a case- or normalization-insensitive filesystem.
419
+ function allocateAssetPaths(assets) {
420
+ const taken = new Set()
421
+ const allocated = new Map()
422
+ for (const asset of [...assets].sort((left, right) => compare(left.repo, right.repo) || compare(left.id, right.id))) {
423
+ const name = asset.path.split('/').at(-1)
424
+ const dot = name.lastIndexOf('.')
425
+ const written = dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
426
+ const extension = /^[a-z0-9]{1,16}$/.test(written) ? written : 'bin'
427
+ const stem = readableTitle(titleWithinBudget(dot > 0 ? name.slice(0, dot) : name, ASSET_STEM_BYTE_BUDGET))
428
+ let length = 12
429
+ let candidate = `attachments/${stem}--${identitySuffix(asset.repo, asset.id, length)}.${extension}`
430
+ while (taken.has(collisionKey(candidate))) {
431
+ length += 4
432
+ if (length > 64) refuse('path-collision', 'unable to allocate a distinct attachment path')
433
+ candidate = `attachments/${stem}--${identitySuffix(asset.repo, asset.id, length)}.${extension}`
434
+ }
435
+ taken.add(collisionKey(candidate))
436
+ assertContained(candidate)
437
+ allocated.set(asset.id, candidate)
438
+ }
439
+ return allocated
440
+ }
441
+
442
+ function canonicalSnapshotOf(graph) {
443
+ if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges) || !Array.isArray(graph.links)) {
444
+ refuse('invalid-snapshot', 'snapshot.graph must carry canonical nodes, edges and links')
445
+ }
446
+ const known = new Set(graph.nodes.map((node) => node?.id))
447
+ // A declared relation may name an identity outside the census. It has no
448
+ // note to point at, so it is not part of any view.
449
+ const edges = graph.edges.filter((edge) => known.has(edge.source) && known.has(edge.target))
450
+ return { nodes: graph.nodes, edges, externalEdgeCount: graph.edges.length - edges.length }
451
+ }
452
+
453
+ // A path the prior generation published stays allocated even when the caller
454
+ // lost the registry: the prior manifest seeds any identity the registry lacks.
455
+ function seededRegistry({ registry, priorManifest, workspaceId }) {
456
+ if (!priorManifest) return registry
457
+ const base = registry ?? emptyPathRegistry(workspaceId)
458
+ if (!Array.isArray(base.entries)) return base
459
+ const known = new Set(base.entries.map((entry) => `${entry?.repoId}\u0000${entry?.nodeId}`))
460
+ const recovered = priorManifest.notes.filter((note) => !known.has(`${note.repoId}\u0000${note.nodeId}`)).map(({ repoId, nodeId, path }) => ({ repoId, nodeId, path }))
461
+ return { ...base, entries: [...base.entries, ...recovered] }
462
+ }
463
+
464
+ export function prepareView({ snapshot, profile, scope, persistentPathRegistry = null, priorManifest = null, existingSettings = null, clock, generationId, vaultRootBytes, maxFullPathBytes, cache = null } = {}) {
465
+ if (cache !== null && !isPreparationCache(cache)) refuse('invalid-preparation-cache', 'the preparation cache must come from createPreparationCache')
466
+ assertObsidianContract('corpus-profile', profile)
467
+ assertObsidianContract('scope', scope)
468
+ if (!snapshot || typeof snapshot.readSource !== 'function') refuse('invalid-snapshot', 'snapshot needs a source snapshot document, a canonical graph and readSource')
469
+ assertObsidianContract('source-snapshot', snapshot.document)
470
+ if (snapshot.document.workspaceId !== profile.workspaceId) refuse('invalid-snapshot', 'source snapshot and corpus profile name different workspaces')
471
+ if (priorManifest) {
472
+ assertObsidianContract('generation-manifest', priorManifest)
473
+ if (priorManifest.scopeId !== scope.scopeId) refuse('prior-manifest-mismatch', 'the prior manifest belongs to another scope')
474
+ }
475
+ const checkedAt = timestampFrom(clock)
476
+ const canonical = canonicalSnapshotOf(snapshot.graph)
477
+
478
+ // Selection and visibility come from the contract, never from this module.
479
+ const universe = selectScope({ canonicalSnapshot: canonical, profile, selector: { all: true }, mode: 'scoped' })
480
+ const selection = selectScope({ canonicalSnapshot: canonical, profile, selector: scope.selector, expansion: scope.expansion, mode: scope.mode })
481
+ const nodeById = new Map(canonical.nodes.map((node) => [node.id, node]))
482
+ const vault = new Set(selection.vaultNodes)
483
+ const vaultNode = (id) => (vault.has(id) ? nodeById.get(id) : null)
484
+
485
+ const { registry, pathOf: allocatedPath } = allocateWorkspacePaths({
486
+ registry: seededRegistry({ registry: persistentPathRegistry, priorManifest, workspaceId: profile.workspaceId }),
487
+ workspaceId: profile.workspaceId,
488
+ nodes: universe.nodes.map((id) => nodeById.get(id)),
489
+ ...(vaultRootBytes === undefined ? {} : { vaultRootBytes }),
490
+ ...(maxFullPathBytes === undefined ? {} : { maxFullPathBytes }),
491
+ })
492
+ const pathOf = (node) => allocatedPath(node.repo, node.id)
493
+ for (const note of priorManifest?.notes ?? []) {
494
+ const current = allocatedPath(note.repoId, note.nodeId)
495
+ if (current !== null && current !== note.path) refuse('path-registry-divergence', 'the path registry and the prior manifest disagree about an allocated path')
496
+ }
497
+
498
+ const edgeById = new Map(canonical.edges.map((edge) => [edge.id, edge]))
499
+ const vaultEdges = selection.vaultEdges.map((id) => edgeById.get(id)).filter((edge) => vault.has(edge.source) && vault.has(edge.target))
500
+ const outsideEdges = selection.outsideSelectionEdges.map((id) => edgeById.get(id)).filter((edge) => vault.has(edge.source) !== vault.has(edge.target))
501
+ const vaultLinkPairs = new Set(vaultEdges.filter((edge) => edge.type === DERIVED_RELATION_TYPE).map((edge) => `${edge.source}\u0000${edge.target}`))
502
+ const occurrencesBySource = new Map()
503
+ for (const link of snapshot.graph.links) {
504
+ if (!vaultLinkPairs.has(`${link.source}\u0000${link.target}`)) continue
505
+ if (!occurrencesBySource.has(link.source)) occurrencesBySource.set(link.source, [])
506
+ occurrencesBySource.get(link.source).push(link)
507
+ }
508
+
509
+ // Asset embeds: canonical occurrences whose source is a note of this view and
510
+ // whose asset this view may copy. Anything else is left exactly as authored
511
+ // and appears in no output.
512
+ const assets = visibleAssets(snapshot.graph, profile)
513
+ const embeddedAssets = new Map()
514
+ for (const embed of Array.isArray(snapshot.graph.embeds) ? snapshot.graph.embeds : []) {
515
+ const asset = assets.get(embed?.asset?.id)
516
+ if (!asset || !vault.has(embed.source) || nodeById.get(embed.source).extension !== 'md') continue
517
+ embeddedAssets.set(asset.id, asset)
518
+ if (!occurrencesBySource.has(embed.source)) occurrencesBySource.set(embed.source, [])
519
+ occurrencesBySource.get(embed.source).push(embed)
520
+ }
521
+ const assetPaths = allocateAssetPaths(embeddedAssets.values())
522
+ const emittedTarget = (occurrence) => {
523
+ if (occurrence.type === 'embeds_asset') {
524
+ const attachment = assetPaths.get(occurrence.asset.id)
525
+ return { key: { assetKey: attachment }, markdown: attachment.split('/').map(encodeHref).join('/'), wikilink: attachment, alias: false }
526
+ }
527
+ const stem = noteBasename(pathOf(vaultNode(occurrence.target)))
528
+ return { key: { edgeKey: occurrence.target }, markdown: encodeHref(`${stem}.md`), wikilink: stem, alias: true }
529
+ }
530
+
531
+ const read = sourceReader(snapshot)
532
+ const orderedNodes = [...vault].map((id) => nodeById.get(id)).sort((left, right) => compare(left.repo, right.repo) || compare(left.id, right.id))
533
+ // Edges indexed by endpoint once, so a note's rows cost its degree and not
534
+ // the size of the view.
535
+ const outgoingBy = new Map()
536
+ const incomingBy = new Map()
537
+ const outsideCountBy = new Map()
538
+ for (const edge of vaultEdges) {
539
+ if (!outgoingBy.has(edge.source)) outgoingBy.set(edge.source, [])
540
+ outgoingBy.get(edge.source).push(edge)
541
+ if (!incomingBy.has(edge.target)) incomingBy.set(edge.target, [])
542
+ incomingBy.get(edge.target).push(edge)
543
+ }
544
+ for (const edge of outsideEdges) {
545
+ // An outside edge has exactly one endpoint in the vault; the other endpoint's count is never read.
546
+ outsideCountBy.set(edge.source, (outsideCountBy.get(edge.source) ?? 0) + 1)
547
+ outsideCountBy.set(edge.target, (outsideCountBy.get(edge.target) ?? 0) + 1)
548
+ }
549
+ const files = []
550
+ const notes = []
551
+ const attachments = []
552
+ const inversionsByEdge = new Map()
553
+ let fenceClosed = false
554
+ const reusable = isPreparationCache(cache) ? cache.notes : null
555
+ const nextCache = reusable ? new Map() : null
556
+ const preparation = { emitted: 0, reused: 0 }
557
+ const titles = titleRenderings()
558
+
559
+ for (const node of orderedNodes) {
560
+ const notePathValue = pathOf(node)
561
+ const pinned = read.pinned(node.repo, node.path)
562
+ const outgoing = (outgoingBy.get(node.id) ?? []).slice().sort((left, right) => compare(left.target, right.target))
563
+ const incoming = (incomingBy.get(node.id) ?? []).slice().sort((left, right) => compare(left.source, right.source))
564
+ const rows = relationRows({ node, outgoing, incoming, vaultNode, pathOf, titles })
565
+ const outsideCount = outsideCountBy.get(node.id) ?? 0
566
+ const occurrences = occurrencesBySource.get(node.id) ?? []
567
+ const key = reusable ? dependencyKey({ node, notePathValue, pinned, rows, outsideCount, occurrences, emittedTarget }) : null
568
+ const cached = reusable?.get(notePathValue)
569
+ let entry
570
+ if (cached && cached.key === key) {
571
+ entry = cached
572
+ preparation.reused += 1
573
+ } else {
574
+ entry = emitNote({ node, notePathValue, source: read(node.repo, node.path), rows, outsideCount, occurrences, emittedTarget, key })
575
+ preparation.emitted += 1
576
+ }
577
+ if (nextCache) nextCache.set(notePathValue, entry)
578
+ // The result never aliases the cache: a caller may change what it was handed, bytes included.
579
+ for (const file of entry.files) files.push({ ...file, bytes: Buffer.from(file.bytes) })
580
+ if (entry.attachment) attachments.push(structuredClone(entry.attachment))
581
+ notes.push(structuredClone(entry.note))
582
+ for (const [edgeKey, inversions] of entry.edgeInversions) {
583
+ const inversionKey = `${node.id}\u0000${edgeKey}`
584
+ if (!inversionsByEdge.has(inversionKey)) inversionsByEdge.set(inversionKey, [])
585
+ inversionsByEdge.get(inversionKey).push(...structuredClone(inversions))
586
+ }
587
+ if (entry.fenceClosed) fenceClosed = true
588
+ }
589
+
590
+ // One copy per asset, however many notes embed it, read through the pinned
591
+ // snapshot like every other source.
592
+ for (const asset of embeddedAssets.values()) {
593
+ const attachmentPath = assetPaths.get(asset.id)
594
+ const { bytes, rawDigest } = read(asset.repo, asset.path)
595
+ attachments.push({ path: attachmentPath, digest: rawDigest, byteLength: bytes.length, ext: { [EXT_KEY]: { kind: 'embedded-asset', repoId: asset.repo, assetPath: asset.path } } })
596
+ files.push({ path: attachmentPath, kind: 'attachment', bytes, digest: rawDigest })
597
+ }
598
+
599
+ const links = vaultEdges
600
+ .map((edge) => {
601
+ const inversions = edge.type === DERIVED_RELATION_TYPE ? inversionsByEdge.get(`${edge.source}\u0000${edge.target}`) ?? [] : []
602
+ return {
603
+ edgeId: edgeIdentifier(edge),
604
+ sourceNodeId: edge.source,
605
+ targetNodeId: edge.target,
606
+ type: edge.type,
607
+ origin: edge.type === DERIVED_RELATION_TYPE ? 'markdown' : 'declared',
608
+ targetState: 'in-scope',
609
+ ...(inversions.length > 0 ? { inversions } : {}),
610
+ }
611
+ })
612
+ .sort((left, right) => compare(left.edgeId, right.edgeId))
613
+
614
+ const settings = prepareSettings({ existing: existingSettings })
615
+ files.push(...settings.files)
616
+ const occupied = new Set()
617
+ for (const file of files) {
618
+ if (occupied.has(collisionKey(file.path))) refuse('path-collision', 'two prepared files would occupy one path')
619
+ occupied.add(collisionKey(file.path))
620
+ assertContained(file.path)
621
+ if (isUserOwnedSettingsPath(file.path)) refuse('user-owned-settings', 'a prepared view may not carry a settings file the person owns')
622
+ }
623
+ files.sort((left, right) => compare(left.path, right.path))
624
+ attachments.sort((left, right) => compare(left.path, right.path))
625
+
626
+ const contentDigest = createHash('sha256')
627
+ for (const file of files) contentDigest.update(`${file.path}\u0000${file.digest}\n`)
628
+ const manifest = {
629
+ schema: 'atelier-obsidian-generation-manifest/v1',
630
+ contractVersion: '1.0.0',
631
+ generationId: generationId ?? `gen-${contentDigest.digest('hex').slice(0, 32)}`,
632
+ scopeId: scope.scopeId,
633
+ snapshotId: snapshot.document.snapshotId,
634
+ notes,
635
+ links,
636
+ attachments,
637
+ completeness: { status: 'complete', expectedNotes: orderedNodes.length, writtenNotes: notes.length },
638
+ freshness: { status: 'current', checkedAt },
639
+ ext: { [EXT_KEY]: { emitterVersion: EMITTER_VERSION, mode: selection.mode, settings: settings.ownership } },
640
+ }
641
+ assertObsidianContract('generation-manifest', manifest)
642
+ const vaultSuffixes = new Set([...orderedNodes, ...embeddedAssets.values()].map((item) => identitySuffix(item.repo, item.id, 64)))
643
+ assertOnlyVaultIdentities({
644
+ manifest,
645
+ files,
646
+ vaultSuffixes,
647
+ // Every census identity that is not part of this view: withheld, out of
648
+ // the selection, or an asset this view does not copy.
649
+ forbiddenSuffixes: new Set([
650
+ // A record without a usable identity is not in any view and has no
651
+ // suffix to forbid; it must not abort the view either.
652
+ ...[...canonical.nodes, ...(Array.isArray(snapshot.graph.assets) ? snapshot.graph.assets : [])]
653
+ .filter((item) => item && typeof item.repo === 'string' && item.repo !== '' && typeof item.id === 'string' && item.id !== '')
654
+ .map((item) => identitySuffix(item.repo, item.id, 64)),
655
+ ].filter((suffix) => !vaultSuffixes.has(suffix))),
656
+ })
657
+
658
+ const prior = new Map((priorManifest?.notes ?? []).map((note) => [note.path, note.noteDigest]))
659
+ const changes = { added: [], changed: [], unchanged: [], removed: [] }
660
+ for (const note of notes) changes[!prior.has(note.path) ? 'added' : prior.get(note.path) === note.noteDigest ? 'unchanged' : 'changed'].push(note.path)
661
+ const present = new Set(notes.map((note) => note.path))
662
+ // Removal is reported, never performed: an editable note leaves a vault only
663
+ // under the publication protocol.
664
+ changes.removed = [...prior.keys()].filter((notePathValue) => !present.has(notePathValue)).sort(compare)
665
+
666
+ // The cache is replaced only by a preparation that passed every check, and
667
+ // holds exactly the notes of this view.
668
+ if (nextCache) cache.notes = nextCache
669
+
670
+ return {
671
+ manifest,
672
+ manifestBytes: utf8(`${JSON.stringify(manifest, null, 2)}\n`),
673
+ files,
674
+ persistentPathRegistry: registry,
675
+ changes,
676
+ // How many notes were emitted on this call and how many were reused from the cache; without a cache every note is emitted.
677
+ preparation,
678
+ diagnostics: [
679
+ ...selection.diagnostics,
680
+ ...(canonical.externalEdgeCount > 0 ? ['relations-to-identities-outside-the-census-are-not-emitted'] : []),
681
+ ...(fenceClosed ? ['unclosed-code-fence-closed-in-generated-region'] : []),
682
+ ],
683
+ }
684
+ }
685
+
686
+ // Last check before anything is returned. Two rules, because the two kinds of
687
+ // bytes differ: a substring the emitter itself produced from an allocated path
688
+ // (note and attachment paths, rewritten link targets) may name only a note or
689
+ // asset of this view; free text in a generated region (titles, tags, source
690
+ // paths, which are the author's words) is refused only when it names an
691
+ // identity of the census that is outside this view. Author text that merely
692
+ // looks like an identity suffix, such as a content-hashed asset name, is not
693
+ // a redaction failure.
694
+ function assertOnlyVaultIdentities({ manifest, files, vaultSuffixes, forbiddenSuffixes }) {
695
+ const index = (suffixes) => {
696
+ const byPrefix = new Map()
697
+ for (const full of suffixes) byPrefix.set(full.slice(0, 12), [...(byPrefix.get(full.slice(0, 12)) ?? []), full])
698
+ return (suffix) => (byPrefix.get(suffix.slice(0, 12)) ?? []).some((full) => full.startsWith(suffix))
699
+ }
700
+ const allowed = index(vaultSuffixes)
701
+ const forbidden = index(forbiddenSuffixes)
702
+ const suffixesIn = (text) => [...text.matchAll(/--([0-9a-f]{12,64})(?![0-9a-f])/g)].map((match) => match[1])
703
+ // An emitted path ends in the allocated suffix (before the extension); the
704
+ // readable part before it is the author's title and may look like anything.
705
+ // A wikilink rewrite may append `|<the author's own words>`; only the part
706
+ // before the first `|` is an emitted path.
707
+ const checkEmitted = (text) => {
708
+ for (const segment of text.split('|')[0].split('#')[0].split('/')) {
709
+ const trailing = /--([0-9a-f]{12,64})(?:\.[^./]+)?$/.exec(segment)
710
+ if (trailing && !allowed(trailing[1])) refuse('redaction-failure', 'an emitted path names a note that is not part of this view')
711
+ }
712
+ for (const suffix of suffixesIn(text)) if (forbidden(suffix)) refuse('redaction-failure', 'an emitted path names a note that is not part of this view')
713
+ }
714
+ const checkText = (text) => {
715
+ for (const suffix of suffixesIn(text)) if (forbidden(suffix)) refuse('redaction-failure', 'generated output names a note that is not part of this view')
716
+ }
717
+ const byPath = new Map(files.map((file) => [file.path, file.bytes]))
718
+ for (const note of manifest.notes) {
719
+ const bytes = byPath.get(note.path)
720
+ for (const region of note.regions.generated) checkText(bytes.subarray(region.range.start, region.range.end).toString('utf8'))
721
+ }
722
+ for (const link of manifest.links) {
723
+ for (const inversion of link.inversions ?? []) checkEmitted(Buffer.from(inversion.ext[EXT_KEY].emitted, 'base64url').toString('utf8'))
724
+ }
725
+ for (const note of manifest.notes) {
726
+ for (const embed of note.ext[EXT_KEY].assetEmbeds ?? []) {
727
+ checkEmitted(embed.attachment)
728
+ for (const inversion of embed.inversions) checkEmitted(Buffer.from(inversion.ext[EXT_KEY].emitted, 'base64url').toString('utf8'))
729
+ }
730
+ }
731
+ for (const note of manifest.notes) checkEmitted(note.path)
732
+ for (const attachment of manifest.attachments) checkEmitted(attachment.path)
733
+ }
734
+
735
+ // Adds the fail-closed eligibility flag selectScope reads. A node is eligible
736
+ // only when `isEligible` returns exactly true for it. Embedded assets follow
737
+ // the same rule through `isAssetEligible`; without it every asset is withheld.
738
+ export function withEligibility(graph, isEligible, isAssetEligible = null) {
739
+ return {
740
+ ...graph,
741
+ nodes: graph.nodes.map((node) => ({ ...node, eligible: isEligible(node) === true })),
742
+ ...(Array.isArray(graph.assets)
743
+ ? { assets: graph.assets.map((asset) => ({ ...asset, eligible: typeof isAssetEligible === 'function' && isAssetEligible(asset) === true })) }
744
+ : {}),
745
+ }
746
+ }