ruvnet-brain 4.3.20 → 4.3.25

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 (143) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +383 -78
  3. package/console/app.js +141 -9
  4. package/console/index.html +51 -24
  5. package/console/scope.css +137 -0
  6. package/console/scope.html +144 -0
  7. package/console/scope.js +209 -0
  8. package/console/tips.html +1 -0
  9. package/kb/corpus-release-identity.mjs +239 -0
  10. package/kb/update-storage-transaction.mjs +20 -3
  11. package/package.json +9 -2
  12. package/plugin/.claude-plugin/plugin.json +2 -2
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/commands/checkpoint.md +61 -0
  15. package/plugin/hooks/codex-hooks.json +64 -1
  16. package/plugin/hooks/hook-contracts.json +299 -6
  17. package/plugin/hooks/hooks.json +81 -1
  18. package/plugin/mcp/server.mjs +23 -0
  19. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  20. package/plugin/scripts/advocacy-route.mjs +460 -0
  21. package/plugin/scripts/continuation-gate.mjs +25 -2
  22. package/plugin/scripts/continuation-objective.mjs +7 -1
  23. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  24. package/plugin/scripts/coverage-integrity.mjs +7 -0
  25. package/plugin/scripts/gates.mjs +113 -10
  26. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  27. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  28. package/plugin/scripts/hook-shim.mjs +14 -0
  29. package/plugin/scripts/host-shell-boundary.mjs +43 -0
  30. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  31. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  32. package/plugin/scripts/project-progression-contract.mjs +16 -0
  33. package/plugin/scripts/project-progression-hook.mjs +3 -0
  34. package/plugin/scripts/project-progression-producer.mjs +252 -0
  35. package/plugin/scripts/project-progression-reader.mjs +271 -0
  36. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  37. package/plugin/scripts/project-progression-sources.mjs +220 -0
  38. package/plugin/scripts/project-progression-store.mjs +106 -13
  39. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  40. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  41. package/plugin/scripts/session-start-budget.mjs +59 -0
  42. package/plugin/scripts/session-start-core.mjs +234 -457
  43. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  44. package/plugin/scripts/session-start-health.mjs +64 -0
  45. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  46. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  47. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  48. package/plugin/scripts/session-start-signals.mjs +73 -0
  49. package/plugin/scripts/session-start-trace.mjs +86 -0
  50. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  51. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  52. package/plugin/scripts/update-apply.mjs +2 -32
  53. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  54. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  55. package/scripts/adr-072-completion.mjs +1 -1
  56. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  57. package/scripts/approved-runtime.mjs +197 -0
  58. package/scripts/brain-novice-50.mjs +16 -1
  59. package/scripts/brain-score.mjs +23 -5
  60. package/scripts/build-bundle.mjs +971 -530
  61. package/scripts/build-concepts.mjs +36 -116
  62. package/scripts/console-engine.test.mjs +8 -7
  63. package/scripts/console-runtime-identity.mjs +4 -0
  64. package/scripts/corpus-aggregates.mjs +94 -77
  65. package/scripts/corpus-candidate.mjs +475 -222
  66. package/scripts/corpus-next-seed.mjs +225 -0
  67. package/scripts/corpus-promotion.mjs +58 -0
  68. package/scripts/corpus-reconcile.mjs +411 -105
  69. package/scripts/doc-currency.mjs +16 -1
  70. package/scripts/dual-host-deliberation.mjs +25 -2
  71. package/scripts/dual-host-suggest.mjs +17 -1
  72. package/scripts/falsify.mjs +13 -3
  73. package/scripts/gist-receipts.mjs +482 -87
  74. package/scripts/github-health-watch.mjs +12 -2
  75. package/scripts/handoff-asset.mjs +34 -0
  76. package/scripts/hook-retirement-check.mjs +8 -1
  77. package/scripts/host-registry.mjs +1 -1
  78. package/scripts/ingest-gists.mjs +74 -101
  79. package/scripts/job-heartbeat.sh +77 -14
  80. package/scripts/learning-replay-execution.mjs +10 -4
  81. package/scripts/nightly-gists.sh +27 -13
  82. package/scripts/nightly-two-run-proof.mjs +1 -1
  83. package/scripts/nightly-watchdog.mjs +61 -4
  84. package/scripts/onboarding-console.mjs +319 -27
  85. package/scripts/oracle/produce-questions.mjs +293 -0
  86. package/scripts/oracle/producer-hosts.mjs +235 -0
  87. package/scripts/oracle/repo-recall.mjs +448 -0
  88. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  89. package/scripts/oracle/source-tree.mjs +165 -0
  90. package/scripts/oracle/source-units.mjs +391 -0
  91. package/scripts/oracle/spike-run.mjs +98 -0
  92. package/scripts/oracle/unit-inventory.mjs +141 -0
  93. package/scripts/oracle/unit-sampling.mjs +128 -0
  94. package/scripts/oracle/validate-labels.mjs +250 -0
  95. package/scripts/private-overlay.mjs +248 -0
  96. package/scripts/product-integrity-contract.mjs +1 -1
  97. package/scripts/proxy/claude-proxied.sh +6 -0
  98. package/scripts/proxy/proxy-revert.sh +5 -0
  99. package/scripts/proxy/proxy-up.sh +6 -0
  100. package/scripts/proxy/proxy-verify.mjs +4 -0
  101. package/scripts/public-inputs.mjs +409 -0
  102. package/scripts/public-verification-inputs.mjs +112 -26
  103. package/scripts/public-verification-lane.mjs +1 -1
  104. package/scripts/published-surface-probe.mjs +34 -4
  105. package/scripts/qe/card-lane-gate.mjs +16 -1
  106. package/scripts/qe/session-start-gate.mjs +16 -1
  107. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  108. package/scripts/record-lesson.mjs +4 -1
  109. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  110. package/scripts/release-abort-stale.mjs +5 -1
  111. package/scripts/release-authority.mjs +104 -12
  112. package/scripts/release-channel-kind.mjs +86 -0
  113. package/scripts/release-convergence-watchdog.mjs +7 -2
  114. package/scripts/release-projection.mjs +177 -72
  115. package/scripts/release-transaction-provider.mjs +23 -6
  116. package/scripts/release.mjs +252 -17
  117. package/scripts/retrieval-canary.mjs +87 -0
  118. package/scripts/rvf-index-audit.mjs +573 -13
  119. package/scripts/rvf-wire.mjs +269 -0
  120. package/scripts/seal-gist-receipt.mjs +65 -0
  121. package/scripts/selfcheck.mjs +42 -21
  122. package/scripts/source-coverage.mjs +253 -24
  123. package/scripts/status-honesty.mjs +25 -0
  124. package/scripts/sync-census.mjs +0 -0
  125. package/scripts/sync-version.mjs +2 -0
  126. package/scripts/trismart.mjs +42 -0
  127. package/scripts/updater-manifest.mjs +162 -0
  128. package/scripts/verify-channels.mjs +17 -5
  129. package/scripts/wired-check.mjs +48 -10
  130. package/tri-smart-skill/QUICKSTART.md +37 -0
  131. package/tri-smart-skill/README.md +92 -0
  132. package/tri-smart-skill/install.cmd +14 -0
  133. package/tri-smart-skill/install.command +13 -0
  134. package/tri-smart-skill/install.mjs +51 -0
  135. package/tri-smart-skill/install.sh +9 -0
  136. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  137. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  138. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  139. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  140. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  141. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  142. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  143. package/scripts/corpus-seed-publish.mjs +0 -110
@@ -1,23 +1,47 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import fs from 'node:fs';
4
+ import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { spawnSync } from 'node:child_process';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { readRvfGenerations, sha256File } from './rvf-generation.mjs';
8
9
  import { canonicalJson, coverageGenerationFor, digest, validateGistAggregateReceipt } from './coverage-integrity.mjs';
9
10
  import { repositoryNames } from '../kb/card-lane.mjs';
11
+ import { rootNeverMaterialized, storeRoot } from '../kb/store-root.mjs';
10
12
 
11
13
  export { canonicalJson, digest } from './coverage-integrity.mjs';
12
14
 
13
15
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+
17
+ // SOURCE POLICY VERSION — sealed into every coverage as `policy.policyVersion` so a recorded
18
+ // measurement produced under an older eligibility policy is DETECTABLE (main --check names the gap)
19
+ // rather than silently compared row-for-row against a generator that classifies differently.
20
+ // 1 — forks and archives INELIGIBLE by flag; anonymous gist fallback capped at 10 pages;
21
+ // no-corpus exclusions active on a pushedAt match alone.
22
+ // 2 — ADR-086 Step 12 (2026-09-13). Archives eligible unconditionally. Forks dispositioned every run
23
+ // by the compare API pinned to both observed heads: ahead_by>0 → `fork:original-content`
24
+ // (ingestible, delta-only), ahead_by=0 → `fork:no-original-content` (recorded with the upstream
25
+ // identity). Gist fallback paginates to an empty page under a throwing ceiling. An active
26
+ // exclusion must carry source-file evidence bound to the observed head.
27
+ export const SOURCE_POLICY_VERSION = 2;
28
+
29
+ // Dispositions whose rows the corpus is expected to contain. `fork:original-content` is deliberately
30
+ // NOT spelled `eligible`: scripts/corpus-reconcile.mjs full-clones every `eligible` repository row,
31
+ // and a full clone of a fork ingests upstream authors' commits under rUv's name — the misattribution
32
+ // the #286 RC3 repo-attribution guard exists to stop. The delta-only build that consumes this
33
+ // disposition is corpus-reconcile's work; this layer records what it needs (`forkDelta`).
34
+ const INGESTIBLE_DISPOSITIONS = new Set(['eligible', 'fork:original-content']);
35
+ export function isIngestibleDisposition(disposition) { return INGESTIBLE_DISPOSITIONS.has(disposition); }
36
+
14
37
  const REPO_QUERY = `query($login:String!,$cursor:String){
15
38
  user(login:$login){
16
39
  publicRepositories:repositories(privacy:PUBLIC){totalCount}
17
40
  repositories(first:100,after:$cursor,privacy:PUBLIC,ownerAffiliations:OWNER,orderBy:{field:NAME,direction:ASC}){
18
41
  pageInfo{hasNextPage endCursor}
19
42
  nodes{databaseId name url description homepageUrl isFork isArchived isDisabled diskUsage updatedAt pushedAt
20
- defaultBranchRef{name target{... on Commit{oid committedDate}}}}
43
+ defaultBranchRef{name target{... on Commit{oid committedDate}}}
44
+ parent{nameWithOwner isPrivate defaultBranchRef{name target{... on Commit{oid}}}}}
21
45
  }
22
46
  }
23
47
  }`;
@@ -28,6 +52,34 @@ function runGh(args) {
28
52
  return result.stdout;
29
53
  }
30
54
 
55
+ // A fork's disposition depends on how many commits it carries that its upstream does not. The
56
+ // compare is pinned to the two heads THIS observation recorded (never to branch names, which move),
57
+ // and is run against the upstream repository in GitHub's documented cross-fork form
58
+ // `BASE...FORK_OWNER:HEAD` (verified live 2026-09-13: ahead_by/behind_by/status/merge_base_commit).
59
+ // Any failure here — 403, 404, an upstream GitHub cannot name — is required content that is
60
+ // inaccessible, and it fails the observation rather than skipping the fork.
61
+ function observeForkDelta(owner, node, gh) {
62
+ const forkHeadSha = node.defaultBranchRef?.target?.oid;
63
+ const upstream = node.parent?.nameWithOwner;
64
+ if (!upstream) throw new Error(`fork ${node.name} has no upstream parent GitHub can name — its original-content delta cannot be established`);
65
+ const upstreamHeadSha = node.parent.defaultBranchRef?.target?.oid;
66
+ if (!upstreamHeadSha) throw new Error(`fork ${node.name}: upstream ${upstream} has no default-branch head to compare against`);
67
+ let compare;
68
+ try {
69
+ compare = JSON.parse(gh(['api', `repos/${upstream}/compare/${upstreamHeadSha}...${owner}:${forkHeadSha}`]));
70
+ } catch (error) {
71
+ throw new Error(`fork ${node.name}: compare against upstream ${upstream} failed — ${error.message}`);
72
+ }
73
+ if (!Number.isInteger(compare?.ahead_by) || !Number.isInteger(compare?.behind_by)) {
74
+ throw new Error(`fork ${node.name}: compare against upstream ${upstream} returned no ahead_by/behind_by`);
75
+ }
76
+ return {
77
+ upstream, upstreamDefaultBranch: node.parent.defaultBranchRef.name, upstreamHeadSha, forkHeadSha,
78
+ mergeBaseSha: compare.merge_base_commit?.sha || null,
79
+ aheadBy: compare.ahead_by, behindBy: compare.behind_by, status: compare.status || null,
80
+ };
81
+ }
82
+
31
83
  export function observeRepositories(owner, { gh = runGh } = {}) {
32
84
  const rows = [];
33
85
  const pages = [];
@@ -45,7 +97,10 @@ export function observeRepositories(owner, { gh = runGh } = {}) {
45
97
  } while (cursor);
46
98
  const expected = rows.length ? Number(JSON.parse(gh(['api', `users/${owner}`])).public_repos) : 0;
47
99
  if (rows.length !== expected) throw new Error(`repository enumeration incomplete: ${rows.length}/${expected}`);
48
- return { rows, expected, pages };
100
+ // A fork with no head has nothing to compare; classifyRepository records it as `empty`.
101
+ const withDeltas = rows.map((node) => (node?.isFork && node.defaultBranchRef?.target?.oid
102
+ ? { ...node, forkDelta: observeForkDelta(owner, node, gh) } : node));
103
+ return { rows: withDeltas, expected, pages };
49
104
  }
50
105
 
51
106
  export function observeExternalRepositories(sources, { gh = runGh } = {}) {
@@ -128,6 +183,13 @@ export function canonicalRepositoryRows(rows = []) {
128
183
  homepageUrl: repo?.homepageUrl ?? null,
129
184
  isFork: repo?.isFork === true,
130
185
  ...(repo?.upstreamIsFork === undefined ? {} : { upstreamIsFork: repo.upstreamIsFork === true }),
186
+ // Sealed into the source identity: the delta baseline (both heads, ahead/behind) IS what was observed.
187
+ ...(repo?.forkDelta === undefined ? {} : { forkDelta: {
188
+ upstream: repo.forkDelta.upstream, upstreamDefaultBranch: repo.forkDelta.upstreamDefaultBranch ?? null,
189
+ upstreamHeadSha: repo.forkDelta.upstreamHeadSha, forkHeadSha: repo.forkDelta.forkHeadSha,
190
+ mergeBaseSha: repo.forkDelta.mergeBaseSha ?? null,
191
+ aheadBy: repo.forkDelta.aheadBy, behindBy: repo.forkDelta.behindBy, status: repo.forkDelta.status ?? null,
192
+ } }),
131
193
  isArchived: repo?.isArchived === true,
132
194
  isDisabled: repo?.isDisabled === true,
133
195
  diskUsage: repo?.diskUsage,
@@ -188,11 +250,64 @@ export function gistVersion(gist) {
188
250
  return versions.length && new Set(versions).size === 1 ? versions[0] : null;
189
251
  }
190
252
 
191
- export function observeGists(owner, { gh = runGh } = {}) {
192
- const raw = gh(['api', `users/${owner}/gists?per_page=100`, '--paginate', '--slurp']);
193
- const parsed = JSON.parse(raw);
194
- const pages = Array.isArray(parsed[0]) ? parsed : [parsed];
253
+ // Test seam: same injection pattern as `gh = runGh` above, so the fallback's failure path is
254
+ // exercisable with a fake HTTP layer instead of the live API or even real loopback networking.
255
+ // Reads the API base fresh per call (not frozen at module load) for the same reason.
256
+ function runCurl(url) {
257
+ const result = spawnSync('curl', ['-sS', '--max-time', '30', '-H', 'accept: application/vnd.github+json',
258
+ '-H', 'user-agent: ruvnet-brain-source-coverage', url],
259
+ { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 35_000 });
260
+ if (result.status !== 0) throw new Error(`unauthenticated gists list failed: ${(result.stderr || '').trim()}`);
261
+ return result.stdout;
262
+ }
263
+
264
+ // Actions' GITHUB_TOKEN is a GitHub App token, and the gists list API is closed to those
265
+ // ("Resource not accessible by integration", HTTP 403 — confirmed live 2026-09-13, corpus-seed.yml's
266
+ // first-ever run). Public gists need no auth, so fall back to curl against the plain API on that
267
+ // specific failure, mirroring ingest-gists.mjs's already-working listGists/listGistsPublic split
268
+ // (kept synchronous here, matching this module's existing gh-injection style, rather than
269
+ // threading async through observeSourceUniverse/buildCoverage's whole synchronous call chain).
270
+ //
271
+ // ADR-086 Step 12 (A1): the former `page <= 10` bound was NOT a silent-omission bug — observeGists
272
+ // throws on rows.length !== public_gists, so beyond 1000 gists the run failed closed — but it was a
273
+ // ceiling the account could grow into. Pagination now runs to the ACTUAL end: the only terminator is
274
+ // an EMPTY page (a short page followed by more must not stop early), under a hard ceiling that
275
+ // THROWS rather than truncating. The rows.length === public_gists check below remains the one and
276
+ // only completeness proof; nothing here adds a second invariant.
277
+ const GIST_PAGE_CEILING = 100; // 10,000 gists at per_page=100
278
+
279
+ function listGistsUnauthenticated(owner, curl) {
280
+ const apiBase = process.env.RUVNET_GISTS_API || 'https://api.github.com';
281
+ const pages = [];
282
+ for (let page = 1; ; page++) {
283
+ if (page > GIST_PAGE_CEILING) {
284
+ throw new Error(`unauthenticated gists list exceeded ${GIST_PAGE_CEILING} pages without reaching an empty page`);
285
+ }
286
+ const parsed = JSON.parse(curl(`${apiBase}/users/${owner}/gists?per_page=100&page=${page}`));
287
+ if (parsed?.message) throw new Error(`unauthenticated gists list failed: ${parsed.message}`);
288
+ if (!Array.isArray(parsed)) throw new Error('unauthenticated gists list returned an unexpected shape');
289
+ pages.push(parsed); // the empty terminal page is kept: it is the receipt that the end was reached
290
+ if (parsed.length === 0) break;
291
+ }
292
+ return pages;
293
+ }
294
+
295
+ export function observeGists(owner, { gh = runGh, curl = runCurl } = {}) {
296
+ let pages;
297
+ try {
298
+ const raw = gh(['api', `users/${owner}/gists?per_page=100`, '--paginate', '--slurp']);
299
+ const parsed = JSON.parse(raw);
300
+ pages = Array.isArray(parsed[0]) ? parsed : [parsed];
301
+ } catch (error) {
302
+ if (!/resource not accessible by integration|HTTP 403/i.test(String(error.message))) throw error;
303
+ pages = listGistsUnauthenticated(owner, curl);
304
+ }
195
305
  const rows = pages.flat().filter((gist) => gist?.id);
306
+ // Identity, not completeness (A1 forbids a second completeness invariant): a gist listed twice is
307
+ // collection-time source movement shifting pages under the cursor, and the same id could then mask
308
+ // a missed one inside an equal count. Mirrors the duplicate-repository check in observeSourceUniverse.
309
+ const ids = rows.map((gist) => String(gist.id));
310
+ if (new Set(ids).size !== ids.length) throw new Error('gist enumeration returned duplicate gist identities (collection-time source movement)');
196
311
  const expected = Number(JSON.parse(gh(['api', `users/${owner}`])).public_gists);
197
312
  if (rows.length !== expected) throw new Error(`gist enumeration incomplete: ${rows.length}/${expected}`);
198
313
  return {
@@ -205,17 +320,50 @@ export function observeGists(owner, { gh = runGh } = {}) {
205
320
 
206
321
  function storeName(name) { return String(name).toLowerCase(); }
207
322
 
323
+ // A no-corpus exclusion is admissible only with INDEPENDENT source-file evidence bound to the exact
324
+ // revision it excludes: the file inventory actually read from the repository tree at the observed
325
+ // head. "0 chunks produced" is an extraction outcome, not proof of an empty source (Dual, Step 12).
326
+ // A record that matches pushedAt but carries no such evidence is a malformed policy entry, and it
327
+ // throws — it must never quietly remove a source from the collected set.
328
+ function assertExclusionEvidence(repo, exclusion, upstreamSha) {
329
+ const evidence = exclusion?.evidence;
330
+ const sha = String(evidence?.headSha || '');
331
+ if (!evidence || typeof evidence !== 'object' || !Array.isArray(evidence.files) || typeof evidence.inspectedAt !== 'string'
332
+ || typeof evidence.method !== 'string') {
333
+ throw new Error(`no-corpus exclusion for ${repo.name} is active but carries no source-file evidence ` +
334
+ '(zero extracted chunks is not proof of an empty source)');
335
+ }
336
+ if (!/^[0-9a-f]{40}$/i.test(sha) || sha.toLowerCase() !== String(upstreamSha || '').toLowerCase()) {
337
+ throw new Error(`no-corpus exclusion for ${repo.name}: evidence headSha ${sha || '(none)'} is not the observed head ${upstreamSha}`);
338
+ }
339
+ }
340
+
208
341
  export function classifyRepository(repo, evidence, exclusion = null) {
209
- const activeExclusion = exclusion && String(exclusion.pushedAt || '') !== ''
210
- && String(exclusion.pushedAt) === String(repo.pushedAt || '');
211
- const disposition = activeExclusion ? 'excluded-no-corpus' : repo.isFork ? 'fork' : repo.isArchived ? 'archived' :
212
- repo.isDisabled ? 'disabled' : !repo.defaultBranchRef ? 'empty' : 'eligible';
213
342
  const upstreamSha = repo.defaultBranchRef?.target?.oid || null;
343
+ const activeExclusion = Boolean(exclusion && String(exclusion.pushedAt || '') !== ''
344
+ && String(exclusion.pushedAt) === String(repo.pushedAt || ''));
345
+ if (activeExclusion) assertExclusionEvidence(repo, exclusion, upstreamSha);
346
+ if (repo.isFork && repo.defaultBranchRef && !repo.forkDelta) {
347
+ throw new Error(`fork ${repo.name} was observed without a fork delta — the observation predates policy ${SOURCE_POLICY_VERSION} or skipped the compare`);
348
+ }
349
+ // Policy 2: archives are eligible unconditionally (no `archived` branch); forks are dispositioned
350
+ // by their delta. Order: an explicit exclusion wins, then GitHub-side unavailability, then emptiness
351
+ // (a fork with no head has nothing to compare), then the fork delta, then eligible.
352
+ const disposition = activeExclusion ? 'excluded-no-corpus'
353
+ : repo.isDisabled ? 'disabled'
354
+ : !repo.defaultBranchRef ? 'empty'
355
+ : repo.isFork ? (repo.forkDelta.aheadBy > 0 ? 'fork:original-content' : 'fork:no-original-content')
356
+ : 'eligible';
214
357
  const reasons = [];
215
358
  let status = 'CURRENT';
216
- if (disposition !== 'eligible') {
359
+ if (!isIngestibleDisposition(disposition)) {
217
360
  status = 'INELIGIBLE';
218
- if (activeExclusion && exclusion?.reason) reasons.push(exclusion.reason);
361
+ if (disposition === 'excluded-no-corpus' && exclusion?.reason) reasons.push(exclusion.reason);
362
+ else if (disposition === 'disabled') reasons.push('repository is disabled by GitHub');
363
+ else if (disposition === 'empty') reasons.push('repository has no default branch — no commits to collect');
364
+ else if (disposition === 'fork:no-original-content') {
365
+ reasons.push(`fork of ${repo.forkDelta.upstream}: 0 commits ahead of upstream ${repo.forkDelta.upstreamHeadSha.slice(0, 12)} — contains no original content`);
366
+ }
219
367
  }
220
368
  else if (!evidence.rvfPresent) { status = 'MISSING'; reasons.push('canonical RVF is absent'); }
221
369
  else if (!evidence.receipt?.sourceCommit) { status = 'UNVERIFIED'; reasons.push('RVF receipt has no sourceCommit'); }
@@ -230,6 +378,8 @@ export function classifyRepository(repo, evidence, exclusion = null) {
230
378
  routing: { description: repo.description || null, homepageUrl: repo.homepageUrl || null,
231
379
  capabilityCardPresent: evidence.cardPresent === true },
232
380
  disposition,
381
+ archived: repo.isArchived === true,
382
+ ...(repo.forkDelta ? { forkDelta: { ...repo.forkDelta } } : {}),
233
383
  upstream: { sha: upstreamSha, committedAt: repo.defaultBranchRef?.target?.committedDate || null,
234
384
  pushedAt: repo.pushedAt, updatedAt: repo.updatedAt },
235
385
  artifact: { store: storeName(repo.storeName || repo.name), sourceCommit: evidence.receipt?.sourceCommit || null,
@@ -243,12 +393,19 @@ export function classifyRepository(repo, evidence, exclusion = null) {
243
393
 
244
394
  export function classifyGist(gist, evidence) {
245
395
  const source = evidence.sources?.gists?.[gist.id] || null;
246
- const ingestedAt = source?.ingestedAt || evidence.cache?.[gist.id] || null;
396
+ const ingestedAt = source?.ingestedAt || null;
247
397
  // The list API does not expose gist history.version. A just-fetched individual source receipt is
248
398
  // still the authoritative version when the live list's updated_at is unchanged; otherwise the
249
399
  // list proves drift and the row is stale until the individual gist is refreshed.
400
+ //
401
+ // 2026-09-13 (Step 4, rule 2): a flat timestamp cache (`.ruv-gists.cache.json` -- "we last saw
402
+ // this updated_at at some point") is NEVER consulted here anymore. It used to OR into
403
+ // `currentByDate`, so a gist could be classified CURRENT purely because a bare cache file claimed
404
+ // a date matched -- with no binding whatsoever to the gist's actual captured/rendered content.
405
+ // Currency is now provable ONLY from the real, already-validated per-gist source receipt (Step 2's
406
+ // schema-3 `ruv-gists.sources.json`): its own `updatedAt` field must equal the live list's.
250
407
  const version = source?.updatedAt === gist.updated_at ? source.versionSha : gistVersion(gist);
251
- const currentByDate = source?.updatedAt === gist.updated_at || evidence.cache?.[gist.id] === gist.updated_at;
408
+ const currentByDate = source?.updatedAt === gist.updated_at;
252
409
  let status = 'CURRENT';
253
410
  const reasons = [];
254
411
  if (!evidence.rvfPresent) { status = 'MISSING'; reasons.push('ruv-gists RVF is absent'); }
@@ -303,6 +460,7 @@ export function renderMarkdown(coverage) {
303
460
  '# RuvNet Brain source coverage', '',
304
461
  `Generated: ${coverage.observedAt} `,
305
462
  `Coverage generation: \`${coverage.coverageGeneration}\` `,
463
+ `Source policy version: ${coverage.policy?.policyVersion ?? 1} `,
306
464
  `Repositories: ${coverage.totals.repositories} · Gists: ${coverage.totals.gists} · ` +
307
465
  Object.entries(counts).sort().map(([state, count]) => `${state} ${count}`).join(' · '), '',
308
466
  '> `CURRENT` is artifact-bound. Clone state and timestamps alone never establish freshness.', '',
@@ -311,7 +469,8 @@ export function renderMarkdown(coverage) {
311
469
  '|---|---:|---|---:|---|---|---|',
312
470
  ];
313
471
  for (const row of coverage.rows.filter((entry) => entry.kind === 'repository')) {
314
- lines.push(`| [${row.name}](${row.url}) | ${row.upstream.committedAt || row.upstream.updatedAt || '—'} | ${row.upstream.sha || '—'} | ${row.artifact.ingestedAt || '—'} | ${row.artifact.sourceCommit || '—'} | ${row.status} | ${row.reasons.join('; ') || row.disposition} |`);
472
+ const marker = row.archived ? ' _(archived)_' : '';
473
+ lines.push(`| [${row.name}](${row.url})${marker} | ${row.upstream.committedAt || row.upstream.updatedAt || '—'} | ${row.upstream.sha || '—'} | ${row.artifact.ingestedAt || '—'} | ${row.artifact.sourceCommit || '—'} | ${row.status} | ${row.reasons.join('; ') || row.disposition} |`);
315
474
  }
316
475
  lines.push('', '## Public gists', '',
317
476
  '| Gist | Upstream updated | Version SHA | Ingested update | State | Reason |',
@@ -337,14 +496,79 @@ export function sealCoverage({ owner, repositories, gists, rows, generatorSource
337
496
  .map((status) => [status, orderedRows.filter((row) => row.status === status).length]));
338
497
  return { schemaVersion: 1, kind: 'ruvnet-brain-corpus-coverage', owner, observedAt, generatorSourceSha,
339
498
  sourceObservationSha256, snapshotRoot,
340
- policy: { policyDispositionDigests, exemptionDigests },
499
+ policy: { policyVersion: SOURCE_POLICY_VERSION, policyDispositionDigests, exemptionDigests },
341
500
  coverageGeneration: coverageGenerationFor({ generatorSourceSha, snapshotRoot, sourceObservationSha256, rows: orderedRows,
342
501
  enumerationReceipt, policyDispositionDigests, exemptionDigests }), enumerationReceipt, rows: orderedRows,
343
502
  totals: { repositories: repositories.expected, gists: gists.expected, rows: orderedRows.length, byStatus } };
344
503
  }
345
504
 
346
- export function buildCoverage({ owner = 'ruvnet', kbDir = path.join(ROOT, 'kb'), policyDir = kbDir,
505
+ // IDENTITY ACCOUNTING between a recorded coverage and a fresh one. Row keys are the stable identity
506
+ // (`repo:<databaseId>` for the owner's repositories — a rename keeps the key and changes the name;
507
+ // `repo:<owner/name>` for configured external sources, where a rename reads as remove + add; `gist:<id>`).
508
+ // This is how "zero unexplained omissions" is made checkable: every repository or gist that left the
509
+ // set since the recorded measurement is NAMED, never silently absent from a diff.
510
+ export function diffCoverageIdentities(recorded, current) {
511
+ const index = (coverage, kind) => new Map((coverage?.rows || []).filter((row) => row.kind === kind)
512
+ .map((row) => [row.key, row.name]));
513
+ const identity = ([key, name]) => ({ key, name });
514
+ const account = (kind, withRenames) => {
515
+ const before = index(recorded, kind);
516
+ const after = index(current, kind);
517
+ const result = {
518
+ added: [...after].filter(([key]) => !before.has(key)).map(identity),
519
+ removed: [...before].filter(([key]) => !after.has(key)).map(identity),
520
+ };
521
+ if (withRenames) {
522
+ result.renamed = [...after].filter(([key, name]) => before.has(key) && before.get(key) !== name)
523
+ .map(([key, to]) => ({ key, from: before.get(key), to }));
524
+ }
525
+ return result;
526
+ };
527
+ return { repositories: account('repository', true), gists: account('gist', false) };
528
+ }
529
+
530
+ // What `--check` prints when the recorded projection differs from the live observation: the named
531
+ // identity differences and the policy-version gap. A recorded coverage produced under an older
532
+ // SOURCE_POLICY_VERSION is stale BY CONSTRUCTION — its rows were classified by different rules — and
533
+ // that must be said out loud, not left as an unexplained byte mismatch.
534
+ export function explainCoverageDrift(recorded, current) {
535
+ const diff = diffCoverageIdentities(recorded, current);
536
+ const list = (rows) => (rows.length ? ` (${rows.map((row) => `${row.key} ${row.name}`).join(', ')})` : '');
537
+ const recordedPolicy = recorded?.policy?.policyVersion ?? 1;
538
+ const currentPolicy = current?.policy?.policyVersion ?? SOURCE_POLICY_VERSION;
539
+ const recordedRows = new Map((recorded?.rows || []).map((row) => [row.key, canonicalJson(row)]));
540
+ const changed = (current?.rows || []).filter((row) => recordedRows.has(row.key) && recordedRows.get(row.key) !== canonicalJson(row));
541
+ const lines = [
542
+ `recorded ${recorded?.observedAt || '(unknown)'} vs live ${current?.observedAt || '(unknown)'}`,
543
+ `rows changed in content: ${changed.length}${changed.length ? ` (first: ${changed.slice(0, 5).map((row) => row.key).join(', ')})` : ''}`,
544
+ `repositories: +${diff.repositories.added.length} added${list(diff.repositories.added)}, ` +
545
+ `-${diff.repositories.removed.length} removed${list(diff.repositories.removed)}, ` +
546
+ `${diff.repositories.renamed.length} renamed${diff.repositories.renamed.length
547
+ ? ` (${diff.repositories.renamed.map((row) => `${row.key} ${row.from} -> ${row.to}`).join(', ')})` : ''}`,
548
+ `gists: +${diff.gists.added.length} added, -${diff.gists.removed.length} removed${list(diff.gists.removed)}`,
549
+ ];
550
+ if (recordedPolicy !== currentPolicy) {
551
+ lines.push(`recorded policyVersion ${recordedPolicy} != current policyVersion ${currentPolicy}: the recorded ` +
552
+ 'measurement was classified under an older eligibility policy and is stale by construction; the next pipeline ' +
553
+ 'run will produce the new shape (archives eligible, forks dispositioned by compare, exclusions evidence-bound)');
554
+ }
555
+ return lines;
556
+ }
557
+
558
+ // The measured directory defaults to THE store root (kb/store-root.mjs) — the one every reader,
559
+ // writer and installer resolves — never `<repo>/kb`, which store-root.mjs declares a build workspace
560
+ // and "never a second brain". Measured 2026-09-11: three commits projected a dirty workspace as the
561
+ // brain (476 of 719 rows FAILED) while the canonical root matched every receipt. Policy files stay
562
+ // where they are source-controlled (`<repo>/kb`) unless the caller names both directories, as the
563
+ // release path does with `--assets`.
564
+ export function buildCoverage({ owner = 'ruvnet', env = process.env, home = os.homedir(), kbDir = null, policyDir = null,
347
565
  observation = null, gh = runGh, now = () => new Date().toISOString() } = {}) {
566
+ policyDir ??= kbDir ?? path.join(ROOT, 'kb');
567
+ kbDir ??= storeRoot(env, home);
568
+ if (rootNeverMaterialized(kbDir)) {
569
+ throw new Error(`store root ${kbDir} does not exist — set RUVNET_BRAIN_KB or pass --assets <dir>; ` +
570
+ 'the build workspace <repo>/kb is never measured by default');
571
+ }
348
572
  const externalPath = path.join(policyDir, 'external-sources.json');
349
573
  const externalPolicy = fs.existsSync(externalPath) ? JSON.parse(fs.readFileSync(externalPath, 'utf8')) : { sources: [] };
350
574
  if (!Array.isArray(externalPolicy.sources)) throw new Error('external-sources.json has no sources array');
@@ -362,8 +586,6 @@ export function buildCoverage({ owner = 'ruvnet', kbDir = path.join(ROOT, 'kb'),
362
586
  const ledger = readRvfGenerations(kbDir);
363
587
  const cards = fs.readFileSync(path.join(kbDir, 'capability-cards.md'), 'utf8');
364
588
  const cardStores = new Set([...cards.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => storeName(match[1])));
365
- const gistCachePath = path.join(kbDir, '.ruv-gists.cache.json');
366
- const gistCache = fs.existsSync(gistCachePath) ? JSON.parse(fs.readFileSync(gistCachePath, 'utf8')) : {};
367
589
  const gistSourcesPath = path.join(kbDir, 'ruv-gists.sources.json');
368
590
  const gistSources = fs.existsSync(gistSourcesPath) ? JSON.parse(fs.readFileSync(gistSourcesPath, 'utf8')) : null;
369
591
  const exclusionsPath = path.join(policyDir, 'no-corpus-repos.json');
@@ -372,7 +594,7 @@ export function buildCoverage({ owner = 'ruvnet', kbDir = path.join(ROOT, 'kb'),
372
594
  const store = storeName(repo.storeName || repo.name);
373
595
  return classifyRepository(repo, artifactEvidence(kbDir, ledger, cardStores, store), exclusions[store] || null);
374
596
  });
375
- const gistEvidence = { ...artifactEvidence(kbDir, ledger, cardStores, 'ruv-gists'), cache: gistCache, sources: gistSources };
597
+ const gistEvidence = { ...artifactEvidence(kbDir, ledger, cardStores, 'ruv-gists'), sources: gistSources };
376
598
  try {
377
599
  validateGistAggregateReceipt({ receipt: gistSources,
378
600
  passagesFile: path.join(kbDir, 'ruv-gists.passages.jsonl'),
@@ -396,20 +618,27 @@ export async function main(argv = process.argv.slice(2)) {
396
618
  const ownerIndex = argv.indexOf('--owner');
397
619
  const owner = ownerIndex >= 0 ? argv[ownerIndex + 1] : 'ruvnet';
398
620
  const assetsIndex = argv.indexOf('--assets');
399
- const kbDir = path.resolve(assetsIndex >= 0 ? argv[assetsIndex + 1] : path.join(ROOT, 'kb'));
621
+ // `--assets <dir>` names a release candidate: measure it and take policy from it, exactly as before.
622
+ // Otherwise measure the installed brain and take policy from the repository.
623
+ const kbDir = assetsIndex >= 0 ? path.resolve(argv[assetsIndex + 1]) : storeRoot();
624
+ const policyDir = assetsIndex >= 0 ? kbDir : path.join(ROOT, 'kb');
400
625
  const jsonPath = path.join(ROOT, 'data', 'source-coverage.json');
401
626
  const markdownPath = path.join(ROOT, 'docs', 'RUVNET-COVERAGE.md');
402
627
  const recorded = argv.includes('--check') && fs.existsSync(jsonPath)
403
628
  ? JSON.parse(fs.readFileSync(jsonPath, 'utf8')) : null;
404
- const coverage = buildCoverage({ owner, kbDir,
629
+ console.error(`measuring ${kbDir} (policy from ${policyDir})`);
630
+ const coverage = buildCoverage({ owner, kbDir, policyDir,
405
631
  now: recorded?.observedAt ? () => recorded.observedAt : () => new Date().toISOString() });
406
632
  const json = `${JSON.stringify(coverage, null, 2)}\n`;
407
633
  const markdown = renderMarkdown(coverage);
408
634
  if (argv.includes('--check')) {
409
635
  const matches = fs.existsSync(jsonPath) && fs.readFileSync(jsonPath, 'utf8') === json &&
410
636
  fs.existsSync(markdownPath) && fs.readFileSync(markdownPath, 'utf8') === markdown;
411
- const blockers = coverage.rows.filter((row) => row.disposition === 'eligible' && row.status !== 'CURRENT');
412
- if (!matches) console.error('source coverage projections differ from live observation');
637
+ const blockers = coverage.rows.filter((row) => isIngestibleDisposition(row.disposition) && row.status !== 'CURRENT');
638
+ if (!matches) {
639
+ console.error('source coverage projections differ from live observation');
640
+ for (const line of explainCoverageDrift(recorded, coverage)) console.error(` ${line}`);
641
+ }
413
642
  if (argv.includes('--strict') && blockers.length) console.error(`strict coverage: ${blockers.length} eligible row(s) are not CURRENT`);
414
643
  return matches && (!argv.includes('--strict') || blockers.length === 0) ? 0 : 1;
415
644
  }
@@ -49,6 +49,30 @@ const DERIVATION_MARKERS = [
49
49
  'remoteBranchExists', 'installedVersion', 'verifyOutcome', 'delivered', 'succeeded ?', 'sent ===',
50
50
  ];
51
51
 
52
+ // Guard-clause derivation: `if (!x.ok) return {...failed...}; ... return { state: 'ok', ... };` gates
53
+ // the terminal literal on an earlier validator call's `.ok` result — a real derivation — but the guard
54
+ // can sit many lines above the literal (one guard per precondition, e.g. refreshRunHealth() checking
55
+ // registration, identity, and envelope validity in turn before its one unconditional success return).
56
+ // That is outside the local ±2-line window designed for inline ternaries, so it false-positived on
57
+ // plugin/scripts/nightly-scheduler.mjs:271 (caught by the 2026-09-12 consistency audit; state:'ok' IS
58
+ // gated, just structurally distant). Recognize the guard-clause idiom explicitly instead of widening
59
+ // the generic window — widening would also swallow unrelated markers anywhere in the function and
60
+ // could hide a genuinely naked literal sitting next to unrelated derivation-looking text.
61
+ const OK_GUARD_RE = /\bif\s*\(\s*!\s*[\w$]+(?:\.[\w$]+)*\.ok\b\s*\)\s*return\b/;
62
+ const FUNCTION_START_RE = /^\s*(export\s+)?(default\s+)?(async\s+)?function\b/;
63
+
64
+ /** True if an earlier `if (!validator(...).ok) return ...;` guard, within the innermost enclosing
65
+ * top-level function, precedes line `matchIndex` — i.e. the function cannot reach `matchIndex`
66
+ * without that validator call having already passed. */
67
+ function hasEnclosingOkGuard(lines, matchIndex) {
68
+ let start = 0;
69
+ for (let j = matchIndex - 1; j >= 0; j--) {
70
+ if (FUNCTION_START_RE.test(lines[j])) { start = j; break; }
71
+ }
72
+ for (let j = start; j < matchIndex; j++) if (OK_GUARD_RE.test(lines[j])) return true;
73
+ return false;
74
+ }
75
+
52
76
  /** Scan one source text. Returns violations: [{line, text}] — success literal with no derivation. */
53
77
  export function scanSource(src, _name = '(inline)') {
54
78
  const violations = [];
@@ -61,6 +85,7 @@ export function scanSource(src, _name = '(inline)') {
61
85
  // The statement window: this line ± 2 (multi-line object literals / sh case arms).
62
86
  const windowText = lines.slice(Math.max(0, i - 2), i + 3).join('\n');
63
87
  if (DERIVATION_MARKERS.some((m) => windowText.includes(m))) continue;
88
+ if (hasEnclosingOkGuard(lines, i)) continue;
64
89
  violations.push({ line: i + 1, text: t.slice(0, 160) });
65
90
  }
66
91
  return violations;
Binary file
@@ -175,6 +175,8 @@ if (CHECK) {
175
175
  'tests/unit/version.test.mjs', 'tests/unit/sync-version-drift.test.mjs', 'tests/unit/self-update-plan.test.mjs',
176
176
  // Asserts stripTag('v1.14.1-dev') === '1.14.1-dev' — the literals ARE the fixture.
177
177
  'tests/unit/version-tag-vs-field.test.mjs',
178
+ // MCP reload tests carry version literals in fixture manifests — these are test payloads, not production code.
179
+ 'tests/unit/mcp-graceful-reload.test.mjs', 'tests/integration/mcp-reload-no-restart-required.test.mjs',
178
180
  ]);
179
181
  const litRe = /['"`](v?\d+\.\d+\.\d+(?:-dev)?)['"`]/g;
180
182
 
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ /** First-class RuvNet Brain entry point for Dual/TriSmart reviews. */
3
+ import { spawnSync } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import path from 'node:path';
6
+
7
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
8
+ const DUAL = path.join(ROOT, 'dual-host-deliberation.mjs');
9
+ const TRISMART = path.resolve(ROOT, '..', 'tri-smart-skill', 'tri-smart', 'scripts', 'review.mjs');
10
+
11
+ function usage() {
12
+ console.log('Usage: node scripts/trismart.mjs [--mode=auto|dual|tri] [--dry-run] <architecture task>');
13
+ console.log('Dual uses Claude Code + Codex; TriSmart adds Grok when its native CLI subscription is verified.');
14
+ console.log('All provider API-key variables are removed before a provider process starts.');
15
+ }
16
+
17
+ const args = process.argv.slice(2);
18
+ if (args.includes('--help') || args.includes('-h')) { usage(); process.exit(0); }
19
+ const mode = args.find((arg) => arg.startsWith('--mode='))?.slice(7) ?? 'auto';
20
+ if (!['auto', 'dual', 'tri'].includes(mode)) { console.error('mode must be auto, dual, or tri'); process.exit(2); }
21
+ const dryRun = args.includes('--dry-run');
22
+ const task = args.filter((arg) => !arg.startsWith('--')).join(' ').trim();
23
+ if (!task) { usage(); process.exit(2); }
24
+
25
+ if (dryRun) {
26
+ const providers = mode === 'dual' ? ['claude', 'codex'] : ['claude', 'codex', 'grok'];
27
+ console.log(JSON.stringify({ status: 'ready', mode, providers, models: {
28
+ claude: 'claude-fable-5-1', codex: 'gpt-6-astra', grok: 'grok-4.6',
29
+ }, billingPath: 'native subscription/OAuth CLI; API-key variables unset', taskHash: task.length }, null, 2));
30
+ process.exit(0);
31
+ }
32
+
33
+ // The portable TriSmart runner owns three-provider probing and truthful degraded output.
34
+ // The brain's Dual path remains the native two-host implementation and is kept separate so
35
+ // a missing Grok CLI can never silently downgrade a requested TriSmart review.
36
+ if (mode === 'tri' || mode === 'auto') {
37
+ const result = spawnSync(process.execPath, [TRISMART, `--mode=${mode}`, task], { stdio: 'inherit' });
38
+ process.exitCode = result.status ?? 1;
39
+ } else {
40
+ const result = spawnSync(process.execPath, [DUAL, task], { stdio: 'inherit' });
41
+ process.exitCode = result.status ?? 1;
42
+ }