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.
- package/README.md +2 -2
- package/bin/install.mjs +120 -15
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/grok-hooks.json +1 -1
- package/plugin/hooks/hook-contracts.json +36 -18
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/agentdb-recall-process.mjs +1 -1
- package/plugin/scripts/agentdb-recall-reader.mjs +1 -1
- package/plugin/scripts/brain-confirmation.mjs +8 -1
- package/plugin/scripts/brain-footprint.mjs +3 -1
- package/plugin/scripts/continuity-hook-policy.mjs +9 -9
- package/plugin/scripts/decision-record.mjs +1 -1
- package/plugin/scripts/degradation-watch.mjs +13 -1
- package/plugin/scripts/developer-update-policy.mjs +31 -5
- package/plugin/scripts/developer-update.mjs +17 -5
- package/plugin/scripts/grok-hook-adapter.mjs +7 -3
- package/plugin/scripts/hook-failures.mjs +76 -8
- package/plugin/scripts/learn-capture.mjs +74 -9
- package/plugin/scripts/learning-store.mjs +17 -7
- package/plugin/scripts/nightly-scheduler.mjs +67 -12
- package/plugin/scripts/ruflo-bin.mjs +5 -1
- package/plugin/scripts/ruflo-memory-hooks.mjs +1 -1
- package/plugin/scripts/turn-outcome-capture.mjs +18 -18
- package/scripts/agentdb-fleet-doctor.mjs +1 -1
- package/scripts/corpus-dispatch-decision.mjs +5 -4
- package/scripts/corpus-watchdog.mjs +28 -2
- package/scripts/distill-project.mjs +1 -1
- package/scripts/falsify.mjs +1 -1
- package/scripts/health-repair.mjs +1 -1
- package/scripts/learning-replay-execution.mjs +3 -3
- package/scripts/learning-replay-fixture.mjs +3 -0
- package/scripts/lesson-migrate-agentdb.mjs +1 -1
- package/scripts/nightly-wrapper.sh +2 -2
- package/scripts/proxy/claude-proxied.sh +1 -0
- package/scripts/proxy/proxy-revert.sh +1 -0
- package/scripts/proxy/proxy-up.sh +1 -0
- package/scripts/proxy/proxy-verify.mjs +1 -1
- package/scripts/record-lesson.mjs +1 -1
- package/scripts/selfcheck.mjs +1 -1
- package/scripts/single-source-check.mjs +1 -1
- 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
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
52
|
-
*
|
|
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
|
|
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
|
-
|
|
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} ${
|
|
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
|
-
|
|
58
|
-
|
|
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)
|
|
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) },
|
|
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
|
-
/**
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|