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.
- package/README.md +2 -2
- package/bin/install.mjs +150 -42
- package/kb/brain-profile.mjs +17 -2
- package/kb/forge-update.mjs +92 -28
- package/kb/lifecycle-evidence-retention.mjs +12 -9
- package/kb/refresh-run.mjs +17 -1
- package/kb/update-storage-transaction.mjs +44 -10
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -2
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/capability-registry.mjs +3 -3
- package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
- package/plugin/scripts/coverage-integrity.mjs +51 -4
- package/plugin/scripts/design-wall.sh +1 -0
- package/plugin/scripts/ground-before-write.sh +1 -0
- package/plugin/scripts/ground-ruvnet.sh +3 -3
- package/plugin/scripts/grounding-answer.mjs +129 -0
- package/plugin/scripts/grounding-stamp.sh +32 -31
- package/plugin/scripts/grounding-turn-evidence.mjs +99 -5
- package/plugin/scripts/grounding-turn-gate.mjs +25 -6
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/kling-preflight.sh +1 -0
- package/plugin/scripts/learn-capture.sh +1 -0
- package/plugin/scripts/project-progression-sources.mjs +16 -4
- package/plugin/scripts/project-progression-store.mjs +49 -1
- package/plugin/scripts/protect-brain-state.sh +1 -0
- package/plugin/scripts/route-dispatch.sh +1 -0
- package/plugin/scripts/session-snapshot-hook.mjs +224 -38
- package/plugin/scripts/session-start-health.mjs +24 -3
- package/plugin/scripts/session-start-update-plane.mjs +1 -1
- package/plugin/scripts/update-apply.mjs +22 -2
- package/scripts/console-instances.mjs +145 -0
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/corpus-canary.mjs +130 -18
- package/scripts/customer-seams.mjs +84 -0
- package/scripts/customer-state-matrix.mjs +363 -0
- package/scripts/full-suite-gate.mjs +155 -0
- package/scripts/grounding-turn-replay.mjs +11 -3
- package/scripts/hook-qualify-core.mjs +346 -0
- package/scripts/hook-qualify-hosts.mjs +115 -0
- package/scripts/hook-qualify.mjs +101 -0
- package/scripts/host-cli.mjs +115 -0
- package/scripts/qe/agentic-qe-4.3.mjs +0 -1
- package/scripts/route-gold-rank.mjs +156 -0
- package/scripts/route-index-memory.mjs +51 -0
- package/scripts/route-latency-warm.mjs +123 -0
- package/scripts/wired-check.mjs +17 -3
package/kb/forge-update.mjs
CHANGED
|
@@ -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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
670
|
-
const
|
|
671
|
-
|
|
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
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1751
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 }) => {
|
package/kb/refresh-run.mjs
CHANGED
|
@@ -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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
4
|
+
"version": "4.4.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -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)\"
|
|
96
|
-
"timeout":
|
|
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
|
}
|
package/plugin/hooks/hooks.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
345
|
-
|
|
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
|
|
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"
|
|
628
|
+
2>/dev/null >> "$METER_LEDGER_DIR/token-ledger.jsonl"
|
|
629
629
|
fi
|
|
630
630
|
|
|
631
631
|
exit 0
|