Skip to content

Commit a3d6f6a

Browse files
committed
feat(hooks): skill router as an opt-in, bounded, evidenced adapter
Split out of affaan-m#2788 per review. The router now: - is off unless ECC_SKILL_ROUTER=1 (or CLAUDE_PLUGIN_OPTION_SKILL_ROUTER) is set, on top of the normal hook profile controls; - routes on-demand skills to paths inside the carrier (on-demand/<id>/SKILL.md from the receipt catalog) and never to a source tree; receipt rows whose path leaves skills/ or on-demand/ are dropped; - suppresses output when routing exceeds ECC_SKILL_ROUTER_BUDGET_MS (default 150 ms) so a cold scan cannot delay prompt submission; - ships an evaluation: scripts/ci/skill-router-eval.js over tests/fixtures/skill-router/prompts.json (52 labelled prompts) reports precision@3 0.962, recall@3 0.962, warm p50 2.9 ms, cold 70 ms on the commit that introduces it; docs/SKILL-ROUTER.md records the numbers and their caveats. Tests: tests/lib/skill-router.test.js 13/13, tests/hooks/skill-router.test.js 9/9.
1 parent f2f673d commit a3d6f6a

7 files changed

Lines changed: 1010 additions & 0 deletions

File tree

docs/SKILL-ROUTER.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Skill Router (opt-in)
2+
3+
A `UserPromptSubmit` hook that scores each prompt against the skill catalog
4+
with offline token matching and injects up to three matching skills as
5+
context for the turn. Installed skills are suggested directly; skills a
6+
generated profile carrier holds on demand are suggested with their path
7+
inside the plugin (`on-demand/<skill>/SKILL.md`). Nothing outside the plugin
8+
is ever referenced.
9+
10+
The router changes what the model sees on every matching prompt, so it is a
11+
separate behavioral feature from profile carriers and is **off by default**.
12+
13+
## Enabling
14+
15+
```bash
16+
# Environment (highest precedence)
17+
export ECC_SKILL_ROUTER=1
18+
19+
# Or the plugin option
20+
CLAUDE_PLUGIN_OPTION_SKILL_ROUTER=1
21+
```
22+
23+
Hook id: `user-prompt:skill-router`. It respects the usual hook controls
24+
(`ECC_HOOKS_ENABLED`, `ECC_HOOK_PROFILE` via its `standard,strict` profile
25+
list, `ECC_DISABLED_HOOKS`) in addition to the explicit opt-in above. Without
26+
the opt-in the hook runs and emits nothing.
27+
28+
## Bounds
29+
30+
- Prompts shorter than 12 characters, slash commands, and `!` commands are
31+
never routed.
32+
- Routing that takes longer than `ECC_SKILL_ROUTER_BUDGET_MS` (default 150)
33+
emits nothing and logs the overrun to stderr, so a cold catalog scan can
34+
never delay prompt submission by more than the budget.
35+
- Output is at most a header plus three bullets. Catalog text is flattened
36+
to one line with control bytes removed before it reaches the model, so a
37+
crafted description cannot forge additional bullets or terminal escapes.
38+
- Receipt catalog rows are accepted only when their path is inside
39+
`skills/` or `on-demand/`; anything else is dropped before scoring.
40+
- The catalog cache lives under `~/.claude/cache` (override:
41+
`ECC_SKILL_ROUTER_CACHE_DIR`), is written with mode 0600 through an
42+
exclusive temp file and atomic rename, and never follows a planted symlink.
43+
- Through `run-with-flags.js`, a disabled, dry-run, or missing
44+
UserPromptSubmit hook emits empty stdout rather than echoing the raw
45+
payload into context.
46+
47+
## Evidence
48+
49+
`scripts/ci/skill-router-eval.js` scores the router against
50+
`tests/fixtures/skill-router/prompts.json` (52 labelled prompts across
51+
frontend, backend, data, mobile, infra, security, research, and homelab
52+
skill families) and measures latency. A prompt is a hit when any expected
53+
skill appears in the routed top-3.
54+
55+
Measured on the commit that introduced this file (Node v24, Windows 11,
56+
286-skill catalog):
57+
58+
```text
59+
precision@3: 0.962 recall@3: 0.962 (hits 50, routed 52)
60+
latency warm p50/p95: 2.88ms / 3.61ms; cold (3 runs): 70, 70, 70ms
61+
miss: "migrate this component from react to vue" expected ui-to-vue|vue-patterns got react-native-patterns, react-patterns, react-testing
62+
miss: "write pytest tests for the payment module" expected python-testing got agent-payment-x402, django-tdd, fastapi-patterns
63+
```
64+
65+
Caveats, stated plainly: the fixture was written by the router's author, so
66+
it is a regression fixture rather than an independent benchmark; "pytest"
67+
does not tokenize to "python", and prompts that name two ecosystems rank the
68+
one with more skills. Re-run with:
69+
70+
```bash
71+
node scripts/ci/skill-router-eval.js # human-readable
72+
node scripts/ci/skill-router-eval.js --json # machine-readable
73+
node scripts/ci/skill-router-eval.js --min-precision 0.9 --min-recall 0.9 # gate
74+
```
75+
76+
## Scoring
77+
78+
Tokens are lowercase alphanumeric runs of three or more characters minus a
79+
small stopword list; long plurals also contribute their singular. A skill-id
80+
token match scores 3, a description token match scores 1; a skill needs a
81+
score of at least 3 to be suggested; ties break alphabetically so output is
82+
deterministic.

scripts/ci/skill-router-eval.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Skill-router evaluation: precision/recall at top-3 and latency.
4+
*
5+
* Usage:
6+
* node scripts/ci/skill-router-eval.js [--fixture tests/fixtures/skill-router/prompts.json] [--json] [--min-precision 0.5] [--min-recall 0.5]
7+
*
8+
* Each fixture entry is { prompt, expected: [skillId, ...] }. A prompt counts
9+
* as a hit when at least one expected skill appears in the routed top-3.
10+
* Precision@3 is hits over prompts that produced any routing; recall@3 is
11+
* hits over all prompts. Latency is measured cold (fresh process, empty
12+
* cache) and warm (in-process repeat).
13+
*/
14+
15+
'use strict';
16+
17+
const fs = require('fs');
18+
const os = require('os');
19+
const path = require('path');
20+
const { spawnSync } = require('child_process');
21+
22+
const repoRoot = path.resolve(__dirname, '..', '..');
23+
const args = process.argv.slice(2);
24+
const flag = (name, fallback) => {
25+
const index = args.indexOf(`--${name}`);
26+
return index === -1 ? fallback : args[index + 1];
27+
};
28+
const fixturePath = path.resolve(repoRoot, flag('fixture', 'tests/fixtures/skill-router/prompts.json'));
29+
const asJson = args.includes('--json');
30+
const minPrecision = Number(flag('min-precision', '0'));
31+
const minRecall = Number(flag('min-recall', '0'));
32+
33+
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-router-eval-cache-'));
34+
process.env.ECC_SKILL_ROUTER_CACHE_DIR = cacheDir;
35+
36+
const { routePrompt } = require('../lib/skill-router');
37+
38+
function percentile(values, p) {
39+
if (values.length === 0) return 0;
40+
const sorted = [...values].sort((a, b) => a - b);
41+
return sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
42+
}
43+
44+
function coldLatencyMs() {
45+
// Fresh process + empty cache: the first prompt pays the full catalog scan.
46+
const coldCache = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-router-eval-cold-'));
47+
const script = `const t=Date.now();require(${JSON.stringify(path.join(repoRoot, 'scripts', 'lib', 'skill-router.js'))}).routePrompt('apply react patterns when refactoring this component',{pluginRoot:${JSON.stringify(repoRoot)}});process.stdout.write(String(Date.now()-t));`;
48+
const result = spawnSync(process.execPath, ['-e', script], {
49+
encoding: 'utf8',
50+
env: { ...process.env, ECC_SKILL_ROUTER_CACHE_DIR: coldCache },
51+
});
52+
fs.rmSync(coldCache, { recursive: true, force: true });
53+
return Number(result.stdout) || 0;
54+
}
55+
56+
function main() {
57+
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
58+
const prompts = fixture.prompts || fixture;
59+
const misses = [];
60+
const warmLatencies = [];
61+
let routed = 0;
62+
let hits = 0;
63+
64+
// Prime the cache once so warm numbers are warm.
65+
routePrompt('warm up the catalog cache please', { pluginRoot: repoRoot });
66+
67+
for (const entry of prompts) {
68+
const startedAt = process.hrtime.bigint();
69+
const matches = routePrompt(entry.prompt, { pluginRoot: repoRoot });
70+
warmLatencies.push(Number(process.hrtime.bigint() - startedAt) / 1e6);
71+
const ids = matches.map(m => m.id);
72+
if (ids.length > 0) routed += 1;
73+
if (entry.expected.some(id => ids.includes(id))) {
74+
hits += 1;
75+
} else {
76+
misses.push({ prompt: entry.prompt, expected: entry.expected, got: ids });
77+
}
78+
}
79+
80+
const coldRuns = [coldLatencyMs(), coldLatencyMs(), coldLatencyMs()];
81+
const report = {
82+
fixture: path.relative(repoRoot, fixturePath).split(path.sep).join('/'),
83+
prompts: prompts.length,
84+
routed,
85+
hits,
86+
precisionAt3: routed === 0 ? 0 : Number((hits / routed).toFixed(3)),
87+
recallAt3: prompts.length === 0 ? 0 : Number((hits / prompts.length).toFixed(3)),
88+
latencyMs: {
89+
warmP50: Number(percentile(warmLatencies, 0.5).toFixed(2)),
90+
warmP95: Number(percentile(warmLatencies, 0.95).toFixed(2)),
91+
coldRuns,
92+
coldMax: Math.max(...coldRuns),
93+
},
94+
misses,
95+
node: process.version,
96+
platform: `${os.platform()} ${os.arch()}`,
97+
};
98+
99+
fs.rmSync(cacheDir, { recursive: true, force: true });
100+
101+
if (asJson) {
102+
console.log(JSON.stringify(report, null, 2));
103+
} else {
104+
console.log(`Skill router eval - ${report.prompts} prompts from ${report.fixture}`);
105+
console.log(` precision@3: ${report.precisionAt3} recall@3: ${report.recallAt3} (hits ${hits}, routed ${routed})`);
106+
console.log(` latency warm p50/p95: ${report.latencyMs.warmP50}ms / ${report.latencyMs.warmP95}ms; cold (3 runs): ${coldRuns.join(', ')}ms`);
107+
for (const miss of misses) {
108+
console.log(` miss: "${miss.prompt}" expected ${miss.expected.join('|')} got ${miss.got.join(', ') || '(none)'}`);
109+
}
110+
}
111+
112+
if (report.precisionAt3 < minPrecision || report.recallAt3 < minRecall) {
113+
console.error(`skill-router-eval: below threshold (precision ${report.precisionAt3} < ${minPrecision} or recall ${report.recallAt3} < ${minRecall})`);
114+
process.exitCode = 1;
115+
}
116+
}
117+
118+
main();

scripts/hooks/skill-router.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Skill router hook (UserPromptSubmit) - OPT-IN.
4+
*
5+
* Scores the submitted prompt against the skill catalog (offline token
6+
* matching via scripts/lib/skill-router.js) and, when skills clearly match,
7+
* emits a short routing note on stdout - which Claude Code injects as
8+
* context for the turn. Installed skills are suggested directly; skills a
9+
* generated carrier holds on demand are suggested with their path inside the
10+
* plugin.
11+
*
12+
* The router injects text into every matching turn, so it is off unless
13+
* explicitly enabled: ECC_SKILL_ROUTER=1 (or the plugin option
14+
* CLAUDE_PLUGIN_OPTION_SKILL_ROUTER=1). It is also bounded: if routing takes
15+
* longer than ECC_SKILL_ROUTER_BUDGET_MS (default 150), it emits nothing.
16+
*
17+
* Exit code 0 always; empty stdout means "no routing opinion".
18+
*/
19+
20+
'use strict';
21+
22+
const path = require('path');
23+
const { routePrompt } = require('../lib/skill-router');
24+
25+
const MAX_STDIN = 1024 * 1024;
26+
const MIN_PROMPT_LENGTH = 12;
27+
const MAX_DESCRIPTION_CHARS = 120;
28+
const DEFAULT_BUDGET_MS = 150;
29+
30+
function isEnabled(env = process.env) {
31+
const raw = env.ECC_SKILL_ROUTER !== undefined ? env.ECC_SKILL_ROUTER : env.CLAUDE_PLUGIN_OPTION_SKILL_ROUTER;
32+
return ['1', 'true', 'yes', 'on'].includes(String(raw || '').trim().toLowerCase());
33+
}
34+
35+
function budgetMs(env = process.env) {
36+
const raw = Number(env.ECC_SKILL_ROUTER_BUDGET_MS);
37+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_BUDGET_MS;
38+
}
39+
40+
/**
41+
* Flatten untrusted catalog text to a single safe line: collapse newlines
42+
* and whitespace and drop C0/C1 control bytes so a description can never
43+
* forge extra routing bullets or terminal escapes.
44+
*/
45+
function sanitizeLine(text) {
46+
// eslint-disable-next-line no-control-regex
47+
return String(text || '').replace(/[\u0000-\u001F\u007F-\u009F]+/g, ' ').replace(/\s+/g, ' ').trim();
48+
}
49+
50+
function buildMessage(matches) {
51+
const lines = ['[SkillRouter] Skills matching this prompt - use them if relevant:'];
52+
for (const match of matches) {
53+
const id = sanitizeLine(match.id);
54+
const description = sanitizeLine(match.description);
55+
const summary = description.length > MAX_DESCRIPTION_CHARS
56+
? `${description.slice(0, MAX_DESCRIPTION_CHARS - 3)}...`
57+
: description;
58+
if (match.installed) {
59+
lines.push(`- ${id} (installed): ${summary}`);
60+
} else {
61+
lines.push(`- ${id} (on demand, read ${sanitizeLine(match.path)} inside this plugin): ${summary}`);
62+
}
63+
}
64+
return `${lines.join('\n')}\n`;
65+
}
66+
67+
/**
68+
* Exportable run() for in-process execution via run-with-flags.js.
69+
* Always returns an explicit stdout key: for UserPromptSubmit, stdout is
70+
* injected as context, so the raw-input echo fallback must never trigger.
71+
*/
72+
function run(inputOrRaw, options = {}) {
73+
const env = options.env || process.env;
74+
if (!isEnabled(env)) {
75+
return { exitCode: 0, stdout: '' };
76+
}
77+
78+
let input;
79+
try {
80+
input = typeof inputOrRaw === 'string'
81+
? (inputOrRaw.trim() ? JSON.parse(inputOrRaw) : {})
82+
: (inputOrRaw || {});
83+
} catch {
84+
return { exitCode: 0, stdout: '' };
85+
}
86+
87+
const prompt = String(input.prompt || '').trim();
88+
if (prompt.length < MIN_PROMPT_LENGTH || prompt.startsWith('/') || prompt.startsWith('!')) {
89+
return { exitCode: 0, stdout: '' };
90+
}
91+
92+
const pluginRoot = options.pluginRoot
93+
|| env.CLAUDE_PLUGIN_ROOT
94+
|| path.resolve(__dirname, '..', '..');
95+
96+
const startedAt = Date.now();
97+
try {
98+
const matches = routePrompt(prompt, { pluginRoot });
99+
const elapsedMs = Date.now() - startedAt;
100+
if (elapsedMs > budgetMs(env)) {
101+
return { exitCode: 0, stdout: '', stderr: `[SkillRouter] routing took ${elapsedMs}ms, over the ${budgetMs(env)}ms budget; suppressed` };
102+
}
103+
if (matches.length === 0) {
104+
return { exitCode: 0, stdout: '' };
105+
}
106+
return { exitCode: 0, stdout: buildMessage(matches) };
107+
} catch (error) {
108+
return { exitCode: 0, stdout: '', stderr: `[SkillRouter] ${error.message}` };
109+
}
110+
}
111+
112+
function main() {
113+
let data = '';
114+
process.stdin.setEncoding('utf8');
115+
process.stdin.on('data', chunk => {
116+
if (data.length < MAX_STDIN) {
117+
data += chunk.substring(0, MAX_STDIN - data.length);
118+
}
119+
});
120+
process.stdin.on('end', () => {
121+
const result = run(data);
122+
if (result.stderr) {
123+
process.stderr.write(`${result.stderr}\n`);
124+
}
125+
process.stdout.write(result.stdout || '');
126+
});
127+
}
128+
129+
module.exports = { run, main, isEnabled };
130+
131+
if (require.main === module) {
132+
main();
133+
}

0 commit comments

Comments
 (0)