@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
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import fs from 'node:fs'
2
3
  import path from 'node:path'
3
4
  import { VALID_AUDIENCES } from '../projection/policy.mjs'
@@ -586,7 +587,9 @@ export function generatedFrontmatter({ id, repoName, rel, title, summary, domain
586
587
  ].join('\n')
587
588
  }
588
589
 
589
- export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig) {
590
+ // `sourceText`, when given, is the Markdown source already read as UTF-8 text,
591
+ // so a caller that holds the bytes does not read the file again.
592
+ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig, sourceText = null) {
590
593
  const inferredLifecycle = inferLifecycle(file.rel)
591
594
  const inferredDomain = inferDomain(repoName, file.rel)
592
595
  const inferredStatus = inferStatus(inferredLifecycle, file.rel)
@@ -601,7 +604,7 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
601
604
  let atelier = { section: coverage.sections.get(file.rel) || null, status: null, kind: null }
602
605
 
603
606
  if (file.ext === '.md') {
604
- const raw = fs.readFileSync(file.abs, 'utf8')
607
+ const raw = typeof sourceText === 'string' ? sourceText : fs.readFileSync(file.abs, 'utf8')
605
608
  const frontmatter = markdownFrontmatterState(raw)
606
609
  metadata = frontmatter.metadata
607
610
  if (frontmatter.kind !== 'valid' || !isPlainObject(metadata.kg)) {
@@ -693,27 +696,461 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
693
696
  }
694
697
  }
695
698
 
696
- export function markdownLinkEdges(repoRoot, nodesByPath) {
697
- const edges = []
698
- for (const [rel, node] of nodesByPath) {
699
- if (!rel.endsWith('.md')) continue
700
- const raw = fs.readFileSync(path.join(repoRoot, rel), 'utf8')
701
- const re = /\[[^\]]+\]\(([^)\s#]+)(?:#[^)]+)?\)/g
702
- let m
703
- while ((m = re.exec(raw))) {
704
- const href = m[1]
705
- if (/^[a-z]+:/i.test(href) || href.startsWith('#')) continue
706
- const target = path
707
- .normalize(path.join(path.dirname(rel), decodeURIComponent(href)))
708
- .split(path.sep)
709
- .join('/')
710
- const direct = nodesByPath.get(target)
711
- const index = nodesByPath.get(posixJoin(target, 'README.md')) || nodesByPath.get(posixJoin(target, 'index.md'))
712
- const targetNode = direct || index
713
- if (targetNode) edges.push({ source: node.id, target: targetNode.id, type: 'links_to' })
699
+ // ---------------------------------------------------------------------------
700
+ // Ordinary links: Markdown links and wikilinks
701
+ // ---------------------------------------------------------------------------
702
+
703
+ // Link findings are reported beside the graph, never inside a graph artifact,
704
+ // so committed graphs stay free of offsets and unresolved-link noise.
705
+ export const LINK_DIAGNOSTIC_CODES = Object.freeze([
706
+ 'link-target-unresolved',
707
+ 'link-target-outside-enrolled-roots',
708
+ 'link-target-ambiguous',
709
+ 'link-href-malformed',
710
+ 'link-source-not-utf8',
711
+ ])
712
+
713
+ const MARKDOWN_LINK_RE = /\[[^\]]+\]\(([^)\s#]+)(#[^)]+)?\)/g
714
+ const WIKILINK_RE = /\[\[([^[\]|#^]+)([#^][^[\]|]*)?(?:\|[^[\]]*)?\]\]/g
715
+
716
+ function frontMatterEnd(text) {
717
+ if (!/^---\r?\n/.test(text)) return 0
718
+ const closeRe = /\r?\n---[ \t]*\r?\n/g
719
+ closeRe.lastIndex = 3
720
+ const close = closeRe.exec(text)
721
+ return close ? close.index + close[0].length : 0
722
+ }
723
+
724
+ // Fenced code from `bodyStart` on: the closed blocks, and the fence still open
725
+ // when the text ends, if any. The one statement of the fence rules, shared by
726
+ // the link scanner and by `unclosedFenceAtEnd`.
727
+ function fencedBlocks(text, bodyStart) {
728
+ const ranges = []
729
+ let fence = null
730
+ let offset = bodyStart
731
+ while (offset < text.length) {
732
+ const eol = text.indexOf('\n', offset)
733
+ const next = eol === -1 ? text.length : eol + 1
734
+ const line = text.slice(offset, eol === -1 ? text.length : eol).replace(/\r$/, '')
735
+ if (fence) {
736
+ const close = line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/)
737
+ if (close && close[1][0] === fence.char && close[1].length >= fence.length) {
738
+ ranges.push([fence.start, next])
739
+ fence = null
740
+ }
741
+ } else {
742
+ const open = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/)
743
+ if (open && !(open[2][0] === '`' && open[3].includes('`'))) {
744
+ fence = { char: open[2][0], length: open[2].length, indent: open[1].length, start: offset }
745
+ }
714
746
  }
747
+ offset = next
715
748
  }
716
- return edges
749
+ return { ranges, open: fence }
750
+ }
751
+
752
+ // The fence a Markdown text ends inside, as the link scanner reads it:
753
+ // { char, length, indent }, or null when every fence closes. Front matter is
754
+ // never read for fences. Anything appended after such a text is code until a
755
+ // closing fence of the same character and at least the same length.
756
+ export function unclosedFenceAtEnd(raw) {
757
+ const text = String(raw ?? '')
758
+ const { open } = fencedBlocks(text, frontMatterEnd(text))
759
+ return open ? { char: open.char, length: open.length, indent: open.indent } : null
760
+ }
761
+
762
+ // Regions whose text is never read as a link: front matter (known or unknown
763
+ // YAML alike), fenced code and inline code. Offsets index the text as read,
764
+ // with no newline normalization.
765
+ function unscannedRanges(text) {
766
+ const bodyStart = frontMatterEnd(text)
767
+ const fenced = fencedBlocks(text, bodyStart)
768
+ const ranges = bodyStart > 0 ? [[0, bodyStart], ...fenced.ranges] : fenced.ranges
769
+ if (fenced.open) ranges.push([fenced.open.start, text.length])
770
+
771
+ // Inline code between the block ranges. A span closes on a backtick run of
772
+ // the same length inside the same paragraph; an unclosed run is plain text.
773
+ const blocks = [...ranges].sort((a, b) => a[0] - b[0])
774
+ let segmentStart = bodyStart
775
+ for (const [blockStart, blockEnd] of [...blocks.filter(([start]) => start >= bodyStart), [text.length, text.length]]) {
776
+ inlineCodeRanges(text, segmentStart, blockStart, ranges)
777
+ segmentStart = Math.max(segmentStart, blockEnd)
778
+ }
779
+ return ranges.sort((a, b) => a[0] - b[0])
780
+ }
781
+
782
+ function inlineCodeRanges(text, from, to, ranges) {
783
+ const runRe = /`+/g
784
+ runRe.lastIndex = from
785
+ let open
786
+ while ((open = runRe.exec(text)) && open.index < to) {
787
+ const blankRe = /\n[ \t]*\r?\n/g
788
+ blankRe.lastIndex = open.index
789
+ const blank = blankRe.exec(text)
790
+ const paragraphEnd = Math.min(to, blank ? blank.index : text.length)
791
+ const closeRe = /`+/g
792
+ closeRe.lastIndex = open.index + open[0].length
793
+ let close
794
+ let closed = false
795
+ while ((close = closeRe.exec(text)) && close.index < paragraphEnd) {
796
+ if (close[0].length !== open[0].length) continue
797
+ ranges.push([open.index, close.index + close[0].length])
798
+ runRe.lastIndex = close.index + close[0].length
799
+ closed = true
800
+ break
801
+ }
802
+ if (!closed) runRe.lastIndex = open.index + open[0].length
803
+ }
804
+ }
805
+
806
+ // Every ordinary link occurrence in one Markdown text, in source order.
807
+ // `range` covers the whole construct and `targetRange` the written target, as
808
+ // half-open UTF-16 offsets into the text exactly as read; byte offsets are
809
+ // added by the resolver once the source is known to round-trip as UTF-8.
810
+ export function scanMarkdownLinks(raw) {
811
+ const text = String(raw ?? '')
812
+ const skipped = unscannedRanges(text)
813
+ const overlaps = (ranges, start, end) => ranges.some(([a, b]) => start < b && end > a)
814
+ // A construct is unscanned when it begins inside such a region. Inline code
815
+ // inside a link's own label ([`name`](target)) is an ordinary link.
816
+ const beginsInside = (ranges, start) => ranges.some(([a, b]) => start >= a && start < b)
817
+ const found = []
818
+ const wikiRanges = []
819
+
820
+ for (const m of text.matchAll(WIKILINK_RE)) {
821
+ const start = m.index
822
+ const end = start + m[0].length
823
+ if (beginsInside(skipped, start)) continue
824
+ wikiRanges.push([start, end])
825
+ found.push({
826
+ syntax: 'wikilink',
827
+ embed: text[start - 1] === '!',
828
+ href: m[1].trim(),
829
+ fragment: m[2] ?? '',
830
+ range: { start, end },
831
+ targetRange: { start: start + 2, end: start + 2 + m[1].length },
832
+ })
833
+ }
834
+
835
+ for (const m of text.matchAll(MARKDOWN_LINK_RE)) {
836
+ const start = m.index
837
+ const end = start + m[0].length
838
+ if (beginsInside(skipped, start) || overlaps(wikiRanges, start, end)) continue
839
+ const targetStart = start + m[0].indexOf('](') + 2
840
+ found.push({
841
+ syntax: 'markdown',
842
+ embed: text[start - 1] === '!',
843
+ href: m[1],
844
+ fragment: m[2] ?? '',
845
+ range: { start, end },
846
+ targetRange: { start: targetStart, end: targetStart + m[1].length },
847
+ })
848
+ }
849
+
850
+ return found.sort((a, b) => a.range.start - b.range.start)
851
+ }
852
+
853
+ function addByteOffsets(text, occurrences) {
854
+ const offsets = [...new Set(occurrences.flatMap((item) => [item.range.start, item.range.end, item.targetRange.start, item.targetRange.end]))].sort(
855
+ (a, b) => a - b,
856
+ )
857
+ const bytes = new Map()
858
+ let previous = 0
859
+ let total = 0
860
+ for (const offset of offsets) {
861
+ total += Buffer.byteLength(text.slice(previous, offset), 'utf8')
862
+ bytes.set(offset, total)
863
+ previous = offset
864
+ }
865
+ for (const item of occurrences) {
866
+ for (const range of [item.range, item.targetRange]) {
867
+ range.byteStart = bytes.get(range.start)
868
+ range.byteEnd = bytes.get(range.end)
869
+ }
870
+ }
871
+ }
872
+
873
+ // `pathOf` and `repoNameOf` come from the census keys, so a caller's node
874
+ // needs only an id: markdownLinkEdges has always accepted such nodes.
875
+ function wikilinkCandidates(target, nodes, pathOf, repoNameOf) {
876
+ if (target.includes('/')) {
877
+ const wanted = new Set([target, `${target}.md`])
878
+ return { by: 'path', nodes: nodes.filter((node) => wanted.has(pathOf.get(node)) || wanted.has(`${repoNameOf.get(node)}/${pathOf.get(node)}`)) }
879
+ }
880
+ const byTitle = nodes.filter((node) => node.title !== undefined && node.title === target)
881
+ const byName = nodes.filter((node) => {
882
+ const base = path.posix.basename(pathOf.get(node))
883
+ return base === target || base.replace(/\.[^.]+$/, '') === target
884
+ })
885
+ const merged = [...new Map([...byTitle, ...byName].map((node) => [node.id, node])).values()]
886
+ return { by: byTitle.length ? 'title' : 'basename', nodes: merged }
887
+ }
888
+
889
+ // Embedded assets: files an embed names that are not documents of the census.
890
+ // An asset is a regular file inside an enrolled repository root: never a link
891
+ // on disk, never reached through one, never git-ignored, never inside `.git`,
892
+ // never Markdown and never a census node.
893
+ const assetId = (repoName, rel) => `${repoName}:asset:${rel}`
894
+ const insideGitDirectory = (rel) => rel.split('/').includes('.git')
895
+ const isMarkdownPath = (rel) => rel.toLowerCase().endsWith('.md')
896
+
897
+ // `gitIgnoreFilter` lists a fully ignored directory once, so every ancestor of
898
+ // a path is tested, not the path alone.
899
+ function ignoredAtAnyDepth(isIgnored, rel) {
900
+ const parts = rel.split('/')
901
+ return parts.some((_, index) => isIgnored(parts.slice(0, index + 1).join('/')))
902
+ }
903
+
904
+ // True only for a regular file whose real location is exactly `rel` under the
905
+ // real repository root: a link anywhere on the way, or a name that matches
906
+ // only because the filesystem folds case, is not this file.
907
+ function isRegularFileInside(root, rel) {
908
+ try {
909
+ const abs = path.join(root, rel)
910
+ if (!fs.lstatSync(abs).isFile()) return false
911
+ return relPath(fs.realpathSync.native(root), fs.realpathSync.native(abs)) === rel
912
+ } catch {
913
+ return false
914
+ }
915
+ }
916
+
917
+ // The one resolver for ordinary links across every enrolled repository.
918
+ // `repos` is [{ name, root, nodesByPath, isIgnored? }]. A target that is
919
+ // absent, outside the census or refused by `isLinkTargetEligible` is reported
920
+ // identically and never inspected, so a finding cannot confirm that a withheld
921
+ // target exists. `isAssetEligible({ repo, path })` gives embedded assets the
922
+ // same rule: a refused asset reads exactly as an absent one.
923
+ // The link scan of one Markdown source: its occurrences, with byte offsets
924
+ // when the bytes round-trip as UTF-8. A pure function of the bytes, which is
925
+ // what lets a builder keep it under the source's content digest.
926
+ export function scanMarkdownSource(buffer) {
927
+ const text = buffer.toString('utf8')
928
+ const occurrences = scanMarkdownLinks(text)
929
+ const utf8Exact = Buffer.from(text, 'utf8').equals(buffer)
930
+ if (utf8Exact) addByteOffsets(text, occurrences)
931
+ return { occurrences, utf8Exact }
932
+ }
933
+
934
+ // `scanned` maps a census node to the scan of its source (scanMarkdownSource)
935
+ // when the caller already holds it; any other Markdown node is read here.
936
+ export function resolveWorkspaceLinks({ repos = [], isLinkTargetEligible = () => true, isAssetEligible = () => true, scanned = null } = {}) {
937
+ const links = []
938
+ const embeds = []
939
+ const diagnostics = []
940
+ const enrolled = repos.map((repo) => ({ ...repo, root: path.resolve(repo.root) }))
941
+ const ownerOf = new Map(enrolled.flatMap((repo) => [...repo.nodesByPath.values()].map((node) => [node, repo])))
942
+ const eligibleNodes = [...ownerOf.keys()].filter((node) => isLinkTargetEligible(node))
943
+ const pathOf = new Map(enrolled.flatMap((repo) => [...repo.nodesByPath].map(([rel, node]) => [node, rel])))
944
+ const repoNameOf = new Map([...ownerOf].map(([node, repo]) => [node, repo.name]))
945
+
946
+ const owningRepo = (abs) =>
947
+ enrolled
948
+ .filter((repo) => abs === repo.root || abs.startsWith(`${repo.root}${path.sep}`))
949
+ .sort((a, b) => b.root.length - a.root.length)[0] ?? null
950
+
951
+ // One ignore lookup per repository, made only when an embed needs it.
952
+ const ignoreFilters = new Map()
953
+ const ignoredIn = (owner) => {
954
+ if (!ignoreFilters.has(owner)) ignoreFilters.set(owner, owner.isIgnored ?? gitIgnoreFilter(owner.root))
955
+ return ignoreFilters.get(owner)
956
+ }
957
+ // Eligibility is asked before the file is looked at, so a refused asset and
958
+ // an absent one take the same path to the same finding.
959
+ const assetAt = (owner, rel) => {
960
+ if (!rel || rel.startsWith('../') || rel === '..' || path.posix.isAbsolute(rel)) return null
961
+ if (owner.nodesByPath.has(rel) || isMarkdownPath(rel) || insideGitDirectory(rel)) return null
962
+ if (isAssetEligible({ repo: owner.name, path: rel }) !== true) return null
963
+ if (ignoredAtAnyDepth(ignoredIn(owner), rel) || !isRegularFileInside(owner.root, rel)) return null
964
+ return { id: assetId(owner.name, rel), repo: owner.name, path: rel, extension: path.posix.extname(rel).slice(1).toLowerCase() }
965
+ }
966
+
967
+ // Basename index over every enrolled repository: built once, and only when a
968
+ // bare wikilink embed asks for it. It holds names only; eligibility is tested
969
+ // per candidate when a name is looked up.
970
+ let basenameIndex = null
971
+ const assetsNamed = (name) => {
972
+ if (!basenameIndex) {
973
+ basenameIndex = new Map()
974
+ const roots = new Set(enrolled.map((item) => item.root))
975
+ for (const owner of enrolled) {
976
+ const isIgnored = ignoredIn(owner)
977
+ const visit = (dir) => {
978
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
979
+ if (ent.name === '.git') continue
980
+ const abs = path.join(dir, ent.name)
981
+ const rel = relPath(owner.root, abs)
982
+ if (isIgnored(rel)) continue
983
+ // A nested enrolled repository owns its own files.
984
+ if (ent.isDirectory()) {
985
+ if (!roots.has(abs)) visit(abs)
986
+ } else if (ent.isFile() && !isMarkdownPath(rel) && !owner.nodesByPath.has(rel)) {
987
+ if (!basenameIndex.has(ent.name)) basenameIndex.set(ent.name, [])
988
+ basenameIndex.get(ent.name).push({ owner, rel })
989
+ }
990
+ }
991
+ }
992
+ visit(owner.root)
993
+ }
994
+ }
995
+ return (basenameIndex.get(name) ?? []).map(({ owner, rel }) => assetAt(owner, rel)).filter(Boolean)
996
+ }
997
+
998
+ // A wikilink embed names an asset by repository-relative path (the source's
999
+ // own repository first, then `<repository>/<path>`), or by bare file name.
1000
+ const wikilinkAssets = (repo, target) => {
1001
+ if (!target.includes('/')) return { by: 'basename', assets: assetsNamed(target) }
1002
+ const wanted = path.posix.normalize(target)
1003
+ const candidates = [
1004
+ assetAt(repo, wanted),
1005
+ ...enrolled.filter((owner) => wanted.startsWith(`${owner.name}/`)).map((owner) => assetAt(owner, wanted.slice(owner.name.length + 1))),
1006
+ ].filter(Boolean)
1007
+ return { by: 'path', assets: candidates.slice(0, 1) }
1008
+ }
1009
+
1010
+ for (const repo of enrolled) {
1011
+ for (const [rel, node] of repo.nodesByPath) {
1012
+ if (!rel.endsWith('.md') || !isLinkTargetEligible(node)) continue
1013
+ const { occurrences, utf8Exact } = scanned?.get(node) ?? scanMarkdownSource(fs.readFileSync(path.join(repo.root, rel)))
1014
+ const finding = (code, occurrence, detail, extra = {}) =>
1015
+ diagnostics.push({
1016
+ severity: 'warning',
1017
+ type: code,
1018
+ code,
1019
+ node: node.id,
1020
+ repo: repo.name,
1021
+ path: rel,
1022
+ ...(occurrence
1023
+ ? { syntax: occurrence.syntax, href: portableText(occurrence.href), range: occurrence.range, targetRange: occurrence.targetRange }
1024
+ : {}),
1025
+ ...extra,
1026
+ message: `${repo.name}/${rel}: ${detail}`,
1027
+ })
1028
+
1029
+ if (!utf8Exact && occurrences.length) finding('link-source-not-utf8', null, 'source is not valid UTF-8; link byte offsets are withheld')
1030
+
1031
+ for (const occurrence of occurrences) {
1032
+ const shown = JSON.stringify(portableText(occurrence.href))
1033
+ let targetNode = null
1034
+ let asset = null
1035
+ let resolvedBy = 'path'
1036
+
1037
+ if (occurrence.syntax === 'wikilink') {
1038
+ const candidates = wikilinkCandidates(occurrence.href, eligibleNodes, pathOf, repoNameOf)
1039
+ if (candidates.nodes.length > 1) {
1040
+ finding('link-target-ambiguous', occurrence, `wikilink ${shown} matches ${candidates.nodes.length} documents; refusing to choose`, {
1041
+ candidates: candidates.nodes.map((item) => item.id).sort(),
1042
+ })
1043
+ continue
1044
+ }
1045
+ targetNode = candidates.nodes[0] ?? null
1046
+ resolvedBy = candidates.by
1047
+ if (!targetNode && occurrence.embed) {
1048
+ const found = wikilinkAssets(repo, occurrence.href)
1049
+ if (found.assets.length > 1) {
1050
+ finding('link-target-ambiguous', occurrence, `embed ${shown} matches ${found.assets.length} files; refusing to choose`, {
1051
+ candidates: found.assets.map((item) => item.id).sort(),
1052
+ })
1053
+ continue
1054
+ }
1055
+ asset = found.assets[0] ?? null
1056
+ resolvedBy = found.by
1057
+ }
1058
+ } else {
1059
+ const href = occurrence.href
1060
+ if (/^[a-z]+:/i.test(href) || href.startsWith('#')) continue
1061
+ let decoded
1062
+ try {
1063
+ decoded = decodeURIComponent(href)
1064
+ } catch {
1065
+ finding('link-href-malformed', occurrence, `link ${shown} is not valid percent-encoding`)
1066
+ continue
1067
+ }
1068
+ const abs = path.join(repo.root, path.dirname(rel), decoded)
1069
+ const owner = owningRepo(abs)
1070
+ if (!owner) {
1071
+ finding('link-target-outside-enrolled-roots', occurrence, `link ${shown} leaves every enrolled repository`)
1072
+ continue
1073
+ }
1074
+ // A link that climbs above its own repository and comes back in
1075
+ // through the checkout's directory name depends on where the
1076
+ // repository happens to be checked out. It is not a repository-local
1077
+ // link and never was one; only a link into ANOTHER enrolled
1078
+ // repository may leave the root.
1079
+ const lexical = path.posix.normalize(posixJoin(path.posix.dirname(rel), decoded.split(path.sep).join('/')))
1080
+ if (owner === repo && (lexical === '..' || lexical.startsWith('../'))) {
1081
+ finding('link-target-outside-enrolled-roots', occurrence, `link ${shown} leaves its repository and re-enters through the checkout path`)
1082
+ continue
1083
+ }
1084
+ const target = relPath(owner.root, abs)
1085
+ // Eligibility is tested per candidate, before choosing: a withheld
1086
+ // candidate is skipped exactly as an absent one, so it can never
1087
+ // shadow an eligible fallback and change the edge set.
1088
+ targetNode =
1089
+ [target, posixJoin(target, 'README.md'), posixJoin(target, 'index.md')]
1090
+ .map((candidate) => owner.nodesByPath.get(candidate))
1091
+ .find((candidate) => candidate && isLinkTargetEligible(candidate)) ?? null
1092
+ if (!targetNode && occurrence.embed) asset = assetAt(owner, target)
1093
+ }
1094
+
1095
+ if (asset) {
1096
+ embeds.push({
1097
+ source: node.id,
1098
+ type: 'embeds_asset',
1099
+ asset,
1100
+ syntax: occurrence.syntax,
1101
+ href: occurrence.href,
1102
+ fragment: occurrence.fragment,
1103
+ resolvedBy,
1104
+ crossRepository: asset.repo !== repo.name,
1105
+ sourceRepo: repo.name,
1106
+ sourcePath: rel,
1107
+ range: occurrence.range,
1108
+ targetRange: occurrence.targetRange,
1109
+ })
1110
+ continue
1111
+ }
1112
+ if (!targetNode) {
1113
+ finding('link-target-unresolved', occurrence, `link ${shown} does not resolve to an enrolled document`)
1114
+ continue
1115
+ }
1116
+ links.push({
1117
+ source: node.id,
1118
+ target: targetNode.id,
1119
+ type: 'links_to',
1120
+ syntax: occurrence.syntax,
1121
+ embed: occurrence.embed,
1122
+ href: occurrence.href,
1123
+ fragment: occurrence.fragment,
1124
+ resolvedBy,
1125
+ crossRepository: ownerOf.get(targetNode) !== repo,
1126
+ sourceRepo: repo.name,
1127
+ sourcePath: rel,
1128
+ targetRepo: ownerOf.get(targetNode).name,
1129
+ targetPath: targetNode.path,
1130
+ range: occurrence.range,
1131
+ targetRange: occurrence.targetRange,
1132
+ })
1133
+ }
1134
+ }
1135
+ }
1136
+
1137
+ const order = (item) => `${item.sourceRepo ?? item.repo}/${item.sourcePath ?? item.path}`
1138
+ const byPosition = (a, b) => order(a).localeCompare(order(b)) || (a.range?.start ?? -1) - (b.range?.start ?? -1)
1139
+ return { links: links.sort(byPosition), embeds: embeds.sort(byPosition), diagnostics: diagnostics.sort(byPosition) }
1140
+ }
1141
+
1142
+ // A repository's own artifact depends on that repository alone: path links
1143
+ // that stay inside it. Wikilinks and cross-repository links resolve against
1144
+ // the whole enrolment and belong to the workspace graph only.
1145
+ function repoLocalLink(link) {
1146
+ return !link.crossRepository && link.syntax === 'markdown'
1147
+ }
1148
+
1149
+ const linkEdge = (link) => ({ source: link.source, target: link.target, type: 'links_to' })
1150
+
1151
+ export function markdownLinkEdges(repoRoot, nodesByPath) {
1152
+ const { links } = resolveWorkspaceLinks({ repos: [{ name: path.basename(repoRoot), root: repoRoot, nodesByPath }] })
1153
+ return links.filter(repoLocalLink).map(linkEdge)
717
1154
  }
718
1155
 
719
1156
  export function declaredRelationEdges(nodes) {
@@ -854,6 +1291,57 @@ export function validateKnowledgeGraph(nodes, edges, orphanSidecars = [], { exte
854
1291
  return errors
855
1292
  }
856
1293
 
1294
+ // A per-file cache for repeated builds of one workspace: the census node and
1295
+ // the link scan of every Markdown source, kept under the sha256 of the bytes
1296
+ // they were derived from together with the per-file inputs that are not in
1297
+ // the bytes (coverage and the repository's read boundary). Both derivations
1298
+ // are pure functions of those inputs, so an entry is reused only when a build
1299
+ // with no cache would compute the same values; a file whose bytes or inputs
1300
+ // differ is parsed again, and a file no longer in the census leaves the cache.
1301
+ // Without `observedDigest` every source is read and hashed on every build: the
1302
+ // cache never trusts a digest it did not compute from the bytes. A caller that
1303
+ // already observes the sources by digest (the maintenance engine's index: stat
1304
+ // hint between full reconciliations, every file hashed on a full one) may pass
1305
+ // `observedDigest(repoName, rel)`, returning `sha256:<hex>` or null. An entry
1306
+ // whose digest equals the observed one is then reused without opening the
1307
+ // file; the tradeoff is exactly the observer's: bytes that change under an
1308
+ // unchanged stat hint are not seen until the observer hashes the file again,
1309
+ // and until then no view is rebuilt for them either. Any other file is read
1310
+ // and hashed here as always. Derived, droppable state.
1311
+ export function createGraphFileCache() {
1312
+ return { files: new Map() }
1313
+ }
1314
+
1315
+ function isGraphFileCache(cache) {
1316
+ return cache !== null && typeof cache === 'object' && cache.files instanceof Map
1317
+ }
1318
+
1319
+ const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex')
1320
+
1321
+ // The Markdown census entry for one file, from the cache under an equal digest
1322
+ // and equal inputs, or freshly derived. The returned node and scan are clones
1323
+ // so a caller's changes never reach the cache.
1324
+ function markdownCensus({ repoName, repoRoot, coverage, file, accessConfig, cache, next, observedDigest }) {
1325
+ const inputs = JSON.stringify([coverage.surfaced.has(file.rel), coverage.sections.get(file.rel) || null, repoReadBoundary(accessConfig, repoName)])
1326
+ const key = `${repoName}\u0000${file.rel}`
1327
+ const cached = cache?.files.get(key)
1328
+ const observed = cached && observedDigest ? observedDigest(repoName, file.rel) : null
1329
+ let entry
1330
+ let read = false
1331
+ if (cached && cached.inputs === inputs && typeof observed === 'string' && observed === `sha256:${cached.digest}`) {
1332
+ entry = cached
1333
+ } else {
1334
+ read = true
1335
+ const buffer = fs.readFileSync(file.abs)
1336
+ const digest = sha256Hex(buffer)
1337
+ entry = cached && cached.digest === digest && cached.inputs === inputs
1338
+ ? cached
1339
+ : { digest, inputs, node: nodeForFile(repoName, repoRoot, coverage, file, accessConfig, buffer.toString('utf8')), scan: scanMarkdownSource(buffer) }
1340
+ }
1341
+ next?.set(key, entry)
1342
+ return { node: structuredClone(entry.node), scan: structuredClone(entry.scan), reused: entry === cached, read }
1343
+ }
1344
+
857
1345
  export function buildKnowledgeGraph({
858
1346
  workspaceRoot,
859
1347
  repoAccessConfig,
@@ -863,8 +1351,14 @@ export function buildKnowledgeGraph({
863
1351
  externalRepos = [],
864
1352
  externalRelationPrefixes = [],
865
1353
  externalRelationIds = [],
1354
+ isLinkTargetEligible = undefined,
1355
+ isAssetEligible = undefined,
1356
+ fileCache = null,
1357
+ observedDigest = null,
866
1358
  } = {}) {
867
1359
  if (!workspaceRoot) throw new Error('workspaceRoot is required')
1360
+ if (fileCache !== null && !isGraphFileCache(fileCache)) throw new Error('fileCache must come from createGraphFileCache')
1361
+ if (observedDigest !== null && typeof observedDigest !== 'function') throw new Error('observedDigest must be a function')
868
1362
  const resolvedWorkspaceRoot = path.resolve(workspaceRoot)
869
1363
  const external = new Set(externalRepos)
870
1364
  const discoveredEntries = repoEntries
@@ -896,6 +1390,10 @@ export function buildKnowledgeGraph({
896
1390
  const workspaceOrphanSidecars = []
897
1391
  const workspaceIgnoredSidecars = []
898
1392
  const repoGraphs = []
1393
+ const census = []
1394
+ const scanned = new Map()
1395
+ const nextCache = fileCache ? new Map() : null
1396
+ const reuse = { reused: 0, derived: 0, read: 0 }
899
1397
 
900
1398
  for (const entry of roots) {
901
1399
  const repoRoot = entry.path
@@ -910,12 +1408,32 @@ export function buildKnowledgeGraph({
910
1408
  const nodesByPath = new Map()
911
1409
 
912
1410
  for (const file of files) {
913
- const node = nodeForFile(repoName, repoRoot, coverage, file, accessConfig)
1411
+ let node
1412
+ if (file.ext === '.md') {
1413
+ // One read per Markdown source: the census entry and the link scan come from the same bytes.
1414
+ const entry = markdownCensus({ repoName, repoRoot, coverage, file, accessConfig, cache: fileCache, next: nextCache, observedDigest })
1415
+ node = entry.node
1416
+ scanned.set(node, entry.scan)
1417
+ reuse[entry.reused ? 'reused' : 'derived'] += 1
1418
+ if (entry.read) reuse.read += 1
1419
+ } else {
1420
+ node = nodeForFile(repoName, repoRoot, coverage, file, accessConfig)
1421
+ }
914
1422
  nodes.push(node)
915
1423
  nodesByPath.set(file.rel, node)
916
1424
  }
917
1425
 
918
- const edges = uniqueEdges([...markdownLinkEdges(repoRoot, nodesByPath), ...declaredRelationEdges(nodes)])
1426
+ census.push({ name: repoName, root: repoRoot, nodes, nodesByPath, isIgnored })
1427
+ }
1428
+ // The cache holds exactly the Markdown sources of this census, each under the digest this build read.
1429
+ if (nextCache) fileCache.files = nextCache
1430
+
1431
+ // Links resolve once, against every enrolled repository together.
1432
+ const resolved = resolveWorkspaceLinks({ repos: census, scanned, ...(isLinkTargetEligible ? { isLinkTargetEligible } : {}), ...(isAssetEligible ? { isAssetEligible } : {}) })
1433
+
1434
+ for (const { name: repoName, root: repoRoot, nodes } of census) {
1435
+ const localLinks = resolved.links.filter((link) => link.sourceRepo === repoName && repoLocalLink(link))
1436
+ const edges = uniqueEdges([...localLinks.map(linkEdge), ...declaredRelationEdges(nodes)])
919
1437
  const graph = {
920
1438
  schema: KNOWLEDGE_GRAPH_SCHEMA,
921
1439
  repo: repoName,
@@ -930,6 +1448,7 @@ export function buildKnowledgeGraph({
930
1448
  workspaceNodes.push(...graph.nodes)
931
1449
  workspaceEdges.push(...graph.edges)
932
1450
  }
1451
+ workspaceEdges.push(...resolved.links.filter((link) => !repoLocalLink(link)).map(linkEdge))
933
1452
 
934
1453
  const validationErrors = validateKnowledgeGraph(workspaceNodes, workspaceEdges, workspaceOrphanSidecars, {
935
1454
  externalRelationPrefixes,
@@ -940,7 +1459,7 @@ export function buildKnowledgeGraph({
940
1459
  workspace: path.basename(resolvedWorkspaceRoot),
941
1460
  diagnostics: graphDiagnostics(workspaceNodes),
942
1461
  nodes: workspaceNodes.sort((a, b) => `${a.repo}/${a.path}`.localeCompare(`${b.repo}/${b.path}`)),
943
- edges: workspaceEdges.sort((a, b) => `${a.source}:${a.target}:${a.type}`.localeCompare(`${b.source}:${b.target}:${b.type}`)),
1462
+ edges: uniqueEdges(workspaceEdges).sort((a, b) => `${a.source}:${a.target}:${a.type}`.localeCompare(`${b.source}:${b.target}:${b.type}`)),
944
1463
  }
945
1464
  workspaceGraph.nodeCount = workspaceGraph.nodes.length
946
1465
  workspaceGraph.edgeCount = workspaceGraph.edges.length
@@ -952,6 +1471,11 @@ export function buildKnowledgeGraph({
952
1471
  workspaceGraph,
953
1472
  orphanSidecars: workspaceOrphanSidecars,
954
1473
  ignoredSidecars: workspaceIgnoredSidecars,
1474
+ resolvedLinks: resolved.links,
1475
+ resolvedEmbeds: resolved.embeds,
1476
+ linkDiagnostics: resolved.diagnostics,
1477
+ // How many Markdown census entries this build derived, how many it reused from the file cache, and how many sources it opened.
1478
+ fileCensus: reuse,
955
1479
  }
956
1480
  }
957
1481