ruvnet-brain 4.0.12 → 4.0.28
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 +5 -5
- 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 +129 -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/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/runtime-preferences.mjs +18 -0
- package/plugin/scripts/session-start-core.mjs +3 -3
- 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/corpus-qa.mjs +44 -6
- package/scripts/doc-currency.mjs +30 -2
- package/scripts/gates.mjs +4 -146
- package/scripts/goal-match.mjs +4 -398
- package/scripts/hook-registry.mjs +4 -567
- 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 -345
- package/scripts/nightly-controller.mjs +4 -66
- package/scripts/nightly-wrapper.sh +23 -1
- package/scripts/proactivity-metrics.mjs +8 -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 +61 -7
- package/scripts/release-transaction.mjs +63 -17
- package/scripts/self-update.mjs +63 -10
- package/scripts/user-settings.mjs +4 -640
|
@@ -10,6 +10,7 @@ const brainHome = process.env.RUVNET_BRAIN_HOME
|
|
|
10
10
|
const versions = path.join(brainHome, 'versions');
|
|
11
11
|
const blockingHooks = new Set([
|
|
12
12
|
'route-dispatch',
|
|
13
|
+
'hijack-ruvnet', // ADR-063: opt-in managed-memory refusal; exits 0 at the default
|
|
13
14
|
'ground-before-write',
|
|
14
15
|
'protect-state',
|
|
15
16
|
'design-wall',
|
|
@@ -200,7 +200,135 @@ function artifactOpenWork() {
|
|
|
200
200
|
} catch { return []; }
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
/**
|
|
204
|
+
* RED CI IS OUTSTANDING WORK. Added 2026-08-06, after the owner asked "is it working? I'm still
|
|
205
|
+
* getting GitHub errors" while `ci` was red on main and this gate said nothing.
|
|
206
|
+
*
|
|
207
|
+
* The SLA source above only knows about ISSUES. A broken build on the branch users install from is
|
|
208
|
+
* the most urgent kind of unfinished work there is, and it was the one kind the gate could not see —
|
|
209
|
+
* so the model could finish a turn, report progress, and leave main red, which is exactly the
|
|
210
|
+
* "stopping in the middle of the work" the owner is describing.
|
|
211
|
+
*
|
|
212
|
+
* Read from scripts/signal-watch.mjs's ci-status.json, whose header names it the SINGLE WRITER of
|
|
213
|
+
* that file. Same discipline as the issues source: only a FRESH observation counts, and only a
|
|
214
|
+
* RESOLVED-and-failed verdict — a run still in flight is not yet evidence of anything, and treating
|
|
215
|
+
* it as such would nag through every normal build.
|
|
216
|
+
*/
|
|
217
|
+
function redCiOpenWork() {
|
|
218
|
+
try {
|
|
219
|
+
const file = process.env.RUVNET_CI_STATUS_FILE
|
|
220
|
+
|| path.join(HOME, '.cache', 'ruvnet-brain', 'external-signals', 'ci-status.json');
|
|
221
|
+
const status = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
return Object.values(status || {})
|
|
224
|
+
.filter((d) => d?.state === 'resolved' && d.conclusion && d.conclusion !== 'success')
|
|
225
|
+
.filter((d) => {
|
|
226
|
+
const at = Date.parse(d.checkedAt || '');
|
|
227
|
+
return Number.isFinite(at) && nowMs - at <= 6 * 3600_000;
|
|
228
|
+
})
|
|
229
|
+
// one item per workflow, not per run — the same red build observed twice is one job to do
|
|
230
|
+
.filter((d) => (seen.has(d.workflowName) ? false : seen.add(d.workflowName)))
|
|
231
|
+
.map((d) => ({
|
|
232
|
+
text: `CI is RED on ${d.repo}: ${d.workflowName} concluded ${d.conclusion} at ${String(d.ref || '').slice(0, 7)}`,
|
|
233
|
+
done: false,
|
|
234
|
+
at: d.checkedAt,
|
|
235
|
+
derived: true,
|
|
236
|
+
}));
|
|
237
|
+
} catch { return []; }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* AN OPEN PR IS UNFINISHED WORK — the third source, added 2026-08-06.
|
|
242
|
+
*
|
|
243
|
+
* WHY, precisely. The two sources above cover an issue past its SLA and a red build. Neither covers
|
|
244
|
+
* the most common shape of half-finished work there is: a pull request sitting open and green, one
|
|
245
|
+
* `gh pr merge` from done. Measured that day — #117, #118 and #119 were opened, every required check
|
|
246
|
+
* passed, and this gate stayed silent at the Stop boundary. It was not malfunctioning: `open` was
|
|
247
|
+
* genuinely empty, because a PR is neither an issue nor a CI failure and nothing had been written to
|
|
248
|
+
* the ledger. The owner asked "why the hell have you stopped?" and the honest answer was that the
|
|
249
|
+
* gate could not see the work. A rule in a memory file cannot fix that; a source can.
|
|
250
|
+
*
|
|
251
|
+
* Read from open-issues.json's `prs` array, produced by scripts/issue-watch.mjs — the same file, the
|
|
252
|
+
* same single producer, the same freshness window as the issues source. No `gh` call happens here:
|
|
253
|
+
* a Stop hook must not depend on the network, and an artifact that cannot be read is not evidence.
|
|
254
|
+
*
|
|
255
|
+
* NARROW ON PURPOSE, so this can never become a permanent nag:
|
|
256
|
+
* - `passing` → actionable: merge it. `failing` → actionable: fix it.
|
|
257
|
+
* - `pending` → NOT actionable, and deliberately silent. A run in flight is not yet evidence,
|
|
258
|
+
* exactly as redCiOpenWork treats an unresolved run. Waiting for CI is not stopping early.
|
|
259
|
+
* - `none` (no checks at all) → treated as actionable, because nothing will ever arrive to
|
|
260
|
+
* resolve it; leaving it open is a decision someone has to make, not a wait.
|
|
261
|
+
* - drafts are already excluded by the producer.
|
|
262
|
+
* The item self-clears the moment the PR merges or closes, so unlike a ledger entry there is nothing
|
|
263
|
+
* to remember to mark done — which is the failure that produced this gap in the first place.
|
|
264
|
+
*/
|
|
265
|
+
function openPrWork() {
|
|
266
|
+
try {
|
|
267
|
+
const file = process.env.RUVNET_OPEN_ISSUES_FILE
|
|
268
|
+
|| path.join(HOME, '.cache', 'ruvnet-brain', 'open-issues.json');
|
|
269
|
+
const status = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
270
|
+
const observedAt = Date.parse(status?.at || '');
|
|
271
|
+
if (!Number.isFinite(observedAt) || nowMs - observedAt > 6 * 3600_000) return [];
|
|
272
|
+
return (Array.isArray(status.prs) ? status.prs : [])
|
|
273
|
+
.filter((pr) => pr && pr.checksState !== 'pending')
|
|
274
|
+
.map((pr) => ({
|
|
275
|
+
text: pr.checksState === 'failing'
|
|
276
|
+
? `PR #${pr.number} on ${status.repo} is RED (${pr.failing} failing check(s)) — fix it, do not leave it open: ${pr.title}`
|
|
277
|
+
: `PR #${pr.number} on ${status.repo} is open and its checks are ${pr.checksState === 'none' ? 'not configured' : 'GREEN'} — merge it or say why not: ${pr.title}`,
|
|
278
|
+
done: false,
|
|
279
|
+
at: new Date(observedAt).toISOString(),
|
|
280
|
+
derived: true,
|
|
281
|
+
}));
|
|
282
|
+
} catch { return []; }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* WHAT GITHUB EMAILS THE OWNER, HE SHOULD NOT HAVE TO RELAY. Fourth source, 2026-08-06.
|
|
287
|
+
*
|
|
288
|
+
* GitHub PUSHES Dependabot, secret-scanning and code-scanning findings to his inbox. Nothing pulled
|
|
289
|
+
* them into the session, so the only path from "GitHub found a vulnerability" to "the agent knows"
|
|
290
|
+
* was the owner reading an email and typing it out. His words: "that stuff that keeps coming to me
|
|
291
|
+
* as an email that I seem to have to tell you and you don't know."
|
|
292
|
+
*
|
|
293
|
+
* A published npm package and a signed bundle make this the highest-consequence blind spot of the
|
|
294
|
+
* four — an unpatched advisory ships to every user of the plugin.
|
|
295
|
+
*
|
|
296
|
+
* NARROW, so it stays a signal rather than a backlog:
|
|
297
|
+
* - only HIGH and CRITICAL. A permanently non-empty low-severity list is a nag, and this gate is
|
|
298
|
+
* worthless the moment it is ignored. Lower severities still reach the Console and the inbox.
|
|
299
|
+
* - secret-scanning is ALWAYS included regardless of severity: a live credential in a public repo
|
|
300
|
+
* is never "medium", and it is the one finding where minutes matter.
|
|
301
|
+
* - collapsed to ONE item per kind, with a count. Twelve advisories is one job — "patch the
|
|
302
|
+
* advisories" — not twelve reasons to refuse to stop.
|
|
303
|
+
*/
|
|
304
|
+
function securityAlertWork() {
|
|
305
|
+
try {
|
|
306
|
+
const file = process.env.RUVNET_OPEN_ISSUES_FILE
|
|
307
|
+
|| path.join(HOME, '.cache', 'ruvnet-brain', 'open-issues.json');
|
|
308
|
+
const status = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
309
|
+
const observedAt = Date.parse(status?.at || '');
|
|
310
|
+
if (!Number.isFinite(observedAt) || nowMs - observedAt > 6 * 3600_000) return [];
|
|
311
|
+
const alerts = (Array.isArray(status.securityAlerts) ? status.securityAlerts : [])
|
|
312
|
+
.filter((a) => a && (a.kind === 'secret-scanning' || a.severity === 'high' || a.severity === 'critical'));
|
|
313
|
+
if (!alerts.length) return [];
|
|
314
|
+
const byKind = new Map();
|
|
315
|
+
for (const a of alerts) {
|
|
316
|
+
if (!byKind.has(a.kind)) byKind.set(a.kind, []);
|
|
317
|
+
byKind.get(a.kind).push(a);
|
|
318
|
+
}
|
|
319
|
+
return [...byKind.entries()].map(([kind, list]) => ({
|
|
320
|
+
text: `${list.length} open ${kind} alert(s) on ${status.repo} — GitHub has already emailed these; highest: ${list[0].title}`,
|
|
321
|
+
done: false,
|
|
322
|
+
at: new Date(observedAt).toISOString(),
|
|
323
|
+
derived: true,
|
|
324
|
+
}));
|
|
325
|
+
} catch { return []; }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const open = [
|
|
329
|
+
...led.items.filter((i) => !i.done),
|
|
330
|
+
...artifactOpenWork(), ...redCiOpenWork(), ...openPrWork(), ...securityAlertWork(),
|
|
331
|
+
];
|
|
204
332
|
if (!open.length) process.exit(EXIT_ALLOW); // nothing outstanding: silence is correct
|
|
205
333
|
|
|
206
334
|
/**
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// gates.mjs — what stands between the model and your machine, and what it has caught.
|
|
2
|
+
//
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// WHY (2026-07-17). Stuart, looking at the console's wiring card: "I have no idea what message it's
|
|
5
|
+
// supposed to tell me… it seems to be facts without purpose." The card counted launch sites. Nobody
|
|
6
|
+
// wants a census. The question worth answering is the one the harness exists for: WHAT STOPS CLAUDE
|
|
7
|
+
// FROM BEING WRONG, AND HAS IT EVER ACTUALLY STOPPED IT?
|
|
8
|
+
//
|
|
9
|
+
// Two things had to be true before that card could be honest, and neither was:
|
|
10
|
+
//
|
|
11
|
+
// 1. The console never read the gates. wiringSurvey() walks ~/Code project settings only, so the
|
|
12
|
+
// 12 machine-wide hooks in ~/.claude/settings.json and the 9 in the plugin's own hooks.json —
|
|
13
|
+
// including the design wall that blocks commits — were invisible to the page bragging about them.
|
|
14
|
+
//
|
|
15
|
+
// 2. The gates never recorded a block. Only successes were logged, so "the harness caught Claude"
|
|
16
|
+
// had no receipt. gate-receipt.sh now writes one at the moment of refusal.
|
|
17
|
+
//
|
|
18
|
+
// THE HONEST DISTINCTION this module exists to draw: a hook wired with `|| true` CANNOT block —
|
|
19
|
+
// it injects context and the tool call proceeds regardless. Only a gate that can exit non-zero
|
|
20
|
+
// stops anything. Counting all hooks as "protection" would be the same inflation as counting
|
|
21
|
+
// cloned upstream repos as your own wiring. Advisory and blocking are different claims.
|
|
22
|
+
//
|
|
23
|
+
// Reads only. Never asserts a count it cannot source from a file on this machine.
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
const HOME = os.homedir();
|
|
31
|
+
const BLOCKS = path.join(HOME, '.cache/ruvnet-brain/gate-blocks.jsonl');
|
|
32
|
+
|
|
33
|
+
function readJSON(f) { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } }
|
|
34
|
+
|
|
35
|
+
// Two independent things must BOTH be true for a hook to stop anything, and conflating them is how
|
|
36
|
+
// a census gets sold as protection:
|
|
37
|
+
// 1. The EVENT must be one that runs before the thing it would stop. PreToolUse gates a tool call;
|
|
38
|
+
// UserPromptSubmit gates a prompt. SessionStart has nothing to refuse yet, and PostToolUse /
|
|
39
|
+
// PreCompact / SessionEnd arrive after the fact — those inject or record, they never block.
|
|
40
|
+
// 2. The COMMAND must not end in `|| true`, which swallows the exit code the gate would refuse with.
|
|
41
|
+
const BLOCKING_EVENTS = new Set(['PreToolUse', 'UserPromptSubmit']);
|
|
42
|
+
const swallowsExit = (cmd) => /\|\|\s*true\s*$/.test(String(cmd || '').trim());
|
|
43
|
+
const canBlock = (event, cmd) => BLOCKING_EVENTS.has(event) && !swallowsExit(cmd);
|
|
44
|
+
|
|
45
|
+
const NAME = (cmd) => {
|
|
46
|
+
const m = String(cmd || '').match(/([\w-]+)\.(sh|mjs|js)/);
|
|
47
|
+
return m ? m[1] : String(cmd || '').split(/\s+/).filter((t) => !t.startsWith('-')).pop()?.slice(0, 28) || 'hook';
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function collect(hooksObj, source) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const [event, groups] of Object.entries(hooksObj || {})) {
|
|
53
|
+
const list = Array.isArray(groups) ? groups : [groups];
|
|
54
|
+
for (const g of list) {
|
|
55
|
+
const hooks = Array.isArray(g?.hooks) ? g.hooks : (g?.command ? [g] : []);
|
|
56
|
+
for (const h of hooks) {
|
|
57
|
+
if (!h?.command) continue;
|
|
58
|
+
out.push({ event, matcher: g?.matcher ?? '*', name: NAME(h.command), blocking: canBlock(event, h.command), source });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Every catch the gates have recorded. This file only exists once a gate has actually refused
|
|
66
|
+
// something — an empty ledger is an honest "nothing caught yet", never a failure.
|
|
67
|
+
export function gateBlocks() {
|
|
68
|
+
try {
|
|
69
|
+
return fs.readFileSync(BLOCKS, 'utf8').trim().split('\n')
|
|
70
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
} catch { return []; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function gatesSurvey({ repo } = {}) {
|
|
76
|
+
const machine = collect(readJSON(path.join(HOME, '.claude/settings.json'))?.hooks, 'machine');
|
|
77
|
+
const pluginCfg = repo ? readJSON(path.join(repo, 'plugin/hooks/hooks.json')) : null;
|
|
78
|
+
const plugin = collect(pluginCfg?.hooks || pluginCfg, 'plugin');
|
|
79
|
+
|
|
80
|
+
const all = [...machine, ...plugin];
|
|
81
|
+
const blocking = all.filter((g) => g.blocking);
|
|
82
|
+
|
|
83
|
+
// THE LEDGER IS MACHINE-WIDE; THIS SURVEY IS ABOUT ONE PROJECT. Every catch ever recorded on the
|
|
84
|
+
// machine used to be counted here, so standing in an empty folder produced "203 refusals have been
|
|
85
|
+
// recorded" — this repo's history, attributed to a project that has never run a gate. Each record
|
|
86
|
+
// carries the `cwd` it was caught in, so when a project is named, only its own catches count.
|
|
87
|
+
//
|
|
88
|
+
// KNOWN LIMIT, stated rather than hidden: `cwd` is a basename, so two projects sharing a folder
|
|
89
|
+
// name share a count. That is a real ambiguity and it is narrow; attributing the whole machine's
|
|
90
|
+
// history to whichever directory you happen to be standing in was neither.
|
|
91
|
+
const here = repo ? path.basename(path.resolve(repo)) : null;
|
|
92
|
+
const allBlocks = gateBlocks();
|
|
93
|
+
const blocks = here ? allBlocks.filter((b) => b.cwd === here) : allBlocks;
|
|
94
|
+
|
|
95
|
+
// Same gate, same event, wired both machine-wide AND by the plugin — it runs twice on every
|
|
96
|
+
// matching call. Harmless to correctness (these gates are idempotent) but it is real duplicated
|
|
97
|
+
// work, and counting it as two protections would inflate the only number on the card that matters.
|
|
98
|
+
const seen = new Map();
|
|
99
|
+
const duplicated = [];
|
|
100
|
+
for (const g of all) {
|
|
101
|
+
const k = `${g.event}:${g.name}`;
|
|
102
|
+
if (seen.has(k) && seen.get(k) !== g.source) { if (!duplicated.includes(g.name)) duplicated.push(g.name); }
|
|
103
|
+
seen.set(k, g.source);
|
|
104
|
+
}
|
|
105
|
+
const uniqueBlocking = new Set(blocking.map((g) => `${g.event}:${g.name}`)).size;
|
|
106
|
+
|
|
107
|
+
// Group the catches by gate so the card can say WHAT was caught, not just how many times.
|
|
108
|
+
const byGate = {};
|
|
109
|
+
for (const b of blocks) (byGate[b.gate] ||= []).push(b);
|
|
110
|
+
|
|
111
|
+
const weekAgo = Date.now() - 7 * 864e5;
|
|
112
|
+
const recent = blocks.filter((b) => Date.parse(b.at || 0) >= weekAgo);
|
|
113
|
+
|
|
114
|
+
// THREE NUMBERS, ONE UNIT. This block used to mix two: `blocking` was DEDUPLICATED
|
|
115
|
+
// (distinct event:name) while `advisory` was `all.length - blocking.length`, computed from the
|
|
116
|
+
// RAW array. So `blocking + advisory` fell short of `armed` by exactly the number of duplicated
|
|
117
|
+
// blocking wirings, and the console printed "N gates armed — B can stop a call. The other A add
|
|
118
|
+
// context" where B + A ≠ N. On a machine with 4 duplicate blocking gates the sentence silently
|
|
119
|
+
// lost four of them — in the one sentence whose entire job is to account for all of them.
|
|
120
|
+
//
|
|
121
|
+
// It summed correctly on any machine with no duplicates, which is why it survived: the defect was
|
|
122
|
+
// invisible exactly where it was most often looked at. Found by Fable 5, 2026-07-24, by adding up
|
|
123
|
+
// the numbers on the owner's own console.
|
|
124
|
+
//
|
|
125
|
+
// Fixed by reporting all three in WIRED-ENTRY units, so armed = blocking + advisory holds by
|
|
126
|
+
// construction. The distinct-gate count is still exported — it is genuinely the more meaningful
|
|
127
|
+
// number for "how many different things can refuse" — but under its own name, where it cannot be
|
|
128
|
+
// mistaken for a term in that sum.
|
|
129
|
+
const blockingWired = blocking.length;
|
|
130
|
+
return {
|
|
131
|
+
summary: {
|
|
132
|
+
armed: all.length,
|
|
133
|
+
blocking: blockingWired, // wired entries that can refuse — same unit as `armed`
|
|
134
|
+
advisory: all.length - blockingWired,
|
|
135
|
+
blockingDistinct: uniqueBlocking, // distinct gates that can refuse; ≤ blocking when wired twice
|
|
136
|
+
duplicated, // wired twice; runs twice
|
|
137
|
+
caughtTotal: blocks.length,
|
|
138
|
+
caughtThisWeek: recent.length,
|
|
139
|
+
everRecorded: blocks.length > 0,
|
|
140
|
+
},
|
|
141
|
+
gates: all.sort((a, b) => Number(b.blocking) - Number(a.blocking)),
|
|
142
|
+
// Newest first — the most recent catch is the one worth reading.
|
|
143
|
+
catches: blocks.slice(-12).reverse(),
|
|
144
|
+
byGate: Object.fromEntries(Object.entries(byGate).map(([k, v]) => [k, v.length])),
|
|
145
|
+
};
|
|
146
|
+
}
|