@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,557 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { spawnSync } from 'node:child_process'
4
+
5
+ export const PROJECT_CONFIG_ARG_PREFIX = '--project-config='
6
+ export const PROJECT_CONFIG_ENV = 'MNSTRY_ATELIER_PROJECT_CONFIG'
7
+ export const PROJECT_CONFIG_SCHEMA = 'mnstry.atelier-project-config@v1'
8
+ export const LOCAL_OVERLAY_SCHEMA = 'mnstry.atelier-local-overlay@v1'
9
+ export const LOCAL_OVERLAY_ENV = 'MNSTRY_ATELIER_LOCAL_CONFIG'
10
+ export const LOCAL_STATE_DIR = '.atelier-local'
11
+ export const LOCAL_OVERLAY_FILES = ['atelier.local.json', 'atelier.workspace.local.json']
12
+
13
+ // A workspace accumulates git folders that are not Atelier repos at all: vendored
14
+ // checkouts, app-builder exports, scratch clones. Declaring one `external`
15
+ // acknowledges its identity without implying a read boundary, so validation stops
16
+ // demanding metadata for content the workspace does not own. It is deliberately not
17
+ // a way to silence checks on a repo you do manage.
18
+ export const EXTERNAL_REPO_KIND = 'external'
19
+
20
+ export const isExternalRepo = (repo) => firstString(repo?.kind) === EXTERNAL_REPO_KIND
21
+
22
+ // Kept here rather than imported from repo-identity.mjs so config validation stays
23
+ // free of that module's git/provider dependencies.
24
+ export const SUPPORTED_IDENTITY_PROVIDERS = ['github']
25
+
26
+ export const asObject = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : {})
27
+
28
+ export function parseArgs(argv = process.argv.slice(2)) {
29
+ const out = { _: [] }
30
+ for (let index = 0; index < argv.length; index += 1) {
31
+ const arg = argv[index]
32
+ if (!arg.startsWith('--')) {
33
+ out._.push(arg)
34
+ continue
35
+ }
36
+ const [key, ...rest] = arg.slice(2).split('=')
37
+ if (rest.length) {
38
+ out[key] = rest.join('=')
39
+ } else if (argv[index + 1] && !argv[index + 1].startsWith('--')) {
40
+ out[key] = argv[index + 1]
41
+ index += 1
42
+ } else {
43
+ out[key] = true
44
+ }
45
+ }
46
+ return out
47
+ }
48
+
49
+ export function firstString(...values) {
50
+ for (const value of values) {
51
+ if (typeof value === 'string' && value.trim()) return value.trim()
52
+ }
53
+ return null
54
+ }
55
+
56
+ export function resolvePathValue(value, baseDir) {
57
+ if (typeof value !== 'string' || !value.trim()) return null
58
+ return path.isAbsolute(value) ? path.resolve(value) : path.resolve(baseDir, value)
59
+ }
60
+
61
+ export function readJson(file) {
62
+ try {
63
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
64
+ } catch (error) {
65
+ throw new Error(`invalid JSON at ${file}: ${error.message}`)
66
+ }
67
+ }
68
+
69
+ export function writeJson(file, value) {
70
+ fs.mkdirSync(path.dirname(file), { recursive: true })
71
+ const text = `${JSON.stringify(value, null, 2)}\n`
72
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
73
+ fs.writeFileSync(tmp, text)
74
+ fs.renameSync(tmp, file)
75
+ }
76
+
77
+ export function projectConfigArg(argv = process.argv.slice(2), prefix = PROJECT_CONFIG_ARG_PREFIX) {
78
+ for (let index = 0; index < argv.length; index += 1) {
79
+ const arg = argv[index]
80
+ if (typeof arg !== 'string') continue
81
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length).trim() || null
82
+ if ((arg === '--project-config' || arg === '--project') && argv[index + 1]) return String(argv[index + 1]).trim() || null
83
+ if (arg.startsWith('--project=')) return arg.slice('--project='.length).trim() || null
84
+ }
85
+ return null
86
+ }
87
+
88
+ export function stripProjectConfigArgs(argv = [], prefix = PROJECT_CONFIG_ARG_PREFIX) {
89
+ return argv.filter((arg) => typeof arg !== 'string' || !arg.startsWith(prefix))
90
+ }
91
+
92
+ export function readProjectConfig(configPath) {
93
+ const doc = readJson(configPath)
94
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
95
+ throw new Error(`atelier project config must be a JSON object: ${configPath}`)
96
+ }
97
+ return doc
98
+ }
99
+
100
+ function mergeLocalOverlay(target, source) {
101
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return target
102
+ target.schema = firstString(source.schema, target.schema) || LOCAL_OVERLAY_SCHEMA
103
+ target.actor = { ...asObject(target.actor), ...asObject(source.actor) }
104
+ target.harness = { ...asObject(target.harness), ...asObject(source.harness) }
105
+ target.preferences = { ...asObject(target.preferences), ...asObject(source.preferences) }
106
+ target.repoPaths = { ...asObject(target.repoPaths), ...asObject(source.repoPaths) }
107
+ target.repos = { ...asObject(target.repos), ...asObject(source.repos) }
108
+ return target
109
+ }
110
+
111
+ export function localOverlayCandidatePaths({ configDir, env = process.env, cwd = process.cwd() } = {}) {
112
+ const envPath = firstString(env[LOCAL_OVERLAY_ENV])
113
+ return [
114
+ path.join(configDir, LOCAL_STATE_DIR, 'workspace.json'),
115
+ path.join(configDir, LOCAL_STATE_DIR, 'atelier.local.json'),
116
+ ...LOCAL_OVERLAY_FILES.map((name) => path.join(configDir, name)),
117
+ envPath ? resolvePathValue(envPath, cwd) : null,
118
+ ].filter(Boolean)
119
+ }
120
+
121
+ export function readLocalOverlay({ configDir, env = process.env, cwd = process.cwd() } = {}) {
122
+ const overlay = { schema: LOCAL_OVERLAY_SCHEMA, repos: {}, repoPaths: {}, actor: {}, harness: {}, preferences: {} }
123
+ const paths = []
124
+ for (const file of localOverlayCandidatePaths({ configDir, env, cwd })) {
125
+ if (!file || !fs.existsSync(file)) continue
126
+ mergeLocalOverlay(overlay, readJson(file))
127
+ paths.push(file)
128
+ }
129
+ return { overlay, paths }
130
+ }
131
+
132
+ export function parseRepoPathOverrides(argv = process.argv.slice(2), cwd = process.cwd()) {
133
+ const out = new Map()
134
+ for (let index = 0; index < argv.length; index += 1) {
135
+ const arg = argv[index]
136
+ let value = null
137
+ if (arg === '--repo-path' && argv[index + 1]) {
138
+ value = argv[index + 1]
139
+ index += 1
140
+ } else if (typeof arg === 'string' && arg.startsWith('--repo-path=')) {
141
+ value = arg.slice('--repo-path='.length)
142
+ }
143
+ if (!value || !value.includes('=')) continue
144
+ const [name, ...rest] = value.split('=')
145
+ const repoPath = rest.join('=')
146
+ if (firstString(name) && firstString(repoPath)) out.set(name.trim(), resolvePathValue(repoPath, cwd))
147
+ }
148
+ return out
149
+ }
150
+
151
+ function overlayRepoPath(overlay, repoName) {
152
+ if (!repoName) return null
153
+ const direct = firstString(overlay.overlay?.repoPaths?.[repoName], overlay.repoPaths?.[repoName])
154
+ const repo = asObject(overlay.overlay?.repos?.[repoName] ?? overlay.repos?.[repoName])
155
+ return firstString(direct, repo.path, repo.localPath)
156
+ }
157
+
158
+ function resolveRepoPath({ repo, repoName, configDir, workspaceRoot, overlay, cliRepoPaths }) {
159
+ if (repoName && cliRepoPaths.has(repoName)) return cliRepoPaths.get(repoName)
160
+ const overlayPath = overlayRepoPath(overlay, repoName)
161
+ const fromOverlay = resolvePathValue(overlayPath, configDir)
162
+ if (fromOverlay) return fromOverlay
163
+ const fromConfig = resolvePathValue(repo.path, configDir) || resolvePathValue(repo.path, workspaceRoot)
164
+ if (fromConfig) return fromConfig
165
+ return discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot })
166
+ }
167
+
168
+ function repoPathSource({ repo, repoName, overlay, cliRepoPaths, workspaceRoot, configDir }) {
169
+ if (repoName && cliRepoPaths.has(repoName)) return 'cli'
170
+ if (overlayRepoPath(overlay, repoName)) return 'local-overlay'
171
+ if (firstString(repo.path)) return 'tracked-config'
172
+ if (discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot })) return 'sibling-discovery'
173
+ return null
174
+ }
175
+
176
+ function discoverSiblingRepoPath({ repo, repoName, configDir, workspaceRoot }) {
177
+ if (!repoName) return null
178
+ const candidates = [
179
+ path.join(workspaceRoot, repoName),
180
+ path.join(configDir, repoName),
181
+ path.join(path.dirname(configDir), repoName),
182
+ ]
183
+ for (const candidate of candidates) {
184
+ if (!fs.existsSync(candidate)) continue
185
+ if (repo.remote && !gitRemoteMatches(candidate, repo.remote)) continue
186
+ return path.resolve(candidate)
187
+ }
188
+ return null
189
+ }
190
+
191
+ function gitRemoteMatches(repoPath, expected) {
192
+ const result = spawnSync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], { encoding: 'utf8' })
193
+ if (result.status !== 0) return false
194
+ const actual = normalizeRemote(result.stdout.trim())
195
+ return actual === normalizeRemote(expected)
196
+ }
197
+
198
+ function normalizeRemote(value) {
199
+ return String(value || '')
200
+ .trim()
201
+ .replace(/^git@github\.com:/, 'https://github.com/')
202
+ .replace(/\.git$/, '')
203
+ .toLowerCase()
204
+ }
205
+
206
+ export function gitRemoteUrl(repoPath) {
207
+ if (!repoPath || !fs.existsSync(repoPath)) return null
208
+ const result = spawnSync('git', ['-C', repoPath, 'remote', 'get-url', 'origin'], { encoding: 'utf8' })
209
+ return result.status === 0 ? firstString(result.stdout) : null
210
+ }
211
+
212
+ export function remoteHost(url) {
213
+ const text = String(url ?? '').trim()
214
+ if (!text) return null
215
+ const scp = text.match(/^[^/@]+@([^:/]+):/)
216
+ if (scp) return scp[1].toLowerCase()
217
+ const parsed = text.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i)
218
+ return parsed ? parsed[1].toLowerCase() : null
219
+ }
220
+
221
+ function fallbackRoot(env, fallback) {
222
+ const config = typeof fallback === 'string' ? { value: fallback } : asObject(fallback)
223
+ return firstString(config.value, config.env ? env[config.env] : null)
224
+ }
225
+
226
+ export function resolveProjectConfig({
227
+ argv = process.argv.slice(2),
228
+ env = process.env,
229
+ cwd = process.cwd(),
230
+ configArgPrefix = PROJECT_CONFIG_ARG_PREFIX,
231
+ configEnv = PROJECT_CONFIG_ENV,
232
+ defaults = {},
233
+ } = {}) {
234
+ const argPath = projectConfigArg(argv, configArgPrefix)
235
+ const envPath = firstString(env[configEnv])
236
+ const source = argPath ? 'cli' : envPath ? 'env' : 'default'
237
+ const defaultRepoOpsRoot = fallbackRoot(env, defaults.repoOpsRoot)
238
+ const defaultWorkspaceRoot = fallbackRoot(env, defaults.workspaceRoot)
239
+ const explicitPath = argPath || envPath
240
+ const requestedPath = explicitPath || (defaultRepoOpsRoot || defaultWorkspaceRoot ? null : 'atelier.project.json')
241
+
242
+ let config = {}
243
+ let configPath = null
244
+ let configDir = cwd
245
+ let missingConfigPath = null
246
+
247
+ if (requestedPath) {
248
+ configPath = resolvePathValue(requestedPath, cwd)
249
+ if (configPath && fs.existsSync(configPath)) {
250
+ config = readProjectConfig(configPath)
251
+ configDir = path.dirname(configPath)
252
+ } else if (defaultRepoOpsRoot || defaultWorkspaceRoot) {
253
+ missingConfigPath = configPath
254
+ configPath = null
255
+ } else {
256
+ throw new Error(`atelier project config not found: ${configPath || requestedPath}`)
257
+ }
258
+ }
259
+
260
+ const roots = asObject(config.roots)
261
+ const graph = asObject(config.graph)
262
+ const projection = asObject(config.projection)
263
+ const alignment = asObject(config.alignment)
264
+ const runtime = asObject(config.runtime)
265
+ const boundaries = asObject(config.boundaries)
266
+
267
+ const workspaceRoot =
268
+ resolvePathValue(firstString(roots.workspace, roots.workspaceRoot), configDir) ||
269
+ resolvePathValue(defaultWorkspaceRoot, cwd) ||
270
+ configDir
271
+ const repoOpsRoot =
272
+ resolvePathValue(firstString(roots.repoOps, roots.repoOpsRoot), configDir) ||
273
+ resolvePathValue(defaultRepoOpsRoot, cwd) ||
274
+ workspaceRoot
275
+ const repoAccessPath =
276
+ resolvePathValue(firstString(graph.repoAccessPath, graph.repoAccessConfig), repoOpsRoot) ||
277
+ resolvePathValue(firstString(defaults.repoAccessPath), repoOpsRoot) ||
278
+ path.join(configDir, 'repo-access.v1.json')
279
+ const graphPath =
280
+ resolvePathValue(firstString(graph.outputPath, graph.workspaceGraphPath, graph.workspaceGraph), repoOpsRoot) ||
281
+ resolvePathValue(firstString(defaults.workspaceGraphPath), repoOpsRoot) ||
282
+ path.join(configDir, 'atelier-output', 'knowledge.graph.json')
283
+ const outputRoot =
284
+ resolvePathValue(firstString(projection.outputRoot, projection.root), configDir) ||
285
+ resolvePathValue(firstString(defaults.outputRoot), repoOpsRoot) ||
286
+ path.join(configDir, 'atelier-output')
287
+ const readinessPath =
288
+ resolvePathValue(firstString(projection.readinessPath, projection.readiness), configDir) ||
289
+ resolvePathValue(firstString(defaults.readinessPath), outputRoot) ||
290
+ path.join(outputRoot, 'atelier-readiness.json')
291
+ const boundaryPolicyPath =
292
+ resolvePathValue(firstString(boundaries.policyPath), configDir) ||
293
+ resolvePathValue(firstString(defaults.boundaryPolicyPath), configDir) ||
294
+ path.join(configDir, 'boundary-policy.v1.json')
295
+ const governanceLedgerPath =
296
+ resolvePathValue(firstString(boundaries.governanceLedgerPath), configDir) ||
297
+ resolvePathValue(firstString(defaults.governanceLedgerPath), configDir) ||
298
+ null
299
+ const appRepoName = firstString(alignment.appRepo, config.appRepoName, defaults.appRepoName)
300
+ const appRootValue = firstString(alignment.appRoot, config.appRoot)
301
+ const appRoot = resolvePathValue(appRootValue, workspaceRoot) || (appRepoName ? path.join(workspaceRoot, appRepoName) : null)
302
+ const alignmentRootValue = firstString(alignment.root, alignment.path, config.alignmentRoot)
303
+ const alignmentRoot =
304
+ resolvePathValue(alignmentRootValue, appRoot || workspaceRoot) ||
305
+ (appRoot && defaults.alignmentRoot ? path.join(appRoot, defaults.alignmentRoot) : null)
306
+
307
+ const localOverlay = readLocalOverlay({ configDir, env, cwd })
308
+ const cliRepoPaths = parseRepoPathOverrides(argv, cwd)
309
+ const resolved = {
310
+ schema: firstString(config.schema) || PROJECT_CONFIG_SCHEMA,
311
+ source,
312
+ config,
313
+ configPath,
314
+ configDir,
315
+ requestedPath,
316
+ missingConfigPath,
317
+ repoOpsRoot,
318
+ workspaceRoot,
319
+ repoAccessPath,
320
+ graphPath,
321
+ workspaceGraphPath: graphPath,
322
+ outputRoot,
323
+ readinessPath,
324
+ boundaryPolicyPath,
325
+ governanceLedgerPath,
326
+ strictNewRepos: boundaries.strictNewRepos === true || defaults.strictNewRepos === true,
327
+ runtimeRoot: resolvePathValue(firstString(runtime.root, runtime.mnstryRuntimeRoot), configDir),
328
+ appRepoName,
329
+ appRoot,
330
+ alignmentRoot,
331
+ localOverlay,
332
+ repos: (Array.isArray(config.repos) ? config.repos : []).map((repo) => {
333
+ const repoName = firstString(repo.name) || (repo.path ? path.basename(repo.path) : null)
334
+ const repoPath = resolveRepoPath({ repo, repoName, configDir, workspaceRoot, overlay: localOverlay.overlay, cliRepoPaths })
335
+ const external = isExternalRepo(repo)
336
+ return {
337
+ ...repo,
338
+ name: repoName || (repoPath ? path.basename(repoPath) : null),
339
+ path: repoPath,
340
+ external,
341
+ readBoundary: external ? null : firstString(repo.readBoundary) || 'team',
342
+ pathSource: repoPath ? repoPathSource({ repo, repoName, overlay: localOverlay.overlay, cliRepoPaths, workspaceRoot, configDir }) : null,
343
+ }
344
+ }),
345
+ }
346
+ resolved.localState = ensureLocalState(resolved, { write: true })
347
+ return resolved
348
+ }
349
+
350
+ export function commandProject({ argv = process.argv.slice(2), env = process.env, cwd = process.cwd() } = {}) {
351
+ const project = resolveProjectConfig({ argv, env, cwd })
352
+ // Fail closed at CLI entry, but only when a real config file was loaded; the
353
+ // defaults/no-file path resolves with an empty config that would spuriously
354
+ // fail document validation.
355
+ if (project.configPath != null) {
356
+ const configErrors = validateProjectConfigDoc(project.config)
357
+ if (configErrors.length) {
358
+ throw new Error(`invalid atelier project config at ${project.configPath}:\n${configErrors.join('\n')}`)
359
+ }
360
+ }
361
+ if (project.schema !== PROJECT_CONFIG_SCHEMA) {
362
+ throw new Error(`project config schema must be ${PROJECT_CONFIG_SCHEMA}`)
363
+ }
364
+ if (!project.repos.length) {
365
+ throw new Error('project config must declare at least one repo')
366
+ }
367
+ if (!project.repos.some((repo) => !repo.external)) {
368
+ throw new Error(`project config must declare at least one managed repo; every entry is kind "${EXTERNAL_REPO_KIND}"`)
369
+ }
370
+ return project
371
+ }
372
+
373
+ export function localStateRoot(projectOrDir) {
374
+ const configDir = typeof projectOrDir === 'string' ? projectOrDir : projectOrDir?.configDir
375
+ return path.join(configDir || process.cwd(), LOCAL_STATE_DIR)
376
+ }
377
+
378
+ function gitRootFor(dir) {
379
+ const result = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' })
380
+ return result.status === 0 ? result.stdout.trim() : null
381
+ }
382
+
383
+ function isIgnoredByGit(gitRoot, rel) {
384
+ const result = spawnSync('git', ['-C', gitRoot, 'check-ignore', '-q', rel], { encoding: 'utf8' })
385
+ return result.status === 0
386
+ }
387
+
388
+ export function ensureLocalState(project, { write = false } = {}) {
389
+ const root = localStateRoot(project)
390
+ const gitRoot = gitRootFor(project.configDir)
391
+ const rel = gitRoot ? path.relative(gitRoot, root).split(path.sep).join('/') : LOCAL_STATE_DIR
392
+ const ignored = gitRoot ? isIgnoredByGit(gitRoot, `${rel}/`) || isIgnoredByGit(gitRoot, rel) : true
393
+ const report = {
394
+ root,
395
+ ignored,
396
+ created: false,
397
+ overlayPaths: localOverlayCandidatePaths({ configDir: project.configDir }).filter((file) => fs.existsSync(file)),
398
+ warnings: [],
399
+ }
400
+ if (!ignored) {
401
+ report.warnings.push(`${LOCAL_STATE_DIR}/ is not ignored; add it to .gitignore before Atelier writes local machine state`)
402
+ return report
403
+ }
404
+ if (write && !fs.existsSync(root)) {
405
+ fs.mkdirSync(root, { recursive: true })
406
+ report.created = true
407
+ }
408
+ return report
409
+ }
410
+
411
+ export function loadRepoAccess(project) {
412
+ if (!fs.existsSync(project.repoAccessPath)) {
413
+ return { schema: 'mnstry.atelier-repo-access@v1', defaultReadBoundary: 'team', repos: {} }
414
+ }
415
+ const doc = readJson(project.repoAccessPath)
416
+ return {
417
+ defaultReadBoundary: firstString(doc.defaultReadBoundary) || 'team',
418
+ repos: asObject(doc.repos),
419
+ }
420
+ }
421
+
422
+ function unknownKeyErrors(value, label, allowed) {
423
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return []
424
+ return Object.keys(value)
425
+ .filter((key) => !allowed.has(key))
426
+ .map((key) => `${label} must not include additional property ${key}`)
427
+ }
428
+
429
+ function stringPathErrors(value, label) {
430
+ if (value == null) return []
431
+ return typeof value === 'string' && value.trim() ? [] : [`${label} must be a non-empty string path`]
432
+ }
433
+
434
+ // The contract reserves `ext` as an extension container whose members are
435
+ // namespaced objects; contents beyond that are deliberately unvalidated at v1
436
+ // and carry no authority over first-class fields. Structured consumers of an
437
+ // ext member (for example the extension-pack loader) fail closed on their own.
438
+ function extErrors(value, label) {
439
+ if (value == null) return []
440
+ if (typeof value !== 'object' || Array.isArray(value)) return [`${label} must be an object`]
441
+ return Object.entries(value)
442
+ .filter(([, member]) => !member || typeof member !== 'object' || Array.isArray(member))
443
+ .map(([key]) => `${label}.${key} must be an object; ext members are namespaced objects`)
444
+ }
445
+
446
+ export function validateProjectConfigDoc(doc, { neutralTemplate = false } = {}) {
447
+ const errors = []
448
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return ['project config must be a JSON object']
449
+ errors.push(...unknownKeyErrors(doc, '/', new Set(['schema', 'contractVersion', 'name', 'roots', 'graph', 'projection', 'alignment', 'runtime', 'boundaries', 'setup', 'repos', 'ext'])))
450
+ if (doc.schema !== PROJECT_CONFIG_SCHEMA) errors.push(`schema must be ${PROJECT_CONFIG_SCHEMA}`)
451
+ if (doc.contractVersion != null && (typeof doc.contractVersion !== 'string' || !/^1\.\d+\.\d+$/.test(doc.contractVersion))) {
452
+ errors.push('contractVersion must be a string matching 1.<minor>.<patch>')
453
+ }
454
+ errors.push(...extErrors(doc.ext, 'ext'))
455
+
456
+ const roots = asObject(doc.roots)
457
+ const graph = asObject(doc.graph)
458
+ const projection = asObject(doc.projection)
459
+ const alignment = asObject(doc.alignment)
460
+ const runtime = asObject(doc.runtime)
461
+ const boundaries = asObject(doc.boundaries)
462
+ const setup = asObject(doc.setup)
463
+ if (doc.roots != null && roots !== doc.roots) errors.push('roots must be an object')
464
+ if (doc.graph != null && graph !== doc.graph) errors.push('graph must be an object')
465
+ if (doc.projection != null && projection !== doc.projection) errors.push('projection must be an object')
466
+ if (doc.alignment != null && alignment !== doc.alignment) errors.push('alignment must be an object')
467
+ if (doc.runtime != null && runtime !== doc.runtime) errors.push('runtime must be an object')
468
+ if (doc.boundaries != null && boundaries !== doc.boundaries) errors.push('boundaries must be an object')
469
+ if (doc.setup != null && setup !== doc.setup) errors.push('setup must be an object')
470
+ const hasRepoArray = Array.isArray(doc.repos) && doc.repos.length > 0
471
+ const hasAlignmentScaffold = Boolean(firstString(alignment.appRepo) && firstString(alignment.root, alignment.path))
472
+ if (!hasRepoArray && !hasAlignmentScaffold) errors.push('repos must be a non-empty array or alignment must define appRepo and root')
473
+
474
+ errors.push(...unknownKeyErrors(roots, 'roots', new Set(['workspace', 'workspaceRoot', 'repoOps', 'repoOpsRoot', 'ext'])))
475
+ errors.push(...unknownKeyErrors(graph, 'graph', new Set(['repoAccessPath', 'repoAccessConfig', 'outputPath', 'workspaceGraphPath', 'workspaceGraph', 'externalRelationPrefixes', 'externalRelationIds', 'ext'])))
476
+ errors.push(...unknownKeyErrors(projection, 'projection', new Set(['outputRoot', 'root', 'readinessPath', 'readiness', 'ext'])))
477
+ errors.push(...unknownKeyErrors(alignment, 'alignment', new Set(['appRepo', 'appRoot', 'root', 'path', 'ext'])))
478
+ errors.push(...unknownKeyErrors(runtime, 'runtime', new Set(['root', 'mnstryRuntimeRoot', 'ext'])))
479
+ errors.push(...unknownKeyErrors(boundaries, 'boundaries', new Set(['policyPath', 'governanceLedgerPath', 'strictNewRepos', 'ext'])))
480
+ errors.push(...unknownKeyErrors(setup, 'setup', new Set(['profile', 'include', 'exclude', 'ext'])))
481
+ errors.push(...extErrors(roots.ext, 'roots.ext'))
482
+ errors.push(...extErrors(graph.ext, 'graph.ext'))
483
+ errors.push(...extErrors(projection.ext, 'projection.ext'))
484
+ errors.push(...extErrors(alignment.ext, 'alignment.ext'))
485
+ errors.push(...extErrors(runtime.ext, 'runtime.ext'))
486
+ errors.push(...extErrors(boundaries.ext, 'boundaries.ext'))
487
+ errors.push(...extErrors(setup.ext, 'setup.ext'))
488
+ errors.push(...stringPathErrors(roots.workspace, 'roots.workspace'))
489
+ errors.push(...stringPathErrors(graph.outputPath, 'graph.outputPath'))
490
+ errors.push(...stringPathErrors(graph.repoAccessPath, 'graph.repoAccessPath'))
491
+ errors.push(...stringPathErrors(graph.workspaceGraphPath, 'graph.workspaceGraphPath'))
492
+ errors.push(...stringPathErrors(projection.outputRoot, 'projection.outputRoot'))
493
+ errors.push(...stringPathErrors(projection.readinessPath, 'projection.readinessPath'))
494
+ errors.push(...stringPathErrors(alignment.root, 'alignment.root'))
495
+ errors.push(...stringPathErrors(alignment.path, 'alignment.path'))
496
+ errors.push(...stringPathErrors(alignment.appRoot, 'alignment.appRoot'))
497
+ errors.push(...stringPathErrors(boundaries.policyPath, 'boundaries.policyPath'))
498
+ errors.push(...stringPathErrors(boundaries.governanceLedgerPath, 'boundaries.governanceLedgerPath'))
499
+ if (boundaries.strictNewRepos != null && typeof boundaries.strictNewRepos !== 'boolean') {
500
+ errors.push('boundaries.strictNewRepos must be a boolean')
501
+ }
502
+ if (setup.profile != null && !['single-repo', 'private-domain', 'shared-project', 'multi-repo', 'monorepo', 'control-workspace'].includes(setup.profile)) {
503
+ errors.push('setup.profile is invalid')
504
+ }
505
+ if (alignment.appRepo != null && !firstString(alignment.appRepo)) errors.push('alignment.appRepo must be a non-empty string')
506
+
507
+ for (const [index, repo] of (Array.isArray(doc.repos) ? doc.repos : []).entries()) {
508
+ if (!repo || typeof repo !== 'object' || Array.isArray(repo)) {
509
+ errors.push(`repos[${index}] must be an object`)
510
+ continue
511
+ }
512
+ errors.push(...unknownKeyErrors(repo, `repos[${index}]`, new Set(['name', 'path', 'readBoundary', 'role', 'kind', 'remote', 'identity', 'aliases', 'required', 'ext'])))
513
+ errors.push(...extErrors(repo.ext, `repos[${index}].ext`))
514
+ if (!firstString(repo.name)) errors.push(`repos[${index}].name is required`)
515
+ if (repo.identity != null) {
516
+ const identity = asObject(repo.identity)
517
+ errors.push(...unknownKeyErrors(identity, `repos[${index}].identity`, new Set(['provider', 'id', 'ext'])))
518
+ errors.push(...extErrors(identity.ext, `repos[${index}].identity.ext`))
519
+ if (!SUPPORTED_IDENTITY_PROVIDERS.includes(identity.provider)) {
520
+ errors.push(`repos[${index}].identity.provider must be one of ${SUPPORTED_IDENTITY_PROVIDERS.join(', ')}`)
521
+ }
522
+ if (!firstString(identity.id)) errors.push(`repos[${index}].identity.id is required and must be the provider's stable id, not a name`)
523
+ }
524
+ if (repo.aliases != null) {
525
+ if (!Array.isArray(repo.aliases)) errors.push(`repos[${index}].aliases must be a list of former names`)
526
+ else if (repo.aliases.some((alias) => !firstString(alias))) errors.push(`repos[${index}].aliases must contain non-empty names`)
527
+ else if (new Set(repo.aliases.map((alias) => alias.toLowerCase())).size !== repo.aliases.length) {
528
+ errors.push(`repos[${index}].aliases must not repeat a name`)
529
+ } else if (repo.aliases.some((alias) => alias.toLowerCase() === String(repo.name ?? '').toLowerCase())) {
530
+ errors.push(`repos[${index}].aliases must not repeat the current name`)
531
+ }
532
+ }
533
+ if (repo.path != null && !firstString(repo.path)) errors.push(`repos[${index}].path must be a non-empty string when present`)
534
+ if (repo.readBoundary && !['private', 'team', 'operator', 'staff', 'public', 'sensitive'].includes(repo.readBoundary)) {
535
+ errors.push(`repos[${index}].readBoundary is invalid`)
536
+ }
537
+ if (isExternalRepo(repo) && repo.readBoundary != null) {
538
+ errors.push(`repos[${index}] is kind "${EXTERNAL_REPO_KIND}" and must not declare readBoundary; an external repo is unmanaged and implies no boundary`)
539
+ }
540
+ if (repo.required != null && typeof repo.required !== 'boolean') errors.push(`repos[${index}].required must be a boolean`)
541
+ if (repo.path && path.isAbsolute(repo.path)) errors.push(`repos[${index}].path must be relative; put machine-local absolute paths in ignored local overlay`)
542
+ }
543
+
544
+ if (neutralTemplate) {
545
+ const text = JSON.stringify(doc)
546
+ const adapterSpecificNamePattern = new RegExp(['h', 'e', 'a', 'r', 't', 'h'].join(''), 'i')
547
+ if (adapterSpecificNamePattern.test(text)) errors.push('example project config must not require adapter-specific names')
548
+ if (!firstString(roots.workspace, roots.workspaceRoot)) errors.push('example project config must define roots.workspace')
549
+ if (!firstString(roots.repoOps, roots.repoOpsRoot)) errors.push('example project config must define roots.repoOps')
550
+ if (!firstString(graph.outputPath, graph.workspaceGraphPath, graph.workspaceGraph)) errors.push('example project config must define a graph output path')
551
+ if (!firstString(graph.repoAccessPath, graph.repoAccessConfig)) errors.push('example project config must define repo access path')
552
+ if (!firstString(projection.outputRoot, projection.root) && !hasAlignmentScaffold) {
553
+ errors.push('example project config must define projection outputRoot or alignment root')
554
+ }
555
+ }
556
+ return errors
557
+ }
@@ -0,0 +1,113 @@
1
+ import { basenameOf, matchesPathPattern, normalizeRelPath } from './path-match.mjs'
2
+
3
+ export const FILE_CLASS_SCHEMA = 'mnstry.atelier-file-classes@v1'
4
+
5
+ export const SOURCE = 'source'
6
+ export const GENERATED_PROJECTION = 'generated-projection'
7
+ export const DISTRIBUTED_RUNTIME_COPY = 'distributed-runtime-copy'
8
+
9
+ export const FILE_CLASSES = Object.freeze([SOURCE, GENERATED_PROJECTION, DISTRIBUTED_RUNTIME_COPY])
10
+
11
+ // What each class means for automated handling. Sync loops, merge policies, upgrade
12
+ // tooling, and CI guards all read this instead of each keeping a list of filenames
13
+ // that drifts out of step with the others.
14
+ export const FILE_CLASS_HANDLING = Object.freeze({
15
+ [SOURCE]: Object.freeze({ rederivable: false, discardable: false, conflictsNeedHuman: true }),
16
+ [GENERATED_PROJECTION]: Object.freeze({ rederivable: true, discardable: true, conflictsNeedHuman: false }),
17
+ [DISTRIBUTED_RUNTIME_COPY]: Object.freeze({ rederivable: true, discardable: true, conflictsNeedHuman: false }),
18
+ })
19
+
20
+ // The kit's own declaration — the single place these paths are classified. A
21
+ // distributed-runtime-copy is canonical in exactly one repo role and rederivable
22
+ // everywhere else; that is why the class carries `canonicalRepoRole` rather than
23
+ // being a bare list of names. Folding runtime copies into a plain "generated" list
24
+ // makes a sync loop discard canonical source in the repo that owns it.
25
+ export const KIT_FILE_CLASSES = Object.freeze([
26
+ { pattern: '**/atelier.manifest.json', class: GENERATED_PROJECTION },
27
+ { pattern: '**/atelier-ledger.html', class: GENERATED_PROJECTION },
28
+ { pattern: '**/atelier-shell.js', class: GENERATED_PROJECTION },
29
+ { pattern: '**/knowledge.graph.json', class: GENERATED_PROJECTION },
30
+ { pattern: 'atelier-output/**', class: GENERATED_PROJECTION },
31
+ { pattern: 'atelier-readers/**', class: GENERATED_PROJECTION },
32
+ { pattern: 'atelier.lock.json', class: GENERATED_PROJECTION },
33
+ { pattern: 'atelier-runtime.lock.json', class: GENERATED_PROJECTION },
34
+ ].map(Object.freeze))
35
+
36
+ const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value)
37
+
38
+ export function validateFileClasses(entries, { label = 'fileClasses' } = {}) {
39
+ const errors = []
40
+ if (!Array.isArray(entries)) return [`${label} must be an array`]
41
+ const seen = new Set()
42
+ for (const [index, entry] of entries.entries()) {
43
+ const at = `${label}[${index}]`
44
+ if (!isObject(entry)) {
45
+ errors.push(`${at} must be an object`)
46
+ continue
47
+ }
48
+ for (const key of Object.keys(entry)) {
49
+ if (!['pattern', 'class', 'canonicalRepoRole', 'note'].includes(key)) errors.push(`${at} must not include additional property ${key}`)
50
+ }
51
+ const pattern = typeof entry.pattern === 'string' ? entry.pattern.trim() : ''
52
+ if (!pattern) errors.push(`${at}.pattern must be a non-empty string`)
53
+ else if (seen.has(pattern)) errors.push(`${at}.pattern duplicates an earlier entry: ${pattern}`)
54
+ else seen.add(pattern)
55
+ if (!FILE_CLASSES.includes(entry.class)) errors.push(`${at}.class must be one of ${FILE_CLASSES.join(', ')}`)
56
+ if (entry.class === DISTRIBUTED_RUNTIME_COPY && !(typeof entry.canonicalRepoRole === 'string' && entry.canonicalRepoRole.trim())) {
57
+ errors.push(`${at}.canonicalRepoRole is required for ${DISTRIBUTED_RUNTIME_COPY}; a copy must declare where it is canonical`)
58
+ }
59
+ if (entry.class !== DISTRIBUTED_RUNTIME_COPY && entry.canonicalRepoRole != null) {
60
+ errors.push(`${at}.canonicalRepoRole is only meaningful for ${DISTRIBUTED_RUNTIME_COPY}`)
61
+ }
62
+ }
63
+ return errors
64
+ }
65
+
66
+ /**
67
+ * Resolve the class of a path for a given repo role.
68
+ *
69
+ * Later entries win, as in .gitignore, so an adopter can narrow a kit default by
70
+ * appending a more specific pattern.
71
+ *
72
+ * A distributed-runtime-copy resolves to `source` in the repo role that owns it and
73
+ * stays a copy everywhere else. That role-dependence is the whole point: the same
74
+ * path is canonical in one repo and freely rederivable in its consumers.
75
+ */
76
+ export function classifyPath(filePath, { repoRole = null, fileClasses = KIT_FILE_CLASSES } = {}) {
77
+ const rel = normalizeRelPath(filePath)
78
+ let matched = null
79
+ for (const entry of fileClasses) {
80
+ if (matchesPathPattern(entry.pattern, rel)) matched = entry
81
+ }
82
+ if (!matched) {
83
+ return { path: rel, class: SOURCE, pattern: null, canonicalRepoRole: null, canonicalHere: true, declared: false, handling: FILE_CLASS_HANDLING[SOURCE] }
84
+ }
85
+ const canonicalHere = matched.class === DISTRIBUTED_RUNTIME_COPY ? repoRole === matched.canonicalRepoRole : matched.class === SOURCE
86
+ const resolved = matched.class === DISTRIBUTED_RUNTIME_COPY && canonicalHere ? SOURCE : matched.class
87
+ return {
88
+ path: rel,
89
+ class: resolved,
90
+ declaredClass: matched.class,
91
+ pattern: matched.pattern,
92
+ canonicalRepoRole: matched.canonicalRepoRole ?? null,
93
+ canonicalHere,
94
+ declared: true,
95
+ handling: FILE_CLASS_HANDLING[resolved],
96
+ }
97
+ }
98
+
99
+ export function createPathClassifier({ repoRole = null, fileClasses = KIT_FILE_CLASSES } = {}) {
100
+ return (filePath) => classifyPath(filePath, { repoRole, fileClasses })
101
+ }
102
+
103
+ // Basenames of projections a walker can skip outright, derived from the declaration
104
+ // rather than restated next to it.
105
+ export function generatedProjectionBasenames(fileClasses = KIT_FILE_CLASSES) {
106
+ const names = new Set()
107
+ for (const entry of fileClasses) {
108
+ if (entry.class !== GENERATED_PROJECTION) continue
109
+ const base = basenameOf(entry.pattern)
110
+ if (base && !base.includes('*')) names.add(base)
111
+ }
112
+ return names
113
+ }