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,72 @@
1
+ // Layer 1: keyword/trigger matching only. Silence is the default output —
2
+ // callers must get [] on anything that doesn't clear the threshold, not a
3
+ // low-confidence guess (VISION.md principle: "silence is a valid output").
4
+ // Threshold/k defaults come from router.config.yaml (core/config.mjs), not
5
+ // a constant here — this module takes them as explicit call arguments.
6
+
7
+ import { capabilityKind, matchesSuggestKind } from "../registry/capability-kind.mjs";
8
+ import { pointerFor } from "../registry/capability-docs.mjs";
9
+
10
+ function escapeRegex(s) {
11
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12
+ }
13
+
14
+ function triggerHit(trigger, promptLower) {
15
+ const escaped = escapeRegex(trigger.toLowerCase());
16
+ // \b works for word-ish triggers; multi-word phrases still match as a
17
+ // literal substring with boundaries at the phrase's own edges.
18
+ const re = new RegExp(`\\b${escaped}\\b`, "i");
19
+ return re.test(promptLower);
20
+ }
21
+
22
+ export function scoreEntry(entry, promptLower) {
23
+ if (!entry.route) return 0;
24
+ let score = 0;
25
+ for (const trigger of entry.route.triggers) {
26
+ if (triggerHit(trigger, promptLower)) score += 1;
27
+ }
28
+ return score;
29
+ }
30
+
31
+ export function route(index, prompt, { threshold, recipeThreshold, k, suggest = "any", factors = null }) {
32
+ const promptLower = prompt.toLowerCase();
33
+ return index.entries
34
+ .filter((e) => e.route)
35
+ .filter((e) => matchesSuggestKind(e.type, suggest))
36
+ .map((e) => ({ entry: e, score: scoreEntry(e, promptLower) * (factors?.get(e.id) ?? 1.0) }))
37
+ .filter(({ entry, score }) => {
38
+ const bar = entry.origin === "recipe" ? recipeThreshold : threshold;
39
+ return score >= bar;
40
+ })
41
+ .sort((a, b) => b.score - a.score)
42
+ .slice(0, k)
43
+ .map(({ entry, score }) => {
44
+ const bar = entry.origin === "recipe" ? recipeThreshold : threshold;
45
+ const confidence = bar > 0 ? Math.min(1, score / bar) : 1;
46
+ return {
47
+ id: entry.id,
48
+ type: entry.type,
49
+ kind: capabilityKind(entry.type),
50
+ score,
51
+ tier: entry.origin === "recipe" ? "required" : "recommended",
52
+ confidence: Number(confidence.toFixed(2)),
53
+ why: `matched ${score} route trigger${score === 1 ? "" : "s"}`,
54
+ action: entry.route.action ?? (entry.type === "skill" ? "read_skill" : "use_capability"),
55
+ how_to_use: entry.route.description,
56
+ pointer: pointerFor(entry),
57
+ origin: entry.origin,
58
+ readyMarker: entry.route.readyMarker ?? null,
59
+ readyHint: entry.route.readyHint ?? null,
60
+ };
61
+ });
62
+ }
63
+
64
+ export function listAll(index, type) {
65
+ return index.entries
66
+ .filter((e) => !type || e.type === type)
67
+ .map((e) => ({
68
+ id: e.id,
69
+ type: e.type,
70
+ description: e.route ? e.route.description : null,
71
+ }));
72
+ }
@@ -0,0 +1,79 @@
1
+ // Lexical retrieval over capability documents — delegates to minisearch
2
+ // (zero dependencies, actively maintained: https://github.com/lucaong/minisearch,
3
+ // used by VitePress's own site search) instead of a hand-rolled BM25 +
4
+ // phrase-boost implementation. Preserves the same call signature
5
+ // (`sparseRetrieve(docs, query, {k, minScore})` -> `{id, doc, score}[]`) so
6
+ // core/hybrid-router.mjs needed no changes.
7
+
8
+ import MiniSearch from "minisearch";
9
+ import { tokenize } from "./tokenize.mjs";
10
+
11
+ const FIELDS = ["idText", "triggers", "description", "frontmatterDescription", "headings", "activation", "related"];
12
+ const BOOST = {
13
+ idText: 5,
14
+ triggers: 4,
15
+ description: 2,
16
+ frontmatterDescription: 2,
17
+ headings: 1,
18
+ activation: 1,
19
+ related: 1,
20
+ };
21
+
22
+ function toSearchDoc(doc) {
23
+ return {
24
+ id: doc.id,
25
+ idText: doc.id,
26
+ triggers: (doc.triggers ?? []).join(" "),
27
+ description: doc.description ?? "",
28
+ frontmatterDescription: doc.frontmatterDescription ?? "",
29
+ headings: (doc.headings ?? []).join(" "),
30
+ activation: doc.activation ?? "",
31
+ related: doc.related ?? "",
32
+ };
33
+ }
34
+
35
+ export function sparseRetrieve(docs, query, { k = 20, minScore = 2 } = {}) {
36
+ if (docs.length === 0) return [];
37
+ const mini = new MiniSearch({
38
+ fields: FIELDS,
39
+ idField: "id",
40
+ // Our own stemmer/stopword filter (core/tokenize.mjs) runs on both doc
41
+ // fields and the query, so common connector words never contribute
42
+ // noise in the first place — default fuzzy+prefix (needed for raw,
43
+ // unstemmed English) turned out to reward broad partial matches on
44
+ // ordinary sentences over a strong single-term hit (verified: ripgrep
45
+ // ranked 5th, behind 4 unrelated docs, on "grep for TODO comments in
46
+ // this codebase" with fuzzy:0.2/prefix:true/no pre-stemming).
47
+ tokenize,
48
+ searchOptions: { boost: BOOST, fuzzy: 0.1, prefix: false, combineWith: "OR" },
49
+ });
50
+ mini.addAll(docs.map(toSearchDoc));
51
+
52
+ const docsById = new Map(docs.map((doc) => [doc.id, doc]));
53
+ const hits = mini.search(query);
54
+
55
+ return hits
56
+ .map((hit) => {
57
+ const doc = docsById.get(hit.id);
58
+ // Curated (recipe.yaml) entries are deliberately authored, not
59
+ // inferred — trust bonus once there's any lexical evidence at all,
60
+ // same rationale as core/scorer.mjs's recipeThreshold split. Must be
61
+ // proportional, not flat: minisearch's score magnitude varies a lot
62
+ // per query (tens to low thousands), so a flat +3 (sized for the old
63
+ // hand-rolled engine's ~20-60 range) was invisible here — verified it
64
+ // let an auto-discovered "pdf" skill (576) outrank the curated
65
+ // "anthropic-skills" entry (318) for the same underlying capability.
66
+ const score = doc?.origin === "recipe" ? hit.score * 1.25 : hit.score;
67
+ return {
68
+ id: hit.id,
69
+ doc,
70
+ score,
71
+ terms: hit.terms ?? [],
72
+ queryTerms: hit.queryTerms ?? [],
73
+ match: hit.match ?? {},
74
+ };
75
+ })
76
+ .filter((item) => item.doc && item.score >= minScore)
77
+ .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
78
+ .slice(0, k);
79
+ }
@@ -0,0 +1,42 @@
1
+ // Small shared tokenizer used by capability-graph-compiler.mjs for simple
2
+ // token-overlap edge scoring (Set intersection, not a search engine — no
3
+ // "existing library" concern for something this small). The actual
4
+ // retrieval engine (core/sparse-retriever.mjs) delegates to minisearch
5
+ // instead of hand-rolling this kind of logic.
6
+
7
+ const STOPWORDS = new Set([
8
+ "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "how",
9
+ "i", "in", "is", "it", "me", "of", "on", "or", "the", "this", "through",
10
+ "to", "use", "what", "when", "why", "with",
11
+ "agent", "agents", "coding", "project", "workflow", "task", "tasks",
12
+ // "capability" is both this project's own self-description AND a generic
13
+ // word many unrelated skill docs happen to use (e.g. "product-capability")
14
+ // — confirmed it drove a false positive on a prompt literally about
15
+ // designing "a capability router" via the word "capability" alone.
16
+ "capability", "capabilities",
17
+ // Same failure mode, different word: the "harness-router" recipe's own id
18
+ // tokenizes to "harness"+"router" and idText carries the highest field
19
+ // boost, so any meta-discussion prompt containing "router"/"routing"/
20
+ // "harness" scored a hit against this repo's own capability regardless of
21
+ // trigger list content. Entries are the STEMMED forms (stem() runs before
22
+ // this filter, and its naive suffix-stripping turns "routing" -> "rout"
23
+ // and "harness" -> "harnes" — stopwording the unstemmed word is a no-op).
24
+ // Stopwording forces a real match to come from a distinguishing word (a
25
+ // file name, "fix", "improve") instead.
26
+ "router", "route", "rout", "harnes", "harnesse",
27
+ ]);
28
+
29
+ function stem(token) {
30
+ if (token.length > 5 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
31
+ if (token.length > 5 && token.endsWith("ing")) return token.slice(0, -3);
32
+ if (token.length > 4 && token.endsWith("es")) return token.slice(0, -2);
33
+ if (token.length > 4 && token.endsWith("s")) return token.slice(0, -1);
34
+ return token;
35
+ }
36
+
37
+ export function tokenize(text) {
38
+ const raw = String(text ?? "")
39
+ .toLowerCase()
40
+ .match(/[a-z0-9]+/g) ?? [];
41
+ return raw.map(stem).filter((token) => token.length > 1 && !STOPWORDS.has(token));
42
+ }
@@ -0,0 +1,124 @@
1
+ // Bundle selection (2026-07-05): tracks which opt-in recipe files a user has
2
+ // turned on, and resolves that selection into the ordered path list
3
+ // registry.mjs (routing) and scripts/load.mjs (install) both consume.
4
+ //
5
+ // Every rule below traces back to one decision made explicit with the owner:
6
+ // bundles are recommend-only, never forced. `foundry` is whipforaweeb's
7
+ // curated core but stays opt-out; `roles` are whipforaweeb's role-based
8
+ // add-ons and a user may tick zero, one, or many. Nothing here auto-selects
9
+ // anything — the default (no selection file) is exactly today's behavior:
10
+ // just the project's own recipe.yaml, unchanged.
11
+
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
13
+ import { dirname, isAbsolute, join } from "node:path";
14
+ import yaml from "js-yaml";
15
+
16
+ export function activeSelectionPathFor(root) {
17
+ return join(root, ".hp-state", "active-recipes.json");
18
+ }
19
+
20
+ export function resolveRuntimeRoot(cwd, packageRoot) {
21
+ return existsSync(join(cwd, "recipe.yaml")) ? cwd : packageRoot;
22
+ }
23
+
24
+ export function readActiveSelection(root) {
25
+ const path = activeSelectionPathFor(root);
26
+ if (!existsSync(path)) return { foundry: false, roles: [] };
27
+ try {
28
+ const raw = JSON.parse(readFileSync(path, "utf8"));
29
+ return {
30
+ foundry: raw.foundry === true,
31
+ roles: Array.isArray(raw.roles) ? raw.roles.filter((r) => typeof r === "string") : [],
32
+ };
33
+ } catch {
34
+ // A corrupted selection file should degrade to "nothing selected", not
35
+ // crash routing/install — same fail-open posture as the rest of this repo.
36
+ return { foundry: false, roles: [] };
37
+ }
38
+ }
39
+
40
+ export function writeActiveSelection(root, selection) {
41
+ const path = activeSelectionPathFor(root);
42
+ mkdirSync(dirname(path), { recursive: true });
43
+ const normalized = {
44
+ foundry: selection.foundry === true,
45
+ roles: [...new Set(selection.roles ?? [])],
46
+ };
47
+ writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`);
48
+ return normalized;
49
+ }
50
+
51
+ // The bundle-only portion of a selection (foundry.yaml + selected
52
+ // recipes/roles/*.yaml, NOT the project's own recipe.yaml). Exposed
53
+ // separately so scripts/bundles.mjs's `select` can install exactly what was
54
+ // just opted into, right away — without reinstalling the project's own
55
+ // recipe.yaml entries every time a bundle changes.
56
+ export function resolveBundlePaths(root, selection) {
57
+ const paths = [];
58
+ if (selection.foundry) {
59
+ const p = join(root, "recipes", "foundry.yaml");
60
+ if (existsSync(p)) paths.push(p);
61
+ }
62
+ for (const role of selection.roles ?? []) {
63
+ const p = join(root, "recipes", "roles", `${role}.yaml`);
64
+ if (existsSync(p)) paths.push(p);
65
+ }
66
+ return paths;
67
+ }
68
+
69
+ // Resolves a selection into the ordered recipe-file list, skipping anything
70
+ // missing rather than throwing — an empty/no-op selection (or a project with
71
+ // no recipes/ directory at all yet) degrades to exactly today's single-recipe
72
+ // behavior. Order matters: registry.mjs/load.mjs both treat the LAST path as
73
+ // highest precedence, so the project's own recipe.yaml always goes last.
74
+ //
75
+ // userRecipe is anchored to `root`, not process.cwd() — this server/hook is
76
+ // invoked globally from ANY caller cwd (that's the whole point), so a
77
+ // cwd-relative resolve would silently look in the CALLER's directory instead
78
+ // of this repo's own recipe.yaml. Same failure mode already fixed once for
79
+ // router.config.yaml's graphPath (see server/mcp-server.mjs's own comment).
80
+ export function resolveRecipePaths(root, selection, { userRecipe = "recipe.yaml" } = {}) {
81
+ const paths = resolveBundlePaths(root, selection);
82
+ // Imported surfaces (Phase S1): user-approved entries promoted from live
83
+ // host discovery by scripts/import-surfaces.mjs. Always-on (not an opt-in
84
+ // bundle), and ordered BEFORE the project's own recipe.yaml so a
85
+ // hand-authored entry still wins on id collision. Like bundle entries, an
86
+ // imported entry only routes if discovery independently confirms it's still
87
+ // installed (buildIndex's fromBundle gate) — so a stale import self-heals.
88
+ const importedPath = join(root, "recipes", "imported.yaml");
89
+ if (existsSync(importedPath)) paths.push(importedPath);
90
+ const userPath = isAbsolute(userRecipe) ? userRecipe : join(root, userRecipe);
91
+ if (existsSync(userPath)) paths.push(userPath);
92
+ return paths;
93
+ }
94
+
95
+ function safeEntryIds(path) {
96
+ if (!existsSync(path)) return [];
97
+ const raw = yaml.load(readFileSync(path, "utf8"));
98
+ return Array.isArray(raw) ? raw.map((e) => e.id) : [];
99
+ }
100
+
101
+ // Catalog is read straight from recipes/ + recipes/manifest.yaml (pure data,
102
+ // no decision logic) so `scripts/bundles.mjs list` never drifts from what
103
+ // resolveRecipePaths would actually load.
104
+ export function listCatalog(root) {
105
+ const manifestPath = join(root, "recipes", "manifest.yaml");
106
+ const manifest = existsSync(manifestPath) ? (yaml.load(readFileSync(manifestPath, "utf8")) ?? {}) : {};
107
+
108
+ const foundryPath = join(root, "recipes", "foundry.yaml");
109
+ const foundry = existsSync(foundryPath)
110
+ ? { id: "foundry", ...manifest.foundry, entryIds: safeEntryIds(foundryPath) }
111
+ : null;
112
+
113
+ const rolesDir = join(root, "recipes", "roles");
114
+ const roles = existsSync(rolesDir)
115
+ ? readdirSync(rolesDir)
116
+ .filter((f) => f.endsWith(".yaml"))
117
+ .map((f) => {
118
+ const id = f.replace(/\.yaml$/, "");
119
+ return { id, ...(manifest.roles?.[id] ?? {}), entryIds: safeEntryIds(join(rolesDir, f)) };
120
+ })
121
+ : [];
122
+
123
+ return { foundry, roles };
124
+ }
@@ -0,0 +1,175 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ listCatalog,
8
+ readActiveSelection,
9
+ resolveRecipePaths,
10
+ writeActiveSelection,
11
+ } from "./bundle-state.mjs";
12
+ import { buildIndex, isDiscovered, mergeRawEntries } from "../registry/registry.mjs";
13
+
14
+ function tmpProject() {
15
+ return mkdtempSync(join(tmpdir(), "harness-bundle-"));
16
+ }
17
+
18
+ function writeRecipe(path, entries) {
19
+ mkdirSync(join(path, ".."), { recursive: true });
20
+ writeFileSync(
21
+ path,
22
+ entries
23
+ .map((e) => `- id: ${e.id}\n type: ${e.type}\n source: ${e.source}\n route:\n triggers: ["${e.trigger}"]\n description: "${e.description}"\n when: [user_prompt]\n inject: hint\n`)
24
+ .join(""),
25
+ );
26
+ }
27
+
28
+ test("bundle-state: default selection (no file yet) is nothing selected", () => {
29
+ const root = tmpProject();
30
+ try {
31
+ assert.deepEqual(readActiveSelection(root), { foundry: false, roles: [] });
32
+ } finally {
33
+ rmSync(root, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ test("bundle-state: default selection resolves to only the project's own recipe.yaml (no regression)", () => {
38
+ const root = tmpProject();
39
+ try {
40
+ writeFileSync(join(root, "recipe.yaml"), "- id: x\n type: cli\n source: x\n");
41
+ const paths = resolveRecipePaths(root, readActiveSelection(root));
42
+ assert.deepEqual(paths, [join(root, "recipe.yaml")]);
43
+ } finally {
44
+ rmSync(root, { recursive: true, force: true });
45
+ }
46
+ });
47
+
48
+ test("bundle-state: write/read selection round-trips and dedupes roles", () => {
49
+ const root = tmpProject();
50
+ try {
51
+ writeActiveSelection(root, { foundry: true, roles: ["secure", "secure", "coding"] });
52
+ const selection = readActiveSelection(root);
53
+ assert.equal(selection.foundry, true);
54
+ assert.deepEqual(selection.roles, ["secure", "coding"]);
55
+ } finally {
56
+ rmSync(root, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ test("bundle-state: resolveRecipePaths orders foundry -> roles -> project recipe.yaml, skipping missing files", () => {
61
+ const root = tmpProject();
62
+ try {
63
+ mkdirSync(join(root, "recipes", "roles"), { recursive: true });
64
+ writeFileSync(join(root, "recipes", "foundry.yaml"), "- id: f\n type: cli\n source: f\n");
65
+ writeFileSync(join(root, "recipes", "roles", "secure.yaml"), "- id: s\n type: cli\n source: s\n");
66
+ writeFileSync(join(root, "recipe.yaml"), "- id: u\n type: cli\n source: u\n");
67
+
68
+ const paths = resolveRecipePaths(root, { foundry: true, roles: ["secure", "does-not-exist"] });
69
+ assert.deepEqual(paths, [
70
+ join(root, "recipes", "foundry.yaml"),
71
+ join(root, "recipes", "roles", "secure.yaml"),
72
+ join(root, "recipe.yaml"),
73
+ ]);
74
+ } finally {
75
+ rmSync(root, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ test("bundle-state: listCatalog reads manifest descriptions and entry ids from disk", () => {
80
+ const root = tmpProject();
81
+ try {
82
+ mkdirSync(join(root, "recipes", "roles"), { recursive: true });
83
+ writeFileSync(join(root, "recipes", "manifest.yaml"), 'foundry:\n description: "core"\nroles:\n secure:\n description: "sec role"\n');
84
+ writeFileSync(join(root, "recipes", "foundry.yaml"), "- id: f\n type: cli\n source: f\n");
85
+ writeFileSync(join(root, "recipes", "roles", "secure.yaml"), "- id: s1\n type: cli\n source: s1\n- id: s2\n type: cli\n source: s2\n");
86
+
87
+ const { foundry, roles } = listCatalog(root);
88
+ assert.equal(foundry.description, "core");
89
+ assert.deepEqual(foundry.entryIds, ["f"]);
90
+ assert.equal(roles.length, 1);
91
+ assert.equal(roles[0].id, "secure");
92
+ assert.equal(roles[0].description, "sec role");
93
+ assert.deepEqual(roles[0].entryIds, ["s1", "s2"]);
94
+ } finally {
95
+ rmSync(root, { recursive: true, force: true });
96
+ }
97
+ });
98
+
99
+ test("bundle-state: catalog is empty-safe when recipes/ doesn't exist yet", () => {
100
+ const root = tmpProject();
101
+ try {
102
+ const { foundry, roles } = listCatalog(root);
103
+ assert.equal(foundry, null);
104
+ assert.deepEqual(roles, []);
105
+ } finally {
106
+ rmSync(root, { recursive: true, force: true });
107
+ }
108
+ });
109
+
110
+ test("registry: mergeRawEntries lets a later path win on id collision (user > role > foundry precedence)", () => {
111
+ const root = tmpProject();
112
+ try {
113
+ const foundryPath = join(root, "foundry.yaml");
114
+ const userPath = join(root, "recipe.yaml");
115
+ writeFileSync(foundryPath, '- id: shared\n type: cli\n source: from-foundry\n route:\n triggers: ["a"]\n description: "foundry version"\n');
116
+ writeFileSync(userPath, '- id: shared\n type: cli\n source: from-user\n route:\n triggers: ["b"]\n description: "user override"\n');
117
+
118
+ const merged = mergeRawEntries([foundryPath, userPath]);
119
+ assert.equal(merged.length, 1);
120
+ assert.equal(merged[0].source, "from-user");
121
+ } finally {
122
+ rmSync(root, { recursive: true, force: true });
123
+ }
124
+ });
125
+
126
+ test("registry: buildIndex composes multiple recipe files into one curated set", async () => {
127
+ const root = tmpProject();
128
+ try {
129
+ const foundryPath = join(root, "foundry.yaml");
130
+ const rolePath = join(root, "role.yaml");
131
+ writeRecipe(foundryPath, [{ id: "tool-a", type: "cli", source: "tool-a", trigger: "alpha task", description: "alpha" }]);
132
+ writeRecipe(rolePath, [{ id: "tool-b", type: "cli", source: "tool-b", trigger: "beta task", description: "beta" }]);
133
+
134
+ const index = await buildIndex([foundryPath, rolePath], { discover: false });
135
+ const ids = index.entries.map((e) => e.id).sort();
136
+ assert.deepEqual(ids, ["tool-a", "tool-b"]);
137
+ assert.ok(index.entries.every((e) => e.origin === "recipe"));
138
+ } finally {
139
+ rmSync(root, { recursive: true, force: true });
140
+ }
141
+ });
142
+
143
+ // Regression: mise keys a backend-qualified install (e.g. "pipx:markitdown")
144
+ // by that full string, not the clean id a recipe entry chooses ("markitdown")
145
+ // - found live 2026-07-05 when route() stayed silent for an actually-installed
146
+ // markitdown because the gate only compared entry.id against discovery.
147
+ test("registry: isDiscovered matches on entry.source when discovery's id differs (backend-qualified sources)", () => {
148
+ const entry = { id: "markitdown", source: "pipx:markitdown" };
149
+ const discovered = [{ id: "pipx:markitdown", source: "pipx:markitdown", type: "cli" }];
150
+ assert.equal(isDiscovered(entry, discovered), true);
151
+ });
152
+
153
+ test("registry: isDiscovered still matches the plain case (id === source)", () => {
154
+ const entry = { id: "ast-grep", source: "ast-grep" };
155
+ const discovered = [{ id: "ast-grep", source: "ast-grep", type: "cli" }];
156
+ assert.equal(isDiscovered(entry, discovered), true);
157
+ });
158
+
159
+ test("registry: isDiscovered returns false when neither id nor source is present", () => {
160
+ const entry = { id: "gitleaks", source: "gitleaks" };
161
+ const discovered = [{ id: "ast-grep", source: "ast-grep", type: "cli" }];
162
+ assert.equal(isDiscovered(entry, discovered), false);
163
+ });
164
+
165
+ test("registry: buildIndex with a single string path still works exactly as before (backward compat)", async () => {
166
+ const root = tmpProject();
167
+ try {
168
+ const path = join(root, "recipe.yaml");
169
+ writeRecipe(path, [{ id: "solo", type: "cli", source: "solo", trigger: "solo task", description: "solo" }]);
170
+ const index = await buildIndex(path, { discover: false });
171
+ assert.deepEqual(index.entries.map((e) => e.id), ["solo"]);
172
+ } finally {
173
+ rmSync(root, { recursive: true, force: true });
174
+ }
175
+ });
@@ -0,0 +1,115 @@
1
+ // Router tuning knobs (threshold, k) live in router.config.yaml, not as
2
+ // hardcoded constants in scorer.mjs — PLAN.md Phase 1 spec requires the
3
+ // threshold be "config in recipe header or router.config.yaml".
4
+
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ import yaml from "js-yaml";
7
+
8
+ const DEFAULTS = {
9
+ engine: "hybrid",
10
+ threshold: 2,
11
+ recipeThreshold: 1,
12
+ hybridThreshold: 350,
13
+ hybridRecipeThreshold: 130,
14
+ hybridToolThreshold: 80,
15
+ graphPath: ".hp-state/capability-graph.json",
16
+ graphBoost: 0.25,
17
+ k: 5,
18
+ // See router.config.yaml's own comment: a lane where the top match barely
19
+ // beats the runner-up is diffuse-vocabulary noise, not a genuine pick.
20
+ peakednessRatio: 1.05,
21
+ // Dense semantic retrieval (core/dense-embedder.mjs, BGE-M3) - on by
22
+ // default so the zero-setup path (just install, no manual model
23
+ // management) is the norm for every user, not an opt-in. Callers that
24
+ // can't amortize a 500MB+ model load across calls (the push hook - see
25
+ // adapters/hooks/universal-hook.mjs) explicitly pass denseEnabled:false.
26
+ denseEnabled: true,
27
+ // 0.6, not the more intuitive-looking 0.55 - verified live against
28
+ // docs/router-eval-set.jsonl (BGE-M3 real model, not simulated): 0.55
29
+ // let a genuinely irrelevant agent/skill pair through on
30
+ // "audit why route suggestions are noisy and injected at the wrong time"
31
+ // (network-config-reviewer/caveman-review, both boosted over the line by
32
+ // actionAlignmentFactor's "review" match on top of raw cosine ~0.48-0.50
33
+ // - BGE-M3's ambient similarity floor for unrelated short text, not a
34
+ // real match). Swept 0.55/0.56/0.58/0.60 - 0.55-0.56 fail, 0.58-0.60 both
35
+ // clean (falsePositiveCount 0, precisionAt1/recallAt3 still 1) - a real
36
+ // plateau, not one lucky number. Picked 0.60 for extra margin over the
37
+ // 0.58 edge.
38
+ denseThreshold: 0.6,
39
+ // Raw-prompt push has no reasoning signal, so it cannot reliably
40
+ // distinguish topical discussion from an actionable capability request.
41
+ // Keep unsolicited routing silent by default. "legacy" is an explicit
42
+ // rollback mode for installations that still want the old push behavior.
43
+ pushMode: "silent",
44
+ // See router.config.yaml's own comment for the 320 -> 640 rationale
45
+ // (actionDirective()'s mcp/ToolSearch clause cost, paid at most once per
46
+ // id per session since repeats render tersely) - kept in sync here so a
47
+ // project with no router.config.yaml at all still gets the same fix.
48
+ pushBudgetChars: 640,
49
+ // Push (unsolicited injection) needs much higher precision than pull
50
+ // (Claude asked via MCP route()) - advisory-systems literature calls the
51
+ // failure mode alert fatigue: interruptions that are wrong even
52
+ // occasionally get tuned out entirely, killing the channel. Hits below
53
+ // this calibrated confidence are dropped from push output unless they are
54
+ // required-tier (curated recipe entries, deliberately authored triggers).
55
+ // Pull is unaffected - recall stays generous when the caller asked.
56
+ pushMinConfidence: 0.75,
57
+ // Interrupt budget: how many times one capability may be rendered per
58
+ // session before going silent (full line, then terse reminder, then
59
+ // nothing). An assistant that reminds twice and then shuts up.
60
+ pushMaxMentionsPerSession: 2,
61
+ // Auto-sync (Phase S1c, decision D): on session start, the hook
62
+ // fire-and-forgets scripts/auto-sync.mjs, which fans out already-canonical
63
+ // capabilities (recipe.yaml + recipes/imported.yaml + selected bundles) to
64
+ // all hosts. It does NOT auto-discover/import — import stays manual
65
+ // (`npm run import`). `enabled:false` disables the auto fan-out.
66
+ // throttleMinutes rate-limits it so rapid session restarts don't re-run it.
67
+ autoSync: { enabled: true, throttleMinutes: 60 },
68
+ };
69
+
70
+ export function loadConfig(path = "router.config.yaml") {
71
+ if (!existsSync(path)) return { ...DEFAULTS };
72
+ const raw = yaml.load(readFileSync(path, "utf8")) ?? {};
73
+ return {
74
+ engine: raw.engine === "keyword" || raw.engine === "hybrid" ? raw.engine : DEFAULTS.engine,
75
+ threshold: typeof raw.threshold === "number" ? raw.threshold : DEFAULTS.threshold,
76
+ recipeThreshold:
77
+ typeof raw.recipeThreshold === "number" ? raw.recipeThreshold : DEFAULTS.recipeThreshold,
78
+ hybridThreshold:
79
+ typeof raw.hybridThreshold === "number" ? raw.hybridThreshold : DEFAULTS.hybridThreshold,
80
+ hybridRecipeThreshold:
81
+ typeof raw.hybridRecipeThreshold === "number"
82
+ ? raw.hybridRecipeThreshold
83
+ : DEFAULTS.hybridRecipeThreshold,
84
+ hybridToolThreshold:
85
+ typeof raw.hybridToolThreshold === "number" ? raw.hybridToolThreshold : DEFAULTS.hybridToolThreshold,
86
+ graphPath:
87
+ typeof raw.graphPath === "string" && raw.graphPath.trim() ? raw.graphPath : DEFAULTS.graphPath,
88
+ graphBoost: typeof raw.graphBoost === "number" ? raw.graphBoost : DEFAULTS.graphBoost,
89
+ k: typeof raw.k === "number" ? raw.k : DEFAULTS.k,
90
+ peakednessRatio:
91
+ typeof raw.peakednessRatio === "number" ? raw.peakednessRatio : DEFAULTS.peakednessRatio,
92
+ denseEnabled: typeof raw.denseEnabled === "boolean" ? raw.denseEnabled : DEFAULTS.denseEnabled,
93
+ denseThreshold:
94
+ typeof raw.denseThreshold === "number" ? raw.denseThreshold : DEFAULTS.denseThreshold,
95
+ pushMode: raw.pushMode === "legacy" || raw.pushMode === "silent" ? raw.pushMode : DEFAULTS.pushMode,
96
+ pushBudgetChars:
97
+ typeof raw.pushBudgetChars === "number" ? raw.pushBudgetChars : DEFAULTS.pushBudgetChars,
98
+ pushMinConfidence:
99
+ typeof raw.pushMinConfidence === "number" ? raw.pushMinConfidence : DEFAULTS.pushMinConfidence,
100
+ pushMaxMentionsPerSession:
101
+ typeof raw.pushMaxMentionsPerSession === "number"
102
+ ? raw.pushMaxMentionsPerSession
103
+ : DEFAULTS.pushMaxMentionsPerSession,
104
+ autoSync: normalizeAutoSync(raw.autoSync),
105
+ };
106
+ }
107
+
108
+ function normalizeAutoSync(raw) {
109
+ if (!raw || typeof raw !== "object") return { ...DEFAULTS.autoSync };
110
+ return {
111
+ enabled: typeof raw.enabled === "boolean" ? raw.enabled : DEFAULTS.autoSync.enabled,
112
+ throttleMinutes:
113
+ typeof raw.throttleMinutes === "number" ? raw.throttleMinutes : DEFAULTS.autoSync.throttleMinutes,
114
+ };
115
+ }