ruvnet-brain 4.3.8 → 4.3.10
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 +26 -9
- package/bin/install.mjs +646 -366
- package/bin/nightly-refresh.mjs +115 -0
- package/kb/brain-profile.mjs +118 -32
- package/kb/lifecycle-evidence-retention.mjs +237 -0
- package/kb/refresh-run.mjs +367 -0
- package/kb/retrieval-result.mjs +39 -0
- package/kb/update-storage-transaction.mjs +439 -0
- package/package.json +11 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -165
- package/plugin/hooks/hook-contracts.json +4 -65
- package/plugin/hooks/hooks.json +2 -208
- package/plugin/scripts/adr-currency-gate.mjs +29 -19
- package/plugin/scripts/capability-registry.mjs +10 -83
- package/plugin/scripts/codex-hook-adapter.mjs +62 -9
- package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
- package/plugin/scripts/continuation-gate.mjs +30 -28
- package/plugin/scripts/continuation-objective.mjs +34 -0
- package/plugin/scripts/development-maintenance.mjs +49 -0
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/learn-flush.mjs +6 -1
- package/plugin/scripts/lesson-gate.mjs +5 -2
- package/plugin/scripts/lesson-presentation.mjs +5 -0
- package/plugin/scripts/md-stamp.mjs +109 -6
- package/plugin/scripts/memory-doctor.mjs +3 -7
- package/plugin/scripts/nightly-controller.mjs +9 -27
- package/plugin/scripts/nightly-scheduler.mjs +472 -0
- package/plugin/scripts/project-progression-contract.mjs +4 -4
- package/plugin/scripts/project-progression-store.mjs +35 -6
- package/plugin/scripts/ruflo-bin.mjs +20 -0
- package/plugin/scripts/session-snapshot-contract.mjs +6 -1
- package/plugin/scripts/version-bump-gate.sh +14 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
- package/scripts/build-bundle.mjs +8 -0
- package/scripts/build-primer.mjs +2 -3
- package/scripts/candidate-host-evidence.mjs +59 -41
- package/scripts/ci/build-fixture-kb.mjs +59 -2
- package/scripts/ci/mutate-hook-timeout.mjs +24 -47
- package/scripts/ci/stranger-scenario.mjs +13 -28
- package/scripts/claims-verify.mjs +219 -21
- package/scripts/console-engine.mjs +4 -18
- package/scripts/console-runtime-identity.mjs +6 -0
- package/scripts/development-maintenance.mjs +47 -0
- package/scripts/development-push-check.mjs +35 -0
- package/scripts/distill-project.mjs +24 -47
- package/scripts/doc-currency.mjs +91 -19
- package/scripts/git-hooks/pre-push +4 -162
- package/scripts/health-repair.mjs +22 -4
- package/scripts/hook-retirement-check.mjs +22 -0
- package/scripts/host-install-matrix.mjs +125 -61
- package/scripts/integration-evidence.mjs +11 -6
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/nightly-two-run-proof.mjs +415 -0
- package/scripts/nightly-watchdog.mjs +17 -2
- package/scripts/npm-invocation.mjs +20 -0
- package/scripts/prepublication-evidence.mjs +42 -6
- package/scripts/primer-grounding.mjs +20 -0
- package/scripts/product-integrity-contract.mjs +22 -2
- package/scripts/public-verification-abandon.mjs +101 -0
- package/scripts/public-verification-aggregate.mjs +53 -19
- package/scripts/public-verification-finalizer.mjs +8 -2
- package/scripts/public-verification-lane.mjs +108 -8
- package/scripts/publication-receipt.mjs +173 -66
- package/scripts/published-surface-probe.mjs +10 -2
- package/scripts/qa-contract.mjs +57 -0
- package/scripts/qa-lanes.mjs +40 -0
- package/scripts/qa-runner.mjs +54 -64
- package/scripts/qe/ux-suite.mjs +10 -34
- package/scripts/qualified-candidate-check.mjs +124 -0
- package/scripts/release-abort-stale.mjs +2 -2
- package/scripts/release-projection.mjs +20 -1
- package/scripts/release-publication-handoff.mjs +101 -0
- package/scripts/release-qualification-contract.mjs +87 -0
- package/scripts/release-qualification.mjs +135 -0
- package/scripts/release-transaction.mjs +81 -1
- package/scripts/release.mjs +10 -1
- package/scripts/remedy-registry.mjs +6 -13
- package/scripts/retrieval-canary.mjs +91 -18
- package/scripts/selfcheck.mjs +12 -4
- package/scripts/snapshot-freshness.mjs +58 -0
- package/scripts/stack-sync.mjs +41 -43
- package/scripts/staged-host-verifier.mjs +63 -14
- package/scripts/wired-check.mjs +31 -2
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Usage: node scripts/claims-verify.mjs (or: npm run claims:verify) — READ-ONLY gate
|
|
11
11
|
// node scripts/claims-verify.mjs --fix (or: npm run claims:fix) — regenerate the surfaces
|
|
12
|
-
// Exit:
|
|
12
|
+
// Exit: 0 = completed diagnostic; 1 = failed claim; --strict additionally exits 4 on UNKNOWN.
|
|
13
13
|
//
|
|
14
14
|
// Verify functions take artifact paths as parameters (repo paths as defaults) so tests can
|
|
15
15
|
// point them at tampered copies. Main-guarded like scripts/eval-brain.mjs so tests can import
|
|
@@ -18,8 +18,14 @@
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import readline from 'node:readline';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import crypto from 'node:crypto';
|
|
21
23
|
import { spawnSync } from 'node:child_process';
|
|
22
24
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
25
|
+
import { storeRoot } from '../kb/store-root.mjs';
|
|
26
|
+
import { validateCoverageDirectory, sha256File } from './coverage-integrity.mjs';
|
|
27
|
+
import { verifyPayload, verifyPayloadMembers, payloadIdFor } from './release-payload.mjs';
|
|
28
|
+
import { extractZip } from '../kb/zip-extract.mjs';
|
|
23
29
|
|
|
24
30
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
31
|
|
|
@@ -110,10 +116,12 @@ export function verifyHeldOutStrata(file = path.join(ROOT, 'evals', 'held-out.js
|
|
|
110
116
|
// (with the 51.33 figure alongside it). ~56× = $15 / $0.267 ≈ 56.2. We re-find both corpus
|
|
111
117
|
// strings with a plain streaming grep (first match wins) and re-do the arithmetic. The passages
|
|
112
118
|
// file ships with the 512MB brain, which CI does not have — absent file is a LOUD SKIP, never
|
|
113
|
-
// a silent pass.
|
|
114
|
-
|
|
119
|
+
// a silent pass. Default reads the canonical store root (kb/store-root.mjs), not <repo>/kb — that
|
|
120
|
+
// directory is a gitignored build workspace the installed brain never lands in (see kbDir on the
|
|
121
|
+
// census functions below for the same fix).
|
|
122
|
+
export async function verifyCheaperFactor(file = path.join(storeRoot(), 'metaharness.passages.jsonl')) {
|
|
115
123
|
if (!fs.existsSync(file)) {
|
|
116
|
-
return skip(`brain not installed — ${
|
|
124
|
+
return skip(`brain not installed — ${file} absent, cannot re-derive ~56× from corpus (runs on machines with the brain)`);
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
const needles = ['$0.267', '51.33'];
|
|
@@ -368,11 +376,21 @@ export async function verifyCoverageBadge(
|
|
|
368
376
|
// the fence). Every user-facing surface that quotes the number must quote THIS number — the count
|
|
369
377
|
// sat at a stale 128,994 across 10+ surfaces after a rebuild moved it (2026-07-10 gremlin hunt).
|
|
370
378
|
// The sidecars ship with the brain, which bare CI does not have — absent kb dir is a LOUD SKIP.
|
|
379
|
+
//
|
|
380
|
+
// kbDir (where the idmap sidecars live) and the private-stores fence are two DIFFERENT facts, same
|
|
381
|
+
// conflation kb/store-root.mjs's header names: the fence is a small policy file this repo commits at
|
|
382
|
+
// a fixed path (scripts/build-bundle.mjs reads the identical path), while the sidecars ship with the
|
|
383
|
+
// installed brain at the canonical store root (kb/store-root.mjs's storeRoot(), not <repo>/kb — that
|
|
384
|
+
// directory is a gitignored build workspace the installed brain never lands in outside the release
|
|
385
|
+
// build's own KB_DIR override, which storeRoot() already honors). Defaulting kbDir to
|
|
386
|
+
// `path.join(ROOT, 'kb')` — the exact anti-pattern kb/forge-currency.mjs's brainKnownSet() carried
|
|
387
|
+
// until PR #222 — made this check silently SKIP on any machine with a real installed brain outside
|
|
388
|
+
// the repo checkout, never re-deriving the count it exists to police.
|
|
371
389
|
export const CHUNK_SURFACES = ['README.md', 'explainer/index.html', 'explainer/llms.txt', 'explainer/llms-full.txt'];
|
|
390
|
+
export const PRIVATE_STORES_FILE = path.join(ROOT, 'kb', 'PRIVATE-STORES.json');
|
|
372
391
|
|
|
373
|
-
export function computePublicChunkTotal(kbDir =
|
|
374
|
-
const
|
|
375
|
-
const priv = new Set(fs.existsSync(privFile) ? JSON.parse(fs.readFileSync(privFile, 'utf8')).privateStores : []);
|
|
392
|
+
export function computePublicChunkTotal(kbDir = storeRoot(), privateStoresFile = PRIVATE_STORES_FILE) {
|
|
393
|
+
const priv = new Set(fs.existsSync(privateStoresFile) ? JSON.parse(fs.readFileSync(privateStoresFile, 'utf8')).privateStores : []);
|
|
376
394
|
let total = 0, stores = 0;
|
|
377
395
|
for (const f of fs.readdirSync(kbDir)) {
|
|
378
396
|
const m = f.match(/^(.+)\.big\.rvf\.idmap\.json$/);
|
|
@@ -384,13 +402,13 @@ export function computePublicChunkTotal(kbDir = path.join(ROOT, 'kb')) {
|
|
|
384
402
|
}
|
|
385
403
|
|
|
386
404
|
/** Every built store on disk, private ones included — what "N built stores incl. private" advertises. */
|
|
387
|
-
export function countBuiltStores(kbDir =
|
|
405
|
+
export function countBuiltStores(kbDir = storeRoot()) {
|
|
388
406
|
return fs.readdirSync(kbDir).filter((f) => /\.big\.rvf\.idmap\.json$/.test(f)).length;
|
|
389
407
|
}
|
|
390
408
|
|
|
391
409
|
/** The three brain census numbers every public surface quotes, all from the same artifacts. */
|
|
392
|
-
export function brainCensus(kbDir =
|
|
393
|
-
const { total, stores } = computePublicChunkTotal(kbDir);
|
|
410
|
+
export function brainCensus(kbDir = storeRoot(), privateStoresFile = PRIVATE_STORES_FILE) {
|
|
411
|
+
const { total, stores } = computePublicChunkTotal(kbDir, privateStoresFile);
|
|
394
412
|
return { chunks: total, publicStores: stores, builtStores: countBuiltStores(kbDir) };
|
|
395
413
|
}
|
|
396
414
|
|
|
@@ -409,11 +427,11 @@ export const SURFACE_CLAIM_RULES = [
|
|
|
409
427
|
|
|
410
428
|
const fmtNum = (n, fmt) => (fmt === 'comma' ? n.toLocaleString('en-US') : String(n));
|
|
411
429
|
|
|
412
|
-
export function verifyChunkCountSurfaces(kbDir =
|
|
430
|
+
export function verifyChunkCountSurfaces(kbDir = storeRoot(), surfaces = CHUNK_SURFACES, root = ROOT, privateStoresFile = PRIVATE_STORES_FILE) {
|
|
413
431
|
if (!fs.existsSync(kbDir) || !fs.readdirSync(kbDir).some((f) => f.endsWith('.big.rvf.idmap.json'))) {
|
|
414
|
-
return skip(
|
|
432
|
+
return skip(`brain not installed — ${path.join(kbDir, '*.big.rvf.idmap.json')} absent, cannot re-derive the chunk count (runs on machines with the brain)`);
|
|
415
433
|
}
|
|
416
|
-
const census = brainCensus(kbDir);
|
|
434
|
+
const census = brainCensus(kbDir, privateStoresFile);
|
|
417
435
|
const want = census.chunks.toLocaleString('en-US');
|
|
418
436
|
|
|
419
437
|
const problems = [];
|
|
@@ -490,7 +508,8 @@ function restampRule(s, rule, value) {
|
|
|
490
508
|
|
|
491
509
|
export async function applyFix({
|
|
492
510
|
root = ROOT,
|
|
493
|
-
kbDir =
|
|
511
|
+
kbDir = storeRoot(),
|
|
512
|
+
privateStoresFile = PRIVATE_STORES_FILE,
|
|
494
513
|
surfaces = CHUNK_SURFACES,
|
|
495
514
|
readmeFile = path.join(root, 'README.md'),
|
|
496
515
|
summaryFile = path.join(root, 'coverage', 'coverage-summary.json'),
|
|
@@ -512,7 +531,7 @@ export async function applyFix({
|
|
|
512
531
|
if (!haveBrain) {
|
|
513
532
|
report.notes.push('brain not installed — chunk/store counts left untouched (nothing to re-derive them from)');
|
|
514
533
|
} else {
|
|
515
|
-
const census = brainCensus(kbDir);
|
|
534
|
+
const census = brainCensus(kbDir, privateStoresFile);
|
|
516
535
|
report.census = census;
|
|
517
536
|
for (const rel of surfaces) {
|
|
518
537
|
const { p, s } = readSurface(rel);
|
|
@@ -615,59 +634,214 @@ export async function verifyLearningReplay() {
|
|
|
615
634
|
// ── the ledger ──────────────────────────────────────────────────────────────────────────────────
|
|
616
635
|
export const ledger = [
|
|
617
636
|
{
|
|
637
|
+
id: 'baseline', scope: 'source',
|
|
618
638
|
claim: 'grounded 12/12 → n=120 baseline',
|
|
619
639
|
source: 'evals/baseline.json',
|
|
620
640
|
verify: verifyBaseline,
|
|
621
641
|
},
|
|
622
642
|
{
|
|
623
643
|
claim: 'held-out set is frozen at 120 questions across 5 strata',
|
|
644
|
+
id: 'held-out', scope: 'source',
|
|
624
645
|
source: 'evals/held-out.json',
|
|
625
646
|
verify: verifyHeldOutStrata,
|
|
626
647
|
},
|
|
627
648
|
{
|
|
628
649
|
claim: '~56× cheaper (explainer + hook)',
|
|
650
|
+
id: 'cost-factor', scope: 'runtime',
|
|
629
651
|
source: 'kb/metaharness.passages.jsonl',
|
|
630
652
|
verify: verifyCheaperFactor,
|
|
631
653
|
},
|
|
632
654
|
{
|
|
633
655
|
claim: 'coverage badge % re-derives from the real coverage run (ALL source)',
|
|
656
|
+
id: 'coverage', scope: 'source',
|
|
634
657
|
source: 'README.md + coverage/coverage-summary.json + vitest.config.mjs',
|
|
635
658
|
verify: verifyCoverageBadge,
|
|
636
659
|
},
|
|
637
660
|
{
|
|
638
661
|
claim: 'version surfaces agree',
|
|
662
|
+
id: 'version', scope: 'source',
|
|
639
663
|
source: 'scripts/sync-version.mjs --check',
|
|
640
664
|
verify: verifyVersionSurfaces,
|
|
641
665
|
},
|
|
642
666
|
{
|
|
643
667
|
claim: 'advertised source-chunk count regenerates from the brain (all surfaces agree)',
|
|
668
|
+
id: 'chunk-count', scope: 'runtime',
|
|
644
669
|
source: 'kb/*.big.rvf.idmap.json + kb/PRIVATE-STORES.json',
|
|
645
670
|
verify: verifyChunkCountSurfaces,
|
|
646
671
|
},
|
|
647
672
|
// The invariant vector rides the same runner so it is printed in the same table and obeys the same
|
|
648
673
|
// "a skip is never a silent pass" rule. The vector is ALSO exported separately (`invariants`) so a
|
|
649
674
|
// future release gate can consume the named states without reading prose out of a markdown row.
|
|
650
|
-
...invariants.map((iv) => ({ claim: `invariant ${iv.name}: ${iv.what}`, source: iv.source, verify: iv.verify })),
|
|
675
|
+
...invariants.map((iv) => ({ id: iv.name, scope: 'runtime', claim: `invariant ${iv.name}: ${iv.what}`, source: iv.source, verify: iv.verify })),
|
|
651
676
|
];
|
|
652
677
|
|
|
678
|
+
export function claimsScope(scope = 'all') {
|
|
679
|
+
if (!['all', 'source', 'runtime'].includes(scope)) throw new Error(`unknown claims scope: ${scope}`);
|
|
680
|
+
const entries = ledger.filter((entry) => scope === 'all' || entry.scope === scope);
|
|
681
|
+
return { scope, complete: scope === 'all', entries, omitted: ledger.filter((entry) => !entries.includes(entry)).map(({ id }) => id) };
|
|
682
|
+
}
|
|
683
|
+
|
|
653
684
|
// ── runner ──────────────────────────────────────────────────────────────────────────────────────
|
|
654
685
|
const cell = (s) => String(s).replaceAll('|', '\\|');
|
|
655
686
|
|
|
656
|
-
|
|
687
|
+
async function qualifyCensusArtifacts({ candidateKb, candidateSha, candidateVersion, candidateRoot,
|
|
688
|
+
payloadManifest, payloadSignature, payloadId, qualificationMode, publicKey }, checked, members, census) {
|
|
689
|
+
if (!['signed', 'staged'].includes(qualificationMode)) throw new Error('runtime qualification mode must be signed or staged');
|
|
690
|
+
const required = [payloadManifest, payloadId, candidateRoot, ...(qualificationMode === 'signed' ? [payloadSignature] : [])];
|
|
691
|
+
if (required.some((value) => !value) || !fs.existsSync(payloadManifest)
|
|
692
|
+
|| (qualificationMode === 'signed' && !fs.existsSync(payloadSignature))) return null;
|
|
693
|
+
const regularFile = (file) => {
|
|
694
|
+
const stat = fs.lstatSync(file);
|
|
695
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('runtime artifact must be a regular non-symlink file');
|
|
696
|
+
return file;
|
|
697
|
+
};
|
|
698
|
+
const manifest = JSON.parse(fs.readFileSync(regularFile(payloadManifest), 'utf8'));
|
|
699
|
+
if (manifest.candidateSha !== candidateSha || manifest.version !== candidateVersion || manifest.tag !== `v${candidateVersion}`
|
|
700
|
+
|| payloadIdFor(manifest) !== payloadId) throw new Error('runtime payload identity differs from the explicit candidate');
|
|
701
|
+
const root = path.dirname(fs.realpathSync(payloadManifest));
|
|
702
|
+
const byRole = (role) => {
|
|
703
|
+
const matches = manifest.members?.filter((member) => member.role === role) || [];
|
|
704
|
+
if (matches.length !== 1) throw new Error(`runtime payload requires exactly one ${role} member`);
|
|
705
|
+
return matches[0];
|
|
706
|
+
};
|
|
707
|
+
const npm = byRole('npm'), bundle = byRole('bundle');
|
|
708
|
+
for (const member of manifest.members) {
|
|
709
|
+
if (!member.name || path.basename(member.name) !== member.name) throw new Error('unsafe runtime payload member name');
|
|
710
|
+
regularFile(path.join(root, member.name));
|
|
711
|
+
}
|
|
712
|
+
const verify = () => qualificationMode === 'signed'
|
|
713
|
+
? verifyPayload({ manifest, root, signature: fs.readFileSync(regularFile(payloadSignature), 'utf8'),
|
|
714
|
+
publicKey: publicKey || crypto.createPublicKey(fs.readFileSync(path.join(ROOT, 'keys/ruvnet-brain-signing.pub.pem'))) })
|
|
715
|
+
: verifyPayloadMembers({ manifest, root });
|
|
716
|
+
verify();
|
|
717
|
+
// The source validator is shared with release recovery. Read committed surface bytes below:
|
|
718
|
+
// untracked lookalikes must never supply the advertising proof.
|
|
719
|
+
const { validateCandidateSource } = await import('./publication-receipt.mjs');
|
|
720
|
+
validateCandidateSource(candidateRoot, { sha: candidateSha, version: candidateVersion });
|
|
721
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'claims-bound-artifacts-'));
|
|
722
|
+
try {
|
|
723
|
+
const extracted = await extractZip(path.join(root, bundle.name), temp);
|
|
724
|
+
const coverageFiles = extracted.entryNames.filter((name) => path.posix.basename(name) === 'COVERAGE.json');
|
|
725
|
+
if (coverageFiles.length !== 1) throw new Error('runtime bundle must contain exactly one release coverage projection');
|
|
726
|
+
const archiveRoot = path.dirname(path.join(temp, coverageFiles[0]));
|
|
727
|
+
const bound = validateCoverageDirectory(archiveRoot, { expectedVersion: candidateVersion,
|
|
728
|
+
expectedSourceSnapshot: candidateSha, requireCompleteProfile: true });
|
|
729
|
+
if (!bound.valid) throw new Error(`runtime archive projection rejected: ${bound.failures.join('; ')}`);
|
|
730
|
+
if (bound.coverageSha256 !== checked.coverageSha256) throw new Error('runtime projection differs from the bound bundle');
|
|
731
|
+
const sidecars = [];
|
|
732
|
+
let boundChunks = 0;
|
|
733
|
+
for (const name of members) {
|
|
734
|
+
const installed = regularFile(path.join(candidateKb, name));
|
|
735
|
+
const archived = regularFile(path.join(archiveRoot, name));
|
|
736
|
+
const bytes = fs.readFileSync(archived);
|
|
737
|
+
const hash = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
738
|
+
const { idToLabel } = JSON.parse(bytes);
|
|
739
|
+
if (!idToLabel || typeof idToLabel !== 'object' || Array.isArray(idToLabel)) throw new Error('bound public sidecar has invalid idToLabel');
|
|
740
|
+
boundChunks += Object.keys(idToLabel).length;
|
|
741
|
+
if (sha256File(installed) !== hash) throw new Error(`runtime public sidecar differs from the bound bundle: ${name}`);
|
|
742
|
+
sidecars.push({ file: name, sha256: hash, bytes: bytes.length });
|
|
743
|
+
}
|
|
744
|
+
if (boundChunks !== census.chunks) throw new Error('runtime public count changed before artifact binding');
|
|
745
|
+
const expected = { ...census, builtStores: census.publicStores };
|
|
746
|
+
const problems = [];
|
|
747
|
+
for (const file of CHUNK_SURFACES) {
|
|
748
|
+
const result = spawnSync('git', ['show', `${candidateSha}:${file}`], { cwd: candidateRoot, encoding: 'utf8' });
|
|
749
|
+
if (result.status !== 0) throw new Error(`candidate source lacks tracked claim surface: ${file}`);
|
|
750
|
+
const text = result.stdout;
|
|
751
|
+
if (!text.includes(census.chunks.toLocaleString('en-US'))) problems.push(`${file}: candidate chunk count absent`);
|
|
752
|
+
for (const rule of SURFACE_CLAIM_RULES) for (const match of text.matchAll(rule.re)) for (const group of rule.groups) {
|
|
753
|
+
if (Number(match[group.i].replace(/,/g, '')) !== expected[rule.key]) problems.push(`${file}: candidate ${rule.name} differs`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (problems.length) throw new Error(problems.join('; '));
|
|
757
|
+
validateCandidateSource(candidateRoot, { sha: candidateSha, version: candidateVersion });
|
|
758
|
+
for (const sidecar of sidecars) {
|
|
759
|
+
if (sha256File(regularFile(path.join(candidateKb, sidecar.file))) !== sidecar.sha256) {
|
|
760
|
+
throw new Error('runtime public sidecar changed during qualification');
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
verify();
|
|
764
|
+
return { payloadId, signatureVerified: qualificationMode === 'signed', packageSha256: npm.sha256,
|
|
765
|
+
bundleSha256: bundle.sha256, sidecarsVerified: sidecars.length, sidecars,
|
|
766
|
+
claimSourceSha: candidateSha, surfacesVerified: CHUNK_SURFACES.length, publicChunks: boundChunks };
|
|
767
|
+
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Ambient counts are diagnostic. Qualification requires explicit candidate artifacts and source.
|
|
771
|
+
export async function verifyRuntimeCensus({ candidateKb = null, candidateSha = null,
|
|
772
|
+
candidateVersion = null, diagnosticKb = storeRoot(), privateStoresFile = PRIVATE_STORES_FILE,
|
|
773
|
+
candidateRoot = null, payloadManifest = null, payloadSignature = null, payloadId = null,
|
|
774
|
+
qualificationMode = 'signed', publicKey = null } = {}) {
|
|
775
|
+
const diagnostics = { ambient: { root: path.resolve(diagnosticKb), diagnostic: true } };
|
|
776
|
+
try { diagnostics.ambient.census = brainCensus(diagnosticKb, privateStoresFile); }
|
|
777
|
+
catch (error) { diagnostics.ambient.error = error.message; }
|
|
778
|
+
const provenance = { projectionVerified: false, candidate: {
|
|
779
|
+
root: candidateKb ? path.resolve(candidateKb) : null, sourceSha: candidateSha, version: candidateVersion,
|
|
780
|
+
} };
|
|
781
|
+
const details = { diagnostic: true, qualification: 'UNKNOWN', diagnostics, provenance,
|
|
782
|
+
untested: ['signed package/bundle-to-sidecar binding'] };
|
|
783
|
+
if (!candidateKb || !candidateSha || !candidateVersion) {
|
|
784
|
+
return { ...skip('Runtime qualification requires explicit candidate KB, source SHA, and version; ambient census is diagnostic only.'), ...details };
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
const checked = validateCoverageDirectory(candidateKb, { expectedVersion: candidateVersion,
|
|
788
|
+
expectedSourceSnapshot: candidateSha, requireCompleteProfile: true });
|
|
789
|
+
if (!checked.valid) return { ...fail(`Candidate projection rejected: ${checked.failures.join('; ')}`), ...details };
|
|
790
|
+
provenance.projectionVerified = true;
|
|
791
|
+
provenance.candidate.coverageSha256 = checked.coverageSha256;
|
|
792
|
+
provenance.candidate.publicGenerationLedgerSha256 = sha256File(path.join(candidateKb, 'PUBLIC-RVF-GENERATIONS.json'));
|
|
793
|
+
const members = new Set(checked.publicInventory.publicStores.map((store) => `${store}.big.rvf.idmap.json`));
|
|
794
|
+
let chunks = 0;
|
|
795
|
+
for (const name of members) {
|
|
796
|
+
const file = path.join(candidateKb, name);
|
|
797
|
+
const stat = fs.lstatSync(file);
|
|
798
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${name} is not a regular census sidecar`);
|
|
799
|
+
const { idToLabel } = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
800
|
+
if (!idToLabel || typeof idToLabel !== 'object' || Array.isArray(idToLabel)) throw new Error(`${name} has invalid idToLabel`);
|
|
801
|
+
chunks += Object.keys(idToLabel).length;
|
|
802
|
+
}
|
|
803
|
+
const extraSidecars = fs.readdirSync(candidateKb)
|
|
804
|
+
.filter((name) => name.endsWith('.big.rvf.idmap.json') && !members.has(name)).length;
|
|
805
|
+
diagnostics.candidate = { chunks, publicStores: members.size, extraSidecars };
|
|
806
|
+
const artifacts = await qualifyCensusArtifacts({ candidateKb, candidateSha, candidateVersion, candidateRoot,
|
|
807
|
+
payloadManifest, payloadSignature, payloadId, qualificationMode, publicKey }, checked, members, diagnostics.candidate);
|
|
808
|
+
if (artifacts) {
|
|
809
|
+
provenance.artifacts = artifacts;
|
|
810
|
+
return { ...pass(`Exact ${qualificationMode} candidate public census and ${CHUNK_SURFACES.length} committed claim surfaces agree.`),
|
|
811
|
+
...details, diagnostic: false, qualification: `${qualificationMode}-candidate`,
|
|
812
|
+
untested: [qualificationMode === 'staged' ? 'signed public publication proof' : 'public installation and channel convergence'] };
|
|
813
|
+
}
|
|
814
|
+
return { ...skip('Public projection verified; census remains diagnostic: signed package/bundle-to-sidecar binding is untested.'), ...details };
|
|
815
|
+
} catch (error) {
|
|
816
|
+
return { ...fail(`Candidate census rejected: ${error.message}`), ...details };
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export async function runLedger(entries = ledger, { strict = false, runtimeCensus = {} } = {}) {
|
|
657
821
|
const rows = [];
|
|
658
822
|
for (const entry of entries) {
|
|
659
823
|
let result;
|
|
660
824
|
try {
|
|
661
|
-
result =
|
|
825
|
+
result = entry.id === 'chunk-count' && strict
|
|
826
|
+
? await verifyRuntimeCensus(runtimeCensus) : await entry.verify();
|
|
827
|
+
if (entry.id === 'chunk-count' && !strict) result = { ...result, diagnostic: true,
|
|
828
|
+
qualification: 'UNKNOWN', provenance: { mode: 'ambient-diagnostic', root: storeRoot() } };
|
|
662
829
|
} catch (e) {
|
|
663
830
|
result = fail(`verify() threw: ${e.message}`);
|
|
664
831
|
}
|
|
665
|
-
rows.push({ claim: entry.claim, source: entry.source, ...result });
|
|
832
|
+
rows.push({ id: entry.id, claim: entry.claim, source: entry.source, ...result });
|
|
666
833
|
}
|
|
667
834
|
return rows;
|
|
668
835
|
}
|
|
669
836
|
|
|
837
|
+
export function claimsVerdict(rows) {
|
|
838
|
+
if (rows.some(({ status }) => status === FAIL)) return FAIL;
|
|
839
|
+
return rows.length && rows.every(({ status }) => status === PASS) ? PASS : 'UNKNOWN';
|
|
840
|
+
}
|
|
841
|
+
|
|
670
842
|
async function main() {
|
|
843
|
+
const strict = process.argv.includes('--strict');
|
|
844
|
+
if (strict && process.argv.includes('--fix')) throw new Error('--strict qualification cannot rewrite advertising from an ambient census');
|
|
671
845
|
// --fix is the ONLY writing path; the gate itself never mutates a surface.
|
|
672
846
|
if (process.argv.includes('--fix')) {
|
|
673
847
|
const report = await applyFix({ write: true });
|
|
@@ -686,7 +860,29 @@ async function main() {
|
|
|
686
860
|
console.log('');
|
|
687
861
|
}
|
|
688
862
|
|
|
689
|
-
const
|
|
863
|
+
const scopeIndex = process.argv.indexOf('--scope');
|
|
864
|
+
if (scopeIndex >= 0 && !process.argv[scopeIndex + 1]) throw new Error('--scope requires all, source, or runtime');
|
|
865
|
+
const selected = claimsScope(scopeIndex < 0 ? 'all' : process.argv[scopeIndex + 1]);
|
|
866
|
+
const option = (name) => {
|
|
867
|
+
const index = process.argv.indexOf(name);
|
|
868
|
+
if (index < 0) return null;
|
|
869
|
+
const value = process.argv[index + 1];
|
|
870
|
+
if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`);
|
|
871
|
+
return value;
|
|
872
|
+
};
|
|
873
|
+
const rows = await runLedger(selected.entries, { strict, runtimeCensus: {
|
|
874
|
+
candidateKb: option('--candidate-kb'), candidateSha: option('--candidate-sha'),
|
|
875
|
+
candidateVersion: option('--candidate-version'),
|
|
876
|
+
candidateRoot: option('--candidate-root'), payloadManifest: option('--payload-manifest'),
|
|
877
|
+
payloadSignature: option('--payload-signature'), payloadId: option('--payload-id'),
|
|
878
|
+
qualificationMode: option('--qualification-mode') || 'signed',
|
|
879
|
+
} });
|
|
880
|
+
const scopeVerdict = claimsVerdict(rows);
|
|
881
|
+
const receipt = { schema: 'ruvnet-brain.claims', scope: selected.scope, complete: selected.complete,
|
|
882
|
+
verdict: scopeVerdict === FAIL || selected.complete ? scopeVerdict : 'UNKNOWN', scopeVerdict, omitted: selected.omitted, rows };
|
|
883
|
+
console.log(JSON.stringify(receipt));
|
|
884
|
+
const reportIndex = process.argv.indexOf('--report');
|
|
885
|
+
if (reportIndex >= 0) fs.writeFileSync(process.argv[reportIndex + 1], JSON.stringify(receipt, null, 2), { flag: 'wx' });
|
|
690
886
|
|
|
691
887
|
console.log('## Claims ledger — every advertised number must regenerate from an artifact\n');
|
|
692
888
|
console.log('| claim | status | evidence |');
|
|
@@ -702,7 +898,9 @@ async function main() {
|
|
|
702
898
|
console.error(`\nclaims:verify FAILED — ${failed.length} claim(s) no longer regenerate from their artifacts.`);
|
|
703
899
|
process.exit(1);
|
|
704
900
|
}
|
|
705
|
-
|
|
901
|
+
const verdict = claimsVerdict(rows);
|
|
902
|
+
console.log(`\nclaims:verify scope=${selected.scope} ${verdict} — ${rows.length - skipped.length} verified, ${skipped.length} unmeasured; omitted=${selected.omitted.join(',') || 'none'}.`);
|
|
903
|
+
if (verdict === 'UNKNOWN' && process.argv.includes('--strict')) process.exitCode = 4;
|
|
706
904
|
}
|
|
707
905
|
|
|
708
906
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
@@ -216,24 +216,10 @@ export function buildHealthRecommendations({ memory = null, learning = null } =
|
|
|
216
216
|
// "here is the gap" to "here is the one-click fix" — the exact gap ADR-027 closed for health/stack/
|
|
217
217
|
// wiring findings, left open for capabilities.
|
|
218
218
|
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
// 644, re-run → 648, 2026-07-24) — an undo that has actually executed, not merely been promised in a
|
|
224
|
-
// comment (see that file's header for why "promised, never run" was this project's origin sin).
|
|
225
|
-
//
|
|
226
|
-
// So this is a small, explicit map, not "every row with a non-null turnOn". A second and third
|
|
227
|
-
// capability (most likely cross-project-lessons, then workflow-pattern-learning) join this map only
|
|
228
|
-
// once THEIR undo is independently proven the same way — never before (Rule 0: verify, don't assume).
|
|
229
|
-
const CAPABILITY_ELIGIBLE = {
|
|
230
|
-
'memory-distillation': {
|
|
231
|
-
title: 'Turn on memory distillation',
|
|
232
|
-
scope: 'project',
|
|
233
|
-
cost: { time: '~10s', usd: 0, risk: 'low' },
|
|
234
|
-
undo: { human: 'restores the pre-distill snapshot exactly (proven 2026-07-24: 644→648 patterns, restore→644, re-run→648)' },
|
|
235
|
-
},
|
|
236
|
-
};
|
|
219
|
+
// A historical restore demonstration is not proof of a currently available inverse.
|
|
220
|
+
// distill-project --restore now refuses unsafe database replacement, so memory-distillation
|
|
221
|
+
// is not offerable. Keep the allowlist empty until an inverse is available and verified.
|
|
222
|
+
const CAPABILITY_ELIGIBLE = {};
|
|
237
223
|
|
|
238
224
|
/**
|
|
239
225
|
* @param {{ capabilities?: Array<{key,label,state,scope,turnOn,evidence,whatItBuysYou}> }} input — the
|
|
@@ -38,6 +38,12 @@ export const CONSOLE_RUNTIME_SURFACE = Object.freeze([
|
|
|
38
38
|
'plugin/.claude-plugin',
|
|
39
39
|
'data/model-catalog.json',
|
|
40
40
|
'kb/brain-profile.mjs',
|
|
41
|
+
// The staged installer imports these helpers, including ZIP extraction on its update path.
|
|
42
|
+
// Keep their bytes in the same copy/digest authority as the installer itself.
|
|
43
|
+
'kb/refresh-run.mjs',
|
|
44
|
+
'kb/lifecycle-evidence-retention.mjs',
|
|
45
|
+
'kb/model-requirements.mjs',
|
|
46
|
+
'kb/zip-extract.mjs',
|
|
41
47
|
'bin/install.mjs',
|
|
42
48
|
'package.json',
|
|
43
49
|
]);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { maintenanceStatus } from '../plugin/scripts/development-maintenance.mjs';
|
|
6
|
+
|
|
7
|
+
// This command changes only repository-local development-hook configuration. Release commands
|
|
8
|
+
// do not import this module and do not consult the flag.
|
|
9
|
+
try {
|
|
10
|
+
const [action = 'status', ...args] = process.argv.slice(2);
|
|
11
|
+
if (!['status', 'suspend', 'resume', 'check'].includes(action)
|
|
12
|
+
|| (args.length && (args.length !== 2 || args[0] !== '--project'))) {
|
|
13
|
+
throw new Error('Usage: node scripts/development-maintenance.mjs status|suspend|resume [--project PATH]');
|
|
14
|
+
}
|
|
15
|
+
const status = maintenanceStatus(args[1] || process.cwd());
|
|
16
|
+
if (action === 'check') process.exit(status.suspended ? 0 : 1);
|
|
17
|
+
if (!status.project) throw new Error('Project must be inside a Git working tree');
|
|
18
|
+
if (action === 'status') {
|
|
19
|
+
process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
|
|
20
|
+
} else if ((action === 'suspend') === status.suspended) {
|
|
21
|
+
process.stdout.write(`${JSON.stringify({ ...status, changed: false }, null, 2)}\n`);
|
|
22
|
+
} else {
|
|
23
|
+
const receipt = {
|
|
24
|
+
schema: 1, id: randomUUID(), action, commonDir: status.commonDir,
|
|
25
|
+
suspended: action === 'suspend', timestamp: new Date().toISOString(),
|
|
26
|
+
scope: 'Brain development hooks only; release checks remain required',
|
|
27
|
+
};
|
|
28
|
+
const receiptsDir = path.join(status.commonDir, 'ruvnet-brain-maintenance-receipts');
|
|
29
|
+
fs.mkdirSync(receiptsDir, { mode: 0o700, recursive: true });
|
|
30
|
+
const receiptStat = fs.lstatSync(receiptsDir);
|
|
31
|
+
if (!receiptStat.isDirectory() || receiptStat.isSymbolicLink()
|
|
32
|
+
|| (process.getuid && receiptStat.uid !== process.getuid())) throw new Error('Invalid maintenance receipt directory');
|
|
33
|
+
const receiptPath = path.join(receiptsDir, `${Date.now()}-${receipt.id}.json`);
|
|
34
|
+
// Record the decision before its side effect. A receipt alone never means suspension is active;
|
|
35
|
+
// status reads the actual state file, and a failed write exits nonzero.
|
|
36
|
+
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
37
|
+
if (action === 'suspend') {
|
|
38
|
+
fs.writeFileSync(status.statePath, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
39
|
+
} else {
|
|
40
|
+
fs.unlinkSync(status.statePath);
|
|
41
|
+
}
|
|
42
|
+
process.stdout.write(`${JSON.stringify({ ...maintenanceStatus(status.project), changed: true, receiptPath }, null, 2)}\n`);
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
process.stderr.write(`development-maintenance: ${error.message}\n`);
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Development pushes inspect unpublished commit patches for credentials. Release qualification
|
|
3
|
+
// belongs to the single hosted producer, never a second checkout's Git hook.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
export function inspectPush(root, input) {
|
|
10
|
+
const checked = [];
|
|
11
|
+
for (const line of input.trim().split('\n').filter(Boolean)) {
|
|
12
|
+
const [, localSha, , remoteSha] = line.trim().split(/\s+/);
|
|
13
|
+
if (!/^[a-f0-9]{40}$/.test(localSha || '') || !/^[a-f0-9]{40}$/.test(remoteSha || '')) {
|
|
14
|
+
throw new Error('malformed Git push identity');
|
|
15
|
+
}
|
|
16
|
+
if (/^0+$/.test(localSha)) continue;
|
|
17
|
+
const range = /^0+$/.test(remoteSha) ? [localSha, '--not', '--remotes'] : [`${remoteSha}..${localSha}`];
|
|
18
|
+
const patch = execFileSync('git', ['log', '--format=', '--no-ext-diff', '-p', ...range],
|
|
19
|
+
{ cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 60_000 });
|
|
20
|
+
const additions = patch.split('\n').filter((row) => row.startsWith('+') && !row.startsWith('+++')).join('\n');
|
|
21
|
+
const matches = additions.match(/(?:sk-(?:proj|ant|or-v1)-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{30,})/g) || [];
|
|
22
|
+
if (matches.some((value) => !/your-key|abc123|xxxx+|deadbeef|example|placeholder|REDACTED/i.test(value))) {
|
|
23
|
+
throw new Error('credential-shaped value found in unpublished commit history');
|
|
24
|
+
}
|
|
25
|
+
checked.push(localSha);
|
|
26
|
+
}
|
|
27
|
+
return { ok: true, checked, scope: 'unpublished-commit-secret-scan' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
31
|
+
try {
|
|
32
|
+
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
|
|
33
|
+
console.log(JSON.stringify(inspectPush(root, fs.readFileSync(0, 'utf8'))));
|
|
34
|
+
} catch (error) { console.error(`development push refused: ${error.message}`); process.exitCode = 1; }
|
|
35
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* distill-project.mjs —
|
|
3
|
+
* distill-project.mjs — snapshot-backed project distillation.
|
|
4
|
+
* Automatic restore is temporarily unavailable: the native CLI has no exact-snapshot restore,
|
|
5
|
+
* and the former raw-copy implementation was unsafe with live WAL writers. Backups are retained
|
|
6
|
+
* for supervised offline recovery; this wrapper no longer claims a verified automatic inverse.
|
|
4
7
|
*
|
|
5
8
|
* WHY THIS FILE EXISTS. ADR-047 proposed a system for offering dormant capabilities to the user
|
|
6
9
|
* unprompted. Both duelists rejected it, and GPT-5.6-Sol's arithmetic is the reason: the registry has
|
|
@@ -31,13 +34,14 @@
|
|
|
31
34
|
* 5. status AFTER + delta — verified, and reported as a measurement rather than a verdict.
|
|
32
35
|
* 6. receipt completed — the same file, updated with the outcome and the exact restore command.
|
|
33
36
|
*
|
|
34
|
-
* `--restore <snapshot>`
|
|
37
|
+
* `--restore <snapshot>` now refuses before mutation; its WAL bytes-preservation test enforces that.
|
|
35
38
|
*/
|
|
36
39
|
import fs from 'node:fs';
|
|
37
40
|
import os from 'node:os';
|
|
38
41
|
import path from 'node:path';
|
|
39
42
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
40
43
|
import { resolveRuflo, RUFLO_MISSING } from '../plugin/scripts/ruflo-bin.mjs';
|
|
44
|
+
import { newestSnapshot, snapshotInventory, MTIME_GRACE_MS } from './snapshot-freshness.mjs';
|
|
41
45
|
|
|
42
46
|
const HOME = os.homedir();
|
|
43
47
|
// Issue #99: this was `process.env.RUFLO_BIN || path.join(HOME, '.npm-global/bin/ruflo')`, so every
|
|
@@ -96,50 +100,19 @@ function writeReceipt(rec) {
|
|
|
96
100
|
} catch { return false; }
|
|
97
101
|
}
|
|
98
102
|
|
|
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) {
|
|
113
|
-
try {
|
|
114
|
-
const files = fs.readdirSync(BACKUP_DIR)
|
|
115
|
-
.filter((f) => /\.(db|sqlite|bak)$/i.test(f) || /memory.*\d/.test(f))
|
|
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)
|
|
118
|
-
.sort((a, b) => b.t - a.t);
|
|
119
|
-
return files.length ? files[0].p : null;
|
|
120
|
-
} catch { return null; }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
103
|
// ── RESTORE — the inverse. Deliberately first, because an undo you cannot reach is not an undo. ────
|
|
124
104
|
if (has('--restore')) {
|
|
125
|
-
const snap = arg('--restore', null) || newestSnapshot();
|
|
105
|
+
const snap = arg('--restore', null) || newestSnapshot(BACKUP_DIR);
|
|
126
106
|
if (!snap) die(`no snapshot to restore from — looked in ${BACKUP_DIR.replace(HOME, '~')}`);
|
|
127
107
|
if (!fs.existsSync(snap)) die(`snapshot not found: ${snap}`);
|
|
128
|
-
if (DRY) { say(`[dry-run]
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
try { fs.copyFileSync(snap, DB); }
|
|
137
|
-
catch (e) { die(`restore FAILED (${e.message}). Your pre-restore copy is at ${pre.replace(HOME, '~')}`); }
|
|
138
|
-
|
|
139
|
-
writeReceipt({ at: new Date().toISOString(), action: 'restore', db: DB, from: snap, preRestore: pre });
|
|
140
|
-
say(`restored ${snap.replace(HOME, '~')} → ${DB.replace(HOME, '~')}`);
|
|
141
|
-
say(`the state you replaced is at ${pre.replace(HOME, '~')} if you need it back`);
|
|
142
|
-
process.exit(0);
|
|
108
|
+
if (DRY) { say(`[dry-run] restore preview only; automatic WAL-safe restore is unavailable. Snapshot ${snap.replace(HOME, '~')}, destination ${DB.replace(HOME, '~')}.`); process.exit(0); }
|
|
109
|
+
|
|
110
|
+
// Ruflo exposes backup but no exact-snapshot restore CLI. Copying a live DB while WAL/SHM
|
|
111
|
+
// remain active can lose committed transactions. Keep every byte intact until a supported
|
|
112
|
+
// restore mechanism is available; a preview or backup file is not a verified recovery path.
|
|
113
|
+
die('WAL-safe restore is temporarily unavailable: no verified exact-snapshot Ruflo restore command exists. '
|
|
114
|
+
+ 'No DB, WAL, SHM or backup bytes were changed. Retain the snapshot and coordinate an offline '
|
|
115
|
+
+ 'SQLite recovery with all database users stopped; do not copy over a live store.');
|
|
143
116
|
}
|
|
144
117
|
|
|
145
118
|
// ── PRE-FLIGHT ─────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -172,6 +145,9 @@ if (DRY) {
|
|
|
172
145
|
// ── 1. SNAPSHOT FIRST. This is the undo; without it the operation is not offerable. ────────────────
|
|
173
146
|
say('snapshot: taking a WAL-safe copy via `ruflo memory backup` before touching anything…');
|
|
174
147
|
const backupStartedAt = Date.now();
|
|
148
|
+
let priorSnapshots;
|
|
149
|
+
try { priorSnapshots = snapshotInventory(BACKUP_DIR); }
|
|
150
|
+
catch (error) { die(`REFUSING TO DISTILL — backup destination cannot be verified: ${error.message}`); }
|
|
175
151
|
const bk = ruflo(['memory', 'backup', '--db', DB, '--dir', BACKUP_DIR]);
|
|
176
152
|
if (!bk.ok) {
|
|
177
153
|
die(`REFUSING TO DISTILL — the snapshot failed (${bk.err.trim().split('\n')[0] || 'unknown error'}).\n`
|
|
@@ -181,7 +157,7 @@ if (!bk.ok) {
|
|
|
181
157
|
// mistaken for proof that THIS backup call actually wrote something (issue: `ruflo memory backup`
|
|
182
158
|
// exiting 0 without landing a new file — process killed post-commit, a --dir misconfiguration, a
|
|
183
159
|
// ruflo regression — would otherwise be indistinguishable from a genuine fresh backup).
|
|
184
|
-
const snapshot = newestSnapshot(backupStartedAt);
|
|
160
|
+
const snapshot = newestSnapshot(BACKUP_DIR, backupStartedAt, MTIME_GRACE_MS, priorSnapshots);
|
|
185
161
|
if (!snapshot) die('REFUSING TO DISTILL — `memory backup` reported success but no snapshot from this run is present, so the undo cannot be located.');
|
|
186
162
|
say(`snapshot: ${snapshot.replace(HOME, '~')}`);
|
|
187
163
|
|
|
@@ -210,17 +186,18 @@ const outcome = {
|
|
|
210
186
|
before: { patterns: before.patterns, episodes: before.episodes },
|
|
211
187
|
after: after.readable ? { patterns: after.patterns, episodes: after.episodes } : { unreadable: after.why },
|
|
212
188
|
deltaPatterns: delta,
|
|
213
|
-
restore:
|
|
189
|
+
restore: null,
|
|
190
|
+
restoreStatus: 'unavailable: retain snapshot for supervised offline recovery',
|
|
214
191
|
};
|
|
215
192
|
writeReceipt(outcome);
|
|
216
193
|
|
|
217
194
|
say('');
|
|
218
195
|
if (!run.ok) {
|
|
219
196
|
say(`distill did NOT complete cleanly: ${run.err.trim().split('\n')[0] || 'unknown error'}`);
|
|
220
|
-
say(`your store is unchanged or partially changed;
|
|
197
|
+
say(`your store is unchanged or partially changed; retain ${snapshot} for supervised offline recovery. Automatic restore is unavailable.`);
|
|
221
198
|
process.exit(1);
|
|
222
199
|
}
|
|
223
|
-
if (delta === null) say('after : counts could not be re-read, so the change is UNVERIFIED — the snapshot
|
|
200
|
+
if (delta === null) say('after : counts could not be re-read, so the change is UNVERIFIED — retain the snapshot for offline recovery.');
|
|
224
201
|
else if (delta > 0) say(`after : ${after.patterns} patterns (+${delta}) — measured, not asserted.`);
|
|
225
202
|
else say(`after : ${after.patterns} patterns (no change). Distillation ran and found nothing new to mine; that is a real answer, not a failure.`);
|
|
226
|
-
say(`
|
|
203
|
+
say(`restore : ${outcome.restoreStatus}`);
|