@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,806 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { buildGraph } from '../graph/graph.mjs'
5
+ import { commandProject, firstString, parseArgs, readJson, resolvePathValue, writeJson } from '../project/config.mjs'
6
+ import { matchesPathPattern } from '../project/path-match.mjs'
7
+ import {
8
+ diffForPushRange,
9
+ parseAddedContent,
10
+ parsePushRefUpdates,
11
+ resolveContentRules,
12
+ scanAddedContent,
13
+ scanTree,
14
+ stagedDiff,
15
+ validateContentRuleExceptions,
16
+ validateContentRules,
17
+ } from './content-rules.mjs'
18
+
19
+ export const BOUNDARY_POLICY_SCHEMA = 'mnstry.atelier-boundary-policy@v1'
20
+ export const PROMOTE_EVENT_SCHEMA = 'git-promote-event@v1'
21
+ // Promote events append to a durable governance ledger; lines written before the
22
+ // contract-stability epoch carry the legacy schema id and must stay readable.
23
+ export const LEGACY_PROMOTE_EVENT_SCHEMAS = new Set(['mnstry.git-promote-event@v1'])
24
+ export const VALID_BOUNDARY_MODES = new Set(['strict', 'legacy-warning'])
25
+ export const VALID_REPO_KINDS = new Set(['private_domain', 'shared', 'generated', 'archive'])
26
+ export const VALID_AUDIENCES = new Set(['private', 'team', 'operator', 'staff', 'public', 'sensitive'])
27
+ export const VALID_AUTOCOMMIT = new Set(['allowed', 'blocked', 'guarded'])
28
+ export const PRIVATE_AUDIENCES = new Set(['private', 'sensitive'])
29
+
30
+ export const DEFAULT_FORBIDDEN_PATHS = [
31
+ '.atelier-local',
32
+ '.atelier-local/**',
33
+ 'atelier.local.json',
34
+ 'atelier.workspace.local.json',
35
+ '.mnstry-local',
36
+ '.mnstry-local/**',
37
+ '.atelier-proposals',
38
+ '.atelier-proposals/**',
39
+ '.atelier-current',
40
+ '.atelier-current.json',
41
+ '.atelier-presence.json',
42
+ '.atelier-nonce',
43
+ '.atelier-capability-grants.json',
44
+ '.atelier-events.jsonl',
45
+ '.atelier-audit.jsonl',
46
+ '.atelier-session.json',
47
+ '.atelier-session.jsonl',
48
+ '.atelier-sessions/**',
49
+ '.atelier-support',
50
+ '.atelier-support/**',
51
+ 'support-bundle',
52
+ 'support-bundle/**',
53
+ 'support-bundles/**',
54
+ 'support-bundle.*',
55
+ '*support-bundle*',
56
+ 'transcripts/**',
57
+ '**/transcripts/**',
58
+ 'prompts/**',
59
+ '**/prompts/**',
60
+ ]
61
+
62
+ const SEMANTIC_FIELD_RE = /(^|["'\s.-])(kg[.]audience|audience|handling|sensitivity|data_boundary)(["'\s:.-]|$)/i
63
+ const SEMANTIC_ASSIGNMENT_RE = /(?:^|[^\w.])["']?(kg\.audience|audience|handling|sensitivity|data_boundary)["']?\s*:\s*(.*)$/i
64
+ const REVIEW_MARKER_RE = /(Atelier-Boundary-Review|boundary-review)\s*:\s*(approved|reviewed)/i
65
+
66
+ // Initializing a boundary field to a value that discloses nothing is not a disclosure
67
+ // decision, so the gate lets it through unreviewed. Widening, narrowing, or removing an
68
+ // existing value still needs a human. Keeping this in sync with the fail-closed default
69
+ // written by generatedFrontmatter() is what stops the kit's own apply tooling from
70
+ // producing changes the kit's own gate refuses.
71
+ export const SEMANTIC_INITIALIZATION_DEFAULTS = Object.freeze({
72
+ audience: Object.freeze([...PRIVATE_AUDIENCES].sort()),
73
+ })
74
+
75
+ const managedRepos = (project) => (project?.repos ?? []).filter((repo) => !repo.external)
76
+
77
+ const normalize = (value) => String(value ?? '').replaceAll('\\', '/').replace(/^\.\/+/, '')
78
+ const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value)
79
+ const asArray = (value) => (Array.isArray(value) ? value : [])
80
+ const unique = (values) => [...new Set(values.filter(Boolean))]
81
+
82
+ export function projectBoundaryPolicyPath(project) {
83
+ const boundaries = project.config?.boundaries && typeof project.config.boundaries === 'object' ? project.config.boundaries : {}
84
+ return (
85
+ resolvePathValue(firstString(boundaries.policyPath), project.configDir) ||
86
+ resolvePathValue(firstString(project.config?.boundaryPolicyPath), project.configDir) ||
87
+ path.join(project.configDir, 'boundary-policy.v1.json')
88
+ )
89
+ }
90
+
91
+ export function projectGovernanceLedgerPath(project, policy = null) {
92
+ const boundaries = project.config?.boundaries && typeof project.config.boundaries === 'object' ? project.config.boundaries : {}
93
+ return (
94
+ resolvePathValue(firstString(boundaries.governanceLedgerPath), project.configDir) ||
95
+ resolvePathValue(firstString(policy?.governanceLedgerPath), project.configDir) ||
96
+ null
97
+ )
98
+ }
99
+
100
+ export function loadBoundaryPolicy(project, policyPath = projectBoundaryPolicyPath(project)) {
101
+ if (!fs.existsSync(policyPath)) {
102
+ return { ok: false, policy: null, policyPath, errors: [`boundary policy not found: ${policyPath}`] }
103
+ }
104
+ try {
105
+ return { ok: true, policy: readJson(policyPath), policyPath, errors: [] }
106
+ } catch (error) {
107
+ return { ok: false, policy: null, policyPath, errors: [error.message] }
108
+ }
109
+ }
110
+
111
+ function unknownKeys(value, label, allowed) {
112
+ if (!isObject(value)) return []
113
+ return Object.keys(value)
114
+ .filter((key) => !allowed.has(key))
115
+ .map((key) => `${label} must not include additional property ${key}`)
116
+ }
117
+
118
+ function validAudienceList(value, label, required = false) {
119
+ if (value == null && !required) return []
120
+ if (!Array.isArray(value)) return [`${label} must be a list`]
121
+ const errors = []
122
+ for (const item of value) {
123
+ if (!VALID_AUDIENCES.has(item)) errors.push(`${label} contains invalid audience ${item}`)
124
+ }
125
+ return errors
126
+ }
127
+
128
+ export function validateBoundaryPolicy(policy, project = null) {
129
+ const errors = []
130
+ if (!isObject(policy)) return ['boundary policy must be a JSON object']
131
+ errors.push(...unknownKeys(policy, '/', new Set(['schema', 'mode', 'actors', 'repos', 'promotion', 'forbiddenPaths', 'contentRules', 'contentRuleExceptions', 'governanceLedgerPath'])))
132
+ if (policy.schema !== BOUNDARY_POLICY_SCHEMA) errors.push(`schema must be ${BOUNDARY_POLICY_SCHEMA}`)
133
+ if (!VALID_BOUNDARY_MODES.has(policy.mode)) errors.push('mode must be strict or legacy-warning')
134
+
135
+ const actors = policy.actors
136
+ const repos = policy.repos
137
+ const promotion = isObject(policy.promotion) ? policy.promotion : {}
138
+ if (!isObject(actors)) errors.push('actors must be an object')
139
+ if (!isObject(repos)) errors.push('repos must be an object')
140
+ if (policy.promotion != null && !isObject(policy.promotion)) errors.push('promotion must be an object')
141
+ errors.push(...unknownKeys(promotion, 'promotion', new Set(['requiresGitPromote', 'recordsPath'])))
142
+ if (policy.forbiddenPaths != null && !Array.isArray(policy.forbiddenPaths)) errors.push('forbiddenPaths must be a list')
143
+ if (policy.contentRules != null) errors.push(...validateContentRules(policy.contentRules))
144
+ if (policy.contentRuleExceptions != null) {
145
+ errors.push(...validateContentRuleExceptions(policy.contentRuleExceptions, resolveContentRules(policy)))
146
+ const declaredRepos = isObject(repos) ? repos : {}
147
+ for (const [index, exception] of policy.contentRuleExceptions.entries()) {
148
+ const name = firstString(exception?.repo)
149
+ if (name && Object.keys(declaredRepos).length && !declaredRepos[name]) {
150
+ errors.push(`contentRuleExceptions[${index}].repo ${name} is not declared in repos`)
151
+ }
152
+ }
153
+ }
154
+
155
+ for (const [actorId, actor] of Object.entries(isObject(actors) ? actors : {})) {
156
+ errors.push(...unknownKeys(actor, `actors.${actorId}`, new Set(['githubLogin', 'gitEmails', 'privateDomainRepo'])))
157
+ if (!isObject(actor)) {
158
+ errors.push(`actors.${actorId} must be an object`)
159
+ continue
160
+ }
161
+ if (!firstString(actor.githubLogin) && !asArray(actor.gitEmails).length) {
162
+ errors.push(`actors.${actorId} must declare githubLogin or gitEmails`)
163
+ }
164
+ if (!firstString(actor.privateDomainRepo)) errors.push(`actors.${actorId}.privateDomainRepo is required`)
165
+ if (firstString(actor.privateDomainRepo) && isObject(repos)) {
166
+ const privateRepo = repos[actor.privateDomainRepo]
167
+ if (!privateRepo) {
168
+ errors.push(`actors.${actorId}.privateDomainRepo ${actor.privateDomainRepo} is not declared in repos`)
169
+ } else if (privateRepo.kind !== 'private_domain') {
170
+ errors.push(`actors.${actorId}.privateDomainRepo ${actor.privateDomainRepo} must reference a private_domain repo`)
171
+ }
172
+ }
173
+ for (const email of asArray(actor.gitEmails)) {
174
+ if (typeof email !== 'string' || !email.includes('@')) errors.push(`actors.${actorId}.gitEmails must contain email strings`)
175
+ }
176
+ }
177
+
178
+ for (const [repoName, repo] of Object.entries(isObject(repos) ? repos : {})) {
179
+ errors.push(...unknownKeys(repo, `repos.${repoName}`, new Set(['kind', 'ownerActor', 'readBoundary', 'allowedAudiences', 'forbiddenAudiences', 'autoCommit'])))
180
+ if (!isObject(repo)) {
181
+ errors.push(`repos.${repoName} must be an object`)
182
+ continue
183
+ }
184
+ if (!VALID_REPO_KINDS.has(repo.kind)) errors.push(`repos.${repoName}.kind is invalid`)
185
+ if (!VALID_AUDIENCES.has(repo.readBoundary)) errors.push(`repos.${repoName}.readBoundary is invalid`)
186
+ if (!VALID_AUTOCOMMIT.has(repo.autoCommit)) errors.push(`repos.${repoName}.autoCommit is invalid`)
187
+ errors.push(...validAudienceList(repo.allowedAudiences, `repos.${repoName}.allowedAudiences`, true))
188
+ errors.push(...validAudienceList(repo.forbiddenAudiences, `repos.${repoName}.forbiddenAudiences`))
189
+ if (repo.kind === 'private_domain') {
190
+ if (!firstString(repo.ownerActor)) errors.push(`repos.${repoName}.ownerActor is required for private_domain repos`)
191
+ if (repo.ownerActor && isObject(actors) && !actors[repo.ownerActor]) errors.push(`repos.${repoName}.ownerActor ${repo.ownerActor} is not declared`)
192
+ }
193
+ }
194
+
195
+ if (project?.repos?.length && isObject(repos)) {
196
+ for (const repo of project.repos) {
197
+ if (repo.external) {
198
+ // Declaring a boundary for a repo nobody manages is the confusion the
199
+ // external kind exists to remove.
200
+ if (repos[repo.name]) errors.push(`policy repos.${repo.name} must not be declared; it is an external (unmanaged) repo`)
201
+ continue
202
+ }
203
+ if (!repos[repo.name]) errors.push(`policy repos.${repo.name} must be declared`)
204
+ }
205
+ }
206
+
207
+ return errors
208
+ }
209
+
210
+ export function resolveCurrentActor({ policy, project, actor = null, env = process.env } = {}) {
211
+ const actors = policy?.actors ?? {}
212
+ const explicit = actor || env.MNSTRY_ATELIER_ACTOR || env.GITHUB_ACTOR
213
+ if (explicit && actors[explicit]) return { actorId: explicit, source: 'explicit' }
214
+ const gitEmails = gitEmailsForProject(project)
215
+ for (const [actorId, info] of Object.entries(actors)) {
216
+ const actorEmails = new Set(asArray(info.gitEmails).map((email) => email.toLowerCase()))
217
+ if (gitEmails.some((email) => actorEmails.has(email.toLowerCase()))) return { actorId, source: 'git-email', gitEmails }
218
+ }
219
+ const login = env.GITHUB_ACTOR || ghLogin()
220
+ if (login) {
221
+ for (const [actorId, info] of Object.entries(actors)) {
222
+ if (String(info.githubLogin || '').toLowerCase() === String(login).toLowerCase()) return { actorId, source: 'github-login', githubLogin: login }
223
+ }
224
+ }
225
+ return { actorId: null, source: 'unverified', gitEmails, githubLogin: login || null }
226
+ }
227
+
228
+ function gitEmailsForProject(project) {
229
+ const roots = unique([project?.repoOpsRoot, project?.workspaceRoot, ...managedRepos(project).map((repo) => repo.path)])
230
+ const emails = []
231
+ for (const root of roots) {
232
+ if (!root || !fs.existsSync(root)) continue
233
+ const result = spawnSync('git', ['-C', root, 'config', 'user.email'], { encoding: 'utf8' })
234
+ if (result.status === 0 && result.stdout.trim()) emails.push(result.stdout.trim())
235
+ }
236
+ return unique(emails)
237
+ }
238
+
239
+ function ghLogin() {
240
+ try {
241
+ return execFileSync('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
242
+ } catch {
243
+ return null
244
+ }
245
+ }
246
+
247
+ function repoPolicy(policy, repoName) {
248
+ return policy?.repos?.[repoName] ?? null
249
+ }
250
+
251
+ function finding({ severity = 'error', code, message, repo = null, path: itemPath = null, node = null, details = {} }) {
252
+ return { severity, code, message, repo, path: itemPath, node, details }
253
+ }
254
+
255
+ function severityFor(policy) {
256
+ return policy?.mode === 'legacy-warning' ? 'warning' : 'error'
257
+ }
258
+
259
+ function nodePlacementFindings({ node, policy }) {
260
+ const findings = []
261
+ const repo = repoPolicy(policy, node.repo)
262
+ const severity = severityFor(policy)
263
+ if (!repo) {
264
+ findings.push(finding({ severity, code: 'repo-policy-missing', repo: node.repo, path: node.path, node: node.id, message: `${node.repo} is missing boundary policy coverage` }))
265
+ return findings
266
+ }
267
+ if (!repo.allowedAudiences?.includes(node.audience)) {
268
+ findings.push(finding({ severity, code: 'audience-not-allowed-in-repo', repo: node.repo, path: node.path, node: node.id, message: `${node.repo}/${node.path}: audience ${node.audience} is not allowed in ${repo.kind} repo ${node.repo}` }))
269
+ }
270
+ if (repo.forbiddenAudiences?.includes(node.audience)) {
271
+ findings.push(finding({ severity, code: 'audience-forbidden-in-repo', repo: node.repo, path: node.path, node: node.id, message: `${node.repo}/${node.path}: audience ${node.audience} is forbidden in ${repo.kind} repo ${node.repo}` }))
272
+ }
273
+ if (PRIVATE_AUDIENCES.has(node.audience) && repo.kind !== 'private_domain') {
274
+ findings.push(finding({ severity, code: 'private-audience-in-shared-repo', repo: node.repo, path: node.path, node: node.id, message: `${node.repo}/${node.path}: ${node.audience} material must live in a private domain repo, not ${repo.kind}` }))
275
+ }
276
+ return findings
277
+ }
278
+
279
+ function actorFindings({ policy, project, actor }) {
280
+ const findings = []
281
+ const current = resolveCurrentActor({ policy, project, actor })
282
+ const severity = severityFor(policy)
283
+ for (const [repoName, repo] of Object.entries(policy.repos ?? {})) {
284
+ if (repo.kind !== 'private_domain') continue
285
+ if (!repo.ownerActor) continue
286
+ if (!current.actorId) {
287
+ findings.push(finding({ severity, code: 'private-domain-actor-unverified', repo: repoName, message: `${repoName}: could not verify local actor for private domain repo owned by ${repo.ownerActor}` }))
288
+ } else if (current.actorId !== repo.ownerActor) {
289
+ findings.push(finding({ severity, code: 'private-domain-actor-mismatch', repo: repoName, message: `${repoName}: private domain repo is owned by ${repo.ownerActor}, but current actor is ${current.actorId}` }))
290
+ }
291
+ }
292
+ return findings
293
+ }
294
+
295
+ export function stagedPathsForProject(project) {
296
+ const paths = []
297
+ for (const repo of managedRepos(project)) {
298
+ if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
299
+ const result = spawnSync('git', ['-C', repo.path, 'diff', '--cached', '--name-only', '--diff-filter=ACMR'], { encoding: 'utf8' })
300
+ if (result.status !== 0) continue
301
+ for (const rel of result.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) {
302
+ paths.push({ repo: repo.name, repoRoot: repo.path, path: normalize(rel) })
303
+ }
304
+ }
305
+ return paths
306
+ }
307
+
308
+ function forbiddenPathFindings({ policy, stagedPaths }) {
309
+ const patterns = [...DEFAULT_FORBIDDEN_PATHS, ...asArray(policy.forbiddenPaths)]
310
+ const findings = []
311
+ const severity = severityFor(policy)
312
+ for (const item of stagedPaths) {
313
+ const matched = patterns.find((pattern) => matchesPathPattern(pattern, item.path))
314
+ if (matched) {
315
+ findings.push(finding({ severity, code: 'forbidden-path-staged', repo: item.repo, path: item.path, message: `${item.repo}/${item.path}: staged path is protected by boundary policy pattern ${matched}` }))
316
+ }
317
+ }
318
+ return findings
319
+ }
320
+
321
+ function unquoteValue(value) {
322
+ const text = String(value ?? '')
323
+ .trim()
324
+ .replace(/,$/, '')
325
+ .trim()
326
+ const quoted = text.match(/^(["'])(.*)\1$/)
327
+ return quoted ? quoted[2] : text
328
+ }
329
+
330
+ export function semanticFieldOnLine(line) {
331
+ if (!SEMANTIC_FIELD_RE.test(line)) return null
332
+ const assignment = line.match(SEMANTIC_ASSIGNMENT_RE)
333
+ if (assignment) {
334
+ return { field: assignment[1].toLowerCase().replace(/^kg\./, ''), value: unquoteValue(assignment[2]), raw: line.trim() }
335
+ }
336
+ // A boundary field is always declared as `field: value` in front matter and in sidecar
337
+ // JSON. A bare mention with no colon is prose, not a declaration.
338
+ if (!line.includes(':')) return null
339
+ return { field: null, value: null, raw: line.trim() }
340
+ }
341
+
342
+ function isInitializationDefault(field, value) {
343
+ const defaults = SEMANTIC_INITIALIZATION_DEFAULTS[field]
344
+ return Array.isArray(defaults) && defaults.includes(value)
345
+ }
346
+
347
+ export function diffFileSections(diff) {
348
+ const files = []
349
+ let current = null
350
+ let hunk = null
351
+ for (const line of String(diff ?? '').split('\n')) {
352
+ if (line.startsWith('diff --git ')) {
353
+ const header = line.match(/^diff --git a\/(.*) b\/(.*)$/)
354
+ current = { path: header ? normalize(header[2]) : null, hunks: [], lines: [] }
355
+ files.push(current)
356
+ hunk = null
357
+ continue
358
+ }
359
+ if (!current) continue
360
+ current.lines.push(line)
361
+ if (line.startsWith('@@')) {
362
+ hunk = { added: [], removed: [] }
363
+ current.hunks.push(hunk)
364
+ continue
365
+ }
366
+ if (!hunk) continue
367
+ if (/^\+(?!\+)/.test(line)) hunk.added.push(line.slice(1))
368
+ else if (/^-(?!-)/.test(line)) hunk.removed.push(line.slice(1))
369
+ }
370
+ return files
371
+ }
372
+
373
+ export function semanticChangesInFile(file) {
374
+ const changes = []
375
+ for (const hunk of file.hunks ?? []) {
376
+ const pending = hunk.removed.map(semanticFieldOnLine).filter(Boolean)
377
+ for (const entry of hunk.added.map(semanticFieldOnLine).filter(Boolean)) {
378
+ const index = pending.findIndex((item) => item.field === entry.field)
379
+ const prior = index === -1 ? null : pending.splice(index, 1)[0]
380
+ if (prior) {
381
+ changes.push(entry.field ? `${entry.field} changed from "${prior.value}" to "${entry.value}"` : `boundary line changed to "${entry.raw}"`)
382
+ } else if (!entry.field || !isInitializationDefault(entry.field, entry.value)) {
383
+ changes.push(entry.field ? `${entry.field} introduced as "${entry.value}"` : `boundary line introduced: "${entry.raw}"`)
384
+ }
385
+ }
386
+ for (const entry of pending) {
387
+ changes.push(entry.field ? `${entry.field} removed (was "${entry.value}")` : `boundary line removed: "${entry.raw}"`)
388
+ }
389
+ }
390
+ return changes
391
+ }
392
+
393
+ function semanticDiffFindings({ policy, project }) {
394
+ const findings = []
395
+ const severity = severityFor(policy)
396
+ for (const repo of managedRepos(project)) {
397
+ if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
398
+ const result = spawnSync('git', ['-C', repo.path, 'diff', '--cached', '--unified=0', '--', '*.md', '*.kg.json'], { encoding: 'utf8' })
399
+ if (result.status !== 0 || !result.stdout.trim()) continue
400
+ for (const file of diffFileSections(result.stdout)) {
401
+ const changes = semanticChangesInFile(file)
402
+ if (!changes.length) continue
403
+ // The marker has to travel in the same file's diff as the change it approves —
404
+ // a marker elsewhere in the commit approves nothing.
405
+ if (REVIEW_MARKER_RE.test(file.lines.join('\n'))) continue
406
+ const label = `${repo.name}/${file.path ?? '(unknown path)'}`
407
+ findings.push(
408
+ finding({
409
+ severity,
410
+ code: 'semantic-field-change-needs-review',
411
+ repo: repo.name,
412
+ path: file.path,
413
+ message: `${label}: ${changes.join('; ')} — add an Atelier-Boundary-Review: approved marker to this file's diff`,
414
+ details: { changes },
415
+ }),
416
+ )
417
+ }
418
+ }
419
+ return findings
420
+ }
421
+
422
+ function contentRuleFindings({ policy, project, files }) {
423
+ const rules = resolveContentRules(policy)
424
+ const exceptions = asArray(policy?.contentRuleExceptions)
425
+ const findings = []
426
+ for (const entry of files) {
427
+ for (const item of scanAddedContent({ files: entry.files, rules, exceptions, repo: entry.repo })) {
428
+ // The rule id and line stay on the finding: they are what a person needs to
429
+ // either fix the line or write the exception.
430
+ findings.push({ ...finding({ ...item, details: { rule: item.rule, line: item.line } }), rule: item.rule, line: item.line })
431
+ }
432
+ }
433
+ return findings
434
+ }
435
+
436
+ function stagedContentFindings({ policy, project }) {
437
+ const files = managedRepos(project)
438
+ .filter((repo) => repo.path && fs.existsSync(path.join(repo.path, '.git')))
439
+ .map((repo) => ({ repo: repo.name, files: parseAddedContent(stagedDiff(repo.path)) }))
440
+ return contentRuleFindings({ policy, project, files })
441
+ }
442
+
443
+ function realPath(value) {
444
+ try {
445
+ return fs.realpathSync(path.resolve(value))
446
+ } catch {
447
+ return path.resolve(value)
448
+ }
449
+ }
450
+
451
+ // Matched through realpath: a workspace reached via a symlink (a macOS temp dir, a
452
+ // linked checkout) otherwise fails to match its own configured path, and the guard
453
+ // silently judges nothing.
454
+ function repoForCwd(project, cwd) {
455
+ const resolved = realPath(cwd)
456
+ return (
457
+ managedRepos(project).find((repo) => {
458
+ if (!repo.path) return false
459
+ const repoPath = realPath(repo.path)
460
+ return resolved === repoPath || resolved.startsWith(`${repoPath}${path.sep}`)
461
+ }) ?? null
462
+ )
463
+ }
464
+
465
+ /**
466
+ * Judge only what is being pushed. The whole-tree view lives in `boundary audit`,
467
+ * which reports without blocking, so an accepted usage elsewhere in the repo can
468
+ * never strand unrelated work on the machine.
469
+ */
470
+ export function checkPushContent({ project, policy, repo, updates, cwd = process.cwd() } = {}) {
471
+ const target = repo ?? repoForCwd(project, cwd)
472
+ if (!target) {
473
+ // Fail closed. This hook is only installed into managed repos, so failing to
474
+ // identify one means the guard would judge nothing — a silent no-op is the
475
+ // failure mode this whole guard exists to avoid.
476
+ const unresolved = finding({
477
+ code: 'push-check-repo-unresolved',
478
+ repo: null,
479
+ message: `${cwd} is not a repo declared in ${project?.configPath ?? 'the project config'}; the push guard cannot tell what it is judging`,
480
+ })
481
+ return { ok: false, repo: null, findings: [unresolved], errors: [unresolved], warnings: [] }
482
+ }
483
+ const files = []
484
+ for (const update of updates) files.push(...parseAddedContent(diffForPushRange(target.path, update)))
485
+ const findings = contentRuleFindings({ policy, project, files: [{ repo: target.name, files }] })
486
+ const errors = findings.filter((item) => item.severity === 'error')
487
+ return { ok: errors.length === 0, repo: target.name, updates, findings, errors, warnings: findings.filter((item) => item.severity !== 'error') }
488
+ }
489
+
490
+ export function auditContentRules({ project, policy } = {}) {
491
+ const rules = resolveContentRules(policy)
492
+ const exceptions = asArray(policy?.contentRuleExceptions)
493
+ const findings = []
494
+ for (const repo of managedRepos(project)) {
495
+ if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
496
+ findings.push(...scanTree(repo.path, { rules, exceptions, repo: repo.name }))
497
+ }
498
+ const accepted = []
499
+ for (const repo of managedRepos(project)) {
500
+ for (const exception of exceptions) {
501
+ if (firstString(exception?.repo) !== repo.name) continue
502
+ accepted.push({ repo: repo.name, rule: exception.rule, paths: exception.paths, reason: exception.reason })
503
+ }
504
+ }
505
+ return { schema: BOUNDARY_POLICY_SCHEMA, findings, accepted }
506
+ }
507
+
508
+ function promotionRecords(project, policy) {
509
+ const recordsPath = firstString(policy?.promotion?.recordsPath)
510
+ if (!recordsPath) return []
511
+ const abs = resolvePathValue(recordsPath, project.configDir)
512
+ if (!abs || !fs.existsSync(abs)) return []
513
+ return fs
514
+ .readFileSync(abs, 'utf8')
515
+ .split('\n')
516
+ .map((line) => line.trim())
517
+ .filter(Boolean)
518
+ .map((line) => {
519
+ try {
520
+ return JSON.parse(line)
521
+ } catch {
522
+ return { invalid: true, line }
523
+ }
524
+ })
525
+ }
526
+
527
+ function promotionFindings({ policy, project, graph }) {
528
+ if (!policy?.promotion?.requiresGitPromote) return []
529
+ const records = promotionRecords(project, policy)
530
+ const nodeById = new Map(graph.nodes.map((node) => [node.id, node]))
531
+ const recordKeys = new Set(records.filter((record) => !record.invalid).map((record) => `${record.source?.repo}:${record.source?.kgId}->${record.target?.repo}:${record.target?.kgId}`))
532
+ const findings = []
533
+ const severity = severityFor(policy)
534
+ for (const node of graph.nodes) {
535
+ const targetRepo = repoPolicy(policy, node.repo)
536
+ if (!targetRepo || targetRepo.kind === 'private_domain') continue
537
+ for (const sourceId of asArray(node.relations?.supersedes)) {
538
+ const sourceNode = nodeById.get(sourceId)
539
+ if (!sourceNode) continue
540
+ const sourceRepo = repoPolicy(policy, sourceNode.repo)
541
+ if (sourceRepo?.kind !== 'private_domain') continue
542
+ const key = `${sourceNode.repo}:${sourceNode.id}->${node.repo}:${node.id}`
543
+ if (!recordKeys.has(key)) {
544
+ findings.push(finding({ severity, code: 'git-promote-required', repo: node.repo, path: node.path, node: node.id, message: `${node.repo}/${node.path}: promotion from private ${sourceNode.id} requires git.promote record` }))
545
+ }
546
+ }
547
+ }
548
+ for (const [index, record] of records.entries()) {
549
+ if (record.invalid) {
550
+ findings.push(finding({ severity, code: 'git-promote-record-invalid-json', message: `git.promote record ${index + 1} is invalid JSON` }))
551
+ } else if (record.schema && record.schema !== PROMOTE_EVENT_SCHEMA && !LEGACY_PROMOTE_EVENT_SCHEMAS.has(record.schema)) {
552
+ findings.push(finding({ severity, code: 'git-promote-record-invalid-schema', message: `git.promote record ${index + 1} has invalid schema ${record.schema}` }))
553
+ } else if (record.event?.type !== 'git.promote' || record.event?.revocable !== false) {
554
+ findings.push(finding({ severity, code: 'git-promote-record-invalid', message: `git.promote record ${index + 1} must be durable and non-revocable` }))
555
+ }
556
+ }
557
+ return findings
558
+ }
559
+
560
+ export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnly = false, actor = null } = {}) {
561
+ const validationErrors = validateBoundaryPolicy(policy, project)
562
+ let graph = null
563
+ const findings = validationErrors.map((message) => finding({ severity: severityFor(policy), code: 'boundary-policy-invalid', message }))
564
+ if (validationErrors.length === 0) {
565
+ if (!stagedOnly) {
566
+ graph = buildGraph(project)
567
+ findings.push(...graph.errors.map((message) => finding({ severity: severityFor(policy), code: 'knowledge-graph-invalid', message })))
568
+ for (const node of graph.nodes ?? []) findings.push(...nodePlacementFindings({ node, policy }))
569
+ findings.push(...promotionFindings({ policy, project, graph }))
570
+ }
571
+ findings.push(...actorFindings({ policy, project, actor }))
572
+ if (staged) {
573
+ const stagedPaths = stagedPathsForProject(project)
574
+ findings.push(...forbiddenPathFindings({ policy, stagedPaths }))
575
+ findings.push(...semanticDiffFindings({ policy, project }))
576
+ findings.push(...stagedContentFindings({ policy, project }))
577
+ }
578
+ }
579
+ const errors = findings.filter((item) => item.severity === 'error')
580
+ const warnings = findings.filter((item) => item.severity !== 'error')
581
+ return {
582
+ ok: errors.length === 0,
583
+ mode: policy?.mode ?? null,
584
+ schema: BOUNDARY_POLICY_SCHEMA,
585
+ graphCounts: graph?.counts ?? null,
586
+ findings,
587
+ errors,
588
+ warnings,
589
+ }
590
+ }
591
+
592
+ export function runBoundaryCheckCommand(argv = process.argv.slice(2)) {
593
+ const args = parseArgs(argv)
594
+ const staged = Boolean(args.staged)
595
+ const stagedOnly = Boolean(args['staged-only'])
596
+ const json = Boolean(args.json)
597
+ const project = commandProject({ argv })
598
+ const loaded = loadBoundaryPolicy(project)
599
+ const missingFindings = loaded.errors?.map((message) => finding({ code: 'boundary-policy-missing', message })) ?? []
600
+ const report = loaded.ok
601
+ ? checkBoundaryPolicy({ project, policy: loaded.policy, staged, stagedOnly, actor: firstString(args.actor) })
602
+ : { ok: false, mode: null, schema: BOUNDARY_POLICY_SCHEMA, graphCounts: null, findings: missingFindings, errors: missingFindings, warnings: [] }
603
+
604
+ if (json) {
605
+ console.log(JSON.stringify(report, null, 2))
606
+ } else {
607
+ const label = report.ok ? 'passed' : 'failed'
608
+ console.log(`[boundary:check] ${label} · ${report.errors.length ?? report.findings.filter((item) => item.severity === 'error').length} errors · ${report.warnings.length ?? report.findings.filter((item) => item.severity !== 'error').length} warnings`)
609
+ for (const item of report.findings.slice(0, 50)) console.log(`${item.severity ?? 'error'} ${item.code}: ${item.message}`)
610
+ }
611
+ process.exit(report.ok ? 0 : 1)
612
+ }
613
+
614
+ export function installBoundaryHooks({ project, force = false } = {}) {
615
+ const installed = []
616
+ const skipped = []
617
+ for (const repo of managedRepos(project)) {
618
+ if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
619
+ const hooksDir = path.join(repo.path, '.git', 'hooks')
620
+ fs.mkdirSync(hooksDir, { recursive: true })
621
+ for (const hookName of ['pre-commit', 'pre-push']) {
622
+ const hookPath = path.join(hooksDir, hookName)
623
+ const existing = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, 'utf8') : ''
624
+ const script = hookScript(project.configPath, hookName)
625
+ if (existing && !existing.includes('MNSTRY_ATELIER_BOUNDARY_GUARD') && !force) {
626
+ const sidecar = `${hookPath}.mnstry-atelier-boundary`
627
+ fs.writeFileSync(sidecar, script)
628
+ fs.chmodSync(sidecar, 0o755)
629
+ skipped.push({ repo: repo.name, hook: hookName, path: hookPath, sidecar })
630
+ continue
631
+ }
632
+ fs.writeFileSync(hookPath, script)
633
+ fs.chmodSync(hookPath, 0o755)
634
+ installed.push({ repo: repo.name, hook: hookName, path: hookPath })
635
+ }
636
+ }
637
+ return { installed, skipped }
638
+ }
639
+
640
+ function hookScript(projectConfigPath, hookName) {
641
+ const config = projectConfigPath ? `--project-config=${projectConfigPath.replaceAll('"', '\\"')}` : ''
642
+ // pre-push reads the ref updates git writes to stdin and judges only that range;
643
+ // pre-commit judges the staged diff. Neither scans the whole tree — that view is
644
+ // `atelier boundary audit`, which reports without blocking.
645
+ const invocation = hookName === 'pre-push' ? `boundary push-check ${config}` : `boundary check --staged ${config}`
646
+ return `#!/usr/bin/env bash
647
+ # MNSTRY_ATELIER_BOUNDARY_GUARD ${hookName}
648
+ set -euo pipefail
649
+ if command -v atelier >/dev/null 2>&1; then
650
+ exec atelier ${invocation}
651
+ elif [ -x "./node_modules/.bin/atelier" ]; then
652
+ exec ./node_modules/.bin/atelier ${invocation}
653
+ elif command -v mnstry-atelier >/dev/null 2>&1; then
654
+ exec mnstry-atelier ${invocation}
655
+ elif [ -x "./node_modules/.bin/mnstry-atelier" ]; then
656
+ exec ./node_modules/.bin/mnstry-atelier ${invocation}
657
+ elif [ -n "\${MNSTRY_ATELIER_PACKAGE_ROOT:-}" ]; then
658
+ exec node "$MNSTRY_ATELIER_PACKAGE_ROOT/bin/mnstry-atelier.mjs" ${invocation}
659
+ else
660
+ echo "Atelier boundary guard is not installed on PATH" >&2
661
+ exit 1
662
+ fi
663
+ `
664
+ }
665
+
666
+ export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdin = readStdin() } = {}) {
667
+ const args = parseArgs(argv)
668
+ const project = commandProject({ argv })
669
+ const loaded = loadBoundaryPolicy(project)
670
+ if (!loaded.ok) {
671
+ console.error(loaded.errors.join('\n'))
672
+ process.exit(1)
673
+ }
674
+ const updates = parsePushRefUpdates(stdin)
675
+ if (!updates.length) {
676
+ console.log('[boundary:push-check] no ref updates on stdin · nothing to judge')
677
+ process.exit(0)
678
+ }
679
+ const report = checkPushContent({ project, policy: loaded.policy, updates, cwd: process.cwd() })
680
+ if (args.json) {
681
+ console.log(JSON.stringify(report, null, 2))
682
+ } else {
683
+ console.log(`[boundary:push-check] ${report.ok ? 'passed' : 'blocked'} · ${report.repo ?? 'unknown repo'} · ${report.errors.length} errors · ${report.warnings.length} warnings`)
684
+ for (const item of report.findings.slice(0, 50)) console.log(`${item.severity} ${item.rule ?? item.code}: ${item.message}`)
685
+ if (!report.ok && report.repo) {
686
+ console.log('')
687
+ console.log('If this usage is intentional, declare it in the boundary policy:')
688
+ console.log(' contentRuleExceptions: [{ rule, repo, paths: [...], reason: "why this is accepted" }]')
689
+ }
690
+ }
691
+ process.exit(report.ok ? 0 : 1)
692
+ }
693
+
694
+ export function runBoundaryAuditCommand(argv = process.argv.slice(2)) {
695
+ const args = parseArgs(argv)
696
+ const project = commandProject({ argv })
697
+ const loaded = loadBoundaryPolicy(project)
698
+ if (!loaded.ok) {
699
+ console.error(loaded.errors.join('\n'))
700
+ process.exit(1)
701
+ }
702
+ const report = auditContentRules({ project, policy: loaded.policy })
703
+ if (args.json) {
704
+ console.log(JSON.stringify(report, null, 2))
705
+ } else {
706
+ console.log(`[boundary:audit] ${report.findings.length} content-rule matches across the tree · ${report.accepted.length} declared exceptions`)
707
+ for (const item of report.findings.slice(0, 100)) console.log(`${item.severity} ${item.rule}: ${item.message}`)
708
+ for (const item of report.accepted) console.log(`accepted ${item.rule} in ${item.repo} (${item.paths.join(', ')}): ${item.reason}`)
709
+ }
710
+ // Reporting only. A pre-existing accepted usage must never block unrelated work.
711
+ process.exit(0)
712
+ }
713
+
714
+ function readStdin() {
715
+ try {
716
+ return fs.readFileSync(0, 'utf8')
717
+ } catch {
718
+ return ''
719
+ }
720
+ }
721
+
722
+ export function runBoundaryInstallHooksCommand(argv = process.argv.slice(2)) {
723
+ const args = parseArgs(argv)
724
+ const project = commandProject({ argv })
725
+ const result = installBoundaryHooks({ project, force: Boolean(args.force) })
726
+ console.log(JSON.stringify({ ok: result.skipped.length === 0, ...result }, null, 2))
727
+ process.exit(result.skipped.length === 0 ? 0 : 1)
728
+ }
729
+
730
+ function parsePromoteArgs(argv) {
731
+ const args = parseArgs(argv)
732
+ const sourceRepo = firstString(args['source-repo'], args.sourceRepo)
733
+ const targetRepo = firstString(args['target-repo'], args.targetRepo)
734
+ const kgId = firstString(args['kg-id'], args.kgId)
735
+ const targetKgId = firstString(args['target-kg-id'], args.targetKgId) || kgId
736
+ const actor = firstString(args.actor, process.env.MNSTRY_ATELIER_ACTOR, process.env.GITHUB_ACTOR, process.env.USER) || 'unknown'
737
+ return { args, sourceRepo, targetRepo, kgId, targetKgId, actor }
738
+ }
739
+
740
+ export function createPromoteEvent({ project, policy, sourceRepo, targetRepo, kgId, targetKgId = kgId, actor }) {
741
+ if (!sourceRepo || !targetRepo || !kgId) throw new Error('promote requires --source-repo, --target-repo, and --kg-id')
742
+ const source = project.repos.find((repo) => repo.name === sourceRepo)
743
+ const target = project.repos.find((repo) => repo.name === targetRepo)
744
+ if (!source) throw new Error(`source repo not configured: ${sourceRepo}`)
745
+ if (!target) throw new Error(`target repo not configured: ${targetRepo}`)
746
+ const sourcePolicy = repoPolicy(policy, sourceRepo)
747
+ const targetPolicy = repoPolicy(policy, targetRepo)
748
+ const sourceCommit = gitHead(source.path)
749
+ const targetCommit = gitHead(target.path)
750
+ return {
751
+ schema: PROMOTE_EVENT_SCHEMA,
752
+ source: {
753
+ repo: sourceRepo,
754
+ repoAccess: sourcePolicy?.readBoundary ?? source.readBoundary ?? 'private',
755
+ kgId,
756
+ audience: 'private',
757
+ commit: sourceCommit,
758
+ },
759
+ target: {
760
+ repo: targetRepo,
761
+ repoAccess: targetPolicy?.readBoundary ?? target.readBoundary ?? 'team',
762
+ kgId: targetKgId,
763
+ audience: 'team',
764
+ commit: targetCommit,
765
+ },
766
+ event: {
767
+ type: 'git.promote',
768
+ actor,
769
+ disclosureClass: 'durable',
770
+ revocable: false,
771
+ recordedAt: new Date().toISOString(),
772
+ },
773
+ }
774
+ }
775
+
776
+ function gitHead(repoPath) {
777
+ try {
778
+ return execFileSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
779
+ } catch {
780
+ return 'unknown'
781
+ }
782
+ }
783
+
784
+ export function runPromoteCommand(argv = process.argv.slice(2)) {
785
+ const parsed = parsePromoteArgs(argv)
786
+ const project = commandProject({ argv })
787
+ const loaded = loadBoundaryPolicy(project)
788
+ if (!loaded.ok) {
789
+ console.error(loaded.errors.join('\n'))
790
+ process.exit(1)
791
+ }
792
+ const event = createPromoteEvent({ project, policy: loaded.policy, ...parsed })
793
+ const recordsPath = firstString(loaded.policy.promotion?.recordsPath)
794
+ if (!recordsPath) {
795
+ console.error('boundary policy promotion.recordsPath is required')
796
+ process.exit(1)
797
+ }
798
+ const abs = resolvePathValue(recordsPath, project.configDir)
799
+ if (parsed.args['dry-run']) {
800
+ console.log(JSON.stringify({ ok: true, dryRun: true, path: abs, event }, null, 2))
801
+ process.exit(0)
802
+ }
803
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
804
+ fs.appendFileSync(abs, `${JSON.stringify(event)}\n`)
805
+ console.log(JSON.stringify({ ok: true, path: abs, event }, null, 2))
806
+ }