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