ruvnet-brain 4.0.8 → 4.0.24

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 (63) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +109 -42
  3. package/console/app.js +32 -11
  4. package/console/style.css +5 -0
  5. package/package.json +1 -1
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/scripts/advocacy-outcomes.mjs +808 -0
  9. package/plugin/scripts/anticipate.sh +80 -14
  10. package/plugin/scripts/capability-registry.mjs +994 -0
  11. package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
  12. package/plugin/scripts/continuation-gate.mjs +169 -1
  13. package/plugin/scripts/gates.mjs +146 -0
  14. package/plugin/scripts/goal-match.mjs +398 -0
  15. package/plugin/scripts/hijack-ruvnet.sh +69 -1
  16. package/plugin/scripts/hook-registry.mjs +616 -0
  17. package/plugin/scripts/hook-shim.mjs +13 -2
  18. package/plugin/scripts/learn-flush.mjs +37 -2
  19. package/plugin/scripts/learning-enable.mjs +382 -0
  20. package/plugin/scripts/lesson-promote.mjs +262 -0
  21. package/plugin/scripts/lesson-provenance.mjs +43 -0
  22. package/plugin/scripts/lesson-store.mjs +67 -56
  23. package/plugin/scripts/memory-doctor.mjs +345 -0
  24. package/plugin/scripts/nightly-controller.mjs +98 -0
  25. package/plugin/scripts/project-identity.mjs +89 -0
  26. package/plugin/scripts/ruflo-bin.mjs +81 -0
  27. package/plugin/scripts/runtime-preferences.mjs +18 -0
  28. package/plugin/scripts/session-snapshot-hook.mjs +4 -1
  29. package/plugin/scripts/session-start-core.mjs +19 -2
  30. package/plugin/scripts/unprompted-runtime.mjs +22 -7
  31. package/plugin/scripts/user-settings.mjs +672 -0
  32. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  33. package/scripts/advocacy-outcomes.mjs +4 -808
  34. package/scripts/capability-registry.mjs +4 -876
  35. package/scripts/console-runtime-identity.mjs +74 -0
  36. package/scripts/corpus-qa.mjs +44 -6
  37. package/scripts/distill-project.mjs +9 -1
  38. package/scripts/doc-currency.mjs +45 -4
  39. package/scripts/gates.mjs +4 -146
  40. package/scripts/goal-match.mjs +4 -398
  41. package/scripts/health-repair.mjs +88 -11
  42. package/scripts/hook-registry.mjs +4 -567
  43. package/scripts/host-install-matrix.mjs +155 -0
  44. package/scripts/issue-watch.mjs +108 -0
  45. package/scripts/learning-enable.mjs +4 -380
  46. package/scripts/lesson-promote.mjs +4 -262
  47. package/scripts/memory-doctor.mjs +4 -342
  48. package/scripts/model-router-catalog.mjs +34 -0
  49. package/scripts/nightly-controller.mjs +4 -66
  50. package/scripts/nightly-wrapper.sh +23 -1
  51. package/scripts/onboarding-console.mjs +34 -12
  52. package/scripts/proactivity-metrics.mjs +8 -1
  53. package/scripts/publication-receipt.mjs +10 -1
  54. package/scripts/qe/ux-suite.mjs +72 -1
  55. package/scripts/release-abort-stale.mjs +111 -0
  56. package/scripts/release-convergence-watchdog.mjs +119 -0
  57. package/scripts/release-transaction-provider.mjs +76 -8
  58. package/scripts/release-transaction.mjs +55 -17
  59. package/scripts/rvf-generation.mjs +17 -0
  60. package/scripts/self-update.mjs +63 -10
  61. package/scripts/staged-host-verifier.mjs +27 -54
  62. package/scripts/sync-version.mjs +10 -10
  63. package/scripts/user-settings.mjs +4 -640
@@ -0,0 +1,74 @@
1
+ // ONE definition of "what the persistent Console runtime is" and "which generation is running".
2
+ //
3
+ // #79 reported a Console that survived an update: the browser was served the new frontend from disk
4
+ // while the Node process kept its pre-update router in memory, and every relaunch said "already
5
+ // running". The reuse check compared a `sourceSha256` computed from a SINGLE file —
6
+ // scripts/onboarding-console.mjs — while the runtime the server actually executes is this whole
7
+ // surface: ~230 modules it imports plus the frontend it serves. Two genuinely different candidates
8
+ // whose entrypoint bytes happened to match produced byte-identical identities, so an update that
9
+ // changed console-engine.mjs, capability-registry.mjs or console/app.js was invisible to the
10
+ // launcher. The identity has to derive from something an update cannot leave behind, which is the
11
+ // installed bytes themselves — all of them.
12
+ //
13
+ // #76 reported the same disease at the asset boundary: the runtime copied plugin/scripts without
14
+ // the sibling docs/ and manifest that plugin/scripts/whats-new.mjs reads, so the executable
15
+ // travelled and its assets did not. The copy list and the identity list were two separate
16
+ // enumerations of one payload; they are one list now, so an asset added to the runtime is part of
17
+ // its generation by construction.
18
+ //
19
+ // The installer hashes the staged tree with this function and the running server hashes its own
20
+ // root with this function, over this list. Neither side can define the fact differently.
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import crypto from 'node:crypto';
25
+
26
+ /**
27
+ * Every path the persistent Console runtime carries, relative to both the candidate source root and
28
+ * the installed runtime root (they are laid out identically). This is simultaneously the installer's
29
+ * copy list and the generation's digest input — one enumeration, so they cannot drift.
30
+ */
31
+ export const CONSOLE_RUNTIME_SURFACE = Object.freeze([
32
+ 'console',
33
+ 'scripts',
34
+ 'plugin/scripts',
35
+ // whats-new.mjs resolves its version manifest and curated notes relative to its own plugin root
36
+ // (#76). Shipping the executable without them is how the installed skill lost its release notes.
37
+ 'plugin/docs',
38
+ 'plugin/.claude-plugin',
39
+ 'data/model-catalog.json',
40
+ 'kb/brain-profile.mjs',
41
+ 'bin/install.mjs',
42
+ 'package.json',
43
+ ]);
44
+
45
+ /** The runtime's own identity file, written by the installer INTO the tree — never part of its own digest. */
46
+ export const CONSOLE_RUNTIME_IDENTITY_FILE = 'runtime-identity.json';
47
+
48
+ /**
49
+ * Deterministic content digest of the runtime surface under `root`.
50
+ *
51
+ * A path that is absent is hashed as absent rather than skipped: a runtime that lost a file is a
52
+ * different generation, not the same one, and a launcher must be able to see that.
53
+ *
54
+ * @param {string} root runtime root (an installed `.console-runtime`, a marketplace clone, or a checkout)
55
+ * @returns {string} hex sha256
56
+ */
57
+ export function consoleRuntimeDigest(root) {
58
+ const hash = crypto.createHash('sha256');
59
+ const visit = (absolute, relative) => {
60
+ let stat;
61
+ try { stat = fs.lstatSync(absolute); } catch { hash.update(`absent\0${relative}\0`); return; }
62
+ if (stat.isDirectory()) {
63
+ hash.update(`d\0${relative}\0`);
64
+ let entries = [];
65
+ try { entries = fs.readdirSync(absolute).sort(); } catch { hash.update(`unreadable\0${relative}\0`); return; }
66
+ for (const entry of entries) visit(path.join(absolute, entry), `${relative}/${entry}`);
67
+ return;
68
+ }
69
+ hash.update(`f\0${relative}\0`);
70
+ try { hash.update(fs.readFileSync(absolute)); } catch { hash.update(`unreadable\0${relative}\0`); }
71
+ };
72
+ for (const relative of CONSOLE_RUNTIME_SURFACE) visit(path.join(root, relative), relative);
73
+ return hash.digest('hex');
74
+ }
@@ -26,9 +26,12 @@
26
26
  // drift (~0.035 measured) exceeds the true gap between near-dups — the row IS stored and
27
27
  // readable (fact.big id=722: rank 6, Δ0.007 behind rank 1), so that's a photo-finish,
28
28
  // not a broken store. Photo-finish passes are still surfaced as a `note` so the
29
- // near-dup-noise signal feeds the dedup backlog instead of being hidden. A row absent
30
- // from top-10 (or far from the leader) remains a hard FAIL — missing/zero vectors and
31
- // broken read paths cannot hide behind the epsilon.
29
+ // near-dup-noise signal feeds the dedup backlog instead of being hidden.
30
+ // Anything that is NOT a top-3 hit and NOT a photo-finish is RE-QUERIED WIDE (WIDE_K,
31
+ // bounded to half the corpus) before any verdict — see the wide-k arm at R1 below.
32
+ // Present at wide k => PASS with a loud `deep crowd` note carrying rank + crowd size.
33
+ // ABSENT at wide k => hard FAIL, always: a missing vector, a zero vector, a mis-slotted
34
+ // vector and a broken read path are absent at ANY k, and nothing forgives them.
32
35
  // Proves embed-write AND read-path in one check. Retrieval QUALITY (real questions) stays
33
36
  // forge-guard/prove's job; this gate proves the machinery, not the answers.
34
37
  //
@@ -62,6 +65,24 @@ const FULL_BODY_MARK = '(full body):';
62
65
  // distance (fact.big id=722 replay); near-dup siblings sit within ~0.005 of each other. 0.02
63
66
  // forgives the drift-scale tie WITHOUT forgiving genuinely different rows.
64
67
  const NEAR_DUP_EPS = 0.02;
68
+ // R1 wide-k arm. Measured 2026-08-06 on the store that failed the nightly six times in three
69
+ // nights (metaharness.big, chunk:2b7c2755…, submissions/swe-bench-lite/…/django__django-13315/
70
+ // test_output.txt): ABSENT from top-10, but rank 188 at k=500, Δ0.0557 behind #1 — and all 187
71
+ // hits ahead of it were near-duplicates of it (187/187, conda-activation boilerplate). 49.3% of
72
+ // that corpus (4,428 of 8,979 rows) is submissions/swe-bench-lite/** of that shape, so a row with
73
+ // ordinary embed drift sinks below its own siblings. That is a corpus-composition fact, not a
74
+ // broken store, and asserting otherwise made this gate assert retrieval QUALITY — explicitly not
75
+ // its job (see header). 500 is ~5.6% of that corpus: deep enough to see under the crowd, far too
76
+ // shallow for a missing/zero/mis-slotted vector to hide in.
77
+ const WIDE_K = 500;
78
+ // Never let wide k reach the whole corpus — a k that returns every row makes "present" vacuous and
79
+ // would silently retire the failure class this gate exists for. Half the corpus is the ceiling.
80
+ const wideKFor = (n) => Math.min(WIDE_K, Math.max(10, Math.floor(n / 2)));
81
+ // Crowd measure for the note: how many hits ahead of the row open with the same normalized text.
82
+ // Cheap, deterministic, model-free — it turns "rank 188" into "rank 188 behind 187 of its own
83
+ // near-duplicates", which is the signal the dedup backlog actually needs.
84
+ const CROWD_PREFIX = 200;
85
+ const crowdKey = (s) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, CROWD_PREFIX);
65
86
 
66
87
  function fnv1a(s) {
67
88
  let h = 0x811c9dc5;
@@ -113,7 +134,7 @@ function getPipeline(embedConf) {
113
134
  * `fails` is [] on PASS; every entry is a specific reason string (receipts, not adjectives).
114
135
  * `notes` are non-fatal signals (e.g. near-dup crowding) that should reach the dedup backlog.
115
136
  */
116
- export async function qaStore(dir, store, variant, { roundtrip = true, samples = 3 } = {}) {
137
+ export async function qaStore(dir, store, variant, { roundtrip = true, samples = 3, pipeline = null } = {}) {
117
138
  const base = variant === 'big' ? `${store}.big` : store;
118
139
  const rvfPath = path.join(dir, `${base}.rvf`);
119
140
  const variantPassages = path.join(dir, `${base}.passages.jsonl`);
@@ -160,7 +181,10 @@ export async function qaStore(dir, store, variant, { roundtrip = true, samples =
160
181
  const byId = new Map(rows.map((r) => [String(r.id), r]));
161
182
  let hit = 0;
162
183
  const misses = [];
163
- const pipe = await getPipeline(embedConf);
184
+ // `pipeline` is the unit tier's injection seam (same shape as getPipeline's resolved value:
185
+ // (texts, opts) => { dims, data }). Production always passes null and loads the real model.
186
+ const pipe = pipeline || await getPipeline(embedConf);
187
+ const wideK = wideKFor(rows.length);
164
188
  for (const i of picks) {
165
189
  const r = rows[i];
166
190
  // Re-embed EXACTLY what the pipeline indexed for this variant (forge-build/forge-big):
@@ -179,7 +203,21 @@ export async function qaStore(dir, store, variant, { roundtrip = true, samples =
179
203
  hit++; // photo-finish behind near-duplicates: stored + readable; surface the crowd as a note
180
204
  res.notes.push(`near-dup crowd: id=${r.id} rank ${rank + 1}, Δ${(top[rank].distance - top[0].distance).toFixed(4)} behind #1 (${r.path})`);
181
205
  } else {
182
- misses.push(`id=${r.id} ${rank < 0 ? 'ABSENT from top-10' : `rank ${rank + 1}, Δ${(top[rank].distance - top[0].distance).toFixed(4)}`} ${r.path}`);
206
+ // Not a top-3 hit and not a photo-finish. Before declaring the store broken, ask the
207
+ // question this gate is actually for: is the row IN there and READABLE at all? Re-query
208
+ // wide. This covers BOTH shapes of miss — absent from top-10 (a deep near-dup crowd) and
209
+ // present-but-far inside top-10 (a shallow one) — because the alternative is perverse:
210
+ // a row buried under 187 siblings would pass while the same row buried under 5 failed.
211
+ const wide = await db.query(Array.from(out.data), wideK);
212
+ const wideRank = wide.findIndex(matches);
213
+ if (wideRank >= 0) {
214
+ hit++; // stored + readable, just outranked by its own near-duplicates
215
+ const mine = crowdKey(r.text);
216
+ const crowd = wide.slice(0, wideRank).filter((t) => crowdKey(byId.get(String(t.id))?.text) === mine).length;
217
+ res.notes.push(`deep crowd: id=${r.id} rank ${wideRank + 1}/${wideK}${rank < 0 ? ' (absent from top-10)' : ''}, Δ${(wide[wideRank].distance - wide[0].distance).toFixed(4)} behind #1, ${crowd}/${wideRank} ahead are near-duplicates (${r.path})`);
218
+ } else {
219
+ misses.push(`id=${r.id} ABSENT from top-${wideK} ${r.path}`);
220
+ }
183
221
  }
184
222
  }
185
223
  res.roundtrip = `${hit}/${picks.length}`;
@@ -37,9 +37,13 @@ import fs from 'node:fs';
37
37
  import os from 'node:os';
38
38
  import path from 'node:path';
39
39
  import { execFileSync, spawnSync } from 'node:child_process';
40
+ import { resolveRuflo, RUFLO_MISSING } from '../plugin/scripts/ruflo-bin.mjs';
40
41
 
41
42
  const HOME = os.homedir();
42
- const RUFLO = process.env.RUFLO_BIN || path.join(HOME, '.npm-global/bin/ruflo');
43
+ // Issue #99: this was `process.env.RUFLO_BIN || path.join(HOME, '.npm-global/bin/ruflo')`, so every
44
+ // install whose npm prefix is not the owner's (Homebrew, nvm, Volta, plain `npm -g`) was told its
45
+ // tool was missing while ruflo sat on their PATH. One resolver, shared — see ruflo-bin.mjs.
46
+ const RUFLO = resolveRuflo();
43
47
  const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
44
48
 
45
49
  const argv = process.argv.slice(2);
@@ -126,6 +130,10 @@ if (has('--restore')) {
126
130
  }
127
131
 
128
132
  // ── PRE-FLIGHT ─────────────────────────────────────────────────────────────────────────────────────
133
+ // Two different facts, so two different sentences. `null` means we looked everywhere and found
134
+ // nothing; a path that does not exist means the user pointed RUFLO_BIN somewhere empty, and naming
135
+ // that exact path back to them is the whole value of the message.
136
+ if (!RUFLO) die(RUFLO_MISSING);
129
137
  if (!fs.existsSync(RUFLO)) die(`ruflo is not at ${RUFLO.replace(HOME, '~')} — install it with \`npm i -g ruflo@latest\``);
130
138
  if (!fs.existsSync(DB)) die(`no memory store at ${DB.replace(HOME, '~')} — nothing to distill for this project`);
131
139
 
@@ -296,8 +296,36 @@ export function deriveDrift(root, docRel, governed) {
296
296
  const doc = lastCommit(root, docRel);
297
297
  if (!doc) return { state: 'not-applicable', commits: 0, days: 0, reason: 'document has no git history' };
298
298
 
299
- const rl = git(root, ['rev-list', '--count', `${doc.sha}..HEAD`, '--', ...paths]);
300
- const commits = rl.ok ? parseInt(rl.out || '0', 10) || 0 : 0;
299
+ // A VERSION BUMP IS NOT DRIFT (2026-08-06). `scripts/sync-version.mjs` rewrites the plugin
300
+ // manifests, package.json, kb/package.json and RVF-GENERATIONS.json on EVERY release bump, and
301
+ // several ADRs legitimately `govern:` those files. So every bump marked those documents
302
+ // presumed-stale whether or not anything they decide had moved — measured four times in one day
303
+ // on ADR-050/051/057/058, each time resolving to "re-read, nothing changed but a version string".
304
+ //
305
+ // That is not a harmless nuisance. A gate that fires on every bump teaches people to stamp
306
+ // without reading, and this repo has already blanket-stamped 61 ADRs from a bad grep exactly
307
+ // once. It also blocks unattended release automation, which cannot author a currency row.
308
+ //
309
+ // Drift is MOVEMENT IN WHAT THE DOCUMENT DECIDES. A commit whose entire diff inside the governed
310
+ // paths is version-identifier lines cannot have changed any decision, so it does not count. Any
311
+ // commit touching one substantive line still counts in full — this narrows the trigger, never the
312
+ // verdict.
313
+ const shas = (() => {
314
+ const r = git(root, ['rev-list', `${doc.sha}..HEAD`, '--', ...paths]);
315
+ return r.ok && r.out ? r.out.split('\n').filter(Boolean) : [];
316
+ })();
317
+ const VERSION_FIELD = /^[+-]\s*"?(version|releaseTag|brainVersion|softwareVersion|tag)"?\s*[:=]/i;
318
+ const isVersionOnly = (sha) => {
319
+ const d = git(root, ['show', '--unified=0', '--format=', sha, '--', ...paths]);
320
+ if (!d.ok || !d.out) return false;
321
+ const changed = d.out.split('\n')
322
+ .filter((l) => /^[+-]/.test(l) && !/^(\+\+\+|---)/.test(l));
323
+ // No changed lines at all (pure rename/mode) is not evidence of a decision change either, but
324
+ // be conservative: only EXEMPT when there is at least one line and every one is a version field.
325
+ return changed.length > 0 && changed.every((l) => VERSION_FIELD.test(l));
326
+ };
327
+ const substantive = shas.filter((s) => !isVersionOnly(s));
328
+ const commits = substantive.length;
301
329
 
302
330
  const codeR = git(root, ['log', '-1', '--format=%ad', '--date=short', `${doc.sha}..HEAD`, '--', ...paths]);
303
331
  const codeDate = codeR.ok && codeR.out ? codeR.out.split('\n')[0] : null;
@@ -408,6 +436,7 @@ export function evaluateDoc(root, rel, opts = {}) {
408
436
  status: k.status ?? null,
409
437
  date: k.date ?? null,
410
438
  updated: k.updated ?? null,
439
+ updatedPinned: k.updated_pinned === true || k.updated_pinned === 'true',
411
440
  implStored: k.impl ?? null,
412
441
  verifiedStamp: k.verified ?? null,
413
442
  verifiedDigestStored: k.verified_digest ?? null,
@@ -455,7 +484,19 @@ export function evaluateDoc(root, rel, opts = {}) {
455
484
  if (Number.isFinite(d) && Number.isFinite(g)) {
456
485
  const deltaDays = Math.round((g - d) / 86400000);
457
486
  if (deltaDays >= 1) {
458
- add(BLOCK, 'stamp-lags-doc',
487
+ // `updated_pinned: true` — the date is a HISTORICAL RECORD, not a currency stamp.
488
+ //
489
+ // Some documents record WHEN SOMETHING HAPPENED, not when the file was last edited.
490
+ // ADR-050's `updated: 2026-08-02` is an incident cutoff and is asserted by
491
+ // tests/unit/fix-workstream-guidance. A blanket "make the stamp match the last commit"
492
+ // rule cannot tell those apart, so it silently rewrote the pinned date — putting two gates
493
+ // into direct contradiction, one demanding the pinned value and one demanding the commit
494
+ // date, with no way to satisfy both. The document itself is the only thing that knows
495
+ // which kind of date it carries, so it now says so, and --fix must never overwrite it.
496
+ if (doc.updatedPinned) {
497
+ add(WARN, 'stamp-pinned',
498
+ `updated: ${doc.updated} is PINNED (updated_pinned: true) and deliberately does not track the last commit ${docCommit.date} — it records an event, not the edit`);
499
+ } else add(BLOCK, 'stamp-lags-doc',
459
500
  `updated: ${doc.updated} but the document's own last commit is ${docCommit.date} (${docCommit.sha.slice(0, 8)}) — edited without touching its own stamp`);
460
501
  } else if (deltaDays <= -1) {
461
502
  // Typed ahead. WARN only: a stamp dated today on a change not yet committed is CORRECT, and
@@ -610,7 +651,7 @@ export function planFix(root, doc) {
610
651
  if (doc.dirty) blocked.push({ code: 'missing-updated', why: 'working tree is modified — the last commit date is not the date of the current contents' });
611
652
  else if (doc.docCommit) changes.push({ key: 'updated', value: doc.docCommit.date, source: 'derived-from-git', evidence: `git log -1 ${doc.file} → ${doc.docCommit.sha.slice(0, 8)}` });
612
653
  else blocked.push({ code: 'missing-updated', why: 'git reports no commit touching this path' });
613
- } else if (doc.findings.some((f) => f.code === 'stamp-lags-doc') && doc.docCommit && !doc.dirty) {
654
+ } else if (!doc.updatedPinned && doc.findings.some((f) => f.code === 'stamp-lags-doc') && doc.docCommit && !doc.dirty) {
614
655
  changes.push({ key: 'updated', value: doc.docCommit.date, source: 'derived-from-git', replaces: doc.updated, evidence: `git log -1 ${doc.file} → ${doc.docCommit.sha.slice(0, 8)}` });
615
656
  }
616
657
  return { changes, blocked };
package/scripts/gates.mjs CHANGED
@@ -1,146 +1,4 @@
1
- // gates.mjs — what stands between the model and your machine, and what it has caught.
2
- //
3
- // ─────────────────────────────────────────────────────────────────────────────────────────────────
4
- // WHY (2026-07-17). Stuart, looking at the console's wiring card: "I have no idea what message it's
5
- // supposed to tell me… it seems to be facts without purpose." The card counted launch sites. Nobody
6
- // wants a census. The question worth answering is the one the harness exists for: WHAT STOPS CLAUDE
7
- // FROM BEING WRONG, AND HAS IT EVER ACTUALLY STOPPED IT?
8
- //
9
- // Two things had to be true before that card could be honest, and neither was:
10
- //
11
- // 1. The console never read the gates. wiringSurvey() walks ~/Code project settings only, so the
12
- // 12 machine-wide hooks in ~/.claude/settings.json and the 9 in the plugin's own hooks.json —
13
- // including the design wall that blocks commits — were invisible to the page bragging about them.
14
- //
15
- // 2. The gates never recorded a block. Only successes were logged, so "the harness caught Claude"
16
- // had no receipt. gate-receipt.sh now writes one at the moment of refusal.
17
- //
18
- // THE HONEST DISTINCTION this module exists to draw: a hook wired with `|| true` CANNOT block —
19
- // it injects context and the tool call proceeds regardless. Only a gate that can exit non-zero
20
- // stops anything. Counting all hooks as "protection" would be the same inflation as counting
21
- // cloned upstream repos as your own wiring. Advisory and blocking are different claims.
22
- //
23
- // Reads only. Never asserts a count it cannot source from a file on this machine.
24
- // ─────────────────────────────────────────────────────────────────────────────────────────────────
25
-
26
- import fs from 'node:fs';
27
- import os from 'node:os';
28
- import path from 'node:path';
29
-
30
- const HOME = os.homedir();
31
- const BLOCKS = path.join(HOME, '.cache/ruvnet-brain/gate-blocks.jsonl');
32
-
33
- function readJSON(f) { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } }
34
-
35
- // Two independent things must BOTH be true for a hook to stop anything, and conflating them is how
36
- // a census gets sold as protection:
37
- // 1. The EVENT must be one that runs before the thing it would stop. PreToolUse gates a tool call;
38
- // UserPromptSubmit gates a prompt. SessionStart has nothing to refuse yet, and PostToolUse /
39
- // PreCompact / SessionEnd arrive after the fact — those inject or record, they never block.
40
- // 2. The COMMAND must not end in `|| true`, which swallows the exit code the gate would refuse with.
41
- const BLOCKING_EVENTS = new Set(['PreToolUse', 'UserPromptSubmit']);
42
- const swallowsExit = (cmd) => /\|\|\s*true\s*$/.test(String(cmd || '').trim());
43
- const canBlock = (event, cmd) => BLOCKING_EVENTS.has(event) && !swallowsExit(cmd);
44
-
45
- const NAME = (cmd) => {
46
- const m = String(cmd || '').match(/([\w-]+)\.(sh|mjs|js)/);
47
- return m ? m[1] : String(cmd || '').split(/\s+/).filter((t) => !t.startsWith('-')).pop()?.slice(0, 28) || 'hook';
48
- };
49
-
50
- function collect(hooksObj, source) {
51
- const out = [];
52
- for (const [event, groups] of Object.entries(hooksObj || {})) {
53
- const list = Array.isArray(groups) ? groups : [groups];
54
- for (const g of list) {
55
- const hooks = Array.isArray(g?.hooks) ? g.hooks : (g?.command ? [g] : []);
56
- for (const h of hooks) {
57
- if (!h?.command) continue;
58
- out.push({ event, matcher: g?.matcher ?? '*', name: NAME(h.command), blocking: canBlock(event, h.command), source });
59
- }
60
- }
61
- }
62
- return out;
63
- }
64
-
65
- // Every catch the gates have recorded. This file only exists once a gate has actually refused
66
- // something — an empty ledger is an honest "nothing caught yet", never a failure.
67
- export function gateBlocks() {
68
- try {
69
- return fs.readFileSync(BLOCKS, 'utf8').trim().split('\n')
70
- .map((l) => { try { return JSON.parse(l); } catch { return null; } })
71
- .filter(Boolean);
72
- } catch { return []; }
73
- }
74
-
75
- export function gatesSurvey({ repo } = {}) {
76
- const machine = collect(readJSON(path.join(HOME, '.claude/settings.json'))?.hooks, 'machine');
77
- const pluginCfg = repo ? readJSON(path.join(repo, 'plugin/hooks/hooks.json')) : null;
78
- const plugin = collect(pluginCfg?.hooks || pluginCfg, 'plugin');
79
-
80
- const all = [...machine, ...plugin];
81
- const blocking = all.filter((g) => g.blocking);
82
-
83
- // THE LEDGER IS MACHINE-WIDE; THIS SURVEY IS ABOUT ONE PROJECT. Every catch ever recorded on the
84
- // machine used to be counted here, so standing in an empty folder produced "203 refusals have been
85
- // recorded" — this repo's history, attributed to a project that has never run a gate. Each record
86
- // carries the `cwd` it was caught in, so when a project is named, only its own catches count.
87
- //
88
- // KNOWN LIMIT, stated rather than hidden: `cwd` is a basename, so two projects sharing a folder
89
- // name share a count. That is a real ambiguity and it is narrow; attributing the whole machine's
90
- // history to whichever directory you happen to be standing in was neither.
91
- const here = repo ? path.basename(path.resolve(repo)) : null;
92
- const allBlocks = gateBlocks();
93
- const blocks = here ? allBlocks.filter((b) => b.cwd === here) : allBlocks;
94
-
95
- // Same gate, same event, wired both machine-wide AND by the plugin — it runs twice on every
96
- // matching call. Harmless to correctness (these gates are idempotent) but it is real duplicated
97
- // work, and counting it as two protections would inflate the only number on the card that matters.
98
- const seen = new Map();
99
- const duplicated = [];
100
- for (const g of all) {
101
- const k = `${g.event}:${g.name}`;
102
- if (seen.has(k) && seen.get(k) !== g.source) { if (!duplicated.includes(g.name)) duplicated.push(g.name); }
103
- seen.set(k, g.source);
104
- }
105
- const uniqueBlocking = new Set(blocking.map((g) => `${g.event}:${g.name}`)).size;
106
-
107
- // Group the catches by gate so the card can say WHAT was caught, not just how many times.
108
- const byGate = {};
109
- for (const b of blocks) (byGate[b.gate] ||= []).push(b);
110
-
111
- const weekAgo = Date.now() - 7 * 864e5;
112
- const recent = blocks.filter((b) => Date.parse(b.at || 0) >= weekAgo);
113
-
114
- // THREE NUMBERS, ONE UNIT. This block used to mix two: `blocking` was DEDUPLICATED
115
- // (distinct event:name) while `advisory` was `all.length - blocking.length`, computed from the
116
- // RAW array. So `blocking + advisory` fell short of `armed` by exactly the number of duplicated
117
- // blocking wirings, and the console printed "N gates armed — B can stop a call. The other A add
118
- // context" where B + A ≠ N. On a machine with 4 duplicate blocking gates the sentence silently
119
- // lost four of them — in the one sentence whose entire job is to account for all of them.
120
- //
121
- // It summed correctly on any machine with no duplicates, which is why it survived: the defect was
122
- // invisible exactly where it was most often looked at. Found by Fable 5, 2026-07-24, by adding up
123
- // the numbers on the owner's own console.
124
- //
125
- // Fixed by reporting all three in WIRED-ENTRY units, so armed = blocking + advisory holds by
126
- // construction. The distinct-gate count is still exported — it is genuinely the more meaningful
127
- // number for "how many different things can refuse" — but under its own name, where it cannot be
128
- // mistaken for a term in that sum.
129
- const blockingWired = blocking.length;
130
- return {
131
- summary: {
132
- armed: all.length,
133
- blocking: blockingWired, // wired entries that can refuse — same unit as `armed`
134
- advisory: all.length - blockingWired,
135
- blockingDistinct: uniqueBlocking, // distinct gates that can refuse; ≤ blocking when wired twice
136
- duplicated, // wired twice; runs twice
137
- caughtTotal: blocks.length,
138
- caughtThisWeek: recent.length,
139
- everRecorded: blocks.length > 0,
140
- },
141
- gates: all.sort((a, b) => Number(b.blocking) - Number(a.blocking)),
142
- // Newest first — the most recent catch is the one worth reading.
143
- catches: blocks.slice(-12).reverse(),
144
- byGate: Object.fromEntries(Object.entries(byGate).map(([k, v]) => [k, v.length])),
145
- };
146
- }
1
+ // Compatibility export for repository tools. The executable implementation belongs inside the
2
+ // self-contained plugin payload so Stable Spine and Codex-only installs never depend on a separate
3
+ // Claude marketplace checkout.
4
+ export * from '../plugin/scripts/gates.mjs';