ruvnet-brain 4.0.7 → 4.0.12
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 +1 -1
- package/bin/install.mjs +115 -44
- package/console/app.js +32 -11
- package/console/style.css +5 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +17 -17
- package/plugin/scripts/codex-hook-wrapper.mjs +72 -1
- package/plugin/scripts/continuation-gate.mjs +41 -1
- package/plugin/scripts/learn-flush.mjs +37 -2
- package/plugin/scripts/project-identity.mjs +89 -0
- package/plugin/scripts/ruflo-bin.mjs +81 -0
- package/plugin/scripts/session-snapshot-hook.mjs +4 -1
- package/plugin/scripts/session-start-core.mjs +19 -2
- package/plugin/skills/release-proof/scripts/release-proof.mjs +12 -5
- package/scripts/candidate-host-evidence.mjs +49 -0
- package/scripts/ci/learning-replay-codex-adapter.mjs +13 -1
- package/scripts/console-runtime-identity.mjs +74 -0
- package/scripts/distill-project.mjs +9 -1
- package/scripts/doc-currency.mjs +15 -2
- package/scripts/health-repair.mjs +88 -11
- package/scripts/host-install-matrix.mjs +155 -0
- package/scripts/learning-replay-contract.mjs +14 -4
- package/scripts/learning-replay-execution.mjs +4 -3
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/memory-doctor.mjs +26 -23
- package/scripts/model-router-catalog.mjs +34 -0
- package/scripts/onboarding-console.mjs +34 -12
- package/scripts/publication-receipt.mjs +135 -34
- package/scripts/release-evidence-aggregate.mjs +55 -0
- package/scripts/release-payload.mjs +100 -0
- package/scripts/release-transaction-provider.mjs +260 -55
- package/scripts/release-transaction.mjs +295 -105
- package/scripts/release.mjs +41 -12
- package/scripts/rvf-generation.mjs +17 -0
- package/scripts/selfcheck.mjs +20 -5
- package/scripts/staged-host-verifier.mjs +27 -57
- package/scripts/sync-version.mjs +10 -10
|
@@ -24,8 +24,12 @@ import path from 'node:path';
|
|
|
24
24
|
import os from 'node:os';
|
|
25
25
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
26
26
|
import { findStores, diagnose } from './memory-doctor.mjs';
|
|
27
|
+
import { loadRuntimePreferences } from '../plugin/scripts/runtime-preferences.mjs';
|
|
27
28
|
|
|
28
29
|
const HOME = os.homedir();
|
|
30
|
+
// The SAME project root learn-flush.mjs computes, by the same rule — the two halves of the flush
|
|
31
|
+
// have to agree about which project they mean or they address different queues (issue #104).
|
|
32
|
+
const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || process.cwd();
|
|
29
33
|
const argv = process.argv.slice(2);
|
|
30
34
|
const has = (f) => argv.includes(f);
|
|
31
35
|
|
|
@@ -93,25 +97,98 @@ function repairMemory() {
|
|
|
93
97
|
return { ok: true, log: `repaired — integrity ok, ${rowsAfter} entries intact (was ${rowsBefore}). Backup: ${backup.replace(HOME, '~')}`, backup };
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Drain the capture queue into rUv's learner — his tool, not ours.
|
|
102
|
+
*
|
|
103
|
+
* ISSUE #104: this measured one queue and drained another, so it could only ever report "fed 0".
|
|
104
|
+
* The flusher was spawned with NO environment, so inside learn-flush.mjs RUVNET_LEARNING_SCOPE was
|
|
105
|
+
* unset and defaulted to 'project' — it drained `<project>/.swarm/ruvnet-brain-learn/` while THIS
|
|
106
|
+
* function counted `<user>/.cache/ruvnet-brain/learn/`. `before - after` was structurally 0: it
|
|
107
|
+
* could not report truthfully in either direction even on a flush that worked, and the real queue
|
|
108
|
+
* grew forever. Two independent defaults are not an agreement.
|
|
109
|
+
*
|
|
110
|
+
* So: resolve the scope ONCE, from the same policy module learn-flush reads, derive the queue root
|
|
111
|
+
* FROM that scope, pass the scope (and the exact queue file) to the child explicitly, and measure
|
|
112
|
+
* the root that was actually drained.
|
|
113
|
+
*
|
|
114
|
+
* And LOOP. learn-flush feeds at most MAX_ACTIONS distinct actions per invocation and writes the
|
|
115
|
+
* remainder back on purpose (a SessionEnd hook must stay fast), so one call cannot drain a deep
|
|
116
|
+
* queue — the reporter needed 15 rounds for 293 entries. A single call would leave a queue that
|
|
117
|
+
* "flushes" every time and never empties, which is the same lie in slow motion.
|
|
118
|
+
*/
|
|
97
119
|
function flushLearning() {
|
|
98
120
|
const flusher = path.join(HOME, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin', 'scripts', 'learn-flush.mjs');
|
|
99
|
-
const local = path.join(
|
|
121
|
+
const local = path.join(PROJECT, 'plugin', 'scripts', 'learn-flush.mjs');
|
|
100
122
|
const script = fs.existsSync(flusher) ? flusher : (fs.existsSync(local) ? local : null);
|
|
101
123
|
if (!script) return { ok: false, log: 'learn-flush.mjs not found — cannot drain the queue' };
|
|
102
124
|
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
125
|
+
const configured = process.env.RUVNET_LEARNING_SCOPE
|
|
126
|
+
|| loadRuntimePreferences({ cwd: PROJECT }).values.learningScope;
|
|
127
|
+
const scope = ['off', 'project', 'user'].includes(configured) ? configured : 'project';
|
|
128
|
+
if (scope === 'off') {
|
|
129
|
+
return { ok: true, noop: true, log: 'learning is switched off for this project — nothing is being captured, so there is nothing to feed' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Derived from the scope, never assumed: this is the directory the child will actually read.
|
|
133
|
+
const queueDir = scope === 'user'
|
|
134
|
+
? path.join(HOME, '.cache', 'ruvnet-brain', 'learn')
|
|
135
|
+
: path.join(PROJECT, '.swarm', 'ruvnet-brain-learn');
|
|
136
|
+
// Displayed to a human and matched by tests, so it is normalised to forward slashes on every
|
|
137
|
+
// platform. Without this, Windows reports `~\.cache\ruvnet-brain\learn` while macOS and Linux
|
|
138
|
+
// report `~/.cache/ruvnet-brain/learn` — the same location under two spellings, which is the
|
|
139
|
+
// exact defect class this branch has been closing (one fact, two representations).
|
|
140
|
+
const where = queueDir.replace(HOME, '~').split(path.sep).join('/');
|
|
141
|
+
|
|
142
|
+
const queueFiles = () => {
|
|
143
|
+
try { return fs.readdirSync(queueDir).filter((f) => f.endsWith('.jsonl')).map((f) => path.join(queueDir, f)); }
|
|
144
|
+
catch { return []; }
|
|
145
|
+
};
|
|
146
|
+
const depthOf = (f) => {
|
|
147
|
+
try { return fs.readFileSync(f, 'utf8').split('\n').filter(Boolean).length; } catch { return 0; }
|
|
109
148
|
};
|
|
149
|
+
const depth = () => queueFiles().reduce((n, f) => n + depthOf(f), 0);
|
|
150
|
+
|
|
110
151
|
const before = depth();
|
|
111
|
-
|
|
112
|
-
|
|
152
|
+
if (!before) return { ok: true, noop: true, log: `nothing queued in ${where} — the learner is already caught up` };
|
|
153
|
+
|
|
154
|
+
const env = { ...process.env, RUVNET_LEARNING_SCOPE: scope, RUVNET_BRAIN_PROJECT_DIR: PROJECT };
|
|
155
|
+
const deadline = Date.now() + 540_000; // inside the 600s this action is allowed overall
|
|
156
|
+
const stalled = [];
|
|
157
|
+
for (const file of queueFiles()) {
|
|
158
|
+
let d = depthOf(file);
|
|
159
|
+
while (d > 0 && Date.now() < deadline) {
|
|
160
|
+
try {
|
|
161
|
+
execFileSync(process.execPath, [script], {
|
|
162
|
+
env: { ...env, LEARN_QUEUE: file },
|
|
163
|
+
stdio: 'ignore',
|
|
164
|
+
timeout: Math.min(120_000, Math.max(1_000, deadline - Date.now())),
|
|
165
|
+
});
|
|
166
|
+
} catch (e) { stalled.push(`${path.basename(file)}: ${String(e.message).split('\n')[0].slice(0, 80)}`); break; }
|
|
167
|
+
const next = depthOf(file);
|
|
168
|
+
// STRICT progress, or stop. learn-flush KEEPS a queue it could not feed (by design — the queue
|
|
169
|
+
// is evidence), so a round that shrinks nothing means the learner is not accepting the work.
|
|
170
|
+
// Spinning on it would burn the whole budget and still report zero.
|
|
171
|
+
if (next >= d) { stalled.push(`${path.basename(file)}: ${d} entr${d === 1 ? 'y' : 'ies'} would not feed`); break; }
|
|
172
|
+
d = next;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
113
176
|
const after = depth();
|
|
114
|
-
|
|
177
|
+
const fed = before - after;
|
|
178
|
+
if (fed <= 0) {
|
|
179
|
+
// Name the most likely cause instead of shrugging: learn-flush invokes ruflo at a FIXED path,
|
|
180
|
+
// so on a machine with a different npm prefix it feeds nothing and honestly keeps the queue.
|
|
181
|
+
const rufloAtFixedPath = fs.existsSync(path.join(HOME, '.npm-global/bin/ruflo'));
|
|
182
|
+
const why = stalled.length ? ` (${stalled.slice(0, 3).join('; ')})` : '';
|
|
183
|
+
const hint = rufloAtFixedPath ? '' : ' ruflo is not at ~/.npm-global/bin/ruflo, which is where the flusher looks for it —'
|
|
184
|
+
+ ' `npm i -g ruflo@latest` installs it there.';
|
|
185
|
+
return { ok: false, log: `fed 0 of ${before} queued events from ${where}${why} — the queue is preserved for retry.${hint}` };
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
log: `fed ${fed} captured events into the ${scope} learner (queue ${before} → ${after} in ${where})`
|
|
190
|
+
+ (after ? ` — ${after} remain and will drain on the next flush` : ''),
|
|
191
|
+
};
|
|
115
192
|
}
|
|
116
193
|
|
|
117
194
|
/** One training cycle, via rUv's own CLI, in the GLOBAL (cross-project) learner. */
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// host-install-matrix.mjs — ONE definition of "install this artifact into clean hosts and judge it".
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. Two harnesses did this same job and disagreed about almost everything:
|
|
4
|
+
//
|
|
5
|
+
// scripts/staged-host-verifier.mjs scripts/publication-receipt.mjs (installHosts)
|
|
6
|
+
// ───────────────────────────────── ────────────────────────────────────────────────
|
|
7
|
+
// modes: claude / codex / dual modes: claudeOnly / codexOnly / dual
|
|
8
|
+
// --local … --no-selfcheck --version v<X> (no --local, selfcheck ON)
|
|
9
|
+
// RUVNET_CLAUDE_MARKETPLACE_SOURCE RUVNET_STRICT_INSTALL=1
|
|
10
|
+
// RUVNET_CODEX_HOOK_TRUST_MODE=bypass RUVNET_BRAIN_PROFILE=complete
|
|
11
|
+
//
|
|
12
|
+
// They could not even be compared: one produced `fixtures.claude`, the other `hosts.claudeOnly`,
|
|
13
|
+
// for the identical fixture. So "the hosts passed" meant two different things depending on which
|
|
14
|
+
// half of the release you asked, and nothing in the system could notice they had drifted apart.
|
|
15
|
+
//
|
|
16
|
+
// Some of that difference is REAL and must survive: a STAGED check runs before publication against
|
|
17
|
+
// local bytes, so it points the marketplace at the unpacked package and skips selfcheck; a
|
|
18
|
+
// PUBLISHED check runs after, against what npm actually serves, so it resolves by version and
|
|
19
|
+
// installs strictly. That is one axis with two values — a parameter. Everything else was accident.
|
|
20
|
+
//
|
|
21
|
+
// So: the modes are named once, the loop is written once, the verdict is classified once, and the
|
|
22
|
+
// only thing a caller chooses is which VARIANT it is running. Same shape as
|
|
23
|
+
// scripts/console-runtime-identity.mjs, where one enumeration serves both the copy list and the
|
|
24
|
+
// digest — a fact stated once cannot drift from itself.
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
|
|
30
|
+
/** The three host shapes a release must survive. ONE name each, for every consumer. */
|
|
31
|
+
export const HOST_MODES = Object.freeze(['claude', 'codex', 'dual']);
|
|
32
|
+
|
|
33
|
+
/** Which CLIs each mode is allowed to see. A codex-only box genuinely has no `claude`. */
|
|
34
|
+
export const MODE_HOSTS = Object.freeze({
|
|
35
|
+
claude: ['claude'],
|
|
36
|
+
codex: ['codex'],
|
|
37
|
+
dual: ['claude', 'codex'],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The published-side receipt has always spelled these `claudeOnly` / `codexOnly` / `dual`, and that
|
|
42
|
+
* name is baked into current-release.json and every receipt already published — renaming it would
|
|
43
|
+
* invalidate history for a cosmetic win. So the two spellings are reconciled HERE, once, instead of
|
|
44
|
+
* being silently different in two files. `publication.installed.claudeOnly` and `fixtures.claude`
|
|
45
|
+
* are now provably the same fixture, which is what made the two halves of a release incomparable.
|
|
46
|
+
*/
|
|
47
|
+
export const RECEIPT_MODE_NAMES = Object.freeze({ claude: 'claudeOnly', codex: 'codexOnly', dual: 'dual' });
|
|
48
|
+
export const MODE_FROM_RECEIPT_NAME = Object.freeze({ claudeOnly: 'claude', codexOnly: 'codex', dual: 'dual' });
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The two genuinely different questions, declared once instead of implied by two files.
|
|
52
|
+
*
|
|
53
|
+
* `staged` — before publication, against local bytes. The marketplace is pointed at the unpacked
|
|
54
|
+
* package because nothing is on npm yet, and selfcheck is skipped for the same reason.
|
|
55
|
+
* `published` — after publication, against what npm actually serves. Resolves by version and
|
|
56
|
+
* installs strictly, because this is the run that must match a stranger's machine.
|
|
57
|
+
*/
|
|
58
|
+
export const VARIANTS = Object.freeze({
|
|
59
|
+
staged: Object.freeze({
|
|
60
|
+
installerArgs: (_v) => ['--local', '--yes', '--force', '--no-nightly-prompt',
|
|
61
|
+
'--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline', '--no-selfcheck'],
|
|
62
|
+
env: ({ packageRoot }) => ({
|
|
63
|
+
RUVNET_CLAUDE_MARKETPLACE_SOURCE: packageRoot,
|
|
64
|
+
RUVNET_CODEX_HOOK_TRUST_MODE: 'bypass',
|
|
65
|
+
}),
|
|
66
|
+
}),
|
|
67
|
+
published: Object.freeze({
|
|
68
|
+
installerArgs: (version) => ['--yes', '--force', '--version', `v${version}`, '--no-nightly-prompt',
|
|
69
|
+
'--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline'],
|
|
70
|
+
env: () => ({
|
|
71
|
+
RUVNET_STRICT_INSTALL: '1',
|
|
72
|
+
RUVNET_BRAIN_PROFILE: 'complete',
|
|
73
|
+
}),
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** A doctor run is ACCEPTED only on a clean exit. One rule, not one per harness. */
|
|
78
|
+
export function classifyDoctor(result) {
|
|
79
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`;
|
|
80
|
+
if (!result.error && result.status === 0) return { accepted: true, status: 'PASS', output };
|
|
81
|
+
return { accepted: false, status: 'FAIL', output };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Build a PATH exposing only the CLIs this mode is entitled to see. */
|
|
85
|
+
export function fixturePath(mode, temp, locate) {
|
|
86
|
+
const bin = path.join(temp, `bin-${mode}`);
|
|
87
|
+
fs.mkdirSync(bin, { recursive: true });
|
|
88
|
+
for (const name of ['node', 'npm', ...MODE_HOSTS[mode]]) {
|
|
89
|
+
const target = locate(name);
|
|
90
|
+
if (!target) throw new Error(`${name} CLI unavailable for the ${mode} host fixture`);
|
|
91
|
+
fs.symlinkSync(target, path.join(bin, name));
|
|
92
|
+
}
|
|
93
|
+
return `${bin}:/usr/bin:/bin`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Install `packageRoot` into a clean HOME per mode and run its doctor. Returns a result for EVERY
|
|
98
|
+
* mode — including the ones that failed — because "which host broke" is the whole diagnostic value,
|
|
99
|
+
* and the previous harnesses threw on the first failure and lost the rest.
|
|
100
|
+
*
|
|
101
|
+
* @returns {{verdict:'PASS'|'FAIL', fixtures:Record<string,object>, error?:string}}
|
|
102
|
+
*/
|
|
103
|
+
export function runHostMatrix({ packageRoot, version, variant = 'staged', locate, temp, run = spawnSync }) {
|
|
104
|
+
const spec = VARIANTS[variant];
|
|
105
|
+
if (!spec) throw new Error(`unknown host-matrix variant: ${variant}`);
|
|
106
|
+
const workspace = temp || fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-host-matrix-'));
|
|
107
|
+
const installer = path.join(packageRoot, 'bin', 'install.mjs');
|
|
108
|
+
const fixtures = {};
|
|
109
|
+
let verdict = 'PASS';
|
|
110
|
+
let error;
|
|
111
|
+
|
|
112
|
+
for (const mode of HOST_MODES) {
|
|
113
|
+
try {
|
|
114
|
+
const home = path.join(workspace, `home-${mode}`);
|
|
115
|
+
const codexHome = path.join(home, '.codex');
|
|
116
|
+
const brainHome = path.join(home, '.cache', 'ruvnet-brain');
|
|
117
|
+
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
|
|
118
|
+
if (mode !== 'claude') fs.mkdirSync(codexHome, { recursive: true });
|
|
119
|
+
const env = {
|
|
120
|
+
...process.env,
|
|
121
|
+
HOME: home,
|
|
122
|
+
CODEX_HOME: codexHome,
|
|
123
|
+
RUVNET_BRAIN_HOME: brainHome,
|
|
124
|
+
RUVNET_BRAIN_KB: path.join(brainHome, 'kb'),
|
|
125
|
+
CI: 'true',
|
|
126
|
+
PATH: fixturePath(mode, workspace, locate),
|
|
127
|
+
...spec.env({ packageRoot }),
|
|
128
|
+
};
|
|
129
|
+
const install = run(process.execPath, [installer, ...spec.installerArgs(version)], {
|
|
130
|
+
cwd: packageRoot, env, encoding: 'utf8', timeout: 1_200_000, maxBuffer: 32 * 1024 * 1024,
|
|
131
|
+
});
|
|
132
|
+
if (install.error || install.status !== 0) {
|
|
133
|
+
throw new Error(`install failed for ${mode}: ${(install.stderr || install.error?.message || '').slice(-4000)}`);
|
|
134
|
+
}
|
|
135
|
+
const doctor = run(process.execPath, [installer, '--doctor', '--hooks'], {
|
|
136
|
+
cwd: packageRoot, env, encoding: 'utf8', timeout: 300_000, maxBuffer: 32 * 1024 * 1024,
|
|
137
|
+
});
|
|
138
|
+
const classified = classifyDoctor(doctor);
|
|
139
|
+
fixtures[mode] = { status: classified.status, doctorExit: doctor.status, version };
|
|
140
|
+
if (!classified.accepted) {
|
|
141
|
+
verdict = 'FAIL';
|
|
142
|
+
fixtures[mode].output = classified.output.slice(-5000);
|
|
143
|
+
// The output belongs IN the error. The previous harness put it there and I dropped it in
|
|
144
|
+
// the consolidation, so CI reported a bare "doctor failed for claude" and the one thing
|
|
145
|
+
// needed to act on it — what the doctor actually said — was thrown away.
|
|
146
|
+
error = error || `doctor failed for ${mode} (exit ${doctor.status}): ${classified.output.slice(-4000)}`;
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
verdict = 'FAIL';
|
|
150
|
+
fixtures[mode] = { status: 'FAIL', error: e.message };
|
|
151
|
+
error = error || e.message;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return error ? { verdict, fixtures, error } : { verdict, fixtures };
|
|
155
|
+
}
|
|
@@ -59,6 +59,16 @@ export function trapSpec(id = TRAP.MEMORY_SEARCH) {
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Codex commonly prints the global binary as $HOME/.npm-global/bin/ruflo. That is a real shell
|
|
63
|
+
// executable path, not prose, but the shared command parser intentionally does not expand shell
|
|
64
|
+
// variables. Canonicalize only an executable-position prefix; never rewrite quoted mentions.
|
|
65
|
+
export function normalizeRufloExecutable(command) {
|
|
66
|
+
return String(command || '').replace(
|
|
67
|
+
/(^|(?:&&|\|\||[;|()\n])\s*)\$(?:HOME|\{HOME\})\/\.npm-global\/bin\/(ruflo|claude-flow)(?=\s|$)/g,
|
|
68
|
+
'$1$2',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
export const LOAD_BEARING = Object.freeze([
|
|
63
73
|
'scripts/learning-replay.mjs',
|
|
64
74
|
'scripts/learning-replay-contract.mjs',
|
|
@@ -80,7 +90,7 @@ export const LOAD_BEARING = Object.freeze([
|
|
|
80
90
|
]);
|
|
81
91
|
|
|
82
92
|
export function classifyCommand(cmd) {
|
|
83
|
-
const invocations = findInvocations(
|
|
93
|
+
const invocations = findInvocations(normalizeRufloExecutable(cmd), ['ruflo', 'claude-flow']);
|
|
84
94
|
if (!invocations.length) return 'none';
|
|
85
95
|
let sawPositional = false;
|
|
86
96
|
for (const inv of invocations) {
|
|
@@ -102,7 +112,7 @@ export function classifyCommand(cmd) {
|
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
export function subcommandCorrect(cmd) {
|
|
105
|
-
for (const inv of findInvocations(
|
|
115
|
+
for (const inv of findInvocations(normalizeRufloExecutable(cmd), ['ruflo', 'claude-flow'])) {
|
|
106
116
|
const words = inv.args.filter((a) => a !== '' && !a.startsWith('-'));
|
|
107
117
|
const memory = words.indexOf('memory');
|
|
108
118
|
if (memory !== -1 && words[memory + 1] === 'search') return true;
|
|
@@ -124,7 +134,7 @@ export function optionValue(args, short, long) {
|
|
|
124
134
|
}
|
|
125
135
|
|
|
126
136
|
export function classifyPostTaskCommand(cmd) {
|
|
127
|
-
const invocations = findInvocations(
|
|
137
|
+
const invocations = findInvocations(normalizeRufloExecutable(cmd), ['ruflo', 'claude-flow']);
|
|
128
138
|
if (!invocations.length) return 'none';
|
|
129
139
|
let sawPostTask = false;
|
|
130
140
|
for (const inv of invocations) {
|
|
@@ -142,7 +152,7 @@ export function classifyPostTaskCommand(cmd) {
|
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
export function postTaskSubcommandCorrect(cmd) {
|
|
145
|
-
return findInvocations(
|
|
155
|
+
return findInvocations(normalizeRufloExecutable(cmd), ['ruflo', 'claude-flow']).some((inv) => {
|
|
146
156
|
const words = inv.args.filter((a) => a !== '' && !a.startsWith('-'));
|
|
147
157
|
const hooks = words.indexOf('hooks');
|
|
148
158
|
return hooks !== -1 && words[hooks + 1] === 'post-task';
|
|
@@ -3,7 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { commandNodes, findInvocations } from '../plugin/scripts/hook-input.mjs';
|
|
6
|
-
import { optionValue, TRAP } from './learning-replay-contract.mjs';
|
|
6
|
+
import { normalizeRufloExecutable, optionValue, TRAP } from './learning-replay-contract.mjs';
|
|
7
7
|
|
|
8
8
|
export const PROJECT_B_MEMORY_KEY = 'note-caching-strategy';
|
|
9
9
|
export const PROJECT_B_MEMORY_VALUE =
|
|
@@ -90,9 +90,10 @@ export function executeProducedCommand(cmd, {
|
|
|
90
90
|
output: '',
|
|
91
91
|
...extra,
|
|
92
92
|
});
|
|
93
|
-
const
|
|
93
|
+
const normalized = normalizeRufloExecutable(cmd);
|
|
94
|
+
const invocations = findInvocations(normalized, ['ruflo', 'claude-flow']);
|
|
94
95
|
if (!invocations.length) return reject('no Ruflo invocation to execute');
|
|
95
|
-
const firstExecutable = path.basename(commandNodes(
|
|
96
|
+
const firstExecutable = path.basename(commandNodes(normalized)[0]?.exe || '').split('@')[0];
|
|
96
97
|
if (invocations.length !== 1 || firstExecutable !== 'ruflo') {
|
|
97
98
|
return reject('corrective command was not the first and only Ruflo invocation');
|
|
98
99
|
}
|
|
@@ -48,6 +48,16 @@ function initMemoryDb(ruflo, db, cwd) {
|
|
|
48
48
|
return sh(ruflo, ['memory', 'init', '--path', db, '--backend', 'hybrid'], { cwd });
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function retrieveExact(ruflo, db, cwd, key, expected) {
|
|
52
|
+
const result = sh(ruflo, [
|
|
53
|
+
'memory', 'retrieve', '-k', key, '-n', 'default', '--value-only', '--path', db,
|
|
54
|
+
], { cwd });
|
|
55
|
+
return {
|
|
56
|
+
exit: result.status,
|
|
57
|
+
matched: result.status === 0 && String(result.stdout || '').includes(expected),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
export function buildFixtures(baseDir) {
|
|
52
62
|
fs.mkdirSync(baseDir, { recursive: true });
|
|
53
63
|
const dirs = {
|
|
@@ -90,6 +100,7 @@ export function recordInProjectA(dirs, {
|
|
|
90
100
|
'memory', 'search', '-q', spec.recordQuery,
|
|
91
101
|
'-n', 'default', '--path', db, '-t', 'keyword',
|
|
92
102
|
], { cwd: project });
|
|
103
|
+
const exact = retrieveExact(ruflo, db, project, key, spec.lesson);
|
|
93
104
|
return {
|
|
94
105
|
project: path.basename(project),
|
|
95
106
|
db,
|
|
@@ -97,7 +108,8 @@ export function recordInProjectA(dirs, {
|
|
|
97
108
|
initExit: init.status,
|
|
98
109
|
storeExit: store.status,
|
|
99
110
|
readBackExit: readBack.status,
|
|
100
|
-
|
|
111
|
+
exactReadExit: exact.exit,
|
|
112
|
+
recorded: exact.matched,
|
|
101
113
|
};
|
|
102
114
|
});
|
|
103
115
|
const lesson = makeLesson({
|
|
@@ -138,12 +150,14 @@ export function seedProjectBMemory(dirs, { ruflo = RUFLO_BIN } = {}) {
|
|
|
138
150
|
'memory', 'store', '-k', PROJECT_B_MEMORY_KEY,
|
|
139
151
|
'--value', PROJECT_B_MEMORY_VALUE, '-n', 'default', '--path', db,
|
|
140
152
|
], { cwd: dirs.projectB });
|
|
153
|
+
const exact = retrieveExact(ruflo, db, dirs.projectB, PROJECT_B_MEMORY_KEY, PROJECT_B_MEMORY_VALUE);
|
|
141
154
|
return {
|
|
142
155
|
db,
|
|
143
156
|
key: PROJECT_B_MEMORY_KEY,
|
|
144
157
|
initExit: init.status,
|
|
145
158
|
storeExit: store.status,
|
|
146
|
-
|
|
159
|
+
exactReadExit: exact.exit,
|
|
160
|
+
ok: init.status === 0 && store.status === 0 && exact.matched,
|
|
147
161
|
};
|
|
148
162
|
}
|
|
149
163
|
|
|
@@ -156,6 +170,13 @@ export function nightlyRefresh(dirs, { ruflo = RUFLO_BIN } = {}) {
|
|
|
156
170
|
path.join(ROOT, 'scripts', 'ci', 'learning-replay-codex-adapter.mjs'),
|
|
157
171
|
path.join(versionDir, 'scripts', 'codex-hook-adapter.mjs'),
|
|
158
172
|
);
|
|
173
|
+
// Codex registrations call this stable, generation-independent wrapper. A fixture that only
|
|
174
|
+
// creates active.json silently falls back to the user's real Brain home and never records the
|
|
175
|
+
// trap's PreToolUse command, making an expensive replay unmeasurable.
|
|
176
|
+
fs.copyFileSync(
|
|
177
|
+
path.join(ROOT, 'plugin', 'scripts', 'codex-hook-wrapper.mjs'),
|
|
178
|
+
path.join(dirs.brainHome, 'codex-hook.mjs'),
|
|
179
|
+
);
|
|
159
180
|
fs.writeFileSync(path.join(dirs.brainHome, 'active.json'), JSON.stringify({
|
|
160
181
|
codeRoot: versionDir,
|
|
161
182
|
generation,
|
|
@@ -41,6 +41,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
41
41
|
import fs from 'node:fs';
|
|
42
42
|
import os from 'node:os';
|
|
43
43
|
import path from 'node:path';
|
|
44
|
+
import { canonicalPath, pathIdentity } from '../plugin/scripts/project-identity.mjs';
|
|
44
45
|
|
|
45
46
|
const HOME = os.homedir();
|
|
46
47
|
const DEFAULT_SCAN_ROOTS = ['Code', 'code', 'src', 'source', 'projects', 'dev', 'work'];
|
|
@@ -132,10 +133,6 @@ const q = (db, sql) => {
|
|
|
132
133
|
// n() returns null for "unknown" and a number only when we genuinely counted. Callers must handle null.
|
|
133
134
|
const n = (r) => (r.ok ? (r.value === null || r.value === '' ? null : parseInt(r.value, 10)) : null);
|
|
134
135
|
|
|
135
|
-
function realExisting(value) {
|
|
136
|
-
try { return fs.realpathSync(value); } catch { return null; }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
136
|
export function candidateRoots({
|
|
140
137
|
home = HOME,
|
|
141
138
|
configPath = path.join(home, '.claude', 'ruvnet-brain', 'config.json'),
|
|
@@ -150,13 +147,18 @@ export function candidateRoots({
|
|
|
150
147
|
}
|
|
151
148
|
} catch { /* absent or malformed config does not erase the common roots */ }
|
|
152
149
|
|
|
153
|
-
|
|
150
|
+
// Keyed by pathIdentity, not by the spelling. DEFAULT_SCAN_ROOTS deliberately lists both `Code`
|
|
151
|
+
// and `code` so neither convention is missed, and on a case-insensitive volume those are ONE
|
|
152
|
+
// directory — which the old Set-of-strings admitted twice and then scanned, counted and summed
|
|
153
|
+
// twice (#107). On a case-sensitive volume they are two inodes and both are still kept.
|
|
154
|
+
const roots = new Map();
|
|
154
155
|
const addRoot = (value) => {
|
|
155
156
|
const absolute = path.isAbsolute(value) ? value : path.join(home, value);
|
|
156
|
-
const canonical =
|
|
157
|
+
const canonical = canonicalPath(absolute);
|
|
157
158
|
try {
|
|
158
159
|
if (canonical && fs.statSync(canonical).isDirectory()) {
|
|
159
|
-
|
|
160
|
+
const identity = pathIdentity(canonical) ?? canonical;
|
|
161
|
+
if (!roots.has(identity)) roots.set(identity, canonical);
|
|
160
162
|
return true;
|
|
161
163
|
}
|
|
162
164
|
} catch { /* missing/non-directory roots are not candidates on this machine */ }
|
|
@@ -170,12 +172,14 @@ export function candidateRoots({
|
|
|
170
172
|
if (configuredCount > 0 && validConfigured === 0) {
|
|
171
173
|
throw new Error('configured scanRoots contain no existing directories');
|
|
172
174
|
}
|
|
173
|
-
return [...roots].sort();
|
|
175
|
+
return [...roots.values()].sort();
|
|
174
176
|
}
|
|
175
177
|
|
|
178
|
+
// Keyed by pathIdentity for the same reason candidateRoots is: one store reached by two names is
|
|
179
|
+
// one store. Values are the canonical spelling, which is what every caller reads back.
|
|
176
180
|
function storesBelow(root) {
|
|
177
|
-
const out = new
|
|
178
|
-
const canonicalRoot =
|
|
181
|
+
const out = new Map();
|
|
182
|
+
const canonicalRoot = canonicalPath(root);
|
|
179
183
|
if (!canonicalRoot) return out;
|
|
180
184
|
const walk = (dir, depth) => {
|
|
181
185
|
if (depth > 4) return;
|
|
@@ -186,8 +190,8 @@ function storesBelow(root) {
|
|
|
186
190
|
if (e.name === 'node_modules' || e.name === '.git') continue;
|
|
187
191
|
if (e.name === '.swarm') {
|
|
188
192
|
const db = path.join(dir, '.swarm/memory.db');
|
|
189
|
-
const canonical =
|
|
190
|
-
if (canonical) out.
|
|
193
|
+
const canonical = canonicalPath(db);
|
|
194
|
+
if (canonical) out.set(pathIdentity(canonical) ?? canonical, canonical);
|
|
191
195
|
continue;
|
|
192
196
|
}
|
|
193
197
|
if (e.name.startsWith('.') && e.name !== '.swarm') continue;
|
|
@@ -199,28 +203,27 @@ function storesBelow(root) {
|
|
|
199
203
|
}
|
|
200
204
|
|
|
201
205
|
export function findStores(root) {
|
|
202
|
-
const out = new
|
|
206
|
+
const out = new Map();
|
|
207
|
+
const collect = (found) => { for (const [identity, db] of found) if (!out.has(identity)) out.set(identity, db); };
|
|
203
208
|
if (root !== undefined) {
|
|
204
|
-
|
|
205
|
-
return [...out].sort();
|
|
209
|
+
collect(storesBelow(root));
|
|
210
|
+
return [...out.values()].sort();
|
|
206
211
|
}
|
|
207
212
|
|
|
208
|
-
for (const candidate of candidateRoots())
|
|
209
|
-
for (const db of storesBelow(candidate)) out.add(db);
|
|
210
|
-
}
|
|
213
|
+
for (const candidate of candidateRoots()) collect(storesBelow(candidate));
|
|
211
214
|
// These two stores intentionally sit outside the project-root convention. They belong only to
|
|
212
215
|
// the fleet-wide no-argument scan; an explicit root must remain genuinely scoped.
|
|
213
216
|
for (const extra of [path.join(HOME, '.claude/.swarm/memory.db'), path.join(HOME, 'cognitum-trader/.swarm/memory.db')]) {
|
|
214
|
-
const canonical =
|
|
215
|
-
if (canonical)
|
|
217
|
+
const canonical = canonicalPath(extra);
|
|
218
|
+
if (canonical) collect([[pathIdentity(canonical) ?? canonical, canonical]]);
|
|
216
219
|
}
|
|
217
|
-
return [...out].sort();
|
|
220
|
+
return [...out.values()].sort();
|
|
218
221
|
}
|
|
219
222
|
|
|
220
223
|
export function displayStoreName(db, home = HOME) {
|
|
221
|
-
const canonicalDb =
|
|
224
|
+
const canonicalDb = canonicalPath(db) || path.resolve(db);
|
|
222
225
|
const project = path.dirname(path.dirname(canonicalDb));
|
|
223
|
-
const canonicalHome =
|
|
226
|
+
const canonicalHome = canonicalPath(home) || path.resolve(home);
|
|
224
227
|
const relative = path.relative(canonicalHome, project);
|
|
225
228
|
if (relative === '') return '~';
|
|
226
229
|
if (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
1
4
|
// Managed facts are additive for existing users. Their existing row is the user overlay: it wins
|
|
2
5
|
// byte-for-byte, including disablement, tier/priority changes, and local candidates. Newly shipped
|
|
3
6
|
// metered rows are not auto-added; that would silently expand spend authority.
|
|
@@ -14,3 +17,34 @@ export function mergeManagedCatalog(existing, managed) {
|
|
|
14
17
|
};
|
|
15
18
|
return JSON.stringify(next) === JSON.stringify(existing) ? existing : next;
|
|
16
19
|
}
|
|
20
|
+
|
|
21
|
+
// Issue #87: the merge above was correct but reachable only from offerRouterProfile(), which runs on
|
|
22
|
+
// the FRESH-INSTALL path alone. `--update` (and therefore Evergreen nightly) never called it, so the
|
|
23
|
+
// one population that needs managed additions — users who already have ~/.claude/model-router/
|
|
24
|
+
// catalog.json — could never acquire a newly shipped managed model. This is that same merge as a
|
|
25
|
+
// non-interactive, idempotent step the update path can call: no prompts, no TEST_MODE gate, backs the
|
|
26
|
+
// user's file up before it writes, and replaces it by atomic rename so a crash can never half-apply.
|
|
27
|
+
export function applyManagedCatalogUpdate({ routerDir, packageRoot }) {
|
|
28
|
+
const template = path.join(packageRoot, 'config', 'model-router', 'catalog.template.json');
|
|
29
|
+
const target = path.join(routerDir, 'catalog.json');
|
|
30
|
+
if (!fs.existsSync(template)) return { action: 'skipped', added: [], detail: 'no managed template in this package' };
|
|
31
|
+
const managed = JSON.parse(fs.readFileSync(template, 'utf8'));
|
|
32
|
+
fs.mkdirSync(routerDir, { recursive: true });
|
|
33
|
+
if (!fs.existsSync(target)) {
|
|
34
|
+
fs.copyFileSync(template, target);
|
|
35
|
+
return { action: 'created', added: (managed.candidates || []).map((candidate) => candidate.id), detail: target };
|
|
36
|
+
}
|
|
37
|
+
const existing = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
38
|
+
const merged = mergeManagedCatalog(existing, managed);
|
|
39
|
+
if (merged === existing) return { action: 'unchanged', added: [], detail: target };
|
|
40
|
+
const before = new Set((existing.candidates || []).map((candidate) => candidate.id));
|
|
41
|
+
fs.copyFileSync(target, `${target}.pre-managed-merge`);
|
|
42
|
+
const staged = `${target}.tmp-${process.pid}`;
|
|
43
|
+
fs.writeFileSync(staged, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
44
|
+
fs.renameSync(staged, target);
|
|
45
|
+
return {
|
|
46
|
+
action: 'merged',
|
|
47
|
+
added: merged.candidates.filter((candidate) => !before.has(candidate.id)).map((candidate) => candidate.id),
|
|
48
|
+
detail: target,
|
|
49
|
+
};
|
|
50
|
+
}
|