@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,491 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import Ajv2020 from 'ajv/dist/2020.js'
6
+ import addFormats from 'ajv-formats'
7
+ import {
8
+ READINESS_PROTOCOL_IDS,
9
+ READINESS_PROTOCOL_SLUGS,
10
+ bundledReadinessProtocols,
11
+ getBundledReadinessProtocol,
12
+ } from '../readiness-protocols/bundled-pack.mjs'
13
+
14
+ // Deterministic extension-pack loader. Packs are declared in the tracked
15
+ // project config under ext["mnstry.atelier"].extensionPacks and load through a
16
+ // fixed pipeline: entry shape, enablement, schema validation, identity,
17
+ // reserved-namespace rules, fixture and protocol path guards, the nine-point
18
+ // safety posture, collision rules against the bundled pack, and lock
19
+ // pin-and-verify. Every check fails closed: in the default (non-report) mode
20
+ // any error throws before a pack becomes visible to the runtime.
21
+
22
+ export const ATELIER_EXT_NAMESPACE = 'mnstry.atelier'
23
+ export const EXTENSION_PACK_SCHEMA = 'mnstry-atelier-extension-pack@v1'
24
+ export const RESERVED_NAMESPACE = 'mnstry'
25
+ // Reserved-namespace rule covers lookalike labels too: 'mnstry' itself plus
26
+ // any label extending it with '.' or '-' ('mnstry-core', 'mnstry-readiness'),
27
+ // aligning with TRADEMARKS.md. Pack namespaces cannot contain '.', but the
28
+ // pattern still guards it so the rule holds if the id grammar ever loosens.
29
+ export const RESERVED_NAMESPACE_PATTERN = /^mnstry([.-]|$)/
30
+
31
+ // Mirrors LOCK_FILE in src/upgrade/upgrade.mjs. Declared locally because the
32
+ // upgrade module imports this loader (writeAtelierLock loads packs before
33
+ // pinning them); importing the constant back would create a module cycle.
34
+ const LOCK_FILE_NAME = 'atelier.lock.json'
35
+
36
+ const PACK_ID_PATTERN = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/
37
+ const PACK_VERSION_PATTERN = /^v\d+$/
38
+
39
+ // The nine-point safety posture every loaded protocol must carry, matching the
40
+ // properties the bundled-pack tests pin. The protocol contract keeps
41
+ // safetyPosture as a bare object on purpose (no schema tightening at v1), so
42
+ // points 1-7 are code-enforced here; points 8 and 9 are schema consts and this
43
+ // table backstops them in case the contract ever loosens.
44
+ export const PROTOCOL_POSTURE_POINTS = [
45
+ { path: 'safetyPosture.runtimeMutation', expected: 'false', holds: (doc) => doc?.safetyPosture?.runtimeMutation === false },
46
+ { path: 'safetyPosture.externalEgress', expected: 'false', holds: (doc) => doc?.safetyPosture?.externalEgress === false },
47
+ { path: 'safetyPosture.defaultVisibility', expected: '"private"', holds: (doc) => doc?.safetyPosture?.defaultVisibility === 'private' },
48
+ { path: 'safetyPosture.authority', expected: '"proposal-only"', holds: (doc) => doc?.safetyPosture?.authority === 'proposal-only' },
49
+ { path: 'safetyPosture.reviewMode', expected: '"static-inspection"', holds: (doc) => doc?.safetyPosture?.reviewMode === 'static-inspection' },
50
+ { path: 'safetyPosture.failClosedOnMissingEvidence', expected: 'true', holds: (doc) => doc?.safetyPosture?.failClosedOnMissingEvidence === true },
51
+ { path: 'safetyPosture.refuses', expected: 'an array including "runtime writes"', holds: (doc) => Array.isArray(doc?.safetyPosture?.refuses) && doc.safetyPosture.refuses.includes('runtime writes') },
52
+ { path: 'safety.runtimeMutation', expected: 'false', holds: (doc) => doc?.safety?.runtimeMutation === false },
53
+ { path: 'outputs.runSchema', expected: '"atelier-readiness-run@v1"', holds: (doc) => doc?.outputs?.runSchema === 'atelier-readiness-run@v1' },
54
+ ]
55
+
56
+ // Pack member arrays whose ids must live inside the pack's own namespace.
57
+ const MEMBER_ID_FIELDS = [
58
+ ['terms', ['id']],
59
+ ['protocols', ['id']],
60
+ ['lenses', ['id']],
61
+ ['readinessRules', ['id']],
62
+ ['actionRules', ['id']],
63
+ ['exportMappings', ['id', 'sourceTerm']],
64
+ ]
65
+
66
+ const PACKAGE_ROOT_URL = new URL('../../', import.meta.url)
67
+
68
+ function isPlainObject(value) {
69
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
70
+ }
71
+
72
+ function asObject(value) {
73
+ return isPlainObject(value) ? value : {}
74
+ }
75
+
76
+ function readJsonFile(file) {
77
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
78
+ }
79
+
80
+ let cachedValidators = null
81
+
82
+ // Compiled once and cached at module scope; compiled validators are immutable.
83
+ function compiledValidators() {
84
+ if (!cachedValidators) {
85
+ const ajv = new Ajv2020({ allErrors: true, strict: false })
86
+ addFormats(ajv)
87
+ cachedValidators = {
88
+ pack: ajv.compile(readJsonFile(fileURLToPath(new URL('contracts/atelier-extension-pack.v1.schema.json', PACKAGE_ROOT_URL)))),
89
+ protocol: ajv.compile(readJsonFile(fileURLToPath(new URL('contracts/atelier-readiness-protocol.v1.schema.json', PACKAGE_ROOT_URL)))),
90
+ }
91
+ }
92
+ return cachedValidators
93
+ }
94
+
95
+ function ajvMessages(validate) {
96
+ return (validate.errors ?? []).map((error) => `${error.instancePath || '/'} ${error.message}`)
97
+ }
98
+
99
+ // Digest of the exact file bytes, not re-serialized JSON. The bundled entry in
100
+ // buildAtelierLock digests JSON.stringify of an in-memory object instead; file
101
+ // packs always digest raw bytes so on-disk edits are visible byte-for-byte.
102
+ //
103
+ // The digest pins the whole pack content, not just the manifest: it is sha256
104
+ // over a length-prefixed concatenation of parts, where the parts are the pack
105
+ // manifest's raw bytes followed by each referenced protocol file's raw bytes
106
+ // in declaration order. Each part contributes the ASCII decimal byte length,
107
+ // a single '\n', then the bytes themselves. The length prefix makes part
108
+ // boundaries unambiguous, so no concatenation of different files can collide
109
+ // with another split of the same bytes. A locked lock entry therefore fails
110
+ // closed when any referenced protocol file changes, not only the manifest.
111
+ export function computePackDigest(manifestBytes, protocolFilesBytes = []) {
112
+ const hash = crypto.createHash('sha256')
113
+ for (const part of [manifestBytes, ...protocolFilesBytes]) {
114
+ const bytes = Buffer.isBuffer(part) ? part : Buffer.from(part)
115
+ hash.update(`${bytes.length}\n`)
116
+ hash.update(bytes)
117
+ }
118
+ return `sha256:${hash.digest('hex')}`
119
+ }
120
+
121
+ function relativePathError(value, { requireJsonSuffix }) {
122
+ if (typeof value !== 'string' || !value.trim()) return 'must be a non-empty relative path'
123
+ if (path.isAbsolute(value)) return 'must not be an absolute path'
124
+ if (value.split(/[\\/]/).some((segment) => segment === '..')) return 'must not contain .. segments'
125
+ if (requireJsonSuffix && !value.endsWith('.json')) return 'must end with .json'
126
+ return null
127
+ }
128
+
129
+ function isUnder(baseDir, resolved) {
130
+ return resolved === baseDir || resolved.startsWith(`${baseDir}${path.sep}`)
131
+ }
132
+
133
+ // Symlink-hardened containment. The lexical isUnder check alone is inert
134
+ // against symlinks: a packs or protocols directory that is a symlink pointing
135
+ // outside the config root resolves lexically inside it. Both the base and the
136
+ // candidate are pushed through realpathSync before comparing, so any declared
137
+ // path whose real resolution leaves the realpathed base is rejected. Call only
138
+ // after the candidate is known to exist (realpathSync throws otherwise).
139
+ function isUnderReal(baseDir, resolved) {
140
+ return isUnder(fs.realpathSync(baseDir), fs.realpathSync(resolved))
141
+ }
142
+
143
+ function entryShapeErrors(entry, label) {
144
+ if (!isPlainObject(entry)) return [`${label} must be an object`]
145
+ const errors = []
146
+ if (typeof entry.id !== 'string' || !PACK_ID_PATTERN.test(entry.id)) {
147
+ errors.push(`${label}.id must be a namespaced pack id matching ${PACK_ID_PATTERN}`)
148
+ }
149
+ if (typeof entry.version !== 'string' || !PACK_VERSION_PATTERN.test(entry.version)) {
150
+ errors.push(`${label}.version must match ${PACK_VERSION_PATTERN}`)
151
+ }
152
+ if (typeof entry.enabled !== 'boolean') {
153
+ errors.push(`${label}.enabled must be a boolean`)
154
+ }
155
+ const pathError = relativePathError(entry.path, { requireJsonSuffix: true })
156
+ if (pathError) errors.push(`${label}.path ${pathError}`)
157
+ return errors
158
+ }
159
+
160
+ function declaredEntries(project) {
161
+ const container = asObject(asObject(asObject(project?.config).ext)[ATELIER_EXT_NAMESPACE])
162
+ const value = container.extensionPacks
163
+ if (value == null) return { entries: [], error: null }
164
+ if (!Array.isArray(value)) {
165
+ return { entries: [], error: `ext["${ATELIER_EXT_NAMESPACE}"].extensionPacks must be an array` }
166
+ }
167
+ return { entries: value, error: null }
168
+ }
169
+
170
+ function readLockState(configDir) {
171
+ const lockPath = path.join(configDir, LOCK_FILE_NAME)
172
+ if (!fs.existsSync(lockPath)) return { exists: false, entries: [], error: null }
173
+ try {
174
+ const doc = readJsonFile(lockPath)
175
+ return { exists: true, entries: Array.isArray(doc?.extensionPacks) ? doc.extensionPacks : [], error: null }
176
+ } catch {
177
+ // Do not echo parser output: it can quote file content. Name the file only.
178
+ return { exists: true, entries: [], error: `${LOCK_FILE_NAME} is not readable JSON; review it and run atelier lock write` }
179
+ }
180
+ }
181
+
182
+ function reservedNamespaceErrors(doc) {
183
+ const errors = []
184
+ const prefix = `${doc.id}:`
185
+ for (const [field, keys] of MEMBER_ID_FIELDS) {
186
+ const items = Array.isArray(doc[field]) ? doc[field] : []
187
+ items.forEach((item, index) => {
188
+ for (const key of keys) {
189
+ const value = item?.[key]
190
+ if (typeof value !== 'string') continue
191
+ if (value.startsWith(`${RESERVED_NAMESPACE}.`)) {
192
+ errors.push(`${field}[${index}].${key} ${value} redefines reserved mnstry runtime term`)
193
+ } else if (!value.startsWith(prefix)) {
194
+ errors.push(`${field}[${index}].${key} ${value} does not belong to pack namespace ${doc.id}`)
195
+ }
196
+ }
197
+ })
198
+ }
199
+ return errors
200
+ }
201
+
202
+ function loadProtocolRecords({ doc, packDir, seenProtocolIds, errors, digestParts }) {
203
+ const validators = compiledValidators()
204
+ const records = []
205
+ const refs = Array.isArray(doc.protocols) ? doc.protocols : []
206
+ refs.forEach((ref, index) => {
207
+ const label = `protocols[${index}]`
208
+ const guard = relativePathError(ref.path, { requireJsonSuffix: true })
209
+ if (guard) {
210
+ errors.push(`${label}.path ${guard}`)
211
+ return
212
+ }
213
+ const resolved = path.resolve(packDir, ref.path)
214
+ if (!isUnder(packDir, resolved)) {
215
+ errors.push(`${label}.path escapes the pack directory`)
216
+ return
217
+ }
218
+ if (!fs.existsSync(resolved)) {
219
+ errors.push(`${label} protocol file not found: ${ref.path}`)
220
+ return
221
+ }
222
+ if (!isUnderReal(packDir, resolved)) {
223
+ errors.push(`${label}.path escapes the pack directory`)
224
+ return
225
+ }
226
+ // Raw bytes are read once and folded into the pack digest (declaration
227
+ // order), so the lock pins protocol content, not just the manifest.
228
+ const protocolBytes = fs.readFileSync(resolved)
229
+ digestParts.push(protocolBytes)
230
+ let protocolDoc
231
+ try {
232
+ protocolDoc = JSON.parse(protocolBytes.toString('utf8'))
233
+ } catch {
234
+ errors.push(`${label} protocol file is not valid JSON: ${ref.path}`)
235
+ return
236
+ }
237
+ if (!validators.protocol(protocolDoc)) {
238
+ errors.push(...ajvMessages(validators.protocol).map((message) => `${label} protocol file fails atelier-readiness-protocol@v1: ${message}`))
239
+ return
240
+ }
241
+ if (protocolDoc.id !== ref.id) {
242
+ errors.push(`${label} protocol file id ${protocolDoc.id} does not match the pack entry id ${ref.id}`)
243
+ return
244
+ }
245
+ let posture = true
246
+ for (const point of PROTOCOL_POSTURE_POINTS) {
247
+ if (!point.holds(protocolDoc)) {
248
+ posture = false
249
+ errors.push(`${label} protocol ${ref.id} violates the required safety posture: ${point.path} must be ${point.expected}`)
250
+ }
251
+ }
252
+ const slug = String(ref.id).split(':').at(-1)
253
+ let collision = false
254
+ if (READINESS_PROTOCOL_IDS.includes(ref.id)) {
255
+ collision = true
256
+ errors.push(`${label} protocol id ${ref.id} collides with a bundled protocol id`)
257
+ }
258
+ if (READINESS_PROTOCOL_SLUGS.includes(slug)) {
259
+ collision = true
260
+ errors.push(`${label} protocol slug ${slug} collides with a bundled protocol slug`)
261
+ }
262
+ if (seenProtocolIds.has(ref.id)) {
263
+ collision = true
264
+ errors.push(`${label} duplicate protocol id ${ref.id} across loaded packs`)
265
+ }
266
+ seenProtocolIds.add(ref.id)
267
+ if (!posture || collision) return
268
+ records.push({
269
+ id: ref.id,
270
+ packId: doc.id,
271
+ slug,
272
+ gate: ref.gate,
273
+ title: ref.title ?? protocolDoc.title,
274
+ declaredPath: ref.path,
275
+ path: resolved,
276
+ protocol: protocolDoc,
277
+ })
278
+ })
279
+ return records
280
+ }
281
+
282
+ function loadOnePack({ entry, configDir, lock, seenProtocolIds, errors, warnings }) {
283
+ const validators = compiledValidators()
284
+ const packPath = path.resolve(configDir, entry.path)
285
+ if (!isUnder(configDir, packPath)) {
286
+ errors.push(`pack path escapes the project config directory: ${entry.path}`)
287
+ return null
288
+ }
289
+ if (!fs.existsSync(packPath)) {
290
+ errors.push(`pack file not found: ${entry.path}`)
291
+ return null
292
+ }
293
+ if (!isUnderReal(configDir, packPath)) {
294
+ errors.push(`pack path escapes the project config directory: ${entry.path}`)
295
+ return null
296
+ }
297
+ const rawBytes = fs.readFileSync(packPath)
298
+ let doc
299
+ try {
300
+ doc = JSON.parse(rawBytes.toString('utf8'))
301
+ } catch {
302
+ errors.push(`pack file is not valid JSON: ${entry.path}`)
303
+ return null
304
+ }
305
+ if (!validators.pack(doc)) {
306
+ errors.push(...ajvMessages(validators.pack).map((message) => `pack file fails ${EXTENSION_PACK_SCHEMA}: ${message}`))
307
+ return null
308
+ }
309
+
310
+ if (doc.id !== entry.id) errors.push(`pack id ${doc.id} does not match the declared id ${entry.id}`)
311
+ if (doc.version !== entry.version) errors.push(`pack version ${doc.version} does not match the declared version ${entry.version}`)
312
+ const idPrefix = String(doc.id).split('.')[0]
313
+ if (doc.namespace !== idPrefix) errors.push(`pack namespace ${doc.namespace} must equal the pack id prefix ${idPrefix}`)
314
+ // The namespace-level check must hold even for a memberless pack: with no
315
+ // member ids to inspect, reservedNamespaceErrors never fires, so this line
316
+ // is the only thing standing between a reserved (or lookalike) namespace
317
+ // and a clean load.
318
+ if (RESERVED_NAMESPACE_PATTERN.test(String(doc.namespace))) {
319
+ errors.push(`pack namespace ${doc.namespace} is reserved for the bundled runtime vocabulary`)
320
+ }
321
+ errors.push(...reservedNamespaceErrors(doc))
322
+ if (errors.length) return null
323
+
324
+ const packDir = path.dirname(packPath)
325
+ const fixtures = Array.isArray(doc.fixtures) ? doc.fixtures : []
326
+ fixtures.forEach((fixtureRef, index) => {
327
+ const guard = relativePathError(fixtureRef, { requireJsonSuffix: false })
328
+ if (guard) {
329
+ errors.push(`fixtures[${index}] ${guard}`)
330
+ return
331
+ }
332
+ const resolved = path.resolve(packDir, fixtureRef)
333
+ if (!isUnder(packDir, resolved)) {
334
+ errors.push(`fixtures[${index}] escapes the pack directory`)
335
+ return
336
+ }
337
+ // Fixtures are declarative references; a missing file is not a load error,
338
+ // but an existing one that resolves outside the pack through a symlink is.
339
+ if (!fs.existsSync(resolved)) {
340
+ warnings.push(`fixtures[${index}] reference not found: ${fixtureRef}`)
341
+ } else if (!isUnderReal(packDir, resolved)) {
342
+ errors.push(`fixtures[${index}] escapes the pack directory`)
343
+ }
344
+ })
345
+
346
+ // Digest parts: manifest bytes first, then each referenced protocol file's
347
+ // bytes in declaration order (see computePackDigest for the exact format).
348
+ const digestParts = []
349
+ const protocolRecords = loadProtocolRecords({ doc, packDir, seenProtocolIds, errors, digestParts })
350
+ const digest = computePackDigest(rawBytes, digestParts)
351
+
352
+ const lockEntry = lock.entries.find((item) => item?.id === entry.id) ?? null
353
+ let lockStatus = 'unlocked'
354
+ if (lockEntry) {
355
+ let pinned = true
356
+ if (lockEntry.version !== entry.version) {
357
+ pinned = false
358
+ errors.push(`lock version mismatch for extension pack ${entry.id}: locked ${lockEntry.version}, declared ${entry.version}; review the pack and run atelier lock write`)
359
+ }
360
+ // A lock entry that carries no digest pins nothing; verifying it as
361
+ // 'locked' would be vacuous, so it is a load error with the remedy named.
362
+ if (typeof lockEntry.digest !== 'string' || !lockEntry.digest) {
363
+ pinned = false
364
+ errors.push(`lock entry for extension pack ${entry.id} is missing a string digest; review the pack and run atelier lock write`)
365
+ } else if (lockEntry.digest !== digest) {
366
+ pinned = false
367
+ errors.push(`lock digest mismatch for extension pack ${entry.id}; review the pack and run atelier lock write`)
368
+ }
369
+ if (pinned) lockStatus = 'locked'
370
+ } else if (lock.exists) {
371
+ warnings.push(`extension pack ${entry.id} is not recorded in ${LOCK_FILE_NAME}; run atelier lock write after review`)
372
+ }
373
+
374
+ if (errors.length) return null
375
+ return {
376
+ id: doc.id,
377
+ version: doc.version,
378
+ namespace: doc.namespace,
379
+ declaredPath: entry.path,
380
+ path: packPath,
381
+ digest,
382
+ lock: lockStatus,
383
+ doc,
384
+ // Lenses are validated and stored as write-only metadata; no lens runtime.
385
+ lenses: Array.isArray(doc.lenses) ? doc.lenses : [],
386
+ protocols: protocolRecords,
387
+ }
388
+ }
389
+
390
+ function formatLoadFailure(errors) {
391
+ const lines = errors.map((item) => (item.packId ? `${item.packId}: ${item.message}` : item.message))
392
+ return ['extension pack loading failed:', ...lines].join('\n')
393
+ }
394
+
395
+ // Loads every declared pack in declaration order. With report: false (the
396
+ // default, used by runtime and lock paths) any error throws — fail closed.
397
+ // With report: true (used by extension-pack validate) errors are collected per
398
+ // pack as { packId, message } entries and the caller decides.
399
+ // With verifyLock: false (used only by writeAtelierLock) the existing lock is
400
+ // neither read nor compared: re-pinning must not be blocked by the very drift
401
+ // the new lock records, and the old lock stays on disk untouched. Every pack
402
+ // then reports lock status 'unlocked'; all other checks still fail closed.
403
+ export function loadExtensionPacks(project, { report = false, verifyLock = true } = {}) {
404
+ const errors = []
405
+ const warnings = []
406
+ const skipped = []
407
+ const packs = []
408
+ const protocols = []
409
+ const configDir = project?.configDir ?? process.cwd()
410
+
411
+ const declared = declaredEntries(project)
412
+ if (declared.error) errors.push({ packId: null, message: declared.error })
413
+
414
+ const lock = verifyLock ? readLockState(configDir) : { exists: false, entries: [], error: null }
415
+ if (lock.error) errors.push({ packId: null, message: lock.error })
416
+
417
+ const overlayPacks = asObject(asObject(asObject(asObject(project?.localOverlay).overlay).preferences).extensionPacks)
418
+ const declaredIds = new Set()
419
+ const seenProtocolIds = new Set()
420
+
421
+ declared.entries.forEach((entry, index) => {
422
+ const label = `ext["${ATELIER_EXT_NAMESPACE}"].extensionPacks[${index}]`
423
+ const packId = typeof entry?.id === 'string' && entry.id ? entry.id : null
424
+ const entryErrors = entryShapeErrors(entry, label)
425
+ if (entryErrors.length) {
426
+ for (const message of entryErrors) errors.push({ packId, message })
427
+ return
428
+ }
429
+ if (declaredIds.has(entry.id)) {
430
+ errors.push({ packId: entry.id, message: `duplicate extension pack declaration for ${entry.id}` })
431
+ return
432
+ }
433
+ declaredIds.add(entry.id)
434
+
435
+ // Enablement is disable-only and decided before the file is read: the
436
+ // tracked config can disable, the machine-local overlay can disable, and
437
+ // neither the overlay nor anything else can enable an undeclared pack.
438
+ if (entry.enabled === false) {
439
+ skipped.push({ packId: entry.id, reason: 'disabled-in-config' })
440
+ return
441
+ }
442
+ if (asObject(overlayPacks[entry.id]).enabled === false) {
443
+ skipped.push({ packId: entry.id, reason: 'disabled-by-overlay' })
444
+ return
445
+ }
446
+
447
+ const packErrors = []
448
+ const packWarnings = []
449
+ const record = loadOnePack({ entry, configDir, lock, seenProtocolIds, errors: packErrors, warnings: packWarnings })
450
+ for (const message of packErrors) errors.push({ packId: entry.id, message })
451
+ for (const message of packWarnings) warnings.push({ packId: entry.id, message })
452
+ if (!record) return
453
+ packs.push(record)
454
+ protocols.push(...record.protocols)
455
+ })
456
+
457
+ for (const id of Object.keys(overlayPacks)) {
458
+ if (!declaredIds.has(id)) {
459
+ warnings.push({ packId: id, message: `overlay preference references undeclared extension pack ${id}; the overlay can only disable declared packs` })
460
+ }
461
+ }
462
+
463
+ const result = { packs, protocols, errors, warnings, skipped }
464
+ if (!report && errors.length) throw new Error(formatLoadFailure(errors))
465
+ return result
466
+ }
467
+
468
+ // Explicit registry object, no global state. Bundled resolution keeps its
469
+ // existing id | slug | namespaced affordance; pack protocols resolve by full
470
+ // namespaced id only, so a pack can never shadow or squat a bundled slug.
471
+ export function createProtocolRegistry({ packs = [] } = {}) {
472
+ const packProtocols = new Map()
473
+ for (const pack of packs) {
474
+ for (const record of pack.protocols ?? []) packProtocols.set(record.id, record)
475
+ }
476
+ return {
477
+ packs,
478
+ protocolById(id) {
479
+ const bundled = getBundledReadinessProtocol(id)
480
+ if (bundled) return bundled
481
+ const record = packProtocols.get(id)
482
+ return record ? record.protocol : null
483
+ },
484
+ listProtocols() {
485
+ return [
486
+ ...bundledReadinessProtocols,
487
+ ...[...packProtocols.values()].map((record) => record.protocol),
488
+ ]
489
+ },
490
+ }
491
+ }