ruvnet-brain 4.0.4 → 4.0.5

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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # 🧠 RuvNet Brain
6
6
 
7
- ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.0.4 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.0.4-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
7
+ ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.0.5 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.0.5-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
8
8
 
9
9
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack — delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.0.4",
3
+ "version": "4.0.5",
4
4
  "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 69 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships an enforced UserPromptSubmit retrieve-and-inject grounding hook that sharply reduces drift.",
4
- "version": "4.0.4",
4
+ "version": "4.0.5",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.0.4",
3
+ "version": "4.0.5",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -0,0 +1,133 @@
1
+ // lesson-command-scope.mjs — pure advisory scope classification for lesson-gate.
2
+ //
3
+ // This is deliberately an allowlist of outside-repository mutations. Unknown commands stay quiet:
4
+ // over-firing an advisory trains users and agents to ignore it. Segments are classified only from
5
+ // executable position, so quoted examples and grep/echo text cannot manufacture a trigger.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ function repositoryRoot(start = process.cwd()) {
10
+ let dir = start;
11
+ for (let i = 0; i < 12; i++) {
12
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
13
+ const parent = path.dirname(dir);
14
+ if (parent === dir) break;
15
+ dir = parent;
16
+ }
17
+ return start;
18
+ }
19
+
20
+ function splitTopLevelSegments(command) {
21
+ const segments = [];
22
+ let current = '';
23
+ let quote = null;
24
+ for (let i = 0; i < command.length; i++) {
25
+ const char = command[i];
26
+ if (quote) {
27
+ current += char;
28
+ if (char === quote) quote = null;
29
+ continue;
30
+ }
31
+ if (char === '"' || char === "'") {
32
+ quote = char;
33
+ current += char;
34
+ continue;
35
+ }
36
+ if ((char === '&' && command[i + 1] === '&') || (char === '|' && command[i + 1] === '|')) {
37
+ segments.push(current);
38
+ current = '';
39
+ i++;
40
+ continue;
41
+ }
42
+ if (char === ';' || char === '&' || char === '|' || char === '\n') {
43
+ segments.push(current);
44
+ current = '';
45
+ continue;
46
+ }
47
+ current += char;
48
+ }
49
+ if (current.trim()) segments.push(current);
50
+ return segments.map((segment) => segment.trim()).filter(Boolean);
51
+ }
52
+
53
+ function tokenize(segment) {
54
+ const tokens = [];
55
+ let current = '';
56
+ let quote = null;
57
+ for (const char of segment) {
58
+ if (quote) {
59
+ if (char === quote) quote = null;
60
+ else current += char;
61
+ continue;
62
+ }
63
+ if (char === '"' || char === "'") {
64
+ quote = char;
65
+ continue;
66
+ }
67
+ if (/\s/.test(char)) {
68
+ if (current) tokens.push(current);
69
+ current = '';
70
+ continue;
71
+ }
72
+ current += char;
73
+ }
74
+ if (current) tokens.push(current);
75
+ return tokens;
76
+ }
77
+
78
+ const SYSTEM_PATH_PREFIXES = ['/etc', '/usr', '/bin', '/sbin', '/System', '/Library', '/private', '/var', '/opt'];
79
+ const INSTALL_VERBS = new Set(['install', 'i', 'add', 'uninstall', 'remove', 'rm', 'un']);
80
+ const GLOBAL_FLAGS = new Set(['-g', '--global']);
81
+ const SECURITY_MUTATING = new Set([
82
+ 'create-keychain', 'delete-keychain', 'set-keychain-password', 'set-keychain-settings',
83
+ 'unlock-keychain', 'lock-keychain', 'import', 'add-generic-password', 'add-internet-password',
84
+ 'delete-generic-password', 'delete-internet-password', 'default-keychain',
85
+ ]);
86
+ const BREW_MUTATING = new Set(['install', 'uninstall', 'remove', 'rm', 'upgrade', 'reinstall', 'tap', 'untap', 'link', 'unlink', 'pin', 'unpin', 'services']);
87
+ const PKG_MUTATING = new Set(['install', 'remove', 'purge', 'upgrade']);
88
+ const FS_MUTATING_VERBS = new Set(['rm', 'mv', 'cp', 'ln', 'shred', 'truncate', 'unlink']);
89
+
90
+ function curlMutates(tokens) {
91
+ for (let i = 1; i < tokens.length; i++) {
92
+ const token = tokens[i];
93
+ if ((token === '-X' || token === '--request') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes((tokens[i + 1] || '').toUpperCase())) return true;
94
+ if (/^--request=/.test(token) && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(token.split('=')[1].toUpperCase())) return true;
95
+ if (token === '-d' || token === '--data' || /^--data(-raw|-binary|-urlencode)?$/.test(token) || token === '--upload-file' || token === '-T') return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ function classifySegment(segment, repoRoot) {
101
+ let tokens = tokenize(segment);
102
+ while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens = tokens.slice(1);
103
+ if (!tokens.length) return false;
104
+ const lead = tokens[0];
105
+ const outsideRepo = (token) => token && !token.startsWith('-') && (
106
+ token.startsWith('~') || (token.startsWith('/') && token !== repoRoot && !token.startsWith(repoRoot + path.sep))
107
+ );
108
+ const systemPath = (token) => token && !token.startsWith('-')
109
+ && SYSTEM_PATH_PREFIXES.some((prefix) => token === prefix || token.startsWith(prefix + '/'));
110
+ if (lead === 'sudo') return true;
111
+ switch (lead) {
112
+ case 'launchctl': return true;
113
+ case 'security': return SECURITY_MUTATING.has(tokens[1]);
114
+ case 'defaults': return tokens[1] === 'write' || tokens[1] === 'delete';
115
+ case 'npm': case 'pnpm': case 'yarn': return INSTALL_VERBS.has(tokens[1]) && tokens.some((token) => GLOBAL_FLAGS.has(token));
116
+ case 'pip': case 'pip3': case 'pipx': return tokens[1] === 'install' || tokens[1] === 'uninstall';
117
+ case 'brew': return BREW_MUTATING.has(tokens[1]);
118
+ case 'gem': return tokens[1] === 'install' || tokens[1] === 'uninstall';
119
+ case 'apt': case 'apt-get': case 'yum': case 'dnf': case 'pacman': case 'port': return PKG_MUTATING.has(tokens[1]);
120
+ case 'git': return tokens[1] === 'push';
121
+ case 'curl': return curlMutates(tokens);
122
+ case 'wget': return tokens.some((token) => token.startsWith('--post-data') || token.startsWith('--post-file'));
123
+ case 'chmod': case 'chown': case 'chgrp': return tokens.slice(1).some(systemPath);
124
+ case 'dd': case 'mkfs': case 'diskutil': return true;
125
+ case 'crontab': return tokens[1] === '-e' || tokens[1] === '-r';
126
+ default: return FS_MUTATING_VERBS.has(lead) && tokens.slice(1).some(outsideRepo);
127
+ }
128
+ }
129
+
130
+ export function looksLikeOutsideRepoMutation(command, repoRoot = repositoryRoot()) {
131
+ if (!command || typeof command !== 'string') return false;
132
+ return splitTopLevelSegments(command).some((segment) => classifySegment(segment, repoRoot));
133
+ }
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lesson-gate.mjs — canonical plugin-payload wire that makes a stored lesson change behaviour.
4
+ *
5
+ * THIS IS THE L3 STEP. ADR-029 mines which lessons are universal; ADR-030 says a lesson must
6
+ * INTERRUPT at a decision point or it is prose. Both shipped. And nothing read the store: a grep for
7
+ * `lessonsFor` across every gate returned zero. Lessons were written, schema-validated, weighted,
8
+ * trust-boundaried — and consumed by nobody.
9
+ *
10
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
11
+ * WHAT CHANGED, 2026-07-22, and it is two separate corrections that happen to point the same way.
12
+ *
13
+ * CORRECTION 1 — THE BLOCK NEVER BLOCKED. Two independent reviewers found it; running it confirmed
14
+ * it. The previous version printed the word "BLOCKED" and then allowed the action, in three
15
+ * compounding ways:
16
+ *
17
+ * scripts/lesson-gate.mjs:86 exited 1, not 2. Exit 1 is a NON-BLOCKING error: the live hooks
18
+ * doc says other non-zero codes show a "hook error" notice to the
19
+ * USER and "execution continues". Only exit 2 refuses anything.
20
+ * scripts/lesson-gate.mjs 15 console.log, 0 console.error. On exit 2 the doc is explicit:
21
+ * "Claude Code ignores stdout... stderr text is fed back to
22
+ * Claude". The refusal reason went to the one stream a refusal
23
+ * cannot use.
24
+ * plugin/scripts/lesson-hooks.sh `|| true` then exit 0 — discarding whatever code did survive.
25
+ *
26
+ * Measured before the fix: `bash plugin/scripts/lesson-hooks.sh Stop` → printed "⛔ BLOCKED", exit 0.
27
+ *
28
+ * ADR-028 claimed "five gates exit 1 and refuse the action — proven by exit code". That proof was
29
+ * obtained by running this file BY HAND on a terminal, which is the one caller that is not a hook.
30
+ * The exit code was real; the claim that it blocked anything was not. This is L01 — verify through a
31
+ * channel CAPABLE of observing the change — violated by the very file that enforces L01.
32
+ *
33
+ * CORRECTION 2 — AND WE DO NOT WANT IT TO BLOCK. The owner, the same day:
34
+ *
35
+ * "Nudging somebody is very fair. Forcing them through a gate is not."
36
+ * "That respect for the individual and how they do it is a big part of the win."
37
+ *
38
+ * So the fix is NOT to turn six silent blocks into six real ones. That would ship, for the first
39
+ * time, the product the owner has just rejected — and it would land on existing users as a machine
40
+ * that suddenly started refusing work it accepted yesterday. Every ratified `block` lesson is now a
41
+ * NUDGE. Blocking is a per-lesson decision the USER makes, in a file only the user writes.
42
+ *
43
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
44
+ * THE CONTRACT, verified against code.claude.com/docs/en/hooks on 2026-07-22 rather than recalled:
45
+ *
46
+ * NUDGE → exit 0 + JSON `hookSpecificOutput.additionalContext` on stdout. Informs, never refuses.
47
+ * BLOCK → exit 2 + reason on stderr. Refuses. Opt-in per lesson, by the user, only.
48
+ *
49
+ * The nudge channel is NOT stderr, and this is the subtle part that a plausible-sounding design got
50
+ * wrong twice. On exit 0 the doc says stdout "is written to the debug log but not shown in the
51
+ * transcript" for most events, with only UserPromptSubmit / UserPromptExpansion / SessionStart as
52
+ * exceptions — and it says nothing about exit-0 stderr at all, because exit-0 stderr is not a
53
+ * delivery channel. A nudge written to stderr at Stop or PreToolUse reaches NOBODY: it would have
54
+ * been the identical built-tested-unwired defect, rebuilt one file to the left.
55
+ *
56
+ * What actually works, quoted from the live doc:
57
+ *
58
+ * "The `additionalContext` field passes a string from your hook into Claude's context window.
59
+ * Claude Code wraps the string in a system reminder and inserts it into the conversation at the
60
+ * point where the hook fired."
61
+ *
62
+ * and it is supported at every event this gate fires on — PreToolUse, Stop, UserPromptSubmit
63
+ * included. (An adversarial review asserted a non-blocking nudge at Stop was IMPOSSIBLE because Stop
64
+ * accepts only `decision: "block"`. The live doc contradicts it: "Stop and SubagentStop also accept
65
+ * hookSpecificOutput.additionalContext for non-error feedback that continues the conversation." The
66
+ * reviewer was reasoning from an older contract. Checked, not assumed — which is the whole rule.)
67
+ *
68
+ * That gives a nudge everything the block was supposed to have and the one thing it should not:
69
+ * it reaches the model, at the decision point, carrying the user's own words — and it refuses nothing.
70
+ *
71
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
72
+ * DESIGN CONSTRAINT, unchanged and load-bearing: a gate must never break the thing it guards. Any
73
+ * failure here — missing store, corrupt JSON, unreadable file — exits 0 silently. A lesson gate that
74
+ * blocked a push because it could not read a config file would be worse than no lesson gate, and
75
+ * would be switched off within a day, which is how every over-eager gate dies.
76
+ */
77
+ import fs from 'node:fs';
78
+ import path from 'node:path';
79
+ import {
80
+ CONFIG_ROOT,
81
+ loadLessons,
82
+ lessonsFor,
83
+ ENFORCEMENT,
84
+ STATUS,
85
+ ORIGIN,
86
+ TRIGGERS,
87
+ } from './lesson-store.mjs';
88
+ import { looksLikeOutsideRepoMutation } from './lesson-command-scope.mjs';
89
+ import { buildLessonPresentation } from './lesson-presentation.mjs';
90
+ export { looksLikeOutsideRepoMutation } from './lesson-command-scope.mjs';
91
+
92
+ // The two codes that mean something to the harness. Anything else is an error, and an error here
93
+ // must never be mistaken for a refusal — see ALLOW-on-failure throughout.
94
+ const EXIT_ALLOW = 0;
95
+ const EXIT_BLOCK = 2;
96
+
97
+ const argv = process.argv.slice(2);
98
+ const arg = (f, d = null) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
99
+ // --trigger is REPEATABLE. One real event carries several decision points at once (ending a turn is
100
+ // simultaneously "reporting status" and "claiming done"), and they must resolve to ONE verdict and
101
+ // ONE JSON object — two JSON documents on stdout is not JSON, and two node spawns on every event is
102
+ // latency on the hot path for no gain.
103
+ const allArgs = (f) => argv.reduce((acc, v, i) => (v === f && argv[i + 1] ? [...acc, argv[i + 1]] : acc), []);
104
+
105
+ const triggers = allArgs('--trigger');
106
+ const event = arg('--event'); // the real Claude Code event name → hook mode
107
+ const quiet = argv.includes('--quiet');
108
+ const json = argv.includes('--json');
109
+ // Session identity for the per-session frequency cap (below). The dispatcher passes the harness's
110
+ // real session_id; a caller that predates this (or a manual run) gets a cwd+day fallback so the cap
111
+ // is still BOUNDED — at worst it repeats an advisory once per project per day — rather than unbounded.
112
+ const session = arg('--session');
113
+ // NOT `arg('--command')`: that helper treats a falsy VALUE the same as an ABSENT flag
114
+ // (`argv[i+1] ? ... : d`), so a real event whose command happens to be "" would silently fall back to
115
+ // the unfiltered default — precisely the false-nag path this fix exists to close. Presence of the flag,
116
+ // not truthiness of its value, is what distinguishes "an old caller that never learned about this" from
117
+ // "the dispatcher, telling us the command text (however short)".
118
+ const commandIdx = argv.indexOf('--command');
119
+ const command = commandIdx >= 0 ? (argv[commandIdx + 1] ?? '') : null;
120
+
121
+ // CANDIDATE MODE (ADR-040 / DDD-0004 "the enforcement chokepoint"). Set by unprompted-runtime.mjs on
122
+ // every producer child. When on, hook mode writes ZERO user-facing bytes and NEVER exits 2 itself:
123
+ // it emits ONE JSON candidate per line on stdout and lets the runtime — the SOLE writer of user bytes
124
+ // — turn a `block` candidate into the real exit 2 + stderr and an `advisory` candidate into the
125
+ // nudge. Unset (every direct/legacy/CLI invocation, and every existing test), behaviour is byte-for-
126
+ // byte unchanged, exit-2 block semantics included. Purely additive.
127
+ const EMIT_CANDIDATES = process.env.RUVNET_EMIT_CANDIDATES === '1';
128
+
129
+ /**
130
+ * THE CONSENT FILE — where "yes, actually refuse me" is recorded, and why it is not in the lesson store.
131
+ *
132
+ * Two reasons, and the first is a measured fact rather than a preference:
133
+ *
134
+ * 1. A per-lesson flag in the store WOULD NOT SURVIVE. `makeLesson()` destructures a fixed field set
135
+ * and returns a frozen object built from exactly those fields; any unknown key is silently
136
+ * dropped on load. Verified 2026-07-22 by construction — `makeLesson({...,userOptedIntoBlocking:
137
+ * true})` returns an object without it. A consent flag stored there would read back as absent on
138
+ * every load, i.e. consent that silently evaporates, which is the worst possible failure mode for
139
+ * a consent record.
140
+ *
141
+ * 2. It is the right trust boundary anyway. `lessons.json` is written by the MINING pipeline —
142
+ * unattended, over session transcripts, over content that may have been planted (ADR-031's whole
143
+ * subject). If "may block" lived in that file, anything that could get a lesson mined could grant
144
+ * itself the power to refuse the user's work. It lives in a file the pipeline never writes.
145
+ *
146
+ * Honest about what this is NOT: it is not a defence against a model that can run shell commands —
147
+ * such a model can write any file on the machine. It is a boundary against MINED and IMPORTED
148
+ * content reaching enforcement, which is the path the adversarial review actually found.
149
+ *
150
+ * The empty default is the point: no file, no blocking. Consent is never inferred from silence.
151
+ */
152
+ export const OPTIN_PATH = process.env.RUVNET_LESSON_OPTIN
153
+ || path.join(CONFIG_ROOT, 'blocking-optin.json');
154
+
155
+ if (!triggers.length) {
156
+ console.log('lesson-gate — surface the lessons in force at a decision point\n');
157
+ console.log(' --trigger <key> one of: ' + Object.values(TRIGGERS).map((t) => t.key).join(', '));
158
+ console.log(' repeatable; one event may carry several decision points');
159
+ console.log(' --event <name> Claude Code event (Stop, PreToolUse, UserPromptSubmit) → hook mode:');
160
+ console.log(' nudges emit JSON additionalContext (exit 0), blocks emit stderr (exit 2)');
161
+ console.log(' --json machine-readable');
162
+ console.log(' --quiet print nothing; exit code only\n');
163
+ console.log(' blocking is OPT-IN per lesson: ' + OPTIN_PATH);
164
+ process.exit(EXIT_ALLOW);
165
+ }
166
+
167
+ function loadBlockingOptIn(file = OPTIN_PATH) {
168
+ try {
169
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
170
+ // Tolerant of both shapes because a human is expected to hand-edit this: a bare array reads
171
+ // fine, and so does the documented object. Being fussy about a consent file's punctuation would
172
+ // silently downgrade someone's explicit "yes" to a "no".
173
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw?.blocking) ? raw.blocking : [];
174
+ return new Set(list.filter((x) => typeof x === 'string' && x.length));
175
+ } catch { return new Set(); } // absent or unparseable → nobody blocks. Never fail INTO refusing.
176
+ }
177
+
178
+ let lessons = [];
179
+ try { lessons = loadLessons(); } catch { process.exit(EXIT_ALLOW); } // never break the caller
180
+
181
+ const optedIn = loadBlockingOptIn();
182
+
183
+ /**
184
+ * THE PER-SESSION FREQUENCY CAP — because a true advisory repeated verbatim on every matching event is
185
+ * the nag ADR-030 bans (measured 2026-07-22: the mutate-machine advisory rendered on every Bash call of
186
+ * a session). anticipate.sh already caps its own nudges per session; this is the same discipline for the
187
+ * lesson gate. A PURE-ADVISORY lesson is shown at most MAX_SHOWS times per session, then stays silent
188
+ * until a new session.
189
+ *
190
+ * THE LOAD-BEARING INVARIANT: an actual REFUSAL is never capped. A lesson the user has opted into as a
191
+ * block (isBlocking, below) exits 2 and refuses the action — the cap must never touch it. But a merely
192
+ * block-CAPABLE lesson the user has NOT opted into renders as an ADVISORY (exit 0): it is a reminder, not
193
+ * a refusal, and it is capped like any other advisory. An earlier version exempted block-capable lessons
194
+ * too — which left exactly the lesson doing the nagging (the block-capable "gate on blast radius", never
195
+ * opted in) repeating unbounded on every mutating command; an independent regrade caught it. Capping a
196
+ * block-capable ADVISORY silences no refusal: a refusal exits 2 regardless of this file's display budget,
197
+ * and the one-time "you could turn this into a refusal" offer needs to be seen a few times, not forever.
198
+ *
199
+ * FAIL-OPEN: any error reading or writing the state degrades to the pre-cap behaviour (show it), never to
200
+ * suppression — a gate that goes quiet because it could not read a JSON file is worse than a repeat.
201
+ */
202
+ const GATE_STATE_PATH = process.env.RUVNET_LESSON_GATE_STATE
203
+ || path.join(CONFIG_ROOT, 'lesson-gate-state.json');
204
+ const MAX_SHOWS = (() => {
205
+ const n = Number(process.env.RUVNET_LESSON_MAX_SHOWS);
206
+ return Number.isInteger(n) && n > 0 ? n : 3;
207
+ })();
208
+ const KEEP_SESSIONS = 20; // bound the state file to the most-recent sessions, same as anticipate.sh
209
+ const SID = (typeof session === 'string' && session.trim())
210
+ ? session.trim()
211
+ : `fallback:${process.cwd()}:${new Date().toISOString().slice(0, 10)}`;
212
+ /**
213
+ * BLOCKING = four conditions, all required; the user's opt-in is necessary and NOT sufficient. Defined
214
+ * here (rather than at the emit site) because the frequency cap below must key its exemption on it: the
215
+ * last two conditions are also guaranteed by makeLesson, and are re-asserted deliberately — this is the
216
+ * one place the answer is "refuse the human's work", and a security invariant enforced only at a distance
217
+ * is one refactor from being enforced nowhere.
218
+ */
219
+ const isBlocking = (l) => optedIn.has(l.id)
220
+ && l.enforcement === ENFORCEMENT.BLOCK
221
+ && (l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE)
222
+ && l.origin === ORIGIN.USER_STATED;
223
+ /** Exempt from the cap: ONLY a lesson that refuses RIGHT NOW (an opted-in block, exit 2). A block-capable
224
+ * lesson that is not opted in is an advisory and is capped like any other — see the invariant above. */
225
+ const capExempt = isBlocking;
226
+ function readGateState() {
227
+ try { const s = JSON.parse(fs.readFileSync(GATE_STATE_PATH, 'utf8')); return s && typeof s === 'object' ? s : {}; }
228
+ catch { return {}; }
229
+ }
230
+ function writeGateState(st) {
231
+ try {
232
+ fs.mkdirSync(path.dirname(GATE_STATE_PATH), { recursive: true });
233
+ fs.writeFileSync(GATE_STATE_PATH, JSON.stringify(st));
234
+ return true;
235
+ } catch { return false; }
236
+ }
237
+ /** How many times THIS session has already surfaced a given advisory lesson (0 if never). */
238
+ function shownCount(st, id) {
239
+ const c = st?.sessions?.[SID]?.shown?.[id];
240
+ return Number.isInteger(c) && c > 0 ? c : 0;
241
+ }
242
+ // Read ONCE, up front — the same snapshot gates the filter and seeds the write below.
243
+ const gateState = event ? readGateState() : {};
244
+
245
+ // Merge every requested decision point into one ranked, de-duplicated list. A lesson registered at
246
+ // two triggers must appear once, or the model reads the same correction twice and learns to skim.
247
+ /**
248
+ * PROJECT SCOPE — a lesson learned in one project has no standing to interrupt work in another.
249
+ *
250
+ * THE BREAKAGE, 2026-07-22: these hooks were installed machine-wide and 8 of 16 lessons were scoped
251
+ * to a SINGLE project, yet fired everywhere. A WhitSentry session was being told about
252
+ * ruvnet-brain's stop-and-report habit on every prompt. The owner's report was blunt: "I've got
253
+ * other repos that are using this thing, and they're breaking."
254
+ *
255
+ * The rule is ADR-029's own promotion bar applied at read time: cross-project rediscovery is what
256
+ * makes a lesson universal. Taught in ONE project, it is local knowledge — real, worth keeping, and
257
+ * not entitled to speak elsewhere. Taught in two or more, it has earned the right to travel.
258
+ *
259
+ * This is P3 (nudge, never force) and P4 (the user is the arbiter) applied to OUR OWN footprint:
260
+ * the fastest way to make someone uninstall a nudge is to nudge them about something that has
261
+ * nothing to do with what they are doing.
262
+ */
263
+ const HERE = (() => {
264
+ let d = process.cwd();
265
+ for (let i = 0; i < 12; i++) {
266
+ if (fs.existsSync(path.join(d, '.git'))) break;
267
+ const up = path.dirname(d);
268
+ if (up === d) { d = process.cwd(); break; }
269
+ d = up;
270
+ }
271
+ return path.basename(d);
272
+ })();
273
+ /** Does this lesson belong to the project we are standing in? Match is loose on purpose — stored
274
+ * names carry prefixes like `Code-` that the directory name does not. */
275
+ const isHome = (l) => {
276
+ const ps = Array.isArray(l.projects) ? l.projects : [];
277
+ if (!ps.length) return true; // unscoped: applies anywhere, by declaration
278
+ return ps.some((p) => {
279
+ const n = String(p).replace(/^Code-/, '');
280
+ return n === HERE || String(p) === HERE || HERE.endsWith(n) || n.endsWith(HERE);
281
+ });
282
+ };
283
+ const isUniversal = (l) => Array.isArray(l.projects) && l.projects.length >= 2;
284
+
285
+ // Apply the mutate-machine predicate defined above. `mutate-machine` is requested for EVERY Bash
286
+ // call (plugin/scripts/lesson-hooks.sh:98) — it is the ONLY trigger the dispatcher fires unconditionally
287
+ // on a tool, so it is the only one that needs narrowing here. When no `--command` was supplied (a bare
288
+ // CLI invocation, or a caller that predates this fix), behavior is UNCHANGED — fail open to the old,
289
+ // unfiltered behavior rather than silently swallow a trigger nobody asked to have filtered.
290
+ const MUTATE_KEY = TRIGGERS.MUTATE_MACHINE.key;
291
+ const effectiveTriggers = command === null
292
+ ? triggers
293
+ : triggers.filter((t) => t !== MUTATE_KEY || looksLikeOutsideRepoMutation(command));
294
+
295
+ const seen = new Set();
296
+ const candidates = [];
297
+ for (const t of effectiveTriggers) {
298
+ for (const l of lessonsFor(t, lessons, { limit: 3 })) {
299
+ if (seen.has(l.id)) continue;
300
+ // Away from home, only a lesson with cross-project evidence may speak.
301
+ if (!isHome(l) && !isUniversal(l)) continue;
302
+ seen.add(l.id); candidates.push(l);
303
+ }
304
+ }
305
+
306
+ const NUDGE_CHAR_BUDGET = Number(process.env.RUVNET_NUDGE_BUDGET) || 1200;
307
+ const presentation = buildLessonPresentation({
308
+ candidates,
309
+ event,
310
+ shownCount: (id) => shownCount(gateState, id),
311
+ isBlocking,
312
+ triggers,
313
+ optInPath: OPTIN_PATH,
314
+ maxShows: MAX_SHOWS,
315
+ nudgeBudget: NUDGE_CHAR_BUDGET,
316
+ });
317
+ const { inForce, blocking, blockCapable, body: renderedBody, advisoryContext } = presentation;
318
+ const renderBody = () => renderedBody;
319
+
320
+ // ── Emit ─────────────────────────────────────────────────────────────────────────────────────────
321
+
322
+ if (json) {
323
+ console.log(JSON.stringify({
324
+ triggers, event: event ?? null, inForce,
325
+ blocking: blocking.map((l) => l.id),
326
+ blockCapable: blockCapable.map((l) => l.id),
327
+ optInPath: OPTIN_PATH,
328
+ }, null, 2));
329
+ process.exit(blocking.length ? EXIT_BLOCK : EXIT_ALLOW);
330
+ }
331
+
332
+ if (event) {
333
+ // RECORD what this session is about to SURFACE, so the frequency cap can act next time. Only
334
+ // pure-advisory lessons count toward their own cap; block-capable lessons are exempt (capExempt) and
335
+ // never recorded. Skipped when nothing will render (quiet with no block). Best-effort and fail-open —
336
+ // a lost write repeats an advisory once more, it never suppresses one. Persisted BEFORE the streams
337
+ // are touched, so a crash mid-emit under-counts (safe) rather than over-counts.
338
+ const willRender = blocking.length > 0 || (inForce.length > 0 && !quiet);
339
+ if (willRender) {
340
+ const st = gateState && typeof gateState === 'object' ? gateState : {};
341
+ st.sessions = st.sessions && typeof st.sessions === 'object' ? st.sessions : {};
342
+ const prev = st.sessions[SID] && typeof st.sessions[SID] === 'object' ? st.sessions[SID] : {};
343
+ const shown = prev.shown && typeof prev.shown === 'object' ? { ...prev.shown } : {};
344
+ for (const l of inForce) {
345
+ if (capExempt(l)) continue;
346
+ shown[l.id] = (Number.isInteger(shown[l.id]) && shown[l.id] > 0 ? shown[l.id] : 0) + 1;
347
+ }
348
+ st.sessions[SID] = { shown, ts: Date.now() };
349
+ // Bound the file to the most-recent sessions, same discipline as anticipate.sh.
350
+ st.sessions = Object.fromEntries(
351
+ Object.entries(st.sessions).sort((a, b) => (b[1]?.ts || 0) - (a[1]?.ts || 0)).slice(0, KEEP_SESSIONS),
352
+ );
353
+ writeGateState(st);
354
+ }
355
+
356
+ // CANDIDATE MODE — emit JSON candidates, let the runtime own the real streams and the exit code.
357
+ // A block DOMINATES exactly as in the stream contract below: when any opted-in block is in force we
358
+ // emit only the block candidate and no advisory. The `copy` of each candidate is byte-identical to
359
+ // what the legacy path would have written (renderBody() to stderr for a block; the advisory preamble
360
+ // + renderBody() as additionalContext for a nudge), so the runtime's delivered bytes match. The
361
+ // frequency-cap persist above already ran, so persist-before-speak holds here too.
362
+ if (EMIT_CANDIDATES) {
363
+ if (blocking.length) {
364
+ process.stdout.write(JSON.stringify({
365
+ channel: 'lesson', effect: 'block', copy: renderBody(), hookEventName: event,
366
+ }) + '\n');
367
+ } else if (inForce.length && !quiet) {
368
+ process.stdout.write(JSON.stringify({
369
+ channel: 'lesson', effect: 'advisory', hookEventName: event,
370
+ copy: advisoryContext,
371
+ }) + '\n');
372
+ }
373
+ process.exit(EXIT_ALLOW);
374
+ }
375
+
376
+ // HOOK MODE — the streams are the contract, so nothing else may touch them.
377
+ if (blocking.length) {
378
+ // Exit 2: stdout is ignored by the harness, stderr becomes the model's error message. Writing
379
+ // the reason anywhere but stderr is exactly the bug this file exists to fix.
380
+ process.stderr.write(renderBody() + '\n');
381
+ process.exit(EXIT_BLOCK);
382
+ }
383
+ if (inForce.length && !quiet) {
384
+ // Exit 0 + additionalContext: reaches the model, at the decision point, refusing nothing.
385
+ // hookEventName MUST name the firing event or the harness discards the envelope.
386
+ process.stdout.write(JSON.stringify({
387
+ hookSpecificOutput: {
388
+ hookEventName: event,
389
+ additionalContext: advisoryContext,
390
+ },
391
+ }));
392
+ }
393
+ process.exit(EXIT_ALLOW);
394
+ }
395
+
396
+ // ── CLI MODE (no --event) ────────────────────────────────────────────────────────────────────────
397
+ // Plain text on stdout, unchanged. version-bump-gate.sh captures this stdout verbatim and appends it
398
+ // to its own refusal under "── from your own lesson store ──"; changing the stream or the shape here
399
+ // would silently empty that section of the only gate in the system that genuinely works.
400
+ if (!quiet && inForce.length) console.log(renderBody());
401
+ process.exit(blocking.length ? EXIT_BLOCK : EXIT_ALLOW);