ruvnet-brain 4.3.37 → 4.3.39
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 +2 -2
- package/bin/install.mjs +32 -9
- package/kb/forge-update.mjs +1884 -0
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +4 -4
- package/plugin/hooks/hook-contracts.json +7 -7
- package/plugin/hooks/hooks.json +2 -2
- package/plugin/scripts/capability-claim-evidence.mjs +11 -2
- package/plugin/scripts/capacity-aware-parallel-work.mjs +71 -15
- package/plugin/scripts/codex-hook-wrapper.mjs +3 -0
- package/plugin/scripts/completion-claim-evidence.mjs +262 -0
- package/plugin/scripts/continuation-gate.mjs +105 -21
- package/plugin/scripts/continuation-objective.mjs +15 -0
- package/plugin/scripts/decision-gate.mjs +22 -2
- package/plugin/scripts/duplicate-gate.mjs +503 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +339 -0
- package/plugin/scripts/grounding-turn-gate.mjs +77 -25
- package/plugin/scripts/grounding-turn-mark.mjs +61 -14
- package/plugin/scripts/hook-input.mjs +15 -0
- package/plugin/scripts/host-update.mjs +45 -0
- package/plugin/scripts/nightly-scheduler.mjs +34 -0
- package/plugin/scripts/session-start-budget.mjs +1 -0
- package/plugin/scripts/session-start-core.mjs +15 -2
- package/plugin/scripts/session-start-health.mjs +101 -1
- package/plugin/scripts/session-start-update-plane.mjs +71 -1
- package/scripts/approved-runtime.mjs +1 -1
- package/scripts/completion-claim-replay.mjs +114 -0
- package/scripts/corpus-canary.mjs +396 -0
- package/scripts/corpus-dispatch-decision.mjs +2 -2
- package/scripts/corpus-promotion.mjs +49 -0
- package/scripts/corpus-reconcile.mjs +27 -3
- package/scripts/corpus-watchdog.mjs +45 -6
- package/scripts/duplicate-gate-replay.mjs +98 -0
- package/scripts/grounding-turn-replay.mjs +131 -0
- package/scripts/nightly-watchdog.mjs +3 -3
- package/scripts/protected-release-invocation.mjs +1 -1
- package/scripts/release.mjs +180 -77
- package/scripts/single-source-check.mjs +12 -6
- package/scripts/wired-check.mjs +2 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* duplicate-gate.mjs — "is this the simplest code that works, without duplicating what already exists?"
|
|
4
|
+
*
|
|
5
|
+
* THE OWNER, 2026-09-30: "whenever you're writing code, is it the simplest version of the code that
|
|
6
|
+
* works, that doesn't create duplicates or replication across the project? That's become a major
|
|
7
|
+
* issue and it's happening again right now." The record: a hand-rolled census restamper written
|
|
8
|
+
* while scripts/sync-census.mjs shipped, a second model router (ADR-0015's deleted router-optimizer),
|
|
9
|
+
* duplicate routing ADRs, overlapping hooks. Prose rules against this failed; ADR-030 measured why.
|
|
10
|
+
*
|
|
11
|
+
* A POLICY OF decision-gate.mjs, NOT A HOOK. It speaks the policy contract (exit 0 allow, exit 2 +
|
|
12
|
+
* stderr refuse) and is composed by the ONE PreToolUse refuser (ADR-067) on both hosts — no new
|
|
13
|
+
* registration. Deterministic, local, no model: it scores the new code against an index of the
|
|
14
|
+
* repo's code on four cheap signals (TF-IDF over header comments and body identifiers, IDF-weighted
|
|
15
|
+
* shared distinctive literals/CLI flags, file-stem and exported-name overlap).
|
|
16
|
+
*
|
|
17
|
+
* WHEN IT LOOKS: a NEW code file under scripts/, kb/, plugin/scripts/, tests/ or bin/, or an
|
|
18
|
+
* edit that adds a NEW exported function/class of >= MIN_EXPORT_LINES lines. Never an ordinary edit,
|
|
19
|
+
* docs, lockfiles, fixtures, generated files, or tests/unit/<stem>.test.* mirroring a <stem> module.
|
|
20
|
+
*
|
|
21
|
+
* AT MOST ONE REFUSAL PER PATH PER SESSION. The second attempt is allowed and recorded as an
|
|
22
|
+
* acknowledgement — the gate can interrupt once, it can never loop. A header line
|
|
23
|
+
* `// DISTINCT-FROM: <existing path> — <reason>` (or `#` for sh/py) allows on the first attempt.
|
|
24
|
+
*
|
|
25
|
+
* SCOPE: active in the RuvNet Brain checkout (a repo containing plugin/scripts/decision-gate.mjs),
|
|
26
|
+
* where its threshold was tuned on real history (scripts/duplicate-gate-replay.mjs). Elsewhere only
|
|
27
|
+
* when RUVNET_DUPLICATE_GATE=on; RUVNET_DUPLICATE_GATE=off disables it everywhere.
|
|
28
|
+
*
|
|
29
|
+
* FAIL OPEN on every error, including a blown time budget. The index is cached under the repo's git
|
|
30
|
+
* common dir (never ~/.cache/ruvnet-brain), keyed by HEAD and each file's mtime+size.
|
|
31
|
+
*
|
|
32
|
+
* OVERLOAD MUST NOT SWITCH IT OFF SILENTLY (2026-09-30). Measured at load average ~450 on 16 cores:
|
|
33
|
+
* the fixed 1300ms index budget ran out on every write, the gate threw 'index budget exhausted' and
|
|
34
|
+
* exited 0 — an allow indistinguishable from "no duplicate found", i.e. the gate was off and nothing
|
|
35
|
+
* said so. Now: (1) the budget scales with the measured 1-minute load per CPU (x1..x4) and never
|
|
36
|
+
* outruns decision-gate's own deadline (RUVNET_DECISION_DEADLINE); (2) past the budget, a file whose
|
|
37
|
+
* cached features are merely STALE keeps them instead of being dropped, so a warm index still judges
|
|
38
|
+
* under overload; (3) progress is always saved, so a cold index warms across writes; (4) only a file
|
|
39
|
+
* with NO usable features left unindexed makes it skip — and a skip exits SKIPPED (3) with one line
|
|
40
|
+
* `duplicate gate skipped: <reason>` that decision-gate records in the outcome ledger and prints.
|
|
41
|
+
* Still never a refusal on error.
|
|
42
|
+
*/
|
|
43
|
+
import fs from 'node:fs';
|
|
44
|
+
import os from 'node:os';
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import { spawnSync } from 'node:child_process';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
|
|
49
|
+
export const SCOPE = ['scripts/', 'kb/', 'plugin/scripts/', 'tests/', 'bin/'];
|
|
50
|
+
export const THRESHOLD = 0.5;
|
|
51
|
+
export const MIN_EXPORT_LINES = 15;
|
|
52
|
+
export const MIN_COPIED_LINES = 10;
|
|
53
|
+
export const MIN_COPIED_SHARE = 0.1;
|
|
54
|
+
const MAX_LINE_DF = 3;
|
|
55
|
+
const MIN_CODE_LINES = 8;
|
|
56
|
+
export const WEIGHTS = { hdr: 0.3, body: 0.35, lit: 0.2, name: 0.15 };
|
|
57
|
+
const CODE = /\.(mjs|cjs|js|ts|mts|sh|py)$/;
|
|
58
|
+
const MAX_BYTES = 400_000;
|
|
59
|
+
const CACHE_VERSION = 2;
|
|
60
|
+
const BUILD_BUDGET_MS = 1300;
|
|
61
|
+
/** Exit code meaning "this policy did not vote, and says why on stderr" (decision-gate records it). */
|
|
62
|
+
export const SKIPPED = 3;
|
|
63
|
+
const DEADLINE_MARGIN_MS = 250;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The index budget for this call: the base, scaled by measured load per CPU (1..4x), clipped to what
|
|
67
|
+
* decision-gate's shared deadline leaves (minus a margin for scoring and exit). Pure given its inputs.
|
|
68
|
+
*/
|
|
69
|
+
export function buildBudgetMs({ env = process.env, now = Date.now(), load = os.loadavg()[0], cpus = os.cpus().length || 1 } = {}) {
|
|
70
|
+
const base = Number.isFinite(Number(env.RUVNET_DUPLICATE_GATE_BUDGET_MS)) && env.RUVNET_DUPLICATE_GATE_BUDGET_MS !== undefined
|
|
71
|
+
? Number(env.RUVNET_DUPLICATE_GATE_BUDGET_MS) : BUILD_BUDGET_MS;
|
|
72
|
+
const scaled = base * Math.min(4, Math.max(1, load / cpus));
|
|
73
|
+
const outer = Number(env.RUVNET_DECISION_DEADLINE);
|
|
74
|
+
return Math.max(0, Math.round(Number.isFinite(outer) && outer > 0 ? Math.min(scaled, outer - now - DEADLINE_MARGIN_MS) : scaled));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Thrown when the index cannot be completed in budget; its message is the visible skip reason. */
|
|
78
|
+
export class IndexBudgetError extends Error {}
|
|
79
|
+
const ACK_TTL_MS = 24 * 3600_000;
|
|
80
|
+
|
|
81
|
+
const STOP = new Set(('the and for with that this from not are was were but its has have had can will '
|
|
82
|
+
+ 'into when than then there their them they what which who why how all any each one two out our '
|
|
83
|
+
+ 'you your only also just more most must never ever here same such over under about after before '
|
|
84
|
+
+ 'because does did done been being else true false null undefined const let var function return '
|
|
85
|
+
+ 'import export default async await new class extends typeof instanceof throw catch try finally '
|
|
86
|
+
+ 'while break continue switch case if elif fi then esac echo local def self none pass lambda '
|
|
87
|
+
+ 'node mjs js json txt www http https com org length push map filter join split slice string '
|
|
88
|
+
+ 'number object array value values key keys path file files dir name').split(' '));
|
|
89
|
+
|
|
90
|
+
/** Export names so common that sharing one says nothing about what a file does. */
|
|
91
|
+
const GENERIC = new Set(['main', 'isMain', 'run', 'parseArgs', 'usage', 'help', 'fixture', 'handler', 'check', 'REPO', 'ROOT', 'log']);
|
|
92
|
+
|
|
93
|
+
/** camelCase / snake / kebab aware word tokens, lowercased, stop words and short tokens dropped. */
|
|
94
|
+
const SPLIT = new Map(); // word -> its tokens; the same identifiers recur across every file
|
|
95
|
+
export function tokens(text) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const raw of String(text).match(/[A-Za-z][A-Za-z0-9]*/g) || []) {
|
|
98
|
+
let parts = SPLIT.get(raw);
|
|
99
|
+
if (!parts) {
|
|
100
|
+
parts = (/[A-Z]/.test(raw) ? raw.match(/[A-Z]?[a-z0-9]+|[A-Z]+(?![a-z])/g) || [raw] : [raw])
|
|
101
|
+
.map((p) => p.toLowerCase()).filter((t) => t.length >= 3 && !STOP.has(t));
|
|
102
|
+
if (SPLIT.size > 100_000) SPLIT.clear();
|
|
103
|
+
SPLIT.set(raw, parts);
|
|
104
|
+
}
|
|
105
|
+
for (const t of parts) out.push(t);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const tf = (list) => { const m = Object.create(null); for (const t of list) m[t] = (m[t] || 0) + 1; return m; };
|
|
111
|
+
const stemOf = (rel) => path.basename(rel).replace(/\.(test|spec)(?=\.)/, '').replace(/\.[^.]+$/, '');
|
|
112
|
+
export const isTest = (rel) => /(^|\/)tests?\//.test(rel) || /\.(test|spec)\.[cm]?[jt]s$/.test(rel);
|
|
113
|
+
|
|
114
|
+
/** The leading comment block (after a shebang): what the author says the file IS. */
|
|
115
|
+
export function headerOf(text) {
|
|
116
|
+
const lines = String(text).split('\n').slice(0, 120);
|
|
117
|
+
const out = [];
|
|
118
|
+
let i = lines[0]?.startsWith('#!') ? 1 : 0;
|
|
119
|
+
for (; i < lines.length && out.length < 80; i++) {
|
|
120
|
+
const t = lines[i].trim();
|
|
121
|
+
if (!t) continue;
|
|
122
|
+
if (/^(\/\/|#|\/\*|\*|\*\/)/.test(t)) out.push(t.replace(/^(\/\/+|#+|\/\*+|\*+\/?|\*\/)\s?/, ''));
|
|
123
|
+
else if (/^(['"]use strict['"]|set -[euo])/.test(t)) continue;
|
|
124
|
+
else break;
|
|
125
|
+
}
|
|
126
|
+
return out.join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function exportsOf(text) {
|
|
130
|
+
const names = new Set();
|
|
131
|
+
const s = String(text);
|
|
132
|
+
for (const m of s.matchAll(/^export\s+(?:default\s+)?(?:async\s+)?(?:function\*?\s*|class\s+|const\s+|let\s+|var\s+)([A-Za-z_$][\w$]*)/gm)) names.add(m[1]);
|
|
133
|
+
for (const m of s.matchAll(/^export\s*\{([^}]*)\}/gm)) {
|
|
134
|
+
for (const n of m[1].split(',')) { const k = n.trim().split(/\s+as\s+/).pop(); if (k) names.add(k); }
|
|
135
|
+
}
|
|
136
|
+
for (const m of s.matchAll(/^(?:function\s+)?([A-Za-z_][\w]*)\s*\(\)\s*\{/gm)) names.add(m[1]); // sh
|
|
137
|
+
for (const m of s.matchAll(/^def\s+([A-Za-z_]\w*)/gm)) names.add(m[1]); // py
|
|
138
|
+
names.delete('default');
|
|
139
|
+
return [...names];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Distinctive literals: quoted strings, CLI flags, regex literals. Import specifiers excluded. */
|
|
143
|
+
export function literalsOf(text) {
|
|
144
|
+
const out = new Set();
|
|
145
|
+
for (const line of String(text).split('\n')) {
|
|
146
|
+
if (/^\s*(import\b|export\s.*\bfrom\b)|\brequire\(/.test(line)) continue;
|
|
147
|
+
for (const m of line.matchAll(/(['"`])((?:\\.|(?!\1)[^\\\n$]){8,120})\1/g)) {
|
|
148
|
+
if (/[A-Za-z]{3}/.test(m[2]) && !/^(node:|\.\.?\/)/.test(m[2])) out.add(m[2]);
|
|
149
|
+
}
|
|
150
|
+
for (const m of line.matchAll(/(?<![\w-])--[a-z][a-z0-9-]{2,40}/g)) out.add(m[0]);
|
|
151
|
+
for (const m of line.matchAll(/(?<=[=(,:!&|?]\s*)\/((?:\\.|[^/\n\\]){8,160})\/[dgimsuy]*/g)) out.add(`/${m[1]}/`);
|
|
152
|
+
}
|
|
153
|
+
return [...out];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Normalised code lines worth comparing (hash -> text): not imports, comments, braces or short boilerplate. */
|
|
157
|
+
export function codeLines(text) {
|
|
158
|
+
const out = new Map();
|
|
159
|
+
for (const raw of String(text).split('\n')) {
|
|
160
|
+
const l = raw.trim().replace(/\s+/g, ' ');
|
|
161
|
+
if (l.length < 24 || /^(import\b|export \{|\} from|\/\/|\*|\/\*|#)/.test(l)) continue;
|
|
162
|
+
let h = 0x811c9dc5;
|
|
163
|
+
for (let i = 0; i < l.length; i++) { h ^= l.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
164
|
+
if (!out.has(h.toString(36))) out.set(h.toString(36), l);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Everything the scorer needs from one file, small enough to cache. */
|
|
170
|
+
export function extract(rel, text) {
|
|
171
|
+
const s = String(text).slice(0, MAX_BYTES);
|
|
172
|
+
const exp = exportsOf(s);
|
|
173
|
+
const header = headerOf(s);
|
|
174
|
+
return {
|
|
175
|
+
stem: [...new Set(tokens(stemOf(rel)))],
|
|
176
|
+
exports: exp,
|
|
177
|
+
names: [...new Set(exp.flatMap(tokens))],
|
|
178
|
+
hdr: tf(tokens(header)),
|
|
179
|
+
hdrLine: header.split('\n').map((l) => l.trim()).find((l) => l.length > 12)?.slice(0, 140) || '',
|
|
180
|
+
body: tf(tokens(s)),
|
|
181
|
+
lits: literalsOf(s),
|
|
182
|
+
lines: [...codeLines(s).keys()],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Document frequencies + norms for one index. Pure; the replay harness uses this same function. */
|
|
187
|
+
export function prepare(entries) {
|
|
188
|
+
const n = entries.length || 1;
|
|
189
|
+
const df = Object.create(null); const ldf = Object.create(null); const edf = Object.create(null);
|
|
190
|
+
for (const e of entries) {
|
|
191
|
+
for (const t of Object.keys(e.f.body)) df[t] = (df[t] || 0) + 1;
|
|
192
|
+
for (const l of e.f.lits) ldf[l] = (ldf[l] || 0) + 1;
|
|
193
|
+
for (const x of e.f.exports) edf[x] = (edf[x] || 0) + 1;
|
|
194
|
+
}
|
|
195
|
+
// Inverted index of DISTINCTIVE lines only (in <= MAX_LINE_DF files): a line many files share is an
|
|
196
|
+
// idiom, not a copy.
|
|
197
|
+
const lineIdx = new Map();
|
|
198
|
+
entries.forEach((e, i) => { for (const h of e.f.lines || []) { const a = lineIdx.get(h); if (!a) lineIdx.set(h, [i]); else a.push(i); } });
|
|
199
|
+
for (const [h, a] of lineIdx) if (a.length > MAX_LINE_DF) lineIdx.delete(h);
|
|
200
|
+
const idf = (t) => Math.log((n + 1) / ((df[t] || 0) + 1)) + 1;
|
|
201
|
+
const lidf = (l) => Math.log((n + 1) / ((ldf[l] || 0) + 1));
|
|
202
|
+
const eidf = (x) => Math.log((n + 1) / ((edf[x] || 0) + 1));
|
|
203
|
+
const norm = (m) => Math.sqrt(Object.entries(m).reduce((a, [t, c]) => a + ((1 + Math.log(c)) * idf(t)) ** 2, 0)) || 1;
|
|
204
|
+
return { n, idf, lidf, eidf, norm, lineIdx, entries: entries.map((e) => ({ ...e, hn: norm(e.f.hdr), bn: norm(e.f.body) })) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function cosine(a, an, b, bn, idf) {
|
|
208
|
+
let dot = 0;
|
|
209
|
+
const [small, big] = Object.keys(a).length < Object.keys(b).length ? [a, b] : [b, a];
|
|
210
|
+
for (const t of Object.keys(small)) if (Object.hasOwn(big, t)) dot += (1 + Math.log(a[t])) * (1 + Math.log(b[t])) * idf(t) ** 2;
|
|
211
|
+
return dot / (an * bn);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const jaccard = (a, b) => { const B = new Set(b); const i = a.filter((x) => B.has(x)).length; return i / ((a.length + b.length - i) || 1); };
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* >= 1 means refuse. Two ways to cross: the four lexical signals together, or distinctive lines copied
|
|
218
|
+
* verbatim — MIN_COPIED_LINES of them, or half that when at least half of the new code is copied.
|
|
219
|
+
*/
|
|
220
|
+
export function strengthOf({ score, copied, copyShare }, { threshold = THRESHOLD, minCopied = MIN_COPIED_LINES } = {}) {
|
|
221
|
+
const need = copyShare >= 0.5 ? minCopied / 2 : minCopied;
|
|
222
|
+
return Math.max(score / threshold, copyShare >= MIN_COPIED_SHARE ? copied.length / need : 0);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Score a candidate against every entry; the top `limit`, best first, each with its evidence. */
|
|
226
|
+
export function rank(model, cand, { self = '', limit = 3, testsOnly = null } = {}) {
|
|
227
|
+
const hn = model.norm(cand.hdr); const bn = model.norm(cand.body);
|
|
228
|
+
const candLitW = cand.lits.reduce((a, l) => a + model.lidf(l), 0) || 1;
|
|
229
|
+
const copiedBy = new Map();
|
|
230
|
+
for (const h of cand.lines || []) for (const i of model.lineIdx.get(h) || []) copiedBy.set(i, [...(copiedBy.get(i) || []), h]);
|
|
231
|
+
const candLines = (cand.lines || []).length || 1;
|
|
232
|
+
const out = [];
|
|
233
|
+
for (const [i, e] of model.entries.entries()) {
|
|
234
|
+
if (e.path === self) continue;
|
|
235
|
+
if (testsOnly !== null && isTest(e.path) !== testsOnly) continue;
|
|
236
|
+
const f = e.f;
|
|
237
|
+
const hdr = Object.keys(cand.hdr).length >= 4 && Object.keys(f.hdr).length >= 4 ? cosine(cand.hdr, hn, f.hdr, e.hn, model.idf) : 0;
|
|
238
|
+
const body = cosine(cand.body, bn, f.body, e.bn, model.idf);
|
|
239
|
+
const L = new Set(f.lits);
|
|
240
|
+
const sharedLits = cand.lits.filter((l) => L.has(l));
|
|
241
|
+
const lit = Math.min(1, sharedLits.reduce((a, l) => a + model.lidf(l), 0) / Math.max(candLitW, 12));
|
|
242
|
+
const E = new Set(f.exports);
|
|
243
|
+
const sharedExports = cand.exports.filter((x) => E.has(x) && !GENERIC.has(x) && model.eidf(x) > 3);
|
|
244
|
+
const name = Math.max(jaccard(cand.stem, f.stem), jaccard(cand.names, f.names), sharedExports.length ? 0.6 : 0);
|
|
245
|
+
const score = WEIGHTS.hdr * hdr + WEIGHTS.body * body + WEIGHTS.lit * lit + WEIGHTS.name * name;
|
|
246
|
+
const copied = copiedBy.get(i) || [];
|
|
247
|
+
const m = { path: e.path, score, copied, copyShare: copied.length / candLines };
|
|
248
|
+
out.push({ ...m, strength: strengthOf(m), parts: { hdr, body, lit, name }, sharedExports, sharedLits: sharedLits.slice(0, 3), hdrLine: f.hdrLine });
|
|
249
|
+
}
|
|
250
|
+
return out.sort((a, b) => b.strength - a.strength).slice(0, limit);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* PRECISION CUT 1 (2026-09-30 replay, 300 commits): a match with the SAME file name in another
|
|
255
|
+
* directory is a relocation (scripts/x.mjs -> plugin/scripts/x.mjs leaving a re-export shim), not a
|
|
256
|
+
* second implementation — 11 of 39 replayed refusals, every one of which ended as a move.
|
|
257
|
+
*/
|
|
258
|
+
export const isRelocation = (rel, matchPath) => path.posix.basename(rel) !== '' && path.posix.basename(rel) === path.posix.basename(matchPath) && rel !== matchPath;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* PRECISION CUT 2: a TEST (or test helper) that repeats another test's setup is logged as a SHADOW
|
|
262
|
+
* would-block, never refused — 19 of 39 replayed refusals were copied test scaffolding.
|
|
263
|
+
*/
|
|
264
|
+
function logShadow(stateDir, row) {
|
|
265
|
+
try { fs.mkdirSync(stateDir, { recursive: true }); fs.appendFileSync(path.join(stateDir, 'shadow.jsonl'), `${JSON.stringify(row)}\n`); } catch { /* shadow is measurement only */ }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** `// DISTINCT-FROM: <path> — <reason>` naming an existing path, in the first 40 lines. */
|
|
269
|
+
export function distinctFrom(text, exists) {
|
|
270
|
+
for (const line of String(text).split('\n').slice(0, 40)) {
|
|
271
|
+
const m = /^\s*(?:\/\/+|#+|\*|\/\*+)\s*DISTINCT-FROM:\s*(\S+)\s+(?:—|–|--|-)\s*(\S.{3,})$/.exec(line);
|
|
272
|
+
if (m && exists(m[1])) return { path: m[1], reason: m[2].trim() };
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Why this candidate is not the gate's business, or null. Pure. */
|
|
278
|
+
export function exemption(rel, text, stems) {
|
|
279
|
+
if (!SCOPE.some((d) => rel.startsWith(d))) return 'out-of-scope';
|
|
280
|
+
if (!CODE.test(rel)) return 'not-code';
|
|
281
|
+
if (/(^|\/)(__)?fixtures?(__)?\//.test(rel) || rel.includes('node_modules/')) return 'fixture';
|
|
282
|
+
if (/@generated|DO NOT EDIT|auto-?generated/i.test(String(text).split('\n').slice(0, 6).join('\n'))) return 'generated';
|
|
283
|
+
if (isTest(rel) && stems.has(stemOf(rel))) return 'test-mirror';
|
|
284
|
+
// A re-export shim or a stub has nothing to duplicate; measured: 4-line shims matched each other.
|
|
285
|
+
if (String(text).split('\n').filter((l) => l.trim() && !/^\s*(\/\/|#|\*|\/\*)/.test(l)).length < MIN_CODE_LINES) return 'too-small';
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** New exported declarations of >= MIN_EXPORT_LINES lines in `next` whose names `prev` lacks. */
|
|
290
|
+
export function largeNewExports(next, prev = '') {
|
|
291
|
+
const known = new Set(exportsOf(prev));
|
|
292
|
+
const lines = String(next).split('\n');
|
|
293
|
+
const out = [];
|
|
294
|
+
for (let i = 0; i < lines.length; i++) {
|
|
295
|
+
const m = /^export\s+(?:default\s+)?(?:async\s+)?(?:function\*?\s*|class\s+|const\s+)([A-Za-z_$][\w$]*)/.exec(lines[i]);
|
|
296
|
+
if (!m || known.has(m[1])) continue;
|
|
297
|
+
let j = i;
|
|
298
|
+
if (!/;\s*$/.test(lines[i])) { j = i + 1; while (j < lines.length && !/^[}\]]/.test(lines[j])) j++; }
|
|
299
|
+
if (j - i + 1 < MIN_EXPORT_LINES) continue;
|
|
300
|
+
let k = i; while (k > 0 && /^\s*(\/\*\*|\*|\/\/)/.test(lines[k - 1])) k--;
|
|
301
|
+
out.push({ name: m[1], text: lines.slice(k, j + 1).join('\n') });
|
|
302
|
+
i = j;
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Repository side: git, index cache, session acknowledgements ─────────────────────────────────
|
|
308
|
+
|
|
309
|
+
function git(cwd, args) {
|
|
310
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 << 20, timeout: 1500 });
|
|
311
|
+
if (r.status !== 0) throw new Error(`git ${args[0]} failed`);
|
|
312
|
+
return r.stdout;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The repo holding `absFile`, and the file's path with symlinks resolved. git reports the REAL
|
|
317
|
+
* toplevel (/private/var/... on macOS, where /var is a symlink), so comparing it with an unresolved
|
|
318
|
+
* payload path made every file look outside the repo — caught by this gate's own tests.
|
|
319
|
+
*/
|
|
320
|
+
export function repoOf(absFile) {
|
|
321
|
+
let dir = path.dirname(absFile);
|
|
322
|
+
while (!fs.existsSync(dir) && path.dirname(dir) !== dir) dir = path.dirname(dir);
|
|
323
|
+
const real = path.join(fs.realpathSync(dir), path.relative(dir, absFile));
|
|
324
|
+
const [top, common] = git(dir, ['rev-parse', '--show-toplevel', '--git-common-dir']).trim().split('\n');
|
|
325
|
+
return { root: fs.realpathSync(top), gitDir: path.resolve(dir, common), real };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Tracked + untracked-not-ignored code files: a file written earlier this session counts too. */
|
|
329
|
+
export function listCode(root) {
|
|
330
|
+
return git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']).split('\0')
|
|
331
|
+
.filter((p) => p && CODE.test(p) && !p.includes('node_modules/') && !/(^|\/)(__)?fixtures?(__)?\//.test(p));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Load the index, re-extracting only files whose mtime/size moved. Throws past `deadline`. */
|
|
335
|
+
export function loadIndex(root, stateDir, { deadline = Date.now() + BUILD_BUDGET_MS, files = listCode(root) } = {}) {
|
|
336
|
+
const cacheFile = path.join(stateDir, 'index.json');
|
|
337
|
+
let head = '';
|
|
338
|
+
try { head = git(root, ['rev-parse', 'HEAD']).trim(); } catch { /* unborn branch */ }
|
|
339
|
+
let cache = {};
|
|
340
|
+
try { const c = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); if (c.v === CACHE_VERSION) cache = c.entries || {}; } catch { /* cold */ }
|
|
341
|
+
const entries = []; const next = {}; let dirty = false; let blown = false; let missing = 0; let stale = 0;
|
|
342
|
+
const started = Date.now();
|
|
343
|
+
for (const rel of files) {
|
|
344
|
+
let st; try { st = fs.statSync(path.join(root, rel)); } catch { continue; }
|
|
345
|
+
if (!st.isFile() || st.size > MAX_BYTES) continue;
|
|
346
|
+
const hit = cache[rel];
|
|
347
|
+
if (hit && hit.m === st.mtimeMs && hit.s === st.size) { next[rel] = hit; entries.push({ path: rel, f: hit.f }); continue; }
|
|
348
|
+
if (blown || Date.now() > deadline) {
|
|
349
|
+
// Out of budget: a STALE entry is still this file's features as of its last index — far better
|
|
350
|
+
// evidence than none. It stays marked stale (old m/s), so a later call with budget refreshes it.
|
|
351
|
+
blown = true;
|
|
352
|
+
if (hit) { next[rel] = hit; entries.push({ path: rel, f: hit.f }); stale += 1; } else missing += 1;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const f = extract(rel, fs.readFileSync(path.join(root, rel), 'utf8'));
|
|
356
|
+
next[rel] = { m: st.mtimeMs, s: st.size, f }; entries.push({ path: rel, f }); dirty = true;
|
|
357
|
+
}
|
|
358
|
+
if (dirty || Object.keys(cache).length !== Object.keys(next).length) {
|
|
359
|
+
try {
|
|
360
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
361
|
+
const tmp = `${cacheFile}.${process.pid}.tmp`;
|
|
362
|
+
fs.writeFileSync(tmp, JSON.stringify({ v: CACHE_VERSION, head, entries: next }));
|
|
363
|
+
fs.renameSync(tmp, cacheFile);
|
|
364
|
+
} catch { /* a cache is an optimisation */ }
|
|
365
|
+
}
|
|
366
|
+
if (missing) {
|
|
367
|
+
throw new IndexBudgetError(`index budget exhausted after ${Date.now() - started}ms (budget ${Math.max(0, deadline - started)}ms, `
|
|
368
|
+
+ `load ${os.loadavg()[0].toFixed(0)} on ${os.cpus().length} CPUs): ${missing} of ${missing + entries.length} files unindexed; `
|
|
369
|
+
+ 'progress is cached and the next write resumes');
|
|
370
|
+
}
|
|
371
|
+
return entries;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function readAcks(stateDir) { try { return JSON.parse(fs.readFileSync(path.join(stateDir, 'acks.json'), 'utf8')); } catch { return {}; } }
|
|
375
|
+
function writeAcks(stateDir, acks, now) {
|
|
376
|
+
try {
|
|
377
|
+
for (const [k, v] of Object.entries(acks)) if (now - (v.ts || 0) > ACK_TTL_MS) delete acks[k];
|
|
378
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
379
|
+
fs.writeFileSync(path.join(stateDir, 'acks.json'), JSON.stringify(acks, null, 1));
|
|
380
|
+
} catch { /* best effort */ }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The candidate text this call adds, and whether it is a new file. null = nothing to judge. */
|
|
384
|
+
export function candidateOf(input, abs, rel) {
|
|
385
|
+
const exists = fs.existsSync(abs);
|
|
386
|
+
const current = exists ? fs.readFileSync(abs, 'utf8') : '';
|
|
387
|
+
const patch = typeof input.new_string === 'string' && /^\*\*\* (Add|Update) File: /m.test(input.new_string) ? input.new_string : null;
|
|
388
|
+
if (patch) { // Codex apply_patch, normalised by codex-hook-adapter.mjs: the '+' lines of this file's section
|
|
389
|
+
const secs = patch.split(/^(?=\*\*\* (?:Add|Update|Delete) File: )/m);
|
|
390
|
+
const sec = secs.find((s) => { const h = /^\*\*\* (Add|Update) File: (.+)$/m.exec(s); return h && path.normalize(h[2].trim()).endsWith(path.normalize(rel)); });
|
|
391
|
+
if (!sec) return null;
|
|
392
|
+
const added = sec.split('\n').filter((l) => l.startsWith('+')).map((l) => l.slice(1)).join('\n');
|
|
393
|
+
if (/^\*\*\* Add File/.test(sec) && !exists) return { isNew: true, text: added };
|
|
394
|
+
const big = largeNewExports(added, current);
|
|
395
|
+
return big.length ? { isNew: false, text: big.map((b) => b.text).join('\n\n'), names: big.map((b) => b.name), header: current } : null;
|
|
396
|
+
}
|
|
397
|
+
if (typeof input.content === 'string') {
|
|
398
|
+
if (!exists) return { isNew: true, text: input.content };
|
|
399
|
+
const big = largeNewExports(input.content, current);
|
|
400
|
+
return big.length ? { isNew: false, text: big.map((b) => b.text).join('\n\n'), names: big.map((b) => b.name), header: input.content } : null;
|
|
401
|
+
}
|
|
402
|
+
const edits = Array.isArray(input.edits) ? input.edits : [input];
|
|
403
|
+
const added = edits.map((e) => (typeof e?.new_string === 'string' ? e.new_string : '')).join('\n');
|
|
404
|
+
const before = `${current}\n${edits.map((e) => e?.old_string || '').join('\n')}`;
|
|
405
|
+
const big = largeNewExports(added, before);
|
|
406
|
+
return big.length ? { isNew: false, text: big.map((b) => b.text).join('\n\n'), names: big.map((b) => b.name), header: current } : null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function refusalText(rel, matches, what) {
|
|
410
|
+
const rows = matches.map((m, i) => {
|
|
411
|
+
const ev = [
|
|
412
|
+
m.sharedExports.length ? `exports ${m.sharedExports.slice(0, 4).join(', ')}` : '',
|
|
413
|
+
m.hdrLine ? `"${m.hdrLine}"` : '',
|
|
414
|
+
m.sharedLits.length ? `shares ${m.sharedLits.map((l) => JSON.stringify(l.slice(0, 40))).join(', ')}` : '',
|
|
415
|
+
m.copied.length ? `${m.copied.length} distinctive line(s) identical, e.g. ${(m.copiedText || []).map((l) => JSON.stringify(l.slice(0, 70))).join(' ')}` : '',
|
|
416
|
+
].filter(Boolean).join('\n ');
|
|
417
|
+
return ` ${i + 1}. ${m.path} (similarity ${m.score.toFixed(2)}, copied lines ${m.copied.length})\n ${ev}`;
|
|
418
|
+
}).join('\n');
|
|
419
|
+
return `⛔ BLOCKED (once) — ${what} ${rel} looks like code this repo already has.
|
|
420
|
+
|
|
421
|
+
${rows}
|
|
422
|
+
|
|
423
|
+
Reuse or extend one of these, or state in the file header why it must be separate:
|
|
424
|
+
|
|
425
|
+
// DISTINCT-FROM: <path> — <reason> (# for .sh/.py)
|
|
426
|
+
|
|
427
|
+
The write is allowed when that header line names an existing path and a reason. MOVING or
|
|
428
|
+
EXTRACTING code out of one of these files? Retry, then remove it from the original in the same change.
|
|
429
|
+
This path is refused at most once per session: retrying is allowed and recorded as your decision.
|
|
430
|
+
Owner, 2026-09-30: "is it the simplest version of the code that works, that doesn't create
|
|
431
|
+
duplicates or replication across the project?"`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The whole policy, as a function of one hook payload. Returns { allow, reason?, why }.
|
|
436
|
+
* Every thrown error becomes an allow at the caller.
|
|
437
|
+
*/
|
|
438
|
+
export function evaluate(payload, { env = process.env, now = Date.now(), deadline = now + buildBudgetMs({ env, now }) } = {}) {
|
|
439
|
+
const mode = String(env.RUVNET_DUPLICATE_GATE || '').toLowerCase();
|
|
440
|
+
if (mode === 'off' || env.RUVNET_SKIP_DUPLICATE_CHECK === '1') return { allow: true, why: 'disabled' };
|
|
441
|
+
const input = payload?.tool_input || {};
|
|
442
|
+
const file = input.file_path || input.path || input.notebook_path || '';
|
|
443
|
+
if (!file || /\.ipynb$/.test(file)) return { allow: true, why: 'no-file' };
|
|
444
|
+
const base = String(payload.cwd || env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
445
|
+
const { root, gitDir, real: abs } = repoOf(path.resolve(base, file));
|
|
446
|
+
if (mode !== 'on' && !fs.existsSync(path.join(root, 'plugin', 'scripts', 'decision-gate.mjs'))) return { allow: true, why: 'not-this-repo' };
|
|
447
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
448
|
+
if (rel.startsWith('..')) return { allow: true, why: 'outside-repo' };
|
|
449
|
+
if (!SCOPE.some((d) => rel.startsWith(d)) || !CODE.test(rel)) return { allow: true, why: 'out-of-scope' };
|
|
450
|
+
|
|
451
|
+
const cand = candidateOf(input, abs, rel);
|
|
452
|
+
if (!cand) return { allow: true, why: 'no-new-code' };
|
|
453
|
+
const files = listCode(root);
|
|
454
|
+
const exists = (p) => fs.existsSync(path.resolve(root, p));
|
|
455
|
+
const stems = new Set(files.filter((p) => !isTest(p)).map(stemOf));
|
|
456
|
+
const ex = exemption(rel, cand.text, stems);
|
|
457
|
+
if (ex) return { allow: true, why: ex };
|
|
458
|
+
if (distinctFrom(cand.text, exists) || (cand.header && distinctFrom(cand.header, exists))) return { allow: true, why: 'distinct-from' };
|
|
459
|
+
|
|
460
|
+
const stateDir = env.RUVNET_DUPLICATE_GATE_STATE_DIR || path.join(gitDir, 'ruvnet-duplicate-gate');
|
|
461
|
+
const session = String(payload.session_id || '');
|
|
462
|
+
const ackKey = `${session}\u0000${rel}`;
|
|
463
|
+
const acks = readAcks(stateDir);
|
|
464
|
+
if (acks[ackKey]?.state === 'refused') {
|
|
465
|
+
acks[ackKey] = { ...acks[ackKey], state: 'acknowledged', ackTs: now, ts: now };
|
|
466
|
+
writeAcks(stateDir, acks, now);
|
|
467
|
+
return { allow: true, why: 'acknowledged' };
|
|
468
|
+
}
|
|
469
|
+
if (acks[ackKey]) return { allow: true, why: 'already-acknowledged' };
|
|
470
|
+
|
|
471
|
+
const model = prepare(loadIndex(root, stateDir, { deadline, files }));
|
|
472
|
+
const matches = rank(model, extract(rel, cand.text), { self: rel, testsOnly: isTest(rel) })
|
|
473
|
+
.filter((m) => m.strength >= 0.6 && !isRelocation(rel, m.path));
|
|
474
|
+
if (!matches.length || matches[0].strength < 1) return { allow: true, why: 'no-match', best: matches[0] };
|
|
475
|
+
if (isTest(rel)) {
|
|
476
|
+
logShadow(stateDir, { ts: now, session, rel, wouldBlock: true, why: 'test-scaffolding', top: matches.map((m) => [m.path, Number(m.score.toFixed(3)), m.copied.length]) });
|
|
477
|
+
return { allow: true, why: 'shadow-test', matches };
|
|
478
|
+
}
|
|
479
|
+
const lineText = codeLines(cand.text);
|
|
480
|
+
for (const m of matches) m.copiedText = m.copied.slice(0, 2).map((h) => lineText.get(h)).filter(Boolean);
|
|
481
|
+
acks[ackKey] = { state: 'refused', ts: now, session, rel, top: matches.map((m) => [m.path, Number(m.score.toFixed(3))]) };
|
|
482
|
+
writeAcks(stateDir, acks, now);
|
|
483
|
+
const what = cand.isNew ? 'new file' : `new export ${cand.names.join(', ')} in`;
|
|
484
|
+
return { allow: false, why: 'duplicate', matches, reason: refusalText(rel, matches, what) };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const isMain = (() => {
|
|
488
|
+
try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
|
|
489
|
+
catch { return false; }
|
|
490
|
+
})();
|
|
491
|
+
|
|
492
|
+
if (isMain) {
|
|
493
|
+
let code = 0;
|
|
494
|
+
try {
|
|
495
|
+
const r = evaluate(JSON.parse(fs.readFileSync(0, 'utf8') || '{}'));
|
|
496
|
+
if (!r.allow) { process.stderr.write(`${r.reason}\n`); code = 2; }
|
|
497
|
+
} catch (e) {
|
|
498
|
+
// Fail open — but an overloaded gate says so. Anything else (not a git repo, no git on PATH, a
|
|
499
|
+
// malformed payload) is "not applicable" and stays silent, as before.
|
|
500
|
+
if (e instanceof IndexBudgetError) { process.stderr.write(`duplicate gate skipped: ${e.message}\n`); code = SKIPPED; } else code = 0;
|
|
501
|
+
}
|
|
502
|
+
process.exit(code);
|
|
503
|
+
}
|