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

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 (64) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +61 -18
  3. package/contracts/atelier-repository-observation.v1.schema.json +163 -0
  4. package/contracts/public-api-baseline.json +57 -0
  5. package/docs/assurance-controls.md +41 -0
  6. package/docs/atelier-runtime.md +28 -2
  7. package/docs/atelier-sync.md +171 -0
  8. package/docs/blocks/claims.md +23 -12
  9. package/docs/blocks/will-not-do.md +9 -3
  10. package/docs/design.md +12 -6
  11. package/docs/install.md +26 -4
  12. package/docs/knowledge-graph.md +8 -4
  13. package/docs/local-services.md +101 -0
  14. package/docs/release-engineering.md +86 -12
  15. package/docs/repo-boundary-guard.md +12 -2
  16. package/docs/upgrade.md +40 -2
  17. package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
  18. package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
  19. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  20. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  21. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  22. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  23. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  24. package/package.json +16 -5
  25. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  26. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  27. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  28. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  29. package/src/boundary/content-rules.mjs +283 -20
  30. package/src/boundary/policy.mjs +162 -72
  31. package/src/cli/execute-command.mjs +36 -0
  32. package/src/cli/run.mjs +35 -7
  33. package/src/collaboration/event-ledger.mjs +365 -0
  34. package/src/collaboration/index.mjs +17 -0
  35. package/src/collaboration/proposals.mjs +265 -65
  36. package/src/commands/attestation.mjs +20 -6
  37. package/src/commands/disclosure.mjs +133 -0
  38. package/src/commands/distribution.mjs +2 -1
  39. package/src/commands/extension-pack.mjs +2 -1
  40. package/src/commands/init.mjs +2 -1
  41. package/src/commands/server.mjs +1 -4
  42. package/src/commands/sync.mjs +100 -0
  43. package/src/contracts/corpus.mjs +6 -0
  44. package/src/disclosure/content-scan.mjs +193 -0
  45. package/src/egress/check.mjs +7 -38
  46. package/src/egress/forbidden-egress.mjs +32 -18
  47. package/src/graph/graph.mjs +112 -314
  48. package/src/graph/knowledge-graph.mjs +94 -18
  49. package/src/harness/context-client.mjs +9 -1
  50. package/src/index.mjs +41 -0
  51. package/src/project/config.mjs +89 -28
  52. package/src/project/file-class.mjs +14 -0
  53. package/src/project/package-root.mjs +10 -0
  54. package/src/project/path-match.mjs +38 -15
  55. package/src/project/private-state.mjs +110 -0
  56. package/src/runtime/git-adapter.mjs +189 -0
  57. package/src/runtime/local-state.mjs +439 -0
  58. package/src/runtime/repository-observation.mjs +491 -0
  59. package/src/runtime/supervisor.mjs +788 -0
  60. package/src/server/local-sidecar.mjs +81 -59
  61. package/src/server/security.mjs +89 -4
  62. package/src/server/server.mjs +3 -2
  63. package/src/support/feedback-report.mjs +4 -3
  64. package/src/upgrade/upgrade.mjs +2 -1
@@ -4,14 +4,14 @@ import path from 'node:path'
4
4
  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
+ import { sanitizedGitEnvironment } from '../runtime/git-adapter.mjs'
7
8
  import {
8
- diffForPushRange,
9
- parseAddedContent,
10
- parsePushRefUpdates,
9
+ CHECK_AGGREGATE_MAX_BYTES,
10
+ parsePushRefInput,
11
11
  resolveContentRules,
12
- scanAddedContent,
12
+ scanPushUpdate,
13
+ scanStagedRepository,
13
14
  scanTree,
14
- stagedDiff,
15
15
  validateContentRuleExceptions,
16
16
  validateContentRules,
17
17
  } from './content-rules.mjs'
@@ -207,16 +207,16 @@ export function validateBoundaryPolicy(policy, project = null) {
207
207
  return errors
208
208
  }
209
209
 
210
- export function resolveCurrentActor({ policy, project, actor = null, env = process.env } = {}) {
210
+ export function resolveCurrentActor({ policy, project, actor = null, env = process.env, gitExecutable = 'git', allowNetworkActorResolution = true, allowHistoryActorResolution = true } = {}) {
211
211
  const actors = policy?.actors ?? {}
212
212
  const explicit = actor || env.MNSTRY_ATELIER_ACTOR || env.GITHUB_ACTOR
213
213
  if (explicit && actors[explicit]) return { actorId: explicit, source: 'explicit' }
214
- const gitEmails = gitEmailsForProject(project)
214
+ const gitEmails = gitEmailsForProject(project, { gitExecutable, env, allowHistoryActorResolution })
215
215
  for (const [actorId, info] of Object.entries(actors)) {
216
216
  const actorEmails = new Set(asArray(info.gitEmails).map((email) => email.toLowerCase()))
217
217
  if (gitEmails.some((email) => actorEmails.has(email.toLowerCase()))) return { actorId, source: 'git-email', gitEmails }
218
218
  }
219
- const login = env.GITHUB_ACTOR || ghLogin()
219
+ const login = env.GITHUB_ACTOR || (allowNetworkActorResolution ? ghLogin() : null)
220
220
  if (login) {
221
221
  for (const [actorId, info] of Object.entries(actors)) {
222
222
  if (String(info.githubLogin || '').toLowerCase() === String(login).toLowerCase()) return { actorId, source: 'github-login', githubLogin: login }
@@ -225,13 +225,17 @@ export function resolveCurrentActor({ policy, project, actor = null, env = proce
225
225
  return { actorId: null, source: 'unverified', gitEmails, githubLogin: login || null }
226
226
  }
227
227
 
228
- function gitEmailsForProject(project) {
228
+ function gitEmailsForProject(project, { gitExecutable = 'git', env = process.env, allowHistoryActorResolution = true } = {}) {
229
229
  const roots = unique([project?.repoOpsRoot, project?.workspaceRoot, ...managedRepos(project).map((repo) => repo.path)])
230
230
  const emails = []
231
231
  for (const root of roots) {
232
232
  if (!root || !fs.existsSync(root)) continue
233
- const result = spawnSync('git', ['-C', root, 'config', 'user.email'], { encoding: 'utf8' })
234
- if (result.status === 0 && result.stdout.trim()) emails.push(result.stdout.trim())
233
+ const probes = [['config', 'user.email']]
234
+ if (allowHistoryActorResolution) probes.push(['log', '-1', '--format=%ae'])
235
+ for (const args of probes) {
236
+ const result = spawnSync(gitExecutable, ['-C', root, ...args], { encoding: 'utf8', env: sanitizedGitEnvironment(env) })
237
+ if (result.status === 0 && result.stdout.trim()) emails.push(result.stdout.trim())
238
+ }
235
239
  }
236
240
  return unique(emails)
237
241
  }
@@ -276,10 +280,10 @@ function nodePlacementFindings({ node, policy }) {
276
280
  return findings
277
281
  }
278
282
 
279
- function actorFindings({ policy, project, actor }) {
283
+ function actorFindings({ policy, project, actor, gitExecutable, allowNetworkActorResolution, allowHistoryActorResolution, forceActorErrors }) {
280
284
  const findings = []
281
- const current = resolveCurrentActor({ policy, project, actor })
282
- const severity = severityFor(policy)
285
+ const current = resolveCurrentActor({ policy, project, actor, gitExecutable, allowNetworkActorResolution, allowHistoryActorResolution })
286
+ const severity = forceActorErrors ? 'error' : severityFor(policy)
283
287
  for (const [repoName, repo] of Object.entries(policy.repos ?? {})) {
284
288
  if (repo.kind !== 'private_domain') continue
285
289
  if (!repo.ownerActor) continue
@@ -292,11 +296,11 @@ function actorFindings({ policy, project, actor }) {
292
296
  return findings
293
297
  }
294
298
 
295
- export function stagedPathsForProject(project) {
299
+ export function stagedPathsForProject(project, { gitExecutable = 'git' } = {}) {
296
300
  const paths = []
297
301
  for (const repo of managedRepos(project)) {
298
302
  if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
299
- const result = spawnSync('git', ['-C', repo.path, 'diff', '--cached', '--name-only', '--diff-filter=ACMR'], { encoding: 'utf8' })
303
+ const result = spawnSync(gitExecutable, ['-C', repo.path, 'diff', '--cached', '--name-only', '--diff-filter=ACMR'], { encoding: 'utf8', env: sanitizedGitEnvironment() })
300
304
  if (result.status !== 0) continue
301
305
  for (const rel of result.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) {
302
306
  paths.push({ repo: repo.name, repoRoot: repo.path, path: normalize(rel) })
@@ -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) {
@@ -390,13 +394,20 @@ export function semanticChangesInFile(file) {
390
394
  return changes
391
395
  }
392
396
 
393
- function semanticDiffFindings({ policy, project }) {
397
+ function semanticDiffFindings({ policy, project, gitExecutable = 'git' }) {
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
398
- const result = spawnSync('git', ['-C', repo.path, 'diff', '--cached', '--unified=0', '--', '*.md', '*.kg.json'], { encoding: 'utf8' })
399
- if (result.status !== 0 || !result.stdout.trim()) continue
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
+ }
405
+ const result = spawnSync(gitExecutable, ['-C', repo.path, 'diff', '--cached', '--unified=0', '--', '*.md', '*.kg.json'], { encoding: 'utf8', env: sanitizedGitEnvironment() })
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, gitExecutable = 'git' }) {
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, gitExecutable })
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) {
@@ -557,24 +603,22 @@ function promotionFindings({ policy, project, graph }) {
557
603
  return findings
558
604
  }
559
605
 
560
- export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnly = false, actor = null } = {}) {
606
+ export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnly = false, actor = null, gitExecutable = 'git', allowNetworkActorResolution = true, allowHistoryActorResolution = true, forceActorErrors = false } = {}) {
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, gitExecutable, allowNetworkActorResolution, allowHistoryActorResolution, forceActorErrors }))
617
+ if (staged) {
618
+ const stagedPaths = stagedPathsForProject(project, { gitExecutable })
619
+ findings.push(...forbiddenPathFindings({ policy, stagedPaths }))
620
+ findings.push(...semanticDiffFindings({ policy, project, gitExecutable }))
621
+ findings.push(...stagedContentFindings({ policy, project, gitExecutable }))
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,11 +51,14 @@ 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']],
57
59
  ['announcements', ['src/commands/announcements.mjs']],
58
60
  ['announcements:list', ['src/commands/announcements.mjs', 'list']],
61
+ ['sync', ['src/commands/sync.mjs']],
59
62
  ])
60
63
 
61
64
  function isDefaultBrand(brand) {
@@ -113,6 +116,12 @@ Core commands:
113
116
  support bundle --dry-run Preview a no-send support bundle.
114
117
  feedback --message TEXT Write a local, never-sent feedback report.
115
118
  announcements list List and verify MNSTRY announcements.
119
+ sync enroll Explicitly enroll one repository for supervision.
120
+ sync status Fully observe the enrolled repository.
121
+ sync reconcile Fetch and perform only proven fast-forwards.
122
+ sync plan Prepare one bounded, reviewable commit plan.
123
+ sync commit Execute an exactly confirmed commit plan.
124
+ sync run --once Run one full-state supervisor cycle.
116
125
  egress check Check extracted Atelier paths for forbidden egress.
117
126
  boundary check Enforce private/shared repo placement rules.
118
127
  boundary audit Report content-rule matches tree-wide without blocking.
@@ -125,6 +134,7 @@ Core commands:
125
134
  extension-pack validate Validate declared extension packs.
126
135
  extension-pack list List declared extension packs.
127
136
  distribution check Check a distribution for MNSTRY attribution.
137
+ disclosure check Scan tracked or staged content for disclosure risks.
128
138
 
129
139
  Attestation commands:
130
140
  attestation hash FILE Print the canonical payload hash of a payload.
@@ -132,8 +142,10 @@ Attestation commands:
132
142
  attestation verify FILE Verify an attestation against a public key file.
133
143
  attestation keygen --key-id ID Generate a signing key pair.
134
144
 
135
- Every project-aware command accepts --project-config=PATH or
136
- MNSTRY_ATELIER_PROJECT_CONFIG=PATH. Machine-local repo paths belong in
145
+ Commands whose usage names --project accept --project=PATH or --project PATH.
146
+ The project resolver also accepts --project-config=PATH and
147
+ MNSTRY_ATELIER_PROJECT_CONFIG=PATH; each command's own help is authoritative.
148
+ Machine-local repo paths belong in
137
149
  .atelier-local/, atelier.local.json, or atelier.workspace.local.json.`
138
150
  }
139
151
 
@@ -152,9 +164,9 @@ Ensures ignored local Atelier state exists, verifies ignore coverage, and record
152
164
  doctor: `Usage: ${c} doctor [--project ./atelier.project.json] [--fix] [--dry-run]
153
165
 
154
166
  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]
167
+ boundary: `Usage: ${c} boundary check|push-check|audit|install-hooks [--project ./atelier.project.json] [--staged] [--source=working-tree|head]
156
168
 
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.`,
169
+ 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
170
  graph: `Usage: ${c} graph [--check] [--project ./atelier.project.json]
159
171
 
160
172
  Builds or checks the project knowledge graph from tracked sources plus ignored local path bindings.`,
@@ -175,21 +187,35 @@ Loads every extension pack declared under ext["mnstry.atelier"].extensionPacks i
175
187
  distribution: `Usage: ${c} distribution check [--target DIR] [--pack DIR]
176
188
 
177
189
  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.`,
190
+ disclosure: `Usage: ${c} disclosure check [--root DIR] [--staged] [--denylist FILE | --structural-only] [--fail-on-binary] [--untrusted]
191
+
192
+ 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
193
  attestation: `Usage:
179
194
  ${c} attestation hash <payload.json>
180
195
  ${c} attestation sign <attestation.json> [--key FILE] [--out FILE]
181
196
  ${c} attestation verify <attestation.json> --public-key FILE [--payload FILE] [--json]
182
197
  ${c} attestation keygen --key-id ID [--algorithm ed25519|es256] [--out FILE]
183
198
 
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.`,
199
+ 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
200
  feedback: `Usage:
186
201
  ${c} feedback create --message TEXT | --message-file PATH [--context FILE] [--include-gates]
187
202
  ${c} feedback check FILE
188
203
 
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.`,
204
+ 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
205
  announcements: `Usage: ${c} announcements list [--dir DIR] [--public-key FILE] | verify <file> [--public-key FILE] [--json] | show <file> [--public-key FILE]
191
206
 
192
207
  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.`,
208
+ sync: `Usage:
209
+ ${c} sync enroll --repo DIR [--project atelier.project.json] [--git ABSOLUTE_PATH]
210
+ ${c} sync status --repo DIR
211
+ ${c} sync reconcile --repo DIR [--retries 3]
212
+ ${c} sync run --repo DIR [--once] [--interval 30]
213
+ ${c} sync plan --repo DIR --path FILE [--path FILE] --message TEXT [--publish]
214
+ ${c} sync commit --repo DIR --operation ID --confirm ID
215
+ ${c} sync pause|freeze|resume --repo DIR
216
+ ${c} sync trace --repo DIR
217
+
218
+ Enrolls exactly one repository and keeps Git plus readable files authoritative. Reconciliation observes the complete repository every cycle and performs only fast-forward updates. Commit creation is a two-phase user-confirmed operation: plan shows one bounded change set, and commit refuses unless the repository is unchanged and --confirm exactly repeats the operation id. No semantic conflict resolution, force operation, browser apply endpoint, telemetry, or desktop shell is present.`,
193
219
  }
194
220
  return help[command] || `Usage: ${c} ${command} [args]\n\nRun ${c} --help for the command list.`
195
221
  }
@@ -219,6 +245,7 @@ function normalizeArgs(argv) {
219
245
  if (args[0] === 'extension-pack' && args[1] === 'validate') args.splice(0, 2, 'extension-pack:validate')
220
246
  if (args[0] === 'extension-pack' && args[1] === 'list') args.splice(0, 2, 'extension-pack:list')
221
247
  if (args[0] === 'distribution' && args[1] === 'check') args.splice(0, 2, 'distribution:check')
248
+ if (args[0] === 'disclosure' && args[1] === 'check') args.splice(0, 2, 'disclosure:check')
222
249
  if (args[0] === 'support' && args[1] === 'bundle') args.splice(0, 2, 'support:bundle')
223
250
  if (args[0] === 'support:bundle' && args[1] === '--dry-run') args.splice(1, 1)
224
251
  return { help: false, args }
@@ -261,12 +288,13 @@ export async function runCli({
261
288
 
262
289
  const [script, ...prefixArgs] = target
263
290
  const scriptPath = path.join(packageRoot, script)
291
+ const executorPath = path.join(packageRoot, 'src', 'cli', 'execute-command.mjs')
264
292
  if (!fs.existsSync(scriptPath)) {
265
293
  stderr(`${brand.displayName} command is not available in this package install: ${command}`)
266
294
  return 1
267
295
  }
268
296
 
269
- const result = spawnSync(process.execPath, [scriptPath, ...prefixArgs, ...rest], {
297
+ const result = spawnSync(process.execPath, [executorPath, scriptPath, ...prefixArgs, ...rest], {
270
298
  cwd,
271
299
  stdio: 'inherit',
272
300
  env: {