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,838 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildIndex } from "../registry/registry.mjs";
|
|
4
|
+
import { compileCapabilityGraph } from "../registry/capability-graph-compiler.mjs";
|
|
5
|
+
import { route, listAll, scoreEntry } from "./scorer.mjs";
|
|
6
|
+
import { buildCapabilityDocs } from "../registry/capability-docs.mjs";
|
|
7
|
+
import { routeHybrid } from "./hybrid-router.mjs";
|
|
8
|
+
import {
|
|
9
|
+
_setPipelineForTest,
|
|
10
|
+
_forceUnavailableForTest,
|
|
11
|
+
_setPipelinePendingForTest,
|
|
12
|
+
} from "./dense-embedder.mjs";
|
|
13
|
+
import { explainRoute, runRoute } from "./route-entry.mjs";
|
|
14
|
+
import { triggerCoverageEvidence } from "./intent-evidence.mjs";
|
|
15
|
+
import { loadConfig } from "../state/config.mjs";
|
|
16
|
+
import { CONNECTOR_TARGETS, targetsForHost } from "../surface/connector-targets.mjs";
|
|
17
|
+
import { HOOK_TARGETS, hookTargetForHost } from "../surface/hook-targets.mjs";
|
|
18
|
+
import { blockForVariant, upsertBlock } from "../../adapters/instructions/generate.mjs";
|
|
19
|
+
import { runRouterEval } from "./router-eval.mjs";
|
|
20
|
+
import { installEntries } from "../../scripts/load.mjs";
|
|
21
|
+
import { discoverAgents, discoverCommands, discoverSkillsFromDirs } from "../registry/discover.mjs";
|
|
22
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
// denseEnabled:false - these tests must stay fast, deterministic, and
|
|
27
|
+
// offline; real dense retrieval (core/dense-embedder.mjs) needs network and
|
|
28
|
+
// a 500MB+ model load on first use. Dense fusion itself gets its own test
|
|
29
|
+
// with an injected fake pipeline (see "hybrid: dense-only" below).
|
|
30
|
+
const CONFIG = { ...loadConfig(), denseEnabled: false };
|
|
31
|
+
|
|
32
|
+
test("intent evidence: declared trigger coverage stays soft when a negative and positive share the same signature", () => {
|
|
33
|
+
const topicalNegative = triggerCoverageEvidence(
|
|
34
|
+
["import tool"],
|
|
35
|
+
"how should a router avoid token bloat when many tools are installed",
|
|
36
|
+
{ source: "declared" },
|
|
37
|
+
);
|
|
38
|
+
const genuinePositive = triggerCoverageEvidence(["sdk usage"], "use the sdk", { source: "declared" });
|
|
39
|
+
|
|
40
|
+
assert.equal(topicalNegative.coverage, 0.5);
|
|
41
|
+
assert.equal(genuinePositive.coverage, 0.5);
|
|
42
|
+
assert.equal(topicalNegative.strength, "partial");
|
|
43
|
+
assert.equal(genuinePositive.strength, "partial");
|
|
44
|
+
assert.equal(topicalNegative.source, "declared");
|
|
45
|
+
assert.equal(topicalNegative.method, "token_overlap");
|
|
46
|
+
assert.equal(topicalNegative.advisoryOnly, true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("config: push is silent by default and legacy rollback must be explicit", () => {
|
|
50
|
+
const root = tempRoot("harness-router-push-mode-");
|
|
51
|
+
try {
|
|
52
|
+
const missing = loadConfig(join(root, "missing.yaml"));
|
|
53
|
+
assert.equal(missing.pushMode, "silent");
|
|
54
|
+
|
|
55
|
+
const path = join(root, "router.config.yaml");
|
|
56
|
+
writeFileSync(path, "pushMode: legacy\n");
|
|
57
|
+
assert.equal(loadConfig(path).pushMode, "legacy");
|
|
58
|
+
|
|
59
|
+
writeFileSync(path, "pushMode: noisy-guess\n");
|
|
60
|
+
assert.equal(loadConfig(path).pushMode, "silent");
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(root, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function tempRoot(prefix = "harness-router-") {
|
|
67
|
+
return mkdtempSync(join(tmpdir(), prefix));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Curated-only (discover: false) tests exercise recipe.yaml + scorer logic
|
|
71
|
+
// in isolation — deterministic, no dependency on what happens to be
|
|
72
|
+
// installed on the machine running the test.
|
|
73
|
+
|
|
74
|
+
test("trigger hit: exact word match scores 1", async () => {
|
|
75
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
76
|
+
const entry = index.entries.find((e) => e.id === "ripgrep");
|
|
77
|
+
assert.equal(scoreEntry(entry, "help me grep this repo"), 1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("phrase hit: multi-word trigger matches as a phrase", async () => {
|
|
81
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
82
|
+
const entry = index.entries.find((e) => e.id === "pdf");
|
|
83
|
+
assert.equal(scoreEntry(entry, "please merge pages of this file"), 1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("abstain: unrelated prompt returns []", async () => {
|
|
87
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
88
|
+
const result = route(index, "design a database schema for orders", CONFIG);
|
|
89
|
+
assert.deepEqual(result, []);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("route: relevant prompt returns the matching entry, not others", async () => {
|
|
93
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
94
|
+
const result = route(index, "how do I extract a table from this pdf", CONFIG);
|
|
95
|
+
assert.ok(result.length >= 1);
|
|
96
|
+
assert.equal(result[0].id, "pdf");
|
|
97
|
+
assert.equal(result[0].tier, "required");
|
|
98
|
+
assert.equal(result[0].action, "read_skill");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("explainRoute: returns actionable results plus structured route metadata", async () => {
|
|
102
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
103
|
+
const engineResults = await routeHybrid(index, "how do I extract a table from this pdf", {
|
|
104
|
+
...CONFIG,
|
|
105
|
+
includeWeak: true,
|
|
106
|
+
});
|
|
107
|
+
const result = await explainRoute(index, "how do I extract a table from this pdf", CONFIG);
|
|
108
|
+
assert.equal(result.status, "success");
|
|
109
|
+
assert.equal(result.results[0].id, "pdf");
|
|
110
|
+
assert.equal(result.results[0].tier, "required");
|
|
111
|
+
assert.equal(result.negative_evidence, null);
|
|
112
|
+
assert.ok(Number.isInteger(result.latency_ms));
|
|
113
|
+
// Stage 4: decision/near_misses/reason are additive aliases of
|
|
114
|
+
// status/suppressed/negative_evidence.reason, in the literal vocabulary
|
|
115
|
+
// ("route"/"abstain") the decision-layer redesign asked for - never a
|
|
116
|
+
// breaking rename of the original fields (this server is published).
|
|
117
|
+
assert.equal(result.decision, "route");
|
|
118
|
+
assert.deepEqual(result.near_misses, result.suppressed);
|
|
119
|
+
assert.equal(result.reason, null);
|
|
120
|
+
for (const annotated of [...result.results, ...result.suppressed]) {
|
|
121
|
+
const expected = engineResults.find((item) => item.id === annotated.id);
|
|
122
|
+
const { intentEvidence, ...unchanged } = annotated;
|
|
123
|
+
assert.deepEqual(unchanged, expected, `delivery evidence changed engine output for ${annotated.id}`);
|
|
124
|
+
}
|
|
125
|
+
assert.equal(result.results[0].intentEvidence.source, "declared");
|
|
126
|
+
assert.equal(result.results[0].intentEvidence.advisoryOnly, true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("runRoute: advisory intent evidence preserves keyword-engine output", async () => {
|
|
130
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
131
|
+
const prompt = "how do I extract a table from this pdf";
|
|
132
|
+
const expected = route(index, prompt, CONFIG);
|
|
133
|
+
const annotated = await runRoute(index, prompt, { ...CONFIG, engine: "keyword" });
|
|
134
|
+
|
|
135
|
+
assert.equal(annotated.length, expected.length);
|
|
136
|
+
for (let i = 0; i < annotated.length; i += 1) {
|
|
137
|
+
const { intentEvidence, ...unchanged } = annotated[i];
|
|
138
|
+
assert.deepEqual(unchanged, expected[i]);
|
|
139
|
+
assert.equal(intentEvidence.advisoryOnly, true);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("runRoute: silent push abstains without changing the hybrid engine candidate", async () => {
|
|
144
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
145
|
+
const prompt = "how do I extract a table from this pdf";
|
|
146
|
+
const engineCandidates = await routeHybrid(index, prompt, CONFIG);
|
|
147
|
+
assert.equal(engineCandidates[0]?.id, "pdf", "characterization: engine must still retrieve pdf");
|
|
148
|
+
|
|
149
|
+
const delivered = await runRoute(index, prompt, {
|
|
150
|
+
...CONFIG,
|
|
151
|
+
engine: "hybrid",
|
|
152
|
+
mode: "push",
|
|
153
|
+
pushMode: "silent",
|
|
154
|
+
});
|
|
155
|
+
assert.deepEqual(delivered, []);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("router eval: mode-aware cases exercise delivery policy instead of the engine directly", async () => {
|
|
159
|
+
const root = tempRoot("harness-router-mode-eval-");
|
|
160
|
+
try {
|
|
161
|
+
const evalPath = join(root, "eval.jsonl");
|
|
162
|
+
writeFileSync(
|
|
163
|
+
evalPath,
|
|
164
|
+
[
|
|
165
|
+
{
|
|
166
|
+
id: "raw-push-pdf",
|
|
167
|
+
prompt: "how do I extract a table from this pdf",
|
|
168
|
+
mode: "push",
|
|
169
|
+
shouldRoute: false,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: "explicit-pdf",
|
|
173
|
+
prompt: "how do I extract a table from this pdf",
|
|
174
|
+
mode: "explicit",
|
|
175
|
+
shouldRoute: true,
|
|
176
|
+
expectedTopId: "pdf",
|
|
177
|
+
expectedAnyIds: ["pdf"],
|
|
178
|
+
expectedKind: "skill",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: "optional-missing-tool",
|
|
182
|
+
prompt: "use a tool that is not installed in this fixture",
|
|
183
|
+
mode: "explicit",
|
|
184
|
+
shouldRoute: true,
|
|
185
|
+
expectedTopId: "not-installed-tool",
|
|
186
|
+
expectedAnyIds: ["not-installed-tool"],
|
|
187
|
+
expectedKind: "tool",
|
|
188
|
+
requiresInstalled: true,
|
|
189
|
+
},
|
|
190
|
+
].map((row) => JSON.stringify(row)).join("\n") + "\n",
|
|
191
|
+
);
|
|
192
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
193
|
+
const report = await runRouterEval(index, { ...CONFIG, pushMode: "silent" }, { evalPath });
|
|
194
|
+
assert.equal(report.status, "success");
|
|
195
|
+
assert.equal(report.metrics.falsePositiveCount, 0);
|
|
196
|
+
assert.equal(report.metrics.skippedCount, 1);
|
|
197
|
+
} finally {
|
|
198
|
+
rmSync(root, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("explainRoute: empty result includes negative evidence", async () => {
|
|
203
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
204
|
+
const result = await explainRoute(index, "what's the capital of France", CONFIG);
|
|
205
|
+
assert.equal(result.status, "empty");
|
|
206
|
+
assert.deepEqual(result.results, []);
|
|
207
|
+
assert.equal(result.negative_evidence.result, "empty");
|
|
208
|
+
assert.match(result.negative_evidence.reason, /threshold|weak|keyword/i);
|
|
209
|
+
assert.equal(result.decision, "abstain");
|
|
210
|
+
assert.deepEqual(result.near_misses, result.suppressed);
|
|
211
|
+
assert.equal(result.reason, result.negative_evidence.reason);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("curated aliases: harness audit routes to workspace surface audit", async () => {
|
|
215
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
216
|
+
const result = await explainRoute(index, "audit connector hook find bugs in harness config", CONFIG);
|
|
217
|
+
assert.equal(result.results[0].id, "workspace-surface-audit");
|
|
218
|
+
assert.equal(result.results[0].action, "read_skill");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("curated aliases: fix-test-commit routes to review and verification", async () => {
|
|
222
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
223
|
+
const result = await explainRoute(index, "fix bug run tests and commit after code review", CONFIG);
|
|
224
|
+
const ids = result.results.map((hit) => hit.id);
|
|
225
|
+
assert.equal(ids[0], "code-review");
|
|
226
|
+
assert.ok(ids.includes("verification-loop"));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("curated aliases: settings hook repair routes to configure-ecc", async () => {
|
|
230
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
231
|
+
const result = await explainRoute(index, "fix settings.json hook for Claude Code config", CONFIG);
|
|
232
|
+
assert.equal(result.results[0].id, "configure-ecc");
|
|
233
|
+
assert.equal(result.results[0].action, "read_skill");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("route-only entries are skipped by the loader", () => {
|
|
237
|
+
const result = installEntries([{ id: "alias-only", type: "skill", source: "missing", install: false }], {
|
|
238
|
+
mcpHosts: ["codex"],
|
|
239
|
+
skillHosts: ["codex"],
|
|
240
|
+
});
|
|
241
|
+
assert.deepEqual(result, [{ id: "alias-only", ok: true, skipped: true }]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("discovery: filesystem scan includes nested Claude plugin cache skills", () => {
|
|
245
|
+
const root = tempRoot();
|
|
246
|
+
try {
|
|
247
|
+
const skillDir = join(root, ".claude", "plugins", "cache", "ecc", "ecc", "2.0.0", ".agents", "skills", "nested-skill");
|
|
248
|
+
mkdirSync(skillDir, { recursive: true });
|
|
249
|
+
writeFileSync(
|
|
250
|
+
join(skillDir, "SKILL.md"),
|
|
251
|
+
'---\nname: nested-plugin-skill\ndescription: Skill from Claude plugin cache\n---\n# Nested\n',
|
|
252
|
+
);
|
|
253
|
+
const skills = discoverSkillsFromDirs([join(root, ".claude", "plugins", "cache")]);
|
|
254
|
+
assert.deepEqual(skills, [
|
|
255
|
+
{
|
|
256
|
+
name: "nested-plugin-skill",
|
|
257
|
+
description: "Skill from Claude plugin cache",
|
|
258
|
+
path: skillDir,
|
|
259
|
+
},
|
|
260
|
+
]);
|
|
261
|
+
} finally {
|
|
262
|
+
rmSync(root, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("discovery: filesystem scan includes nested plugin commands", () => {
|
|
267
|
+
const root = tempRoot();
|
|
268
|
+
try {
|
|
269
|
+
const commandDir = join(root, ".claude", "plugins", "cache", "ecc", "ecc", "2.0.0", "commands");
|
|
270
|
+
mkdirSync(commandDir, { recursive: true });
|
|
271
|
+
const commandPath = join(commandDir, "harness-audit.md");
|
|
272
|
+
writeFileSync(commandPath, "---\ndescription: Run a deterministic harness audit.\n---\n# Harness Audit\n");
|
|
273
|
+
const commands = discoverCommands([join(root, ".claude", "plugins", "cache")]);
|
|
274
|
+
assert.deepEqual(commands.map((command) => ({
|
|
275
|
+
id: command.id,
|
|
276
|
+
type: command.type,
|
|
277
|
+
path: command.path,
|
|
278
|
+
kindTrigger: command.route.triggers.includes("/harness-audit"),
|
|
279
|
+
})), [
|
|
280
|
+
{
|
|
281
|
+
id: "harness-audit",
|
|
282
|
+
type: "command",
|
|
283
|
+
path: commandPath,
|
|
284
|
+
kindTrigger: true,
|
|
285
|
+
},
|
|
286
|
+
]);
|
|
287
|
+
} finally {
|
|
288
|
+
rmSync(root, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("discovery: filesystem scan includes nested plugin agents", () => {
|
|
293
|
+
const root = tempRoot();
|
|
294
|
+
try {
|
|
295
|
+
const agentDir = join(root, ".claude", "plugins", "cache", "ecc", "ecc", "2.0.0", "agents");
|
|
296
|
+
mkdirSync(agentDir, { recursive: true });
|
|
297
|
+
const agentPath = join(agentDir, "harness-optimizer.md");
|
|
298
|
+
writeFileSync(
|
|
299
|
+
agentPath,
|
|
300
|
+
"---\nname: harness-optimizer\ndescription: Analyze and improve local agent harness configuration.\n---\n# Agent\n",
|
|
301
|
+
);
|
|
302
|
+
const agents = discoverAgents([join(root, ".claude", "plugins", "cache")]);
|
|
303
|
+
assert.deepEqual(agents.map((agent) => ({
|
|
304
|
+
id: agent.id,
|
|
305
|
+
type: agent.type,
|
|
306
|
+
path: agent.path,
|
|
307
|
+
description: agent.route.description,
|
|
308
|
+
})), [
|
|
309
|
+
{
|
|
310
|
+
id: "harness-optimizer",
|
|
311
|
+
type: "agent",
|
|
312
|
+
path: agentPath,
|
|
313
|
+
description: "Analyze and improve local agent harness configuration.",
|
|
314
|
+
},
|
|
315
|
+
]);
|
|
316
|
+
} finally {
|
|
317
|
+
rmSync(root, { recursive: true, force: true });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("malformed route block is rejected", async () => {
|
|
322
|
+
const badRecipe = "core/fixtures/bad-recipe.yaml";
|
|
323
|
+
await assert.rejects(() => buildIndex(badRecipe, { discover: false }));
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("list: filters by type", async () => {
|
|
327
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
328
|
+
const skills = listAll(index, "skill");
|
|
329
|
+
assert.ok(skills.every((e) => e.type === "skill"));
|
|
330
|
+
assert.ok(skills.some((e) => e.id === "pdf"));
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("budget: how_to_use string stays short (hint, not full content)", async () => {
|
|
334
|
+
const index = await buildIndex("recipe.yaml", { discover: false });
|
|
335
|
+
const result = route(index, "grep for a regex in the codebase", CONFIG);
|
|
336
|
+
for (const r of result) {
|
|
337
|
+
assert.ok(r.how_to_use.length < 300, `${r.id} how_to_use exceeds budget`);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("hybrid: hyphenated ids match natural spaced phrasing", async () => {
|
|
342
|
+
const index = {
|
|
343
|
+
entries: [
|
|
344
|
+
{
|
|
345
|
+
id: "database-migrations",
|
|
346
|
+
type: "skill",
|
|
347
|
+
origin: "auto:skill",
|
|
348
|
+
path: "/skills/database-migrations",
|
|
349
|
+
route: {
|
|
350
|
+
triggers: ["database-migrations"],
|
|
351
|
+
description: "Database migration best practices for zero-downtime deployments",
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
};
|
|
356
|
+
const result = await routeHybrid(index, "walk me through a zero-downtime database migration", {
|
|
357
|
+
hybridThreshold: 2,
|
|
358
|
+
k: 5,
|
|
359
|
+
denseEnabled: false,
|
|
360
|
+
});
|
|
361
|
+
assert.equal(result[0].id, "database-migrations");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("hybrid: token sequence matching does not match inside longer words", async () => {
|
|
365
|
+
const index = {
|
|
366
|
+
entries: [
|
|
367
|
+
{
|
|
368
|
+
id: "x-api",
|
|
369
|
+
type: "skill",
|
|
370
|
+
origin: "auto:skill",
|
|
371
|
+
path: "/skills/x-api",
|
|
372
|
+
route: {
|
|
373
|
+
triggers: ["x-api"],
|
|
374
|
+
description: "X API integration",
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
};
|
|
379
|
+
const result = await routeHybrid(index, "what's the capital of France", {
|
|
380
|
+
hybridThreshold: 2,
|
|
381
|
+
k: 5,
|
|
382
|
+
denseEnabled: false,
|
|
383
|
+
});
|
|
384
|
+
assert.deepEqual(result, []);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("hybrid: broad vocabulary matches are suppressed as keyword-only noise", async () => {
|
|
388
|
+
const index = {
|
|
389
|
+
entries: [
|
|
390
|
+
{
|
|
391
|
+
id: "vue-patterns",
|
|
392
|
+
type: "skill",
|
|
393
|
+
origin: "auto:skill",
|
|
394
|
+
path: "/skills/vue-patterns",
|
|
395
|
+
route: {
|
|
396
|
+
triggers: ["vue-patterns"],
|
|
397
|
+
description: "Vue.js component architecture patterns",
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
};
|
|
402
|
+
const result = await routeHybrid(index, "router architecture", {
|
|
403
|
+
hybridThreshold: 0.01,
|
|
404
|
+
includeWeak: true,
|
|
405
|
+
k: 5,
|
|
406
|
+
denseEnabled: false,
|
|
407
|
+
});
|
|
408
|
+
assert.equal(result[0].tier, "irrelevant_but_keyword_matched");
|
|
409
|
+
assert.equal(result[0].action, "ignore_by_default");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("hybrid: suggest filters split skills from tools", async () => {
|
|
413
|
+
const index = {
|
|
414
|
+
entries: [
|
|
415
|
+
{
|
|
416
|
+
id: "pdf-skill",
|
|
417
|
+
type: "skill",
|
|
418
|
+
origin: "auto:skill",
|
|
419
|
+
path: "/skills/pdf-skill",
|
|
420
|
+
route: {
|
|
421
|
+
triggers: ["pdf"],
|
|
422
|
+
description: "PDF skill",
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
id: "pdf-tool",
|
|
427
|
+
type: "mcp",
|
|
428
|
+
origin: "auto:mcp",
|
|
429
|
+
source: "pdf-tool",
|
|
430
|
+
route: {
|
|
431
|
+
triggers: ["pdf"],
|
|
432
|
+
description: "PDF tool",
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
};
|
|
437
|
+
// threshold near 0: this fixture's only purpose is testing suggest-kind
|
|
438
|
+
// filtering, not score calibration — with just 2 docs sharing "pdf" as
|
|
439
|
+
// their only trigger, idf (and thus score) is naturally tiny.
|
|
440
|
+
const skills = await routeHybrid(index, "pdf", { hybridThreshold: 0.01, suggest: "skill", k: 5, denseEnabled: false });
|
|
441
|
+
const tools = await routeHybrid(index, "pdf", { hybridThreshold: 0.01, suggest: "tool", k: 5, denseEnabled: false });
|
|
442
|
+
assert.ok(skills.every((result) => result.kind === "skill"));
|
|
443
|
+
assert.ok(tools.every((result) => result.kind === "tool"));
|
|
444
|
+
assert.equal(skills[0].id, "pdf-skill");
|
|
445
|
+
assert.equal(tools[0].id, "pdf-tool");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("hybrid: tool and skill lanes don't crowd each other out of a shared k", async () => {
|
|
449
|
+
// Same fixture as the suggest-filter test above, but called the way real
|
|
450
|
+
// callers actually do (push hook / MCP route tool / router-cli route all
|
|
451
|
+
// default to suggest:"any", never "skill" or "tool" alone). A single
|
|
452
|
+
// shared slice-to-k treats pdf-skill and pdf-tool as competitors for the
|
|
453
|
+
// same k slots even though a task usually wants both side by side (the
|
|
454
|
+
// tool to do it, the skill for how to do it well) — k:1 here makes that
|
|
455
|
+
// crowding impossible to miss if the fix regresses.
|
|
456
|
+
const index = {
|
|
457
|
+
entries: [
|
|
458
|
+
{
|
|
459
|
+
id: "pdf-skill",
|
|
460
|
+
type: "skill",
|
|
461
|
+
origin: "auto:skill",
|
|
462
|
+
path: "/skills/pdf-skill",
|
|
463
|
+
route: {
|
|
464
|
+
triggers: ["pdf"],
|
|
465
|
+
description: "PDF skill",
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
id: "pdf-tool",
|
|
470
|
+
type: "mcp",
|
|
471
|
+
origin: "auto:mcp",
|
|
472
|
+
source: "pdf-tool",
|
|
473
|
+
route: {
|
|
474
|
+
triggers: ["pdf"],
|
|
475
|
+
description: "PDF tool",
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
};
|
|
480
|
+
const result = await routeHybrid(index, "pdf", { hybridThreshold: 0.01, k: 1, denseEnabled: false });
|
|
481
|
+
assert.ok(result.some((r) => r.id === "pdf-skill"), "skill lane must not be crowded out");
|
|
482
|
+
assert.ok(result.some((r) => r.id === "pdf-tool"), "tool lane must not be crowded out");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("hybrid: a lane where the top match barely beats the runner-up is silenced as diffuse noise", async () => {
|
|
486
|
+
// Two docs sharing identical generic triggers score exactly tied (ratio
|
|
487
|
+
// 1.0) — the same shape as the real remaining false positive (example-skill
|
|
488
|
+
// vs skill-development both lit up by "router"/"skill"/"injecting").
|
|
489
|
+
const index = {
|
|
490
|
+
entries: [
|
|
491
|
+
{
|
|
492
|
+
id: "skill-a",
|
|
493
|
+
type: "skill",
|
|
494
|
+
origin: "auto:skill",
|
|
495
|
+
path: "/skills/skill-a",
|
|
496
|
+
route: { triggers: ["architecture", "pattern"], description: "Skill A" },
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
id: "skill-b",
|
|
500
|
+
type: "skill",
|
|
501
|
+
origin: "auto:skill",
|
|
502
|
+
path: "/skills/skill-b",
|
|
503
|
+
route: { triggers: ["architecture", "pattern"], description: "Skill B" },
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
};
|
|
507
|
+
const result = await routeHybrid(index, "architecture pattern", { hybridThreshold: 0.01, k: 5, denseEnabled: false });
|
|
508
|
+
assert.equal(result.length, 0, "near-tied scores in a lane should be silenced, not guessed");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("hybrid: a single dominant match in a lane still fires even with no competing runner-up", async () => {
|
|
512
|
+
const index = {
|
|
513
|
+
entries: [
|
|
514
|
+
{
|
|
515
|
+
id: "pdf-skill",
|
|
516
|
+
type: "skill",
|
|
517
|
+
origin: "auto:skill",
|
|
518
|
+
path: "/skills/pdf-skill",
|
|
519
|
+
route: { triggers: ["pdf"], description: "PDF skill" },
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
};
|
|
523
|
+
const result = await routeHybrid(index, "pdf", { hybridThreshold: 0.01, k: 5, denseEnabled: false });
|
|
524
|
+
assert.ok(result.some((r) => r.id === "pdf-skill"));
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Fake extractor: keyed by exact input text so vectors (and thus cosine
|
|
528
|
+
// similarity) are fully controlled, no real model/network involved. Query
|
|
529
|
+
// and doc text share zero real vocabulary on purpose - the whole point is
|
|
530
|
+
// proving dense rescues a case sparse structurally cannot reach at all
|
|
531
|
+
// (sparse.length === 0), the same shape as the real e2e-testing miss
|
|
532
|
+
// (docs/router-eval-set.jsonl) that motivated adding this channel.
|
|
533
|
+
function fakeExtractor(vectors) {
|
|
534
|
+
return async (text) => ({ data: vectors[text] ?? [0, 0, 1] });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
test("hybrid: dense channel rescues a paraphrase with zero shared vocabulary", async () => {
|
|
538
|
+
const index = {
|
|
539
|
+
entries: [
|
|
540
|
+
{
|
|
541
|
+
id: "wibble-skill",
|
|
542
|
+
type: "skill",
|
|
543
|
+
origin: "auto:skill",
|
|
544
|
+
path: "/skills/wibble-skill",
|
|
545
|
+
route: { triggers: ["wibble"], description: "wibble wobble gadzooks" },
|
|
546
|
+
},
|
|
547
|
+
],
|
|
548
|
+
};
|
|
549
|
+
_setPipelineForTest(
|
|
550
|
+
fakeExtractor({
|
|
551
|
+
"xyzzy quux frobnicate": [1, 0, 0],
|
|
552
|
+
"wibble-skill wibble wibble wobble gadzooks": [0.99, 0.1411, 0],
|
|
553
|
+
}),
|
|
554
|
+
);
|
|
555
|
+
try {
|
|
556
|
+
const result = await routeHybrid(index, "xyzzy quux frobnicate", {
|
|
557
|
+
hybridThreshold: 350,
|
|
558
|
+
k: 5,
|
|
559
|
+
denseEnabled: true,
|
|
560
|
+
denseThreshold: 0.5,
|
|
561
|
+
});
|
|
562
|
+
assert.ok(result.some((r) => r.id === "wibble-skill"), "dense channel should surface a pure paraphrase miss");
|
|
563
|
+
} finally {
|
|
564
|
+
_forceUnavailableForTest();
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("hybrid: dense channel degrades to sparse-only behavior when the model is unavailable", async () => {
|
|
569
|
+
const index = {
|
|
570
|
+
entries: [
|
|
571
|
+
{
|
|
572
|
+
id: "wibble-skill",
|
|
573
|
+
type: "skill",
|
|
574
|
+
origin: "auto:skill",
|
|
575
|
+
path: "/skills/wibble-skill",
|
|
576
|
+
route: { triggers: ["wibble"], description: "wibble wobble gadzooks" },
|
|
577
|
+
},
|
|
578
|
+
],
|
|
579
|
+
};
|
|
580
|
+
_forceUnavailableForTest();
|
|
581
|
+
const result = await routeHybrid(index, "xyzzy quux frobnicate", {
|
|
582
|
+
hybridThreshold: 350,
|
|
583
|
+
k: 5,
|
|
584
|
+
denseEnabled: true,
|
|
585
|
+
denseThreshold: 0.5,
|
|
586
|
+
});
|
|
587
|
+
assert.deepEqual(result, [], "no lexical match + unavailable dense = same silent abstain as sparse-only");
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("hybrid: denseBlock:false returns sparse-only while the model is still warming, then dense once ready", async () => {
|
|
591
|
+
const index = {
|
|
592
|
+
entries: [
|
|
593
|
+
{
|
|
594
|
+
id: "wibble-skill",
|
|
595
|
+
type: "skill",
|
|
596
|
+
origin: "auto:skill",
|
|
597
|
+
path: "/skills/wibble-skill",
|
|
598
|
+
route: { triggers: ["wibble"], description: "wibble wobble gadzooks" },
|
|
599
|
+
},
|
|
600
|
+
],
|
|
601
|
+
};
|
|
602
|
+
const opts = { hybridThreshold: 350, k: 5, denseEnabled: true, denseThreshold: 0.5, denseBlock: false };
|
|
603
|
+
|
|
604
|
+
// Model still loading in the background: a non-blocking caller (the MCP
|
|
605
|
+
// server's tier) must NOT await it - it gets sparse-only this call. With no
|
|
606
|
+
// lexical match either, that means a silent abstain, never a 73s hang.
|
|
607
|
+
_setPipelinePendingForTest();
|
|
608
|
+
const whileWarming = await routeHybrid(index, "xyzzy quux frobnicate", opts);
|
|
609
|
+
assert.deepEqual(whileWarming, [], "non-blocking call must not wait on a cold model load");
|
|
610
|
+
|
|
611
|
+
// Once warm, the same non-blocking call picks dense up with no code change.
|
|
612
|
+
_setPipelineForTest(
|
|
613
|
+
fakeExtractor({
|
|
614
|
+
"xyzzy quux frobnicate": [1, 0, 0],
|
|
615
|
+
"wibble-skill wibble wibble wobble gadzooks": [0.99, 0.1411, 0],
|
|
616
|
+
}),
|
|
617
|
+
);
|
|
618
|
+
try {
|
|
619
|
+
const warmed = await routeHybrid(index, "xyzzy quux frobnicate", opts);
|
|
620
|
+
assert.ok(warmed.some((r) => r.id === "wibble-skill"), "dense joins in once the model is ready");
|
|
621
|
+
} finally {
|
|
622
|
+
_forceUnavailableForTest();
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("hybrid: capability docs enrich skills from SKILL metadata without bloating results", async () => {
|
|
627
|
+
const index = {
|
|
628
|
+
entries: [
|
|
629
|
+
{
|
|
630
|
+
id: "viewport-audit",
|
|
631
|
+
type: "skill",
|
|
632
|
+
origin: "auto:skill",
|
|
633
|
+
path: "core/fixtures/skill-with-metadata",
|
|
634
|
+
route: {
|
|
635
|
+
triggers: ["viewport-audit"],
|
|
636
|
+
description: "Viewport inspection",
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
],
|
|
640
|
+
};
|
|
641
|
+
const docs = buildCapabilityDocs(index);
|
|
642
|
+
assert.match(docs[0].text, /mobile toolbar wrapping/);
|
|
643
|
+
assert.match(docs[0].text, /responsive viewport failures/);
|
|
644
|
+
|
|
645
|
+
const result = await routeHybrid(index, "mobile toolbar wrapping layout check", {
|
|
646
|
+
hybridThreshold: 2,
|
|
647
|
+
suggest: "skill",
|
|
648
|
+
k: 5,
|
|
649
|
+
denseEnabled: false,
|
|
650
|
+
});
|
|
651
|
+
assert.equal(result[0].id, "viewport-audit");
|
|
652
|
+
assert.equal(result[0].how_to_use, "Viewport inspection");
|
|
653
|
+
assert.ok(result[0].how_to_use.length < 300);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("hybrid graph: expands only from seeded candidates", async () => {
|
|
657
|
+
const index = {
|
|
658
|
+
entries: [
|
|
659
|
+
{
|
|
660
|
+
id: "database-migrations",
|
|
661
|
+
type: "skill",
|
|
662
|
+
origin: "auto:skill",
|
|
663
|
+
path: "/skills/database-migrations",
|
|
664
|
+
route: {
|
|
665
|
+
triggers: ["database-migrations"],
|
|
666
|
+
description: "Database migration best practices",
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
id: "postgres-patterns",
|
|
671
|
+
type: "skill",
|
|
672
|
+
origin: "auto:skill",
|
|
673
|
+
path: "/skills/postgres-patterns",
|
|
674
|
+
route: {
|
|
675
|
+
triggers: ["postgres-patterns"],
|
|
676
|
+
description: "PostgreSQL schema design",
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
],
|
|
680
|
+
};
|
|
681
|
+
const result = await routeHybrid(index, "database migration rollout", {
|
|
682
|
+
graphPath: "core/fixtures/capability-graph.json",
|
|
683
|
+
graphBoost: 0.5,
|
|
684
|
+
hybridThreshold: 2,
|
|
685
|
+
k: 5,
|
|
686
|
+
denseEnabled: false,
|
|
687
|
+
});
|
|
688
|
+
assert.equal(result[0].id, "database-migrations");
|
|
689
|
+
assert.ok(result.some((r) => r.id === "postgres-patterns" && r.graphBoosted));
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("graph compiler: links related skills and tools from capability docs", () => {
|
|
693
|
+
const index = {
|
|
694
|
+
entries: [
|
|
695
|
+
{
|
|
696
|
+
id: "e2e-testing",
|
|
697
|
+
type: "skill",
|
|
698
|
+
origin: "auto:skill",
|
|
699
|
+
path: null,
|
|
700
|
+
route: {
|
|
701
|
+
triggers: ["e2e-testing", "playwright"],
|
|
702
|
+
description: "Playwright E2E testing patterns",
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
id: "playwright",
|
|
707
|
+
type: "mcp",
|
|
708
|
+
origin: "auto:mcp",
|
|
709
|
+
source: "@playwright/mcp",
|
|
710
|
+
route: {
|
|
711
|
+
triggers: ["playwright"],
|
|
712
|
+
description: "MCP server: playwright",
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
],
|
|
716
|
+
};
|
|
717
|
+
const graph = compileCapabilityGraph(index, { minScore: 2, maxEdgesPerNode: 2 });
|
|
718
|
+
assert.ok(
|
|
719
|
+
graph.edges.some(
|
|
720
|
+
(edge) =>
|
|
721
|
+
edge.from === "e2e-testing" &&
|
|
722
|
+
edge.to === "playwright" &&
|
|
723
|
+
edge.type === "skill_uses_tool",
|
|
724
|
+
),
|
|
725
|
+
);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("hybrid graph: abstains when retrieval has no seed", async () => {
|
|
729
|
+
const index = {
|
|
730
|
+
entries: [
|
|
731
|
+
{
|
|
732
|
+
id: "database-migrations",
|
|
733
|
+
type: "skill",
|
|
734
|
+
origin: "auto:skill",
|
|
735
|
+
path: "/skills/database-migrations",
|
|
736
|
+
route: {
|
|
737
|
+
triggers: ["database-migrations"],
|
|
738
|
+
description: "Database migration best practices",
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
id: "postgres-patterns",
|
|
743
|
+
type: "skill",
|
|
744
|
+
origin: "auto:skill",
|
|
745
|
+
path: "/skills/postgres-patterns",
|
|
746
|
+
route: {
|
|
747
|
+
triggers: ["postgres-patterns"],
|
|
748
|
+
description: "PostgreSQL schema design",
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
],
|
|
752
|
+
};
|
|
753
|
+
const result = await routeHybrid(index, "write a poem", {
|
|
754
|
+
graphPath: "core/fixtures/capability-graph.json",
|
|
755
|
+
graphBoost: 1,
|
|
756
|
+
hybridThreshold: 2,
|
|
757
|
+
k: 5,
|
|
758
|
+
denseEnabled: false,
|
|
759
|
+
});
|
|
760
|
+
assert.deepEqual(result, []);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// Discovery (discover: true) tests hit the real machine's installed
|
|
764
|
+
// tools/skills/servers — assertions stay loose (structure + precedence),
|
|
765
|
+
// not tied to exact counts, since installed content varies by machine.
|
|
766
|
+
|
|
767
|
+
test("discovery: curated entry wins over its auto-discovered twin", async () => {
|
|
768
|
+
const index = await buildIndex("recipe.yaml", { discover: true });
|
|
769
|
+
const context7Entries = index.entries.filter((e) => e.id === "context7");
|
|
770
|
+
assert.equal(context7Entries.length, 1, "no duplicate id after merge");
|
|
771
|
+
assert.equal(context7Entries[0].origin, "recipe");
|
|
772
|
+
assert.equal(
|
|
773
|
+
context7Entries[0].route.description,
|
|
774
|
+
"Fetch current docs/examples for a library, framework, or API",
|
|
775
|
+
);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("discovery: auto-discovered entries carry an origin tag", async () => {
|
|
779
|
+
const index = await buildIndex("recipe.yaml", { discover: true });
|
|
780
|
+
const auto = index.entries.filter((e) => e.origin !== "recipe");
|
|
781
|
+
for (const e of auto) {
|
|
782
|
+
assert.ok(e.origin.startsWith("auto:"), `${e.id} missing auto: origin tag`);
|
|
783
|
+
assert.ok(Array.isArray(e.route.triggers) && e.route.triggers.length > 0);
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test("connectors: every instruction target has a renderable harness block", () => {
|
|
788
|
+
for (const [hostId, config] of Object.entries(CONNECTOR_TARGETS)) {
|
|
789
|
+
assert.ok(config.instructionTargets.length > 0, `${hostId} has no instruction targets`);
|
|
790
|
+
for (const target of config.instructionTargets) {
|
|
791
|
+
const block = blockForVariant(target.variant);
|
|
792
|
+
assert.match(block, /harness-router:start/);
|
|
793
|
+
assert.match(block, /route\(task summary\)/);
|
|
794
|
+
assert.match(block, /requested action/i, `${hostId} route instruction must ask for a reasoned action summary`);
|
|
795
|
+
assert.match(block, /not (?:repeat|copy) the raw (?:prompt|topic)/i);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test("connectors: relinking replaces an old route block and remains idempotent", () => {
|
|
801
|
+
const root = tempRoot("harness-router-instruction-upgrade-");
|
|
802
|
+
try {
|
|
803
|
+
const path = join(root, "AGENTS.md");
|
|
804
|
+
writeFileSync(
|
|
805
|
+
path,
|
|
806
|
+
"<!-- harness-router:start -->\nBefore starting, call route(task summary).\n<!-- harness-router:end -->\n",
|
|
807
|
+
);
|
|
808
|
+
assert.equal(upsertBlock(path, blockForVariant("generic")), true);
|
|
809
|
+
const upgraded = readFileSync(path, "utf8");
|
|
810
|
+
assert.match(upgraded, /positively requested action/i);
|
|
811
|
+
assert.equal((upgraded.match(/harness-router:start/g) ?? []).length, 1);
|
|
812
|
+
assert.equal(upsertBlock(path, blockForVariant("generic")), false);
|
|
813
|
+
} finally {
|
|
814
|
+
rmSync(root, { recursive: true, force: true });
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("connectors: scope filter returns only requested targets", () => {
|
|
819
|
+
assert.ok(targetsForHost("codex", { scope: "global" }).every((target) => target.scope === "global"));
|
|
820
|
+
assert.ok(targetsForHost("codex", { scope: "project" }).every((target) => target.scope === "project"));
|
|
821
|
+
assert.deepEqual(targetsForHost("claude-desktop", { scope: "project" }), []);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
test("hooks: native targets map logical events to host events", () => {
|
|
825
|
+
assert.equal(HOOK_TARGETS["claude-code"].events.user_prompt, "UserPromptSubmit");
|
|
826
|
+
assert.equal(HOOK_TARGETS.codex.events.post_tool, "PostToolUse");
|
|
827
|
+
assert.equal(HOOK_TARGETS["gemini-cli"].events.user_prompt, "BeforeAgent");
|
|
828
|
+
assert.equal(HOOK_TARGETS["gemini-cli"].events.post_tool, "AfterTool");
|
|
829
|
+
assert.equal(hookTargetForHost("cursor", { scope: "project" }), null);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("hooks: scoped targets resolve project and global paths", () => {
|
|
833
|
+
const project = hookTargetForHost("codex", { scope: "project" });
|
|
834
|
+
const global = hookTargetForHost("codex", { scope: "global" });
|
|
835
|
+
assert.match(project.path.replace(/\\/g, "/"), /\.codex\/hooks\.json$/);
|
|
836
|
+
assert.match(global.path.replace(/\\/g, "/"), /\.codex\/hooks\.json$/);
|
|
837
|
+
assert.notEqual(project.path, global.path);
|
|
838
|
+
});
|