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.
- package/.claude-plugin/marketplace.json +13 -0
- package/README.md +3 -3
- 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,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
|