ruvnet-brain 4.7.24 → 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.24-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
@@ -2108,6 +2108,12 @@ export function codexBrokenMarketplaces(text) {
2108
2108
  }
2109
2109
  const sleepSync = (ms) => { if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
2110
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
+
2111
2117
  export function runCodexJson(args, {
2112
2118
  codexBin = process.env.CODEX_BIN || 'codex',
2113
2119
  codexHome = codexHomeDir(),
@@ -2117,9 +2123,10 @@ export function runCodexJson(args, {
2117
2123
  // Refreshing another tool's marketplace is a network write; only an install/update that is about to change the
2118
2124
  // Brain's Codex plugin opts in. --doctor and listings report the broken snapshot and change nothing (review 2026-10-24).
2119
2125
  repair = false,
2126
+ timeoutMs = CODEX_PLUGIN_COMMAND_TIMEOUT_MS,
2120
2127
  } = {}) {
2121
2128
  const options = { stdio: 'pipe', shell: false, cwd, env: { ...process.env, CODEX_HOME: codexHome }, encoding: 'utf8',
2122
- timeout: 30_000, maxBuffer: 20 * 1024 * 1024 };
2129
+ timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 };
2123
2130
  const snapshotFailure = (res) => !res.missingBinary && res.status !== 0 && CODEX_SNAPSHOT_FAILURE.test(String(res.stderr || res.stdout || ''));
2124
2131
  let r = run(codexBin, args, options);
2125
2132
  // One wait covers a refresh Codex is finishing; then refresh only the OTHER Git marketplaces it named (the Brain's
@@ -4156,51 +4163,66 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
4156
4163
  return { ok: false, results, ...detail, error: detail.error || hostFailureReason(results) };
4157
4164
  };
4158
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);
4159
4173
  try {
4160
4174
  if (wireClaude === wirePlugin) results.hookRetirement = retireManagedHookRegistrations();
4161
4175
  results.claude = wireClaude === wirePlugin
4162
4176
  ? wirePlugin({ expectedVersion: PACKAGE_VERSION, requireManaged: true })
4163
4177
  : wireClaude({ expectedVersion: PACKAGE_VERSION, requireManaged: true });
4164
- 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 {
4165
4184
  results.codexHost = detectCodexHost();
4166
4185
  if (results.codexHost.host) {
4167
4186
  if (!['added', 'rewritten', 'unchanged', 'user-owned'].includes(results.codexHost.action)) {
4168
- return fail({ error: `Codex host convergence failed: ${results.codexHost.action || 'unknown action'}` });
4169
- }
4170
- results.codex = installCodexPlugin({ expectedVersion: PACKAGE_VERSION });
4171
- // ~/.codex exists but no `codex` binary is on PATH (an uninstalled CLI, or a desktop-only setup whose shell
4172
- // cannot see it): there is no Codex host to converge here, which is absence, not a failed update (4.7.21,
4173
- // audit C-opus #6). Any other unavailability (the binary ran and failed) still fails.
4174
- if (results.codex.action === 'codex-unavailable' && results.codex.missingBinary === true) {
4175
- results.codexAbsent = true;
4176
- } else if (!['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
4177
- 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
+ }
4178
4199
  }
4179
4200
  }
4180
4201
  } catch (error) {
4181
- results.codex = { action: 'failed', error: error?.message || String(error) };
4182
- return fail();
4202
+ results.codex = { action: 'failed', error: hostError(error) };
4203
+ failedHosts.add('codex');
4183
4204
  }
4184
4205
 
4185
4206
  const apply = path.join(sourceRoot, 'plugin', 'scripts', 'update-apply.mjs');
4186
4207
  if (!fs.existsSync(apply)) return fail({ error: 'Stable Spine updater missing from package' });
4187
4208
  const applied = runStableSpine(apply);
4188
4209
  const okApplied = !applied.error && applied.status === 0;
4189
- const codexReceipt = {
4190
- state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host && !results.codexAbsent ? 'ready' : 'absent'),
4191
- version: results.codex?.version || null,
4192
- };
4193
- 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) {
4194
4217
  codexReceipt.restartRequired = true;
4195
4218
  codexReceipt.sessionSafety = results.codex.sessionSafety || null;
4196
4219
  codexReceipt.sessionSafetyReason = results.codex.sessionSafetyReason || null;
4197
4220
  codexReceipt.restartScope = results.codex.restartScope || 'unproven';
4198
4221
  }
4199
- const claudeReceipt = {
4200
- state: results.claude.host ? 'ready' : 'absent',
4201
- version: results.claude.version || null,
4202
- };
4203
- 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) {
4204
4226
  claudeReceipt.restartRequired = true;
4205
4227
  claudeReceipt.sessionSafety = results.claude.sessionSafety || null;
4206
4228
  claudeReceipt.sessionSafetyReason = results.claude.sessionSafetyReason || null;
@@ -4287,6 +4309,11 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
4287
4309
  };
4288
4310
  const convergence = classifyHostConvergence(convergedReceipt);
4289
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
+ }
4290
4317
  return { ok: convergence.healthy === true, convergence, results, applyStatus: applied.status,
4291
4318
  ...(convergence.healthy !== true && hostRestartIsOnlyGap(convergedReceipt) ? { restartOnly: true } : {}),
4292
4319
  error: convergence.healthy === true ? null : `host convergence is ${convergence.state}: ${convergence.action}` };
@@ -4425,13 +4452,16 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
4425
4452
  /** Why host synchronization stopped, from the per-host results it had gathered; null when nothing says. */
4426
4453
  export function hostFailureReason(results = {}) {
4427
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 = [];
4428
4457
  if (results.claude?.host && !results.claude.wired) {
4429
- 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)}` : ''}`);
4430
4459
  }
4431
- if (results.codex && !['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
4432
- 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)}` : ''}`);
4433
4463
  }
4434
- return null;
4464
+ return reasons.length ? reasons.join('; ') : null;
4435
4465
  }
4436
4466
 
4437
4467
  export function hostSynchronizationFailureMessage(convergence, active, expectedVersion = PACKAGE_VERSION) {
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.24",
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.24",
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.24",
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,8 +68,15 @@ 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' };
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' };
73
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; } };
@@ -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));
@@ -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 } : {}) };
@@ -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));
@@ -364,6 +364,15 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
364
364
  "macos": ["tests/unit/update-recovery-disposition.test.mjs", "tests/unit/learn-capture-ack-after-readback.test.mjs"]
365
365
  }
366
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
+ },
367
376
  {
368
377
  "id": "issue-autofix-bounded-writer",
369
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",