ruvnet-brain 4.7.22 → 4.7.24

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 (39) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +59 -1
  3. package/kb/lifecycle-evidence-retention.mjs +1 -1
  4. package/kb/update-storage-transaction.mjs +161 -1
  5. package/package.json +2 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/scripts/brain-footprint.mjs +5 -1
  9. package/plugin/scripts/learn-capture.mjs +26 -3
  10. package/scripts/generation-census.mjs +220 -0
  11. package/scripts/issue-autofix-agents.mjs +172 -0
  12. package/scripts/issue-autofix-comment.mjs +224 -0
  13. package/scripts/issue-autofix-diff.mjs +108 -0
  14. package/scripts/issue-autofix-exec.mjs +128 -0
  15. package/scripts/issue-autofix-fix.mjs +104 -0
  16. package/scripts/issue-autofix-gate.mjs +137 -0
  17. package/scripts/issue-autofix-night.mjs +164 -0
  18. package/scripts/issue-autofix-notes.mjs +346 -0
  19. package/scripts/issue-autofix-policy.mjs +349 -0
  20. package/scripts/issue-autofix-prompts.mjs +105 -0
  21. package/scripts/issue-autofix-release-ship.mjs +226 -0
  22. package/scripts/issue-autofix-release-util.mjs +141 -0
  23. package/scripts/issue-autofix-release.mjs +272 -0
  24. package/scripts/issue-autofix-report.mjs +121 -0
  25. package/scripts/issue-autofix-runner.mjs +75 -0
  26. package/scripts/issue-autofix-sandbox.mjs +108 -0
  27. package/scripts/issue-autofix-security.mjs +346 -0
  28. package/scripts/issue-autofix-selftest.mjs +101 -0
  29. package/scripts/issue-autofix-state.mjs +61 -0
  30. package/scripts/issue-autofix.mjs +382 -0
  31. package/scripts/issue-fix-lock.mjs +40 -0
  32. package/scripts/issue-fix.mjs +33 -20
  33. package/scripts/no-silent-substitution.mjs +1 -1
  34. package/scripts/ntfy-inbox.mjs +15 -0
  35. package/scripts/onboarding-console.mjs +6 -1
  36. package/scripts/release-prepush-checks.mjs +251 -0
  37. package/scripts/release-prepush.mjs +168 -0
  38. package/scripts/release-qualification-contract.mjs +32 -0
  39. package/scripts/wired-check.mjs +11 -5
package/README.md CHANGED
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
9
9
 
10
10
  ### Discover the stack. Read the source. Build with confidence.
11
11
 
12
- [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.22-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
12
+ [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.24-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
13
13
 
14
14
  **A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
15
15
 
package/bin/install.mjs CHANGED
@@ -25,7 +25,7 @@ import { applyBrainProfile, readBrainProfile } from '../kb/brain-profile.mjs';
25
25
  import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, physicalPath, recordRefreshAdvisory,
26
26
  recordRefreshPhase, settleRefreshRun, UPDATE_REFRESH_PHASES } from '../kb/refresh-run.mjs';
27
27
  import { assessLifecycleEvidence, pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
28
- import { checkDiskSpace, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
28
+ import { checkDiskSpace, disposeRecoveryRequired, previewRecoveryRequired, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
29
29
  import { saveUpdateSource, stableNode } from '../plugin/scripts/automatic-update.mjs';
30
30
  import { acquireDeveloperLock } from '../plugin/scripts/developer-update-lock.mjs';
31
31
  import { footprintRoots, inventoryFootprint, sweepFootprint } from '../plugin/scripts/brain-footprint.mjs';
@@ -172,6 +172,13 @@ const MOVE_BRAIN_TO = (() => { const i = argv.indexOf('--move-brain'); const v =
172
172
  const FLAG_CLEAN = argv.includes('--clean'); // enforce the footprint guarantee now (ADR-0098), then confirm
173
173
  const FLAG_JSON = argv.includes('--json'); // with --doctor / --clean: machine-readable confirmation only
174
174
  const FLAG_ARCHIVE = argv.includes('--archive'); // with --clean only (#335): archive files unique to old KB copies, then release them
175
+ // --recover-update (#335): show every update transaction stuck at RECOVERY_REQUIRED, changing nothing.
176
+ // --recover-update --keep-live <sha256> [--transaction <id>]: keep that exact live tree, move the
177
+ // transaction's other trees intact into .kb.recovered/<id>/, and record a terminal DISPOSED receipt.
178
+ const FLAG_RECOVER_UPDATE = argv.includes('--recover-update');
179
+ const argValue = (flag) => { const i = argv.indexOf(flag); const v = i === -1 ? null : argv[i + 1]; return v && !v.startsWith('-') ? v : null; };
180
+ const RECOVER_KEEP_LIVE = argValue('--keep-live');
181
+ const RECOVER_TRANSACTION = argValue('--transaction');
175
182
  // ── onboarding-experience flags (all optional; every offer is safe to decline) ──
176
183
  const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
177
184
  const FLAG_PLAN = argv.includes('--plan') || argv.includes('--dry-run'); // show the interactive checklist, then exit — install NOTHING
@@ -4072,6 +4079,50 @@ export function acquireHostConvergenceLock(lockPath, {
4072
4079
  return { acquired: false, error: 'host synchronization lock changed concurrently' };
4073
4080
  }
4074
4081
 
4082
+ /**
4083
+ * `--recover-update` (#335). Preview is read-only; disposition needs the exact live sha256 the preview
4084
+ * printed, so nothing is decided for the operator. Returns the exit code.
4085
+ */
4086
+ export function runRecoverUpdate({ kbDir = physicalPath(resolvedKbDir()), keepLive = RECOVER_KEEP_LIVE,
4087
+ transaction = RECOVER_TRANSACTION, json = FLAG_JSON, log = console.log } = {}) {
4088
+ const stuck = previewRecoveryRequired(kbDir);
4089
+ const short = (identity) => identity?.sha256 ? `${identity.sha256} (${identity.fileCount} files, ${identity.bytes} bytes)` : 'absent';
4090
+ if (!keepLive) {
4091
+ if (json) log(JSON.stringify({ kbDir, stuck }, null, 2));
4092
+ else if (!stuck.length) log('No update is stuck at RECOVERY_REQUIRED. Nothing to recover.');
4093
+ else {
4094
+ for (const t of stuck) {
4095
+ log(`Update ${t.transactionId} needs recovery: ${t.reason || 'no reason recorded'}`);
4096
+ log(` sealed prior: ${short(t.sealed.prior)}`);
4097
+ log(` sealed candidate: ${short(t.sealed.candidate)}`);
4098
+ for (const [name, tree] of Object.entries(t.trees)) {
4099
+ const match = tree.matchesPrior ? ' = sealed prior' : tree.matchesCandidate ? ' = sealed candidate' : tree.exists ? ' matches no sealed identity' : '';
4100
+ log(` ${name.padEnd(9)} ${tree.exists ? short(tree.identity) : 'absent'}${match}`);
4101
+ }
4102
+ log(` To keep the live tree above and close this update (other trees are moved, never deleted):`);
4103
+ log(` npx ruvnet-brain --recover-update --transaction ${t.transactionId} --keep-live ${t.trees.live.identity?.sha256 || '<live sha256>'}`);
4104
+ }
4105
+ log('Nothing was changed.');
4106
+ }
4107
+ return stuck.length ? 1 : 0;
4108
+ }
4109
+ const targets = transaction ? stuck.filter((t) => t.transactionId === transaction) : stuck;
4110
+ if (!targets.length) { log(transaction ? `No stuck update named ${transaction}.` : 'No update is stuck at RECOVERY_REQUIRED.'); return 1; }
4111
+ if (targets.length > 1) { log('More than one update is stuck; name one with --transaction <id>. Nothing was changed.'); return 1; }
4112
+ try {
4113
+ const result = disposeRecoveryRequired(kbDir, targets[0].transactionId, { keepLiveSha256: keepLive });
4114
+ if (json) log(JSON.stringify(result, null, 2));
4115
+ else {
4116
+ log(`Closed update ${result.transactionId}: kept the live tree ${result.live.sha256}`
4117
+ + ` (${result.liveMatchesPrior ? 'it equals the sealed prior' : result.liveMatchesCandidate ? 'it equals the sealed candidate' : 'it matches no sealed identity'}).`);
4118
+ for (const p of result.preserved) log(` moved ${p.tree} intact to ${p.to}`);
4119
+ if (!result.preserved.length) log(' the update owned no other tree.');
4120
+ log('Run npx ruvnet-brain --update next; nothing was deleted.');
4121
+ }
4122
+ return 0;
4123
+ } catch (error) { log(`Not recovered: ${error.message}. Nothing was deleted.`); return 1; }
4124
+ }
4125
+
4075
4126
  export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
4076
4127
  sourceRoot = REPO_ROOT,
4077
4128
  brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'),
@@ -6829,6 +6880,12 @@ Usage:
6829
6880
  footprint within budget, no cruft. --doctor --json prints only that, as JSON.
6830
6881
  npx ruvnet-brain --clean Remove everything that must not exist (old KB copies — never one holding
6831
6882
  private files the live brain lacks — old installers, over-cap logs), then confirm.
6883
+ npx ruvnet-brain --recover-update
6884
+ Read-only: list any update stuck at RECOVERY_REQUIRED, with the measured
6885
+ identity of the live tree and every tree that update owns.
6886
+ npx ruvnet-brain --recover-update --keep-live <sha256> [--transaction <id>]
6887
+ Keep that exact live tree, move the update's other trees intact into
6888
+ .kb.recovered/<id>/ (nothing is deleted) and close the update for good.
6832
6889
  npx ruvnet-brain --clean --archive
6833
6890
  Same, and for a kept copy: copy only the files it alone holds into
6834
6891
  kb.archive/<stamp>/ (verified byte-for-byte), then release that copy.
@@ -6942,6 +6999,7 @@ the installer reports that boot-level declarations changed.
6942
6999
  // and `echo $?` → 0 coexisted for so long.
6943
7000
  // `--doctor --json`: the positive-confirmation object ONLY (no narration), exit 0 iff every line that
6944
7001
  // can be proven here is green — for scripts, CI, and agents (ADR-0098).
7002
+ if (FLAG_RECOVER_UPDATE) { process.exitCode = runRecoverUpdate(); return; }
6945
7003
  if (FLAG_DOCTOR) { process.exitCode = await doctor({ json: FLAG_JSON }); return; }
6946
7004
  // `--clean`: enforce the footprint guarantee now, then confirm. Exit 0 only when exactly one KB copy
6947
7005
  // remains and nothing that must not exist is left (a private-unique copy kept for safety is a 1).
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { physicalPath } from './refresh-run.mjs';
5
5
 
6
6
  const TERMINAL_REFRESH = new Set(['SUCCEEDED', 'FAILED', 'ABANDONED']);
7
- const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
7
+ const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']);
8
8
  const SAFE_ID = /^[A-Za-z0-9._-]+$/;
9
9
  const PHASE_FILE = /^\d{3}-[A-Z_]+\.json$/;
10
10
  const QUARANTINE = /^\.prune-[A-Za-z0-9._-]+-[a-f0-9]{16}$/;
@@ -15,8 +15,13 @@ const STATES = Object.freeze([
15
15
  'COMMITTED',
16
16
  'ROLLED_BACK',
17
17
  'RECOVERY_REQUIRED',
18
+ 'DISPOSED',
18
19
  ]);
19
20
 
21
+ // Terminal states: recovery never acts on a transaction whose newest receipt is one of these.
22
+ // DISPOSED is the operator's explicit, evidence-preserving end to a RECOVERY_REQUIRED transaction (#335).
23
+ export const TERMINAL_TRANSACTION_STATES = Object.freeze(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']);
24
+
20
25
  // ── DISK-SPACE PREFLIGHT ──────────────────────────────────────────────────────────────────────────
21
26
  // An apply needs room for the unpacked bundle (temp), the candidate generation beside the live one
22
27
  // (the bundle plus the live node_modules carried into it) and its receipts; measured ~3.3 GB growth and
@@ -337,6 +342,7 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
337
342
  const phaseFiles = fs.readdirSync(receipts).filter((name) => /^\d{3}-[A-Z_]+\.json$/.test(name)).sort();
338
343
  if (!phaseFiles.length) throw new Error(`storage transaction receipt is empty: ${receipts}`);
339
344
  const latest = JSON.parse(fs.readFileSync(path.join(receipts, phaseFiles.at(-1)), 'utf8'));
345
+ if (latest.state === 'DISPOSED') continue;
340
346
  if (['NOOP', 'COMMITTED', 'ROLLED_BACK'].includes(latest.state)) {
341
347
  // A quarantined unsealed candidate is kept for ONE full update cycle, then released on the next
342
348
  // run — but only if its bytes are exactly what was sealed when it was quarantined.
@@ -354,7 +360,10 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
354
360
  }
355
361
  continue;
356
362
  }
357
- if (latest.state === 'RECOVERY_REQUIRED') throw new Error(`storage transaction requires manual recovery: ${transactionId}`);
363
+ if (latest.state === 'RECOVERY_REQUIRED') {
364
+ throw new Error(`storage transaction requires manual recovery: ${transactionId} `
365
+ + `(preview with npx ruvnet-brain --recover-update ; nothing is changed by the preview)`);
366
+ }
358
367
  const paths = latest.paths;
359
368
  const expectedPaths = transactionPaths(live, transactionId);
360
369
  if (!paths || ['live', 'candidate', 'rollback', 'failed'].some((name) =>
@@ -567,3 +576,154 @@ export function runStorageTransaction({
567
576
  record('COMMITTED', { terminalVerdict: 'applied', prior, candidate, storageDelta: delta });
568
577
  return { terminalVerdict: 'applied', prior, candidate, paths, storageDelta: delta };
569
578
  }
579
+
580
+ // ── OPERATOR RECOVERY FOR A RECOVERY_REQUIRED TRANSACTION (#335) ──────────────────────────────────
581
+ // Automatic recovery stops at RECOVERY_REQUIRED on purpose: the trees no longer match what the receipts
582
+ // sealed, so no receipt can say which bytes are disposable. These two functions give the operator a
583
+ // supported way out without weakening that check:
584
+ // 1. previewRecoveryRequired() measures every tree the transaction owns and compares each with the
585
+ // identities its receipts sealed. It changes nothing.
586
+ // 2. disposeRecoveryRequired() keeps the CURRENT live tree in service only when the operator names its
587
+ // exact sha256 from the preview, moves every other tree the transaction owns intact into
588
+ // `.<kb>.recovered/<transaction>/` (nothing is deleted), proves each moved tree is byte-identical
589
+ // after the move, and records a terminal DISPOSED receipt with what was measured. It never infers
590
+ // that live equals the prior sealed tree: it records whether it does.
591
+
592
+ const SAFE_TRANSACTION_ID = /^[A-Za-z0-9._-]+$/;
593
+ const OWNED_TREES = Object.freeze(['candidate', 'rollback', 'failed']);
594
+
595
+ function receiptsRootFor(live) {
596
+ return path.join(path.dirname(live), `.${path.basename(live)}.update-transactions`);
597
+ }
598
+
599
+ /** Where a disposition keeps the trees it moved. Sibling of the live tree, so a move is a rename. */
600
+ export function recoveredRootFor(liveDir) {
601
+ const live = path.resolve(liveDir);
602
+ return path.join(path.dirname(live), `.${path.basename(live)}.recovered`);
603
+ }
604
+
605
+ function latestReceipt(receipts) {
606
+ const phaseFiles = fs.readdirSync(receipts).filter((name) => /^\d{3}-[A-Z_]+\.json$/.test(name)).sort();
607
+ if (!phaseFiles.length) return null;
608
+ return JSON.parse(fs.readFileSync(path.join(receipts, phaseFiles.at(-1)), 'utf8'));
609
+ }
610
+
611
+ function sealedReceipts(receipts) {
612
+ // Every identity any receipt of this transaction sealed, newest last.
613
+ const out = { prior: null, candidate: null };
614
+ for (const name of fs.readdirSync(receipts).filter((n) => /^\d{3}-[A-Z_]+\.json$/.test(n)).sort()) {
615
+ try {
616
+ const r = JSON.parse(fs.readFileSync(path.join(receipts, name), 'utf8'));
617
+ if (r.prior?.sha256) out.prior = r.prior;
618
+ if (r.candidate?.sha256) out.candidate = r.candidate;
619
+ } catch { /* an unreadable receipt seals nothing */ }
620
+ }
621
+ return out;
622
+ }
623
+
624
+ const sameIdentity = (a, b) => Boolean(a?.sha256 && b?.sha256 && a.sha256 === b.sha256
625
+ && a.bytes === b.bytes && a.fileCount === b.fileCount);
626
+
627
+ function measureTree(dir, sealed) {
628
+ let st = null;
629
+ try { st = fs.lstatSync(dir); } catch { /* absent */ }
630
+ if (!st) return { path: dir, exists: false, identity: null, matchesPrior: false, matchesCandidate: false };
631
+ if (st.isSymbolicLink() || !st.isDirectory()) {
632
+ return { path: dir, exists: true, directory: false, identity: null, matchesPrior: false, matchesCandidate: false };
633
+ }
634
+ const identity = identitySummary(treeIdentity(dir));
635
+ return { path: dir, exists: true, directory: true, identity,
636
+ matchesPrior: sameIdentity(identity, sealed.prior), matchesCandidate: sameIdentity(identity, sealed.candidate) };
637
+ }
638
+
639
+ /**
640
+ * Mutation-free report of every transaction whose newest receipt is RECOVERY_REQUIRED.
641
+ * @returns {Array<{transactionId:string, reason:string|null, recordedAt:string|null,
642
+ * sealed:{prior:object|null,candidate:object|null}, trees:Record<string,object>}>}
643
+ */
644
+ export function previewRecoveryRequired(liveDir) {
645
+ const live = path.resolve(liveDir);
646
+ const root = receiptsRootFor(live);
647
+ if (!fs.existsSync(root)) return [];
648
+ const out = [];
649
+ for (const transactionId of fs.readdirSync(root).sort()) {
650
+ if (!SAFE_TRANSACTION_ID.test(transactionId) || transactionId.startsWith('.')) continue;
651
+ const receipts = path.join(root, transactionId);
652
+ if (!fs.lstatSync(receipts).isDirectory()) continue;
653
+ const latest = latestReceipt(receipts);
654
+ if (latest?.state !== 'RECOVERY_REQUIRED') continue;
655
+ const sealed = sealedReceipts(receipts);
656
+ const expected = transactionPaths(live, transactionId);
657
+ const trees = { live: measureTree(live, sealed) };
658
+ for (const name of OWNED_TREES) trees[name] = measureTree(expected[name], sealed);
659
+ out.push({ transactionId, reason: latest.reason || null, recordedAt: latest.recordedAt || null,
660
+ sealed: { prior: sealed.prior ? identitySummary(sealed.prior) : null,
661
+ candidate: sealed.candidate ? identitySummary(sealed.candidate) : null }, trees });
662
+ }
663
+ return out;
664
+ }
665
+
666
+ /**
667
+ * The operator's explicit disposition: keep the current live tree, preserve every other tree intact.
668
+ * @param {string} liveDir
669
+ * @param {string} transactionId
670
+ * @param {{ keepLiveSha256: string, rename?: Function }} options keepLiveSha256 must be the live tree's
671
+ * sha256 exactly as the preview reported it; any change since the preview refuses.
672
+ */
673
+ export function disposeRecoveryRequired(liveDir, transactionId, { keepLiveSha256, rename = fs.renameSync } = {}) {
674
+ const live = path.resolve(liveDir);
675
+ if (!SAFE_TRANSACTION_ID.test(String(transactionId || '')) || String(transactionId).startsWith('.')) {
676
+ throw new Error(`not a transaction id: ${transactionId}`);
677
+ }
678
+ if (!/^[a-f0-9]{64}$/.test(String(keepLiveSha256 || ''))) {
679
+ throw new Error('name the live tree to keep by the exact sha256 the preview printed (--keep-live <sha256>)');
680
+ }
681
+ const receipts = path.join(receiptsRootFor(live), transactionId);
682
+ if (!fs.existsSync(receipts) || !fs.lstatSync(receipts).isDirectory()) throw new Error(`no such transaction: ${transactionId}`);
683
+ const latest = latestReceipt(receipts);
684
+ if (latest?.state !== 'RECOVERY_REQUIRED') {
685
+ throw new Error(`transaction ${transactionId} is ${latest?.state || 'empty'}, not RECOVERY_REQUIRED; nothing to dispose`);
686
+ }
687
+ const expected = transactionPaths(live, transactionId);
688
+ if (latest.paths && ['live', ...OWNED_TREES].some((name) => latest.paths[name] && path.resolve(latest.paths[name]) !== expected[name])) {
689
+ throw new Error(`transaction ${transactionId} names paths that belong to another live tree; refusing`);
690
+ }
691
+ assertDirectory(live, 'live tree');
692
+ const sealed = sealedReceipts(receipts);
693
+ const liveIdentity = identitySummary(treeIdentity(live));
694
+ if (liveIdentity.sha256 !== keepLiveSha256) {
695
+ throw new Error(`the live tree changed since the preview (now ${liveIdentity.sha256}); run the preview again`);
696
+ }
697
+ const archiveDir = path.join(recoveredRootFor(live), transactionId);
698
+ const moves = [];
699
+ for (const name of OWNED_TREES) {
700
+ const source = expected[name];
701
+ if (!fs.existsSync(source)) continue;
702
+ assertDirectory(source, `transaction ${name} tree`);
703
+ const target = path.join(archiveDir, name);
704
+ if (fs.existsSync(target)) throw new Error(`recovered tree already exists: ${target}`);
705
+ moves.push({ name, source, target, before: identitySummary(treeIdentity(source)) });
706
+ }
707
+ fs.mkdirSync(archiveDir, { recursive: true });
708
+ const preserved = [];
709
+ for (const move of moves) {
710
+ rename(move.source, move.target);
711
+ const after = identitySummary(treeIdentity(move.target));
712
+ if (!sameIdentity(after, move.before)) {
713
+ throw new Error(`${move.name} tree changed while it was moved to ${move.target}; it is kept there, nothing was deleted`);
714
+ }
715
+ preserved.push({ tree: move.name, from: move.source, to: move.target, identity: after,
716
+ matchesPrior: sameIdentity(after, sealed.prior), matchesCandidate: sameIdentity(after, sealed.candidate) });
717
+ }
718
+ const receipt = appendRecoveryReceipt(receipts, 'DISPOSED', {
719
+ terminalVerdict: 'operator-kept-live',
720
+ live: liveIdentity,
721
+ liveMatchesPrior: sameIdentity(liveIdentity, sealed.prior),
722
+ liveMatchesCandidate: sameIdentity(liveIdentity, sealed.candidate),
723
+ preserved,
724
+ recoveredFrom: { reason: latest.reason || null, recordedAt: latest.recordedAt || null },
725
+ reason: 'operator kept the current live tree; every other tree this transaction owned was moved intact, not deleted',
726
+ });
727
+ return { transactionId, terminalVerdict: 'operator-kept-live', live: liveIdentity,
728
+ liveMatchesPrior: receipt.liveMatchesPrior, liveMatchesCandidate: receipt.liveMatchesCandidate, preserved, archiveDir };
729
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.22",
3
+ "version": "4.7.24",
4
4
  "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -77,6 +77,7 @@
77
77
  "deferral:replay": "node scripts/deferral-replay.mjs",
78
78
  "hooks:check": "node scripts/hook-retirement-check.mjs",
79
79
  "release:qualify": "node scripts/release-qualification.mjs",
80
+ "release:prepush": "node scripts/release-prepush.mjs",
80
81
  "rehearse:corpus": "node scripts/rehearse-corpus-pipeline.mjs",
81
82
  "single-source:check": "node scripts/single-source-check.mjs",
82
83
  "host:codex:execution-proof": "node scripts/codex-host-execution-proof.mjs"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.7.22",
4
+ "version": "4.7.24",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.22",
3
+ "version": "4.7.24",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -70,7 +70,7 @@ const RUFLO_DEBRIS = new Set(RUFLO_CWD_ARTIFACTS);
70
70
  // Directories in the brain home that hold data another tool owns: measured, shown, never budgeted or removed.
71
71
  const FOREIGN = { 'ruvector-mcp': 'RuVector MCP server working store (its own live memory)',
72
72
  'worktree-recovery': 'hand-made recovery archives, not created by the Brain' };
73
- const TERMINAL = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
73
+ const TERMINAL = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']); // DISPOSED: operator recovery (#335)
74
74
 
75
75
  const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
76
76
  const lstat = (file) => { try { return fs.lstatSync(file); } catch { return null; } };
@@ -190,6 +190,10 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
190
190
  }
191
191
  };
192
192
  const classifySibling = (name, full, st) => {
193
+ if (name === `.${base}.recovered` && st.isDirectory() && !st.isSymbolicLink()) { // #335: written only by --recover-update --keep-live
194
+ return void add({ id: 'kb-recovered', path: full, class: 'may-exist', kind: 'kb-recovered', action: 'keep', bytes: bytes(full),
195
+ reason: 'trees an operator-disposed update transaction owned, moved intact (--recover-update); never removed automatically' });
196
+ }
193
197
  if (name === `${base}.archive` && st.isDirectory() && !st.isSymbolicLink()) { // #335: written only by --clean --archive
194
198
  return void add({ id: 'kb-archive', path: full, class: 'may-exist', kind: 'kb-archive', action: 'keep', bytes: bytes(full),
195
199
  reason: 'files unique to released KB copies, archived on request (--clean --archive); never removed automatically' });
@@ -24,6 +24,21 @@ const learnDir = (brainHome, sub) => { const dir = path.join(brainHome, 'learn-c
24
24
  export function recentlyCaptured(brainHome, key, now = Date.now()) {
25
25
  try { return now - fs.statSync(path.join(learnDir(brainHome, 'recent'), key)).mtimeMs < COALESCE_MS; } catch { return false; }
26
26
  }
27
+ // #377 residual (sparkling, 2026-10-30): the parent used to call markCaptured() BEFORE the detached child stored
28
+ // anything, so a child whose store or exact readback failed left the key acknowledged as captured for the whole
29
+ // COALESCE_MS window. Now the parent only marks the key IN FLIGHT (`<key>.pending`, which is never an
30
+ // acknowledgement and expires with the child's slot), and the child acknowledges with markCaptured() only after
31
+ // recordLearningObservation() has completed its exact readback. A failed child clears the in-flight mark.
32
+ const inFlightMarker = (brainHome, key) => path.join(learnDir(brainHome, 'recent'), `${key}.pending`);
33
+ export function captureInFlight(brainHome, key, now = Date.now()) {
34
+ try { return now - fs.statSync(inFlightMarker(brainHome, key)).mtimeMs < SLOT_STALE_MS; } catch { return false; }
35
+ }
36
+ export function markInFlight(brainHome, key) {
37
+ try { fs.writeFileSync(inFlightMarker(brainHome, key), '', { mode: 0o600 }); } catch { /* best effort: coalescing only */ }
38
+ }
39
+ export function clearInFlight(brainHome, key) {
40
+ try { fs.unlinkSync(inFlightMarker(brainHome, key)); } catch { /* already gone */ }
41
+ }
27
42
  export function markCaptured(brainHome, key, now = Date.now()) {
28
43
  const dir = learnDir(brainHome, 'recent');
29
44
  try { fs.writeFileSync(path.join(dir, key), '', { mode: 0o600 }); } catch { return; }
@@ -105,12 +120,13 @@ export function captureLearning(input, { env = process.env, binary = resolveRufl
105
120
  const observation = { tool, action, session: String(input.session_id || '') };
106
121
  const key = observationKey(observation);
107
122
  if (recentlyCaptured(brainHome, key)) return true; // identical observation already captured this window: no process
123
+ if (detach && captureInFlight(brainHome, key)) return true; // the same observation is being stored right now
108
124
  // Review M3: on the hook path the store + exact readback run in a detached, bounded child, so the
109
125
  // tool call returns at once; that child writes the ledger row if the write is refused or unverified.
110
126
  if (detach) {
111
127
  const slot = acquireObserveSlot(brainHome);
112
128
  if (!slot) return shedObservation(brainHome);
113
- markCaptured(brainHome, key);
129
+ markInFlight(brainHome, key); // NOT an acknowledgement: only the child's exact readback acknowledges (#377)
114
130
  return detachLearning('--observe', { env: { ...env, RUFLO_BIN: binary }, brainHome, observation, slot });
115
131
  }
116
132
  recordLearningObservation(binary, context, observation, { env, deadline: Date.now() + 6000 });
@@ -136,6 +152,8 @@ export function detachLearning(mode, { env = process.env, brainHome, observation
136
152
  }
137
153
 
138
154
  function runDetached(mode) {
155
+ const brainHome = defaultBrainHome(process.env);
156
+ let observation = null;
139
157
  const fail = (error) => recordHookFailure({ hook: mode === '--distill' ? 'learn-distill' : 'learn-capture', event: mode === '--distill' ? 'SessionEnd' : 'PostToolUse', error: String(error?.message || error).slice(0, 160) });
140
158
  try {
141
159
  const context = learningContext(); // consent is re-read: a withdrawn consent writes nothing
@@ -145,8 +163,13 @@ function runDetached(mode) {
145
163
  if (mode === '--distill') { distillObservations(binary, context, { env: process.env }); return; }
146
164
  const o = JSON.parse(process.env.RUVNET_LEARN_OBSERVATION || '{}');
147
165
  if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(o.tool) || typeof o.action !== 'string') throw new Error('invalid detached observation');
148
- recordLearningObservation(binary, context, { tool: o.tool, action: o.action, session: String(o.session || '') }, { env: process.env, deadline: Date.now() + 14_000, allowed: () => learningContext().enabled });
149
- } catch (error) { fail(error); } finally { if (process.env.RUVNET_LEARN_SLOT) releaseObserveSlot(process.env.RUVNET_LEARN_SLOT); }
166
+ observation = { tool: o.tool, action: o.action, session: String(o.session || '') };
167
+ recordLearningObservation(binary, context, observation, { env: process.env, deadline: Date.now() + 14_000, allowed: () => learningContext().enabled });
168
+ markCaptured(brainHome, observationKey(observation)); // acknowledged only after the exact readback succeeded (#377)
169
+ } catch (error) { fail(error); } finally {
170
+ if (observation) clearInFlight(brainHome, observationKey(observation));
171
+ if (process.env.RUVNET_LEARN_SLOT) releaseObserveSlot(process.env.RUVNET_LEARN_SLOT);
172
+ }
150
173
  }
151
174
 
152
175
  const isMain = isDirectInvocation(import.meta.url);
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+ // DISTINCT-FROM: kb/zip-extract.mjs — that extracts a whole local archive from a file descriptor; this reads only the idmap sidecars of a REMOTE release asset over HTTP Range and never fetches the ~750 MB body.
3
+ //
4
+ // scripts/generation-census.mjs — the public census (public stores, chunks) of a published corpus
5
+ // generation, read straight from its release asset without downloading it.
6
+ //
7
+ // Release QE requires the committed claim surfaces (README, explainer/*) to quote the exact census of
8
+ // the NEWEST corpus generation, and the corpus republishes nightly. Assembling a candidate to learn
9
+ // two numbers costs ~750 MB and a minute; the numbers live in ~200 small `*.big.rvf.idmap.json`
10
+ // members of the zip. So: Range-read the end-of-central-directory, the central directory, and each
11
+ // idmap member; inflate, CRC-check, count `idToLabel` keys; exclude the private fence exactly as
12
+ // scripts/claims-verify.mjs computePublicChunkTotal does. Anything unexpected (zip64, another
13
+ // compression method, a CRC mismatch, a short read) fails loudly — a wrong census is worse than none.
14
+ //
15
+ // node scripts/generation-census.mjs --repo owner/name --tag corpus-sha256-<digest>
16
+
17
+ import crypto from 'node:crypto';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import zlib from 'node:zlib';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
24
+
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ export const ARCHIVE_ASSET = 'ruvnet-brain.zip';
27
+ export const IDMAP_SUFFIX = '.big.rvf.idmap.json';
28
+ const TAIL_BYTES = 66_000; // EOCD (22) + the largest possible comment (65,535) + the zip64 locator (20)
29
+ const SIG = { eocd: 0x06054b50, zip64Locator: 0x07064b50, central: 0x02014b50, local: 0x04034b50 };
30
+ const U16 = 0xffff;
31
+ const U32 = 0xffffffff;
32
+ const CONCURRENCY = 8;
33
+
34
+ const zip64 = (what) => new Error(`zip64 ${what}: this reader refuses zip64 archives rather than guess at their layout`);
35
+
36
+ /** End-of-central-directory from the archive's last bytes. */
37
+ export function parseEocd(tail) {
38
+ for (let i = tail.length - 22; i >= 0; i -= 1) {
39
+ if (tail.readUInt32LE(i) !== SIG.eocd) continue;
40
+ if (i >= 20 && tail.readUInt32LE(i - 20) === SIG.zip64Locator) throw zip64('end-of-central-directory locator');
41
+ const disk = tail.readUInt16LE(i + 4), cdDisk = tail.readUInt16LE(i + 6);
42
+ const onDisk = tail.readUInt16LE(i + 8), total = tail.readUInt16LE(i + 10);
43
+ const cdSize = tail.readUInt32LE(i + 12), cdOffset = tail.readUInt32LE(i + 16);
44
+ if (total === U16 || onDisk === U16 || cdSize === U32 || cdOffset === U32) throw zip64('marker in the end-of-central-directory');
45
+ if (disk !== 0 || cdDisk !== 0 || onDisk !== total) throw new Error('multi-disk zip archives are not supported');
46
+ return { total, cdSize, cdOffset };
47
+ }
48
+ throw new Error('no end-of-central-directory record in the archive tail');
49
+ }
50
+
51
+ /** Every central-directory entry: name, method, flags, crc, sizes, local-header offset. */
52
+ export function parseCentralDirectory(cd, expected) {
53
+ const entries = [];
54
+ let p = 0;
55
+ while (p < cd.length) {
56
+ if (p + 46 > cd.length || cd.readUInt32LE(p) !== SIG.central) throw new Error(`malformed central directory at byte ${p}`);
57
+ const nameLength = cd.readUInt16LE(p + 28), extraLength = cd.readUInt16LE(p + 30), commentLength = cd.readUInt16LE(p + 32);
58
+ const entry = {
59
+ name: cd.subarray(p + 46, p + 46 + nameLength).toString('utf8'),
60
+ flags: cd.readUInt16LE(p + 8), method: cd.readUInt16LE(p + 10), crc: cd.readUInt32LE(p + 16),
61
+ compressedSize: cd.readUInt32LE(p + 20), size: cd.readUInt32LE(p + 24), offset: cd.readUInt32LE(p + 42),
62
+ extraLength,
63
+ };
64
+ if (entry.compressedSize === U32 || entry.size === U32 || entry.offset === U32) throw zip64(`marker on entry ${entry.name}`);
65
+ const extra = cd.subarray(p + 46 + nameLength, p + 46 + nameLength + extraLength);
66
+ for (let q = 0; q + 4 <= extra.length; q += 4 + extra.readUInt16LE(q + 2)) {
67
+ if (extra.readUInt16LE(q) === 0x0001) throw zip64(`extra field on entry ${entry.name}`);
68
+ }
69
+ entries.push(entry);
70
+ p += 46 + nameLength + extraLength + commentLength;
71
+ }
72
+ if (Number.isInteger(expected) && entries.length !== expected) {
73
+ throw new Error(`central directory lists ${entries.length} entries; the end record says ${expected}`);
74
+ }
75
+ return entries;
76
+ }
77
+
78
+ /** The public idmap members: no resource forks, no private store, no store counted twice. */
79
+ export function selectIdmapEntries(entries, privateStores = []) {
80
+ const fence = new Set(privateStores);
81
+ const seen = new Map();
82
+ for (const entry of entries) {
83
+ if (!entry.name.endsWith(IDMAP_SUFFIX) || entry.name.split('/').includes('__MACOSX')) continue;
84
+ const base = path.posix.basename(entry.name);
85
+ if (base.startsWith('._')) continue;
86
+ const store = base.slice(0, -IDMAP_SUFFIX.length);
87
+ if (fence.has(store)) continue;
88
+ if (seen.has(store)) throw new Error(`store ${store} has two idmap members (${seen.get(store).name}, ${entry.name})`);
89
+ seen.set(store, { ...entry, store });
90
+ }
91
+ return [...seen.values()];
92
+ }
93
+
94
+ /** One member's bytes from a buffer that starts at its local header; null when the buffer is too short. */
95
+ export function decodeEntry(entry, local) {
96
+ if (local.length < 30 || local.readUInt32LE(0) !== SIG.local) throw new Error(`no local header for ${entry.name}`);
97
+ const start = 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
98
+ if (local.length < start + entry.compressedSize) return { need: start + entry.compressedSize };
99
+ const raw = local.subarray(start, start + entry.compressedSize);
100
+ let data;
101
+ if (entry.method === 0) data = raw;
102
+ else if (entry.method === 8) data = zlib.inflateRawSync(raw);
103
+ else throw new Error(`${entry.name} uses compression method ${entry.method}; only stored (0) and deflate (8) are read`);
104
+ if (typeof zlib.crc32 !== 'function') throw new Error('zlib.crc32 is unavailable (Node >= 20.15 / 22.2 required) — cannot verify members');
105
+ if (data.length !== entry.size) throw new Error(`${entry.name} inflated to ${data.length} bytes; the directory says ${entry.size}`);
106
+ const crc = zlib.crc32(data) >>> 0;
107
+ if (crc !== entry.crc >>> 0) throw new Error(`${entry.name} failed its CRC-32 check (expected ${entry.crc.toString(16)}, got ${crc.toString(16)})`);
108
+ return { data };
109
+ }
110
+
111
+ export function countIdmap(buffer, name) {
112
+ const { idToLabel } = JSON.parse(buffer.toString('utf8'));
113
+ if (!idToLabel || typeof idToLabel !== 'object' || Array.isArray(idToLabel)) throw new Error(`${name} has no idToLabel object`);
114
+ return Object.keys(idToLabel).length;
115
+ }
116
+
117
+ export function readPrivateStores(file = path.join(ROOT, 'kb', 'PRIVATE-STORES.json')) {
118
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
119
+ if (!Array.isArray(parsed?.privateStores)) throw new Error(`${file} lacks a privateStores array`);
120
+ return [...parsed.privateStores].sort();
121
+ }
122
+
123
+ /** The release asset row (id, size, url) from the GitHub API. */
124
+ async function resolveAsset({ repo, tag, fetch, token }) {
125
+ const headers = { Accept: 'application/vnd.github+json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
126
+ const response = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`, { headers });
127
+ if (!response.ok) throw new Error(`release ${tag}: GitHub API answered HTTP ${response.status}`);
128
+ const assets = ((await response.json())?.assets || []).filter((asset) => asset?.name === ARCHIVE_ASSET);
129
+ if (assets.length !== 1) throw new Error(`release ${tag} has ${assets.length} ${ARCHIVE_ASSET} assets; expected exactly one`);
130
+ return { id: assets[0].id, size: assets[0].size, url: assets[0].browser_download_url };
131
+ }
132
+
133
+ /** Range reads against the asset; the redirect is resolved once and re-resolved if the signed URL lapses. */
134
+ function rangeReader({ url, size, fetch }) {
135
+ let target = url;
136
+ let fetched = 0;
137
+ const read = async (start, end, retried = false) => {
138
+ const response = await fetch(target, { headers: { Range: `bytes=${start}-${end}` }, redirect: 'follow' });
139
+ if (response.status !== 206) {
140
+ if (!retried && target !== url) { target = url; return read(start, end, true); }
141
+ throw new Error(`range ${start}-${end} of ${url}: expected HTTP 206, got ${response.status}`);
142
+ }
143
+ const total = Number(/\/(\d+)$/.exec(response.headers.get('content-range') || '')?.[1]);
144
+ if (total !== size) throw new Error(`asset is ${total} bytes; the release says ${size}`);
145
+ if (response.url) target = response.url;
146
+ const buffer = Buffer.from(await response.arrayBuffer());
147
+ if (buffer.length !== end - start + 1) throw new Error(`short range read ${start}-${end}: ${buffer.length} bytes`);
148
+ fetched += buffer.length;
149
+ return buffer;
150
+ };
151
+ return { read, fetched: () => fetched };
152
+ }
153
+
154
+ async function pool(items, limit, work) {
155
+ const out = new Array(items.length);
156
+ let next = 0;
157
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
158
+ while (next < items.length) { const i = next++; out[i] = await work(items[i]); }
159
+ }));
160
+ return out;
161
+ }
162
+
163
+ export async function rangeCensus({ repo, tag, bytes = null, privateStoresFile, fetch = globalThis.fetch,
164
+ cacheDir = os.tmpdir(), token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null, asset = null,
165
+ concurrency = CONCURRENCY } = {}) {
166
+ if (!/^[^/\s]+\/[^/\s]+$/.test(String(repo || ''))) throw new Error('repo must be owner/name');
167
+ if (!/^[A-Za-z0-9._-]+$/.test(String(tag || ''))) throw new Error(`invalid release tag: ${tag}`);
168
+ const privateStores = readPrivateStores(privateStoresFile);
169
+ const resolved = asset || await resolveAsset({ repo, tag, fetch, token });
170
+ if (!Number.isSafeInteger(resolved.size) || resolved.size < 22) throw new Error(`release ${tag} asset has no usable size`);
171
+ if (bytes !== null && bytes !== resolved.size) throw new Error(`release ${tag} asset is ${resolved.size} bytes; expected ${bytes}`);
172
+
173
+ const key = crypto.createHash('sha256').update(JSON.stringify({ repo, tag, assetId: resolved.id, size: resolved.size, privateStores }))
174
+ .digest('hex').slice(0, 32);
175
+ const cacheFile = path.join(cacheDir, 'ruvnet-brain-generation-census', `${key}.json`);
176
+ try {
177
+ const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
178
+ if (cached?.tag === tag && Number.isSafeInteger(cached.chunks)) return { ...cached, cached: true };
179
+ } catch { /* no usable cache entry: measure */ }
180
+
181
+ const started = Date.now();
182
+ const { read, fetched } = rangeReader({ url: resolved.url || `https://github.com/${repo}/releases/download/${tag}/${ARCHIVE_ASSET}`,
183
+ size: resolved.size, fetch });
184
+ const tailStart = Math.max(0, resolved.size - TAIL_BYTES);
185
+ const eocd = parseEocd(await read(tailStart, resolved.size - 1));
186
+ if (eocd.cdOffset + eocd.cdSize > resolved.size) throw new Error('central directory lies outside the archive');
187
+ const entries = parseCentralDirectory(await read(eocd.cdOffset, eocd.cdOffset + eocd.cdSize - 1), eocd.total);
188
+ const selected = selectIdmapEntries(entries, privateStores);
189
+ if (!selected.length) throw new Error(`release ${tag} contains no public *${IDMAP_SUFFIX} members`);
190
+ const counts = await pool(selected, concurrency, async (entry) => {
191
+ const guess = 30 + Buffer.byteLength(entry.name) + entry.extraLength + entry.compressedSize + 256;
192
+ let local = await read(entry.offset, Math.min(resolved.size, entry.offset + guess) - 1);
193
+ let decoded = decodeEntry(entry, local);
194
+ if (decoded.need) {
195
+ local = await read(entry.offset, entry.offset + decoded.need - 1);
196
+ decoded = decodeEntry(entry, local);
197
+ if (decoded.need) throw new Error(`${entry.name}: local header disagrees with the central directory`);
198
+ }
199
+ return countIdmap(decoded.data, entry.name);
200
+ });
201
+ const result = { tag, publicStores: selected.length, chunks: counts.reduce((a, b) => a + b, 0), builtStores: selected.length,
202
+ assetId: resolved.id ?? null, archiveBytes: resolved.size, fetchedBytes: fetched(), entries: entries.length,
203
+ seconds: Math.round((Date.now() - started) / 100) / 10 };
204
+ try {
205
+ fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
206
+ const temp = `${cacheFile}.${process.pid}.tmp`;
207
+ fs.writeFileSync(temp, `${JSON.stringify(result, null, 2)}\n`);
208
+ fs.renameSync(temp, cacheFile);
209
+ } catch { /* a cache is an optimisation; the measured result stands */ }
210
+ return { ...result, cached: false };
211
+ }
212
+
213
+ const arg = (argv, name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
214
+
215
+ if (isDirectInvocation(import.meta.url)) {
216
+ const argv = process.argv.slice(2);
217
+ rangeCensus({ repo: arg(argv, '--repo') || process.env.GITHUB_REPOSITORY, tag: arg(argv, '--tag') })
218
+ .then((result) => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`))
219
+ .catch((error) => { console.error(`[generation-census] ${error.message}`); process.exitCode = 1; });
220
+ }