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

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 (58) hide show
  1. package/CHANGELOG.md +233 -0
  2. package/README.md +22 -5
  3. package/docs/blocks/capability-stewardship.md +1 -1
  4. package/docs/blocks/learning-harnesses.md +1 -1
  5. package/docs/capability-stewardship.md +2 -4
  6. package/docs/ingestion.md +3 -3
  7. package/docs/inquiry-harnesses.md +2 -3
  8. package/docs/install.md +3 -3
  9. package/docs/learning-harnesses.md +2 -3
  10. package/docs/local-services.md +42 -3
  11. package/docs/obsidian-contract.md +23 -0
  12. package/docs/obsidian-plugin.md +214 -3
  13. package/docs/obsidian.md +271 -11
  14. package/docs/personal-workspace.md +13 -4
  15. package/docs/release-engineering.md +2 -1
  16. package/docs/repo-boundary-guard.md +26 -3
  17. package/docs/semantic-operation.md +4 -3
  18. package/docs/upgrade.md +174 -2
  19. package/fixtures/templates/local-library.v1.json +2 -1
  20. package/package.json +2 -1
  21. package/plugins/obsidian/main.js +57 -8
  22. package/plugins/obsidian/manifest.json +1 -1
  23. package/src/boundary/content-rules.mjs +532 -67
  24. package/src/boundary/policy.mjs +135 -56
  25. package/src/cli/run.mjs +3 -2
  26. package/src/commands/obsidian.mjs +23 -7
  27. package/src/graph/graph.mjs +13 -5
  28. package/src/graph/knowledge-graph.mjs +19 -1
  29. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  30. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  31. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  32. package/src/knowledge-health/rule-identity.mjs +14 -9
  33. package/src/personal-workspace/index.mjs +1 -1
  34. package/src/personal-workspace/selection.mjs +17 -1
  35. package/src/project/path-match.mjs +2 -1
  36. package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
  37. package/src/projection/obsidian/personal-workspace.mjs +87 -4
  38. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  39. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  40. package/src/projection/obsidian/publication/publisher.mjs +72 -5
  41. package/src/projection/project.mjs +3 -2
  42. package/src/runtime/obsidian/engine.mjs +293 -55
  43. package/src/runtime/obsidian/lifecycle.mjs +17 -12
  44. package/src/runtime/obsidian/login-item.mjs +4 -1
  45. package/src/runtime/obsidian/observation.mjs +36 -4
  46. package/src/runtime/obsidian/opening.mjs +3 -2
  47. package/src/runtime/obsidian/personal-selection.mjs +125 -0
  48. package/src/runtime/obsidian/pipeline.mjs +44 -3
  49. package/src/runtime/obsidian/private-lock.mjs +39 -4
  50. package/src/runtime/obsidian/service-main.mjs +58 -12
  51. package/src/runtime/obsidian/service.mjs +66 -6
  52. package/src/runtime/obsidian/view-permission.mjs +334 -0
  53. package/src/ui/control-system.mjs +201 -0
  54. package/src/ui/html-primitives.mjs +3 -0
  55. package/src/upgrade/closure-diagnostics.mjs +135 -0
  56. package/src/upgrade/closure.mjs +649 -0
  57. package/src/upgrade/upgrade.mjs +19 -7
  58. package/templates/external-project-workspace/pack-lifecycle.json +2 -1
@@ -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,
@@ -358,17 +361,83 @@ function actorFindings({ policy, project, actor, gitExecutable, allowNetworkActo
358
361
  return findings
359
362
  }
360
363
 
361
- export function stagedPathsForProject(project, { gitExecutable = 'git' } = {}) {
364
+ /**
365
+ * The index a pre-commit hook is judging. Git names it in GIT_INDEX_FILE (a
366
+ * temporary index under `git commit -a` or `git commit <paths>`, a linked
367
+ * worktree's own index, or `.git/index` relative to the work tree's top) and
368
+ * runs the hook at that top, though a chained hook may have moved. The work
369
+ * tree is found by Git's own discovery from where the hook runs; the hook's
370
+ * GIT_DIR is not used, because in a linked worktree Git exports it without a
371
+ * work tree, which would make any subdirectory look like the top. A work tree
372
+ * discovery cannot find is unresolved, and blocks. Returns null when no index
373
+ * is named.
374
+ */
375
+ export function hookIndexFromEnvironment({ env = process.env, cwd = process.cwd(), gitExecutable = 'git' } = {}) {
376
+ const named = typeof env.GIT_INDEX_FILE === 'string' ? env.GIT_INDEX_FILE : ''
377
+ if (!named) return null
378
+ const workTree = gitPathOutput(cwd, ['rev-parse', '--show-toplevel'], sanitizedGitEnvironment(), gitExecutable)
379
+ const commonDir = gitPathOutput(cwd, ['rev-parse', '--git-common-dir'], sanitizedGitEnvironment(), gitExecutable)
380
+ return { workTree, indexFile: path.resolve(workTree ?? cwd, named), commonDir }
381
+ }
382
+
383
+ function gitPathOutput(cwd, args, env, gitExecutable = 'git') {
384
+ const result = spawnSync(gitExecutable, ['-C', cwd, ...args], { encoding: 'utf8', env })
385
+ const output = result.status === 0 ? String(result.stdout ?? '').replace(/\r?\n$/, '') : ''
386
+ return output ? nativeRealPath(path.resolve(cwd, output)) : null
387
+ }
388
+
389
+ // The file system's own spelling of a path, so a configured path that differs
390
+ // only in case (macOS, Windows) still names the same repo.
391
+ function nativeRealPath(value) {
392
+ try {
393
+ return fs.realpathSync.native(path.resolve(value))
394
+ } catch {
395
+ return path.resolve(value)
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Where each managed repo's staged state is read. The repo the hook is
401
+ * committing is read from the hook's work tree and index: the managed repo
402
+ * configured at that work tree, or the one managed repo sharing its Git
403
+ * directory (a linked worktree of it). Every other repo is read as configured.
404
+ * When an index is named but no single managed repo is being committed, the
405
+ * guard cannot tell what it is judging, and says so.
406
+ */
407
+ function stagedTargets(project, hookIndex, gitExecutable = 'git') {
408
+ const repos = managedRepos(project)
409
+ const targets = new Map(repos.map((repo) => [repo, { root: repo.path, indexFile: null }]))
410
+ if (!hookIndex) return { targets, unresolved: null }
411
+ let committing = hookIndex.workTree ? repos.filter((repo) => repo.path && nativeRealPath(repo.path) === hookIndex.workTree) : []
412
+ if (committing.length === 0 && hookIndex.commonDir) {
413
+ committing = repos.filter((repo) => repo.path && fs.existsSync(repo.path)
414
+ && gitPathOutput(repo.path, ['rev-parse', '--git-common-dir'], sanitizedGitEnvironment(), gitExecutable) === hookIndex.commonDir)
415
+ }
416
+ if (committing.length !== 1) return { targets, unresolved: hookIndex.workTree ?? '(an unresolved work tree)' }
417
+ targets.set(committing[0], { root: hookIndex.workTree, indexFile: hookIndex.indexFile })
418
+ return { targets, unresolved: null }
419
+ }
420
+
421
+ function stagedPathsResult(targets, { gitExecutable = 'git' } = {}) {
362
422
  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) })
423
+ const failures = []
424
+ for (const [repo, target] of targets) {
425
+ if (!target.root || !fs.existsSync(path.join(target.root, '.git'))) continue
426
+ // NUL-separated names are exact: a quoted name could never match a forbidden-path glob.
427
+ const result = gitOutputResult(target.root, ['diff', '--cached', '--name-only', '-z', '--no-relative', '--diff-filter=ACMR'], { gitExecutable, indexFile: target.indexFile })
428
+ if (!result.ok) {
429
+ failures.push({ repo, reason: result.code })
430
+ continue
431
+ }
432
+ for (const rel of result.stdout.split('\0').filter(Boolean)) {
433
+ paths.push({ repo: repo.name, repoRoot: target.root, path: normalize(rel) })
369
434
  }
370
435
  }
371
- return paths
436
+ return { paths, failures }
437
+ }
438
+
439
+ export function stagedPathsForProject(project, { gitExecutable = 'git', hookIndex = null } = {}) {
440
+ return stagedPathsResult(stagedTargets(project, hookIndex, gitExecutable).targets, { gitExecutable }).paths
372
441
  }
373
442
 
374
443
  function forbiddenPathFindings({ policy, stagedPaths }) {
@@ -410,30 +479,14 @@ function isInitializationDefault(field, value) {
410
479
  return Array.isArray(defaults) && defaults.includes(value)
411
480
  }
412
481
 
482
+ /** Diff sections with their hunks, read by the same parser as the content scan. */
413
483
  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
484
+ return parseDiffSections(diff, { detail: true }).map((section) => ({
485
+ path: section.path,
486
+ hunks: section.hunks,
487
+ lines: section.lines,
488
+ incomplete: section.incomplete,
489
+ }))
437
490
  }
438
491
 
439
492
  export function semanticChangesInFile(file) {
@@ -456,26 +509,38 @@ export function semanticChangesInFile(file) {
456
509
  return changes
457
510
  }
458
511
 
459
- function semanticDiffFindings({ policy, project, gitExecutable = 'git' }) {
512
+ function semanticDiffFindings({ targets, gitExecutable = 'git' }) {
460
513
  const findings = []
461
514
  const severity = 'error'
462
- for (const repo of managedRepos(project)) {
463
- if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) {
515
+ for (const [repo, target] of targets) {
516
+ if (!target.root || !fs.existsSync(path.join(target.root, '.git'))) {
464
517
  findings.push(unscannableRepoFinding(repo, 'configured path is absent or is not a Git checkout'))
465
518
  continue
466
519
  }
467
- const result = spawnSync(gitExecutable, ['-C', repo.path, 'diff', '--cached', '--unified=0', '--', '*.md', '*.kg.json'], { encoding: 'utf8', env: sanitizedGitEnvironment() })
468
- if (result.status !== 0) {
520
+ // --text: a document declared binary, or holding a NUL byte, still has its fields read.
521
+ const result = gitOutputResult(target.root, [...canonicalDiffArgs(['--cached'], ['--text']), '--', '*.md', '*.kg.json'], { gitExecutable, indexFile: target.indexFile })
522
+ if (!result.ok) {
469
523
  findings.push(unscannableRepoFinding(repo, 'git diff --cached failed'))
470
524
  continue
471
525
  }
472
526
  if (!result.stdout.trim()) continue
473
527
  for (const file of diffFileSections(result.stdout)) {
528
+ if (file.incomplete) {
529
+ findings.push(finding({
530
+ severity,
531
+ code: 'semantic-field-scan-incomplete',
532
+ repo: repo.name,
533
+ path: file.path,
534
+ message: `${repo.name}/${file.path ?? '(unknown path)'}: the staged diff could not be read, so its semantic fields were not checked`,
535
+ details: { reason: file.incomplete },
536
+ }))
537
+ continue
538
+ }
474
539
  const changes = semanticChangesInFile(file)
475
540
  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
541
+ // The marker has to be added in the same file's diff as the change it approves:
542
+ // a marker elsewhere in the commit, a removed one, or one in a file name approves nothing.
543
+ if (file.hunks.some((hunk) => hunk.added.some((line) => REVIEW_MARKER_RE.test(line)))) continue
479
544
  const label = `${repo.name}/${file.path ?? '(unknown path)'}`
480
545
  findings.push(
481
546
  finding({
@@ -492,17 +557,17 @@ function semanticDiffFindings({ policy, project, gitExecutable = 'git' }) {
492
557
  return findings
493
558
  }
494
559
 
495
- function stagedContentFindings({ policy, project, gitExecutable = 'git' }) {
560
+ function stagedContentFindings({ policy, targets, gitExecutable = 'git' }) {
496
561
  const rules = resolveContentRules(policy)
497
562
  const exceptions = asArray(policy?.contentRuleExceptions)
498
563
  const findings = []
499
564
  let totalBytes = 0
500
- for (const repo of managedRepos(project)) {
501
- if (!repo.path || !fs.existsSync(path.join(repo.path, '.git'))) {
565
+ for (const [repo, target] of targets) {
566
+ if (!target.root || !fs.existsSync(path.join(target.root, '.git'))) {
502
567
  findings.push(unscannableRepoFinding(repo, 'configured path is absent or is not a Git checkout'))
503
568
  continue
504
569
  }
505
- const result = scanStagedRepository({ repoRoot: repo.path, rules, exceptions, repo: repo.name, gitExecutable })
570
+ const result = scanStagedRepository({ repoRoot: target.root, rules, exceptions, repo: repo.name, gitExecutable, indexFile: target.indexFile })
506
571
  findings.push(...result.findings, ...result.diagnostics)
507
572
  totalBytes += result.bytes
508
573
  if (totalBytes > CHECK_AGGREGATE_MAX_BYTES) {
@@ -545,7 +610,7 @@ function repoForCwd(project, cwd) {
545
610
  * which reports without blocking, so an accepted usage elsewhere in the repo can
546
611
  * never strand unrelated work on the machine.
547
612
  */
548
- export function checkPushContent({ project, policy, repo, updates, cwd = process.cwd(), gitRunner = spawnSync } = {}) {
613
+ export function checkPushContent({ project, policy, repo, updates, remote = null, cwd = process.cwd(), gitRunner = spawnSync } = {}) {
549
614
  const target = repo ?? repoForCwd(project, cwd)
550
615
  if (!target) {
551
616
  // Fail closed. This hook is only installed into managed repos, so failing to
@@ -563,15 +628,17 @@ export function checkPushContent({ project, policy, repo, updates, cwd = process
563
628
  const findings = []
564
629
  let totalBytes = 0
565
630
  for (const update of updates) {
566
- const result = scanPushUpdate({ repoRoot: target.path, update, rules, exceptions, repo: target.name, gitRunner })
631
+ const result = scanPushUpdate({ repoRoot: target.path, update, rules, exceptions, repo: target.name, remote, gitRunner, maxBytes: CHECK_AGGREGATE_MAX_BYTES - totalBytes })
567
632
  findings.push(...result.findings, ...result.diagnostics)
568
633
  totalBytes += result.bytes
634
+ // The per-commit scan reports its own budget refusal; say it once.
635
+ if (result.diagnostics.some((item) => item.details?.reason === 'push-evidence-too-large')) break
569
636
  if (totalBytes > CHECK_AGGREGATE_MAX_BYTES) {
570
637
  findings.push(finding({
571
638
  severity: 'error',
572
639
  code: 'content-scan-incomplete',
573
640
  repo: target.name,
574
- message: `push evidence exceeds the ${CHECK_AGGREGATE_MAX_BYTES}-byte aggregate limit`,
641
+ message: `push evidence exceeds the ${CHECK_AGGREGATE_MAX_BYTES}-byte aggregate limit; push it in smaller parts`,
575
642
  }))
576
643
  break
577
644
  }
@@ -665,7 +732,7 @@ function promotionFindings({ policy, project, graph }) {
665
732
  return findings
666
733
  }
667
734
 
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() } = {}) {
735
+ 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
736
  const validationErrors = validateBoundaryPolicy(policy, project)
670
737
  let graph = null
671
738
  const findings = validationErrors.map((message) => finding({ severity: 'error', code: 'boundary-policy-invalid', message }))
@@ -684,10 +751,18 @@ export function checkBoundaryPolicy({ project, policy, staged = false, stagedOnl
684
751
  const operation = staged ? 'pre-commit' : 'boundary-check'
685
752
  findings.push(...actorFindings({ policy, project, actor, gitExecutable, allowNetworkActorResolution, allowHistoryActorResolution, forceActorErrors, operation, delegations: delegationList, ownerKeys, now }))
686
753
  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 }))
754
+ const { targets, unresolved } = stagedTargets(project, hookIndex, gitExecutable)
755
+ if (unresolved) {
756
+ findings.push(finding({
757
+ code: 'pre-commit-repo-unresolved',
758
+ 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`,
759
+ }))
760
+ }
761
+ const listed = stagedPathsResult(targets, { gitExecutable })
762
+ findings.push(...listed.failures.map(({ repo, reason }) => unscannableRepoFinding(repo, `staged paths could not be listed (${reason})`)))
763
+ findings.push(...forbiddenPathFindings({ policy, stagedPaths: listed.paths }))
764
+ findings.push(...semanticDiffFindings({ targets, gitExecutable }))
765
+ findings.push(...stagedContentFindings({ policy, targets, gitExecutable }))
691
766
  }
692
767
  const errors = findings.filter((item) => item.severity === 'error')
693
768
  const warnings = findings.filter((item) => item.severity !== 'error')
@@ -715,7 +790,7 @@ export function runBoundaryCheckCommand(argv = process.argv.slice(2)) {
715
790
  // Owner keys are read only when a delegations document exists.
716
791
  const ownerKeys = sidecar.present && sidecar.document ? ownerKeysForCommand() : null
717
792
  let report = loaded.ok
718
- ? checkBoundaryPolicy({ project, policy: loaded.policy, staged, stagedOnly, actor: firstString(args.actor), delegations: sidecar.document, ownerKeys })
793
+ ? checkBoundaryPolicy({ project, policy: loaded.policy, staged, stagedOnly, actor: firstString(args.actor), delegations: sidecar.document, ownerKeys, hookIndex: staged ? hookIndexFromEnvironment() : null })
719
794
  : { ok: false, mode: null, schema: BOUNDARY_POLICY_SCHEMA, graphCounts: null, findings: missingFindings, errors: missingFindings, warnings: [] }
720
795
  if (sidecarFindings.length) report = { ...report, ok: false, findings: [...sidecarFindings, ...report.findings], errors: [...sidecarFindings, ...report.errors] }
721
796
 
@@ -784,7 +859,7 @@ ATELIER_HOOK_PROJECT_CONFIG="$ATELIER_HOOK_REPO_ROOT"/${shellSingleQuote(portabl
784
859
  // pre-push reads the ref updates git writes to stdin and judges only that range;
785
860
  // pre-commit judges the staged diff. Neither scans the whole tree — that view is
786
861
  // `atelier boundary audit`, which reports without blocking.
787
- const invocation = hookName === 'pre-push' ? `boundary push-check ${config}` : `boundary check --staged ${config}`
862
+ const invocation = hookName === 'pre-push' ? `boundary push-check ${config} --remote="\${1:-}"` : `boundary check --staged ${config}`
788
863
  return `#!/usr/bin/env bash
789
864
  # MNSTRY_ATELIER_BOUNDARY_GUARD ${hookName}
790
865
  set -euo pipefail
@@ -830,13 +905,17 @@ export function runBoundaryPushCheckCommand(argv = process.argv.slice(2), { stdi
830
905
  console.log('[boundary:push-check] no ref updates on stdin · nothing to judge')
831
906
  process.exit(0)
832
907
  }
833
- const report = checkPushContent({ project, policy: loaded.policy, updates: parsed.updates, cwd: process.cwd() })
908
+ // The installed hook passes the remote Git names as its first argument.
909
+ const report = checkPushContent({ project, policy: loaded.policy, updates: parsed.updates, remote: firstString(args.remote), cwd: process.cwd() })
834
910
  if (args.json) {
835
911
  console.log(JSON.stringify(report, null, 2))
836
912
  } else {
837
913
  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) {
914
+ for (const item of report.findings.slice(0, 50)) {
915
+ const also = item.commits?.length ? ` (also in commits ${item.commits.map((commit) => commit.slice(0, 12)).join(', ')})` : ''
916
+ console.log(`${item.severity} ${item.rule ?? item.code}: ${item.message}${also}`)
917
+ }
918
+ if (!report.ok && report.repo && report.errors.some((item) => item.code === 'content-rule-violation')) {
840
919
  console.log('')
841
920
  console.log('If this usage is intentional, declare it in the boundary policy:')
842
921
  console.log(' contentRuleExceptions: [{ rule, repo, paths: [...], reason: "why this is accepted" }]')
package/src/cli/run.mjs CHANGED
@@ -150,6 +150,7 @@ Core commands:
150
150
  upgrade explain --plan Explain saved changes and consent without writes.
151
151
  upgrade apply --plan --confirm Apply the selected plan through commit hooks.
152
152
  upgrade status|recover Inspect receipts or preview recovery.
153
+ upgrade closure --npm-root Plan a consumer dependency-closure check; reads only, proves nothing.
153
154
  upgrade --dry-run Plan a legacy package/template upgrade.
154
155
  upgrade --apply Apply a branch-based reviewable upgrade.
155
156
  lock check|write Verify or create atelier.lock.json.
@@ -279,7 +280,7 @@ Assembles a local feedback report under ignored .atelier-local/feedback/ (mode 0
279
280
  announcements: `Usage: ${c} announcements list [--dir DIR] [--public-key FILE] | verify <file> [--public-key FILE] [--json] | show <file> [--public-key FILE]
280
281
 
281
282
  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]
283
+ 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
284
 
284
285
  ${c} obsidian status
285
286
  ${c} obsidian settings
@@ -300,7 +301,7 @@ MNSTRY announcements are a pull-only channel: signed JSON documents under announ
300
301
  ${c} obsidian selection resolve ID | persist ID [allow-empty] | show ID | list
301
302
  ${c} obsidian proposals list | show OPERATION
302
303
 
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.`,
304
+ 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, none by default. Exit codes: 0 done, 1 internal error, 2 refusal or usage, 3 ran and the answer is not success.`,
304
305
  sync: `Usage:
305
306
  ${c} sync enroll --repo DIR [--project atelier.project.json] [--git ABSOLUTE_PATH]
306
307
  ${c} sync status --repo DIR
@@ -69,7 +69,7 @@ const PRODUCTION_ADAPTER = 'obsidian-cli'
69
69
  const MAX_POLICY_BYTES = 64 * 1024
70
70
  const AUDIENCE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
71
71
 
72
- export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.project.json] [--data-root DIR] [--json] [--no-input]
72
+ export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.project.json | --personal-home DIR] [--data-root DIR] [--json] [--no-input]
73
73
 
74
74
  status Enablement, machine settings, service and per-view freshness. Read-only.
75
75
  settings What this machine remembers for this workspace, and how to change it. Read-only.
@@ -111,6 +111,11 @@ export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.pro
111
111
  service publishes the view at once (with --adapter, one of an earlier release
112
112
  is replaced first).
113
113
 
114
+ --personal-home DIR, given instead of --project, follows the generation last confirmed in that private home of a personal
115
+ workspace (the personal-workspace module's selection); its views come from the overlay. Nothing here confirms one: while
116
+ none is confirmed and current nothing is published, and status, open and service start refuse with the reason. service
117
+ status and stop, unit --remove and uninstall find the service from the home alone. Who may see its vaults stays this
118
+ machine's audience setting, none by default.
114
119
  Reaching the installed app needs --adapter=${PRODUCTION_ADAPTER} once for a workspace; it is remembered after that.
115
120
  Obsidian's own settings file (obsidian.json) is written only while no Obsidian runs: open adds the view's vault to its
116
121
  list, turns its command line on (cli: true), and creates the file for an Obsidian that never started; every write is
@@ -139,7 +144,7 @@ Exit codes: 0 done; 1 internal error; 2 refusal or usage; 3 ran, and the answer
139
144
  // spelling of `project`); a test holds it to that.
140
145
  export const FLAGS = Object.freeze({
141
146
  json: 'flag', 'allow-stale': 'flag', 'restart-obsidian': 'flag', print: 'flag', install: 'flag', remove: 'flag', help: 'flag', 'no-input': 'flag', 'allow-synced-location': 'flag', all: 'flag', default: 'flag', 'allow-empty': 'flag', yes: 'flag',
142
- project: 'value', 'project-config': 'value', 'data-root': 'value', scope: 'value', 'consent-actor': 'value', actor: 'value', adapter: 'value', 'wait-ms': 'value', repo: 'value', tag: 'value',
147
+ project: 'value', 'project-config': 'value', 'personal-home': 'value', 'data-root': 'value', scope: 'value', 'consent-actor': 'value', actor: 'value', adapter: 'value', 'wait-ms': 'value', repo: 'value', tag: 'value',
143
148
  expand: 'value', folder: 'values',
144
149
  })
145
150
 
@@ -276,6 +281,7 @@ const NEXT = Object.freeze({
276
281
  'login-item-remove-failed': 'see `atelier obsidian status`; `atelier obsidian service unit --remove` tries again',
277
282
  'login-item-file-unsafe': 'the login item\'s file is a link or not a regular file and is left alone; remove it by hand, then try again',
278
283
  'invalid-login-item': 'the login item record in private state does not validate; inspect it, remove it, then install the login item again',
284
+ 'selection-history-shortened': 'records of the selection history were removed after this process read them; put them back, or run the command in a process of its own, which reads the history as it now stands',
279
285
  })
280
286
 
281
287
  // The login item of `status`, in words.
@@ -372,10 +378,16 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
372
378
 
373
379
  const dataRoot = flags['data-root'] === undefined ? options.dataRoot : path.resolve(cwd, flags['data-root'])
374
380
  const configFlag = flags.project ?? flags['project-config']
375
- const loadProject = options.loadProject ?? (() => resolveProjectConfig({ argv: configFlag === undefined ? [] : [`--project=${path.resolve(cwd, configFlag)}`], cwd, env, writeLocalState: false }))
381
+ // A personal home is followed instead of a project: the generation confirmed there is loaded (composed in this
382
+ // thread, as for a command run once), and the service's workspace is located from the home alone.
383
+ const personalHome = flags['personal-home'] === undefined ? null : path.resolve(cwd, flags['personal-home'])
384
+ if (personalHome !== null && configFlag !== undefined) refuse('usage', '--personal-home is given instead of --project, not with it')
385
+ const selection = personalHome === null ? null : await import('../runtime/obsidian/personal-selection.mjs')
386
+ const loadProject = options.loadProject ?? (selection !== null ? () => selection.loadSelectedProject({ personalHome }) : () => resolveProjectConfig({ argv: configFlag === undefined ? [] : [`--project=${path.resolve(cwd, configFlag)}`], cwd, env, writeLocalState: false }))
387
+ const locateProject = selection === null ? null : () => selection.locatePersonalHome({ personalHome })
376
388
  const registry = createObsidianRegistry({ reservedOperations: BUILT_IN_OPERATIONS, contributions: contributions ?? await loadContributions(contributionsDirectory === undefined ? {} : { directory: contributionsDirectory }) })
377
389
  const applyAvailable = registry.extensions.applyOperation() !== UNAVAILABLE_APPLY_OPERATION
378
- const lifecycle = { loadProject, dataRoot, env, platform, ...(options.probeTimeoutMs === undefined ? {} : { probeTimeoutMs: options.probeTimeoutMs }) }
390
+ const lifecycle = { loadProject, ...(locateProject === null ? {} : { locateProject }), dataRoot, env, platform, ...(options.probeTimeoutMs === undefined ? {} : { probeTimeoutMs: options.probeTimeoutMs }) }
379
391
 
380
392
  // The adapter of this run: given now, or remembered by this workspace (read only when the real entry has no flag).
381
393
  const chooseAdapter = () => selectAdapter({ flag: flags.adapter, remembered: flags.adapter === undefined && production === true ? rememberedAdapter() : null, production, env })
@@ -617,7 +629,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
617
629
  const removeUnit = async () => {
618
630
  if (!STARTUP_PLATFORMS.includes(platform)) buildStartupAdapter({ platform })
619
631
  const manager = await managerSeam()
620
- const removed = await removeLoginItem({ loadProject, dataRoot, env, platform, manager, clock })
632
+ const removed = await removeLoginItem({ loadProject: locateProject ?? loadProject, dataRoot, env, platform, manager, clock })
621
633
  const failed = removed.removed !== true && removed.reason !== undefined && removed.reason !== 'workspace-not-prepared'
622
634
  if (failed) return { exit: EXIT.notSuccess, document: { loginItem: removed }, human: [`login item not removed: ${removed.reason}${removed.message ? ` (${removed.message})` : ''}`, `Next: ${NEXT[removed.reason] ?? 'see `atelier obsidian status`'}`] }
623
635
  const remembered = removed.reason === 'workspace-not-prepared' ? false : rememberLoginItem('off')
@@ -720,7 +732,10 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
720
732
  const plugin = withPluginFilesPending(pluginView(running, workspace, scopeId), report.freshness)
721
733
  return enablement.state === 'disabled' ? { ...report, outcome: 'disabled', reason: enablement.reason, next: OPENING_OUTCOMES.disabled.next, plugin } : { ...report, plugin }
722
734
  })
735
+ // The confirmed selection a personal home's project was loaded under; reported, never confirmed here.
736
+ const pinned = selection === null ? null : selection.personalSelectionOf(project)
723
737
  const document = {
738
+ ...(pinned === null ? {} : { selection: { generationId: pinned.generationId, sequence: pinned.sequence } }),
724
739
  enablement: { state: enablement.state, reason: enablement.reason, defaultScopeId: enablement.defaultScopeId }, workspace: { workspaceId, prepared: workspace !== null },
725
740
  machine: shownMachine(machineOf(workspace), workspace), apply: applyShown,
726
741
  service: { state: service.state, reason: service.reason ?? null, address: service.address ?? null, runtimeId: service.record?.runtimeId ?? null, pid: service.record?.pid ?? null, lastTick: running?.lastTick ?? null, lastError: running?.lastError ?? null, app },
@@ -729,6 +744,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
729
744
  return {
730
745
  exit: EXIT.ok, document,
731
746
  human: [
747
+ ...(pinned === null ? [] : [`personal workspace: generation ${pinned.generationId}, confirmed as selection ${pinned.sequence}`]),
732
748
  `obsidian: ${enablement.state} (${enablement.reason}); mode ${document.machine.maintenanceMode}; audiences ${document.machine.audienceAllow.join(', ') || 'none'}`,
733
749
  `remembered: ${decisionSummary(document.machine.decisions)}`,
734
750
  `service: ${service.state} (${service.reason ?? 'no reason'})${app ? `; app ${app.outcome} (${app.reason})` : ''}`,
@@ -1073,13 +1089,13 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
1073
1089
  // Maintenance of this workspace ends here: the login item goes first, so its manager does not start the service
1074
1090
  // again, then the proven service stops. Everything a person made or may want back stays, and is named.
1075
1091
  async uninstall() {
1076
- const project = loadProject()
1092
+ const project = (locateProject ?? loadProject)()
1077
1093
  let workspace = null
1078
1094
  try { const found = resolveServiceWorkspace({ project, dataRoot, env, platform }); workspace = found?.workspaceRoot ? found : null } catch (error) { if (!isTyped(error)) throw error }
1079
1095
  const record = workspace === null ? null : readLoginItemRecord(workspace)
1080
1096
  // What is kept is read before anything is stopped or removed, so nothing it finds can stop the answer after that.
1081
1097
  const kept = keptLocations(project, workspace)
1082
- const item = record === null ? { removed: false, reason: 'not-installed' } : await removeLoginItem({ loadProject, dataRoot, env, platform, manager: await managerSeam(), clock })
1098
+ const item = record === null ? { removed: false, reason: 'not-installed' } : await removeLoginItem({ loadProject: locateProject ?? loadProject, dataRoot, env, platform, manager: await managerSeam(), clock })
1083
1099
  const service = workspace === null ? { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' } : await stopService(lifecycle, lifecycleRules)
1084
1100
  const itemGone = item.removed === true || item.reason === 'not-installed' || item.reason === 'workspace-not-prepared'
1085
1101
  const serviceGone = service.stopped === true || service.state === 'stopped'
@@ -5,9 +5,10 @@ import { VALID_AUDIENCES as PROJECTION_AUDIENCES } from '../projection/policy.mj
5
5
  import {
6
6
  REPO_ACCESS_SCHEMA,
7
7
  VALID_RELATION_TYPES,
8
- buildKnowledgeGraph,
8
+ buildKnowledgeGraphSteps,
9
9
  createGraphFileCache,
10
10
  parseYamlSubset,
11
+ runGraphSteps,
11
12
  } from './knowledge-graph.mjs'
12
13
 
13
14
  export { createGraphFileCache }
@@ -116,11 +117,12 @@ function externalRepos(project) {
116
117
  .sort((a, b) => String(a.name).localeCompare(String(b.name)))
117
118
  }
118
119
 
119
- function canonicalBuild(project, overrides = {}) {
120
+ function* canonicalBuildSteps(project, overrides = {}) {
120
121
  const input = canonicalInput(project)
121
- const result = buildKnowledgeGraph({ ...input.options, ...overrides })
122
+ const result = yield* buildKnowledgeGraphSteps({ ...input.options, ...overrides })
122
123
  return { input, result }
123
124
  }
125
+ const canonicalBuild = (project, overrides = {}) => runGraphSteps(canonicalBuildSteps(project, overrides))
124
126
 
125
127
  // The supported project-to-options seam: the exact buildKnowledgeGraph options
126
128
  // every graph consumer derives from a loaded project, plus the configured
@@ -144,8 +146,14 @@ const canonicalEdgeId = (edge) => JSON.stringify([edge.source, edge.type, edge.t
144
146
  // bytes did not change; the result is the same as without it. `observedDigest`
145
147
  // (see createGraphFileCache) lets a caller that observes sources by digest
146
148
  // spare the read of a source it knows unchanged.
147
- export function buildCanonicalGraph(project, { isLinkTargetEligible, isAssetEligible, fileCache = null, observedDigest = null } = {}) {
148
- const { input, result } = canonicalBuild(project, {
149
+ // buildCanonicalGraphSteps is the same build as steps (see buildKnowledgeGraphSteps), for a caller that waits between
150
+ // them; buildCanonicalGraph runs them through.
151
+ export function buildCanonicalGraph(project, options = {}) {
152
+ return runGraphSteps(buildCanonicalGraphSteps(project, options))
153
+ }
154
+
155
+ export function* buildCanonicalGraphSteps(project, { isLinkTargetEligible, isAssetEligible, fileCache = null, observedDigest = null } = {}) {
156
+ const { input, result } = yield* canonicalBuildSteps(project, {
149
157
  ...(isLinkTargetEligible ? { isLinkTargetEligible } : {}),
150
158
  ...(isAssetEligible ? { isAssetEligible } : {}),
151
159
  ...(fileCache ? { fileCache } : {}),
@@ -1387,7 +1387,24 @@ function markdownCensus({ repoName, repoRoot, coverage, file, accessConfig, cach
1387
1387
  return { node: structuredClone(entry.node), scan: structuredClone(entry.scan), reused: entry === cached, read }
1388
1388
  }
1389
1389
 
1390
- export function buildKnowledgeGraph({
1390
+ // Runs the steps of a graph build through, with no wait, and answers what the build answers.
1391
+ export function runGraphSteps(steps) {
1392
+ for (;;) {
1393
+ const step = steps.next()
1394
+ if (step.done) return step.value
1395
+ }
1396
+ }
1397
+
1398
+ // The knowledge graph of a workspace, in one stretch. Every caller but the maintenance engine's tick builds it this way.
1399
+ export function buildKnowledgeGraph(options) {
1400
+ return runGraphSteps(buildKnowledgeGraphSteps(options))
1401
+ }
1402
+
1403
+ // The same build as steps: one after each Markdown source of the census (its read, hash and parse, or its reuse from
1404
+ // the file cache), named 'graph-census'. A caller that gives the event loop a turn between steps (the maintenance
1405
+ // engine) keeps a large census from holding it; walking the directories, resolving links and assembling the graph
1406
+ // are not split. The answer is the one buildKnowledgeGraph gives.
1407
+ export function* buildKnowledgeGraphSteps({
1391
1408
  workspaceRoot,
1392
1409
  repoAccessConfig,
1393
1410
  repoRoots = null,
@@ -1466,6 +1483,7 @@ export function buildKnowledgeGraph({
1466
1483
  scanned.set(node, entry.scan)
1467
1484
  reuse[entry.reused ? 'reused' : 'derived'] += 1
1468
1485
  if (entry.read) reuse.read += 1
1486
+ yield 'graph-census'
1469
1487
  } else {
1470
1488
  node = nodeForFile(repoName, repoRoot, coverage, file, accessConfig)
1471
1489
  }
@@ -717,6 +717,11 @@ class GraphifySupplier:
717
717
  settings = {"parallel": False, "max_workers": 1, **settings}
718
718
  require(type(settings["parallel"]) is bool and type(settings["max_workers"]) is int
719
719
  and 1 <= settings["max_workers"] <= 32, "BUDGET_UNBOUNDED", "Finite native structural worker bound required")
720
+ # Parallel extraction starts worker processes that import Graphify through the
721
+ # ordinary import system, outside the pinned source loader, so they may run a
722
+ # cached pyc this supplier never attested. Only in-process extraction is admitted.
723
+ require(settings["parallel"] is False, "NATIVE_WORKERS_UNATTESTED",
724
+ "Parallel native extraction runs worker processes outside the attested loader")
720
725
  if op == "cache-check":
721
726
  require(settings.get("prompt") is None or isinstance(settings["prompt"], str), "PROMPT_SCOPE", "Prompt must be explicit text")
722
727
  if settings.get("prompt_file") is not None: