ruvnet-brain 4.7.23 → 4.7.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/install.mjs +59 -1
- package/kb/lifecycle-evidence-retention.mjs +1 -1
- package/kb/update-storage-transaction.mjs +161 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/brain-footprint.mjs +5 -1
- package/plugin/scripts/learn-capture.mjs +26 -3
- package/scripts/issue-autofix-night.mjs +1 -1
- package/scripts/onboarding-console.mjs +6 -1
- package/scripts/release-qualification-contract.mjs +9 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
|
|
|
9
9
|
|
|
10
10
|
### Discover the stack. Read the source. Build with confidence.
|
|
11
11
|
|
|
12
|
-
[](plugin/.claude-plugin/plugin.json)
|
|
13
13
|
|
|
14
14
|
**A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
|
|
15
15
|
|
package/bin/install.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { applyBrainProfile, readBrainProfile } from '../kb/brain-profile.mjs';
|
|
|
25
25
|
import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, physicalPath, recordRefreshAdvisory,
|
|
26
26
|
recordRefreshPhase, settleRefreshRun, UPDATE_REFRESH_PHASES } from '../kb/refresh-run.mjs';
|
|
27
27
|
import { assessLifecycleEvidence, pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
|
|
28
|
-
import { checkDiskSpace, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
|
|
28
|
+
import { checkDiskSpace, disposeRecoveryRequired, previewRecoveryRequired, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
|
|
29
29
|
import { saveUpdateSource, stableNode } from '../plugin/scripts/automatic-update.mjs';
|
|
30
30
|
import { acquireDeveloperLock } from '../plugin/scripts/developer-update-lock.mjs';
|
|
31
31
|
import { footprintRoots, inventoryFootprint, sweepFootprint } from '../plugin/scripts/brain-footprint.mjs';
|
|
@@ -172,6 +172,13 @@ const MOVE_BRAIN_TO = (() => { const i = argv.indexOf('--move-brain'); const v =
|
|
|
172
172
|
const FLAG_CLEAN = argv.includes('--clean'); // enforce the footprint guarantee now (ADR-0098), then confirm
|
|
173
173
|
const FLAG_JSON = argv.includes('--json'); // with --doctor / --clean: machine-readable confirmation only
|
|
174
174
|
const FLAG_ARCHIVE = argv.includes('--archive'); // with --clean only (#335): archive files unique to old KB copies, then release them
|
|
175
|
+
// --recover-update (#335): show every update transaction stuck at RECOVERY_REQUIRED, changing nothing.
|
|
176
|
+
// --recover-update --keep-live <sha256> [--transaction <id>]: keep that exact live tree, move the
|
|
177
|
+
// transaction's other trees intact into .kb.recovered/<id>/, and record a terminal DISPOSED receipt.
|
|
178
|
+
const FLAG_RECOVER_UPDATE = argv.includes('--recover-update');
|
|
179
|
+
const argValue = (flag) => { const i = argv.indexOf(flag); const v = i === -1 ? null : argv[i + 1]; return v && !v.startsWith('-') ? v : null; };
|
|
180
|
+
const RECOVER_KEEP_LIVE = argValue('--keep-live');
|
|
181
|
+
const RECOVER_TRANSACTION = argValue('--transaction');
|
|
175
182
|
// ── onboarding-experience flags (all optional; every offer is safe to decline) ──
|
|
176
183
|
const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
|
|
177
184
|
const FLAG_PLAN = argv.includes('--plan') || argv.includes('--dry-run'); // show the interactive checklist, then exit — install NOTHING
|
|
@@ -4072,6 +4079,50 @@ export function acquireHostConvergenceLock(lockPath, {
|
|
|
4072
4079
|
return { acquired: false, error: 'host synchronization lock changed concurrently' };
|
|
4073
4080
|
}
|
|
4074
4081
|
|
|
4082
|
+
/**
|
|
4083
|
+
* `--recover-update` (#335). Preview is read-only; disposition needs the exact live sha256 the preview
|
|
4084
|
+
* printed, so nothing is decided for the operator. Returns the exit code.
|
|
4085
|
+
*/
|
|
4086
|
+
export function runRecoverUpdate({ kbDir = physicalPath(resolvedKbDir()), keepLive = RECOVER_KEEP_LIVE,
|
|
4087
|
+
transaction = RECOVER_TRANSACTION, json = FLAG_JSON, log = console.log } = {}) {
|
|
4088
|
+
const stuck = previewRecoveryRequired(kbDir);
|
|
4089
|
+
const short = (identity) => identity?.sha256 ? `${identity.sha256} (${identity.fileCount} files, ${identity.bytes} bytes)` : 'absent';
|
|
4090
|
+
if (!keepLive) {
|
|
4091
|
+
if (json) log(JSON.stringify({ kbDir, stuck }, null, 2));
|
|
4092
|
+
else if (!stuck.length) log('No update is stuck at RECOVERY_REQUIRED. Nothing to recover.');
|
|
4093
|
+
else {
|
|
4094
|
+
for (const t of stuck) {
|
|
4095
|
+
log(`Update ${t.transactionId} needs recovery: ${t.reason || 'no reason recorded'}`);
|
|
4096
|
+
log(` sealed prior: ${short(t.sealed.prior)}`);
|
|
4097
|
+
log(` sealed candidate: ${short(t.sealed.candidate)}`);
|
|
4098
|
+
for (const [name, tree] of Object.entries(t.trees)) {
|
|
4099
|
+
const match = tree.matchesPrior ? ' = sealed prior' : tree.matchesCandidate ? ' = sealed candidate' : tree.exists ? ' matches no sealed identity' : '';
|
|
4100
|
+
log(` ${name.padEnd(9)} ${tree.exists ? short(tree.identity) : 'absent'}${match}`);
|
|
4101
|
+
}
|
|
4102
|
+
log(` To keep the live tree above and close this update (other trees are moved, never deleted):`);
|
|
4103
|
+
log(` npx ruvnet-brain --recover-update --transaction ${t.transactionId} --keep-live ${t.trees.live.identity?.sha256 || '<live sha256>'}`);
|
|
4104
|
+
}
|
|
4105
|
+
log('Nothing was changed.');
|
|
4106
|
+
}
|
|
4107
|
+
return stuck.length ? 1 : 0;
|
|
4108
|
+
}
|
|
4109
|
+
const targets = transaction ? stuck.filter((t) => t.transactionId === transaction) : stuck;
|
|
4110
|
+
if (!targets.length) { log(transaction ? `No stuck update named ${transaction}.` : 'No update is stuck at RECOVERY_REQUIRED.'); return 1; }
|
|
4111
|
+
if (targets.length > 1) { log('More than one update is stuck; name one with --transaction <id>. Nothing was changed.'); return 1; }
|
|
4112
|
+
try {
|
|
4113
|
+
const result = disposeRecoveryRequired(kbDir, targets[0].transactionId, { keepLiveSha256: keepLive });
|
|
4114
|
+
if (json) log(JSON.stringify(result, null, 2));
|
|
4115
|
+
else {
|
|
4116
|
+
log(`Closed update ${result.transactionId}: kept the live tree ${result.live.sha256}`
|
|
4117
|
+
+ ` (${result.liveMatchesPrior ? 'it equals the sealed prior' : result.liveMatchesCandidate ? 'it equals the sealed candidate' : 'it matches no sealed identity'}).`);
|
|
4118
|
+
for (const p of result.preserved) log(` moved ${p.tree} intact to ${p.to}`);
|
|
4119
|
+
if (!result.preserved.length) log(' the update owned no other tree.');
|
|
4120
|
+
log('Run npx ruvnet-brain --update next; nothing was deleted.');
|
|
4121
|
+
}
|
|
4122
|
+
return 0;
|
|
4123
|
+
} catch (error) { log(`Not recovered: ${error.message}. Nothing was deleted.`); return 1; }
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4075
4126
|
export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
4076
4127
|
sourceRoot = REPO_ROOT,
|
|
4077
4128
|
brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'),
|
|
@@ -6829,6 +6880,12 @@ Usage:
|
|
|
6829
6880
|
footprint within budget, no cruft. --doctor --json prints only that, as JSON.
|
|
6830
6881
|
npx ruvnet-brain --clean Remove everything that must not exist (old KB copies — never one holding
|
|
6831
6882
|
private files the live brain lacks — old installers, over-cap logs), then confirm.
|
|
6883
|
+
npx ruvnet-brain --recover-update
|
|
6884
|
+
Read-only: list any update stuck at RECOVERY_REQUIRED, with the measured
|
|
6885
|
+
identity of the live tree and every tree that update owns.
|
|
6886
|
+
npx ruvnet-brain --recover-update --keep-live <sha256> [--transaction <id>]
|
|
6887
|
+
Keep that exact live tree, move the update's other trees intact into
|
|
6888
|
+
.kb.recovered/<id>/ (nothing is deleted) and close the update for good.
|
|
6832
6889
|
npx ruvnet-brain --clean --archive
|
|
6833
6890
|
Same, and for a kept copy: copy only the files it alone holds into
|
|
6834
6891
|
kb.archive/<stamp>/ (verified byte-for-byte), then release that copy.
|
|
@@ -6942,6 +6999,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6942
6999
|
// and `echo $?` → 0 coexisted for so long.
|
|
6943
7000
|
// `--doctor --json`: the positive-confirmation object ONLY (no narration), exit 0 iff every line that
|
|
6944
7001
|
// can be proven here is green — for scripts, CI, and agents (ADR-0098).
|
|
7002
|
+
if (FLAG_RECOVER_UPDATE) { process.exitCode = runRecoverUpdate(); return; }
|
|
6945
7003
|
if (FLAG_DOCTOR) { process.exitCode = await doctor({ json: FLAG_JSON }); return; }
|
|
6946
7004
|
// `--clean`: enforce the footprint guarantee now, then confirm. Exit 0 only when exactly one KB copy
|
|
6947
7005
|
// remains and nothing that must not exist is left (a private-unique copy kept for safety is a 1).
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { physicalPath } from './refresh-run.mjs';
|
|
5
5
|
|
|
6
6
|
const TERMINAL_REFRESH = new Set(['SUCCEEDED', 'FAILED', 'ABANDONED']);
|
|
7
|
-
const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
|
|
7
|
+
const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']);
|
|
8
8
|
const SAFE_ID = /^[A-Za-z0-9._-]+$/;
|
|
9
9
|
const PHASE_FILE = /^\d{3}-[A-Z_]+\.json$/;
|
|
10
10
|
const QUARANTINE = /^\.prune-[A-Za-z0-9._-]+-[a-f0-9]{16}$/;
|
|
@@ -15,8 +15,13 @@ const STATES = Object.freeze([
|
|
|
15
15
|
'COMMITTED',
|
|
16
16
|
'ROLLED_BACK',
|
|
17
17
|
'RECOVERY_REQUIRED',
|
|
18
|
+
'DISPOSED',
|
|
18
19
|
]);
|
|
19
20
|
|
|
21
|
+
// Terminal states: recovery never acts on a transaction whose newest receipt is one of these.
|
|
22
|
+
// DISPOSED is the operator's explicit, evidence-preserving end to a RECOVERY_REQUIRED transaction (#335).
|
|
23
|
+
export const TERMINAL_TRANSACTION_STATES = Object.freeze(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']);
|
|
24
|
+
|
|
20
25
|
// ── DISK-SPACE PREFLIGHT ──────────────────────────────────────────────────────────────────────────
|
|
21
26
|
// An apply needs room for the unpacked bundle (temp), the candidate generation beside the live one
|
|
22
27
|
// (the bundle plus the live node_modules carried into it) and its receipts; measured ~3.3 GB growth and
|
|
@@ -337,6 +342,7 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
|
|
|
337
342
|
const phaseFiles = fs.readdirSync(receipts).filter((name) => /^\d{3}-[A-Z_]+\.json$/.test(name)).sort();
|
|
338
343
|
if (!phaseFiles.length) throw new Error(`storage transaction receipt is empty: ${receipts}`);
|
|
339
344
|
const latest = JSON.parse(fs.readFileSync(path.join(receipts, phaseFiles.at(-1)), 'utf8'));
|
|
345
|
+
if (latest.state === 'DISPOSED') continue;
|
|
340
346
|
if (['NOOP', 'COMMITTED', 'ROLLED_BACK'].includes(latest.state)) {
|
|
341
347
|
// A quarantined unsealed candidate is kept for ONE full update cycle, then released on the next
|
|
342
348
|
// run — but only if its bytes are exactly what was sealed when it was quarantined.
|
|
@@ -354,7 +360,10 @@ export function recoverIncompleteStorageTransactions(liveDir, { removeTree = rem
|
|
|
354
360
|
}
|
|
355
361
|
continue;
|
|
356
362
|
}
|
|
357
|
-
if (latest.state === 'RECOVERY_REQUIRED')
|
|
363
|
+
if (latest.state === 'RECOVERY_REQUIRED') {
|
|
364
|
+
throw new Error(`storage transaction requires manual recovery: ${transactionId} `
|
|
365
|
+
+ `(preview with npx ruvnet-brain --recover-update ; nothing is changed by the preview)`);
|
|
366
|
+
}
|
|
358
367
|
const paths = latest.paths;
|
|
359
368
|
const expectedPaths = transactionPaths(live, transactionId);
|
|
360
369
|
if (!paths || ['live', 'candidate', 'rollback', 'failed'].some((name) =>
|
|
@@ -567,3 +576,154 @@ export function runStorageTransaction({
|
|
|
567
576
|
record('COMMITTED', { terminalVerdict: 'applied', prior, candidate, storageDelta: delta });
|
|
568
577
|
return { terminalVerdict: 'applied', prior, candidate, paths, storageDelta: delta };
|
|
569
578
|
}
|
|
579
|
+
|
|
580
|
+
// ── OPERATOR RECOVERY FOR A RECOVERY_REQUIRED TRANSACTION (#335) ──────────────────────────────────
|
|
581
|
+
// Automatic recovery stops at RECOVERY_REQUIRED on purpose: the trees no longer match what the receipts
|
|
582
|
+
// sealed, so no receipt can say which bytes are disposable. These two functions give the operator a
|
|
583
|
+
// supported way out without weakening that check:
|
|
584
|
+
// 1. previewRecoveryRequired() measures every tree the transaction owns and compares each with the
|
|
585
|
+
// identities its receipts sealed. It changes nothing.
|
|
586
|
+
// 2. disposeRecoveryRequired() keeps the CURRENT live tree in service only when the operator names its
|
|
587
|
+
// exact sha256 from the preview, moves every other tree the transaction owns intact into
|
|
588
|
+
// `.<kb>.recovered/<transaction>/` (nothing is deleted), proves each moved tree is byte-identical
|
|
589
|
+
// after the move, and records a terminal DISPOSED receipt with what was measured. It never infers
|
|
590
|
+
// that live equals the prior sealed tree: it records whether it does.
|
|
591
|
+
|
|
592
|
+
const SAFE_TRANSACTION_ID = /^[A-Za-z0-9._-]+$/;
|
|
593
|
+
const OWNED_TREES = Object.freeze(['candidate', 'rollback', 'failed']);
|
|
594
|
+
|
|
595
|
+
function receiptsRootFor(live) {
|
|
596
|
+
return path.join(path.dirname(live), `.${path.basename(live)}.update-transactions`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Where a disposition keeps the trees it moved. Sibling of the live tree, so a move is a rename. */
|
|
600
|
+
export function recoveredRootFor(liveDir) {
|
|
601
|
+
const live = path.resolve(liveDir);
|
|
602
|
+
return path.join(path.dirname(live), `.${path.basename(live)}.recovered`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function latestReceipt(receipts) {
|
|
606
|
+
const phaseFiles = fs.readdirSync(receipts).filter((name) => /^\d{3}-[A-Z_]+\.json$/.test(name)).sort();
|
|
607
|
+
if (!phaseFiles.length) return null;
|
|
608
|
+
return JSON.parse(fs.readFileSync(path.join(receipts, phaseFiles.at(-1)), 'utf8'));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function sealedReceipts(receipts) {
|
|
612
|
+
// Every identity any receipt of this transaction sealed, newest last.
|
|
613
|
+
const out = { prior: null, candidate: null };
|
|
614
|
+
for (const name of fs.readdirSync(receipts).filter((n) => /^\d{3}-[A-Z_]+\.json$/.test(n)).sort()) {
|
|
615
|
+
try {
|
|
616
|
+
const r = JSON.parse(fs.readFileSync(path.join(receipts, name), 'utf8'));
|
|
617
|
+
if (r.prior?.sha256) out.prior = r.prior;
|
|
618
|
+
if (r.candidate?.sha256) out.candidate = r.candidate;
|
|
619
|
+
} catch { /* an unreadable receipt seals nothing */ }
|
|
620
|
+
}
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const sameIdentity = (a, b) => Boolean(a?.sha256 && b?.sha256 && a.sha256 === b.sha256
|
|
625
|
+
&& a.bytes === b.bytes && a.fileCount === b.fileCount);
|
|
626
|
+
|
|
627
|
+
function measureTree(dir, sealed) {
|
|
628
|
+
let st = null;
|
|
629
|
+
try { st = fs.lstatSync(dir); } catch { /* absent */ }
|
|
630
|
+
if (!st) return { path: dir, exists: false, identity: null, matchesPrior: false, matchesCandidate: false };
|
|
631
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
632
|
+
return { path: dir, exists: true, directory: false, identity: null, matchesPrior: false, matchesCandidate: false };
|
|
633
|
+
}
|
|
634
|
+
const identity = identitySummary(treeIdentity(dir));
|
|
635
|
+
return { path: dir, exists: true, directory: true, identity,
|
|
636
|
+
matchesPrior: sameIdentity(identity, sealed.prior), matchesCandidate: sameIdentity(identity, sealed.candidate) };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Mutation-free report of every transaction whose newest receipt is RECOVERY_REQUIRED.
|
|
641
|
+
* @returns {Array<{transactionId:string, reason:string|null, recordedAt:string|null,
|
|
642
|
+
* sealed:{prior:object|null,candidate:object|null}, trees:Record<string,object>}>}
|
|
643
|
+
*/
|
|
644
|
+
export function previewRecoveryRequired(liveDir) {
|
|
645
|
+
const live = path.resolve(liveDir);
|
|
646
|
+
const root = receiptsRootFor(live);
|
|
647
|
+
if (!fs.existsSync(root)) return [];
|
|
648
|
+
const out = [];
|
|
649
|
+
for (const transactionId of fs.readdirSync(root).sort()) {
|
|
650
|
+
if (!SAFE_TRANSACTION_ID.test(transactionId) || transactionId.startsWith('.')) continue;
|
|
651
|
+
const receipts = path.join(root, transactionId);
|
|
652
|
+
if (!fs.lstatSync(receipts).isDirectory()) continue;
|
|
653
|
+
const latest = latestReceipt(receipts);
|
|
654
|
+
if (latest?.state !== 'RECOVERY_REQUIRED') continue;
|
|
655
|
+
const sealed = sealedReceipts(receipts);
|
|
656
|
+
const expected = transactionPaths(live, transactionId);
|
|
657
|
+
const trees = { live: measureTree(live, sealed) };
|
|
658
|
+
for (const name of OWNED_TREES) trees[name] = measureTree(expected[name], sealed);
|
|
659
|
+
out.push({ transactionId, reason: latest.reason || null, recordedAt: latest.recordedAt || null,
|
|
660
|
+
sealed: { prior: sealed.prior ? identitySummary(sealed.prior) : null,
|
|
661
|
+
candidate: sealed.candidate ? identitySummary(sealed.candidate) : null }, trees });
|
|
662
|
+
}
|
|
663
|
+
return out;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* The operator's explicit disposition: keep the current live tree, preserve every other tree intact.
|
|
668
|
+
* @param {string} liveDir
|
|
669
|
+
* @param {string} transactionId
|
|
670
|
+
* @param {{ keepLiveSha256: string, rename?: Function }} options keepLiveSha256 must be the live tree's
|
|
671
|
+
* sha256 exactly as the preview reported it; any change since the preview refuses.
|
|
672
|
+
*/
|
|
673
|
+
export function disposeRecoveryRequired(liveDir, transactionId, { keepLiveSha256, rename = fs.renameSync } = {}) {
|
|
674
|
+
const live = path.resolve(liveDir);
|
|
675
|
+
if (!SAFE_TRANSACTION_ID.test(String(transactionId || '')) || String(transactionId).startsWith('.')) {
|
|
676
|
+
throw new Error(`not a transaction id: ${transactionId}`);
|
|
677
|
+
}
|
|
678
|
+
if (!/^[a-f0-9]{64}$/.test(String(keepLiveSha256 || ''))) {
|
|
679
|
+
throw new Error('name the live tree to keep by the exact sha256 the preview printed (--keep-live <sha256>)');
|
|
680
|
+
}
|
|
681
|
+
const receipts = path.join(receiptsRootFor(live), transactionId);
|
|
682
|
+
if (!fs.existsSync(receipts) || !fs.lstatSync(receipts).isDirectory()) throw new Error(`no such transaction: ${transactionId}`);
|
|
683
|
+
const latest = latestReceipt(receipts);
|
|
684
|
+
if (latest?.state !== 'RECOVERY_REQUIRED') {
|
|
685
|
+
throw new Error(`transaction ${transactionId} is ${latest?.state || 'empty'}, not RECOVERY_REQUIRED; nothing to dispose`);
|
|
686
|
+
}
|
|
687
|
+
const expected = transactionPaths(live, transactionId);
|
|
688
|
+
if (latest.paths && ['live', ...OWNED_TREES].some((name) => latest.paths[name] && path.resolve(latest.paths[name]) !== expected[name])) {
|
|
689
|
+
throw new Error(`transaction ${transactionId} names paths that belong to another live tree; refusing`);
|
|
690
|
+
}
|
|
691
|
+
assertDirectory(live, 'live tree');
|
|
692
|
+
const sealed = sealedReceipts(receipts);
|
|
693
|
+
const liveIdentity = identitySummary(treeIdentity(live));
|
|
694
|
+
if (liveIdentity.sha256 !== keepLiveSha256) {
|
|
695
|
+
throw new Error(`the live tree changed since the preview (now ${liveIdentity.sha256}); run the preview again`);
|
|
696
|
+
}
|
|
697
|
+
const archiveDir = path.join(recoveredRootFor(live), transactionId);
|
|
698
|
+
const moves = [];
|
|
699
|
+
for (const name of OWNED_TREES) {
|
|
700
|
+
const source = expected[name];
|
|
701
|
+
if (!fs.existsSync(source)) continue;
|
|
702
|
+
assertDirectory(source, `transaction ${name} tree`);
|
|
703
|
+
const target = path.join(archiveDir, name);
|
|
704
|
+
if (fs.existsSync(target)) throw new Error(`recovered tree already exists: ${target}`);
|
|
705
|
+
moves.push({ name, source, target, before: identitySummary(treeIdentity(source)) });
|
|
706
|
+
}
|
|
707
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
708
|
+
const preserved = [];
|
|
709
|
+
for (const move of moves) {
|
|
710
|
+
rename(move.source, move.target);
|
|
711
|
+
const after = identitySummary(treeIdentity(move.target));
|
|
712
|
+
if (!sameIdentity(after, move.before)) {
|
|
713
|
+
throw new Error(`${move.name} tree changed while it was moved to ${move.target}; it is kept there, nothing was deleted`);
|
|
714
|
+
}
|
|
715
|
+
preserved.push({ tree: move.name, from: move.source, to: move.target, identity: after,
|
|
716
|
+
matchesPrior: sameIdentity(after, sealed.prior), matchesCandidate: sameIdentity(after, sealed.candidate) });
|
|
717
|
+
}
|
|
718
|
+
const receipt = appendRecoveryReceipt(receipts, 'DISPOSED', {
|
|
719
|
+
terminalVerdict: 'operator-kept-live',
|
|
720
|
+
live: liveIdentity,
|
|
721
|
+
liveMatchesPrior: sameIdentity(liveIdentity, sealed.prior),
|
|
722
|
+
liveMatchesCandidate: sameIdentity(liveIdentity, sealed.candidate),
|
|
723
|
+
preserved,
|
|
724
|
+
recoveredFrom: { reason: latest.reason || null, recordedAt: latest.recordedAt || null },
|
|
725
|
+
reason: 'operator kept the current live tree; every other tree this transaction owned was moved intact, not deleted',
|
|
726
|
+
});
|
|
727
|
+
return { transactionId, terminalVerdict: 'operator-kept-live', live: liveIdentity,
|
|
728
|
+
liveMatchesPrior: receipt.liveMatchesPrior, liveMatchesCandidate: receipt.liveMatchesCandidate, preserved, archiveDir };
|
|
729
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.24",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
3
|
"description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
|
|
4
|
-
"version": "4.7.
|
|
4
|
+
"version": "4.7.24",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -70,7 +70,7 @@ const RUFLO_DEBRIS = new Set(RUFLO_CWD_ARTIFACTS);
|
|
|
70
70
|
// Directories in the brain home that hold data another tool owns: measured, shown, never budgeted or removed.
|
|
71
71
|
const FOREIGN = { 'ruvector-mcp': 'RuVector MCP server working store (its own live memory)',
|
|
72
72
|
'worktree-recovery': 'hand-made recovery archives, not created by the Brain' };
|
|
73
|
-
const TERMINAL = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
|
|
73
|
+
const TERMINAL = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']); // DISPOSED: operator recovery (#335)
|
|
74
74
|
|
|
75
75
|
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
76
76
|
const lstat = (file) => { try { return fs.lstatSync(file); } catch { return null; } };
|
|
@@ -190,6 +190,10 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
|
|
|
190
190
|
}
|
|
191
191
|
};
|
|
192
192
|
const classifySibling = (name, full, st) => {
|
|
193
|
+
if (name === `.${base}.recovered` && st.isDirectory() && !st.isSymbolicLink()) { // #335: written only by --recover-update --keep-live
|
|
194
|
+
return void add({ id: 'kb-recovered', path: full, class: 'may-exist', kind: 'kb-recovered', action: 'keep', bytes: bytes(full),
|
|
195
|
+
reason: 'trees an operator-disposed update transaction owned, moved intact (--recover-update); never removed automatically' });
|
|
196
|
+
}
|
|
193
197
|
if (name === `${base}.archive` && st.isDirectory() && !st.isSymbolicLink()) { // #335: written only by --clean --archive
|
|
194
198
|
return void add({ id: 'kb-archive', path: full, class: 'may-exist', kind: 'kb-archive', action: 'keep', bytes: bytes(full),
|
|
195
199
|
reason: 'files unique to released KB copies, archived on request (--clean --archive); never removed automatically' });
|
|
@@ -24,6 +24,21 @@ const learnDir = (brainHome, sub) => { const dir = path.join(brainHome, 'learn-c
|
|
|
24
24
|
export function recentlyCaptured(brainHome, key, now = Date.now()) {
|
|
25
25
|
try { return now - fs.statSync(path.join(learnDir(brainHome, 'recent'), key)).mtimeMs < COALESCE_MS; } catch { return false; }
|
|
26
26
|
}
|
|
27
|
+
// #377 residual (sparkling, 2026-10-30): the parent used to call markCaptured() BEFORE the detached child stored
|
|
28
|
+
// anything, so a child whose store or exact readback failed left the key acknowledged as captured for the whole
|
|
29
|
+
// COALESCE_MS window. Now the parent only marks the key IN FLIGHT (`<key>.pending`, which is never an
|
|
30
|
+
// acknowledgement and expires with the child's slot), and the child acknowledges with markCaptured() only after
|
|
31
|
+
// recordLearningObservation() has completed its exact readback. A failed child clears the in-flight mark.
|
|
32
|
+
const inFlightMarker = (brainHome, key) => path.join(learnDir(brainHome, 'recent'), `${key}.pending`);
|
|
33
|
+
export function captureInFlight(brainHome, key, now = Date.now()) {
|
|
34
|
+
try { return now - fs.statSync(inFlightMarker(brainHome, key)).mtimeMs < SLOT_STALE_MS; } catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
export function markInFlight(brainHome, key) {
|
|
37
|
+
try { fs.writeFileSync(inFlightMarker(brainHome, key), '', { mode: 0o600 }); } catch { /* best effort: coalescing only */ }
|
|
38
|
+
}
|
|
39
|
+
export function clearInFlight(brainHome, key) {
|
|
40
|
+
try { fs.unlinkSync(inFlightMarker(brainHome, key)); } catch { /* already gone */ }
|
|
41
|
+
}
|
|
27
42
|
export function markCaptured(brainHome, key, now = Date.now()) {
|
|
28
43
|
const dir = learnDir(brainHome, 'recent');
|
|
29
44
|
try { fs.writeFileSync(path.join(dir, key), '', { mode: 0o600 }); } catch { return; }
|
|
@@ -105,12 +120,13 @@ export function captureLearning(input, { env = process.env, binary = resolveRufl
|
|
|
105
120
|
const observation = { tool, action, session: String(input.session_id || '') };
|
|
106
121
|
const key = observationKey(observation);
|
|
107
122
|
if (recentlyCaptured(brainHome, key)) return true; // identical observation already captured this window: no process
|
|
123
|
+
if (detach && captureInFlight(brainHome, key)) return true; // the same observation is being stored right now
|
|
108
124
|
// Review M3: on the hook path the store + exact readback run in a detached, bounded child, so the
|
|
109
125
|
// tool call returns at once; that child writes the ledger row if the write is refused or unverified.
|
|
110
126
|
if (detach) {
|
|
111
127
|
const slot = acquireObserveSlot(brainHome);
|
|
112
128
|
if (!slot) return shedObservation(brainHome);
|
|
113
|
-
|
|
129
|
+
markInFlight(brainHome, key); // NOT an acknowledgement: only the child's exact readback acknowledges (#377)
|
|
114
130
|
return detachLearning('--observe', { env: { ...env, RUFLO_BIN: binary }, brainHome, observation, slot });
|
|
115
131
|
}
|
|
116
132
|
recordLearningObservation(binary, context, observation, { env, deadline: Date.now() + 6000 });
|
|
@@ -136,6 +152,8 @@ export function detachLearning(mode, { env = process.env, brainHome, observation
|
|
|
136
152
|
}
|
|
137
153
|
|
|
138
154
|
function runDetached(mode) {
|
|
155
|
+
const brainHome = defaultBrainHome(process.env);
|
|
156
|
+
let observation = null;
|
|
139
157
|
const fail = (error) => recordHookFailure({ hook: mode === '--distill' ? 'learn-distill' : 'learn-capture', event: mode === '--distill' ? 'SessionEnd' : 'PostToolUse', error: String(error?.message || error).slice(0, 160) });
|
|
140
158
|
try {
|
|
141
159
|
const context = learningContext(); // consent is re-read: a withdrawn consent writes nothing
|
|
@@ -145,8 +163,13 @@ function runDetached(mode) {
|
|
|
145
163
|
if (mode === '--distill') { distillObservations(binary, context, { env: process.env }); return; }
|
|
146
164
|
const o = JSON.parse(process.env.RUVNET_LEARN_OBSERVATION || '{}');
|
|
147
165
|
if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(o.tool) || typeof o.action !== 'string') throw new Error('invalid detached observation');
|
|
148
|
-
|
|
149
|
-
|
|
166
|
+
observation = { tool: o.tool, action: o.action, session: String(o.session || '') };
|
|
167
|
+
recordLearningObservation(binary, context, observation, { env: process.env, deadline: Date.now() + 14_000, allowed: () => learningContext().enabled });
|
|
168
|
+
markCaptured(brainHome, observationKey(observation)); // acknowledged only after the exact readback succeeded (#377)
|
|
169
|
+
} catch (error) { fail(error); } finally {
|
|
170
|
+
if (observation) clearInFlight(brainHome, observationKey(observation));
|
|
171
|
+
if (process.env.RUVNET_LEARN_SLOT) releaseObserveSlot(process.env.RUVNET_LEARN_SLOT);
|
|
172
|
+
}
|
|
150
173
|
}
|
|
151
174
|
|
|
152
175
|
const isMain = isDirectInvocation(import.meta.url);
|
|
@@ -139,7 +139,7 @@ export async function shipAndSettle(rt, journal) {
|
|
|
139
139
|
publish('fixed', ['install-verified on Linux, macOS and Windows']);
|
|
140
140
|
patchNs(rt.paths.state, { _daily: { day: etDay(rt.now()), shipped: true, version: journal.version, issues: journal.issues } });
|
|
141
141
|
rt.page(`AUTOFIX SHIPPED v${journal.version}`, `Issues ${journal.issues.join(', ')}; install-verified on main ${journal.sha}.`, 'low');
|
|
142
|
-
return { items, status:
|
|
142
|
+
return { items, status: res.phase, exitCode: 0 }; // derived from the release result, never a literal (ADR-0024)
|
|
143
143
|
}
|
|
144
144
|
if (res.phase === 'published-unverified') {
|
|
145
145
|
publish('shipping', []);
|
|
@@ -2092,7 +2092,12 @@ function gatherActivity(cwd) {
|
|
|
2092
2092
|
// recent decisions from its append-only log. Nothing here can disagree with what actually routes.
|
|
2093
2093
|
function gatherRouterEngine() {
|
|
2094
2094
|
const profile = engineProfile();
|
|
2095
|
-
|
|
2095
|
+
// A missing or invalid per-user engine catalog is a degraded panel (catalogSource says 'unavailable'),
|
|
2096
|
+
// never a thrown read model that takes the whole Console API down with it (issue #86 boundary; red on
|
|
2097
|
+
// CI from 4.7.22, where no per-user catalog exists). No built-in model list is substituted.
|
|
2098
|
+
let engineCandidates = [];
|
|
2099
|
+
try { engineCandidates = engineCatalog(); } catch { /* reported through catalogSource: 'unavailable' */ }
|
|
2100
|
+
const candidates = applyProfile(engineCandidates, profile);
|
|
2096
2101
|
const prices = effectivePrices(candidates, profile);
|
|
2097
2102
|
const { rows, unusable } = loadLabelledRows();
|
|
2098
2103
|
const installed = fs.existsSync(path.join(__dirname, '..', 'node_modules', '@metaharness', 'router', 'package.json'));
|
|
@@ -355,6 +355,15 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
|
|
|
355
355
|
"macos": ["tests/unit/continuation-gate-codex-transcript.test.mjs", "tests/unit/codex-boot-surface-proof.test.mjs", "tests/unit/console-activity-fleet-dedupe.test.mjs", "tests/unit/console-activity-identity.test.mjs", "tests/unit/console-activity-lessons.test.mjs", "tests/unit/lesson-gate.test.mjs", "tests/unit/ruflo-memory-session.test.mjs"]
|
|
356
356
|
}
|
|
357
357
|
},
|
|
358
|
+
{
|
|
359
|
+
"id": "open-issue-fixes-4-7-24",
|
|
360
|
+
"reason": "A storage transaction stuck at RECOVERY_REQUIRED has a supported way out: a read-only preview, then an explicit keep-live disposition bound to the live tree's exact sha256 that moves every other tree intact and records a terminal DISPOSED receipt (#335); a detached learning capture is acknowledged only after its exact readback, so a refused store is retried instead of being coalesced away for ten minutes (#377). Windows is not verified",
|
|
361
|
+
"files": [],
|
|
362
|
+
"platformFiles": {
|
|
363
|
+
"linux": ["tests/unit/update-recovery-disposition.test.mjs", "tests/unit/learn-capture-ack-after-readback.test.mjs"],
|
|
364
|
+
"macos": ["tests/unit/update-recovery-disposition.test.mjs", "tests/unit/learn-capture-ack-after-readback.test.mjs"]
|
|
365
|
+
}
|
|
366
|
+
},
|
|
358
367
|
{
|
|
359
368
|
"id": "issue-autofix-bounded-writer",
|
|
360
369
|
"reason": "The unattended issue fixer (ADR-107) stays inside its authority: kill switch, single-writer lock, deny-by-default editable paths, the static malware and secret scan, the manifest/lockfile/pack/audit night gate, the red-team judge, restricted tool-less LLM stages, the macOS sandbox for model-written code (macOS only), never-weakened tests, independent verifier, resumable never-doubled release steps, no force or merge, owner-marker-prefixed comments, personal notes that are never boilerplate or repeated, and a registry and LaunchAgent that match. It runs on the owner's macOS launchd, so it is qualified on macOS and Linux only, the same scope as the ntfy inbox reader",
|