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