ruvnet-brain 4.0.8 → 4.0.24
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 +3 -3
- package/bin/install.mjs +109 -42
- package/console/app.js +32 -11
- package/console/style.css +5 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/advocacy-outcomes.mjs +808 -0
- package/plugin/scripts/anticipate.sh +80 -14
- package/plugin/scripts/capability-registry.mjs +994 -0
- package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
- package/plugin/scripts/continuation-gate.mjs +169 -1
- package/plugin/scripts/gates.mjs +146 -0
- package/plugin/scripts/goal-match.mjs +398 -0
- package/plugin/scripts/hijack-ruvnet.sh +69 -1
- package/plugin/scripts/hook-registry.mjs +616 -0
- package/plugin/scripts/hook-shim.mjs +13 -2
- package/plugin/scripts/learn-flush.mjs +37 -2
- package/plugin/scripts/learning-enable.mjs +382 -0
- package/plugin/scripts/lesson-promote.mjs +262 -0
- package/plugin/scripts/lesson-provenance.mjs +43 -0
- package/plugin/scripts/lesson-store.mjs +67 -56
- package/plugin/scripts/memory-doctor.mjs +345 -0
- package/plugin/scripts/nightly-controller.mjs +98 -0
- package/plugin/scripts/project-identity.mjs +89 -0
- package/plugin/scripts/ruflo-bin.mjs +81 -0
- package/plugin/scripts/runtime-preferences.mjs +18 -0
- package/plugin/scripts/session-snapshot-hook.mjs +4 -1
- package/plugin/scripts/session-start-core.mjs +19 -2
- package/plugin/scripts/unprompted-runtime.mjs +22 -7
- package/plugin/scripts/user-settings.mjs +672 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/advocacy-outcomes.mjs +4 -808
- package/scripts/capability-registry.mjs +4 -876
- package/scripts/console-runtime-identity.mjs +74 -0
- package/scripts/corpus-qa.mjs +44 -6
- package/scripts/distill-project.mjs +9 -1
- package/scripts/doc-currency.mjs +45 -4
- package/scripts/gates.mjs +4 -146
- package/scripts/goal-match.mjs +4 -398
- package/scripts/health-repair.mjs +88 -11
- package/scripts/hook-registry.mjs +4 -567
- package/scripts/host-install-matrix.mjs +155 -0
- package/scripts/issue-watch.mjs +108 -0
- package/scripts/learning-enable.mjs +4 -380
- package/scripts/lesson-promote.mjs +4 -262
- package/scripts/memory-doctor.mjs +4 -342
- package/scripts/model-router-catalog.mjs +34 -0
- package/scripts/nightly-controller.mjs +4 -66
- package/scripts/nightly-wrapper.sh +23 -1
- package/scripts/onboarding-console.mjs +34 -12
- package/scripts/proactivity-metrics.mjs +8 -1
- package/scripts/publication-receipt.mjs +10 -1
- package/scripts/qe/ux-suite.mjs +72 -1
- package/scripts/release-abort-stale.mjs +111 -0
- package/scripts/release-convergence-watchdog.mjs +119 -0
- package/scripts/release-transaction-provider.mjs +76 -8
- package/scripts/release-transaction.mjs +55 -17
- package/scripts/rvf-generation.mjs +17 -0
- package/scripts/self-update.mjs +63 -10
- package/scripts/staged-host-verifier.mjs +27 -54
- package/scripts/sync-version.mjs +10 -10
- package/scripts/user-settings.mjs +4 -640
|
@@ -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
|
+
}
|
package/scripts/issue-watch.mjs
CHANGED
|
@@ -209,6 +209,112 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
|
|
|
209
209
|
return { results, alertsSent, checkedAt: new Date(now).toISOString() };
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* AN OPEN PR YOU OPENED IS UNFINISHED WORK. Added 2026-08-06.
|
|
214
|
+
*
|
|
215
|
+
* The continuation gate could already see two kinds of outstanding work — an issue past its SLA,
|
|
216
|
+
* and a red CI run. It could not see the third and most common kind: a pull request that is sitting
|
|
217
|
+
* open, fully green, one `gh pr merge` from done. Measured that day: PRs #117, #118 and #119 were
|
|
218
|
+
* opened and the gate said nothing at the Stop boundary, because a PR is neither an issue nor a CI
|
|
219
|
+
* failure and nobody had called `--commit-to`. The owner's reaction — "why the hell have you
|
|
220
|
+
* stopped?" — was the gate's blind spot talking, not a missing rule.
|
|
221
|
+
*
|
|
222
|
+
* This is the producer half. `issue-watch.mjs` already answers "what is outstanding on GitHub" once
|
|
223
|
+
* an hour and already writes the artifact the gate reads; it simply only knew about issues. Teaching
|
|
224
|
+
* it PRs needs no new pipeline, no new writer, and no network call at the Stop boundary — which is
|
|
225
|
+
* the whole reason to put it here rather than in the gate.
|
|
226
|
+
*
|
|
227
|
+
* DELIBERATELY NARROW, for the same reason the issues source reports only breaches: a permanently
|
|
228
|
+
* non-empty list makes the gate nag forever, which trains people to ignore it.
|
|
229
|
+
* - DRAFT PRs are excluded. A draft is explicitly "not ready" — nagging about it is noise.
|
|
230
|
+
* - `checksState` is recorded, never filtered on here. The gate decides what is actionable, so
|
|
231
|
+
* this stays a plain observation and the policy lives in one place.
|
|
232
|
+
* Failure is silent-and-empty by design: this is best-effort decoration on a status file whose
|
|
233
|
+
* absence must never break the watcher, and a PR list that cannot be read is not evidence of work.
|
|
234
|
+
*/
|
|
235
|
+
export function collectOpenPrs() {
|
|
236
|
+
try {
|
|
237
|
+
const prs = ghJson(['pr', 'list', '--repo', REPO, '--state', 'open', '--json',
|
|
238
|
+
'number,title,isDraft,mergeable,statusCheckRollup,url,author']);
|
|
239
|
+
if (!Array.isArray(prs)) return [];
|
|
240
|
+
return prs.filter((p) => !p.isDraft).map((p) => {
|
|
241
|
+
const rollup = Array.isArray(p.statusCheckRollup) ? p.statusCheckRollup : [];
|
|
242
|
+
// A check is UNRESOLVED while it has no conclusion yet. Same discipline as redCiOpenWork:
|
|
243
|
+
// a run still in flight is not yet evidence of anything.
|
|
244
|
+
const unresolved = rollup.filter((c) => {
|
|
245
|
+
const s = String(c?.status || '').toUpperCase();
|
|
246
|
+
const concl = String(c?.conclusion || c?.state || '');
|
|
247
|
+
return s === 'IN_PROGRESS' || s === 'QUEUED' || s === 'PENDING' || (!concl && s !== 'COMPLETED');
|
|
248
|
+
}).length;
|
|
249
|
+
const failing = rollup.filter((c) => {
|
|
250
|
+
const v = String(c?.conclusion || c?.state || '').toUpperCase();
|
|
251
|
+
return v === 'FAILURE' || v === 'TIMED_OUT' || v === 'CANCELLED' || v === 'ERROR';
|
|
252
|
+
}).length;
|
|
253
|
+
let checksState = 'none';
|
|
254
|
+
if (rollup.length) checksState = unresolved ? 'pending' : (failing ? 'failing' : 'passing');
|
|
255
|
+
return {
|
|
256
|
+
number: p.number,
|
|
257
|
+
title: String(p.title || '').slice(0, 80),
|
|
258
|
+
author: p.author?.login || '',
|
|
259
|
+
checksState,
|
|
260
|
+
failing,
|
|
261
|
+
mergeable: p.mergeable || 'UNKNOWN',
|
|
262
|
+
url: p.url || `https://github.com/${REPO}/pull/${p.number}`,
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
} catch { return []; }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* THE THINGS GITHUB EMAILS THE OWNER ABOUT. Added 2026-08-06, at his request:
|
|
270
|
+
*
|
|
271
|
+
* "That stuff that keeps coming to me as an email that I seem to have to tell you and you don't
|
|
272
|
+
* know. I want you to know about anything that would cause GitHub to spit out a notify note."
|
|
273
|
+
*
|
|
274
|
+
* That is a real asymmetry: GitHub pushes Dependabot and secret-scanning findings to his inbox, and
|
|
275
|
+
* nothing pulled them into this session, so he had to relay them by hand. A watcher that already
|
|
276
|
+
* answers "what is outstanding on GitHub" every hour is the right place to close it.
|
|
277
|
+
*
|
|
278
|
+
* Failed workflow runs are deliberately NOT collected here — scripts/signal-watch.mjs is the SINGLE
|
|
279
|
+
* WRITER of ci-status.json and the continuation gate already reads red CI from it. Duplicating that
|
|
280
|
+
* signal would give one fact two owners, which is the one-poisoned-predicate hazard ADR-050 exists
|
|
281
|
+
* to prevent.
|
|
282
|
+
*
|
|
283
|
+
* Each endpoint is independent and best-effort: a repo with code scanning disabled 404s, a token
|
|
284
|
+
* without `security_events` 403s, and neither is evidence of a problem — an alert we cannot read is
|
|
285
|
+
* not an alert. Never let this fail the watcher.
|
|
286
|
+
*/
|
|
287
|
+
export function collectSecurityAlerts() {
|
|
288
|
+
const alerts = [];
|
|
289
|
+
const safe = (args) => { try { const v = ghJson(args); return Array.isArray(v) ? v : []; } catch { return []; } };
|
|
290
|
+
|
|
291
|
+
for (const a of safe(['api', `repos/${REPO}/dependabot/alerts?state=open&per_page=50`])) {
|
|
292
|
+
alerts.push({
|
|
293
|
+
kind: 'dependabot',
|
|
294
|
+
severity: String(a?.security_advisory?.severity || 'unknown').toLowerCase(),
|
|
295
|
+
title: `${a?.dependency?.package?.name || 'dependency'}: ${String(a?.security_advisory?.summary || '').slice(0, 70)}`,
|
|
296
|
+
url: a?.html_url || `https://github.com/${REPO}/security/dependabot`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
for (const a of safe(['api', `repos/${REPO}/secret-scanning/alerts?state=open&per_page=50`])) {
|
|
300
|
+
alerts.push({
|
|
301
|
+
kind: 'secret-scanning',
|
|
302
|
+
severity: 'critical', // a live credential in a public repo has exactly one severity
|
|
303
|
+
title: String(a?.secret_type_display_name || a?.secret_type || 'secret'),
|
|
304
|
+
url: a?.html_url || `https://github.com/${REPO}/security/secret-scanning`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
for (const a of safe(['api', `repos/${REPO}/code-scanning/alerts?state=open&per_page=50`])) {
|
|
308
|
+
alerts.push({
|
|
309
|
+
kind: 'code-scanning',
|
|
310
|
+
severity: String(a?.rule?.security_severity_level || a?.rule?.severity || 'unknown').toLowerCase(),
|
|
311
|
+
title: String(a?.rule?.description || a?.rule?.id || 'code scanning alert').slice(0, 70),
|
|
312
|
+
url: a?.html_url || `https://github.com/${REPO}/security/code-scanning`,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return alerts;
|
|
316
|
+
}
|
|
317
|
+
|
|
212
318
|
async function main() {
|
|
213
319
|
const dryRun = process.argv.includes('--dry-run');
|
|
214
320
|
const asJson = process.argv.includes('--json');
|
|
@@ -260,6 +366,8 @@ async function main() {
|
|
|
260
366
|
fs.writeFileSync(STATUS_PATH, JSON.stringify({
|
|
261
367
|
at: new Date().toISOString(), repo: REPO,
|
|
262
368
|
open: issues.length, breaches: issues.filter((i) => i.breach).length, issues,
|
|
369
|
+
prs: collectOpenPrs(),
|
|
370
|
+
securityAlerts: collectSecurityAlerts(),
|
|
263
371
|
}, null, 2));
|
|
264
372
|
} catch { /* status file is best-effort; never break the watcher */ }
|
|
265
373
|
}
|
|
@@ -1,380 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
7
|
-
* THE FAILURE THIS FILE EXISTS TO PREVENT (2026-07-21, caught before it shipped)
|
|
8
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
9
|
-
* A survey of this machine reported, as the headline finding of the night: "26 rUv learning hooks
|
|
10
|
-
* are installed and ZERO are enabled — learning is off." Stuart had been asking "is learning
|
|
11
|
-
* actually on?" all evening and was told no.
|
|
12
|
-
*
|
|
13
|
-
* That finding was FALSE, in both directions, and it was read straight off `ruflo hooks list`:
|
|
14
|
-
*
|
|
15
|
-
* | pre-edit | PreToolUse | No | | | Never | ← "Enabled: No", 26 times
|
|
16
|
-
*
|
|
17
|
-
* Reading the actual installed source rather than trusting the table
|
|
18
|
-
* (~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/
|
|
19
|
-
* mcp-tools/hooks-tools.js, `export const hooksList`) shows the handler is a HARDCODED STATIC
|
|
20
|
-
* ARRAY. It takes no input (`inputSchema: { properties: {} }`), opens no file, reads no database,
|
|
21
|
-
* and stamps every one of the 26 entries with the literal `status: 'active'`. Confirmed live via
|
|
22
|
-
* `ruflo hooks list --format json` — the payload contains `"status": "active"` and has NO `enabled`
|
|
23
|
-
* key at all.
|
|
24
|
-
*
|
|
25
|
-
* The CLI renderer (commands/hooks.js, `listCommand`) then draws a column keyed `enabled`:
|
|
26
|
-
*
|
|
27
|
-
* { key: 'enabled', ..., format: (v) => v ? output.success('Yes') : output.dim('No') }
|
|
28
|
-
*
|
|
29
|
-
* `v` is `undefined` for every row, so it prints "No" 26 times. The same mismatch empties the
|
|
30
|
-
* Priority and Executions columns and makes Last Executed read "Never" for everything. Proof the
|
|
31
|
-
* table is inert: `ruflo hooks list --enabled`, which is documented as "show only enabled hooks",
|
|
32
|
-
* returns all 26 rows unchanged — the CLI passes the filter to a handler that accepts no arguments.
|
|
33
|
-
*
|
|
34
|
-
* So BOTH readings of that table are worthless as an answer to "is learning on?":
|
|
35
|
-
* • "Enabled: No" → a field-name bug. Not a state. Never was.
|
|
36
|
-
* • `status: "active"` → a hardcoded literal in a catalog of which subcommands exist. Also not a state.
|
|
37
|
-
*
|
|
38
|
-
* `ruflo hooks list` is a MENU, not a dashboard. It cannot answer the question in either direction.
|
|
39
|
-
*
|
|
40
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
41
|
-
* WHAT "ENABLED" ACTUALLY MEANS
|
|
42
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
43
|
-
* There is no ruflo-side enable flag: `ruflo hooks enable` and `ruflo hooks disable` do not exist
|
|
44
|
-
* (both fall through to the help text, verified live). There is no per-hook config file under
|
|
45
|
-
* ~/.ruflo or ~/.claude-flow — the only file there with an `enabled` key is first-run-enabled.json,
|
|
46
|
-
* which is `{"enabled":{"spinner":true}}`, about a CLI spinner, and a trap for any detector that
|
|
47
|
-
* greps for the word.
|
|
48
|
-
*
|
|
49
|
-
* rUv's learner is driven by the ruflo DAEMON and the `mcp__ruflo__hooks_*` MCP tools that agents
|
|
50
|
-
* call. It is NOT driven by entries in Claude Code's ~/.claude/settings.json. That distinction is
|
|
51
|
-
* the whole ballgame, because it is the second way to get this wrong: this machine has ZERO ruflo
|
|
52
|
-
* learning hooks wired into settings.json (the only `ruflo` command there is a statusline helper)
|
|
53
|
-
* and yet the learner had recorded 457 trajectories and 457 patterns, last adapted 67 minutes
|
|
54
|
-
* before this was written. A detector that concluded "no ruflo hooks in settings.json → learning is
|
|
55
|
-
* off" would be just as wrong as the one that read the table, and far more convincing.
|
|
56
|
-
*
|
|
57
|
-
* The ONLY honest signal is the learner's own accumulated state, which is a real file with real
|
|
58
|
-
* counters that move: ~/.claude-flow/neural/stats.json. scripts/learnings.mjs already treats it as
|
|
59
|
-
* the source of truth for the console's "What I've learned" panel; this agrees with it rather than
|
|
60
|
-
* inventing a second, disagreeing answer.
|
|
61
|
-
*
|
|
62
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
63
|
-
* WHY --enable REFUSES
|
|
64
|
-
* ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
65
|
-
* You cannot enable something that has no enabled state. There is no verified command that turns
|
|
66
|
-
* those 26 rows to "Yes", because nothing reads them. The tempting move — wiring `ruflo hooks
|
|
67
|
-
* post-edit` into every PostToolUse in ~/.claude/settings.json — would be a GUESS dressed as a fix:
|
|
68
|
-
* it edits a file that affects every project on this machine, to solve a problem that does not
|
|
69
|
-
* exist, duplicating capture the daemon is already doing. So --enable writes nothing and prints
|
|
70
|
-
* what actually drives the learner instead.
|
|
71
|
-
*
|
|
72
|
-
* This whole file is therefore READ-ONLY BY CONSTRUCTION. It never opens a file for writing, which
|
|
73
|
-
* is a stronger guarantee than backing one up: there is no write path to get wrong, and the tests
|
|
74
|
-
* assert ~/.claude/settings.json is byte-identical afterwards.
|
|
75
|
-
*
|
|
76
|
-
* Usage:
|
|
77
|
-
* node scripts/learning-enable.mjs [--status] report real state with evidence (default)
|
|
78
|
-
* node scripts/learning-enable.mjs --enable refuse, and explain what genuinely drives learning
|
|
79
|
-
* node scripts/learning-enable.mjs --disable refuse, same reason, inverted
|
|
80
|
-
* node scripts/learning-enable.mjs --json machine-readable state
|
|
81
|
-
*
|
|
82
|
-
* Exit codes: 0 = reported. 2 = refused (deliberate non-action, distinct from 1 = error).
|
|
83
|
-
*/
|
|
84
|
-
import fs from 'node:fs';
|
|
85
|
-
import os from 'node:os';
|
|
86
|
-
import path from 'node:path';
|
|
87
|
-
import { fileURLToPath } from 'node:url';
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Resolve HOME honestly. os.homedir() consults $HOME on POSIX, but reading it explicitly first
|
|
91
|
-
* means the tests can point this at a temp HOME without depending on that implementation detail
|
|
92
|
-
* — and means a caller can audit a different machine's dotfiles without lying about whose they are.
|
|
93
|
-
*/
|
|
94
|
-
const homeOf = (home) => home || process.env.HOME || os.homedir();
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Staleness threshold: a learner that hasn't adapted in a week is idle, not off.
|
|
98
|
-
*
|
|
99
|
-
* EXPORTED because capability-registry.mjs needs the same number and used to have no staleness check
|
|
100
|
-
* at all — it called a learner last adapted 400 days ago "on", while this file called the identical
|
|
101
|
-
* file "IDLE — nothing in 400 days". One constant, one definition; a second copy is a future
|
|
102
|
-
* disagreement with a delay fuse on it.
|
|
103
|
-
*/
|
|
104
|
-
export const STALE_DAYS = 7;
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Read the learner's accumulated state — the real signal.
|
|
108
|
-
*
|
|
109
|
-
* Rule: if the file is missing or unparseable we return null counters, NEVER 0. "0 patterns" is a
|
|
110
|
-
* claim that the learner ran and learned nothing; "not checked" is the truth when we never found
|
|
111
|
-
* the file. Shipping the former as the latter is how a detector starts lying.
|
|
112
|
-
*/
|
|
113
|
-
export function readLearnerState({ home, now = Date.now() } = {}) {
|
|
114
|
-
const HOME = homeOf(home);
|
|
115
|
-
const statsPath = path.join(HOME, '.claude-flow', 'neural', 'stats.json');
|
|
116
|
-
|
|
117
|
-
let raw = null;
|
|
118
|
-
let parsed = null;
|
|
119
|
-
try { raw = fs.readFileSync(statsPath, 'utf8'); } catch { /* no learner on this machine yet */ }
|
|
120
|
-
if (raw !== null) { try { parsed = JSON.parse(raw); } catch { /* present but corrupt */ } }
|
|
121
|
-
|
|
122
|
-
// `Number(v)` was the bug, not the guard around it. Number(null) === 0, Number('') === 0 and
|
|
123
|
-
// Number(false) === 0 are all finite, so the previous `Number.isFinite(Number(v))` accepted three
|
|
124
|
-
// non-numbers as a MEASURED ZERO — the precise substitution this function's docstring above forbids.
|
|
125
|
-
// MEASURED: `{"trajectoriesRecorded": null}` produced the verdict "the learner file exists and
|
|
126
|
-
// GENUINELY records 0 trajectories", the word "genuinely" attached to a value nobody ever counted.
|
|
127
|
-
// A counter is a counter only when it arrives as a JSON number.
|
|
128
|
-
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
129
|
-
|
|
130
|
-
// Timestamps are NOT counters and must not share their reader. `lastAdaptation` is written as epoch
|
|
131
|
-
// milliseconds today, but an upstream switch to ISO-8601 is the single most ordinary schema change
|
|
132
|
-
// there is — and under num() an ISO string returns null, which nulls `days`, which makes the IDLE
|
|
133
|
-
// branch unreachable. MEASURED: a learner idle 400 days reported "ON — the learner is accumulating"
|
|
134
|
-
// purely because its timestamp was a string. The staleness check evaporated on exactly the drift it
|
|
135
|
-
// was written to survive, so this reader accepts both shapes and still refuses everything else.
|
|
136
|
-
const ts = (v) => {
|
|
137
|
-
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
138
|
-
if (typeof v === 'string' && v.trim()) {
|
|
139
|
-
const ms = Date.parse(v);
|
|
140
|
-
if (Number.isFinite(ms)) return ms;
|
|
141
|
-
}
|
|
142
|
-
return null;
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
const trajectories = parsed ? num(parsed.trajectoriesRecorded) : null;
|
|
146
|
-
const patterns = parsed ? num(parsed.patternsLearned) : null;
|
|
147
|
-
const lastMs = parsed ? ts(parsed.lastAdaptation) : null;
|
|
148
|
-
|
|
149
|
-
return {
|
|
150
|
-
statsPath,
|
|
151
|
-
present: raw !== null,
|
|
152
|
-
corrupt: raw !== null && parsed === null,
|
|
153
|
-
trajectories,
|
|
154
|
-
patterns,
|
|
155
|
-
lastAdaptationMs: lastMs,
|
|
156
|
-
// `lastMs ? …` treated epoch 0 as "no timestamp"; `=== null` is the only honest test for absence.
|
|
157
|
-
ageMinutes: lastMs === null ? null : Math.floor((now - lastMs) / 60000),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Is the ruflo daemon alive? This is the process that feeds the learner, so its liveness is
|
|
163
|
-
* corroborating evidence — but NOT proof on its own, and not required: a dead daemon with fresh
|
|
164
|
-
* counters still means learning ran. Reported as a separate line, never folded into the verdict.
|
|
165
|
-
*/
|
|
166
|
-
export function readDaemonState({ home } = {}) {
|
|
167
|
-
const HOME = homeOf(home);
|
|
168
|
-
const pidPath = path.join(HOME, '.claude-flow', 'daemon.pid');
|
|
169
|
-
let pid = null;
|
|
170
|
-
try { pid = Number(String(fs.readFileSync(pidPath, 'utf8')).trim()); } catch { return { pidPath, pid: null, alive: null }; }
|
|
171
|
-
if (!Number.isInteger(pid) || pid <= 0) return { pidPath, pid: null, alive: null };
|
|
172
|
-
// Signal 0 probes existence without touching the process.
|
|
173
|
-
try { process.kill(pid, 0); return { pidPath, pid, alive: true }; }
|
|
174
|
-
catch (e) { return { pidPath, pid, alive: e.code === 'EPERM' }; }
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Count ruflo LEARNING hooks wired into Claude Code's settings.json.
|
|
179
|
-
*
|
|
180
|
-
* Reported for completeness and explicitly labelled NOT REQUIRED, because the honest finding is
|
|
181
|
-
* that this number was 0 on a machine that was actively learning. It is here so nobody re-derives
|
|
182
|
-
* the wrong conclusion from its absence later. A statusline helper is not a learning hook, so
|
|
183
|
-
* matching bare "ruflo" would overcount — we match a `ruflo hooks <subcommand>` invocation.
|
|
184
|
-
*/
|
|
185
|
-
export function readSettingsWiring({ home } = {}) {
|
|
186
|
-
const HOME = homeOf(home);
|
|
187
|
-
const settingsPath = path.join(HOME, '.claude', 'settings.json');
|
|
188
|
-
let cfg = null;
|
|
189
|
-
try { cfg = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { return { settingsPath, present: false, learningHooks: null }; }
|
|
190
|
-
|
|
191
|
-
// TWO REAL CRASHES, both from a hand-edited settings.json, both reproduced with exit code 1 and a
|
|
192
|
-
// raw stack trace — which is the worst possible outcome for a command whose entire job is to tell
|
|
193
|
-
// a worried person whether learning is on. It left them unable to find out at all.
|
|
194
|
-
//
|
|
195
|
-
// `null` is valid JSON, so the try above succeeds and cfg is null → `cfg.hooks` threw
|
|
196
|
-
// "TypeError: Cannot read properties of null (reading 'hooks')".
|
|
197
|
-
// `hooks: {command: …}` written as an object instead of the array the schema wants → the inner
|
|
198
|
-
// for..of threw "TypeError: object is not iterable".
|
|
199
|
-
//
|
|
200
|
-
// This file invites hand-editing (it prints the path), so malformed shapes are an EXPECTED state,
|
|
201
|
-
// not an exceptional one. A structural surprise degrades to "not checked" — null, never 0, because
|
|
202
|
-
// a count of 0 is a claim about their configuration and null is the truth about our reading of it.
|
|
203
|
-
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return { settingsPath, present: true, learningHooks: null };
|
|
204
|
-
if (cfg.hooks !== undefined && (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks))) {
|
|
205
|
-
return { settingsPath, present: true, learningHooks: null };
|
|
206
|
-
}
|
|
207
|
-
const groups = cfg.hooks || {};
|
|
208
|
-
|
|
209
|
-
// AN UNREADABLE SUB-STRUCTURE POISONS THE COUNT, and it must. Guarding the iteration with
|
|
210
|
-
// `Array.isArray(x) ? x : []` stops the crash and then quietly SKIPS the malformed entry — so a
|
|
211
|
-
// settings.json with one hook group written as an object came back as a confident `0`, meaning
|
|
212
|
-
// "we looked everywhere and found no ruflo learning hooks." We did not look everywhere; we
|
|
213
|
-
// skipped the part we could not parse. That is this file's own thesis violated three lines below
|
|
214
|
-
// where it is stated, and its regression test caught it.
|
|
215
|
-
//
|
|
216
|
-
// A partially-read structure yields null ("not checked"), never a total. Losing the count of the
|
|
217
|
-
// entries we COULD read is the correct trade: an incomplete count presented as a complete one is
|
|
218
|
-
// the failure mode, and there is no honest way to render "at least 3" in a field documented as
|
|
219
|
-
// an exact number.
|
|
220
|
-
let unreadable = false;
|
|
221
|
-
let count = 0;
|
|
222
|
-
for (const entries of Object.values(groups)) {
|
|
223
|
-
if (!Array.isArray(entries)) { unreadable = true; continue; }
|
|
224
|
-
for (const entry of entries) {
|
|
225
|
-
if (entry?.hooks !== undefined && !Array.isArray(entry.hooks)) { unreadable = true; continue; }
|
|
226
|
-
for (const h of Array.isArray(entry?.hooks) ? entry.hooks : []) {
|
|
227
|
-
if (/\bruflo\b[^"]*\bhooks\b/.test(String(h?.command || ''))) count += 1;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return { settingsPath, present: true, learningHooks: unreadable ? null : count };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Derive the verdict from the evidence. Never asserted, never cached, never guessed. */
|
|
235
|
-
export function verdict(learner) {
|
|
236
|
-
if (!learner.present) {
|
|
237
|
-
return { code: 'NO_LEARNER_STATE', headline: 'NOT LEARNING YET — no learner state on this machine' };
|
|
238
|
-
}
|
|
239
|
-
if (learner.corrupt) {
|
|
240
|
-
return { code: 'CORRUPT', headline: 'UNKNOWN — learner state file exists but is unreadable' };
|
|
241
|
-
}
|
|
242
|
-
const t = learner.trajectories;
|
|
243
|
-
const p = learner.patterns;
|
|
244
|
-
if (t === null && p === null) {
|
|
245
|
-
return { code: 'UNKNOWN_SHAPE', headline: 'UNKNOWN — learner state file has no recognisable counters' };
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// PARTIAL DRIFT IS STILL DRIFT. Upstream does not rename both counters in the same release, and the
|
|
249
|
-
// half-renamed case is the one that reaches users. Every line below needs BOTH numbers — to compare
|
|
250
|
-
// them against zero, and to print them — so one unread counter is one unanswerable question.
|
|
251
|
-
//
|
|
252
|
-
// The two failures this replaces were mirror images of the same coercion, and both were MEASURED on
|
|
253
|
-
// real bytes. `{trajectories_recorded: 457, patternsLearned: 0}` fell through `(t || 0) === 0` —
|
|
254
|
-
// null coerced to 0 — and reported "genuinely records 0 trajectories and 0 patterns" to a machine
|
|
255
|
-
// holding 457. Read the other way, `{trajectoriesRecorded: null, patternsLearned: 457}` reached the
|
|
256
|
-
// ON branch and rendered the literal string "null work sessions recorded and 457 patterns learned".
|
|
257
|
-
// A confident OFF and a printed "null" from one missing field.
|
|
258
|
-
//
|
|
259
|
-
// This is the guard num() exists to enable, placed where it survives: the `|| 0` three lines below
|
|
260
|
-
// it discarded null the instant it was produced, so the correct reader upstream bought nothing.
|
|
261
|
-
if (t === null || p === null) {
|
|
262
|
-
const missing = t === null ? 'trajectoriesRecorded' : 'patternsLearned';
|
|
263
|
-
const seen = t === null ? `patternsLearned=${p}` : `trajectoriesRecorded=${t}`;
|
|
264
|
-
return {
|
|
265
|
-
code: 'UNKNOWN_PARTIAL',
|
|
266
|
-
missingField: missing,
|
|
267
|
-
headline: `UNKNOWN — learner state file is half-readable (${seen}, but ${missing} is not a number this version can read)`,
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (t === 0 && p === 0) {
|
|
272
|
-
return { code: 'INITIALISED_EMPTY', headline: 'INITIALISED BUT EMPTY — learner exists, has recorded nothing' };
|
|
273
|
-
}
|
|
274
|
-
const days = learner.ageMinutes === null ? null : Math.floor(learner.ageMinutes / 1440);
|
|
275
|
-
if (days !== null && days >= STALE_DAYS) {
|
|
276
|
-
return { code: 'IDLE', headline: `IDLE — learned before, but nothing in ${days} days` };
|
|
277
|
-
}
|
|
278
|
-
return { code: 'ON', headline: 'ON — the learner is accumulating' };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Gather everything, and NEVER throw doing it.
|
|
283
|
-
*
|
|
284
|
-
* The guards inside readSettingsWiring cover the two shapes that actually crashed, but the outer
|
|
285
|
-
* belt matters independently: this function is the entry point for the CLI *and* for
|
|
286
|
-
* capability-registry's learning row, so an unanticipated structural surprise anywhere below must
|
|
287
|
-
* degrade to an honest UNKNOWN rather than take down the page that was asked the question. A
|
|
288
|
-
* crashed probe and a probe reporting "off" are equally useless to the reader; only one of them is
|
|
289
|
-
* also a lie, and neither is acceptable when "I could not tell" is available and true.
|
|
290
|
-
*/
|
|
291
|
-
export function gatherState(opts = {}) {
|
|
292
|
-
const learner = readLearnerState(opts);
|
|
293
|
-
const safe = (fn, fallback) => { try { return fn(); } catch { return fallback; } };
|
|
294
|
-
return {
|
|
295
|
-
learner,
|
|
296
|
-
daemon: safe(() => readDaemonState(opts), { pidPath: null, pid: null, alive: null }),
|
|
297
|
-
settings: safe(() => readSettingsWiring(opts), { settingsPath: null, present: false, learningHooks: null }),
|
|
298
|
-
verdict: safe(() => verdict(learner), { code: 'CORRUPT', headline: 'UNKNOWN — learner state could not be interpreted' }),
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** "not checked" beats a confident 0. Every rendered number passes through here. */
|
|
303
|
-
const show = (v) => (v === null || v === undefined ? 'not checked' : String(v));
|
|
304
|
-
|
|
305
|
-
const tilde = (p, HOME) => (p.startsWith(HOME) ? p.replace(HOME, '~') : p);
|
|
306
|
-
|
|
307
|
-
function renderStatus(state, HOME) {
|
|
308
|
-
const { learner, daemon, settings, verdict: v } = state;
|
|
309
|
-
const L = [];
|
|
310
|
-
L.push('');
|
|
311
|
-
L.push('rUv learning — actual state');
|
|
312
|
-
L.push('');
|
|
313
|
-
L.push(` ${v.headline}`);
|
|
314
|
-
L.push('');
|
|
315
|
-
L.push(' Evidence (all read at render time):');
|
|
316
|
-
L.push(` trajectories recorded : ${show(learner.trajectories)}`);
|
|
317
|
-
L.push(` patterns learned : ${show(learner.patterns)}`);
|
|
318
|
-
L.push(` last adaptation : ${learner.ageMinutes === null ? 'not checked' : `${learner.ageMinutes} min ago`}`);
|
|
319
|
-
L.push(` source : ${tilde(learner.statsPath, HOME)}${learner.present ? '' : ' (absent)'}`);
|
|
320
|
-
L.push(` ruflo daemon : ${daemon.alive === null ? 'no pid file' : daemon.alive ? `running (pid ${daemon.pid})` : `pid ${daemon.pid} not running`}`);
|
|
321
|
-
L.push('');
|
|
322
|
-
L.push(' Not evidence, despite appearances:');
|
|
323
|
-
L.push(' `ruflo hooks list` prints "Enabled: No" for all 26 hooks. That column is a');
|
|
324
|
-
L.push(' field-name bug, not a state — the handler returns `status: "active"` from a');
|
|
325
|
-
L.push(' hardcoded array and has no `enabled` key for the renderer to read. Proof:');
|
|
326
|
-
L.push(' `ruflo hooks list --enabled` still returns all 26. Ignore that table.');
|
|
327
|
-
L.push('');
|
|
328
|
-
L.push(` ruflo learning hooks in settings.json: ${show(settings.learningHooks)} — and this is NOT required.`);
|
|
329
|
-
L.push(' The learner is fed by the ruflo daemon and the mcp__ruflo__hooks_* tools, not by');
|
|
330
|
-
L.push(' Claude Code hook entries. A count of 0 here does not mean learning is off.');
|
|
331
|
-
L.push('');
|
|
332
|
-
return L.join('\n');
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function renderRefusal(which) {
|
|
336
|
-
const L = [];
|
|
337
|
-
L.push('');
|
|
338
|
-
L.push(`REFUSING --${which}: there is nothing to ${which}.`);
|
|
339
|
-
L.push('');
|
|
340
|
-
L.push(' The 26 hooks in `ruflo hooks list` have no enabled state to toggle. The "Enabled"');
|
|
341
|
-
L.push(' column is a field-name bug (handler returns `status: "active"`; renderer reads a');
|
|
342
|
-
L.push(' nonexistent `enabled` key), and `ruflo hooks enable` / `ruflo hooks disable` do not');
|
|
343
|
-
L.push(' exist — both fall through to the help text.');
|
|
344
|
-
L.push('');
|
|
345
|
-
L.push(' This command will not edit ~/.claude/settings.json to fake it. That file affects every');
|
|
346
|
-
L.push(' project on this machine, and wiring `ruflo hooks ...` into it would duplicate capture');
|
|
347
|
-
L.push(' the daemon already performs — a guess dressed as a fix.');
|
|
348
|
-
L.push('');
|
|
349
|
-
L.push(' What actually drives the learner (verified commands, run them yourself):');
|
|
350
|
-
L.push(' ruflo hooks intelligence --status initialise / inspect the intelligence system');
|
|
351
|
-
L.push(' ruflo hooks intelligence --train force one training cycle');
|
|
352
|
-
L.push(' ruflo hooks metrics learning metrics dashboard');
|
|
353
|
-
L.push('');
|
|
354
|
-
L.push(' To see whether learning is on, with evidence:');
|
|
355
|
-
L.push(' node scripts/learning-enable.mjs --status');
|
|
356
|
-
L.push('');
|
|
357
|
-
return L.join('\n');
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function main(argv) {
|
|
361
|
-
const has = (f) => argv.includes(f);
|
|
362
|
-
const HOME = homeOf();
|
|
363
|
-
|
|
364
|
-
if (has('--enable') || has('--disable')) {
|
|
365
|
-
process.stdout.write(renderRefusal(has('--enable') ? 'enable' : 'disable'));
|
|
366
|
-
return 2;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const state = gatherState();
|
|
370
|
-
if (has('--json')) {
|
|
371
|
-
process.stdout.write(`${JSON.stringify(state, null, 2)}\n`);
|
|
372
|
-
return 0;
|
|
373
|
-
}
|
|
374
|
-
process.stdout.write(`${renderStatus(state, HOME)}\n`);
|
|
375
|
-
return 0;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
379
|
-
process.exit(main(process.argv.slice(2)));
|
|
380
|
-
}
|
|
1
|
+
// Compatibility export for repository tools. The executable implementation belongs inside the
|
|
2
|
+
// self-contained plugin payload so Stable Spine and Codex-only installs never depend on a separate
|
|
3
|
+
// Claude marketplace checkout.
|
|
4
|
+
export * from '../plugin/scripts/learning-enable.mjs';
|