ruvnet-brain 4.3.21 → 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 (141) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +275 -60
  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/nightly-scheduler.mjs +37 -4
  30. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  31. package/plugin/scripts/project-progression-contract.mjs +16 -0
  32. package/plugin/scripts/project-progression-hook.mjs +3 -0
  33. package/plugin/scripts/project-progression-producer.mjs +252 -0
  34. package/plugin/scripts/project-progression-reader.mjs +271 -0
  35. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  36. package/plugin/scripts/project-progression-sources.mjs +220 -0
  37. package/plugin/scripts/project-progression-store.mjs +106 -13
  38. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  39. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  40. package/plugin/scripts/session-start-budget.mjs +59 -0
  41. package/plugin/scripts/session-start-core.mjs +234 -457
  42. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  43. package/plugin/scripts/session-start-health.mjs +64 -0
  44. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  45. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  46. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  47. package/plugin/scripts/session-start-signals.mjs +73 -0
  48. package/plugin/scripts/session-start-trace.mjs +86 -0
  49. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  50. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  51. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  52. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  53. package/scripts/adr-072-completion.mjs +1 -1
  54. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  55. package/scripts/approved-runtime.mjs +197 -0
  56. package/scripts/brain-novice-50.mjs +16 -1
  57. package/scripts/brain-score.mjs +23 -5
  58. package/scripts/build-bundle.mjs +971 -530
  59. package/scripts/build-concepts.mjs +36 -116
  60. package/scripts/console-engine.test.mjs +8 -7
  61. package/scripts/console-runtime-identity.mjs +4 -0
  62. package/scripts/corpus-aggregates.mjs +94 -77
  63. package/scripts/corpus-candidate.mjs +475 -222
  64. package/scripts/corpus-next-seed.mjs +225 -0
  65. package/scripts/corpus-promotion.mjs +58 -0
  66. package/scripts/corpus-reconcile.mjs +411 -105
  67. package/scripts/doc-currency.mjs +16 -1
  68. package/scripts/dual-host-deliberation.mjs +25 -2
  69. package/scripts/dual-host-suggest.mjs +17 -1
  70. package/scripts/falsify.mjs +13 -3
  71. package/scripts/gist-receipts.mjs +482 -87
  72. package/scripts/github-health-watch.mjs +12 -2
  73. package/scripts/handoff-asset.mjs +34 -0
  74. package/scripts/hook-retirement-check.mjs +8 -1
  75. package/scripts/host-registry.mjs +1 -1
  76. package/scripts/ingest-gists.mjs +74 -101
  77. package/scripts/job-heartbeat.sh +77 -14
  78. package/scripts/learning-replay-execution.mjs +10 -4
  79. package/scripts/nightly-gists.sh +27 -13
  80. package/scripts/nightly-two-run-proof.mjs +1 -1
  81. package/scripts/nightly-watchdog.mjs +61 -4
  82. package/scripts/onboarding-console.mjs +319 -27
  83. package/scripts/oracle/produce-questions.mjs +293 -0
  84. package/scripts/oracle/producer-hosts.mjs +235 -0
  85. package/scripts/oracle/repo-recall.mjs +448 -0
  86. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  87. package/scripts/oracle/source-tree.mjs +165 -0
  88. package/scripts/oracle/source-units.mjs +391 -0
  89. package/scripts/oracle/spike-run.mjs +98 -0
  90. package/scripts/oracle/unit-inventory.mjs +141 -0
  91. package/scripts/oracle/unit-sampling.mjs +128 -0
  92. package/scripts/oracle/validate-labels.mjs +250 -0
  93. package/scripts/private-overlay.mjs +248 -0
  94. package/scripts/product-integrity-contract.mjs +1 -1
  95. package/scripts/proxy/claude-proxied.sh +6 -0
  96. package/scripts/proxy/proxy-revert.sh +5 -0
  97. package/scripts/proxy/proxy-up.sh +6 -0
  98. package/scripts/proxy/proxy-verify.mjs +4 -0
  99. package/scripts/public-inputs.mjs +409 -0
  100. package/scripts/public-verification-inputs.mjs +112 -26
  101. package/scripts/public-verification-lane.mjs +1 -1
  102. package/scripts/published-surface-probe.mjs +34 -4
  103. package/scripts/qe/card-lane-gate.mjs +16 -1
  104. package/scripts/qe/session-start-gate.mjs +16 -1
  105. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  106. package/scripts/record-lesson.mjs +4 -1
  107. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  108. package/scripts/release-abort-stale.mjs +5 -1
  109. package/scripts/release-authority.mjs +104 -12
  110. package/scripts/release-channel-kind.mjs +86 -0
  111. package/scripts/release-convergence-watchdog.mjs +7 -2
  112. package/scripts/release-projection.mjs +177 -72
  113. package/scripts/release-transaction-provider.mjs +23 -6
  114. package/scripts/release.mjs +252 -17
  115. package/scripts/retrieval-canary.mjs +87 -0
  116. package/scripts/rvf-index-audit.mjs +573 -13
  117. package/scripts/rvf-wire.mjs +269 -0
  118. package/scripts/seal-gist-receipt.mjs +65 -0
  119. package/scripts/selfcheck.mjs +42 -21
  120. package/scripts/source-coverage.mjs +253 -24
  121. package/scripts/status-honesty.mjs +25 -0
  122. package/scripts/sync-census.mjs +0 -0
  123. package/scripts/sync-version.mjs +2 -0
  124. package/scripts/trismart.mjs +42 -0
  125. package/scripts/updater-manifest.mjs +162 -0
  126. package/scripts/verify-channels.mjs +17 -5
  127. package/scripts/wired-check.mjs +48 -10
  128. package/tri-smart-skill/QUICKSTART.md +37 -0
  129. package/tri-smart-skill/README.md +92 -0
  130. package/tri-smart-skill/install.cmd +14 -0
  131. package/tri-smart-skill/install.command +13 -0
  132. package/tri-smart-skill/install.mjs +51 -0
  133. package/tri-smart-skill/install.sh +9 -0
  134. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  135. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  136. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  137. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  138. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  139. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  140. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  141. package/scripts/corpus-seed-publish.mjs +0 -110
@@ -0,0 +1,141 @@
1
+ /**
2
+ * scripts/oracle/unit-inventory.mjs — the parser-independent core of oracle-source-units/2.
3
+ *
4
+ * ADR-086:248 asks for U "meaningful source units" per repository. The EXTEND_FIRST Dual verdict
5
+ * (2026-09-14) made that operational, and this module enforces the parts that do not depend on which
6
+ * parsers are chosen:
7
+ *
8
+ * - Every tracked entry of the pinned tree (scripts/oracle/source-tree.mjs) receives exactly ONE
9
+ * disposition. Nothing is skipped without a named reason.
10
+ * - A narrow, published exclusion list only: lockfiles, vendored dependency trees, license
11
+ * boilerplate, submodule pointers, symlinks, and opaque binary / LFS modality. Tests, examples,
12
+ * configuration, dist output and large files are NOT blanket-excluded any more.
13
+ * - Anything else must be claimed by an ADAPTER. Adapters are injected (their parsers are a separate
14
+ * decision); each returns units plus errors.
15
+ * - COMPLETENESS: any adapter error, or any entry no adapter supports, makes the inventory INCOMPLETE:
16
+ * `inventoryComplete: false`, `U: null`, and a separately named `enumeratedU` lower bound. An
17
+ * incomplete inventory is never sampled, because it "cannot authorize a passing C3 result".
18
+ * - Opaque binaries, LFS pointers, symlinks and gitlinks never count toward emptiness.
19
+ *
20
+ * Adapter contract:
21
+ * { id, version, parserIdentity, matches(entry, text) -> boolean,
22
+ * enumerate({ path, text, bytes }) -> { units: [{ kind, name, sourceType, startByte, endByte, startLine, endLine }],
23
+ * errors: [{ message, location }] } }
24
+ */
25
+ import crypto from 'node:crypto';
26
+ import { selectUnits, DEFAULT_SEED } from './unit-sampling.mjs';
27
+
28
+ export const RULES_VERSION = 'oracle-source-units/2';
29
+
30
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
31
+
32
+ const LOCKFILES = new Set(['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'Cargo.lock', 'poetry.lock', 'Pipfile.lock',
33
+ 'bun.lockb', 'go.sum', 'composer.lock', 'Gemfile.lock', 'flake.lock', 'uv.lock', 'npm-shrinkwrap.json']);
34
+ // External code vendored into the tree is outside the declared repository-source scope. `dist`, `build`
35
+ // and `target` are NOT here: the verdict removed that blanket exclusion, since sole-source distribution
36
+ // content is still the repository's own.
37
+ const VENDORED_DIRS = new Set(['node_modules', 'bower_components', 'vendor', 'vendors', 'third_party', 'third-party',
38
+ 'site-packages', '.venv', 'venv']);
39
+ const LICENSE_FILE = /^(license|licence|copying|notice|patents)(\.[a-z0-9]+)?$/i;
40
+
41
+ /** The single disposition for an entry that is decided without any adapter, or null if an adapter must decide. */
42
+ export function fixedDisposition(entry) {
43
+ const segments = entry.path.split('/');
44
+ const base = segments[segments.length - 1];
45
+ if (entry.entryKind === 'gitlink') return { disposition: 'excluded', reason: 'submodule-pointer' };
46
+ if (entry.entryKind === 'symlink') return { disposition: 'excluded', reason: 'symlink' };
47
+ if (entry.entryKind === 'lfs-pointer') return { disposition: 'modality-excluded', reason: 'lfs-object-not-in-tree' };
48
+ if (entry.entryKind === 'binary') return { disposition: 'modality-excluded', reason: 'opaque-binary' };
49
+ if (segments.slice(0, -1).some((s) => VENDORED_DIRS.has(s))) return { disposition: 'excluded', reason: 'vendored-dependency' };
50
+ if (LOCKFILES.has(base)) return { disposition: 'excluded', reason: 'lockfile' };
51
+ if (LICENSE_FILE.test(base)) return { disposition: 'excluded', reason: 'license-boilerplate' };
52
+ return null;
53
+ }
54
+
55
+ export function unitIdFor({ repo, path, objectSha, kind, startByte, endByte }) {
56
+ return sha256(Buffer.from(JSON.stringify([repo, path, objectSha, kind, startByte, endByte]), 'utf8')).slice(0, 16);
57
+ }
58
+
59
+ /**
60
+ * Build a v2 inventory from a snapshot manifest, its blob bytes and the injected adapters. Pure over its
61
+ * inputs. `adapters` order is the published dialect precedence: the first adapter that matches claims
62
+ * the entry.
63
+ */
64
+ export function buildInventory({ manifest, blobs, adapters, seed = DEFAULT_SEED }) {
65
+ if (!manifest?.repo || !manifest?.treeSha || !Array.isArray(manifest.entries)) {
66
+ throw new Error('buildInventory needs a source-tree snapshot manifest');
67
+ }
68
+ const rows = [];
69
+ const units = [];
70
+ const failures = [];
71
+ const unsupported = [];
72
+ for (const entry of manifest.entries) {
73
+ const fixed = fixedDisposition(entry);
74
+ if (fixed) { rows.push({ path: entry.path, entryKind: entry.entryKind, ...fixed }); continue; }
75
+ const bytes = blobs.get(entry.objectSha);
76
+ if (!bytes) throw new Error(`no blob bytes for ${entry.path} (${entry.objectSha})`);
77
+ const text = bytes.toString('utf8');
78
+ if (Buffer.byteLength(text, 'utf8') !== bytes.length || text.includes('�')) {
79
+ failures.push({ path: entry.path, objectSha: entry.objectSha, adapter: null, message: 'not valid UTF-8 text' });
80
+ rows.push({ path: entry.path, entryKind: entry.entryKind, disposition: 'extraction-failed', reason: 'decoding' });
81
+ continue;
82
+ }
83
+ const adapter = adapters.find((a) => a.matches(entry, text));
84
+ if (!adapter) {
85
+ unsupported.push({ path: entry.path, objectSha: entry.objectSha });
86
+ rows.push({ path: entry.path, entryKind: entry.entryKind, disposition: 'unsupported-modality', reason: 'no adapter claims this entry' });
87
+ continue;
88
+ }
89
+ let result;
90
+ try {
91
+ result = adapter.enumerate({ path: entry.path, text, bytes });
92
+ } catch (error) {
93
+ result = { units: [], errors: [{ message: `adapter threw: ${error.message}`, location: null }] };
94
+ }
95
+ if (result.errors?.length) {
96
+ for (const e of result.errors) failures.push({ path: entry.path, objectSha: entry.objectSha, adapter: `${adapter.id}@${adapter.version}`, ...e });
97
+ rows.push({ path: entry.path, entryKind: entry.entryKind, disposition: 'extraction-failed', adapter: adapter.id, reason: 'parser diagnostics' });
98
+ continue;
99
+ }
100
+ const fileUnits = (result.units || []).map((u) => {
101
+ if (!(Number.isSafeInteger(u.startByte) && Number.isSafeInteger(u.endByte) && u.startByte >= 0 && u.endByte > u.startByte && u.endByte <= bytes.length)) {
102
+ throw new Error(`${adapter.id} returned an invalid owned span for ${entry.path}: ${u.startByte}..${u.endByte}`);
103
+ }
104
+ const owned = bytes.subarray(u.startByte, u.endByte);
105
+ return {
106
+ unitId: unitIdFor({ repo: manifest.repo, path: entry.path, objectSha: entry.objectSha, kind: u.kind, startByte: u.startByte, endByte: u.endByte }),
107
+ path: entry.path, blobSha: entry.objectSha, kind: u.kind, name: u.name ?? null, sourceType: u.sourceType,
108
+ startByte: u.startByte, endByte: u.endByte, startLine: u.startLine, endLine: u.endLine,
109
+ bytesSha256: sha256(owned), chars: owned.toString('utf8').length, adapter: `${adapter.id}@${adapter.version}`,
110
+ };
111
+ });
112
+ units.push(...fileUnits);
113
+ rows.push({ path: entry.path, entryKind: entry.entryKind, disposition: fileUnits.length ? 'eligible' : 'no-eligible-units', adapter: adapter.id, units: fileUnits.length });
114
+ }
115
+
116
+ const inventoryComplete = failures.length === 0 && unsupported.length === 0;
117
+ const identity = {
118
+ rulesVersion: RULES_VERSION, repo: manifest.repo, commitSha: manifest.commitSha, treeSha: manifest.treeSha,
119
+ manifestSha256: manifest.manifestSha256,
120
+ adapters: adapters.map((a) => ({ id: a.id, version: a.version, parserIdentity: a.parserIdentity })),
121
+ };
122
+ if (!inventoryComplete) {
123
+ return {
124
+ ...identity, inventoryComplete: false, U: null, enumeratedU: units.length,
125
+ failures, unsupported, entries: rows, units, selection: null,
126
+ };
127
+ }
128
+ if (units.length === 0) {
129
+ // Complete accounting that finds no eligible unit is NOT a measurement and NOT proof of emptiness:
130
+ // N=0 is NOT_MEASURED, and emptySources needs an independent review receipt over this manifest.
131
+ return {
132
+ ...identity, inventoryComplete: true, U: 0, enumeratedU: 0, emptyOfEligibleSource: true, requiresEmptinessReview: true,
133
+ failures, unsupported, entries: rows, units, selection: null,
134
+ };
135
+ }
136
+ const selection = selectUnits({ units, repo: manifest.repo, treeSha: manifest.treeSha, seed });
137
+ return {
138
+ ...identity, inventoryComplete: true, U: units.length, enumeratedU: units.length, emptyOfEligibleSource: false,
139
+ requiresEmptinessReview: false, failures, unsupported, entries: rows, units, selection,
140
+ };
141
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * scripts/oracle/unit-sampling.mjs — deterministic stratified selection of min(100, U) source units.
3
+ *
4
+ * ADR-086:248: "Deterministically stratify and select min(100, U) units across modules and source
5
+ * types." The exact procedure below is the one the EXTEND_FIRST Dual verdict (2026-09-14) specified:
6
+ *
7
+ * stratum = (source type, module), module = first repository-relative path component, root files
8
+ * assigned to the "(root)" module;
9
+ * K = min(100, U);
10
+ * if |nonempty strata| <= K: one slot per stratum, then the remaining K - |strata| slots by HAMILTON
11
+ * (largest remainder) allocation proportional to each stratum's remaining capacity, ties
12
+ * broken by a published seeded hash ordering;
13
+ * if |nonempty strata| > K: choose K strata by the seeded hash ordering, one unit each, and publish
14
+ * the omitted strata rather than claim comprehensive representation;
15
+ * within a stratum: order unit ids by SHA-256 of the canonical tuple
16
+ * [seed, repository, tree, stratum, unit id] and take the allocated prefix.
17
+ *
18
+ * Pure: no filesystem, no clock, no randomness. The same input yields the same selection in any process.
19
+ */
20
+ import crypto from 'node:crypto';
21
+
22
+ export const SAMPLING_VERSION = 'oracle-unit-sampling/1';
23
+ export const MAX_SELECTED = 100;
24
+ export const DEFAULT_SEED = 'adr-086-c3/1';
25
+ export const ROOT_MODULE = '(root)';
26
+ // The tuple is encoded as a JSON array of strings. JSON.stringify on an array of strings is canonical:
27
+ // no key ordering exists and string escaping is fully specified.
28
+ export const TUPLE_ENCODING = 'json-array-of-strings/utf-8';
29
+
30
+ const sha256Hex = (text) => crypto.createHash('sha256').update(Buffer.from(text, 'utf8')).digest('hex');
31
+ const byHashThenId = (a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
32
+
33
+ export function moduleOf(unitPath) {
34
+ const parts = String(unitPath).split('/').filter(Boolean);
35
+ return parts.length > 1 ? parts[0] : ROOT_MODULE;
36
+ }
37
+
38
+ export function stratumOf(unit) {
39
+ if (!unit?.sourceType) throw new Error(`unit ${unit?.unitId ?? '(unnamed)'} has no sourceType`);
40
+ return `${unit.sourceType}|${moduleOf(unit.path)}`;
41
+ }
42
+
43
+ export function orderKey({ seed, repo, treeSha, stratum, unitId }) {
44
+ return sha256Hex(JSON.stringify([seed, repo, treeSha, stratum, unitId]));
45
+ }
46
+
47
+ function stratumKey({ seed, repo, treeSha, stratum }) {
48
+ return sha256Hex(JSON.stringify([seed, repo, treeSha, stratum]));
49
+ }
50
+
51
+ /** Slots per stratum. `sizes` maps stratum -> eligible unit count (> 0). */
52
+ export function allocate({ sizes, K, seed, repo, treeSha }) {
53
+ const strata = [...sizes.entries()].filter(([, n]) => n > 0)
54
+ .map(([id, n]) => ({ id, n, key: stratumKey({ seed, repo, treeSha, stratum: id }) }))
55
+ .sort(byHashThenId);
56
+ const total = strata.reduce((s, x) => s + x.n, 0);
57
+ if (!Number.isSafeInteger(K) || K < 0 || K > total) throw new Error(`cannot allocate K=${K} slots over ${total} units`);
58
+ const slots = new Map(strata.map((s) => [s.id, 0]));
59
+ if (K === 0) return { slots, omittedStrata: strata.map((s) => s.id) };
60
+
61
+ if (strata.length > K) {
62
+ // More strata than slots: K strata by seeded hash order, one unit each; the rest are published as omitted.
63
+ strata.slice(0, K).forEach((s) => slots.set(s.id, 1));
64
+ return { slots, omittedStrata: strata.slice(K).map((s) => s.id) };
65
+ }
66
+
67
+ strata.forEach((s) => slots.set(s.id, 1));
68
+ let remaining = K - strata.length;
69
+ const capacity = strata.map((s) => ({ ...s, cap: s.n - 1 }));
70
+ const capTotal = capacity.reduce((s, x) => s + x.cap, 0);
71
+ if (remaining > 0 && capTotal > 0) {
72
+ // Hamilton: floor of each exact quota, then the leftover to the largest remainders. Remainders are
73
+ // compared as exact integer numerators (remaining * cap mod capTotal) — no floating point.
74
+ const quotas = capacity.map((s) => ({
75
+ ...s,
76
+ floor: Math.floor((remaining * s.cap) / capTotal),
77
+ remainder: (remaining * s.cap) % capTotal,
78
+ }));
79
+ for (const q of quotas) slots.set(q.id, slots.get(q.id) + q.floor);
80
+ let leftover = remaining - quotas.reduce((s, q) => s + q.floor, 0);
81
+ const byRemainder = quotas
82
+ .filter((q) => slots.get(q.id) < q.n)
83
+ .sort((a, b) => (b.remainder - a.remainder) || byHashThenId(a, b));
84
+ for (const q of byRemainder) {
85
+ if (leftover === 0) break;
86
+ slots.set(q.id, slots.get(q.id) + 1);
87
+ leftover -= 1;
88
+ }
89
+ remaining = leftover;
90
+ }
91
+ if (remaining !== 0) throw new Error(`allocation left ${remaining} slot(s) unassigned`);
92
+ return { slots, omittedStrata: [] };
93
+ }
94
+
95
+ /**
96
+ * Select min(100, U) of `units` (each: { unitId, path, sourceType }). Returns the full publication record
97
+ * the Dual verdict requires: U, K, seed, algorithm identity, per-stratum allocations, omitted strata and
98
+ * the exact selected ids in selection order.
99
+ */
100
+ export function selectUnits({ units, repo, treeSha, seed = DEFAULT_SEED, maxSelected = MAX_SELECTED }) {
101
+ if (!repo || !treeSha) throw new Error('selectUnits requires the repository and tree identity it samples');
102
+ const seen = new Set();
103
+ const byStratum = new Map();
104
+ for (const unit of units) {
105
+ if (seen.has(unit.unitId)) throw new Error(`duplicate unit id ${unit.unitId}`);
106
+ seen.add(unit.unitId);
107
+ const stratum = stratumOf(unit);
108
+ if (!byStratum.has(stratum)) byStratum.set(stratum, []);
109
+ byStratum.get(stratum).push({ id: unit.unitId, key: orderKey({ seed, repo, treeSha, stratum, unitId: unit.unitId }) });
110
+ }
111
+ const U = units.length;
112
+ const K = Math.min(maxSelected, U);
113
+ const sizes = new Map([...byStratum].map(([s, list]) => [s, list.length]));
114
+ const { slots, omittedStrata } = allocate({ sizes, K, seed, repo, treeSha });
115
+ const selected = [];
116
+ const allocations = [];
117
+ for (const stratum of [...byStratum.keys()].sort()) {
118
+ const take = slots.get(stratum) || 0;
119
+ const ordered = byStratum.get(stratum).sort(byHashThenId);
120
+ allocations.push({ stratum, eligible: ordered.length, selected: take });
121
+ selected.push(...ordered.slice(0, take).map((u) => u.id));
122
+ }
123
+ if (selected.length !== K) throw new Error(`selected ${selected.length} units, expected K=${K}`);
124
+ return {
125
+ samplingVersion: SAMPLING_VERSION, tupleEncoding: TUPLE_ENCODING, seed, repo, treeSha,
126
+ U, K, N: 2 * K, allocations, omittedStrata, selected,
127
+ };
128
+ }
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/oracle/validate-labels.mjs — Step 14 (ADR-086 C3) deterministic label validation.
4
+ *
5
+ * NO LLM. Every produced label is checked against exact upstream bytes re-read from disk:
6
+ * (a) verbatim — the span is an exact substring of the unit text at the pinned blob SHA (the blob is
7
+ * re-hashed; drift fails everything). Diagnostics record whether the span would have
8
+ * matched after whitespace normalisation, and whether the producer's line range points
9
+ * at the same text — the two facts that decide the Step 15 span-binding design.
10
+ * (b) non-trivial — ≥ 40 chars and ≥ 6 tokens, not the whole unit (≤ 90% of it), not a heading alone.
11
+ * (c) no leakage — neither question restates the answer: bge-base cosine(question, span) < 0.92 AND
12
+ * 4-gram coverage of the question by the span < 0.5. The 0.92 bound is calibrated in
13
+ * the run itself: synthetic leaky questions built from the span's own words are
14
+ * embedded and their cosine distribution is reported next to the real one.
15
+ * (d) paraphrase differs — token Jaccard(direct, paraphrase) ≤ 0.7 and not equal after normalisation.
16
+ * (e) informational, not scored — cosine(direct, paraphrase) ≥ 0.70 (a paraphrase that drifted).
17
+ * Labels the producer skipped or failed on FAIL every check: errors count as failures (Dual threshold).
18
+ *
19
+ * The embedder is the local ONNX Xenova/bge-base-en-v1.5 already cached for the KB (768-dim, cls
20
+ * pooling, normalised) — no network. If it is not cached the validator refuses rather than downloads.
21
+ */
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { gitBlobSha, sha256Hex, unitText } from './source-units.mjs';
26
+
27
+ export const THRESHOLDS = Object.freeze({
28
+ minSpanChars: 40, minSpanTokens: 6, maxSpanFractionOfUnit: 0.9,
29
+ leakCosine: 0.92, leakNgramCoverage: 0.5, ngram: 4,
30
+ paraphraseMaxJaccard: 0.7, paraphraseMinCosine: 0.70,
31
+ });
32
+
33
+ export const normalizeWs = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
34
+ export const tokens = (s) => String(s ?? '').toLowerCase().match(/[a-z0-9_]+/g) || [];
35
+ export function jaccard(a, b) {
36
+ const A = new Set(a); const B = new Set(b);
37
+ if (A.size === 0 && B.size === 0) return 1;
38
+ let inter = 0;
39
+ for (const t of A) if (B.has(t)) inter++;
40
+ return inter / (A.size + B.size - inter);
41
+ }
42
+ export function ngramCoverage(question, span, n = THRESHOLDS.ngram) {
43
+ const q = tokens(question); const s = tokens(span);
44
+ if (q.length < n) return q.length && q.every((t) => s.includes(t)) ? 1 : 0;
45
+ const grams = new Set();
46
+ for (let i = 0; i + n <= s.length; i++) grams.add(s.slice(i, i + n).join(' '));
47
+ let hit = 0; let total = 0;
48
+ for (let i = 0; i + n <= q.length; i++) { total++; if (grams.has(q.slice(i, i + n).join(' '))) hit++; }
49
+ return total ? hit / total : 0;
50
+ }
51
+ export function cosine(a, b) {
52
+ let dot = 0; let na = 0; let nb = 0;
53
+ for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
54
+ return na && nb ? dot / Math.sqrt(na * nb) : 0;
55
+ }
56
+ const isHeadingLine = (line) => /^\s*#{1,6}\s/.test(line);
57
+
58
+ export function checkVerbatim(unit, label) {
59
+ const span = String(label.span ?? '');
60
+ const pass = span.length > 0 && unit.includes(span);
61
+ const normalizedMatch = span.length > 0 && normalizeWs(unit).includes(normalizeWs(span));
62
+ const lines = unit.split('\n');
63
+ const s = label.spanStartLine; const e = label.spanEndLine;
64
+ let lineRangeMatch = false;
65
+ if (Number.isInteger(s) && Number.isInteger(e) && s >= 1 && e >= s && e <= lines.length) {
66
+ // CONTAINMENT, not Jaccard: a short span inside a long line is a correct line reference, and
67
+ // Jaccard would score it ~0.27 purely because the line has other words on it.
68
+ const rangeTokens = new Set(tokens(lines.slice(s - 1, e).join('\n')));
69
+ const spanTokens = tokens(span);
70
+ lineRangeMatch = spanTokens.length > 0 && spanTokens.filter((t) => rangeTokens.has(t)).length / spanTokens.length >= 0.8;
71
+ }
72
+ return { pass, reason: pass ? '' : span.length === 0 ? 'empty span' : normalizedMatch ? 'not verbatim (matches after whitespace normalisation)' : 'span not found in unit', normalizedMatch, lineRangeMatch };
73
+ }
74
+
75
+ export function checkNonTrivial(unit, span, t = THRESHOLDS) {
76
+ span = String(span ?? '');
77
+ const toks = tokens(span);
78
+ if (span.length < t.minSpanChars) return { pass: false, reason: `span ${span.length} chars < ${t.minSpanChars}` };
79
+ if (toks.length < t.minSpanTokens) return { pass: false, reason: `span ${toks.length} tokens < ${t.minSpanTokens}` };
80
+ if (normalizeWs(span) === normalizeWs(unit)) return { pass: false, reason: 'span is the whole unit' };
81
+ if (span.length > t.maxSpanFractionOfUnit * unit.length) return { pass: false, reason: `span is ${Math.round((100 * span.length) / unit.length)}% of the unit` };
82
+ const contentLines = span.split('\n').filter((l) => l.trim());
83
+ if (contentLines.length && contentLines.every((l) => isHeadingLine(l))) return { pass: false, reason: 'span is a heading line alone' };
84
+ return { pass: true, reason: '' };
85
+ }
86
+
87
+ export function checkNoLeak({ question, span, cos }, t = THRESHOLDS) {
88
+ const coverage = ngramCoverage(question, span, t.ngram);
89
+ const reasons = [];
90
+ if (cos >= t.leakCosine) reasons.push(`cosine ${cos.toFixed(3)} >= ${t.leakCosine}`);
91
+ if (coverage >= t.leakNgramCoverage) reasons.push(`${t.ngram}-gram coverage ${coverage.toFixed(2)} >= ${t.leakNgramCoverage}`);
92
+ return { pass: reasons.length === 0, reason: reasons.join('; '), cosine: cos, ngramCoverage: coverage };
93
+ }
94
+
95
+ export function checkParaphraseDiffers(direct, paraphrase, t = THRESHOLDS) {
96
+ if (!normalizeWs(paraphrase)) return { pass: false, reason: 'empty paraphrase', jaccard: 0 };
97
+ if (normalizeWs(direct).toLowerCase() === normalizeWs(paraphrase).toLowerCase()) return { pass: false, reason: 'paraphrase equals direct question', jaccard: 1 };
98
+ const j = jaccard(tokens(direct), tokens(paraphrase));
99
+ return { pass: j <= t.paraphraseMaxJaccard, reason: j <= t.paraphraseMaxJaccard ? '' : `token Jaccard ${j.toFixed(2)} > ${t.paraphraseMaxJaccard}`, jaccard: j };
100
+ }
101
+
102
+ function readUnit(snapshotDir, label, cache) {
103
+ if (!cache.has(label.path)) {
104
+ let buf;
105
+ try { buf = fs.readFileSync(path.join(snapshotDir, label.path)); } catch { cache.set(label.path, null); return null; }
106
+ cache.set(label.path, { blobSha: gitBlobSha(buf), lines: buf.toString('utf8').split('\n') });
107
+ }
108
+ const file = cache.get(label.path);
109
+ if (!file || file.blobSha !== label.blobSha) return null;
110
+ const text = unitText(file.lines, label.startLine, label.endLine);
111
+ return sha256Hex(Buffer.from(text, 'utf8')) === label.bytesSha256 ? text : null;
112
+ }
113
+
114
+ const failAll = (reason) => ({ a: { pass: false, reason }, b: { pass: false, reason }, c: { pass: false, reason }, d: { pass: false, reason } });
115
+ const quantiles = (xs) => {
116
+ const s = [...xs].sort((a, b) => a - b);
117
+ const q = (p) => (s.length ? s[Math.min(s.length - 1, Math.floor(p * (s.length - 1)))] : null);
118
+ return { n: s.length, min: q(0), p10: q(0.1), p50: q(0.5), p90: q(0.9), max: q(1) };
119
+ };
120
+
121
+ /** Synthetic leaky question: the span's own first 20 tokens — what a lazy producer would emit. */
122
+ export const leakyQuestionFor = (span) => `According to the text, ${tokens(span).slice(0, 20).join(' ')}?`;
123
+
124
+ export async function validateLabels({ labels, snapshotDir, embed, thresholds = THRESHOLDS }) {
125
+ const cache = new Map();
126
+ const rows = labels.labels.map((label) => ({ label, unit: label.producerError || label.skip ? null : readUnit(snapshotDir, label, cache) }));
127
+ const live = rows.filter((r) => r.unit !== null);
128
+ const texts = [];
129
+ const index = (t) => { texts.push(t); return texts.length - 1; };
130
+ for (const r of live) r.emb = { d: index(r.label.direct), p: index(r.label.paraphrase), s: index(r.label.span), leak: index(leakyQuestionFor(r.label.span)) };
131
+ const vectors = texts.length ? await embed(texts) : [];
132
+ const perLabel = [];
133
+ const leakyCos = [];
134
+ const directCos = [];
135
+ const paraCos = [];
136
+ for (const { label, unit, emb } of rows) {
137
+ if (label.producerError) { perLabel.push({ unitId: label.unitId, path: label.path, pass: false, checks: failAll(`producer error: ${label.producerError}`) }); continue; }
138
+ if (label.skip) { perLabel.push({ unitId: label.unitId, path: label.path, pass: false, checks: failAll(`skipped by producer: ${label.skipReason}`) }); continue; }
139
+ if (unit === null) { perLabel.push({ unitId: label.unitId, path: label.path, pass: false, checks: failAll('blob or unit drift: upstream bytes differ from the inventory') }); continue; }
140
+ const v = (i) => vectors[i];
141
+ const a = checkVerbatim(unit, label);
142
+ const b = checkNonTrivial(unit, label.span, thresholds);
143
+ const cd = checkNoLeak({ question: label.direct, span: label.span, cos: cosine(v(emb.d), v(emb.s)) }, thresholds);
144
+ const cp = checkNoLeak({ question: label.paraphrase, span: label.span, cos: cosine(v(emb.p), v(emb.s)) }, thresholds);
145
+ const c = { pass: cd.pass && cp.pass, reason: [cd.reason && `direct: ${cd.reason}`, cp.reason && `paraphrase: ${cp.reason}`].filter(Boolean).join('; '), direct: cd, paraphrase: cp };
146
+ const d = checkParaphraseDiffers(label.direct, label.paraphrase, thresholds);
147
+ const eCos = cosine(v(emb.d), v(emb.p));
148
+ const e = { pass: eCos >= thresholds.paraphraseMinCosine, cosine: eCos, reason: eCos >= thresholds.paraphraseMinCosine ? '' : `cosine(direct, paraphrase) ${eCos.toFixed(3)} < ${thresholds.paraphraseMinCosine}` };
149
+ leakyCos.push(cosine(v(emb.leak), v(emb.s)));
150
+ directCos.push(cd.cosine);
151
+ paraCos.push(cp.cosine);
152
+ // Role-neutral judge verdicts: path B (2026-09-14) lets claude judge, so read `judge` and fall back to
153
+ // the historical codex-shaped record. `codex` is still carried unchanged for existing consumers.
154
+ const judge = label.judge ?? (label.codex ? { host: 'codex', ...label.codex } : undefined);
155
+ perLabel.push({ unitId: label.unitId, path: label.path, kind: label.kind, pass: a.pass && b.pass && c.pass && d.pass, checks: { a, b, c, d }, informational: { e }, codex: label.codex, judge });
156
+ }
157
+ const count = (key) => ({ pass: perLabel.filter((r) => r.checks[key].pass).length, fail: perLabel.filter((r) => !r.checks[key].pass).length });
158
+ const codexVerdicts = perLabel.filter((r) => r.codex && !r.codex.error);
159
+ const yes = (side) => codexVerdicts.filter((r) => r.codex[side]?.answers === 'yes').length;
160
+ return {
161
+ schemaVersion: 1, kind: 'oracle-validation', repo: labels.repo, commit: labels.commit, thresholds,
162
+ aggregate: {
163
+ total: perLabel.length, pass: perLabel.filter((r) => r.pass).length,
164
+ producerErrors: rows.filter((r) => r.label.producerError).length, skipped: rows.filter((r) => r.label.skip).length,
165
+ byCheck: { a: count('a'), b: count('b'), c: count('c'), d: count('d') },
166
+ informationalE: { pass: perLabel.filter((r) => r.informational?.e.pass).length, evaluated: perLabel.filter((r) => r.informational).length },
167
+ secondary: {
168
+ whitespaceNormalizedMatch: perLabel.filter((r) => r.checks.a.normalizedMatch).length,
169
+ lineRangeMatch: perLabel.filter((r) => r.checks.a.lineRangeMatch).length,
170
+ verbatimOrNormalized: perLabel.filter((r) => r.checks.a.pass || r.checks.a.normalizedMatch).length,
171
+ },
172
+ codex: {
173
+ withVerdicts: codexVerdicts.length, directYes: yes('direct'), paraphraseYes: yes('paraphrase'),
174
+ bothYes: codexVerdicts.filter((r) => r.codex.direct?.answers === 'yes' && r.codex.paraphrase?.answers === 'yes').length,
175
+ bothYesAndAllChecksPass: codexVerdicts.filter((r) => r.pass && r.codex.direct?.answers === 'yes' && r.codex.paraphrase?.answers === 'yes').length,
176
+ },
177
+ // Whichever host judged. `allThreeYes` also requires the pair-equivalence verdict Dual required:
178
+ // two supported questions are not proof the paraphrase means the same thing.
179
+ judge: (() => {
180
+ const judged = perLabel.filter((r) => r.judge && !r.judge.error && !r.judge.direct?.error);
181
+ const yesOn = (side) => judged.filter((r) => r.judge[side]?.answers === 'yes').length;
182
+ const allThree = (r) => ['direct', 'paraphrase', 'equivalent'].every((side) => r.judge[side]?.answers === 'yes');
183
+ return {
184
+ hosts: [...new Set(judged.map((r) => r.judge.host))], withVerdicts: judged.length,
185
+ directYes: yesOn('direct'), paraphraseYes: yesOn('paraphrase'), equivalentYes: yesOn('equivalent'),
186
+ allThreeYes: judged.filter(allThree).length, allThreeYesAndAllChecksPass: judged.filter((r) => r.pass && allThree(r)).length,
187
+ };
188
+ })(),
189
+ cosineCalibration: { directVsSpan: quantiles(directCos), paraphraseVsSpan: quantiles(paraCos), syntheticLeakyVsSpan: quantiles(leakyCos) },
190
+ },
191
+ perLabel,
192
+ };
193
+ }
194
+
195
+ /** Local ONNX bge-base via the KB's own loader. Refuses to download: validation must be offline. */
196
+ export async function loadBgeEmbedder({ kbDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../kb') } = {}) {
197
+ const [{ loadTransformers, chooseModelCache }, { BGE_MODEL, configureTransformersModel, modelCacheReady }] = await Promise.all([
198
+ import(path.join(kbDir, 'resolve-deps.mjs')), import(path.join(kbDir, 'model-requirements.mjs')),
199
+ ]);
200
+ const { T } = await loadTransformers();
201
+ const cache = chooseModelCache({ kbDir });
202
+ if (!modelCacheReady(cache, BGE_MODEL)) throw new Error(`bge-base is not cached at ${cache}; set KB_MODEL_CACHE to a warm cache (validation never downloads)`);
203
+ configureTransformersModel(T, cache, BGE_MODEL);
204
+ T.env.allowRemoteModels = false;
205
+ const fe = await T.pipeline('feature-extraction', BGE_MODEL, { quantized: true });
206
+ return async (texts) => {
207
+ const out = [];
208
+ for (let i = 0; i < texts.length; i += 32) {
209
+ const batch = texts.slice(i, i + 32).map((t) => (t && t.trim() ? t : ' '));
210
+ const res = await fe(batch, { pooling: 'cls', normalize: true });
211
+ const [, dim] = res.dims;
212
+ for (let j = 0; j < batch.length; j++) out.push(Array.from(res.data.slice(j * dim, (j + 1) * dim)));
213
+ }
214
+ return out;
215
+ };
216
+ }
217
+
218
+ function arg(argv, flag) { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : undefined; }
219
+
220
+ export async function main(argv = process.argv.slice(2)) {
221
+ const labelsFile = arg(argv, '--labels');
222
+ const snapshotDir = arg(argv, '--dir');
223
+ const out = arg(argv, '--out');
224
+ if (!labelsFile || !snapshotDir) { process.stderr.write('Usage: validate-labels.mjs --labels <labels.json> --dir <snapshot> [--out <validation.json>]\n'); return 64; }
225
+ const labels = JSON.parse(fs.readFileSync(labelsFile, 'utf8'));
226
+ const embed = await loadBgeEmbedder();
227
+ const validation = await validateLabels({ labels, snapshotDir: path.resolve(snapshotDir), embed });
228
+ const json = `${JSON.stringify(validation, null, 2)}\n`;
229
+ if (out) fs.writeFileSync(out, json); else process.stdout.write(json);
230
+ const a = validation.aggregate;
231
+ process.stderr.write(`[validate] ${labels.repo}: ${a.pass}/${a.total} pass | a=${a.byCheck.a.pass} b=${a.byCheck.b.pass} c=${a.byCheck.c.pass} d=${a.byCheck.d.pass} | ws-normalised=${a.secondary.whitespaceNormalizedMatch} line-range=${a.secondary.lineRangeMatch}\n`);
232
+ return 0;
233
+ }
234
+
235
+ // Entry-point guard. Compares REALPATHS on both sides: path.resolve() normalizes a path but does
236
+ // NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
237
+ // shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
238
+ // never runs -- and because nothing throws, the process exits 0. A silent exit 0 is indistinguishable
239
+ // from "ran, found nothing", which is how prepareCorpusCandidate once reported SUCCESS with no
240
+ // archive on disk. Reproduced live 2026-07-27; pinned by tests/unit/entrypoint-symlink.test.mjs.
241
+ function isDirectInvocation() {
242
+ try {
243
+ if (!process.argv[1]) return false;
244
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+
250
+ if (isDirectInvocation()) process.exitCode = await main();