ruvnet-brain 3.9.133-dev → 4.0.1

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.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/README.md +3 -3
  3. package/bin/install.mjs +284 -33
  4. package/kb/zip-extract.mjs +53 -14
  5. package/package.json +7 -1
  6. package/plugin/.claude-plugin/marketplace.json +13 -0
  7. package/plugin/.claude-plugin/plugin.json +23 -0
  8. package/plugin/.codex-plugin/plugin.json +21 -0
  9. package/plugin/.mcp.json +8 -0
  10. package/plugin/commands/brain-console.md +16 -0
  11. package/plugin/commands/configure.md +32 -0
  12. package/plugin/commands/rvbc.md +78 -0
  13. package/plugin/commands/rvcb.md +16 -0
  14. package/plugin/commands/whats-new.md +57 -0
  15. package/plugin/hooks/codex-hooks.json +160 -0
  16. package/plugin/hooks/hook-contracts.json +77 -0
  17. package/plugin/hooks/hooks.json +203 -0
  18. package/plugin/mcp/server.mjs +35 -6
  19. package/plugin/scripts/anticipate.sh +534 -0
  20. package/plugin/scripts/codex-hook-adapter.mjs +96 -0
  21. package/plugin/scripts/continuation-gate.mjs +267 -0
  22. package/plugin/scripts/design-wall.sh +137 -0
  23. package/plugin/scripts/detach.mjs +168 -0
  24. package/plugin/scripts/finalize-token-meter.mjs +25 -0
  25. package/plugin/scripts/gate-receipt.sh +35 -0
  26. package/plugin/scripts/ground-before-write.sh +199 -0
  27. package/plugin/scripts/ground-ruvnet.sh +507 -0
  28. package/plugin/scripts/grounding-stamp.sh +113 -0
  29. package/plugin/scripts/grounding-substance.mjs +595 -0
  30. package/plugin/scripts/hijack-ruvnet.sh +81 -0
  31. package/plugin/scripts/hook-input.mjs +558 -0
  32. package/plugin/scripts/hook-shim-bash.mjs +55 -0
  33. package/plugin/scripts/hook-shim.mjs +303 -0
  34. package/plugin/scripts/host-update.mjs +58 -0
  35. package/plugin/scripts/kling-preflight.sh +146 -0
  36. package/plugin/scripts/learn-capture.sh +154 -0
  37. package/plugin/scripts/learn-flush.mjs +138 -0
  38. package/plugin/scripts/lesson-hooks.sh +213 -0
  39. package/plugin/scripts/md-stamp.mjs +219 -0
  40. package/plugin/scripts/protect-brain-state.sh +84 -0
  41. package/plugin/scripts/route-dispatch.sh +147 -0
  42. package/plugin/scripts/routing-outcome-capture.mjs +89 -0
  43. package/plugin/scripts/session-start.sh +868 -0
  44. package/plugin/scripts/signal-watch.mjs +193 -0
  45. package/plugin/scripts/unprompted-runtime.mjs +377 -0
  46. package/plugin/scripts/update-apply.mjs +419 -0
  47. package/plugin/scripts/verify-interface.sh +53 -0
  48. package/plugin/scripts/version-bump-gate.sh +112 -0
  49. package/plugin/skills/brain-build/SKILL.md +123 -0
  50. package/plugin/skills/brain-console/SKILL.md +20 -0
  51. package/plugin/skills/brain-prompt/SKILL.md +83 -0
  52. package/plugin/skills/brain-score/SKILL.md +101 -0
  53. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +117 -0
  54. package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
  55. package/plugin/skills/rvbc/SKILL.md +20 -0
  56. package/plugin/skills/savings/SKILL.md +46 -0
  57. package/plugin/skills/whats-new/SKILL.md +22 -0
@@ -0,0 +1,534 @@
1
+ #!/bin/sh
2
+ # anticipate.sh — the L4 DELIVERY surface (ADR-028 "Anticipatory"; anti-nag contract from ADR-027).
3
+ #
4
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
5
+ # WHAT THIS IS. A UserPromptSubmit hook that reads the prompt the user is about to send, asks the
6
+ # goal matcher whether any DORMANT capability on this machine serves that goal, and — at most once
7
+ # per session per capability — says exactly one line about it. Silence is the default and by far the
8
+ # common case.
9
+ #
10
+ # WHY IT EXISTS AS A HOOK AND NOT A PAGE. ADR-028 is blunt about the failure it diagnoses: "The
11
+ # console is a page you have to visit. A surface the user must navigate to is a PULL surface.
12
+ # Advocacy that waits for you to open it is not proactivity — it is a dashboard with better copy."
13
+ # The measured cost of that shape was 21 days between a capability becoming dormant and anyone
14
+ # being told, with the data sitting there the whole time. A matcher nothing calls repeats that
15
+ # mistake exactly; this file is the thing that calls it.
16
+ #
17
+ # WHY IT IS SO AGGRESSIVELY QUIET. ADR-027: "Advocacy must not become nagging... a nag trains users
18
+ # to ignore the real alarm." ADR-028 fixes a hard precision floor of 0.60 (recommendations acted on
19
+ # ÷ recommendations fired) and states that frequency is "a feature with a hard ceiling, not a dial
20
+ # to turn up". A hook that speaks too often is a hook people disable, and a disabled hook protects
21
+ # nothing — so every ambiguous case in this file resolves to SILENCE, never to speech.
22
+ #
23
+ # THE FOUR SILENCE RULES, each enforced below and each with a test:
24
+ # 1. Only state 'off' ever speaks. NEVER 'unknown' — see the learning-hooks detector in
25
+ # capability-registry.mjs, which reported "26 hooks off" from a CLI's cosmetic table column
26
+ # while the learner held 457 trajectories. 'unknown' means we do not know, and a system that
27
+ # renders not-knowing as a fault is lying. 'absent' is silent too: nothing to switch on.
28
+ # 2. No evidence, no speech. A match with no `why` string is dropped, same discipline as
29
+ # console-engine.makeRecommendation() throwing on a recommendation with no evidence/undo.
30
+ # 3. Cannot remember → must not speak. If the "already said this" state fails to persist, we
31
+ # stay silent rather than risk repeating on the very next prompt. Forgetting is a nag.
32
+ # 4. If in doubt, nothing. Missing module, unparseable payload, odd confidence, matcher naming a
33
+ # capability that is not actually dormant — all silent, all exit 0.
34
+ #
35
+ # PERFORMANCE. This runs on EVERY prompt, inside a 5s hook budget already partly spent by
36
+ # ground-ruvnet.sh. Two guards keep it near-free: a no-node fast path (if the matcher module is not
37
+ # on disk, this hook costs one stat and exits — which is the state of the world until goal-match.mjs
38
+ # lands), and a hard watchdog that SIGKILLs the single node process. Nothing here can block a turn.
39
+ #
40
+ # NEVER DAEMONIZES. The registry's own comment records the bill for getting this wrong: one call to
41
+ # an earlier auditAll() left a live `node cli.js daemon start --foreground` running and wrote four
42
+ # files into HOME. auditAll() is now read-only (it LOCATES ruflo, never executes it), and this hook
43
+ # adds no execution of its own — it imports three modules (goal-match, capability-registry,
44
+ # advocacy-outcomes) and does filesystem reads.
45
+ #
46
+ # CONTRACT: always exit 0. stdout on exit 0 is injected verbatim into the model's context, so the
47
+ # single line printed here is phrased as an instruction to the model, matching ground-ruvnet.sh.
48
+ # The only paths this writes to are the state file and the outcomes ledger, both under
49
+ # ~/.config/ruvnet-brain/.
50
+ #
51
+ # CLI (the "silence it" instruction we print must actually work — house rule: never render a
52
+ # control without a real executor AND a real undo). SUPPRESSION IS SEVERITY-WEIGHTED (2026-07-23):
53
+ # --dismiss is not a single permanent mute at every severity — see advocacy-outcomes.mjs's
54
+ # DISMISSAL_BUDGET. A routine finding is silenced by its first --dismiss; a high-severity one needs
55
+ # three before it goes fully quiet, because one distracted click must not bury a serious finding.
56
+ # anticipate.sh --dismiss <capability-key> record a decline; silences it once its budget is spent
57
+ # anticipate.sh --undismiss <capability-key> reset the budget — it can be raised again (the undo)
58
+ # anticipate.sh --status the dismissal ledger + what was said this session
59
+ # Kill switch for the whole hook: RUVNET_ANTICIPATE=0
60
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
61
+
62
+ set +e
63
+
64
+ SELF_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
65
+ [ -n "$SELF_DIR" ] || exit 0
66
+
67
+ # <codeRoot>/plugin/scripts/anticipate.sh → <codeRoot>. Resolved from THIS file's own location, so
68
+ # it is correct under the Stable Spine (an immutable ~/.cache/ruvnet-brain/versions/<gen> tree) and
69
+ # in a dev checkout alike, without reading active.json — hook-shim.mjs has already chosen the tree
70
+ # by the time it executes this body.
71
+ CODE_ROOT=$(CDPATH='' cd -- "$SELF_DIR/../.." 2>/dev/null && pwd)
72
+ [ -n "$CODE_ROOT" ] || exit 0
73
+
74
+ # All THREE module paths are env-overridable, matching the RUVNET_LESSON_STORE / RUVNET_SETTINGS_FILE
75
+ # idiom already used across this repo — so tests never load the real registry or touch a real user's
76
+ # state, and a future relocation needs no edit here.
77
+ GOAL_MATCH="${RUVNET_GOAL_MATCH:-$CODE_ROOT/scripts/goal-match.mjs}"
78
+ CAP_REGISTRY="${RUVNET_CAPABILITY_REGISTRY:-$CODE_ROOT/scripts/capability-registry.mjs}"
79
+ # THE SINGLE SUPPRESSION POLICY (2026-07-23). Until this build this file decided "is X suppressed"
80
+ # with its OWN local dismissed-Set in anticipate-state.json — one dismissal muted forever, no
81
+ # severity, no budget — while advocacy-outcomes.mjs's shouldStillOffer()/DISMISSAL_BUDGET (a nag dies
82
+ # on 1 dismissal, a high-severity finding needs 3, with a state-change reprieve) sat completely
83
+ # uncalled. Two thresholds for one decision is the exact hazard named below for the confidence floor;
84
+ # the fix is the same shape — wire this module the IDENTICAL env-var-with-a-default way as the two
85
+ # above, NOT a hardcoded `../scripts` path, so it resolves correctly whether this file runs from a dev
86
+ # checkout or the Stable Spine's `<gen>/plugin/scripts/anticipate.sh` (both keep `scripts/` as a
87
+ # sibling of `plugin/`, per this file's own CODE_ROOT comment above).
88
+ ADVOCACY_MODULE="${RUVNET_ADVOCACY_OUTCOMES_MODULE:-$CODE_ROOT/scripts/advocacy-outcomes.mjs}"
89
+
90
+ # ── Subcommands (dismiss/undismiss/status) run the same node program in a different mode ─────────
91
+ MODE="suggest"
92
+ ARG=""
93
+ case "${1:-}" in
94
+ --dismiss) MODE="dismiss"; ARG="${2:-}"; [ -n "$ARG" ] || { echo "usage: anticipate.sh --dismiss <capability-key>" >&2; exit 0; } ;;
95
+ --undismiss) MODE="undismiss"; ARG="${2:-}"; [ -n "$ARG" ] || { echo "usage: anticipate.sh --undismiss <capability-key>" >&2; exit 0; } ;;
96
+ --status) MODE="status" ;;
97
+ "") ;;
98
+ *) exit 0 ;;
99
+ esac
100
+
101
+ if [ "$MODE" = "suggest" ]; then
102
+ # Kill switch. Checked before anything else so a user who has switched this off pays nothing.
103
+ case "${RUVNET_ANTICIPATE:-1}" in 0|off|false|no|OFF|FALSE|No) exit 0 ;; esac
104
+
105
+ # FAST PATH, and the reason this hook is honestly free today: no matcher on disk, no work at all.
106
+ # Degrading silently on a missing module is also the documented contract with goal-match.mjs.
107
+ [ -f "$GOAL_MATCH" ] || exit 0
108
+ [ -f "$CAP_REGISTRY" ] || exit 0
109
+
110
+ EVENT=$(cat 2>/dev/null)
111
+ # A payload this small cannot contain a goal-shaped prompt ("ok", "yes", "continue", or nothing at
112
+ # all). Deliberately a LENGTH test and not a vocabulary test: a keyword prefilter here would be a
113
+ # second matcher that silently drifts from goal-match.mjs, suppressing true matches with no way to
114
+ # notice. Length makes no claim about meaning, so it cannot disagree with the matcher.
115
+ [ "${#EVENT}" -ge 30 ] || exit 0
116
+ else
117
+ EVENT=""
118
+ fi
119
+
120
+ # advocacy-outcomes.mjs is the single suppression policy for EVERY mode below (suggest AND
121
+ # dismiss/undismiss/status), so all four now need it, not suggest alone. Same fast-path discipline as
122
+ # GOAL_MATCH/CAP_REGISTRY above — a missing module is not guessed around. `suggest` degrades to the
123
+ # existing silent contract; the CLI modes get one honest line on stderr, because a control (--dismiss)
124
+ # reporting nothing when it did nothing is the "dead button" failure this file's header warns against.
125
+ if [ ! -f "$ADVOCACY_MODULE" ]; then
126
+ if [ "$MODE" = "suggest" ]; then exit 0; fi
127
+ echo "advocacy-outcomes module not found at $ADVOCACY_MODULE — cannot read or write the dismissal ledger" >&2
128
+ exit 0
129
+ fi
130
+
131
+ # ── The one node process ─────────────────────────────────────────────────────────────────────────
132
+ # The program is fed to node on stdin by a QUOTED heredoc, and the payload travels in the
133
+ # environment — so no temp file is created anywhere and neither the JS nor the user's prompt is ever
134
+ # exposed to shell quoting.
135
+ #
136
+ # The heredoc goes DIRECTLY to node rather than through `PROG=$(cat <<'JS' ... )`. That indirection
137
+ # is what the first version did and it does not survive contact with real JavaScript: inside `$( )`
138
+ # the shell keeps parsing, so the backticks of a template literal read as nested command
139
+ # substitution and the apostrophes in the comments read as quotes. It failed at parse time with
140
+ # "unexpected EOF while looking for matching `'" — before a single line of the hook ran. Fed
141
+ # straight to a command, a quoted-delimiter heredoc is genuinely literal, which is the property
142
+ # being relied on here.
143
+ RUVNET_ANTICIPATE_MODE="$MODE" \
144
+ RUVNET_ANTICIPATE_ARG="$ARG" \
145
+ RUVNET_ANTICIPATE_EVENT="$EVENT" \
146
+ RUVNET_ANTICIPATE_SELF="$SELF_DIR/anticipate.sh" \
147
+ RUVNET_GOAL_MATCH="$GOAL_MATCH" \
148
+ RUVNET_CAPABILITY_REGISTRY="$CAP_REGISTRY" \
149
+ RUVNET_ADVOCACY_OUTCOMES_MODULE="$ADVOCACY_MODULE" \
150
+ node --input-type=module 2>/dev/null <<'JS' &
151
+ import fs from 'node:fs';
152
+ import os from 'node:os';
153
+ import path from 'node:path';
154
+ import { pathToFileURL } from 'node:url';
155
+
156
+ const MODE = process.env.RUVNET_ANTICIPATE_MODE || 'suggest';
157
+ const ARG = process.env.RUVNET_ANTICIPATE_ARG || '';
158
+ const SELF = process.env.RUVNET_ANTICIPATE_SELF || 'anticipate.sh';
159
+
160
+ // CANDIDATE MODE (ADR-040 / DDD-0004 "the enforcement chokepoint"). Set by unprompted-runtime.mjs on
161
+ // every producer child. When on, this hook writes ZERO user-facing prose: it emits ONE advocacy
162
+ // candidate as a JSON line and lets the runtime — the SOLE writer of user bytes — enforce the dial,
163
+ // the DismissalLedger, and the OFFERED denominator centrally. Unset (every direct/legacy invocation),
164
+ // behaviour is byte-for-byte unchanged. Purely additive: it only swaps the shape of the ONE line this
165
+ // hook would already have decided to speak, at the very end, after the persist-first write below.
166
+ const EMIT_CANDIDATES = process.env.RUVNET_EMIT_CANDIDATES === '1';
167
+
168
+ // Same directory, and for the same reason, as user-settings.mjs STORE_PATH and lesson-store's
169
+ // STORE_PATH: bin/install.mjs rmSync's ~/.cache/ruvnet-brain on --update and --uninstall, and has
170
+ // ZERO code paths that touch ~/.config/ruvnet-brain. The argument for this path is not that it
171
+ // feels permanent — it is that the only program which deletes things cannot see it. A "don't say
172
+ // this again" promise that a release quietly revokes is worse than never having made it.
173
+ const STATE_FILE = process.env.RUVNET_ANTICIPATE_STATE
174
+ || path.join(os.homedir(), '.config', 'ruvnet-brain', 'anticipate-state.json');
175
+
176
+ // v2 (2026-07-23): dropped the local `dismissed` array — see "THE SINGLE SUPPRESSION POLICY" below.
177
+ // Any v1 file on disk (which HAD one) simply fails the version check and resets to v2 defaults; no
178
+ // suppression history is lost by that, because every dismissal made through --dismiss was ALREADY
179
+ // being double-written into the outcomes ledger too (the two-writes-one-decision bug this build
180
+ // fixes) — the ledger the new policy reads is already populated with that same history.
181
+ const STATE_VERSION = 2;
182
+ // Per-capability "once per session" is the ADR-027 rule. This ceiling bounds the WORST case on top
183
+ // of it: with eleven capabilities in the registry, "once each" is still eleven interruptions in one
184
+ // session, which is a nag by any honest reading of the precision floor.
185
+ const MAX_PER_SESSION = 2;
186
+ // A matcher that cannot express how sure it is does not get to speak: non-numeric or NaN confidence
187
+ // is silence, never a guess dressed as a suggestion.
188
+ //
189
+ // The NUMBER, though, is the matcher's to own, not this hook's. The first version hardcoded 0.7
190
+ // here — and goal-match.mjs publishes `CONFIDENCE_FLOOR = 0.6` and already filters to it, so every
191
+ // match it deliberately surfaced between 0.6 and 0.69 was being thrown away by a second, invisible
192
+ // threshold that its author could not see or tune. Two thresholds for one decision is how a matcher
193
+ // gets "fixed" for a silence it never caused. This reads the matcher's own floor when it exports
194
+ // one; the fallback exists only for a module that publishes none.
195
+ const FALLBACK_CONFIDENCE_FLOOR = 0.6;
196
+ // Nothing the matcher can say should turn one advisory line into a wall of injected context — this
197
+ // runs on every prompt and the repo meters that cost for a reason.
198
+ const MAX_WHY = 400;
199
+ const KEEP_SESSIONS = 20; // bound the file; sessions are worthless once they end
200
+
201
+ const out = [];
202
+ const quit = () => { if (out.length) process.stdout.write(out.join('\n') + '\n'); process.exit(0); };
203
+
204
+ function readState() {
205
+ try {
206
+ const j = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
207
+ // An unknown or newer on-disk shape degrades to defaults rather than throwing. Worst case we
208
+ // re-offer once; the alternative is a hook that crashes on a file a future version wrote.
209
+ if (j && typeof j === 'object' && j.version === STATE_VERSION) return j;
210
+ } catch { /* absent or unreadable — defaults */ }
211
+ return { version: STATE_VERSION, sessions: {} };
212
+ }
213
+
214
+ /** Atomic write, entirely INSIDE the config dir. Returns false on any failure — never throws. */
215
+ function writeState(st) {
216
+ try {
217
+ fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
218
+ // The temp file is a sibling, not a /tmp entry: this hook's whole footprint must stay inside
219
+ // one directory a user can inspect and delete, and rename() is only atomic within a filesystem.
220
+ const tmp = `${STATE_FILE}.tmp.${process.pid}`;
221
+ fs.writeFileSync(tmp, JSON.stringify(st, null, 2));
222
+ fs.renameSync(tmp, STATE_FILE);
223
+ return true;
224
+ } catch { return false; }
225
+ }
226
+
227
+ const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : []);
228
+
229
+ // ── THE SINGLE SUPPRESSION POLICY (2026-07-23) ──────────────────────────────────────────────────
230
+ // Until this build, this file kept its OWN dismissed-Set in anticipate-state.json as a second,
231
+ // disconnected gate: one call to --dismiss muted a capability FOREVER, at every severity, while
232
+ // advocacy-outcomes.mjs's shouldStillOffer()/DISMISSAL_BUDGET (a nag dies on 1 dismissal, a
233
+ // high-severity finding needs 3, with a state-change reprieve) sat completely uncalled — the exact
234
+ // "two thresholds for one decision" hazard the confidence-floor comment above already names for a
235
+ // different value. It also hand-rolled its OWN ledger writer (recordOutcome(), removed here) rather
236
+ // than call record() — a second, unimported copy of the same validation, which is the write-side
237
+ // half of the identical mistake.
238
+ //
239
+ // From here on, advocacy-outcomes.mjs — loaded ONCE, used in every mode — is the only place "is X
240
+ // suppressed" or "record what happened" is decided; nothing else in this file keeps a shadow copy
241
+ // of that answer. Wired the SAME env-var-with-a-default way as RUVNET_GOAL_MATCH/
242
+ // RUVNET_CAPABILITY_REGISTRY above (see ADVOCACY_MODULE in the bash section), not a hardcoded path.
243
+ let advocacy = null;
244
+ const ADVOCACY_MODULE_PATH = process.env.RUVNET_ADVOCACY_OUTCOMES_MODULE || '';
245
+ if (ADVOCACY_MODULE_PATH) {
246
+ try { advocacy = await import(pathToFileURL(ADVOCACY_MODULE_PATH).href); } catch { advocacy = null; }
247
+ }
248
+ if (!advocacy || typeof advocacy.shouldStillOffer !== 'function' || typeof advocacy.record !== 'function') {
249
+ // SILENCE RULE 4 for `suggest` — no suppression policy available, no speech, same as a missing
250
+ // matcher/registry. The CLI modes get one honest line instead: a control (--dismiss) that reports
251
+ // nothing when it did nothing is the "dead button" failure this file's header warns against.
252
+ if (MODE === 'suggest') quit();
253
+ out.push(`advocacy-outcomes module unavailable (${ADVOCACY_MODULE_PATH || 'RUVNET_ADVOCACY_OUTCOMES_MODULE unset'}) — cannot record or check the dismissal ledger`);
254
+ quit();
255
+ }
256
+ const {
257
+ record, shouldStillOffer, outcomesFor, summarize, pendingOffers, reconcileApplied,
258
+ ACTIONS, DISMISSAL_BUDGET, weightClass, stateHashOf,
259
+ } = advocacy;
260
+ const OUTCOMES_FILE = process.env.RUVNET_ADVOCACY_OUTCOMES
261
+ || path.join(os.homedir(), '.config', 'ruvnet-brain', 'advocacy-outcomes.jsonl');
262
+ /** Recording an outcome must never break the hook it measures — same contract recordOutcome() had. */
263
+ function safeRecord(spec) {
264
+ try { return record(spec, { file: OUTCOMES_FILE }); } catch (e) { return { ok: false, reason: e.message }; }
265
+ }
266
+
267
+ // ── dismiss / undismiss / status ────────────────────────────────────────────────────────────────
268
+ if (MODE === 'dismiss' || MODE === 'undismiss' || MODE === 'status') {
269
+ const st = readState();
270
+ const sessions = st.sessions && typeof st.sessions === 'object' ? st.sessions : {};
271
+
272
+ if (MODE === 'status') {
273
+ const said = Object.values(sessions).flatMap((s) => strings(s?.said));
274
+ const rows = summarize({ file: OUTCOMES_FILE }).filter((r) => r.offered > 0);
275
+ const ledger = rows.length
276
+ ? rows.map((r) => `${r.id}: ${r.suppressed ? 'suppressed' : 'active'} (applied ${r.applied}, dismissed ${r.dismissed}, ignored ${r.ignored})`).join(' | ')
277
+ : 'none';
278
+ out.push(`dismissal ledger: ${ledger}`);
279
+ out.push(`raised in recent sessions: ${said.length ? [...new Set(said)].join(', ') : 'none'}`);
280
+ out.push(`state file: ${STATE_FILE.replace(os.homedir(), '~')}`);
281
+ out.push(`outcomes ledger: ${OUTCOMES_FILE.replace(os.homedir(), '~')}`);
282
+ quit();
283
+ }
284
+
285
+ if (MODE === 'undismiss') {
286
+ // A RESET is the ledger's own undo: a CHECKPOINT, never a deletion (advocacy-outcomes.mjs).
287
+ // Everything before it stays on the record — precision is never laundered — only the suppression
288
+ // arithmetic starts counting again after it.
289
+ const res = safeRecord({ id: ARG, action: ACTIONS.RESET });
290
+ out.push(res.ok
291
+ ? `un-dismissed: "${ARG}" can be raised again`
292
+ : `could not record the reset (${res.reason}) — nothing changed`);
293
+ quit();
294
+ }
295
+
296
+ // MODE === 'dismiss'. Severity is whatever the LEDGER already knows about this id — the CLI call
297
+ // has no fresh row to read (--dismiss runs standalone, without re-auditing the capability), and
298
+ // guessing 'normal' when history already says 'high' would let one distracted click silence a
299
+ // high-severity finding in a single shot, the exact failure DISMISSAL_BUDGET exists to prevent.
300
+ //
301
+ // TWO SOURCES, because neither alone covers every dismissal in the sequence. The offer's own
302
+ // severity lives in the PENDING `offered` record, but the moment THIS dismissal resolves it, it is
303
+ // no longer pending — so pendingOffers() only ever sees it before the FIRST dismissal.
304
+ // outcomesFor().lastSeverity only reads resolved records (applied/dismissed/ignored — deliberately
305
+ // NOT `offered`, so a mere show never counts as evidence), so it only ever has an answer from the
306
+ // SECOND dismissal onward. Together they cover every dismissal; genuinely unknown (neither source
307
+ // has ever seen this id) resolves to 'normal' — the quieter class, same direction as weightClass()'s
308
+ // own documented default.
309
+ const pendingSeverity = pendingOffers({ file: OUTCOMES_FILE }).find((p) => p.id === ARG)?.severity;
310
+ const priorSeverity = pendingSeverity || outcomesFor(ARG, { file: OUTCOMES_FILE }).lastSeverity || 'normal';
311
+ const res = safeRecord({ id: ARG, action: ACTIONS.DISMISSED, severity: priorSeverity });
312
+ if (!res.ok) { out.push(`could not record the dismissal (${res.reason}) — nothing changed`); quit(); }
313
+
314
+ // HONEST MESSAGE. Whether it "will not be raised again" now genuinely depends on the budget, not
315
+ // on the mere fact that --dismiss was called — saying so unconditionally would repeat the false
316
+ // "never again" promise the old permanent dismissed-Set made regardless of severity.
317
+ const cls = weightClass(priorSeverity);
318
+ const budget = DISMISSAL_BUDGET[cls];
319
+ const spent = outcomesFor(ARG, { file: OUTCOMES_FILE }).dismissed;
320
+ const stillMayReturn = shouldStillOffer(ARG, { severity: priorSeverity, file: OUTCOMES_FILE });
321
+ out.push(stillMayReturn
322
+ ? `acknowledged: "${ARG}" dismissed (${spent}/${budget} for a ${cls}-severity finding) — a single click cannot bury a high-severity finding, so it may still resurface until the budget is spent. Dismiss again to move it toward silence, or ${SELF} --undismiss ${ARG} to restore it now.`
323
+ : `dismissed: "${ARG}" will not be raised again (undo: ${SELF} --undismiss ${ARG})`);
324
+ quit();
325
+ }
326
+
327
+ // ── suggest ─────────────────────────────────────────────────────────────────────────────────────
328
+ let ev = null;
329
+ try { ev = JSON.parse(process.env.RUVNET_ANTICIPATE_EVENT || ''); } catch { /* not JSON */ }
330
+ if (!ev || typeof ev !== 'object') quit();
331
+
332
+ const prompt = [ev.prompt, ev.user_prompt, ev.input].find((v) => typeof v === 'string' && v.trim()) || '';
333
+ if (prompt.trim().length < 12) quit();
334
+
335
+ // THE DIAL, ENFORCED (ADR-032 / DDD-0004 "The three channels"). This hook is the ADVOCACY channel —
336
+ // unsolicited suggestions — so the user's `advocacy` level governs whether it may speak. Alarms live
337
+ // in session-start.sh and bypass this by design; nothing here can silence a broken-brain warning.
338
+ // Read the settings file DIRECTLY (no ESM import) so a missing module path can never turn the dial
339
+ // into a no-op — the exact failure that left it declared-but-dead. Default is 'important-only' (the
340
+ // owner's "recommend on, do not force"): on out of the box for important findings, one setting away
341
+ // from silent. Unreadable/absent settings resolve to that same default rather than to unbounded speech.
342
+ function advocacyLevel() {
343
+ try {
344
+ const f = process.env.RUVNET_SETTINGS_FILE
345
+ || path.join(os.homedir(), '.config', 'ruvnet-brain', 'settings.json');
346
+ const parsed = JSON.parse(fs.readFileSync(f, 'utf8'));
347
+ // user-settings.mjs saveSettings() writes a VERSIONED ENVELOPE: { version, updated, settings:{…} }.
348
+ // Read the nested `.settings.advocacy` FIRST — reading top-level `.advocacy` (which an earlier
349
+ // version did) meant every real save through the console/CLI was invisible here and the dial
350
+ // silently fell back to the default. Keep a top-level fallback for a hand-written/legacy file.
351
+ const v = (parsed && parsed.settings && parsed.settings.advocacy) ?? (parsed && parsed.advocacy);
352
+ return (v === 'off' || v === 'important-only' || v === 'all') ? v : 'important-only';
353
+ } catch { return 'important-only'; }
354
+ }
355
+ const ADVOCACY = advocacyLevel();
356
+ // THE DIAL for this emitter. anticipate produces exactly ONE class of output: a dormant-capability
357
+ // nudge that has already cleared a high evidence bar (two independent cues + the matcher's confidence
358
+ // floor + once-per-session). Its meaningful dial is therefore off-vs-on: `off` is verifiably silent;
359
+ // both `important-only` (the default — the owner's "recommend on") and `all` let the gated nudge
360
+ // through. There is NO severity axis to split on here — auditAll()/matchGoal() emit none — so a
361
+ // severity gate at this point silences the whole feature at the default (a real regression, caught by
362
+ // the dial integration test 2026-07-23 and removed). The `off` gate is the real, honoured control.
363
+ if (ADVOCACY === 'off') quit();
364
+
365
+ // Session identity decides what "once per session" means. Claude Code supplies session_id; when it
366
+ // is missing we do NOT fall back to something unbounded (that would make every prompt a fresh
367
+ // session and turn this hook into the nag it exists to avoid). A cwd+day key keeps the promise
368
+ // bounded — at worst once per capability per project per day — while still being able to fire.
369
+ const sid = typeof ev.session_id === 'string' && ev.session_id.trim()
370
+ ? ev.session_id.trim()
371
+ : `fallback:${process.cwd()}:${new Date().toISOString().slice(0, 10)}`;
372
+
373
+ const st = readState();
374
+ const sessions = st.sessions && typeof st.sessions === 'object' ? st.sessions : {};
375
+ const said = new Set(strings(sessions[sid]?.said));
376
+ if (said.size >= MAX_PER_SESSION) quit();
377
+
378
+ let auditAll, matchGoal, floor;
379
+ try { ({ auditAll } = await import(pathToFileURL(process.env.RUVNET_CAPABILITY_REGISTRY).href)); } catch { quit(); }
380
+ try {
381
+ const gm = await import(pathToFileURL(process.env.RUVNET_GOAL_MATCH).href);
382
+ matchGoal = gm.matchGoal;
383
+ floor = gm.CONFIDENCE_FLOOR;
384
+ } catch { quit(); }
385
+ if (typeof auditAll !== 'function' || typeof matchGoal !== 'function') quit();
386
+ const MIN_CONFIDENCE = typeof floor === 'number' && Number.isFinite(floor) ? floor : FALLBACK_CONFIDENCE_FLOOR;
387
+
388
+ let rows = [];
389
+ try { rows = auditAll({ project: process.cwd() }) || []; } catch { quit(); }
390
+
391
+ // CONTINUOUS RECONCILIATION — the L5 loop must not depend on someone opening the console. This is the
392
+ // PUSH surface (it already runs every prompt); reconcileApplied() credits an APPLIED for any capability
393
+ // we OFFERED that is now switched on. It is idempotent (a resolved offer stops being pending, so a
394
+ // re-run is a no-op), writes only when there is a real observed on-state WITH a pending offer to close,
395
+ // and never throws. Wiring it here is what makes precision computable from ordinary use instead of only
396
+ // when /api/capabilities is polled — ADR-028's own named failure is a PULL surface guarding an anti-pull
397
+ // metric. Guarded on typeof so an older advocacy module that predates this export degrades to prior
398
+ // behaviour rather than throwing; a lost credit costs one ledger row, never the hook.
399
+ try { if (typeof reconcileApplied === 'function') reconcileApplied(rows, { file: OUTCOMES_FILE }); } catch { /* never break the hook we measure */ }
400
+
401
+ // SILENCE RULE 1. 'off' is the only state that has earned a sentence: installed, and switched off.
402
+ // 'unknown' is a detector saying it could not tell — advocating on it would be fabricating a fault,
403
+ // which is precisely the "26 hooks off" incident. 'absent' means there is nothing to turn on.
404
+ //
405
+ // SUPPRESSION is shouldStillOffer() — the ledger's severity-weighted budget — not a local Set. A row
406
+ // carries no `severity` field today (neither auditAll() nor matchGoal() emit one, confirmed live), so
407
+ // `null` is passed and shouldStillOffer() falls back to whatever it last recorded for this id (or
408
+ // 'normal' if it has never seen one); a `severity` DOES flow through the moment a future registry
409
+ // adds it, because record()/shouldStillOffer() already accept it. `stateHash` comes from the row's
410
+ // own real, measured `evidence` string — never fabricated — so the high-severity state-change
411
+ // reprieve can fire the moment a dismissal is ever recorded WITH a hash (see --dismiss's own comment
412
+ // on why it cannot supply one today).
413
+ const dormant = rows.filter((r) => {
414
+ if (!r || r.state !== 'off' || said.has(r.key)) return false;
415
+ return shouldStillOffer(r.key, { severity: r.severity || null, stateHash: stateHashOf(r.evidence), file: OUTCOMES_FILE });
416
+ });
417
+ if (!dormant.length) quit();
418
+
419
+ let matches = [];
420
+ try { matches = matchGoal(prompt, dormant) || []; } catch { quit(); }
421
+ if (!Array.isArray(matches) || !matches.length) quit();
422
+
423
+ // The matcher is trusted to rank, never to assert existence: every match is re-resolved against the
424
+ // dormant rows THIS audit produced. A capability the matcher names that is not dormant right now is
425
+ // dropped, so a stale or over-eager matcher can only ever cause silence, never a false claim.
426
+ const byKey = new Map(dormant.map((r) => [r.key, r]));
427
+ const scored = [];
428
+ for (const m of matches) {
429
+ if (!m || typeof m !== 'object') continue;
430
+ const key = typeof m.capability === 'string'
431
+ ? m.capability
432
+ : (m.capability && typeof m.capability.key === 'string' ? m.capability.key : '');
433
+ const row = byKey.get(key);
434
+ if (!row) continue;
435
+ const conf = m.confidence;
436
+ if (typeof conf !== 'number' || !Number.isFinite(conf) || conf < MIN_CONFIDENCE) continue;
437
+ const why = typeof m.why === 'string' ? m.why.trim() : '';
438
+ if (!why) continue; // SILENCE RULE 2 — no evidence, no speech
439
+ scored.push({ row, why, conf });
440
+ }
441
+ if (!scored.length) quit();
442
+ scored.sort((a, b) => b.conf - a.conf);
443
+ const best = scored[0];
444
+
445
+ // SILENCE RULE 3, and the ordering here is the whole rule: PERSIST FIRST, SPEAK SECOND. Killed
446
+ // between the two, we lose one suggestion (silent, harmless). The other order risks speaking
447
+ // without recording it, which repeats on the next prompt — and repeating is the failure mode that
448
+ // gets hooks switched off for good. Losing a suggestion is cheap; becoming a nag is not.
449
+ said.add(best.row.key);
450
+ sessions[sid] = { said: [...said], ts: Date.now() };
451
+ st.sessions = Object.fromEntries(
452
+ Object.entries(sessions).sort((a, b) => (b[1]?.ts || 0) - (a[1]?.ts || 0)).slice(0, KEEP_SESSIONS),
453
+ );
454
+ if (!writeState(st)) quit(); // cannot remember having spoken → do not speak
455
+
456
+ // Only real, derived values reach this line: `label`, `whatItBuysYou` and `turnOn` come straight
457
+ // off the audited row, `why` from the matcher. Where the registry has no verified command it says
458
+ // so in those words — there is no invented one-liner and no fabricated state anywhere in it.
459
+ const cmd = best.row.turnOn && typeof best.row.turnOn.cmd === 'string' && best.row.turnOn.cmd.trim()
460
+ ? `turn on with \`${best.row.turnOn.cmd.trim()}\``
461
+ : 'no verified one-line command exists for it — offer to walk them through it';
462
+
463
+ // DO NOT REPEAT THE PAYOFF. goal-match.mjs's explain() already folds `whatItBuysYou` into its `why`,
464
+ // so appending the row's copy of it printed the same sentence twice in one line — which only showed
465
+ // up when the hook was first run against the real matcher instead of a fixture. Add it only when the
466
+ // matcher has not already said it.
467
+ // Cut at the last word boundary inside the cap rather than mid-word ("nothing it discov…" was the
468
+ // real output). Falls back to a hard slice when the text has no space to break on.
469
+ let why = best.why;
470
+ if (why.length > MAX_WHY) {
471
+ const cut = why.slice(0, MAX_WHY);
472
+ const brk = cut.lastIndexOf(' ');
473
+ why = `${(brk > MAX_WHY * 0.6 ? cut.slice(0, brk) : cut).trimEnd()}…`;
474
+ }
475
+ const buys = typeof best.row.whatItBuysYou === 'string' ? best.row.whatItBuysYou.trim() : '';
476
+ const payoff = buys && !why.includes(buys) ? ` It buys them: ${buys}` : '';
477
+
478
+ // The one line this hook has decided to speak, built once. In legacy mode it is printed verbatim; in
479
+ // candidate mode it becomes the `copy` of the advocacy candidate. Byte-identical either way.
480
+ const COPY = `[RuvNet Brain — anticipating] "${best.row.label}" is installed here and switched OFF, and it serves this turn: ${why}${payoff} Offer it ONCE, in one plain sentence (${cmd}), then drop it and get on with the actual work. If they decline: ${SELF} --dismiss ${best.row.key} (each decline moves it toward silence, faster for a routine finding than a serious one)`;
481
+
482
+ if (EMIT_CANDIDATES) {
483
+ // CANDIDATE MODE: emit ONE advocacy candidate, no prose. The runtime honours the dial + the
484
+ // DismissalLedger on this candidate and records the OFFERED denominator centrally — so this path
485
+ // deliberately does NOT safeRecord(OFFERED) here (doing so would double-count precision's
486
+ // denominator once the runtime records it too). severity + observationHash travel on the candidate
487
+ // so the runtime's shouldStillOffer()/record() see the identical inputs this hook used;
488
+ // observationHash maps to the ledger's stateHash. findingId is REQUIRED (the runtime drops an
489
+ // advocacy candidate without one). The persist-first write above already ran.
490
+ out.push(JSON.stringify({
491
+ channel: 'advocacy',
492
+ effect: 'advisory',
493
+ copy: COPY,
494
+ hookEventName: 'UserPromptSubmit',
495
+ findingId: best.row.key,
496
+ severity: best.row.severity || 'normal',
497
+ observationHash: stateHashOf(best.row.evidence),
498
+ }));
499
+ quit();
500
+ }
501
+
502
+ // RECORD THE DENOMINATOR (legacy/direct path only — see the candidate branch above for why this must
503
+ // not also run under the runtime). precision = acted-on / OFFERED, and without this line the
504
+ // denominator is always zero.
505
+ //
506
+ // NOTE what is deliberately absent: `scope: best.row.scope`. An earlier version passed it here, but
507
+ // `best.row.scope` is the CAPABILITY's scope (machine/project/user, from capability-registry.mjs) —
508
+ // a different field entirely from the ledger's own `scope:'forever'` (a permanent-silence marker,
509
+ // valid only on a dismissal). record() validates this strictly and would have THROWN on every single
510
+ // offer the moment this file started calling it instead of a hand-rolled, unvalidated writer — caught
511
+ // here rather than in production.
512
+ safeRecord({
513
+ id: best.row.key, action: ACTIONS.OFFERED,
514
+ severity: best.row.severity || 'normal',
515
+ stateHash: stateHashOf(best.row.evidence),
516
+ });
517
+
518
+ out.push(COPY);
519
+ quit();
520
+ JS
521
+ NODE_PID=$!
522
+
523
+ # HARD WATCHDOG. Not optional and not a `timeout` binary: macOS ships no `timeout`, and a hook that
524
+ # silently depends on coreutils is a hook that hangs on half the machines it runs on. 2s against a
525
+ # 5s hook budget that ground-ruvnet.sh has already partly spent. A SIGKILL mid-write can at worst
526
+ # truncate one advisory line — it can never fail the turn, and the state file was already committed
527
+ # by then (see the persist-first ordering above).
528
+ ( sleep 2; kill -9 "$NODE_PID" 2>/dev/null ) >/dev/null 2>&1 &
529
+ WATCHDOG_PID=$!
530
+ wait "$NODE_PID" 2>/dev/null
531
+ kill "$WATCHDOG_PID" 2>/dev/null
532
+
533
+ # ALWAYS. Every failure above already routed to silence; this makes the guarantee unconditional.
534
+ exit 0
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const raw = fs.readFileSync(0, 'utf8');
8
+ let input = {};
9
+ try { input = raw ? JSON.parse(raw) : {}; } catch { /* the shared hook bodies already fail soft */ }
10
+
11
+ const hookId = process.argv[2] || '';
12
+ const event = String(input.hook_event_name || '');
13
+ let adapted = false;
14
+ const codexToolName = String(input.tool_name).toLowerCase();
15
+
16
+ // Codex names these tools differently from the shared Claude hook contracts. Normalize at the
17
+ // host boundary once so every existing safety/learning body sees the same typed event.
18
+ if (['exec_command', 'functions.exec_command', 'functions__exec_command'].includes(codexToolName)) {
19
+ input.tool_name = 'Bash';
20
+ input.tool_input = {
21
+ ...(input.tool_input || {}),
22
+ command: input.tool_input?.command || input.tool_input?.cmd || '',
23
+ };
24
+ adapted = true;
25
+ } else if (codexToolName === 'apply_patch') {
26
+ const patch = typeof input.tool_input?.command === 'string' ? input.tool_input.command : '';
27
+ const filePath = patch.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/m)?.[1]?.trim() || '';
28
+ input.tool_name = 'Edit';
29
+ input.tool_input = {
30
+ ...(input.tool_input || {}),
31
+ ...(filePath ? { file_path: filePath } : {}),
32
+ new_string: patch,
33
+ };
34
+ adapted = true;
35
+ } else if (codexToolName === 'spawn_agent') {
36
+ input.tool_name = 'Agent';
37
+ input.tool_input = {
38
+ ...(input.tool_input || {}),
39
+ description: input.tool_input?.description || input.tool_input?.message || '',
40
+ subagent_type: input.tool_input?.subagent_type || input.tool_input?.agent_type || 'default',
41
+ };
42
+ adapted = true;
43
+ }
44
+
45
+ const hookInput = adapted ? JSON.stringify(input) : raw;
46
+ const shim = path.join(path.dirname(fileURLToPath(import.meta.url)), 'hook-shim.mjs');
47
+ const env = {
48
+ ...process.env,
49
+ CLAUDE_SESSION_ID: String(input.session_id || process.env.CLAUDE_SESSION_ID || ''),
50
+ CLAUDE_PLUGIN_ROOT: String(process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || ''),
51
+ CLAUDE_PROJECT_DIR: String(input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd()),
52
+ RUVNET_HOOK_HOST: 'codex',
53
+ };
54
+ const result = spawnSync(process.execPath, [shim, hookId, ...process.argv.slice(3)], {
55
+ input: hookInput,
56
+ encoding: 'utf8',
57
+ env,
58
+ });
59
+
60
+ if (result.status && result.stderr) process.stderr.write(result.stderr);
61
+ if (result.status) process.exit(result.status);
62
+
63
+ const stdout = result.stdout || '';
64
+ if (!stdout) process.exit(0);
65
+
66
+ let parsed = null;
67
+ try { parsed = JSON.parse(stdout); } catch { /* plain text is valid for some Codex events */ }
68
+
69
+ if (event === 'Stop') {
70
+ const reason = parsed?.hookSpecificOutput?.additionalContext
71
+ || parsed?.reason
72
+ || parsed?.stopReason;
73
+ if (reason) process.stdout.write(JSON.stringify({ decision: 'block', reason }));
74
+ process.exit(0);
75
+ }
76
+
77
+ if ((event === 'SessionStart' || event === 'UserPromptSubmit') && !parsed) {
78
+ process.stdout.write(JSON.stringify({
79
+ hookSpecificOutput: {
80
+ hookEventName: event,
81
+ additionalContext: stdout,
82
+ },
83
+ }));
84
+ process.exit(0);
85
+ }
86
+
87
+ if (parsed?.hookSpecificOutput?.permissionDecision === 'defer') {
88
+ delete parsed.hookSpecificOutput.permissionDecision;
89
+ if (Object.keys(parsed.hookSpecificOutput).length === 1 && parsed.hookSpecificOutput.hookEventName) {
90
+ delete parsed.hookSpecificOutput;
91
+ }
92
+ process.stdout.write(JSON.stringify(parsed));
93
+ process.exit(0);
94
+ }
95
+
96
+ process.stdout.write(stdout);