@mnstry/atelier 0.2.0-alpha.1

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 (260) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +262 -0
  5. package/SECURITY.md +93 -0
  6. package/TRADEMARKS.md +88 -0
  7. package/announcements/2026-08-06-announcements-channel.v1.json +12 -0
  8. package/announcements/keys/mnstry-announcements.public.v1.json +9 -0
  9. package/bin/atelier.mjs +2 -0
  10. package/bin/mnstry-atelier.mjs +3 -0
  11. package/contracts/analysis-adapter.v1.schema.json +248 -0
  12. package/contracts/atelier-action-intent.v1.schema.json +143 -0
  13. package/contracts/atelier-analysis-adapter.v1.schema.json +360 -0
  14. package/contracts/atelier-attestation.v1.schema.json +239 -0
  15. package/contracts/atelier-boundary-policy.v1.schema.json +339 -0
  16. package/contracts/atelier-claim.v1.schema.json +97 -0
  17. package/contracts/atelier-export.v1.schema.json +1015 -0
  18. package/contracts/atelier-extension-pack.v1.schema.json +290 -0
  19. package/contracts/atelier-kit-manifest.v1.schema.json +273 -0
  20. package/contracts/atelier-lock.v1.schema.json +364 -0
  21. package/contracts/atelier-migration.v1.schema.json +162 -0
  22. package/contracts/atelier-project-config.v1.schema.json +352 -0
  23. package/contracts/atelier-readiness-protocol.v1.schema.json +517 -0
  24. package/contracts/atelier-readiness-run.v1.schema.json +229 -0
  25. package/contracts/atelier-readiness.v1.schema.json +457 -0
  26. package/contracts/compat-baseline.json +10 -0
  27. package/contracts/git-promote-event.v1.schema.json +145 -0
  28. package/contracts/knowledge-source-sidecar.v1.schema.json +157 -0
  29. package/contracts/mnstry-atelier-semantic-profile.v1.json +254 -0
  30. package/docs/announcements.md +101 -0
  31. package/docs/atelier-runtime.md +34 -0
  32. package/docs/attestation.md +201 -0
  33. package/docs/continuity.md +55 -0
  34. package/docs/contract-stability.md +111 -0
  35. package/docs/distributions.md +220 -0
  36. package/docs/install.md +140 -0
  37. package/docs/knowledge-graph.md +140 -0
  38. package/docs/ontology.md +206 -0
  39. package/docs/release-engineering.md +151 -0
  40. package/docs/repo-boundary-guard.md +199 -0
  41. package/docs/tenant-readiness.md +64 -0
  42. package/docs/upgrade.md +124 -0
  43. package/fixtures/analysis/invalid/native-graph.v1.json +4 -0
  44. package/fixtures/analysis/invalid/promoted-claim.v1.json +17 -0
  45. package/fixtures/analysis/invalid/runtime-import.v1.json +20 -0
  46. package/fixtures/analysis/valid/output.claims.v1.json +17 -0
  47. package/fixtures/analysis-adapter/invalid/manifest-hidden-provider.v1.json +24 -0
  48. package/fixtures/analysis-adapter/invalid/output-frontmatter-mutation.v1.json +21 -0
  49. package/fixtures/analysis-adapter/invalid/output-native-graph.v1.json +14 -0
  50. package/fixtures/analysis-adapter/manifest.disabled.v1.json +22 -0
  51. package/fixtures/analysis-adapter/manifest.enabled-local.v1.json +23 -0
  52. package/fixtures/analysis-adapter/output.claims.v1.json +21 -0
  53. package/fixtures/atelier-action-intent/invalid/runtime-mutation-authority.v1.json +16 -0
  54. package/fixtures/atelier-action-intent/invalid/unknown-top-level-field.v1.json +17 -0
  55. package/fixtures/atelier-action-intent/invalid/writes-beyond-clipboard.v1.json +16 -0
  56. package/fixtures/atelier-action-intent/valid/copy-agent-prompt.v1.json +16 -0
  57. package/fixtures/atelier-action-intent/valid/copy-repo-path.v1.json +22 -0
  58. package/fixtures/atelier-analysis-adapter/invalid/hidden-model-provider.v1.json +24 -0
  59. package/fixtures/atelier-analysis-adapter/invalid/inert-with-execute-command.v1.json +24 -0
  60. package/fixtures/atelier-analysis-adapter/invalid/proposals-with-runtime-import.v1.json +30 -0
  61. package/fixtures/atelier-analysis-adapter/valid/manifest-inert.v1.json +24 -0
  62. package/fixtures/atelier-analysis-adapter/valid/proposals-claim-only.v1.json +27 -0
  63. package/fixtures/atelier-attestation/invalid/bad-digest.v1.json +23 -0
  64. package/fixtures/atelier-attestation/invalid/conformance-scope.v1.json +23 -0
  65. package/fixtures/atelier-attestation/invalid/missing-signature.v1.json +22 -0
  66. package/fixtures/atelier-attestation/invalid/unknown-root-field.v1.json +24 -0
  67. package/fixtures/atelier-attestation/keys/roundtrip-issuer.public.v1.json +9 -0
  68. package/fixtures/atelier-attestation/valid/rejected-with-reasons.v1.json +49 -0
  69. package/fixtures/atelier-attestation/valid/signed-admitted.v1.json +35 -0
  70. package/fixtures/atelier-attestation/valid/signed-roundtrip.v1.json +29 -0
  71. package/fixtures/atelier-attestation/valid/unsigned-advisory.v1.json +23 -0
  72. package/fixtures/atelier-claim/invalid/unknown-provider.v1.json +15 -0
  73. package/fixtures/atelier-claim/invalid/unknown-top-level-field.v1.json +16 -0
  74. package/fixtures/atelier-claim/invalid/wrong-schema-const.v1.json +15 -0
  75. package/fixtures/atelier-claim/valid/adapter-style-proposed-relation.v1.json +19 -0
  76. package/fixtures/atelier-claim/valid/codex-proposed-relation.v1.json +16 -0
  77. package/fixtures/atelier-claim/valid/manual-proposed-relation.v1.json +16 -0
  78. package/fixtures/atelier-claim/valid/readiness-proposed-relation.v1.json +18 -0
  79. package/fixtures/atelier-export/invalid/dirty-unforced-empty-taint.v1.json +19 -0
  80. package/fixtures/atelier-export/invalid/object-local-audience-visibility.v1.json +11 -0
  81. package/fixtures/atelier-export/invalid/open-object-class-owner.v1.json +23 -0
  82. package/fixtures/atelier-export/invalid/pricing-legacy-amount.v1.json +23 -0
  83. package/fixtures/atelier-export/invalid/public-disguised-internal-source-ref.v1.json +11 -0
  84. package/fixtures/atelier-export/invalid/public-nested-private-source-ref.v1.json +17 -0
  85. package/fixtures/atelier-export/invalid/public-resolved-private-source-ref.v1.json +13 -0
  86. package/fixtures/atelier-export/invalid/public-resolved-sensitive-source-ref.v1.json +13 -0
  87. package/fixtures/atelier-export/invalid/public-source-ref-missing-provenance.v1.json +20 -0
  88. package/fixtures/atelier-export/invalid/public-source-team-kg.json +12 -0
  89. package/fixtures/atelier-export/invalid/source-node-legacy-visibility.v1.json +21 -0
  90. package/fixtures/atelier-export/invalid/source-node-unknown-field.v1.json +18 -0
  91. package/fixtures/atelier-export/invalid/unresolved-public-source-ref.v1.json +11 -0
  92. package/fixtures/atelier-export/sample-studio-offer.v1.json +397 -0
  93. package/fixtures/atelier-extension-pack/invalid/bundled-slug-collision.v1.json +22 -0
  94. package/fixtures/atelier-extension-pack/invalid/escapes-root.v1.json +22 -0
  95. package/fixtures/atelier-extension-pack/invalid/posture-tampered.v1.json +22 -0
  96. package/fixtures/atelier-extension-pack/invalid/protocols/autonomous-authority.v1.json +118 -0
  97. package/fixtures/atelier-extension-pack/invalid/protocols/offer-map.v1.json +109 -0
  98. package/fixtures/atelier-extension-pack/invalid/redefines-runtime-term.v1.json +19 -0
  99. package/fixtures/atelier-extension-pack/invalid/wrong-namespace.v1.json +19 -0
  100. package/fixtures/atelier-extension-pack/valid/protocols/contract-gate.v1.json +109 -0
  101. package/fixtures/atelier-extension-pack/valid/sample-pack.v1.json +48 -0
  102. package/fixtures/atelier-kit-manifest/invalid/runtime-copy-without-canonical-role.v1.json +53 -0
  103. package/fixtures/atelier-kit-manifest/invalid/telemetry-send-path.v1.json +21 -0
  104. package/fixtures/atelier-kit-manifest/valid/mnstry-atelier.valid.v1.json +76 -0
  105. package/fixtures/atelier-lock/invalid/absolute-contract-path.v1.json +40 -0
  106. package/fixtures/atelier-lock/invalid/runtime-mutation-enabled.v1.json +41 -0
  107. package/fixtures/atelier-lock/invalid/unknown-top-level-field.v1.json +42 -0
  108. package/fixtures/atelier-lock/valid/extension-pack.v1.json +55 -0
  109. package/fixtures/atelier-lock/valid/minimal.v1.json +44 -0
  110. package/fixtures/atelier-migration/invalid/live-apply-mode.v1.json +21 -0
  111. package/fixtures/atelier-migration/invalid/missing-review-evidence.v1.json +20 -0
  112. package/fixtures/atelier-migration/invalid/runtime-mutation-enabled.v1.json +21 -0
  113. package/fixtures/atelier-migration/valid/minimal.v1.json +32 -0
  114. package/fixtures/atelier-project-config/invalid/absolute-repo-path.v1.json +11 -0
  115. package/fixtures/atelier-project-config/invalid/ext-not-object.v1.json +12 -0
  116. package/fixtures/atelier-project-config/invalid/external-kind-read-boundary.v1.json +12 -0
  117. package/fixtures/atelier-project-config/invalid/missing-repos-and-alignment.v1.json +7 -0
  118. package/fixtures/atelier-project-config/invalid/unknown-top-level-field.v1.json +14 -0
  119. package/fixtures/atelier-project-config/valid/alignment-scaffold.v1.json +15 -0
  120. package/fixtures/atelier-project-config/valid/ext-extension-packs.v1.json +36 -0
  121. package/fixtures/atelier-project-config/valid/repo-workspace.v1.json +23 -0
  122. package/fixtures/atelier-project-config/validator-only/ext-member-not-object.v1.json +14 -0
  123. package/fixtures/atelier-readiness/invalid/dry-run-only-false.v1.json +87 -0
  124. package/fixtures/atelier-readiness/invalid/support-bundle-send-path.v1.json +87 -0
  125. package/fixtures/atelier-readiness/invalid/unknown-top-level-field.v1.json +88 -0
  126. package/fixtures/atelier-readiness/valid/ready-workspace.v1.json +84 -0
  127. package/fixtures/boundary-policy/invalid/content-rule-exception-blanket-path.v1.json +67 -0
  128. package/fixtures/boundary-policy/invalid/invalid-actor-private-repo-mapping.v1.json +20 -0
  129. package/fixtures/boundary-policy/invalid/legacy-warning-missing-warning.v1.json +20 -0
  130. package/fixtures/boundary-policy/invalid/missing-repo-coverage.v1.json +19 -0
  131. package/fixtures/boundary-policy/invalid/unknown-key.v1.json +12 -0
  132. package/fixtures/boundary-policy/valid/content-rule-exception.v1.json +67 -0
  133. package/fixtures/boundary-policy/valid/legacy-warning.v1.json +33 -0
  134. package/fixtures/boundary-policy/valid/strict-private-shared.v1.json +40 -0
  135. package/fixtures/git-promote/invalid/provision-named-revocable.v1.json +29 -0
  136. package/fixtures/git-promote/private-to-team.v1.json +29 -0
  137. package/fixtures/knowledge-source-sidecar/invalid/legacy-visibility-field.v1.json +17 -0
  138. package/fixtures/knowledge-source-sidecar/invalid/missing-audience.v1.json +15 -0
  139. package/fixtures/knowledge-source-sidecar/invalid/unknown-relation-kind.v1.json +18 -0
  140. package/fixtures/knowledge-source-sidecar/valid/html-source.v1.json +18 -0
  141. package/fixtures/knowledge-source-sidecar/valid/pdf-evidence.v1.json +18 -0
  142. package/fixtures/projects/neutral-project/atelier.project.json +15 -0
  143. package/fixtures/projects/neutral-project/ops/config/repo-access.v1.json +12 -0
  144. package/fixtures/projects/neutral-project/project-knowledge.graph.json +69 -0
  145. package/fixtures/projects/sample-workspace/atelier.project.json +23 -0
  146. package/fixtures/projects/sample-workspace/boundary-policy.v1.json +59 -0
  147. package/fixtures/projects/sample-workspace/content/README.md +16 -0
  148. package/fixtures/projects/sample-workspace/content/source.html +5 -0
  149. package/fixtures/projects/sample-workspace/content/source.html.kg.json +15 -0
  150. package/fixtures/projects/sample-workspace/repo-access.v1.json +9 -0
  151. package/fixtures/projects/source-formats-workspace/atelier.project.json +23 -0
  152. package/fixtures/projects/source-formats-workspace/content/README.md +17 -0
  153. package/fixtures/projects/source-formats-workspace/content/data.json +7 -0
  154. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +15 -0
  155. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +15 -0
  156. package/fixtures/projects/source-formats-workspace/content/metrics.csv +3 -0
  157. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +15 -0
  158. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml +4 -0
  159. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +15 -0
  160. package/fixtures/projects/source-formats-workspace/repo-access.v1.json +9 -0
  161. package/fixtures/readiness-protocols/extension-pack/invalid/protocol-missing-path.v1.json +20 -0
  162. package/fixtures/readiness-protocols/extension-pack/valid/with-protocol.v1.json +24 -0
  163. package/fixtures/readiness-protocols/protocol/invalid/missing-check-target.v1.json +103 -0
  164. package/fixtures/readiness-protocols/protocol/invalid/runtime-mutation-enabled.v1.json +107 -0
  165. package/fixtures/readiness-protocols/protocol/valid/minimal.v1.json +100 -0
  166. package/fixtures/readiness-protocols/run/invalid/missing-evidence.v1.json +37 -0
  167. package/fixtures/readiness-protocols/run/invalid/pass-with-blocker.v1.json +22 -0
  168. package/fixtures/readiness-protocols/run/invalid/runtime-mutation-enabled.v1.json +22 -0
  169. package/fixtures/readiness-protocols/run/valid/pass.v1.json +44 -0
  170. package/fixtures/support/invalid/leaky-support.v1.json +13 -0
  171. package/fixtures/support/valid/minimal-safe.v1.json +20 -0
  172. package/package.json +98 -0
  173. package/skills/claude/atelier-open/SKILL.md +68 -0
  174. package/skills/claude/mnstry-readiness/SKILL.md +41 -0
  175. package/skills/codex/atelier-open/SKILL.md +68 -0
  176. package/skills/codex/mnstry-readiness/SKILL.md +41 -0
  177. package/src/analysis/adapter.mjs +20 -0
  178. package/src/analysis/analysis-claim-contract.mjs +335 -0
  179. package/src/attestation/jcs.mjs +64 -0
  180. package/src/attestation/sign.mjs +305 -0
  181. package/src/boundary/content-rules.mjs +262 -0
  182. package/src/boundary/policy.mjs +806 -0
  183. package/src/check-atelier-export-contract.mjs +116 -0
  184. package/src/cli/run.mjs +283 -0
  185. package/src/collaboration/proposals.mjs +262 -0
  186. package/src/commands/analysis.mjs +8 -0
  187. package/src/commands/announcements.mjs +308 -0
  188. package/src/commands/attestation.mjs +198 -0
  189. package/src/commands/boundary.mjs +25 -0
  190. package/src/commands/config.mjs +4 -0
  191. package/src/commands/context.mjs +3 -0
  192. package/src/commands/distribution.mjs +224 -0
  193. package/src/commands/egress.mjs +11 -0
  194. package/src/commands/extension-pack.mjs +209 -0
  195. package/src/commands/feedback.mjs +3 -0
  196. package/src/commands/graph.mjs +3 -0
  197. package/src/commands/init.mjs +130 -0
  198. package/src/commands/lock.mjs +3 -0
  199. package/src/commands/project.mjs +3 -0
  200. package/src/commands/promote.mjs +4 -0
  201. package/src/commands/readiness.mjs +3 -0
  202. package/src/commands/server.mjs +6 -0
  203. package/src/commands/setup.mjs +250 -0
  204. package/src/commands/support.mjs +3 -0
  205. package/src/commands/upgrade.mjs +3 -0
  206. package/src/contracts/corpus.mjs +171 -0
  207. package/src/egress/check.mjs +45 -0
  208. package/src/egress/forbidden-egress.mjs +323 -0
  209. package/src/export/atelier-export-contract.mjs +722 -0
  210. package/src/extension-packs/loader.mjs +491 -0
  211. package/src/graph/graph.mjs +370 -0
  212. package/src/graph/knowledge-graph.mjs +887 -0
  213. package/src/harness/context-client.mjs +144 -0
  214. package/src/harness/context.mjs +27 -0
  215. package/src/index.mjs +88 -0
  216. package/src/project/config.mjs +557 -0
  217. package/src/project/file-class.mjs +113 -0
  218. package/src/project/git-ignore.mjs +31 -0
  219. package/src/project/path-match.mjs +30 -0
  220. package/src/project/repo-identity.mjs +176 -0
  221. package/src/projection/alignment-projection.mjs +169 -0
  222. package/src/projection/policy.mjs +90 -0
  223. package/src/projection/project.mjs +152 -0
  224. package/src/readiness/readiness.mjs +312 -0
  225. package/src/readiness-protocols/bundled-pack.mjs +766 -0
  226. package/src/readiness-protocols/runtime.mjs +419 -0
  227. package/src/server/local-sidecar.mjs +640 -0
  228. package/src/server/security.mjs +229 -0
  229. package/src/server/server.mjs +42 -0
  230. package/src/support/feedback-report.mjs +414 -0
  231. package/src/support/support-bundle.mjs +226 -0
  232. package/src/ui/html-primitives.mjs +104 -0
  233. package/src/upgrade/upgrade.mjs +701 -0
  234. package/src/validate-atelier-export-dry-run.mjs +75 -0
  235. package/templates/atelier.project.example.json +23 -0
  236. package/templates/distribution-workspace/.claude/launch.json +12 -0
  237. package/templates/distribution-workspace/README.md +86 -0
  238. package/templates/distribution-workspace/atelier.project.json +36 -0
  239. package/templates/distribution-workspace/boundary-policy.v1.json +61 -0
  240. package/templates/distribution-workspace/gitignore +29 -0
  241. package/templates/distribution-workspace/governance/repo-boundary-ledger.md +5 -0
  242. package/templates/distribution-workspace/project/README.md +19 -0
  243. package/templates/distribution-workspace/repo-access.v1.json +9 -0
  244. package/templates/launch.json +12 -0
  245. package/templates/private-domain-workspace/.claude/launch.json +12 -0
  246. package/templates/private-domain-workspace/README.md +50 -0
  247. package/templates/private-domain-workspace/atelier.project.json +28 -0
  248. package/templates/private-domain-workspace/boundary-policy.v1.json +54 -0
  249. package/templates/private-domain-workspace/domain/README.md +19 -0
  250. package/templates/private-domain-workspace/gitignore +29 -0
  251. package/templates/private-domain-workspace/governance/repo-boundary-ledger.md +4 -0
  252. package/templates/private-domain-workspace/repo-access.v1.json +9 -0
  253. package/templates/shared-project-workspace/.claude/launch.json +12 -0
  254. package/templates/shared-project-workspace/README.md +51 -0
  255. package/templates/shared-project-workspace/atelier.project.json +28 -0
  256. package/templates/shared-project-workspace/boundary-policy.v1.json +61 -0
  257. package/templates/shared-project-workspace/gitignore +29 -0
  258. package/templates/shared-project-workspace/governance/repo-boundary-ledger.md +5 -0
  259. package/templates/shared-project-workspace/project/README.md +19 -0
  260. package/templates/shared-project-workspace/repo-access.v1.json +9 -0
@@ -0,0 +1,887 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { VALID_AUDIENCES } from '../projection/policy.mjs'
4
+ import { generatedProjectionBasenames } from '../project/file-class.mjs'
5
+ import { gitIgnoreFilter } from '../project/git-ignore.mjs'
6
+
7
+ export const KNOWLEDGE_GRAPH_SCHEMA = 'mnstry.knowledge-graph@v1'
8
+ export const REPO_ACCESS_SCHEMA = 'mnstry.repo-access@v1'
9
+ export const SOURCE_SIDECAR_SCHEMA = 'mnstry.source-sidecar@v1'
10
+
11
+ export const DOC_EXTENSIONS = new Set(['.md', '.html', '.pdf', '.docx'])
12
+ export const VALID_RELATION_TYPES = new Set(['related', 'supports', 'supersedes', 'implements', 'depends_on', 'evidences', 'contradicts', 'belongs_to'])
13
+ export const VALID_STATUSES = new Set(['active', 'draft', 'archived', 'template'])
14
+ 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
+ export const VALID_SIDECAR_KG_TYPES = new Set(['html', 'pdf', 'docx', 'artifact', 'evidence', 'source', 'prototype', 'research', 'report', 'manifest'])
16
+
17
+ const SKIP_DIRS = new Set(['.git', '.agents', '.claude', '.github', 'node_modules', 'output', 'uploads', 'scripts', 'lib'])
18
+ // Derived from the kit's file-class declaration, never restated here.
19
+ const GENERATED_FILES = generatedProjectionBasenames()
20
+ const PRIVATE_AUDIENCES = new Set(['private', 'sensitive'])
21
+ const AUDIENCE_READ_RANK = {
22
+ public: 0,
23
+ team: 1,
24
+ operator: 2,
25
+ staff: 2,
26
+ private: 3,
27
+ sensitive: 3,
28
+ }
29
+
30
+ const yamlString = (value) => JSON.stringify(String(value ?? ''))
31
+ const relPath = (root, abs) => path.relative(root, abs).split(path.sep).join('/')
32
+ const posixJoin = (...parts) => parts.filter(Boolean).join('/').replace(/\/+/g, '/')
33
+
34
+ export const slug = (value) =>
35
+ String(value ?? '')
36
+ .toLowerCase()
37
+ .replace(/\.[^.]+$/, '')
38
+ .replace(/[^a-z0-9]+/g, '-')
39
+ .replace(/^-+|-+$/g, '')
40
+
41
+ export const titleCase = (value) =>
42
+ String(value ?? '')
43
+ .replace(/\.[^.]+$/, '')
44
+ .replace(/[-_]+/g, ' ')
45
+ .replace(/\s+/g, ' ')
46
+ .trim()
47
+ .replace(/\b\w/g, (m) => m.toUpperCase())
48
+
49
+ export function fallbackId(repoName, rel, ext = path.extname(rel).toLowerCase()) {
50
+ const suffix = ext && ext !== '.md' ? `-${ext.slice(1)}` : ''
51
+ return `${repoName}:${slug(rel)}${suffix}`
52
+ }
53
+
54
+ export function portableText(value) {
55
+ return String(value ?? '')
56
+ .replace(/\/Users\/[^/\s`'")]+/g, '~')
57
+ .replace(/\/home\/[^/\s`'")]+/g, '~')
58
+ .replace(/[A-Za-z]:\\Users\\[^\\\s`'")]+/g, '~')
59
+ }
60
+
61
+ export function readJsonFile(file, fallback = null) {
62
+ try {
63
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
64
+ } catch {
65
+ return fallback
66
+ }
67
+ }
68
+
69
+ export function writeJsonFile(file, value) {
70
+ fs.mkdirSync(path.dirname(file), { recursive: true })
71
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
72
+ }
73
+
74
+ function isPlainObject(value) {
75
+ return value && typeof value === 'object' && !Array.isArray(value)
76
+ }
77
+
78
+ function asString(value, fallback = '') {
79
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback
80
+ }
81
+
82
+ function asStringArray(value) {
83
+ if (!Array.isArray(value)) return []
84
+ return value.map((item) => String(item).trim()).filter(Boolean)
85
+ }
86
+
87
+ function uniqueStringArrayErrors(value, label) {
88
+ const errors = []
89
+ if (!Array.isArray(value)) return [`${label} must be a list`]
90
+ const seen = new Set()
91
+ for (const item of value) {
92
+ if (typeof item !== 'string' || !item.trim()) {
93
+ errors.push(`${label} must contain non-empty strings`)
94
+ continue
95
+ }
96
+ if (seen.has(item)) errors.push(`${label} must not contain duplicate "${item}"`)
97
+ seen.add(item)
98
+ }
99
+ return errors
100
+ }
101
+
102
+ export function splitFrontmatter(raw) {
103
+ const normalized = raw.replace(/\r\n/g, '\n')
104
+ if (!normalized.startsWith('---\n')) return null
105
+ const rest = normalized.slice(4)
106
+ const match = rest.match(/\n---\s*\n/)
107
+ if (!match) return null
108
+ const end = 4 + match.index
109
+ const closeEnd = end + match[0].length
110
+ return {
111
+ yaml: normalized.slice(4, end),
112
+ body: normalized.slice(closeEnd),
113
+ }
114
+ }
115
+
116
+ function parseYamlScalar(value) {
117
+ const trimmed = String(value ?? '').trim()
118
+ if (!trimmed) return ''
119
+ if (trimmed === '[]') return []
120
+ if (trimmed === 'true') return true
121
+ if (trimmed === 'false') return false
122
+ if (trimmed === 'null') return null
123
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
124
+ try {
125
+ return JSON.parse(trimmed)
126
+ } catch {
127
+ return trimmed.slice(1, -1)
128
+ }
129
+ }
130
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1)
131
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
132
+ return trimmed
133
+ .slice(1, -1)
134
+ .split(',')
135
+ .map((part) => parseYamlScalar(part))
136
+ .filter((part) => part !== '')
137
+ }
138
+ return trimmed
139
+ }
140
+
141
+ function nextYamlValue(lines, fromIndex, indent) {
142
+ for (let i = fromIndex + 1; i < lines.length; i += 1) {
143
+ const raw = lines[i]
144
+ if (!raw.trim() || raw.trim().startsWith('#')) continue
145
+ const nextIndent = raw.match(/^\s*/)[0].length
146
+ if (nextIndent <= indent) return {}
147
+ return raw.trim().startsWith('- ') ? [] : {}
148
+ }
149
+ return {}
150
+ }
151
+
152
+ export function parseYamlSubset(yaml) {
153
+ const root = {}
154
+ const stack = [{ indent: -1, value: root }]
155
+ const lines = yaml.replace(/\t/g, ' ').replace(/\r\n/g, '\n').split('\n')
156
+
157
+ for (let i = 0; i < lines.length; i += 1) {
158
+ const raw = lines[i]
159
+ const trimmed = raw.trim()
160
+ if (!trimmed || trimmed.startsWith('#')) continue
161
+ const indent = raw.match(/^\s*/)[0].length
162
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop()
163
+ const parent = stack[stack.length - 1].value
164
+
165
+ if (trimmed.startsWith('- ')) {
166
+ if (Array.isArray(parent)) parent.push(parseYamlScalar(trimmed.slice(2)))
167
+ continue
168
+ }
169
+
170
+ const match = trimmed.match(/^([^:]+):(.*)$/)
171
+ if (!match) continue
172
+ const key = match[1].trim()
173
+ const valuePart = match[2].trim()
174
+ if (!key || Array.isArray(parent)) continue
175
+ if (valuePart) {
176
+ parent[key] = parseYamlScalar(valuePart)
177
+ } else {
178
+ parent[key] = nextYamlValue(lines, i, indent)
179
+ stack.push({ indent, value: parent[key] })
180
+ }
181
+ }
182
+
183
+ return root
184
+ }
185
+
186
+ export function markdownMetadata(raw) {
187
+ const fm = splitFrontmatter(raw)
188
+ return fm ? parseYamlSubset(fm.yaml) : {}
189
+ }
190
+
191
+ function relationMap(value) {
192
+ if (!isPlainObject(value)) return {}
193
+ const relations = {}
194
+ for (const [key, items] of Object.entries(value)) {
195
+ relations[key] = Array.isArray(items) ? asStringArray(items) : items
196
+ }
197
+ return relations
198
+ }
199
+
200
+ function extractMarkdownTitle(raw, rel) {
201
+ const body = splitFrontmatter(raw)?.body ?? raw
202
+ const h1 = body.match(/^#\s+(.+?)\s*$/m)
203
+ return h1 ? h1[1].trim() : titleCase(path.basename(rel))
204
+ }
205
+
206
+ function extractMarkdownSummary(raw) {
207
+ const body = splitFrontmatter(raw)?.body ?? raw
208
+ for (const line of body.replace(/\r\n/g, '\n').split('\n')) {
209
+ const trimmed = line.trim()
210
+ if (!trimmed) continue
211
+ if (/^(#|---|>|```|\||[-*]\s|\d+\.)/.test(trimmed)) continue
212
+ return portableText(trimmed.replace(/\s+/g, ' ')).slice(0, 180)
213
+ }
214
+ return ''
215
+ }
216
+
217
+ function decodeHtml(value) {
218
+ return String(value ?? '')
219
+ .replaceAll('&amp;', '&')
220
+ .replaceAll('&lt;', '<')
221
+ .replaceAll('&gt;', '>')
222
+ .replaceAll('&quot;', '"')
223
+ .replaceAll('&#39;', "'")
224
+ .replaceAll('&#x27;', "'")
225
+ }
226
+
227
+ function extractHtmlMeta(abs, rel) {
228
+ const html = fs.readFileSync(abs, 'utf8')
229
+ const meta = {}
230
+ const re = /<meta\s+name="([^"]+)"\s+content="([^"]*)"\s*\/?>/gi
231
+ let m
232
+ while ((m = re.exec(html))) meta[m[1]] = decodeHtml(m[2])
233
+ const title = html.match(/<title>([^<]*)<\/title>/i)?.[1]?.trim()
234
+ return {
235
+ title: meta['atelier:title'] || decodeHtml(title || titleCase(path.basename(rel))),
236
+ summary: portableText(meta['atelier:summary'] || ''),
237
+ atelier: {
238
+ section: meta['atelier:section'] || null,
239
+ status: meta['atelier:status'] || null,
240
+ kind: meta['atelier:kind'] || null,
241
+ },
242
+ }
243
+ }
244
+
245
+ export function inferLifecycle(rel) {
246
+ const top = rel.split('/')[0] || ''
247
+ if (top === 'INBOX') return 'inbox'
248
+ if (top === 'WORK IN PROGRESS') return 'work-in-progress'
249
+ if (top === 'REFERENCES') return 'reference'
250
+ if (top === 'ARCHIVE') return 'archive'
251
+ if (top === 'research') return 'research'
252
+ if (top === 'discovery') return 'discovery'
253
+ if (top === 'DESIGN') return 'design'
254
+ if (top === 'APP') return 'app'
255
+ if (top === 'PORTALS') return 'portal'
256
+ if (top === 'WORKSPACE') return 'workspace'
257
+ if (top === 'docs') return 'docs'
258
+ return 'root'
259
+ }
260
+
261
+ export function inferDomain(_repoName, rel) {
262
+ const parts = rel.split('/')
263
+ const top = parts[0] || ''
264
+ if (top === 'DESIGN') return 'design'
265
+ if (top === 'APP') return 'app'
266
+ if (top === 'PORTALS' || top === 'WORKSPACE') return 'prototype'
267
+ if (top === 'research') return 'research'
268
+ if (top === 'discovery') return 'discovery'
269
+ if (top === 'REFERENCES') return parts[1] || 'reference'
270
+ if (top === 'runbooks' || top === 'policies' || top === 'launch' || top === 'staff') return 'operations'
271
+ return 'workstream'
272
+ }
273
+
274
+ export function inferStatus(lifecycle, rel) {
275
+ if (lifecycle === 'archive') return 'archived'
276
+ if (lifecycle === 'inbox' || lifecycle === 'work-in-progress') return 'draft'
277
+ if (/template/i.test(rel)) return 'template'
278
+ return 'active'
279
+ }
280
+
281
+ export function inferTags(repoName, rel, domain, lifecycle) {
282
+ const tags = new Set([repoName, domain, lifecycle])
283
+ for (const part of rel.split('/')) {
284
+ const partSlug = slug(part)
285
+ if (!partSlug || partSlug.length < 3) continue
286
+ if (['readme', 'index', 'authoring'].includes(partSlug)) continue
287
+ if (tags.size >= 10) break
288
+ tags.add(partSlug)
289
+ }
290
+ return [...tags].sort()
291
+ }
292
+
293
+ export function listRepos(workspaceRoot) {
294
+ return fs
295
+ .readdirSync(workspaceRoot, { withFileTypes: true })
296
+ .filter((ent) => ent.isDirectory() && !ent.name.startsWith('.'))
297
+ .map((ent) => path.join(workspaceRoot, ent.name))
298
+ .filter((dir) => fs.existsSync(path.join(dir, '.git')))
299
+ .sort()
300
+ }
301
+
302
+ // Git-ignored paths are machine-local. Counting them makes committed graph
303
+ // artifacts differ per machine, which turns every sync tick into a conflict.
304
+ export function walkDocuments(dir, root, acc = [], isIgnored = gitIgnoreFilter(root)) {
305
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
306
+ if (SKIP_DIRS.has(ent.name) || ent.name.startsWith('.')) continue
307
+ const abs = path.join(dir, ent.name)
308
+ const rel = relPath(root, abs)
309
+ if (isIgnored(rel)) continue
310
+ if (ent.isDirectory()) {
311
+ walkDocuments(abs, root, acc, isIgnored)
312
+ } else if (ent.isFile()) {
313
+ // Sidecar-first census: document extensions are always sources; any
314
+ // other file opts in by carrying an adjacent .kg.json sidecar. The
315
+ // sidecar itself is metadata, never a source asset.
316
+ if (ent.name.endsWith('.kg.json')) continue
317
+ if (GENERATED_FILES.has(ent.name)) continue
318
+ if (rel === 'index.html') continue
319
+ const ext = path.extname(ent.name).toLowerCase()
320
+ // Opt-in requires a VISIBLE sidecar. A git-ignored one is machine-local:
321
+ // it would enrol a node that exists in no tracked file, and hand it an
322
+ // audience, on one machine only. Markdown never consults a sidecar.
323
+ const sidecarVisible = ext === '.md' ? false : sidecarIsVisible(abs, root, isIgnored)
324
+ if (!DOC_EXTENSIONS.has(ext) && !sidecarVisible) continue
325
+ acc.push({ abs, rel, ext, sidecarVisible })
326
+ }
327
+ }
328
+ return acc
329
+ }
330
+
331
+ function sidecarIsVisible(abs, root, isIgnored) {
332
+ const sidecar = `${abs}.kg.json`
333
+ return fs.existsSync(sidecar) && !isIgnored(relPath(root, sidecar))
334
+ }
335
+
336
+ // Sidecars that exist on disk but are git-ignored, next to a file the census
337
+ // can see: metadata the walk refused. Reported alongside the graph (like
338
+ // orphan sidecars) and never inside it, so committed artifacts stay a function
339
+ // of tracked state alone.
340
+ export function ignoredSourceSidecars(repoName, repoRoot, isIgnored = gitIgnoreFilter(repoRoot)) {
341
+ const found = []
342
+ function visit(dir) {
343
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
344
+ if (SKIP_DIRS.has(ent.name) || ent.name.startsWith('.')) continue
345
+ const abs = path.join(dir, ent.name)
346
+ const rel = relPath(repoRoot, abs)
347
+ if (ent.isDirectory()) {
348
+ if (!isIgnored(rel)) visit(abs)
349
+ continue
350
+ }
351
+ if (!ent.isFile() || !ent.name.endsWith('.kg.json') || !isIgnored(rel)) continue
352
+ const sourceRel = rel.replace(/\.kg\.json$/, '')
353
+ if (sourceRel.toLowerCase().endsWith('.md')) continue
354
+ if (isIgnored(sourceRel) || !fs.existsSync(path.join(repoRoot, sourceRel))) continue
355
+ found.push({ repo: repoName, path: sourceRel, sidecar: rel })
356
+ }
357
+ }
358
+ visit(repoRoot)
359
+ return found
360
+ }
361
+
362
+ export function walkSidecars(dir, root, acc = [], isIgnored = gitIgnoreFilter(root)) {
363
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
364
+ if (SKIP_DIRS.has(ent.name) || ent.name.startsWith('.')) continue
365
+ const abs = path.join(dir, ent.name)
366
+ if (isIgnored(relPath(root, abs))) continue
367
+ if (ent.isDirectory()) {
368
+ walkSidecars(abs, root, acc, isIgnored)
369
+ } else if (ent.isFile() && ent.name.endsWith('.kg.json')) {
370
+ acc.push({ abs, rel: relPath(root, abs) })
371
+ }
372
+ }
373
+ return acc
374
+ }
375
+
376
+ function sidecarPath(file) {
377
+ return `${file.abs}.kg.json`
378
+ }
379
+
380
+ function sidecarMetadata(file) {
381
+ const sidecar = sidecarPath(file)
382
+ // The walk already decided whether this sidecar is visible in tracked state.
383
+ // An ignored one is treated as absent, so it can neither describe the asset
384
+ // nor satisfy the demand a document extension makes for a sidecar.
385
+ const exists = file.sidecarVisible === true
386
+ let parsed = null
387
+ let parseError = ''
388
+ if (exists) {
389
+ try {
390
+ parsed = JSON.parse(fs.readFileSync(sidecar, 'utf8'))
391
+ } catch (err) {
392
+ parseError = err instanceof Error ? err.message : String(err)
393
+ }
394
+ }
395
+ return {
396
+ sidecar,
397
+ metadata: parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {},
398
+ parseError,
399
+ present: exists,
400
+ }
401
+ }
402
+
403
+ export function validateSourceSidecar(metadata, assetFilename) {
404
+ const errors = []
405
+ if (!isPlainObject(metadata)) return ['sidecar must be a JSON object']
406
+
407
+ const allowedRoot = new Set(['schema', 'asset', 'title', 'summary', 'tags', 'kg'])
408
+ for (const key of Object.keys(metadata)) {
409
+ if (!allowedRoot.has(key)) errors.push(`sidecar has unknown property "${key}"`)
410
+ }
411
+ for (const key of allowedRoot) {
412
+ if (!Object.hasOwn(metadata, key)) errors.push(`sidecar must declare ${key}`)
413
+ }
414
+
415
+ if (metadata.schema !== SOURCE_SIDECAR_SCHEMA) errors.push(`sidecar must declare schema "${SOURCE_SIDECAR_SCHEMA}"`)
416
+ if (metadata.asset !== assetFilename) errors.push(`sidecar asset "${metadata.asset ?? ''}" must match source filename "${assetFilename}"`)
417
+ if (typeof metadata.title !== 'string' || !metadata.title.trim()) errors.push('sidecar title must be a non-empty string')
418
+ if (typeof metadata.summary !== 'string') errors.push('sidecar summary must be a string')
419
+ errors.push(...uniqueStringArrayErrors(metadata.tags, 'sidecar tags'))
420
+
421
+ if (!isPlainObject(metadata.kg)) {
422
+ errors.push('sidecar kg must be an object')
423
+ return errors
424
+ }
425
+
426
+ const allowedKg = new Set(['id', 'type', 'domain', 'lifecycle', 'status', 'audience', 'relations'])
427
+ for (const key of Object.keys(metadata.kg)) {
428
+ if (!allowedKg.has(key)) errors.push(`sidecar kg has unknown property "${key}"`)
429
+ }
430
+ for (const key of allowedKg) {
431
+ if (!Object.hasOwn(metadata.kg, key)) errors.push(`sidecar must declare kg.${key}`)
432
+ }
433
+
434
+ if (typeof metadata.kg.id !== 'string' || !/^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9:._/-]*$/.test(metadata.kg.id)) {
435
+ errors.push('sidecar kg.id must be a stable repo-prefixed id')
436
+ }
437
+ if (!VALID_SIDECAR_KG_TYPES.has(metadata.kg.type)) errors.push(`sidecar kg.type "${metadata.kg.type ?? ''}" is invalid`)
438
+ if (typeof metadata.kg.domain !== 'string' || !metadata.kg.domain.trim()) errors.push('sidecar kg.domain must be a non-empty string')
439
+ if (typeof metadata.kg.lifecycle !== 'string' || !metadata.kg.lifecycle.trim()) errors.push('sidecar kg.lifecycle must be a non-empty string')
440
+ if (!VALID_STATUSES.has(metadata.kg.status)) errors.push(`sidecar kg.status "${metadata.kg.status ?? ''}" is invalid`)
441
+ if (!VALID_AUDIENCES.has(metadata.kg.audience)) errors.push(`sidecar kg.audience "${metadata.kg.audience ?? ''}" is invalid`)
442
+ if (!isPlainObject(metadata.kg.relations)) {
443
+ errors.push('sidecar kg.relations must be an object')
444
+ } else {
445
+ for (const [type, targets] of Object.entries(metadata.kg.relations)) {
446
+ if (!VALID_RELATION_TYPES.has(type)) errors.push(`sidecar kg.relations type "${type}" is invalid`)
447
+ errors.push(...uniqueStringArrayErrors(targets, `sidecar kg.relations.${type}`))
448
+ }
449
+ }
450
+
451
+ return errors
452
+ }
453
+
454
+ export function validateRepoAccessConfig(config, repoNames, { configPath = 'repo-access config', externalRepos = [] } = {}) {
455
+ const errors = []
456
+ const external = new Set(externalRepos)
457
+ if (config?.schema !== REPO_ACCESS_SCHEMA) {
458
+ errors.push(`${configPath}: schema must be ${REPO_ACCESS_SCHEMA}`)
459
+ }
460
+ if (!VALID_AUDIENCES.has(config?.defaultReadBoundary)) {
461
+ errors.push(`${configPath}: defaultReadBoundary must be a valid local audience/read-boundary value`)
462
+ }
463
+ if (!isPlainObject(config?.repos)) {
464
+ errors.push(`${configPath}: repos must be an object`)
465
+ return errors
466
+ }
467
+
468
+ for (const [repoName, entry] of Object.entries(config.repos)) {
469
+ if (external.has(repoName)) {
470
+ errors.push(`${configPath}: repos.${repoName} is declared kind "external" and must not declare a read boundary`)
471
+ continue
472
+ }
473
+ if (!isPlainObject(entry)) {
474
+ errors.push(`${configPath}: repos.${repoName} must declare readBoundary`)
475
+ continue
476
+ }
477
+ if (!VALID_AUDIENCES.has(entry.readBoundary)) {
478
+ errors.push(`${configPath}: repos.${repoName}.readBoundary must be a valid local audience/read-boundary value`)
479
+ }
480
+ }
481
+
482
+ for (const repoName of repoNames) {
483
+ if (external.has(repoName)) continue
484
+ if (!Object.hasOwn(config.repos, repoName)) {
485
+ errors.push(
486
+ `${configPath}: repos.${repoName} must declare readBoundary, or be declared with kind "external" in the project config if it is not an Atelier-managed repo`,
487
+ )
488
+ }
489
+ }
490
+
491
+ return errors
492
+ }
493
+
494
+ export function repoReadBoundary(repoAccessConfig, repoName) {
495
+ const entry = repoAccessConfig?.repos?.[repoName]
496
+ if (typeof entry?.readBoundary === 'string' && entry.readBoundary) return entry.readBoundary
497
+ if (typeof repoAccessConfig?.defaultReadBoundary === 'string' && repoAccessConfig.defaultReadBoundary) return repoAccessConfig.defaultReadBoundary
498
+ return 'team'
499
+ }
500
+
501
+ export function loadAtelierCoverage(repoRoot) {
502
+ const manifestPath = path.join(repoRoot, 'atelier.manifest.json')
503
+ const surfaced = new Set()
504
+ const sections = new Map()
505
+ if (!fs.existsSync(manifestPath)) return { surfaced, sections }
506
+ try {
507
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
508
+ for (const sec of manifest.sections ?? []) {
509
+ for (const item of sec.items ?? []) {
510
+ if (item.source) {
511
+ surfaced.add(item.source)
512
+ sections.set(item.source, sec.id)
513
+ }
514
+ }
515
+ }
516
+ for (const doc of manifest.documents ?? []) {
517
+ if (doc.source) {
518
+ surfaced.add(doc.source)
519
+ sections.set(doc.source, doc.section || 'documents')
520
+ }
521
+ }
522
+ } catch {
523
+ return { surfaced, sections }
524
+ }
525
+ return { surfaced, sections }
526
+ }
527
+
528
+ export function generatedFrontmatter({ id, repoName, rel, title, summary, domain, lifecycle, status, audience, surfaced, tags }) {
529
+ return [
530
+ `title: ${yamlString(title)}`,
531
+ summary ? `summary: ${yamlString(summary)}` : `summary: ""`,
532
+ 'kg:',
533
+ ` id: ${yamlString(id || fallbackId(repoName, rel, '.md'))}`,
534
+ ' type: "document"',
535
+ ` repo: ${yamlString(repoName)}`,
536
+ ` path: ${yamlString(rel)}`,
537
+ ` domain: ${yamlString(domain)}`,
538
+ ` lifecycle: ${yamlString(lifecycle)}`,
539
+ ` status: ${yamlString(status)}`,
540
+ ` audience: ${yamlString(audience || 'private')}`,
541
+ ` surfaced: ${surfaced ? 'true' : 'false'}`,
542
+ ' relations:',
543
+ ' related: []',
544
+ ' supports: []',
545
+ ' supersedes: []',
546
+ 'tags:',
547
+ ...tags.map((tag) => ` - ${yamlString(tag)}`),
548
+ ].join('\n')
549
+ }
550
+
551
+ export function nodeForFile(repoName, repoRoot, coverage, file, repoAccessConfig) {
552
+ const inferredLifecycle = inferLifecycle(file.rel)
553
+ const inferredDomain = inferDomain(repoName, file.rel)
554
+ const inferredStatus = inferStatus(inferredLifecycle, file.rel)
555
+ const surfaced = coverage.surfaced.has(file.rel)
556
+ let title = titleCase(path.basename(file.rel))
557
+ let summary = ''
558
+ let metadata = {}
559
+ let sidecar = null
560
+ let hasSidecar = false
561
+ let atelier = { section: coverage.sections.get(file.rel) || null, status: null, kind: null }
562
+
563
+ if (file.ext === '.md') {
564
+ const raw = fs.readFileSync(file.abs, 'utf8')
565
+ metadata = markdownMetadata(raw)
566
+ title = asString(metadata.title, extractMarkdownTitle(raw, file.rel))
567
+ summary = asString(metadata.summary, extractMarkdownSummary(raw))
568
+ } else if (file.ext === '.html') {
569
+ const meta = extractHtmlMeta(file.abs, file.rel)
570
+ title = meta.title
571
+ summary = meta.summary
572
+ atelier = { ...atelier, ...meta.atelier }
573
+ const sidecarData = sidecarMetadata(file)
574
+ sidecar = relPath(repoRoot, sidecarData.sidecar)
575
+ hasSidecar = sidecarData.present
576
+ metadata = sidecarData.metadata
577
+ if (sidecarData.parseError) metadata = { ...metadata, __parseError: sidecarData.parseError }
578
+ if (metadata.title) title = asString(metadata.title, title)
579
+ if (metadata.summary) summary = portableText(asString(metadata.summary, summary))
580
+ } else {
581
+ const sidecarData = sidecarMetadata(file)
582
+ sidecar = relPath(repoRoot, sidecarData.sidecar)
583
+ hasSidecar = sidecarData.present
584
+ metadata = sidecarData.metadata
585
+ if (sidecarData.parseError) metadata = { ...metadata, __parseError: sidecarData.parseError }
586
+ title = asString(metadata.title, title)
587
+ summary = portableText(asString(metadata.summary, summary))
588
+ }
589
+ summary = portableText(summary)
590
+
591
+ const kg = isPlainObject(metadata.kg) ? metadata.kg : {}
592
+ const hasKgBlock = isPlainObject(metadata.kg)
593
+ const hasExplicitKgId = typeof kg.id === 'string' && kg.id.trim() !== ''
594
+ const domain = asString(kg.domain, inferredDomain)
595
+ const lifecycle = asString(kg.lifecycle, inferredLifecycle)
596
+ const status = asString(kg.status, inferredStatus)
597
+ const audience = asString(kg.audience, '')
598
+ const frontmatterHasLegacyVisibility = Object.hasOwn(kg, 'visibility')
599
+ const type = asString(kg.type, file.ext === '.md' ? 'document' : file.ext.slice(1))
600
+ const id = asString(kg.id, fallbackId(repoName, file.rel, file.ext))
601
+ const tags = asStringArray(metadata.tags)
602
+ const relations = relationMap(kg.relations)
603
+ const resolvedTags = tags.length ? tags : inferTags(repoName, file.rel, domain, lifecycle)
604
+
605
+ return {
606
+ id,
607
+ title,
608
+ summary,
609
+ repo: repoName,
610
+ path: file.rel,
611
+ extension: file.ext.slice(1),
612
+ type: file.ext === '.md' ? 'markdown' : file.ext.slice(1),
613
+ kgType: type,
614
+ domain,
615
+ lifecycle,
616
+ status,
617
+ audience,
618
+ ...(frontmatterHasLegacyVisibility ? { frontmatterHasLegacyVisibility: true } : {}),
619
+ ...(file.ext === '.md' ? { markdownHasKgBlock: hasKgBlock, markdownHasKgId: hasExplicitKgId } : {}),
620
+ ...(file.ext !== '.md'
621
+ ? {
622
+ sidecar,
623
+ sidecarSchema: asString(metadata.schema, ''),
624
+ sidecarAsset: asString(metadata.asset, ''),
625
+ sidecarSchemaErrors: hasSidecar
626
+ ? [
627
+ ...(metadata.__parseError ? [`sidecar JSON must parse: ${metadata.__parseError}`] : []),
628
+ ...validateSourceSidecar(
629
+ Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== '__parseError')),
630
+ path.basename(file.rel),
631
+ ),
632
+ ]
633
+ : [],
634
+ hasSidecar,
635
+ sidecarHasKgId: hasExplicitKgId,
636
+ }
637
+ : {}),
638
+ repoAccess: {
639
+ readBoundary: repoReadBoundary(repoAccessConfig, repoName),
640
+ },
641
+ surfaced,
642
+ atelier,
643
+ tags: resolvedTags,
644
+ relations,
645
+ }
646
+ }
647
+
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' })
666
+ }
667
+ }
668
+ return edges
669
+ }
670
+
671
+ export function declaredRelationEdges(nodes) {
672
+ const edges = []
673
+ for (const node of nodes) {
674
+ for (const [type, targets] of Object.entries(node.relations ?? {})) {
675
+ if (!Array.isArray(targets)) continue
676
+ for (const target of targets) edges.push({ source: node.id, target, type, declared: true })
677
+ }
678
+ }
679
+ return edges
680
+ }
681
+
682
+ export function uniqueEdges(edges) {
683
+ const seen = new Set()
684
+ const result = []
685
+ for (const edge of edges) {
686
+ const key = `${edge.source}\u0000${edge.target}\u0000${edge.type}\u0000${edge.declared ? 'declared' : 'derived'}`
687
+ if (seen.has(key)) continue
688
+ seen.add(key)
689
+ result.push(edge)
690
+ }
691
+ return result
692
+ }
693
+
694
+ export function graphDiagnostics(nodes) {
695
+ const diagnostics = []
696
+ for (const node of nodes) {
697
+ const audienceRank = AUDIENCE_READ_RANK[node.audience]
698
+ const readBoundary = node.repoAccess?.readBoundary
699
+ const readBoundaryRank = AUDIENCE_READ_RANK[readBoundary]
700
+ if (audienceRank == null || readBoundaryRank == null || readBoundaryRank >= audienceRank) continue
701
+ const privateOrSensitive = PRIVATE_AUDIENCES.has(node.audience) && readBoundary !== 'private'
702
+ diagnostics.push({
703
+ severity: 'warning',
704
+ type: privateOrSensitive ? 'private-repo-recommended' : 'repo-read-boundary-wider-than-audience',
705
+ node: node.id,
706
+ repo: node.repo,
707
+ path: node.path,
708
+ audience: node.audience,
709
+ repoAccess: node.repoAccess,
710
+ message: privateOrSensitive
711
+ ? `${node.repo}/${node.path}: audience ${node.audience} is in a ${readBoundary}-readable repo`
712
+ : `${node.repo}/${node.path}: audience ${node.audience} is narrower than ${readBoundary} repository read access`,
713
+ })
714
+ }
715
+ return diagnostics
716
+ }
717
+
718
+ export function activeOrphanSidecars(repoName, repoRoot, files, isIgnored = gitIgnoreFilter(repoRoot)) {
719
+ const indexedPaths = new Set(files.map((file) => file.rel))
720
+ const orphans = []
721
+ for (const sidecar of walkSidecars(repoRoot, repoRoot, [], isIgnored)) {
722
+ const sourceRel = sidecar.rel.replace(/\.kg\.json$/, '')
723
+ if (indexedPaths.has(sourceRel)) continue
724
+ if (fs.existsSync(path.join(repoRoot, sourceRel))) continue
725
+ const metadata = readJsonFile(sidecar.abs, {})
726
+ const status = asString(metadata?.kg?.status, 'active')
727
+ if (status === 'archived' || status === 'template') continue
728
+ orphans.push({ repo: repoName, path: sidecar.rel, source: sourceRel, status })
729
+ }
730
+ return orphans
731
+ }
732
+
733
+ export function validateKnowledgeGraph(nodes, edges, orphanSidecars = []) {
734
+ const errors = []
735
+ const seenIds = new Map()
736
+
737
+ for (const node of nodes) {
738
+ 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`)
741
+ }
742
+ if (node.extension !== 'md' && !node.hasSidecar) {
743
+ errors.push(`${node.repo}/${node.path}: missing non-Markdown sidecar ${node.sidecar || `${node.path}.kg.json`}`)
744
+ }
745
+ if (node.extension !== 'md' && node.hasSidecar) {
746
+ for (const schemaError of node.sidecarSchemaErrors ?? []) {
747
+ errors.push(`${node.repo}/${node.path}: sidecar ${node.sidecar} ${schemaError}`)
748
+ }
749
+ }
750
+ if (seenIds.has(node.id)) {
751
+ errors.push(`duplicate kg.id "${node.id}" in ${seenIds.get(node.id)} and ${node.repo}/${node.path}`)
752
+ } else {
753
+ seenIds.set(node.id, `${node.repo}/${node.path}`)
754
+ }
755
+ if (node.frontmatterHasLegacyVisibility) {
756
+ errors.push(`${node.repo}/${node.path}: legacy kg.visibility is reserved for runtime export; use kg.audience`)
757
+ }
758
+ if (!VALID_AUDIENCES.has(node.audience)) {
759
+ errors.push(`${node.repo}/${node.path}: invalid or missing kg.audience "${node.audience ?? ''}"`)
760
+ }
761
+ if (!VALID_STATUSES.has(node.status)) errors.push(`${node.repo}/${node.path}: invalid kg.status "${node.status}"`)
762
+ if (!VALID_KG_TYPES.has(node.kgType)) errors.push(`${node.repo}/${node.path}: invalid kg.type "${node.kgType}"`)
763
+ for (const [type, targets] of Object.entries(node.relations ?? {})) {
764
+ if (!VALID_RELATION_TYPES.has(type)) errors.push(`${node.repo}/${node.path}: invalid kg.relations type "${type}"`)
765
+ if (!Array.isArray(targets)) {
766
+ errors.push(`${node.repo}/${node.path}: kg.relations.${type} must be a list`)
767
+ continue
768
+ }
769
+ for (const target of targets) {
770
+ if (typeof target !== 'string' || !target.trim()) {
771
+ errors.push(`${node.repo}/${node.path}: kg.relations.${type} contains an empty target`)
772
+ }
773
+ }
774
+ }
775
+ }
776
+
777
+ for (const edge of edges) {
778
+ if (!edge.declared) continue
779
+ if (!seenIds.has(edge.target)) {
780
+ const sourcePath = seenIds.get(edge.source) || edge.source
781
+ errors.push(`${sourcePath}: declared ${edge.type} target "${edge.target}" was not found`)
782
+ }
783
+ }
784
+
785
+ for (const orphan of orphanSidecars) {
786
+ errors.push(`${orphan.repo}/${orphan.path}: active orphan sidecar has no adjacent source ${orphan.source}`)
787
+ }
788
+
789
+ return errors
790
+ }
791
+
792
+ export function buildKnowledgeGraph({
793
+ workspaceRoot,
794
+ repoAccessConfig,
795
+ repoRoots = null,
796
+ repoAccessConfigPath = 'repo-access config',
797
+ externalRepos = [],
798
+ } = {}) {
799
+ if (!workspaceRoot) throw new Error('workspaceRoot is required')
800
+ const resolvedWorkspaceRoot = path.resolve(workspaceRoot)
801
+ const external = new Set(externalRepos)
802
+ const discovered = repoRoots ? repoRoots.map((repoRoot) => path.resolve(repoRoot)).sort() : listRepos(resolvedWorkspaceRoot)
803
+ // External repos are acknowledged but never walked: no document census, no
804
+ // sidecar demands, no projection.
805
+ const roots = discovered.filter((repoRoot) => !external.has(path.basename(repoRoot)))
806
+ const repoNames = roots.map((repoRoot) => path.basename(repoRoot))
807
+ const accessConfig = repoAccessConfig ?? {
808
+ schema: REPO_ACCESS_SCHEMA,
809
+ defaultReadBoundary: 'team',
810
+ repos: Object.fromEntries(repoNames.map((repoName) => [repoName, { readBoundary: 'team' }])),
811
+ }
812
+ const repoAccessErrors = validateRepoAccessConfig(accessConfig, repoNames, { configPath: repoAccessConfigPath, externalRepos })
813
+ if (repoAccessErrors.length) {
814
+ return {
815
+ ok: false,
816
+ errors: repoAccessErrors,
817
+ repoGraphs: [],
818
+ workspaceGraph: null,
819
+ }
820
+ }
821
+
822
+ const workspaceNodes = []
823
+ const workspaceEdges = []
824
+ const workspaceOrphanSidecars = []
825
+ const workspaceIgnoredSidecars = []
826
+ const repoGraphs = []
827
+
828
+ for (const repoRoot of roots) {
829
+ const repoName = path.basename(repoRoot)
830
+ // One batched ignore lookup per repo, shared by every walk below.
831
+ const isIgnored = gitIgnoreFilter(repoRoot)
832
+ const files = walkDocuments(repoRoot, repoRoot, [], isIgnored)
833
+ workspaceOrphanSidecars.push(...activeOrphanSidecars(repoName, repoRoot, files, isIgnored))
834
+ workspaceIgnoredSidecars.push(...ignoredSourceSidecars(repoName, repoRoot, isIgnored))
835
+ const coverage = loadAtelierCoverage(repoRoot)
836
+ const nodes = []
837
+ const nodesByPath = new Map()
838
+
839
+ for (const file of files) {
840
+ const node = nodeForFile(repoName, repoRoot, coverage, file, accessConfig)
841
+ nodes.push(node)
842
+ nodesByPath.set(file.rel, node)
843
+ }
844
+
845
+ const edges = uniqueEdges([...markdownLinkEdges(repoRoot, nodesByPath), ...declaredRelationEdges(nodes)])
846
+ const graph = {
847
+ schema: KNOWLEDGE_GRAPH_SCHEMA,
848
+ repo: repoName,
849
+ diagnostics: graphDiagnostics(nodes),
850
+ nodes: nodes.sort((a, b) => a.path.localeCompare(b.path)),
851
+ edges: edges.sort((a, b) => `${a.source}:${a.target}:${a.type}`.localeCompare(`${b.source}:${b.target}:${b.type}`)),
852
+ }
853
+ graph.nodeCount = graph.nodes.length
854
+ graph.edgeCount = graph.edges.length
855
+
856
+ repoGraphs.push({ repoName, repoRoot, graph })
857
+ workspaceNodes.push(...graph.nodes)
858
+ workspaceEdges.push(...graph.edges)
859
+ }
860
+
861
+ const validationErrors = validateKnowledgeGraph(workspaceNodes, workspaceEdges, workspaceOrphanSidecars)
862
+ const workspaceGraph = {
863
+ schema: KNOWLEDGE_GRAPH_SCHEMA,
864
+ workspace: path.basename(resolvedWorkspaceRoot),
865
+ diagnostics: graphDiagnostics(workspaceNodes),
866
+ 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}`)),
868
+ }
869
+ workspaceGraph.nodeCount = workspaceGraph.nodes.length
870
+ workspaceGraph.edgeCount = workspaceGraph.edges.length
871
+
872
+ return {
873
+ ok: validationErrors.length === 0,
874
+ errors: validationErrors,
875
+ repoGraphs,
876
+ workspaceGraph,
877
+ orphanSidecars: workspaceOrphanSidecars,
878
+ ignoredSidecars: workspaceIgnoredSidecars,
879
+ }
880
+ }
881
+
882
+ export function writeKnowledgeGraphs({ repoGraphs = [], workspaceGraph, workspaceGraphPath }) {
883
+ for (const { repoRoot, graph } of repoGraphs) {
884
+ writeJsonFile(path.join(repoRoot, 'knowledge.graph.json'), graph)
885
+ }
886
+ if (workspaceGraphPath && workspaceGraph) writeJsonFile(workspaceGraphPath, workspaceGraph)
887
+ }