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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +17 -17
- package/plugin/scripts/codex-hook-wrapper.mjs +36 -4
- package/plugin/scripts/lesson-command-scope.mjs +133 -0
- package/plugin/scripts/lesson-gate.mjs +401 -0
- package/plugin/scripts/lesson-presentation.mjs +99 -0
- package/plugin/scripts/lesson-store.mjs +452 -0
- package/plugin/scripts/route-dispatch.sh +1 -0
- package/plugin/scripts/verify-interface.sh +1 -0
- package/scripts/learning-replay-cli.mjs +236 -0
- package/scripts/learning-replay-contract.mjs +255 -0
- package/scripts/learning-replay-execution.mjs +193 -0
- package/scripts/learning-replay-fixture.mjs +380 -0
- package/scripts/learning-replay-proof.mjs +459 -0
- package/scripts/learning-replay.mjs +10 -1565
- package/scripts/lesson-gate.mjs +3 -679
- package/scripts/lesson-store.mjs +4 -447
- package/scripts/memory-doctor.mjs +19 -3
- package/scripts/onboarding-console.mjs +77 -43
- package/scripts/release-vector.mjs +49 -25
- package/scripts/stabilization-receipt.mjs +1 -1
|
@@ -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);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// lesson-presentation.mjs — pure selection and rendering for lesson-gate.
|
|
2
|
+
// The executable adapter owns I/O and consent state; this module turns its verified candidates into
|
|
3
|
+
// one bounded, deterministic presentation so hook and candidate modes cannot drift in wording.
|
|
4
|
+
import { ENFORCEMENT, TRIGGERS } from './lesson-store.mjs';
|
|
5
|
+
|
|
6
|
+
const ADVISORY_APPLICATION_CONTRACT = [
|
|
7
|
+
'Your own recorded corrections apply at this moment. They are advisory: they do not refuse',
|
|
8
|
+
"anything or override the user's current instruction or a safety boundary.",
|
|
9
|
+
"Apply any relevant correction directly to the user's requested action.",
|
|
10
|
+
'When a correction already provides the required form, do not replace the requested action with help, setup, status, or other discovery.',
|
|
11
|
+
'When a correction supplies an exact Ruflo command, use it as the FIRST and ONLY Ruflo invocation.',
|
|
12
|
+
'You must not prefix it with --help, --version, status, or any alternate Ruflo call.',
|
|
13
|
+
'If you intentionally take another path, state why.',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function clip(text, max) {
|
|
17
|
+
if (text.length <= max) return text;
|
|
18
|
+
const cut = text.slice(0, max);
|
|
19
|
+
const boundary = cut.lastIndexOf(' ');
|
|
20
|
+
return `${(boundary > max * 0.6 ? cut.slice(0, boundary) : cut).trimEnd()}…`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function renderLesson(lesson, mark) {
|
|
24
|
+
const lines = [` ${mark} ${lesson.statement}`];
|
|
25
|
+
if (lesson.evidence?.[0]?.observed) lines.push(` ${clip(String(lesson.evidence[0].observed), 150)}`);
|
|
26
|
+
if (lesson.repeatCount >= 3) {
|
|
27
|
+
lines.push(` you have had to say this ${lesson.repeatCount} times across ${lesson.projects.length} project(s)`);
|
|
28
|
+
}
|
|
29
|
+
return lines.join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildLessonPresentation({
|
|
33
|
+
candidates,
|
|
34
|
+
event,
|
|
35
|
+
shownCount,
|
|
36
|
+
isBlocking,
|
|
37
|
+
triggers,
|
|
38
|
+
optInPath,
|
|
39
|
+
maxShows = 3,
|
|
40
|
+
nudgeBudget = 1200,
|
|
41
|
+
}) {
|
|
42
|
+
const capped = event
|
|
43
|
+
? candidates.filter((lesson) => isBlocking(lesson) || shownCount(lesson.id) < maxShows)
|
|
44
|
+
: candidates;
|
|
45
|
+
const ranked = [...capped].sort((a, b) => (b.repeatCount || 0) - (a.repeatCount || 0));
|
|
46
|
+
|
|
47
|
+
// Give every distinct decision point one voice before any trigger receives a second.
|
|
48
|
+
const seeded = [];
|
|
49
|
+
const seededTriggers = new Set();
|
|
50
|
+
for (const lesson of ranked) {
|
|
51
|
+
if (seededTriggers.has(lesson.trigger)) continue;
|
|
52
|
+
seededTriggers.add(lesson.trigger);
|
|
53
|
+
seeded.push(lesson);
|
|
54
|
+
}
|
|
55
|
+
const order = [...seeded, ...ranked.filter((lesson) => !seeded.includes(lesson))];
|
|
56
|
+
|
|
57
|
+
const inForce = [];
|
|
58
|
+
let spent = 0;
|
|
59
|
+
for (const lesson of order) {
|
|
60
|
+
const cost = renderLesson(lesson, '·').length;
|
|
61
|
+
if (inForce.length && spent + cost > nudgeBudget) continue;
|
|
62
|
+
inForce.push(lesson);
|
|
63
|
+
spent += cost;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const represented = new Set(inForce.map((lesson) => lesson.trigger));
|
|
67
|
+
const compactExtras = seeded.filter((lesson) => !represented.has(lesson.trigger));
|
|
68
|
+
const trimmed = capped.length - inForce.length;
|
|
69
|
+
const blocking = inForce.filter(isBlocking);
|
|
70
|
+
const blockCapable = inForce.filter((lesson) => !isBlocking(lesson)
|
|
71
|
+
&& (lesson.enforcement === ENFORCEMENT.BLOCK || lesson.intendedEnforcement === ENFORCEMENT.BLOCK));
|
|
72
|
+
|
|
73
|
+
const lines = [''];
|
|
74
|
+
const label = Object.values(TRIGGERS).find((trigger) => trigger.key === triggers[0])?.label || triggers.join(', ');
|
|
75
|
+
lines.push(` ⚑ ${blocking.length ? 'BLOCKED' : 'Before you continue'} — you are ${label}.`, '');
|
|
76
|
+
for (const lesson of inForce) lines.push(renderLesson(lesson, isBlocking(lesson) ? '⛔' : '·'), '');
|
|
77
|
+
if (compactExtras.length) {
|
|
78
|
+
lines.push(' Also live at this moment:');
|
|
79
|
+
for (const lesson of compactExtras) lines.push(` · ${clip(String(lesson.statement), 150)}`);
|
|
80
|
+
lines.push('');
|
|
81
|
+
}
|
|
82
|
+
if (trimmed > 0) {
|
|
83
|
+
lines.push(` (${trimmed} further lesson${trimmed === 1 ? '' : 's'} also applies here, trimmed to keep this short —`);
|
|
84
|
+
lines.push(' see them all with: node scripts/lesson-ratify.mjs --list)', '');
|
|
85
|
+
}
|
|
86
|
+
if (blockCapable.length && !blocking.length) {
|
|
87
|
+
lines.push(` ${blockCapable.length} of these can REFUSE this action instead of mentioning it,`);
|
|
88
|
+
lines.push(' if you want that. Entirely your call — nothing changes unless you add the id:');
|
|
89
|
+
lines.push(` ${optInPath}`, '');
|
|
90
|
+
}
|
|
91
|
+
const body = lines.join('\n');
|
|
92
|
+
return {
|
|
93
|
+
inForce,
|
|
94
|
+
blocking,
|
|
95
|
+
blockCapable,
|
|
96
|
+
body,
|
|
97
|
+
advisoryContext: [...ADVISORY_APPLICATION_CONTRACT, body].join('\n'),
|
|
98
|
+
};
|
|
99
|
+
}
|