ruvnet-brain 4.0.4 → 4.0.6

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.
@@ -1,680 +1,4 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * lesson-gate.mjs — the wire that makes a stored lesson actually 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 os from 'node:os';
79
- import path from 'node:path';
80
- import { loadLessons, lessonsFor, ENFORCEMENT, STATUS, ORIGIN, TRIGGERS } from './lesson-store.mjs';
81
-
82
- // The two codes that mean something to the harness. Anything else is an error, and an error here
83
- // must never be mistaken for a refusal — see ALLOW-on-failure throughout.
84
- const EXIT_ALLOW = 0;
85
- const EXIT_BLOCK = 2;
86
-
87
- const argv = process.argv.slice(2);
88
- const arg = (f, d = null) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
89
- // --trigger is REPEATABLE. One real event carries several decision points at once (ending a turn is
90
- // simultaneously "reporting status" and "claiming done"), and they must resolve to ONE verdict and
91
- // ONE JSON object — two JSON documents on stdout is not JSON, and two node spawns on every event is
92
- // latency on the hot path for no gain.
93
- const allArgs = (f) => argv.reduce((acc, v, i) => (v === f && argv[i + 1] ? [...acc, argv[i + 1]] : acc), []);
94
-
95
- const triggers = allArgs('--trigger');
96
- const event = arg('--event'); // the real Claude Code event name → hook mode
97
- const quiet = argv.includes('--quiet');
98
- const json = argv.includes('--json');
99
- // Session identity for the per-session frequency cap (below). The dispatcher passes the harness's
100
- // real session_id; a caller that predates this (or a manual run) gets a cwd+day fallback so the cap
101
- // is still BOUNDED — at worst it repeats an advisory once per project per day — rather than unbounded.
102
- const session = arg('--session');
103
- // NOT `arg('--command')`: that helper treats a falsy VALUE the same as an ABSENT flag
104
- // (`argv[i+1] ? ... : d`), so a real event whose command happens to be "" would silently fall back to
105
- // the unfiltered default — precisely the false-nag path this fix exists to close. Presence of the flag,
106
- // not truthiness of its value, is what distinguishes "an old caller that never learned about this" from
107
- // "the dispatcher, telling us the command text (however short)".
108
- const commandIdx = argv.indexOf('--command');
109
- const command = commandIdx >= 0 ? (argv[commandIdx + 1] ?? '') : null;
110
-
111
- // CANDIDATE MODE (ADR-040 / DDD-0004 "the enforcement chokepoint"). Set by unprompted-runtime.mjs on
112
- // every producer child. When on, hook mode writes ZERO user-facing bytes and NEVER exits 2 itself:
113
- // it emits ONE JSON candidate per line on stdout and lets the runtime — the SOLE writer of user bytes
114
- // — turn a `block` candidate into the real exit 2 + stderr and an `advisory` candidate into the
115
- // nudge. Unset (every direct/legacy/CLI invocation, and every existing test), behaviour is byte-for-
116
- // byte unchanged, exit-2 block semantics included. Purely additive.
117
- const EMIT_CANDIDATES = process.env.RUVNET_EMIT_CANDIDATES === '1';
118
-
119
- /**
120
- * THE MUTATE-MACHINE PREDICATE — narrows "about to change something outside this repo" to commands
121
- * that plausibly do that, instead of firing on every Bash call.
122
- *
123
- * THE BUG, found by an independent grader reading real session transcripts (2026-07-22/23): the
124
- * dispatcher maps `PreToolUse-bash` → `--trigger mutate-machine` UNCONDITIONALLY —
125
- * plugin/scripts/lesson-hooks.sh:98 — with no inspection of the command at all. Not a weak keyword
126
- * match: NO match. Every `ls`, `grep`, `wc`, `git status`, `git rev-parse` fired the identical L07
127
- * advisory, ~10x/session verbatim. A true finding repeated on false triggers is exactly the nagging
128
- * ADR-030's own P3 (nudge, never force) exists to prevent — a correct lesson trains itself to be
129
- * ignored by firing when it has nothing to say.
130
- *
131
- * THE FIX is an ALLOWLIST OF MUTATING PATTERNS, not a read-only allowlist — chosen because the
132
- * trigger's own label is "about to change something", so the honest default for an unrecognized
133
- * command is SILENCE, not suspicion. Under-firing on some obscure mutating command is the safe
134
- * failure mode for an advisory nudge; over-firing is the bug this whole fix exists to close.
135
- *
136
- * Every pattern is anchored to COMMAND POSITION (the leading word of a shell segment), never a bare
137
- * substring search — the same discipline verify-interface.sh already uses and for the identical
138
- * reason: an unanchored match fires on `grep -r "npm install -g" .` or `echo "curl -X POST"`, which
139
- * would reintroduce the exact false-positive nagging this fix removes, just spelled differently.
140
- *
141
- * Known, accepted limitation: command substitution (`$(rm -rf ~/x)`) and path traversal (`../../etc`)
142
- * are not resolved — this is a heuristic for an ADVISORY nudge, not a security boundary. It is
143
- * layered on top of the existing consent/ratification trust boundary (ORIGIN/STATUS/opt-in above),
144
- * which is where the real security property already lives.
145
- */
146
- const REPO_ROOT = (() => {
147
- let d = process.cwd();
148
- for (let i = 0; i < 12; i++) {
149
- if (fs.existsSync(path.join(d, '.git'))) return d;
150
- const up = path.dirname(d);
151
- if (up === d) break;
152
- d = up;
153
- }
154
- return process.cwd();
155
- })();
156
-
157
- /** Split on top-level `;`, `&&`, `||`, `&`, `|`, newline — NOT inside single/double quotes. One
158
- * compound command ("cmd1 && rm -rf ~/x") must be judged by its most dangerous segment, not its first. */
159
- function splitTopLevelSegments(cmd) {
160
- const segments = [];
161
- let cur = '';
162
- let quote = null;
163
- for (let i = 0; i < cmd.length; i++) {
164
- const c = cmd[i];
165
- if (quote) { cur += c; if (c === quote) quote = null; continue; }
166
- if (c === '"' || c === "'") { quote = c; cur += c; continue; }
167
- if ((c === '&' && cmd[i + 1] === '&') || (c === '|' && cmd[i + 1] === '|')) { segments.push(cur); cur = ''; i++; continue; }
168
- if (c === ';' || c === '&' || c === '|' || c === '\n') { segments.push(cur); cur = ''; continue; }
169
- cur += c;
170
- }
171
- if (cur.trim()) segments.push(cur);
172
- return segments.map((s) => s.trim()).filter(Boolean);
173
- }
174
-
175
- /** Whitespace tokenizer that keeps a quoted argument ("a path with spaces") as ONE token. */
176
- function tokenize(segment) {
177
- const tokens = [];
178
- let cur = '';
179
- let quote = null;
180
- for (let i = 0; i < segment.length; i++) {
181
- const c = segment[i];
182
- if (quote) { if (c === quote) quote = null; else cur += c; continue; }
183
- if (c === '"' || c === "'") { quote = c; continue; }
184
- if (/\s/.test(c)) { if (cur) { tokens.push(cur); cur = ''; } continue; }
185
- cur += c;
186
- }
187
- if (cur) tokens.push(cur);
188
- return tokens;
189
- }
190
-
191
- const SYSTEM_PATH_PREFIXES = ['/etc', '/usr', '/bin', '/sbin', '/System', '/Library', '/private', '/var', '/opt'];
192
- /** A path argument that targets a system-wide location — "chmod 777 /etc/x", never "chmod +x ./run.sh". */
193
- function isSystemAbsolutePath(tok) {
194
- if (!tok || tok.startsWith('-')) return false;
195
- return SYSTEM_PATH_PREFIXES.some((p) => tok === p || tok.startsWith(p + '/'));
196
- }
197
- /** A path argument that resolves OUTSIDE this repo — a bare `~` reference, or an absolute path that
198
- * is not rooted under REPO_ROOT. A relative path ("./tmp", "build") is inside the repo by construction. */
199
- function isOutsideRepoPath(tok) {
200
- if (!tok || tok.startsWith('-')) return false;
201
- if (tok.startsWith('~')) return true;
202
- if (tok.startsWith('/')) return tok !== REPO_ROOT && !tok.startsWith(REPO_ROOT + path.sep);
203
- return false;
204
- }
205
- /** curl mutates when it names a non-GET verb or attaches a request body — "-X POST", "--data", etc. */
206
- function curlMutates(tokens) {
207
- for (let i = 1; i < tokens.length; i++) {
208
- const t = tokens[i];
209
- if ((t === '-X' || t === '--request') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes((tokens[i + 1] || '').toUpperCase())) return true;
210
- if (/^--request=/.test(t) && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(t.split('=')[1].toUpperCase())) return true;
211
- if (t === '-d' || t === '--data' || /^--data(-raw|-binary|-urlencode)?$/.test(t) || t === '--upload-file' || t === '-T') return true;
212
- }
213
- return false;
214
- }
215
-
216
- const INSTALL_VERBS = new Set(['install', 'i', 'add', 'uninstall', 'remove', 'rm', 'un']);
217
- const GLOBAL_FLAGS = new Set(['-g', '--global']);
218
- const SECURITY_MUTATING = new Set([
219
- 'create-keychain', 'delete-keychain', 'set-keychain-password', 'set-keychain-settings',
220
- 'unlock-keychain', 'lock-keychain', 'import', 'add-generic-password', 'add-internet-password',
221
- 'delete-generic-password', 'delete-internet-password', 'default-keychain',
222
- ]);
223
- const BREW_MUTATING = new Set(['install', 'uninstall', 'remove', 'rm', 'upgrade', 'reinstall', 'tap', 'untap', 'link', 'unlink', 'pin', 'unpin', 'services']);
224
- const PKG_MUTATING = new Set(['install', 'remove', 'purge', 'upgrade']);
225
- const FS_MUTATING_VERBS = new Set(['rm', 'mv', 'cp', 'ln', 'shred', 'truncate', 'unlink']);
226
-
227
- /** One shell segment → does its LEADING command plausibly mutate something outside this repo? */
228
- function classifySegment(segment) {
229
- let tokens = tokenize(segment);
230
- while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens = tokens.slice(1); // FOO=bar cmd
231
- if (!tokens.length) return false;
232
- const lead = tokens[0];
233
- if (lead === 'sudo') return true; // elevated privilege is outside-repo blast radius by definition
234
- switch (lead) {
235
- case 'launchctl': return true; // any subcommand — LaunchAgents/system services, never repo-scoped
236
- case 'security': return SECURITY_MUTATING.has(tokens[1]);
237
- case 'defaults': return tokens[1] === 'write' || tokens[1] === 'delete';
238
- case 'npm': case 'pnpm': case 'yarn':
239
- return INSTALL_VERBS.has(tokens[1]) && tokens.some((t) => GLOBAL_FLAGS.has(t));
240
- case 'pip': case 'pip3': case 'pipx':
241
- return tokens[1] === 'install' || tokens[1] === 'uninstall';
242
- case 'brew': return BREW_MUTATING.has(tokens[1]);
243
- case 'gem': return tokens[1] === 'install' || tokens[1] === 'uninstall';
244
- case 'apt': case 'apt-get': case 'yum': case 'dnf': case 'pacman': case 'port':
245
- return PKG_MUTATING.has(tokens[1]);
246
- case 'git': return tokens[1] === 'push';
247
- case 'curl': return curlMutates(tokens);
248
- case 'wget': return tokens.some((t) => t.startsWith('--post-data') || t.startsWith('--post-file'));
249
- case 'chmod': case 'chown': case 'chgrp':
250
- return tokens.slice(1).some(isSystemAbsolutePath);
251
- case 'dd': case 'mkfs': case 'diskutil': return true;
252
- case 'crontab': return tokens[1] === '-e' || tokens[1] === '-r';
253
- default:
254
- return FS_MUTATING_VERBS.has(lead) && tokens.slice(1).some(isOutsideRepoPath);
255
- }
256
- }
257
-
258
- /** Exported for the test suite: does this WHOLE command plausibly mutate something outside the repo? */
259
- export function looksLikeOutsideRepoMutation(cmd) {
260
- if (!cmd || typeof cmd !== 'string') return false;
261
- return splitTopLevelSegments(cmd).some(classifySegment);
262
- }
263
-
264
- /**
265
- * THE CONSENT FILE — where "yes, actually refuse me" is recorded, and why it is not in the lesson store.
266
- *
267
- * Two reasons, and the first is a measured fact rather than a preference:
268
- *
269
- * 1. A per-lesson flag in the store WOULD NOT SURVIVE. `makeLesson()` destructures a fixed field set
270
- * and returns a frozen object built from exactly those fields; any unknown key is silently
271
- * dropped on load. Verified 2026-07-22 by construction — `makeLesson({...,userOptedIntoBlocking:
272
- * true})` returns an object without it. A consent flag stored there would read back as absent on
273
- * every load, i.e. consent that silently evaporates, which is the worst possible failure mode for
274
- * a consent record.
275
- *
276
- * 2. It is the right trust boundary anyway. `lessons.json` is written by the MINING pipeline —
277
- * unattended, over session transcripts, over content that may have been planted (ADR-031's whole
278
- * subject). If "may block" lived in that file, anything that could get a lesson mined could grant
279
- * itself the power to refuse the user's work. It lives in a file the pipeline never writes.
280
- *
281
- * Honest about what this is NOT: it is not a defence against a model that can run shell commands —
282
- * such a model can write any file on the machine. It is a boundary against MINED and IMPORTED
283
- * content reaching enforcement, which is the path the adversarial review actually found.
284
- *
285
- * The empty default is the point: no file, no blocking. Consent is never inferred from silence.
286
- */
287
- export const OPTIN_PATH = process.env.RUVNET_LESSON_OPTIN
288
- || path.join(os.homedir(), '.config', 'ruvnet-brain', 'blocking-optin.json');
289
-
290
- if (!triggers.length) {
291
- console.log('lesson-gate — surface the lessons in force at a decision point\n');
292
- console.log(' --trigger <key> one of: ' + Object.values(TRIGGERS).map((t) => t.key).join(', '));
293
- console.log(' repeatable; one event may carry several decision points');
294
- console.log(' --event <name> Claude Code event (Stop, PreToolUse, UserPromptSubmit) → hook mode:');
295
- console.log(' nudges emit JSON additionalContext (exit 0), blocks emit stderr (exit 2)');
296
- console.log(' --json machine-readable');
297
- console.log(' --quiet print nothing; exit code only\n');
298
- console.log(' blocking is OPT-IN per lesson: ' + OPTIN_PATH);
299
- process.exit(EXIT_ALLOW);
300
- }
301
-
302
- function loadBlockingOptIn(file = OPTIN_PATH) {
303
- try {
304
- const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
305
- // Tolerant of both shapes because a human is expected to hand-edit this: a bare array reads
306
- // fine, and so does the documented object. Being fussy about a consent file's punctuation would
307
- // silently downgrade someone's explicit "yes" to a "no".
308
- const list = Array.isArray(raw) ? raw : Array.isArray(raw?.blocking) ? raw.blocking : [];
309
- return new Set(list.filter((x) => typeof x === 'string' && x.length));
310
- } catch { return new Set(); } // absent or unparseable → nobody blocks. Never fail INTO refusing.
311
- }
312
-
313
- let lessons = [];
314
- try { lessons = loadLessons(); } catch { process.exit(EXIT_ALLOW); } // never break the caller
315
-
316
- const optedIn = loadBlockingOptIn();
317
-
318
- /**
319
- * THE PER-SESSION FREQUENCY CAP — because a true advisory repeated verbatim on every matching event is
320
- * the nag ADR-030 bans (measured 2026-07-22: the mutate-machine advisory rendered on every Bash call of
321
- * a session). anticipate.sh already caps its own nudges per session; this is the same discipline for the
322
- * lesson gate. A PURE-ADVISORY lesson is shown at most MAX_SHOWS times per session, then stays silent
323
- * until a new session.
324
- *
325
- * THE LOAD-BEARING INVARIANT: an actual REFUSAL is never capped. A lesson the user has opted into as a
326
- * block (isBlocking, below) exits 2 and refuses the action — the cap must never touch it. But a merely
327
- * block-CAPABLE lesson the user has NOT opted into renders as an ADVISORY (exit 0): it is a reminder, not
328
- * a refusal, and it is capped like any other advisory. An earlier version exempted block-capable lessons
329
- * too — which left exactly the lesson doing the nagging (the block-capable "gate on blast radius", never
330
- * opted in) repeating unbounded on every mutating command; an independent regrade caught it. Capping a
331
- * block-capable ADVISORY silences no refusal: a refusal exits 2 regardless of this file's display budget,
332
- * and the one-time "you could turn this into a refusal" offer needs to be seen a few times, not forever.
333
- *
334
- * FAIL-OPEN: any error reading or writing the state degrades to the pre-cap behaviour (show it), never to
335
- * suppression — a gate that goes quiet because it could not read a JSON file is worse than a repeat.
336
- */
337
- const GATE_STATE_PATH = process.env.RUVNET_LESSON_GATE_STATE
338
- || path.join(os.homedir(), '.config', 'ruvnet-brain', 'lesson-gate-state.json');
339
- const MAX_SHOWS = (() => {
340
- const n = Number(process.env.RUVNET_LESSON_MAX_SHOWS);
341
- return Number.isInteger(n) && n > 0 ? n : 3;
342
- })();
343
- const KEEP_SESSIONS = 20; // bound the state file to the most-recent sessions, same as anticipate.sh
344
- const SID = (typeof session === 'string' && session.trim())
345
- ? session.trim()
346
- : `fallback:${process.cwd()}:${new Date().toISOString().slice(0, 10)}`;
347
- /**
348
- * BLOCKING = four conditions, all required; the user's opt-in is necessary and NOT sufficient. Defined
349
- * here (rather than at the emit site) because the frequency cap below must key its exemption on it: the
350
- * last two conditions are also guaranteed by makeLesson, and are re-asserted deliberately — this is the
351
- * one place the answer is "refuse the human's work", and a security invariant enforced only at a distance
352
- * is one refactor from being enforced nowhere.
353
- */
354
- const isBlocking = (l) => optedIn.has(l.id)
355
- && l.enforcement === ENFORCEMENT.BLOCK
356
- && (l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE)
357
- && l.origin === ORIGIN.USER_STATED;
358
- /** Exempt from the cap: ONLY a lesson that refuses RIGHT NOW (an opted-in block, exit 2). A block-capable
359
- * lesson that is not opted in is an advisory and is capped like any other — see the invariant above. */
360
- const capExempt = isBlocking;
361
- function readGateState() {
362
- try { const s = JSON.parse(fs.readFileSync(GATE_STATE_PATH, 'utf8')); return s && typeof s === 'object' ? s : {}; }
363
- catch { return {}; }
364
- }
365
- function writeGateState(st) {
366
- try {
367
- fs.mkdirSync(path.dirname(GATE_STATE_PATH), { recursive: true });
368
- fs.writeFileSync(GATE_STATE_PATH, JSON.stringify(st));
369
- return true;
370
- } catch { return false; }
371
- }
372
- /** How many times THIS session has already surfaced a given advisory lesson (0 if never). */
373
- function shownCount(st, id) {
374
- const c = st?.sessions?.[SID]?.shown?.[id];
375
- return Number.isInteger(c) && c > 0 ? c : 0;
376
- }
377
- // Read ONCE, up front — the same snapshot gates the filter and seeds the write below.
378
- const gateState = event ? readGateState() : {};
379
-
380
- // Merge every requested decision point into one ranked, de-duplicated list. A lesson registered at
381
- // two triggers must appear once, or the model reads the same correction twice and learns to skim.
382
- /**
383
- * PROJECT SCOPE — a lesson learned in one project has no standing to interrupt work in another.
384
- *
385
- * THE BREAKAGE, 2026-07-22: these hooks were installed machine-wide and 8 of 16 lessons were scoped
386
- * to a SINGLE project, yet fired everywhere. A WhitSentry session was being told about
387
- * ruvnet-brain's stop-and-report habit on every prompt. The owner's report was blunt: "I've got
388
- * other repos that are using this thing, and they're breaking."
389
- *
390
- * The rule is ADR-029's own promotion bar applied at read time: cross-project rediscovery is what
391
- * makes a lesson universal. Taught in ONE project, it is local knowledge — real, worth keeping, and
392
- * not entitled to speak elsewhere. Taught in two or more, it has earned the right to travel.
393
- *
394
- * This is P3 (nudge, never force) and P4 (the user is the arbiter) applied to OUR OWN footprint:
395
- * the fastest way to make someone uninstall a nudge is to nudge them about something that has
396
- * nothing to do with what they are doing.
397
- */
398
- const HERE = (() => {
399
- let d = process.cwd();
400
- for (let i = 0; i < 12; i++) {
401
- if (fs.existsSync(path.join(d, '.git'))) break;
402
- const up = path.dirname(d);
403
- if (up === d) { d = process.cwd(); break; }
404
- d = up;
405
- }
406
- return path.basename(d);
407
- })();
408
- /** Does this lesson belong to the project we are standing in? Match is loose on purpose — stored
409
- * names carry prefixes like `Code-` that the directory name does not. */
410
- const isHome = (l) => {
411
- const ps = Array.isArray(l.projects) ? l.projects : [];
412
- if (!ps.length) return true; // unscoped: applies anywhere, by declaration
413
- return ps.some((p) => {
414
- const n = String(p).replace(/^Code-/, '');
415
- return n === HERE || String(p) === HERE || HERE.endsWith(n) || n.endsWith(HERE);
416
- });
417
- };
418
- const isUniversal = (l) => Array.isArray(l.projects) && l.projects.length >= 2;
419
-
420
- // Apply the mutate-machine predicate defined above. `mutate-machine` is requested for EVERY Bash
421
- // call (plugin/scripts/lesson-hooks.sh:98) — it is the ONLY trigger the dispatcher fires unconditionally
422
- // on a tool, so it is the only one that needs narrowing here. When no `--command` was supplied (a bare
423
- // CLI invocation, or a caller that predates this fix), behavior is UNCHANGED — fail open to the old,
424
- // unfiltered behavior rather than silently swallow a trigger nobody asked to have filtered.
425
- const MUTATE_KEY = TRIGGERS.MUTATE_MACHINE.key;
426
- const effectiveTriggers = command === null
427
- ? triggers
428
- : triggers.filter((t) => t !== MUTATE_KEY || looksLikeOutsideRepoMutation(command));
429
-
430
- const seen = new Set();
431
- const candidates = [];
432
- for (const t of effectiveTriggers) {
433
- for (const l of lessonsFor(t, lessons, { limit: 3 })) {
434
- if (seen.has(l.id)) continue;
435
- // Away from home, only a lesson with cross-project evidence may speak.
436
- if (!isHome(l) && !isUniversal(l)) continue;
437
- seen.add(l.id); candidates.push(l);
438
- }
439
- }
440
-
441
- /**
442
- * THE CHARACTER BUDGET — because an event can now carry four decision points.
443
- *
444
- * Measured 2026-07-22: UserPromptSubmit rendered 6 lessons / 3,757 characters, injected on EVERY
445
- * prompt. That is not a reminder, it is a wall, and a wall gets skimmed and then switched off —
446
- * which costs the user every lesson at once, including the ones that were working.
447
- *
448
- * Budget the COST, not the COUNT: a lesson is ~300 chars, so "three lessons" and "900 characters"
449
- * are the same rule until an unusually long lesson arrives, and then only the character rule holds.
450
- *
451
- * Ranked by repeatCount — how many times the user has actually had to say it — so the correction
452
- * they are most tired of repeating is the one that always survives the trim.
453
- *
454
- * AND THE TRIM IS ANNOUNCED. A silent truncation reads as "that is all there is", which is the same
455
- * lie as a silent cap one layer up. If something was dropped, the model is told how many and where
456
- * to read the rest.
457
- */
458
- const NUDGE_CHAR_BUDGET = Number(process.env.RUVNET_NUDGE_BUDGET) || 1200;
459
- // PER-SESSION FREQUENCY CAP (hook mode only — a human running the CLI asked to see everything). Drop a
460
- // PURE-ADVISORY lesson already shown MAX_SHOWS times this session; a block-capable lesson passes untouched
461
- // (capExempt), so a refusal the user opted into is never silenced to reduce noise.
462
- const capped = event
463
- ? candidates.filter((l) => capExempt(l) || shownCount(gateState, l.id) < MAX_SHOWS)
464
- : candidates;
465
- const ranked = [...capped].sort((a, b) => (b.repeatCount || 0) - (a.repeatCount || 0));
466
-
467
- /* ONE VOICE PER DECISION POINT, BEFORE ANY SECOND VOICE.
468
- *
469
- * Ranking by repeatCount alone has a failure mode that hid a lesson for its entire life. An event can
470
- * carry several triggers at once — UserPromptSubmit now fires five — and the character budget is
471
- * spent strictly in repeat-count order. So the lessons attached to ONE trigger, if they happen to be
472
- * the most-repeated, consume the whole budget and every OTHER decision point that genuinely fired
473
- * goes silent.
474
- *
475
- * Measured 2026-07-24: L16-parallel-by-default (trigger `choose-work`, taught 4x, weight ~0.5) was
476
- * competing against L14-architecture-recipe (36x, 4.13) and L02-check-before-you-assert (28x, 4.50).
477
- * It could never win a slot — not because it was irrelevant to the moment, but because a DIFFERENT
478
- * aspect of the same moment had louder lessons. The owner had to supply that correction by hand a
479
- * fourth time, and the honest diagnosis was: the lesson was in force and structurally unseeable.
480
- *
481
- * repeatCount measures HOW OFTEN A LESSON HAS BEEN NEEDED. It does not measure how relevant it is to
482
- * the decision in front of us, and treating it as a global priority silently converts "taught most
483
- * often overall" into "the only thing you may be told right now."
484
- *
485
- * So: seed the set with the single highest-ranked lesson per DISTINCT TRIGGER that fired, then spend
486
- * whatever budget remains by rank as before. Each decision point that fired gets to say one thing;
487
- * the loudest lessons still fill the rest. The first-lesson overrun allowance is preserved.
488
- */
489
- const seeded = [];
490
- const seenTriggers = new Set();
491
- for (const l of ranked) {
492
- if (seenTriggers.has(l.trigger)) continue;
493
- seenTriggers.add(l.trigger);
494
- seeded.push(l);
495
- }
496
- const order = [...seeded, ...ranked.filter((l) => !seeded.includes(l))];
497
-
498
- const inForce = [];
499
- let spent = 0;
500
- for (const l of order) {
501
- const cost = renderLesson(l, '·').length;
502
- // Always admit the first lesson even if it alone exceeds the budget — a budget that can render
503
- // nothing is worse than a budget that overruns once.
504
- if (inForce.length && spent + cost > NUDGE_CHAR_BUDGET) continue;
505
- inForce.push(l); spent += cost;
506
- }
507
- const trimmed = capped.length - inForce.length;
508
-
509
- /* EVERY DECISION POINT THAT FIRED GETS AT LEAST ONE LINE — compactly, if that is all that fits.
510
- *
511
- * Seeding one lesson per trigger (above) fixed the ORDER but not the outcome: a full render carries
512
- * statement + evidence + repeat-count, ~400-600 chars, so the 1200-char budget is spent after TWO of
513
- * them. Five triggers fire at UserPromptSubmit; three decision points still said nothing. Measured:
514
- * L16 was seeded first for `choose-work` and still never reached the page.
515
- *
516
- * The budget exists to stop flooding, and that is right. But "do not flood" and "stay silent about
517
- * three of the five things that just became relevant" are different policies, and the character cap
518
- * was quietly enforcing the second. The repo's own standing rule on this is explicit: budget the
519
- * COST, not the count, and a cap that trips is a signal to grow the container — never to drop the
520
- * knowledge.
521
- *
522
- * So: any trigger left unrepresented after the budget is spent gets a ONE-LINE compact entry —
523
- * statement only, clipped, no evidence, no counts. A clipped sentence the model actually reads beats
524
- * a perfectly-formatted one it never sees. Full renders still go to the highest-ranked lessons.
525
- */
526
- const representedTriggers = new Set(inForce.map((l) => l.trigger));
527
- const compactExtras = seeded.filter((l) => !representedTriggers.has(l.trigger));
528
-
529
- // isBlocking is defined above (the frequency cap keys its exemption on it). BLOCKING = four ANDed
530
- // conditions; the user's opt-in is necessary and NOT sufficient, and the security invariant lives there.
531
- const blocking = inForce.filter(isBlocking);
532
- // Lessons that COULD block if the user asked them to. Shown, because the entire product claim is
533
- // that the user can see what is available and choose — not discover enforcement by being refused.
534
- const blockCapable = inForce.filter((l) => !isBlocking(l)
535
- && (l.enforcement === ENFORCEMENT.BLOCK || l.intendedEnforcement === ENFORCEMENT.BLOCK));
536
-
537
- /** Cut at the last word boundary inside the cap, with an ellipsis, rather than mid-word — the live
538
- * output truncated "…the ris" (from "the risk"), which reads as a bug in the lesson, not a length
539
- * cap. Falls back to a hard slice when there is no space to break on (a single very long token).
540
- * Same rule anticipate.sh already applies to its `why`. */
541
- function clip(text, max) {
542
- if (text.length <= max) return text;
543
- const cut = text.slice(0, max);
544
- const brk = cut.lastIndexOf(' ');
545
- return `${(brk > max * 0.6 ? cut.slice(0, brk) : cut).trimEnd()}…`;
546
- }
547
-
548
- /** One lesson, rendered. Evidence is what makes this a lesson rather than a nag — it says why, from
549
- * real history, in the user's own words. Counts come from the store; nothing here is invented. */
550
- function renderLesson(l, mark) {
551
- const out = [` ${mark} ${l.statement}`];
552
- if (l.evidence?.[0]?.observed) out.push(` ${clip(String(l.evidence[0].observed), 150)}`);
553
- if (l.repeatCount >= 3) out.push(` you have had to say this ${l.repeatCount} times across ${l.projects.length} project(s)`);
554
- return out.join('\n');
555
- }
556
-
557
- function renderBody() {
558
- const lines = [''];
559
- const label = Object.values(TRIGGERS).find((t) => t.key === triggers[0])?.label || triggers.join(', ');
560
- lines.push(` ⚑ ${blocking.length ? 'BLOCKED' : 'Before you continue'} — you are ${label}.`);
561
- lines.push('');
562
- for (const l of inForce) {
563
- lines.push(renderLesson(l, isBlocking(l) ? '⛔' : '·'));
564
- lines.push('');
565
- }
566
- // The decision points that fired but lost the budget — one clipped line each, so none is silent.
567
- if (compactExtras.length) {
568
- lines.push(' Also live at this moment:');
569
- for (const l of compactExtras) lines.push(` · ${clip(String(l.statement), 150)}`);
570
- lines.push('');
571
- }
572
- // Say it out loud when the budget trips. A silent truncation reads as "that is all there is".
573
- if (trimmed > 0) {
574
- lines.push(` (${trimmed} further lesson${trimmed === 1 ? '' : 's'} also applies here, trimmed to keep this short —`);
575
- lines.push(` see them all with: node scripts/lesson-ratify.mjs --list)`);
576
- lines.push('');
577
- }
578
- if (blockCapable.length && !blocking.length) {
579
- // Deliberately phrased as an available choice, not as a pending threat. The previous wording
580
- // ("would REFUSE this action once you ratify them") described enforcement arriving on its own.
581
- // It does not arrive on its own any more, and telling someone a refusal is coming when they
582
- // never asked for one is the coercive framing the owner rejected.
583
- lines.push(` ${blockCapable.length} of these can REFUSE this action instead of mentioning it,`);
584
- lines.push(` if you want that. Entirely your call — nothing changes unless you add the id:`);
585
- lines.push(` ${OPTIN_PATH}`);
586
- lines.push('');
587
- }
588
- return lines.join('\n');
589
- }
590
-
591
- // ── Emit ─────────────────────────────────────────────────────────────────────────────────────────
592
-
593
- if (json) {
594
- console.log(JSON.stringify({
595
- triggers, event: event ?? null, inForce,
596
- blocking: blocking.map((l) => l.id),
597
- blockCapable: blockCapable.map((l) => l.id),
598
- optInPath: OPTIN_PATH,
599
- }, null, 2));
600
- process.exit(blocking.length ? EXIT_BLOCK : EXIT_ALLOW);
601
- }
602
-
603
- if (event) {
604
- // RECORD what this session is about to SURFACE, so the frequency cap can act next time. Only
605
- // pure-advisory lessons count toward their own cap; block-capable lessons are exempt (capExempt) and
606
- // never recorded. Skipped when nothing will render (quiet with no block). Best-effort and fail-open —
607
- // a lost write repeats an advisory once more, it never suppresses one. Persisted BEFORE the streams
608
- // are touched, so a crash mid-emit under-counts (safe) rather than over-counts.
609
- const willRender = blocking.length > 0 || (inForce.length > 0 && !quiet);
610
- if (willRender) {
611
- const st = gateState && typeof gateState === 'object' ? gateState : {};
612
- st.sessions = st.sessions && typeof st.sessions === 'object' ? st.sessions : {};
613
- const prev = st.sessions[SID] && typeof st.sessions[SID] === 'object' ? st.sessions[SID] : {};
614
- const shown = prev.shown && typeof prev.shown === 'object' ? { ...prev.shown } : {};
615
- for (const l of inForce) {
616
- if (capExempt(l)) continue;
617
- shown[l.id] = (Number.isInteger(shown[l.id]) && shown[l.id] > 0 ? shown[l.id] : 0) + 1;
618
- }
619
- st.sessions[SID] = { shown, ts: Date.now() };
620
- // Bound the file to the most-recent sessions, same discipline as anticipate.sh.
621
- st.sessions = Object.fromEntries(
622
- Object.entries(st.sessions).sort((a, b) => (b[1]?.ts || 0) - (a[1]?.ts || 0)).slice(0, KEEP_SESSIONS),
623
- );
624
- writeGateState(st);
625
- }
626
-
627
- // CANDIDATE MODE — emit JSON candidates, let the runtime own the real streams and the exit code.
628
- // A block DOMINATES exactly as in the stream contract below: when any opted-in block is in force we
629
- // emit only the block candidate and no advisory. The `copy` of each candidate is byte-identical to
630
- // what the legacy path would have written (renderBody() to stderr for a block; the advisory preamble
631
- // + renderBody() as additionalContext for a nudge), so the runtime's delivered bytes match. The
632
- // frequency-cap persist above already ran, so persist-before-speak holds here too.
633
- if (EMIT_CANDIDATES) {
634
- if (blocking.length) {
635
- process.stdout.write(JSON.stringify({
636
- channel: 'lesson', effect: 'block', copy: renderBody(), hookEventName: event,
637
- }) + '\n');
638
- } else if (inForce.length && !quiet) {
639
- process.stdout.write(JSON.stringify({
640
- channel: 'lesson', effect: 'advisory', hookEventName: event,
641
- copy: [
642
- 'Your own recorded corrections apply at this moment. These are advisory — they do not',
643
- 'refuse anything, and you may proceed. Weigh them and say so if you go another way.',
644
- renderBody(),
645
- ].join('\n'),
646
- }) + '\n');
647
- }
648
- process.exit(EXIT_ALLOW);
649
- }
650
-
651
- // HOOK MODE — the streams are the contract, so nothing else may touch them.
652
- if (blocking.length) {
653
- // Exit 2: stdout is ignored by the harness, stderr becomes the model's error message. Writing
654
- // the reason anywhere but stderr is exactly the bug this file exists to fix.
655
- process.stderr.write(renderBody() + '\n');
656
- process.exit(EXIT_BLOCK);
657
- }
658
- if (inForce.length && !quiet) {
659
- // Exit 0 + additionalContext: reaches the model, at the decision point, refusing nothing.
660
- // hookEventName MUST name the firing event or the harness discards the envelope.
661
- process.stdout.write(JSON.stringify({
662
- hookSpecificOutput: {
663
- hookEventName: event,
664
- additionalContext: [
665
- 'Your own recorded corrections apply at this moment. These are advisory — they do not',
666
- 'refuse anything, and you may proceed. Weigh them and say so if you go another way.',
667
- renderBody(),
668
- ].join('\n'),
669
- },
670
- }));
671
- }
672
- process.exit(EXIT_ALLOW);
673
- }
674
-
675
- // ── CLI MODE (no --event) ────────────────────────────────────────────────────────────────────────
676
- // Plain text on stdout, unchanged. version-bump-gate.sh captures this stdout verbatim and appends it
677
- // to its own refusal under "── from your own lesson store ──"; changing the stream or the shape here
678
- // would silently empty that section of the only gate in the system that genuinely works.
679
- if (!quiet && inForce.length) console.log(renderBody());
680
- process.exit(blocking.length ? EXIT_BLOCK : EXIT_ALLOW);
2
+ // Compatibility launcher for repository commands and tests. The executable implementation belongs
3
+ // inside the self-contained plugin payload used by Stable Spine and every supported host.
4
+ await import('../plugin/scripts/lesson-gate.mjs');