ruvnet-brain 4.7.16 → 4.7.18

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.
Files changed (43) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +120 -15
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/hooks/grok-hooks.json +1 -1
  7. package/plugin/hooks/hook-contracts.json +36 -18
  8. package/plugin/hooks/hooks.json +1 -1
  9. package/plugin/scripts/agentdb-recall-process.mjs +1 -1
  10. package/plugin/scripts/agentdb-recall-reader.mjs +1 -1
  11. package/plugin/scripts/brain-confirmation.mjs +8 -1
  12. package/plugin/scripts/brain-footprint.mjs +3 -1
  13. package/plugin/scripts/continuity-hook-policy.mjs +9 -9
  14. package/plugin/scripts/decision-record.mjs +1 -1
  15. package/plugin/scripts/degradation-watch.mjs +13 -1
  16. package/plugin/scripts/developer-update-policy.mjs +31 -5
  17. package/plugin/scripts/developer-update.mjs +17 -5
  18. package/plugin/scripts/grok-hook-adapter.mjs +7 -3
  19. package/plugin/scripts/hook-failures.mjs +76 -8
  20. package/plugin/scripts/learn-capture.mjs +74 -9
  21. package/plugin/scripts/learning-store.mjs +17 -7
  22. package/plugin/scripts/nightly-scheduler.mjs +67 -12
  23. package/plugin/scripts/ruflo-bin.mjs +5 -1
  24. package/plugin/scripts/ruflo-memory-hooks.mjs +1 -1
  25. package/plugin/scripts/turn-outcome-capture.mjs +18 -18
  26. package/scripts/agentdb-fleet-doctor.mjs +1 -1
  27. package/scripts/corpus-dispatch-decision.mjs +5 -4
  28. package/scripts/corpus-watchdog.mjs +28 -2
  29. package/scripts/distill-project.mjs +1 -1
  30. package/scripts/falsify.mjs +1 -1
  31. package/scripts/health-repair.mjs +1 -1
  32. package/scripts/learning-replay-execution.mjs +3 -3
  33. package/scripts/learning-replay-fixture.mjs +3 -0
  34. package/scripts/lesson-migrate-agentdb.mjs +1 -1
  35. package/scripts/nightly-wrapper.sh +2 -2
  36. package/scripts/proxy/claude-proxied.sh +1 -0
  37. package/scripts/proxy/proxy-revert.sh +1 -0
  38. package/scripts/proxy/proxy-up.sh +1 -0
  39. package/scripts/proxy/proxy-verify.mjs +1 -1
  40. package/scripts/record-lesson.mjs +1 -1
  41. package/scripts/selfcheck.mjs +1 -1
  42. package/scripts/single-source-check.mjs +1 -1
  43. package/scripts/stack-sync.mjs +11 -3
@@ -52,6 +52,7 @@ import path from 'node:path';
52
52
  // is the exact defect class this file exists to close. Caught by counting occurrences, not by
53
53
  // reading the code and believing it.
54
54
  import { fileURLToPath } from 'node:url';
55
+ import { rufloRunDir } from './ruflo-bin.mjs';
55
56
 
56
57
  /**
57
58
  * THE PROBE TARGET IS A SCRATCH DB, NOT THE PROJECT'S.
@@ -73,8 +74,19 @@ import { fileURLToPath } from 'node:url';
73
74
  */
74
75
  const defaultDb = () => path.join(os.tmpdir(), `ruvnet-durability-probe-${process.pid}.db`);
75
76
 
77
+ // ruflo writes into its cwd on every call (.claude/, .claude-flow/, ruvector.db, agentdb.rvf) and its startup
78
+ // self-update runs `npm install @claude-flow/cli --save-exact` there. This probe runs on every `git push`, so with the
79
+ // caller's cwd it could leave those in the user's project (measured 2026-10-22: a 1.4 GB node_modules and a
80
+ // package.json in a project). It now runs in a private scratch dir, removed after the call, with both switches off.
76
81
  function defaultRun(cmd, args) {
77
- return execFileSync(cmd, args, { encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] });
82
+ let cwd = null;
83
+ try { cwd = rufloRunDir(path.join(os.tmpdir(), 'ruvnet-durability-probe')); } catch { /* fall back to tmp below */ }
84
+ try {
85
+ return execFileSync(cmd, args, { cwd: cwd || os.tmpdir(), encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'],
86
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' } });
87
+ } finally {
88
+ if (cwd) { try { fs.rmSync(cwd, { recursive: true, force: true }); } catch { /* swept later as a stale run- dir */ } }
89
+ }
78
90
  }
79
91
 
80
92
  /**
@@ -32,17 +32,43 @@ export function normalizeNpmDistTags(value) {
32
32
  }
33
33
  return value;
34
34
  }
35
- export function pickTargetTag(tags, want = 'latest', defaultTag = 'latest') {
36
- const candidates = [...new Set([want, defaultTag])].filter(tag => typeof tags?.[tag] === 'string');
35
+ export function normalizeNpmTimes(value) {
36
+ // `npm view <pkg> time --json`: an object of version -> ISO time (npm 12 wraps it in a one-record array).
37
+ if (Array.isArray(value)) value = value.length === 1 ? value[0] : null;
38
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
39
+ }
40
+ /**
41
+ * The newer of {want, defaultTag} by version — EXCEPT a non-default tag whose version was published BEFORE the
42
+ * default tag's version. That is an abandoned pre-release line, not a newer build: measured 2026-10-23, agentic-flow
43
+ * `alpha` = 3.0.0-alpha.2 (published 2026-03-25) against `latest` = 2.1.4 (2026-10-09); by version alone the alpha
44
+ * channel would install seven-month-old code. `published` is `npm view <pkg> time`; without it, ordering is unchanged.
45
+ */
46
+ export function pickTargetTag(tags, want = 'latest', defaultTag = 'latest', { published = null } = {}) {
47
+ const at = (version) => Date.parse(published?.[version] ?? '');
48
+ // `published` null: the caller did not ask, ordering is by version. An object: times were REQUIRED, so a missing or
49
+ // unreadable time makes the non-default tag unverifiable and it is skipped (cross-model review 2026-10-24: a failed
50
+ // lookup must not let a seven-month-old pre-release through).
51
+ const abandoned = (tag) => tag !== defaultTag && typeof tags?.[defaultTag] === 'string' && published !== null
52
+ && (!Number.isFinite(at(tags[tag])) || !Number.isFinite(at(tags[defaultTag])) || at(tags[tag]) < at(tags[defaultTag]));
53
+ const candidates = [...new Set([want, defaultTag])].filter(tag => typeof tags?.[tag] === 'string' && !abandoned(tag));
37
54
  candidates.sort((a, b) => cmpVersion(tags[b], tags[a]));
38
55
  return { tag: candidates[0] || null, target: tags?.[candidates[0]] || null };
39
56
  }
40
- export function selectTag(name, current, tags, channel = 'latest') {
57
+ /** Whether choosing between `want` and `latest` needs publish times: only when the two tags name different versions. */
58
+ export function needsPublishTimes(tags, want) {
59
+ return want !== 'latest' && typeof tags?.[want] === 'string' && typeof tags?.latest === 'string' && tags[want] !== tags.latest;
60
+ }
61
+ /** The ONE answer to "which tag does this package track": shared by the updater and the stack-sync notice. */
62
+ export function wantedTag(name, channel = 'latest') {
41
63
  if (!['latest', 'alpha'].includes(channel)) throw Error('unsupported update channel');
42
64
  // Kit's installed release contract uses next; never convert it to an npm alpha alias.
65
+ return name === '@pacphi/agentic-kit' ? 'next' : FAMILY.test(name) && channel === 'alpha' ? 'alpha' : 'latest';
66
+ }
67
+ export function selectTag(name, current, tags, channel = 'latest', { published = null } = {}) {
68
+ const wanted = wantedTag(name, channel);
43
69
  tags = normalizeNpmDistTags(tags);
44
- const wanted = name === '@pacphi/agentic-kit' ? 'next' : FAMILY.test(name) && channel === 'alpha' ? 'alpha' : 'latest';
45
- const { tag, target: version } = pickTargetTag(tags, wanted);
70
+ const times = published === null || published === undefined ? null : (normalizeNpmTimes(published) || {});
71
+ const { tag, target: version } = pickTargetTag(tags, wanted, 'latest', { published: times });
46
72
  if (!tag) throw Error(`no allowed release tag: ${name}`);
47
73
  return { tag, version, upgrade: cmpVersion(version, current) > 0, ahead: cmpVersion(version, current) < 0 };
48
74
  }
@@ -6,7 +6,7 @@ import os from 'node:os';
6
6
  import crypto from 'node:crypto';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { pathToFileURL } from 'node:url';
9
- import { cmpVersion as compare, selectTag, FAMILY, PLUGIN_MARKETPLACES, REVIEWED_INSTALL_SCRIPTS, EXECUTION_MODULES } from './developer-update-policy.mjs';
9
+ import { cmpVersion as compare, selectTag, needsPublishTimes, normalizeNpmDistTags, wantedTag, FAMILY, PLUGIN_MARKETPLACES, REVIEWED_INSTALL_SCRIPTS, EXECUTION_MODULES } from './developer-update-policy.mjs';
10
10
  import { acquireDeveloperLock, sharedLockStatus } from './developer-update-lock.mjs';
11
11
  import { maintenance } from './developer-update-maintenance.mjs';
12
12
  import { cleanupNpxDuplicates } from './developer-update-cleanup.mjs';
@@ -31,7 +31,7 @@ export function atomic(file, value) {
31
31
  function invoke(command, args, { cwd = HOME, timeout = 600_000, allowed = [0], env = process.env, capture = false } = {}) {
32
32
  const r = spawnSync(command, args, { cwd, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024,
33
33
  env: { ...env, PATH: [...new Set([path.dirname(NODE), path.join(env.npm_config_prefix || PREFIX, process.platform === 'win32' ? '' : 'bin'), ...(env.PATH || '').split(path.delimiter)])].join(path.delimiter),
34
- CI: '1', DISABLE_AUTOUPDATER: '1', GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'Never', GH_PROMPT_DISABLED: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1', HOMEBREW_NO_AUTO_UPDATE: '1', RUVNET_BRAIN_HOME: env.RUVNET_BRAIN_HOME || CACHE } });
34
+ CI: '1', DISABLE_AUTOUPDATER: '1', CLAUDE_FLOW_AUTO_UPDATE: 'false', GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'Never', GH_PROMPT_DISABLED: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1', HOMEBREW_NO_AUTO_UPDATE: '1', RUVNET_BRAIN_HOME: env.RUVNET_BRAIN_HOME || CACHE } });
35
35
  if (!capture && (r.error || !allowed.includes(r.status))) throw Error(`${path.basename(command)} ${args[0]}: ${r.error?.message || `exit ${r.status}`}: ${(r.stderr || r.stdout || '').trim().slice(-600)}`);
36
36
  return { stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim(), exitCode: r.status, error: r.error?.message || null };
37
37
  }
@@ -120,7 +120,7 @@ function verifyPackage(before, expected, run) {
120
120
  }
121
121
  return after;
122
122
  }
123
- export function upgradePackage(before, tags, scripts = REVIEWED_INSTALL_SCRIPTS, { run = invokeText, dryRun = false, channel = 'latest', npm = NPM, preservePackages = [] } = {}) {
123
+ export function upgradePackage(before, tags, scripts = REVIEWED_INSTALL_SCRIPTS, { run = invokeText, dryRun = false, channel = 'latest', npm = NPM, preservePackages = [], published = null } = {}) {
124
124
  // Snapshot identity must still exist and own the original paths before any installer runs.
125
125
  if (before.localSource && before.rawLink !== undefined) {
126
126
  if (!fs.lstatSync(before.location).isSymbolicLink() || fs.readlinkSync(before.location) !== before.rawLink) throw Error(`source link changed before preservation: ${before.name}`);
@@ -133,7 +133,7 @@ export function upgradePackage(before, tags, scripts = REVIEWED_INSTALL_SCRIPTS,
133
133
  if (live.localSource) return { name: before.name, state: 'local-source-preserved', before, after: live };
134
134
  if (live.launchers.some(b => !b.real)) throw Error(`absent launcher: ${before.name}`);
135
135
  if (live.launchers.some(b => !b.owned)) return { name: before.name, state: 'shadowed-preserved', before, after: live };
136
- const target = selectTag(before.name, before.version, tags, channel);
136
+ const target = selectTag(before.name, before.version, tags, channel, { published });
137
137
  if (preservePackages.includes(before.name)) return { name: before.name, state: 'local-modification-preserved', reason: 'preservedLocalModification', target, before, after: live };
138
138
  if (!target.upgrade || dryRun) return { name: before.name, state: target.upgrade ? 'update-available' : target.ahead ? 'ahead-preserved' : 'current', target, before, after: live };
139
139
  const spec = `${before.name}@${target.tag}`;
@@ -422,7 +422,19 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
422
422
  persistReceipt();
423
423
  for (const before of receipt.before) {
424
424
  const tags = before.localSource ? {} : JSON.parse(run(npm, ['view', before.name, 'dist-tags', '--json'], { timeout: 90_000 }));
425
- receipt.steps.push(upgradePackage(before, tags, REVIEWED_INSTALL_SCRIPTS, { run, dryRun: mode === 'check', channel: config.channel, npm, preservePackages: config.preservePackages }));
425
+ // A tracked non-latest tag (alpha, Kit's next) needs publish times to tell a newer pre-release from an abandoned
426
+ // one (developer-update-policy pickTargetTag); fetched only when that tag and latest name different versions. A
427
+ // failed lookup is {} — required but unknown — so the pre-release is skipped, never installed blind.
428
+ let published = null;
429
+ if (!before.localSource) {
430
+ let needed = false;
431
+ try { needed = needsPublishTimes(normalizeNpmDistTags(tags), wantedTag(before.name, config.channel)); } catch { needed = false; }
432
+ if (needed) {
433
+ try { published = JSON.parse(run(npm, ['view', before.name, 'time', '--json'], { timeout: 90_000 })); }
434
+ catch { published = {}; }
435
+ }
436
+ }
437
+ receipt.steps.push(upgradePackage(before, tags, REVIEWED_INSTALL_SCRIPTS, { run, dryRun: mode === 'check', channel: config.channel, npm, preservePackages: config.preservePackages, published }));
426
438
  persistReceipt();
427
439
  }
428
440
  receipt.plugins = await synchronizePlugins(run, mode === 'check', receipt.notes, { home, prefix, scope: config.scope, locate, unattended: !!env.RUVNET_NIGHTLY_IDENTITY });
@@ -48,11 +48,14 @@ export const GROK_OUTPUT_EVENTS = Object.freeze(new Set(['PreToolUse', 'PostTool
48
48
 
49
49
  /**
50
50
  * This adapter's own kill budget, below the registration timeout in grok-hooks.json so the adapter,
51
- * not Grok, ends a slow body and records it. SessionEnd: Grok bounds each SessionEnd hook by its own
52
- * timeout (default 1.5 s, 10-hooks.md:477-478); 2200 ms sits under the 3 s registration.
51
+ * not Grok, ends a slow body and records it. SessionEnd: "A plugin's SessionEnd hook runs for at most
52
+ * that default [1.5 s], whatever its timeout" (10-hooks.md:644-645), so the budget sits under 1500 ms and
53
+ * is handed to the body (RUVNET_CODEX_BUDGET_MS, the host-budget seam the Codex wrapper already uses), which
54
+ * then detaches its checkpoint instead of starting an inline store+readback Grok would kill partway.
53
55
  */
56
+ export const SESSION_END_BUDGET_MS = 1_400;
54
57
  export function budgetFor(hookId, event) {
55
- if (event === 'SessionEnd') return 2_200;
58
+ if (event === 'SessionEnd') return SESSION_END_BUDGET_MS;
56
59
  if (hookId === 'decision-gate') return 6_500;
57
60
  if (hookId === 'grounding-stamp' || hookId === 'grounding-turn-mark') return 4_000;
58
61
  return 8_500;
@@ -133,6 +136,7 @@ async function main() {
133
136
  CLAUDE_PROJECT_DIR: projectDir,
134
137
  CLAUDE_SESSION_ID: String(payload.session_id || process.env.GROK_SESSION_ID || ''),
135
138
  RUVNET_HOOK_HOST: 'grok',
139
+ ...(event === 'SessionEnd' ? { RUVNET_CODEX_BUDGET_MS: String(budget) } : {}), // the body plans inside Grok's 1.5 s
136
140
  };
137
141
  const r = spawnSync(process.execPath, [path.join(HERE, 'hook-shim.mjs'), hookId, ...extra], {
138
142
  input: JSON.stringify(payload), encoding: 'utf8', env, cwd, timeout: budget, killSignal: 'SIGKILL',
@@ -9,16 +9,21 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import os from 'node:os';
12
+ import { createHash } from 'node:crypto';
12
13
 
13
14
  export const FAILURE_FILE = 'hook-failures.jsonl';
14
15
  export const FAILURE_LINE_PREFIX = '[RuvNet Brain — HOOK FAILURES]';
15
16
  const CURSOR_FILE = 'hook-failures.cursor';
17
+ const WINDOW_FILE = 'hook-failures.window.json';
16
18
  const MAX_READ = 256 * 1024;
19
+ export const REPEAT_WINDOW_MS = 10 * 60_000;
20
+ const MAX_WINDOWS = 64;
17
21
 
18
22
  /** Summarize failures recorded after the cursor; advance it. Returns '' when there is nothing new. */
19
- export function failureLine(brainHome, { advance = true } = {}) {
23
+ export function failureLine(brainHome, { advance = true, now = Date.now() } = {}) {
20
24
  const file = path.join(brainHome, FAILURE_FILE);
21
25
  const cursorFile = path.join(brainHome, CURSOR_FILE);
26
+ try { flushRepeats(brainHome, now, { all: true }); } catch { /* the rows already on disk are still reported */ } // the report is now
22
27
  let size;
23
28
  try { size = fs.statSync(file).size; } catch { return ''; }
24
29
  let offset = 0;
@@ -33,29 +38,92 @@ export function failureLine(brainHome, { advance = true } = {}) {
33
38
  if (advance) { try { fs.writeFileSync(cursorFile, JSON.stringify({ offset: size })); } catch { /* re-reported next session */ } }
34
39
  if (!rows.length) return '';
35
40
  const byHook = new Map();
41
+ let total = 0;
36
42
  for (const r of rows) {
37
43
  const id = String(r.hook || 'unknown').slice(0, 40);
38
- const why = r.note ? String(r.note).split(':')[0].slice(0, 48) : r.error ? String(r.error).slice(0, 40) : r.signal ? String(r.signal) : `exit ${r.status}`;
44
+ const why = r.note ? String(r.note).split(':')[0].slice(0, 48) : r.error ? String(r.error).replace(/ \(repeated\)$/, '').slice(0, 40) : r.signal ? String(r.signal) : `exit ${r.status}`;
39
45
  const e = byHook.get(id) || { n: 0, why: new Set() };
40
- e.n += 1; e.why.add(why); byHook.set(id, e);
46
+ const n = Number(r.count) > 0 ? Number(r.count) : 1; // an aggregated row stands for `count` runs
47
+ e.n += n; total += n; e.why.add(why); byHook.set(id, e);
41
48
  }
42
49
  const parts = [...byHook.entries()].sort((a, b) => b[1].n - a[1].n)
43
50
  .map(([id, e]) => `${id} ×${e.n} (${[...e.why].slice(0, 3).join(', ')})`);
44
- return `${FAILURE_LINE_PREFIX} ${rows.length} advisory hook run(s) did not complete or did not resolve since ${rows[0].at}: ${parts.join('; ')}. `
51
+ return `${FAILURE_LINE_PREFIX} ${total} advisory hook run(s) did not complete or did not resolve since ${rows[0].at}: ${parts.join('; ')}. `
45
52
  + `They failed open. Details: ${file}`;
46
53
  }
47
54
 
55
+ function appendRow(brainHome, row) {
56
+ const file = path.join(brainHome, FAILURE_FILE);
57
+ fs.mkdirSync(brainHome, { recursive: true });
58
+ try { if (fs.statSync(file).size > 256 * 1024) fs.renameSync(file, `${file}.1`); } catch { /* absent */ }
59
+ fs.appendFileSync(file, `${JSON.stringify(row)}\n`, { mode: 0o600 });
60
+ }
61
+
62
+ /** Hashes and long numbers vary per row (`workflow-<sha>`, pids, ms, timestamps); short ones (exit codes, counts) are the error. */
63
+ export const normalizeFailure = (text) => String(text || '').replace(/[0-9a-f]{16,}/gi, '*').replace(/\d{5,}/g, 'N').slice(0, 160);
64
+ const windowFile = (brainHome) => path.join(brainHome, WINDOW_FILE);
65
+ /**
66
+ * A window entry must be one this module could have written: a real epoch `at` no later than one window ahead (a
67
+ * Date cannot hold -1e300 or 1e300, and 8.64e15+1 never expires), an integer count ≥ 1, string hook and error.
68
+ * Entries failing that are dropped, the rest kept; any other file shape is an empty window file.
69
+ */
70
+ const validWindow = (w, now) => w && typeof w === 'object' && !Array.isArray(w) && Number.isFinite(w.at) && w.at >= 0 && w.at <= now + REPEAT_WINDOW_MS
71
+ && Number.isInteger(w.count) && w.count >= 1 && typeof w.hook === 'string' && typeof w.error === 'string';
72
+ function loadWindows(brainHome, now) {
73
+ try {
74
+ const parsed = JSON.parse(fs.readFileSync(windowFile(brainHome), 'utf8'));
75
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
76
+ return Object.fromEntries(Object.entries(parsed).filter(([, w]) => validWindow(w, now)));
77
+ } catch { return {}; }
78
+ }
79
+ function saveWindows(brainHome, windows) { // rename, so a concurrent reader never sees a torn file
80
+ const tmp = `${windowFile(brainHome)}.${process.pid}`;
81
+ fs.writeFileSync(tmp, JSON.stringify(windows), { mode: 0o600 }); fs.renameSync(tmp, windowFile(brainHome));
82
+ }
83
+
84
+ /** A closed window whose repeats were held back becomes ONE row carrying their count. */
85
+ function closeWindow(brainHome, w, now) {
86
+ if (w.count > 1) appendRow(brainHome, { at: new Date(now).toISOString(), hook: w.hook, event: w.event, error: `${w.error} (repeated)`, count: w.count - 1, since: new Date(w.at).toISOString() });
87
+ }
88
+
89
+ /** Close the expired windows; `all` closes the open ones too (SessionStart reports now). Returns the windows left open. */
90
+ function flushRepeats(brainHome, now, { all = false } = {}) {
91
+ const windows = loadWindows(brainHome, now);
92
+ let changed = false;
93
+ for (const [sig, w] of Object.entries(windows)) {
94
+ if (!all && now - w.at < REPEAT_WINDOW_MS) continue;
95
+ try { closeWindow(brainHome, w, now); } catch { /* one bad window must not stop the others being reported */ }
96
+ delete windows[sig]; changed = true;
97
+ }
98
+ if (changed) try { saveWindows(brainHome, windows); } catch { /* the rows are written; the window file is a cache */ }
99
+ return windows;
100
+ }
101
+
48
102
  /**
49
103
  * The writer for bodies that run OUTSIDE the shim's watchdog (a refused `ruflo memory store`, a failed
50
104
  * exact readback). Same file, same row shape as hook-shim.mjs's inlined recordFailure, so the next
51
105
  * SessionStart's failureLine() reports it. Never throws: an unrecordable failure must not become a second one.
106
+ *
107
+ * REPEATS ARE COUNTED, NOT APPENDED (measured 2026-10-21: 1,162 rows, 826 of them one learn-capture failure
108
+ * repeated, so the real failures were invisible). The first occurrence of (hook, event, normalized error)
109
+ * is a row at once; repeats inside REPEAT_WINDOW_MS only increment a count in hook-failures.window.json,
110
+ * and the window closes as ONE row carrying `count`. Distinct errors keep distinct rows. The window file has no
111
+ * lock: two writers racing can lose an increment (under-count), and two processes closing the same expired window
112
+ * can both write its count row (over-count). Tolerated: the ledger is a signal, never an audit. The first row is
113
+ * appended BEFORE its window is saved, so a failed append never leaves a window claiming a row that is not there.
52
114
  */
53
- export function recordHookFailure(fields, { brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain') } = {}) {
115
+ export function recordHookFailure(fields, { brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), now = Date.now() } = {}) {
54
116
  try {
55
- const file = path.join(brainHome, FAILURE_FILE);
56
117
  fs.mkdirSync(brainHome, { recursive: true });
57
- try { if (fs.statSync(file).size > 256 * 1024) fs.renameSync(file, `${file}.1`); } catch { /* absent */ }
58
- fs.appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), ...fields })}\n`, { mode: 0o600 });
118
+ const windows = flushRepeats(brainHome, now);
119
+ const error = normalizeFailure(fields.error ?? fields.note ?? (fields.signal || `exit ${fields.status}`));
120
+ const sig = createHash('sha256').update(`${fields.hook}\0${fields.event || ''}\0${error}`).digest('hex').slice(0, 16);
121
+ if (windows[sig]) { windows[sig].count += 1; saveWindows(brainHome, windows); return true; }
122
+ appendRow(brainHome, { at: new Date(now).toISOString(), ...fields });
123
+ windows[sig] = { at: now, count: 1, hook: fields.hook, event: fields.event || '', error };
124
+ const oldest = Object.keys(windows).sort((a, b) => windows[a].at - windows[b].at).slice(0, Math.max(0, Object.keys(windows).length - MAX_WINDOWS));
125
+ for (const old of oldest) { closeWindow(brainHome, windows[old], now); delete windows[old]; } // bounded: the oldest close early
126
+ try { saveWindows(brainHome, windows); } catch { /* the row is on disk; the next repeat starts a fresh window */ }
59
127
  return true;
60
128
  } catch { return false; }
61
129
  }
@@ -1,14 +1,66 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
+ import path from 'node:path';
4
5
  import { spawn } from 'node:child_process';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { readStdinBounded } from './hook-input.mjs';
7
8
  import { learningContext } from './runtime-preferences.mjs';
8
- import { recordLearningObservation, distillObservations } from './learning-store.mjs';
9
+ import { recordLearningObservation, distillObservations, observationKey } from './learning-store.mjs';
9
10
  import { resolveRuflo } from './ruflo-bin.mjs';
10
11
  import { recordHookFailure } from './hook-failures.mjs';
11
12
 
13
+ // LOAD BOUNDS (measured 2026-10-21: one busy session spawned hundreds of concurrent detached `ruflo memory store`
14
+ // children, which timed out against each other and wrote 826 ledger rows in a day). Owner ruling 2026-10-12 stands:
15
+ // no local queue, no replay — an observation that finds no slot is DROPPED and counted.
16
+ export const OBSERVE_SLOTS = 2; // detached --observe children alive at once, per Brain home
17
+ export const SLOT_STALE_MS = 60_000; // a slot older than this, or held by a dead pid, is reclaimed
18
+ export const COALESCE_MS = 10 * 60_000; // an identical observation key inside this window does not spawn again
19
+ const defaultBrainHome = (env) => env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
20
+ const learnDir = (brainHome, sub) => { const dir = path.join(brainHome, 'learn-capture', sub); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); return dir; };
21
+
22
+ /** Parent-side coalescing: marker file per key under `learn-capture/recent/`, pruned when it grows past 256 entries. */
23
+ export function recentlyCaptured(brainHome, key, now = Date.now()) {
24
+ try { return now - fs.statSync(path.join(learnDir(brainHome, 'recent'), key)).mtimeMs < COALESCE_MS; } catch { return false; }
25
+ }
26
+ export function markCaptured(brainHome, key, now = Date.now()) {
27
+ const dir = learnDir(brainHome, 'recent');
28
+ try { fs.writeFileSync(path.join(dir, key), '', { mode: 0o600 }); } catch { return; }
29
+ const names = fs.readdirSync(dir);
30
+ if (names.length > 256) for (const name of names) { try { if (now - fs.statSync(path.join(dir, name)).mtimeMs >= COALESCE_MS) fs.unlinkSync(path.join(dir, name)); } catch { /* raced */ } }
31
+ }
32
+
33
+ function slotStale(slot, now) {
34
+ let held;
35
+ try { held = JSON.parse(fs.readFileSync(slot, 'utf8')); } catch { try { return now - fs.statSync(slot).mtimeMs > SLOT_STALE_MS; } catch { return false; } }
36
+ if (!Number.isInteger(held?.pid) || now - Number(held.at) > SLOT_STALE_MS) return true;
37
+ try { process.kill(held.pid, 0); return false; } catch (error) { return error.code === 'ESRCH'; }
38
+ }
39
+ /** O_EXCL slot files `learn-capture/slots/slot-N`; a stale one is reclaimed by rename, so only one claimant wins it. */
40
+ export function acquireObserveSlot(brainHome, now = Date.now()) {
41
+ const dir = learnDir(brainHome, 'slots');
42
+ for (let i = 0; i < OBSERVE_SLOTS; i += 1) {
43
+ const slot = path.join(dir, `slot-${i}`);
44
+ for (let attempt = 0; attempt < 2; attempt += 1) {
45
+ try { fs.writeFileSync(slot, JSON.stringify({ pid: process.pid, at: now }), { flag: 'wx', mode: 0o600 }); return slot; } catch (error) { if (error.code !== 'EEXIST') break; }
46
+ if (!slotStale(slot, now)) break;
47
+ const claim = `${slot}.reclaim-${process.pid}`;
48
+ try { fs.renameSync(slot, claim); fs.unlinkSync(claim); } catch { break; } // another claimant took it
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ export function releaseObserveSlot(slot, pid = process.pid) {
54
+ try { if (JSON.parse(fs.readFileSync(slot, 'utf8')).pid === pid) fs.unlinkSync(slot); } catch { /* reclaimed already */ }
55
+ }
56
+
57
+ /** Every shed is one fixed-string ledger call; hook-failures.mjs's own window counts the repeats on one row. */
58
+ export const SHED_ERROR = 'shed observation (load)';
59
+ export function shedObservation(brainHome) {
60
+ recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: SHED_ERROR }, { brainHome });
61
+ return false;
62
+ }
63
+
12
64
  // Fixed vocabulary: arbitrary executable names, arguments, basenames and paths never persist.
13
65
  const verbs = new Set('git npm node python python3 cargo rustc go gh cd ls rg cat export mkdir rm cp mv bash sh curl wget npx ruflo'.split(' '));
14
66
  const subs = new Set('status diff log show add commit push pull fetch checkout switch branch test run install ci build check fmt clippy auth workflow release memory hooks'.split(' '));
@@ -30,7 +82,7 @@ export function safeAction(tool, value) {
30
82
  * session-snapshot's PostToolUse boundary. learningContext() owns consent and brain-off. A refused or
31
83
  * unverified write is LOUD: one row in the hook-failures ledger, summarized at the next SessionStart.
32
84
  */
33
- export function captureLearning(input, { env = process.env, binary = resolveRuflo({ env }), brainHome, detach = false } = {}) {
85
+ export function captureLearning(input, { env = process.env, binary = resolveRuflo({ env }), brainHome = defaultBrainHome(env), detach = false } = {}) {
34
86
  let tool; let action;
35
87
  try {
36
88
  const context = learningContext();
@@ -48,24 +100,37 @@ export function captureLearning(input, { env = process.env, binary = resolveRufl
48
100
  if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(tool)) return false;
49
101
  action = safeAction(tool, tool === 'Bash' ? input.tool_input?.command : 'edit file');
50
102
  if (!action) return false;
51
- if (!binary) throw new Error('ruflo not found');
103
+ if (!binary) throw new Error('ruflo not found'); // one ledger row per 10 min: recordHookFailure counts the repeats
52
104
  const observation = { tool, action, session: String(input.session_id || '') };
105
+ const key = observationKey(observation);
106
+ if (recentlyCaptured(brainHome, key)) return true; // identical observation already captured this window: no process
53
107
  // Review M3: on the hook path the store + exact readback run in a detached, bounded child, so the
54
108
  // tool call returns at once; that child writes the ledger row if the write is refused or unverified.
55
- if (detach) return detachLearning('--observe', { env, brainHome, observation });
109
+ if (detach) {
110
+ const slot = acquireObserveSlot(brainHome);
111
+ if (!slot) return shedObservation(brainHome);
112
+ markCaptured(brainHome, key);
113
+ return detachLearning('--observe', { env: { ...env, RUFLO_BIN: binary }, brainHome, observation, slot });
114
+ }
56
115
  recordLearningObservation(binary, context, observation, { env, deadline: Date.now() + 6000 });
116
+ markCaptured(brainHome, key);
57
117
  return true;
58
118
  } catch (error) {
59
- if (tool && action) recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: String(error?.message || error).slice(0, 160) }, brainHome ? { brainHome } : undefined);
119
+ if (tool && action) recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: String(error?.message || error).slice(0, 160) }, { brainHome });
60
120
  return false;
61
121
  }
62
122
  }
63
123
 
64
- /** Detached, bounded child (`--observe` one observation, `--distill` the namespace). Only fixed-vocabulary fields cross. */
65
- export function detachLearning(mode, { env = process.env, brainHome, observation } = {}) {
124
+ /**
125
+ * Detached, bounded child (`--observe` one observation, `--distill` the namespace). Only fixed-vocabulary fields
126
+ * cross. The parent's resolved ruflo travels as RUFLO_BIN (resolveRuflo's authoritative seam), so the child never
127
+ * re-resolves it; the slot it holds travels as RUVNET_LEARN_SLOT and is re-stamped with the child's pid.
128
+ */
129
+ export function detachLearning(mode, { env = process.env, brainHome, observation, slot } = {}) {
66
130
  const child = spawn(process.execPath, [fileURLToPath(import.meta.url), mode], { cwd: process.cwd(), detached: true, stdio: 'ignore', windowsHide: true,
67
- env: { ...env, ...(observation ? { RUVNET_LEARN_OBSERVATION: JSON.stringify(observation) } : {}), ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}) } });
131
+ env: { ...env, ...(observation ? { RUVNET_LEARN_OBSERVATION: JSON.stringify(observation) } : {}), ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}), ...(slot ? { RUVNET_LEARN_SLOT: slot } : {}) } });
68
132
  child.unref();
133
+ if (slot) { try { fs.writeFileSync(slot, JSON.stringify({ pid: child.pid, at: Date.now() }), { mode: 0o600 }); } catch { /* reclaimed by age */ } }
69
134
  return true;
70
135
  }
71
136
 
@@ -80,7 +145,7 @@ function runDetached(mode) {
80
145
  const o = JSON.parse(process.env.RUVNET_LEARN_OBSERVATION || '{}');
81
146
  if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(o.tool) || typeof o.action !== 'string') throw new Error('invalid detached observation');
82
147
  recordLearningObservation(binary, context, { tool: o.tool, action: o.action, session: String(o.session || '') }, { env: process.env, deadline: Date.now() + 14_000, allowed: () => learningContext().enabled });
83
- } catch (error) { fail(error); }
148
+ } catch (error) { fail(error); } finally { if (process.env.RUVNET_LEARN_SLOT) releaseObserveSlot(process.env.RUVNET_LEARN_SLOT); }
84
149
  }
85
150
 
86
151
  const isMain = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
@@ -32,7 +32,7 @@ export function runLearningRuflo(binary, args, db, { env = process.env, deadline
32
32
  const cwd = rufloRunDir(db, { root: env.RUVNET_RUFLO_CWD_ROOT || path.join(env.HOME || env.USERPROFILE || os.homedir(), '.cache', 'ruvnet-brain', 'learning-ruflo') });
33
33
  try {
34
34
  const invocation = rufloInvocation(binary, args);
35
- return spawnSync(invocation.executable, invocation.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' },
35
+ return spawnSync(invocation.executable, invocation.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' },
36
36
  encoding: 'utf8', shell: false, timeout: Math.min(maxStepMs, remaining), killSignal: 'SIGKILL', maxBuffer: 65536, windowsHide: true });
37
37
  } finally { fs.rmSync(cwd, { force: true, recursive: true }); }
38
38
  }
@@ -42,22 +42,32 @@ export function runLearningRuflo(binary, args, db, { env = process.env, deadline
42
42
  * No local queue, no worker, no replay (owner ruling 2026-10-12): a failure throws to the caller, which
43
43
  * records it in the hook-failures ledger so SessionStart reports it.
44
44
  */
45
+ // DETERMINISTIC per (session, tool, action) (review N1): with --no-upsert, rUv's store itself dedupes, so a
46
+ // session holds at most one row per distinct pair and concurrent writers of the same pair collapse.
47
+ export const observationKey = ({ session = '', tool, action }) => 'workflow-' + createHash('sha256').update(JSON.stringify([String(session), tool, action])).digest('hex');
48
+
45
49
  export function recordLearningObservation(binary, context, { tool, action, session = '' }, options = {}) {
46
50
  const db = learningTarget(context, options);
47
- // DETERMINISTIC per (session, tool, action) (review N1): with --no-upsert, rUv's store itself dedupes, so a
48
- // session holds at most one row per distinct pair and concurrent writers of the same pair collapse.
49
- const key = 'workflow-' + createHash('sha256').update(JSON.stringify([session, tool, action])).digest('hex');
51
+ const key = observationKey({ session, tool, action });
50
52
  const value = JSON.stringify({ schemaVersion: 1, tool, action, scope: context.scope,
51
53
  authoritative: false, provenance: 'system-observation', outcome: 'host-reported-success' });
52
54
  const run = args => { if (options.allowed && !options.allowed()) throw new Error('learning consent changed'); return runLearningRuflo(binary, args, db, options); };
53
55
  const stored = run(['memory', 'store', '--key', key, '--value', value, '--namespace', LEARNING_NAMESPACE,
54
56
  '--no-upsert', '--provenance', 'system_observation', '--path', db]);
55
57
  const retrieved = run(['memory', 'retrieve', '--key', key, '--namespace', LEARNING_NAMESPACE, '--value-only', '--path', db]);
56
- const exact = !retrieved.error && retrieved.status === 0 && String(retrieved.stdout || '').trim() === value;
57
- // A refused store whose key already holds these exact bytes is the dedupe working, not a failure.
58
+ const readBack = !retrieved.error && retrieved.status === 0 ? String(retrieved.stdout || '').trim() : null;
59
+ const exact = readBack === value;
60
+ // A refused store whose key already holds these exact bytes is the dedupe working, not a failure (idempotent).
61
+ // Measured 2026-10-21: 141 ledger rows said "already exists" because the readback itself had timed out, so the
62
+ // row blamed the store; the reason names which step failed, and a key holding DIFFERENT bytes stays a failure.
58
63
  if (stored.error || stored.status !== 0) {
59
64
  if (exact) return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true, alreadyStored: true };
60
- throw new Error(`ruflo memory store refused the observation: ${captureFailureReason(stored, stored.status)}`);
65
+ const reason = captureFailureReason(stored, stored.status);
66
+ if (/already exists/i.test(reason)) {
67
+ if (readBack !== null) throw new Error('duplicate key holds a different observation');
68
+ throw new Error(`exact readback of the existing key failed: ${captureFailureReason(retrieved, retrieved.status)}`);
69
+ }
70
+ throw new Error(`ruflo memory store refused the observation: ${reason}`);
61
71
  }
62
72
  if (!exact) throw new Error('canonical exact CLI readback failed');
63
73
  return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true };
@@ -147,17 +147,23 @@ const BRIDGED_UPDATE_MODULES = [...HISTORICAL_UPDATE_MODULES, 'developer-update-
147
147
  const LEGACY_UPDATE_MODULES = [...BRIDGED_UPDATE_MODULES, 'developer-update-policy.mjs'];
148
148
  const PREVIOUS_DEVELOPER_MODULES = [...BRIDGED_UPDATE_MODULES, ...EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs')];
149
149
  const DEVELOPER_UPDATE_MODULES = [...new Set([...LEGACY_UPDATE_MODULES, ...EXECUTION_MODULES])];
150
- function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.meta.url)), developerSuite = true) {
150
+ /** The shipped module set and the content digest it is registered under (`update-modules-<digest>`); reads only. */
151
+ function readModuleSet(sourceDir, developerSuite) {
151
152
  const files = developerSuite ? DEVELOPER_UPDATE_MODULES : LEGACY_UPDATE_MODULES;
152
153
  const bytes = Object.fromEntries(files.map(file => [file, fs.readFileSync(path.join(sourceDir, file))]));
153
- const digest = sha256(Buffer.concat(files.flatMap(file => [Buffer.from(file), bytes[file]])));
154
+ return { files, bytes, digest: sha256(Buffer.concat(files.flatMap(file => [Buffer.from(file), bytes[file]]))) };
155
+ }
156
+ const registeredModuleDigest = (record) => /^update-modules-([a-f0-9]{64})$/.exec(path.basename(path.dirname(record.updateModules?.['automatic-update.mjs']?.path || '')))?.[1] || null;
157
+
158
+ function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.meta.url)), developerSuite = true, { write = true } = {}) {
159
+ const { files, bytes, digest } = readModuleSet(sourceDir, developerSuite);
154
160
  const target = path.join(directory, `update-modules-${digest}`);
155
- fs.mkdirSync(target, { recursive: true });
161
+ if (write) fs.mkdirSync(target, { recursive: true });
156
162
  return Object.fromEntries(files.map(file => {
157
163
  const dest = path.join(target, file);
158
164
  const hash = sha256(bytes[file]);
159
- if (fs.existsSync(dest) && sha256(fs.readFileSync(dest)) !== hash) throw new Error('immutable update module was changed');
160
- if (!fs.existsSync(dest)) fs.writeFileSync(dest, bytes[file], { mode: 0o600 });
165
+ if (write && fs.existsSync(dest) && sha256(fs.readFileSync(dest)) !== hash) throw new Error('immutable update module was changed');
166
+ if (write && !fs.existsSync(dest)) fs.writeFileSync(dest, bytes[file], { mode: 0o600 });
161
167
  return [file, { path: dest, sha256: hash }];
162
168
  }));
163
169
  }
@@ -174,31 +180,80 @@ function verifyUpdateModules(modules) {
174
180
  }
175
181
  }
176
182
 
183
+ /**
184
+ * The ONE writer of a nightly registration. `dryRun` builds the exact record it WOULD write (runner, modules, record)
185
+ * and writes nothing — the refresh path compares that candidate before any rename. `environment`: undefined derives
186
+ * the allowlisted values from env; an object is kept verbatim; null omits the field (an older registration without it).
187
+ */
177
188
  export function installNightlyRunner({ brainHome, source, nodePath = process.execPath,
178
- identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity) } = {}) {
189
+ identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, environment, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity), dryRun = false } = {}) {
179
190
  validateIdentity(identity);
180
191
  if (!source || !fs.existsSync(source)) throw new Error(`nightly runner source is missing: ${source || '(unset)'}`);
181
192
  const bytes = fs.readFileSync(source);
182
193
  const digest = sha256(bytes);
183
194
  const dir = path.join(brainHome, 'scheduler');
184
195
  const runnerPath = path.join(dir, `nightly-refresh-${digest}.mjs`);
185
- fs.mkdirSync(dir, { recursive: true });
186
- if (fs.existsSync(runnerPath) && sha256(fs.readFileSync(runnerPath)) !== digest) {
187
- throw new Error(`nightly runner at ${runnerPath} does not match its content-addressed identity`);
196
+ if (!dryRun) {
197
+ fs.mkdirSync(dir, { recursive: true });
198
+ if (fs.existsSync(runnerPath) && sha256(fs.readFileSync(runnerPath)) !== digest) {
199
+ throw new Error(`nightly runner at ${runnerPath} does not match its content-addressed identity`);
200
+ }
201
+ if (!fs.existsSync(runnerPath)) fs.writeFileSync(runnerPath, bytes, { mode: 0o755 });
188
202
  }
189
- if (!fs.existsSync(runnerPath)) fs.writeFileSync(runnerPath, bytes, { mode: 0o755 });
190
- const modules = updateModules(dir, moduleSource, developerSuite);
203
+ const modules = updateModules(dir, moduleSource, developerSuite, { write: !dryRun });
191
204
  const record = { updateModules: modules, ...(developerSuite ? { mode: 'developer-suite' } : {}), schemaVersion: 2, kind: 'ruvnet-brain-nightly-scheduler', identity,
192
205
  nodePath: path.resolve(nodePath), runnerPath, runnerSha256: digest, argv: [],
193
- environment: Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])),
206
+ ...(environment === null ? {} : { environment: environment ?? Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])) }),
194
207
  packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity) };
195
208
  const recordPath = path.join(dir, registrationName(identity));
209
+ if (dryRun) return { ...record, recordPath };
196
210
  const tmp = `${recordPath}.tmp-${process.pid}`;
197
211
  fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`);
198
212
  fs.renameSync(tmp, recordPath);
199
213
  return { ...record, recordPath };
200
214
  }
201
215
 
216
+ export const NIGHTLY_ENABLE_FIX = 'npx ruvnet-brain@latest --enable-nightly';
217
+ export const NIGHTLY_UPDATE_FIX = 'npx ruvnet-brain@latest --update';
218
+ /**
219
+ * Does the registered nightly run the code installed now? The scheduler executes a content-addressed snapshot of
220
+ * the update modules that only --enable-nightly used to refresh (measured 2026-10-22: the 4.7.15 developer-update
221
+ * fix shipped to disk and the 03:30 job still ran the old module). Pure read: `current`, `stale` (modules only;
222
+ * --update refreshes them), `runner-changed` (the LaunchAgent command would change: explicit enablement only),
223
+ * or `unregistered`.
224
+ */
225
+ export function nightlyModuleCurrency({ brainHome, identity = NIGHTLY_LABEL, moduleSource = path.dirname(fileURLToPath(import.meta.url)), runnerSource } = {}) {
226
+ const registration = readNightlyRegistration({ brainHome, identity });
227
+ if (!registration.ok) return { state: 'unregistered', why: registration.why };
228
+ const record = registration.record;
229
+ const registered = registeredModuleDigest(record);
230
+ if (!registered) return { state: 'unregistered', why: 'registration carries no module snapshot', fix: NIGHTLY_ENABLE_FIX };
231
+ const current = readModuleSet(moduleSource, record.mode === 'developer-suite').digest;
232
+ if (runnerSource && sha256(fs.readFileSync(runnerSource)) !== record.runnerSha256) return { state: 'runner-changed', registered, current, record, fix: NIGHTLY_ENABLE_FIX };
233
+ return registered === current ? { state: 'current', registered, current, record } : { state: 'stale', registered, current, record, fix: NIGHTLY_UPDATE_FIX };
234
+ }
235
+
236
+ /**
237
+ * After a successful --update: re-point a stale registration at the installed module set. Same identity, node,
238
+ * runner bytes, environment and targets, so only registration.json and a new update-modules-<digest> dir change
239
+ * and the LaunchAgent command stays exactly what it was; anything else is refused and left as it was.
240
+ */
241
+ export const SCHEDULER_VISIBLE_FIELDS = Object.freeze(['identity', 'nodePath', 'runnerPath', 'runnerSha256', 'argv', 'environment', 'packageTarget', 'bundleTarget', 'mode']);
242
+ export function refreshNightlyModules(options = {}) {
243
+ const currency = nightlyModuleCurrency(options);
244
+ if (currency.state !== 'stale') return currency;
245
+ const { record } = currency;
246
+ const plan = { brainHome: options.brainHome, identity: record.identity, source: record.runnerPath, nodePath: record.nodePath, packageTarget: record.packageTarget,
247
+ bundleTarget: record.bundleTarget, environment: record.environment ?? null, moduleSource: options.moduleSource, developerSuite: record.mode === 'developer-suite' };
248
+ // Compare the candidate BEFORE anything is written (reviewer proof 2026-10-22: an older registration without
249
+ // `environment` was rewritten with {} and only then refused). `build` is the dry-run of the one writer.
250
+ const candidate = (options.build || ((p) => installNightlyRunner({ ...p, dryRun: true })))(plan);
251
+ const differs = SCHEDULER_VISIBLE_FIELDS.filter((key) => JSON.stringify(candidate[key] ?? null) !== JSON.stringify(record[key] ?? null));
252
+ if (differs.length) return { ...currency, state: 'refused', reason: `refresh would change ${differs.join(', ')}; registration left as it was`, fix: NIGHTLY_ENABLE_FIX };
253
+ const refreshed = installNightlyRunner(plan);
254
+ return { ...currency, state: 'refreshed', registered: registeredModuleDigest(refreshed), fix: undefined };
255
+ }
256
+
202
257
  export function readNightlyRegistration({ brainHome, identity = NIGHTLY_LABEL,
203
258
  recordPath = path.join(brainHome, 'scheduler', registrationName(identity)) }) {
204
259
  try {
@@ -245,7 +245,11 @@ export function rufloCwdFor(storePath, { root = rufloScratchRoot() } = {}) {
245
245
  return ensurePrivateDir(path.join(root, projectKey));
246
246
  }
247
247
 
248
- const RUFLO_CWD_ARTIFACTS = Object.freeze(['.swarm', '.claude', '.claude-flow', 'ruvector.db']);
248
+ // node_modules / package.json / package-lock.json: ruflo's startup self-update runs `npm install @claude-flow/cli@X
249
+ // --save-exact` in its cwd (ruflo v3 @claude-flow/cli dist/src/update/executor.js). Measured 2026-10-22: a 1.3 GB
250
+ // install landed here. Every Brain spawn now sets CLAUDE_FLOW_AUTO_UPDATE=false (update/rate-limiter.js reads it);
251
+ // these names are the backstop for a ruflo started without it.
252
+ export const RUFLO_CWD_ARTIFACTS = Object.freeze(['.swarm', '.claude', '.claude-flow', 'ruvector.db', 'node_modules', 'package.json', 'package-lock.json']);
249
253
  const STALE_RUN_MS = 3_600_000;
250
254
 
251
255
  /**
@@ -48,7 +48,7 @@ export function projectStore(projectDir, { deadlineAt = Date.now() + 3000 } = {}
48
48
  export function rufloSpawnOptions(db, env = process.env) {
49
49
  let cwd;
50
50
  try { cwd = rufloRunDir(db, { root: rufloScratchRoot(env) }); } catch { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-ruflo-')); }
51
- return { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' }, cleanup: () => { try { fs.rmSync(cwd, { recursive: true, force: true }); } catch { /* swept later as a stale run- dir */ } } };
51
+ return { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' }, cleanup: () => { try { fs.rmSync(cwd, { recursive: true, force: true }); } catch { /* swept later as a stale run- dir */ } } };
52
52
  }
53
53
 
54
54
  /** One `ruflo memory <args>` call, bounded by the caller's deadline. Resolves, never rejects. */