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.
Files changed (85) hide show
  1. package/README.md +26 -9
  2. package/bin/install.mjs +646 -366
  3. package/bin/nightly-refresh.mjs +115 -0
  4. package/kb/brain-profile.mjs +118 -32
  5. package/kb/lifecycle-evidence-retention.mjs +237 -0
  6. package/kb/refresh-run.mjs +367 -0
  7. package/kb/retrieval-result.mjs +39 -0
  8. package/kb/update-storage-transaction.mjs +439 -0
  9. package/package.json +11 -2
  10. package/plugin/.claude-plugin/plugin.json +1 -1
  11. package/plugin/.codex-plugin/plugin.json +1 -1
  12. package/plugin/hooks/codex-hooks.json +2 -165
  13. package/plugin/hooks/hook-contracts.json +4 -65
  14. package/plugin/hooks/hooks.json +2 -208
  15. package/plugin/scripts/adr-currency-gate.mjs +29 -19
  16. package/plugin/scripts/capability-registry.mjs +10 -83
  17. package/plugin/scripts/codex-hook-adapter.mjs +62 -9
  18. package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
  19. package/plugin/scripts/continuation-gate.mjs +30 -28
  20. package/plugin/scripts/continuation-objective.mjs +34 -0
  21. package/plugin/scripts/development-maintenance.mjs +49 -0
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/learn-flush.mjs +6 -1
  24. package/plugin/scripts/lesson-gate.mjs +5 -2
  25. package/plugin/scripts/lesson-presentation.mjs +5 -0
  26. package/plugin/scripts/md-stamp.mjs +109 -6
  27. package/plugin/scripts/memory-doctor.mjs +3 -7
  28. package/plugin/scripts/nightly-controller.mjs +9 -27
  29. package/plugin/scripts/nightly-scheduler.mjs +472 -0
  30. package/plugin/scripts/project-progression-contract.mjs +4 -4
  31. package/plugin/scripts/project-progression-store.mjs +35 -6
  32. package/plugin/scripts/ruflo-bin.mjs +20 -0
  33. package/plugin/scripts/session-snapshot-contract.mjs +6 -1
  34. package/plugin/scripts/version-bump-gate.sh +14 -2
  35. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
  36. package/scripts/build-bundle.mjs +8 -0
  37. package/scripts/build-primer.mjs +2 -3
  38. package/scripts/candidate-host-evidence.mjs +59 -41
  39. package/scripts/ci/build-fixture-kb.mjs +59 -2
  40. package/scripts/ci/mutate-hook-timeout.mjs +24 -47
  41. package/scripts/ci/stranger-scenario.mjs +13 -28
  42. package/scripts/claims-verify.mjs +219 -21
  43. package/scripts/console-engine.mjs +4 -18
  44. package/scripts/console-runtime-identity.mjs +6 -0
  45. package/scripts/development-maintenance.mjs +47 -0
  46. package/scripts/development-push-check.mjs +35 -0
  47. package/scripts/distill-project.mjs +24 -47
  48. package/scripts/doc-currency.mjs +91 -19
  49. package/scripts/git-hooks/pre-push +4 -162
  50. package/scripts/health-repair.mjs +22 -4
  51. package/scripts/hook-retirement-check.mjs +22 -0
  52. package/scripts/host-install-matrix.mjs +125 -61
  53. package/scripts/integration-evidence.mjs +11 -6
  54. package/scripts/learning-replay-fixture.mjs +23 -2
  55. package/scripts/nightly-two-run-proof.mjs +415 -0
  56. package/scripts/nightly-watchdog.mjs +17 -2
  57. package/scripts/npm-invocation.mjs +20 -0
  58. package/scripts/prepublication-evidence.mjs +42 -6
  59. package/scripts/primer-grounding.mjs +20 -0
  60. package/scripts/product-integrity-contract.mjs +22 -2
  61. package/scripts/public-verification-abandon.mjs +101 -0
  62. package/scripts/public-verification-aggregate.mjs +53 -19
  63. package/scripts/public-verification-finalizer.mjs +8 -2
  64. package/scripts/public-verification-lane.mjs +108 -8
  65. package/scripts/publication-receipt.mjs +173 -66
  66. package/scripts/published-surface-probe.mjs +10 -2
  67. package/scripts/qa-contract.mjs +57 -0
  68. package/scripts/qa-lanes.mjs +40 -0
  69. package/scripts/qa-runner.mjs +54 -64
  70. package/scripts/qe/ux-suite.mjs +10 -34
  71. package/scripts/qualified-candidate-check.mjs +124 -0
  72. package/scripts/release-abort-stale.mjs +2 -2
  73. package/scripts/release-projection.mjs +20 -1
  74. package/scripts/release-publication-handoff.mjs +101 -0
  75. package/scripts/release-qualification-contract.mjs +87 -0
  76. package/scripts/release-qualification.mjs +135 -0
  77. package/scripts/release-transaction.mjs +81 -1
  78. package/scripts/release.mjs +10 -1
  79. package/scripts/remedy-registry.mjs +6 -13
  80. package/scripts/retrieval-canary.mjs +91 -18
  81. package/scripts/selfcheck.mjs +12 -4
  82. package/scripts/snapshot-freshness.mjs +58 -0
  83. package/scripts/stack-sync.mjs +41 -43
  84. package/scripts/staged-host-verifier.mjs +63 -14
  85. 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: 0 = every claim regenerated (skips allowed, printed); 1 = any claim failed.
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
- export async function verifyCheaperFactor(file = path.join(ROOT, 'kb', 'metaharness.passages.jsonl')) {
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 — ${path.relative(ROOT, file)} absent, cannot re-derive ~56× from corpus (runs on machines with the brain)`);
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 = path.join(ROOT, 'kb')) {
374
- const privFile = path.join(kbDir, 'PRIVATE-STORES.json');
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 = path.join(ROOT, 'kb')) {
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 = path.join(ROOT, 'kb')) {
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 = path.join(ROOT, 'kb'), surfaces = CHUNK_SURFACES, root = ROOT) {
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('brain not installed — kb/*.big.rvf.idmap.json absent, cannot re-derive the chunk count (runs on machines with the brain)');
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 = path.join(root, 'kb'),
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
- export async function runLedger(entries = ledger) {
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 = await entry.verify();
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 rows = await runLedger();
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
- console.log(`\nclaims:verify OK — ${rows.length - skipped.length} verified, ${skipped.length} skipped (loudly).`);
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
- // THE BAR IS HIGHER THAN "has a turnOn command". capability-registry.mjs's own header states turnOn
220
- // is null unless the exact command was verified with --help — that proves the command EXISTS, not
221
- // that its INVERSE has ever been run. Of the registry's rows, only `memory-distillation` clears both:
222
- // distill-project.mjs's header records a live, round-tripped proof (644 → 648 patterns, restore →
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 — the ONE genuinely offerable capability, made genuinely reversible.
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>` is the inverse, and it is tested. An undo nobody has run is a promise.
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] would restore ${snap.replace(HOME, '~')} → ${DB.replace(HOME, '~')}`); process.exit(0); }
129
-
130
- // Snapshot the CURRENT state before overwriting it. Restoring is itself a mutation, and an undo
131
- // that destroys the thing it replaces leaves the user with no way back if they restored by mistake.
132
- const pre = path.join(BACKUP_DIR, `pre-restore-${Date.now()}.db`);
133
- try { fs.mkdirSync(BACKUP_DIR, { recursive: true }); fs.copyFileSync(DB, pre); }
134
- catch (e) { die(`refusing to restore — could not snapshot the current DB first (${e.message})`); }
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: `node ${path.join(path.dirname(new URL(import.meta.url).pathname))}/distill-project.mjs --restore ${snapshot}`,
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; restore with:\n ${outcome.restore}`);
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 above is still your undo.');
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(`undo : ${outcome.restore}`);
203
+ say(`restore : ${outcome.restoreStatus}`);