ruvnet-brain 4.2.2-dev → 4.3.1-dev

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 (73) hide show
  1. package/README.md +15 -14
  2. package/bin/install.mjs +11 -2
  3. package/console/architecture.html +12 -14
  4. package/console/install-mockup.html +3 -3
  5. package/kb/zip-extract.mjs +8 -2
  6. package/package.json +7 -1
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/hooks/codex-hooks.json +1 -1
  10. package/plugin/hooks/hooks.json +10 -0
  11. package/plugin/host-adapters/claude.json +11 -0
  12. package/plugin/host-adapters/codex.json +11 -0
  13. package/plugin/mcp/managed-cli-interface.mjs +77 -10
  14. package/plugin/mcp/server.mjs +2 -1
  15. package/plugin/scripts/anticipate.sh +30 -1
  16. package/plugin/scripts/capability-claim-evidence.mjs +431 -0
  17. package/plugin/scripts/capability-inventory-receipt.mjs +225 -0
  18. package/plugin/scripts/capability-registry.mjs +6 -0
  19. package/plugin/scripts/capability-routing.mjs +70 -0
  20. package/plugin/scripts/continuation-gate.mjs +68 -2
  21. package/plugin/scripts/coverage-integrity.mjs +575 -0
  22. package/plugin/scripts/ground-ruvnet.sh +2 -2
  23. package/plugin/scripts/nightly-controller.mjs +19 -2
  24. package/plugin/scripts/project-progression-contract.mjs +409 -0
  25. package/plugin/scripts/project-progression-hook.mjs +196 -0
  26. package/plugin/scripts/project-progression-outbox.mjs +95 -0
  27. package/plugin/scripts/project-progression-session-start.mjs +183 -0
  28. package/plugin/scripts/project-progression-store.mjs +248 -0
  29. package/plugin/scripts/project-store-resolver.mjs +106 -0
  30. package/plugin/scripts/session-snapshot-hook.mjs +29 -1
  31. package/plugin/scripts/session-start-core.mjs +22 -18
  32. package/plugin/scripts/spend-guard.mjs +6 -1
  33. package/scripts/adr-072-completion.mjs +98 -0
  34. package/scripts/behavioral-l1-l4.mjs +11 -8
  35. package/scripts/brain-score.mjs +7 -2
  36. package/scripts/build-bundle.mjs +82 -6
  37. package/scripts/card-from-source.mjs +32 -6
  38. package/scripts/corpus-aggregates.mjs +194 -0
  39. package/scripts/corpus-candidate.mjs +56 -36
  40. package/scripts/corpus-reconcile.mjs +295 -28
  41. package/scripts/corpus-seed-publish.mjs +3 -3
  42. package/scripts/coverage-integrity.mjs +3 -0
  43. package/scripts/gist-receipts.mjs +183 -0
  44. package/scripts/git-hooks/pre-push +21 -0
  45. package/scripts/host-registry.mjs +118 -0
  46. package/scripts/independent-review-receipt.mjs +499 -0
  47. package/scripts/ingest-gists.mjs +34 -5
  48. package/scripts/ingest-new-repos.mjs +5 -0
  49. package/scripts/metaharness-gate.mjs +58 -0
  50. package/scripts/nightly-gists.sh +3 -3
  51. package/scripts/nightly-wrapper.sh +75 -69
  52. package/scripts/product-integrity-contract.mjs +156 -0
  53. package/scripts/public-inventory.mjs +4 -0
  54. package/scripts/public-verification-aggregate.mjs +342 -0
  55. package/scripts/public-verification-finalizer.mjs +59 -0
  56. package/scripts/public-verification-inputs.mjs +473 -0
  57. package/scripts/public-verification-lane.mjs +244 -0
  58. package/scripts/publication-receipt.mjs +50 -6
  59. package/scripts/rebuild-gists-from-receipts.mjs +50 -5
  60. package/scripts/release-projection.mjs +86 -0
  61. package/scripts/release-transaction-provider.mjs +43 -102
  62. package/scripts/release-transaction.mjs +139 -51
  63. package/scripts/release.mjs +15 -7
  64. package/scripts/restore-local-ingests.mjs +2 -2
  65. package/scripts/retrieval-canary.mjs +533 -0
  66. package/scripts/routing-flywheel.mjs +12 -1
  67. package/scripts/rvf-generation.mjs +9 -3
  68. package/scripts/self-update.mjs +19 -44
  69. package/scripts/source-coverage.mjs +415 -0
  70. package/scripts/source-scope-receipt.mjs +84 -0
  71. package/scripts/sync-census.mjs +0 -0
  72. package/scripts/wired-check.mjs +114 -24
  73. package/scripts/worktree-integrity.mjs +123 -0
@@ -98,11 +98,10 @@ const STANDALONE = [
98
98
  ['fix-workstream', 'session-supervised coordination CLI run explicitly by the integration owner or an '
99
99
  + 'isolated writing agent to start and hand off a fix lane. Scheduling it would violate its safety '
100
100
  + 'boundary: it prepares evidence but never merges, pushes, publishes, deletes, or cleans worktrees'],
101
- // Run by the launchd nightly, which lives OUTSIDE this repo — so no in-repo caller can exist.
102
- // This is the one category the scanner genuinely cannot reach, and saying so is the honest form.
103
- ['self-update', 'launchd nightly (out-of-repo scheduler)'],
101
+ ['self-update', 'author-run candidate rebuild; --apply is guarded by worktree-integrity.mjs and is not scheduled'],
102
+ ['ingest-new-repos', 'author-run corpus expansion; --apply is guarded by worktree-integrity.mjs and is not scheduled'],
104
103
  ['count-chunks', 'human-run CLI — recount + restamp chunk surfaces (--check for drift); no scheduler'],
105
- ['brain-stamp', 'invoked by self-update.mjs:249, the nightly launchd driver (com.ruvnet.brain-nightly)'],
104
+ ['brain-stamp', 'invoked by the author-run self-update.mjs candidate builder'],
106
105
  ['lesson-promote', 'human-run CLI — promotion is manual (--apply); no scheduler yet (automation is ADR-029 #4, open)'],
107
106
  ['behavioral-l1-l4', 'behavioural harness invoked by its own test file — not a product path'],
108
107
  // A measurement harness, not a product path: it answers "would bounding the cross-encoder pool
@@ -129,8 +128,7 @@ const STANDALONE = [
129
128
  + 'com.stuartkerr.clear-claude-tmp.plist is loaded and its ProgramArguments invoke this exact file'],
130
129
  ['nightly-gists', 'launchd nightly 21:47 (out-of-repo scheduler) — confirmed live: '
131
130
  + 'com.ruvnet.brain-gists.plist is loaded and its ProgramArguments invoke this exact file'],
132
- ['nightly-wrapper', 'launchd nightly 03:15 (out-of-repo scheduler) — confirmed live: '
133
- + 'com.ruvnet.brain-nightly.plist is loaded and its ProgramArguments invoke this exact file'],
131
+ ['nightly-wrapper', 'author-run maintenance harness, deliberately unscheduled; refuses primary, nested, and dirty worktrees'],
134
132
  ['routing-flywheel', 'launchd nightly 04:45 --dry-run (out-of-repo scheduler) — confirmed live: '
135
133
  + 'com.ruvnet.routing-flywheel.plist is loaded and its ProgramArguments invoke this exact file'],
136
134
  ['install-npx-witness', 'one-shot idempotent installer for the com.ruvnet.npx-witness launchd job, '
@@ -298,8 +296,21 @@ const INVENTORY_ROOTS = [
298
296
  * invoker outside the roots proves the ROOTS are incomplete — the fix is to add the root, never to
299
297
  * write an exemption. That rule caught its own author within a minute of the gate first running.
300
298
  */
301
- const CALLER_ROOTS = ['scripts', 'plugin', 'console', 'bin', '.github', '.claude', 'package.json'];
299
+ // `dream.config.json` is executable configuration, not documentation: the Dream compiler runs its
300
+ // controlPlaneProbes and evaluatorEntrypoints. Excluding it made two real scheduled callers look
301
+ // dead while the same scanner already trusted workflow YAML and package.json command manifests.
302
+ const CALLER_ROOTS = [
303
+ 'scripts', 'plugin', 'console', 'bin', '.github', '.claude', 'package.json', 'dream.config.json',
304
+ ];
302
305
  const CALLER_EXTS = new Set(['.mjs', '.js', '.sh', '.json', '.html', '.yml', '.yaml']);
306
+ export const REQUIRED_OPERATIONAL_EXPORTS = [
307
+ { rel: 'scripts/corpus-reconcile.mjs', symbol: 'syncCorpusInputs' },
308
+ { rel: 'scripts/corpus-reconcile.mjs', symbol: 'materializeGistReceipts' },
309
+ { rel: 'scripts/corpus-reconcile.mjs', symbol: 'observeAndMaterializeGistReceipts' },
310
+ { rel: 'scripts/corpus-aggregates.mjs', symbol: 'rebuildCorpusAggregates' },
311
+ { rel: 'scripts/corpus-reconcile.mjs', symbol: 'reconcileCorpusUntilStable' },
312
+ { rel: 'scripts/corpus-reconcile.mjs', symbol: 'reconcileAndPrepareCorpusCandidate' },
313
+ ];
303
314
 
304
315
  const isTestFile = (f) => /\.(test|spec)\.(mjs|js)$/.test(path.basename(f))
305
316
  || f.includes(`${path.sep}tests${path.sep}`) || f.startsWith(`tests${path.sep}`);
@@ -505,7 +516,33 @@ export function callersOf(mod, files, repo = REPO) {
505
516
  return hits;
506
517
  }
507
518
 
508
- export function audit({ repo = REPO, standalone = STANDALONE, held = HELD } = {}) {
519
+ export function operationalExportAudit({ repo = REPO, required = REQUIRED_OPERATIONAL_EXPORTS } = {}) {
520
+ const files = callerFiles(repo);
521
+ const rows = required.map(({ rel, symbol }) => {
522
+ const sourceFile = path.join(repo, rel);
523
+ let source = '';
524
+ try { source = fs.readFileSync(sourceFile, 'utf8'); } catch { /* reported as missing below */ }
525
+ const quoted = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
526
+ const exported = new RegExp(`\\bexport\\s+(?:async\\s+)?function\\s+${quoted}\\s*\\(`).test(source);
527
+ if (!exported) return { rel, symbol, state: 'missing', callers: [] };
528
+ const declaration = new RegExp(`\\bexport\\s+(?:async\\s+)?function\\s+${quoted}\\s*\\(`, 'g');
529
+ const invocation = new RegExp(`\\b${quoted}\\s*\\(`);
530
+ const callers = [];
531
+ for (const file of files) {
532
+ const callerRel = path.relative(repo, file).split(path.sep).join('/');
533
+ if (isTestFile(callerRel)) continue;
534
+ let body = '';
535
+ try { body = stripComments(fs.readFileSync(file, 'utf8'), path.extname(file)); } catch { continue; }
536
+ body = body.replace(declaration, 'export function __operational_definition__(');
537
+ if (invocation.test(body)) callers.push(callerRel);
538
+ }
539
+ return { rel, symbol, state: callers.length ? 'wired' : 'unwired', callers };
540
+ });
541
+ return { rows };
542
+ }
543
+
544
+ export function audit({ repo = REPO, standalone = STANDALONE, held = HELD,
545
+ operationalExports = REQUIRED_OPERATIONAL_EXPORTS } = {}) {
509
546
  const dupes = [];
510
547
  const seen = new Map();
511
548
  for (const [name, why] of standalone) {
@@ -532,13 +569,15 @@ export function audit({ repo = REPO, standalone = STANDALONE, held = HELD } = {}
532
569
  state = 'manual';
533
570
  const list = names.map((n) => `\`${n}\``).join(', ');
534
571
  why = `defined as npm script ${list} and invoked by NOTHING automated — reachable only by a `
535
- + `human typing \`npm run ${names[0]}\`. Built and correct; not in any ship path.`;
572
+ + `human typing \`npm run ${names[0]}\`. This wiring audit does not establish operational `
573
+ + `correctness.`;
536
574
  }
537
575
  }
538
576
  }
539
577
  rows.push({ ...m, state, callers, ...(why ? { why } : {}) });
540
578
  }
541
- return { rows, dupes, inventory: all.length };
579
+ const operationalRows = operationalExportAudit({ repo, required: operationalExports }).rows;
580
+ return { rows, operationalRows, dupes, inventory: all.length };
542
581
  }
543
582
 
544
583
  /**
@@ -567,9 +606,12 @@ export function audit({ repo = REPO, standalone = STANDALONE, held = HELD } = {}
567
606
  * exists to prevent), different surface. Two new, narrow predicates, each modelling the REAL
568
607
  * mechanism Claude Code / the lesson dispatcher actually uses, not a generic text search:
569
608
  *
570
- * CHECK B — HOOK WIRING. Walks the real reachability chain: plugin/hooks/hooks.json (what we
571
- * ship) → hook-shim.mjs's own TABLE (resolving its id-based indirection explicitly, since a hook
572
- * id like "route-dispatch" is not the string "route-dispatch.sh") → this repo's own
609
+ * CHECK B — HOOK WIRING. Walks the real reachability chain for both supported hosts:
610
+ * plugin/hooks/hooks.json (Claude Code) → hook-shim.mjs's own TABLE (resolving its id-based
611
+ * indirection explicitly, since a hook id like "route-dispatch" is not the string
612
+ * "route-dispatch.sh"); and plugin/hooks/codex-hooks.json → bin/install.mjs's Stable Spine copy
613
+ * (codex-hook-wrapper.mjs installed as codex-hook.mjs) → codex-hook-adapter.mjs. It also reads
614
+ * this repo's own
573
615
  * .claude/settings.json → the user's REAL ~/.claude/settings.json (what is actually installed on
574
616
  * THIS machine — never checked before). One further hop is closed by a small fixed-point pass
575
617
  * (the unprompted-speech runtime spawns anticipate.sh/lesson-hooks.sh as candidate producers),
@@ -671,6 +713,28 @@ function hookShimIdIn(cmd) {
671
713
  return m ? m[1] : null;
672
714
  }
673
715
 
716
+ /** The dispatch id after Codex's inline bootstrap and numeric timeout. Same grammar as hook-registry. */
717
+ function codexHookIdIn(cmd) {
718
+ const m = cmd.match(/"\s+\d+\s+([a-zA-Z][\w-]*)/);
719
+ return m ? m[1] : null;
720
+ }
721
+
722
+ /**
723
+ * Derive the Stable Spine wrapper copy from the installer source. The Codex manifest names the
724
+ * durable installed target (`codex-hook.mjs`), while the repository inventory contains its source
725
+ * (`codex-hook-wrapper.mjs`). Both names plus the actual copy operation must be present; otherwise
726
+ * there is no proven bridge and the hook stays unwired.
727
+ */
728
+ function installedCodexHookWrapper(repo) {
729
+ let src = '';
730
+ try { src = fs.readFileSync(path.join(repo, 'bin/install.mjs'), 'utf8'); } catch { return null; }
731
+ const stripped = stripComments(src, '.mjs');
732
+ const source = stripped.match(/hookWrapperSource\s*=\s*path\.join\([^\n]*['"]([\w.-]+\.mjs)['"]\)/)?.[1];
733
+ const target = stripped.match(/const codexHookWrapperPath[\s\S]{0,260}?['"]([\w.-]+\.mjs)['"]\)/)?.[1];
734
+ const copiesSource = /fs\.copyFileSync\(hookWrapperSource,\s*tmp\)/.test(stripped);
735
+ return source && target && copiesSource ? { source, target } : null;
736
+ }
737
+
674
738
  /** hook-shim.mjs's own dispatch TABLE: id -> file. Parsed, not re-implemented — it IS the authority. */
675
739
  function hookShimTable(repo) {
676
740
  let src = '';
@@ -691,9 +755,9 @@ function hookHeaderDeclares(src) { return HOOK_EVENT_RE.test(src.slice(0, 1600))
691
755
 
692
756
  /**
693
757
  * CHECK B — HOOK WIRING. See the file-level comment above for the full reasoning. Walks the real
694
- * chain from the real entry points (plugin/hooks/hooks.json, this repo's .claude/settings.json, the
695
- * user's actual ~/.claude/settings.json) through hook-shim.mjs's id-based indirection, then closes
696
- * one further hop with a small fixed-point pass restricted to files already proven reachable.
758
+ * chain from the real entry points (both plugin hook manifests, this repo's .claude/settings.json,
759
+ * the user's actual ~/.claude/settings.json) through each host's real indirection, then closes one
760
+ * further hop with a small fixed-point pass restricted to files already proven reachable.
697
761
  */
698
762
  export function hookWiringAudit({
699
763
  repo = REPO,
@@ -701,6 +765,7 @@ export function hookWiringAudit({
701
765
  held = HOOK_HELD,
702
766
  } = {}) {
703
767
  const table = hookShimTable(repo);
768
+ const codexWrapper = installedCodexHookWrapper(repo);
704
769
  const reached = new Map(); // basename -> Set(reason)
705
770
  const add = (name, reason) => {
706
771
  if (!name) return;
@@ -708,16 +773,21 @@ export function hookWiringAudit({
708
773
  reached.get(name).add(reason);
709
774
  };
710
775
 
711
- const scanConfig = (file, label) => {
776
+ const scanConfig = (file, label, { codex = false } = {}) => {
712
777
  const doc = readJsonSafe(file);
713
778
  if (!doc || !doc.hooks) return;
714
779
  for (const cmd of commandStrings(doc.hooks)) {
715
- for (const b of basenamesIn(cmd)) add(b, label);
716
- const id = hookShimIdIn(cmd);
717
- if (id && table[id]) add(table[id], `${label} (hook-shim id "${id}")`);
780
+ const basenames = basenamesIn(cmd);
781
+ for (const b of basenames) add(b, label);
782
+ const id = codex ? codexHookIdIn(cmd) : hookShimIdIn(cmd);
783
+ if (id && table[id]) add(table[id], `${label} (${codex ? 'Codex dispatch' : 'hook-shim'} id "${id}")`);
784
+ if (codex && codexWrapper && basenames.includes(codexWrapper.target)) {
785
+ add(codexWrapper.source, `${label} via bin/install.mjs Stable Spine copy (${codexWrapper.target})`);
786
+ }
718
787
  }
719
788
  };
720
789
  scanConfig(path.join(repo, 'plugin/hooks/hooks.json'), 'plugin/hooks/hooks.json');
790
+ scanConfig(path.join(repo, 'plugin/hooks/codex-hooks.json'), 'plugin/hooks/codex-hooks.json', { codex: true });
721
791
  scanConfig(path.join(repo, '.claude/settings.json'), '.claude/settings.json (this repo)');
722
792
  scanConfig(homeSettingsFile, '~/.claude/settings.json (this machine)');
723
793
 
@@ -753,7 +823,14 @@ export function hookWiringAudit({
753
823
  return { rows, plumbing: HOOK_PLUMBING };
754
824
  }
755
825
 
756
- /** The trigger tokens requested by lesson-hooks.sh's `case "$EVENT" in` block — the sole authority. */
826
+ /**
827
+ * The trigger tokens requested by lesson-hooks.sh — the sole authority. A trigger reaches the gate
828
+ * two ways: a static `case "$EVENT" in ... TRIGGERS="<name>"` label, or a dynamic conditional append
829
+ * (`ARGS+=(--trigger <name>)`, e.g. `ship`, appended only when a live regex matches the real command
830
+ * text). Reading only the static form means a trigger whose SOLE live path is dynamic is invisible
831
+ * to this audit — and Check C would then depend on an unrelated dead case label merely coexisting in
832
+ * the file to report it correctly, which breaks the moment that dead label is ever cleaned up.
833
+ */
757
834
  function lessonHooksRequestedTriggers(repo) {
758
835
  let src = '';
759
836
  try { src = fs.readFileSync(path.join(repo, 'plugin/scripts/lesson-hooks.sh'), 'utf8'); } catch { return new Set(); }
@@ -762,6 +839,8 @@ function lessonHooksRequestedTriggers(repo) {
762
839
  const re = /TRIGGERS="([^"]*)"/g;
763
840
  let m;
764
841
  while ((m = re.exec(stripped))) { for (const t of m[1].split(/\s+/)) if (t) requested.add(t); }
842
+ const dynRe = /ARGS\+=\(--trigger\s+"?([A-Za-z][\w-]*)"?\)/g;
843
+ while ((m = dynRe.exec(stripped))) { requested.add(m[1]); }
765
844
  return requested;
766
845
  }
767
846
 
@@ -784,9 +863,10 @@ const invokedDirectly = process.argv[1]
784
863
  && path.resolve(process.argv[1]).endsWith(`wired-check${path.extname(process.argv[1])}`);
785
864
 
786
865
  if (invokedDirectly) {
787
- const { rows, dupes, inventory } = audit();
866
+ const { rows, operationalRows, dupes, inventory } = audit();
788
867
  const by = (s) => rows.filter((r) => r.state === s);
789
868
  const unwired = by('unwired');
869
+ const operationalUnwired = operationalRows.filter((row) => row.state !== 'wired');
790
870
 
791
871
  const hookAudit = hookWiringAudit();
792
872
  const hookBy = (s) => hookAudit.rows.filter((r) => r.state === s);
@@ -805,6 +885,16 @@ if (invokedDirectly) {
805
885
  console.log(` or add it to STANDALONE in this file WITH A TRUE REASON.\n`);
806
886
  }
807
887
 
888
+ console.log(` ${operationalRows.length} release-critical operational export(s) checked · `
889
+ + `${operationalUnwired.length} UNWIRED\n`);
890
+ for (const row of operationalUnwired) {
891
+ console.log(` ✗ ${row.rel}#${row.symbol} — exported, but no production invocation reaches it`);
892
+ }
893
+ if (operationalUnwired.length) {
894
+ console.log('\n Importing or re-exporting a function is not operation. A release-critical export must');
895
+ console.log(' be invoked through a production path whose module is itself wired.\n');
896
+ }
897
+
808
898
  // Every exemption, every run. v1 never printed these, so 3 false reasons rotted unseen for a
809
899
  // day inside the gate built to stop exactly that.
810
900
  if (by('manual').length) {
@@ -821,7 +911,7 @@ if (invokedDirectly) {
821
911
  if (dupes.length) console.log(` ✗ DUPLICATE exemption(s): ${dupes.join(', ')}\n`);
822
912
 
823
913
  // ── CHECK B: HOOK WIRING ──────────────────────────────────────────────────────────────────
824
- console.log(`\n ── HOOK WIRING — plugin/scripts/*.sh|*.mjs vs plugin/hooks/hooks.json, `
914
+ console.log(`\n ── HOOK WIRING — plugin/scripts/*.sh|*.mjs vs plugin/hooks/{hooks,codex-hooks}.json, `
825
915
  + `.claude/settings.json, ~/.claude/settings.json ──\n`);
826
916
  console.log(` ${hookAudit.rows.length} hook-intended script(s) in the census`);
827
917
  console.log(` ${hookBy('wired').length} wired · ${hookBy('held').length} held · `
@@ -862,6 +952,6 @@ if (invokedDirectly) {
862
952
  }
863
953
  }
864
954
 
865
- const bad = unwired.length || dupes.length || hookUnwired.length;
955
+ const bad = unwired.length || operationalUnwired.length || dupes.length || hookUnwired.length;
866
956
  process.exit(argv.includes('--check') && bad ? 1 : 0);
867
957
  }
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One mutation boundary for author-side automation.
4
+ *
5
+ * A clean linked worktree is the only place a corpus/update command may write. The primary
6
+ * checkout is never a writer, even when it is on a feature branch, because scheduled work cannot
7
+ * distinguish the owner's active edits from its own generated changes. This module only classifies
8
+ * and refuses; it never resets, cleans, removes, commits, or switches a checkout.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ function git(cwd, args) {
17
+ const result = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8' });
18
+ if (result.status !== 0) {
19
+ const detail = String(result.stderr || result.stdout || result.error?.message || 'unknown git error').trim();
20
+ throw new Error(`git ${args.join(' ')} failed: ${detail}`);
21
+ }
22
+ return String(result.stdout || '').trim();
23
+ }
24
+
25
+ function samePath(left, right) {
26
+ try { return fs.realpathSync(left) === fs.realpathSync(right); } catch { return false; }
27
+ }
28
+
29
+ function within(parent, candidate) {
30
+ const relative = path.relative(parent, candidate);
31
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
32
+ }
33
+
34
+ function digest(value) {
35
+ return crypto.createHash('sha256').update(value).digest('hex');
36
+ }
37
+
38
+ export function inspectMutationWorktree(root) {
39
+ const requestedRoot = path.resolve(root);
40
+ const worktreeRoot = fs.realpathSync(git(requestedRoot, ['rev-parse', '--show-toplevel']));
41
+ const commonDirRaw = git(worktreeRoot, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
42
+ const commonDir = fs.realpathSync(commonDirRaw);
43
+ const primaryRoot = fs.realpathSync(path.dirname(commonDir));
44
+ const dotGit = fs.lstatSync(path.join(worktreeRoot, '.git'));
45
+ const linked = dotGit.isFile();
46
+ const primary = samePath(worktreeRoot, primaryRoot);
47
+ const nestedInPrimary = !primary && within(primaryRoot, worktreeRoot);
48
+ const status = git(worktreeRoot, ['status', '--porcelain=v1', '--untracked-files=all']);
49
+ const clean = status.length === 0;
50
+ const allowed = linked && !primary && !nestedInPrimary && clean;
51
+ let reason = 'clean-linked-worktree';
52
+ if (primary) reason = 'primary-checkout';
53
+ else if (!linked) reason = 'not-linked-worktree';
54
+ else if (nestedInPrimary) reason = 'nested-inside-primary';
55
+ else if (!clean) reason = 'dirty-writer-worktree';
56
+ return { allowed, reason, worktreeRoot, primaryRoot, commonDir, linked, primary, nestedInPrimary, clean, status };
57
+ }
58
+
59
+ export function assertIsolatedMutationWorktree(root, operation = 'repository mutation') {
60
+ const state = inspectMutationWorktree(root);
61
+ if (!state.allowed) {
62
+ throw new Error(
63
+ `[worktree-integrity] DENIED ${operation}: ${state.reason}. `
64
+ + `Run author-side mutations in a clean linked worktree outside ${state.primaryRoot}; `
65
+ + 'the primary checkout is immutable to automation.',
66
+ );
67
+ }
68
+ return state;
69
+ }
70
+
71
+ /**
72
+ * Seal the primary checkout around an isolated author-side mutation.
73
+ *
74
+ * A status-only comparison is insufficient: a checkout that was already dirty can be modified
75
+ * again while keeping the same porcelain status. Bind HEAD, the staged diff, the full tracked
76
+ * working diff, and the exact non-ignored untracked-file set instead. Ignored runtime state (logs,
77
+ * AgentDB, locks) is deliberately outside the source-checkout contract.
78
+ */
79
+ export function snapshotPrimaryCheckout(root) {
80
+ const { primaryRoot } = inspectMutationWorktree(root);
81
+ return {
82
+ primaryRoot,
83
+ head: git(primaryRoot, ['rev-parse', 'HEAD']),
84
+ indexDigest: digest(git(primaryRoot, ['diff', '--binary', '--cached', 'HEAD', '--'])),
85
+ trackedDigest: digest(git(primaryRoot, ['diff', '--binary', 'HEAD', '--'])),
86
+ untracked: git(primaryRoot, ['ls-files', '--others', '--exclude-standard', '-z'])
87
+ .split('\0').filter(Boolean).sort(),
88
+ };
89
+ }
90
+
91
+ export function assertPrimaryCheckoutUnchanged(root, before) {
92
+ const after = snapshotPrimaryCheckout(root);
93
+ const fields = ['primaryRoot', 'head', 'indexDigest', 'trackedDigest', 'untracked'];
94
+ const changed = fields.filter((field) => JSON.stringify(after[field]) !== JSON.stringify(before[field]));
95
+ if (changed.length) {
96
+ throw new Error(
97
+ `[worktree-integrity] PRIMARY CHECKOUT CHANGED during isolated mutation: ${changed.join(', ')}. `
98
+ + 'The candidate is invalid and must not be promoted.',
99
+ );
100
+ }
101
+ return after;
102
+ }
103
+
104
+ const invokedDirectly = process.argv[1]
105
+ && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
106
+ if (invokedDirectly) {
107
+ try {
108
+ const command = process.argv[2];
109
+ if (command === 'snapshot') {
110
+ process.stdout.write(`${JSON.stringify(snapshotPrimaryCheckout(process.argv[3] || process.cwd()))}\n`);
111
+ } else if (command === 'verify') {
112
+ const root = process.argv[3] || process.cwd();
113
+ const receipt = JSON.parse(fs.readFileSync(process.argv[4], 'utf8'));
114
+ process.stdout.write(`${JSON.stringify(assertPrimaryCheckoutUnchanged(root, receipt))}\n`);
115
+ } else {
116
+ const state = assertIsolatedMutationWorktree(command || process.cwd(), process.argv[3] || 'repository mutation');
117
+ process.stdout.write(`${JSON.stringify(state)}\n`);
118
+ }
119
+ } catch (error) {
120
+ console.error(error?.message || String(error));
121
+ process.exit(2);
122
+ }
123
+ }