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.
Files changed (38) hide show
  1. package/README.md +12 -13
  2. package/bin/install.mjs +10 -1
  3. package/console/app.js +4 -4
  4. package/kb/verify-citation.mjs +32 -5
  5. package/package.json +12 -9
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/mcp/managed-cli-interface.mjs +4 -2
  9. package/plugin/scripts/codex-hook-adapter.mjs +12 -7
  10. package/plugin/scripts/codex-hook-events.mjs +39 -0
  11. package/plugin/scripts/coverage-integrity.mjs +4 -4
  12. package/plugin/scripts/hook-shim.mjs +9 -7
  13. package/plugin/scripts/learn-capture.sh +20 -1
  14. package/plugin/scripts/learn-flush.mjs +7 -1
  15. package/plugin/scripts/lesson-gate.mjs +17 -2
  16. package/plugin/scripts/lesson-hooks.sh +17 -2
  17. package/plugin/scripts/session-start-core.mjs +47 -11
  18. package/scripts/brain-stamp-resolve.mjs +22 -0
  19. package/scripts/brain-stamp.mjs +5 -3
  20. package/scripts/build-bundle.mjs +41 -4
  21. package/scripts/ci/step-watchdog.mjs +86 -0
  22. package/scripts/convergence-manifest.mjs +66 -0
  23. package/scripts/distill-project.mjs +22 -4
  24. package/scripts/eval-brain.mjs +13 -3
  25. package/scripts/execution-policy.mjs +66 -0
  26. package/scripts/git-hooks/pre-push +16 -0
  27. package/scripts/health-repair.mjs +5 -1
  28. package/scripts/public-verification-aggregate.mjs +3 -1
  29. package/scripts/public-verification-inputs.mjs +5 -3
  30. package/scripts/qa-runner.mjs +71 -0
  31. package/scripts/qe/agentic-qe-4.3.mjs +195 -0
  32. package/scripts/qe/aggregate-4.3.mjs +42 -0
  33. package/scripts/record-lesson.mjs +28 -6
  34. package/scripts/release-projection.mjs +76 -32
  35. package/scripts/release.mjs +6 -11
  36. package/scripts/retrieval-canary.mjs +14 -6
  37. package/scripts/set-version.mjs +31 -0
  38. 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}. Say it plainly and offer to look (gh run list --repo ${debt.repo} --commit ${debt.ref}).`);
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. The old implementation
358
- // injected response scripts, setup questions and maintainer workflow into every host session;
359
- // Claude correctly treated that unsolicited prose as noise. Keep the diagnostic work and
360
- // durable state updates, but make user-facing output opt-in for troubleshooting only.
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 = running || 'unknown';
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
+ }
@@ -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 || 'unknown') : null,
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 ? (shaOf(cloneDir) || 'unknown') : 'unknown',
72
+ builtFromSha: resolveBuiltFromSha(store, { generations, localSha: cloneDir ? shaOf(cloneDir) : null }),
71
73
  latestRemoteSha: null,
72
74
  status: 'built',
73
75
  });
@@ -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) 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', 'ruv-gists.sources.json']) cp(suf, OUT, { asset: true });
295
- // The inventory projection consumes this registry from the assembled bundle, not the checkout.
296
- cp('public-store-classes.json', OUT, { asset: true });
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
- /** The newest snapshot this project has, or null. Used to tell the user what `--restore` would take. */
100
- function newestSnapshot() {
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
- const snapshot = newestSnapshot();
167
- if (!snapshot) die('REFUSING TO DISTILL — `memory backup` reported success but no snapshot file is present, so the undo cannot be located.');
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. ─────────────────────────────────────────────────
@@ -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 routed = !!(grounded && q.expectRepo?.length && top && q.expectRepo.includes(top.repo));
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
- const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || process.cwd();
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
- deltaCitationRate: delta.filter(({ retrievalHit, citationResolved }) => retrievalHit === true && citationResolved === true).length / delta.length,
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
  };