ruvnet-brain 3.9.134-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.
- package/.claude-plugin/marketplace.json +13 -0
- package/README.md +2 -2
- package/bin/install.mjs +284 -33
- package/kb/zip-extract.mjs +53 -14
- package/package.json +7 -1
- package/plugin/.claude-plugin/marketplace.json +13 -0
- package/plugin/.claude-plugin/plugin.json +23 -0
- package/plugin/.codex-plugin/plugin.json +21 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/commands/brain-console.md +16 -0
- package/plugin/commands/configure.md +32 -0
- package/plugin/commands/rvbc.md +78 -0
- package/plugin/commands/rvcb.md +16 -0
- package/plugin/commands/whats-new.md +57 -0
- package/plugin/hooks/codex-hooks.json +160 -0
- package/plugin/hooks/hook-contracts.json +77 -0
- package/plugin/hooks/hooks.json +203 -0
- package/plugin/mcp/server.mjs +35 -6
- package/plugin/scripts/anticipate.sh +534 -0
- package/plugin/scripts/codex-hook-adapter.mjs +96 -0
- package/plugin/scripts/continuation-gate.mjs +267 -0
- package/plugin/scripts/design-wall.sh +137 -0
- package/plugin/scripts/detach.mjs +168 -0
- package/plugin/scripts/finalize-token-meter.mjs +25 -0
- package/plugin/scripts/gate-receipt.sh +35 -0
- package/plugin/scripts/ground-before-write.sh +199 -0
- package/plugin/scripts/ground-ruvnet.sh +507 -0
- package/plugin/scripts/grounding-stamp.sh +113 -0
- package/plugin/scripts/grounding-substance.mjs +595 -0
- package/plugin/scripts/hijack-ruvnet.sh +81 -0
- package/plugin/scripts/hook-input.mjs +558 -0
- package/plugin/scripts/hook-shim-bash.mjs +55 -0
- package/plugin/scripts/hook-shim.mjs +303 -0
- package/plugin/scripts/host-update.mjs +58 -0
- package/plugin/scripts/kling-preflight.sh +146 -0
- package/plugin/scripts/learn-capture.sh +154 -0
- package/plugin/scripts/learn-flush.mjs +138 -0
- package/plugin/scripts/lesson-hooks.sh +213 -0
- package/plugin/scripts/md-stamp.mjs +219 -0
- package/plugin/scripts/protect-brain-state.sh +84 -0
- package/plugin/scripts/route-dispatch.sh +147 -0
- package/plugin/scripts/routing-outcome-capture.mjs +89 -0
- package/plugin/scripts/session-start.sh +868 -0
- package/plugin/scripts/signal-watch.mjs +193 -0
- package/plugin/scripts/unprompted-runtime.mjs +377 -0
- package/plugin/scripts/update-apply.mjs +419 -0
- package/plugin/scripts/verify-interface.sh +53 -0
- package/plugin/scripts/version-bump-gate.sh +112 -0
- package/plugin/skills/brain-build/SKILL.md +123 -0
- package/plugin/skills/brain-console/SKILL.md +20 -0
- package/plugin/skills/brain-prompt/SKILL.md +83 -0
- package/plugin/skills/brain-score/SKILL.md +101 -0
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +117 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
- package/plugin/skills/rvbc/SKILL.md +20 -0
- package/plugin/skills/savings/SKILL.md +46 -0
- 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);
|