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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SECURITY.md +15 -0
- package/VISION.md +281 -0
- package/adapters/hooks/hook-stub.mjs +21 -0
- package/adapters/hooks/hook-stub.test.mjs +43 -0
- package/adapters/hooks/universal-hook.mjs +348 -0
- package/adapters/hooks/universal-hook.test.mjs +294 -0
- package/adapters/instructions/generate.mjs +114 -0
- package/core/fixtures/bad-recipe.yaml +5 -0
- package/core/fixtures/capability-graph.json +10 -0
- package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
- package/core/registry/capability-docs.mjs +145 -0
- package/core/registry/capability-graph-compiler.mjs +90 -0
- package/core/registry/capability-graph.mjs +53 -0
- package/core/registry/capability-kind.mjs +9 -0
- package/core/registry/cli-enrich.mjs +316 -0
- package/core/registry/cli-enrich.test.mjs +119 -0
- package/core/registry/discover.mjs +379 -0
- package/core/registry/enrich.mjs +213 -0
- package/core/registry/enrich.test.mjs +53 -0
- package/core/registry/eval-harvest.mjs +93 -0
- package/core/registry/eval-harvest.test.mjs +64 -0
- package/core/registry/registry.mjs +197 -0
- package/core/router/concept-vector.mjs +91 -0
- package/core/router/concept-vector.test.mjs +36 -0
- package/core/router/curated-trigger.test.mjs +28 -0
- package/core/router/dense-embedder.mjs +242 -0
- package/core/router/fusion.mjs +18 -0
- package/core/router/hybrid-router.mjs +375 -0
- package/core/router/intent-evidence.mjs +60 -0
- package/core/router/prompt-hygiene.mjs +31 -0
- package/core/router/route-entry.mjs +74 -0
- package/core/router/router-cli.mjs +218 -0
- package/core/router/router-cli.test.mjs +25 -0
- package/core/router/router-eval.mjs +166 -0
- package/core/router/router-live.test.mjs +89 -0
- package/core/router/router.test.mjs +838 -0
- package/core/router/scorer.mjs +72 -0
- package/core/router/sparse-retriever.mjs +79 -0
- package/core/router/tokenize.mjs +42 -0
- package/core/state/bundle-state.mjs +124 -0
- package/core/state/bundle-state.test.mjs +175 -0
- package/core/state/config.mjs +115 -0
- package/core/state/feedback.mjs +129 -0
- package/core/state/feedback.test.mjs +181 -0
- package/core/state/runtime-root.test.mjs +22 -0
- package/core/state/stack-detect.mjs +102 -0
- package/core/state/stack-detect.test.mjs +64 -0
- package/core/surface/config-sync-backends.mjs +224 -0
- package/core/surface/connector-targets.mjs +205 -0
- package/core/surface/discover-hooks.mjs +151 -0
- package/core/surface/discover-hooks.test.mjs +63 -0
- package/core/surface/discover-surface.test.mjs +49 -0
- package/core/surface/extra-hosts.mjs +48 -0
- package/core/surface/extra-hosts.test.mjs +24 -0
- package/core/surface/hook-targets.mjs +102 -0
- package/core/surface/surface-copy-targets.mjs +37 -0
- package/core/surface/surface-inventory.mjs +106 -0
- package/core/surface/surface-matrix.mjs +133 -0
- package/core/surface/surface-matrix.test.mjs +90 -0
- package/docs/router-eval-set.jsonl +38 -0
- package/hooks.manifest.yaml +16 -0
- package/package.json +101 -0
- package/recipe.yaml +247 -0
- package/recipes/foundry.yaml +27 -0
- package/recipes/manifest.yaml +18 -0
- package/recipes/roles/backend-data.yaml +26 -0
- package/recipes/roles/coding.yaml +26 -0
- package/recipes/roles/frontend.yaml +14 -0
- package/recipes/roles/research.yaml +31 -0
- package/recipes/roles/secure.yaml +27 -0
- package/router.config.yaml +120 -0
- package/scripts/bundles.mjs +131 -0
- package/scripts/doctor.mjs +117 -0
- package/scripts/embedded-hooks.mjs +27 -0
- package/scripts/hosts.mjs +60 -0
- package/scripts/link/link-connectors.mjs +190 -0
- package/scripts/link/link-connectors.test.mjs +111 -0
- package/scripts/link/link-hooks.mjs +259 -0
- package/scripts/link/link-hooks.test.mjs +112 -0
- package/scripts/link/link-surfaces.mjs +188 -0
- package/scripts/link/link-surfaces.test.mjs +76 -0
- package/scripts/load.mjs +143 -0
- package/scripts/package-contract.test.mjs +26 -0
- package/scripts/surface-inventory.mjs +6 -0
- package/scripts/sync/agents-surface.mjs +55 -0
- package/scripts/sync/agents-surface.test.mjs +18 -0
- package/scripts/sync/auto-sync.mjs +135 -0
- package/scripts/sync/auto-sync.test.mjs +41 -0
- package/scripts/sync/import-surfaces.mjs +331 -0
- package/scripts/sync/import-surfaces.test.mjs +106 -0
- package/scripts/sync/sync-config.mjs +230 -0
- package/scripts/sync/sync-config.test.mjs +141 -0
- package/scripts/sync/sync-surfaces.mjs +229 -0
- package/scripts/sync/sync-surfaces.test.mjs +66 -0
- package/scripts/tui-actions.mjs +59 -0
- package/scripts/tui-actions.test.mjs +53 -0
- package/scripts/tui.mjs +782 -0
- package/scripts/uninstall-all.mjs +39 -0
- package/server/mcp-server.mjs +129 -0
- package/server/mcp-server.test.mjs +175 -0
- package/skills/portawhip/SKILL.md +80 -0
- 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
|
+
}
|