ruvnet-brain 4.3.1 → 4.3.8

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 (56) hide show
  1. package/README.md +12 -13
  2. package/bin/install.mjs +18 -8
  3. package/console/app.js +4 -4
  4. package/data/model-catalog.json +3 -3
  5. package/kb/verify-citation.mjs +32 -5
  6. package/package.json +13 -9
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/mcp/managed-cli-interface.mjs +4 -2
  10. package/plugin/scripts/codex-hook-adapter.mjs +12 -7
  11. package/plugin/scripts/codex-hook-events.mjs +39 -0
  12. package/plugin/scripts/coverage-integrity.mjs +4 -4
  13. package/plugin/scripts/hook-shim.mjs +9 -7
  14. package/plugin/scripts/learn-capture.sh +20 -1
  15. package/plugin/scripts/learn-flush.mjs +7 -1
  16. package/plugin/scripts/lesson-gate.mjs +17 -2
  17. package/plugin/scripts/lesson-hooks.sh +17 -2
  18. package/plugin/scripts/session-start-core.mjs +47 -11
  19. package/plugin/skills/release-proof/SKILL.md +19 -10
  20. package/plugin/skills/release-proof/references/receipt-contract.md +18 -8
  21. package/scripts/brain-stamp-resolve.mjs +22 -0
  22. package/scripts/brain-stamp.mjs +5 -3
  23. package/scripts/build-bundle.mjs +41 -4
  24. package/scripts/candidate-host-evidence.mjs +9 -2
  25. package/scripts/ci/step-watchdog.mjs +86 -0
  26. package/scripts/convergence-manifest.mjs +66 -0
  27. package/scripts/distill-project.mjs +22 -4
  28. package/scripts/eval-brain.mjs +13 -3
  29. package/scripts/execution-policy.mjs +111 -0
  30. package/scripts/execution-preflight.mjs +18 -0
  31. package/scripts/git-hooks/pre-push +16 -0
  32. package/scripts/health-repair.mjs +5 -1
  33. package/scripts/host-install-matrix.mjs +247 -40
  34. package/scripts/integration-evidence.mjs +73 -0
  35. package/scripts/nightly-wrapper.sh +4 -3
  36. package/scripts/onboarding-console.mjs +3 -3
  37. package/scripts/prepublication-evidence.mjs +174 -0
  38. package/scripts/protected-release-invocation.mjs +32 -4
  39. package/scripts/public-verification-aggregate.mjs +27 -54
  40. package/scripts/public-verification-inputs.mjs +5 -3
  41. package/scripts/qa-runner.mjs +72 -0
  42. package/scripts/qe/agentic-qe-4.3.mjs +195 -0
  43. package/scripts/qe/aggregate-4.3.mjs +42 -0
  44. package/scripts/record-lesson.mjs +28 -6
  45. package/scripts/refresh-model-catalog.mjs +32 -3
  46. package/scripts/release-authority.mjs +19 -7
  47. package/scripts/release-convergence-watchdog.mjs +10 -22
  48. package/scripts/release-projection.mjs +76 -32
  49. package/scripts/release-transaction.mjs +1 -1
  50. package/scripts/release.mjs +9 -15
  51. package/scripts/retrieval-canary.mjs +14 -6
  52. package/scripts/set-version.mjs +31 -0
  53. package/scripts/source-coverage.mjs +11 -2
  54. package/scripts/staged-host-verifier.mjs +2 -2
  55. package/scripts/verify-channels.mjs +1 -1
  56. package/scripts/wired-check.mjs +1 -0
@@ -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 });
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: release-proof
3
- description: Fail-closed exact-artifact release and deployment authority. Use before saying a release is ready, pushing a release commit, publishing npm packages, creating GitHub releases, deploying production, closing release-blocking issues, or claiming all gates are green. Requires clean immutable lineage, zero open issues, exact-SHA GitHub success, nonzero no-skip QE, packed-artifact host tests, installed Brain/RVF proof, independent graders, and post-publication byte verification.
3
+ description: Fail-closed exact-artifact release and deployment authority. Use before saying a release is ready, pushing a release commit, publishing npm packages, creating GitHub releases, deploying production, closing release-blocking issues, or claiming all gates are green. Requires clean immutable lineage, zero labeled release blockers, exact-SHA GitHub success, nonzero no-skip QE, packed-artifact host tests, installed Brain/RVF proof, and post-publication byte verification.
4
+ updated: 2026-09-04
4
5
  ---
5
6
 
6
7
  # Release Proof
@@ -12,10 +13,12 @@ green.
12
13
  ## Non-bypassable rules
13
14
 
14
15
  1. Use the exact source SHA and one packed-artifact SHA-256 everywhere.
15
- 2. Require zero open GitHub issues for RuvNet Brain. A local fix is not a closed issue.
16
- 3. Require every named GitHub workflow to complete successfully on the exact candidate SHA.
16
+ 2. Require zero open GitHub issues labeled `release-blocker`. Unlabeled backlog is not release authority; a local fix does not clear a labeled blocker.
17
+ 3. Run CI, integration, UX, and stranger qualification once in
18
+ `.github/workflows/release-candidate-preflight.yml` on `release/**`. Require its exact-SHA,
19
+ source-bound package and aggregate before that unchanged SHA reaches `main`.
17
20
  4. Reject any test/QE result with zero tests, skips, todos, unknowns, pending jobs, or failures.
18
- 5. Require two distinct independent graders scoring at least 95, each bound to the SHA and digest.
21
+ 5. When the candidate changes architecture or the retrieval oracle, require the accepted Fable 5 and GPT-5.6-Sol design-review receipts for that change. Routine releases do not manufacture caller-supplied reviewer keys; bundled public keys are integrity material, not an independent trust anchor.
19
22
  6. Install the sealed artifact into virgin Claude Code and Codex homes; test their real entrypoints.
20
23
  7. Require the source package, Claude manifest, Codex manifest, packed npm version, bundle
21
24
  `brainVersion`/`releaseTag`, and both installed host versions to identify one exact generation.
@@ -29,12 +32,18 @@ green.
29
32
 
30
33
  ## Candidate seal
31
34
 
32
- Generate the receipt from commands in the protected candidate workflow. Do not hand-author it.
33
- Dispatch `.github/workflows/protected-release.yml` only with the full candidate SHA, the exact
34
- current version, and the successful exact-SHA CI run ID whose named `release-qe` job produced
35
- `release-evidence-<sha>`. The workflow derives the artifact digest from those sealed bytes and checks
36
- every binding before creating its handoff and again at the Production boundary. Missing artifacts,
37
- pending/red jobs, malformed inputs, version splits, and byte mismatches stop before the publisher.
35
+ Generate the candidate receipt in `.github/workflows/release-candidate-preflight.yml`; do not
36
+ hand-author it. The preflight runs the long CI, integration, UX, and stranger lanes once on
37
+ `release/**`, then emits the source-bound package and aggregate as
38
+ `release-candidate-<exact SHA>`. Fast-forward that unchanged SHA to `main`.
39
+
40
+ Dispatch `.github/workflows/protected-release.yml` only after the fast-forward. It is the sole
41
+ publication controller: it proves current `origin/main` is the preflight SHA, selects the artifact
42
+ by its deterministic name rather than a caller-supplied run ID, revalidates every receipt,
43
+ payload/source binding, and digest, then signs and publishes once. The same protected run downloads
44
+ the public bytes, executes the three-OS by three-host-mode matrix, and appends `install-verified`.
45
+ It does not rerun the long preflight lanes. Conditional architecture/retrieval-oracle reviews are
46
+ candidate inputs only when the governed surfaces changed; they do not authorize publication.
38
47
  Validate it from the repository with:
39
48
 
40
49
  ```bash
@@ -1,7 +1,10 @@
1
1
  # Receipt contract
2
2
 
3
- The authority accepts schema version 1 JSON. Receipts are append-only evidence artifacts generated
4
- by protected workflows, never editable status documents.
3
+ Updated: 2026-09-04
4
+
5
+ The authority accepts only typed receipt schemas. The candidate receipts are emitted by
6
+ `release-candidate-preflight` and imported by `protected-release`; publication receipts are emitted
7
+ inside that protected run. All are append-only evidence artifacts, never editable status documents.
5
8
 
6
9
  ## Candidate receipt
7
10
 
@@ -11,17 +14,20 @@ Required bindings:
11
14
  - `version`, `tag`, and exact-equal `sourceVersions.package`, `sourceVersions.claudePlugin`, and
12
15
  `sourceVersions.codexPlugin`
13
16
  - `artifact.path`, `artifact.sha256`, `artifact.sourceSha`
17
+ - artifact name exactly `release-candidate-<sha>`; selection is by name and exact source SHA, never
18
+ by a caller-supplied workflow run ID
14
19
  - exact-equal `artifact.version`, `artifact.bundle.brainVersion`, and
15
20
  `artifact.bundle.releaseTag`
16
21
  - exact-SHA release-vector verdict with zero unknown/skipped
17
22
  - aggregate tests with nonzero total, all passed, zero failed/skipped/todo
18
23
  - fresh coverage floor and zero critical/high security findings
19
- - zero open GitHub issues
24
+ - zero open GitHub issues labeled `release-blocker`
20
25
  - required GitHub workflow results on the same SHA
21
26
  - virgin-home Claude and Codex results on the same artifact digest and exact candidate version
22
27
  - installed Brain self-RVF plus narrow, broad, and concurrent cited search timings
23
28
  - nonzero Agentic QE totals with zero failed/skipped
24
- - two distinct independent grader receipts at 95 or higher, bound to SHA and digest
29
+ - when architecture or retrieval-oracle surfaces changed, accepted Fable 5 and GPT-5.6-Sol review
30
+ receipts bound to that change; no per-release caller-supplied key can create independent authority
25
31
 
26
32
  ## Publication receipt
27
33
 
@@ -35,10 +41,14 @@ Required bindings:
35
41
  - installed Brain self-RVF and broad search within 80 percent of deadline
36
42
  - successful exact-SHA `published-surface-probe`
37
43
 
44
+ Before provider mutation, `protected-release` revalidates the imported candidate receipt, package
45
+ payload, source binding, and digest against current `origin/main`. It consumes the long-lane proof;
46
+ it does not rerun CI, integration, UX, or stranger qualification.
47
+
38
48
  ## Failure semantics
39
49
 
40
- Any missing field, split version identity, malformed digest, mismatched SHA, dirty tree, open issue, absent/pending/red
50
+ Any missing field, split version identity, malformed digest, mismatched SHA, dirty tree, open labeled release blocker, absent/pending/red
41
51
  workflow, skipped/todo/zero-test result, missing RVF store, uncited search, deadline-margin breach,
42
- low/missing grader, or public byte mismatch is `FAIL`. There is no warning state and no score
43
- average. The authority never publishes; publication belongs to the protected workflow after the
44
- candidate seal.
52
+ missing required change-triggered design review, or public byte mismatch is `FAIL`. There is no
53
+ warning state and no score average. Preflight owns candidate qualification; one `protected-release`
54
+ run owns import revalidation, publication, public verification, and the terminal receipt.
@@ -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
  }
@@ -25,7 +25,12 @@ if (result.verdict !== 'PASS') throw new Error(`candidate host matrix failed: ${
25
25
  const modeNames = { claude: 'claude-only', codex: 'codex-only', dual: 'dual-host' };
26
26
  const leaves = Object.entries(modeNames).map(([mode, name]) => {
27
27
  const fixture = result.fixtures?.[mode];
28
- if (fixture?.status !== 'PASS' || fixture?.doctorExit !== 0) throw new Error(`${name} did not produce a clean doctor receipt`);
28
+ const grounding = fixture?.grounding;
29
+ const grounded = grounding && ['repo', 'path', 'file', 'storedPath']
30
+ .every((field) => typeof grounding[field] === 'string' && grounding[field].trim());
31
+ if (fixture?.status !== 'PASS' || fixture?.process?.status !== 0 || !grounded) {
32
+ throw new Error(`${name} did not produce a clean installed-search grounding receipt`);
33
+ }
29
34
  return {
30
35
  name,
31
36
  sha: manifest.candidateSha,
@@ -35,7 +40,9 @@ const leaves = Object.entries(modeNames).map(([mode, name]) => {
35
40
  verdict: 'PASS',
36
41
  source: 'candidate-host-evidence',
37
42
  mode,
38
- doctorExit: fixture.doctorExit,
43
+ functionalSearch: true,
44
+ searchExit: fixture.process.status,
45
+ grounding,
39
46
  artifactSha256: sha256(packagePath),
40
47
  };
41
48
  });
@@ -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,