@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.15

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 (253) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +102 -52
  3. package/SECURITY.md +1 -1
  4. package/TRADEMARKS.md +2 -2
  5. package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
  6. package/contracts/atelier-graph-view.v1.schema.json +1085 -0
  7. package/contracts/public-api-baseline.json +298 -19
  8. package/docs/README.md +90 -0
  9. package/docs/blocks/capability-stewardship.md +1 -1
  10. package/docs/blocks/claims.md +2 -2
  11. package/docs/blocks/conformance-admission.md +1 -1
  12. package/docs/blocks/learning-harnesses.md +1 -1
  13. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  14. package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
  15. package/docs/guides/co-authoring.md +113 -0
  16. package/docs/{distributions.md → guides/distributions.md} +13 -8
  17. package/docs/guides/extensions.md +189 -0
  18. package/docs/guides/graph-refresh.md +152 -0
  19. package/docs/guides/graph-workspace.md +64 -0
  20. package/docs/{ingestion.md → guides/ingestion.md} +16 -15
  21. package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
  22. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  23. package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
  24. package/docs/{learning.md → guides/learning.md} +12 -5
  25. package/docs/{obsidian.md → guides/obsidian.md} +370 -23
  26. package/docs/guides/ontology-guide.md +111 -0
  27. package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
  28. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  29. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  30. package/docs/{upgrade.md → guides/upgrade.md} +197 -11
  31. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  32. package/docs/knowledge-health/facilitator.md +15 -4
  33. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  34. package/docs/knowledge-health/quickstart.md +97 -75
  35. package/docs/knowledge-health/readme.md +13 -5
  36. package/docs/maintainers/describing-the-atelier.md +173 -0
  37. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
  38. package/docs/{announcements.md → reference/announcements.md} +1 -1
  39. package/docs/{architecture.md → reference/architecture.md} +31 -5
  40. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  41. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  42. package/docs/{coordination.md → reference/coordination.md} +1 -1
  43. package/docs/{decisions.md → reference/decisions.md} +3 -3
  44. package/docs/{design.md → reference/design.md} +1 -1
  45. package/docs/reference/edge-policy.md +151 -0
  46. package/docs/reference/graph-view-structured.md +119 -0
  47. package/docs/reference/graph-view.md +139 -0
  48. package/docs/reference/graph-workspace.md +37 -0
  49. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  50. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  51. package/docs/{local-services.md → reference/local-services.md} +43 -4
  52. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
  53. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
  54. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  55. package/docs/reference/portability.md +92 -0
  56. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  57. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  58. package/docs/{project-options.md → reference/project-options.md} +17 -0
  59. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
  60. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  61. package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
  62. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  63. package/docs/{trackables.md → reference/trackables.md} +2 -2
  64. package/docs/start/independent-start.md +89 -0
  65. package/docs/{install.md → start/install.md} +9 -9
  66. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  67. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  68. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  69. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  70. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  71. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  72. package/fixtures/knowledge-health/workshop/README.md +1 -1
  73. package/fixtures/templates/local-library.v1.json +3 -1
  74. package/package.json +22 -8
  75. package/plugins/obsidian/main.js +57 -8
  76. package/plugins/obsidian/manifest.json +1 -1
  77. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  78. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  79. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  80. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  81. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  82. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  83. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  84. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  85. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  86. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  87. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  88. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  89. package/src/architecture/catalog.mjs +2 -2
  90. package/src/attestation/sign.mjs +1 -1
  91. package/src/boundary/content-rules.mjs +534 -68
  92. package/src/boundary/policy.mjs +187 -60
  93. package/src/cli/run.mjs +26 -9
  94. package/src/commands/distribution.mjs +1 -1
  95. package/src/commands/extension.mjs +27 -0
  96. package/src/commands/ingest.mjs +1 -1
  97. package/src/commands/learn.mjs +1 -1
  98. package/src/commands/obsidian.mjs +49 -21
  99. package/src/commands/review.mjs +3 -0
  100. package/src/edge-policy/contract.mjs +69 -0
  101. package/src/edge-policy/policy.mjs +142 -0
  102. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  103. package/src/extension-packs/loader.mjs +4 -3
  104. package/src/extensions/admission.mjs +83 -0
  105. package/src/extensions/client.d.mts +23 -0
  106. package/src/extensions/client.mjs +99 -0
  107. package/src/extensions/contracts.d.mts +29 -0
  108. package/src/extensions/contracts.mjs +163 -0
  109. package/src/extensions/convex.d.mts +24 -0
  110. package/src/extensions/convex.mjs +189 -0
  111. package/src/extensions/delegation.mjs +54 -0
  112. package/src/extensions/drizzle.d.mts +11 -0
  113. package/src/extensions/drizzle.mjs +33 -0
  114. package/src/extensions/postgres.d.mts +7 -0
  115. package/src/extensions/postgres.mjs +66 -0
  116. package/src/extensions/prisma.d.mts +13 -0
  117. package/src/extensions/prisma.mjs +63 -0
  118. package/src/extensions/project.mjs +432 -0
  119. package/src/extensions/scaffold-convex.mjs +130 -0
  120. package/src/extensions/scaffold-sql.mjs +144 -0
  121. package/src/extensions/server.d.mts +20 -0
  122. package/src/extensions/server.mjs +102 -0
  123. package/src/extensions/sql-scope.mjs +22 -0
  124. package/src/graph/graph.mjs +13 -5
  125. package/src/graph/knowledge-graph.mjs +20 -2
  126. package/src/graph/repo-contribution.mjs +405 -0
  127. package/src/graph/repo-contribution.types.mjs +32 -0
  128. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  129. package/src/graph-access/edge-record-adapter.mjs +73 -0
  130. package/src/graph-access/index.mjs +253 -0
  131. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  132. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  133. package/src/graph-access/source-adapter.mjs +81 -0
  134. package/src/graph-refresh/compare.mjs +110 -0
  135. package/src/graph-refresh/controller.mjs +324 -0
  136. package/src/graph-refresh/index.mjs +3 -0
  137. package/src/graph-refresh/local.mjs +78 -0
  138. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  139. package/src/graph-view/babylon/index.mjs +2 -0
  140. package/src/graph-view/babylon/navigator.mjs +312 -0
  141. package/src/graph-view/babylon/provenance.mjs +36 -0
  142. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  143. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  144. package/src/graph-view/babylon/web-host.mjs +188 -0
  145. package/src/graph-view/cli.mjs +3 -0
  146. package/src/graph-view/command.mjs +34 -0
  147. package/src/graph-view/contracts.mjs +79 -0
  148. package/src/graph-view/errors.mjs +5 -0
  149. package/src/graph-view/index.mjs +4 -0
  150. package/src/graph-view/intents.mjs +30 -0
  151. package/src/graph-view/query.mjs +213 -0
  152. package/src/graph-view/refs.mjs +2 -0
  153. package/src/graph-view/structured/browser.mjs +262 -0
  154. package/src/graph-view/structured/styles.mjs +8 -0
  155. package/src/graph-workspace/authoring.mjs +185 -0
  156. package/src/graph-workspace/browser/constellation.mjs +369 -0
  157. package/src/graph-workspace/browser/dom.mjs +23 -0
  158. package/src/graph-workspace/browser/index.mjs +6 -0
  159. package/src/graph-workspace/browser/review.mjs +55 -0
  160. package/src/graph-workspace/browser/styles.mjs +11 -0
  161. package/src/graph-workspace/browser/workbench.mjs +351 -0
  162. package/src/graph-workspace/browser/workspace.mjs +431 -0
  163. package/src/graph-workspace/disclosure.mjs +39 -0
  164. package/src/graph-workspace/geometry.mjs +78 -0
  165. package/src/graph-workspace/index.mjs +11 -0
  166. package/src/graph-workspace/labels.mjs +50 -0
  167. package/src/graph-workspace/model.mjs +109 -0
  168. package/src/graph-workspace/recipes.mjs +88 -0
  169. package/src/graph-workspace/scale.mjs +95 -0
  170. package/src/graph-workspace/theme.mjs +156 -0
  171. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  172. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  173. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  174. package/src/knowledge/ontology-guide.mjs +152 -0
  175. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  176. package/src/knowledge-health/rule-identity.mjs +14 -9
  177. package/src/knowledge-health/workshop.mjs +22 -2
  178. package/src/personal-workspace/index.mjs +1 -1
  179. package/src/personal-workspace/selection.mjs +34 -3
  180. package/src/project/config.mjs +1 -1
  181. package/src/project/path-match.mjs +2 -1
  182. package/src/project/providers/github.mjs +24 -0
  183. package/src/project/repo-identity.mjs +3 -22
  184. package/src/projection/obsidian/contracts.mjs +1 -1
  185. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  186. package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
  187. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  188. package/src/projection/obsidian/personal-workspace.mjs +84 -5
  189. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  190. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  191. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  192. package/src/projection/obsidian/publication/publisher.mjs +96 -8
  193. package/src/projection/project.mjs +21 -16
  194. package/src/readiness/readiness.mjs +20 -11
  195. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  196. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  197. package/src/readiness-protocols/evidence.mjs +2 -1
  198. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  199. package/src/readiness-protocols/runtime.mjs +43 -6
  200. package/src/runtime/obsidian/engine.mjs +387 -76
  201. package/src/runtime/obsidian/lifecycle.mjs +20 -15
  202. package/src/runtime/obsidian/login-item.mjs +6 -3
  203. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  204. package/src/runtime/obsidian/observation.mjs +36 -4
  205. package/src/runtime/obsidian/opening.mjs +4 -3
  206. package/src/runtime/obsidian/personal-selection.mjs +187 -0
  207. package/src/runtime/obsidian/pipeline.mjs +115 -6
  208. package/src/runtime/obsidian/private-lock.mjs +50 -6
  209. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  210. package/src/runtime/obsidian/service-main.mjs +68 -16
  211. package/src/runtime/obsidian/service.mjs +80 -6
  212. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  213. package/src/runtime/obsidian/view-permission.mjs +335 -0
  214. package/src/ui/control-system.mjs +201 -0
  215. package/src/ui/html-primitives.mjs +3 -0
  216. package/src/upgrade/closure-diagnostics.mjs +135 -0
  217. package/src/upgrade/closure.mjs +649 -0
  218. package/src/upgrade/upgrade.mjs +42 -11
  219. package/templates/distribution-workspace/README.md +2 -2
  220. package/templates/external-project-workspace/atelier.project.json +3 -0
  221. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  222. package/templates/knowledge-workspace/README.md +2 -2
  223. package/docs/integration-and-review-plan.md +0 -409
  224. package/docs/integration-contract-decisions.md +0 -44
  225. package/docs/knowledge-health/devday/facilitator.md +0 -16
  226. package/docs/knowledge-health/devday/public-default.md +0 -107
  227. package/docs/knowledge-health/devday/quickstart.md +0 -18
  228. package/docs/knowledge-health/devday/support.md +0 -22
  229. package/docs/knowledge-health/exercise.md +0 -17
  230. package/docs/knowledge-health/support.md +0 -34
  231. package/docs/presentation-review-disposition.md +0 -47
  232. package/docs/progressive-ingestion-acceptance.md +0 -89
  233. package/docs/progressive-ingestion.md +0 -235
  234. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  235. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  236. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  237. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  238. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  239. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  240. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  241. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  242. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  243. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  244. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  245. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  246. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  247. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  248. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  249. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  250. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  251. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  252. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  253. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -7,6 +7,9 @@ import { matchesPathPattern } from '../project/path-match.mjs'
7
7
  import { sanitizedGitEnvironment } from '../runtime/git-adapter.mjs'
8
8
  import {
9
9
  CHECK_AGGREGATE_MAX_BYTES,
10
+ canonicalDiffArgs,
11
+ gitOutputResult,
12
+ parseDiffSections,
10
13
  parsePushRefInput,
11
14
  resolveContentRules,
12
15
  scanPushUpdate,
@@ -299,6 +302,19 @@ function finding({ severity = 'error', code, message, repo = null, path: itemPat
299
302
  return { severity, code, message, repo, path: itemPath, node, details }
300
303
  }
301
304
 
305
+ // Text output lists blocking errors before warnings, with incomplete or unresolved evidence first within each
306
+ // severity group. The report and --json keep every finding in their original order.
307
+ const PRINTED_FINDINGS_MAX = 50
308
+ const INCOMPLETE_SCAN_CODES = new Set(['content-scan-incomplete', 'semantic-field-scan-incomplete', 'repo-unscannable', 'pre-commit-repo-unresolved', 'push-check-repo-unresolved'])
309
+
310
+ function printedFindings(findings) {
311
+ const ordered = [true, false].flatMap((blocking) => {
312
+ const group = findings.filter((item) => (item.severity === 'error') === blocking)
313
+ return [...group.filter((item) => INCOMPLETE_SCAN_CODES.has(item.code)), ...group.filter((item) => !INCOMPLETE_SCAN_CODES.has(item.code))]
314
+ })
315
+ return { shown: ordered.slice(0, PRINTED_FINDINGS_MAX), hidden: Math.max(0, ordered.length - PRINTED_FINDINGS_MAX) }
316
+ }
317
+
302
318
  function severityFor(policy) {
303
319
  return policy?.mode === 'legacy-warning' ? 'warning' : 'error'
304
320
  }
@@ -358,17 +374,90 @@ function actorFindings({ policy, project, actor, gitExecutable, allowNetworkActo
358
374
  return findings
359
375
  }
360
376
 
361
- export function stagedPathsForProject(project, { gitExecutable = 'git' } = {}) {
377
+ /**
378
+ * The index a pre-commit hook is judging. Git names it in GIT_INDEX_FILE (a
379
+ * temporary index under `git commit -a` or `git commit <paths>`, a linked
380
+ * worktree's own index, or `.git/index` relative to the work tree's top) and
381
+ * runs the hook at that top, though a chained hook may have moved. The work
382
+ * tree is found by Git's own discovery from where the hook runs; the hook's
383
+ * GIT_DIR is not used, because in a linked worktree Git exports it without a
384
+ * work tree, which would make any subdirectory look like the top. A work tree
385
+ * discovery cannot find is unresolved, and blocks. Returns null when no index
386
+ * is named.
387
+ */
388
+ export function hookIndexFromEnvironment({ env = process.env, cwd = process.cwd(), gitExecutable = 'git' } = {}) {
389
+ const named = typeof env.GIT_INDEX_FILE === 'string' ? env.GIT_INDEX_FILE : ''
390
+ if (!named) return null
391
+ const workTree = gitPathOutput(cwd, ['rev-parse', '--show-toplevel'], sanitizedGitEnvironment(), gitExecutable)
392
+ const commonDir = gitPathOutput(cwd, ['rev-parse', '--git-common-dir'], sanitizedGitEnvironment(), gitExecutable)
393
+ return { workTree, indexFile: path.resolve(workTree ?? cwd, named), commonDir }
394
+ }
395
+
396
+ function gitPathOutput(cwd, args, env, gitExecutable = 'git') {
397
+ const result = spawnSync(gitExecutable, ['-C', cwd, ...args], { encoding: 'utf8', env })
398
+ const output = result.status === 0 ? String(result.stdout ?? '').replace(/\r?\n$/, '') : ''
399
+ return output ? nativeRealPath(path.resolve(cwd, output)) : null
400
+ }
401
+
402
+ // The file system's own spelling of a path, so a configured path that differs
403
+ // only in case (macOS, Windows) still names the same repo.
404
+ function nativeRealPath(value) {
405
+ try {
406
+ return fs.realpathSync.native(path.resolve(value))
407
+ } catch {
408
+ return path.resolve(value)
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Where each managed repo's staged state is read. The repo the hook is
414
+ * committing is read from the hook's work tree and index: the managed repo
415
+ * configured at that work tree, or the one managed repo sharing its Git
416
+ * directory (a linked worktree of it). Every other repo is read as configured.
417
+ * When an index is named but no single managed repo is being committed, the
418
+ * guard cannot tell what it is judging, and says so.
419
+ */
420
+ function stagedTargets(project, hookIndex, gitExecutable = 'git') {
421
+ const repos = managedRepos(project)
422
+ const targets = new Map(repos.map((repo) => [repo, { root: repo.path, indexFile: null }]))
423
+ if (!hookIndex) return { targets, unresolved: null }
424
+ let committing = hookIndex.workTree ? repos.filter((repo) => repo.path && nativeRealPath(repo.path) === hookIndex.workTree) : []
425
+ if (committing.length === 0 && hookIndex.commonDir) {
426
+ committing = repos.filter((repo) => repo.path && fs.existsSync(repo.path)
427
+ && gitPathOutput(repo.path, ['rev-parse', '--git-common-dir'], sanitizedGitEnvironment(), gitExecutable) === hookIndex.commonDir)
428
+ }
429
+ if (committing.length !== 1) return { targets, unresolved: hookIndex.workTree ?? '(an unresolved work tree)' }
430
+ targets.set(committing[0], { root: hookIndex.workTree, indexFile: hookIndex.indexFile })
431
+ return { targets, unresolved: null }
432
+ }
433
+
434
+ function stagedPathsResult(targets, { gitExecutable = 'git' } = {}) {
362
435
  const paths = []
363
- for (const repo of managedRepos(project)) {
364
- if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) continue
365
- const result = spawnSync(gitExecutable, ['-C', repo.path, 'diff', '--cached', '--name-only', '--diff-filter=ACMR'], { encoding: 'utf8', env: sanitizedGitEnvironment() })
366
- if (result.status !== 0) continue
367
- for (const rel of result.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) {
368
- paths.push({ repo: repo.name, repoRoot: repo.path, path: normalize(rel) })
436
+ const failures = []
437
+ for (const [repo, target] of targets) {
438
+ if (!target.root || !fs.existsSync(path.join(target.root, '.git'))) {
439
+ failures.push({ repo, reason: 'configured-path-absent-or-not-git' })
440
+ continue
441
+ }
442
+ // NUL-separated names are exact: a quoted name could never match a forbidden-path glob.
443
+ const result = gitOutputResult(target.root, ['diff', '--cached', '--name-only', '-z', '--no-relative', '--no-renames', '--diff-filter=ACMRT'], { gitExecutable, indexFile: target.indexFile, maxBuffer: CHECK_AGGREGATE_MAX_BYTES })
444
+ if (!result.ok || (result.stdout && !result.stdout.endsWith('\0'))) {
445
+ failures.push({ repo, reason: result.code ?? 'unterminated-staged-path-evidence' })
446
+ continue
447
+ }
448
+ for (const rel of result.stdout.split('\0').filter(Boolean)) {
449
+ paths.push({ repo: repo.name, repoRoot: target.root, path: normalize(rel) })
369
450
  }
370
451
  }
371
- return paths
452
+ return { paths, failures }
453
+ }
454
+
455
+ export function stagedPathsForProject(project, { gitExecutable = 'git', hookIndex = null } = {}) {
456
+ const { targets, unresolved } = stagedTargets(project, hookIndex, gitExecutable)
457
+ const result = stagedPathsResult(targets, { gitExecutable })
458
+ if (unresolved) throw new Error('the committing repository could not be resolved; staged paths are incomplete')
459
+ if (result.failures.length) throw new Error('staged paths could not be listed completely')
460
+ return result.paths
372
461
  }
373
462
 
374
463
  function forbiddenPathFindings({ policy, stagedPaths }) {
@@ -405,42 +494,48 @@ export function semanticFieldOnLine(line) {
405
494
  return { field: null, value: null, raw: line.trim() }
406
495
  }
407
496
 
497
+ // A hunk cannot establish the quote/comment state at its first line. Every literal boundary assignment therefore
498
+ // remains a review signal, including examples inside quoted text. A plain mention is not an assignment. Keep the
499
+ // whole suffix: trimming a partial collection or comment could misread it as the private initialization default.
500
+ function semanticFieldsOnLine(line) {
501
+ const raw = String(line).trim()
502
+ const values = []
503
+ const match = raw.match(/(?:^|[^\w.])["']?(kg\.audience|audience|handling|sensitivity|data_boundary)["']?\s*:/i)
504
+ if (match) values.push({ field: match[1].toLowerCase().replace(/^kg\./, ''), value: unquoteValue(raw.slice(match.index + match[0].length)), raw })
505
+ // Complete escaped JSON keys add signals without guessing the document context or changing the legacy helper.
506
+ for (const escaped of raw.matchAll(/("(?:\\.|[^"\\])*")\s*:/g)) {
507
+ if (!escaped[1].includes('\\')) continue
508
+ try {
509
+ const field = JSON.parse(escaped[1]).toLowerCase().replace(/^kg\./, '')
510
+ if (['audience', 'handling', 'sensitivity', 'data_boundary'].includes(field)) {
511
+ values.push({ field, value: unquoteValue(raw.slice(escaped.index + escaped[0].length)), raw })
512
+ break
513
+ }
514
+ } catch { /* Invalid quoted key text has no decoded field identity. */ }
515
+ }
516
+ return values
517
+ }
518
+
408
519
  function isInitializationDefault(field, value) {
409
520
  const defaults = SEMANTIC_INITIALIZATION_DEFAULTS[field]
410
521
  return Array.isArray(defaults) && defaults.includes(value)
411
522
  }
412
523
 
524
+ /** Diff sections with their hunks, read by the same parser as the content scan. */
413
525
  export function diffFileSections(diff) {
414
- const files = []
415
- let current = null
416
- let hunk = null
417
- for (const line of String(diff ?? '').split('\n')) {
418
- if (line.startsWith('diff --git ')) {
419
- const header = line.match(/^diff --git a\/(.*) b\/(.*)$/)
420
- current = { path: header ? normalize(header[2]) : null, hunks: [], lines: [] }
421
- files.push(current)
422
- hunk = null
423
- continue
424
- }
425
- if (!current) continue
426
- current.lines.push(line)
427
- if (line.startsWith('@@')) {
428
- hunk = { added: [], removed: [] }
429
- current.hunks.push(hunk)
430
- continue
431
- }
432
- if (!hunk) continue
433
- if (/^\+(?!\+)/.test(line)) hunk.added.push(line.slice(1))
434
- else if (/^-(?!-)/.test(line)) hunk.removed.push(line.slice(1))
435
- }
436
- return files
526
+ return parseDiffSections(diff, { detail: true }).map((section) => ({
527
+ path: section.path,
528
+ hunks: section.hunks,
529
+ lines: section.lines,
530
+ incomplete: section.incomplete,
531
+ }))
437
532
  }
438
533
 
439
534
  export function semanticChangesInFile(file) {
440
535
  const changes = []
441
536
  for (const hunk of file.hunks ?? []) {
442
- const pending = hunk.removed.map(semanticFieldOnLine).filter(Boolean)
443
- for (const entry of hunk.added.map(semanticFieldOnLine).filter(Boolean)) {
537
+ const pending = hunk.removed.flatMap(semanticFieldsOnLine)
538
+ for (const entry of hunk.added.flatMap(semanticFieldsOnLine)) {
444
539
  const index = pending.findIndex((item) => item.field === entry.field)
445
540
  const prior = index === -1 ? null : pending.splice(index, 1)[0]
446
541
  if (prior) {
@@ -456,26 +551,38 @@ export function semanticChangesInFile(file) {
456
551
  return changes
457
552
  }
458
553
 
459
- function semanticDiffFindings({ policy, project, gitExecutable = 'git' }) {
554
+ function semanticDiffFindings({ targets, gitExecutable = 'git' }) {
460
555
  const findings = []
461
556
  const severity = 'error'
462
- for (const repo of managedRepos(project)) {
463
- if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) {
557
+ for (const [repo, target] of targets) {
558
+ if (!target.root || !fs.existsSync(path.join(target.root, '.git'))) {
464
559
  findings.push(unscannableRepoFinding(repo, 'configured path is absent or is not a Git checkout'))
465
560
  continue
466
561
  }
467
- const result = spawnSync(gitExecutable, ['-C', repo.path, 'diff', '--cached', '--unified=0', '--', '*.md', '*.kg.json'], { encoding: 'utf8', env: sanitizedGitEnvironment() })
468
- if (result.status !== 0) {
562
+ // --text: a document declared binary, or holding a NUL byte, still has its fields read.
563
+ const result = gitOutputResult(target.root, [...canonicalDiffArgs(['--cached'], ['--text', '--no-renames']), '--', '*.md', '*.kg.json'], { gitExecutable, indexFile: target.indexFile, maxBuffer: CHECK_AGGREGATE_MAX_BYTES })
564
+ if (!result.ok) {
469
565
  findings.push(unscannableRepoFinding(repo, 'git diff --cached failed'))
470
566
  continue
471
567
  }
472
568
  if (!result.stdout.trim()) continue
473
569
  for (const file of diffFileSections(result.stdout)) {
570
+ if (file.incomplete) {
571
+ findings.push(finding({
572
+ severity,
573
+ code: 'semantic-field-scan-incomplete',
574
+ repo: repo.name,
575
+ path: file.path,
576
+ message: `${repo.name}/${file.path ?? '(unknown path)'}: the staged diff could not be read, so its semantic fields were not checked`,
577
+ details: { reason: file.incomplete },
578
+ }))
579
+ continue
580
+ }
474
581
  const changes = semanticChangesInFile(file)
475
582
  if (!changes.length) continue
476
- // The marker has to travel in the same file's diff as the change it approves —
477
- // a marker elsewhere in the commit approves nothing.
478
- if (REVIEW_MARKER_RE.test(file.lines.join('\n'))) continue
583
+ // The marker has to be added in the same file's diff as the change it approves:
584
+ // a marker elsewhere in the commit, a removed one, or one in a file name approves nothing.
585
+ if (file.hunks.some((hunk) => hunk.added.some((line) => REVIEW_MARKER_RE.test(line)))) continue
479
586
  const label = `${repo.name}/${file.path ?? '(unknown path)'}`
480
587
  findings.push(
481
588
  finding({
@@ -492,17 +599,17 @@ function semanticDiffFindings({ policy, project, gitExecutable = 'git' }) {
492
599
  return findings
493
600
  }
494
601
 
495
- function stagedContentFindings({ policy, project, gitExecutable = 'git' }) {
602
+ function stagedContentFindings({ policy, targets, gitExecutable = 'git' }) {
496
603
  const rules = resolveContentRules(policy)
497
604
  const exceptions = asArray(policy?.contentRuleExceptions)
498
605
  const findings = []
499
606
  let totalBytes = 0
500
- for (const repo of managedRepos(project)) {
501
- if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) {
607
+ for (const [repo, target] of targets) {
608
+ if (!target.root || !fs.existsSync(path.join(target.root, '.git'))) {
502
609
  findings.push(unscannableRepoFinding(repo, 'configured path is absent or is not a Git checkout'))
503
610
  continue
504
611
  }
505
- const result = scanStagedRepository({ repoRoot: repo.path, rules, exceptions, repo: repo.name, gitExecutable })
612
+ const result = scanStagedRepository({ repoRoot: target.root, rules, exceptions, repo: repo.name, gitExecutable, indexFile: target.indexFile })
506
613
  findings.push(...result.findings, ...result.diagnostics)
507
614
  totalBytes += result.bytes
508
615
  if (totalBytes > CHECK_AGGREGATE_MAX_BYTES) {
@@ -545,7 +652,7 @@ function repoForCwd(project, cwd) {
545
652
  * which reports without blocking, so an accepted usage elsewhere in the repo can
546
653
  * never strand unrelated work on the machine.
547
654
  */
548
- export function checkPushContent({ project, policy, repo, updates, cwd = process.cwd(), gitRunner = spawnSync } = {}) {
655
+ export function checkPushContent({ project, policy, repo, updates, remote = null, cwd = process.cwd(), gitRunner = spawnSync } = {}) {
549
656
  const target = repo ?? repoForCwd(project, cwd)
550
657
  if (!target) {
551
658
  // Fail closed. This hook is only installed into managed repos, so failing to
@@ -563,15 +670,18 @@ export function checkPushContent({ project, policy, repo, updates, cwd = process
563
670
  const findings = []
564
671
  let totalBytes = 0
565
672
  for (const update of updates) {
566
- const result = scanPushUpdate({ repoRoot: target.path, update, rules, exceptions, repo: target.name, gitRunner })
673
+ const result = scanPushUpdate({ repoRoot: target.path, update, rules, exceptions, repo: target.name, remote, gitRunner, maxBytes: CHECK_AGGREGATE_MAX_BYTES - totalBytes, aggregateLimitBytes: CHECK_AGGREGATE_MAX_BYTES })
567
674
  findings.push(...result.findings, ...result.diagnostics)
568
675
  totalBytes += result.bytes
676
+ // The per-commit scan reports its own budget refusal; say it once.
677
+ if (result.diagnostics.some((item) => item.details?.reason === 'push-evidence-too-large')) break
569
678
  if (totalBytes > CHECK_AGGREGATE_MAX_BYTES) {
570
679
  findings.push(finding({
571
680
  severity: 'error',
572
681
  code: 'content-scan-incomplete',
573
682
  repo: target.name,
574
- message: `push evidence exceeds the ${CHECK_AGGREGATE_MAX_BYTES}-byte aggregate limit`,
683
+ message: `push evidence exceeds the ${CHECK_AGGREGATE_MAX_BYTES}-byte aggregate limit; push it in smaller parts`,
684
+ details: { reason: 'push-evidence-too-large', limitBytes: CHECK_AGGREGATE_MAX_BYTES },
575
685
  }))
576
686
  break
577
687
  }
@@ -665,7 +775,7 @@ function promotionFindings({ policy, project, graph }) {
665
775
  return findings
666
776
  }
667
777
 
668
- export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnly = false, actor = null, gitExecutable = 'git', allowNetworkActorResolution = true, allowHistoryActorResolution = false, forceActorErrors = false, delegations = null, ownerKeys = null, now = Date.now() } = {}) {
778
+ export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnly = false, actor = null, gitExecutable = 'git', allowNetworkActorResolution = true, allowHistoryActorResolution = false, forceActorErrors = false, delegations = null, ownerKeys = null, now = Date.now(), hookIndex = null } = {}) {
669
779
  const validationErrors = validateBoundaryPolicy(policy, project)
670
780
  let graph = null
671
781
  const findings = validationErrors.map((message) => finding({ severity: 'error', code: 'boundary-policy-invalid', message }))
@@ -684,10 +794,18 @@ export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnl
684
794
  const operation = staged ? 'pre-commit' : 'boundary-check'
685
795
  findings.push(...actorFindings({ policy, project, actor, gitExecutable, allowNetworkActorResolution, allowHistoryActorResolution, forceActorErrors, operation, delegations: delegationList, ownerKeys, now }))
686
796
  if (staged) {
687
- const stagedPaths = stagedPathsForProject(project, { gitExecutable })
688
- findings.push(...forbiddenPathFindings({ policy, stagedPaths }))
689
- findings.push(...semanticDiffFindings({ policy, project, gitExecutable }))
690
- findings.push(...stagedContentFindings({ policy, project, gitExecutable }))
797
+ const { targets, unresolved } = stagedTargets(project, hookIndex, gitExecutable)
798
+ if (unresolved) {
799
+ findings.push(finding({
800
+ code: 'pre-commit-repo-unresolved',
801
+ message: `${unresolved} is not a repo declared in ${project?.configPath ?? 'the project config'}, or several declared repos share its Git directory; the pre-commit guard cannot tell what is being committed`,
802
+ }))
803
+ }
804
+ const listed = stagedPathsResult(targets, { gitExecutable })
805
+ findings.push(...listed.failures.map(({ repo, reason }) => unscannableRepoFinding(repo, `staged paths could not be listed (${reason})`)))
806
+ findings.push(...forbiddenPathFindings({ policy, stagedPaths: listed.paths }))
807
+ findings.push(...semanticDiffFindings({ targets, gitExecutable }))
808
+ findings.push(...stagedContentFindings({ policy, targets, gitExecutable }))
691
809
  }
692
810
  const errors = findings.filter((item) => item.severity === 'error')
693
811
  const warnings = findings.filter((item) => item.severity !== 'error')
@@ -715,7 +833,7 @@ export function runBoundaryCheckCommand(argv = process.argv.slice(2)) {
715
833
  // Owner keys are read only when a delegations document exists.
716
834
  const ownerKeys = sidecar.present && sidecar.document ? ownerKeysForCommand() : null
717
835
  let report = loaded.ok
718
- ? checkBoundaryPolicy({ project, policy: loaded.policy, staged, stagedOnly, actor: firstString(args.actor), delegations: sidecar.document, ownerKeys })
836
+ ? checkBoundaryPolicy({ project, policy: loaded.policy, staged, stagedOnly, actor: firstString(args.actor), delegations: sidecar.document, ownerKeys, hookIndex: staged ? hookIndexFromEnvironment() : null })
719
837
  : { ok: false, mode: null, schema: BOUNDARY_POLICY_SCHEMA, graphCounts: null, findings: missingFindings, errors: missingFindings, warnings: [] }
720
838
  if (sidecarFindings.length) report = { ...report, ok: false, findings: [...sidecarFindings, ...report.findings], errors: [...sidecarFindings, ...report.errors] }
721
839
 
@@ -724,9 +842,12 @@ export function runBoundaryCheckCommand(argv = process.argv.slice(2)) {
724
842
  } else {
725
843
  const label = report.ok ? 'passed' : 'failed'
726
844
  console.log(`[boundary:check] ${label} · ${report.errors.length ?? report.findings.filter((item) => item.severity === 'error').length} errors · ${report.warnings.length ?? report.findings.filter((item) => item.severity !== 'error').length} warnings`)
727
- for (const item of report.findings.slice(0, 50)) console.log(`${item.severity ?? 'error'} ${item.code}: ${item.message}`)
845
+ const { shown, hidden } = printedFindings(report.findings)
846
+ for (const item of shown) console.log(`${item.severity ?? 'error'} ${item.code}: ${item.message}`)
847
+ if (hidden) console.log(`${hidden} more not shown; --json lists every finding`)
728
848
  }
729
- process.exit(report.ok ? 0 : 1)
849
+ // Let queued stdout drain before the command process exits, including a complete JSON report through a pipe.
850
+ process.exitCode = report.ok ? 0 : 1
730
851
  }
731
852
 
732
853
  export function installBoundaryHooks({ project, force = false } = {}) {
@@ -784,7 +905,7 @@ ATELIER_HOOK_PROJECT_CONFIG="$ATELIER_HOOK_REPO_ROOT"/${shellSingleQuote(portabl
784
905
  // pre-push reads the ref updates git writes to stdin and judges only that range;
785
906
  // pre-commit judges the staged diff. Neither scans the whole tree — that view is
786
907
  // `atelier boundary audit`, which reports without blocking.
787
- const invocation = hookName === 'pre-push' ? `boundary push-check ${config}` : `boundary check --staged ${config}`
908
+ const invocation = hookName === 'pre-push' ? `boundary push-check ${config} --remote="\${1:-}"` : `boundary check --staged ${config}`
788
909
  return `#!/usr/bin/env bash
789
910
  # MNSTRY_ATELIER_BOUNDARY_GUARD ${hookName}
790
911
  set -euo pipefail
@@ -830,13 +951,19 @@ export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdi
830
951
  console.log('[boundary:push-check] no ref updates on stdin · nothing to judge')
831
952
  process.exit(0)
832
953
  }
833
- const report = checkPushContent({ project, policy: loaded.policy, updates: parsed.updates, cwd: process.cwd() })
954
+ // The installed hook passes the remote Git names as its first argument.
955
+ const report = checkPushContent({ project, policy: loaded.policy, updates: parsed.updates, remote: firstString(args.remote), cwd: process.cwd() })
834
956
  if (args.json) {
835
957
  console.log(JSON.stringify(report, null, 2))
836
958
  } else {
837
959
  console.log(`[boundary:push-check] ${report.ok ? 'passed' : 'blocked'} · ${report.repo ?? 'unknown repo'} · ${report.errors.length} errors · ${report.warnings.length} warnings`)
838
- for (const item of report.findings.slice(0, 50)) console.log(`${item.severity} ${item.rule ?? item.code}: ${item.message}`)
839
- if (!report.ok && report.repo) {
960
+ const { shown, hidden } = printedFindings(report.findings)
961
+ for (const item of shown) {
962
+ const also = item.commits?.length ? ` (also in commits ${item.commits.map((commit) => commit.slice(0, 12)).join(', ')})` : ''
963
+ console.log(`${item.severity} ${item.rule ?? item.code}: ${item.message}${also}`)
964
+ }
965
+ if (hidden) console.log(`${hidden} more not shown; --json lists every finding`)
966
+ if (!report.ok && report.repo && report.errors.some((item) => item.code === 'content-rule-violation')) {
840
967
  console.log('')
841
968
  console.log('If this usage is intentional, declare it in the boundary policy:')
842
969
  console.log(' contentRuleExceptions: [{ rule, repo, paths: [...], reason: "why this is accepted" }]')
package/src/cli/run.mjs CHANGED
@@ -48,6 +48,11 @@ export const commandMap = new Map([
48
48
  ['config', ['src/commands/config.mjs']],
49
49
  ['config:check', ['src/commands/config.mjs']],
50
50
  ['manifest', ['src/commands/config.mjs']],
51
+ ['extension', ['src/commands/extension.mjs']],
52
+ ['extension:init', ['src/commands/extension.mjs', 'init']],
53
+ ['extension:validate', ['src/commands/extension.mjs', 'validate']],
54
+ ['extension:seal', ['src/commands/extension.mjs', 'seal']],
55
+ ['extension:bundle', ['src/commands/extension.mjs', 'bundle']],
51
56
  ['extension-pack', ['src/commands/extension-pack.mjs']],
52
57
  ['extension-pack:validate', ['src/commands/extension-pack.mjs', 'validate']],
53
58
  ['extension-pack:list', ['src/commands/extension-pack.mjs', 'list']],
@@ -101,7 +106,7 @@ export function buildHelpText(brand = DEFAULT_BRAND, atelierVersion = null) {
101
106
 
102
107
  Usage:
103
108
  atelier <command> [args]
104
- mnstry-atelier <command> [args] # legacy alias
109
+ mnstry-atelier <command> [args] # supported npm alias
105
110
  mnstry atelier <command> [args] # compatibility`
106
111
  : `${brand.displayName}
107
112
  powered by MNSTRY Atelier ${atelierVersion}
@@ -150,6 +155,7 @@ Core commands:
150
155
  upgrade explain --plan Explain saved changes and consent without writes.
151
156
  upgrade apply --plan --confirm Apply the selected plan through commit hooks.
152
157
  upgrade status|recover Inspect receipts or preview recovery.
158
+ upgrade closure --npm-root Plan a consumer dependency-closure check; reads only, proves nothing.
153
159
  upgrade --dry-run Plan a legacy package/template upgrade.
154
160
  upgrade --apply Apply a branch-based reviewable upgrade.
155
161
  lock check|write Verify or create atelier.lock.json.
@@ -167,6 +173,10 @@ Core commands:
167
173
  learn capture|propose|decide|activate|withdraw Manage scoped lessons and their evidence.
168
174
  ingest plan|run|status|query Preserve selected sources and search bounded evidence.
169
175
  config check Validate project config.
176
+ extension init Scaffold a custom database extension.
177
+ extension validate Check extension source and release digests.
178
+ extension seal Seal reviewed extension edits.
179
+ extension bundle Export a sealed host-registration bundle.
170
180
  extension-pack validate Validate declared extension packs.
171
181
  extension-pack list List declared extension packs.
172
182
  distribution check Check a distribution for MNSTRY attribution.
@@ -192,7 +202,7 @@ export function buildCommandHelpText(command, brand = DEFAULT_BRAND) {
192
202
  const c = brand.command
193
203
  const serverHelp = (name) => `Usage: ${c} ${name} [--project ./atelier.project.json] [--port=PORT] [--review] [--knowledge]
194
204
 
195
- Serves the project's built projection on 127.0.0.1 in the foreground until stopped. It builds nothing: run ${c} graph, then ${c} build, with the same --project path first. The port is --port=PORT, else the PORT environment variable, else 8137; 0 picks a free port, and the address printed on start names it. --review also serves the local review workspace at /review (docs/local-review.md). --knowledge adds guided knowledge dashboards and private coauthoring at /knowledge (docs/knowledge-workspace.md).`
205
+ Serves the project's built projection on 127.0.0.1 in the foreground until stopped. It builds nothing: run ${c} graph, then ${c} build, with the same --project path first. The port is --port=PORT, else the PORT environment variable, else 8137; 0 picks a free port, and the address printed on start names it. --review also serves the local review workspace at /review (docs/guides/local-review.md). --knowledge adds guided knowledge dashboards and private coauthoring at /knowledge (docs/guides/knowledge-workspace.md).`
196
206
  const help = {
197
207
  capability: `Usage: ${c} capability seal|verify|inventory|plan|apply|status|observe|candidates|recover|fleet|graph|profiles
198
208
  Run ${c} capability help for options. Releases stay local; adoption is per repository
@@ -200,10 +210,10 @@ and requires an exact plan digest. Installed bytes, host loading and successful
200
210
  exercise are separate evidence. No model execution or tool permission grants.`,
201
211
  ingest: `Usage: ${c} ingest plan|run|status|query
202
212
 
203
- Read one bounded UTF-8 JSON object {workspaceId,input} from stdin inside the intended Git workspace. Plans bind explicit sources, scope and budgets. Run performs bounded local text, CSV and JSON extraction. Query returns source-bound lexical evidence with partial coverage; it does not synthesize or accept claims. Private state stays in ignored .atelier-local/. See docs/ingestion.md.`,
213
+ Read one bounded UTF-8 JSON object {workspaceId,input} from stdin inside the intended Git workspace. Plans bind explicit sources, scope and budgets. Run performs bounded local text, CSV and JSON extraction. Query returns source-bound lexical evidence with partial coverage; it does not synthesize or accept claims. Private state stays in ignored .atelier-local/. See docs/guides/ingestion.md.`,
204
214
  learn: `Usage: ${c} learn capture|propose|decide|activate|withdraw|list|context|render|graph|export|plan
205
215
 
206
- Read a bounded JSON object from stdin in the intended Git workspace. Every request includes workspaceId. Writes include actor, requestId, expectedRevision and input. Context and render include query. Local actor identity is asserted, not authenticated. State stays in ignored .atelier-local/. Accepted content becomes context only after explicit activation for a named harness; no permission grant, file installation or sending occurs. See docs/learning.md.`,
216
+ Read a bounded JSON object from stdin in the intended Git workspace. Every request includes workspaceId. Writes include actor, requestId, expectedRevision and input. Context and render include query. Local actor identity is asserted, not authenticated. State stays in ignored .atelier-local/. Accepted content becomes context only after explicit activation for a named harness; no permission grant, file installation or sending occurs. See docs/guides/learning.md.`,
207
217
  dev: serverHelp('dev'),
208
218
  server: serverHelp('server'),
209
219
  skills: `Usage: ${c} skills audit|observe|candidates|sync
@@ -222,7 +232,7 @@ check reports ontology-to-question coverage against the current canonical graph.
222
232
  context --question TEXT [--mode graph|lexical] [--max-bytes N] prints a bounded JSON packet of complete active Markdown sources.
223
233
  evaluate compares lexical and graph selection on the plan's pinned evidence cases; exit 1 means missing or stale expected evidence, missing relationships, or an expected abstention that retrieved or omitted matching evidence.
224
234
  The packet is local operator context, may contain private material, and grants no sharing or execution authority. Payload bytes are exact; token estimates are not measured usage. Session operations read JSON on stdin (except list); saved answers are private drafts.
225
- semantic reads one JSON object {workspaceId, run, request} on stdin (at most 256 KiB) in the intended Git workspace, the one ingest uses. Each operation accepts exactly its documented request fields: required fields non-null, every field of its declared kind; anything else refuses as SEMANTIC_OPERATION_INVALID before anything is written. Formats such as timestamps are the runner's to check. Atelier calls no model: the host supplies its raw output, candidates and usage (null when unknown), and every interpretation stays pending until receiver records accept it; those records are caller assertions, not authenticated identity. Failures print {ok:false, code, error}, with recorded or captured details for reopening when the runner attached them. See docs/knowledge-setup.md, docs/knowledge-workspace.md and docs/semantic-operation.md.`,
235
+ semantic reads one JSON object {workspaceId, run, request} on stdin (at most 256 KiB) in the intended Git workspace, the one ingest uses. Each operation accepts exactly its documented request fields: required fields non-null, every field of its declared kind; anything else refuses as SEMANTIC_OPERATION_INVALID before anything is written. Formats such as timestamps are the runner's to check. Atelier calls no model: the host supplies its raw output, candidates and usage (null when unknown), and every interpretation stays pending until receiver records accept it; those records are caller assertions, not authenticated identity. Failures print {ok:false, code, error}, with recorded or captured details for reopening when the runner attached them. See docs/start/knowledge-setup.md, docs/guides/knowledge-workspace.md and docs/reference/semantic-operation.md.`,
226
236
  init: `Usage: ${c} init [--template private-domain|shared-project|sample-workspace|distribution|external-project|knowledge-workspace] [--target DIR] [--actor ID]
227
237
 
228
238
  Creates tracked starter files and an Atelier lockfile. It does not install hooks unless asked separately. An unrecognized --template exits 1 and writes nothing; omit --template for the blank scaffold.`,
@@ -253,6 +263,13 @@ Writes a minimal <file>.kg.json sidecar next to every .html, .pdf and .docx docu
253
263
  ${c} readiness export --dry-run [--project ./atelier.project.json]
254
264
 
255
265
  Runs the bundled MNSTRY readiness pack claim-first. Protocol state and packet drafts stay in ignored .atelier-local/readiness/ unless explicitly materialized by a reviewed future flow.`,
266
+ extension: `Usage:
267
+ ${c} extension init --directory NEW_PATH [--id sample.counter] [--profile postgres-sql|drizzle-db-first|drizzle-code-first|prisma-7-postgres] [--json]
268
+ ${c} extension validate --directory PATH [--json]
269
+ ${c} extension seal --directory PATH [--json]
270
+ ${c} extension bundle --directory PATH [--json]
271
+
272
+ Generate a customer-owned Postgres project or validate/seal its source digests. Init requires a new directory. These commands create no account, database, process, deployment or installation. See docs/guides/extensions.md.`,
256
273
  'extension-pack': `Usage:
257
274
  ${c} extension-pack validate [--json] [--project ./atelier.project.json]
258
275
  ${c} extension-pack list [--json] [--project ./atelier.project.json]
@@ -260,7 +277,7 @@ Runs the bundled MNSTRY readiness pack claim-first. Protocol state and packet dr
260
277
  Loads every extension pack declared under ext["mnstry.atelier"].extensionPacks in the tracked project config and reports one line per pack. Packs load additively alongside the bundled MNSTRY protocols and can never replace them. list is the default subcommand. Shared --project-config and repeated --repo-path NAME=PATH options are supported in both space and equals forms.`,
261
278
  distribution: `Usage: ${c} distribution check [--target DIR] [--pack DIR]
262
279
 
263
- 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.`,
280
+ 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/reference/attestation.md and docs/guides/distributions.md.`,
264
281
  disclosure: `Usage: ${c} disclosure check [--root DIR] [--staged] [--denylist FILE | --structural-only] [--fail-on-binary] [--untrusted]
265
282
 
266
283
  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.`,
@@ -279,7 +296,7 @@ Assembles a local feedback report under ignored .atelier-local/feedback/ (mode 0
279
296
  announcements: `Usage: ${c} announcements list [--dir DIR] [--public-key FILE] | verify <file> [--public-key FILE] [--json] | show <file> [--public-key FILE]
280
297
 
281
298
  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.`,
282
- obsidian: `Usage: ${c} obsidian status|settings|location|scope|view|audience|mode|policy|service|open|uninstall|plugin|apply|conflicts|apply-policy|selection|proposals [--project atelier.project.json] [--data-root DIR] [--json] [--no-input]
299
+ obsidian: `Usage: ${c} obsidian status|settings|location|scope|view|audience|mode|policy|service|open|uninstall|plugin|apply|conflicts|apply-policy|selection|proposals [--project atelier.project.json | --personal-home DIR] [--data-root DIR] [--json] [--no-input]
283
300
 
284
301
  ${c} obsidian status
285
302
  ${c} obsidian settings
@@ -300,7 +317,7 @@ MNSTRY announcements are a pull-only channel: signed JSON documents under announ
300
317
  ${c} obsidian selection resolve ID | persist ID [allow-empty] | show ID | list
301
318
  ${c} obsidian proposals list | show OPERATION
302
319
 
303
- Every input is an argument. view add changes the project configuration, which you commit: it shows the change and how many notes the view would show, and asks a person at a terminal first (--yes answers beforehand); anyone else's command is their consent, and nothing is ever committed. With --json exactly one JSON document is printed, for a refusal too. status, settings, location show, scope, audience show, mode show, policy show, policy digest, plugin show and service status are read-only. open reports current only when the owned maintenance service ticked after the request, the view's trusted generation is the prepared one and reads back byte for byte, and the installed Obsidian meets the minimum version and answers for that vault; every other outcome is typed and exits 3. Reaching the installed app is never a default: open, service start and service unit need --adapter=obsidian-cli once for a workspace, which remembers it (never used under the test runner). service unit --install installs a login item on macOS (a launchd agent) or Linux (a systemd user unit) so maintenance starts when you log in, under a consent that covers startup; it runs the package installed in the project and is refused from a package runner's cache. Once installed, the service is started through it, and service start replaces a service of an earlier release. service unit --remove removes it and starts the service for this session only. uninstall removes the login item and stops the service, and keeps vaults, private state, the project file and Obsidian's vault list. Obsidian's own settings file is written only while no Obsidian runs, and every write is shown in open's answer: the view's vault is added to its list with a backup of the file as it was, its command line is turned on (cli: true), and for an Obsidian that never started the file is created. A running Obsidian that open cannot reach (its command line turned off, or no vault open) is quit and started again only with --restart-obsidian, which no other flag implies; otherwise open names that flag. --wait-ms bounds open's wait for maintenance's tick. The first start of the maintenance service records who allowed it: --consent-actor ID, or for a person at a terminal the account's name; --json, --no-input, CI or ATELIER_NONINTERACTIVE=1 mean nobody is at a terminal. Audiences (audience set me: only you, with the notes that carry no classification, which no other list shows), mode, the apply policy and what was decided are private machine settings, never project configuration and never a note. apply, conflicts, apply-policy, selection and proposals are contributed operations registered from modules shipped under src/runtime/obsidian/contributions/; the built-in names cannot be replaced. While no apply operation is registered every apply reports apply-unavailable. Exit codes: 0 done, 1 internal error, 2 refusal or usage, 3 ran and the answer is not success.`,
320
+ Every input is an argument. view add changes the project configuration, which you commit: it shows the change and how many notes the view would show, and asks a person at a terminal first (--yes answers beforehand); anyone else's command is their consent, and nothing is ever committed. With --json exactly one JSON document is printed, for a refusal too. status, settings, location show, scope, audience show, mode show, policy show, policy digest, plugin show and service status are read-only. open reports current only when the owned maintenance service ticked after the request, the view's trusted generation is the prepared one and reads back byte for byte, and the installed Obsidian meets the minimum version and answers for that vault; every other outcome is typed and exits 3. Reaching the installed app is never a default: open, service start and service unit need --adapter=obsidian-cli once for a workspace, which remembers it (never used under the test runner). service unit --install installs a login item on macOS (a launchd agent) or Linux (a systemd user unit) so maintenance starts when you log in, under a consent that covers startup; it runs the package installed in the project and is refused from a package runner's cache. Once installed, the service is started through it, and service start replaces a service of an earlier release. service unit --remove removes it and starts the service for this session only. uninstall removes the login item and stops the service, and keeps vaults, private state, the project file and Obsidian's vault list. Obsidian's own settings file is written only while no Obsidian runs, and every write is shown in open's answer: the view's vault is added to its list with a backup of the file as it was, its command line is turned on (cli: true), and for an Obsidian that never started the file is created. A running Obsidian that open cannot reach (its command line turned off, or no vault open) is quit and started again only with --restart-obsidian, which no other flag implies; otherwise open names that flag. --wait-ms bounds open's wait for maintenance's tick. The first start of the maintenance service records who allowed it: --consent-actor ID, or for a person at a terminal the account's name; --json, --no-input, CI or ATELIER_NONINTERACTIVE=1 mean nobody is at a terminal. Audiences (audience set me: only you, with the notes that carry no classification, which no other list shows), mode, the apply policy and what was decided are private machine settings, never project configuration and never a note. apply, conflicts, apply-policy, selection and proposals are contributed operations registered from modules shipped under src/runtime/obsidian/contributions/; the built-in names cannot be replaced. While no apply operation is registered every apply reports apply-unavailable. --personal-home DIR, given instead of --project, follows the generation last confirmed in that private home of a personal workspace; nothing here confirms one, and while none is confirmed and current nothing is published and status, open and service start refuse with the reason. Who may see its vaults stays this machine's audience setting: owner only when undecided; an explicit clear stays empty. Exit codes: 0 done, 1 internal error, 2 refusal or usage, 3 ran and the answer is not success.`,
304
321
  sync: `Usage:
305
322
  ${c} sync enroll --repo DIR [--project atelier.project.json] [--git ABSOLUTE_PATH]
306
323
  ${c} sync status --repo DIR
@@ -372,7 +389,7 @@ export async function runCli({
372
389
 
373
390
  const [command, ...rest] = normalized.args
374
391
  if (rest[0] === '--help' || rest[0] === '-h') {
375
- stdout(buildCommandHelpText(command, brand))
392
+ stdout(buildCommandHelpText(command.startsWith('extension:') ? 'extension' : command, brand))
376
393
  return 0
377
394
  }
378
395
  const target = commandMap.get(command)
@@ -15,7 +15,7 @@ const ATTRIBUTION_BYTES = 'powered by MNSTRY Atelier'
15
15
  const ATTRIBUTION_EXT_KEY = 'mnstry.atelier/attribution'
16
16
  const PACK_MANIFEST_NAME = 'atelier.pack.json'
17
17
  const PREFIX = '[atelier-distribution:check]'
18
- const POLICY_POINTER = 'see TRADEMARKS.md ("Required attribution") and docs/attestation.md'
18
+ const POLICY_POINTER = 'see TRADEMARKS.md ("Required attribution") and docs/reference/attestation.md'
19
19
 
20
20
  const log = (message) => console.log(`${PREFIX} ${message}`)
21
21
  const logError = (message) => console.error(`${PREFIX} ${message}`)
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { scaffoldSqlProject, scaffoldConvexProject, sealProject, validateProject, exportReleaseBundle } from '../extensions/project.mjs'
3
+
4
+ const usage = 'Usage: atelier extension init|validate|seal|bundle --directory PATH [--id sample.counter] [--profile postgres-sql|drizzle-db-first|drizzle-code-first|prisma-7-postgres|convex-native] [--json]'
5
+ const [command, ...args] = process.argv.slice(2)
6
+ try {
7
+ if (command === '--help' || command === '-h' || !command) { console.log(usage) }
8
+ else {
9
+ if (!['init', 'validate', 'seal', 'bundle'].includes(command)) throw new Error('unknown extension command')
10
+ const options = {}
11
+ for (let i = 0; i < args.length; i++) {
12
+ const arg = args[i]
13
+ if (arg === '--json') { if (options.json) throw new Error('duplicate option'); options.json = true; continue }
14
+ if (!['--directory', '--id', '--profile'].includes(arg) || !args[i + 1] || args[i + 1].startsWith('--') || Object.hasOwn(options, arg)) throw new Error('invalid option')
15
+ options[arg] = args[++i]
16
+ }
17
+ if (!options['--directory'] || (command !== 'init' && (options['--id'] || options['--profile']))) throw new Error('invalid options')
18
+ if (options['--profile'] && !['postgres-sql','drizzle-db-first','drizzle-code-first','prisma-7-postgres','convex-native'].includes(options['--profile'])) throw new Error('profile not implemented')
19
+ const result = command === 'init' ? await (options['--profile']==='convex-native'?scaffoldConvexProject:scaffoldSqlProject)({ directory: options['--directory'], extensionId: options['--id'], profile: options['--profile'] }) :
20
+ command === 'bundle' ? await exportReleaseBundle(options['--directory']) :
21
+ command === 'validate' ? await validateProject(options['--directory']) : await sealProject(options['--directory']).then(manifest => ({ releaseDigest: manifest.releaseDigest, qualified: false }))
22
+ console.log(command === 'bundle' || options.json ? JSON.stringify(result) : `${command}: ${result.releaseDigest} (qualification pending)`)
23
+ }
24
+ } catch {
25
+ // Do not echo a caller path, secret, source file or arbitrary exception text.
26
+ console.error(`Extension command failed. ${usage}`); process.exitCode = 1
27
+ }
@@ -9,7 +9,7 @@ ignored .atelier-local/ state. Plans bind explicit sources, scope and budgets.
9
9
  Run processes bounded local text, CSV and JSON. Status retains every selected
10
10
  source's disposition. Query returns source-bound lexical evidence with coverage;
11
11
  no operation accepts semantic claims, calls a provider or enrolls raw files into
12
- the graph. Output is private JSON. See docs/ingestion.md.`
12
+ the graph. Output is private JSON. See docs/guides/ingestion.md.`
13
13
 
14
14
  try {
15
15
  const command = process.argv[2]
@@ -17,7 +17,7 @@ lesson digest. Activate makes approved content available to a named harness;
17
17
  it does not install files or grant tool permissions. Withdraw stops future use.
18
18
  List, context, render, graph, export and plan print private JSON to stdout.
19
19
  Export is an explicit private archive, never a send or publication operation.
20
- See docs/learning.md for complete examples.`
20
+ See docs/guides/learning.md for complete examples.`
21
21
 
22
22
  function exactObject(value, allowed, required) {
23
23
  if (!value || typeof value !== 'object' || Array.isArray(value)