ruvnet-brain 4.7.23 → 4.7.25

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.25-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
@@ -2101,6 +2108,12 @@ export function codexBrokenMarketplaces(text) {
2101
2108
  }
2102
2109
  const sleepSync = (ms) => { if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
2103
2110
 
2111
+ // `codex plugin list` refreshes Codex's remote plugin catalog when its cache is stale: a 56 MB download measured at
2112
+ // 38-40 s on the owner's Mac (codex-cli 0.166.0, 2026-10-30). The old 30 s limit killed it every time, and a killed
2113
+ // fetch writes no cache, so every later attempt (the 03:30 nightly, each retry, each --update) repeated the cold fetch
2114
+ // and failed the same way until someone used Codex by hand. Bounded, with ~4x headroom over the measured fetch.
2115
+ export const CODEX_PLUGIN_COMMAND_TIMEOUT_MS = 180_000;
2116
+
2104
2117
  export function runCodexJson(args, {
2105
2118
  codexBin = process.env.CODEX_BIN || 'codex',
2106
2119
  codexHome = codexHomeDir(),
@@ -2110,9 +2123,10 @@ export function runCodexJson(args, {
2110
2123
  // Refreshing another tool's marketplace is a network write; only an install/update that is about to change the
2111
2124
  // Brain's Codex plugin opts in. --doctor and listings report the broken snapshot and change nothing (review 2026-10-24).
2112
2125
  repair = false,
2126
+ timeoutMs = CODEX_PLUGIN_COMMAND_TIMEOUT_MS,
2113
2127
  } = {}) {
2114
2128
  const options = { stdio: 'pipe', shell: false, cwd, env: { ...process.env, CODEX_HOME: codexHome }, encoding: 'utf8',
2115
- timeout: 30_000, maxBuffer: 20 * 1024 * 1024 };
2129
+ timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 };
2116
2130
  const snapshotFailure = (res) => !res.missingBinary && res.status !== 0 && CODEX_SNAPSHOT_FAILURE.test(String(res.stderr || res.stdout || ''));
2117
2131
  let r = run(codexBin, args, options);
2118
2132
  // One wait covers a refresh Codex is finishing; then refresh only the OTHER Git marketplaces it named (the Brain's
@@ -4072,6 +4086,50 @@ export function acquireHostConvergenceLock(lockPath, {
4072
4086
  return { acquired: false, error: 'host synchronization lock changed concurrently' };
4073
4087
  }
4074
4088
 
4089
+ /**
4090
+ * `--recover-update` (#335). Preview is read-only; disposition needs the exact live sha256 the preview
4091
+ * printed, so nothing is decided for the operator. Returns the exit code.
4092
+ */
4093
+ export function runRecoverUpdate({ kbDir = physicalPath(resolvedKbDir()), keepLive = RECOVER_KEEP_LIVE,
4094
+ transaction = RECOVER_TRANSACTION, json = FLAG_JSON, log = console.log } = {}) {
4095
+ const stuck = previewRecoveryRequired(kbDir);
4096
+ const short = (identity) => identity?.sha256 ? `${identity.sha256} (${identity.fileCount} files, ${identity.bytes} bytes)` : 'absent';
4097
+ if (!keepLive) {
4098
+ if (json) log(JSON.stringify({ kbDir, stuck }, null, 2));
4099
+ else if (!stuck.length) log('No update is stuck at RECOVERY_REQUIRED. Nothing to recover.');
4100
+ else {
4101
+ for (const t of stuck) {
4102
+ log(`Update ${t.transactionId} needs recovery: ${t.reason || 'no reason recorded'}`);
4103
+ log(` sealed prior: ${short(t.sealed.prior)}`);
4104
+ log(` sealed candidate: ${short(t.sealed.candidate)}`);
4105
+ for (const [name, tree] of Object.entries(t.trees)) {
4106
+ const match = tree.matchesPrior ? ' = sealed prior' : tree.matchesCandidate ? ' = sealed candidate' : tree.exists ? ' matches no sealed identity' : '';
4107
+ log(` ${name.padEnd(9)} ${tree.exists ? short(tree.identity) : 'absent'}${match}`);
4108
+ }
4109
+ log(` To keep the live tree above and close this update (other trees are moved, never deleted):`);
4110
+ log(` npx ruvnet-brain --recover-update --transaction ${t.transactionId} --keep-live ${t.trees.live.identity?.sha256 || '<live sha256>'}`);
4111
+ }
4112
+ log('Nothing was changed.');
4113
+ }
4114
+ return stuck.length ? 1 : 0;
4115
+ }
4116
+ const targets = transaction ? stuck.filter((t) => t.transactionId === transaction) : stuck;
4117
+ if (!targets.length) { log(transaction ? `No stuck update named ${transaction}.` : 'No update is stuck at RECOVERY_REQUIRED.'); return 1; }
4118
+ if (targets.length > 1) { log('More than one update is stuck; name one with --transaction <id>. Nothing was changed.'); return 1; }
4119
+ try {
4120
+ const result = disposeRecoveryRequired(kbDir, targets[0].transactionId, { keepLiveSha256: keepLive });
4121
+ if (json) log(JSON.stringify(result, null, 2));
4122
+ else {
4123
+ log(`Closed update ${result.transactionId}: kept the live tree ${result.live.sha256}`
4124
+ + ` (${result.liveMatchesPrior ? 'it equals the sealed prior' : result.liveMatchesCandidate ? 'it equals the sealed candidate' : 'it matches no sealed identity'}).`);
4125
+ for (const p of result.preserved) log(` moved ${p.tree} intact to ${p.to}`);
4126
+ if (!result.preserved.length) log(' the update owned no other tree.');
4127
+ log('Run npx ruvnet-brain --update next; nothing was deleted.');
4128
+ }
4129
+ return 0;
4130
+ } catch (error) { log(`Not recovered: ${error.message}. Nothing was deleted.`); return 1; }
4131
+ }
4132
+
4075
4133
  export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
4076
4134
  sourceRoot = REPO_ROOT,
4077
4135
  brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'),
@@ -4105,51 +4163,66 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
4105
4163
  return { ok: false, results, ...detail, error: detail.error || hostFailureReason(results) };
4106
4164
  };
4107
4165
 
4166
+ // ONE HOST CANNOT HOLD THE OTHERS BACK (2026-10-30). A Codex failure used to return before the Stable Spine and the
4167
+ // convergence receipt, so a single `codex plugin list` timeout left Claude Code on the new plugin, the runtime and
4168
+ // Codex on the old one, and no receipt saying so (owner's Mac, 4.7.21 -> 4.7.24, three nights). Each host is now
4169
+ // attempted on its own, the runtime is activated regardless, and a failed host is RECORDED as `failed` with its
4170
+ // reason: the run still ends not-ok, so the night still fails and pages, but nothing is left half-updated.
4171
+ const failedHosts = new Set();
4172
+ const hostError = (error) => error?.message || String(error);
4108
4173
  try {
4109
4174
  if (wireClaude === wirePlugin) results.hookRetirement = retireManagedHookRegistrations();
4110
4175
  results.claude = wireClaude === wirePlugin
4111
4176
  ? wirePlugin({ expectedVersion: PACKAGE_VERSION, requireManaged: true })
4112
4177
  : wireClaude({ expectedVersion: PACKAGE_VERSION, requireManaged: true });
4113
- if (results.claude.host && !results.claude.wired) return fail();
4178
+ if (results.claude.host && !results.claude.wired) failedHosts.add('claude');
4179
+ } catch (error) {
4180
+ results.claude = { host: true, wired: false, action: 'failed', error: hostError(error) };
4181
+ failedHosts.add('claude');
4182
+ }
4183
+ try {
4114
4184
  results.codexHost = detectCodexHost();
4115
4185
  if (results.codexHost.host) {
4116
4186
  if (!['added', 'rewritten', 'unchanged', 'user-owned'].includes(results.codexHost.action)) {
4117
- return fail({ error: `Codex host convergence failed: ${results.codexHost.action || 'unknown action'}` });
4118
- }
4119
- results.codex = installCodexPlugin({ expectedVersion: PACKAGE_VERSION });
4120
- // ~/.codex exists but no `codex` binary is on PATH (an uninstalled CLI, or a desktop-only setup whose shell
4121
- // cannot see it): there is no Codex host to converge here, which is absence, not a failed update (4.7.21,
4122
- // audit C-opus #6). Any other unavailability (the binary ran and failed) still fails.
4123
- if (results.codex.action === 'codex-unavailable' && results.codex.missingBinary === true) {
4124
- results.codexAbsent = true;
4125
- } else if (!['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
4126
- return fail();
4187
+ results.codexHostError = `Codex host convergence failed: ${results.codexHost.action || 'unknown action'}`;
4188
+ failedHosts.add('codex');
4189
+ } else {
4190
+ results.codex = installCodexPlugin({ expectedVersion: PACKAGE_VERSION });
4191
+ // ~/.codex exists but no `codex` binary is on PATH (an uninstalled CLI, or a desktop-only setup whose shell
4192
+ // cannot see it): there is no Codex host to converge here, which is absence, not a failed update (4.7.21,
4193
+ // audit C-opus #6). Any other unavailability (the binary ran and failed) still fails.
4194
+ if (results.codex.action === 'codex-unavailable' && results.codex.missingBinary === true) {
4195
+ results.codexAbsent = true;
4196
+ } else if (!['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
4197
+ failedHosts.add('codex');
4198
+ }
4127
4199
  }
4128
4200
  }
4129
4201
  } catch (error) {
4130
- results.codex = { action: 'failed', error: error?.message || String(error) };
4131
- return fail();
4202
+ results.codex = { action: 'failed', error: hostError(error) };
4203
+ failedHosts.add('codex');
4132
4204
  }
4133
4205
 
4134
4206
  const apply = path.join(sourceRoot, 'plugin', 'scripts', 'update-apply.mjs');
4135
4207
  if (!fs.existsSync(apply)) return fail({ error: 'Stable Spine updater missing from package' });
4136
4208
  const applied = runStableSpine(apply);
4137
4209
  const okApplied = !applied.error && applied.status === 0;
4138
- const codexReceipt = {
4139
- state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host && !results.codexAbsent ? 'ready' : 'absent'),
4140
- version: results.codex?.version || null,
4141
- };
4142
- if (results.codex?.restartRequired === true) {
4210
+ const codexReceipt = failedHosts.has('codex')
4211
+ ? { state: 'failed', version: results.codex?.version || null, error: hostFailureReason({ codex: results.codex, codexHostError: results.codexHostError }) }
4212
+ : {
4213
+ state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host && !results.codexAbsent ? 'ready' : 'absent'),
4214
+ version: results.codex?.version || null,
4215
+ };
4216
+ if (!failedHosts.has('codex') && results.codex?.restartRequired === true) {
4143
4217
  codexReceipt.restartRequired = true;
4144
4218
  codexReceipt.sessionSafety = results.codex.sessionSafety || null;
4145
4219
  codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
4146
4220
  codexReceipt.restartScope = results.codex.restartScope || 'unproven';
4147
4221
  }
4148
- const claudeReceipt = {
4149
- state: results.claude.host ? 'ready' : 'absent',
4150
- version: results.claude.version || null,
4151
- };
4152
- if (results.claude?.restartRequired === true) {
4222
+ const claudeReceipt = failedHosts.has('claude')
4223
+ ? { state: 'failed', version: results.claude.version || null, error: hostFailureReason({ claude: results.claude }) }
4224
+ : { state: results.claude.host ? 'ready' : 'absent', version: results.claude.version || null };
4225
+ if (!failedHosts.has('claude') && results.claude?.restartRequired === true) {
4153
4226
  claudeReceipt.restartRequired = true;
4154
4227
  claudeReceipt.sessionSafety = results.claude.sessionSafety || null;
4155
4228
  claudeReceipt.sessionSafetyReason = results.claude.sessionSafetyReason || null;
@@ -4236,6 +4309,11 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
4236
4309
  };
4237
4310
  const convergence = classifyHostConvergence(convergedReceipt);
4238
4311
  hostLock.release();
4312
+ if (failedHosts.size) {
4313
+ // The runtime and every other host converged; the failed host is the night's failure, with its own reason.
4314
+ return { ok: false, convergence, results, applyStatus: applied.status, failedHosts: [...failedHosts],
4315
+ error: `${hostFailureReason(results)}; runtime ${PACKAGE_VERSION} and the other hosts converged` };
4316
+ }
4239
4317
  return { ok: convergence.healthy === true, convergence, results, applyStatus: applied.status,
4240
4318
  ...(convergence.healthy !== true && hostRestartIsOnlyGap(convergedReceipt) ? { restartOnly: true } : {}),
4241
4319
  error: convergence.healthy === true ? null : `host convergence is ${convergence.state}: ${convergence.action}` };
@@ -4374,13 +4452,16 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
4374
4452
  /** Why host synchronization stopped, from the per-host results it had gathered; null when nothing says. */
4375
4453
  export function hostFailureReason(results = {}) {
4376
4454
  const firstLines = (text) => String(text || '').trim().split('\n').slice(0, 3).join(' ');
4455
+ // Every failed host is named: since hosts no longer stop at the first failure, two can fail in one run.
4456
+ const reasons = [];
4377
4457
  if (results.claude?.host && !results.claude.wired) {
4378
- return `Claude Code plugin ${results.claude.action || 'wiring failed'}${results.claude.version ? ` (installed ${results.claude.version}, required ${results.claude.expectedVersion || PACKAGE_VERSION})` : ''}`;
4458
+ reasons.push(`Claude Code plugin ${results.claude.action || 'wiring failed'}${results.claude.version ? ` (installed ${results.claude.version}, required ${results.claude.expectedVersion || PACKAGE_VERSION})` : ''}${results.claude.error ? `: ${firstLines(results.claude.error)}` : ''}`);
4379
4459
  }
4380
- if (results.codex && !['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
4381
- return `Codex plugin ${results.codex.action || 'failed'}${results.codex.error ? `: ${firstLines(results.codex.error)}` : ''}`;
4460
+ if (results.codexHostError) reasons.push(results.codexHostError);
4461
+ else if (results.codex && !['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
4462
+ reasons.push(`Codex plugin ${results.codex.action || 'failed'}${results.codex.error ? `: ${firstLines(results.codex.error)}` : ''}`);
4382
4463
  }
4383
- return null;
4464
+ return reasons.length ? reasons.join('; ') : null;
4384
4465
  }
4385
4466
 
4386
4467
  export function hostSynchronizationFailureMessage(convergence, active, expectedVersion = PACKAGE_VERSION) {
@@ -6829,6 +6910,12 @@ Usage:
6829
6910
  footprint within budget, no cruft. --doctor --json prints only that, as JSON.
6830
6911
  npx ruvnet-brain --clean Remove everything that must not exist (old KB copies — never one holding
6831
6912
  private files the live brain lacks — old installers, over-cap logs), then confirm.
6913
+ npx ruvnet-brain --recover-update
6914
+ Read-only: list any update stuck at RECOVERY_REQUIRED, with the measured
6915
+ identity of the live tree and every tree that update owns.
6916
+ npx ruvnet-brain --recover-update --keep-live <sha256> [--transaction <id>]
6917
+ Keep that exact live tree, move the update's other trees intact into
6918
+ .kb.recovered/<id>/ (nothing is deleted) and close the update for good.
6832
6919
  npx ruvnet-brain --clean --archive
6833
6920
  Same, and for a kept copy: copy only the files it alone holds into
6834
6921
  kb.archive/<stamp>/ (verified byte-for-byte), then release that copy.
@@ -6942,6 +7029,7 @@ the installer reports that boot-level declarations changed.
6942
7029
  // and `echo $?` → 0 coexisted for so long.
6943
7030
  // `--doctor --json`: the positive-confirmation object ONLY (no narration), exit 0 iff every line that
6944
7031
  // can be proven here is green — for scripts, CI, and agents (ADR-0098).
7032
+ if (FLAG_RECOVER_UPDATE) { process.exitCode = runRecoverUpdate(); return; }
6945
7033
  if (FLAG_DOCTOR) { process.exitCode = await doctor({ json: FLAG_JSON }); return; }
6946
7034
  // `--clean`: enforce the footprint guarantee now, then confirm. Exit 0 only when exactly one KB copy
6947
7035
  // 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}$/;
@@ -349,8 +349,17 @@ function compactTerminalPhases(row) {
349
349
  }
350
350
 
351
351
  export const MAX_VANISHED_RETRIES = 64;
352
+ // A prune stops this far UNDER the byte budget, not at it. The update that prunes still writes its own terminal refresh
353
+ // receipt afterwards; measured on the owner's Mac on 2026-10-30, a prune left 16,769,613 bytes (8 KiB under the
354
+ // 16 MiB budget), the settled receipt pushed it to 16,785,892, and --doctor reported the evidence as cruft after a
355
+ // clean update. The room is made by compacting terminal phase files first, then by removing the oldest removable entry.
356
+ export const PRUNE_HEADROOM_BYTES = 1024 * 1024;
352
357
  export function pruneLifecycleEvidence(options = {}) {
353
358
  const policy = options.policy || LIFECYCLE_EVIDENCE_RETENTION_POLICY;
359
+ // 1 MiB on the real 16 MiB budget; never more than a sixteenth of a (test-sized) budget.
360
+ const headroom = Number.isFinite(options.headroomBytes) ? Math.max(0, options.headroomBytes)
361
+ : Math.min(PRUNE_HEADROOM_BYTES, Math.floor(policy.maxEvidenceBytes / 16));
362
+ const roomy = (state) => isWithin(state, policy) && state.bytes <= policy.maxEvidenceBytes - headroom;
354
363
  const removeEntry = options.removeEntry || ((entry) => fs.rmSync(entry, { recursive: true, force: true }));
355
364
  // A prior invocation's quarantine name is not proof of ownership of its current
356
365
  // bytes. Preserve it as unsafe, measured evidence; only this invocation may remove
@@ -393,7 +402,7 @@ export function pruneLifecycleEvidence(options = {}) {
393
402
  }
394
403
  const compacted = [];
395
404
  const compactState = snapshot(options);
396
- if (!compactState.unsafe.length && compactState.bytes > policy.maxEvidenceBytes) {
405
+ if (!compactState.unsafe.length && compactState.bytes > policy.maxEvidenceBytes - headroom) {
397
406
  for (const row of compactState.transactions.filter((entry) => TERMINAL_TRANSACTION.has(entry.latest.state))) {
398
407
  compacted.push(...compactTerminalPhases(row));
399
408
  }
@@ -404,7 +413,7 @@ export function pruneLifecycleEvidence(options = {}) {
404
413
  let vanishedRetries = 0; let stopped = null;
405
414
  for (;;) {
406
415
  const state = snapshot(options);
407
- if (isWithin(state, policy)) break;
416
+ if (roomy(state)) break;
408
417
  if (state.unsafe.length) break;
409
418
  const refreshCandidates = state.refresh.filter((row) => TERMINAL_REFRESH.has(row.receipt.status)
410
419
  && !state.protectedRefresh.has(row.path)).sort((a, b) => a.timestamp - b.timestamp);
@@ -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.25",
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.25",
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.25",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -68,9 +68,16 @@ const RECOVERY_LEFTOVER = /(?:\.(?:bak|retired|dead)-\d{6,}|^bootstrap-backup-\d
68
68
  // One list with ruflo-bin.mjs, which clears the same names before each call (incl. a self-update's npm install, 1.3 GB on 2026-10-22).
69
69
  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
+ // The maintainer-only issue-autofix job (ADR-107, scripts/issue-autofix-state.mjs) keeps its clone, runner and fix
72
+ // worktrees here too: ~1.1 GB on the owner's Mac on 2026-10-30, counted as "Brain state", which put a correctly
73
+ // pruned Brain 0.7 GB over its budget every night. They are that job's working data, not the product's footprint.
71
74
  const FOREIGN = { 'ruvector-mcp': 'RuVector MCP server working store (its own live memory)',
72
- 'worktree-recovery': 'hand-made recovery archives, not created by the Brain' };
73
- const TERMINAL = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
75
+ 'worktree-recovery': 'hand-made recovery archives, not created by the Brain',
76
+ 'autofix-repo': 'the maintainer issue-autofix job\'s clone (ADR-107); that job owns and prunes it',
77
+ 'autofix-runner': 'the maintainer issue-autofix job\'s pinned runner worktree (ADR-107); that job owns it',
78
+ 'issue-fix-worktrees': 'the maintainer issue-autofix job\'s fix worktrees (ADR-107); that job owns and prunes them',
79
+ 'issue-autofix': 'the maintainer issue-autofix job\'s run receipts (ADR-107); that job owns them' };
80
+ const TERMINAL = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK', 'DISPOSED']); // DISPOSED: operator recovery (#335)
74
81
 
75
82
  const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
76
83
  const lstat = (file) => { try { return fs.lstatSync(file); } catch { return null; } };
@@ -190,6 +197,10 @@ export function inventoryFootprint({ env = process.env, home = os.homedir(), now
190
197
  }
191
198
  };
192
199
  const classifySibling = (name, full, st) => {
200
+ if (name === `.${base}.recovered` && st.isDirectory() && !st.isSymbolicLink()) { // #335: written only by --recover-update --keep-live
201
+ return void add({ id: 'kb-recovered', path: full, class: 'may-exist', kind: 'kb-recovered', action: 'keep', bytes: bytes(full),
202
+ reason: 'trees an operator-disposed update transaction owned, moved intact (--recover-update); never removed automatically' });
203
+ }
193
204
  if (name === `${base}.archive` && st.isDirectory() && !st.isSymbolicLink()) { // #335: written only by --clean --archive
194
205
  return void add({ id: 'kb-archive', path: full, class: 'may-exist', kind: 'kb-archive', action: 'keep', bytes: bytes(full),
195
206
  reason: 'files unique to released KB copies, archived on request (--clean --archive); never removed automatically' });
@@ -232,6 +232,9 @@ export function pluginUpdateDecision(plugin, target) {
232
232
  return { state: target.version === plugin.version ? 'CURRENT' : 'UPDATE_AVAILABLE', proof: target.proof,
233
233
  sourceCommitMatched: target.catalogCommit ? plugin.gitCommitSha?.toLowerCase() === target.catalogCommit : null };
234
234
  }
235
+ // Codex refreshes a remote plugin catalog (56 MB, 38-40 s cold, 2026-10-30) inside its plugin commands; a killed refresh
236
+ // caches nothing, so a 30 s limit failed every later night the same way. Same bound as bin/install.mjs.
237
+ export const CODEX_COMMAND_TIMEOUT_MS = 180_000;
235
238
  export async function synchronizePlugins(run, dryRun, notes, { home, prefix, scope, locate, unattended = false, artifactProvider = pinnedClaudeTarget }) {
236
239
  const installed = path.join(home, '.claude/plugins/installed_plugins.json');
237
240
  const inventoryOptions = { unattended };
@@ -290,10 +293,10 @@ export async function synchronizePlugins(run, dryRun, notes, { home, prefix, sco
290
293
  for (const p of after.filter(p => writable.some(b => scopeKey(b) === scopeKey(p)))) if (!exists(p.installPath)) throw Error(`plugin artifact missing: ${p.id}`);
291
294
  const codex = locate('codex');
292
295
  if (codex) {
293
- const result = JSON.parse(run(codex, ['plugin', 'marketplace', 'list', '--json'], { timeout: 30_000 }));
296
+ const result = JSON.parse(run(codex, ['plugin', 'marketplace', 'list', '--json'], { timeout: CODEX_COMMAND_TIMEOUT_MS }));
294
297
  const git = (result.marketplaces || []).filter(m => m.marketplaceSource?.sourceType === 'git' && (scope === 'all' || PLUGIN_MARKETPLACES.has(m.name)));
295
298
  if (!dryRun) for (const m of git) run(codex, ['plugin', 'marketplace', 'upgrade', m.name, '--json']);
296
- const help = run(codex, ['plugin', '--help'], { timeout: 30_000 });
299
+ const help = run(codex, ['plugin', '--help'], { timeout: CODEX_COMMAND_TIMEOUT_MS });
297
300
  // Tripwire, reported not thrown: a new Codex command must not end the night before the knowledge is checked (AuditC #17).
298
301
  if (/^\s+update\s/m.test(help)) notes.push('Codex plugin update now exists and is unsupported until its scope-aware policy is reviewed; installed generations preserved');
299
302
  else notes.push('Codex has no plugin update command; configured Git catalogues refreshed, installed generations preserved');
@@ -307,6 +310,13 @@ export const INSTALL_UPDATE_TIMEOUT_MS = 135 * 60_000;
307
310
  export const TRANSIENT_RETRY_DELAYS_MS = Object.freeze([15_000, 30_000]);
308
311
  const sleepSync = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
309
312
  const exitOf = result => typeof result === 'string' ? 0 : (result?.exitCode ?? result?.status ?? result?.code ?? null);
313
+ /** The lines of a child's output that say why it failed (the ✗/failed/incomplete ones, else its last lines), ANSI-free, <= 600 chars. */
314
+ export function outputReason(result) {
315
+ if (!result || typeof result === 'string') return '';
316
+ const lines = `${result.stderr || ''}\n${result.stdout || ''}`.replace(/\x1b\[[0-9;]*m/g, '').split('\n').map(l => l.trim()).filter(Boolean);
317
+ const telling = lines.filter(l => /✗|\berror\b|failed|incomplete|refused|timed out|ETIMEDOUT/i.test(l));
318
+ return [...new Set((telling.length ? telling : lines).slice(-3))].join(' | ').slice(-600);
319
+ }
310
320
  export const TRANSIENT_NETWORK = /ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|ENETUNREACH|socket hang up|network|timed out|\bE5\d\d\b|\b50[234]\b/i;
311
321
  /** Three tries of `attempt` (15 s, then 30 s apart) while `transient(error)` says the failure was the network's. */
312
322
  export function withTransientRetry(attempt, { transient = () => true, sleep = sleepSync, delays = TRANSIENT_RETRY_DELAYS_MS } = {}) {
@@ -314,15 +324,17 @@ export function withTransientRetry(attempt, { transient = () => true, sleep = sl
314
324
  try { return attempt(i); } catch (error) { if (i >= delays.length || !transient(error)) throw error; sleep(delays[i]); }
315
325
  }
316
326
  }
317
- export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = sleepSync }) {
327
+ export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = sleepSync, convergeRuntime = true }) {
318
328
  const kb = path.join(brainHome, 'kb'), updater = path.join(kb, 'forge-update.mjs');
319
329
  if (!exists(updater)) throw Error('existing Brain KB updater absent; fresh install refused');
320
330
  const file = path.join(brainHome, `nightly-suite-corpus-${runId}.json`);
321
331
  // Exit codes are forge-update's: 0 current, 10 newer, 2 network (retried), 5 incompatible with this runtime.
332
+ let lastOutput = '';
322
333
  const exec = (args, options) => {
323
334
  if (!run.receipt) { run(node, args, options); return 0; }
324
335
  const result = run.receipt(node, args, options);
325
- if (result?.error) throw Error(`${path.basename(args[0])} ${args[1]}: ${result.error}`);
336
+ lastOutput = outputReason(result);
337
+ if (result?.error) throw Error(`${path.basename(args[0])} ${args[1]}: ${result.error}${lastOutput ? ` — ${lastOutput}` : ''}`);
326
338
  return exitOf(result);
327
339
  };
328
340
  function check() {
@@ -333,7 +345,7 @@ export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = s
333
345
  return status;
334
346
  }, { transient: error => error.transient === true, sleep });
335
347
  if (code === 5) return { currencyVerdict: 'INCOMPATIBLE', exitCode: 5 };
336
- if (code !== 0 && code !== 10) throw Error(`corpus check exited ${code}`);
348
+ if (code !== 0 && code !== 10) throw Error(`corpus check exited ${code}${lastOutput ? `: ${lastOutput}` : ''}`);
337
349
  let receipt;
338
350
  try { receipt = read(file); } finally { fs.rmSync(file, { force: true }); }
339
351
  if (receipt.kind !== 'ruvnet-brain-check-result' || Date.parse(receipt.recordedAt) < started - 1000) throw Error('fresh corpus check receipt absent');
@@ -344,7 +356,8 @@ export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = s
344
356
  if (!exists(installer)) throw Error('existing Brain installer absent');
345
357
  // 12 is cleanup-pending: the knowledge was applied and the next run finishes the cleanup (shared exit contract).
346
358
  const code = exec([installer, '--update', '--no-nightly-prompt', '--no-stack'], { timeout: INSTALL_UPDATE_TIMEOUT_MS, allowed: [0, 12] });
347
- if (code !== 0 && code !== 12) throw Error(`Brain installer --update exited ${code}`);
359
+ // The installer's own words ride along: "exited 1" alone hid a Codex timeout for three nights (2026-10-28..30).
360
+ if (code !== 0 && code !== 12) throw Error(`Brain installer --update exited ${code}${lastOutput ? `: ${lastOutput}` : ''}`);
348
361
  return code;
349
362
  };
350
363
  const before = check();
@@ -360,7 +373,21 @@ export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = s
360
373
  if (!['CURRENT', 'REFUSED'].includes(after.currencyVerdict)) throw Error(`corpus update failed to converge after re-stamping ${stamped} -> ${installed}`);
361
374
  return { before, after, state: 'updated', installerExit, restamped: { from: stamped, to: installed } };
362
375
  }
363
- if (before.currencyVerdict === 'CURRENT' || before.currencyVerdict === 'REFUSED') return { before, after: before, state: before.currencyVerdict === 'CURRENT' ? 'current' : 'ahead-preserved' };
376
+ if (before.currencyVerdict === 'CURRENT' || before.currencyVerdict === 'REFUSED') {
377
+ // A CURRENT corpus is not a converged machine: the package stage can install a newer ruvnet-brain on a night with no
378
+ // new corpus, and only the installer's --update moves the runtime and hosts to it (a no-op on the corpus). Before
379
+ // this, such a night reported "current" and left runtime + hosts on the old version until a corpus was published.
380
+ const runtime = runtimeConvergence({ root, brainHome });
381
+ const state = before.currencyVerdict === 'CURRENT' ? 'current' : 'ahead-preserved';
382
+ if (runtime.converged) return { before, after: before, state, runtime };
383
+ // The SessionStart self-heal (--knowledge-only) runs unattended without the nightly's identity, so an installer
384
+ // run there would count as interactive (and could reload a shared Codex app-server): it reports, the nightly acts.
385
+ if (!convergeRuntime) return { before, after: before, state, runtime, deferred: 'runtime convergence is left to the scheduled nightly' };
386
+ if (dryRun) return { before, state: 'update-available', reason: runtime.reason, runtime };
387
+ const installerExit = apply(), after = check(), converged = runtimeConvergence({ root, brainHome });
388
+ if (!converged.converged) throw Error(`runtime did not converge: ${converged.reason}`);
389
+ return { before, after, state: 'runtime-converged', installerExit, runtime: converged };
390
+ }
364
391
  // UNKNOWN is what a tree installed from a code release reports (no corpus generation stamp): forge-update
365
392
  // documents it as apply-allowed, exactly like UPDATE_AVAILABLE, and the check after the apply must reach
366
393
  // CURRENT or REFUSED. Throwing here failed every scheduled night until someone ran --update by hand.
@@ -369,7 +396,34 @@ export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = s
369
396
  const installerExit = apply();
370
397
  const after = check();
371
398
  if (!['CURRENT', 'REFUSED'].includes(after.currencyVerdict)) throw Error('corpus update failed to converge');
372
- return { before, after, state: 'updated', installerExit };
399
+ // Positive confirmation: the corpus converged AND the runtime and hosts sit on the installed package.
400
+ const runtime = runtimeConvergence({ root, brainHome });
401
+ if (!runtime.converged) throw Error(`corpus updated but the runtime did not converge: ${runtime.reason}`);
402
+ return { before, after, state: 'updated', installerExit, runtime };
403
+ }
404
+ /**
405
+ * Does this machine RUN the installed ruvnet-brain package? The Stable Spine (active.json) and the host-convergence
406
+ * receipt must name that version (or a newer one: ahead is not behind) and no host may be recorded failed. Evidence
407
+ * that does not exist is not judged (an install without a spine has nothing to compare).
408
+ */
409
+ export function runtimeConvergence({ root = ROOT, brainHome = CACHE } = {}) {
410
+ const value = (file) => { try { return read(file); } catch { return null; } };
411
+ // A linked source checkout's version is not a release the runtime can be converged to (it may be ahead of npm): its
412
+ // versions are not compared, but a host recorded failed is still a host that did not converge.
413
+ let linked = false;
414
+ try { linked = fs.lstatSync(path.join(root, 'ruvnet-brain')).isSymbolicLink(); } catch { /* absent: judged below */ }
415
+ const installed = linked ? null : value(path.join(root, 'ruvnet-brain/package.json'))?.version || null;
416
+ const active = value(path.join(brainHome, 'active.json'));
417
+ const receipt = value(path.join(brainHome, 'host-convergence.json'));
418
+ const evidence = { installed, active: active?.version || null, desired: receipt?.desiredVersion || null };
419
+ const behind = (version) => { try { return !version || compare(version, installed) < 0; } catch { return true; } };
420
+ if (!installed && !linked) return { converged: true, ...evidence, reason: 'no installed ruvnet-brain package' };
421
+ if (!linked && active && behind(active.version)) return { converged: false, ...evidence, reason: `runtime ${active.version || 'unknown'} is behind the installed package ${installed}` };
422
+ if (!linked && receipt && behind(receipt.desiredVersion)) return { converged: false, ...evidence, reason: `hosts were last converged to ${receipt.desiredVersion || 'unknown'}, not the installed package ${installed}` };
423
+ const failed = Object.entries(receipt?.hosts || {}).filter(([, host]) => !['ready', 'disabled', 'absent'].includes(host?.state));
424
+ if (failed.length) return { converged: false, ...evidence, reason: `host ${failed.map(([name, host]) => `${name} ${host?.state || 'unknown'}${host?.error ? ` (${host.error})` : ''}`).join(', ')}` };
425
+ if (linked) return { converged: true, ...evidence, reason: 'ruvnet-brain is a linked source checkout' };
426
+ return { converged: true, ...evidence, reason: active || receipt ? `runtime and hosts on ${installed}` : 'no runtime evidence to compare' };
373
427
  }
374
428
  /** Corpus check results this coordinator left behind (a run killed between check and delete) older than 48 h. */
375
429
  export function pruneCorpusResults(brainHome, now = Date.now()) {
@@ -419,7 +473,31 @@ export function locateExecutable(name, { env = process.env, platform = process.p
419
473
  }
420
474
  return null;
421
475
  }
422
- export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(), brainHome, config: override, runner = invoke, env = process.env, knowledgeOnly = false, sleep = sleepSync } = {}) {
476
+ // A failed scheduled knowledge stage is tried once more the same night, this long after the first attempt (a network or
477
+ // release-publication blip clears; a deterministic failure fails twice and pages with both reasons).
478
+ export const SCHEDULED_RETRY_DELAY_MS = 20 * 60_000;
479
+ /**
480
+ * Page the owner about a scheduled night through the shipped scripts/notify.sh (ntfy). Topic: NTFY_TOPIC, else the
481
+ * owner's `<brain home>/ntfy-topic` opt-in file; neither = paging off (recorded, not an error). Never throws, never
482
+ * records the topic. `kind`: 'failed' (urgent) or 'recovered' (after a failed night).
483
+ */
484
+ export function pageNight(receipt, kind, { root = ROOT, brainHome = CACHE, env = process.env, runner = invoke, platform = process.platform } = {}) {
485
+ try {
486
+ if (platform === 'win32') return { kind, state: 'unsupported', reason: 'scripts/notify.sh needs a POSIX shell' };
487
+ const notify = path.join(root, 'ruvnet-brain/scripts/notify.sh');
488
+ if (!exists(notify)) return { kind, state: 'unavailable', reason: 'the installed package has no scripts/notify.sh' };
489
+ let topic = env.NTFY_TOPIC;
490
+ if (topic === undefined) { try { topic = fs.readFileSync(path.join(brainHome, 'ntfy-topic'), 'utf8').trim(); } catch { topic = ''; } }
491
+ if (!topic) return { kind, state: 'off', reason: 'no ntfy topic (NTFY_TOPIC or the brain home ntfy-topic file)' };
492
+ const title = kind === 'failed' ? 'RuvNet Brain nightly update FAILED' : 'RuvNet Brain nightly update recovered';
493
+ const body = `${kind === 'failed' ? receipt.error || receipt.state : `completed after a failed night (${receipt.state})`}\n\nrun ${receipt.runId}, started ${receipt.startedAt}\nreceipt: ${path.join(brainHome, 'scheduler', 'last-suite-attempt.json')}`;
494
+ const r = runner('/bin/sh', [notify, title, body.slice(0, 1500), kind === 'failed' ? 'urgent' : 'default', kind === 'failed' ? 'rotating_light' : 'white_check_mark'],
495
+ { capture: true, timeout: 30_000, env: { ...env, NTFY_TOPIC: topic } });
496
+ const exitCode = r?.exitCode ?? null;
497
+ return { kind, state: exitCode === 0 ? 'delivered' : exitCode === 2 ? 'off' : 'not-delivered', exitCode, at: new Date().toISOString() };
498
+ } catch (error) { return { kind, state: 'not-delivered', reason: error.message }; }
499
+ }
500
+ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(), brainHome, config: override, runner = invoke, env = process.env, knowledgeOnly = false, sleep = sleepSync, retryDelayMs = SCHEDULED_RETRY_DELAY_MS } = {}) {
423
501
  if (!['check', 'apply'].includes(mode)) throw Error('mode must be check or apply');
424
502
  const paths = developerUpdatePaths({ home, brainHome });
425
503
  const config = validateDeveloperUpdateConfig(override || readDeveloperUpdateConfig(paths));
@@ -467,6 +545,8 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
467
545
  }
468
546
  };
469
547
  let latestMayBeReplaced = receipt.scheduled;
548
+ let previousScheduled = null;
549
+ try { previousScheduled = receipt.scheduled ? readAttempt(paths.scheduledReceipt) : null; } catch { previousScheduled = null; }
470
550
  try {
471
551
  if (!receipt.scheduled) {
472
552
  const latest = readAttempt(paths.receipt), retained = readAttempt(paths.scheduledReceipt);
@@ -521,8 +601,20 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
521
601
  }
522
602
  }
523
603
  if (exists(path.join(paths.brainHome, 'kb/forge-update.mjs'))) {
524
- receipt.knowledge = await stage('knowledge', () => knowledge(run, mode === 'check', receipt.runId, { brainHome: paths.brainHome, root, node: process.execPath, sleep }),
525
- error => { receipt.knowledge = { state: 'failed', error: error.message }; }) ?? receipt.knowledge;
604
+ const knowledgeStage = () => stage('knowledge', () => knowledge(run, mode === 'check', receipt.runId, { brainHome: paths.brainHome, root, node: process.execPath, sleep, convergeRuntime: !knowledgeOnly }),
605
+ error => { receipt.knowledge = { state: 'failed', error: error.message }; });
606
+ receipt.knowledge = await knowledgeStage() ?? receipt.knowledge;
607
+ // SELF-RETRY THE SAME NIGHT (2026-10-30): a scheduled night whose knowledge stage failed tries once more after
608
+ // retryDelayMs. Only the second attempt's outcome counts; the first one's reason stays on the receipt.
609
+ if (receipt.scheduled && mode === 'apply' && receipt.knowledge?.state === 'failed') {
610
+ const firstAttemptError = receipt.knowledge.error;
611
+ receipt.notes.push(`knowledge attempt 1 failed (${firstAttemptError}); retrying once in ${Math.round(retryDelayMs / 60_000)} min`);
612
+ persistReceipt();
613
+ sleep(retryDelayMs);
614
+ const firstFailure = failures.findIndex(f => f.stage === 'knowledge');
615
+ if (firstFailure >= 0) failures.splice(firstFailure, 1);
616
+ receipt.knowledge = { ...(await knowledgeStage() ?? receipt.knowledge), attempts: 2, firstAttemptError };
617
+ }
526
618
  pruneCorpusResults(paths.brainHome);
527
619
  } else receipt.notes.push('Brain corpus absent; fresh knowledge install excluded');
528
620
  persistReceipt();
@@ -556,6 +648,12 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
556
648
  throw error;
557
649
  } finally {
558
650
  receipt.finishedAt = new Date().toISOString();
651
+ // Every scheduled night ends with a word to the owner when it failed, and once more when it recovers.
652
+ if (receipt.scheduled && mode === 'apply') {
653
+ const previousFailed = previousScheduled && previousScheduled.ok !== true && previousScheduled.state !== 'running';
654
+ if (receipt.ok !== true) receipt.page = pageNight(receipt, 'failed', { root: receipt.npmIdentity?.root, brainHome: paths.brainHome, env, runner });
655
+ else if (previousFailed) receipt.page = pageNight(receipt, 'recovered', { root: receipt.npmIdentity?.root, brainHome: paths.brainHome, env, runner });
656
+ }
559
657
  try { if (latestMayBeReplaced) persistReceipt(); } finally { lock.release(); }
560
658
  }
561
659
  }
@@ -131,7 +131,29 @@ export function treeBytes(target) {
131
131
 
132
132
  /** Atomic rename-rotation: <name> -> <name>.1 (replacing the previous .1). Appenders reopen by path. */
133
133
  export function rotate(file) { fs.renameSync(file, `${file}.1`); }
134
+ /**
135
+ * Keep a text log's newest tail IN PLACE (same inode). A rename-over gave the log a new inode, so a writer that still
136
+ * held it open (launchd's StandardOutPath for the nightly, whose own installer runs this sweep) wrote the rest of the
137
+ * night into a deleted file (review, 2026-10-30). Appenders (O_APPEND) continue at the new end.
138
+ */
139
+ // In-place trimming is only safe for an O_APPEND writer; the SessionStart jobs open their `.last-*.log` with 'w' (a
140
+ // trim under them would leave a hole of zero bytes), so only the launchd nightly log, appended to all night, is
141
+ // trimmed in place; every other text log keeps the rename-over trim (review r2, 2026-10-30).
142
+ const TRIM_IN_PLACE = new Set(['.last-nightly-update.log']);
134
143
  export function truncateToTail(file, keep) {
144
+ if (!TRIM_IN_PLACE.has(path.basename(file))) return truncateByRename(file, keep);
145
+ const fd = fs.openSync(file, 'r+');
146
+ try {
147
+ const size = fs.fstatSync(fd).size;
148
+ const buf = Buffer.alloc(Math.min(keep, size));
149
+ fs.readSync(fd, buf, 0, buf.length, size - buf.length);
150
+ const nl = buf.indexOf(10);
151
+ const tail = nl >= 0 ? buf.subarray(nl + 1) : buf;
152
+ fs.ftruncateSync(fd, 0);
153
+ fs.writeSync(fd, tail, 0, tail.length, 0);
154
+ } finally { fs.closeSync(fd); }
155
+ }
156
+ function truncateByRename(file, keep) {
135
157
  const size = fs.statSync(file).size;
136
158
  const fd = fs.openSync(file, 'r');
137
159
  const buf = Buffer.alloc(Math.min(keep, size));
@@ -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);
@@ -609,6 +609,17 @@ function cronRows(run) {
609
609
  return { ok: true, rows: String(listed.stdout || '').split('\n').filter(Boolean) };
610
610
  }
611
611
 
612
+ /**
613
+ * Where the scheduled run's own output goes. On macOS it was kb/update.log, INSIDE the tree each knowledge update
614
+ * replaces: launchd wrote the night into the old tree, the swap moved it aside and the updater reclaimed it, so every
615
+ * failed night's log was gone by morning (owner's Mac, 2026-10-28..30: no update.log existed). The brain home keeps it;
616
+ * the `.last-*.log` name puts it under the footprint's text-log cap (truncated to its tail past 512 KiB). Linux keeps
617
+ * its exact registered cron line (changing it would read every existing install as degraded); Windows logs nothing.
618
+ */
619
+ export function nightlyLogPath({ platform = process.platform, kbDir }) {
620
+ return platform === 'darwin' ? path.join(path.dirname(kbDir), '.last-nightly-update.log') : path.join(kbDir, 'update.log');
621
+ }
622
+
612
623
  export function installScheduler(record, { platform = process.platform, env = process.env, kbDir,
613
624
  run = spawnSync, testMode = false, proofTick = false, proofAt, pathValue = process.env.PATH || '' } = {}) {
614
625
  validateIdentity(record.identity);
@@ -616,7 +627,7 @@ export function installScheduler(record, { platform = process.platform, env = pr
616
627
  const artifact = nightlyArtifact({ platform, env, brainHome: path.dirname(kbDir), identity: record.identity });
617
628
  if (!artifact.supported) return { ok: false, artifact, why: `unsupported platform: ${platform}` };
618
629
  if (testMode && platform !== 'darwin') run = fixtureSchedulerRun(env, platform, record.identity);
619
- const logPath = path.join(kbDir, 'update.log');
630
+ const logPath = nightlyLogPath({ platform, kbDir });
620
631
  if (platform === 'darwin') {
621
632
  fs.mkdirSync(path.dirname(artifact.path), { recursive: true });
622
633
  fs.writeFileSync(artifact.path, launchdPlist(record, { kbDir, logPath, pathValue, env }));
@@ -40,7 +40,7 @@ import crypto from 'node:crypto';
40
40
  import { spawn, spawnSync } from 'node:child_process';
41
41
  import { fileURLToPath } from 'node:url';
42
42
  import { nativeTurnLines } from './native-turn-transcript.mjs';
43
- import { resolveRuflo, rufloInvocation, rufloCwdFor, rufloScratchRoot } from './ruflo-bin.mjs';
43
+ import { resolveRuflo, rufloInvocation, rufloRunDir, rufloScratchRoot } from './ruflo-bin.mjs';
44
44
  import { redactText } from './continuity-events.mjs';
45
45
  import { resolveProjectStore, globalEnrollmentAllowed, storeEncryptedAtRest, ENCRYPTED_STORE_REASON } from './project-store-resolver.mjs';
46
46
  import { recordHookFailure } from './hook-failures.mjs';
@@ -388,7 +388,7 @@ export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppl
388
388
  };
389
389
  const results = [];
390
390
  for (const queued of steps) {
391
- let status = 1; let error = null; let skipped = null; let verified = false; let db = ''; let key; let privacyPatterns = [];
391
+ let status = 1; let error = null; let skipped = null; let verified = false; let db = ''; let key; let privacyPatterns = []; let scratchRun = null;
392
392
  const kind = queued.kind;
393
393
  try {
394
394
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
@@ -439,9 +439,12 @@ export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppl
439
439
  if (remaining <= 0) throw new Error('turn write deadline exhausted');
440
440
  // Measured 2026-10-10 with global Ruflo 3.56.3: cwd=<project>/.swarm litters ruvector.db/.claude-flow there, and
441
441
  // CLAUDE_FLOW_MEMORY_PATH=<.swarm> makes Ruflo create a second agentdb-memory.db beside the canonical store.
442
- // The explicit --path already binds the store, so run from private scratch with neither.
442
+ // The explicit --path already binds the store, so run from private scratch with neither. One fresh run-* directory
443
+ // per call, removed after it (ruflo-bin rufloRunDir): the shared project scratch dir used here before kept ruflo's
444
+ // ruvector.db/.swarm/.claude-flow copies between turns, which --doctor counted as cruft after every turn (2026-10-30).
445
+ scratchRun = rufloRunDir(db, { root: rufloScratchRoot({ ...env, RUVNET_BRAIN_HOME: brainHome }) });
443
446
  const options = { encoding: 'utf8', timeout: Math.min(STEP_TIMEOUT_MS, remaining), windowsHide: true,
444
- cwd: rufloCwdFor(db, { root: rufloScratchRoot({ ...env, RUVNET_BRAIN_HOME: brainHome }) }),
447
+ cwd: scratchRun,
445
448
  maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV } };
446
449
  delete options.env.CLAUDE_FLOW_MEMORY_PATH;
447
450
  const r = run(invocation.executable, invocation.args, options);
@@ -456,6 +459,7 @@ export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppl
456
459
  else if (!verified && (r.error || status !== 0)) error = captureFailureReason(r, status);
457
460
  else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
458
461
  } catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
462
+ if (scratchRun) { try { fs.rmSync(scratchRun, { recursive: true, force: true }); } catch { /* a stale run-* is swept by the next rufloRunDir */ } }
459
463
  if (error) error = maskExcludedPaths(error, privacyPatterns, projectDir);
460
464
  const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
461
465
  ...(kind === 'store' ? { verified } : {}), ...(skipped ? { skipped } : {}) };
@@ -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', []);
@@ -245,8 +245,13 @@ export function checkAll(now, { registry = REGISTRY, loaded = loadedLabels(), hb
245
245
  }
246
246
 
247
247
  export function productSchedulerVerdict(status) {
248
+ // The night's receipt: its runId tells one failed night from the next, and a failed night the coordinator already
249
+ // paged itself (developer-update.mjs pageNight, delivered) is handled like a wrapper-paged one.
250
+ const receipt = status?.runHealth?.receipt || null;
251
+ const night = { ...(receipt?.runId ? { runId: receipt.runId } : {}),
252
+ ...(receipt?.page?.kind === 'failed' && receipt.page.state === 'delivered' ? { coordinatorPaged: true } : {}) };
248
253
  const base = { label: NIGHTLY_LABEL, what: 'Refreshes the installed Brain corpus and converges every detected host',
249
- schedule: 'daily 03:30', maxAgeHours: 30, required: true };
254
+ schedule: 'daily 03:30', maxAgeHours: 30, required: true, ...night };
250
255
  // A 'degraded' adapter whose only defect is a NON-ZERO LAST EXIT is a job that is loaded, verified
251
256
  // and FIRED — the exact opposite of MISSING ("not loaded — it can never fire", top of this file).
252
257
  // Its truth is the receipt. Measured 2026-09-12 against two FAILED receipts: this projected as
@@ -282,9 +287,13 @@ export function brainUpdateOwnedByAgenticKit(home = os.homedir()) {
282
287
  const loadState = () => { try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return {}; } };
283
288
  const saveState = (s) => { fs.mkdirSync(path.dirname(STATE), { recursive: true }); fs.writeFileSync(STATE, JSON.stringify(s, null, 2)); };
284
289
 
285
- /** Page only on a CHANGE of state. Repeating the same alarm nightly is how alarms get ignored. */
290
+ /** Page only on a CHANGE of state. Repeating the same alarm nightly is how alarms get ignored. One exception: a NEW
291
+ * failed night of the product nightly (a different receipt runId than the one last handled) pages once, because "it
292
+ * failed again tonight" is news; before this, three failed nights in a row paged only the first (2026-10-27..30). */
286
293
  export function transitions(results, prev) {
287
- return results.filter((r) => !r.self && (prev[r.label] ?? OK) !== r.state);
294
+ const handledRun = (label) => prev._runs?.[label];
295
+ return results.filter((r) => !r.self && ((prev[r.label] ?? OK) !== r.state
296
+ || (r.state === FAILING && !!r.runId && !!handledRun(r.label) && handledRun(r.label) !== r.runId)));
288
297
  }
289
298
 
290
299
  /** An HTTP header value must be a ByteString. An emoji in `Title` made fetch() throw before sending a byte, so
@@ -371,7 +380,7 @@ async function main() {
371
380
  const pagedBy = { ...(prev._pagedBy || {}) };
372
381
  const sends = []; const owners = []; const viaBoth = new Set();
373
382
  for (const c of transitions(results, prev)) {
374
- if (c.wrapperPaged) { pagedBy[c.label] = 'wrapper'; continue; }
383
+ if (c.wrapperPaged || (c.coordinatorPaged && c.state === FAILING)) { pagedBy[c.label] = 'wrapper'; continue; }
375
384
  if (c.state === OK) {
376
385
  if (pagedBy[c.label] === 'both') { delete pagedBy[c.label]; continue; } // the wrapper's "Recovered" covers it
377
386
  if (prev[c.label] === FAILING && pagedBy[c.label] !== 'watchdog') { delete pagedBy[c.label]; continue; }
@@ -389,7 +398,10 @@ async function main() {
389
398
  }
390
399
  const next = Object.fromEntries(results.map((r) => [r.label,
391
400
  r.self || undelivered.has(r.label) ? (prev[r.label] ?? OK) : r.state]));
392
- saveState({ ...next, _pagedBy: pagedBy });
401
+ // The night each label's verdict was last handled for; an undelivered page keeps the old one so it re-fires.
402
+ const runs = { ...(prev._runs || {}) };
403
+ for (const r of results) if (r.runId && !undelivered.has(r.label)) runs[r.label] = r.runId;
404
+ saveState({ ...next, _pagedBy: pagedBy, _runs: runs });
393
405
  }
394
406
 
395
407
  if (json) console.log(JSON.stringify({ results, checkedAt: new Date().toISOString() }, null, 2));
@@ -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,24 @@ 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
+ },
367
+ {
368
+ "id": "nightly-converges-4-7-25",
369
+ "reason": "The scheduled nightly converges or says why (ADR-104 4.7.25): a CURRENT corpus still runs the installer when the runtime or a host is behind the installed package, an installer exit 0 that leaves it behind is a failure, a failed knowledge stage is retried once the same night, a night that ends not-ok pages through scripts/notify.sh and the next good one pages recovered, the failure carries the installer's own lines, and Codex plugin commands get the catalog-refresh limit. Paging needs a POSIX shell, so Windows is not verified",
370
+ "files": [],
371
+ "platformFiles": {
372
+ "linux": ["tests/unit/nightly-self-retry-and-page.test.mjs"],
373
+ "macos": ["tests/unit/nightly-self-retry-and-page.test.mjs"]
374
+ }
375
+ },
358
376
  {
359
377
  "id": "issue-autofix-bounded-writer",
360
378
  "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",