@mnstry/atelier 0.2.0-alpha.4 → 0.2.0-alpha.5

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 (53) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +38 -12
  3. package/contracts/public-api-baseline.json +57 -0
  4. package/docs/assurance-controls.md +39 -0
  5. package/docs/atelier-runtime.md +15 -0
  6. package/docs/blocks/claims.md +15 -9
  7. package/docs/design.md +12 -6
  8. package/docs/install.md +26 -4
  9. package/docs/knowledge-graph.md +8 -4
  10. package/docs/local-services.md +101 -0
  11. package/docs/release-engineering.md +75 -10
  12. package/docs/repo-boundary-guard.md +12 -2
  13. package/docs/upgrade.md +25 -2
  14. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  15. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  16. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  17. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  18. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  19. package/package.json +12 -5
  20. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  21. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  22. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  23. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  24. package/src/boundary/content-rules.mjs +278 -20
  25. package/src/boundary/policy.mjs +150 -60
  26. package/src/cli/execute-command.mjs +36 -0
  27. package/src/cli/run.mjs +17 -7
  28. package/src/collaboration/event-ledger.mjs +365 -0
  29. package/src/collaboration/index.mjs +17 -0
  30. package/src/collaboration/proposals.mjs +265 -65
  31. package/src/commands/attestation.mjs +20 -6
  32. package/src/commands/disclosure.mjs +133 -0
  33. package/src/commands/distribution.mjs +2 -1
  34. package/src/commands/extension-pack.mjs +2 -1
  35. package/src/commands/init.mjs +2 -1
  36. package/src/commands/server.mjs +1 -4
  37. package/src/disclosure/content-scan.mjs +193 -0
  38. package/src/egress/check.mjs +7 -38
  39. package/src/egress/forbidden-egress.mjs +32 -18
  40. package/src/graph/graph.mjs +112 -314
  41. package/src/graph/knowledge-graph.mjs +94 -18
  42. package/src/harness/context-client.mjs +9 -1
  43. package/src/index.mjs +12 -0
  44. package/src/project/config.mjs +66 -7
  45. package/src/project/file-class.mjs +14 -0
  46. package/src/project/package-root.mjs +10 -0
  47. package/src/project/path-match.mjs +38 -15
  48. package/src/project/private-state.mjs +110 -0
  49. package/src/server/local-sidecar.mjs +81 -59
  50. package/src/server/security.mjs +89 -4
  51. package/src/server/server.mjs +3 -2
  52. package/src/support/feedback-report.mjs +4 -3
  53. package/src/upgrade/upgrade.mjs +2 -1
@@ -5,13 +5,12 @@ import { buildGraph } from '../graph/graph.mjs'
5
5
  import { commandProject, firstString, parseArgs, readJson, resolvePathValue, writeJson } from '../project/config.mjs'
6
6
  import { matchesPathPattern } from '../project/path-match.mjs'
7
7
  import {
8
- diffForPushRange,
9
- parseAddedContent,
10
- parsePushRefUpdates,
8
+ CHECK_AGGREGATE_MAX_BYTES,
9
+ parsePushRefInput,
11
10
  resolveContentRules,
12
- scanAddedContent,
11
+ scanPushUpdate,
12
+ scanStagedRepository,
13
13
  scanTree,
14
- stagedDiff,
15
14
  validateContentRuleExceptions,
16
15
  validateContentRules,
17
16
  } from './content-rules.mjs'
@@ -230,8 +229,13 @@ function gitEmailsForProject(project) {
230
229
  const emails = []
231
230
  for (const root of roots) {
232
231
  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())
232
+ for (const args of [
233
+ ['config', 'user.email'],
234
+ ['log', '-1', '--format=%ae'],
235
+ ]) {
236
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' })
237
+ if (result.status === 0 && result.stdout.trim()) emails.push(result.stdout.trim())
238
+ }
235
239
  }
236
240
  return unique(emails)
237
241
  }
@@ -308,7 +312,7 @@ export function stagedPathsForProject(project) {
308
312
  function forbiddenPathFindings({ policy, stagedPaths }) {
309
313
  const patterns = [...DEFAULT_FORBIDDEN_PATHS, ...asArray(policy.forbiddenPaths)]
310
314
  const findings = []
311
- const severity = severityFor(policy)
315
+ const severity = 'error'
312
316
  for (const item of stagedPaths) {
313
317
  const matched = patterns.find((pattern) => matchesPathPattern(pattern, item.path))
314
318
  if (matched) {
@@ -392,11 +396,18 @@ export function semanticChangesInFile(file) {
392
396
 
393
397
  function semanticDiffFindings({ policy, project }) {
394
398
  const findings = []
395
- const severity = severityFor(policy)
399
+ const severity = 'error'
396
400
  for (const repo of managedRepos(project)) {
397
- if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
401
+ if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) {
402
+ findings.push(unscannableRepoFinding(repo, 'configured path is absent or is not a Git checkout'))
403
+ continue
404
+ }
398
405
  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
406
+ if (result.status !== 0) {
407
+ findings.push(unscannableRepoFinding(repo, 'git diff --cached failed'))
408
+ continue
409
+ }
410
+ if (!result.stdout.trim()) continue
400
411
  for (const file of diffFileSections(result.stdout)) {
401
412
  const changes = semanticChangesInFile(file)
402
413
  if (!changes.length) continue
@@ -419,27 +430,32 @@ function semanticDiffFindings({ policy, project }) {
419
430
  return findings
420
431
  }
421
432
 
422
- function contentRuleFindings({ policy, project, files }) {
433
+ function stagedContentFindings({ policy, project }) {
423
434
  const rules = resolveContentRules(policy)
424
435
  const exceptions = asArray(policy?.contentRuleExceptions)
425
436
  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 })
437
+ let totalBytes = 0
438
+ for (const repo of managedRepos(project)) {
439
+ if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) {
440
+ findings.push(unscannableRepoFinding(repo, 'configured path is absent or is not a Git checkout'))
441
+ continue
442
+ }
443
+ const result = scanStagedRepository({ repoRoot: repo.path, rules, exceptions, repo: repo.name })
444
+ findings.push(...result.findings, ...result.diagnostics)
445
+ totalBytes += result.bytes
446
+ if (totalBytes > CHECK_AGGREGATE_MAX_BYTES) {
447
+ findings.push(finding({
448
+ severity: 'error',
449
+ code: 'content-scan-incomplete',
450
+ repo: repo.name,
451
+ message: `staged evidence exceeds the ${CHECK_AGGREGATE_MAX_BYTES}-byte aggregate limit`,
452
+ }))
453
+ break
431
454
  }
432
455
  }
433
456
  return findings
434
457
  }
435
458
 
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
459
  function realPath(value) {
444
460
  try {
445
461
  return fs.realpathSync(path.resolve(value))
@@ -467,7 +483,7 @@ function repoForCwd(project, cwd) {
467
483
  * which reports without blocking, so an accepted usage elsewhere in the repo can
468
484
  * never strand unrelated work on the machine.
469
485
  */
470
- export function checkPushContent({ project, policy, repo, updates, cwd = process.cwd() } = {}) {
486
+ export function checkPushContent({ project, policy, repo, updates, cwd = process.cwd(), gitRunner = spawnSync } = {}) {
471
487
  const target = repo ?? repoForCwd(project, cwd)
472
488
  if (!target) {
473
489
  // Fail closed. This hook is only installed into managed repos, so failing to
@@ -480,20 +496,41 @@ export function checkPushContent({ project, policy, repo, updates, cwd = process
480
496
  })
481
497
  return { ok: false, repo: null, findings: [unresolved], errors: [unresolved], warnings: [] }
482
498
  }
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 }] })
499
+ const rules = resolveContentRules(policy)
500
+ const exceptions = asArray(policy?.contentRuleExceptions)
501
+ const findings = []
502
+ let totalBytes = 0
503
+ for (const update of updates) {
504
+ const result = scanPushUpdate({ repoRoot: target.path, update, rules, exceptions, repo: target.name, gitRunner })
505
+ findings.push(...result.findings, ...result.diagnostics)
506
+ totalBytes += result.bytes
507
+ if (totalBytes > CHECK_AGGREGATE_MAX_BYTES) {
508
+ findings.push(finding({
509
+ severity: 'error',
510
+ code: 'content-scan-incomplete',
511
+ repo: target.name,
512
+ message: `push evidence exceeds the ${CHECK_AGGREGATE_MAX_BYTES}-byte aggregate limit`,
513
+ }))
514
+ break
515
+ }
516
+ }
486
517
  const errors = findings.filter((item) => item.severity === 'error')
487
518
  return { ok: errors.length === 0, repo: target.name, updates, findings, errors, warnings: findings.filter((item) => item.severity !== 'error') }
488
519
  }
489
520
 
490
- export function auditContentRules({ project, policy } = {}) {
521
+ export function auditContentRules({ project, policy, source = 'working-tree' } = {}) {
491
522
  const rules = resolveContentRules(policy)
492
523
  const exceptions = asArray(policy?.contentRuleExceptions)
493
524
  const findings = []
525
+ const diagnostics = []
494
526
  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 }))
527
+ if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) {
528
+ diagnostics.push(unscannableRepoFinding(repo, 'configured path is absent or is not a Git checkout'))
529
+ continue
530
+ }
531
+ const result = scanTree(repo.path, { rules, exceptions, repo: repo.name, source })
532
+ findings.push(...result.findings)
533
+ diagnostics.push(...result.diagnostics)
497
534
  }
498
535
  const accepted = []
499
536
  for (const repo of managedRepos(project)) {
@@ -502,7 +539,16 @@ export function auditContentRules({ project, policy } = {}) {
502
539
  accepted.push({ repo: repo.name, rule: exception.rule, paths: exception.paths, reason: exception.reason })
503
540
  }
504
541
  }
505
- return { schema: BOUNDARY_POLICY_SCHEMA, findings, accepted }
542
+ return { schema: BOUNDARY_POLICY_SCHEMA, source, findings, diagnostics, accepted }
543
+ }
544
+
545
+ function unscannableRepoFinding(repo, reason) {
546
+ return finding({
547
+ severity: 'error',
548
+ code: 'repo-unscannable',
549
+ repo: repo?.name ?? null,
550
+ message: `${repo?.name ?? '(unnamed repo)'} cannot be scanned: ${reason}`,
551
+ })
506
552
  }
507
553
 
508
554
  function promotionRecords(project, policy) {
@@ -560,21 +606,19 @@ function promotionFindings({ policy, project, graph }) {
560
606
  export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnly = false, actor = null } = {}) {
561
607
  const validationErrors = validateBoundaryPolicy(policy, project)
562
608
  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
- }
609
+ const findings = validationErrors.map((message) => finding({ severity: 'error', code: 'boundary-policy-invalid', message }))
610
+ if (!stagedOnly) {
611
+ graph = buildGraph(project)
612
+ findings.push(...graph.errors.map((message) => finding({ severity: 'error', code: 'knowledge-graph-invalid', message })))
613
+ for (const node of graph.nodes ?? []) findings.push(...nodePlacementFindings({ node, policy }))
614
+ findings.push(...promotionFindings({ policy, project, graph }))
615
+ }
616
+ findings.push(...actorFindings({ policy, project, actor }))
617
+ if (staged) {
618
+ const stagedPaths = stagedPathsForProject(project)
619
+ findings.push(...forbiddenPathFindings({ policy, stagedPaths }))
620
+ findings.push(...semanticDiffFindings({ policy, project }))
621
+ findings.push(...stagedContentFindings({ policy, project }))
578
622
  }
579
623
  const errors = findings.filter((item) => item.severity === 'error')
580
624
  const warnings = findings.filter((item) => item.severity !== 'error')
@@ -616,12 +660,12 @@ export function installBoundaryHooks({ project, force = false } = {}) {
616
660
  const skipped = []
617
661
  for (const repo of managedRepos(project)) {
618
662
  if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
619
- const hooksDir = path.join(repo.path, '.git', 'hooks')
663
+ const hooksDir = gitHooksDir(repo.path)
620
664
  fs.mkdirSync(hooksDir, { recursive: true })
621
665
  for (const hookName of ['pre-commit', 'pre-push']) {
622
666
  const hookPath = path.join(hooksDir, hookName)
623
667
  const existing = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, 'utf8') : ''
624
- const script = hookScript(project.configPath, hookName)
668
+ const script = boundaryHookScript(project.configPath, hookName, repo.path)
625
669
  if (existing && !existing.includes('MNSTRY_ATELIER_BOUNDARY_GUARD') && !force) {
626
670
  const sidecar = `${hookPath}.mnstry-atelier-boundary`
627
671
  fs.writeFileSync(sidecar, script)
@@ -637,8 +681,32 @@ export function installBoundaryHooks({ project, force = false } = {}) {
637
681
  return { installed, skipped }
638
682
  }
639
683
 
640
- function hookScript(projectConfigPath, hookName) {
641
- const config = projectConfigPath ? `--project-config=${projectConfigPath.replaceAll('"', '\\"')}` : ''
684
+ function gitHooksDir(repoPath) {
685
+ const result = spawnSync('git', ['-C', repoPath, 'rev-parse', '--git-path', 'hooks'], { encoding: 'utf8' })
686
+ if (result.status !== 0 || !result.stdout.trim()) throw new Error(`cannot resolve Git hooks directory for ${repoPath}`)
687
+ const value = result.stdout.trim()
688
+ return path.isAbsolute(value) ? value : path.resolve(repoPath, value)
689
+ }
690
+
691
+ function shellSingleQuote(value) {
692
+ const text = String(value ?? '')
693
+ if (!text || /[\0\r\n]/.test(text)) throw new Error('project config path contains unsupported control characters')
694
+ return `'${text.replaceAll("'", `'"'"'`)}'`
695
+ }
696
+
697
+ export function boundaryHookScript(projectConfigPath, hookName, repoPath) {
698
+ const relativeConfigPath = projectConfigPath && repoPath ? path.relative(repoPath, projectConfigPath) : null
699
+ const configLivesInRepo = relativeConfigPath && relativeConfigPath !== '..' && !relativeConfigPath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeConfigPath)
700
+ const portableRelativeConfig = configLivesInRepo ? relativeConfigPath.replaceAll(path.sep, '/') : null
701
+ const configSetup = configLivesInRepo
702
+ ? `ATELIER_HOOK_REPO_ROOT="$(git rev-parse --show-toplevel)"
703
+ ATELIER_HOOK_PROJECT_CONFIG="$ATELIER_HOOK_REPO_ROOT"/${shellSingleQuote(portableRelativeConfig)}`
704
+ : ''
705
+ const config = configLivesInRepo
706
+ ? '--project-config="$ATELIER_HOOK_PROJECT_CONFIG"'
707
+ : projectConfigPath
708
+ ? `--project-config=${shellSingleQuote(projectConfigPath)}`
709
+ : ''
642
710
  // pre-push reads the ref updates git writes to stdin and judges only that range;
643
711
  // pre-commit judges the staged diff. Neither scans the whole tree — that view is
644
712
  // `atelier boundary audit`, which reports without blocking.
@@ -646,6 +714,7 @@ function hookScript(projectConfigPath, hookName) {
646
714
  return `#!/usr/bin/env bash
647
715
  # MNSTRY_ATELIER_BOUNDARY_GUARD ${hookName}
648
716
  set -euo pipefail
717
+ ${configSetup}
649
718
  if command -v atelier >/dev/null 2>&1; then
650
719
  exec atelier ${invocation}
651
720
  elif [ -x "./node_modules/.bin/atelier" ]; then
@@ -663,7 +732,7 @@ fi
663
732
  `
664
733
  }
665
734
 
666
- export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdin = readStdin() } = {}) {
735
+ export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdin = readBoundaryPushInput() } = {}) {
667
736
  const args = parseArgs(argv)
668
737
  const project = commandProject({ argv })
669
738
  const loaded = loadBoundaryPolicy(project)
@@ -671,12 +740,23 @@ export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdi
671
740
  console.error(loaded.errors.join('\n'))
672
741
  process.exit(1)
673
742
  }
674
- const updates = parsePushRefUpdates(stdin)
675
- if (!updates.length) {
743
+ const input = stdin && typeof stdin === 'object' && Object.hasOwn(stdin, 'ok')
744
+ ? stdin
745
+ : { ok: true, text: String(stdin ?? '') }
746
+ if (!input.ok) {
747
+ console.error('[boundary:push-check] could not read pre-push ref updates')
748
+ process.exit(1)
749
+ }
750
+ const parsed = parsePushRefInput(input.text)
751
+ if (!parsed.ok) {
752
+ console.error(`[boundary:push-check] invalid pre-push input: ${parsed.issues.join('; ')}`)
753
+ process.exit(1)
754
+ }
755
+ if (parsed.kind === 'empty') {
676
756
  console.log('[boundary:push-check] no ref updates on stdin · nothing to judge')
677
757
  process.exit(0)
678
758
  }
679
- const report = checkPushContent({ project, policy: loaded.policy, updates, cwd: process.cwd() })
759
+ const report = checkPushContent({ project, policy: loaded.policy, updates: parsed.updates, cwd: process.cwd() })
680
760
  if (args.json) {
681
761
  console.log(JSON.stringify(report, null, 2))
682
762
  } else {
@@ -691,6 +771,10 @@ export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdi
691
771
  process.exit(report.ok ? 0 : 1)
692
772
  }
693
773
 
774
+ export function resolveBoundaryAuditSource(args = {}) {
775
+ return args.head ? 'head' : firstString(args.source) || 'working-tree'
776
+ }
777
+
694
778
  export function runBoundaryAuditCommand(argv = process.argv.slice(2)) {
695
779
  const args = parseArgs(argv)
696
780
  const project = commandProject({ argv })
@@ -699,23 +783,29 @@ export function runBoundaryAuditCommand(argv = process.argv.slice(2)) {
699
783
  console.error(loaded.errors.join('\n'))
700
784
  process.exit(1)
701
785
  }
702
- const report = auditContentRules({ project, policy: loaded.policy })
786
+ const source = resolveBoundaryAuditSource(args)
787
+ if (!['working-tree', 'head'].includes(source)) {
788
+ console.error('boundary audit --source must be working-tree or head')
789
+ process.exit(1)
790
+ }
791
+ const report = auditContentRules({ project, policy: loaded.policy, source })
703
792
  if (args.json) {
704
793
  console.log(JSON.stringify(report, null, 2))
705
794
  } else {
706
- console.log(`[boundary:audit] ${report.findings.length} content-rule matches across the tree · ${report.accepted.length} declared exceptions`)
795
+ console.log(`[boundary:audit] ${report.findings.length} content-rule matches · ${report.diagnostics.length} incomplete reads · ${report.accepted.length} declared exceptions · source ${report.source}`)
707
796
  for (const item of report.findings.slice(0, 100)) console.log(`${item.severity} ${item.rule}: ${item.message}`)
797
+ for (const item of report.diagnostics.slice(0, 100)) console.log(`${item.severity} ${item.code}: ${item.message}`)
708
798
  for (const item of report.accepted) console.log(`accepted ${item.rule} in ${item.repo} (${item.paths.join(', ')}): ${item.reason}`)
709
799
  }
710
800
  // Reporting only. A pre-existing accepted usage must never block unrelated work.
711
801
  process.exit(0)
712
802
  }
713
803
 
714
- function readStdin() {
804
+ export function readBoundaryPushInput(reader = () => fs.readFileSync(0, 'utf8')) {
715
805
  try {
716
- return fs.readFileSync(0, 'utf8')
806
+ return { ok: true, text: reader() }
717
807
  } catch {
718
- return ''
808
+ return { ok: false, text: '', error: 'stdin-read-failed' }
719
809
  }
720
810
  }
721
811
 
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+
6
+ const [, , requestedScript, ...args] = process.argv
7
+
8
+ function renderError(error) {
9
+ const debug = process.env.ATELIER_DEBUG === '1'
10
+ if (debug) {
11
+ console.error(error?.stack || String(error))
12
+ return Number.isInteger(error?.exitCode) ? error.exitCode : 1
13
+ }
14
+ if (typeof error?.code === 'string' && error.code) {
15
+ console.error(`[${error.code}] ${error.message}`)
16
+ if (error.hint) console.error(`Next: ${error.hint}`)
17
+ return Number.isInteger(error.exitCode) ? error.exitCode : 2
18
+ }
19
+ console.error('[internal-error] command failed without a safe diagnostic')
20
+ console.error('Next: rerun with ATELIER_DEBUG=1 to inspect the stack locally.')
21
+ return 1
22
+ }
23
+
24
+ if (!requestedScript) {
25
+ console.error('[command-missing] no command module was selected')
26
+ process.exit(2)
27
+ }
28
+
29
+ const scriptPath = path.resolve(requestedScript)
30
+ process.argv = [process.execPath, scriptPath, ...args]
31
+
32
+ try {
33
+ await import(pathToFileURL(scriptPath).href)
34
+ } catch (error) {
35
+ process.exitCode = renderError(error)
36
+ }
package/src/cli/run.mjs CHANGED
@@ -51,6 +51,8 @@ export const commandMap = new Map([
51
51
  ['extension-pack:list', ['src/commands/extension-pack.mjs', 'list']],
52
52
  ['distribution', ['src/commands/distribution.mjs']],
53
53
  ['distribution:check', ['src/commands/distribution.mjs', 'check']],
54
+ ['disclosure', ['src/commands/disclosure.mjs']],
55
+ ['disclosure:check', ['src/commands/disclosure.mjs', 'check']],
54
56
  ['attestation', ['src/commands/attestation.mjs']],
55
57
  ['feedback', ['src/commands/feedback.mjs']],
56
58
  ['feedback:check', ['src/commands/feedback.mjs', 'check']],
@@ -125,6 +127,7 @@ Core commands:
125
127
  extension-pack validate Validate declared extension packs.
126
128
  extension-pack list List declared extension packs.
127
129
  distribution check Check a distribution for MNSTRY attribution.
130
+ disclosure check Scan tracked or staged content for disclosure risks.
128
131
 
129
132
  Attestation commands:
130
133
  attestation hash FILE Print the canonical payload hash of a payload.
@@ -132,8 +135,10 @@ Attestation commands:
132
135
  attestation verify FILE Verify an attestation against a public key file.
133
136
  attestation keygen --key-id ID Generate a signing key pair.
134
137
 
135
- Every project-aware command accepts --project-config=PATH or
136
- MNSTRY_ATELIER_PROJECT_CONFIG=PATH. Machine-local repo paths belong in
138
+ Commands whose usage names --project accept --project=PATH or --project PATH.
139
+ The project resolver also accepts --project-config=PATH and
140
+ MNSTRY_ATELIER_PROJECT_CONFIG=PATH; each command's own help is authoritative.
141
+ Machine-local repo paths belong in
137
142
  .atelier-local/, atelier.local.json, or atelier.workspace.local.json.`
138
143
  }
139
144
 
@@ -152,9 +157,9 @@ Ensures ignored local Atelier state exists, verifies ignore coverage, and record
152
157
  doctor: `Usage: ${c} doctor [--project ./atelier.project.json] [--fix] [--dry-run]
153
158
 
154
159
  Reports project config, local overlay, and repo boundary readiness. --fix only repairs ignored local state.`,
155
- boundary: `Usage: ${c} boundary check|push-check|audit|install-hooks [--project ./atelier.project.json] [--staged]
160
+ boundary: `Usage: ${c} boundary check|push-check|audit|install-hooks [--project ./atelier.project.json] [--staged] [--source=working-tree|head]
156
161
 
157
- check --staged judges the staged diff. push-check reads pre-push ref updates on stdin and judges only the pushed range. audit scans the whole tree and reports without blocking, so an accepted usage never strands unrelated work.`,
162
+ check --staged judges the staged diff. push-check reads pre-push ref updates on stdin and judges only the pushed range. audit scans the working tree by default and reports without blocking; --source=head selects the committed snapshot.`,
158
163
  graph: `Usage: ${c} graph [--check] [--project ./atelier.project.json]
159
164
 
160
165
  Builds or checks the project knowledge graph from tracked sources plus ignored local path bindings.`,
@@ -175,18 +180,21 @@ Loads every extension pack declared under ext["mnstry.atelier"].extensionPacks i
175
180
  distribution: `Usage: ${c} distribution check [--target DIR] [--pack DIR]
176
181
 
177
182
  Checks a distribution package for the required MNSTRY attribution markers. Blocking: the distribution README.md byte check, and a CLI probe that EXECUTES the target's declared bin with --version (spawned with the current Node, cwd set to the target — only run this against distributions you trust) and requires the attribution in its output; a target that looks like a distribution but declares no probe-able bin, or ships a malformed package.json, is also blocking. The extension-pack manifest attribution key is advisory and reported only. The normative wording lives in TRADEMARKS.md under "Required attribution"; see also docs/attestation.md and docs/distributions.md.`,
183
+ disclosure: `Usage: ${c} disclosure check [--root DIR] [--staged] [--denylist FILE | --structural-only] [--fail-on-binary] [--untrusted]
184
+
185
+ Scans Git-tracked files, or staged index blobs with --staged, without following symlinks. A private denylist is required by default and must be supplied through ATELIER_DENYLIST_JSON, --denylist, or ignored .atelier-local/disclosure-denylist.json. --structural-only is the explicit no-denylist lane. --untrusted suppresses finding details.`,
178
186
  attestation: `Usage:
179
187
  ${c} attestation hash <payload.json>
180
188
  ${c} attestation sign <attestation.json> [--key FILE] [--out FILE]
181
189
  ${c} attestation verify <attestation.json> --public-key FILE [--payload FILE] [--json]
182
190
  ${c} attestation keygen --key-id ID [--algorithm ed25519|es256] [--out FILE]
183
191
 
184
- Records and checks admission decisions. hash prints the canonical payload hash (RFC 8785 JCS, SHA-256). sign reads the local signing key file. verify reads a public key file and exits 1 when it judges the attestation invalid. keygen writes the signing key file mode 0600, refuses to overwrite, and prints only the public key document.`,
192
+ Records and checks admission decisions. hash prints the canonical payload hash (RFC 8785 JCS, SHA-256). sign reads the local signing key file. verify reads a public key file and exits 1 when it judges the attestation invalid. keygen writes the signing key file mode 0600 on POSIX, refuses to overwrite, and prints only the public key document.`,
185
193
  feedback: `Usage:
186
194
  ${c} feedback create --message TEXT | --message-file PATH [--context FILE] [--include-gates]
187
195
  ${c} feedback check FILE
188
196
 
189
- Assembles a local feedback report under ignored .atelier-local/feedback/ (mode 0600), scanned with the support-bundle banned key and value patterns before writing; any match refuses the write naming pattern label and location only. Files given to --message-file and --context must be valid UTF-8 text of at most 262144 bytes. The scan is a backstop, not clearance: read the whole report before sharing it. The kit has no send path — sharing the file is always the user's own explicit act.`,
197
+ Assembles a local feedback report under ignored .atelier-local/feedback/ (mode 0600 on POSIX), scanned with the support-bundle banned key and value patterns before writing; any match refuses the write naming pattern label and location only. Files given to --message-file and --context must be valid UTF-8 text of at most 262144 bytes. The scan is a backstop, not clearance: read the whole report before sharing it. The kit has no send path — sharing the file is always the user's own explicit act.`,
190
198
  announcements: `Usage: ${c} announcements list [--dir DIR] [--public-key FILE] | verify <file> [--public-key FILE] [--json] | show <file> [--public-key FILE]
191
199
 
192
200
  MNSTRY announcements are a pull-only channel: signed JSON documents under announcements/ in the repository. The trust anchor is always the committed MNSTRY key, or one you pass explicitly with --public-key; --dir changes only where documents are read from and never which key verifies them. Every run names the key and keyId it used. The kit never fetches anything — receiving announcements is the git pull you chose to run, and show refuses to print a body whose signature does not verify.`,
@@ -219,6 +227,7 @@ function normalizeArgs(argv) {
219
227
  if (args[0] === 'extension-pack' && args[1] === 'validate') args.splice(0, 2, 'extension-pack:validate')
220
228
  if (args[0] === 'extension-pack' && args[1] === 'list') args.splice(0, 2, 'extension-pack:list')
221
229
  if (args[0] === 'distribution' && args[1] === 'check') args.splice(0, 2, 'distribution:check')
230
+ if (args[0] === 'disclosure' && args[1] === 'check') args.splice(0, 2, 'disclosure:check')
222
231
  if (args[0] === 'support' && args[1] === 'bundle') args.splice(0, 2, 'support:bundle')
223
232
  if (args[0] === 'support:bundle' && args[1] === '--dry-run') args.splice(1, 1)
224
233
  return { help: false, args }
@@ -261,12 +270,13 @@ export async function runCli({
261
270
 
262
271
  const [script, ...prefixArgs] = target
263
272
  const scriptPath = path.join(packageRoot, script)
273
+ const executorPath = path.join(packageRoot, 'src', 'cli', 'execute-command.mjs')
264
274
  if (!fs.existsSync(scriptPath)) {
265
275
  stderr(`${brand.displayName} command is not available in this package install: ${command}`)
266
276
  return 1
267
277
  }
268
278
 
269
- const result = spawnSync(process.execPath, [scriptPath, ...prefixArgs, ...rest], {
279
+ const result = spawnSync(process.execPath, [executorPath, scriptPath, ...prefixArgs, ...rest], {
270
280
  cwd,
271
281
  stdio: 'inherit',
272
282
  env: {