portawhip 0.1.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/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/SECURITY.md +15 -0
  5. package/VISION.md +281 -0
  6. package/adapters/hooks/hook-stub.mjs +21 -0
  7. package/adapters/hooks/hook-stub.test.mjs +43 -0
  8. package/adapters/hooks/universal-hook.mjs +348 -0
  9. package/adapters/hooks/universal-hook.test.mjs +294 -0
  10. package/adapters/instructions/generate.mjs +114 -0
  11. package/core/fixtures/bad-recipe.yaml +5 -0
  12. package/core/fixtures/capability-graph.json +10 -0
  13. package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
  14. package/core/registry/capability-docs.mjs +145 -0
  15. package/core/registry/capability-graph-compiler.mjs +90 -0
  16. package/core/registry/capability-graph.mjs +53 -0
  17. package/core/registry/capability-kind.mjs +9 -0
  18. package/core/registry/cli-enrich.mjs +316 -0
  19. package/core/registry/cli-enrich.test.mjs +119 -0
  20. package/core/registry/discover.mjs +379 -0
  21. package/core/registry/enrich.mjs +213 -0
  22. package/core/registry/enrich.test.mjs +53 -0
  23. package/core/registry/eval-harvest.mjs +93 -0
  24. package/core/registry/eval-harvest.test.mjs +64 -0
  25. package/core/registry/registry.mjs +197 -0
  26. package/core/router/concept-vector.mjs +91 -0
  27. package/core/router/concept-vector.test.mjs +36 -0
  28. package/core/router/curated-trigger.test.mjs +28 -0
  29. package/core/router/dense-embedder.mjs +242 -0
  30. package/core/router/fusion.mjs +18 -0
  31. package/core/router/hybrid-router.mjs +375 -0
  32. package/core/router/intent-evidence.mjs +60 -0
  33. package/core/router/prompt-hygiene.mjs +31 -0
  34. package/core/router/route-entry.mjs +74 -0
  35. package/core/router/router-cli.mjs +218 -0
  36. package/core/router/router-cli.test.mjs +25 -0
  37. package/core/router/router-eval.mjs +166 -0
  38. package/core/router/router-live.test.mjs +89 -0
  39. package/core/router/router.test.mjs +838 -0
  40. package/core/router/scorer.mjs +72 -0
  41. package/core/router/sparse-retriever.mjs +79 -0
  42. package/core/router/tokenize.mjs +42 -0
  43. package/core/state/bundle-state.mjs +124 -0
  44. package/core/state/bundle-state.test.mjs +175 -0
  45. package/core/state/config.mjs +115 -0
  46. package/core/state/feedback.mjs +129 -0
  47. package/core/state/feedback.test.mjs +181 -0
  48. package/core/state/runtime-root.test.mjs +22 -0
  49. package/core/state/stack-detect.mjs +102 -0
  50. package/core/state/stack-detect.test.mjs +64 -0
  51. package/core/surface/config-sync-backends.mjs +224 -0
  52. package/core/surface/connector-targets.mjs +205 -0
  53. package/core/surface/discover-hooks.mjs +151 -0
  54. package/core/surface/discover-hooks.test.mjs +63 -0
  55. package/core/surface/discover-surface.test.mjs +49 -0
  56. package/core/surface/extra-hosts.mjs +48 -0
  57. package/core/surface/extra-hosts.test.mjs +24 -0
  58. package/core/surface/hook-targets.mjs +102 -0
  59. package/core/surface/surface-copy-targets.mjs +37 -0
  60. package/core/surface/surface-inventory.mjs +106 -0
  61. package/core/surface/surface-matrix.mjs +133 -0
  62. package/core/surface/surface-matrix.test.mjs +90 -0
  63. package/docs/router-eval-set.jsonl +38 -0
  64. package/hooks.manifest.yaml +16 -0
  65. package/package.json +101 -0
  66. package/recipe.yaml +247 -0
  67. package/recipes/foundry.yaml +27 -0
  68. package/recipes/manifest.yaml +18 -0
  69. package/recipes/roles/backend-data.yaml +26 -0
  70. package/recipes/roles/coding.yaml +26 -0
  71. package/recipes/roles/frontend.yaml +14 -0
  72. package/recipes/roles/research.yaml +31 -0
  73. package/recipes/roles/secure.yaml +27 -0
  74. package/router.config.yaml +120 -0
  75. package/scripts/bundles.mjs +131 -0
  76. package/scripts/doctor.mjs +117 -0
  77. package/scripts/embedded-hooks.mjs +27 -0
  78. package/scripts/hosts.mjs +60 -0
  79. package/scripts/link/link-connectors.mjs +190 -0
  80. package/scripts/link/link-connectors.test.mjs +111 -0
  81. package/scripts/link/link-hooks.mjs +259 -0
  82. package/scripts/link/link-hooks.test.mjs +112 -0
  83. package/scripts/link/link-surfaces.mjs +188 -0
  84. package/scripts/link/link-surfaces.test.mjs +76 -0
  85. package/scripts/load.mjs +143 -0
  86. package/scripts/package-contract.test.mjs +26 -0
  87. package/scripts/surface-inventory.mjs +6 -0
  88. package/scripts/sync/agents-surface.mjs +55 -0
  89. package/scripts/sync/agents-surface.test.mjs +18 -0
  90. package/scripts/sync/auto-sync.mjs +135 -0
  91. package/scripts/sync/auto-sync.test.mjs +41 -0
  92. package/scripts/sync/import-surfaces.mjs +331 -0
  93. package/scripts/sync/import-surfaces.test.mjs +106 -0
  94. package/scripts/sync/sync-config.mjs +230 -0
  95. package/scripts/sync/sync-config.test.mjs +141 -0
  96. package/scripts/sync/sync-surfaces.mjs +229 -0
  97. package/scripts/sync/sync-surfaces.test.mjs +66 -0
  98. package/scripts/tui-actions.mjs +59 -0
  99. package/scripts/tui-actions.test.mjs +53 -0
  100. package/scripts/tui.mjs +782 -0
  101. package/scripts/uninstall-all.mjs +39 -0
  102. package/server/mcp-server.mjs +129 -0
  103. package/server/mcp-server.test.mjs +175 -0
  104. package/skills/portawhip/SKILL.md +80 -0
  105. package/surface-matrix.yaml +93 -0
@@ -0,0 +1,242 @@
1
+ // Phase 4 semantic layer (PLAN.md): a second, meaning-based retrieval channel
2
+ // alongside core/sparse-retriever.mjs's lexical one. Margin-gate research
3
+ // (docs/router-eval-set.jsonl) proved score-geometry tricks hit a wall - the
4
+ // remaining miss (e2e-testing on "use Playwright to test login flow") is a
5
+ // paraphrase gap no amount of threshold/margin tuning on lexical scores can
6
+ // close. That needs actual semantic similarity.
7
+ //
8
+ // Zero-setup by design: BAAI/bge-m3 (MIT, 100+ languages including Thai,
9
+ // verified live 2026-07-06 - HF reachable from this machine, ~543MB
10
+ // quantized ONNX, works with transformers.js's plain feature-extraction
11
+ // pipeline with no instruction-prefix ceremony unlike some competitors).
12
+ // transformers.js downloads and caches the model itself on first use - no
13
+ // install script, no manual model management. Must degrade gracefully when
14
+ // the model/network isn't available (PLAN.md Phase 4 spec): every export
15
+ // here fails soft (returns [] / null), never throws into the caller.
16
+
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname } from "node:path";
19
+
20
+ const MODEL_ID = "Xenova/bge-m3";
21
+
22
+ // The resolved pipeline, or null until the (slow) first load finishes. Kept
23
+ // separate from the load promise so callers can synchronously ask "is it
24
+ // ready RIGHT NOW?" without awaiting - the whole point of the non-blocking
25
+ // path below.
26
+ let extractor = null;
27
+ let loadStarted = false;
28
+ let unavailable = false;
29
+ let loadPromise = null;
30
+
31
+ // Kicks off the model load in the background (idempotent). The first load is
32
+ // slow - measured ~73s cold in the live MCP server (download + ONNX init) -
33
+ // so a caller must NEVER sit inside an await on this while serving an
34
+ // interactive request: an MCP client times out (~30-60s) long before it
35
+ // finishes, drops the connection, and route() becomes uncallable for the
36
+ // whole session (found live 2026-07-07: dense-on-by-default silently broke
37
+ // the MCP server this way). This returns immediately; the load resolves
38
+ // `extractor` whenever it's done.
39
+ function startWarm() {
40
+ if (extractor || unavailable || loadStarted) return loadPromise;
41
+ loadStarted = true;
42
+ loadPromise = import("@huggingface/transformers")
43
+ .then(({ pipeline }) => pipeline("feature-extraction", MODEL_ID))
44
+ .then((ex) => {
45
+ extractor = ex;
46
+ return ex;
47
+ })
48
+ .catch(() => {
49
+ unavailable = true;
50
+ return null;
51
+ });
52
+ return loadPromise;
53
+ }
54
+
55
+ // Let a long-lived caller (the MCP server) start the load at startup so the
56
+ // ~73s cold load overlaps idle time before the first route() call, instead
57
+ // of landing on top of it. Fire-and-forget: returns nothing, never throws.
58
+ export function warmDense() {
59
+ startWarm();
60
+ }
61
+
62
+ // Docs are rebuilt fresh per call (core/hybrid-router.mjs's own comment on
63
+ // buildCapabilityDocs), so caching on object identity is useless across
64
+ // calls - key by id+text instead (same content-hash-cache shape as
65
+ // core/eval-harvest.mjs, just keyed by the raw text since collision risk
66
+ // over a few hundred short capability descriptions is not worth a hash).
67
+ // One process-lifetime cache: a fresh push-hook subprocess gets a fresh
68
+ // cache (acceptable - push hook opts out of dense entirely, see
69
+ // universal-hook.mjs), while the long-lived MCP server/CLI process reuses
70
+ // it across every route() call for the life of the process.
71
+ const embeddingCache = new Map();
72
+
73
+ // Persists embeddingCache to disk so a fresh MCP server process (a new one
74
+ // per Claude Code session, not a single long-lived daemon) doesn't re-pay the
75
+ // "ready-but-cache-cold" trap on every restart: found live, route() latency
76
+ // of 67-104s on the first request after model warm, because 559 capability
77
+ // docs were being embedded one-by-one in that request's hot path. Keying is
78
+ // still by id+text (embeddingCache's own key), so a changed doc text just
79
+ // misses the disk entry instead of serving a stale vector - no separate
80
+ // invalidation hash needed.
81
+ let diskCachePath = null;
82
+ let diskCacheLoaded = false;
83
+ let primeInFlight = null;
84
+
85
+ // Called once at server boot (see server/mcp-server.mjs) so denseRetrieve and
86
+ // primeDocCache below know where to read/write the persisted cache. Never
87
+ // throws - a caller that skips this just gets the always-worked in-memory-only
88
+ // behavior.
89
+ export function setDenseCachePath(path) {
90
+ diskCachePath = path;
91
+ }
92
+
93
+ function ensureDiskCacheLoaded() {
94
+ if (diskCacheLoaded || !diskCachePath) return;
95
+ diskCacheLoaded = true;
96
+ try {
97
+ if (!existsSync(diskCachePath)) return;
98
+ const raw = JSON.parse(readFileSync(diskCachePath, "utf8"));
99
+ for (const [key, vector] of Object.entries(raw)) {
100
+ if (!embeddingCache.has(key)) embeddingCache.set(key, vector);
101
+ }
102
+ } catch {
103
+ // Corrupt/unreadable cache file - proceed as if it never existed.
104
+ }
105
+ }
106
+
107
+ function persistDiskCache() {
108
+ if (!diskCachePath) return;
109
+ try {
110
+ mkdirSync(dirname(diskCachePath), { recursive: true });
111
+ writeFileSync(diskCachePath, JSON.stringify(Object.fromEntries(embeddingCache)));
112
+ } catch {
113
+ // Best-effort - a failed write just means next boot re-embeds.
114
+ }
115
+ }
116
+
117
+ // Warms the doc-embedding cache in the BACKGROUND, deliberately called
118
+ // separately from warmDense() (which only starts the model load) so a caller
119
+ // controls exactly when the (docs, not just model) priming kicks off - here,
120
+ // right after loadIndex() at server boot, overlapping the idle time before
121
+ // the first real route() call instead of landing inside it. Safe to call
122
+ // concurrently/repeatedly: dedupes via primeInFlight, and every doc is a
123
+ // no-op past the first time it's embedded (same embeddingCache the request
124
+ // path reads).
125
+ export async function primeDocCache(docs) {
126
+ ensureDiskCacheLoaded();
127
+ if (primeInFlight) return primeInFlight;
128
+ primeInFlight = (async () => {
129
+ const ready = await startWarm();
130
+ if (!ready) return;
131
+ let embeddedAny = false;
132
+ for (const doc of docs) {
133
+ const key = `${doc.id}::${docText(doc)}`;
134
+ if (embeddingCache.has(key)) continue;
135
+ try {
136
+ embeddingCache.set(key, await embed(ready, docText(doc)));
137
+ embeddedAny = true;
138
+ } catch {
139
+ unavailable = true;
140
+ return;
141
+ }
142
+ }
143
+ if (embeddedAny) persistDiskCache();
144
+ })();
145
+ try {
146
+ await primeInFlight;
147
+ } finally {
148
+ primeInFlight = null;
149
+ }
150
+ }
151
+
152
+ function docText(doc) {
153
+ return [doc.id, (doc.triggers ?? []).join(" "), doc.description ?? ""].join(" ");
154
+ }
155
+
156
+ async function embed(extractor, text) {
157
+ const output = await extractor(text, { pooling: "cls", normalize: true });
158
+ return Array.from(output.data);
159
+ }
160
+
161
+ function cosineSim(a, b) {
162
+ let dot = 0;
163
+ for (let i = 0; i < a.length; i += 1) dot += a[i] * b[i];
164
+ return dot; // both vectors are already normalized, so dot product == cosine
165
+ }
166
+
167
+ async function embedDoc(extractor, doc) {
168
+ const key = `${doc.id}::${docText(doc)}`;
169
+ if (embeddingCache.has(key)) return embeddingCache.get(key);
170
+ const vector = await embed(extractor, docText(doc));
171
+ embeddingCache.set(key, vector);
172
+ return vector;
173
+ }
174
+
175
+ // Same call shape as sparseRetrieve(docs, query, {k, minScore}) -> {id, doc,
176
+ // score}[], so hybrid-router.mjs can treat it as a second, optional channel.
177
+ // Score is raw cosine similarity (0..1), never comparable to sparse's
178
+ // hundreds/thousands-scale BM25-ish numbers - callers must not mix the two
179
+ // on one bar (see hybrid-router.mjs's own dense-specific threshold).
180
+ // block=false (the MCP server's tier): if the model isn't warm yet, start the
181
+ // background load and return [] immediately - this call is sparse-only, and
182
+ // dense silently joins in on a later call once warm. block=true (CLI/eval):
183
+ // wait for the load, because those paths want full, deterministic results and
184
+ // there's no interactive client to time out.
185
+ export async function denseRetrieve(docs, query, { k = 20, minScore = 0.6, block = true } = {}) {
186
+ if (docs.length === 0) return [];
187
+ if (unavailable) return [];
188
+ ensureDiskCacheLoaded();
189
+ if (!extractor) {
190
+ const pending = startWarm();
191
+ if (!block) return [];
192
+ await pending;
193
+ if (!extractor) return [];
194
+ }
195
+ const ready = extractor;
196
+
197
+ try {
198
+ const queryVector = await embed(ready, query);
199
+ const scored = [];
200
+ for (const doc of docs) {
201
+ const docVector = await embedDoc(ready, doc);
202
+ const score = cosineSim(queryVector, docVector);
203
+ if (score >= minScore) scored.push({ id: doc.id, doc, score });
204
+ }
205
+ return scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, k);
206
+ } catch (err) {
207
+ unavailable = true;
208
+ return [];
209
+ }
210
+ }
211
+
212
+ // Test-only seams: real model load takes seconds and needs network/disk, so
213
+ // unit tests for hybrid-router's fusion logic inject a fake extractor (or
214
+ // force the "unavailable"/"still loading" states) instead of paying that cost
215
+ // on every run. Never called from production code paths.
216
+ export function _setPipelineForTest(fakeExtractor) {
217
+ extractor = fakeExtractor;
218
+ unavailable = false;
219
+ loadStarted = true;
220
+ loadPromise = Promise.resolve(fakeExtractor);
221
+ embeddingCache.clear();
222
+ }
223
+
224
+ export function _forceUnavailableForTest() {
225
+ extractor = null;
226
+ unavailable = true;
227
+ loadStarted = true;
228
+ loadPromise = Promise.resolve(null);
229
+ embeddingCache.clear();
230
+ }
231
+
232
+ // Simulates "load kicked off but not resolved yet" - lets a test exercise the
233
+ // non-blocking (block:false) sparse-only path without triggering a real model
234
+ // download. loadStarted is pre-set so denseRetrieve won't start a real import;
235
+ // the promise deliberately never resolves, so only block:false is safe here.
236
+ export function _setPipelinePendingForTest() {
237
+ extractor = null;
238
+ unavailable = false;
239
+ loadStarted = true;
240
+ loadPromise = new Promise(() => {});
241
+ embeddingCache.clear();
242
+ }
@@ -0,0 +1,18 @@
1
+ export function reciprocalRankFusion(rankings, { k = 60 } = {}) {
2
+ const scores = new Map();
3
+ for (const ranking of rankings) {
4
+ for (let i = 0; i < ranking.length; i += 1) {
5
+ const item = ranking[i];
6
+ const current = scores.get(item.id) ?? { ...item, rrfScore: 0 };
7
+ current.rrfScore += 1 / (k + i + 1);
8
+ scores.set(item.id, current);
9
+ }
10
+ }
11
+ return [...scores.values()].sort(
12
+ (a, b) =>
13
+ b.rrfScore - a.rrfScore ||
14
+ (b.score ?? 0) - (a.score ?? 0) ||
15
+ a.id.localeCompare(b.id),
16
+ );
17
+ }
18
+
@@ -0,0 +1,375 @@
1
+ import { buildCapabilityDocs } from "../registry/capability-docs.mjs";
2
+ import { expandWithGraph, loadCapabilityGraph } from "../registry/capability-graph.mjs";
3
+ import { capabilityKind, matchesSuggestKind } from "../registry/capability-kind.mjs";
4
+ import { actionAlignmentFactor } from "./concept-vector.mjs";
5
+ import { denseRetrieve } from "./dense-embedder.mjs";
6
+ import { reciprocalRankFusion } from "./fusion.mjs";
7
+ import { sparseRetrieve } from "./sparse-retriever.mjs";
8
+
9
+ const BROAD_TERMS = new Set([
10
+ "agent",
11
+ "architecture",
12
+ "automation",
13
+ "build",
14
+ "code",
15
+ "coding",
16
+ "context",
17
+ "debug",
18
+ "design",
19
+ "develop",
20
+ "engineering",
21
+ "framework",
22
+ "hook",
23
+ "implement",
24
+ "integration",
25
+ "pattern",
26
+ "project",
27
+ "route",
28
+ "router",
29
+ "skill",
30
+ "test",
31
+ "tool",
32
+ "workflow",
33
+ // core/enrich.mjs (router-cli enrich) seeds MCP tool triggers from a
34
+ // server's own tools/list — sub-tool names conventionally follow a
35
+ // verb_noun pattern (push_files, create_pull_request, list_issues), so
36
+ // these generic CRUD verbs are now real trigger tokens for almost every
37
+ // enriched MCP tool. Found live: "compare push and pull routing modes for
38
+ // an agent harness" (a router-architecture question) matched github
39
+ // through "push"/"pull" alone with zero other overlap. Same
40
+ // weakKeywordOnly mechanism as the rest of this list — only suppresses a
41
+ // candidate when EVERY matched term is generic; a query that also shares
42
+ // a tool-specific word (e.g. "push files to my github repo") still
43
+ // passes untouched.
44
+ "push",
45
+ "pull",
46
+ "create",
47
+ "get",
48
+ "list",
49
+ "update",
50
+ "search",
51
+ "fetch",
52
+ "add",
53
+ "remove",
54
+ "delete",
55
+ // The capability system's own vocabulary. "tool"/"skill"/"agent"/"hook"/
56
+ // "router" are already above; "mcp"/"cli"/"capability" complete the set.
57
+ // A prompt that overlaps a capability ONLY through these words is talking
58
+ // ABOUT the tool/skill/MCP machinery, not requesting a task - it's the
59
+ // intent-gate case (docs/router-live.test.mjs): "research MCP availability
60
+ // ... dynamic tools and skills ..." matched build-mcp-server purely on
61
+ // {mcp, tool, skill}, all meta-vocabulary, and cleared the bar because
62
+ // "mcp" alone wasn't yet classed as generic. Same weakKeywordOnly
63
+ // mechanism - a prompt that ALSO shares a task-specific word (e.g. "wrap
64
+ // this REST API as an MCP server") still passes untouched.
65
+ "mcp",
66
+ "cli",
67
+ "capability",
68
+ ]);
69
+
70
+ function isWordChar(value) {
71
+ return typeof value === "string" && /[\p{L}\p{N}_]/u.test(value);
72
+ }
73
+
74
+ function containsBoundedPhrase(text, phrase) {
75
+ const haystack = text.toLocaleLowerCase();
76
+ const needle = phrase.trim().toLocaleLowerCase();
77
+ if (!needle) return false;
78
+ let offset = 0;
79
+ while (offset <= haystack.length - needle.length) {
80
+ const index = haystack.indexOf(needle, offset);
81
+ if (index < 0) return false;
82
+ const before = index > 0 ? haystack[index - 1] : null;
83
+ const afterIndex = index + needle.length;
84
+ const after = afterIndex < haystack.length ? haystack[afterIndex] : null;
85
+ if (!isWordChar(before) && !isWordChar(after)) return true;
86
+ offset = index + 1;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ function hasDirectCuratedTrigger(candidate, prompt) {
92
+ return (
93
+ candidate.doc?.origin === "recipe" &&
94
+ (candidate.doc.triggers ?? []).some((trigger) => containsBoundedPhrase(prompt, trigger))
95
+ );
96
+ }
97
+
98
+ function candidateBar(candidate, prompt, { autoBar, recipeBar, toolBar }) {
99
+ const isTool = candidate.doc.type === "mcp" || candidate.doc.type === "cli";
100
+ const base = candidate.doc.origin === "recipe" ? recipeBar : isTool ? toolBar : autoBar;
101
+ // A full, bounded match on a deliberately authored recipe trigger is the
102
+ // hybrid equivalent of keyword scorer's trusted single-trigger hit. This
103
+ // prevents clean installs from depending on machine-local graph/feedback
104
+ // boosts while keeping generic token overlap behind the calibrated bar.
105
+ return hasDirectCuratedTrigger(candidate, prompt) ? Math.min(base, candidate.score) : base;
106
+ }
107
+
108
+ // Infinity means "sole lane survivor, nothing to be tied with" - the same
109
+ // no-penalty case gateLane already treats as automatically dominant.
110
+ function laneMarginRatio(laneCandidates) {
111
+ if (laneCandidates.length < 2) return Infinity;
112
+ const sorted = [...laneCandidates].sort((a, b) => b.score - a.score);
113
+ const [top, runnerUp] = sorted;
114
+ return runnerUp.score <= 0 ? Infinity : top.score / runnerUp.score;
115
+ }
116
+
117
+ // A lane where the top match barely beats the runner-up is diffuse-vocabulary
118
+ // noise, not a genuine pick — verified against docs/router-eval-set.jsonl's
119
+ // one remaining false positive: "what context signals should a router
120
+ // observe before injecting a skill" scores example-skill and
121
+ // skill-development within 0.3% of each other (both lit up by the same
122
+ // generic "router"/"skill"/"injecting" words), while every real match in the
123
+ // set has a clear top pick with no close runner-up. A lane with only one
124
+ // surviving candidate is never penalized (nothing to be tied with).
125
+ //
126
+ // Survivors are tagged with the same marginRatio used for the gate decision,
127
+ // not just recomputed silently - Stage 3 calibration (classifyCandidate)
128
+ // reads it to keep confidence honest for a candidate that barely cleared the
129
+ // gate, not just ones that got silenced entirely. See marginConfidenceFactor.
130
+ function gateLane(laneCandidates, ratio) {
131
+ const marginRatio = laneMarginRatio(laneCandidates);
132
+ if (laneCandidates.length >= 2 && marginRatio < ratio) return [];
133
+ return laneCandidates.map((candidate) => ({ ...candidate, marginRatio }));
134
+ }
135
+
136
+ // Ramps confidence down for a candidate that only barely cleared the
137
+ // peakedness gate (docs/intent-gate-bakeoff.md's calibration ask - Codex
138
+ // point 3: "broad-term/dense-only/no-margin candidates must not read
139
+ // confidence 1"). marginRatio===Infinity (sole lane survivor - nothing was
140
+ // close) is full confidence; right at the gate ratio is the floor; by 0.45
141
+ // above the gate ("clearly dominant" - empirically chosen headroom, not
142
+ // tuned to a specific eval case) it's back to full.
143
+ function marginConfidenceFactor(marginRatio, gateRatio) {
144
+ if (!Number.isFinite(marginRatio)) return 1.0;
145
+ const CONFIDENT = gateRatio + 0.45;
146
+ if (marginRatio <= gateRatio) return 0.5;
147
+ if (marginRatio >= CONFIDENT) return 1.0;
148
+ return 0.5 + ((marginRatio - gateRatio) / (CONFIDENT - gateRatio)) * 0.5;
149
+ }
150
+
151
+ // Curated (recipe.yaml) entries are trusted outright - deliberately
152
+ // authored, same rationale as the recipe/auto threshold split. A candidate
153
+ // with no direct lexical evidence at all (terms.length===0 - always true for
154
+ // a dense-only or graph-only hit, since neither carries real matched terms)
155
+ // is a similarity/adjacency GUESS, not an exact match, and must not read as
156
+ // confidently as one. A sparse hit that survived the tier filter above
157
+ // always has at least one specific (non-broad) term by construction.
158
+ function specificityConfidenceFactor(item) {
159
+ if (item.doc?.origin === "recipe") return 1.0;
160
+ return (item.terms ?? []).length === 0 ? 0.6 : 1.0;
161
+ }
162
+
163
+ function compactReason(item) {
164
+ const terms = (item.terms ?? []).slice(0, 5);
165
+ if (item.graphBoosted) {
166
+ return `graph-related to ${item.graphSource}`;
167
+ }
168
+ if (terms.length > 0) {
169
+ return `matched ${terms.join(", ")} in ${Object.keys(item.match ?? {}).slice(0, 3).join(", ") || "capability text"}`;
170
+ }
171
+ return "matched capability text";
172
+ }
173
+
174
+ function classifyCandidate(item, bar, peakednessRatio = 1.05) {
175
+ const score = item.score ?? item.rrfScore ?? 0;
176
+ const baseConfidence = bar > 0 ? Math.min(1, score / bar) : 1;
177
+ const terms = item.terms ?? [];
178
+ const specificTerms = terms.filter((term) => !BROAD_TERMS.has(term));
179
+ const weakKeywordOnly = terms.length > 0 && specificTerms.length === 0 && item.doc?.origin !== "recipe";
180
+ // Composite, not a single score/threshold ratio (docs/intent-gate-bakeoff.md
181
+ // / Codex point 3): a candidate that barely cleared the peakedness gate, or
182
+ // has no direct lexical evidence (dense/graph-only), must not read as
183
+ // confidently as one that's both dominant in its lane AND directly matched.
184
+ const confidence = Math.max(
185
+ 0,
186
+ Math.min(
187
+ 1,
188
+ baseConfidence *
189
+ specificityConfidenceFactor(item) *
190
+ marginConfidenceFactor(item.marginRatio ?? Infinity, peakednessRatio),
191
+ ),
192
+ );
193
+
194
+ if (score >= bar && !weakKeywordOnly) {
195
+ return {
196
+ tier: confidence >= 0.9 && item.doc?.origin === "recipe" ? "required" : "recommended",
197
+ confidence,
198
+ action: item.doc?.action ?? (item.doc?.type === "skill" ? "read_skill" : "use_capability"),
199
+ };
200
+ }
201
+
202
+ return {
203
+ tier: weakKeywordOnly ? "irrelevant_but_keyword_matched" : "weak_match",
204
+ confidence,
205
+ action: "ignore_by_default",
206
+ };
207
+ }
208
+
209
+ function formatResult(item, bar, peakednessRatio) {
210
+ const doc = item.doc;
211
+ const classification = classifyCandidate(item, bar, peakednessRatio);
212
+ return {
213
+ id: doc.id,
214
+ type: doc.type,
215
+ kind: capabilityKind(doc.type),
216
+ score: Number((item.score ?? item.rrfScore ?? 0).toFixed(4)),
217
+ tier: classification.tier,
218
+ confidence: Number(classification.confidence.toFixed(2)),
219
+ why: compactReason(item),
220
+ action: classification.action,
221
+ how_to_use: doc.description,
222
+ pointer: doc.pointer,
223
+ origin: doc.origin,
224
+ readyMarker: doc.readyMarker ?? null,
225
+ readyHint: doc.readyHint ?? null,
226
+ engine: "hybrid",
227
+ graphBoosted: item.graphBoosted === true || undefined,
228
+ graphSource: item.graphSource,
229
+ graphEdgeType: item.graphEdgeType,
230
+ };
231
+ }
232
+
233
+ export async function routeHybrid(
234
+ index,
235
+ prompt,
236
+ {
237
+ threshold = 2,
238
+ hybridThreshold,
239
+ hybridRecipeThreshold,
240
+ hybridToolThreshold,
241
+ graphPath = null,
242
+ graphBoost = 0.25,
243
+ k = 5,
244
+ suggest = "any",
245
+ factors = null,
246
+ includeWeak = false,
247
+ peakednessRatio = 1.05,
248
+ denseEnabled = true,
249
+ denseThreshold = 0.6,
250
+ // Default true = wait for the dense model (deterministic - what CLI and
251
+ // eval want). The MCP server passes false so a cold model load never
252
+ // blocks an interactive route() call into a client timeout; see
253
+ // core/dense-embedder.mjs's warmDense/denseRetrieve.
254
+ denseBlock = true,
255
+ } = {},
256
+ ) {
257
+ const docs = buildCapabilityDocs(index);
258
+ const autoBar = hybridThreshold ?? threshold;
259
+ // Curated (recipe.yaml) entries need their own, much lower bar — same
260
+ // rationale as scorer.mjs's recipeThreshold split. Confirmed empirically:
261
+ // generic domain vocabulary ("architecture", "patterns") that's common
262
+ // across hundreds of auto-discovered skill docs can score higher than a
263
+ // curated entry's genuine match (react-patterns/vue-patterns out-scoring
264
+ // ripgrep/context7 on unrelated prompts) — no single global threshold
265
+ // separates "real auto-discovered match" from "generic-vocabulary noise"
266
+ // AND "real curated match" at the same time.
267
+ const recipeBar = hybridRecipeThreshold ?? Math.min(autoBar, 130);
268
+ // Auto-discovered MCP/CLI tool entries are a third bucket, not "skills
269
+ // with a lower bar" — their documents are just a bare name (add-mcp/mise
270
+ // give no description), so they have almost no text to accumulate a false
271
+ // match from generic vocabulary in the first place. Verified against the
272
+ // full negative eval set: no mcp/cli-type entry ever scored above 0 as a
273
+ // false positive, while real matches (bare tool name against a prompt
274
+ // naming that tool) score far below the skill-noise ceiling that forces
275
+ // autoBar so high. Reusing autoBar for them was silently dropping real
276
+ // "use <tool>" matches (exa/github/playwright tools).
277
+ const toolBar = hybridToolThreshold ?? Math.min(autoBar, 80);
278
+ // Direct curated triggers may score below the corpus-wide calibrated bars
279
+ // (short CLI docs are especially sparse), so retrieval must not discard
280
+ // them before candidateBar can apply the exact-trigger trust path.
281
+ const minScore = 0;
282
+ // No early-return on an empty sparse hit list: a genuine paraphrase can
283
+ // share zero lexical vocabulary with any doc field at all, and dense
284
+ // retrieval below runs independently of minisearch - exiting here would
285
+ // deny it the one case it exists to rescue. Graph expansion/candidate
286
+ // building all handle an empty seed list fine already (see "hybrid graph:
287
+ // abstains when retrieval has no seed").
288
+ const sparse = sparseRetrieve(docs, prompt, { k: Math.max(k * 4, 20), minScore });
289
+
290
+ const graph = loadCapabilityGraph(graphPath);
291
+ const expanded = expandWithGraph(sparse, docs, graph, { boost: graphBoost }).map((candidate) => ({
292
+ ...candidate,
293
+ score:
294
+ candidate.score *
295
+ (factors?.get(candidate.doc.id) ?? 1.0) *
296
+ actionAlignmentFactor(candidate.doc, prompt),
297
+ }));
298
+ const candidates = expanded
299
+ .filter((candidate) => matchesSuggestKind(candidate.doc.type, suggest))
300
+ .map((candidate) => {
301
+ const bar = candidateBar(candidate, prompt, { autoBar, recipeBar, toolBar });
302
+ return { ...candidate, bar };
303
+ });
304
+ const filtered = candidates.filter((candidate) => {
305
+ const bar = candidateBar(candidate, prompt, { autoBar, recipeBar, toolBar });
306
+ return candidate.score >= bar && classifyCandidate(candidate, bar).tier !== "irrelevant_but_keyword_matched";
307
+ });
308
+
309
+ // Dense retrieval (core/dense-embedder.mjs, BGE-M3) is a second,
310
+ // meaning-based channel additive to the lexical one above - it runs
311
+ // regardless of what sparse found, so a genuine paraphrase with little or
312
+ // no shared vocabulary (the gap margin-gate/threshold tuning on lexical
313
+ // scores alone provably cannot close - see docs/router-eval-set.jsonl's
314
+ // e2e-testing miss) still gets a chance. Fails soft to [] whenever the
315
+ // model isn't installed/reachable or a caller opts out (push hook does -
316
+ // see universal-hook.mjs - a fresh process per keystroke can't amortize a
317
+ // 500MB+ model load), so every caller sees identical output to before
318
+ // whenever dense doesn't contribute. minScore:0 is deliberate - the real
319
+ // gate is `bar` below, applied after factors/actionAlignment, the same
320
+ // two-stage "loose net, then precise bar" shape the lexical path above
321
+ // already uses.
322
+ const filteredIds = new Set(filtered.map((candidate) => candidate.doc.id));
323
+ let denseOnly = [];
324
+ if (denseEnabled) {
325
+ const denseHits = await denseRetrieve(docs, prompt, {
326
+ k: Math.max(k * 4, 20),
327
+ minScore: 0,
328
+ block: denseBlock,
329
+ });
330
+ denseOnly = denseHits
331
+ .filter((hit) => matchesSuggestKind(hit.doc.type, suggest) && !filteredIds.has(hit.id))
332
+ .map((hit) => ({
333
+ ...hit,
334
+ score: hit.score * (factors?.get(hit.doc.id) ?? 1.0) * actionAlignmentFactor(hit.doc, prompt),
335
+ bar: denseThreshold,
336
+ terms: [],
337
+ }))
338
+ .filter((hit) => hit.score >= denseThreshold);
339
+ }
340
+
341
+ const candidatesForLanes = [...filtered, ...denseOnly];
342
+ if (candidatesForLanes.length === 0) {
343
+ if (!includeWeak) return [];
344
+ return candidates.slice(0, k).map((candidate) => formatResult(candidate, candidate.bar, peakednessRatio));
345
+ }
346
+
347
+ // Tools and skills get their own reserved k slots, not one shared slice.
348
+ // Found live: real callers (push hook, MCP route tool, router-cli route)
349
+ // all call with suggest:"any", so a query where BOTH a skill and a tool
350
+ // are genuinely relevant used to have them compete for the same k slots
351
+ // — a strong skill match could silently crowd a real tool match (or vice
352
+ // versa) out of the result entirely, even though a task usually wants
353
+ // both side by side (the tool to do it, the skill for how to do it well).
354
+ const lanes = new Map();
355
+ for (const candidate of candidatesForLanes) {
356
+ const kind = capabilityKind(candidate.doc.type);
357
+ if (!lanes.has(kind)) lanes.set(kind, []);
358
+ lanes.get(kind).push(candidate);
359
+ }
360
+ const laneWinners = [...lanes.values()].flatMap((laneCandidates) =>
361
+ reciprocalRankFusion([gateLane(laneCandidates, peakednessRatio)]).slice(0, k),
362
+ );
363
+ const fused = laneWinners.sort(
364
+ (a, b) => (b.score ?? 0) - (a.score ?? 0) || a.doc.id.localeCompare(b.doc.id),
365
+ );
366
+ const routed = fused.map((item) => formatResult(item, item.bar, peakednessRatio));
367
+ if (!includeWeak || routed.length >= k) return routed;
368
+
369
+ const routedIds = new Set(routed.map((item) => item.id));
370
+ const weak = candidates
371
+ .filter((candidate) => !routedIds.has(candidate.doc.id))
372
+ .slice(0, k - routed.length)
373
+ .map((candidate) => formatResult(candidate, candidate.bar, peakednessRatio));
374
+ return [...routed, ...weak];
375
+ }