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,60 @@
|
|
|
1
|
+
import { tokenize } from "./tokenize.mjs";
|
|
2
|
+
|
|
3
|
+
function coverageFor(trigger, queryTokens) {
|
|
4
|
+
const triggerTokens = [...new Set(tokenize(trigger))];
|
|
5
|
+
if (triggerTokens.length === 0) return { matchedCount: 0, totalCount: 0, coverage: 0 };
|
|
6
|
+
const matchedCount = triggerTokens.filter((token) => queryTokens.has(token)).length;
|
|
7
|
+
return {
|
|
8
|
+
matchedCount,
|
|
9
|
+
totalCount: triggerTokens.length,
|
|
10
|
+
coverage: matchedCount / triggerTokens.length,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Authored trigger coverage is useful evidence, but the characterization
|
|
15
|
+
// spikes proved it cannot decide intent: a topical false positive and a real
|
|
16
|
+
// request can have the same coverage signature. Keep it compact, provenance-
|
|
17
|
+
// tagged, and explicitly advisory so downstream policy cannot mistake it for
|
|
18
|
+
// an eligibility gate.
|
|
19
|
+
export function triggerCoverageEvidence(triggers, query, { source = "unknown" } = {}) {
|
|
20
|
+
const queryTokens = new Set(tokenize(query));
|
|
21
|
+
let best = { matchedCount: 0, totalCount: 0, coverage: 0 };
|
|
22
|
+
|
|
23
|
+
for (const trigger of Array.isArray(triggers) ? triggers : []) {
|
|
24
|
+
const current = coverageFor(trigger, queryTokens);
|
|
25
|
+
if (
|
|
26
|
+
current.coverage > best.coverage ||
|
|
27
|
+
(current.coverage === best.coverage && current.matchedCount > best.matchedCount)
|
|
28
|
+
) {
|
|
29
|
+
best = current;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
source,
|
|
35
|
+
method: "token_overlap",
|
|
36
|
+
strength: best.coverage === 1 ? "full" : best.matchedCount > 0 ? "partial" : "none",
|
|
37
|
+
coverage: Number(best.coverage.toFixed(3)),
|
|
38
|
+
matchedCount: best.matchedCount,
|
|
39
|
+
totalCount: best.totalCount,
|
|
40
|
+
advisoryOnly: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function annotateIntentEvidence(index, query, candidates, { mode = "explicit" } = {}) {
|
|
45
|
+
const entries = new Map((index?.entries ?? []).map((entry) => [entry.id, entry]));
|
|
46
|
+
return candidates.map((candidate) => {
|
|
47
|
+
const entry = entries.get(candidate.id);
|
|
48
|
+
// Current normalized entries do not preserve per-field provenance for
|
|
49
|
+
// auto-discovered triggers. Recipe triggers are authored; everything else
|
|
50
|
+
// remains unknown until Contract V1 can distinguish parsed vs inferred.
|
|
51
|
+
const source = entry?.origin === "recipe" ? "declared" : "unknown";
|
|
52
|
+
return {
|
|
53
|
+
...candidate,
|
|
54
|
+
intentEvidence: {
|
|
55
|
+
...triggerCoverageEvidence(entry?.route?.triggers ?? [], query, { source }),
|
|
56
|
+
mode,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Shared predicate: is this "prompt" actually a human prompt, or a
|
|
2
|
+
// harness-generated payload wrapped in XML that merely arrived through the
|
|
3
|
+
// same UserPromptSubmit channel?
|
|
4
|
+
//
|
|
5
|
+
// Found live (2026-07-09, .hp-state/feedback/events.jsonl): 21 of 26
|
|
6
|
+
// "suggested" events had been fired against <task-notification> blobs -
|
|
7
|
+
// background-task completion notices, not anything a person typed. Every one
|
|
8
|
+
// of those suggestions resolves as "ignored" in computeFactors, so genuinely
|
|
9
|
+
// good capabilities were being decayed by noise the router should never have
|
|
10
|
+
// routed. One predicate, used by BOTH the push hook (skip routing entirely)
|
|
11
|
+
// and computeFactors (retroactively ignore historical noise without
|
|
12
|
+
// rewriting the append-only log).
|
|
13
|
+
|
|
14
|
+
const KNOWN_WRAPPERS = [
|
|
15
|
+
"<task-notification>",
|
|
16
|
+
"<system-reminder>",
|
|
17
|
+
"<local-command-caveat>",
|
|
18
|
+
"<command-name>",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// A prompt that IS a single XML-ish element (opens with a tag, closes with a
|
|
22
|
+
// matching-shaped tag) is harness plumbing even if the tag name is one we've
|
|
23
|
+
// never seen - new wrapper types shouldn't each need a release here.
|
|
24
|
+
const XMLISH_WHOLE = /^<([a-z][a-z0-9-]*)[\s>][\s\S]*<\/[a-z][a-z0-9-]*>$/;
|
|
25
|
+
|
|
26
|
+
export function isSyntheticPrompt(prompt) {
|
|
27
|
+
const trimmed = String(prompt ?? "").trim();
|
|
28
|
+
if (!trimmed.startsWith("<")) return false;
|
|
29
|
+
if (KNOWN_WRAPPERS.some((w) => trimmed.startsWith(w))) return true;
|
|
30
|
+
return XMLISH_WHOLE.test(trimmed);
|
|
31
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Single place that decides keyword vs hybrid engine, shared by
|
|
2
|
+
// router-cli.mjs and server/mcp-server.mjs — the Phase 2.5 audit found
|
|
3
|
+
// these two callers had drifted (CLI defaulted to hybrid, the live MCP
|
|
4
|
+
// server silently kept using the old keyword-only scorer). One function,
|
|
5
|
+
// used by both, so that can't happen again.
|
|
6
|
+
|
|
7
|
+
import { route } from "./scorer.mjs";
|
|
8
|
+
import { routeHybrid } from "./hybrid-router.mjs";
|
|
9
|
+
import { annotateIntentEvidence } from "./intent-evidence.mjs";
|
|
10
|
+
|
|
11
|
+
function pushIsSilent(config) {
|
|
12
|
+
return config.mode === "push" && config.pushMode === "silent";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runRoute(index, prompt, config) {
|
|
16
|
+
// Delivery policy, not retrieval: raw-prompt push has no reasoning signal,
|
|
17
|
+
// so the default mode abstains before invoking either engine. Direct engine
|
|
18
|
+
// callers remain available for characterization and evals.
|
|
19
|
+
if (pushIsSilent(config)) return [];
|
|
20
|
+
const candidates =
|
|
21
|
+
config.engine === "hybrid" ? await routeHybrid(index, prompt, config) : route(index, prompt, config);
|
|
22
|
+
return annotateIntentEvidence(index, prompt, candidates, { mode: config.mode });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function explainRoute(index, prompt, config) {
|
|
26
|
+
const startedAt = Date.now();
|
|
27
|
+
if (pushIsSilent(config)) {
|
|
28
|
+
const reason = "push delivery is silent by mode policy";
|
|
29
|
+
return {
|
|
30
|
+
status: "empty",
|
|
31
|
+
decision: "abstain",
|
|
32
|
+
summary: "no actionable capability matched this task",
|
|
33
|
+
results: [],
|
|
34
|
+
suppressed: [],
|
|
35
|
+
near_misses: [],
|
|
36
|
+
negative_evidence: { result: "empty", reason },
|
|
37
|
+
reason,
|
|
38
|
+
latency_ms: Date.now() - startedAt,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const engineCandidates = config.engine === "hybrid"
|
|
42
|
+
? await routeHybrid(index, prompt, { ...config, includeWeak: true })
|
|
43
|
+
: route(index, prompt, config);
|
|
44
|
+
const all = annotateIntentEvidence(index, prompt, engineCandidates, { mode: config.mode });
|
|
45
|
+
const results = all.filter((item) => item.tier === "required" || item.tier === "recommended");
|
|
46
|
+
const suppressed = all.filter((item) => item.tier !== "required" && item.tier !== "recommended");
|
|
47
|
+
const reason =
|
|
48
|
+
results.length === 0
|
|
49
|
+
? suppressed.length > 0
|
|
50
|
+
? "only weak or keyword-only matches were found; ignore them by default"
|
|
51
|
+
: "no installed capability cleared the routing threshold"
|
|
52
|
+
: null;
|
|
53
|
+
return {
|
|
54
|
+
status: results.length > 0 ? "success" : "empty",
|
|
55
|
+
// decision/near_misses: the same information as status/suppressed above,
|
|
56
|
+
// spelled out in the vocabulary Stage 4 (docs/intent-gate-bakeoff.md)
|
|
57
|
+
// asked for - "abstain is a first-class decision with a reason, not a
|
|
58
|
+
// shrug." Added alongside the original fields, not replacing them: this
|
|
59
|
+
// server is published (github.com/VVeb1250/portawhip) and any external
|
|
60
|
+
// caller may already depend on status/negative_evidence/suppressed, so
|
|
61
|
+
// this is additive, never a breaking rename.
|
|
62
|
+
decision: results.length > 0 ? "route" : "abstain",
|
|
63
|
+
summary:
|
|
64
|
+
results.length > 0
|
|
65
|
+
? `found ${results.length} actionable capability match${results.length === 1 ? "" : "es"}`
|
|
66
|
+
: "no actionable capability matched this task",
|
|
67
|
+
results,
|
|
68
|
+
suppressed,
|
|
69
|
+
near_misses: suppressed,
|
|
70
|
+
negative_evidence: reason ? { result: "empty", reason } : null,
|
|
71
|
+
reason,
|
|
72
|
+
latency_ms: Date.now() - startedAt,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// node core/router/router-cli.mjs route --prompt "..." [--threshold N] [--recipeThreshold N] [--k N]
|
|
3
|
+
// node core/router/router-cli.mjs list [--type skill]
|
|
4
|
+
|
|
5
|
+
import { loadIndex } from "../registry/registry.mjs";
|
|
6
|
+
import { listAll } from "./scorer.mjs";
|
|
7
|
+
import { runRoute } from "./route-entry.mjs";
|
|
8
|
+
import { compileCapabilityGraph, writeCapabilityGraph } from "../registry/capability-graph-compiler.mjs";
|
|
9
|
+
import { runRouterEval, runRouterEvalComparison, loadEvalSet } from "./router-eval.mjs";
|
|
10
|
+
import { loadConfig } from "../state/config.mjs";
|
|
11
|
+
import { computeFactors } from "../state/feedback.mjs";
|
|
12
|
+
import { stackFactors, combineFactors } from "../state/stack-detect.mjs";
|
|
13
|
+
import { harvestHardNegatives } from "../registry/eval-harvest.mjs";
|
|
14
|
+
import { runEnrichment } from "../registry/enrich.mjs";
|
|
15
|
+
import { readActiveSelection, resolveRecipePaths, resolveRuntimeRoot } from "../state/bundle-state.mjs";
|
|
16
|
+
import { dirname, resolve, join } from "node:path";
|
|
17
|
+
import { appendFileSync, existsSync } from "node:fs";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
21
|
+
|
|
22
|
+
function runtimeFile(path, root = resolveRuntimeRoot(process.cwd(), PACKAGE_ROOT)) {
|
|
23
|
+
if (existsSync(resolve(path))) return path;
|
|
24
|
+
return join(root, path);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseArgs(argv) {
|
|
28
|
+
const args = {};
|
|
29
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
30
|
+
if (argv[i].startsWith("--")) {
|
|
31
|
+
const key = argv[i].slice(2);
|
|
32
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : true;
|
|
33
|
+
args[key] = value;
|
|
34
|
+
if (value !== true) i += 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function printUsage() {
|
|
41
|
+
console.log(`usage:
|
|
42
|
+
node core/router/router-cli.mjs route --prompt "..." [--engine hybrid|keyword] [--no-dense|--dense-block]
|
|
43
|
+
node core/router/router-cli.mjs eval [--engine hybrid|keyword]
|
|
44
|
+
node core/router/router-cli.mjs compare
|
|
45
|
+
node core/router/router-cli.mjs enrich
|
|
46
|
+
node core/router/router-cli.mjs harvest-negatives
|
|
47
|
+
node core/router/router-cli.mjs graph-compile [--out path]
|
|
48
|
+
node core/router/router-cli.mjs list [--type mcp|cli|skill]
|
|
49
|
+
|
|
50
|
+
Notes:
|
|
51
|
+
route is interactive-fast by default: dense retrieval joins only if already warm.
|
|
52
|
+
use --dense-block when you explicitly want route to wait for dense retrieval.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function main() {
|
|
56
|
+
const [, , command, ...rest] = process.argv;
|
|
57
|
+
const args = parseArgs(rest);
|
|
58
|
+
if (!command || command === "--help" || command === "-h" || args.help || args.h) {
|
|
59
|
+
printUsage();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// --recipe is an explicit override (bypasses the opt-in bundle layer
|
|
63
|
+
// entirely) so existing single-recipe usage and tests are unaffected.
|
|
64
|
+
// Otherwise resolve whatever bundles the user has opted into via
|
|
65
|
+
// scripts/bundles.mjs select, defaulting to just this project's
|
|
66
|
+
// recipe.yaml when nothing has been selected (today's exact behavior).
|
|
67
|
+
const root = resolveRuntimeRoot(process.cwd(), PACKAGE_ROOT);
|
|
68
|
+
const recipePaths = args.recipe ?? resolveRecipePaths(root, readActiveSelection(root));
|
|
69
|
+
const primaryRecipe = Array.isArray(recipePaths) ? recipePaths[recipePaths.length - 1] : recipePaths;
|
|
70
|
+
const index = await loadIndex(recipePaths, { discover: !args["no-discover"] });
|
|
71
|
+
|
|
72
|
+
if (command === "route") {
|
|
73
|
+
if (!args.prompt) {
|
|
74
|
+
console.error('usage: router-cli route --prompt "..."');
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const config = loadConfig(args.config ?? runtimeFile("router.config.yaml", root));
|
|
79
|
+
const opts = {
|
|
80
|
+
threshold: args.threshold ? Number(args.threshold) : config.threshold,
|
|
81
|
+
recipeThreshold: args.recipeThreshold ? Number(args.recipeThreshold) : config.recipeThreshold,
|
|
82
|
+
hybridThreshold: args.hybridThreshold ? Number(args.hybridThreshold) : config.hybridThreshold,
|
|
83
|
+
hybridRecipeThreshold: args.hybridRecipeThreshold ? Number(args.hybridRecipeThreshold) : config.hybridRecipeThreshold,
|
|
84
|
+
hybridToolThreshold: args.hybridToolThreshold ? Number(args.hybridToolThreshold) : config.hybridToolThreshold,
|
|
85
|
+
graphPath: args.graphPath ?? config.graphPath,
|
|
86
|
+
graphBoost: args.graphBoost ? Number(args.graphBoost) : config.graphBoost,
|
|
87
|
+
suggest: args.suggest ?? "any",
|
|
88
|
+
k: args.k ? Number(args.k) : config.k,
|
|
89
|
+
peakednessRatio: args.peakednessRatio ? Number(args.peakednessRatio) : config.peakednessRatio,
|
|
90
|
+
denseEnabled: args["no-dense"] ? false : config.denseEnabled,
|
|
91
|
+
denseThreshold: args.denseThreshold ? Number(args.denseThreshold) : config.denseThreshold,
|
|
92
|
+
pushMode: config.pushMode,
|
|
93
|
+
denseBlock: args["dense-block"] ? true : false,
|
|
94
|
+
factors: combineFactors(
|
|
95
|
+
computeFactors(dirname(resolve(primaryRecipe))),
|
|
96
|
+
stackFactors(index, process.cwd()),
|
|
97
|
+
),
|
|
98
|
+
};
|
|
99
|
+
const engine = args.engine ?? config.engine;
|
|
100
|
+
const result = await runRoute(index, args.prompt, { ...opts, engine });
|
|
101
|
+
console.log(JSON.stringify(result));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (command === "eval") {
|
|
106
|
+
const config = loadConfig(args.config ?? runtimeFile("router.config.yaml", root));
|
|
107
|
+
const opts = {
|
|
108
|
+
threshold: args.threshold ? Number(args.threshold) : config.threshold,
|
|
109
|
+
recipeThreshold: args.recipeThreshold ? Number(args.recipeThreshold) : config.recipeThreshold,
|
|
110
|
+
hybridThreshold: args.hybridThreshold ? Number(args.hybridThreshold) : config.hybridThreshold,
|
|
111
|
+
hybridRecipeThreshold: args.hybridRecipeThreshold ? Number(args.hybridRecipeThreshold) : config.hybridRecipeThreshold,
|
|
112
|
+
hybridToolThreshold: args.hybridToolThreshold ? Number(args.hybridToolThreshold) : config.hybridToolThreshold,
|
|
113
|
+
graphPath: args.graphPath ?? config.graphPath,
|
|
114
|
+
graphBoost: args.graphBoost ? Number(args.graphBoost) : config.graphBoost,
|
|
115
|
+
suggest: args.suggest ?? "any",
|
|
116
|
+
k: args.k ? Number(args.k) : config.k,
|
|
117
|
+
peakednessRatio: args.peakednessRatio ? Number(args.peakednessRatio) : config.peakednessRatio,
|
|
118
|
+
denseEnabled: args["no-dense"] ? false : config.denseEnabled,
|
|
119
|
+
denseThreshold: args.denseThreshold ? Number(args.denseThreshold) : config.denseThreshold,
|
|
120
|
+
pushMode: config.pushMode,
|
|
121
|
+
};
|
|
122
|
+
console.log(
|
|
123
|
+
JSON.stringify(
|
|
124
|
+
await runRouterEval(index, opts, {
|
|
125
|
+
evalPath: args.evalPath ?? runtimeFile("docs/router-eval-set.jsonl", root),
|
|
126
|
+
engine: args.engine ?? "hybrid",
|
|
127
|
+
suggest: args.suggest ?? "any",
|
|
128
|
+
}),
|
|
129
|
+
null,
|
|
130
|
+
2,
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (command === "compare") {
|
|
137
|
+
const config = loadConfig(args.config ?? runtimeFile("router.config.yaml", root));
|
|
138
|
+
console.log(
|
|
139
|
+
JSON.stringify(
|
|
140
|
+
await runRouterEvalComparison(index, config, {
|
|
141
|
+
evalPath: args.evalPath ?? runtimeFile("docs/router-eval-set.jsonl", root),
|
|
142
|
+
suggest: args.suggest ?? "any",
|
|
143
|
+
}),
|
|
144
|
+
null,
|
|
145
|
+
2,
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (command === "enrich") {
|
|
152
|
+
// Anchored to the same recipe.yaml directory as registry.mjs's own
|
|
153
|
+
// enrichCachePathFor - init/update-time only, never inside route()'s
|
|
154
|
+
// hot path (loadIndex() re-discovers on every prompt).
|
|
155
|
+
const feedbackRoot = dirname(resolve(primaryRecipe));
|
|
156
|
+
const cachePath = args.cachePath ?? join(feedbackRoot, ".hp-state", "tool-descriptions.json");
|
|
157
|
+
const timeoutMs = args.timeoutMs ? Number(args.timeoutMs) : undefined;
|
|
158
|
+
const result = await runEnrichment({ cachePath, timeoutMs });
|
|
159
|
+
console.log(
|
|
160
|
+
JSON.stringify({
|
|
161
|
+
status: "success",
|
|
162
|
+
cachePath,
|
|
163
|
+
enrichedCount: Object.keys(result).length,
|
|
164
|
+
enrichedIds: Object.keys(result),
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (command === "harvest-negatives") {
|
|
171
|
+
const evalPath = args.evalPath ?? runtimeFile("docs/router-eval-set.jsonl", root);
|
|
172
|
+
const minIgnoredCount = args.minIgnoredCount ? Number(args.minIgnoredCount) : 2;
|
|
173
|
+
const feedbackRoot = dirname(resolve(primaryRecipe));
|
|
174
|
+
const existing = loadEvalSet(evalPath);
|
|
175
|
+
const existingPrompts = new Set(existing.map((c) => c.prompt.toLowerCase()));
|
|
176
|
+
const harvested = harvestHardNegatives(feedbackRoot, { minIgnoredCount });
|
|
177
|
+
const fresh = harvested.filter((c) => !existingPrompts.has(c.prompt.toLowerCase()));
|
|
178
|
+
if (fresh.length > 0) {
|
|
179
|
+
appendFileSync(evalPath, `${fresh.map((c) => JSON.stringify(c)).join("\n")}\n`);
|
|
180
|
+
}
|
|
181
|
+
console.log(
|
|
182
|
+
JSON.stringify({
|
|
183
|
+
status: "success",
|
|
184
|
+
harvested: harvested.length,
|
|
185
|
+
added: fresh.length,
|
|
186
|
+
skippedAsDuplicate: harvested.length - fresh.length,
|
|
187
|
+
evalPath,
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (command === "graph-compile") {
|
|
194
|
+
const graph = compileCapabilityGraph(index, {
|
|
195
|
+
maxEdgesPerNode: args.maxEdgesPerNode ? Number(args.maxEdgesPerNode) : undefined,
|
|
196
|
+
minScore: args.minScore ? Number(args.minScore) : undefined,
|
|
197
|
+
});
|
|
198
|
+
if (args.out) {
|
|
199
|
+
writeCapabilityGraph(args.out, graph);
|
|
200
|
+
console.log(JSON.stringify({ status: "success", out: args.out, edgeCount: graph.edges.length }));
|
|
201
|
+
} else {
|
|
202
|
+
console.log(JSON.stringify(graph, null, 2));
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (command === "list") {
|
|
208
|
+
console.log(JSON.stringify(listAll(index, args.type)));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.error(
|
|
213
|
+
`unknown command "${command}". Use "route", "eval", "compare", "enrich", "harvest-negatives", "graph-compile", or "list".`,
|
|
214
|
+
);
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
main();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const cli = fileURLToPath(new URL("./router-cli.mjs", import.meta.url));
|
|
10
|
+
|
|
11
|
+
test("router CLI falls back to the packaged recipe outside a managed workspace", () => {
|
|
12
|
+
const cwd = mkdtempSync(join(tmpdir(), "portawhip-cli-"));
|
|
13
|
+
try {
|
|
14
|
+
const result = spawnSync(process.execPath, [cli, "list", "--type", "mcp", "--no-discover"], {
|
|
15
|
+
cwd,
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.equal(result.status, 0, result.stderr);
|
|
20
|
+
const entries = JSON.parse(result.stdout);
|
|
21
|
+
assert.ok(entries.some((entry) => entry.id === "context7"));
|
|
22
|
+
} finally {
|
|
23
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { runRoute } from "./route-entry.mjs";
|
|
3
|
+
|
|
4
|
+
export function loadEvalSet(path = "docs/router-eval-set.jsonl") {
|
|
5
|
+
return readFileSync(path, "utf8")
|
|
6
|
+
.split(/\r?\n/)
|
|
7
|
+
.map((line) => line.trim())
|
|
8
|
+
.filter(Boolean)
|
|
9
|
+
.map((line) => JSON.parse(line));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function rankOf(results, expectedIds) {
|
|
13
|
+
const ids = new Set(expectedIds);
|
|
14
|
+
const index = results.findIndex((result) => ids.has(result.id));
|
|
15
|
+
return index === -1 ? null : index + 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function emptyKindMetrics() {
|
|
19
|
+
return { positiveCount: 0, top1Correct: 0, recall3Correct: 0, reciprocalRankSum: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function publicKindMetrics(metrics) {
|
|
23
|
+
return {
|
|
24
|
+
positiveCount: metrics.positiveCount,
|
|
25
|
+
precisionAt1: metrics.positiveCount ? metrics.top1Correct / metrics.positiveCount : 0,
|
|
26
|
+
recallAt3: metrics.positiveCount ? metrics.recall3Correct / metrics.positiveCount : 0,
|
|
27
|
+
mrr: metrics.positiveCount ? metrics.reciprocalRankSum / metrics.positiveCount : 0,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runRouterEval(index, config, { evalPath, engine = "hybrid", suggest = "any" } = {}) {
|
|
32
|
+
const cases = loadEvalSet(evalPath);
|
|
33
|
+
const routeFn = async (prompt, caseSuggest, mode) =>
|
|
34
|
+
runRoute(index, prompt, { ...config, engine, suggest: caseSuggest, mode });
|
|
35
|
+
|
|
36
|
+
const failures = [];
|
|
37
|
+
const skipped = [];
|
|
38
|
+
const availableIds = new Set((index.entries ?? []).map((entry) => entry.id));
|
|
39
|
+
let positiveCount = 0;
|
|
40
|
+
let top1Correct = 0;
|
|
41
|
+
let recall3Correct = 0;
|
|
42
|
+
let reciprocalRankSum = 0;
|
|
43
|
+
let negativeCount = 0;
|
|
44
|
+
let abstainCorrect = 0;
|
|
45
|
+
let falsePositiveCount = 0;
|
|
46
|
+
const byKind = { skill: emptyKindMetrics(), tool: emptyKindMetrics() };
|
|
47
|
+
|
|
48
|
+
for (const testCase of cases) {
|
|
49
|
+
const expectedIds = testCase.expectedAnyIds ?? [testCase.expectedTopId].filter(Boolean);
|
|
50
|
+
if (
|
|
51
|
+
testCase.shouldRoute &&
|
|
52
|
+
testCase.requiresInstalled === true &&
|
|
53
|
+
!expectedIds.some((id) => availableIds.has(id))
|
|
54
|
+
) {
|
|
55
|
+
skipped.push({ id: testCase.id, reason: "expected capability is not installed", expected: expectedIds });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const testSuggest = testCase.suggest ?? suggest;
|
|
59
|
+
const routePrompt = testCase.routePrompt ?? testCase.prompt;
|
|
60
|
+
const results = await routeFn(routePrompt, testSuggest, testCase.mode ?? "explicit");
|
|
61
|
+
const ids = results.map((result) => result.id);
|
|
62
|
+
if (testCase.shouldRoute) {
|
|
63
|
+
positiveCount += 1;
|
|
64
|
+
const kindMetrics = byKind[testCase.expectedKind] ?? null;
|
|
65
|
+
if (kindMetrics) kindMetrics.positiveCount += 1;
|
|
66
|
+
const rank = rankOf(results, testCase.expectedAnyIds ?? [testCase.expectedTopId]);
|
|
67
|
+
if (results[0]?.id === testCase.expectedTopId) {
|
|
68
|
+
top1Correct += 1;
|
|
69
|
+
if (kindMetrics) kindMetrics.top1Correct += 1;
|
|
70
|
+
}
|
|
71
|
+
if (rank !== null && rank <= 3) {
|
|
72
|
+
recall3Correct += 1;
|
|
73
|
+
if (kindMetrics) kindMetrics.recall3Correct += 1;
|
|
74
|
+
}
|
|
75
|
+
if (rank !== null) {
|
|
76
|
+
reciprocalRankSum += 1 / rank;
|
|
77
|
+
if (kindMetrics) kindMetrics.reciprocalRankSum += 1 / rank;
|
|
78
|
+
}
|
|
79
|
+
if (results[0]?.id !== testCase.expectedTopId) {
|
|
80
|
+
failures.push({
|
|
81
|
+
id: testCase.id,
|
|
82
|
+
expected: testCase.expectedTopId,
|
|
83
|
+
suggest: testSuggest,
|
|
84
|
+
mode: testCase.mode ?? "explicit",
|
|
85
|
+
got: ids,
|
|
86
|
+
prompt: testCase.prompt,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
negativeCount += 1;
|
|
91
|
+
if (results.length === 0) {
|
|
92
|
+
abstainCorrect += 1;
|
|
93
|
+
} else {
|
|
94
|
+
falsePositiveCount += 1;
|
|
95
|
+
failures.push({
|
|
96
|
+
id: testCase.id,
|
|
97
|
+
expected: "[]",
|
|
98
|
+
suggest: testSuggest,
|
|
99
|
+
mode: testCase.mode ?? "explicit",
|
|
100
|
+
got: ids,
|
|
101
|
+
prompt: testCase.prompt,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const metrics = {
|
|
108
|
+
positiveCount,
|
|
109
|
+
precisionAt1: positiveCount ? top1Correct / positiveCount : 0,
|
|
110
|
+
recallAt3: positiveCount ? recall3Correct / positiveCount : 0,
|
|
111
|
+
mrr: positiveCount ? reciprocalRankSum / positiveCount : 0,
|
|
112
|
+
negativeCount,
|
|
113
|
+
abstainAccuracy: negativeCount ? abstainCorrect / negativeCount : 0,
|
|
114
|
+
falsePositiveCount,
|
|
115
|
+
skippedCount: skipped.length,
|
|
116
|
+
byKind: {
|
|
117
|
+
skill: publicKindMetrics(byKind.skill),
|
|
118
|
+
tool: publicKindMetrics(byKind.tool),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
const pass =
|
|
122
|
+
metrics.precisionAt1 === 1 &&
|
|
123
|
+
metrics.recallAt3 === 1 &&
|
|
124
|
+
metrics.abstainAccuracy === 1 &&
|
|
125
|
+
metrics.falsePositiveCount === 0;
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
status: pass ? "success" : "warning",
|
|
129
|
+
summary: pass ? "router eval passed" : "router eval has failures",
|
|
130
|
+
engine,
|
|
131
|
+
metrics,
|
|
132
|
+
failures,
|
|
133
|
+
skipped,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function runRouterEvalComparison(index, config, { evalPath, suggest = "any" } = {}) {
|
|
138
|
+
const keyword = await runRouterEval(index, config, {
|
|
139
|
+
evalPath,
|
|
140
|
+
engine: "keyword",
|
|
141
|
+
suggest,
|
|
142
|
+
});
|
|
143
|
+
const hybrid = await runRouterEval(index, config, {
|
|
144
|
+
evalPath,
|
|
145
|
+
engine: "hybrid",
|
|
146
|
+
suggest,
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
status: hybrid.status === "success" ? "success" : "warning",
|
|
150
|
+
summary:
|
|
151
|
+
hybrid.status === "success"
|
|
152
|
+
? "hybrid router meets the eval pass bar"
|
|
153
|
+
: "hybrid router has eval failures",
|
|
154
|
+
engines: {
|
|
155
|
+
keyword,
|
|
156
|
+
hybrid,
|
|
157
|
+
},
|
|
158
|
+
delta: {
|
|
159
|
+
precisionAt1: hybrid.metrics.precisionAt1 - keyword.metrics.precisionAt1,
|
|
160
|
+
recallAt3: hybrid.metrics.recallAt3 - keyword.metrics.recallAt3,
|
|
161
|
+
mrr: hybrid.metrics.mrr - keyword.metrics.mrr,
|
|
162
|
+
abstainAccuracy: hybrid.metrics.abstainAccuracy - keyword.metrics.abstainAccuracy,
|
|
163
|
+
falsePositiveCount: hybrid.metrics.falsePositiveCount - keyword.metrics.falsePositiveCount,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { test, before } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildIndex } from "../registry/registry.mjs";
|
|
4
|
+
import { loadConfig } from "../state/config.mjs";
|
|
5
|
+
import { routeHybrid } from "./hybrid-router.mjs";
|
|
6
|
+
import { stackFactors } from "../state/stack-detect.mjs";
|
|
7
|
+
|
|
8
|
+
// Clean-state routing tests - the real router, on requested-action summaries,
|
|
9
|
+
// against the default curated recipe with no machine-local discovery, graph,
|
|
10
|
+
// or feedback state. The
|
|
11
|
+
// "hybrid: ..." tests in router.test.mjs are deliberately synthetic (a 1-2
|
|
12
|
+
// entry hand-built index + a fake extractor) to isolate mechanics - lane
|
|
13
|
+
// split, the peakedness gate, fusion math. That is exactly what makes them
|
|
14
|
+
// unable to tell you whether the router is actually *smart* on a real prompt
|
|
15
|
+
// against 300+ real skills. This file closes that gap: no fixtures, no mocks.
|
|
16
|
+
//
|
|
17
|
+
// denseEnabled:false on purpose. These assert routing DECISIONS (route vs
|
|
18
|
+
// abstain), and the decisions that matter here - the false positives found
|
|
19
|
+
// live this session - are driven by the sparse channel and reproduce without
|
|
20
|
+
// the model. Keeping dense off makes the test deterministic and offline; the
|
|
21
|
+
// dense channel's own behavior is covered by the fake-extractor fusion tests
|
|
22
|
+
// and the live e2e proof in server/mcp-server.test.mjs.
|
|
23
|
+
//
|
|
24
|
+
// Stack factors remain enabled because they are derived from repository files
|
|
25
|
+
// and are deterministic. Live discovery and learned feedback have their own
|
|
26
|
+
// focused tests and must not decide whether this suite passes on a clean CI
|
|
27
|
+
// runner.
|
|
28
|
+
|
|
29
|
+
let index;
|
|
30
|
+
let config;
|
|
31
|
+
let factors;
|
|
32
|
+
|
|
33
|
+
before(async () => {
|
|
34
|
+
index = await buildIndex("recipe.yaml", { discover: false });
|
|
35
|
+
config = { ...loadConfig(), graphPath: "missing-capability-graph.json", denseEnabled: false };
|
|
36
|
+
factors = stackFactors(index, process.cwd());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function route(prompt) {
|
|
40
|
+
return routeHybrid(index, prompt, { ...config, factors });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("clean state: a curated capability routes on a requested-action summary", async () => {
|
|
44
|
+
const result = await route("search codebase for TODO comments");
|
|
45
|
+
assert.ok(result.length > 0, "expected a routing decision, got abstain");
|
|
46
|
+
assert.ok(result.some((r) => r.id === "ripgrep"), `expected ripgrep, got ${result.map((r) => r.id).join(",")}`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("clean state: an unrelated requested action abstains", async () => {
|
|
50
|
+
const result = await route("identify the capital of France");
|
|
51
|
+
assert.deepEqual(result, [], `expected abstain, got ${result.map((r) => r.id).join(",")}`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("clean state: a router-analysis action abstains", async () => {
|
|
55
|
+
// Talking ABOUT the router is not a request to USE a capability. This one
|
|
56
|
+
// already abstains correctly today - it guards against a regression that
|
|
57
|
+
// would make the router fire on discussion of its own internals.
|
|
58
|
+
const result = await route("assess router suggestion quality");
|
|
59
|
+
assert.deepEqual(result, [], `expected abstain on meta discussion, got ${result.map((r) => r.id).join(",")}`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("clean state: a routing-policy action abstains", async () => {
|
|
63
|
+
// Probed 2026-07-07: most meta/research/design prompts already abstain -
|
|
64
|
+
// the existing peakedness gate + broad-term suppression handle them. This
|
|
65
|
+
// guards that the router doesn't regress into firing on its own design talk.
|
|
66
|
+
const result = await route("compare capability-router abstention and injection policies");
|
|
67
|
+
assert.deepEqual(result, [], `expected abstain on design discussion, got ${result.map((r) => r.id).join(",")}`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Stage-1 intent gate (fixed 2026-07-07). This prompt used to be the one
|
|
71
|
+
// deterministic false positive: saturated with capability-name vocab ("MCP",
|
|
72
|
+
// "tools", "skills"), it matched build-mcp-server/build-mcp-app purely on
|
|
73
|
+
// {mcp, tool, skill} and cleared the bar, because "mcp" wasn't yet classed as
|
|
74
|
+
// generic. Diagnosing the actual matched terms (not guessing) showed this
|
|
75
|
+
// needed no heavy semantic classifier - the overlap was entirely the
|
|
76
|
+
// capability system's OWN vocabulary. Adding mcp/cli/capability to
|
|
77
|
+
// hybrid-router.mjs's BROAD_TERMS makes weakKeywordOnly suppress it on the
|
|
78
|
+
// sparse channel; the dense channel abstains on its own (abstract-research
|
|
79
|
+
// cosine stays under denseThreshold). Verified clean on BOTH channels.
|
|
80
|
+
//
|
|
81
|
+
// (A second, separate FP axis exists but is deliberately NOT tested here
|
|
82
|
+
// because it is non-deterministic: a capability that has been used before can
|
|
83
|
+
// get boosted by computeFactors' feedback signal onto a tangential prompt -
|
|
84
|
+
// found live, but it depends on feedback-log state, so it can't be a stable
|
|
85
|
+
// committed assertion.)
|
|
86
|
+
test("clean state: a capability-vocab-saturated research action abstains (intent gate)", async () => {
|
|
87
|
+
const result = await route("research MCP availability for dynamic agent capabilities");
|
|
88
|
+
assert.deepEqual(result, [], `expected abstain on capability-domain research, got ${result.map((r) => r.id).join(",")}`);
|
|
89
|
+
});
|