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.
- package/README.md +12 -13
- package/bin/install.mjs +18 -8
- package/console/app.js +4 -4
- package/data/model-catalog.json +3 -3
- package/kb/verify-citation.mjs +32 -5
- package/package.json +13 -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/plugin/skills/release-proof/SKILL.md +19 -10
- package/plugin/skills/release-proof/references/receipt-contract.md +18 -8
- package/scripts/brain-stamp-resolve.mjs +22 -0
- package/scripts/brain-stamp.mjs +5 -3
- package/scripts/build-bundle.mjs +41 -4
- package/scripts/candidate-host-evidence.mjs +9 -2
- 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 +111 -0
- package/scripts/execution-preflight.mjs +18 -0
- package/scripts/git-hooks/pre-push +16 -0
- package/scripts/health-repair.mjs +5 -1
- package/scripts/host-install-matrix.mjs +247 -40
- package/scripts/integration-evidence.mjs +73 -0
- package/scripts/nightly-wrapper.sh +4 -3
- package/scripts/onboarding-console.mjs +3 -3
- package/scripts/prepublication-evidence.mjs +174 -0
- package/scripts/protected-release-invocation.mjs +32 -4
- package/scripts/public-verification-aggregate.mjs +27 -54
- package/scripts/public-verification-inputs.mjs +5 -3
- package/scripts/qa-runner.mjs +72 -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/refresh-model-catalog.mjs +32 -3
- package/scripts/release-authority.mjs +19 -7
- package/scripts/release-convergence-watchdog.mjs +10 -22
- package/scripts/release-projection.mjs +76 -32
- package/scripts/release-transaction.mjs +1 -1
- package/scripts/release.mjs +9 -15
- package/scripts/retrieval-canary.mjs +14 -6
- package/scripts/set-version.mjs +31 -0
- package/scripts/source-coverage.mjs +11 -2
- package/scripts/staged-host-verifier.mjs +2 -2
- package/scripts/verify-channels.mjs +1 -1
- 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}
|
|
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 });
|
|
@@ -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
|
|
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
|
|
16
|
-
3.
|
|
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.
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
`release-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
+
}
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|