ruvnet-brain 4.3.1 → 4.3.3
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 +12 -13
- package/bin/install.mjs +10 -1
- package/console/app.js +4 -4
- package/kb/verify-citation.mjs +32 -5
- package/package.json +12 -9
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +4 -2
- package/plugin/scripts/codex-hook-adapter.mjs +12 -7
- package/plugin/scripts/codex-hook-events.mjs +39 -0
- package/plugin/scripts/coverage-integrity.mjs +4 -4
- package/plugin/scripts/hook-shim.mjs +9 -7
- package/plugin/scripts/learn-capture.sh +20 -1
- package/plugin/scripts/learn-flush.mjs +7 -1
- package/plugin/scripts/lesson-gate.mjs +17 -2
- package/plugin/scripts/lesson-hooks.sh +17 -2
- package/plugin/scripts/session-start-core.mjs +47 -11
- package/scripts/brain-stamp-resolve.mjs +22 -0
- package/scripts/brain-stamp.mjs +5 -3
- package/scripts/build-bundle.mjs +41 -4
- package/scripts/ci/step-watchdog.mjs +86 -0
- package/scripts/convergence-manifest.mjs +66 -0
- package/scripts/distill-project.mjs +22 -4
- package/scripts/eval-brain.mjs +13 -3
- package/scripts/execution-policy.mjs +66 -0
- package/scripts/git-hooks/pre-push +16 -0
- package/scripts/health-repair.mjs +5 -1
- package/scripts/public-verification-aggregate.mjs +3 -1
- package/scripts/public-verification-inputs.mjs +5 -3
- package/scripts/qa-runner.mjs +71 -0
- package/scripts/qe/agentic-qe-4.3.mjs +195 -0
- package/scripts/qe/aggregate-4.3.mjs +42 -0
- package/scripts/record-lesson.mjs +28 -6
- package/scripts/release-projection.mjs +76 -32
- package/scripts/release.mjs +6 -11
- package/scripts/retrieval-canary.mjs +14 -6
- package/scripts/set-version.mjs +31 -0
- package/scripts/verify-channels.mjs +1 -1
|
@@ -98,12 +98,12 @@ const surfaceIssues = (stateDir, emit, now, env, home, platform) => {
|
|
|
98
98
|
const more = selected.length > 4 ? ` · +${selected.length - 4} more` : '';
|
|
99
99
|
if (breaches.length) {
|
|
100
100
|
emit('[RuvNet Brain — OPEN ISSUES need attention (surface this to the maintainer, once, near the top)]');
|
|
101
|
-
emit(`${breaches.length} open issue(s) past SLA on ${status.repo}: ${top}${more}`);
|
|
101
|
+
emit(`${breaches.length} open issue(s) past SLA on ${status.repo}: ${top}${more}; open issues are visible to the maintainer`);
|
|
102
102
|
emit('These are real user-filed bugs sitting past the response SLA. Mention them plainly so they do not stack unseen; offer to fix them (gh issue list --state open for detail).');
|
|
103
103
|
return;
|
|
104
104
|
}
|
|
105
105
|
emit("[RuvNet Brain — open issues on the maintainer's repo (mention once, calmly, near the top)]");
|
|
106
|
-
emit(`${open.length} open issue(s) on ${status.repo}, none past SLA: ${top}${more}`);
|
|
106
|
+
emit(`${open.length} open issue(s) on ${status.repo}, none past SLA: ${top}${more}; open issues are visible to the maintainer`);
|
|
107
107
|
emit('Within SLA, but the maintainer should know they exist. One line is enough; offer to look.');
|
|
108
108
|
};
|
|
109
109
|
|
|
@@ -133,7 +133,7 @@ const surfaceSignals = ({ env, cwd, stateDir, hookDir, emit, now }) => {
|
|
|
133
133
|
if (debt?.state === 'resolved' && debt.conclusion !== 'success') {
|
|
134
134
|
if (surfaced.debts[key] === 'red') continue;
|
|
135
135
|
emit(`[RuvNet Brain — EXTERNAL SIGNAL: CI is RED for ${debt.repo}@${shortSha} — surface this to the user now, near the top, with ZERO prompting]`);
|
|
136
|
-
emit(`Workflow ${debt.workflowName || 'ci'} concluded ${debt.conclusion} on ${debt.repo}@${shortSha}
|
|
136
|
+
emit(`Workflow ${debt.workflowName || 'ci'} concluded ${debt.conclusion} on ${debt.repo}@${shortSha}; inspect with gh run list --repo ${debt.repo} --commit ${debt.ref}.`);
|
|
137
137
|
surfaced.debts[key] = 'red';
|
|
138
138
|
surfaced.redRepo[debt.repo] = key;
|
|
139
139
|
changed = true;
|
|
@@ -352,14 +352,40 @@ export async function runSessionStart({
|
|
|
352
352
|
stderr = process.stderr,
|
|
353
353
|
platform = process.platform,
|
|
354
354
|
restoreContinuity = restoreProgressionForSession,
|
|
355
|
+
runHeartbeat = true,
|
|
355
356
|
} = {}) {
|
|
356
357
|
const lines = [];
|
|
357
|
-
// SessionStart is context plumbing, not an instruction channel.
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
358
|
+
// SessionStart is context plumbing, not an instruction channel. Keep factual, actionable
|
|
359
|
+
// health/CI/issue signals and the single status line; suppress response scripts, setup
|
|
360
|
+
// questions, promotional copy, and injected "tell the user" prose. This preserves #198's
|
|
361
|
+
// maintainer alarms without recreating the unsolicited Claude Code hook noise.
|
|
362
|
+
const isSafeStatus = (line) => {
|
|
363
|
+
const s = String(line);
|
|
364
|
+
return s.startsWith('🚨')
|
|
365
|
+
|| s.startsWith('[RuvNet Brain — HEALTH ALARM')
|
|
366
|
+
|| s.startsWith('[RuvNet Brain — NIGHTLY FAILED')
|
|
367
|
+
|| s.startsWith('[RuvNet Brain — OPEN ISSUES')
|
|
368
|
+
|| /\bopen issue\(s\)/i.test(s)
|
|
369
|
+
|| /^\[RuvNet Brain — external signal/i.test(s)
|
|
370
|
+
|| (s.startsWith('Workflow ') && !/\b(Say it plainly|offer to look|ask only|run:|invoke)\b/i.test(s))
|
|
371
|
+
|| s.startsWith('[RuvNet Brain — grounding not yet PROVEN')
|
|
372
|
+
|| s.startsWith('The last check (')
|
|
373
|
+
|| s.startsWith('[RuvNet Brain — update available')
|
|
374
|
+
|| s.startsWith('[RuvNet Brain — brain OFF by your setting')
|
|
375
|
+
|| s.startsWith('[RuvNet Brain — new in v')
|
|
376
|
+
|| s.startsWith('[RuvNet Brain — first session initialized]')
|
|
377
|
+
|| s.startsWith('[RuvNet Brain — one-time note')
|
|
378
|
+
|| s.startsWith('At a natural CLOSING')
|
|
379
|
+
|| s.includes('github.com/stuinfla/ruvnet-brain or leave feedback')
|
|
380
|
+
|| s.startsWith('[RuvNet Brain v')
|
|
381
|
+
|| s.startsWith('USER-LEVEL:')
|
|
382
|
+
|| s.startsWith('[ASCII→SVG]')
|
|
383
|
+
|| s.startsWith('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]')
|
|
384
|
+
|| s.startsWith('[RuvNet Brain — PROJECT CONTINUITY RESTORED]')
|
|
385
|
+
|| s.startsWith('[RuvNet Brain — MAINTAINER ONLY:');
|
|
386
|
+
};
|
|
361
387
|
const emit = (line = '') => {
|
|
362
|
-
if (env.RUVNET_VERBOSE_HOOKS === '1') lines.push(String(line));
|
|
388
|
+
if (env.RUVNET_VERBOSE_HOOKS === '1' || isSafeStatus(line)) lines.push(String(line));
|
|
363
389
|
};
|
|
364
390
|
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
365
391
|
const stateDir = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
@@ -370,6 +396,11 @@ export async function runSessionStart({
|
|
|
370
396
|
const pluginRoot = env.CLAUDE_PLUGIN_ROOT || path.resolve(hookDir, '..');
|
|
371
397
|
const manifest = json(path.join(pluginRoot, '.claude-plugin', 'plugin.json'), {});
|
|
372
398
|
const running = typeof manifest?.version === 'string' ? manifest.version : '';
|
|
399
|
+
// The Stable Spine supplies the immutable generation actually executing this invocation. This
|
|
400
|
+
// avoids showing a boot-frozen plugin version after a restart-free update.
|
|
401
|
+
const activeVersion = typeof env.RUVNET_BRAIN_ACTIVE_VERSION === 'string'
|
|
402
|
+
? env.RUVNET_BRAIN_ACTIVE_VERSION : '';
|
|
403
|
+
const effectiveVersion = activeVersion || running;
|
|
373
404
|
const updated = typeof manifest?.updated === 'string' ? manifest.updated : '';
|
|
374
405
|
const trace = (stage) => {
|
|
375
406
|
if (env.RUVNET_SESSION_TRACE === '1') {
|
|
@@ -414,6 +445,8 @@ export async function runSessionStart({
|
|
|
414
445
|
}
|
|
415
446
|
|
|
416
447
|
const consoleOffered = path.join(stateDir, '.console-offered');
|
|
448
|
+
const firstSession = !brain.off && !exists(consoleOffered);
|
|
449
|
+
if (firstSession) emit('[RuvNet Brain — first session initialized]');
|
|
417
450
|
if (!brain.off && !exists(consoleOffered)) {
|
|
418
451
|
write(consoleOffered, '');
|
|
419
452
|
emit('[RuvNet Brain — FIRST LOAD: offer the Console once]');
|
|
@@ -447,7 +480,7 @@ export async function runSessionStart({
|
|
|
447
480
|
emit('No answer: ask next session, never twice.');
|
|
448
481
|
}
|
|
449
482
|
const spine = stableSpine({ env, hookDir, stateDir, home, pluginVersion: running, emit, now });
|
|
450
|
-
heartbeat({ env, hookDir, stateDir, home, running, ...spine, emit, now });
|
|
483
|
+
if (runHeartbeat) heartbeat({ env, hookDir, stateDir, home, running, ...spine, emit, now });
|
|
451
484
|
|
|
452
485
|
const star = path.join(stateDir, '.star-ask-shown');
|
|
453
486
|
if (!brain.off && exists(path.join(stateDir, '.grounded-once')) && !exists(star) && write(star, '')) {
|
|
@@ -456,7 +489,7 @@ export async function runSessionStart({
|
|
|
456
489
|
emit(' "Finding this useful? Star github.com/stuinfla/ruvnet-brain or leave feedback — it keeps the nightly updates coming."');
|
|
457
490
|
}
|
|
458
491
|
|
|
459
|
-
const bannerVersion =
|
|
492
|
+
const bannerVersion = effectiveVersion || 'unknown';
|
|
460
493
|
if (pluginRoot.startsWith(path.join(home, '.claude', 'plugins') + path.sep)) {
|
|
461
494
|
if (running) write(path.join(stateDir, '.running-version'), `${running}\n`);
|
|
462
495
|
} else if (running) {
|
|
@@ -521,8 +554,11 @@ export async function runSessionStart({
|
|
|
521
554
|
if (env.RUVNET_SESSION_TRACE === '1') stderr.write(`SESSION_TRACE native-fail-open ${error?.message || error}\n`);
|
|
522
555
|
}
|
|
523
556
|
|
|
524
|
-
if (env.RUVNET_VERBOSE_HOOKS !== '1') {
|
|
557
|
+
if (env.RUVNET_VERBOSE_HOOKS !== '1' && !brain.off) {
|
|
525
558
|
lines.push(`[RuvNet Brain v${running || 'unknown'} — active this session]`);
|
|
559
|
+
// Keep the legacy plain status token for host integrations that key off it; it carries no
|
|
560
|
+
// instruction and does not introduce a second version or bundle identity.
|
|
561
|
+
lines.push('[RuvNet Brain active]');
|
|
526
562
|
}
|
|
527
563
|
const output = lines.length ? `${lines.join('\n')}\n` : '';
|
|
528
564
|
meter({ env, cwd, stateDir, output });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// brain-stamp-resolve.mjs — pure helper split out of brain-stamp.mjs specifically so it can be unit
|
|
2
|
+
// tested. brain-stamp.mjs is 100% top-level script: importing it for real shells out to git and
|
|
3
|
+
// writes data/manifest.json + rewrites primer/ruvnet-primer.md as a side effect of the import alone
|
|
4
|
+
// (documented in tests/unit/brain-stamp-manifest.test.mjs's header, and confirmed the hard way while
|
|
5
|
+
// writing this candidate's test — an early version imported resolveBuiltFromSha straight from
|
|
6
|
+
// brain-stamp.mjs and it silently restamped this checkout's own manifest/primer on `vitest run`).
|
|
7
|
+
// This module has zero side effects on import; brain-stamp.mjs imports resolveBuiltFromSha from here.
|
|
8
|
+
|
|
9
|
+
// `builtFromSha` promises the commit the SHIPPED RVF bytes were built from — not whatever HEAD the
|
|
10
|
+
// local clone happens to sit at when the stamper runs. RVF-GENERATIONS.json's `sourceCommit` is
|
|
11
|
+
// recorded once, at actual build time (rvf-generation.mjs's writeRvfGeneration); the clone's live
|
|
12
|
+
// HEAD drifts from it the moment the clone is pulled or rebuilt after that. ADR-069's audit named
|
|
13
|
+
// this exact gap ("clone freshness is not artifact freshness") and cites synthlang/autogenous as
|
|
14
|
+
// cases where the two values already disagree in this repo's own committed kb/RVF-GENERATIONS.json.
|
|
15
|
+
// Prefer the recorded generation; fall back to the live clone HEAD only for stores with no generation
|
|
16
|
+
// record yet. Case-insensitive lookup matches brain-stamp.mjs's own findClone() convention (store
|
|
17
|
+
// names are lowercased, clone dirs are not).
|
|
18
|
+
export function resolveBuiltFromSha(name, { generations = {}, localSha = null } = {}) {
|
|
19
|
+
const key = Object.keys(generations).find((k) => k.toLowerCase() === String(name).toLowerCase());
|
|
20
|
+
const recorded = key ? generations[key].sourceCommit : null;
|
|
21
|
+
return recorded || localSha || 'unknown';
|
|
22
|
+
}
|
package/scripts/brain-stamp.mjs
CHANGED
|
@@ -8,7 +8,8 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { execFileSync } from 'node:child_process';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import { canonicalRvfStores, hasCanonicalRvfStore } from './rvf-generation.mjs';
|
|
11
|
+
import { canonicalRvfStores, hasCanonicalRvfStore, readRvfGenerations } from './rvf-generation.mjs';
|
|
12
|
+
import { resolveBuiltFromSha } from './brain-stamp-resolve.mjs';
|
|
12
13
|
import { getVersionTag, stripTag } from './version.mjs';
|
|
13
14
|
// The org total is DERIVED, never a literal: it was hardcoded 248 in this file and in its
|
|
14
15
|
// sibling while the account actually had 200 — one stale fact, restated twice (2026-08-12).
|
|
@@ -27,6 +28,7 @@ const shaOf = (dir) => { try { return execFileSync('git', ['rev-parse', 'HEAD'],
|
|
|
27
28
|
// no shell: slug is placed in a URL arg, never interpreted by a shell
|
|
28
29
|
const remoteSha = (slug) => { try { return execFileSync('git', ['ls-remote', `https://github.com/ruvnet/${slug}`, 'HEAD']).toString().split(/\s/)[0] || null; } catch { return null; } };
|
|
29
30
|
|
|
31
|
+
const generations = readRvfGenerations(KB_DIR).stores || {};
|
|
30
32
|
const tiers = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/registry.tiers.json'), 'utf8'));
|
|
31
33
|
const repos = [];
|
|
32
34
|
for (const t of ['T0', 'T1', 'T2', 'T3']) {
|
|
@@ -38,7 +40,7 @@ for (const t of ['T0', 'T1', 'T2', 'T3']) {
|
|
|
38
40
|
const localSha = fs.existsSync(path.join(cloneDir, '.git')) ? shaOf(cloneDir) : null;
|
|
39
41
|
repos.push({
|
|
40
42
|
name: r.name, tier: t, stars: r.stars,
|
|
41
|
-
builtFromSha: builtName(r.name) ? (localSha
|
|
43
|
+
builtFromSha: builtName(r.name) ? resolveBuiltFromSha(r.name, { generations, localSha }) : null,
|
|
42
44
|
latestRemoteSha: null, // filled by --check-remote (network) in self-update
|
|
43
45
|
status: builtName(r.name) ? 'built' : 'pending',
|
|
44
46
|
});
|
|
@@ -67,7 +69,7 @@ for (const store of canonicalRvfStores(KB_DIR)) {
|
|
|
67
69
|
const cloneDir = findClone(store);
|
|
68
70
|
repos.push({
|
|
69
71
|
name: store, tier: 'fresh', stars: undefined,
|
|
70
|
-
builtFromSha: cloneDir ?
|
|
72
|
+
builtFromSha: resolveBuiltFromSha(store, { generations, localSha: cloneDir ? shaOf(cloneDir) : null }),
|
|
71
73
|
latestRemoteSha: null,
|
|
72
74
|
status: 'built',
|
|
73
75
|
});
|
package/scripts/build-bundle.mjs
CHANGED
|
@@ -38,6 +38,7 @@ const OUT = path.resolve(ROOT, arg('--out', 'dist/ruvnet-brain'));
|
|
|
38
38
|
const COVERAGE = arg('--coverage', null);
|
|
39
39
|
const PROJECTION = arg('--projection', null);
|
|
40
40
|
const BRAIN_VERSION = arg('--version', getVersionTag()); // inherits the single source of truth
|
|
41
|
+
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
41
42
|
|
|
42
43
|
// ---- registry: tier + the full 169-repo pending list -------------------------------------------
|
|
43
44
|
const registry = JSON.parse(fs.readFileSync(path.join(DATA, 'registry.tiers.json'), 'utf8'));
|
|
@@ -182,6 +183,22 @@ if (COVERAGE) {
|
|
|
182
183
|
}
|
|
183
184
|
let inventory;
|
|
184
185
|
try {
|
|
186
|
+
if (PROJECTION) {
|
|
187
|
+
const projectionDir = path.resolve(PROJECTION);
|
|
188
|
+
const receiptFile = path.join(projectionDir, 'ruv-gists.sources.json');
|
|
189
|
+
if (fs.existsSync(receiptFile)) fs.copyFileSync(receiptFile, path.join(ASSETS, 'ruv-gists.sources.json'));
|
|
190
|
+
const classesFile = path.join(projectionDir, 'public-store-classes.json');
|
|
191
|
+
if (fs.existsSync(classesFile)) fs.copyFileSync(classesFile, path.join(ASSETS, 'public-store-classes.json'));
|
|
192
|
+
const conceptsReceipt = path.join(projectionDir, 'concepts.sources.json');
|
|
193
|
+
if (fs.existsSync(conceptsReceipt)) fs.copyFileSync(conceptsReceipt, path.join(ASSETS, 'concepts.sources.json'));
|
|
194
|
+
// The projection is derived from the public seed and therefore does not
|
|
195
|
+
// contain the repository's release-policy fence. The inventory validator
|
|
196
|
+
// still needs that fence when validating the projected input.
|
|
197
|
+
const privateStores = path.join(KB, 'PRIVATE-STORES.json');
|
|
198
|
+
if (fs.existsSync(privateStores) && !fs.existsSync(path.join(ASSETS, 'PRIVATE-STORES.json'))) {
|
|
199
|
+
fs.copyFileSync(privateStores, path.join(ASSETS, 'PRIVATE-STORES.json'));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
185
202
|
inventory = validatePublicInventory({ assetsDir: ASSETS, coverage, ledger: readRvfGenerations(ASSETS) });
|
|
186
203
|
} catch (error) {
|
|
187
204
|
console.error(`[build-bundle] FATAL: public inventory is not exhaustive (${error.message})`);
|
|
@@ -291,9 +308,29 @@ if (hasConcepts) for (const suf of ['concepts.big.rvf', 'concepts.big.rvf.idmap.
|
|
|
291
308
|
// so ruv-gists — a PUBLIC store, not private-fenced — was silently omitted from EVERY bundle ever
|
|
292
309
|
// shipped. Include it, WITH .big.passages.jsonl (the big variant opens it by name, same as concepts).
|
|
293
310
|
const hasGists = fs.existsSync(path.join(ASSETS, 'ruv-gists.big.rvf'));
|
|
294
|
-
if (hasGists)
|
|
295
|
-
//
|
|
296
|
-
|
|
311
|
+
if (hasGists) {
|
|
312
|
+
// RVF and passage bytes come from the sealed seed; the source receipt is governed source
|
|
313
|
+
// evidence from this checkout. The seed predates shipping that receipt, so sourcing every file
|
|
314
|
+
// from ASSETS makes the release projection fail closed even though the exact gist bytes exist.
|
|
315
|
+
for (const suf of ['ruv-gists.big.rvf', 'ruv-gists.big.rvf.idmap.json', 'ruv-gists.big.rvf.embed.json', 'ruv-gists.big.passages.jsonl', 'ruv-gists.big.meta.json', 'ruv-gists.passages.jsonl', 'ruv-gists.meta.json']) cp(suf, OUT, { asset: true });
|
|
316
|
+
cp('ruv-gists.sources.json', OUT, { required: !PROJECTION });
|
|
317
|
+
}
|
|
318
|
+
// The inventory projection consumes this registry from the assembled bundle. Older corpus seeds
|
|
319
|
+
// do not carry it, so derive the only valid legacy class (concepts) from the exact bytes already
|
|
320
|
+
// assembled instead of failing before projection can create its release-bound evidence set.
|
|
321
|
+
if (!cp('public-store-classes.json', OUT, { asset: true })) {
|
|
322
|
+
const derived = [];
|
|
323
|
+
const conceptsPassages = path.join(OUT, 'concepts.passages.jsonl');
|
|
324
|
+
if (fs.existsSync(conceptsPassages)) {
|
|
325
|
+
const bytes = fs.readFileSync(conceptsPassages);
|
|
326
|
+
const receipt = { schemaVersion: 1, kind: 'ruvnet-brain-derived-store-receipt', store: 'concepts',
|
|
327
|
+
inputs: [{ path: 'concepts.passages.jsonl', sha256: sha256(bytes) }], passagesSha256: sha256(bytes) };
|
|
328
|
+
fs.writeFileSync(path.join(OUT, 'concepts.sources.json'), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
329
|
+
derived.push({ store: 'concepts', receipt: 'concepts.sources.json' });
|
|
330
|
+
}
|
|
331
|
+
fs.writeFileSync(path.join(OUT, 'public-store-classes.json'), `${JSON.stringify({ schemaVersion: 1, derived }, null, 2)}\n`);
|
|
332
|
+
copied++;
|
|
333
|
+
}
|
|
297
334
|
|
|
298
335
|
// capability-cards.md — the FAST LANE's zero-ML answer source (kb/card-lane.mjs, the first
|
|
299
336
|
// responder search_ruvnet consults before the heavy cross-repo search). Ships as its own small
|
|
@@ -470,7 +507,7 @@ cp(path.join(ROOT, 'keys', 'ruvnet-brain-signing.pub.pem'), path.join(OUT, 'keys
|
|
|
470
507
|
// ReleaseProjection is generated from the exact candidate asset tree immediately before this
|
|
471
508
|
// assembly. Copy all three linked ledgers together; a partial projection is never publishable.
|
|
472
509
|
if (PROJECTION) {
|
|
473
|
-
for (const file of ['COVERAGE.json', 'CORPUS-COVERAGE.json', 'PUBLIC-RVF-GENERATIONS.json']) {
|
|
510
|
+
for (const file of ['COVERAGE.json', 'CORPUS-COVERAGE.json', 'PUBLIC-RVF-GENERATIONS.json', 'ruv-gists.sources.json', 'public-store-classes.json', 'concepts.sources.json']) {
|
|
474
511
|
cp(path.join(path.resolve(PROJECTION), file), OUT, { required: true });
|
|
475
512
|
}
|
|
476
513
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Run one CI operation with an explicit wall-clock budget and visible progress.
|
|
3
|
+
// GitHub Actions has job timeouts, but no per-step timeout. This wrapper makes a
|
|
4
|
+
// stalled operation fail locally at its named boundary and leaves a receipt.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
const argv = process.argv.slice(2);
|
|
11
|
+
const value = (flag, fallback = null) => {
|
|
12
|
+
const index = argv.indexOf(flag);
|
|
13
|
+
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
14
|
+
};
|
|
15
|
+
const separator = argv.indexOf('--');
|
|
16
|
+
const command = separator >= 0 ? argv.slice(separator + 1) : [];
|
|
17
|
+
const name = value('--name', command[0] || 'ci-step');
|
|
18
|
+
const timeoutMs = Number(value('--timeout-ms', '600000'));
|
|
19
|
+
const heartbeatMs = Number(value('--heartbeat-ms', '30000'));
|
|
20
|
+
const receiptDir = value('--receipt-dir', process.env.RUNNER_TEMP ? path.join(process.env.RUNNER_TEMP, 'step-receipts') : path.join(os.tmpdir(), 'ruvnet-brain-step-receipts'));
|
|
21
|
+
|
|
22
|
+
if (!command.length || !Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isFinite(heartbeatMs) || heartbeatMs <= 0) {
|
|
23
|
+
console.error('usage: node step-watchdog.mjs --name <name> --timeout-ms <ms> [--heartbeat-ms <ms>] [--receipt-dir <dir>] -- <command> [args...]');
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fs.mkdirSync(receiptDir, { recursive: true });
|
|
28
|
+
const startedAt = new Date().toISOString();
|
|
29
|
+
const started = Date.now();
|
|
30
|
+
const receiptPath = path.join(receiptDir, `${name}.json`);
|
|
31
|
+
const child = spawn(command[0], command.slice(1), {
|
|
32
|
+
cwd: process.cwd(),
|
|
33
|
+
env: process.env,
|
|
34
|
+
stdio: 'inherit',
|
|
35
|
+
shell: false,
|
|
36
|
+
detached: process.platform !== 'win32',
|
|
37
|
+
});
|
|
38
|
+
let timedOut = false;
|
|
39
|
+
let heartbeat;
|
|
40
|
+
const writeReceipt = (status, exitCode = null, signal = null) => {
|
|
41
|
+
const receipt = {
|
|
42
|
+
schema: 'ruvnet-brain.ci.step-receipt',
|
|
43
|
+
name,
|
|
44
|
+
command,
|
|
45
|
+
startedAt,
|
|
46
|
+
endedAt: new Date().toISOString(),
|
|
47
|
+
elapsedMs: Date.now() - started,
|
|
48
|
+
timeoutMs,
|
|
49
|
+
status,
|
|
50
|
+
exitCode,
|
|
51
|
+
signal,
|
|
52
|
+
};
|
|
53
|
+
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
54
|
+
return receipt;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
console.log(`[watchdog] START ${name} budget=${timeoutMs}ms command=${command.join(' ')}`);
|
|
58
|
+
heartbeat = setInterval(() => {
|
|
59
|
+
console.log(`[watchdog] HEARTBEAT ${name} elapsed=${Date.now() - started}ms budget=${timeoutMs}ms`);
|
|
60
|
+
}, heartbeatMs);
|
|
61
|
+
const timeout = setTimeout(() => {
|
|
62
|
+
timedOut = true;
|
|
63
|
+
console.error(`[watchdog] TIMEOUT ${name} after ${timeoutMs}ms; terminating process`);
|
|
64
|
+
if (process.platform === 'win32') child.kill('SIGTERM');
|
|
65
|
+
else try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
if (!child.killed) {
|
|
68
|
+
if (process.platform === 'win32') child.kill('SIGKILL');
|
|
69
|
+
else try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
|
|
70
|
+
}
|
|
71
|
+
}, 2000).unref();
|
|
72
|
+
}, timeoutMs);
|
|
73
|
+
|
|
74
|
+
child.on('error', (error) => {
|
|
75
|
+
clearTimeout(timeout); clearInterval(heartbeat);
|
|
76
|
+
writeReceipt('ERROR', null, error.code || error.name);
|
|
77
|
+
console.error(`[watchdog] ERROR ${name}: ${error.message}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
});
|
|
80
|
+
child.on('close', (code, signal) => {
|
|
81
|
+
clearTimeout(timeout); clearInterval(heartbeat);
|
|
82
|
+
const status = timedOut ? 'TIMEOUT' : code === 0 ? 'PASS' : 'FAIL';
|
|
83
|
+
const receipt = writeReceipt(status, code, signal);
|
|
84
|
+
console.log(`[watchdog] ${status} ${name} elapsed=${receipt.elapsedMs}ms receipt=${receiptPath}`);
|
|
85
|
+
process.exit(status === 'PASS' ? 0 : 1);
|
|
86
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Deterministic convergence authority for source identity, version surfaces, ADR inventory,
|
|
3
|
+
// and executable ownership. No timestamps or live-network answers are included.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
const OUT = path.join(ROOT, 'data', 'convergence-manifest.json');
|
|
12
|
+
const VERSION_FILES = [
|
|
13
|
+
'package.json', 'plugin/.claude-plugin/plugin.json', 'plugin/.codex-plugin/plugin.json',
|
|
14
|
+
'data/manifest.json', 'kb/RVF-GENERATIONS.json', 'explainer/index.html', 'primer/ruvnet-primer.md',
|
|
15
|
+
];
|
|
16
|
+
const INVENTORY_DIRS = ['src', 'bin', 'console', 'data', 'docs/adr', 'kb', 'plugin', 'scripts', 'tests', '.github'];
|
|
17
|
+
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
18
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel));
|
|
19
|
+
const tracked = () => execFileSync('git', ['-C', ROOT, 'ls-files', '-z', '--', ...INVENTORY_DIRS], { encoding: 'buffer' })
|
|
20
|
+
.toString().split('\0').filter(Boolean).sort();
|
|
21
|
+
const fileRows = (files) => files.filter((rel) => rel !== 'data/convergence-manifest.json' && fs.existsSync(path.join(ROOT, rel)))
|
|
22
|
+
.map((rel) => ({ path: rel, sha256: sha256(read(rel)), bytes: fs.statSync(path.join(ROOT, rel)).size }));
|
|
23
|
+
const stable = (value) => {
|
|
24
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
|
|
25
|
+
if (value && typeof value === 'object') {
|
|
26
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`;
|
|
27
|
+
}
|
|
28
|
+
return JSON.stringify(value);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function build() {
|
|
32
|
+
const versions = Object.fromEntries(VERSION_FILES.map((rel) => {
|
|
33
|
+
const text = read(rel).toString('utf8');
|
|
34
|
+
const match = rel.endsWith('.html') ? text.match(/softwareVersion["']\s*:\s*["']([^"']+)/)
|
|
35
|
+
: rel.endsWith('.md') ? text.match(/Brain version:\s*v([^\s·]+)/i)
|
|
36
|
+
: text.match(/"(?:version|brainVersion)"\s*:\s*"([^\"]+)"/);
|
|
37
|
+
return [rel, match?.[1] || null];
|
|
38
|
+
}));
|
|
39
|
+
const version = JSON.parse(read('package.json')).version;
|
|
40
|
+
const files = fileRows(tracked());
|
|
41
|
+
const sourceIdentity = sha256(Buffer.from(files.map((row) => `${row.path}\0${row.sha256}\0${row.bytes}\n`).join('')));
|
|
42
|
+
return { schemaVersion: 1, kind: 'ruvnet-brain-convergence-manifest', version, sourceIdentity,
|
|
43
|
+
versionSurfaces: versions,
|
|
44
|
+
adrInventory: fs.readdirSync(path.join(ROOT, 'docs/adr')).filter((name) => name.endsWith('.md')).sort(),
|
|
45
|
+
trackedFileCount: files.length, trackedFilesSha256: sha256(Buffer.from(JSON.stringify(files))),
|
|
46
|
+
ownershipChecks: ['version:check', 'doc:currency', 'wired:check'] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function check() {
|
|
50
|
+
if (!fs.existsSync(OUT)) throw new Error('manifest missing; run npm run convergence:write');
|
|
51
|
+
const expected = build();
|
|
52
|
+
const actual = JSON.parse(fs.readFileSync(OUT, 'utf8'));
|
|
53
|
+
if (stable(actual) !== stable(expected)) throw new Error('manifest is stale; run npm run convergence:write');
|
|
54
|
+
if (Object.values(actual.versionSurfaces).some((value) => value !== actual.version)) {
|
|
55
|
+
throw new Error('version surfaces do not converge on package.json');
|
|
56
|
+
}
|
|
57
|
+
console.log(JSON.stringify({ ok: true, version: actual.version, sourceIdentity: actual.sourceIdentity,
|
|
58
|
+
trackedFileCount: actual.trackedFileCount, adrCount: actual.adrInventory.length }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (process.argv.includes('--write')) {
|
|
62
|
+
fs.writeFileSync(OUT, `${JSON.stringify(build(), null, 2)}\n`);
|
|
63
|
+
console.log(`[convergence] wrote ${path.relative(ROOT, OUT)}`);
|
|
64
|
+
} else {
|
|
65
|
+
try { check(); } catch (error) { console.error(`[convergence] ${error.message}`); process.exitCode = 1; }
|
|
66
|
+
}
|
|
@@ -96,12 +96,25 @@ function writeReceipt(rec) {
|
|
|
96
96
|
} catch { return false; }
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
// Some filesystems truncate mtime to whole seconds (FAT32, some overlay/network mounts), so a file
|
|
100
|
+
// written a moment after `sinceMs` was captured can still report an mtime slightly before it. This
|
|
101
|
+
// grace window is tolerance for that truncation, not a loophole — it stays far smaller than the gap
|
|
102
|
+
// between one run and the next.
|
|
103
|
+
const MTIME_GRACE_MS = 1500;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The newest snapshot this project has, or null. With `sinceMs`, only a file whose mtime is no
|
|
107
|
+
* older than that moment (minus the grace window) counts — so a stale snapshot left over from a
|
|
108
|
+
* PRIOR run can never be mistaken for proof that THIS run's backup actually landed. `--restore`
|
|
109
|
+
* calls this with no floor: it legitimately wants the newest snapshot ever, not the newest since a
|
|
110
|
+
* particular run.
|
|
111
|
+
*/
|
|
112
|
+
function newestSnapshot(sinceMs = 0) {
|
|
101
113
|
try {
|
|
102
114
|
const files = fs.readdirSync(BACKUP_DIR)
|
|
103
115
|
.filter((f) => /\.(db|sqlite|bak)$/i.test(f) || /memory.*\d/.test(f))
|
|
104
116
|
.map((f) => ({ f, p: path.join(BACKUP_DIR, f), t: fs.statSync(path.join(BACKUP_DIR, f)).mtimeMs }))
|
|
117
|
+
.filter((x) => x.t >= sinceMs - MTIME_GRACE_MS)
|
|
105
118
|
.sort((a, b) => b.t - a.t);
|
|
106
119
|
return files.length ? files[0].p : null;
|
|
107
120
|
} catch { return null; }
|
|
@@ -158,13 +171,18 @@ if (DRY) {
|
|
|
158
171
|
|
|
159
172
|
// ── 1. SNAPSHOT FIRST. This is the undo; without it the operation is not offerable. ────────────────
|
|
160
173
|
say('snapshot: taking a WAL-safe copy via `ruflo memory backup` before touching anything…');
|
|
174
|
+
const backupStartedAt = Date.now();
|
|
161
175
|
const bk = ruflo(['memory', 'backup', '--db', DB, '--dir', BACKUP_DIR]);
|
|
162
176
|
if (!bk.ok) {
|
|
163
177
|
die(`REFUSING TO DISTILL — the snapshot failed (${bk.err.trim().split('\n')[0] || 'unknown error'}).\n`
|
|
164
178
|
+ 'This is deliberate: distillation without a verified undo is exactly the unsafe offer this wrapper exists to prevent.');
|
|
165
179
|
}
|
|
166
|
-
|
|
167
|
-
|
|
180
|
+
// sinceMs=backupStartedAt: a stale snapshot from a PRIOR run sitting in BACKUP_DIR must never be
|
|
181
|
+
// mistaken for proof that THIS backup call actually wrote something (issue: `ruflo memory backup`
|
|
182
|
+
// exiting 0 without landing a new file — process killed post-commit, a --dir misconfiguration, a
|
|
183
|
+
// ruflo regression — would otherwise be indistinguishable from a genuine fresh backup).
|
|
184
|
+
const snapshot = newestSnapshot(backupStartedAt);
|
|
185
|
+
if (!snapshot) die('REFUSING TO DISTILL — `memory backup` reported success but no snapshot from this run is present, so the undo cannot be located.');
|
|
168
186
|
say(`snapshot: ${snapshot.replace(HOME, '~')}`);
|
|
169
187
|
|
|
170
188
|
// ── 2. RECEIPT, FAIL-CLOSED, BEFORE THE MUTATION. ─────────────────────────────────────────────────
|
package/scripts/eval-brain.mjs
CHANGED
|
@@ -70,10 +70,20 @@ export function wilson(k, n, z = 1.96) {
|
|
|
70
70
|
/**
|
|
71
71
|
* Grade one answered question by its stratum's rule. Pure — takes the verifier verdict, the top
|
|
72
72
|
* citation (with its ce score), and whether the raw output carried the gist provenance banner.
|
|
73
|
+
*
|
|
74
|
+
* `receipt` (verifyGrounding's `receipt.repo`, from kb/verify-citation.mjs) names the citation that
|
|
75
|
+
* ACTUALLY resolved on disk — not necessarily citations[0]. citationResolves walks citations in rank
|
|
76
|
+
* order and accepts the first that resolves, so when the top-ranked citation is unverified/fabricated
|
|
77
|
+
* but a lower-ranked one grounds the answer, `citations[0].repo` is the WRONG repo to credit or debit
|
|
78
|
+
* "routed" against: it can under-count a correctly-routed answer (top fabricated, real hit lower) or
|
|
79
|
+
* over-count a wrong one (top coincidentally names the expected repo but never resolved). `routed`
|
|
80
|
+
* answers "does the query reach the store that actually holds the answer" (brain-score.mjs's own
|
|
81
|
+
* wording for this metric) — that store is the one named in `receipt`, when one was verified.
|
|
73
82
|
*/
|
|
74
|
-
export function gradeQuestion(q, { grounded, citations, bannerPresent }) {
|
|
83
|
+
export function gradeQuestion(q, { grounded, citations, bannerPresent, receipt }) {
|
|
75
84
|
const top = citations?.[0] ?? null;
|
|
76
|
-
const
|
|
85
|
+
const routedRepo = receipt?.repo ?? top?.repo ?? null;
|
|
86
|
+
const routed = !!(grounded && q.expectRepo?.length && routedRepo && q.expectRepo.includes(routedRepo));
|
|
77
87
|
const abstained = !top || (typeof top.ce === 'number' && top.ce < ABSTAIN_CE);
|
|
78
88
|
switch (q.stratum) {
|
|
79
89
|
case 'adversarial':
|
|
@@ -180,7 +190,7 @@ async function main() {
|
|
|
180
190
|
continue;
|
|
181
191
|
}
|
|
182
192
|
const v = await verifyGrounding(out, KB);
|
|
183
|
-
const graded = gradeQuestion(q, { grounded: v.grounded, citations: v.citations, bannerPresent: /GIST STATUS/.test(out) });
|
|
193
|
+
const graded = gradeQuestion(q, { grounded: v.grounded, citations: v.citations, receipt: v.receipt, bannerPresent: /GIST STATUS/.test(out) });
|
|
184
194
|
const top = v.citations?.[0] ?? null;
|
|
185
195
|
rows[i] = {
|
|
186
196
|
id: q.id, stratum: q.stratum, query: q.query,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One deterministic boundary between Brain knowledge and consequential execution.
|
|
3
|
+
// This module is deliberately pure: it never loads credentials, calls a provider, or mutates state.
|
|
4
|
+
|
|
5
|
+
const NATIVE_HOSTS = new Set(['claude', 'codex']);
|
|
6
|
+
const API_EXECUTORS = new Set(['agent_execute', 'sdk', 'openrouter', 'api']);
|
|
7
|
+
const ARCHITECTURE_TERMS = /\b(adr|ddd|architecture|release|deploy|qa|security|schema|migration)\b/i;
|
|
8
|
+
|
|
9
|
+
export function classifyExecutionPolicy(input = {}) {
|
|
10
|
+
const action = String(input.action || 'read');
|
|
11
|
+
const description = String(input.description || '');
|
|
12
|
+
const changedFiles = [...new Set((input.changedFiles || []).map(String).filter(Boolean))];
|
|
13
|
+
const nativeHosts = [...new Set((input.nativeHosts || []).map(String).filter((h) => NATIVE_HOSTS.has(h)))];
|
|
14
|
+
const requestedExecutor = String(input.requestedExecutor || '');
|
|
15
|
+
const explicitSwarm = input.explicitSwarm === true;
|
|
16
|
+
const multiFile = changedFiles.length >= 3;
|
|
17
|
+
const architectureTask = ARCHITECTURE_TERMS.test(description) || ['release', 'external'].includes(action);
|
|
18
|
+
const swarmRequired = explicitSwarm || multiFile || architectureTask;
|
|
19
|
+
const swarmReason = explicitSwarm
|
|
20
|
+
? 'explicit-swarm-request'
|
|
21
|
+
: multiFile
|
|
22
|
+
? 'three-or-more-independent-file-surfaces'
|
|
23
|
+
: architectureTask
|
|
24
|
+
? 'architecture-or-consequential-action'
|
|
25
|
+
: 'single-sequential-surface';
|
|
26
|
+
|
|
27
|
+
if (action !== 'delegate') {
|
|
28
|
+
return {
|
|
29
|
+
schema: 'ruvnet-brain.execution-policy.v1',
|
|
30
|
+
verdict: 'ALLOW', action, swarmRequired, swarmReason,
|
|
31
|
+
executor: 'current-agent', reason: 'delegation-not-requested',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const nativeHost = NATIVE_HOSTS.has(input.activeHost) && nativeHosts.includes(input.activeHost)
|
|
36
|
+
? input.activeHost : nativeHosts[0] || null;
|
|
37
|
+
if (API_EXECUTORS.has(requestedExecutor)) {
|
|
38
|
+
return {
|
|
39
|
+
schema: 'ruvnet-brain.execution-policy.v1',
|
|
40
|
+
verdict: nativeHost ? 'REFUSE' : 'DEGRADED', action, swarmRequired, swarmReason,
|
|
41
|
+
executor: nativeHost ? `native:${nativeHost}` : 'unknown',
|
|
42
|
+
reason: nativeHost
|
|
43
|
+
? 'api-backed-executor-is-not-the-native-subscription-route'
|
|
44
|
+
: 'no-authenticated-native-host-is-available',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (!nativeHost) {
|
|
48
|
+
return {
|
|
49
|
+
schema: 'ruvnet-brain.execution-policy.v1',
|
|
50
|
+
verdict: 'DEGRADED', action, swarmRequired, swarmReason, executor: 'unknown',
|
|
51
|
+
reason: 'no-authenticated-native-host-is-available',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
schema: 'ruvnet-brain.execution-policy.v1',
|
|
56
|
+
verdict: 'ALLOW', action, swarmRequired, swarmReason,
|
|
57
|
+
executor: `native:${nativeHost}`, reason: 'native-subscription-route-selected',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
62
|
+
const raw = process.argv[2] || '{}';
|
|
63
|
+
let input;
|
|
64
|
+
try { input = JSON.parse(raw); } catch { process.stderr.write('execution-policy: input must be JSON\n'); process.exit(2); }
|
|
65
|
+
process.stdout.write(`${JSON.stringify(classifyExecutionPolicy(input))}\n`);
|
|
66
|
+
}
|
|
@@ -114,6 +114,22 @@ if [ "$commands_status" -ne 0 ]; then
|
|
|
114
114
|
exit 1
|
|
115
115
|
fi
|
|
116
116
|
|
|
117
|
+
# The convergence manifest is the exact source/version/ADR identity consumed by hosted QA.
|
|
118
|
+
# Check it here so a push cannot publish a commit whose generated identity was produced before
|
|
119
|
+
# the final source edit (the failure mode that repeatedly escaped local pre-push validation).
|
|
120
|
+
if [ -f "$ROOT/scripts/convergence-manifest.mjs" ]; then
|
|
121
|
+
node "$ROOT/scripts/convergence-manifest.mjs"
|
|
122
|
+
convergence_status=$?
|
|
123
|
+
else
|
|
124
|
+
convergence_status=0
|
|
125
|
+
fi
|
|
126
|
+
if [ "$convergence_status" -ne 0 ]; then
|
|
127
|
+
echo ""
|
|
128
|
+
echo " ⛔ pre-push gate refused this push — convergence identity is stale."
|
|
129
|
+
echo " Run: npm run convergence:write"
|
|
130
|
+
exit 1
|
|
131
|
+
fi
|
|
132
|
+
|
|
117
133
|
# ── DOCUMENT CURRENCY (ADR-056 §5) ─────────────────────────────────────────────────────────────────
|
|
118
134
|
# The tool was written and tested on 2026-07-22 and then invoked by NOTHING for five days — absent
|
|
119
135
|
# from this hook, gate.sh, gates.mjs and every workflow, while `wired-check` reported it "wired"
|
|
@@ -25,11 +25,15 @@ import os from 'node:os';
|
|
|
25
25
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
26
26
|
import { findStores, diagnose } from './memory-doctor.mjs';
|
|
27
27
|
import { learnerCwd, loadRuntimePreferences } from '../plugin/scripts/runtime-preferences.mjs';
|
|
28
|
+
import { projectDirectory } from '../plugin/scripts/project-identity.mjs';
|
|
28
29
|
|
|
29
30
|
const HOME = os.homedir();
|
|
30
31
|
// The SAME project root learn-flush.mjs computes, by the same rule — the two halves of the flush
|
|
31
32
|
// have to agree about which project they mean or they address different queues (issue #104).
|
|
32
|
-
|
|
33
|
+
// RESIDUAL of #134: RUVNET_BRAIN_PROJECT_DIR is never set by real hook dispatch on either host, so
|
|
34
|
+
// consult `projectDirectory()` (project-identity.mjs) — the CLAUDE_PROJECT_DIR-with-containment
|
|
35
|
+
// rule #85/#107 already established, reused rather than trusting the variable unconditionally.
|
|
36
|
+
const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || projectDirectory();
|
|
33
37
|
const argv = process.argv.slice(2);
|
|
34
38
|
const has = (f) => argv.includes(f);
|
|
35
39
|
|
|
@@ -159,7 +159,9 @@ export function buildPublicVerificationAggregate(leaves, reviews) {
|
|
|
159
159
|
deltaCases: delta.length,
|
|
160
160
|
deltaHits: delta.filter(({ retrievalHit }) => retrievalHit === true).length,
|
|
161
161
|
recallAt10: retrievalCases.filter(({ retrievalHit }) => retrievalHit === true).length / retrievalCases.length,
|
|
162
|
-
|
|
162
|
+
// A release with no corpus delta has no delta citations to score; the sealed
|
|
163
|
+
// retrieval plan carries noDelta=true and still requires the legacy cohort.
|
|
164
|
+
deltaCitationRate: delta.length === 0 ? 1 : delta.filter(({ retrievalHit, citationResolved }) => retrievalHit === true && citationResolved === true).length / delta.length,
|
|
163
165
|
skipped: leaves.reduce((sum, leaf) => sum + leaf.skipped + leaf.retrieval.metrics.skipped, 0),
|
|
164
166
|
unknown: leaves.reduce((sum, leaf) => sum + leaf.unknown + leaf.retrieval.metrics.unknown, 0),
|
|
165
167
|
};
|