ruvnet-brain 4.3.9 → 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 (83) 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 +63 -8
  65. package/scripts/publication-receipt.mjs +109 -45
  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-qualification-contract.mjs +87 -0
  75. package/scripts/release-qualification.mjs +135 -0
  76. package/scripts/release-transaction.mjs +81 -1
  77. package/scripts/remedy-registry.mjs +6 -13
  78. package/scripts/retrieval-canary.mjs +91 -18
  79. package/scripts/selfcheck.mjs +12 -4
  80. package/scripts/snapshot-freshness.mjs +58 -0
  81. package/scripts/stack-sync.mjs +41 -43
  82. package/scripts/staged-host-verifier.mjs +63 -14
  83. package/scripts/wired-check.mjs +31 -2
@@ -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
+ }
@@ -186,13 +186,15 @@ export function listInstalledPlugins(pluginsDir = PLUGINS_DIR) {
186
186
  // not equality" discipline as the rest of this file. Fall back to the manifest's own version field
187
187
  // if the on-disk plugin.json is unreadable, so a present plugin is NEVER reported "not installed".
188
188
  let installed = null;
189
+ const instances = [];
189
190
  for (const rec of Array.isArray(records) ? records : []) {
190
191
  const v = (rec && rec.installPath ? pluginVersion(rec.installPath) : null)
191
192
  || (rec && rec.version && rec.version !== 'unknown' ? rec.version : null);
193
+ instances.push({ scope: rec?.scope || 'unknown', installPath: rec?.installPath || null, version: v, readable: Boolean(rec?.installPath && pluginVersion(rec.installPath)) });
192
194
  if (!v) continue;
193
195
  if (installed === null || cmpVersion(v, installed) > 0) installed = v;
194
196
  }
195
- out.push({ name, installed, source: 'plugin', marketplace });
197
+ out.push({ name, installed, source: 'plugin', marketplace, instances });
196
198
  }
197
199
  return out;
198
200
  }
@@ -209,15 +211,8 @@ function listInstalled({ lib = GLOBAL_LIB, pluginsDir = PLUGINS_DIR } = {}) {
209
211
  }
210
212
  };
211
213
  scan(lib);
212
- // Merge in plugin-sourced tools (ISSUE #22). Dedup by name: a tool present BOTH globally and as a
213
- // plugin appears once, and the global-npm copy wins — it is the one classify() can compare against
214
- // npm dist-tags. A plugin-only tool is added, so it can never be reported "not installed".
215
- const seen = new Set(out.map((r) => r.name));
216
- for (const p of listInstalledPlugins(pluginsDir)) {
217
- if (seen.has(p.name)) continue;
218
- seen.add(p.name);
219
- out.push(p);
220
- }
214
+ // Preserve both distribution sources; one must not hide the other.
215
+ out.push(...listInstalledPlugins(pluginsDir));
221
216
  return out.sort((a, b) => a.name.localeCompare(b.name));
222
217
  }
223
218
 
@@ -258,14 +253,9 @@ export function findShadows(npxCache = NPX_CACHE, lib = GLOBAL_LIB) {
258
253
  // exits) share ONE classification, never two that can drift.
259
254
  export function classify(pkgs) {
260
255
  return pkgs.map((p) => {
261
- // ISSUE #22 — a Claude Code plugin tracks ITS MARKETPLACE's update cadence, not npm semver. There
262
- // is no npm dist-tag oracle for it (querying `npm view <plugin>` would compare against an unrelated
263
- // package or 404), so we do not manufacture a drift signal. It is present and installed ⇒ CURRENT;
264
- // a plugin is only BROKEN if we could not read any version at all. Either way it is COUNTED and is
265
- // never reported "not installed". This also keeps the npm-registry "blind tool" guard scoped to
266
- // real npm rows (see audit()/auditModel()).
256
+ // Installed records prove presence, not marketplace currency.
267
257
  if (p.source === 'plugin') {
268
- return { ...p, tag: 'plugin', target: p.installed, state: p.installed ? 'CURRENT' : 'BROKEN' };
258
+ return { ...p, tag: 'plugin', target: null, state: p.installed ? 'INSTALLED_UNVERIFIED' : 'BROKEN', evidence: 'local install record only; marketplace revision not checked' };
269
259
  }
270
260
  const want = TAG_POLICY[p.name] || DEFAULT_TAG;
271
261
  const tags = registryTags(p.name);
@@ -293,9 +283,7 @@ function audit() {
293
283
  // inside the fix for it. Silence is not health.
294
284
  const rows = classify(pkgs);
295
285
  const unresolved = rows.filter((r) => r.state === 'UNRESOLVED');
296
- // Guard scoped to npm rows (ISSUE #22): plugin rows are CURRENT by construction, so counting them
297
- // here would let a fully-unreachable registry hide behind a couple of installed plugins — the exact
298
- // "blind tool reports health" bug this guard exists to kill, reintroduced. Denominator = npm rows.
286
+ // Registry reachability is measured only against npm rows; plugins remain unverified.
299
287
  const npmRows = rows.filter((r) => r.source !== 'plugin');
300
288
  if (npmRows.length && unresolved.length === npmRows.length) {
301
289
  die(`could not reach the npm registry for ANY of ${npmRows.length} npm packages.\n` +
@@ -303,7 +291,7 @@ function audit() {
303
291
  }
304
292
 
305
293
  const shadows = findShadows();
306
- return { rows, unresolved, shadows, stale: shadows.filter((s) => s.global && s.version !== s.global) };
294
+ return { rows, unresolved, shadows, stale: shadows.filter((s) => s.global && isBehind(s.version, s.global)) };
307
295
  }
308
296
 
309
297
  // Non-exiting audit for embedders (the Onboarding Console). Same measurement as audit(), but returns
@@ -316,32 +304,44 @@ export function auditModel() {
316
304
  const rows = classify(pkgs);
317
305
  const unresolved = rows.filter((r) => r.state === 'UNRESOLVED');
318
306
  const shadows = findShadows();
319
- const stale = shadows.filter((s) => s.global && s.version !== s.global);
307
+ const stale = shadows.filter((s) => s.global && isBehind(s.version, s.global));
320
308
  // Same npm-scoped guard as audit() (ISSUE #22): plugin rows never count toward "registry unreachable".
321
309
  const npmRows = rows.filter((r) => r.source !== 'plugin');
322
310
  const error = npmRows.length && unresolved.length === npmRows.length
323
311
  ? `could not reach the npm registry for any of ${npmRows.length} npm packages` : null;
324
- return { rows, unresolved, shadows, stale, error };
312
+ return { rows, unresolved, shadows, stale, error, summary: summarizeAudit({ rows, shadows }) };
313
+ }
314
+
315
+ export function summarizeAudit({ rows, shadows }) {
316
+ const count = (state) => rows.filter((row) => row.state === state).length;
317
+ const summary = { behind: count('BEHIND'), broken: count('BROKEN'),
318
+ unverified: count('INSTALLED_UNVERIFIED') + count('UNRESOLVED'), shadows: shadows.length,
319
+ stale: shadows.filter((s) => s.global && isBehind(s.version, s.global)).length };
320
+ summary.verdict = summary.behind || summary.broken || summary.shadows ? 'DRIFT'
321
+ : summary.unverified || !rows.length ? 'UNKNOWN' : 'PASS';
322
+ summary.exitCode = summary.verdict === 'PASS' ? 0 : summary.verdict === 'UNKNOWN' ? 4 : 1;
323
+ return summary;
325
324
  }
326
325
 
327
326
  function report({ rows, shadows, stale }) {
328
- const w = Math.max(...rows.map((r) => r.name.length));
327
+ const w = Math.max(1, ...rows.map((r) => r.name.length));
329
328
  const nPlugin = rows.filter((r) => r.source === 'plugin').length;
330
329
  const nNpm = rows.length - nPlugin;
331
- log(`\n RuvNet stack — ${rows.length} packages (${nNpm} npm-global, ${nPlugin} Claude Code plugin)\n`);
330
+ log(`\n RuvNet stack — ${rows.length} distribution records (${nNpm} npm-global, ${nPlugin} Claude Code plugin)\n`);
332
331
  for (const r of rows) {
333
- const mark = { CURRENT: ' ok ', BEHIND: 'BEHIND', AHEAD: ' ahead', BROKEN: 'BROKEN', UNRESOLVED: ' ?? ' }[r.state];
332
+ const mark = { CURRENT: ' ok ', BEHIND: 'BEHIND', AHEAD: ' ahead', BROKEN: 'BROKEN', UNRESOLVED: ' ?? ', INSTALLED_UNVERIFIED: 'UNVERIFIED' }[r.state];
334
333
  const detail = r.state === 'BEHIND' ? `${r.installed} -> ${r.target} (@${r.tag})`
335
334
  : r.state === 'AHEAD' ? `${r.installed} (ahead of @${r.tag} ${r.target} — alpha track; left alone)`
336
335
  : r.state === 'BROKEN' ? `no readable version on disk; registry has ${r.target ?? '?'}`
337
- : r.source === 'plugin' ? `${r.installed} (plugin · ${r.marketplace} marketplace)`
336
+ : r.source === 'plugin' ? `${r.installed} (plugin · ${r.marketplace}; marketplace currency NOT CHECKED; ${r.instances?.length || 1} scope record(s))`
338
337
  : r.installed;
339
338
  log(` [${mark}] ${r.name.padEnd(w)} ${detail}`);
339
+ if (r.instances?.length > 1) for (const instance of r.instances) log(` ${instance.scope}: ${instance.version || 'UNREADABLE'} (${instance.readable ? 'disk' : 'record only'})`);
340
340
  }
341
341
  if (shadows.length) {
342
342
  log(`\n npx shadow copies:`);
343
343
  for (const s of shadows) {
344
- log(` ${s.name}@${s.version}${s.global && s.version !== s.global ? ` STALE — global is ${s.global}` : ''}`);
344
+ log(` ${s.name}@${s.version}${s.global && isBehind(s.version, s.global) ? ` STALE — global is ${s.global}` : ''}`);
345
345
  }
346
346
  }
347
347
  log('');
@@ -391,8 +391,9 @@ function sync({ dryRun = false } = {}) {
391
391
  const toInstall = [...behind, ...broken.filter((r) => r.target)].map((r) => `${r.name}@${r.target}`);
392
392
 
393
393
  if (!toInstall.length && !stale.length) {
394
- log(' one copy of everything, all current, no shadows. Nothing to do.');
395
- writeReceipt(a, [], []);
394
+ log(` No npm repair selected. Audit: ${JSON.stringify(summarizeAudit(a))}`);
395
+ if (!dryRun) writeReceipt(a, [], []);
396
+ process.exitCode = summarizeAudit(a).exitCode;
396
397
  return;
397
398
  }
398
399
  if (dryRun) {
@@ -431,7 +432,10 @@ function sync({ dryRun = false } = {}) {
431
432
  }
432
433
  if (wrong.length) die(`npm reported success but THE DISK DISAGREES:\n - ${wrong.join('\n - ')}`);
433
434
 
434
- writeReceipt(audit(), toInstall, purged);
435
+ const after = audit();
436
+ writeReceipt(after, toInstall, purged);
437
+ process.exitCode = summarizeAudit(after).exitCode;
438
+ log(` Remaining audit: ${JSON.stringify(summarizeAudit(after))}`);
435
439
  log(`\n synced ${toInstall.length} package(s); purged ${purged.length} shadow(s); verified against disk.`);
436
440
  }
437
441
 
@@ -440,18 +444,12 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('stack-sync.mjs'))
440
444
  const args = process.argv.slice(2);
441
445
  if (args.includes('--audit')) {
442
446
  const a = audit();
443
- const { behind, broken, stale } = report(a);
444
- // UNRESOLVED counts as drift, not as health: a package we could not measure is a package we
445
- // cannot vouch for. Reporting "current" for something we never checked is the lie this tool exists to stop.
446
- const unres = a.unresolved.length;
447
- const bad = behind.length + broken.length + stale.length + unres;
448
- if (bad) {
449
- log(` DRIFT: ${behind.length} behind, ${broken.length} broken, ${stale.length} stale shadow(s)` +
450
- (unres ? `, ${unres} UNMEASURED (registry unreachable)` : '') + '.');
451
- log(` Fix: node scripts/stack-sync.mjs --sync\n`);
452
- process.exit(1); // non-zero: a watchdog must never call a drifted stack "green"
453
- }
454
- log(' one copy of everything, all current, no shadows.\n');
447
+ report(a);
448
+ const summary = summarizeAudit(a);
449
+ log(` Audit: ${JSON.stringify(summary)}`);
450
+ if (summary.unverified) log(' Required: verify each plugin against its marketplace revision and each unresolved npm target against registry tags.');
451
+ if (summary.shadows) log(' Cached copies are present; inspect invocation paths before removing any cache. No copies removed by audit.');
452
+ process.exitCode = summary.exitCode;
455
453
  } else if (args.includes('--sync')) {
456
454
  sync({ dryRun: args.includes('--dry-run') });
457
455
  } else {
@@ -4,8 +4,48 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { runHostMatrixAsync } from './host-install-matrix.mjs';
7
+ import { validatePlanAgainstCoverage } from './retrieval-canary.mjs';
8
+ import { extractZip } from '../kb/zip-extract.mjs';
9
+ import { tarExtractionInvocation } from './publication-receipt.mjs';
7
10
 
8
11
  const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
12
+
13
+ export function readCandidateRetrieval({ manifest, planFile, coverageFile }) {
14
+ if (!planFile || !coverageFile) throw new Error('candidate retrieval requires sealed plan and coverage files');
15
+ const plan = JSON.parse(fs.readFileSync(planFile));
16
+ const coverageBytes = fs.readFileSync(coverageFile);
17
+ const coverage = JSON.parse(coverageBytes);
18
+ // An observed baseline defines the delta only; it never supplies candidate PASS evidence.
19
+ validatePlanAgainstCoverage(plan, coverage, { allowObservedBaseline: true });
20
+ const npm = manifest.members?.filter(({ role }) => role === 'npm') || [];
21
+ const bundle = manifest.members?.filter(({ role }) => role === 'bundle') || [];
22
+ const coverageSha256 = crypto.createHash('sha256').update(coverageBytes).digest('hex');
23
+ if (npm.length !== 1 || bundle.length !== 1
24
+ || plan.candidate.sourceSha !== manifest.candidateSha
25
+ || plan.candidate.packageSha256 !== npm[0].sha256 || plan.candidate.archiveSha256 !== bundle[0].sha256
26
+ || plan.coverage.sha256 !== coverageSha256 || plan.candidate.coverageSha256 !== coverageSha256
27
+ || plan.coverage.bytes !== coverageBytes.length
28
+ || coverage.releaseIdentity?.sourceSnapshot !== manifest.candidateSha
29
+ || coverage.releaseIdentity?.version !== manifest.version || coverage.releaseIdentity?.tag !== manifest.tag
30
+ || plan.candidate.publicLedgerSha256 !== coverage.generationLedger?.sha256
31
+ || plan.candidate.publicLedgerBytes !== coverage.generationLedger?.bytes
32
+ || plan.candidate.publicStoreCount !== coverage.generationLedger?.storeCount
33
+ || plan.candidate.publicInventoryPartitionSha256 !== coverage.publicInventoryPartitionSha256) {
34
+ throw new Error('candidate retrieval plan, coverage or artifact identity mismatch');
35
+ }
36
+ return { plan, sourceSha: manifest.candidateSha, artifactSha256: npm[0].sha256,
37
+ candidateArchiveSha256: bundle[0].sha256, packageBytes: npm[0].size, bundleBytes: bundle[0].size };
38
+ }
39
+
40
+ export function verifyCandidateRetrievalAssets({ retrieval, assets }) {
41
+ for (const [file, digest, bytes] of [[assets.packagePath, retrieval.artifactSha256, retrieval.packageBytes],
42
+ [assets.bundlePath, retrieval.candidateArchiveSha256, retrieval.bundleBytes]]) {
43
+ if (!Number.isSafeInteger(bytes) || bytes < 1 || fs.statSync(file).size !== bytes || sha256(file) !== digest) {
44
+ throw new Error(`candidate artifact does not match sealed retrieval input: ${file}`);
45
+ }
46
+ }
47
+ return true;
48
+ }
9
49
  const locate = (name) => {
10
50
  try { return execFileSync('which', [name], { encoding: 'utf8' }).trim(); } catch { return null; }
11
51
  };
@@ -23,25 +63,32 @@ const run = (name, args, options) => {
23
63
  // tests/unit/staged-host-verifier.test.mjs pins it, but it is now an alias, not a second copy.
24
64
  export { classifyDoctor as classifyDoctorResult } from './host-install-matrix.mjs';
25
65
 
26
- const preparePackage = ({ packagePath, bundlePath }) => {
66
+ const preparePackage = async ({ packagePath, bundlePath }) => {
27
67
  const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-staged-host-'));
28
- run('tar', ['-xzf', packagePath, '-C', temp]);
29
- const packageRoot = path.join(temp, 'package');
30
- const bundleRoot = path.join(packageRoot, 'dist', 'ruvnet-brain');
31
- fs.mkdirSync(bundleRoot, { recursive: true });
32
- run('unzip', ['-q', bundlePath, '-d', bundleRoot]);
33
- const nested = path.join(bundleRoot, 'ruvnet-brain');
34
- if (fs.existsSync(nested)) {
35
- for (const name of fs.readdirSync(nested)) fs.renameSync(path.join(nested, name), path.join(bundleRoot, name));
36
- fs.rmdirSync(nested);
68
+ try {
69
+ const extraction = tarExtractionInvocation(packagePath, temp);
70
+ run('tar', extraction.args, { cwd: extraction.cwd });
71
+ const packageRoot = path.join(temp, 'package');
72
+ const bundleRoot = path.join(packageRoot, 'dist', 'ruvnet-brain');
73
+ fs.mkdirSync(bundleRoot, { recursive: true });
74
+ await extractZip(bundlePath, bundleRoot);
75
+ const nested = path.join(bundleRoot, 'ruvnet-brain');
76
+ if (fs.existsSync(nested)) {
77
+ for (const name of fs.readdirSync(nested)) fs.renameSync(path.join(nested, name), path.join(bundleRoot, name));
78
+ fs.rmdirSync(nested);
79
+ }
80
+ return { temp, packageRoot };
81
+ } catch (error) {
82
+ fs.rmSync(temp, { recursive: true, force: true });
83
+ throw error;
37
84
  }
38
- return { temp, packageRoot };
39
85
  };
40
86
 
41
- export function stagedHostVerifier({ assets, identity }) {
87
+ export function stagedHostVerifier({ assets, identity, retrieval }, { runMatrix = runHostMatrixAsync } = {}) {
42
88
  return {
43
89
  async verify({ source, assets: observedAssets = assets }) {
44
- const prepared = preparePackage(observedAssets);
90
+ if (retrieval) verifyCandidateRetrievalAssets({ retrieval, assets: observedAssets });
91
+ const prepared = await preparePackage(observedAssets);
45
92
  try {
46
93
  // The loop, the mode names, the env and the doctor verdict all live in
47
94
  // scripts/host-install-matrix.mjs, shared with the published-side check in
@@ -50,13 +97,15 @@ export function stagedHostVerifier({ assets, identity }) {
50
97
  // install shape than the one that runs after publication — so the two halves of a release
51
98
  // were judging different things and could not be compared. Only the STAGED-vs-PUBLISHED
52
99
  // difference is real, and it is now a named variant rather than a second implementation.
53
- const matrix = await runHostMatrixAsync({
100
+ const matrix = await runMatrix({
54
101
  packageRoot: prepared.packageRoot,
55
102
  version: identity.version,
56
103
  variant: 'staged',
57
104
  locate,
58
105
  temp: prepared.temp,
106
+ retrieval,
59
107
  });
108
+ if (retrieval) verifyCandidateRetrievalAssets({ retrieval, assets: observedAssets });
60
109
  if (matrix.verdict !== 'PASS') {
61
110
  return { verdict: 'FAIL', source, error: matrix.error, fixtures: matrix.fixtures };
62
111
  }