@mnstry/atelier 0.2.0-alpha.1 → 0.2.0-alpha.10

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 (414) hide show
  1. package/CHANGELOG.md +372 -0
  2. package/README.md +402 -205
  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/contracts/public-api-baseline.json +57 -0
  31. package/docs/assurance-controls.md +49 -0
  32. package/docs/atelier-runtime.md +28 -2
  33. package/docs/atelier-sync.md +172 -0
  34. package/docs/blocks/audience-visibility.md +9 -0
  35. package/docs/blocks/claims.md +61 -0
  36. package/docs/blocks/conformance-admission.md +15 -0
  37. package/docs/blocks/will-not-do.md +18 -0
  38. package/docs/coauthor-session.md +57 -0
  39. package/docs/connected-composition.md +75 -0
  40. package/docs/continuity.md +3 -4
  41. package/docs/decisions.md +167 -0
  42. package/docs/design.md +167 -0
  43. package/docs/exact-upgrades.md +159 -0
  44. package/docs/extension-pack-lifecycle.md +33 -0
  45. package/docs/guided-upgrades.md +99 -0
  46. package/docs/install.md +99 -16
  47. package/docs/intake-and-guides.md +65 -0
  48. package/docs/integration-and-review-plan.md +409 -0
  49. package/docs/integration-contract-decisions.md +22 -0
  50. package/docs/knowledge-graph.md +8 -4
  51. package/docs/local-review.md +84 -0
  52. package/docs/local-services.md +328 -0
  53. package/docs/local-state.md +107 -0
  54. package/docs/obsidian-contract.md +690 -0
  55. package/docs/obsidian.md +423 -0
  56. package/docs/portable-presentation.md +328 -0
  57. package/docs/presentation-consumer-boundaries.md +75 -0
  58. package/docs/presentation-review-disposition.md +47 -0
  59. package/docs/project-command-inventory.md +38 -0
  60. package/docs/project-options.md +53 -0
  61. package/docs/release-engineering.md +174 -12
  62. package/docs/repo-boundary-guard.md +12 -2
  63. package/docs/review-portability.md +55 -0
  64. package/docs/skill-steward.md +156 -0
  65. package/docs/upgrade.md +146 -5
  66. package/docs/vault-service.md +292 -0
  67. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  68. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  69. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  70. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  71. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  72. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  73. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  74. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  75. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  76. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  77. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  78. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  79. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  80. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  81. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  82. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  83. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  84. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  85. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  86. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  87. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  88. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  89. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  90. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  91. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  92. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  93. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  94. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  95. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  96. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  97. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  98. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  99. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  100. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  101. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  102. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  103. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  104. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  105. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  106. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  107. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  108. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  109. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  110. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  111. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  112. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  113. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  114. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  115. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  116. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  117. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  118. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  119. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  120. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  121. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  122. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  123. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  124. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  125. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  126. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  127. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  128. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  129. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  130. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  131. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  132. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  133. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  134. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  135. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  136. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  137. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  138. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  139. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  140. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  141. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  142. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  143. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  144. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  145. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  146. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  147. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  148. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  149. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  150. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  151. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  152. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  153. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  154. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  155. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  156. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  157. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  158. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  159. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  160. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  161. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  162. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  163. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  164. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  165. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  166. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  167. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  168. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  169. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  170. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  171. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  172. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  173. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  174. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  175. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  176. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  177. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  178. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  179. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  180. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  181. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  182. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  183. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  184. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  185. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  186. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  187. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  188. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  189. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  190. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  191. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  192. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  193. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  194. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  195. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  196. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  197. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  198. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  199. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  200. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  201. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  202. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  203. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  204. package/fixtures/obsidian/edits/workspace.json +65 -0
  205. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  206. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  207. package/fixtures/obsidian/materialization/workspace.json +54 -0
  208. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  209. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  210. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  211. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  212. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  213. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  214. package/fixtures/ui/presentation/pane.v1.json +7 -0
  215. package/fixtures/ui/presentation/reference.v1.json +293 -0
  216. package/package.json +86 -10
  217. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  218. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  219. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  220. package/skills/claude/atelier-open/SKILL.md +11 -5
  221. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  222. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  223. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  224. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  225. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  226. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  227. package/skills/codex/atelier-open/SKILL.md +11 -5
  228. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  229. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  230. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  231. package/src/access/preview-enforcer.mjs +84 -0
  232. package/src/boundary/content-rules.mjs +283 -20
  233. package/src/boundary/policy.mjs +213 -91
  234. package/src/cli/execute-command.mjs +36 -0
  235. package/src/cli/project-options.mjs +58 -0
  236. package/src/cli/run.mjs +78 -10
  237. package/src/coauthor/session.mjs +161 -0
  238. package/src/coauthor/store.mjs +173 -0
  239. package/src/collaboration/event-ledger.mjs +348 -0
  240. package/src/collaboration/index.mjs +17 -0
  241. package/src/collaboration/inspection-bundle.mjs +223 -0
  242. package/src/collaboration/proposals.mjs +265 -65
  243. package/src/collaboration/review-contracts.mjs +25 -0
  244. package/src/collaboration/review-store.mjs +305 -0
  245. package/src/commands/attestation.mjs +20 -6
  246. package/src/commands/coauthor.mjs +26 -0
  247. package/src/commands/config.mjs +6 -1
  248. package/src/commands/disclosure.mjs +133 -0
  249. package/src/commands/distribution.mjs +2 -1
  250. package/src/commands/extension-pack.mjs +17 -9
  251. package/src/commands/init.mjs +46 -6
  252. package/src/commands/obsidian.mjs +371 -0
  253. package/src/commands/review.mjs +147 -0
  254. package/src/commands/server.mjs +1 -4
  255. package/src/commands/setup.mjs +51 -14
  256. package/src/commands/skills.mjs +199 -0
  257. package/src/commands/sync.mjs +107 -0
  258. package/src/composition/registry.mjs +27 -0
  259. package/src/composition/wire.mjs +122 -0
  260. package/src/contracts/corpus.mjs +35 -0
  261. package/src/decisions/contracts.d.mts +96 -0
  262. package/src/decisions/contracts.mjs +321 -0
  263. package/src/disclosure/content-scan.mjs +206 -0
  264. package/src/egress/check.mjs +7 -38
  265. package/src/egress/forbidden-egress.mjs +32 -18
  266. package/src/export/atelier-export-contract.mjs +16 -1
  267. package/src/extension-packs/lifecycle.mjs +154 -0
  268. package/src/graph/graph.mjs +176 -316
  269. package/src/graph/knowledge-graph.mjs +643 -43
  270. package/src/guides/contracts.mjs +54 -0
  271. package/src/harness/context-client.mjs +9 -1
  272. package/src/index.mjs +41 -0
  273. package/src/intake/store.mjs +119 -0
  274. package/src/preview/channel.mjs +89 -0
  275. package/src/project/config.mjs +112 -52
  276. package/src/project/durable-state.mjs +161 -0
  277. package/src/project/file-class.mjs +180 -0
  278. package/src/project/package-root.mjs +10 -0
  279. package/src/project/path-match.mjs +38 -15
  280. package/src/project/private-state.mjs +120 -0
  281. package/src/projection/obsidian/contracts.mjs +571 -0
  282. package/src/projection/obsidian/edits/align.mjs +169 -0
  283. package/src/projection/obsidian/edits/apply.mjs +889 -0
  284. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  285. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  286. package/src/projection/obsidian/edits/index.mjs +19 -0
  287. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  288. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  289. package/src/projection/obsidian/edits/observe.mjs +123 -0
  290. package/src/projection/obsidian/edits/policy.mjs +123 -0
  291. package/src/projection/obsidian/edits/regions.mjs +648 -0
  292. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  293. package/src/projection/obsidian/materialize/index.mjs +7 -0
  294. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  295. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  296. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  297. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  298. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  299. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  300. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  301. package/src/projection/obsidian/proposals/index.mjs +23 -0
  302. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  303. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  304. package/src/projection/obsidian/proposals/router.mjs +168 -0
  305. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  306. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  307. package/src/projection/obsidian/publication/index.mjs +5 -0
  308. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  309. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  310. package/src/projection/obsidian/publication/transport.mjs +160 -0
  311. package/src/projection/obsidian/recovery/index.mjs +6 -0
  312. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  313. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  314. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  315. package/src/projection/obsidian/recovery/store.mjs +234 -0
  316. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  317. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  318. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  319. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  320. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  321. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  322. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  323. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  324. package/src/projection/project.mjs +28 -19
  325. package/src/readiness-protocols/evidence.mjs +282 -0
  326. package/src/readiness-protocols/runtime.mjs +4 -3
  327. package/src/readiness-protocols/source-read.mjs +39 -0
  328. package/src/runtime/git-adapter.mjs +189 -0
  329. package/src/runtime/local-state.mjs +439 -0
  330. package/src/runtime/obsidian/app-capability.mjs +129 -0
  331. package/src/runtime/obsidian/app-production-seams.mjs +105 -0
  332. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  333. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  334. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  335. package/src/runtime/obsidian/contributions.mjs +27 -0
  336. package/src/runtime/obsidian/documents.mjs +37 -0
  337. package/src/runtime/obsidian/enablement.mjs +53 -0
  338. package/src/runtime/obsidian/engine.mjs +532 -0
  339. package/src/runtime/obsidian/errors.mjs +14 -0
  340. package/src/runtime/obsidian/extension-points.mjs +103 -0
  341. package/src/runtime/obsidian/index.mjs +34 -0
  342. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  343. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  344. package/src/runtime/obsidian/observation.mjs +158 -0
  345. package/src/runtime/obsidian/opening.mjs +263 -0
  346. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  347. package/src/runtime/obsidian/pipeline.mjs +122 -0
  348. package/src/runtime/obsidian/private-lock.mjs +156 -0
  349. package/src/runtime/obsidian/process-identity.mjs +58 -0
  350. package/src/runtime/obsidian/service-client.mjs +66 -0
  351. package/src/runtime/obsidian/service-entry-path.mjs +9 -0
  352. package/src/runtime/obsidian/service-main.mjs +91 -0
  353. package/src/runtime/obsidian/service-record.mjs +169 -0
  354. package/src/runtime/obsidian/service-server.mjs +121 -0
  355. package/src/runtime/obsidian/service.mjs +216 -0
  356. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  357. package/src/runtime/obsidian/state-store.mjs +180 -0
  358. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  359. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  360. package/src/runtime/obsidian/watchers.mjs +36 -0
  361. package/src/runtime/repository-observation.mjs +491 -0
  362. package/src/runtime/supervisor.mjs +803 -0
  363. package/src/server/local-sidecar.mjs +125 -59
  364. package/src/server/security.mjs +89 -4
  365. package/src/server/server.mjs +4 -2
  366. package/src/skills/steward.mjs +780 -0
  367. package/src/support/feedback-report.mjs +4 -3
  368. package/src/ui/presentation/browser.mjs +278 -0
  369. package/src/ui/presentation/contract.mjs +116 -0
  370. package/src/ui/presentation/index.mjs +7 -0
  371. package/src/ui/presentation/native.mjs +153 -0
  372. package/src/ui/presentation/proof.mjs +57 -0
  373. package/src/ui/presentation/schema-check.mjs +30 -0
  374. package/src/ui/presentation/schema.generated.mjs +1371 -0
  375. package/src/ui/presentation/state.mjs +43 -0
  376. package/src/ui/presentation/styles.mjs +67 -0
  377. package/src/ui/presentation/tokens.mjs +74 -0
  378. package/src/ui/presentation/web.mjs +85 -0
  379. package/src/ui/review-page.mjs +32 -0
  380. package/src/upgrade/explanation.mjs +33 -0
  381. package/src/upgrade/provenance.mjs +338 -0
  382. package/src/upgrade/transaction-files.mjs +103 -0
  383. package/src/upgrade/transaction.mjs +475 -0
  384. package/src/upgrade/upgrade.mjs +65 -29
  385. package/src/vault/hosts.mjs +21 -0
  386. package/src/vault/identity.mjs +19 -0
  387. package/src/vault/index.mjs +7 -0
  388. package/src/vault/interface.mjs +11 -0
  389. package/src/vault/metadata.mjs +31 -0
  390. package/src/vault/privacy.mjs +148 -0
  391. package/src/vault/probe.mjs +89 -0
  392. package/src/vault/service.mjs +131 -0
  393. package/src/vault/source.mjs +45 -0
  394. package/src/vault/storage.mjs +23 -0
  395. package/templates/distribution-workspace/.claude/launch.json +6 -2
  396. package/templates/distribution-workspace/gitignore +1 -0
  397. package/templates/distribution-workspace/project/README.md +2 -2
  398. package/templates/external-project-workspace/README.md +45 -0
  399. package/templates/external-project-workspace/answers.example.json +4 -0
  400. package/templates/external-project-workspace/atelier.project.json +40 -0
  401. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  402. package/templates/external-project-workspace/gitignore +8 -0
  403. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  404. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  405. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  406. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  407. package/templates/external-project-workspace/source/README.md +11 -0
  408. package/templates/launch.json +6 -2
  409. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  410. package/templates/private-domain-workspace/domain/README.md +2 -2
  411. package/templates/private-domain-workspace/gitignore +1 -0
  412. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  413. package/templates/shared-project-workspace/gitignore +1 -0
  414. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -1,7 +1,8 @@
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'
4
- import { generatedProjectionBasenames } from '../project/file-class.mjs'
5
+ import { generatedProjectionBasenames, generatedProjectionDirectoryBasenames } from '../project/file-class.mjs'
5
6
  import { gitIgnoreFilter } from '../project/git-ignore.mjs'
6
7
 
7
8
  export const KNOWLEDGE_GRAPH_SCHEMA = 'mnstry.knowledge-graph@v1'
@@ -14,7 +15,7 @@ export const VALID_STATUSES = new Set(['active', 'draft', 'archived', 'template'
14
15
  export const VALID_KG_TYPES = new Set(['document', 'artifact', 'evidence', 'source', 'index', 'contract', 'guide', 'runbook', 'policy', 'report', 'prototype', 'research', 'decision', 'map', 'manifest', 'html', 'pdf', 'docx'])
15
16
  export const VALID_SIDECAR_KG_TYPES = new Set(['html', 'pdf', 'docx', 'artifact', 'evidence', 'source', 'prototype', 'research', 'report', 'manifest'])
16
17
 
17
- const SKIP_DIRS = new Set(['.git', '.agents', '.claude', '.github', 'node_modules', 'output', 'uploads', 'scripts', 'lib'])
18
+ const SKIP_DIRS = new Set(['.git', '.agents', '.claude', '.github', 'node_modules', 'output', 'uploads', 'scripts', 'lib', ...generatedProjectionDirectoryBasenames()])
18
19
  // Derived from the kit's file-class declaration, never restated here.
19
20
  const GENERATED_FILES = generatedProjectionBasenames()
20
21
  const PRIVATE_AUDIENCES = new Set(['private', 'sensitive'])
@@ -100,7 +101,7 @@ function uniqueStringArrayErrors(value, label) {
100
101
  }
101
102
 
102
103
  export function splitFrontmatter(raw) {
103
- const normalized = raw.replace(/\r\n/g, '\n')
104
+ const normalized = String(raw).replace(/\r\n?/g, '\n')
104
105
  if (!normalized.startsWith('---\n')) return null
105
106
  const rest = normalized.slice(4)
106
107
  const match = rest.match(/\n---\s*\n/)
@@ -188,11 +189,50 @@ export function markdownMetadata(raw) {
188
189
  return fm ? parseYamlSubset(fm.yaml) : {}
189
190
  }
190
191
 
192
+ function markdownFrontmatterState(raw) {
193
+ const fm = splitFrontmatter(raw)
194
+ if (!fm) return { kind: 'absent', metadata: {} }
195
+ if (!fm.yaml.trim()) return { kind: 'empty', metadata: {} }
196
+
197
+ const metadata = parseYamlSubset(fm.yaml)
198
+ const lines = fm.yaml.replace(/\t/g, ' ').replace(/\r\n/g, '\n').split('\n')
199
+ let malformed = false
200
+ const containerIndents = [-1]
201
+ const containerKinds = ['object']
202
+ for (let index = 0; index < lines.length; index += 1) {
203
+ const rawLine = lines[index]
204
+ const trimmed = rawLine.trim()
205
+ if (!trimmed || trimmed.startsWith('#')) continue
206
+ const indent = rawLine.match(/^\s*/)[0].length
207
+ while (containerIndents.length > 1 && indent <= containerIndents.at(-1)) {
208
+ containerIndents.pop()
209
+ containerKinds.pop()
210
+ }
211
+ if (trimmed.startsWith('- ')) {
212
+ if (containerKinds.at(-1) !== 'array' || !trimmed.slice(2).trim()) malformed = true
213
+ continue
214
+ }
215
+ const match = trimmed.match(/^([^:]+):(.*)$/)
216
+ if (!match || !match[1].trim()) {
217
+ malformed = true
218
+ continue
219
+ }
220
+ if (!match[2].trim()) {
221
+ const next = nextYamlValue(lines, index, indent)
222
+ containerIndents.push(indent)
223
+ containerKinds.push(Array.isArray(next) ? 'array' : 'object')
224
+ }
225
+ }
226
+ return { kind: malformed ? 'malformed' : 'valid', metadata }
227
+ }
228
+
191
229
  function relationMap(value) {
192
230
  if (!isPlainObject(value)) return {}
193
231
  const relations = {}
194
232
  for (const [key, items] of Object.entries(value)) {
195
- relations[key] = Array.isArray(items) ? asStringArray(items) : items
233
+ // Preserve the original graph command's shorthand while emitting one
234
+ // canonical shape: a scalar Markdown relation is a one-target list.
235
+ relations[key] = Array.isArray(items) ? asStringArray(items) : typeof items === 'string' ? asStringArray([items]) : items
196
236
  }
197
237
  return relations
198
238
  }
@@ -315,7 +355,6 @@ export function walkDocuments(dir, root, acc = [], isIgnored = gitIgnoreFilter(r
315
355
  // sidecar itself is metadata, never a source asset.
316
356
  if (ent.name.endsWith('.kg.json')) continue
317
357
  if (GENERATED_FILES.has(ent.name)) continue
318
- if (rel === 'index.html') continue
319
358
  const ext = path.extname(ent.name).toLowerCase()
320
359
  // Opt-in requires a VISIBLE sidecar. A git-ignored one is machine-local:
321
360
  // it would enrol a node that exists in no tracked file, and hand it an
@@ -548,7 +587,9 @@ export function generatedFrontmatter({ id, repoName, rel, title, summary, domain
548
587
  ].join('\n')
549
588
  }
550
589
 
551
- 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) {
552
593
  const inferredLifecycle = inferLifecycle(file.rel)
553
594
  const inferredDomain = inferDomain(repoName, file.rel)
554
595
  const inferredStatus = inferStatus(inferredLifecycle, file.rel)
@@ -556,13 +597,21 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
556
597
  let title = titleCase(path.basename(file.rel))
557
598
  let summary = ''
558
599
  let metadata = {}
600
+ let classification = 'classified'
601
+ let classificationReason = null
559
602
  let sidecar = null
560
603
  let hasSidecar = false
561
604
  let atelier = { section: coverage.sections.get(file.rel) || null, status: null, kind: null }
562
605
 
563
606
  if (file.ext === '.md') {
564
- const raw = fs.readFileSync(file.abs, 'utf8')
565
- metadata = markdownMetadata(raw)
607
+ const raw = typeof sourceText === 'string' ? sourceText : fs.readFileSync(file.abs, 'utf8')
608
+ const frontmatter = markdownFrontmatterState(raw)
609
+ metadata = frontmatter.metadata
610
+ if (frontmatter.kind !== 'valid' || !isPlainObject(metadata.kg)) {
611
+ classification = 'unclassified'
612
+ classificationReason = frontmatter.kind === 'valid' ? 'missing-kg-block' : `${frontmatter.kind}-frontmatter`
613
+ metadata = {}
614
+ }
566
615
  title = asString(metadata.title, extractMarkdownTitle(raw, file.rel))
567
616
  summary = asString(metadata.summary, extractMarkdownSummary(raw))
568
617
  } else if (file.ext === '.html') {
@@ -594,7 +643,7 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
594
643
  const domain = asString(kg.domain, inferredDomain)
595
644
  const lifecycle = asString(kg.lifecycle, inferredLifecycle)
596
645
  const status = asString(kg.status, inferredStatus)
597
- const audience = asString(kg.audience, '')
646
+ const audience = classification === 'unclassified' ? 'private' : asString(kg.audience, '')
598
647
  const frontmatterHasLegacyVisibility = Object.hasOwn(kg, 'visibility')
599
648
  const type = asString(kg.type, file.ext === '.md' ? 'document' : file.ext.slice(1))
600
649
  const id = asString(kg.id, fallbackId(repoName, file.rel, file.ext))
@@ -615,6 +664,8 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
615
664
  lifecycle,
616
665
  status,
617
666
  audience,
667
+ classification,
668
+ ...(classificationReason ? { classificationReason } : {}),
618
669
  ...(frontmatterHasLegacyVisibility ? { frontmatterHasLegacyVisibility: true } : {}),
619
670
  ...(file.ext === '.md' ? { markdownHasKgBlock: hasKgBlock, markdownHasKgId: hasExplicitKgId } : {}),
620
671
  ...(file.ext !== '.md'
@@ -645,27 +696,461 @@ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig
645
696
  }
646
697
  }
647
698
 
648
- export function markdownLinkEdges(repoRoot, nodesByPath) {
649
- const edges = []
650
- for (const [rel, node] of nodesByPath) {
651
- if (!rel.endsWith('.md')) continue
652
- const raw = fs.readFileSync(path.join(repoRoot, rel), 'utf8')
653
- const re = /\[[^\]]+\]\(([^)\s#]+)(?:#[^)]+)?\)/g
654
- let m
655
- while ((m = re.exec(raw))) {
656
- const href = m[1]
657
- if (/^[a-z]+:/i.test(href) || href.startsWith('#')) continue
658
- const target = path
659
- .normalize(path.join(path.dirname(rel), decodeURIComponent(href)))
660
- .split(path.sep)
661
- .join('/')
662
- const direct = nodesByPath.get(target)
663
- const index = nodesByPath.get(posixJoin(target, 'README.md')) || nodesByPath.get(posixJoin(target, 'index.md'))
664
- const targetNode = direct || index
665
- 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
+ }
666
746
  }
747
+ offset = next
667
748
  }
668
- 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)
669
1154
  }
670
1155
 
671
1156
  export function declaredRelationEdges(nodes) {
@@ -694,6 +1179,20 @@ export function uniqueEdges(edges) {
694
1179
  export function graphDiagnostics(nodes) {
695
1180
  const diagnostics = []
696
1181
  for (const node of nodes) {
1182
+ if (node.classification === 'unclassified') {
1183
+ diagnostics.push({
1184
+ severity: 'warning',
1185
+ type: 'unclassified-content',
1186
+ code: 'unclassified-content',
1187
+ node: node.id,
1188
+ repo: node.repo,
1189
+ path: node.path,
1190
+ audience: node.audience,
1191
+ repoAccess: node.repoAccess,
1192
+ reason: node.classificationReason,
1193
+ message: `${node.repo}/${node.path}: Markdown classification metadata is ${node.classificationReason}; enrolled as private unclassified content`,
1194
+ })
1195
+ }
697
1196
  const audienceRank = AUDIENCE_READ_RANK[node.audience]
698
1197
  const readBoundary = node.repoAccess?.readBoundary
699
1198
  const readBoundaryRank = AUDIENCE_READ_RANK[readBoundary]
@@ -730,14 +1229,16 @@ export function activeOrphanSidecars(repoName, repoRoot, files, isIgnored = gitI
730
1229
  return orphans
731
1230
  }
732
1231
 
733
- export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
1232
+ export function validateKnowledgeGraph(nodes, edges, orphanSidecars = [], { externalRelationPrefixes = [], externalRelationIds = [] } = {}) {
734
1233
  const errors = []
735
1234
  const seenIds = new Map()
1235
+ const allowedExternalPrefixes = new Set(externalRelationPrefixes)
1236
+ const allowedExternalIds = new Set(externalRelationIds)
736
1237
 
737
1238
  for (const node of nodes) {
738
1239
  if (!node.id) errors.push(`${node.repo}/${node.path}: missing kg.id`)
739
- if (node.extension === 'md' && node.markdownHasKgBlock && !node.markdownHasKgId) {
740
- errors.push(`${node.repo}/${node.path}: Markdown kg block must declare kg.id`)
1240
+ if (node.extension === 'md' && node.classification !== 'unclassified' && node.markdownHasKgBlock && !node.markdownHasKgId) {
1241
+ errors.push(`${node.repo}/${node.path}: kg.id is required in a Markdown kg block`)
741
1242
  }
742
1243
  if (node.extension !== 'md' && !node.hasSidecar) {
743
1244
  errors.push(`${node.repo}/${node.path}: missing non-Markdown sidecar ${node.sidecar || `${node.path}.kg.json`}`)
@@ -753,7 +1254,7 @@ export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
753
1254
  seenIds.set(node.id, `${node.repo}/${node.path}`)
754
1255
  }
755
1256
  if (node.frontmatterHasLegacyVisibility) {
756
- errors.push(`${node.repo}/${node.path}: legacy kg.visibility is reserved for runtime export; use kg.audience`)
1257
+ errors.push(`${node.repo}/${node.path}: kg.visibility is invalid for graph classification; use kg.audience`)
757
1258
  }
758
1259
  if (!VALID_AUDIENCES.has(node.audience)) {
759
1260
  errors.push(`${node.repo}/${node.path}: invalid or missing kg.audience "${node.audience ?? ''}"`)
@@ -776,7 +1277,8 @@ export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
776
1277
 
777
1278
  for (const edge of edges) {
778
1279
  if (!edge.declared) continue
779
- if (!seenIds.has(edge.target)) {
1280
+ const externalPrefix = String(edge.target).split(':')[0]
1281
+ if (!seenIds.has(edge.target) && !allowedExternalIds.has(edge.target) && !allowedExternalPrefixes.has(externalPrefix)) {
780
1282
  const sourcePath = seenIds.get(edge.source) || edge.source
781
1283
  errors.push(`${sourcePath}: declared ${edge.type} target "${edge.target}" was not found`)
782
1284
  }
@@ -789,21 +1291,85 @@ export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
789
1291
  return errors
790
1292
  }
791
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
+
792
1345
  export function buildKnowledgeGraph({
793
1346
  workspaceRoot,
794
1347
  repoAccessConfig,
795
1348
  repoRoots = null,
1349
+ repoEntries = null,
796
1350
  repoAccessConfigPath = 'repo-access config',
797
1351
  externalRepos = [],
1352
+ externalRelationPrefixes = [],
1353
+ externalRelationIds = [],
1354
+ isLinkTargetEligible = undefined,
1355
+ isAssetEligible = undefined,
1356
+ fileCache = null,
1357
+ observedDigest = null,
798
1358
  } = {}) {
799
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')
800
1362
  const resolvedWorkspaceRoot = path.resolve(workspaceRoot)
801
1363
  const external = new Set(externalRepos)
802
- const discovered = repoRoots ? repoRoots.map((repoRoot) => path.resolve(repoRoot)).sort() : listRepos(resolvedWorkspaceRoot)
1364
+ const discoveredEntries = repoEntries
1365
+ ? repoEntries.map((entry) => ({ name: entry.name, path: path.resolve(entry.path) })).sort((a, b) => String(a.name).localeCompare(String(b.name)))
1366
+ : (repoRoots ? repoRoots.map((repoRoot) => path.resolve(repoRoot)) : listRepos(resolvedWorkspaceRoot))
1367
+ .sort()
1368
+ .map((repoRoot) => ({ name: path.basename(repoRoot), path: repoRoot }))
803
1369
  // External repos are acknowledged but never walked: no document census, no
804
1370
  // sidecar demands, no projection.
805
- const roots = discovered.filter((repoRoot) => !external.has(path.basename(repoRoot)))
806
- const repoNames = roots.map((repoRoot) => path.basename(repoRoot))
1371
+ const roots = discoveredEntries.filter((entry) => !external.has(entry.name))
1372
+ const repoNames = roots.map((entry) => entry.name)
807
1373
  const accessConfig = repoAccessConfig ?? {
808
1374
  schema: REPO_ACCESS_SCHEMA,
809
1375
  defaultReadBoundary: 'team',
@@ -824,9 +1390,14 @@ export function buildKnowledgeGraph({
824
1390
  const workspaceOrphanSidecars = []
825
1391
  const workspaceIgnoredSidecars = []
826
1392
  const repoGraphs = []
827
-
828
- for (const repoRoot of roots) {
829
- const repoName = path.basename(repoRoot)
1393
+ const census = []
1394
+ const scanned = new Map()
1395
+ const nextCache = fileCache ? new Map() : null
1396
+ const reuse = { reused: 0, derived: 0, read: 0 }
1397
+
1398
+ for (const entry of roots) {
1399
+ const repoRoot = entry.path
1400
+ const repoName = entry.name
830
1401
  // One batched ignore lookup per repo, shared by every walk below.
831
1402
  const isIgnored = gitIgnoreFilter(repoRoot)
832
1403
  const files = walkDocuments(repoRoot, repoRoot, [], isIgnored)
@@ -837,12 +1408,32 @@ export function buildKnowledgeGraph({
837
1408
  const nodesByPath = new Map()
838
1409
 
839
1410
  for (const file of files) {
840
- 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
+ }
841
1422
  nodes.push(node)
842
1423
  nodesByPath.set(file.rel, node)
843
1424
  }
844
1425
 
845
- 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)])
846
1437
  const graph = {
847
1438
  schema: KNOWLEDGE_GRAPH_SCHEMA,
848
1439
  repo: repoName,
@@ -857,14 +1448,18 @@ export function buildKnowledgeGraph({
857
1448
  workspaceNodes.push(...graph.nodes)
858
1449
  workspaceEdges.push(...graph.edges)
859
1450
  }
1451
+ workspaceEdges.push(...resolved.links.filter((link) => !repoLocalLink(link)).map(linkEdge))
860
1452
 
861
- const validationErrors = validateKnowledgeGraph(workspaceNodes, workspaceEdges, workspaceOrphanSidecars)
1453
+ const validationErrors = validateKnowledgeGraph(workspaceNodes, workspaceEdges, workspaceOrphanSidecars, {
1454
+ externalRelationPrefixes,
1455
+ externalRelationIds,
1456
+ })
862
1457
  const workspaceGraph = {
863
1458
  schema: KNOWLEDGE_GRAPH_SCHEMA,
864
1459
  workspace: path.basename(resolvedWorkspaceRoot),
865
1460
  diagnostics: graphDiagnostics(workspaceNodes),
866
1461
  nodes: workspaceNodes.sort((a, b) => `${a.repo}/${a.path}`.localeCompare(`${b.repo}/${b.path}`)),
867
- 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}`)),
868
1463
  }
869
1464
  workspaceGraph.nodeCount = workspaceGraph.nodes.length
870
1465
  workspaceGraph.edgeCount = workspaceGraph.edges.length
@@ -876,6 +1471,11 @@ export function buildKnowledgeGraph({
876
1471
  workspaceGraph,
877
1472
  orphanSidecars: workspaceOrphanSidecars,
878
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,
879
1479
  }
880
1480
  }
881
1481