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.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/README.md +2 -2
  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,267 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * continuation-gate.mjs — the gate that fires on STOPPING, because nothing else can.
4
+ *
5
+ * THE HOLE THIS CLOSES, and it is a real architectural gap in ADR-030, not a missing feature.
6
+ *
7
+ * Every gate in this project fires on an ACTION: a Write, an Edit, a push, a claim, a status
8
+ * report. That is what makes them enforceable — there is a tool call to intercept.
9
+ *
10
+ * **Stopping is the absence of an action.** When the model finishes a unit of work, writes a
11
+ * summary, and waits — no tool fires, no text is classified, nothing is intercepted. The single
12
+ * most costly failure of 2026-07-22 had NO TRIGGER, which is why a system explicitly built to
13
+ * prevent it did not prevent it.
14
+ *
15
+ * The owner, 05:45, and it is the correct indictment: *"This was exactly the stuff that RuvNet-Brain
16
+ * was designed to stop, so the fact that you didn't is yet another failure... you agree you are
17
+ * going to finish something and you stop because you have some excuse, and then you don't start
18
+ * yourself up again."*
19
+ *
20
+ * L13 was recorded and ratified an hour earlier and did not help, because it fires on
21
+ * `report-status` — it can only catch a stop that ANNOUNCES itself. A silent stop is invisible to
22
+ * every gate in the system.
23
+ *
24
+ * HOW THIS WORKS. A `Stop` hook runs when a turn ends. It reads the work ledger — a plain list of
25
+ * committed-to items with a done state — and if authorized work remains unfinished, it says so, in
26
+ * the last place the model looks before going quiet.
27
+ *
28
+ * WHAT IT DOES, verified against code.claude.com/docs/en/hooks.md (2026-07-23, not recalled, ADR-043):
29
+ * a Stop hook's `additionalContext` at exit 0 DOES force a continuation — under the same loop
30
+ * protections as decision:block (the `stop_hook_active` input + the 8-consecutive-continuation cap). An
31
+ * earlier version of this header claimed "a Stop hook cannot force another turn"; that was wrong. The
32
+ * gate still exits 0 always — continuation is driven by the envelope, never by a non-zero exit code.
33
+ *
34
+ * FAILS OPEN ALWAYS. Exit 0 unconditionally. A gate that breaks a turn's completion because it
35
+ * could not read a JSON file would be disabled within a day, and a disabled gate protects nothing.
36
+ */
37
+ import fs from 'node:fs';
38
+ import path from 'node:path';
39
+ import os from 'node:os';
40
+ import { readStdinBounded } from './hook-input.mjs';
41
+
42
+ const HOME = os.homedir();
43
+
44
+ // The only exit code this file may ever use. A Stop hook that exits non-zero refuses to let the turn
45
+ // end; this gate informs and never refuses, so every path below returns exactly this.
46
+ const EXIT_ALLOW = 0;
47
+ /**
48
+ * PROJECT-SCOPED, because this runs machine-wide.
49
+ *
50
+ * The owner runs three projects simultaneously. A single global ledger would mix their commitments
51
+ * and fire "you did not finish X" in a repo that never heard of X — which is a false alarm, and
52
+ * ADR-028 fixes the false-alarm rate at ZERO. So the ledger is keyed by the git repo root (falling
53
+ * back to cwd), stored centrally under ~/.config so it survives `--update`, but partitioned per
54
+ * project so the three never see each other's work.
55
+ */
56
+ function projectKey() {
57
+ let dir = process.cwd();
58
+ // Walk up to the git root — the stable identity of a project, regardless of which subdirectory
59
+ // a hook happens to fire from. (A CWD-derived key was exactly the bug that scattered ledgers
60
+ // through users' project trees in issue #36.)
61
+ for (let i = 0; i < 12; i++) {
62
+ if (fs.existsSync(path.join(dir, '.git'))) break;
63
+ const up = path.dirname(dir);
64
+ if (up === dir) { dir = process.cwd(); break; }
65
+ dir = up;
66
+ }
67
+ return path.basename(dir).replace(/[^a-zA-Z0-9._-]/g, '_');
68
+ }
69
+
70
+ const LEDGER = process.env.RUVNET_WORK_LEDGER
71
+ || path.join(HOME, '.config', 'ruvnet-brain', 'work-ledgers', `${projectKey()}.json`);
72
+
73
+ const argv = process.argv.slice(2);
74
+ const arg = (f) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : null; };
75
+ const has = (f) => argv.includes(f);
76
+
77
+ function load() {
78
+ try {
79
+ const j = JSON.parse(fs.readFileSync(LEDGER, 'utf8'));
80
+ return Array.isArray(j.items) ? j : { items: [] };
81
+ } catch { return { items: [] }; }
82
+ }
83
+ function save(led) {
84
+ try {
85
+ fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
86
+ fs.writeFileSync(LEDGER, JSON.stringify({ ...led, updated: new Date().toISOString() }, null, 2) + '\n');
87
+ } catch { /* the ledger is advisory — never break a turn over it */ }
88
+ }
89
+
90
+ // ── commands ─────────────────────────────────────────────────────────────────────────────────────
91
+ if (has('--commit-to')) {
92
+ // Record work the model AGREED to do. The agreement is the thing that makes stopping a defect —
93
+ // without it, ending a turn is simply finishing, and this gate must stay silent.
94
+ const led = load();
95
+ const text = arg('--commit-to');
96
+ if (text && !led.items.some((i) => i.text === text && !i.done)) {
97
+ led.items.push({ text, done: false, at: new Date().toISOString() });
98
+ save(led);
99
+ }
100
+ console.log(`committed: ${text}`);
101
+ process.exit(0);
102
+ }
103
+
104
+ if (has('--done')) {
105
+ const led = load();
106
+ const needle = arg('--done');
107
+ // EXACT text match only (GPT-5.6-Sol review). The earlier "unambiguous substring" fallback could still
108
+ // clear a SINGLETON open item via a fragment — a fake-completion valve under a gate that now applies real
109
+ // continuation pressure. Marking done requires the item's exact text (copy it from the ledger line).
110
+ const targets = led.items.filter((i) => !i.done && i.text === needle);
111
+ for (const i of targets) { i.done = true; i.doneAt = new Date().toISOString(); }
112
+ save(led);
113
+ console.log(`marked done: ${targets.length}`);
114
+ process.exit(0);
115
+ }
116
+
117
+ if (has('--clear')) { save({ items: [] }); console.log('ledger cleared'); process.exit(0); }
118
+
119
+ // ── the Stop hook itself (default action) ────────────────────────────────────────────────────────
120
+ /**
121
+ * READ THE PAYLOAD. Every Stop hook receives a JSON object on stdin, and until 2026-07-22 this file
122
+ * ignored it completely — which made the loop guard below not merely absent but UNREACHABLE.
123
+ *
124
+ * Never block waiting for stdin: the CLI paths (--commit-to / --done) are invoked from a terminal
125
+ * with no piped input, and a gate that hangs is worse than a gate that is silent.
126
+ */
127
+ async function readHookInput() {
128
+ // Three cases, treated DIFFERENTLY (ADR-043, Fable red-team #1):
129
+ // - 'tty' : run bare in a terminal, not as a hook → never force.
130
+ // - 'unreadable' : stdin present but read/parse FAILED. `fs.readFileSync(0)` throws EAGAIN
131
+ // intermittently on macOS — a real footgun. The old code returned {} here, which
132
+ // under a forcing gate LAUNDERS a read error into a fresh-stop verdict → a forced
133
+ // loop. We must not force when we could not confirm the payload.
134
+ // - 'stdin' : a payload we actually parsed → the only case allowed to force.
135
+ if (process.stdin.isTTY) return { __source: 'tty' };
136
+ try {
137
+ const raw = (await readStdinBounded()).toString('utf8');
138
+ return { ...JSON.parse(raw || '{}'), __source: 'stdin' };
139
+ } catch { return { __source: 'unreadable' }; }
140
+ }
141
+ const hookInput = await readHookInput();
142
+
143
+ // LOOP-SAFETY 1 (ADR-043 / Fable #1) — only an affirmatively-parsed hook payload may force. A 'tty' or
144
+ // 'unreadable' source cannot be confirmed a fresh stop, so it never forces.
145
+ if (hookInput.__source !== 'stdin') process.exit(EXIT_ALLOW);
146
+
147
+ /**
148
+ * LOOP-SAFETY 2 — the documented guard. `stop_hook_active` is true once Claude Code is already
149
+ * continuing because of a stop hook (verified against code.claude.com/docs/en/hooks.md, ADR-043).
150
+ * Honouring it caps each natural-stop episode at EXACTLY ONE forced continuation. Truthy, not
151
+ * `=== true`, so a future string/number drift ("true", 1) cannot slip past into a loop.
152
+ */
153
+ if (hookInput.stop_hook_active) process.exit(EXIT_ALLOW);
154
+
155
+ const led = load();
156
+ const nowMs = Date.now();
157
+
158
+ // LOOP-SAFETY 1b (GPT-5.6-Sol review) — an empty-but-parseable `{}` is NOT a real Stop payload; a genuine
159
+ // one carries `session_id` (a documented Stop input). Without it we cannot confirm a real stop, so we never
160
+ // force. This closes the empty-stdin hole that LOOP-SAFETY 1's `__source` check does not cover.
161
+ if (!hookInput.session_id) process.exit(EXIT_ALLOW);
162
+
163
+ const open = led.items.filter((i) => !i.done);
164
+ if (!open.length) process.exit(EXIT_ALLOW); // nothing outstanding: silence is correct
165
+
166
+ /**
167
+ * FRESHNESS (ADR-043 / Fable #3, tightened by GPT-5.6-Sol) — only FORCE for work with a VALID, recent
168
+ * timestamp. A missing or unparseable `at` is treated as STALE and NOT forced: a real item always carries
169
+ * an `at` (set by --commit-to), so only a malformed/legacy row lacks one, and forcing forever on an item of
170
+ * UNKNOWN age is exactly the fabrication-pressure this guard exists to stop.
171
+ */
172
+ // CORRECTED 2026-07-24, SAME DAY IT WAS INTRODUCED — and it had already broken the gate for ~30 hours.
173
+ //
174
+ // The guard above was written to stop "forcing forever on an item of UNKNOWN age." That intent is
175
+ // right and is preserved: a row with a missing or unparseable `at` is still refused, because an item
176
+ // of unknown age can nag forever with no evidence it is real.
177
+ //
178
+ // What shipped was different and wrong: `(nowMs - t) < 24h` ALSO discarded items with a perfectly
179
+ // VALID timestamp that were merely old. Measured on this machine: four genuinely-open commitments
180
+ // aged 53-56h, `forceable` came back empty, and the gate exited EXIT_ALLOW in silence. The owner's
181
+ // single most emphatic standing rule — "do not stop until it is done" — was enforced by a mechanism
182
+ // that had quietly switched itself off, and the only symptom was nothing happening.
183
+ //
184
+ // The inversion is the lesson: OLD OPEN WORK IS THE CASE THAT MOST NEEDS THE NUDGE. Work finished in
185
+ // an hour never reaches this gate. Work still open after two days is exactly what gets forgotten, and
186
+ // treating age as a reason for silence hands the failure mode a timer. It is the same shape as every
187
+ // other defect found today — silence standing in for a measurement — committed inside the guard whose
188
+ // whole job is to prevent stopping early.
189
+ //
190
+ // So: age no longer gates DELIVERY, it decorates it. An old item is still forced, and the nudge SAYS
191
+ // how old it is, which is information the reader needs rather than a reason to withhold. If a ledger
192
+ // is genuinely abandoned, the honest fix is to mark its items done — not to let a clock silently
193
+ // decide the commitment expired.
194
+ const forceable = open.filter((i) => Number.isFinite(Date.parse(i.at)));
195
+ if (!forceable.length) process.exit(EXIT_ALLOW);
196
+
197
+ /** Age, only ever used to LABEL an item — never to suppress one. See the note above. */
198
+ const ageLabel = (i) => {
199
+ const h = (nowMs - Date.parse(i.at)) / 3_600_000;
200
+ if (h < 1) return '';
201
+ if (h < 24) return ` (committed ${Math.round(h)}h ago)`;
202
+ return ` (committed ${Math.round(h / 24)}d ago — still open)`;
203
+ };
204
+
205
+ /**
206
+ * LOOP-SAFETY 3 (belt-and-braces this file OWNS — Fable #1, made fail-closed + race-safe by the GPT-5.6-Sol
207
+ * review). Claim the force ATOMICALLY via an exclusive-create lock that doubles as the cooldown marker:
208
+ * - a fresh lock (another force within COOLDOWN_MS, incl. a concurrent second Stop hook) → do NOT force;
209
+ * - the claim cannot be persisted → do NOT force (fail CLOSED — never a force we could not record);
210
+ * - exclusive create (`wx`) serialises two racing hooks so they can never both win.
211
+ * This replaces a read-lastForcedAt-then-write that failed OPEN on a write error and let two hooks race.
212
+ */
213
+ const COOLDOWN_MS = Number(process.env.RUVNET_CONTINUATION_COOLDOWN_MS ?? 20000);
214
+ const LOCK = LEDGER + '.cooldown';
215
+ function claimCooldown(now, windowMs) {
216
+ try {
217
+ const prev = Date.parse(fs.readFileSync(LOCK, 'utf8'));
218
+ if (Number.isFinite(prev) && (now - prev) < windowMs) return false; // fresh lock: someone forced recently
219
+ fs.unlinkSync(LOCK); // stale: clear it so we can re-claim
220
+ } catch { /* no lock yet */ }
221
+ try { fs.writeFileSync(LOCK, new Date(now).toISOString(), { flag: 'wx' }); return true; }
222
+ catch { return false; } // lost the race / cannot persist → fail closed
223
+ }
224
+ if (!claimCooldown(nowMs, COOLDOWN_MS)) process.exit(EXIT_ALLOW);
225
+
226
+ /**
227
+ * DELIVERY. `additionalContext` in a Stop envelope forces the continuation (same protection as
228
+ * decision:block). Directive copy — continue, do not look for an exit.
229
+ */
230
+ const lines = [
231
+ 'You have unfinished work you committed to. Do NOT end the turn — continue now.',
232
+ 'Pick the highest-leverage open item below and make real progress on it this turn. Stop only when',
233
+ 'EVERY item is genuinely done or blocked; if one is blocked, say why in a single line and move to',
234
+ 'the next — never stop on the first obstacle, and never manufacture a reason to go quiet.',
235
+ '',
236
+ // Age is LABELLED, never used to suppress — an item open for days is the one most worth naming.
237
+ ...forceable.slice(0, 8).map((i) => ` ☐ ${i.text}${ageLabel(i)}`),
238
+ ...(forceable.length > 8 ? [` … and ${forceable.length - 8} more`] : []),
239
+ '',
240
+ 'Mark each item done as you complete it: node plugin/scripts/continuation-gate.mjs --done "<exact item text>"',
241
+ // THE HONEST EXIT, and it is what makes forcing old items safe.
242
+ //
243
+ // Fable's red-team #3 was right that a stale item pressuring every turn "breeds
244
+ // mark-done-without-doing". The first answer to that was a 24h TTL — which silently disabled the
245
+ // gate on genuine multi-day work (measured 2026-07-24: four real commitments, 53-56h old, gate mute
246
+ // for ~30 hours). Both failure modes are real, and they are not opposites: the pressure to fake a
247
+ // completion comes from being nagged with NO LEGITIMATE WAY OUT.
248
+ //
249
+ // So the resolution is neither silence nor endless nagging: keep forcing, and name the honest
250
+ // disposal out loud. An item that is genuinely dead gets cleared — a deliberate, recorded act —
251
+ // instead of expiring on a timer nobody sees, or being falsely marked done to stop the noise.
252
+ ...(forceable.some((i) => (nowMs - Date.parse(i.at)) > 24 * 3_600_000)
253
+ ? ['', 'Some of these are days old. If one is genuinely no longer real, say so and CLEAR it —',
254
+ 'that is a legitimate answer and the right one. What is never acceptable is marking it done',
255
+ 'without doing it, or letting it age quietly out of view.']
256
+ : []),
257
+ ];
258
+
259
+ process.stdout.write(JSON.stringify({
260
+ hookSpecificOutput: {
261
+ hookEventName: 'Stop', // must name the firing event or the envelope is discarded
262
+ additionalContext: lines.join('\n'),
263
+ },
264
+ }));
265
+
266
+ // Exit 0 regardless. This gate informs at the boundary; it never breaks the turn.
267
+ process.exit(EXIT_ALLOW);
@@ -0,0 +1,137 @@
1
+ #!/bin/bash
2
+ # design-wall.sh — PreToolUse gate on Bash. NOTHING VISUAL SHIPS, COMMITS, OR OPENS UNGRADED.
3
+ #
4
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
5
+ # WHY (2026-07-16). Stuart, after a carelessly-composed public band shipped twice: "You are always,
6
+ # and I mean always, supposed to look at a page as an end user would: take pictures of it, review it,
7
+ # analyze it, grade it, see if it gets a 95 or better, and if it doesn't, tweak it until it does —
8
+ # before you ever tell me something is ready." And when that landed as a memory note instead of a
9
+ # mechanism: "Suggestions mean bullshit to you. RuvNet Brain needs to be smart enough to make sure
10
+ # those suggestions become law and the law becomes followed."
11
+ #
12
+ # He is right about the mechanism. This repo's entire history says advisory rules fail and walls hold
13
+ # (route-dispatch, verify-interface, ground-before-write, substitution:check, narrative-version).
14
+ # So the 95-gate is a WALL: deploying the explainer, committing visual surfaces, or opening a page
15
+ # for the user REQUIRES a fresh passing design-grade stamp for that surface. The only key is
16
+ # scripts/design-grade.mjs, which itself refuses to stamp without >=2 fresh screenshots at distinct
17
+ # widths and written deductions. The grade stays judgment; the ritual is enforced; the receipt is
18
+ # auditable.
19
+ #
20
+ # CONTRACT: exit 0 = allow · exit 2 + stderr = BLOCK (stderr returns to the model as the reason).
21
+ # FAILS OPEN on anything it cannot parse — a gate that breaks the shell protects nothing.
22
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
23
+ set -uo pipefail
24
+
25
+ INPUT=""
26
+ # BOUNDED READ (2026-07-27, ADR-055 F20): an unqualified `read` never returns on a stdin that is
27
+ # opened and never closed — measured across the mesh, 18 of 37 registered commands sat until the
28
+ # harness killed them. Real Claude Code writes and closes, so this costs no normal turn; that is
29
+ # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
30
+ # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
31
+ # the whole thing back at once and a per-iteration cap never fires.
32
+ while IFS= read -r -t 2 _l; do
33
+ INPUT+="$_l"
34
+ [ ${#INPUT} -ge 65536 ] && break
35
+ done
36
+ [ -n "$_l" ] && INPUT+="$_l"
37
+ INPUT="${INPUT:0:65536}"
38
+ [ -n "$INPUT" ] || exit 0
39
+
40
+ # Parse the payload with the shared JSON parser (hook-input.mjs), NOT a bash regex. The old
41
+ # field() { …"([^"]*)"… } truncated any command containing a quote at the first one — the exact
42
+ # #13 fail-open verify-interface.sh already fixed, silently reintroduced here (design-wall.sh was
43
+ # written after). node is present in Claude Code's environment; fail open (exit 0) if it isn't. ADR-0021.
44
+ NODE_BIN=$(command -v node) || exit 0
45
+ HOOK_INPUT="$(dirname "${BASH_SOURCE[0]}")/hook-input.mjs"
46
+ [ "$(printf '%s' "$INPUT" | "$NODE_BIN" "$HOOK_INPUT" tool_name 2>/dev/null)" = "Bash" ] || exit 0
47
+ CMD=$(printf '%s' "$INPUT" | "$NODE_BIN" "$HOOK_INPUT" command 2>/dev/null)
48
+ [ -n "$CMD" ] || exit 0
49
+
50
+ # ── Repo identity gate (2026-07-17, issue #17) ───────────────────────────────────────────────────
51
+ # BUG: this hook never checked WHICH repo it was running in, so a plain `git commit` touching a
52
+ # README.md in ANY unrelated project on this machine (any repo where the plugin happens to be
53
+ # installed) got blocked demanding a ruvnet-brain design-grade ritual for someone else's CLI tool.
54
+ # The whole point of this wall is to guard ruvnet-brain's OWN surfaces — its stamp path lives under
55
+ # ~/.cache/ruvnet-brain, and explainer/ + console/ only exist in ruvnet-brain's own checkout — so it
56
+ # has no business firing anywhere else. Identify the repo via the plugin manifest's own name field
57
+ # first (authoritative — a renamed clone/fork can't fake it just by matching a directory name), and
58
+ # fall back to the console/+explainer/+design-grade.mjs trio only if the manifest is missing.
59
+ ROOT=$(git -C "${CLAUDE_PROJECT_DIR:-.}" rev-parse --show-toplevel 2>/dev/null)
60
+ [ -n "$ROOT" ] || exit 0
61
+ IS_RUVNET_BRAIN=0
62
+ if [ -f "$ROOT/plugin/.claude-plugin/plugin.json" ]; then
63
+ grep -Eq '"name"[[:space:]]*:[[:space:]]*"ruvnet-brain"' "$ROOT/plugin/.claude-plugin/plugin.json" 2>/dev/null && IS_RUVNET_BRAIN=1
64
+ elif [ -d "$ROOT/console" ] && [ -d "$ROOT/explainer" ] && [ -f "$ROOT/scripts/design-grade.mjs" ]; then
65
+ IS_RUVNET_BRAIN=1
66
+ fi
67
+ [ "$IS_RUVNET_BRAIN" = "1" ] || exit 0
68
+
69
+ # ── The escape hatch, made reachable (2026-07-17) ────────────────────────────────────────────────
70
+ # BUG: this check read RUVNET_SKIP_DESIGN_WALL from the HOOK's own environment. But a PreToolUse
71
+ # hook runs in its own process, spawned BEFORE the command exists — so `RUVNET_SKIP_DESIGN_WALL=1
72
+ # git commit …` set the variable for git and the wall never saw it. The wall advertised an override
73
+ # that nobody on the agent side could actually use. It deadlocked a commit whose only visual diff
74
+ # was two version strings written by sync-version.mjs (zero pixels changed), leaving exactly two
75
+ # exits: fake a >=95 self-grade to open the gate, or don't ship. That first exit is the precise
76
+ # failure this repo learned the hard way on 2026-07-17 — a self-assigned grade of my own taste,
77
+ # laundered into a timestamped receipt (Stuart looked at a page I had graded 96 and scored it 55).
78
+ # An UNREACHABLE escape hatch is worse than none: it turns "this gate is wrong in this case" into
79
+ # "this gate cannot be wrong", and a gate that cannot be wrong is not a gate, it is a wish.
80
+ # So: still honored from the env, and now also from the command string — but LOUD. Every override
81
+ # writes a receipt, so skipping the wall leaves the same auditable trail as being caught by it.
82
+ # The wall still holds for anything that actually changed pixels; it just can no longer force a lie.
83
+ if [ "${RUVNET_SKIP_DESIGN_WALL:-0}" = "1" ] || [[ $CMD == *"RUVNET_SKIP_DESIGN_WALL=1"* ]]; then
84
+ bash "$(dirname "${BASH_SOURCE[0]}")/gate-receipt.sh" design-wall override \
85
+ "deliberate override — wall skipped, reason stated in the turn" 2>/dev/null || true
86
+ exit 0
87
+ fi
88
+
89
+ STAMPDIR="$HOME/.cache/ruvnet-brain"
90
+ need=()
91
+
92
+ # 1) Production deploys of the public explainer.
93
+ if [[ $CMD == *vercel* && $CMD == *--prod* ]]; then need+=("explainer"); fi
94
+
95
+ # 2) Commits that stage visual surfaces.
96
+ if [[ $CMD == *"git commit"* ]]; then
97
+ STAGED=$(git -C "${CLAUDE_PROJECT_DIR:-.}" diff --cached --name-only 2>/dev/null || true)
98
+ [[ $STAGED == *"explainer/"* ]] && need+=("explainer")
99
+ [[ $STAGED == *"console/"* ]] && need+=("console")
100
+ [[ $STAGED == *"README.md"* ]] && need+=("readme")
101
+ fi
102
+
103
+ # 3) Opening a page for the user — presenting IS shipping.
104
+ if [[ $CMD =~ open[^\|\;]*https?:// ]]; then
105
+ [[ $CMD == *"isovision.ai/ruvnet-brain"* || $CMD == *"ruvnet-brain.vercel.app"* ]] && need+=("explainer")
106
+ [[ $CMD == *"localhost:7411"* || $CMD == *"127.0.0.1:7411"* ]] && need+=("console")
107
+ fi
108
+
109
+ [ ${#need[@]} -eq 0 ] && exit 0
110
+
111
+ for s in "${need[@]}"; do
112
+ ST="$STAMPDIR/design-stamp-$s.json"
113
+ ok=0
114
+ if [ -f "$ST" ] && grep -Eq '"passing":[[:space:]]*true' "$ST" 2>/dev/null; then
115
+ agemin=$(( ( $(date +%s) - $(stat -f %m "$ST" 2>/dev/null || echo 0) ) / 60 ))
116
+ [ "$agemin" -le 45 ] && ok=1
117
+ fi
118
+ if [ "$ok" != "1" ]; then
119
+ # Record the catch BEFORE refusing. The block is the evidence — without this line the ledger
120
+ # shows only the passing grade that came after the fix, and the wall cannot prove it ever held.
121
+ bash "$(dirname "${BASH_SOURCE[0]}")/gate-receipt.sh" design-wall "$s" "ungraded visual surface, no fresh passing grade" 2>/dev/null || true
122
+ cat >&2 <<EOF
123
+ ⛔ DESIGN WALL — no fresh passing grade for surface '$s'.
124
+ Nothing visual ships, commits, or opens for the user until it has been LOOKED AT as an end user
125
+ would and graded 95 or better. The ritual (minutes, enforced):
126
+ 1. Screenshot the REAL surface at TWO widths (1440 and ~1920).
127
+ 2. LOOK at both. Actively: does it look great? typography right? does every block earn its place?
128
+ Write the deductions down.
129
+ 3. node scripts/design-grade.mjs --surface $s --grade <n> --shot <p1> --shot <p2> --deductions "…"
130
+ Below 95 it records the grade and STAYS CLOSED — fix, re-shoot, re-grade until it opens.
131
+ Stamp: $ST (valid 45 min — pages change, grades expire).
132
+ Deliberate override (rare, say why out loud): RUVNET_SKIP_DESIGN_WALL=1
133
+ EOF
134
+ exit 2
135
+ fi
136
+ done
137
+ exit 0
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * detach.mjs — launch one long-running maintenance job OUT of the hook's process group, with an
4
+ * explicit lifetime and a written receipt.
5
+ *
6
+ * ── THE DEFECT THIS EXISTS TO CLOSE (measured, not reasoned) ────────────────────────────────────
7
+ * `scripts/selfcheck.mjs` fires every registration with the child in its OWN process group and then
8
+ * asks, at exit and again after SIGTERM, whether ANYTHING in that group is still alive
9
+ * (`kill(-pgid, 0)`). session-start.sh backgrounded three jobs with a bare `&`. A bare `&` in a
10
+ * non-interactive `sh` does NOT change the process group — the job stays a member of the hook's
11
+ * group — so the answer was "yes, descendants alive": the `orphan` violation the stranger-matrix
12
+ * reported on all five images. The failure is real and not cosmetic: on a stranger's machine those
13
+ * are `node` and `claude` processes still running after Claude Code has moved on, invisible to the
14
+ * user and multiplied by every session start.
15
+ *
16
+ * ── WHY THE JOBS ARE NOT SIMPLY KILLED AT EXIT ─────────────────────────────────────────────────
17
+ * "Kill the group on the way out" is the obvious fix and it is wrong for this workload. The three
18
+ * jobs are a spine seed, a signed-bundle freshness check, and a plugin auto-update; each is seconds
19
+ * to minutes of work, and session-start.sh exits in ~200ms. Killing them on exit would mean the
20
+ * update NEVER completes on any machine — trading a hygiene violation for a permanently broken
21
+ * updater. So the honest answer is the third one ADR-023 already implies: these jobs do not belong
22
+ * to the session's lifetime at all. They are machine maintenance, like a package manager's
23
+ * background install, and they are moved out of the session's process group ON PURPOSE.
24
+ *
25
+ * "On purpose" has to be worth something, so it comes with two obligations this file discharges:
26
+ *
27
+ * 1. AN EXPLICIT LIFETIME. Every job carries a TTL in seconds. A supervisor — itself detached —
28
+ * holds a timer and SIGTERMs the job's whole group at the deadline (SIGKILL 3s later). A
29
+ * detached job with no deadline is exactly the invisible-forever process this file is fixing;
30
+ * moving it to a new process group without a clock would only hide it better.
31
+ * 2. A RECEIPT. Every start, exit and TTL-kill appends one line to
32
+ * ~/.cache/ruvnet-brain/detached-jobs.jsonl. "Invisible and unkillable-by-the-user" was half
33
+ * the complaint; a user who wants to know what is running, or wants to kill it, has a pid and
34
+ * a command to look at rather than a mystery in `ps`.
35
+ *
36
+ * ── USAGE ───────────────────────────────────────────────────────────────────────────────────────
37
+ * node detach.mjs <ttlSeconds> <logPath|-> <cmd> [args...]
38
+ * Returns in ~40ms having spawned nothing the caller must wait for. Exit code is always 0: a
39
+ * maintenance job that cannot be launched must never fail a session start.
40
+ */
41
+ import fs from 'node:fs';
42
+ import os from 'node:os';
43
+ import path from 'node:path';
44
+ import { spawn } from 'node:child_process';
45
+ import { fileURLToPath } from 'node:url';
46
+
47
+ const SELF = fileURLToPath(import.meta.url);
48
+ const SUPERVISOR = process.env.RUVNET_DETACH_SUPERVISOR === '1';
49
+ const GRACE_MS = 3000; // SIGTERM → this long → SIGKILL. Matches selfcheck's own watchdog shape.
50
+
51
+ let inputArgs = process.argv.slice(2);
52
+ if (SUPERVISOR && inputArgs.length === 1 && inputArgs[0] === '--payload-env') {
53
+ try {
54
+ inputArgs = JSON.parse(Buffer.from(process.env.RUVNET_DETACH_PAYLOAD_B64 || '', 'base64').toString('utf8'));
55
+ } catch {
56
+ inputArgs = [];
57
+ }
58
+ }
59
+ const [ttlRaw, logPath, ...cmd] = inputArgs;
60
+ const ttlSec = Number(ttlRaw);
61
+ if (!cmd.length || !Number.isFinite(ttlSec) || ttlSec <= 0) {
62
+ process.stderr.write('usage: detach.mjs <ttlSeconds> <logPath|-> <cmd> [args...]\n');
63
+ process.exit(0); // never fail a hook over a bad maintenance invocation
64
+ }
65
+
66
+ const receiptPath = () => path.join(
67
+ process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'ruvnet-brain', 'detached-jobs.jsonl',
68
+ );
69
+
70
+ /** Best-effort, fail-silent. A receipt that throws would defeat the point of writing one. */
71
+ function receipt(row) {
72
+ try {
73
+ const p = receiptPath();
74
+ fs.mkdirSync(path.dirname(p), { recursive: true });
75
+ fs.appendFileSync(p, `${JSON.stringify({ ts: new Date().toISOString(), ...row })}\n`);
76
+ } catch { /* a maintenance job must not die because its log directory is read-only */ }
77
+ }
78
+
79
+ /** Open the job's log, or /dev/null. Never throws — an unwritable log is not a reason to skip work. */
80
+ function openLog() {
81
+ if (!logPath || logPath === '-') return 'ignore';
82
+ try {
83
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
84
+ return fs.openSync(logPath, 'w');
85
+ } catch { return 'ignore'; }
86
+ }
87
+
88
+ if (!SUPERVISOR) {
89
+ // ── FOREGROUND HALF. Re-exec self, detached, and return immediately. This process IS still in the
90
+ // hook's process group, which is correct and is the whole trick: it is short-lived and finishes
91
+ // before the hook does, so the group is empty at exit. Everything with a real duration is on the
92
+ // far side of the setsid boundary below.
93
+ try {
94
+ const supervisorEnv = {
95
+ ...process.env,
96
+ RUVNET_DETACH_SUPERVISOR: '1',
97
+ RUVNET_DETACH_PAYLOAD_B64: Buffer.from(JSON.stringify(process.argv.slice(2))).toString('base64'),
98
+ };
99
+ let child;
100
+ if (process.platform === 'win32') {
101
+ // detached:true and `cmd start /b` both left the cold hook's capture pipe open on
102
+ // windows-latest after session-start.sh itself had finished. Start-Process crosses a native
103
+ // process-launch boundary without `/b`'s same-console inheritance. All variable arguments
104
+ // still travel in a base64 environment payload, so the PowerShell command contains only
105
+ // trusted executable paths and no job/log-path metacharacters.
106
+ const powershell = process.env.SystemRoot
107
+ ? path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
108
+ : 'powershell.exe';
109
+ const quotePs = (value) => `'${String(value).replaceAll("'", "''")}'`;
110
+ const launch = [
111
+ 'Start-Process',
112
+ '-FilePath', quotePs(process.execPath),
113
+ '-ArgumentList', `@(${quotePs(SELF)},${quotePs('--payload-env')})`,
114
+ '-WindowStyle', 'Hidden',
115
+ ].join(' ');
116
+ child = spawn(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', launch], {
117
+ detached: true,
118
+ stdio: 'ignore',
119
+ windowsHide: true,
120
+ env: supervisorEnv,
121
+ });
122
+ } else {
123
+ child = spawn(process.execPath, [SELF, ...process.argv.slice(2)], {
124
+ detached: true, // POSIX setsid() — the child leads its OWN process group, not the session's
125
+ stdio: 'ignore',
126
+ env: supervisorEnv,
127
+ });
128
+ }
129
+ child.on('error', () => {});
130
+ child.unref();
131
+ } catch { /* nothing to report — the session must start regardless */ }
132
+ process.exit(0);
133
+ }
134
+
135
+ // ── SUPERVISOR HALF (already outside the session's process group). Runs the real job, holds the
136
+ // clock, and is the only thing that can end it early.
137
+ const out = openLog();
138
+ const job = spawn(cmd[0], cmd.slice(1), {
139
+ detached: true, // its own group again, so the TTL kill reaches ITS children too (npm, git, node)
140
+ stdio: ['ignore', out, out],
141
+ windowsHide: true,
142
+ env: process.env,
143
+ });
144
+
145
+ let killedAtTtl = false;
146
+ receipt({ state: 'started', pid: job.pid ?? null, ttlSec, cmd });
147
+
148
+ const deadline = setTimeout(() => {
149
+ killedAtTtl = true;
150
+ try { process.kill(-job.pid, 'SIGTERM'); } catch { /* already gone */ }
151
+ setTimeout(() => {
152
+ try { process.kill(-job.pid, 'SIGKILL'); } catch { /* already gone */ }
153
+ receipt({ state: 'killed-at-ttl', pid: job.pid ?? null, ttlSec, cmd });
154
+ process.exit(0);
155
+ }, GRACE_MS).unref();
156
+ }, ttlSec * 1000);
157
+
158
+ job.on('error', (e) => {
159
+ clearTimeout(deadline);
160
+ receipt({ state: 'spawn-failed', pid: null, ttlSec, cmd, detail: e.message });
161
+ process.exit(0);
162
+ });
163
+ job.on('exit', (code, signal) => {
164
+ clearTimeout(deadline);
165
+ if (killedAtTtl) return; // the TTL path writes its own, more specific receipt
166
+ receipt({ state: 'exited', pid: job.pid ?? null, code, signal, cmd });
167
+ process.exit(0);
168
+ });
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // Replay one hook's captured stdout and append its exact byte count to the user-level ledger.
3
+ // Kept in one process because spawning cat + wc + rm + date + pwd + sed on every SessionStart
4
+ // consumed most of the 1s Windows UX budget.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ const [capturePath, ledgerDir, cwd] = process.argv.slice(2);
9
+
10
+ try {
11
+ const output = fs.readFileSync(capturePath);
12
+ process.stdout.write(output);
13
+ fs.mkdirSync(ledgerDir, { recursive: true });
14
+ fs.appendFileSync(path.join(ledgerDir, 'token-ledger.jsonl'), `${JSON.stringify({
15
+ ts: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
16
+ source: 'hook',
17
+ class: 'session-start',
18
+ bytes: output.length,
19
+ cwd,
20
+ })}\n`);
21
+ } catch {
22
+ // Metering is observability, never a reason to break a session.
23
+ } finally {
24
+ try { fs.rmSync(capturePath, { force: true }); } catch {}
25
+ }
@@ -0,0 +1,35 @@
1
+ #!/bin/bash
2
+ # gate-receipt.sh — record what a gate CAUGHT.
3
+ #
4
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
5
+ # WHY (2026-07-17). The walls logged only their successes. design-grades.jsonl held 13 receipts,
6
+ # 13 of them passing — yet the design wall had just blocked a commit minutes earlier. The block left
7
+ # no trace: the ledger showed the 96 that came after the fix, and nothing about the refusal that
8
+ # forced it. So the system could prove Claude complied and could not prove it had ever CAUGHT Claude,
9
+ # which is the only part anyone would want to see. Stuart, looking at the console: "make it worth
10
+ # something, because right now it seems to be facts without purpose."
11
+ #
12
+ # A gate that stops something and says nothing is unfalsifiable. This makes the catch auditable.
13
+ #
14
+ # CONTRACT: called by a blocking gate immediately before it exits non-zero.
15
+ # gate-receipt.sh <gate> <subject> <reason>
16
+ # NEVER fails, never blocks, never writes to stdout/stderr — a receipt that breaks a gate would
17
+ # trade a real protection for a log line. Every failure path here is swallowed on purpose.
18
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
19
+ set -uo pipefail
20
+
21
+ F="$HOME/.cache/ruvnet-brain/gate-blocks.jsonl"
22
+ mkdir -p "$(dirname "$F")" 2>/dev/null || exit 0
23
+
24
+ # Strip the few characters that would break a JSON line. Reasons are short human strings, not data.
25
+ clean() { printf '%s' "${1:-}" | tr -d '"\\' | tr '\n\r\t' ' ' | cut -c1-160; }
26
+
27
+ printf '{"at":"%s","gate":"%s","subject":"%s","reason":"%s","cwd":"%s"}\n' \
28
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
29
+ "$(clean "${1:-unknown}")" \
30
+ "$(clean "${2:-}")" \
31
+ "$(clean "${3:-}")" \
32
+ "$(clean "$(basename "${CLAUDE_PROJECT_DIR:-$PWD}")")" \
33
+ >> "$F" 2>/dev/null || true
34
+
35
+ exit 0