ruvnet-brain 4.4.0 → 4.5.0

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 (105) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +679 -121
  3. package/console/app.js +178 -81
  4. package/console/index.html +1 -1
  5. package/console/install-architecture.html +1 -0
  6. package/console/scope.css +4 -1
  7. package/console/style.css +13 -0
  8. package/console/tips.html +4 -4
  9. package/kb/brain-profile.mjs +1 -0
  10. package/kb/corpus-release-identity.mjs +1 -1
  11. package/kb/forge-update.mjs +41 -17
  12. package/kb/model-requirements.mjs +4 -1
  13. package/kb/update-storage-transaction.mjs +79 -0
  14. package/kb/zip-extract.mjs +22 -0
  15. package/package.json +1 -1
  16. package/plugin/.claude-plugin/plugin.json +1 -1
  17. package/plugin/.codex-plugin/plugin.json +1 -1
  18. package/plugin/commands/brain-console.md +5 -4
  19. package/plugin/commands/configure.md +5 -4
  20. package/plugin/commands/rnb-brief.md +41 -0
  21. package/plugin/commands/rnb.md +80 -0
  22. package/plugin/commands/rnbc.md +80 -0
  23. package/plugin/commands/rvbc.md +5 -4
  24. package/plugin/commands/rvcb.md +5 -4
  25. package/plugin/commands/whats-new.md +4 -4
  26. package/plugin/mcp/server.mjs +10 -2
  27. package/plugin/scripts/advocacy-route.mjs +59 -23
  28. package/plugin/scripts/anticipate.sh +4 -0
  29. package/plugin/scripts/brain-confirmation.mjs +258 -0
  30. package/plugin/scripts/brain-footprint.mjs +494 -0
  31. package/plugin/scripts/brain-location.mjs +47 -0
  32. package/plugin/scripts/capability-registry.mjs +11 -1
  33. package/plugin/scripts/continuity-brief.mjs +324 -0
  34. package/plugin/scripts/continuity-events.mjs +327 -0
  35. package/plugin/scripts/continuity-journal.mjs +500 -0
  36. package/plugin/scripts/decision-gate.mjs +56 -3
  37. package/plugin/scripts/footprint-io.mjs +186 -0
  38. package/plugin/scripts/ground-before-write.sh +8 -1
  39. package/plugin/scripts/ground-ruvnet.sh +103 -12
  40. package/plugin/scripts/grounding-answer.mjs +2 -1
  41. package/plugin/scripts/grounding-stamp.sh +3 -0
  42. package/plugin/scripts/grounding-substance.mjs +1 -1
  43. package/plugin/scripts/grounding-turn-evidence.mjs +68 -6
  44. package/plugin/scripts/hook-input.mjs +78 -4
  45. package/plugin/scripts/kb-copy-proof.mjs +148 -0
  46. package/plugin/scripts/lesson-bridge.mjs +6 -2
  47. package/plugin/scripts/nightly-controller.mjs +8 -1
  48. package/plugin/scripts/node-sqlite.mjs +41 -0
  49. package/plugin/scripts/package-cards.json +797 -0
  50. package/plugin/scripts/package-cards.rvf +0 -0
  51. package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
  52. package/plugin/scripts/package-cards.rvf.meta.json +1 -0
  53. package/plugin/scripts/package-recommender-client.mjs +138 -0
  54. package/plugin/scripts/package-recommender-flag.mjs +30 -0
  55. package/plugin/scripts/package-recommender.mjs +391 -0
  56. package/plugin/scripts/project-progression-outbox.mjs +26 -8
  57. package/plugin/scripts/project-progression-reader.mjs +14 -2
  58. package/plugin/scripts/project-progression-store.mjs +153 -6
  59. package/plugin/scripts/protect-brain-state.sh +4 -1
  60. package/plugin/scripts/session-snapshot-hook.mjs +225 -41
  61. package/plugin/scripts/session-start-budget.mjs +1 -0
  62. package/plugin/scripts/session-start-core.mjs +34 -4
  63. package/plugin/scripts/session-start-health.mjs +7 -1
  64. package/plugin/scripts/session-start-update-plane.mjs +35 -0
  65. package/plugin/scripts/turn-outcome-capture.mjs +12 -1
  66. package/plugin/scripts/unprompted-runtime.mjs +2 -2
  67. package/plugin/skills/brain-console/SKILL.md +3 -3
  68. package/plugin/skills/rnbc/SKILL.md +24 -0
  69. package/plugin/skills/rvbc/SKILL.md +2 -2
  70. package/scripts/approved-runtime.mjs +2 -2
  71. package/scripts/ci/warm-brain-models.mjs +28 -0
  72. package/scripts/codex-hook-trust.mjs +94 -0
  73. package/scripts/console-instances.mjs +70 -12
  74. package/scripts/console-runtime-identity.mjs +5 -0
  75. package/scripts/corpus-canary.mjs +46 -6
  76. package/scripts/corpus-dispatch-decision.mjs +2 -2
  77. package/scripts/corpus-promotion.mjs +1 -1
  78. package/scripts/full-suite-gate.mjs +9 -2
  79. package/scripts/hook-qualify-hosts.mjs +15 -3
  80. package/scripts/host-install-matrix.mjs +63 -2
  81. package/scripts/human-approval-phrases.mjs +46 -0
  82. package/scripts/installed-brain-health.mjs +53 -0
  83. package/scripts/move-brain.mjs +310 -0
  84. package/scripts/onboarding-console.mjs +93 -10
  85. package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
  86. package/scripts/oracle/abstain-trace.mjs +139 -0
  87. package/scripts/oracle/doc2query-generate.mjs +162 -0
  88. package/scripts/oracle/doc2query-reach.mjs +110 -0
  89. package/scripts/oracle/judge-train.mjs +158 -0
  90. package/scripts/oracle/need-set-split.mjs +48 -0
  91. package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
  92. package/scripts/package-cards.mjs +374 -0
  93. package/scripts/publication-receipt.mjs +37 -9
  94. package/scripts/recommendation-e2e.mjs +110 -0
  95. package/scripts/recommendation-eval.mjs +105 -0
  96. package/scripts/recommendation-floor.mjs +56 -0
  97. package/scripts/recommendation-judge-score.mjs +74 -0
  98. package/scripts/recommendation-latency.mjs +95 -0
  99. package/scripts/recommendation-real-host-score.mjs +76 -0
  100. package/scripts/recommendation-real-host.mjs +137 -0
  101. package/scripts/release-channel-kind.mjs +1 -1
  102. package/scripts/release-environment-policy.mjs +33 -0
  103. package/scripts/single-source-check.mjs +15 -10
  104. package/scripts/sync-commands.mjs +5 -2
  105. package/scripts/wired-check.mjs +17 -2
@@ -0,0 +1,391 @@
1
+ // package-recommender.mjs — "what would rUv do?" over PACKAGE-LEVEL capability cards (ADR-0093,
2
+ // status Proposed). A pure matcher: prompt text in, at most ONE package card out, or null (silence).
3
+ // No IO except reading the card file once; no child process; no network; no embedder.
4
+ //
5
+ // WHY IT EXISTS. advocacy-catalog.mjs is a closed list of seven intents bound to seven building
6
+ // blocks. On 2026-09-30 the owner had to name @ruvector/typesafe himself — a package whose manifest
7
+ // the Brain had ingested — because nothing on the hook path could reach any package the catalogue's
8
+ // author had not hand-written. This matcher reads the cards scripts/package-cards.mjs derives from the
9
+ // corpus's own manifests, so a package rUv ships tonight is recommendable after the next card build
10
+ // with no code change.
11
+ //
12
+ // WHY LEXICAL. Same reason as advocacy-route.mjs and kb/card-lane.mjs: an embedder's cold init is
13
+ // ~3 s against a 3 s hook timeout. This is the card lane's discipline — content-token overlap gated by
14
+ // a minimum overlap, a coverage floor and a winner margin — with one addition the lane does not need:
15
+ // IDF weighting. ~800 package cards share a lot of vocabulary ("vector", "search", "rust", "fast");
16
+ // a word that half the cards carry says nothing about which card is meant.
17
+ //
18
+ // WHY THE TOKENIZER IS A COPY. The plugin and the knowledge bundle ship separately and cannot import
19
+ // each other (issue #32). The tokenizer below is the card lane's contentTokens() without its query
20
+ // phrase rewrites, and tests/unit/package-recommender.test.mjs holds the two equal on a shared table.
21
+ //
22
+ // SILENCE IS THE DEFAULT. Null for: short text, no design/diagnosis cue, no card clearing every gate,
23
+ // or a near-tie. A recommender that speaks on a coincidence is the nag ADR-028 exists to prevent.
24
+ import crypto from 'node:crypto';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { stateHashOf } from './advocacy-outcomes.mjs';
29
+
30
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
31
+ export const SNAPSHOT_FILE = path.join(HERE, 'package-cards.json');
32
+ export const SCHEMA = 'ruvnet-brain.package-cards/1';
33
+
34
+ // ── Tokenizer (held equal to kb/card-lane.mjs contentTokens by test) ─────────────────────────────
35
+ export const STOPWORDS = new Set(`
36
+ a an the of and or but if then else for to from in on at by with without into onto over under
37
+ is are was were be been being do does did doing done can could should would will shall may might
38
+ what which who whom whose when where why how
39
+ this that these those it its i you he she they we my your his her their our
40
+ need needs want wants use uses using used tool tools reach like ask asks question questions
41
+ have has had not no nor so such too very just about also
42
+ each other
43
+ `.trim().split(/\s+/));
44
+
45
+ const normalizeApostrophes = (text) => String(text ?? '').replace(/[‘’ʼ]/g, "'");
46
+
47
+ export function lexTokens(text) {
48
+ const raw = normalizeApostrophes(text).toLowerCase().match(/[a-z0-9][a-z0-9+.#-]*[a-z0-9]|[a-z0-9]/g) || [];
49
+ const out = new Set();
50
+ for (const t of raw) {
51
+ if (t.length >= 3 && !STOPWORDS.has(t)) out.add(t);
52
+ if (t.includes('-')) for (const part of t.split('-')) if (part.length >= 3 && !STOPWORDS.has(part)) out.add(part);
53
+ }
54
+ return [...out];
55
+ }
56
+
57
+ /**
58
+ * A deliberately light suffix stripper applied to BOTH sides at scoring time, so "permitted" meets
59
+ * "permit" and "queries" meets "query". Not Porter: four rules, length-guarded, no dictionary.
60
+ */
61
+ export function stem(t) {
62
+ if (t.length <= 4 || t.includes('-') || /\d/.test(t)) return t;
63
+ if (t.endsWith('ies')) return `${t.slice(0, -3)}y`;
64
+ if (t.endsWith('ied')) return `${t.slice(0, -3)}y`;
65
+ if (t.endsWith('ing') && t.length > 6) return t.slice(0, -3);
66
+ if (t.endsWith('ed') && t.length > 5) return t.endsWith('eed') ? t : t.slice(0, -2).replace(/(.)\1$/, '$1');
67
+ if (/(ss|us|is)$/.test(t)) return t;
68
+ if (t.endsWith('es') && /(s|x|z|ch|sh)es$/.test(t)) return t.slice(0, -2);
69
+ if (t.endsWith('s')) return t.slice(0, -1);
70
+ return t;
71
+ }
72
+
73
+ const scoringTokens = (text) => [...new Set(lexTokens(text).filter((t) => !GENERIC.has(t)).map(stem).filter((t) => !GENERIC.has(t)))];
74
+
75
+ // ── Scoring vocabulary ────────────────────────────────────────────────────────────────────────────
76
+ // Words that are true of almost every rUv package and of almost every prompt. They may still match,
77
+ // but they never count toward the overlap floor and carry no weight.
78
+ const GENERIC = new Set(`
79
+ ruv ruvector ruvnet rust native napi napi-rs wasm webassembly node node.js nodejs typescript javascript
80
+ fast faster high-performance performance simd optimized optimization library sdk cli bindings binding
81
+ package module api based support system systems tool engine framework platform production ready
82
+ agent agents agentic llm llms data build app application real-time time new run runs running
83
+ every keep keeps get gets make makes one two way lot lots thing things work works working
84
+ first right best good better instead just really basically whole same
85
+ `.trim().split(/\s+/));
86
+
87
+ // Ordinary-language → manifest-language expansions. Generic vocabulary bridges ONLY: each maps a word
88
+ // people say to the words manifests use. None names a package — naming a package here would rebuild
89
+ // the closed catalogue this module replaces.
90
+ const EXPANSIONS = [
91
+ [/\b(classif\w*|categori[sz]\w*|triage|sort\w* (?:\w+ ){0,4}into)\b/g, 'classification decisions choice'],
92
+ [/\b(sentiment|urgency)\b/g, 'decisions score confidence'],
93
+ [/\bcalibrat\w*\b/g, 'calibration confidence'],
94
+ [/\bintents?\b/g, 'intent intent-matching'],
95
+ [/\bby meaning\b|\bsemantic(ally)?\b/g, 'semantic embeddings'],
96
+ [/\b(exact (?:keyword|term|match)\w*|keyword match\w*|part numbers?|sku)\b/g, 'bm25 sparse keyword'],
97
+ [/\b(fuse|merg\w*|combin\w*)\b[^.!?]{0,60}\b(rank\w*|results?|lists?)\b/g, 'fusion rrf hybrid-search'],
98
+ [/\b(colbert|multi[- ]vector|late interaction)\b/g, 'maxsim multi-vector late interaction'],
99
+ [/\b(re-?rank\w*|reorder\w*|ordering)\b/g, 'reranking rerank'],
100
+ [/\bcach\w*\b/g, 'cache caching'],
101
+ [/\b(prompt injection|jailbreak\w*|system prompt)\b/g, 'prompt-injection jailbreak-detection'],
102
+ [/\bforg[eo]t\w*\b|\blong[- ]term memory\b/g, 'memory agent-memory persistent'],
103
+ [/\b(pinecone|qdrant|weaviate|chroma)\b/g, 'vector database hnsw'],
104
+ [/\b(postgres\w*|pgvector)\b/g, 'postgresql pgvector'],
105
+ [/\b(ssd|on disk|fit in ram|billion)\b/g, 'diskann ssd billion-scale'],
106
+ [/\bknowledge graph\b/g, 'knowledge-graph graph'],
107
+ [/\b(roll(?:ed|ing)?(?: \w+)? back|rollback|branch\w*)\b/g, 'branching copy-on-write'],
108
+ [/\bsynthetic\b/g, 'synthetic data generator'],
109
+ [/\b(topological|critical path)\b/g, 'dag topological scheduling'],
110
+ [/\b(latex|equations?)\b/g, 'latex ocr'],
111
+ [/\bsparse linear\b|\blinear system\b/g, 'sparse linear solver'],
112
+ [/\b(min(imum)?[- ]cut)\b/g, 'mincut minimum cut'],
113
+ [/\bwi-?fi\b/g, 'wifi sensing csi'],
114
+ [/\b(replica\w*|partition\w*)\b/g, 'replication conflict resolution'],
115
+ [/\bleader election\b/g, 'raft consensus leader election'],
116
+ [/\b(photos?|images?)\b/g, 'image'],
117
+ [/\b(flaky|coverage)\b/g, 'flaky coverage quality'],
118
+ [/\b(traffic )?spikes?\b/g, 'burst scaling traffic spikes'],
119
+ [/\b(cheap\w*|expensive|bill)\b/g, 'cost routing model'],
120
+ ];
121
+
122
+ // Two concepts that, TOGETHER, name a third: literal matching plus meaning-matching IS hybrid search.
123
+ const CONJUNCTIONS = [
124
+ [/\b(keyword|exact|literal|bm25)\b/, /\b(semantic\w*|meaning|vector|similar\w*|embedding\w*)\b/, 'hybrid-search hybrid fusion'],
125
+ ];
126
+
127
+ export function expand(text) {
128
+ const lower = normalizeApostrophes(text).toLowerCase();
129
+ const extra = [];
130
+ for (const [re, add] of EXPANSIONS) { re.lastIndex = 0; if (re.test(lower)) extra.push(add); }
131
+ for (const [a, b, add] of CONJUNCTIONS) if (a.test(lower) && b.test(lower)) extra.push(add);
132
+ return extra.length ? `${lower} ${extra.join(' ')}` : lower;
133
+ }
134
+
135
+ // A recommendation is for DESIGN ("build/add/I want/how do we") or DIAGNOSIS ("slow/keeps/costs")
136
+ // turns. Status checks, chit-chat, git chores, and explanations get nothing.
137
+ const DESIGN = /\b(build|add|implement|design|architect|create|set ?up|wire|integrate|needs?|wants?|looking for|how (?:do|can|should) (?:i|we)|should (?:i|we)|is there a way|choose|pick|replace|swap|migrate|let|give|store|generate|extract|detect|decide|route|split|solve|schedule|match|combine|rerank|apply|make)\b/;
138
+ const DIAGNOSIS = /\b(slow|latency|broken|fail\w*|keeps?|doesn'?t|does not|isn'?t|won'?t|bad|poor|wrong|worse|weird|problems?|too (?:expensive|slow|high)|costs?|bill|spikes?|drift\w*|corrupt\w*|forg[eo]t\w*|leak\w*|flaky|falls? over|hammered|washes out|misses|brittle|off|tripled|doubled)\b/;
139
+ // A conversational opener ("ok", "yes", "run …") only marks a non-work turn when the turn is SHORT.
140
+ // Measured on the blind set (2026-10-01): "ok so I want each of our AI helpers to …" was silenced by
141
+ // an unconditional opener rule — owners dictate, and dictation starts with "ok so".
142
+ const NON_WORK = /^(ok|okay|yes|no|thanks|thank you|continue|go on|commit|push|run|explain|what is|what's|why|summari[sz]e|format|rename|draft|write me)\b/;
143
+ const NON_WORK_MAX_LEN = 60;
144
+
145
+ export function isDesignOrDiagnosis(text) {
146
+ const t = normalizeApostrophes(text).trim().toLowerCase();
147
+ if (t.length < 20 || (t.length <= NON_WORK_MAX_LEN && NON_WORK.test(t))) return false;
148
+ return DESIGN.test(t) || DIAGNOSIS.test(t);
149
+ }
150
+
151
+ // ── Card index ────────────────────────────────────────────────────────────────────────────────────
152
+ function cardText(card) {
153
+ const short = String(card.id || '').replace(/^@[^/]+\//, '');
154
+ return `${short} ${card.family || ''} ${card.description || ''} ${(card.keywords || []).join(' ')}`;
155
+ }
156
+
157
+ /** Build the scoring index once per process: per-card token sets + IDF over the whole card set. */
158
+ /**
159
+ * The tokenizer identity. The card generator stores each card's scoring tokens pre-computed under
160
+ * this version (cold-start cost: tokenizing ~800 cards was ~35 ms per prompt). A card file stamped
161
+ * with any other version is re-tokenized here, so a stale precompute can cost time but never
162
+ * correctness. tests/unit/package-recommender.test.mjs fails if the snapshot's stored tokens differ
163
+ * from what this code computes — bump the version whenever STOPWORDS, GENERIC or stem() change.
164
+ */
165
+ export const TOKENIZER_VERSION = 'pkgrec-tok/1';
166
+
167
+ /** A card's scoring token sets: `t` over all its text, `s` (strong) over its name and keywords. */
168
+ export function cardTokenSets(card) {
169
+ return {
170
+ t: scoringTokens(cardText(card)),
171
+ s: scoringTokens(`${card.id} ${card.family || ''} ${(card.keywords || []).join(' ')}`),
172
+ };
173
+ }
174
+
175
+ export function indexCards(doc) {
176
+ const cards = Array.isArray(doc?.cards) ? doc.cards.filter((c) => c && typeof c.id === 'string' && typeof c.source === 'string') : [];
177
+ const precomputed = doc?.tokenizer === TOKENIZER_VERSION;
178
+ const entries = cards.map((card) => {
179
+ const sets = precomputed && Array.isArray(card.t) && Array.isArray(card.s) ? card : cardTokenSets(card);
180
+ return { card, tokens: new Set(sets.t), strong: new Set(sets.s) };
181
+ });
182
+ const df = new Map();
183
+ for (const e of entries) for (const t of e.tokens) df.set(t, (df.get(t) || 0) + 1);
184
+ const n = Math.max(1, entries.length);
185
+ const idf = (t) => Math.log((n + 1) / ((df.get(t) || 0) + 1));
186
+ return { entries, idf, n };
187
+ }
188
+
189
+ let _loaded = null; // { file, mtimeMs, size, index }
190
+
191
+ /** Candidate card files, highest precedence first: an explicit override, the bundle copy, the snapshot. */
192
+ export function cardFiles(env = process.env) {
193
+ const out = [];
194
+ if (env.RUVNET_PACKAGE_CARDS) out.push(env.RUVNET_PACKAGE_CARDS);
195
+ const kb = env.RUVNET_BRAIN_KB || (env.RUVNET_BRAIN_HOME ? path.join(env.RUVNET_BRAIN_HOME, 'kb') : null);
196
+ if (kb) out.push(path.join(kb, 'package-cards.json'));
197
+ out.push(SNAPSHOT_FILE);
198
+ return out;
199
+ }
200
+
201
+ /** Load + index the first readable, schema-valid card file. Null when none — never a fake index. */
202
+ export function loadIndex(files = cardFiles()) {
203
+ for (const file of files) {
204
+ let stat;
205
+ try { stat = fs.statSync(file); } catch { continue; }
206
+ if (_loaded && _loaded.file === file && _loaded.mtimeMs === stat.mtimeMs && _loaded.size === stat.size) return _loaded.index;
207
+ try {
208
+ const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
209
+ if (doc?.schema !== SCHEMA || !Array.isArray(doc.cards) || !doc.cards.length) continue;
210
+ const index = { ...indexCards(doc), file, derivedFrom: doc.derivedFrom || null };
211
+ _loaded = { file, mtimeMs: stat.mtimeMs, size: stat.size, index };
212
+ return index;
213
+ } catch { /* unreadable or malformed → try the next source */ }
214
+ }
215
+ return null;
216
+ }
217
+
218
+ // ── The gates (tuned on evals/recommendation-eval.v1.json split=dev ONLY; see ADR-0093) ──────────
219
+ export const GATES = Object.freeze({
220
+ MIN_OVERLAP: 2, // distinct non-generic tokens shared with the card
221
+ MIN_STRONG: 1, // at least one of them from the card's name/keywords, not only its prose
222
+ MIN_SCORE: 7.0, // IDF-weighted overlap
223
+ MIN_COVERAGE: 0.15, // share of the prompt's own non-generic tokens the card explains
224
+ MARGIN: 1.25, // winner score must be >= MARGIN x runner-up (a different family)
225
+ });
226
+
227
+ /**
228
+ * Score every card; return the ranked list (for evaluation) and the decision (for the hook).
229
+ * decision is { card, score, overlap, matched } or null with a reason.
230
+ */
231
+ export function rank(prompt, index, gates = GATES) {
232
+ if (!index) return { decision: null, reason: 'no-card-index', ranked: [] };
233
+ if (!isDesignOrDiagnosis(prompt)) return { decision: null, reason: 'not-design-or-diagnosis', ranked: [] };
234
+ const q = scoringTokens(expand(prompt));
235
+ if (q.length < 2) return { decision: null, reason: 'too-few-content-words', ranked: [] };
236
+ const ranked = [];
237
+ for (const e of index.entries) {
238
+ let score = 0; let strongHits = 0; const matched = [];
239
+ for (const t of q) {
240
+ if (!e.tokens.has(t)) continue;
241
+ const w = index.idf(t) * (e.strong.has(t) ? 1.5 : 1);
242
+ score += w; matched.push(t);
243
+ if (e.strong.has(t)) strongHits++;
244
+ }
245
+ if (matched.length) ranked.push({ card: e.card, score, overlap: matched.length, strongHits, matched, coverage: matched.length / q.length });
246
+ }
247
+ // Code-unit order for the tie-break, NOT localeCompare: the first localeCompare in a cold process
248
+ // initialises ICU collation (~15 ms measured), paid on every prompt by a hook.
249
+ ranked.sort((a, b) => b.score - a.score || (a.card.id < b.card.id ? -1 : a.card.id > b.card.id ? 1 : 0));
250
+ const top = ranked[0];
251
+ if (!top) return { decision: null, reason: 'no-overlap', ranked };
252
+ const rival = ranked.find((r) => r.card.family !== top.card.family || r.card.store !== top.card.store);
253
+ if (top.overlap < gates.MIN_OVERLAP) return { decision: null, reason: 'overlap', ranked };
254
+ if (top.strongHits < gates.MIN_STRONG && top.overlap < 3) return { decision: null, reason: 'no-strong-token', ranked };
255
+ if (top.score < gates.MIN_SCORE) return { decision: null, reason: 'score', ranked };
256
+ if (top.coverage < gates.MIN_COVERAGE) return { decision: null, reason: 'coverage', ranked };
257
+ if (rival && top.score < gates.MARGIN * rival.score) return { decision: null, reason: 'margin', ranked };
258
+ return { decision: top, reason: null, ranked };
259
+ }
260
+
261
+ /** The hook's entry point: the one card to recommend, or null. Never throws. */
262
+ export function recommend(prompt, { index } = {}) {
263
+ try {
264
+ const idx = index === undefined ? loadIndex() : index;
265
+ const d = rank(prompt, idx).decision;
266
+ if (!d) return null;
267
+ // Name the PRODUCT's canonical install (ADR-093 rev 3), not whichever sibling package matched.
268
+ const canon = d.card.canonical && d.card.canonical !== d.card.id ? idx.entries.find((e) => e.card.id === d.card.canonical)?.card : null;
269
+ return canon ? { ...d, card: canon, matchedVia: d.card.id } : d;
270
+ } catch { return null; }
271
+ }
272
+
273
+ /** One card per PRODUCT, named by its canonical install (ADR-093 rev 3). Unknown ids drop out. */
274
+ export function canonicalPicks(candidates, byId) {
275
+ const seen = new Set();
276
+ const out = [];
277
+ for (const c of candidates || []) {
278
+ const card = byId.get(c.id);
279
+ if (!card) continue;
280
+ const canon = (card.canonical && byId.get(card.canonical)) || card;
281
+ const product = card.product || canon.id;
282
+ if (seen.has(product)) continue;
283
+ seen.add(product);
284
+ out.push({ card: canon, similarity: c.similarity, matchedVia: card.id });
285
+ }
286
+ return out;
287
+ }
288
+
289
+ export { packageRecommenderEnabled, offerNames } from './package-recommender-flag.mjs';
290
+
291
+ /** The short name a user says back ("use typesafe"): the package id without its scope. */
292
+ export function shortName(card) {
293
+ return String(card?.id || '').replace(/^@[^/]+\//, '');
294
+ }
295
+
296
+ /**
297
+ * The advocacy candidate for one picked card: ONE line naming the package, its manifest description
298
+ * and its source path. Same channel and aggregate shape as advocacy-route's catalogue candidate, so
299
+ * unprompted-runtime applies the dial, the DismissalLedger and the OFFERED record unchanged.
300
+ */
301
+ export function buildPackageCandidate({ prompt, pick, findingPrefix = 'recommend:pkg:' }) {
302
+ const card = pick?.card;
303
+ if (!card || typeof card.id !== 'string' || typeof card.source !== 'string') return null;
304
+ const name = shortName(card);
305
+ const description = String(card.description || '').replace(/\s+/g, ' ').trim().slice(0, 220);
306
+ return {
307
+ channel: 'advocacy',
308
+ effect: 'advisory',
309
+ hookEventName: 'UserPromptSubmit',
310
+ findingId: `${findingPrefix}${card.id}`,
311
+ severity: 'normal',
312
+ observationHash: stateHashOf([`package:${card.id}`]),
313
+ copy: `[RuvNet Brain — rUv already ships this] If it genuinely fits this request, tell the user in ONE `
314
+ + `sentence: "rUv ships ${card.id} — ${description} (source: ${card.source}). Say 'use ${name}' to proceed, or ignore this." `
315
+ + 'Install state unknown from here; confirm with search_ruvnet before building, say it once, then carry on.',
316
+ capability: name,
317
+ package: card.id,
318
+ source: card.source,
319
+ sourceSha256: card.sourceSha256 || null,
320
+ matched: Array.isArray(pick.matched) ? pick.matched : [],
321
+ score: Number.isFinite(pick.score) ? +pick.score.toFixed(3) : null,
322
+ promptHash: crypto.createHash('sha256').update(String(prompt || '').trim().toLowerCase()).digest('hex').slice(0, 16),
323
+ };
324
+ }
325
+
326
+ // ── The semantic lane (ADR-093 rev 2) ────────────────────────────────────────────────────────────
327
+ // When a warm worker answers, the hook does NOT pick: it hands the host model the K nearest package
328
+ // cards and an instruction to mention at most ONE, and only if it genuinely fits. Measured on two blind
329
+ // sets with a model standing in for the host (evals/runs/2026-10-01-recommender-4.6/): the embedding is
330
+ // the better finder of candidates, the model the better judge of fit.
331
+ export const SEMANTIC_K = 4;
332
+ // ONE candidate set per session until a real host run shows the model stays quiet on negatives
333
+ // (adversarial review H1, 2026-10-01). Raise only on that evidence.
334
+ export const SEMANTIC_MAX_PER_SESSION = 1;
335
+ // Inject only when the NEAREST card clears this cosine similarity. Chosen on the self-authored set only
336
+ // (5th percentile of top-1 similarity among judge-correct hits = 0.532), then frozen; on the blinds it
337
+ // cut injections on negative prompts from 16/36 to 4/36 at a measured recall cost (ADR-093 rev 2).
338
+ export const SEMANTIC_MIN_SIMILARITY = 0.532;
339
+ export function semanticFloor(env = process.env) {
340
+ const v = Number(env.RUVNET_PACKAGE_RECOMMENDER_MIN_SIMILARITY);
341
+ return Number.isFinite(v) && v >= 0 && v <= 1 ? v : SEMANTIC_MIN_SIMILARITY;
342
+ }
343
+
344
+ /** The advocacy candidate carrying a candidate SET, phrased exactly as the measured instruction. */
345
+ export function buildCandidateSetCandidate({ prompt, picks, findingPrefix = 'recommend:pkg:' }) {
346
+ const cards = (picks || []).map((p) => p.card).filter((c) => c && typeof c.id === 'string' && typeof c.source === 'string');
347
+ if (!cards.length) return null;
348
+ const list = cards.map((c) => `${c.id} — ${String(c.description || '').replace(/\s+/g, ' ').trim().slice(0, 200)} (${c.source})`).join('; ');
349
+ const top = cards[0];
350
+ return {
351
+ channel: 'advocacy',
352
+ effect: 'advisory',
353
+ hookEventName: 'UserPromptSubmit',
354
+ findingId: `${findingPrefix}${top.id}`,
355
+ severity: 'normal',
356
+ observationHash: stateHashOf([`package:${top.id}`]),
357
+ copy: `[RuvNet Brain — rUv may already ship this] Candidate rUv packages for this request (from the Brain's package cards): ${list}. `
358
+ + 'If, and only if, ONE of them would materially help with exactly what the user is asking for, tell the user in one sentence: '
359
+ + "'rUv ships <id> — <why it fits> (source: <source>)'. If none clearly fits, say nothing about them. Never mention more than one.",
360
+ capability: shortName(top),
361
+ package: top.id,
362
+ candidates: cards.map((c) => c.id),
363
+ similarities: (picks || []).map((p) => (Number.isFinite(p.similarity) ? +p.similarity.toFixed(4) : null)),
364
+ promptHash: crypto.createHash('sha256').update(String(prompt || '').trim().toLowerCase()).digest('hex').slice(0, 16),
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Turn a warm worker's answer into an advocacy lane, or null. `offered` = short names already offered
370
+ * this session; `allowed(findingId)` = the DismissalLedger's verdict. A dismissed or already-offered
371
+ * package is dropped from the set, never re-shown.
372
+ */
373
+ export function semanticLane({ prompt, semantic, offered = new Set(), allowed = () => true, index, findingPrefix = 'recommend:pkg:', floor = semanticFloor() }) {
374
+ if (!Array.isArray(semantic?.candidates) || !semantic.candidates.length) return null;
375
+ if (!(semantic.candidates[0].similarity >= floor)) return null; // nearest card too far: no hint at all
376
+ if (!isDesignOrDiagnosis(prompt)) return null;
377
+ const idx = index === undefined ? loadIndex() : index;
378
+ if (!idx) return null;
379
+ const byId = new Map(idx.entries.map((e) => [e.card.id, e.card]));
380
+ const picks = canonicalPicks(semantic.candidates, byId)
381
+ .filter((p) => p.card && !offered.has(shortName(p.card)) && allowed(`${findingPrefix}${p.card.id}`))
382
+ .slice(0, SEMANTIC_K);
383
+ if (!picks.length) return null;
384
+ const top = picks[0].card;
385
+ return {
386
+ capability: shortName(top), id: `${findingPrefix}${top.id}`, intent: 'package-candidates', cap: SEMANTIC_MAX_PER_SESSION,
387
+ extra: { package: top.id, candidates: picks.map((p) => shortName(p.card)), packages: picks.map((p) => p.card.id) },
388
+ stateHash: stateHashOf([`package:${top.id}`]),
389
+ build: () => buildCandidateSetCandidate({ prompt, picks, findingPrefix }),
390
+ };
391
+ }
@@ -65,31 +65,49 @@ export class ProgressionOutbox {
65
65
  });
66
66
  }
67
67
 
68
+ /**
69
+ * Snapshots fsynced but not committed. A key whose records DISAGREE (two snapshots, or a snapshot and
70
+ * a commit, with different digests) is still never replayed — that is the fail-closed part — but it
71
+ * is QUARANTINED, not thrown: on this repo's real outbox one such collision (2026-09-18, two Stop
72
+ * boundaries of one session producing the same sequence and dedup id) made this method throw on
73
+ * every call, every caller swallowed it, and no pending snapshot was replayed for 13 days while the
74
+ * SessionStart notice read 0 pending. Quarantined keys are reported by quarantinedKeys().
75
+ */
68
76
  pendingSnapshots() {
69
77
  const snapshots = new Map();
70
78
  const committed = new Map();
79
+ const quarantined = new Map();
71
80
  for (const record of this.records()) {
72
81
  requireIdentity(record?.eventKey, 'outbox eventKey');
73
82
  requireIdentity(record?.payloadDigest, 'outbox payloadDigest');
74
83
  if (record.type === 'snapshot') {
75
84
  const prior = snapshots.get(record.eventKey);
76
- if (prior && prior.payloadDigest !== record.payloadDigest) throw new Error('outbox event key collision');
77
- snapshots.set(record.eventKey, record);
85
+ if (prior && prior.payloadDigest !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox event key collision');
86
+ else snapshots.set(record.eventKey, record);
78
87
  } else if (record.type === 'commit') {
79
88
  const prior = committed.get(record.eventKey);
80
- if (prior && prior !== record.payloadDigest) throw new Error('outbox commit collision');
89
+ if (prior && prior !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox commit collision');
81
90
  committed.set(record.eventKey, record.payloadDigest);
82
91
  } else {
83
92
  throw new Error('unsupported outbox record');
84
93
  }
85
94
  }
95
+ for (const record of snapshots.values()) {
96
+ const digest = committed.get(record.eventKey);
97
+ if (digest && digest !== record.payloadDigest && !quarantined.has(record.eventKey)) {
98
+ quarantined.set(record.eventKey, 'outbox commit digest mismatch');
99
+ }
100
+ }
101
+ this.quarantine = [...quarantined].map(([eventKey, reason]) => ({ eventKey, reason }));
86
102
  return [...snapshots.values()]
87
- .filter((record) => {
88
- const digest = committed.get(record.eventKey);
89
- if (digest && digest !== record.payloadDigest) throw new Error('outbox commit digest mismatch');
90
- return !digest;
91
- })
103
+ .filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey))
92
104
  .sort((left, right) => left.eventKey.localeCompare(right.eventKey))
93
105
  .map((record) => record.snapshot);
94
106
  }
107
+
108
+ /** Keys pendingSnapshots() refused to replay because their records disagree, with the reason. */
109
+ quarantinedKeys() {
110
+ this.pendingSnapshots();
111
+ return this.quarantine;
112
+ }
95
113
  }
@@ -41,7 +41,7 @@
41
41
  * • DIGEST VERIFICATION — untouched: the caller still validates payloadDigest on every snapshot.
42
42
  */
43
43
  import fs from 'node:fs';
44
- import { createRequire } from 'node:module';
44
+ import { loadNodeSqlite } from './node-sqlite.mjs';
45
45
 
46
46
  /** Rows that `ruflo memory` itself considers live (memory-initializer.js ACTIVE_MEMORY_ROW_SQL). */
47
47
  const ACTIVE_ROW_SQL = "(status = 'active' OR status IS NULL)";
@@ -134,7 +134,9 @@ let sqliteBinding;
134
134
  function databaseSync() {
135
135
  if (sqliteBinding === undefined) {
136
136
  try {
137
- sqliteBinding = createRequire(import.meta.url)('node:sqlite').DatabaseSync ?? null;
137
+ // node-sqlite.mjs: loaded on first real use and without Node 22's SQLite ExperimentalWarning,
138
+ // which otherwise lands in every SessionStart hook's output.
139
+ sqliteBinding = loadNodeSqlite()?.DatabaseSync ?? null;
138
140
  } catch { sqliteBinding = null; }
139
141
  }
140
142
  return sqliteBinding;
@@ -207,11 +209,13 @@ export function openProgressionReader(dbPath) {
207
209
  };
208
210
  let listStatement;
209
211
  let readStatement;
212
+ let allStatement;
210
213
  try {
211
214
  // Prepared eagerly so an unexpected schema (or a WAL image this process cannot read) is
212
215
  // reported as UNAVAILABLE now, before the caller has committed to the fast path.
213
216
  listStatement = prepare(`SELECT key FROM memory_entries WHERE ${ACTIVE_ROW_SQL} AND namespace = ? ORDER BY key`);
214
217
  readStatement = prepare(`SELECT content FROM memory_entries WHERE ${ACTIVE_ROW_SQL} AND namespace = ? AND key = ?`);
218
+ allStatement = prepare(`SELECT namespace, key, content FROM memory_entries WHERE ${ACTIVE_ROW_SQL} ORDER BY namespace, key`);
215
219
  } catch (error) {
216
220
  try { database.close(); } catch { /* the open failure is the news */ }
217
221
  throw error;
@@ -252,6 +256,14 @@ export function openProgressionReader(dbPath) {
252
256
  return content;
253
257
  },
254
258
 
259
+ /** Every active (namespace, key, content) row — bounded, never truncated (past the bound is an error). */
260
+ allRows({ maxRows = 100_000 } = {}) {
261
+ const rows = query(allStatement, []);
262
+ if (rows.length > maxRows) throw new ProgressionReaderUnavailable(`store holds more than ${maxRows} active rows`);
263
+ return rows.map((row) => ({ namespace: String(row.namespace ?? ''), key: String(row.key ?? ''),
264
+ content: typeof row.content === 'string' ? row.content : null }));
265
+ },
266
+
255
267
  close() {
256
268
  try { database.close(); } catch { /* closing a spent read handle is never news */ }
257
269
  },