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
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { digest } from './coverage-integrity.mjs';
8
+ import { sourceIdentity } from './qa-contract.mjs';
9
+ import { RELEASE_REQUIREMENTS } from './release-qualification-contract.mjs';
10
+
11
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const PLATFORM = { linux: 'linux', darwin: 'macos', win32: 'windows' }[process.platform];
13
+
14
+ export function qualificationPlan(suite = 'source') {
15
+ const requirements = RELEASE_REQUIREMENTS[suite];
16
+ if (!requirements?.length) throw new Error('unknown qualification suite');
17
+ const files = requirements.flatMap((entry) => entry.files);
18
+ if (new Set(files).size !== files.length || files.some((file) => !file.startsWith('tests/')
19
+ || !file.endsWith('.test.mjs') || file.includes('..'))) throw new Error('invalid qualification inventory');
20
+ return { requirements, files, contractSha256: digest(requirements) };
21
+ }
22
+
23
+ export function assessTestReport(report, files, root = ROOT, platform = PLATFORM) {
24
+ if (!['linux', 'macos', 'windows'].includes(platform)) throw new Error('invalid test report platform');
25
+ const paths = platform === 'windows' ? path.win32 : path.posix;
26
+ if (typeof root !== 'string' || !paths.isAbsolute(root)) throw new Error('test report checkout root must be absolute');
27
+ const results = report?.testResults;
28
+ if (!Array.isArray(results) || results.length !== files.length) throw new Error('test report file inventory differs');
29
+ const expected = new Set(files.map((file) => paths.resolve(root, file)));
30
+ if (results.some(file => typeof file.name !== 'string' || !paths.isAbsolute(file.name))) throw new Error('test report file inventory requires absolute names');
31
+ const names = results.map((file) => paths.resolve(file.name));
32
+ if (new Set(names).size !== files.length || names.some((name) => !expected.has(name))) {
33
+ throw new Error('test report contains missing, duplicate, or unexpected files');
34
+ }
35
+ const cases = results.flatMap((file) => file.assertionResults || []);
36
+ if (!cases.length || results.some((file) => file.status !== 'passed' || !file.assertionResults?.length)
37
+ || cases.some((test) => test.status !== 'passed') || report.success !== true
38
+ || report.numFailedTests !== 0 || report.numFailedTestSuites !== 0
39
+ || report.numPendingTests !== 0 || (report.numTodoTests || 0) !== 0
40
+ || report.numTotalTests !== cases.length || report.numPassedTests !== cases.length) {
41
+ throw new Error('reviewed release cases did not all execute and pass');
42
+ }
43
+ return { total: cases.length, passed: cases.length, failed: 0, skipped: 0 };
44
+ }
45
+
46
+ export function validateQualificationReceipt(report, { suite, platform, sourceSha }) {
47
+ const plan = qualificationPlan(suite);
48
+ const { receiptSha256, ...payload } = report || {};
49
+ if (report?.schemaVersion !== 1 || report.kind !== 'ruvnet-brain-release-qualification'
50
+ || report.suite !== suite || report.platform !== platform || report.status !== 'PASS'
51
+ || digest(payload) !== receiptSha256 || report.contractSha256 !== plan.contractSha256
52
+ || digest(report.files) !== digest(plan.files)
53
+ || digest(report.requirements) !== digest(plan.requirements.map(row => row.id))) {
54
+ throw new Error('qualification receipt identity, digest, or reviewed contract differs');
55
+ }
56
+ if (!/^[a-f0-9]{40}$/.test(String(sourceSha)) || report.source?.sha !== sourceSha
57
+ || report.sourceAfter?.sha !== sourceSha || report.source.dirty !== false || report.sourceAfter.dirty !== false
58
+ || !/^[a-f0-9]{64}$/.test(report.source.digest || '') || report.source.digest !== report.sourceAfter.digest
59
+ || report.sourceStable !== true) throw new Error('qualification source is not clean and unchanged');
60
+ if (!Array.isArray(report.results) || !report.results.length
61
+ || report.results.some(row => row.status !== 'PASS' || row.exitCode !== 0)) {
62
+ throw new Error('qualification command did not complete successfully');
63
+ }
64
+ if (typeof report.checkoutRoot !== 'string' || !report.checkoutRoot) throw new Error('qualification checkout root is missing');
65
+ const tests = assessTestReport(report.testReport, plan.files, report.checkoutRoot, platform);
66
+ if (digest(tests) !== digest(report.tests)) throw new Error('qualification test summary differs from executed cases');
67
+ return tests;
68
+ }
69
+
70
+ export function runQualification({ suite = 'source', platform = PLATFORM, report: output, root = ROOT } = {}) {
71
+ if (platform !== PLATFORM) throw new Error(`cannot produce ${platform} evidence on ${PLATFORM}`);
72
+ if (!output || fs.existsSync(output)) throw new Error('a new --report path is required');
73
+ const plan = qualificationPlan(suite);
74
+ for (const file of plan.files) if (!fs.statSync(path.resolve(root, file)).isFile()) throw new Error(`missing test ${file}`);
75
+ const source = sourceIdentity(root);
76
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'release-qualification-'));
77
+ const results = [];
78
+ let testReport = null;
79
+ let tests = { total: 0, passed: 0, failed: 0, skipped: 0 };
80
+ const execute = (name, args) => {
81
+ const run = spawnSync(process.execPath, ['scripts/ci/step-watchdog.mjs', '--name', name,
82
+ '--timeout-ms', '900000', '--receipt-dir', temp, '--', process.execPath, ...args],
83
+ { cwd: root, env: { ...process.env, ...(suite === 'integration' ? { RUVNET_REQUIRE_CODEX_DISCOVERY: '1' } : {}) },
84
+ stdio: 'inherit' });
85
+ const ok = !run.error && run.status === 0;
86
+ results.push({ name, status: ok ? 'PASS' : 'FAIL', exitCode: run.status });
87
+ if (!ok) throw new Error(`${name} failed`);
88
+ };
89
+ let failure = null;
90
+ try {
91
+ if (suite === 'source') {
92
+ execute('release-version', ['scripts/sync-version.mjs', '--check']);
93
+ execute('release-source-identity', ['scripts/convergence-manifest.mjs']);
94
+ execute('automatic-hook-retirement', ['scripts/hook-retirement-check.mjs']);
95
+ }
96
+ const testPath = path.join(temp, 'tests.json');
97
+ execute(`release-${suite}-${platform}`, ['node_modules/vitest/vitest.mjs', 'run', ...plan.files,
98
+ '--reporter=json', '--outputFile', testPath, '--maxWorkers=1']);
99
+ testReport = JSON.parse(fs.readFileSync(testPath, 'utf8'));
100
+ tests = assessTestReport(testReport, plan.files, root);
101
+ } catch (error) {
102
+ failure = error.message;
103
+ const testPath = path.join(temp, 'tests.json');
104
+ if (fs.existsSync(testPath)) {
105
+ try {
106
+ testReport = JSON.parse(fs.readFileSync(testPath, 'utf8'));
107
+ tests = { total: testReport.numTotalTests || 0, passed: testReport.numPassedTests || 0,
108
+ failed: testReport.numFailedTests || 0,
109
+ skipped: (testReport.numPendingTests || 0) + (testReport.numTodoTests || 0) };
110
+ } catch { /* failed report remains a failure */ }
111
+ }
112
+ }
113
+ const sourceAfter = sourceIdentity(root);
114
+ const sourceStable = source.digest === sourceAfter.digest;
115
+ const receipt = { schemaVersion: 1, kind: 'ruvnet-brain-release-qualification', suite, platform, checkoutRoot: path.resolve(root),
116
+ source, sourceAfter, sourceStable, status: !failure && sourceStable ? 'PASS' : 'FAIL',
117
+ requirements: plan.requirements.map((entry) => entry.id), contractSha256: plan.contractSha256,
118
+ files: plan.files, tests, testReport, results, failure, observedAt: new Date().toISOString() };
119
+ receipt.receiptSha256 = digest(receipt);
120
+ fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
121
+ fs.writeFileSync(output, JSON.stringify(receipt, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
122
+ fs.rmSync(temp, { recursive: true, force: true });
123
+ return receipt;
124
+ }
125
+
126
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
127
+ const args = process.argv.slice(2);
128
+ const value = (flag, fallback) => { const i = args.indexOf(flag); return i < 0 ? fallback : args[i + 1]; };
129
+ try {
130
+ const receipt = runQualification({ suite: value('--suite', 'source'), platform: value('--platform', PLATFORM),
131
+ report: value('--report') });
132
+ console.log(JSON.stringify({ status: receipt.status, tests: receipt.tests, receiptSha256: receipt.receiptSha256 }));
133
+ process.exitCode = receipt.status === 'PASS' ? 0 : 1;
134
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
135
+ }
@@ -68,7 +68,7 @@ export const ALLOWED_TRANSITIONS = Object.freeze({
68
68
  compensated: ['github-promote-intent', 'npm-promote-intent', 'manual-intervention-required', ...ABORTABLE],
69
69
  'defaults-promoted': ['finalize-intent', 'manual-intervention-required', ...ABORTABLE],
70
70
  'finalize-intent': ['channels-converged', 'manual-intervention-required', ...ABORTABLE],
71
- 'manual-intervention-required': [],
71
+ 'manual-intervention-required': [...ABORTABLE],
72
72
  'channels-converged': ['install-verified', 'abandoned', 'superseded'],
73
73
  'install-verified': [],
74
74
  abandoned: [],
@@ -506,11 +506,18 @@ export async function abortReleaseTransaction({ identity, receipts, reason, auth
506
506
  export async function finalizeReleaseTransaction({
507
507
  identity,
508
508
  aggregate,
509
+ verifierSha,
510
+ workflowRunId,
509
511
  adapter,
510
512
  privateKey,
511
513
  publicKey,
512
514
  aggregatePublicKey,
513
515
  } = {}) {
516
+ if (!/^[1-9][0-9]*$/.test(String(workflowRunId || ''))) throw new Error('expected workflow run ID is required');
517
+ if (verifierSha !== undefined && (!/^[a-f0-9]{40}$/.test(String(verifierSha))
518
+ || aggregate?.identity?.verifierSha !== verifierSha)) {
519
+ throw new Error('public verification verifier SHA differs from the expected verifier');
520
+ }
514
521
  if (!adapter || typeof adapter.discover !== 'function' || typeof adapter.observeSnapshot !== 'function'
515
522
  || typeof adapter.materializePublicVerificationAggregate !== 'function'
516
523
  || typeof adapter.appendReceipt !== 'function' || typeof adapter.readReceipt !== 'function') {
@@ -518,6 +525,7 @@ export async function finalizeReleaseTransaction({
518
525
  }
519
526
  const expectedAggregateIdentity = {
520
527
  ...aggregate?.identity,
528
+ workflowRunId: String(workflowRunId),
521
529
  sourceSha: identity?.candidateSha,
522
530
  version: identity?.version,
523
531
  tag: identity?.tag,
@@ -560,6 +568,7 @@ export async function finalizeReleaseTransaction({
560
568
  verdict: 'INSTALL_VERIFIED',
561
569
  publicVerification: {
562
570
  aggregateSha256: aggregate.aggregateSha256,
571
+ ...(aggregate.identity.verifierSha === undefined ? {} : { verifierSha: aggregate.identity.verifierSha }),
563
572
  aggregateAssetSha256,
564
573
  signatureAssetSha256,
565
574
  lanes: aggregate.metrics.leaves,
@@ -580,3 +589,74 @@ export async function finalizeReleaseTransaction({
580
589
  validateReceiptChain([...chain, persisted], identity, publicKey);
581
590
  return persisted;
582
591
  }
592
+
593
+ // Close an unsuccessful public verification attempt without changing public bytes or truth.
594
+ export async function abandonPublicVerificationTransaction({ identity, expected, reason, authorization,
595
+ recovery, failure, adapter, privateKey, publicKey } = {}) {
596
+ const hex = (value, length) => new RegExp(`^[a-f0-9]{${length}}$`).test(value || '');
597
+ const bounded = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
598
+ if (!expected || expected.transactionId !== transactionIdFor(identity)
599
+ || !Number.isSafeInteger(expected.sequence) || expected.sequence < 0 || !hex(expected.receiptDigest, 64)) {
600
+ throw new Error('abandonment requires exact expected transaction, sequence, and digest');
601
+ }
602
+ if (!bounded(reason, 1024) || !bounded(authorization?.actor, 100) || !bounded(authorization?.reference, 1024)
603
+ || authorization.event !== 'repository_dispatch' || authorization.action !== 'abandon-public-verification'
604
+ || !Number.isSafeInteger(authorization.dispatchRunId) || authorization.dispatchRunId <= 0
605
+ || !hex(authorization.verifierSha, 40)) throw new Error('explicit abandonment authorization provenance is required');
606
+ if (recovery?.event !== 'repository_dispatch' || !Number.isSafeInteger(recovery.workflowId) || recovery.workflowId <= 0
607
+ || !Number.isSafeInteger(recovery.repositoryId) || recovery.repositoryId <= 0
608
+ || recovery.repository !== identity.repository || recovery.workflow !== '.github/workflows/recover-public-verification.yml'
609
+ || recovery.conclusion !== 'failure' || !Number.isSafeInteger(recovery.runId) || recovery.runId <= 0
610
+ || !Number.isSafeInteger(recovery.artifactId) || recovery.artifactId <= 0 || !hex(recovery.verifierSha, 40)
611
+ || recovery.artifactName !== `recovered-public-verification-${failure?.os}-${identity.candidateSha}-${recovery.runId}`) {
612
+ throw new Error('failed recovery provenance differs from the candidate');
613
+ }
614
+ const { failureSha256, ...failurePayload } = failure || {};
615
+ if (Buffer.byteLength(canonicalJson(failure || {})) > 131072 || failure?.schemaVersion !== 1
616
+ || failure.kind !== 'ruvnet-brain-public-verification-failure' || !['linux', 'macos', 'windows'].includes(failure.os)
617
+ || failure.sourceSha !== identity.candidateSha || failure.artifactSha256 !== identity.packageSha256
618
+ || failure.bundleSha256 !== identity.bundleSha256 || !Array.isArray(failure.failures)
619
+ || failure.failures.length < 1 || failure.failures.length > 3
620
+ || failure.failures.some((entry) => !bounded(entry.reason, 4096))
621
+ || (failure.verifierSha !== undefined && failure.verifierSha !== recovery.verifierSha)
622
+ || !hex(failureSha256, 64)
623
+ || crypto.createHash('sha256').update(canonicalJson(failurePayload)).digest('hex') !== failureSha256) {
624
+ throw new Error('bounded failed recovery evidence is invalid or belongs to another candidate');
625
+ }
626
+ const intent = { expected, reason, authorization: { actor: authorization.actor, reference: authorization.reference }, recovery, failureSha256 };
627
+ const intentSha256 = crypto.createHash('sha256').update(canonicalJson(intent)).digest('hex');
628
+ if (!adapter || ['discover', 'observeSnapshot', 'appendReceipt', 'readReceipt'].some((key) => typeof adapter[key] !== 'function')) {
629
+ throw new Error('abandonment adapter is incomplete');
630
+ }
631
+ const discovered = await adapter.discover(identity);
632
+ const chain = validateReceiptChain(discovered.receipts || [], identity, publicKey);
633
+ const current = chain.at(-1);
634
+ if (current?.state === 'abandoned') {
635
+ if (current.observation?.abandonment?.intentSha256 !== intentSha256) throw new Error('conflicting abandonment intent');
636
+ return current;
637
+ }
638
+ if (current?.schemaVersion !== 3 || current.state !== 'channels-converged'
639
+ || current.observation?.verdict !== 'PUBLISHED_NOT_VERIFIED'
640
+ || current.sequence !== expected.sequence || current.receiptDigest !== expected.receiptDigest) {
641
+ throw new Error('abandonment expected receipt is not the current published-unverified transaction');
642
+ }
643
+ const snapshot = await adapter.observeSnapshot(identity, discovered.matchingDrafts?.[0], { forceAssets: true });
644
+ if (snapshot.readError || !npmLatestIsB(snapshot, identity) || !npmCandidateExact(snapshot, identity)
645
+ || !githubIsB(snapshot, identity) || snapshot.github?.published !== true || snapshot.github?.latest !== true
646
+ || snapshot.github?.assetsExact !== true
647
+ || canonicalJson(snapshot.npm) !== canonicalJson(current.observation.npm)
648
+ || canonicalJson(snapshot.github) !== canonicalJson(current.observation.github)) {
649
+ throw new Error('public artifacts or channels changed before abandonment');
650
+ }
651
+ const receipt = stateReceipt({ identity, prior: current, state: 'abandoned', privateKey,
652
+ observation: { verdict: 'PUBLISHED_NOT_VERIFIED', abandonment: { intentSha256, ...intent, authorization, failure },
653
+ npm: snapshot.npm, github: snapshot.github } });
654
+ verifyReceipt(receipt, publicKey);
655
+ const name = `${RECEIPT_PREFIX}${String(receipt.sequence).padStart(4, '0')}.json`;
656
+ await adapter.appendReceipt(discovered.matchingDrafts?.[0] || null, receipt, name);
657
+ const persisted = await adapter.readReceipt(discovered.matchingDrafts?.[0] || null, receipt.sequence);
658
+ verifyReceipt(persisted, publicKey);
659
+ if (canonicalJson(persisted) !== canonicalJson(receipt)) throw new Error('abandoned receipt readback differs');
660
+ validateReceiptChain([...chain, persisted], identity, publicKey);
661
+ return persisted;
662
+ }
@@ -31,6 +31,7 @@ import fs from 'node:fs';
31
31
  import crypto from 'node:crypto';
32
32
  import { validateProtectedPublishEnvironment, validateProtectedPublishInvocation } from './protected-release-invocation.mjs';
33
33
  import { runReleaseTransaction } from './release-transaction.mjs';
34
+ import { materializePublicationHandoff, resolvePublicationHandoffPaths } from './release-publication-handoff.mjs';
34
35
  import { liveReleaseProvider } from './release-transaction-provider.mjs';
35
36
  import { stagedHostVerifier } from './staged-host-verifier.mjs';
36
37
  import { verifyPayload } from './release-payload.mjs';
@@ -44,6 +45,7 @@ let publicationReceiptPath = null;
44
45
  let protectedReleaseMode = 'strict';
45
46
  let verifiedPayload = null;
46
47
  let aggregateEnvelope = null;
48
+ let publicationHandoffPaths = null;
47
49
  const c = { g: (s) => `\x1b[32m${s}\x1b[0m`, r: (s) => `\x1b[31m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m` };
48
50
  const V = () => JSON.parse(fs.readFileSync(path.join(ROOT, 'plugin/.claude-plugin/plugin.json'), 'utf8')).version;
49
51
 
@@ -222,6 +224,11 @@ if (PUBLISH) {
222
224
  console.error(`\n${c.r('✗ PROTECTED RELEASE GATE FAILED')}\n publication receipt output must be a new file inside release-evidence\n`);
223
225
  process.exit(1);
224
226
  }
227
+ publicationHandoffPaths = resolvePublicationHandoffPaths({
228
+ root: ROOT,
229
+ identityPath: process.env.RUVNET_RELEASE_IDENTITY,
230
+ receiptPath: process.env.RUVNET_CHANNEL_RECEIPT,
231
+ });
225
232
  const payloadManifestPath = path.resolve(ROOT, process.env.RUVNET_CANDIDATE_PAYLOAD || '');
226
233
  const payloadSignaturePath = path.resolve(ROOT, process.env.RUVNET_CANDIDATE_PAYLOAD_SIGNATURE || '');
227
234
  const aggregatePath = path.resolve(ROOT, process.env.RUVNET_AGGREGATE_ENVELOPE || '');
@@ -375,6 +382,7 @@ if (PUBLISH) {
375
382
  };
376
383
  const privatePem = process.env.RUVNET_SIGNING_KEY;
377
384
  if (!privatePem) throw new Error('RUVNET_SIGNING_KEY is required for signed transaction receipts');
385
+ const publicKey = crypto.createPublicKey(fs.readFileSync(path.join(ROOT, 'keys/ruvnet-brain-signing.pub.pem'), 'utf8'));
378
386
  const finalReceipt = await runReleaseTransaction({
379
387
  identity, assets, adapter: liveReleaseProvider({
380
388
  root: ROOT,
@@ -382,10 +390,11 @@ if (PUBLISH) {
382
390
  publicationReceipt: process.env.RUVNET_PUBLICATION_RECEIPT,
383
391
  }),
384
392
  privateKey: crypto.createPrivateKey(privatePem),
385
- publicKey: crypto.createPublicKey(fs.readFileSync(path.join(ROOT, 'keys/ruvnet-brain-signing.pub.pem'), 'utf8')),
393
+ publicKey,
386
394
  hostVerifier: stagedHostVerifier({ assets, identity }),
387
395
  });
388
396
  if (finalReceipt.state !== 'channels-converged') throw new Error(`release transaction stopped at ${finalReceipt.state}`);
397
+ materializePublicationHandoff({ paths: publicationHandoffPaths, identity, receipt: finalReceipt, publicKey });
389
398
  } else {
390
399
  step('D', 'remote staged release transaction — SKIPPED (check-only; pass --publish to publish)');
391
400
  }
@@ -103,26 +103,19 @@ export const REMEDIES = [
103
103
  inverse: () => ({ kind: K.RESTORE_STORE_BACKUPS }),
104
104
  },
105
105
  {
106
- // THE CAPABILITY BRIDGE'S ONLY CURRENT MEMBER. buildCapabilityRecommendations() in
107
- // console-engine.mjs offers `enable:memory-distillation` only while the capability is OFF; this
108
- // is the executor behind it. See distill-project.mjs's header for why THIS script and not bare
109
- // `ruflo memory distill run`: the wrapper snapshots first, fails closed on a receipt-write
110
- // failure, and its `--restore` is the tested inverse (proven 644→648→644→648, 2026-07-24).
106
+ // Retained for explicit execution and existing receipts, not offered as reversible.
107
+ // The restore command refuses unsafe replacement; historical roundtrips do not authorize it.
111
108
  key: 'enable-memory-distillation',
112
- autoEligible: true,
113
- summary: "mine this project's stored memories into reusable patterns (snapshots first; reversible)",
109
+ autoEligible: false,
110
+ summary: "mine this project's stored memories into reusable patterns (snapshots first; automatic undo unavailable)",
114
111
  match: (id) => (id === 'enable:memory-distillation' ? {} : null),
115
112
  // This console instance is always scoped to ONE project — the directory it was started in — the
116
113
  // same assumption the memory-index/learning-flush/learning-train remedies above already make.
117
114
  // `usesServerProject` asks onboarding-console.mjs (impure, process-aware) to supply that directory
118
115
  // at call time; this file stays pure and never reads process.cwd() itself (see header).
119
116
  plan: () => ({ script: 'scripts/distill-project.mjs', args: [], usesServerProject: true }),
120
- // `--restore` with no path argument uses distill-project.mjs's OWN newestSnapshot() lookup inside
121
- // this project's `.swarm/backups` — the exact mechanism its header proves end to end. Re-deriving
122
- // which snapshot to restore here, instead of asking the tool that took it, is the kind of
123
- // duplicate implementation this project has already been burned by once (ADR-047's rejected
124
- // "offered command and promised undo live on different execution paths" bug).
125
- inverse: () => ({ kind: K.RESTORE_PROJECT_DISTILL }),
117
+ inverse: () => ({ kind: K.RESTORE_PROJECT_DISTILL, available: false,
118
+ human: 'automatic undo is unavailable; retain the snapshot and coordinate offline SQLite recovery with all database users stopped' }),
126
119
  },
127
120
  {
128
121
  key: 'stack-sync',
@@ -2,10 +2,74 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
+ import readline from 'node:readline';
5
6
  import { spawnSync } from 'node:child_process';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { canonicalJson, digest, validateCoverageLedger } from './coverage-integrity.mjs';
8
9
 
10
+ // Both release phases resolve against an explicit installed context, never the checkout.
11
+ export async function resolveInstalledCanaryCitation({ kbDir, matched, expected, passageFileDigests = new Map() }) {
12
+ if (!path.isAbsolute(kbDir || '')) throw new Error('installed canary KB path must be absolute');
13
+ if (String(matched?.repo || '').toLowerCase() !== expected.repo || matched?.path !== expected.path) return { resolved: false };
14
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(expected.repo)) throw new Error('installed citation repository violates containment');
15
+ const rootStat = fs.lstatSync(kbDir);
16
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || fs.realpathSync(kbDir) !== path.resolve(kbDir)) {
17
+ throw new Error('installed canary KB root or parent is a symlink; canonical containment required');
18
+ }
19
+ const files = [path.join(kbDir, `${expected.repo}.passages.jsonl`), path.join(kbDir, `${expected.repo}.big.passages.jsonl`)]
20
+ .filter((file) => { try { fs.lstatSync(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } });
21
+ for (const file of files) {
22
+ const stat = fs.lstatSync(file);
23
+ if (!stat.isFile() || stat.isSymbolicLink() || path.dirname(fs.realpathSync(file)) !== kbDir) {
24
+ throw new Error('installed citation file is a symlink or violates containment');
25
+ }
26
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
27
+ const stream = fs.createReadStream(file, { fd, autoClose: false });
28
+ const hash = crypto.createHash('sha256');
29
+ stream.on('data', (bytes) => hash.update(bytes));
30
+ const rows = readline.createInterface({ input: stream, crlfDelay: Infinity });
31
+ let passageSha256 = null;
32
+ try {
33
+ for await (const line of rows) {
34
+ let record;
35
+ try { record = JSON.parse(line); } catch { continue; }
36
+ if (record?.path !== expected.path) continue;
37
+ if (digest(record) !== expected.passageSha256) continue;
38
+ const text = record.fullText || record.text;
39
+ if (typeof text !== 'string' || !text || typeof matched.text !== 'string' || !matched.text.includes(text)) continue;
40
+ passageSha256 = expected.passageSha256;
41
+ }
42
+ const after = fs.lstatSync(file);
43
+ const opened = fs.fstatSync(fd);
44
+ if (after.isSymbolicLink() || after.dev !== stat.dev || after.ino !== stat.ino
45
+ || opened.ino !== stat.ino || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs
46
+ || after.ctimeMs !== stat.ctimeMs) throw new Error('installed citation changed during verification');
47
+ const passageFileSha256 = hash.digest('hex');
48
+ passageFileDigests.set(file, passageFileSha256);
49
+ if (passageSha256) return { resolved: true, evidence: { passageSha256, passageFileSha256,
50
+ hitContentSha256: crypto.createHash('sha256').update(matched.text).digest('hex') } };
51
+ } finally {
52
+ rows.close();
53
+ await new Promise((resolve, reject) => stream.close((error) => error ? reject(error) : resolve()));
54
+ }
55
+ }
56
+ return { resolved: false };
57
+ }
58
+
59
+ function expectedSources(expected) {
60
+ return [{ path: expected.path, passageSha256: expected.passageSha256 }, ...(expected.alternatives || [])];
61
+ }
62
+ function validExpectedSources(expected) {
63
+ if (!expected || !['passageSha256,path', 'alternatives,passageSha256,path'].includes(Object.keys(expected).sort().join(','))) return false;
64
+ if (expected.alternatives !== undefined && (!Array.isArray(expected.alternatives)
65
+ || expected.alternatives.length < 1 || expected.alternatives.length > 8)) return false;
66
+ const sources = expectedSources(expected);
67
+ return new Set(sources.map((source) => source?.path)).size === sources.length && sources.every((source) =>
68
+ source && Object.keys(source).sort().join(',') === 'passageSha256,path'
69
+ && typeof source.path === 'string' && source.path && !path.isAbsolute(source.path)
70
+ && !source.path.split(/[\\/]/).includes('..') && HEX64.test(String(source.passageSha256 || '')));
71
+ }
72
+
9
73
  const HEX40 = /^[a-f0-9]{40}$/;
10
74
  const HEX64 = /^[a-f0-9]{64}$/;
11
75
 
@@ -96,10 +160,7 @@ export function validateRetrievalQueryEvidence(evidence) {
96
160
  if (Object.keys(row || {}).sort().join(',') !== 'expected,query,recordSha256'
97
161
  || typeof row.query !== 'string' || row.query !== row.query.trim().replace(/\s+/g, ' ')
98
162
  || normalized.length < 24 || seenQueries.has(normalized)
99
- || !expected || Object.keys(expected).sort().join(',') !== 'passageSha256,path'
100
- || typeof expected.path !== 'string' || !expected.path || path.isAbsolute(expected.path)
101
- || expected.path.split(/[\\/]/).includes('..')
102
- || !HEX64.test(String(expected.passageSha256 || ''))
163
+ || !validExpectedSources(expected)
103
164
  || row.recordSha256 !== digest({ store, query: row.query, expected })) {
104
165
  throw new Error(`independent retrieval query evidence for ${store} is malformed`);
105
166
  }
@@ -187,7 +248,8 @@ export function validateRetrievalCanaryPlan(plan) {
187
248
  throw new Error('oracle denominator differs from eligible coverage');
188
249
  }
189
250
  if (new Set(ids).size !== ids.length) throw new Error('retrieval canary plan has duplicate case ids');
190
- if ((!plan.cases.some(({ cohort }) => cohort === 'delta') && plan.noDelta !== true)
251
+ const hasDelta = plan.cases.some(({ cohort }) => cohort === 'delta');
252
+ if ((!hasDelta && plan.noDelta !== true) || (hasDelta && plan.noDelta === true)
191
253
  || !plan.cases.some(({ cohort }) => cohort === 'legacy')) {
192
254
  throw new Error('retrieval canary plan requires delta and legacy cohorts');
193
255
  }
@@ -316,12 +378,15 @@ export function buildRetrievalCanaryPlan({ coverage, baseline, candidate, covera
316
378
  if (!strata.has(stratum)) strata.set(stratum, []);
317
379
  strata.get(stratum).push(entry);
318
380
  });
381
+ // Source-only releases retain the same corpus sample; the plan still seals exact release bytes.
382
+ const samplingGeneration = coverage.kind === 'ruvnet-brain-release-coverage'
383
+ ? coverage.corpusCoverage.coverageGeneration : coverageGeneration;
319
384
  const sampleCount = Math.min(legacyPool.length, legacySampleSize ?? Math.max(10, Math.ceil(eligible.length * 0.1)));
320
385
  if (sampleCount < strata.size) throw new Error(`legacy sample must cover all ${strata.size} passage-count strata`);
321
386
  const selected = [];
322
387
  for (const [stratum, entries] of strata) {
323
- entries.sort((a, b) => digest(`${coverageGeneration}:${stratum}:${storeOf(a.row)}`)
324
- .localeCompare(digest(`${coverageGeneration}:${stratum}:${storeOf(b.row)}`)));
388
+ entries.sort((a, b) => digest(`${samplingGeneration}:${stratum}:${storeOf(a.row)}`)
389
+ .localeCompare(digest(`${samplingGeneration}:${stratum}:${storeOf(b.row)}`)));
325
390
  selected.push({ ...entries.shift(), stratum });
326
391
  }
327
392
  while (selected.length < sampleCount) {
@@ -338,9 +403,8 @@ export function buildRetrievalCanaryPlan({ coverage, baseline, candidate, covera
338
403
  const store = storeOf(row);
339
404
  const observedPassageCount = passageCount ?? passages.get(store).length;
340
405
  const evidence = queryEvidence.queries[store];
341
- const matches = passages.get(store).filter((row) => row.path === evidence?.expected?.path
342
- && digest(row) === evidence.expected.passageSha256);
343
- if (!evidence || matches.length !== 1) {
406
+ if (!evidence || expectedSources(evidence.expected).some((source) =>
407
+ passages.get(store).filter((row) => row.path === source.path && digest(row) === source.passageSha256).length !== 1)) {
344
408
  throw new Error(`${store} has no sealed independent query evidence`);
345
409
  }
346
410
  return {
@@ -430,10 +494,12 @@ export function validateRetrievalCanaryReceipt(receipt, { plan, requireAcceptanc
430
494
  for (const row of receipt.cases) {
431
495
  const expected = plan.cases.find(({ id }) => id === row.id)?.expected;
432
496
  const ranked = row.retrievalHit ? row.citations?.[row.rank - 1] : null;
497
+ const accepted = expectedSources(expected).find((source) => source.path === ranked?.path);
433
498
  if (row.retrievalHit && (String(ranked?.repo || '').toLowerCase() !== expected.repo
434
- || ranked?.path !== expected.path
435
- || (row.citationResolved === true && (row.citationEvidence?.passageSha256 !== expected.passageSha256
436
- || !HEX64.test(String(row.citationEvidence?.passageFileSha256 || '')))))) {
499
+ || !accepted
500
+ || (row.citationResolved === true && (row.citationEvidence?.passageSha256 !== accepted.passageSha256
501
+ || !HEX64.test(String(row.citationEvidence?.passageFileSha256 || ''))
502
+ || (ranked.contentSha256 !== undefined && row.citationEvidence?.hitContentSha256 !== ranked.contentSha256))))) {
437
503
  throw new Error(`retrieval canary ${row.id} hit or citation evidence differs from the sealed plan`);
438
504
  }
439
505
  }
@@ -448,7 +514,9 @@ export function validateRetrievalCanaryReceipt(receipt, { plan, requireAcceptanc
448
514
  deltaHits: delta.filter(({ retrievalHit }) => retrievalHit === true).length,
449
515
  deltaCitations: delta.filter(({ retrievalHit, citationResolved }) => retrievalHit === true && citationResolved === true).length,
450
516
  recallAt10: hits / receipt.cases.length,
451
- deltaCitationRate: delta.length ? delta.filter(({ retrievalHit, citationResolved }) => retrievalHit === true && citationResolved === true).length / delta.length : 0,
517
+ deltaCitationRate: delta.length
518
+ ? delta.filter(({ retrievalHit, citationResolved }) => retrievalHit === true && citationResolved === true).length / delta.length
519
+ : plan.noDelta === true ? 1 : 0,
452
520
  unknown,
453
521
  skipped,
454
522
  };
@@ -478,12 +546,14 @@ export async function runRetrievalCanaries({ plan, sourceSha, artifactSha256, ca
478
546
  const rows = resultRows(await search({ query: canary.query, k: 10 }));
479
547
  const top = rows.slice(0, 10);
480
548
  const rank = top.findIndex((row) => String(row?.repo || '').toLowerCase() === canary.expected.repo
481
- && row?.path === canary.expected.path);
549
+ && expectedSources(canary.expected).some((source) => row?.path === source.path));
482
550
  const matched = rank >= 0 ? top[rank] : null;
483
- const resolved = matched ? await citationResolver(matched, canary.expected) : { resolved: false };
551
+ const accepted = matched ? expectedSources(canary.expected).find((source) => source.path === matched.path) : null;
552
+ const resolved = matched ? await citationResolver(matched, { repo: canary.expected.repo, ...accepted }) : { resolved: false };
484
553
  cases[index] = { id: canary.id, cohort: canary.cohort, status: 'COMPLETED', retrievalHit: rank >= 0,
485
554
  citationResolved: resolved?.resolved === true, citationEvidence: resolved?.evidence || null,
486
- rank: rank >= 0 ? rank + 1 : null, citations: top.map(({ repo, path: hitPath }) => ({ repo, path: hitPath })) };
555
+ rank: rank >= 0 ? rank + 1 : null, citations: top.map(({ repo, path: hitPath, contentSha256 }) => ({ repo, path: hitPath,
556
+ ...(contentSha256 ? { contentSha256 } : {}) })) };
487
557
  } catch (error) {
488
558
  cases[index] = { id: canary.id, cohort: canary.cohort, status: 'UNKNOWN', retrievalHit: false,
489
559
  citationResolved: false, citationEvidence: null, rank: null, citations: [], error: error.message };
@@ -494,7 +564,10 @@ export async function runRetrievalCanaries({ plan, sourceSha, artifactSha256, ca
494
564
  const hits = cases.filter(({ retrievalHit }) => retrievalHit).length;
495
565
  const metrics = { total: cases.length, hits, deltaTotal: delta.length, deltaHits: delta.filter(({ retrievalHit }) => retrievalHit).length,
496
566
  deltaCitations: delta.filter(({ retrievalHit, citationResolved }) => retrievalHit && citationResolved).length,
497
- recallAt10: hits / cases.length, deltaCitationRate: delta.length ? delta.filter(({ retrievalHit, citationResolved }) => retrievalHit && citationResolved).length / delta.length : 0,
567
+ recallAt10: hits / cases.length,
568
+ deltaCitationRate: delta.length
569
+ ? delta.filter(({ retrievalHit, citationResolved }) => retrievalHit && citationResolved).length / delta.length
570
+ : plan.noDelta === true ? 1 : 0,
498
571
  unknown: cases.filter(({ status }) => status === 'UNKNOWN').length,
499
572
  skipped: cases.filter(({ status }) => status === 'SKIPPED').length };
500
573
  const payload = { schemaVersion: 1, kind: 'ruvnet-brain-retrieval-canary-receipt', sourceSha, artifactSha256,
@@ -477,7 +477,7 @@ export function assertContract({ rec, measurement, mode, timeoutSec }) {
477
477
  }
478
478
 
479
479
  // ── §4 THE BATTERY: every installed registration × every stdin regime ───────────────────────────
480
- export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tmpdir(), regimes = STDIN_REGIMES, surface = null, env = {} } = {}) {
480
+ export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tmpdir(), regimes = STDIN_REGIMES, surface = null, env = {}, inspectOnly = false } = {}) {
481
481
  const reg = await loadRegistry();
482
482
  const s = surface ?? resolveInstalledSurface({ home, repo });
483
483
  if (!s.ok) return { ok: false, reason: s.reason, violations: [], results: [] };
@@ -487,6 +487,7 @@ export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tm
487
487
  const { contracts } = reg.loadContracts(s.root);
488
488
 
489
489
  const registrations = readInstalledRegistrations(s.hooksFile);
490
+ if (inspectOnly) return { ok: true, surface: s, registrations, violations: [], results: [] };
490
491
  const violations = [];
491
492
  const results = [];
492
493
 
@@ -614,13 +615,20 @@ export async function selfCheck({ home = os.homedir(), repo = null, cwd = os.tmp
614
615
  }
615
616
 
616
617
  // (b) THE BATTERY
617
- const battery = await runBattery({ home, repo, cwd, regimes });
618
+ const battery = await runBattery({ home, repo, cwd, regimes, inspectOnly: true });
618
619
  if (!battery.ok) {
619
620
  lines.push(`hooks: ${battery.reason}`);
620
621
  violations.push({ kind: 'no-plugin', where: 'hooks', detail: battery.reason });
621
622
  } else {
622
623
  violations.push(...battery.violations);
623
- lines.push(`hooks: ${battery.registrations.length} registrations from ${battery.surface.source}, ${regimes.length} stdin regimes each (${battery.results.length} firings)`);
624
+ if (battery.registrations.length !== 0) {
625
+ violations.push({
626
+ kind: 'automatic-registration',
627
+ where: battery.surface.source,
628
+ detail: `${battery.registrations.length} retired Brain lifecycle registration(s) remain installed`,
629
+ });
630
+ }
631
+ lines.push(`hooks: ${battery.registrations.length} automatic registrations from ${battery.surface.source}; zero is the required retired state`);
624
632
  }
625
633
 
626
634
  // (c) COEXISTENCE — reported, never charged to the user
@@ -656,7 +664,7 @@ export function formatVerdict(result, { color = null } = {}) {
656
664
  const out = [];
657
665
  for (const l of result.lines) out.push(` ${c.dim(l)}`);
658
666
  if (!result.violations.length) {
659
- out.push(` ${c.green('✓ Self-check passed.')} Every shipped hook answered inside its contract on this machine.`);
667
+ out.push(` ${c.green('✓ Self-check passed.')} The installed Brain surface has zero automatic lifecycle registrations.`);
660
668
  return out.join('\n');
661
669
  }
662
670
  out.push(` ${c.red(`✗ Self-check FAILED — ${result.violations.length} contract violation(s):`)}`);
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * snapshot-freshness.mjs — "a claimed backup is not a found backup" as a shared, pure function.
4
+ *
5
+ * `distill-project.mjs` established this discipline (PR #192): `ruflo memory backup` exiting 0
6
+ * proves nothing about whether a NEW file actually landed — a stale snapshot from a prior run
7
+ * sitting in the same directory is otherwise indistinguishable from a genuine fresh one, and a
8
+ * caller that trusts the exit code alone can proceed to mutate state on a false undo guarantee.
9
+ * Extracted here so `health-repair.mjs`'s fleet distillation (a second, independent caller of
10
+ * `ruflo memory backup` per store) can share the exact same proof instead of re-deriving it —
11
+ * duplicating this logic once already cost a real, shipped defect (see the 2026-08-29 Dream Cycle
12
+ * report, "Next steps #1").
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ // Some filesystems truncate mtime to whole seconds (FAT32, some overlay/network mounts), so a file
18
+ // written a moment after `sinceMs` was captured can still report an mtime slightly before it. This
19
+ // grace window is tolerance for that truncation, not a loophole — it stays far smaller than the gap
20
+ // between one run and the next.
21
+ export const MTIME_GRACE_MS = 1500;
22
+
23
+ /**
24
+ * The newest matching snapshot file in `dir`, or null. With `sinceMs`, only a file whose mtime is
25
+ * no older than that moment (minus the grace window) counts. Mutating callers also provide a
26
+ * pre-backup inventory, so an unchanged prior snapshot cannot count within that grace. Callers that
27
+ * legitimately want the newest snapshot ever (e.g. a restore path) pass no `sinceMs`.
28
+ */
29
+ export function snapshotInventory(dir) {
30
+ let root;
31
+ try { root = fs.lstatSync(dir); }
32
+ catch (error) { if (error.code === 'ENOENT') return new Map(); throw error; }
33
+ if (!root.isDirectory() || root.isSymbolicLink()) throw new Error('Backup destination is not a regular directory');
34
+ const inventory = new Map();
35
+ for (const name of fs.readdirSync(dir)) {
36
+ if (!/\.(db|sqlite|bak)$/i.test(name) && !/memory.*\d/.test(name)) continue;
37
+ const file = path.join(dir, name);
38
+ try {
39
+ const stat = fs.lstatSync(file);
40
+ if (!stat.isFile() || stat.isSymbolicLink() || !stat.size) continue;
41
+ inventory.set(file, {
42
+ path: file, mtimeMs: stat.mtimeMs,
43
+ identity: [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':'),
44
+ });
45
+ } catch { /* a disappearing candidate is not a located backup */ }
46
+ }
47
+ return inventory;
48
+ }
49
+
50
+ export function newestSnapshot(dir, sinceMs = 0, graceMs = MTIME_GRACE_MS, before = null) {
51
+ try {
52
+ const files = [...snapshotInventory(dir).values()]
53
+ .filter(file => file.mtimeMs >= sinceMs - graceMs
54
+ && (!before || before.get(file.path)?.identity !== file.identity))
55
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
56
+ return files[0]?.path ?? null;
57
+ } catch { return null; }
58
+ }