ruvnet-brain 4.3.39 → 4.4.0

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 (49) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +150 -42
  3. package/kb/brain-profile.mjs +17 -2
  4. package/kb/forge-update.mjs +92 -28
  5. package/kb/lifecycle-evidence-retention.mjs +12 -9
  6. package/kb/refresh-run.mjs +17 -1
  7. package/kb/update-storage-transaction.mjs +44 -10
  8. package/package.json +1 -1
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/hooks/codex-hooks.json +2 -2
  12. package/plugin/hooks/hooks.json +1 -1
  13. package/plugin/scripts/capability-registry.mjs +3 -3
  14. package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
  15. package/plugin/scripts/coverage-integrity.mjs +51 -4
  16. package/plugin/scripts/design-wall.sh +1 -0
  17. package/plugin/scripts/ground-before-write.sh +1 -0
  18. package/plugin/scripts/ground-ruvnet.sh +3 -3
  19. package/plugin/scripts/grounding-answer.mjs +129 -0
  20. package/plugin/scripts/grounding-stamp.sh +32 -31
  21. package/plugin/scripts/grounding-turn-evidence.mjs +99 -5
  22. package/plugin/scripts/grounding-turn-gate.mjs +25 -6
  23. package/plugin/scripts/hook-shim.mjs +3 -0
  24. package/plugin/scripts/kling-preflight.sh +1 -0
  25. package/plugin/scripts/learn-capture.sh +1 -0
  26. package/plugin/scripts/project-progression-sources.mjs +16 -4
  27. package/plugin/scripts/project-progression-store.mjs +49 -1
  28. package/plugin/scripts/protect-brain-state.sh +1 -0
  29. package/plugin/scripts/route-dispatch.sh +1 -0
  30. package/plugin/scripts/session-snapshot-hook.mjs +224 -38
  31. package/plugin/scripts/session-start-health.mjs +24 -3
  32. package/plugin/scripts/session-start-update-plane.mjs +1 -1
  33. package/plugin/scripts/update-apply.mjs +22 -2
  34. package/scripts/console-instances.mjs +145 -0
  35. package/scripts/console-runtime-identity.mjs +2 -0
  36. package/scripts/corpus-canary.mjs +130 -18
  37. package/scripts/customer-seams.mjs +84 -0
  38. package/scripts/customer-state-matrix.mjs +363 -0
  39. package/scripts/full-suite-gate.mjs +155 -0
  40. package/scripts/grounding-turn-replay.mjs +11 -3
  41. package/scripts/hook-qualify-core.mjs +346 -0
  42. package/scripts/hook-qualify-hosts.mjs +115 -0
  43. package/scripts/hook-qualify.mjs +101 -0
  44. package/scripts/host-cli.mjs +115 -0
  45. package/scripts/qe/agentic-qe-4.3.mjs +0 -1
  46. package/scripts/route-gold-rank.mjs +156 -0
  47. package/scripts/route-index-memory.mjs +51 -0
  48. package/scripts/route-latency-warm.mjs +123 -0
  49. package/scripts/wired-check.mjs +17 -3
@@ -420,9 +420,13 @@ export async function applyVerifiedStagedRelease({
420
420
  if (runtime.brainVersion !== expectedRuntimeVersion) throw new Error('installed runtime identity differs from the approved recovery runtime');
421
421
  fs.copyFileSync(assertNoFollowPath(liveDir, runtimeIdentity),
422
422
  assertNoFollowPath(candidateDir, path.join(candidateDir, 'RUNTIME-IDENTITY.json')));
423
- const privateFence = path.join(liveDir, 'PRIVATE-STORES.json');
424
- if (fs.existsSync(privateFence)) fs.copyFileSync(privateFence,
425
- assertNoFollowPath(candidateDir, path.join(candidateDir, 'PRIVATE-STORES.json')));
423
+ // The candidate keeps the BUNDLE's fence; restorePrivateOverlayState adds the restored private
424
+ // names to it. Copying the LIVE fence over it imported every stale entry: measured 2026-09-30, the
425
+ // owner's live fence listed 108 stores, 100 of them public in 4.3.39, so the rail could only fail
426
+ // with "private/public store collision".
427
+ // Same carry main() does: without it the exact-tree swap deleted the live node_modules, and the
428
+ // recovered brain could no longer load its embedder (measured 2026-09-30 on the owner's copy).
429
+ carryLiveNodeModules({ candidateDir, liveDir });
426
430
  // S1: ONE APPLY PATH — the same helper main()'s normal apply uses, not a second, duplicated
427
431
  // copy-loop. It also adds the collision refusal main() previously lacked.
428
432
  restorePrivateFilesIntoCandidate({ candidateDir, sourceDir: liveDir, overlay });
@@ -646,7 +650,9 @@ export function restorePrivateOverlayState({ kbDir, overlay }) {
646
650
  const cardsFile = path.join(kbDir, 'capability-cards.md');
647
651
  const source = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
648
652
  const generations = JSON.parse(fs.readFileSync(generationsFile, 'utf8'));
649
- const aliases = JSON.parse(fs.readFileSync(aliasesFile, 'utf8'));
653
+ // A public bundle may ship no repo-aliases.json at all (build-bundle: "aliases will not resolve"); the
654
+ // private aliases then start from an empty map instead of failing the whole update on ENOENT.
655
+ const aliases = fs.existsSync(aliasesFile) ? JSON.parse(fs.readFileSync(aliasesFile, 'utf8')) : {};
650
656
  const mergedSource = mergePrivateEntries(source.stores, overlay.sourceStores, 'SOURCE.json');
651
657
  const mergedGenerations = mergePrivateEntries(generations.stores, overlay.generationStores, 'RVF-GENERATIONS.json');
652
658
  const mergedAliases = mergePrivateEntries(aliases, overlay.aliases, 'repo-aliases.json');
@@ -658,23 +664,48 @@ export function restorePrivateOverlayState({ kbDir, overlay }) {
658
664
  }
659
665
  }
660
666
 
667
+ // The published capability-cards.md is a sealed input of the derived `concepts` store: its digest
668
+ // is in the release's concepts receipt, and the trusted validator re-hashes it on every candidate and
669
+ // live tree. So the public bytes are kept EXACTLY as extracted and private cards are APPENDED as
670
+ // whole sections after them — never re-serialized in between. Rejoining every section used to put
671
+ // private cards inside the hashed bytes, and every overlay install refused its own update with
672
+ // "derived concepts input receipt differs from capability-cards.md" (measured 2026-09-30).
661
673
  const publicCardsText = fs.existsSync(cardsFile) ? fs.readFileSync(cardsFile, 'utf8') : '';
662
674
  const publicCards = cardSections(publicCardsText);
675
+ const appendedCards = [];
676
+ // Case-folded, like the trusted validator (coverage-integrity derivedInputIdentity): an appended
677
+ // card whose heading folds onto a published one would fail the sealed-input check, so refuse it here
678
+ // with the collision named instead of writing a tree that cannot validate.
679
+ const publicFolded = new Set([...publicCards.keys()].map((name) => name.toLowerCase()));
663
680
  for (const [name, section] of Object.entries(overlay.cards || {})) {
664
- if (publicCards.has(name) && publicCards.get(name) !== section) {
665
- throw new Error(`capability-cards.md collision for private store ${name}`);
681
+ if (publicCards.has(name)) {
682
+ if (publicCards.get(name) !== section) throw new Error(`capability-cards.md collision for private store ${name}`);
683
+ continue; // already published verbatim
666
684
  }
667
- publicCards.set(name, section);
685
+ if (publicFolded.has(name.toLowerCase())) throw new Error(`capability-cards.md collision for private store ${name}`);
686
+ appendedCards.push(section);
668
687
  }
669
- const preambleEnd = publicCardsText.search(/^## /m);
670
- const preamble = preambleEnd >= 0 ? publicCardsText.slice(0, preambleEnd).trimEnd() : publicCardsText.trimEnd();
671
- const mergedCards = `${preamble}${preamble ? '\n\n' : ''}${[...publicCards.values()].join('\n\n')}\n`;
688
+ const separator = !publicCardsText ? '' : publicCardsText.endsWith('\n') ? '\n' : '\n\n';
689
+ const mergedCards = appendedCards.length
690
+ ? `${publicCardsText}${separator}${appendedCards.join('\n\n')}\n` : publicCardsText;
691
+
692
+ // The candidate's PRIVATE-STORES.json is the PUBLIC bundle's fence. A restored private store the
693
+ // bundle does not fence (a local ingest, or a store the bundle never knew) would leave the runtime
694
+ // ledger with "unclassified stores", so the restored names are added — never removed, and the file
695
+ // is untouched when the bundle already fences them all (a byte-identical re-apply stays a no-op).
696
+ const fenceFile = path.join(kbDir, 'PRIVATE-STORES.json');
697
+ const fence = fs.existsSync(fenceFile) ? JSON.parse(fs.readFileSync(fenceFile, 'utf8')) : { privateStores: [] };
698
+ const fenced = new Set((Array.isArray(fence.privateStores) ? fence.privateStores : []).map((name) => String(name).toLowerCase()));
699
+ const unfenced = Object.keys(overlay.sourceStores || {}).filter((name) => !fenced.has(name.toLowerCase()));
672
700
 
673
701
  atomicJson(sourceFile, { ...source, stores: mergedSource });
674
702
  atomicJson(generationsFile, { ...generations, stores: mergedGenerations });
675
703
  atomicJson(aliasesFile, mergedAliases);
676
- fs.writeFileSync(`${cardsFile}.tmp-${process.pid}`, mergedCards);
677
- fs.renameSync(`${cardsFile}.tmp-${process.pid}`, cardsFile);
704
+ if (unfenced.length) atomicJson(fenceFile, { ...fence, privateStores: [...(fence.privateStores || []), ...unfenced] });
705
+ if (mergedCards !== publicCardsText) {
706
+ fs.writeFileSync(`${cardsFile}.tmp-${process.pid}`, mergedCards);
707
+ fs.renameSync(`${cardsFile}.tmp-${process.pid}`, cardsFile);
708
+ }
678
709
  return { restored: Object.keys(overlay.sourceStores).length };
679
710
  }
680
711
 
@@ -702,6 +733,20 @@ export function restorePrivateOverlayState({ kbDir, overlay }) {
702
733
  * copying anything is the assertion `applyPublicBundlePreservingPrivate` had and the real path did
703
734
  * not; it is preserved here rather than dropped.
704
735
  */
736
+ /**
737
+ * node_modules (the ONNX embedder and RVF readers) is installer-placed and never ships inside a
738
+ * bundle. The candidate is validated in a SIBLING directory with no parent node_modules, and the
739
+ * exact-tree swap would delete it from the live KB, so both apply paths carry the LIVE copy across.
740
+ * Reflink clone where the filesystem supports it (APFS/btrfs), plain copy otherwise.
741
+ */
742
+ export function carryLiveNodeModules({ candidateDir, liveDir }) {
743
+ const liveModules = path.join(liveDir, 'node_modules');
744
+ if (!fs.existsSync(liveModules) || fs.existsSync(path.join(candidateDir, 'node_modules'))) return false;
745
+ fs.cpSync(assertNoFollowPath(liveDir, liveModules), path.join(candidateDir, 'node_modules'),
746
+ { recursive: true, verbatimSymlinks: true, mode: fs.constants.COPYFILE_FICLONE });
747
+ return true;
748
+ }
749
+
705
750
  export function restorePrivateFilesIntoCandidate({ candidateDir, sourceDir, overlay }) {
706
751
  if (!overlay) return { restored: 0 };
707
752
  for (const relative of Object.keys(overlay.files || {})) {
@@ -720,7 +765,9 @@ export function restorePrivateFilesIntoCandidate({ candidateDir, sourceDir, over
720
765
  }
721
766
 
722
767
  const manifestUrl = source.canonicalManifestUrl || stores.find((s) => s.canonicalManifestUrl)?.canonicalManifestUrl;
723
- if (!manifestUrl) {
768
+ // The recovery rail runs from the npm package's kb/forge-update.mjs, and the package ships no
769
+ // kb/SOURCE.json (verified in ruvnet-brain-4.3.39.tgz), so this die() stopped the rail before it began.
770
+ if (!manifestUrl && !STAGED_RELEASE_FILE) {
724
771
  die(`self-update not configured for this build — SOURCE.json has no canonicalManifestUrl ` +
725
772
  `(forge-build.mjs was run without --canonical-url). Provenance is still in SOURCE.json.`);
726
773
  }
@@ -1595,7 +1642,9 @@ async function main() {
1595
1642
  if (!APPLY) {
1596
1643
  writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
1597
1644
  candidateKind: candidateIdentity.kind, storeCount: targets.length });
1598
- if (anyBehind) { console.log(`\nA newer build exists. Run: node forge-update.mjs --apply`); process.exit(10); }
1645
+ // The npx door upgrades this updater before applying; an old installed updater run directly can fail
1646
+ // the guard on a newer bundle (customer-state-matrix D8, 2026-09-30).
1647
+ if (anyBehind) { console.log(`\nA newer build exists. Run: npx ruvnet-brain@latest --update`); process.exit(10); }
1599
1648
  console.log(`\nAll stores current. Nothing to do.`); process.exit(0);
1600
1649
  }
1601
1650
 
@@ -1692,6 +1741,28 @@ async function main() {
1692
1741
  die(`staged ReleaseCoverage failed integrity: ${stagedCoverage.failures.join('; ')} — local files untouched.`);
1693
1742
  }
1694
1743
 
1744
+ // A release may RETIRE a store this brain still lists. Measured 2026-09-30: 4.3.39 no longer ships
1745
+ // agentic-flows/agentic-music (excluded-no-corpus) or cogs/support under those names, and guarding
1746
+ // them could only fail ("[FAIL] MISSING file: agentic-flows.rvf"), so no brain listing them could
1747
+ // ever update. The release coverage validated above is the authority on what ships; only the stores
1748
+ // the staged bundle declares are guarded and read back, and the retired ones are named, not dropped
1749
+ // silently. (A legacy flat SOURCE.json has no store name to compare and is always guarded.)
1750
+ let stagedStoreNames;
1751
+ try {
1752
+ const staged = JSON.parse(fs.readFileSync(path.join(extractDir, 'SOURCE.json'), 'utf8')).stores || {};
1753
+ stagedStoreNames = new Set(Array.isArray(staged) ? staged.map((s) => s?.kbName) : Object.keys(staged));
1754
+ } catch (error) {
1755
+ fs.rmSync(tmp, { recursive: true, force: true });
1756
+ die(`staged SOURCE.json is unreadable: ${error.message} — local files untouched.`);
1757
+ }
1758
+ const landingTargets = resolvedTargets.filter(({ local }) => local.kbName == null || stagedStoreNames.has(local.kbName));
1759
+ const retiredStores = resolvedTargets.filter((target) => !landingTargets.includes(target)).map(({ local }) => local.kbName);
1760
+ if (!landingTargets.length) {
1761
+ fs.rmSync(tmp, { recursive: true, force: true });
1762
+ die(`release ${canonTag || canonLabel} ships none of the selected stores (${retiredStores.join(', ')}) — local files untouched.`);
1763
+ }
1764
+ if (retiredStores.length) console.log(`\n retired by this release (no longer shipped): ${retiredStores.join(', ')}`);
1765
+
1695
1766
  const privateNames = Object.keys(privateOverlay?.sourceStores || {});
1696
1767
  let profileResult = null;
1697
1768
  const finalVerificationByStore = new Map();
@@ -1703,7 +1774,7 @@ async function main() {
1703
1774
  const guard = path.join(dir, 'forge-guard.mjs');
1704
1775
  if (!fs.existsSync(guard)) return { valid: false, failures: ['forge-guard.mjs is missing'] };
1705
1776
  try {
1706
- for (const { local, resolved: storeResolution } of resolvedTargets) {
1777
+ for (const { local, resolved: storeResolution } of landingTargets) {
1707
1778
  execFileSync(process.execPath, [guard, '--dir', dir, '--name', local.kbName], { cwd: dir, stdio: 'pipe' });
1708
1779
  const verified = verifyLanded({ kbDir: dir, kbName: local.kbName, before: local, beforeBundle: source,
1709
1780
  expectedDigest: storeResolution.digest, downloadedBuffer: buf });
@@ -1747,16 +1818,8 @@ async function main() {
1747
1818
  fs.copyFileSync(assertNoFollowPath(liveDir, liveRuntimeIdentity),
1748
1819
  assertNoFollowPath(candidateDir, path.join(candidateDir, 'RUNTIME-IDENTITY.json')));
1749
1820
  }
1750
- // node_modules (the ONNX embedder and RVF readers) is installer-placed and never ships inside a
1751
- // bundle, the same class as the two files above. The candidate is validated by forge-guard in a
1752
- // SIBLING directory with no parent node_modules, so without it the guard cannot load the
1753
- // embedder and every apply fails; and the exact-tree swap would delete it from the live KB.
1754
- // Reflink clone where the filesystem supports it (APFS/btrfs), plain copy otherwise.
1755
- const liveModules = path.join(liveDir, 'node_modules');
1756
- if (fs.existsSync(liveModules) && !fs.existsSync(path.join(candidateDir, 'node_modules'))) {
1757
- fs.cpSync(assertNoFollowPath(liveDir, liveModules), path.join(candidateDir, 'node_modules'),
1758
- { recursive: true, verbatimSymlinks: true, mode: fs.constants.COPYFILE_FICLONE });
1759
- }
1821
+ // node_modules: the same installer-owned class as the two files above (see carryLiveNodeModules).
1822
+ carryLiveNodeModules({ candidateDir, liveDir });
1760
1823
  restorePrivateFilesIntoCandidate({ candidateDir, sourceDir: liveDir, overlay: privateOverlay });
1761
1824
  // ATOMIC WITH INSTALLATION, not after it. The transport identity is written INTO the
1762
1825
  // candidate, so the storage transaction's single rename either promotes the bytes AND the
@@ -1796,7 +1859,7 @@ async function main() {
1796
1859
  process.exitCode = 12;
1797
1860
  return;
1798
1861
  }
1799
- for (const { local, resolved: storeResolution } of resolvedTargets) {
1862
+ for (const { local, resolved: storeResolution } of landingTargets) {
1800
1863
  const verified = finalVerificationByStore.get(local.kbName)
1801
1864
  || verifyLanded({ kbDir: KB_DIR, kbName: local.kbName, before: local, beforeBundle: source,
1802
1865
  expectedDigest: storeResolution.digest, downloadedBuffer: buf });
@@ -1842,7 +1905,8 @@ async function main() {
1842
1905
  ? `\n=== DONE — exact no-op; installed bytes already equal the validated candidate ===`
1843
1906
  : `\n=== DONE — ${behindStores.length} store(s) updated ===`);
1844
1907
  console.log(`resolved target (live manifest, checked BEFORE downloading): ${canonLabel}`);
1845
- for (const { local } of behindStores) {
1908
+ if (retiredStores.length) console.log(`retired by this release (no longer shipped): ${retiredStores.join(', ')}`);
1909
+ for (const { local } of landingTargets) {
1846
1910
  const r = landedByStore.get(local.kbName);
1847
1911
  const l = r.landed;
1848
1912
  console.log(`[${local.kbName}] SOURCE.json on disk now reads: built ${l.builtUtc || '?'} from ${short(l.sourceCommit)}${l.sourceDescribe ? ` (${l.sourceDescribe})` : ''}`);
@@ -1855,7 +1919,7 @@ async function main() {
1855
1919
  console.log(`\n${unchangedStores.length} of ${behindStores.length} store(s) were already at the canonical build and did not change: ${unchangedStores.join(', ')}`);
1856
1920
  console.log(` (stores are forged independently — an unchanged store means its upstream repo did not move, not a failed update.)`);
1857
1921
  }
1858
- const finalOutcome = writeUpdateOutcome({ terminalVerdict: transaction.terminalVerdict, storeCount: behindStores.length,
1922
+ const finalOutcome = writeUpdateOutcome({ terminalVerdict: transaction.terminalVerdict, storeCount: behindStores.length, retiredStores,
1859
1923
  currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
1860
1924
  storageDelta: transaction.storageDelta,
1861
1925
  transactionReceipts: transaction.paths.receipts,
@@ -1,6 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { physicalPath } from './refresh-run.mjs';
4
5
 
5
6
  const TERMINAL_REFRESH = new Set(['SUCCEEDED', 'FAILED', 'ABANDONED']);
6
7
  const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
@@ -113,10 +114,11 @@ function newest(rows, predicate) {
113
114
 
114
115
  function trustedEvidenceRoot(root, unsafe) {
115
116
  try {
116
- for (const entry of [path.dirname(root), root]) {
117
- const stat = fs.lstatSync(entry);
118
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('evidence root is not a trusted directory (symbolic or special entry)');
119
- }
117
+ // The PARENT is where the user put the brain (`~/.cache/ruvnet-brain` may be a link to another disk):
118
+ // it must be a directory once resolved. The evidence root itself must not be a link.
119
+ if (!fs.statSync(physicalPath(path.dirname(root))).isDirectory()) throw new Error('evidence root parent is not a directory');
120
+ const stat = fs.lstatSync(root);
121
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('evidence root is not a trusted directory (symbolic or special entry)');
120
122
  return true;
121
123
  } catch (error) {
122
124
  if (error.code !== 'ENOENT') unsafe.push({ path: root, reason: error.message });
@@ -130,7 +132,7 @@ function snapshot(options) {
130
132
  const refresh = trustedEvidenceRoot(roots.refresh, unsafe) ? scanRefresh(roots.refresh, unsafe) : [];
131
133
  const transactions = trustedEvidenceRoot(roots.transactions, unsafe) ? scanTransactions(roots.transactions, unsafe) : [];
132
134
  const preserveRefresh = new Set((options.preserveRefreshRunIds || []).map(String));
133
- const preserveTransactions = new Set((options.preserveTransactionPaths || []).map((entry) => path.resolve(entry)));
135
+ const preserveTransactions = new Set((options.preserveTransactionPaths || []).map((entry) => physicalPath(entry)));
134
136
  const protectedRefresh = new Map();
135
137
  const protectRefresh = (row, reason) => { if (row) protectedRefresh.set(row.path, reason); };
136
138
  for (const row of refresh) {
@@ -147,17 +149,18 @@ function snapshot(options) {
147
149
  if (!protectedRefresh.has(row.path)) continue;
148
150
  for (const reference of transactionReferences(row.receipt)) {
149
151
  const resolved = path.resolve(String(reference || ''));
150
- const relative = path.relative(roots.transactions, resolved);
152
+ // The updater records real paths; the caller may spell the brain through a link. Same space, both sides.
153
+ const relative = path.relative(physicalPath(roots.transactions), physicalPath(resolved));
151
154
  if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || relative.includes(path.sep)) {
152
155
  unsafe.push({ path: row.path, reason: `forged external transaction reference: ${String(reference)}` });
153
- } else retainedTransactionPaths.add(resolved);
156
+ } else retainedTransactionPaths.add(physicalPath(resolved));
154
157
  }
155
158
  }
156
159
  const protectedTransactions = new Map();
157
160
  for (const row of transactions) {
158
161
  if (!TERMINAL_TRANSACTION.has(row.latest.state)) protectedTransactions.set(row.path, `nonterminal ${row.latest.state}`);
159
- if (preserveTransactions.has(row.path)) protectedTransactions.set(row.path, 'explicitly preserved transaction');
160
- if (retainedTransactionPaths.has(row.path)) protectedTransactions.set(row.path, 'referenced by retained refresh receipt');
162
+ if (preserveTransactions.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'explicitly preserved transaction');
163
+ if (retainedTransactionPaths.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'referenced by retained refresh receipt');
161
164
  }
162
165
  const bytes = [...refresh, ...transactions].reduce((sum, row) => sum + row.bytes, 0)
163
166
  + unsafe.filter(({ reason }) => /quarantine remains/.test(reason)).reduce((sum, { path: entry }) => {
@@ -78,6 +78,19 @@ export function inspectRefreshOwner(owner, { hostname = os.hostname(), inspectPr
78
78
  return ownerState(owner, { hostname, inspectProcess, isAlive });
79
79
  }
80
80
 
81
+ /**
82
+ * The ONE physical-path rule for brain directories. A user may reach the brain through a symlink
83
+ * (`~/.cache -> /Volumes/<disk>`) while the updater knows it by its real path (Node resolves
84
+ * import.meta.url), so identity checks compare this, never spellings. A directory that does not
85
+ * exist right now (kb/ mid-rename) resolves through its parent, so the answer does not flip.
86
+ */
87
+ export function physicalPath(dir) {
88
+ const resolved = path.resolve(String(dir || ''));
89
+ try { return fs.realpathSync.native(resolved); } catch { /* absent: resolve through the parent */ }
90
+ try { return path.join(fs.realpathSync.native(path.dirname(resolved)), path.basename(resolved)); }
91
+ catch { return resolved; }
92
+ }
93
+
81
94
  export const refreshLockPath = (kbDir) =>
82
95
  path.join(path.dirname(path.resolve(kbDir)), `.${path.basename(path.resolve(kbDir))}.refresh-run.lock`);
83
96
 
@@ -156,7 +169,10 @@ export function acquireRefreshLock({
156
169
  const inherited = String(env.RUVNET_REFRESH_RUN_TOKEN || '');
157
170
  if (inherited) {
158
171
  const owner = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
159
- if (owner.schemaVersion !== 3 || owner.token !== inherited || path.resolve(owner.kbDir) !== root
172
+ // Same DIRECTORY, not same spelling: the installer locks RUVNET_BRAIN_KB as given (possibly through
173
+ // a symlinked ~/.cache), the child updater knows its KB by import.meta.url, which Node resolves to
174
+ // the real path. Compare physical identity; a genuinely different directory still refuses.
175
+ if (owner.schemaVersion !== 3 || owner.token !== inherited || physicalPath(owner.kbDir) !== physicalPath(root)
160
176
  || path.resolve(owner.receiptPath || '') !== refreshReceiptPath(owner.brainHome, owner.runId)) {
161
177
  throw new Error(`refresh lock inheritance does not match ${lockPath}`);
162
178
  }
@@ -96,21 +96,27 @@ export function managedStorageInventory(liveDir, { measuredAt = new Date().toISO
96
96
  const basename = path.basename(live);
97
97
  const fullCorpusCopies = [];
98
98
  const evidence = [];
99
+ const evidenceNames = [`.${basename}.update-transactions`, 'refresh-runs'];
99
100
  for (const name of fs.readdirSync(parent).sort()) {
100
101
  const file = path.join(parent, name);
101
- const stat = fs.lstatSync(file);
102
102
  const installerKind = name.startsWith(`${basename}.install-preserved-`) ? 'installer-preserved'
103
103
  : name.startsWith(`${basename}.install-prior-`) ? 'installer-prior'
104
104
  : name.startsWith(`.${basename}.install-stage-`) ? 'installer-stage' : null;
105
- if (installerKind) {
106
- fullCorpusCopies.push(observedInstallerSummary(file, installerKind));
107
- continue;
108
- }
109
105
  const kind = name === basename ? 'active'
110
106
  : name.startsWith(`${basename}.next-`) ? 'candidate'
111
107
  : name.startsWith(`${basename}.rollback-`) ? 'rollback'
112
108
  : name.startsWith(`${basename}.failed-`) ? 'failed'
113
109
  : name.startsWith(`${basename}.bak-`) ? 'backup' : null;
110
+ // Classify by NAME first and lstat only managed entries. The parent (e.g. ~/.cache/ruvnet-brain)
111
+ // also holds hook stamps and logs created and deleted constantly; one vanishing between readdir
112
+ // and lstat threw ENOENT and aborted the whole update. A managed entry that vanished is absent.
113
+ if (!installerKind && !kind && !evidenceNames.includes(name)) continue;
114
+ let stat;
115
+ try { stat = fs.lstatSync(file); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
116
+ if (installerKind) {
117
+ fullCorpusCopies.push(observedInstallerSummary(file, installerKind));
118
+ continue;
119
+ }
114
120
  if (kind) {
115
121
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`managed ${kind} entry is not a trusted directory: ${file}`);
116
122
  fullCorpusCopies.push(trustedTreeSummary(file, kind));
@@ -252,7 +258,23 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
252
258
  const phaseFiles = fs.readdirSync(receipts).filter((name) => /^\d{3}-[A-Z_]+\.json$/.test(name)).sort();
253
259
  if (!phaseFiles.length) throw new Error(`storage transaction receipt is empty: ${receipts}`);
254
260
  const latest = JSON.parse(fs.readFileSync(path.join(receipts, phaseFiles.at(-1)), 'utf8'));
255
- if (['NOOP', 'COMMITTED', 'ROLLED_BACK'].includes(latest.state)) continue;
261
+ if (['NOOP', 'COMMITTED', 'ROLLED_BACK'].includes(latest.state)) {
262
+ // A quarantined unsealed candidate is kept for ONE full update cycle, then released on the next
263
+ // run — but only if its bytes are exactly what was sealed when it was quarantined.
264
+ const quarantine = latest.quarantinedUnsealedCandidate;
265
+ if (latest.state === 'ROLLED_BACK' && quarantine && latest.quarantineReclaimed !== true
266
+ && path.resolve(quarantine) === transactionPaths(live, transactionId).failed) {
267
+ const unchanged = !fs.existsSync(quarantine) || (() => {
268
+ try { requireDigest(quarantine, latest.quarantineIdentity, 'quarantined candidate'); return true; } catch { return false; }
269
+ })();
270
+ if (unchanged) {
271
+ removeIfPresent(quarantine);
272
+ appendRecoveryReceipt(receipts, 'ROLLED_BACK', { quarantineReclaimed: true,
273
+ reason: 'released the quarantined unsealed candidate one update cycle later (bytes unchanged)' });
274
+ }
275
+ }
276
+ continue;
277
+ }
256
278
  if (latest.state === 'RECOVERY_REQUIRED') throw new Error(`storage transaction requires manual recovery: ${transactionId}`);
257
279
  const paths = latest.paths;
258
280
  const expectedPaths = transactionPaths(live, transactionId);
@@ -267,12 +289,22 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
267
289
  try {
268
290
  // Validate every retained tree before any rename or deletion. A receipt owns
269
291
  // paths, but cannot authorize discarding bytes added after the process died.
270
- if (fs.existsSync(paths.candidate)) requireDigest(paths.candidate, latest.candidate, 'interrupted candidate');
292
+ // A kill DURING candidate building (the long phase: copy, private restore, guard) leaves a candidate
293
+ // that was never sealed, so no receipt can vouch for its bytes. Refusing made every later update
294
+ // fail forever; deleting would discard bytes nothing proved disposable. It is QUARANTINED instead:
295
+ // renamed intact to this transaction's `failed` path, named in the receipt, and live (proved equal
296
+ // to the prior identity) stays in service.
297
+ const unsealedCandidate = fs.existsSync(paths.candidate) && !latest.candidate?.sha256
298
+ && ['LOCKED', 'CANDIDATE_BUILDING'].includes(latest.state);
299
+ if (fs.existsSync(paths.candidate) && !unsealedCandidate) requireDigest(paths.candidate, latest.candidate, 'interrupted candidate');
271
300
  if (fs.existsSync(paths.rollback)) requireDigest(paths.rollback, prior, 'interrupted rollback');
272
301
  if (fs.existsSync(paths.failed)) throw new Error('interrupted failed tree has no safe recovery disposition');
273
302
  if (['LOCKED', 'CANDIDATE_BUILDING', 'CANDIDATE_VERIFIED'].includes(latest.state)) {
274
303
  requireDigest(live, prior, 'interrupted live');
275
- removeIfPresent(paths.candidate);
304
+ if (unsealedCandidate) {
305
+ assertDirectory(paths.candidate, 'unsealed candidate');
306
+ fs.renameSync(paths.candidate, paths.failed);
307
+ } else removeIfPresent(paths.candidate);
276
308
  } else if (latest.state === 'OLD_RENAME_STARTED') {
277
309
  const hasLive = fs.existsSync(live);
278
310
  const hasRollback = fs.existsSync(paths.rollback);
@@ -318,10 +350,12 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
318
350
  continue;
319
351
  } else throw new Error(`unsupported interrupted state ${latest.state}`);
320
352
  const delta = storageDelta(paths, { prior, candidate: latest.candidate || null });
353
+ const quarantined = unsealedCandidate
354
+ ? { quarantinedUnsealedCandidate: paths.failed, quarantineIdentity: identitySummary(treeIdentity(paths.failed)) } : {};
321
355
  appendRecoveryReceipt(receipts, 'ROLLED_BACK', { terminalVerdict: 'interrupted-run-restored', prior,
322
- storageDelta: delta, reason: `recovered interrupted ${latest.state} transaction before new work` });
356
+ storageDelta: delta, reason: `recovered interrupted ${latest.state} transaction before new work`, ...quarantined });
323
357
  recovered.push({ transactionId, from: latest.state, terminalVerdict: 'interrupted-run-restored',
324
- storageDelta: delta });
358
+ storageDelta: delta, ...quarantined });
325
359
  } catch (error) {
326
360
  appendRecoveryReceipt(receipts, 'RECOVERY_REQUIRED', { terminalVerdict: 'recovery-required', prior,
327
361
  reason: error.message });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.39",
3
+ "version": "4.4.0",
4
4
  "description": "One-command installer for RuvNet Brain \u2014 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": {
@@ -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.3.39",
4
+ "version": "4.4.0",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.39",
3
+ "version": "4.4.0",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -92,8 +92,8 @@
92
92
  "hooks": [
93
93
  {
94
94
  "type": "command",
95
- "command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 9000 session-snapshot SessionEnd",
96
- "timeout": 10
95
+ "command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 2500 session-snapshot SessionEnd",
96
+ "timeout": 3
97
97
  }
98
98
  ]
99
99
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "RuvNet Brain lifecycle plane. The broad legacy routing, learning, and release interceptors remain retired. What is automatic is exactly: SessionStart restores the canonical project checkpoint; UserPromptSubmit runs the single unprompted-speech chokepoint, ground-ruvnet grounding injection, and a capacity-aware context hint for clearly large independent work. The capacity hook uses bounded CPU/memory-pressure evidence, never starts agents, and tells the coordinator to check live tools and clamp to the runtime cap; it is not user-facing speech. ground-ruvnet remains a directive to the model, not speech — ADR-040 §Amendment 2026-09-11. PreToolUse runs decision-gate's write route, the ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067, composing ground-before-write per ADR-0012); PostToolUse on a successful search_ruvnet mints the grounding stamp that opens that gate. grounding-turn-mark (UserPromptSubmit) records that the grounding directive fired this turn, and grounding-turn-gate (Stop) forces continuation if no search_ruvnet call was recorded since. Stop also runs the ledger-scoped continuation handler and captures a project snapshot; PreCompact and SessionEnd capture a project snapshot. Every capture is bounded and fails open; only decision-gate may block on exit code, and it fails open on its own errors — the Stop stdout envelopes are advisory at the shim boundary but keep the agent working.",
2
+ "description": "RuvNet Brain lifecycle plane. The broad legacy routing, learning, and release interceptors remain retired. What is automatic is exactly: SessionStart restores the canonical project checkpoint; UserPromptSubmit runs the single unprompted-speech chokepoint, ground-ruvnet grounding injection, and a capacity-aware context hint for clearly large independent work. The capacity hook uses bounded CPU/memory-pressure evidence, never starts agents, and tells the coordinator to check live tools and clamp to the runtime cap; it is not user-facing speech. ground-ruvnet remains a directive to the model, not speech — ADR-040 §Amendment 2026-09-11. PreToolUse runs decision-gate's write route, the ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067, composing ground-before-write per ADR-0012); PostToolUse on a successful search_ruvnet mints the grounding stamp that opens that gate. grounding-turn-mark (UserPromptSubmit) records that the grounding directive fired this turn, and grounding-turn-gate (Stop) forces continuation if the final answer asserts a rUv capability and no search_ruvnet call was recorded since. Stop also runs the ledger-scoped continuation handler and captures a project snapshot; PreCompact and SessionEnd capture a project snapshot. Every capture is bounded and fails open; only decision-gate may block on exit code, and it fails open on its own errors — the Stop stdout envelopes are advisory at the shim boundary but keep the agent working.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -451,11 +451,11 @@ export const CAPABILITIES = [
451
451
  if (d.unreadable) {
452
452
  const locked = /lock|busy|writer/i.test(String(d.unreadable));
453
453
  return row(STATE.UNKNOWN, locked
454
- ? `the memory store is currently held by another process (${d.unreadable}) — that is a passing lock, not a fault; re-checking in a moment should clear it`
454
+ ? `could not read the memory store: another process is holding it (${d.unreadable}) — that is a passing lock, not a fault; re-checking in a moment should clear it`
455
455
  : `the memory store could not be read (${d.unreadable}) — this is not a transient lock, so re-checking will not clear it; the store or its journal files need attention before distillation state can be established`);
456
456
  }
457
- if (d.schemaless) return row(STATE.UNKNOWN, 'the store exists but has no memory_entries table (pre-AgentDB schema, never initialised) — nothing to distill yet, and nothing is broken');
458
- if (typeof d.total !== 'number') return row(STATE.UNKNOWN, 'the store opened but returned no countable rows — distillation state not established');
457
+ if (d.schemaless) return row(STATE.UNKNOWN, 'cannot measure distillation: the store exists but has no memory_entries table (pre-AgentDB schema, never initialised) — nothing to distill yet, and nothing is broken');
458
+ if (typeof d.total !== 'number') return row(STATE.UNKNOWN, 'the store opened but returned no countable rows — distillation state could not be established');
459
459
 
460
460
  if (d.total === 0) return row(STATE.ABSENT, 'the memory store is empty, so there is nothing to distill yet');
461
461
  if (d.learns) return row(STATE.ON, `${d.patterns} reusable patterns distilled from ${d.real} memories (${(d.cover * 100).toFixed(1)}% embedded)`);
@@ -60,7 +60,7 @@ const blockingHooks = new Set([
60
60
  const DETACHED_HOOKS = new Set(['learn-flush']);
61
61
 
62
62
  /** THE BUDGET IS DERIVED FROM WHAT THE HOOK MEASURABLY COSTS, never from what looks tidy. */
63
- function timeoutFor(hookId) {
63
+ function timeoutFor(hookId, event = '') {
64
64
  const override = Number(process.env.RUVNET_CODEX_HOOK_TIMEOUT_MS);
65
65
  if (Number.isFinite(override) && override > 0) return override;
66
66
  // decision-gate's own internal budget is 4000ms (RUVNET_DECISION_BUDGET_MS) and it is allowed to
@@ -75,6 +75,11 @@ function timeoutFor(hookId) {
75
75
  if (hookId === 'ground-ruvnet' || hookId === 'unprompted-speech' || hookId === 'continuation-gate') {
76
76
  return 8_500;
77
77
  }
78
+ // SessionEnd is hard-capped at 3s by the host (see DETACHED_HOOKS above) and the codex-hooks.json
79
+ // launcher kills this wrapper at 2500ms. A 4000ms budget here was a number nobody would ever reach:
80
+ // the body planned for 8s and was SIGKILLed mid-write. 2200ms leaves the launcher its margin, and the
81
+ // body receives it as RUVNET_CODEX_BUDGET_MS (below) so it can save the new snapshot first.
82
+ if (event === 'SessionEnd') return 2_200;
78
83
  return 4_000;
79
84
  }
80
85
 
@@ -193,7 +198,7 @@ if (DETACHED_HOOKS.has(hookId)) {
193
198
  process.exit(0);
194
199
  }
195
200
 
196
- const budgetMs = timeoutFor(hookId);
201
+ const budgetMs = timeoutFor(hookId, process.argv[3] || '');
197
202
  const result = spawnSync(process.execPath, [adapter, ...process.argv.slice(2)], {
198
203
  input,
199
204
  encoding: 'utf8',
@@ -182,6 +182,54 @@ function evidenceIdentity(root, file, kind) {
182
182
  };
183
183
  }
184
184
 
185
+ /**
186
+ * The ONE derived input a private overlay legitimately extends on an installed brain.
187
+ * kb/forge-update.mjs's restorePrivateOverlayState keeps the published capability-cards.md bytes
188
+ * verbatim and APPENDS the user's private `## <store>` cards after them, so card-lane and every other
189
+ * reader keep a single file. Hashing the whole file made every overlay install fail this check
190
+ * ("derived concepts input receipt differs from capability-cards.md", measured 2026-09-30 on the
191
+ * owner's 4.3.39 update) — the published digest can never cover bytes the user added locally.
192
+ */
193
+ export const OVERLAY_EXTENSIBLE_DERIVED_INPUTS = Object.freeze(['capability-cards.md']);
194
+
195
+ /**
196
+ * Identity of a derived input as the release sealed it. Exact file match first. For an
197
+ * overlay-extensible input, accept ONLY a file whose leading bytes, cut at a line boundary, hash to
198
+ * the sealed digest AND whose remainder is nothing but whole `## ` card sections — so every published
199
+ * card still parses to exactly its published body, and nothing local can be glued onto one. The
200
+ * evidence row records the sealed prefix (digest + byte count), which is what the release's partition
201
+ * digest was computed over. Returns null when the sealed bytes are not present.
202
+ */
203
+ function derivedInputIdentity(root, file, input) {
204
+ const relative = path.relative(root, file).split(path.sep).join('/');
205
+ const bytes = fs.readFileSync(file);
206
+ const whole = crypto.createHash('sha256').update(bytes).digest('hex');
207
+ if (whole === input.sha256) return { kind: 'derived-input', path: relative, sha256: whole, bytes: bytes.length };
208
+ if (!OVERLAY_EXTENSIBLE_DERIVED_INPUTS.includes(relative)) return null;
209
+ const hash = crypto.createHash('sha256');
210
+ let consumed = 0;
211
+ // An appended section may only ADD a card. One that repeats a sealed `## ` heading (another copy,
212
+ // a case variant, or an empty one) would be served by the card lane as curated evidence for that
213
+ // PUBLIC product, so it is refused, compared trimmed and case-insensitively.
214
+ const headings = (text) => [...text.matchAll(/^## ([^\n]*)$/gm)].map((m) => m[1].trim().toLowerCase());
215
+ const matchesAt = (end) => {
216
+ hash.update(bytes.subarray(consumed, end));
217
+ consumed = end;
218
+ if (hash.copy().digest('hex') !== input.sha256) return false;
219
+ const rest = bytes.subarray(end).toString('utf8');
220
+ if (!/^\n*## \S/.test(rest)) return false;
221
+ const sealed = new Set(headings(bytes.subarray(0, end).toString('utf8')));
222
+ return !headings(rest).some((heading) => sealed.has(heading));
223
+ };
224
+ for (let index = bytes.indexOf(0x0a); index >= 0; index = bytes.indexOf(0x0a, index + 1)) {
225
+ // Both sides of every newline: a published file that ends without one is still a clean prefix.
226
+ for (const end of [index, index + 1]) {
227
+ if (matchesAt(end)) return { kind: 'derived-input', path: relative, sha256: input.sha256, bytes: end };
228
+ }
229
+ }
230
+ return null;
231
+ }
232
+
185
233
  const canonicalStores = (root) => {
186
234
  const stores = [];
187
235
  for (const name of fs.readdirSync(root).filter((entry) => !entry.startsWith('._'))) {
@@ -341,10 +389,9 @@ export function validatePublicInventory({ assetsDir, coverage, ledger, installed
341
389
  for (const input of receipt.inputs) {
342
390
  let inputFile = null;
343
391
  try { inputFile = containedRegular(root, input?.path, `derived ${store} input`); } catch { /* handled below */ }
344
- if (!inputFile || !HEX64.test(String(input.sha256 || '')) || input.sha256 !== sha256File(inputFile)) {
345
- throw new Error(`derived ${store} input receipt differs from ${input?.path || '(missing)'}`);
346
- }
347
- evidenceFiles.push(evidenceIdentity(root, inputFile, 'derived-input'));
392
+ const identity = inputFile && HEX64.test(String(input.sha256 || '')) ? derivedInputIdentity(root, inputFile, input) : null;
393
+ if (!identity) throw new Error(`derived ${store} input receipt differs from ${input?.path || '(missing)'}`);
394
+ evidenceFiles.push(identity);
348
395
  }
349
396
  evidenceFiles.push(evidenceIdentity(root, passages, 'derived-passages'));
350
397
  evidenceFiles.push(evidenceIdentity(root, receiptFile, 'derived-receipt'));
@@ -29,6 +29,7 @@ INPUT=""
29
29
  # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
30
30
  # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
31
31
  # the whole thing back at once and a per-iteration cap never fires.
32
+ _l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
32
33
  while IFS= read -r -t 2 _l; do
33
34
  INPUT+="$_l"
34
35
  [ ${#INPUT} -ge 65536 ] && break
@@ -43,6 +43,7 @@ INPUT=""
43
43
  # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
44
44
  # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
45
45
  # the whole thing back at once and a per-iteration cap never fires.
46
+ _l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
46
47
  while IFS= read -r -t 2 _l; do
47
48
  INPUT+="$_l"
48
49
  [ ${#INPUT} -ge 65536 ] && break
@@ -347,7 +347,7 @@ LASTV=$(cat "$VSTAMP" 2>/dev/null || echo 0)
347
347
  VJITTER=$(( ( $(hostname 2>/dev/null | cksum 2>/dev/null | cut -d' ' -f1 || echo 0) % 8641 ) - 4320 ))
348
348
  VINTERVAL=$(( 21600 + VJITTER ))
349
349
  if [ "$NOWV" -gt 0 ] && [ $((NOWV - LASTV)) -gt "$VINTERVAL" ]; then
350
- echo "$NOWV" > "$VSTAMP" 2>/dev/null
350
+ echo "$NOWV" 2>/dev/null > "$VSTAMP" # 2>/dev/null FIRST: a failed redirect prints the shell's own error otherwise
351
351
  # BACKGROUNDED (QE-0011 code#1): these are 3 sequential `curl --max-time 3` = up to ~9s. Running
352
352
  # them synchronously HERE — before the grounding gates below — risks the whole hook being killed by
353
353
  # Claude Code's ~5s hook timeout on the once/20h refresh tick, which would DROP the actual grounding
@@ -357,7 +357,7 @@ if [ "$NOWV" -gt 0 ] && [ $((NOWV - LASTV)) -gt "$VINTERVAL" ]; then
357
357
  ( for PKG in ruflo @claude-flow/cli @ruvector/rvf; do
358
358
  L=$(curl -fsS --max-time 3 "https://registry.npmjs.org/$PKG/latest" 2>/dev/null | sed -E 's/.*"version":"([^"]+)".*/\1/' | head -c 40)
359
359
  [ -n "$L" ] && echo "$PKG $L"
360
- done > "$VCACHE".tmp 2>/dev/null && mv -f "$VCACHE".tmp "$VCACHE" 2>/dev/null ) &
360
+ done 2>/dev/null > "$VCACHE".tmp && mv -f "$VCACHE".tmp "$VCACHE" 2>/dev/null ) 2>/dev/null &
361
361
  fi
362
362
  if [ -s "$VCACHE" ]; then
363
363
  OUTDATED=""
@@ -625,7 +625,7 @@ if [ -n "$METER_TMP" ]; then
625
625
  mkdir -p "$METER_LEDGER_DIR" 2>/dev/null && \
626
626
  printf '{"ts":"%s","source":"hook","class":"%s","bytes":%d,"cwd":"%s"}\n' \
627
627
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$METER_CLASS" "$METER_BYTES" "$( { pwd -W 2>/dev/null || pwd 2>/dev/null; } | sed 's/"/\\"/g')" \
628
- >> "$METER_LEDGER_DIR/token-ledger.jsonl" 2>/dev/null
628
+ 2>/dev/null >> "$METER_LEDGER_DIR/token-ledger.jsonl"
629
629
  fi
630
630
 
631
631
  exit 0