ruvnet-brain 4.3.38 β 4.3.40
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 +39 -15
- package/kb/forge-update.mjs +104 -27
- package/kb/update-storage-transaction.mjs +11 -5
- 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 +4 -4
- package/plugin/hooks/hook-contracts.json +2 -2
- package/plugin/hooks/hooks.json +2 -2
- package/plugin/scripts/codex-hook-wrapper.mjs +3 -0
- package/plugin/scripts/coverage-integrity.mjs +51 -4
- package/scripts/approved-runtime.mjs +1 -1
- package/scripts/corpus-canary.mjs +9 -12
- package/scripts/protected-release-invocation.mjs +1 -1
- package/scripts/release.mjs +3 -3
- package/scripts/single-source-check.mjs +12 -6
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
|
|
|
7
7
|
|
|
8
8
|
# π§ RuvNet Brain
|
|
9
9
|
|
|
10
|
-
### π§ RuvNet Brain β [](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
|
|
11
11
|
|
|
12
12
|
**A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack β delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
|
|
13
13
|
|
|
@@ -562,7 +562,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
|
|
|
562
562
|
|
|
563
563
|
This project versions in the open (see the live badge up top for the exact plugin version; the downloadable knowledge bundle is a separate track) β we don't claim βdone,β βcomplete,β or βzero hallucinations.β Where it stands:
|
|
564
564
|
|
|
565
|
-
- β
**The grounding brain is real and proven** β 199 public stores Β· 161,
|
|
565
|
+
- β
**The grounding brain is real and proven** β 199 public stores Β· 161,365 public source chunks, dual embeddings, cross-encoder rerank, plugin (MCP tool + explicit skills; automatic hooks retired), all re-runnable.
|
|
566
566
|
- β
**Code-level depth** β the code-rich repos are indexed to full function bodies; βhow is it implemented?β returns the implementation. Verified in the shipped bundle (clean-room 3/3).
|
|
567
567
|
- β
**Routing holds** β named 47/48, described 26/28, scenario 7/8; behavioral L1βL3 all pass (**L4 downgraded β it measures that the brain spoke, not that anything listened**); private stores fenced out of the public bundle (zero-leak verified).
|
|
568
568
|
- β οΈ **Two routing residuals** (above) β surfaced, not hidden.
|
package/bin/install.mjs
CHANGED
|
@@ -102,8 +102,9 @@ const PACKAGE_VERSION = (() => {
|
|
|
102
102
|
const REPO = 'stuinfla/ruvnet-brain';
|
|
103
103
|
const RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
104
104
|
const ASSET_NAME = 'ruvnet-brain.zip';
|
|
105
|
-
// Known-good BUNDLE tag, used
|
|
106
|
-
//
|
|
105
|
+
// Known-good BUNDLE tag, used ONLY by --pin. It is no longer a silent fallback for a failed
|
|
106
|
+
// latest-release lookup: that bundle predates ReleaseCoverage and cannot pass validation, so a lookup
|
|
107
|
+
// failure now stops with its real cause (resolveRelease / releaseLookupFailure).
|
|
107
108
|
//
|
|
108
109
|
// This MUST NOT be derived from this package's own version. The installer and the brain bundle are
|
|
109
110
|
// two independent version streams (README: "Three independent things version separately here β by
|
|
@@ -299,7 +300,9 @@ function fetchJson(url, redirects = 0) {
|
|
|
299
300
|
}
|
|
300
301
|
if (statusCode !== 200) {
|
|
301
302
|
res.resume();
|
|
302
|
-
|
|
303
|
+
const reset = headers['x-ratelimit-remaining'] === '0' && Number(headers['x-ratelimit-reset']);
|
|
304
|
+
return reject(new Error(`GitHub API returned HTTP ${statusCode}${reset
|
|
305
|
+
? ` (anonymous rate limit used up; it resets at ${new Date(reset * 1000).toISOString()})` : ''}`));
|
|
303
306
|
}
|
|
304
307
|
let body = '';
|
|
305
308
|
res.setEncoding('utf8');
|
|
@@ -321,8 +324,8 @@ function fetchJson(url, redirects = 0) {
|
|
|
321
324
|
// ββ step: resolve which Release to download (latest by default; safe fallback) βββββββββββββββββββ
|
|
322
325
|
// Default behavior: ask GitHub for the LATEST Release and use its ruvnet-brain.zip asset.
|
|
323
326
|
// --version <tag> forces a tag; --pin skips the network check and uses the bundled known-good tag.
|
|
324
|
-
// Any failure (offline / rate-limited / no releases)
|
|
325
|
-
//
|
|
327
|
+
// Any failure (offline / rate-limited / no releases) THROWS with the HTTP status or network error and
|
|
328
|
+
// a retry hint; callers that must download stop on it, the staleness check reports "could not check".
|
|
326
329
|
/**
|
|
327
330
|
* Which asset of a Release actually holds the brain bundle.
|
|
328
331
|
*
|
|
@@ -387,12 +390,31 @@ async function resolveRelease() {
|
|
|
387
390
|
ok(`latest Release is ${c.bold(tag)}`);
|
|
388
391
|
return { tag, url, source: 'latest' };
|
|
389
392
|
} catch (e) {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
+
// FAIL LOUD. This used to return the hardcoded RELEASE_VERSION and promise "the install is still
|
|
394
|
+
// safe and complete" β but that bundle predates ReleaseCoverage, so two steps later it failed
|
|
395
|
+
// validation with "COVERAGE.json is missing", and the reason (a rate limit, a network blip) was
|
|
396
|
+
// gone from the screen. Measured 2026-09-30 on the owner's recovery rail. Only --pin and
|
|
397
|
+
// --version choose a tag the lookup did not return.
|
|
398
|
+
const failure = releaseLookupFailure(e);
|
|
399
|
+
throw Object.assign(new Error(failure.message), { hint: failure.hint });
|
|
393
400
|
}
|
|
394
401
|
}
|
|
395
402
|
|
|
403
|
+
/** Why the latest-release lookup failed, and what to do about it. Pure, for testing. */
|
|
404
|
+
export function releaseLookupFailure(error) {
|
|
405
|
+
const detail = (error && error.message) || String(error);
|
|
406
|
+
const limited = /HTTP (?:403|429)\b/.test(detail);
|
|
407
|
+
return {
|
|
408
|
+
message: `couldn't look up the latest RuvNet Brain release (${detail}).`,
|
|
409
|
+
hint: [
|
|
410
|
+
limited
|
|
411
|
+
? 'GitHub allows a limited number of anonymous release checks per hour from one network address. Wait until the reset time above, then re-run the same command.'
|
|
412
|
+
: 'Check your connection, then re-run the same command in a minute.',
|
|
413
|
+
`Nothing was downloaded or installed. To install one specific release instead: add --version <tag> (tags: https://github.com/${REPO}/releases).`,
|
|
414
|
+
].join('\n'),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
396
418
|
// ββ step: resolve the cache dir ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
397
419
|
function resolveCacheDir() {
|
|
398
420
|
const custom = process.env.RUVNET_BRAIN_KB;
|
|
@@ -3650,7 +3672,7 @@ async function runUpdate() {
|
|
|
3650
3672
|
let fr;
|
|
3651
3673
|
if (hasPrivateOverlay) {
|
|
3652
3674
|
warn("\nthe installed updater failed; using authenticated staged recovery to preserve private storesβ¦\n");
|
|
3653
|
-
const release = await resolveRelease();
|
|
3675
|
+
const release = await resolveRelease().catch((error) => die(error.message, error.hint));
|
|
3654
3676
|
const bundle = await obtainBundle(release);
|
|
3655
3677
|
if (!bundle.zipPath) throw new Error('private-overlay recovery requires a downloadable signed bundle');
|
|
3656
3678
|
const sigPath = `${bundle.zipPath}.sig`;
|
|
@@ -5333,8 +5355,9 @@ function showHelp() {
|
|
|
5333
5355
|
console.log(`
|
|
5334
5356
|
RuvNet Brain installer
|
|
5335
5357
|
|
|
5336
|
-
By default this installs the LATEST published Release (it asks GitHub which one that is)
|
|
5337
|
-
|
|
5358
|
+
By default this installs the LATEST published Release (it asks GitHub which one that is).
|
|
5359
|
+
If GitHub can't be reached or rate-limits the check, it STOPS with the reason and downloads
|
|
5360
|
+
nothing; re-run later, or pick a release yourself with --version <tag>.
|
|
5338
5361
|
|
|
5339
5362
|
Usage:
|
|
5340
5363
|
npx ruvnet-brain Install the brain + Claude Code plugin (recommended, npm)
|
|
@@ -5476,9 +5499,9 @@ the installer reports that boot-level declarations changed.
|
|
|
5476
5499
|
installedTag = installedBrainVersion(cacheDir); // 'unknown' when SOURCE.json has no releaseTag
|
|
5477
5500
|
try {
|
|
5478
5501
|
resolvedRelease = await resolveRelease();
|
|
5479
|
-
// ONLY a genuine `latest` lookup counts as "what current means".
|
|
5480
|
-
//
|
|
5481
|
-
// source:'fallback'. Treating that as latest
|
|
5502
|
+
// ONLY a genuine `latest` lookup counts as "what current means". A failed lookup now THROWS
|
|
5503
|
+
// (caught below: "could not check"); it used to return the hardcoded known-good pin with
|
|
5504
|
+
// source:'fallback'. Treating that as latest inverted this whole fix: a rate-limited lookup
|
|
5482
5505
|
// would report installed v3.4.21-dev "β latest v2.9.0" and DOWNGRADE a perfectly current
|
|
5483
5506
|
// machine. (Caught by exercising the failure path against a 404 repo β the first version of
|
|
5484
5507
|
// this fix did exactly that.) A pinned/forced resolution is likewise the operator's explicit
|
|
@@ -5541,7 +5564,8 @@ the installer reports that boot-level declarations changed.
|
|
|
5541
5564
|
const localZipPresent =
|
|
5542
5565
|
FLAG_LOCAL || fs.existsSync(path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip'));
|
|
5543
5566
|
// Reuse the staleness check's resolution when it already ran β one network round-trip, not two.
|
|
5544
|
-
const release = localZipPresent ? null
|
|
5567
|
+
const release = localZipPresent ? null
|
|
5568
|
+
: (resolvedRelease || await resolveRelease().catch((error) => die(error.message, error.hint)));
|
|
5545
5569
|
const { zipPath, sourceDir, tmpDir, downloaded, sigError } = await obtainBundle(release);
|
|
5546
5570
|
// Verify the Ed25519 signature BEFORE extracting a downloaded bundle into the user's config
|
|
5547
5571
|
// (SEC-0010 #6 β trust root = the pubkey EMBEDDED in this file, so an attacker who swaps the
|
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 });
|
|
@@ -658,23 +662,48 @@ export function restorePrivateOverlayState({ kbDir, overlay }) {
|
|
|
658
662
|
}
|
|
659
663
|
}
|
|
660
664
|
|
|
665
|
+
// The published capability-cards.md is a sealed input of the derived `concepts` store: its digest
|
|
666
|
+
// is in the release's concepts receipt, and the trusted validator re-hashes it on every candidate and
|
|
667
|
+
// live tree. So the public bytes are kept EXACTLY as extracted and private cards are APPENDED as
|
|
668
|
+
// whole sections after them β never re-serialized in between. Rejoining every section used to put
|
|
669
|
+
// private cards inside the hashed bytes, and every overlay install refused its own update with
|
|
670
|
+
// "derived concepts input receipt differs from capability-cards.md" (measured 2026-09-30).
|
|
661
671
|
const publicCardsText = fs.existsSync(cardsFile) ? fs.readFileSync(cardsFile, 'utf8') : '';
|
|
662
672
|
const publicCards = cardSections(publicCardsText);
|
|
673
|
+
const appendedCards = [];
|
|
674
|
+
// Case-folded, like the trusted validator (coverage-integrity derivedInputIdentity): an appended
|
|
675
|
+
// card whose heading folds onto a published one would fail the sealed-input check, so refuse it here
|
|
676
|
+
// with the collision named instead of writing a tree that cannot validate.
|
|
677
|
+
const publicFolded = new Set([...publicCards.keys()].map((name) => name.toLowerCase()));
|
|
663
678
|
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}`);
|
|
679
|
+
if (publicCards.has(name)) {
|
|
680
|
+
if (publicCards.get(name) !== section) throw new Error(`capability-cards.md collision for private store ${name}`);
|
|
681
|
+
continue; // already published verbatim
|
|
666
682
|
}
|
|
667
|
-
|
|
683
|
+
if (publicFolded.has(name.toLowerCase())) throw new Error(`capability-cards.md collision for private store ${name}`);
|
|
684
|
+
appendedCards.push(section);
|
|
668
685
|
}
|
|
669
|
-
const
|
|
670
|
-
const
|
|
671
|
-
|
|
686
|
+
const separator = !publicCardsText ? '' : publicCardsText.endsWith('\n') ? '\n' : '\n\n';
|
|
687
|
+
const mergedCards = appendedCards.length
|
|
688
|
+
? `${publicCardsText}${separator}${appendedCards.join('\n\n')}\n` : publicCardsText;
|
|
689
|
+
|
|
690
|
+
// The candidate's PRIVATE-STORES.json is the PUBLIC bundle's fence. A restored private store the
|
|
691
|
+
// bundle does not fence (a local ingest, or a store the bundle never knew) would leave the runtime
|
|
692
|
+
// ledger with "unclassified stores", so the restored names are added β never removed, and the file
|
|
693
|
+
// is untouched when the bundle already fences them all (a byte-identical re-apply stays a no-op).
|
|
694
|
+
const fenceFile = path.join(kbDir, 'PRIVATE-STORES.json');
|
|
695
|
+
const fence = fs.existsSync(fenceFile) ? JSON.parse(fs.readFileSync(fenceFile, 'utf8')) : { privateStores: [] };
|
|
696
|
+
const fenced = new Set((Array.isArray(fence.privateStores) ? fence.privateStores : []).map((name) => String(name).toLowerCase()));
|
|
697
|
+
const unfenced = Object.keys(overlay.sourceStores || {}).filter((name) => !fenced.has(name.toLowerCase()));
|
|
672
698
|
|
|
673
699
|
atomicJson(sourceFile, { ...source, stores: mergedSource });
|
|
674
700
|
atomicJson(generationsFile, { ...generations, stores: mergedGenerations });
|
|
675
701
|
atomicJson(aliasesFile, mergedAliases);
|
|
676
|
-
|
|
677
|
-
|
|
702
|
+
if (unfenced.length) atomicJson(fenceFile, { ...fence, privateStores: [...(fence.privateStores || []), ...unfenced] });
|
|
703
|
+
if (mergedCards !== publicCardsText) {
|
|
704
|
+
fs.writeFileSync(`${cardsFile}.tmp-${process.pid}`, mergedCards);
|
|
705
|
+
fs.renameSync(`${cardsFile}.tmp-${process.pid}`, cardsFile);
|
|
706
|
+
}
|
|
678
707
|
return { restored: Object.keys(overlay.sourceStores).length };
|
|
679
708
|
}
|
|
680
709
|
|
|
@@ -702,6 +731,20 @@ export function restorePrivateOverlayState({ kbDir, overlay }) {
|
|
|
702
731
|
* copying anything is the assertion `applyPublicBundlePreservingPrivate` had and the real path did
|
|
703
732
|
* not; it is preserved here rather than dropped.
|
|
704
733
|
*/
|
|
734
|
+
/**
|
|
735
|
+
* node_modules (the ONNX embedder and RVF readers) is installer-placed and never ships inside a
|
|
736
|
+
* bundle. The candidate is validated in a SIBLING directory with no parent node_modules, and the
|
|
737
|
+
* exact-tree swap would delete it from the live KB, so both apply paths carry the LIVE copy across.
|
|
738
|
+
* Reflink clone where the filesystem supports it (APFS/btrfs), plain copy otherwise.
|
|
739
|
+
*/
|
|
740
|
+
export function carryLiveNodeModules({ candidateDir, liveDir }) {
|
|
741
|
+
const liveModules = path.join(liveDir, 'node_modules');
|
|
742
|
+
if (!fs.existsSync(liveModules) || fs.existsSync(path.join(candidateDir, 'node_modules'))) return false;
|
|
743
|
+
fs.cpSync(assertNoFollowPath(liveDir, liveModules), path.join(candidateDir, 'node_modules'),
|
|
744
|
+
{ recursive: true, verbatimSymlinks: true, mode: fs.constants.COPYFILE_FICLONE });
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
|
|
705
748
|
export function restorePrivateFilesIntoCandidate({ candidateDir, sourceDir, overlay }) {
|
|
706
749
|
if (!overlay) return { restored: 0 };
|
|
707
750
|
for (const relative of Object.keys(overlay.files || {})) {
|
|
@@ -720,7 +763,9 @@ export function restorePrivateFilesIntoCandidate({ candidateDir, sourceDir, over
|
|
|
720
763
|
}
|
|
721
764
|
|
|
722
765
|
const manifestUrl = source.canonicalManifestUrl || stores.find((s) => s.canonicalManifestUrl)?.canonicalManifestUrl;
|
|
723
|
-
|
|
766
|
+
// The recovery rail runs from the npm package's kb/forge-update.mjs, and the package ships no
|
|
767
|
+
// kb/SOURCE.json (verified in ruvnet-brain-4.3.39.tgz), so this die() stopped the rail before it began.
|
|
768
|
+
if (!manifestUrl && !STAGED_RELEASE_FILE) {
|
|
724
769
|
die(`self-update not configured for this build β SOURCE.json has no canonicalManifestUrl ` +
|
|
725
770
|
`(forge-build.mjs was run without --canonical-url). Provenance is still in SOURCE.json.`);
|
|
726
771
|
}
|
|
@@ -1073,6 +1118,23 @@ function writeSnapshotReceipt(backupPath, { state, reason = null, recoveryComman
|
|
|
1073
1118
|
*
|
|
1074
1119
|
* @returns {{removed: string[], kept: [string, string][], freed: number}}
|
|
1075
1120
|
*/
|
|
1121
|
+
/**
|
|
1122
|
+
* WHY a guard run failed. forge-guard prints its `[FAIL] ...` lines to STDOUT, and execFileSync puts only
|
|
1123
|
+
* STDERR in error.message, so a refused store used to read "Command failed: node .../forge-guard.mjs --name X"
|
|
1124
|
+
* with the cause dropped (measured 2026-09-30: the customer canary refused a generation and its log did not
|
|
1125
|
+
* say why). Keep the command line, then append the guard's own FAIL lines.
|
|
1126
|
+
*/
|
|
1127
|
+
export function describeGuardFailure(error) {
|
|
1128
|
+
const text = (value) => (value == null ? '' : Buffer.isBuffer(value) ? value.toString('utf8') : String(value));
|
|
1129
|
+
const fails = `${text(error?.stdout)}\n${text(error?.stderr)}`.split('\n')
|
|
1130
|
+
.map((line) => line.trim()).filter((line) => /\[FAIL\]|Error:/.test(line));
|
|
1131
|
+
// Keep the WHOLE message: execFileSync appends the child's stderr on the lines after the command line.
|
|
1132
|
+
const message = String(error?.message || error);
|
|
1133
|
+
const extra = fails.filter((line) => !message.includes(line));
|
|
1134
|
+
const cause = extra.length ? ` -- ${extra.join(' | ').slice(0, 800)}` : '';
|
|
1135
|
+
return `${message.slice(0, 1600)}${cause}`;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1076
1138
|
export function reclaimBackups({
|
|
1077
1139
|
kbDir,
|
|
1078
1140
|
backupsMade = [],
|
|
@@ -1675,6 +1737,28 @@ async function main() {
|
|
|
1675
1737
|
die(`staged ReleaseCoverage failed integrity: ${stagedCoverage.failures.join('; ')} β local files untouched.`);
|
|
1676
1738
|
}
|
|
1677
1739
|
|
|
1740
|
+
// A release may RETIRE a store this brain still lists. Measured 2026-09-30: 4.3.39 no longer ships
|
|
1741
|
+
// agentic-flows/agentic-music (excluded-no-corpus) or cogs/support under those names, and guarding
|
|
1742
|
+
// them could only fail ("[FAIL] MISSING file: agentic-flows.rvf"), so no brain listing them could
|
|
1743
|
+
// ever update. The release coverage validated above is the authority on what ships; only the stores
|
|
1744
|
+
// the staged bundle declares are guarded and read back, and the retired ones are named, not dropped
|
|
1745
|
+
// silently. (A legacy flat SOURCE.json has no store name to compare and is always guarded.)
|
|
1746
|
+
let stagedStoreNames;
|
|
1747
|
+
try {
|
|
1748
|
+
const staged = JSON.parse(fs.readFileSync(path.join(extractDir, 'SOURCE.json'), 'utf8')).stores || {};
|
|
1749
|
+
stagedStoreNames = new Set(Array.isArray(staged) ? staged.map((s) => s?.kbName) : Object.keys(staged));
|
|
1750
|
+
} catch (error) {
|
|
1751
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1752
|
+
die(`staged SOURCE.json is unreadable: ${error.message} β local files untouched.`);
|
|
1753
|
+
}
|
|
1754
|
+
const landingTargets = resolvedTargets.filter(({ local }) => local.kbName == null || stagedStoreNames.has(local.kbName));
|
|
1755
|
+
const retiredStores = resolvedTargets.filter((target) => !landingTargets.includes(target)).map(({ local }) => local.kbName);
|
|
1756
|
+
if (!landingTargets.length) {
|
|
1757
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1758
|
+
die(`release ${canonTag || canonLabel} ships none of the selected stores (${retiredStores.join(', ')}) β local files untouched.`);
|
|
1759
|
+
}
|
|
1760
|
+
if (retiredStores.length) console.log(`\n retired by this release (no longer shipped): ${retiredStores.join(', ')}`);
|
|
1761
|
+
|
|
1678
1762
|
const privateNames = Object.keys(privateOverlay?.sourceStores || {});
|
|
1679
1763
|
let profileResult = null;
|
|
1680
1764
|
const finalVerificationByStore = new Map();
|
|
@@ -1686,7 +1770,7 @@ async function main() {
|
|
|
1686
1770
|
const guard = path.join(dir, 'forge-guard.mjs');
|
|
1687
1771
|
if (!fs.existsSync(guard)) return { valid: false, failures: ['forge-guard.mjs is missing'] };
|
|
1688
1772
|
try {
|
|
1689
|
-
for (const { local, resolved: storeResolution } of
|
|
1773
|
+
for (const { local, resolved: storeResolution } of landingTargets) {
|
|
1690
1774
|
execFileSync(process.execPath, [guard, '--dir', dir, '--name', local.kbName], { cwd: dir, stdio: 'pipe' });
|
|
1691
1775
|
const verified = verifyLanded({ kbDir: dir, kbName: local.kbName, before: local, beforeBundle: source,
|
|
1692
1776
|
expectedDigest: storeResolution.digest, downloadedBuffer: buf });
|
|
@@ -1694,7 +1778,7 @@ async function main() {
|
|
|
1694
1778
|
if (phase === 'live') finalVerificationByStore.set(local.kbName, verified);
|
|
1695
1779
|
}
|
|
1696
1780
|
return { valid: true, failures: [] };
|
|
1697
|
-
} catch (error) { return { valid: false, failures: [`forge-guard failed: ${error
|
|
1781
|
+
} catch (error) { return { valid: false, failures: [`forge-guard failed: ${describeGuardFailure(error)}`] }; }
|
|
1698
1782
|
};
|
|
1699
1783
|
let transaction;
|
|
1700
1784
|
try {
|
|
@@ -1730,16 +1814,8 @@ async function main() {
|
|
|
1730
1814
|
fs.copyFileSync(assertNoFollowPath(liveDir, liveRuntimeIdentity),
|
|
1731
1815
|
assertNoFollowPath(candidateDir, path.join(candidateDir, 'RUNTIME-IDENTITY.json')));
|
|
1732
1816
|
}
|
|
1733
|
-
// node_modules
|
|
1734
|
-
|
|
1735
|
-
// SIBLING directory with no parent node_modules, so without it the guard cannot load the
|
|
1736
|
-
// embedder and every apply fails; and the exact-tree swap would delete it from the live KB.
|
|
1737
|
-
// Reflink clone where the filesystem supports it (APFS/btrfs), plain copy otherwise.
|
|
1738
|
-
const liveModules = path.join(liveDir, 'node_modules');
|
|
1739
|
-
if (fs.existsSync(liveModules) && !fs.existsSync(path.join(candidateDir, 'node_modules'))) {
|
|
1740
|
-
fs.cpSync(assertNoFollowPath(liveDir, liveModules), path.join(candidateDir, 'node_modules'),
|
|
1741
|
-
{ recursive: true, verbatimSymlinks: true, mode: fs.constants.COPYFILE_FICLONE });
|
|
1742
|
-
}
|
|
1817
|
+
// node_modules: the same installer-owned class as the two files above (see carryLiveNodeModules).
|
|
1818
|
+
carryLiveNodeModules({ candidateDir, liveDir });
|
|
1743
1819
|
restorePrivateFilesIntoCandidate({ candidateDir, sourceDir: liveDir, overlay: privateOverlay });
|
|
1744
1820
|
// ATOMIC WITH INSTALLATION, not after it. The transport identity is written INTO the
|
|
1745
1821
|
// candidate, so the storage transaction's single rename either promotes the bytes AND the
|
|
@@ -1779,7 +1855,7 @@ async function main() {
|
|
|
1779
1855
|
process.exitCode = 12;
|
|
1780
1856
|
return;
|
|
1781
1857
|
}
|
|
1782
|
-
for (const { local, resolved: storeResolution } of
|
|
1858
|
+
for (const { local, resolved: storeResolution } of landingTargets) {
|
|
1783
1859
|
const verified = finalVerificationByStore.get(local.kbName)
|
|
1784
1860
|
|| verifyLanded({ kbDir: KB_DIR, kbName: local.kbName, before: local, beforeBundle: source,
|
|
1785
1861
|
expectedDigest: storeResolution.digest, downloadedBuffer: buf });
|
|
@@ -1825,7 +1901,8 @@ async function main() {
|
|
|
1825
1901
|
? `\n=== DONE β exact no-op; installed bytes already equal the validated candidate ===`
|
|
1826
1902
|
: `\n=== DONE β ${behindStores.length} store(s) updated ===`);
|
|
1827
1903
|
console.log(`resolved target (live manifest, checked BEFORE downloading): ${canonLabel}`);
|
|
1828
|
-
|
|
1904
|
+
if (retiredStores.length) console.log(`retired by this release (no longer shipped): ${retiredStores.join(', ')}`);
|
|
1905
|
+
for (const { local } of landingTargets) {
|
|
1829
1906
|
const r = landedByStore.get(local.kbName);
|
|
1830
1907
|
const l = r.landed;
|
|
1831
1908
|
console.log(`[${local.kbName}] SOURCE.json on disk now reads: built ${l.builtUtc || '?'} from ${short(l.sourceCommit)}${l.sourceDescribe ? ` (${l.sourceDescribe})` : ''}`);
|
|
@@ -1838,7 +1915,7 @@ async function main() {
|
|
|
1838
1915
|
console.log(`\n${unchangedStores.length} of ${behindStores.length} store(s) were already at the canonical build and did not change: ${unchangedStores.join(', ')}`);
|
|
1839
1916
|
console.log(` (stores are forged independently β an unchanged store means its upstream repo did not move, not a failed update.)`);
|
|
1840
1917
|
}
|
|
1841
|
-
const finalOutcome = writeUpdateOutcome({ terminalVerdict: transaction.terminalVerdict, storeCount: behindStores.length,
|
|
1918
|
+
const finalOutcome = writeUpdateOutcome({ terminalVerdict: transaction.terminalVerdict, storeCount: behindStores.length, retiredStores,
|
|
1842
1919
|
currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
|
|
1843
1920
|
storageDelta: transaction.storageDelta,
|
|
1844
1921
|
transactionReceipts: transaction.paths.receipts,
|
|
@@ -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));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.40",
|
|
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.
|
|
4
|
+
"version": "4.3.40",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"hooks": [
|
|
66
66
|
{
|
|
67
67
|
"type": "command",
|
|
68
|
-
"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)\"
|
|
69
|
-
"timeout":
|
|
68
|
+
"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 unprompted-speech UserPromptSubmit",
|
|
69
|
+
"timeout": 10
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
72
|
"type": "command",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
"type": "command",
|
|
78
|
-
"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)\"
|
|
79
|
-
"timeout":
|
|
78
|
+
"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)\" 5500 capacity-aware-parallel-work",
|
|
79
|
+
"timeout": 6
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
"type": "command",
|
|
@@ -179,7 +179,7 @@
|
|
|
179
179
|
"mode": "blocking",
|
|
180
180
|
"offBehavior": "silence",
|
|
181
181
|
"matcher": "*",
|
|
182
|
-
"timeout":
|
|
182
|
+
"timeout": 9
|
|
183
183
|
},
|
|
184
184
|
{
|
|
185
185
|
"id": "continuation-gate",
|
|
@@ -250,7 +250,7 @@
|
|
|
250
250
|
"mode": "advisory",
|
|
251
251
|
"offBehavior": "run",
|
|
252
252
|
"matcher": "*",
|
|
253
|
-
"timeout":
|
|
253
|
+
"timeout": 6
|
|
254
254
|
},
|
|
255
255
|
{
|
|
256
256
|
"id": "decision-gate",
|
package/plugin/hooks/hooks.json
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
{
|
|
21
21
|
"type": "command",
|
|
22
22
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" unprompted-speech UserPromptSubmit",
|
|
23
|
-
"timeout":
|
|
23
|
+
"timeout": 9
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"type": "command",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
{
|
|
31
31
|
"type": "command",
|
|
32
32
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" capacity-aware-parallel-work || true",
|
|
33
|
-
"timeout":
|
|
33
|
+
"timeout": 6
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"type": "command",
|
|
@@ -69,6 +69,9 @@ function timeoutFor(hookId) {
|
|
|
69
69
|
// tinguishable from a crash; 6000ms covers the gate's cap plus this chain's spawn overhead
|
|
70
70
|
// (measured 773β1145ms end-to-end warm, so ~150β400ms of that is the wrapper/adapter/shim).
|
|
71
71
|
if (hookId === 'decision-gate') return 6_000;
|
|
72
|
+
// Advisory capacity hook: 4 of 8 runs were killed at the old 2s host timeout on a loaded machine (measured
|
|
73
|
+
// 2026-09-30). Keep the wrapper's budget below the registration's own 5.5s so the wrapper, not the host, ends it.
|
|
74
|
+
if (hookId === 'capacity-aware-parallel-work') return 5_000;
|
|
72
75
|
if (hookId === 'ground-ruvnet' || hookId === 'unprompted-speech' || hookId === 'continuation-gate') {
|
|
73
76
|
return 8_500;
|
|
74
77
|
}
|
|
@@ -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'));
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// scripts/verify-bundle.mjs (:645) and keys/ruvnet-brain-signing.pub.pem (:648) β and the customer
|
|
8
8
|
// updater extracts that archive straight into the user's Claude Code config. So an unattended corpus
|
|
9
9
|
// promotion built at HEAD would ship whatever unreleased executable bytes happen to be on main that
|
|
10
|
-
// night, to every installed client, with no
|
|
10
|
+
// night, to every installed client, with no install-verified code release behind it. That is a code
|
|
11
11
|
// release wearing a corpus release's clothes.
|
|
12
12
|
//
|
|
13
13
|
// The pin closes it by ENFORCED EQUALITY, not by a version string. Dual's correction is explicit:
|
|
@@ -117,10 +117,13 @@ function coverageRows(file) {
|
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
119
|
* Per-store freshness, judged from the generation's own sealed coverage (CORPUS-COVERAGE.json inside
|
|
120
|
-
* the installed tree): the upstream observation must be under 48h old, no store
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
120
|
+
* the installed tree): the upstream observation must be under 48h old, and no store may carry a commit
|
|
121
|
+
* other than the upstream commit observed then. Together those ARE the 48h guarantee. There is
|
|
122
|
+
* deliberately NO rule on a store's own ingestedAt: a store keeps the ingestion time of the last night
|
|
123
|
+
* its upstream moved (measured 2026-09-30: one generation holds ingestedAt from 2026-07-30 to
|
|
124
|
+
* 2026-09-30), so a "moved store must be ingested within 48h of now" rule would wrongly refuse a
|
|
125
|
+
* perfectly fresh generation about three nights after every code release.
|
|
126
|
+
* MISSING/INELIGIBLE rows are the coverage validator's business (it already ran), not a freshness question.
|
|
124
127
|
*/
|
|
125
128
|
export function storeFreshness({ after, before = null, now }) {
|
|
126
129
|
if (!after) return { ok: false, detail: 'CORPUS-COVERAGE.json is missing from the installed tree' };
|
|
@@ -131,7 +134,6 @@ export function storeFreshness({ after, before = null, now }) {
|
|
|
131
134
|
if (age > FRESHNESS_LIMIT_MS || age < 0) problems.push(`upstream observed ${(age / 3_600_000).toFixed(1)}h ago (limit 48h)`);
|
|
132
135
|
const priorCommit = new Map((before?.rows || []).map((row) => [row.key, row.artifact?.sourceCommit || null]));
|
|
133
136
|
const stale = [];
|
|
134
|
-
const late = [];
|
|
135
137
|
let moved = 0;
|
|
136
138
|
for (const row of after.rows) {
|
|
137
139
|
if (row.status === 'INELIGIBLE') continue;
|
|
@@ -139,17 +141,12 @@ export function storeFreshness({ after, before = null, now }) {
|
|
|
139
141
|
const built = row.artifact?.sourceCommit || null;
|
|
140
142
|
if (!upstream || !built) continue;
|
|
141
143
|
if (upstream !== built) { stale.push(row.artifact?.store || row.key); continue; }
|
|
142
|
-
if (before && priorCommit.get(row.key) !== upstream)
|
|
143
|
-
moved += 1;
|
|
144
|
-
const ingested = Date.parse(row.artifact?.ingestedAt);
|
|
145
|
-
if (!Number.isFinite(ingested) || now - ingested > FRESHNESS_LIMIT_MS) late.push(row.artifact?.store || row.key);
|
|
146
|
-
}
|
|
144
|
+
if (before && priorCommit.get(row.key) !== upstream) moved += 1;
|
|
147
145
|
}
|
|
148
146
|
if (stale.length) problems.push(`${stale.length} store(s) built from a commit older than their observed upstream: ${stale.slice(0, 10).join(', ')}`);
|
|
149
|
-
if (late.length) problems.push(`${late.length} moved store(s) ingested more than 48h ago: ${late.slice(0, 10).join(', ')}`);
|
|
150
147
|
return problems.length
|
|
151
148
|
? { ok: false, detail: problems.join('; ') }
|
|
152
|
-
: { ok: true, detail: `observed ${(age / 3_600_000).toFixed(1)}h ago; ${after.rows.length} row(s), 0 stale; ${before ? `${moved} moved since the installed generation, all
|
|
149
|
+
: { ok: true, detail: `observed ${(age / 3_600_000).toFixed(1)}h ago; ${after.rows.length} row(s), 0 stale; ${before ? `${moved} moved since the installed generation, all at their observed upstream commit` : 'no prior coverage to diff against'}` };
|
|
153
150
|
}
|
|
154
151
|
|
|
155
152
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Local publication is deliberately impossible. The one publisher may mutate public channels only
|
|
3
|
-
// when the
|
|
3
|
+
// when the branch-protected GitHub workflow carries a fully valid candidate seal into it.
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
package/scripts/release.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// header called check-only "the DEFINITION OF DONE" and claimed it alone decided "shipped"; that was
|
|
12
12
|
// never true and duplicated a second, drifting gate list alongside CI's real one). Publishing a
|
|
13
13
|
// release β creating the GitHub Release, moving the npm dist-tag β happens ONLY inside the
|
|
14
|
-
//
|
|
14
|
+
// branch-protected `protected-release.yml` workflow, against an exact-SHA CI-sealed candidate;
|
|
15
15
|
// `--publish` mode is that workflow's publisher (it validates the protected-invocation receipt before
|
|
16
16
|
// doing anything and refuses outside it), never rebuilds or retests source, and treats the immutable
|
|
17
17
|
// artifact as the evidence boundary. `--check` mode is a read-only LOCAL PREVIEW for a human on a dev
|
|
@@ -680,7 +680,7 @@ if (CORPUS_SEED) {
|
|
|
680
680
|
console.log(`\n${c.b('RuvNet Brain β release / definition-of-done')} ${c.dim('Β· ' + (PUBLISH ? 'PUBLISH' : 'check-only') + ' Β· shipping ' + V())}\n`);
|
|
681
681
|
|
|
682
682
|
// The local CLI remains useful as a read-only preflight, but publication authority lives only in
|
|
683
|
-
// the
|
|
683
|
+
// the branch-protected workflow. Validate the exact candidate receipt and artifact bytes before
|
|
684
684
|
// any command capable of pushing, tagging, releasing, or publishing can run.
|
|
685
685
|
if (PUBLISH) {
|
|
686
686
|
const protectedInvocation = validateProtectedPublishInvocation({ root: ROOT });
|
|
@@ -782,7 +782,7 @@ if (PUBLISH) {
|
|
|
782
782
|
}
|
|
783
783
|
}
|
|
784
784
|
// ADR-0091 D6.6 β THE BACKWARD-MOVE RACE. Release QE sealed the corpus generation this bundle was
|
|
785
|
-
// built from; publication happens later,
|
|
785
|
+
// built from; publication happens later, in the protected workflow. Clients always accept a code release
|
|
786
786
|
// and drop their corpusGeneration marker when they install one (kb/forge-update.mjs), so publishing
|
|
787
787
|
// a bundle built from generation G after G+1 already shipped rolls every user back one night.
|
|
788
788
|
// Re-resolve with the SAME resolver, before any asset upload, and refuse on any difference -- or on
|
|
@@ -33,6 +33,8 @@ const HISTORY = /^(docs\/(adr|ddd|research|audits|reviews|qe)\/|CHANGELOG\.md$|P
|
|
|
33
33
|
// kb/ is corpus content (primers/cards describing OTHER projects' own commands) and docs/issues/
|
|
34
34
|
// are upstream bug reports quoting other configs β both are data, not instructions to this project.
|
|
35
35
|
const instructions = tracked.filter((f) => /\.md$/.test(f) && !HISTORY.test(f) && !/^(kb|docs\/issues)\//.test(f));
|
|
36
|
+
// Phrases that re-introduce a person as a release gate. Negated statements ("no human approval step") are filtered by the caller.
|
|
37
|
+
const HUMAN_APPROVAL_STEP = /(owner|stuart'?s?|maintainer) (approves?|approval|click|must approve)\b[^.]*(deployment|release|publish|gate)|approves? the `?Production|hand (it|the work) over[^.]*click|required[- ]reviewers?\b|standing authori[sz]ation permits/i;
|
|
36
38
|
const grepIn = (files, re) => files.flatMap((f) => read(f).split('\n')
|
|
37
39
|
.map((l, i) => (re.test(l) ? `${f}:${i + 1}: ${l.trim().slice(0, 140)}` : null)).filter(Boolean));
|
|
38
40
|
const none = (hits) => ({ ok: hits.length === 0, detail: hits.slice(0, 8).join('\n') || 'none' });
|
|
@@ -80,8 +82,10 @@ const checks = [
|
|
|
80
82
|
return none(instructions.flatMap((f) => [...read(f).matchAll(/npm run ([a-z0-9:_-]+)/g)]
|
|
81
83
|
.filter((m) => !scripts.has(m[1])).map((m) => `${f}: npm run ${m[1]}`)));
|
|
82
84
|
} },
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
// The owner is not in the release loop (2026-09-30): the gates are machine gates, and no instruction may
|
|
86
|
+
// route a release through a person clicking in GitHub. B12 applies the same idea to the local CLAUDE.md/AGENTS.md.
|
|
87
|
+
{ id: 'B6', area: 'rules', scope: 'repo', title: 'No instruction puts a human approval click (or a required reviewer) in the release path',
|
|
88
|
+
run: () => none(grepIn(instructions, HUMAN_APPROVAL_STEP).filter((l) => !/\bno (human|reviewer)\b|\bnever\b|\bnot asked\b/i.test(l))) },
|
|
85
89
|
{ id: 'B7', area: 'rules', scope: 'repo', title: 'Model IDs are selected only in their owner modules',
|
|
86
90
|
run: () => {
|
|
87
91
|
const re = /['"`](claude-(fable|opus|sonnet|haiku)-[0-9][a-z0-9.-]*|gpt-[0-9][a-z0-9.-]*)['"`]/;
|
|
@@ -127,7 +131,7 @@ const checks = [
|
|
|
127
131
|
{ id: 'B12', area: 'rules', scope: 'machine', title: 'The local (untracked) CLAUDE.md / AGENTS.md carry no contradicting instruction',
|
|
128
132
|
run: () => {
|
|
129
133
|
const main = path.join(HOME, 'Code/ruvnet-brain');
|
|
130
|
-
const bad = /npx (-y )?(@claude-flow|claude-flow|ruflo)\b|standing authori[sz]ation permits|
|
|
134
|
+
const bad = /npx (-y )?(@claude-flow|claude-flow|ruflo)\b|standing authori[sz]ation permits|owner (approves?|click)|Stuart's approval (of|in GitHub)|approves? the `?Production|hand (it|the work) over[^.]*click|npm run (build|dev|test:integration|test:coverage|test:security)\b|not direct Agent tool/i;
|
|
131
135
|
return none(['CLAUDE.md', 'AGENTS.md'].flatMap((f) => readAbs(path.join(main, f)).split('\n')
|
|
132
136
|
.map((l, i) => (bad.test(l) ? `${f}:${i + 1}: ${l.trim().slice(0, 120)}` : null)).filter(Boolean)));
|
|
133
137
|
} },
|
|
@@ -157,11 +161,13 @@ const checks = [
|
|
|
157
161
|
} },
|
|
158
162
|
{ id: 'C4', area: 'release', scope: 'machine', title: 'The corpus gist job has its authenticated token (RUVNET_GISTS_TOKEN)',
|
|
159
163
|
run: () => { const r = spawnSync('gh', ['secret', 'list', '-R', 'stuinfla/ruvnet-brain'], { encoding: 'utf8' }); return { ok: /^RUVNET_GISTS_TOKEN\b/m.test(r.stdout), detail: r.stdout.split('\n').map((l) => l.split(/\s/)[0]).filter(Boolean).join(', ') }; } },
|
|
160
|
-
{ id: 'C3', area: 'release', scope: 'machine', title: '
|
|
164
|
+
{ id: 'C3', area: 'release', scope: 'machine', title: 'The npm-scoped Production environment is a boundary: branch policy present, admins cannot bypass (no human reviewer is part of the design)',
|
|
161
165
|
run: () => {
|
|
162
166
|
const r = spawnSync('gh', ['api', 'repos/stuinfla/ruvnet-brain/environments', '-q',
|
|
163
|
-
'.environments[]|select(.name=="Production β ruvnet-brain")|[.protection_rules[]?|select(.type=="
|
|
164
|
-
|
|
167
|
+
'.environments[]|select(.name=="Production β ruvnet-brain")|[(.can_admins_bypass==false),([.protection_rules[]?|select(.type=="branch_policy")]|length)]|@json'], { encoding: 'utf8' });
|
|
168
|
+
let noBypass = false; let policies = 0;
|
|
169
|
+
try { [noBypass, policies] = JSON.parse(r.stdout.trim()); } catch { /* unreadable: falls through to fail */ }
|
|
170
|
+
return { ok: r.status === 0 && noBypass === true && policies > 0, detail: `can_admins_bypass=false: ${noBypass}; branch_policy rules: ${policies}${r.status === 0 ? '' : ` (${r.stderr.trim()})`}` };
|
|
165
171
|
} },
|
|
166
172
|
|
|
167
173
|
// D β one corpus / update path
|