@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,31 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ const GIT_IGNORE_TIMEOUT_MS = 8000
4
+
5
+ // One batched call per root. Per-file `git check-ignore` is correct but too slow
6
+ // once a workspace has thousands of files, which is how adopters end up skipping
7
+ // the check entirely and shipping a machine-dependent census.
8
+ export function gitIgnoredPaths(root) {
9
+ const ignored = new Set()
10
+ if (!root) return ignored
11
+ const result = spawnSync('git', ['-C', root, 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], {
12
+ encoding: 'utf8',
13
+ timeout: GIT_IGNORE_TIMEOUT_MS,
14
+ })
15
+ if (result.status !== 0 || !result.stdout) return ignored
16
+ for (const entry of result.stdout.split('\0')) {
17
+ if (entry) ignored.add(entry.replace(/\/+$/, ''))
18
+ }
19
+ return ignored
20
+ }
21
+
22
+ export const includeEverything = () => false
23
+
24
+ // `--directory` collapses a fully ignored directory to a single entry, so callers
25
+ // must test each directory before descending into it; a file deep inside an
26
+ // ignored directory is not itself listed.
27
+ export function gitIgnoreFilter(root) {
28
+ const ignored = gitIgnoredPaths(root)
29
+ if (!ignored.size) return includeEverything
30
+ return (rel) => ignored.has(String(rel ?? '').replace(/\/+$/, ''))
31
+ }
@@ -0,0 +1,30 @@
1
+ import path from 'node:path'
2
+
3
+ export const normalizeRelPath = (value) =>
4
+ String(value ?? '')
5
+ .replaceAll('\\', '/')
6
+ .replace(/^\.\/+/, '')
7
+
8
+ const escapeRe = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
9
+
10
+ // Shared by the boundary policy and the file-class resolver. Both used to carry
11
+ // their own copy; two glob dialects in one kit is exactly the drift this file exists
12
+ // to prevent.
13
+ export function matchesPathPattern(pattern, rel) {
14
+ const normalizedPattern = normalizeRelPath(pattern)
15
+ const normalizedRel = normalizeRelPath(rel)
16
+ if (normalizedPattern === normalizedRel) return true
17
+ if (normalizedPattern.endsWith('/**')) {
18
+ return normalizedRel === normalizedPattern.slice(0, -3) || normalizedRel.startsWith(normalizedPattern.slice(0, -2))
19
+ }
20
+ if (normalizedPattern.startsWith('**/')) {
21
+ const tail = normalizedPattern.slice(3)
22
+ return normalizedRel === tail || normalizedRel.endsWith(`/${tail}`) || matchesPathPattern(tail, normalizedRel)
23
+ }
24
+ if (normalizedPattern.includes('*')) {
25
+ return new RegExp(`^${normalizedPattern.split('*').map(escapeRe).join('.*')}$`).test(normalizedRel)
26
+ }
27
+ return false
28
+ }
29
+
30
+ export const basenameOf = (pattern) => path.posix.basename(normalizeRelPath(pattern))
@@ -0,0 +1,176 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { SUPPORTED_IDENTITY_PROVIDERS, gitRemoteUrl, remoteHost } from './config.mjs'
3
+
4
+ export const SUPPORTED_PROVIDERS = Object.freeze([...SUPPORTED_IDENTITY_PROVIDERS])
5
+
6
+ const PROVIDER_HOSTS = Object.freeze({ 'github.com': 'github' })
7
+
8
+ const lower = (value) => String(value ?? '').trim().toLowerCase()
9
+
10
+ /**
11
+ * Parse owner/name out of a remote URL. This is the *recorded* name, which after a
12
+ * rename is the stale one — hosting providers redirect the old URL indefinitely, so
13
+ * a clone keeps fetching happily under a name that no longer exists.
14
+ */
15
+ export function parseRemoteRef(url) {
16
+ const host = remoteHost(url)
17
+ if (!host) return null
18
+ const text = String(url).trim().replace(/\.git$/, '')
19
+ const tail = text.match(/^[^/@]+@[^:/]+:(.+)$/) ?? text.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]+\/(.+)$/i)
20
+ if (!tail) return null
21
+ const parts = tail[1].split('/').filter(Boolean)
22
+ if (parts.length < 2) return null
23
+ return { host, provider: PROVIDER_HOSTS[host] ?? null, owner: lower(parts[0]), name: lower(parts.slice(1).join('/')) }
24
+ }
25
+
26
+ // Ask the provider to resolve the recorded ref. GitHub follows the rename redirect
27
+ // and answers with the stable numeric id plus the current full name, which is the
28
+ // only signal that survives a rename.
29
+ export function githubLookup({ owner, name }) {
30
+ try {
31
+ const out = execFileSync('gh', ['api', `repos/${owner}/${name}`, '--jq', '{id: .id, fullName: .full_name}'], {
32
+ encoding: 'utf8',
33
+ stdio: ['ignore', 'pipe', 'ignore'],
34
+ })
35
+ const parsed = JSON.parse(out)
36
+ if (!parsed?.id) return null
37
+ return { id: String(parsed.id), fullName: lower(parsed.fullName), currentName: lower(String(parsed.fullName).split('/').pop()) }
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ const defaultLookups = { github: githubLookup }
44
+
45
+ /**
46
+ * Resolve a clone's stable identity.
47
+ *
48
+ * Never keys on the root commit: repos created from a shared template have
49
+ * identical root commits, so that heuristic reports false duplicates, and it
50
+ * cannot see a rename at all. Never keys on the folder name either. When the
51
+ * provider is unreachable it falls back to the recorded identity and then to
52
+ * declared aliases, and says so in `source` rather than guessing.
53
+ */
54
+ export function resolveRepoIdentity(cloneDir, { repo = {}, lookups = defaultLookups, remoteUrl = null } = {}) {
55
+ const url = remoteUrl ?? gitRemoteUrl(cloneDir)
56
+ const ref = parseRemoteRef(url)
57
+ const declared = repo.identity ?? null
58
+ const aliases = (Array.isArray(repo.aliases) ? repo.aliases : []).map(lower)
59
+ const base = {
60
+ path: cloneDir ?? null,
61
+ remoteUrl: url,
62
+ provider: ref?.provider ?? declared?.provider ?? null,
63
+ owner: ref?.owner ?? null,
64
+ remoteName: ref?.name ?? null,
65
+ declaredId: declared?.id ? String(declared.id) : null,
66
+ aliases,
67
+ }
68
+
69
+ const lookup = base.provider ? lookups[base.provider] : null
70
+ if (ref && lookup) {
71
+ const found = lookup(ref)
72
+ if (found) {
73
+ return {
74
+ ...base,
75
+ ok: true,
76
+ id: found.id,
77
+ currentName: found.currentName,
78
+ fullName: found.fullName,
79
+ renamed: Boolean(ref.name && found.currentName && ref.name !== found.currentName),
80
+ source: 'provider',
81
+ reachable: true,
82
+ }
83
+ }
84
+ }
85
+
86
+ if (base.declaredId) {
87
+ return { ...base, ok: true, id: base.declaredId, currentName: lower(repo.name) || base.remoteName, renamed: false, source: 'recorded-identity', reachable: false }
88
+ }
89
+ if (base.remoteName && aliases.includes(base.remoteName)) {
90
+ return { ...base, ok: true, id: null, currentName: lower(repo.name), renamed: true, source: 'alias', reachable: false }
91
+ }
92
+ if (base.remoteName) {
93
+ return { ...base, ok: true, id: null, currentName: base.remoteName, renamed: false, source: 'remote-url', reachable: false }
94
+ }
95
+ return { ...base, ok: false, id: null, currentName: null, renamed: false, source: 'unresolved', reachable: false }
96
+ }
97
+
98
+ export function identityKey(identity) {
99
+ if (identity?.id) return `${identity.provider ?? 'unknown'}:${identity.id}`
100
+ if (identity?.owner && identity?.currentName) return `${identity.provider ?? identity.remoteUrl ?? 'unknown'}:${identity.owner}/${identity.currentName}`
101
+ return null
102
+ }
103
+
104
+ /**
105
+ * Report identity problems a workspace cannot see on its own: stale folder names
106
+ * after a rename, two clones that are really the same repo, and config entries
107
+ * pointing at a repo the provider does not know.
108
+ */
109
+ export function auditRepoIdentities(project, { lookups = defaultLookups } = {}) {
110
+ const findings = []
111
+ const byKey = new Map()
112
+
113
+ for (const repo of project.repos ?? []) {
114
+ if (repo.external || !repo.path) continue
115
+ const identity = resolveRepoIdentity(repo.path, { repo, lookups })
116
+
117
+ if (!identity.ok) {
118
+ findings.push({
119
+ severity: 'warning',
120
+ code: 'repo-identity-unresolved',
121
+ repo: repo.name,
122
+ message: `${repo.name}: no origin remote, so the workspace cannot tell this clone apart from any other`,
123
+ })
124
+ continue
125
+ }
126
+ if (identity.source === 'provider' && identity.renamed) {
127
+ findings.push({
128
+ severity: 'warning',
129
+ code: 'repo-renamed-upstream',
130
+ repo: repo.name,
131
+ message: `${repo.name}: remote still says "${identity.remoteName}" but the provider now calls it "${identity.currentName}" — a rename is a metadata update, not a new identity`,
132
+ details: { from: identity.remoteName, to: identity.currentName, id: identity.id },
133
+ })
134
+ }
135
+ if (identity.currentName && repo.name && lower(repo.name) !== identity.currentName) {
136
+ const viaAlias = identity.aliases.includes(lower(repo.name)) || identity.aliases.includes(identity.remoteName ?? '')
137
+ findings.push({
138
+ severity: viaAlias ? 'warning' : 'error',
139
+ code: viaAlias ? 'repo-name-alias-deprecated' : 'repo-folder-name-stale',
140
+ repo: repo.name,
141
+ message: viaAlias
142
+ ? `${repo.name}: resolved through a recorded alias; update the config name to "${identity.currentName}"`
143
+ : `${repo.name}: config name does not match the canonical name "${identity.currentName}"; record it under aliases if this rename is intentional`,
144
+ details: { configured: repo.name, canonical: identity.currentName },
145
+ })
146
+ }
147
+ if (identity.id && !identity.declaredId) {
148
+ findings.push({
149
+ severity: 'warning',
150
+ code: 'repo-identity-undeclared',
151
+ repo: repo.name,
152
+ message: `${repo.name}: record identity { provider: "${identity.provider}", id: "${identity.id}" } so this repo survives a rename while the provider is unreachable`,
153
+ details: { provider: identity.provider, id: identity.id },
154
+ })
155
+ }
156
+
157
+ const key = identityKey(identity)
158
+ if (!key) continue
159
+ const first = byKey.get(key)
160
+ if (first) {
161
+ // A retired clone left behind by a rename. It fetches fine through the
162
+ // provider redirect, which is exactly why it goes unnoticed.
163
+ findings.push({
164
+ severity: 'error',
165
+ code: 'repo-identity-duplicate',
166
+ repo: repo.name,
167
+ message: `${repo.name} and ${first.repo} resolve to one repository (${key}); park the retired clone instead of syncing both`,
168
+ details: { key, clones: [first.path, repo.path] },
169
+ })
170
+ continue
171
+ }
172
+ byKey.set(key, { repo: repo.name, path: repo.path })
173
+ }
174
+
175
+ return { ok: !findings.some((item) => item.severity === 'error'), findings }
176
+ }
@@ -0,0 +1,169 @@
1
+ import path from 'node:path'
2
+ import { canProjectNode, projectGraph } from './policy.mjs'
3
+
4
+ export const ALIGNMENT_PROJECTION_SCHEMA = 'mnstry.alignment-projection@v1'
5
+ export const PROJECTION_POLICY_VERSION = 'projection-policy@v1'
6
+
7
+ // Root graph names are workspace-configured via sduiMap.rootGraphs. The kit
8
+ // ships no defaults, so name-based alignment classification stays inactive
9
+ // until a workspace supplies its own graph names.
10
+ export const DEFAULT_ROOT_GRAPHS = []
11
+
12
+ export const DEFAULT_PROOF_GATES = [
13
+ 'tenant-purity',
14
+ 'token-check',
15
+ 'sdui-check',
16
+ 'action-check',
17
+ 'media-check',
18
+ 'consent-check',
19
+ 'provider-check',
20
+ 'browser-proof',
21
+ 'storybook-proof',
22
+ ]
23
+
24
+ function posixRelative(root, file) {
25
+ if (!root || !file) return file ?? null
26
+ return path.relative(root, file).split(path.sep).join('/')
27
+ }
28
+
29
+ function defaultSourceCommitResolver() {
30
+ return null
31
+ }
32
+
33
+ export function projectionRecord(node, { target = 'team', sourceCommitResolver = defaultSourceCommitResolver } = {}) {
34
+ const verdict = canProjectNode(node, target)
35
+ return {
36
+ target,
37
+ sourceKgId: node.id,
38
+ allowed: verdict.ok,
39
+ reasons: verdict.reasons,
40
+ policyVersion: PROJECTION_POLICY_VERSION,
41
+ sourceCommit: sourceCommitResolver(node),
42
+ }
43
+ }
44
+
45
+ export function stableProjectionForCheck(value) {
46
+ return {
47
+ ...value,
48
+ generatedAt: null,
49
+ projectionRecords: (value?.projectionRecords ?? []).map((record) => ({ ...record, sourceCommit: null })),
50
+ }
51
+ }
52
+
53
+ function hasAlignmentPath(node, alignmentRepo, alignmentRoot) {
54
+ if (!alignmentRepo || !alignmentRoot) return false
55
+ return node.repo === alignmentRepo && String(node.path ?? '').startsWith(alignmentRoot.replace(/\/?$/, '/'))
56
+ }
57
+
58
+ function isAlignmentNode(node, { alignmentRepo, alignmentRoot, alignmentTags, rootGraphs }) {
59
+ if (hasAlignmentPath(node, alignmentRepo, alignmentRoot)) return true
60
+ const tags = new Set(node.tags ?? [])
61
+ if (alignmentTags.some((tag) => tags.has(tag))) return true
62
+ const haystack = [node.title, node.summary, node.path].join(' ').toLowerCase()
63
+ return rootGraphs.some((name) => haystack.includes(name.toLowerCase()))
64
+ }
65
+
66
+ export function buildAlignmentProjection({
67
+ graph,
68
+ target = 'team',
69
+ workspaceRoot = null,
70
+ graphPath = null,
71
+ sduiMap = {},
72
+ conformance = {},
73
+ tenantTheme = {},
74
+ tokenReport = '',
75
+ alignmentRepo = null,
76
+ alignmentRoot = null,
77
+ alignmentTags = ['mnstry-alignment', 'sdui'],
78
+ rootGraphs = sduiMap.rootGraphs ?? DEFAULT_ROOT_GRAPHS,
79
+ proofGates = DEFAULT_PROOF_GATES,
80
+ previousProjection = null,
81
+ now = () => new Date(),
82
+ sourceCommitResolver = defaultSourceCommitResolver,
83
+ source = {},
84
+ } = {}) {
85
+ const gatedGraph = projectGraph(graph ?? { nodes: [], edges: [] }, { target })
86
+ const alignmentNodes = (gatedGraph.nodes ?? []).filter((node) =>
87
+ isAlignmentNode(node, { alignmentRepo, alignmentRoot, alignmentTags, rootGraphs })
88
+ )
89
+ const alignmentIds = new Set(alignmentNodes.map((node) => node.id))
90
+ const alignmentEdges = (gatedGraph.edges ?? []).filter((edge) => alignmentIds.has(edge.source) || alignmentIds.has(edge.target))
91
+ const audienceCounts = {}
92
+ const statusCounts = {}
93
+
94
+ for (const node of alignmentNodes) {
95
+ audienceCounts[node.audience] = (audienceCounts[node.audience] ?? 0) + 1
96
+ statusCounts[node.status] = (statusCounts[node.status] ?? 0) + 1
97
+ }
98
+
99
+ const primitiveMap = sduiMap.primitiveMap ?? []
100
+ const gaps = []
101
+ for (const node of alignmentNodes) {
102
+ if (!node.surfaced && String(node.path ?? '').endsWith('.md')) {
103
+ gaps.push({ severity: 'info', type: 'unsurfaced-alignment-doc', node: node.id, path: `${node.repo}/${node.path}` })
104
+ }
105
+ if (hasAlignmentPath(node, alignmentRepo, alignmentRoot) && !Object.values(node.relations ?? {}).some((targets) => Array.isArray(targets) && targets.length)) {
106
+ gaps.push({ severity: 'info', type: 'missing-declared-relations', node: node.id, path: `${node.repo}/${node.path}` })
107
+ }
108
+ }
109
+
110
+ for (const diagnostic of graph?.diagnostics ?? []) {
111
+ gaps.push({
112
+ severity: diagnostic.severity ?? 'warning',
113
+ type: diagnostic.type ?? 'graph-diagnostic',
114
+ node: diagnostic.node ?? null,
115
+ path: diagnostic.path ? `${diagnostic.repo}/${diagnostic.path}` : null,
116
+ message: diagnostic.message ?? '',
117
+ })
118
+ }
119
+
120
+ const projectionRecords = (graph?.nodes ?? []).map((node) => projectionRecord(node, { target, sourceCommitResolver }))
121
+ const projection = {
122
+ schema: ALIGNMENT_PROJECTION_SCHEMA,
123
+ generatedAt: previousProjection?.generatedAt ?? now().toISOString(),
124
+ source: {
125
+ graph: source.graph ?? posixRelative(workspaceRoot, graphPath),
126
+ sduiMap: source.sduiMap ?? null,
127
+ conformanceContract: source.conformanceContract ?? null,
128
+ },
129
+ summary: {
130
+ graphNodes: graph?.nodeCount ?? graph?.nodes?.length ?? 0,
131
+ graphEdges: graph?.edgeCount ?? graph?.edges?.length ?? 0,
132
+ alignmentNodes: alignmentNodes.length,
133
+ alignmentEdges: alignmentEdges.length,
134
+ rootGraphs: rootGraphs.length,
135
+ sduiMappings: primitiveMap.length,
136
+ proofGates: proofGates.length,
137
+ audienceCounts,
138
+ statusCounts,
139
+ gaps: gaps.length,
140
+ blockedByProjection: gatedGraph.blocked.length,
141
+ diagnostics: (graph?.diagnostics ?? []).length,
142
+ },
143
+ projectionGate: {
144
+ target: gatedGraph.target,
145
+ blocked: gatedGraph.blocked,
146
+ },
147
+ projectionRecords,
148
+ rootGraphs,
149
+ proofGates,
150
+ contracts: {
151
+ conformanceVersion: conformance.version ?? conformance.contractVersion ?? null,
152
+ tenantThemeName: tenantTheme.name ?? tenantTheme.project ?? null,
153
+ tokenReportPresent: Boolean(String(tokenReport).trim()),
154
+ acceptedSduiPrimitiveCount: sduiMap.acceptedSduiPrimitives?.length ?? 0,
155
+ },
156
+ nodes: alignmentNodes,
157
+ edges: alignmentEdges,
158
+ primitiveMap,
159
+ gaps,
160
+ }
161
+
162
+ const comparableProjection = stableProjectionForCheck(projection)
163
+ const comparablePrevious = previousProjection ? stableProjectionForCheck(previousProjection) : null
164
+ if (JSON.stringify(comparableProjection) !== JSON.stringify(comparablePrevious)) {
165
+ projection.generatedAt = now().toISOString()
166
+ }
167
+
168
+ return projection
169
+ }
@@ -0,0 +1,90 @@
1
+ export const LOCAL_AUDIENCES = ['public', 'team', 'operator', 'staff', 'private', 'sensitive']
2
+ export const RUNTIME_VISIBILITIES = ['private', 'shared', 'platform', 'public']
3
+ export const RUNTIME_SPACE_DATA_BOUNDARIES = [
4
+ 'public_shareable',
5
+ 'member_shareable',
6
+ 'client_visible',
7
+ 'practitioner_private',
8
+ 'specially_protected',
9
+ ]
10
+
11
+ export const VALID_AUDIENCES = new Set(LOCAL_AUDIENCES)
12
+ export const VALID_RUNTIME_VISIBILITIES = new Set(RUNTIME_VISIBILITIES)
13
+ export const VALID_RUNTIME_SPACE_DATA_BOUNDARIES = new Set(RUNTIME_SPACE_DATA_BOUNDARIES)
14
+
15
+ export const PROJECTION_TARGETS = {
16
+ public: new Set(['public']),
17
+ team: new Set(['public', 'team']),
18
+ operator: new Set(['public', 'team', 'operator', 'staff']),
19
+ local: new Set(LOCAL_AUDIENCES),
20
+ }
21
+
22
+ export function normalizeAudience(value) {
23
+ return typeof value === 'string' && VALID_AUDIENCES.has(value.trim()) ? value.trim() : null
24
+ }
25
+
26
+ export function projectionTarget(value = 'local') {
27
+ return Object.hasOwn(PROJECTION_TARGETS, value) ? value : 'local'
28
+ }
29
+
30
+ export function canProjectNode(node, target = 'local') {
31
+ const normalizedTarget = projectionTarget(target)
32
+ const audience = normalizeAudience(node?.audience)
33
+ const reasons = []
34
+ if (!audience) reasons.push('missing-or-invalid-audience')
35
+ if (audience && !PROJECTION_TARGETS[normalizedTarget].has(audience)) {
36
+ reasons.push(`audience-${audience}-blocked-for-${normalizedTarget}`)
37
+ }
38
+ return {
39
+ ok: reasons.length === 0,
40
+ audience,
41
+ target: normalizedTarget,
42
+ reasons,
43
+ }
44
+ }
45
+
46
+ export function projectGraph(graph, { target = 'local' } = {}) {
47
+ const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []
48
+ const edges = Array.isArray(graph?.edges) ? graph.edges : []
49
+ const allowedNodes = []
50
+ const blocked = []
51
+ const byId = new Map()
52
+
53
+ for (const node of nodes) {
54
+ const verdict = canProjectNode(node, target)
55
+ if (verdict.ok) {
56
+ allowedNodes.push(node)
57
+ byId.set(node.id, node)
58
+ } else {
59
+ blocked.push({ id: node?.id ?? null, repo: node?.repo ?? null, path: node?.path ?? null, reasons: verdict.reasons })
60
+ }
61
+ }
62
+
63
+ const allowedEdges = []
64
+ for (const edge of edges) {
65
+ if (byId.has(edge.source) && byId.has(edge.target)) allowedEdges.push(edge)
66
+ else blocked.push({ edge, reasons: ['edge-endpoint-blocked'] })
67
+ }
68
+
69
+ return {
70
+ target: projectionTarget(target),
71
+ nodes: allowedNodes,
72
+ edges: allowedEdges,
73
+ blocked,
74
+ }
75
+ }
76
+
77
+ export function publicSourceVerdict(nodes, kgIds) {
78
+ const byId = new Map((nodes ?? []).map((node) => [node.id, node]))
79
+ const failures = []
80
+ for (const kgId of kgIds) {
81
+ const node = byId.get(kgId)
82
+ if (!node) {
83
+ failures.push({ kgId, reason: 'source-node-not-found' })
84
+ continue
85
+ }
86
+ const verdict = canProjectNode(node, 'public')
87
+ if (!verdict.ok) failures.push({ kgId, reason: verdict.reasons.join(','), audience: node.audience ?? null })
88
+ }
89
+ return { ok: failures.length === 0, failures }
90
+ }
@@ -0,0 +1,152 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { commandProject, readJson, writeJson } from '../project/config.mjs'
4
+ import { summarizeReadinessJourney } from '../readiness-protocols/runtime.mjs'
5
+
6
+ const esc = (value) => String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
7
+
8
+ // CSS hex colors come in exactly four widths: #rgb, #rgba, #rrggbb, #rrggbbaa.
9
+ // A bare {3,8} range also admits 5- and 7-digit strings that no browser parses.
10
+ const HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
11
+ const THEME_TOKENS = [
12
+ ['background', '--atelier-bg'],
13
+ ['surface', '--atelier-surface'],
14
+ ['text', '--atelier-text'],
15
+ ['accent', '--atelier-accent'],
16
+ ['eyebrow', '--atelier-eyebrow'],
17
+ ]
18
+ const KNOWN_THEME_TOKENS = new Set(THEME_TOKENS.map(([token]) => token))
19
+
20
+ function resolveDistributionBranding(project) {
21
+ const distribution = project.config?.ext?.['mnstry.atelier']?.distribution ?? {}
22
+ const theme = distribution.theme ?? {}
23
+ // Theme values are interpolated into a <style> block, so the hex-only rule
24
+ // is the CSS-injection guard; a deterministic build must not silently vary.
25
+ // Unknown keys throw for the same reason: a typo that silently drops an
26
+ // override would make two builds of the same config disagree by accident.
27
+ for (const token of Object.keys(theme)) {
28
+ if (!KNOWN_THEME_TOKENS.has(token)) {
29
+ throw new Error(`unknown distribution theme key (theme.${token})`)
30
+ }
31
+ if (typeof theme[token] !== 'string' || !HEX_COLOR.test(theme[token])) {
32
+ throw new Error(`distribution theme values must be hex colors (theme.${token})`)
33
+ }
34
+ }
35
+ const overrides = THEME_TOKENS
36
+ .filter(([token]) => typeof theme[token] === 'string')
37
+ .map(([token, property]) => `${property}:${theme[token]}`)
38
+ return {
39
+ name: distribution.name || 'MNSTRY Atelier',
40
+ eyebrow: distribution.eyebrow || 'local projection',
41
+ themeCss: overrides.length ? `:root{${overrides.join(';')}}\n` : '',
42
+ }
43
+ }
44
+
45
+ export function buildProjectProjection(project) {
46
+ const graph = readJson(project.graphPath)
47
+ const branding = resolveDistributionBranding(project)
48
+ const readinessJourney = summarizeReadinessJourney(project)
49
+ const output = path.join(project.outputRoot, 'index.html')
50
+ const dimensionCards = readinessJourney.dimensions.map((item) => `<article class="readiness-card" data-protocol="${esc(item.protocolId)}">
51
+ <p class="eyebrow">${esc(item.label)}</p>
52
+ <h3>${esc(item.title)}</h3>
53
+ <p><strong>${esc(item.status)}</strong> · ${esc(item.score)} / 100</p>
54
+ <p>${esc(item.blockers[0] || 'review-ready')}</p>
55
+ <button type="button" data-copy="${esc(item.agentPrompt)}">Copy protocol prompt</button>
56
+ </article>`).join('\n')
57
+ const cards = graph.nodes.map((node) => `<article class="card" data-node="${esc(node.id)}">
58
+ <p class="eyebrow">${esc(node.repo)} · ${esc(node.type)} · ${esc(node.audience)}</p>
59
+ <h2>${esc(node.title || node.id)}</h2>
60
+ <p>${esc(node.summary || node.path)}</p>
61
+ </article>`).join('\n')
62
+ const html = `<!doctype html>
63
+ <html lang="en">
64
+ <head>
65
+ <meta charset="utf-8">
66
+ <meta name="viewport" content="width=device-width, initial-scale=1">
67
+ <meta name="mnstry:atelier" content="project-projection">
68
+ <title>${esc(project.config.name || branding.name)}</title>
69
+ <style>
70
+ :root{--atelier-bg:#151312;--atelier-surface:#201a18;--atelier-text:#f4eee5;--atelier-accent:#e3b56e;--atelier-eyebrow:#c39a5b}
71
+ body{margin:0;font:16px/1.5 ui-sans-serif,system-ui;background:var(--atelier-bg);color:var(--atelier-text)}
72
+ main{max-width:1120px;margin:0 auto;padding:32px 20px}
73
+ h1{font-size:clamp(2rem,4vw,4rem);margin:.2em 0}
74
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px}
75
+ .card{border:1px solid #3a302b;border-radius:8px;padding:16px;background:var(--atelier-surface)}
76
+ .readiness{margin:24px 0 32px;padding:20px;border:1px solid #463b34;border-radius:8px;background:#1c1715}
77
+ .readiness-head{display:flex;gap:16px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap}
78
+ .readiness-score{font-size:2rem;font-weight:700;color:var(--atelier-accent)}
79
+ .readiness-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-top:16px}
80
+ .readiness-card{border:1px solid #342b26;border-radius:8px;padding:14px;background:#171312}
81
+ .readiness-card h3{font-size:1.05rem;margin:.2rem 0}
82
+ .readiness-card button{min-height:44px;border:1px solid #755f43;border-radius:6px;background:#241d18;color:var(--atelier-text);padding:8px 10px;cursor:pointer}
83
+ .eyebrow{color:var(--atelier-eyebrow);text-transform:uppercase;letter-spacing:.08em;font-size:.8rem}
84
+ a{color:var(--atelier-accent)}
85
+ ${branding.themeCss}</style>
86
+ </head>
87
+ <body><main>
88
+ <p class="eyebrow">${esc(branding.name)} · ${esc(branding.eyebrow)}</p>
89
+ <h1>${esc(project.config.name || 'Project Workspace')}</h1>
90
+ <p>${graph.counts.nodes} graph nodes · ${graph.counts.edges} graph edges · ${graph.counts.diagnostics} diagnostics</p>
91
+ <section class="readiness" aria-labelledby="tenant-readiness-title">
92
+ <div class="readiness-head">
93
+ <div>
94
+ <p class="eyebrow">MNSTRY Tenant Readiness</p>
95
+ <h2 id="tenant-readiness-title">Readiness Journey</h2>
96
+ <p>${esc(readinessJourney.dimensions.length)} dimensions · next protocol: ${esc(readinessJourney.nextProtocol || 'none')}</p>
97
+ </div>
98
+ <div class="readiness-score">${esc(readinessJourney.score)}%</div>
99
+ </div>
100
+ <div class="readiness-grid">${dimensionCards}</div>
101
+ </section>
102
+ <section class="grid">${cards}</section>
103
+ <script>
104
+ document.addEventListener('click', async (event) => {
105
+ const button = event.target.closest('button[data-copy]')
106
+ if (!button) return
107
+ try {
108
+ await navigator.clipboard.writeText(button.getAttribute('data-copy') || '')
109
+ button.textContent = 'Copied'
110
+ } catch {
111
+ button.textContent = 'Copy failed'
112
+ }
113
+ })
114
+ </script>
115
+ </main></body></html>
116
+ `
117
+ return { output, html, graph, readinessJourney }
118
+ }
119
+
120
+ export function runProjectCommand(argv = process.argv.slice(2)) {
121
+ const check = argv.includes('--check')
122
+ const project = commandProject({ argv })
123
+ const projection = buildProjectProjection(project)
124
+ if (check) {
125
+ const current = fs.existsSync(projection.output) ? fs.readFileSync(projection.output, 'utf8') : null
126
+ if (current !== projection.html) {
127
+ console.error(`project projection is stale: ${projection.output}`)
128
+ process.exit(1)
129
+ }
130
+ } else {
131
+ fs.mkdirSync(path.dirname(projection.output), { recursive: true })
132
+ fs.writeFileSync(projection.output, projection.html)
133
+ writeJson(path.join(project.outputRoot, 'atelier.manifest.json'), {
134
+ schema: 'mnstry.atelier-manifest@v1',
135
+ generatedAt: 'deterministic',
136
+ graphPath: project.graphPath,
137
+ entry: 'index.html',
138
+ tenantReadiness: {
139
+ score: projection.readinessJourney.score,
140
+ ready: projection.readinessJourney.ready,
141
+ dimensions: projection.readinessJourney.dimensions.map((item) => ({
142
+ key: item.key,
143
+ protocolId: item.protocolId,
144
+ status: item.status,
145
+ score: item.score,
146
+ })),
147
+ },
148
+ nodes: projection.graph.nodes.map((node) => ({ id: node.id, title: node.title, audience: node.audience, path: node.path })),
149
+ })
150
+ }
151
+ console.log(`project projection: ${projection.graph.counts.nodes} nodes -> ${projection.output}`)
152
+ }