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,93 @@
1
+ // Closes the loop between live usage (feedback.mjs's push/pull events) and
2
+ // the offline precision eval (docs/router-eval-set.jsonl). Today a
3
+ // "suggested, never used" event only ever feeds computeFactors()'s per-id
4
+ // trust decay — the labeled false positive it represents just evaporates.
5
+ // A capability repeatedly suggested and never confirmed used IS a
6
+ // hard-negative case, the same shape the hand-authored ones in
7
+ // docs/router-eval-set.jsonl already are — harvesting it lets the eval set
8
+ // grow from what real sessions actually got wrong, not only from what a
9
+ // human thought to write down.
10
+
11
+ import { readEvents } from "../state/feedback.mjs";
12
+
13
+ const MAX_PROMPT_CHARS = 200;
14
+
15
+ function truncatePrompt(prompt) {
16
+ const trimmed = String(prompt ?? "").trim();
17
+ return trimmed.length > MAX_PROMPT_CHARS ? `${trimmed.slice(0, MAX_PROMPT_CHARS - 1)}…` : trimmed;
18
+ }
19
+
20
+ // Short, stable, dependency-free digest — only needs to be unique enough
21
+ // for a human-readable eval-case id, not cryptographically sound.
22
+ function hashLike(str) {
23
+ let hash = 0;
24
+ for (let i = 0; i < str.length; i += 1) {
25
+ hash = (hash * 31 + str.charCodeAt(i)) | 0;
26
+ }
27
+ return Math.abs(hash).toString(36).slice(0, 8);
28
+ }
29
+
30
+ // Same "next 'used' resolves the most recent pending 'suggested'" pairing
31
+ // core/feedback.mjs's computeFactors() uses, kept per-id here too, but
32
+ // carrying the actual prompt text through instead of collapsing to a bare
33
+ // true/false outcome.
34
+ function unconfirmedSuggestions(root) {
35
+ const events = readEvents(root).sort((a, b) => a.ts - b.ts);
36
+ const byId = new Map();
37
+ for (const e of events) {
38
+ if (e.type !== "suggested" && e.type !== "used") continue;
39
+ if (!byId.has(e.id)) byId.set(e.id, []);
40
+ byId.get(e.id).push(e);
41
+ }
42
+
43
+ const unconfirmed = [];
44
+ for (const [id, idEvents] of byId) {
45
+ let pending = null;
46
+ for (const e of idEvents) {
47
+ if (e.type === "suggested") {
48
+ if (pending) unconfirmed.push({ id, prompt: pending.prompt });
49
+ pending = e;
50
+ } else if (e.type === "used" && pending) {
51
+ pending = null;
52
+ }
53
+ }
54
+ if (pending) unconfirmed.push({ id, prompt: pending.prompt });
55
+ }
56
+ return unconfirmed.filter((entry) => entry.prompt);
57
+ }
58
+
59
+ // minIgnoredCount is per-id, not per-exact-prompt: the real signal is "this
60
+ // capability keeps firing and never getting used", regardless of whether
61
+ // the wording repeats. One-off ignores are noise (the user may just not
62
+ // have gotten to it yet); a repeated pattern across several real prompts is
63
+ // a genuine false positive worth feeding back.
64
+ export function harvestHardNegatives(root, { minIgnoredCount = 2 } = {}) {
65
+ const unconfirmed = unconfirmedSuggestions(root);
66
+ const byId = new Map();
67
+ for (const entry of unconfirmed) {
68
+ if (!byId.has(entry.id)) byId.set(entry.id, []);
69
+ byId.get(entry.id).push(entry.prompt);
70
+ }
71
+
72
+ const cases = [];
73
+ const seenPrompts = new Set();
74
+ for (const [id, prompts] of byId) {
75
+ if (prompts.length < minIgnoredCount) continue;
76
+ for (const prompt of prompts) {
77
+ const key = prompt.toLowerCase();
78
+ if (seenPrompts.has(key)) continue;
79
+ seenPrompts.add(key);
80
+ const truncated = truncatePrompt(prompt);
81
+ cases.push({
82
+ id: `auto-${id}-${hashLike(truncated)}`,
83
+ prompt: truncated,
84
+ shouldRoute: false,
85
+ expectedTopId: null,
86
+ expectedAnyIds: [],
87
+ category: "auto-harvested",
88
+ notes: `auto-harvested: "${id}" suggested and ignored ${prompts.length}x across real sessions; this is one such prompt`,
89
+ });
90
+ }
91
+ }
92
+ return cases;
93
+ }
@@ -0,0 +1,64 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { logEvent } from "../state/feedback.mjs";
7
+ import { harvestHardNegatives } from "./eval-harvest.mjs";
8
+
9
+ function tempRoot() {
10
+ return mkdtempSync(join(tmpdir(), "eval-harvest-test-"));
11
+ }
12
+
13
+ test("harvestHardNegatives: below the ignored-count bar produces nothing", () => {
14
+ const root = tempRoot();
15
+ try {
16
+ logEvent(root, { type: "suggested", id: "noisy-skill", prompt: "design the architecture for X" });
17
+ const cases = harvestHardNegatives(root, { minIgnoredCount: 2 });
18
+ assert.equal(cases.length, 0);
19
+ } finally {
20
+ rmSync(root, { recursive: true, force: true });
21
+ }
22
+ });
23
+
24
+ test("harvestHardNegatives: repeated ignore across different prompts for the same id crosses the bar", () => {
25
+ const root = tempRoot();
26
+ try {
27
+ logEvent(root, { type: "suggested", id: "noisy-skill", prompt: "design the architecture for X" });
28
+ logEvent(root, { type: "suggested", id: "noisy-skill", prompt: "how should a router avoid bloat" });
29
+ const cases = harvestHardNegatives(root, { minIgnoredCount: 2 });
30
+ assert.equal(cases.length, 2);
31
+ assert.ok(cases.every((c) => c.shouldRoute === false));
32
+ assert.ok(cases.every((c) => c.category === "auto-harvested"));
33
+ assert.ok(cases.every((c) => c.id.startsWith("auto-noisy-skill-")));
34
+ } finally {
35
+ rmSync(root, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ test("harvestHardNegatives: a suggestion later confirmed used is not harvested", () => {
40
+ const root = tempRoot();
41
+ try {
42
+ logEvent(root, { type: "suggested", id: "real-skill", prompt: "prompt one" });
43
+ logEvent(root, { type: "used", id: "real-skill" });
44
+ logEvent(root, { type: "suggested", id: "real-skill", prompt: "prompt two" });
45
+ logEvent(root, { type: "used", id: "real-skill" });
46
+ const cases = harvestHardNegatives(root, { minIgnoredCount: 2 });
47
+ assert.equal(cases.length, 0);
48
+ } finally {
49
+ rmSync(root, { recursive: true, force: true });
50
+ }
51
+ });
52
+
53
+ test("harvestHardNegatives: duplicate prompt text for the same id is only harvested once", () => {
54
+ const root = tempRoot();
55
+ try {
56
+ logEvent(root, { type: "suggested", id: "noisy-skill", prompt: "same prompt" });
57
+ logEvent(root, { type: "suggested", id: "noisy-skill", prompt: "same prompt" });
58
+ logEvent(root, { type: "suggested", id: "noisy-skill", prompt: "same prompt" });
59
+ const cases = harvestHardNegatives(root, { minIgnoredCount: 2 });
60
+ assert.equal(cases.length, 1);
61
+ } finally {
62
+ rmSync(root, { recursive: true, force: true });
63
+ }
64
+ });
@@ -0,0 +1,197 @@
1
+ // Parses recipe.yaml into a normalized, cached index for the router.
2
+ // recipe.yaml stays the single source of truth (PLAN.md §3) — this module
3
+ // owns no data of its own, only validation + normalization + a cache file.
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
6
+ import { dirname, resolve, join } from "node:path";
7
+ import yaml from "js-yaml";
8
+ import { discoverAll } from "./discover.mjs";
9
+
10
+ // Cache lives next to whichever recipe.yaml was actually loaded — not cwd —
11
+ // so this still works when called from a globally-registered MCP server
12
+ // whose caller's cwd has nothing to do with this repo.
13
+ function cachePathFor(recipePath) {
14
+ return join(dirname(resolve(recipePath)), ".hp-state", "route-index.json");
15
+ }
16
+
17
+ // Same anchoring as cachePathFor above — core/enrich.mjs writes here via
18
+ // `router-cli enrich`, discover.mjs reads it back through discoverAll().
19
+ function enrichCachePathFor(recipePath) {
20
+ return join(dirname(resolve(recipePath)), ".hp-state", "tool-descriptions.json");
21
+ }
22
+
23
+ function validateRoute(entry) {
24
+ const r = entry.route;
25
+ if (r === undefined) return null;
26
+ if (typeof r !== "object" || r === null) {
27
+ throw new Error(`${entry.id}: route block must be an object`);
28
+ }
29
+ if (!Array.isArray(r.triggers) || r.triggers.length === 0) {
30
+ throw new Error(`${entry.id}: route.triggers must be a non-empty array`);
31
+ }
32
+ for (const t of r.triggers) {
33
+ if (typeof t !== "string" || t.trim() === "") {
34
+ throw new Error(`${entry.id}: route.triggers must all be non-empty strings`);
35
+ }
36
+ }
37
+ if (typeof r.description !== "string" || r.description.trim() === "") {
38
+ throw new Error(`${entry.id}: route.description is required and must be a non-empty string`);
39
+ }
40
+ if (r.readyMarker !== undefined && typeof r.readyMarker !== "string") {
41
+ throw new Error(`${entry.id}: route.readyMarker must be a string (relative path)`);
42
+ }
43
+ if (r.readyHint !== undefined && typeof r.readyHint !== "string") {
44
+ throw new Error(`${entry.id}: route.readyHint must be a string`);
45
+ }
46
+ if (r.binary !== undefined && typeof r.binary !== "string") {
47
+ throw new Error(`${entry.id}: route.binary must be a string`);
48
+ }
49
+ if (r.action !== undefined && typeof r.action !== "string") {
50
+ throw new Error(`${entry.id}: route.action must be a string`);
51
+ }
52
+ return {
53
+ triggers: r.triggers,
54
+ description: r.description,
55
+ when: Array.isArray(r.when) && r.when.length > 0 ? r.when : ["user_prompt"],
56
+ inject: r.inject === "full" ? "full" : "hint",
57
+ // CLI entries' `source` is the package manager's name (mise/etc), which
58
+ // often differs from the actual invoked binary (e.g. source "ripgrep"
59
+ // -> binary "rg"). Optional, only needed for Bash-usage feedback
60
+ // matching in adapters/hooks/universal-hook.mjs.
61
+ binary: r.binary ?? null,
62
+ action: r.action ?? null,
63
+ // Per-project readiness (VISION.md gap: tools installed globally but
64
+ // needing local init, e.g. codegraph's .codegraph/ index). Existence
65
+ // check only — no arbitrary probe command, no shell/subprocess surface.
66
+ // Curated-only by design: auto-discovered entries have no schema for
67
+ // this and no reliable per-tool init-check to infer generically.
68
+ readyMarker: r.readyMarker ?? null,
69
+ readyHint: r.readyHint ?? null,
70
+ };
71
+ }
72
+
73
+ export function readRawEntries(recipePath) {
74
+ const raw = yaml.load(readFileSync(recipePath, "utf8"));
75
+ if (!Array.isArray(raw)) throw new Error(`${recipePath}: expected a top-level list of entries`);
76
+ return raw;
77
+ }
78
+
79
+ // Bundle compose (2026-07-05): a caller may pass one recipe path (today's
80
+ // usage, unchanged) or an ORDERED array for the opt-in bundle layer —
81
+ // [foundry?, ...selected roles, the project's own recipe.yaml]. Later paths
82
+ // win on id collision, matching the agreed precedence "user > role >
83
+ // foundry": the project's own recipe.yaml goes last in the array so a
84
+ // deliberate project entry always overrides a same-id bundle default.
85
+ // sourceById records which path each surviving entry came from — buildIndex
86
+ // needs that provenance to decide trust tier; mergeRawEntries' own public
87
+ // contract (used by scripts/load.mjs for install-dispatch) stays a plain
88
+ // array, since install must act on a bundle entry regardless of whether
89
+ // it's installed yet — that's the whole point of installing it.
90
+ function mergeWithProvenance(paths) {
91
+ const byId = new Map();
92
+ const sourceById = new Map();
93
+ for (const path of paths) {
94
+ for (const entry of readRawEntries(path)) {
95
+ if (!entry.id || !entry.type) {
96
+ throw new Error(`malformed entry (missing id/type) in ${path}: ${JSON.stringify(entry)}`);
97
+ }
98
+ byId.set(entry.id, entry);
99
+ sourceById.set(entry.id, path);
100
+ }
101
+ }
102
+ return { byId, sourceById };
103
+ }
104
+
105
+ export function mergeRawEntries(recipePath) {
106
+ const paths = Array.isArray(recipePath) ? recipePath : [recipePath];
107
+ if (paths.length === 0) throw new Error("mergeRawEntries: at least one recipe path is required");
108
+ const { byId } = mergeWithProvenance(paths);
109
+ return [...byId.values()];
110
+ }
111
+
112
+ // discoverCli() keys installed CLI tools by mise's OWN install key, which
113
+ // for a backend-qualified source (e.g. "pipx:markitdown") is that full
114
+ // string, not the clean id a recipe entry chooses (e.g. "markitdown"). Every
115
+ // other bundle entry so far used a bare mise registry name where id ===
116
+ // source, so this never surfaced until markitdown - caught live 2026-07-05:
117
+ // route() stayed silent for markitdown even after it was actually installed,
118
+ // because the gate only checked entry.id and never found the
119
+ // "pipx:markitdown" key discovery actually produced. Checking entry.source
120
+ // too covers both cases without needing to know which convention a given
121
+ // backend uses.
122
+ export function isDiscovered(entry, discovered) {
123
+ return discovered.some((e) => e.id === entry.id || (entry.source && e.source === entry.source));
124
+ }
125
+
126
+ export async function buildIndex(recipePath = "recipe.yaml", { discover = true } = {}) {
127
+ const paths = Array.isArray(recipePath) ? recipePath : [recipePath];
128
+ if (paths.length === 0) throw new Error("buildIndex: at least one recipe path is required");
129
+ // The project's own recipe.yaml is always last in a compose array (see the
130
+ // precedence note above) and stays the one trusted-without-verification
131
+ // tier — the pre-existing convention that whoever hand-writes an entry
132
+ // there has already set it up. A single-path call has trustedPath === its
133
+ // only path, so every entry is trusted and behavior is unchanged from
134
+ // before bundles existed.
135
+ const trustedPath = paths[paths.length - 1];
136
+ const { byId, sourceById } = mergeWithProvenance(paths);
137
+
138
+ const discovered = discover
139
+ ? await discoverAll({ enrichCachePath: enrichCachePathFor(trustedPath) })
140
+ : [];
141
+
142
+ // Bundle-sourced entries (foundry.yaml / recipes/roles/*.yaml) carry
143
+ // curated route metadata but are NOT presumed installed just because a
144
+ // user opted into that bundle — `scripts/bundles.mjs select` only records
145
+ // intent, `scripts/load.mjs` does the actual install. Routing an entry
146
+ // before it's installed would suggest a capability that can't run yet —
147
+ // exactly the overclaim VISION.md's "live-probe, never overclaim" rule
148
+ // forbids, and was caught live 2026-07-05: route() suggested gitleaks and
149
+ // ast-grep while neither was mise-installed on this machine. Gate: a
150
+ // bundle-sourced entry only routes once discovery independently confirms
151
+ // it's actually present. Skipped when discover:false (curated-only tests)
152
+ // — those calls opt out of live machine state on purpose and must stay
153
+ // deterministic.
154
+ const curated = [];
155
+ for (const entry of byId.values()) {
156
+ const fromBundle = sourceById.get(entry.id) !== trustedPath;
157
+ if (discover && fromBundle && !isDiscovered(entry, discovered)) continue;
158
+ curated.push({
159
+ id: entry.id,
160
+ type: entry.type,
161
+ source: entry.source,
162
+ path: entry.path ?? null,
163
+ origin: "recipe",
164
+ route: validateRoute(entry),
165
+ });
166
+ }
167
+
168
+ // recipe.yaml stays authoritative for anything it names — a hand-authored
169
+ // entry wins over its auto-discovered twin so deliberate route metadata
170
+ // is never silently overwritten by inferred keywords.
171
+ const curatedIds = new Set(curated.map((e) => e.id));
172
+ const finalDiscovered = discover ? discovered.filter((e) => !curatedIds.has(e.id)) : [];
173
+
174
+ const entries = [...curated, ...finalDiscovered];
175
+ const index = { generatedAt: new Date().toISOString(), entries };
176
+ // Cache anchors to the LAST (highest-precedence) path — for a single-path
177
+ // call this is that same path (today's behavior, unchanged); for a bundle
178
+ // compose it's the project's own recipe.yaml, so the cache still lands in
179
+ // that project's .hp-state like every other per-project cache here.
180
+ const cachePath = cachePathFor(trustedPath);
181
+ mkdirSync(dirname(cachePath), { recursive: true });
182
+ writeFileSync(cachePath, JSON.stringify(index, null, 2));
183
+ return index;
184
+ }
185
+
186
+ export async function loadIndex(recipePath = "recipe.yaml", opts) {
187
+ // Cache is a perf/inspection convenience, not a second source of truth —
188
+ // always rebuild rather than trusting a stale cache.
189
+ return buildIndex(recipePath, opts);
190
+ }
191
+
192
+ export function readCachedIndex(recipePath = "recipe.yaml") {
193
+ const paths = Array.isArray(recipePath) ? recipePath : [recipePath];
194
+ const cachePath = cachePathFor(paths[paths.length - 1]);
195
+ if (!existsSync(cachePath)) return null;
196
+ return JSON.parse(readFileSync(cachePath, "utf8"));
197
+ }
@@ -0,0 +1,91 @@
1
+ // A second, non-lexical signal — PLAN.md Phase 4 item 3 called for a local
2
+ // transformer embedding model (fastembed/BGE-M3) here. Checked this session:
3
+ // huggingface.co (the model weight host) did not respond within 10s from
4
+ // this sandbox, so a real download-a-model approach is not reliably
5
+ // available. This ships a zero-dependency, zero-download stand-in instead.
6
+ //
7
+ // First attempt was a full curated concept-vector (action + domain
8
+ // dimensions) scored by cosine similarity, fused via reciprocalRankFusion
9
+ // as a competing ranking. Eval caught a real regression: cosine over
10
+ // sparse low-dimensional vectors is pathological — a doc that only ever
11
+ // mentions ONE concept dimension gets an artificially perfect cosine (1.0)
12
+ // against a query with the same single dimension active, beating a doc
13
+ // that's a much stronger overall match but touches more dimensions (and so
14
+ // has a "diluted" vector norm). That's exactly what flipped
15
+ // prisma-patterns above postgres-patterns on a Postgres-specific query.
16
+ //
17
+ // Fix: score ONLY the action-intent dimension (build/test/fix/review/
18
+ // refactor/deploy/document), as a discrete match/mismatch/neutral factor,
19
+ // not a cosine similarity. Domain specificity (postgres vs prisma vs mysql)
20
+ // is left entirely to the lexical BM25 score, which already handles it
21
+ // correctly — the actual observed failure mode this exists to fix
22
+ // (react-testing outranking react-patterns on a build task) is an
23
+ // action-intent conflation, not a domain one.
24
+
25
+ import { tokenize } from "./tokenize.mjs";
26
+
27
+ const ACTION_CONCEPTS = {
28
+ build: ["add", "create", "build", "implement", "scaffold", "generate"],
29
+ test: ["test", "spec", "coverage", "verify", "assert", "mock"],
30
+ fix: ["fix", "bug", "defect", "repair", "debug", "broken", "crash"],
31
+ review: ["review", "audit", "inspect", "lint", "vet"],
32
+ refactor: ["refactor", "restructure", "cleanup", "simplify", "reorganize"],
33
+ deploy: ["deploy", "release", "ship", "publish", "rollout"],
34
+ document: ["document", "explain", "readme"],
35
+ };
36
+
37
+ function dominantAction(text) {
38
+ const tokens = new Set(tokenize(text));
39
+ let best = null;
40
+ let bestCount = 0;
41
+ for (const [name, keywords] of Object.entries(ACTION_CONCEPTS)) {
42
+ let hits = 0;
43
+ for (const kw of keywords) {
44
+ const kwTokens = tokenize(kw);
45
+ if (kwTokens.length > 0 && kwTokens.every((t) => tokens.has(t))) hits += 1;
46
+ }
47
+ if (hits > bestCount) {
48
+ bestCount = hits;
49
+ best = name;
50
+ }
51
+ }
52
+ return bestCount > 0 ? best : null;
53
+ }
54
+
55
+ // Docs are rebuilt fresh per call in hybrid-router.mjs, so no cross-call
56
+ // staleness risk from caching on the object itself.
57
+ const docActionCache = new WeakMap();
58
+
59
+ function docText(doc) {
60
+ return [doc.id, (doc.triggers ?? []).join(" "), doc.description ?? ""].join(" ");
61
+ }
62
+
63
+ // The id is a curated, compact "what this IS" signal (security-review,
64
+ // react-testing) — trust it alone first. Only fall back to the full
65
+ // description when the id itself carries no action-cluster word at all.
66
+ // Needed because descriptions are often "when to activate" prose (e.g.
67
+ // security-review's own description talks about "adding authentication,
68
+ // implementing payment features" — build-flavored words describing WHEN
69
+ // to use a review skill, not what the skill itself does) that would
70
+ // otherwise outvote the id on raw keyword-presence count.
71
+ function dominantActionForDoc(doc) {
72
+ return dominantAction(doc.id) ?? dominantAction(docText(doc));
73
+ }
74
+
75
+ // Neutral (1.0) unless BOTH the query and the doc have a clear, single
76
+ // dominant action-intent — a query with no strong action verb (e.g. ",
77
+ // "PostgreSQL schema design and query optimization") never triggers this,
78
+ // and neither does a doc with no action-flavored vocabulary at all (most
79
+ // domain-pattern docs). Only fires, and only ever demotes/boosts, when
80
+ // there's a genuine mismatch/match to react to.
81
+ export function actionAlignmentFactor(doc, query, { match = 1.15, mismatch = 0.6 } = {}) {
82
+ const queryAction = dominantAction(query);
83
+ if (!queryAction) return 1.0;
84
+ let docAction = docActionCache.get(doc);
85
+ if (docAction === undefined) {
86
+ docAction = dominantActionForDoc(doc);
87
+ docActionCache.set(doc, docAction);
88
+ }
89
+ if (!docAction) return 1.0;
90
+ return docAction === queryAction ? match : mismatch;
91
+ }
@@ -0,0 +1,36 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { actionAlignmentFactor } from "./concept-vector.mjs";
4
+
5
+ test("actionAlignmentFactor: neutral when query has no clear action-intent", () => {
6
+ const doc = { id: "react-testing", triggers: [], description: "" };
7
+ assert.equal(actionAlignmentFactor(doc, "PostgreSQL schema design and query optimization"), 1.0);
8
+ });
9
+
10
+ test("actionAlignmentFactor: neutral when doc id has no action-cluster word", () => {
11
+ const doc = { id: "react-patterns", triggers: [], description: "hooks and boundaries" };
12
+ assert.equal(actionAlignmentFactor(doc, "add a new component"), 1.0);
13
+ });
14
+
15
+ test("actionAlignmentFactor: demotes a doc whose action-intent conflicts with the query's", () => {
16
+ const doc = { id: "react-testing", triggers: [], description: "" };
17
+ assert.equal(actionAlignmentFactor(doc, "add a new component"), 0.6);
18
+ });
19
+
20
+ test("actionAlignmentFactor: boosts a doc whose action-intent matches the query's", () => {
21
+ const doc = { id: "cpp-testing", triggers: [], description: "" };
22
+ assert.equal(actionAlignmentFactor(doc, "fix a failing test"), 1.15);
23
+ });
24
+
25
+ test("actionAlignmentFactor: id is trusted over a noisy description", () => {
26
+ // security-review's own description talks about "adding authentication,
27
+ // implementing payment features" (build-flavored activation prose), which
28
+ // must not outvote the "review" signal already carried by the id alone.
29
+ const doc = {
30
+ id: "security-review",
31
+ triggers: [],
32
+ description:
33
+ "Use this skill when adding authentication, handling user input, creating API endpoints, or implementing payment features.",
34
+ };
35
+ assert.equal(actionAlignmentFactor(doc, "run a security review for authentication code"), 1.15);
36
+ });
@@ -0,0 +1,28 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { buildIndex } from "../registry/registry.mjs";
4
+ import { loadConfig } from "../state/config.mjs";
5
+ import { routeHybrid } from "./hybrid-router.mjs";
6
+
7
+ const index = await buildIndex("recipe.yaml", { discover: false });
8
+ const cleanConfig = {
9
+ ...loadConfig(),
10
+ graphPath: "missing-capability-graph.json",
11
+ factors: null,
12
+ denseEnabled: false,
13
+ };
14
+
15
+ test("a direct curated trigger routes without machine-local graph or feedback state", async () => {
16
+ const result = await routeHybrid(index, "grep for TODO comments in this codebase", cleanConfig);
17
+ assert.ok(result.some((entry) => entry.id === "ripgrep"), `expected ripgrep, got ${result.map((entry) => entry.id)}`);
18
+ });
19
+
20
+ test("generic token overlap does not inherit the direct-trigger trust path", async () => {
21
+ const result = await routeHybrid(
22
+ index,
23
+ "why did the old skill-router hook misfire while we were discussing harness architecture",
24
+ cleanConfig,
25
+ );
26
+ assert.deepEqual(result, []);
27
+ });
28
+