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.
- package/README.md +3 -3
- package/bin/install.mjs +109 -42
- package/console/app.js +32 -11
- package/console/style.css +5 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/advocacy-outcomes.mjs +808 -0
- package/plugin/scripts/anticipate.sh +80 -14
- package/plugin/scripts/capability-registry.mjs +994 -0
- package/plugin/scripts/codex-hook-wrapper.mjs +1 -0
- package/plugin/scripts/continuation-gate.mjs +169 -1
- package/plugin/scripts/gates.mjs +146 -0
- package/plugin/scripts/goal-match.mjs +398 -0
- package/plugin/scripts/hijack-ruvnet.sh +69 -1
- package/plugin/scripts/hook-registry.mjs +616 -0
- package/plugin/scripts/hook-shim.mjs +13 -2
- package/plugin/scripts/learn-flush.mjs +37 -2
- package/plugin/scripts/learning-enable.mjs +382 -0
- package/plugin/scripts/lesson-promote.mjs +262 -0
- package/plugin/scripts/lesson-provenance.mjs +43 -0
- package/plugin/scripts/lesson-store.mjs +67 -56
- package/plugin/scripts/memory-doctor.mjs +345 -0
- package/plugin/scripts/nightly-controller.mjs +98 -0
- package/plugin/scripts/project-identity.mjs +89 -0
- package/plugin/scripts/ruflo-bin.mjs +81 -0
- package/plugin/scripts/runtime-preferences.mjs +18 -0
- package/plugin/scripts/session-snapshot-hook.mjs +4 -1
- package/plugin/scripts/session-start-core.mjs +19 -2
- package/plugin/scripts/unprompted-runtime.mjs +22 -7
- package/plugin/scripts/user-settings.mjs +672 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/advocacy-outcomes.mjs +4 -808
- package/scripts/capability-registry.mjs +4 -876
- package/scripts/console-runtime-identity.mjs +74 -0
- package/scripts/corpus-qa.mjs +44 -6
- package/scripts/distill-project.mjs +9 -1
- package/scripts/doc-currency.mjs +45 -4
- package/scripts/gates.mjs +4 -146
- package/scripts/goal-match.mjs +4 -398
- package/scripts/health-repair.mjs +88 -11
- package/scripts/hook-registry.mjs +4 -567
- package/scripts/host-install-matrix.mjs +155 -0
- package/scripts/issue-watch.mjs +108 -0
- package/scripts/learning-enable.mjs +4 -380
- package/scripts/lesson-promote.mjs +4 -262
- package/scripts/memory-doctor.mjs +4 -342
- package/scripts/model-router-catalog.mjs +34 -0
- package/scripts/nightly-controller.mjs +4 -66
- package/scripts/nightly-wrapper.sh +23 -1
- package/scripts/onboarding-console.mjs +34 -12
- package/scripts/proactivity-metrics.mjs +8 -1
- package/scripts/publication-receipt.mjs +10 -1
- package/scripts/qe/ux-suite.mjs +72 -1
- package/scripts/release-abort-stale.mjs +111 -0
- package/scripts/release-convergence-watchdog.mjs +119 -0
- package/scripts/release-transaction-provider.mjs +76 -8
- package/scripts/release-transaction.mjs +55 -17
- package/scripts/rvf-generation.mjs +17 -0
- package/scripts/self-update.mjs +63 -10
- package/scripts/staged-host-verifier.mjs +27 -54
- package/scripts/sync-version.mjs +10 -10
- 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
|
+
}
|
package/scripts/corpus-qa.mjs
CHANGED
|
@@ -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.
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/scripts/doc-currency.mjs
CHANGED
|
@@ -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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
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';
|