skillgauntlet 1.0.0 → 1.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/LICENSE +201 -0
- package/LICENSE-DATA +438 -0
- package/README.md +238 -0
- package/bin/skillgauntlet.js +485 -0
- package/contracts/diagnosing-bugs.json +37 -0
- package/contracts/tdd.json +33 -0
- package/contracts/webapp-testing.json +38 -0
- package/contracts/xlsx.json +33 -0
- package/lib/codewhale.js +174 -0
- package/lib/codex.js +318 -0
- package/lib/depth.js +55 -0
- package/lib/discover.js +83 -0
- package/lib/execute.js +153 -0
- package/lib/gain.js +177 -0
- package/lib/i18n.js +754 -0
- package/lib/installed.js +43 -0
- package/lib/queries.js +37 -0
- package/lib/report.js +145 -0
- package/lib/runs.js +167 -0
- package/lib/spawn.js +84 -0
- package/lib/static.js +187 -0
- package/lib/trial.js +413 -0
- package/lib/ui.js +156 -0
- package/package.json +40 -6
- package/index.js +0 -1
package/lib/trial.js
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// Layer 1: trigger rate. Ported from step0/trigger_test.py (runner v0.3.2) so the numbers stay
|
|
2
|
+
// comparable with the Python runner and with Anthropic's skill-creator protocol.
|
|
3
|
+
//
|
|
4
|
+
// Verdict rule (headline, maxExplore 0): the first tool decision alone decides.
|
|
5
|
+
// Skill with a matching name, or Read of the skill's SKILL.md -> triggered
|
|
6
|
+
// Skill naming another skill -> other_skill (stolen)
|
|
7
|
+
// any other tool -> not_triggered, killed at once
|
|
8
|
+
// answers without calling any tool -> not_triggered, counted apart
|
|
9
|
+
//
|
|
10
|
+
// NOT YET VERIFIED against a live host: written from the Python runner, exercised only in dry-run.
|
|
11
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { launchSpec } from './spawn.js';
|
|
16
|
+
import { runCodewhaleTrial, draftWithCodewhale } from './codewhale.js';
|
|
17
|
+
|
|
18
|
+
const READ_ONLY = new Set(['Glob', 'Grep', 'Read', 'LS']);
|
|
19
|
+
|
|
20
|
+
// Hosts whose stream carries the same `Skill` tool_use as Claude Code. CodeBuddy Code (the CLI behind
|
|
21
|
+
// WorkBuddy) was checked against a live run: an `assistant` message with a tool_use named `Skill` and
|
|
22
|
+
// `input.skill`. Its default tool list is 60+ entries (image generation, chat replies, computer use), so
|
|
23
|
+
// a trial restricts it to the coding set Claude Code offers by default.
|
|
24
|
+
export const HOSTS = {
|
|
25
|
+
'claude-code': { bin: 'claude', skillsDir: ['.claude', 'skills'] },
|
|
26
|
+
codewhale: { bin: 'codewhale' },
|
|
27
|
+
codebuddy: { bin: 'codebuddy', skillsDir: ['.codebuddy', 'skills'], tools: 'Skill,Read,Bash,Write,Edit,Glob,Grep' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// What `backend` means per host: Claude Code can be pointed at another API; CodeBuddy is always its own account.
|
|
31
|
+
export function backendFor(host, model) {
|
|
32
|
+
if (host === 'codewhale') return 'codewhale:api.deepseek.com';
|
|
33
|
+
if (host !== 'codebuddy') return backendHost();
|
|
34
|
+
return isDeepseekModel(model) ? 'codebuddy:api.deepseek.com' : 'codebuddy';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A model id starting with "deepseek" means the DeepSeek API, whichever host drives it.
|
|
38
|
+
export const isDeepseekModel = (model) => /^deepseek/i.test(String(model || ''));
|
|
39
|
+
|
|
40
|
+
// DeepSeek's integration guide for WorkBuddy/CodeBuddy: a models.json entry whose key is read from the
|
|
41
|
+
// environment (${DEEPSEEK_API_KEY}), so the file holds no secret. It is written into the trial's own
|
|
42
|
+
// directory (project level), never into the user's ~/.codebuddy.
|
|
43
|
+
export function codebuddyModelsJson(model) {
|
|
44
|
+
return `${JSON.stringify({
|
|
45
|
+
models: [{
|
|
46
|
+
id: model,
|
|
47
|
+
name: model,
|
|
48
|
+
vendor: 'DeepSeek',
|
|
49
|
+
url: 'https://api.deepseek.com/v1/chat/completions',
|
|
50
|
+
apiKey: '${DEEPSEEK_API_KEY}',
|
|
51
|
+
maxInputTokens: 128000,
|
|
52
|
+
maxOutputTokens: 8192,
|
|
53
|
+
supportsToolCall: true,
|
|
54
|
+
supportsImages: false,
|
|
55
|
+
}],
|
|
56
|
+
availableModels: [model],
|
|
57
|
+
}, null, 2)}
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeModelsJson(dir, model) {
|
|
62
|
+
fs.mkdirSync(path.join(dir, '.codebuddy'), { recursive: true });
|
|
63
|
+
fs.writeFileSync(path.join(dir, '.codebuddy', 'models.json'), codebuddyModelsJson(model));
|
|
64
|
+
}
|
|
65
|
+
const LINE_BREAK = /\r?\n/;
|
|
66
|
+
|
|
67
|
+
// On Windows `where` returns the extension-less shim first (a shell script Node cannot exec), then the
|
|
68
|
+
// .cmd that it can -- with shell: true. Prefer a real executable, then a cmd shim, then whatever is left.
|
|
69
|
+
export function resolveBin(name) {
|
|
70
|
+
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
71
|
+
let found = [];
|
|
72
|
+
try {
|
|
73
|
+
found = execFileSync(finder, [name], { encoding: 'utf8' }).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
74
|
+
} catch {
|
|
75
|
+
return name;
|
|
76
|
+
}
|
|
77
|
+
return found.find((p) => /\.exe$/i.test(p))
|
|
78
|
+
|| found.find((p) => /\.(cmd|bat)$/i.test(p))
|
|
79
|
+
|| found[0]
|
|
80
|
+
|| name;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Variables a host (the Claude desktop app, the Agent SDK, an outer Claude Code session) sets for its own
|
|
84
|
+
// child processes. A trial inherits them otherwise, and they change what the model under test sees: under
|
|
85
|
+
// the desktop app the CLI adds Artifact, ArtifactComments and ArtifactData to the tool list, and the model
|
|
86
|
+
// then picks Artifact first on page-building requests. Auth and platform settings a user sets on purpose
|
|
87
|
+
// are kept. Mirrors HOST_ENV_KEEP / HOST_ENV_DROP in step0/trigger_test.py.
|
|
88
|
+
export const HOST_ENV_KEEP = new Set(['CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX',
|
|
89
|
+
'CLAUDE_CODE_GIT_BASH_PATH', 'CLAUDE_CODE_MAX_OUTPUT_TOKENS']);
|
|
90
|
+
export const HOST_ENV_DROP = new Set(['CLAUDECODE', 'CLAUDE_PID', 'CLAUDE_EFFORT', 'CLAUDE_AGENT_SDK_VERSION',
|
|
91
|
+
'CLAUDE_AGENT_SDK_MCP_NO_PREFIX', 'CLAUDE_PREVIEW_CLASSIFIER_FLOOR', 'MCP_SERVER_CONNECTION_BATCH_SIZE',
|
|
92
|
+
'MCP_CONNECTION_NONBLOCKING']);
|
|
93
|
+
|
|
94
|
+
// The environment for a trial: the given one, minus what a host injected for its own children.
|
|
95
|
+
export function trialEnv(env = process.env) {
|
|
96
|
+
const out = {};
|
|
97
|
+
for (const [k, v] of Object.entries(env)) {
|
|
98
|
+
if (HOST_ENV_KEEP.has(k) || !(k.startsWith('CLAUDE_CODE_') || HOST_ENV_DROP.has(k))) out[k] = v;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function backendHost() {
|
|
104
|
+
const url = process.env.ANTHROPIC_BASE_URL;
|
|
105
|
+
if (!url) return 'anthropic';
|
|
106
|
+
try { return new URL(url.includes('://') ? url : `https://${url}`).host; } catch { return url; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function backend() {
|
|
110
|
+
return backendHost();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// DeepSeek-style gateways remap Anthropic model names instead of rejecting them.
|
|
114
|
+
export function checkModel(model, allowRemap = false) {
|
|
115
|
+
const host = backendHost();
|
|
116
|
+
if (host === 'anthropic' || !model || allowRemap) return;
|
|
117
|
+
if (/(haiku|sonnet|opus|claude)/i.test(model)) {
|
|
118
|
+
const err = new Error(`${host} remaps Anthropic model names, so "${model}" would test a different model`);
|
|
119
|
+
err.messageKey = 'modelRemap';
|
|
120
|
+
err.vars = { host, model };
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function copyDir(src, dst) {
|
|
126
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
127
|
+
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
|
128
|
+
if (e.name === '.git') continue;
|
|
129
|
+
const s = path.join(src, e.name);
|
|
130
|
+
const d = path.join(dst, e.name);
|
|
131
|
+
if (e.isDirectory()) copyDir(s, d);
|
|
132
|
+
else if (e.isFile()) fs.copyFileSync(s, d);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function killTree(child) {
|
|
137
|
+
if (!child || child.killed) return;
|
|
138
|
+
try {
|
|
139
|
+
if (process.platform === 'win32') execFileSync('taskkill', ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore' });
|
|
140
|
+
else child.kill('SIGKILL');
|
|
141
|
+
} catch { /* already gone */ }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function runTrial({ bin, skillDir, skillName, query, model, timeout = 180, maxExplore = 0, host = 'claude-code' }) {
|
|
145
|
+
if (host === 'codewhale') return runCodewhaleTrial({ bin, skillDir, skillName, query, model, timeout });
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-'));
|
|
148
|
+
const spec = HOSTS[host];
|
|
149
|
+
copyDir(skillDir, path.join(tmp, ...spec.skillsDir, skillName));
|
|
150
|
+
if (host === 'codebuddy' && isDeepseekModel(model)) writeModelsJson(tmp, model);
|
|
151
|
+
const args = host === 'codebuddy'
|
|
152
|
+
? ['-p', query, '--output-format', 'stream-json', '--verbose', '--setting-sources', 'project', '--strict-mcp-config',
|
|
153
|
+
'--no-session-persistence', '--tools', spec.tools]
|
|
154
|
+
: ['-p', query, '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
|
|
155
|
+
'--permission-mode', 'dontAsk', '--setting-sources', 'project', '--strict-mcp-config', '--no-session-persistence'];
|
|
156
|
+
if (maxExplore > 0) {
|
|
157
|
+
args.push('--allowedTools', ...(host === 'codebuddy' ? ['Read', 'Glob', 'Grep'] : ['Read(./**)', 'Glob(./**)', 'Grep(./**)', 'LS(./**)']));
|
|
158
|
+
}
|
|
159
|
+
if (model) args.push('--model', model);
|
|
160
|
+
|
|
161
|
+
// The host is recorded with every trial, not just the run: without it a record cannot be told
|
|
162
|
+
// apart from a Codex one, and the two mean different things (a Codex record has no trigger).
|
|
163
|
+
const rec = { host, verdict: null, invokedSkill: null, firstTool: null, tools: [], exploreSteps: 0, error: null,
|
|
164
|
+
events: { lines: 0, toolUse: 0, types: {} } };
|
|
165
|
+
// Not through a shell: cmd.exe would re-split the prompt on spaces (see spawn.js).
|
|
166
|
+
const launch = launchSpec(bin, args, spec.bin);
|
|
167
|
+
const child = spawn(launch.cmd, launch.args, {
|
|
168
|
+
cwd: tmp,
|
|
169
|
+
shell: launch.shell,
|
|
170
|
+
env: trialEnv(),
|
|
171
|
+
windowsHide: true,
|
|
172
|
+
stdio: ['ignore', 'pipe', 'pipe'], // a prompt is on the command line; stdin must look closed
|
|
173
|
+
});
|
|
174
|
+
let timedOut = false;
|
|
175
|
+
const timer = setTimeout(() => { timedOut = true; killTree(child); }, timeout * 1000);
|
|
176
|
+
let stderr = '';
|
|
177
|
+
child.stderr.setEncoding('utf8');
|
|
178
|
+
child.stderr.on('data', (c) => { if (stderr.length < 4000) stderr += c; });
|
|
179
|
+
let buf = '';
|
|
180
|
+
let settled = false;
|
|
181
|
+
const finish = (verdict, extra = {}) => {
|
|
182
|
+
if (settled) return;
|
|
183
|
+
settled = true;
|
|
184
|
+
rec.verdict = verdict;
|
|
185
|
+
Object.assign(rec, extra);
|
|
186
|
+
killTree(child);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
child.stdout.setEncoding('utf8');
|
|
190
|
+
child.stdout.on('data', (chunk) => {
|
|
191
|
+
buf += chunk;
|
|
192
|
+
let nl;
|
|
193
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
194
|
+
const line = buf.slice(0, nl).trim();
|
|
195
|
+
buf = buf.slice(nl + 1);
|
|
196
|
+
if (!line) continue;
|
|
197
|
+
let ev;
|
|
198
|
+
try { ev = JSON.parse(line); } catch { continue; }
|
|
199
|
+
rec.events.lines += 1;
|
|
200
|
+
rec.events.types[ev.type] = (rec.events.types[ev.type] || 0) + 1;
|
|
201
|
+
const type = ev.type;
|
|
202
|
+
if (type === 'system' && ev.subtype === 'init') {
|
|
203
|
+
rec.modelResolved = ev.model;
|
|
204
|
+
rec.apiKeySource = ev.apiKeySource;
|
|
205
|
+
// Lifted off the stored row by the executor: it becomes run-level metadata (what the model was offered).
|
|
206
|
+
rec.init = { model: ev.model, apiKeySource: ev.apiKeySource, claudeCodeVersion: ev.claude_code_version,
|
|
207
|
+
skills: ev.skills || [], tools: ev.tools || [] };
|
|
208
|
+
} else if (type === 'stream_event') {
|
|
209
|
+
const block = ev.event?.content_block;
|
|
210
|
+
if (block?.type === 'tool_use') {
|
|
211
|
+
rec.events.toolUse += 1;
|
|
212
|
+
if (block.name !== 'Skill'
|
|
213
|
+
&& !(maxExplore > 0 ? READ_ONLY.has(block.name) : block.name === 'Read')) {
|
|
214
|
+
rec.tools.push(block.name);
|
|
215
|
+
finish('not_triggered', { firstTool: rec.firstTool || block.name });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} else if (type === 'assistant') {
|
|
220
|
+
for (const c of ev.message?.content || []) {
|
|
221
|
+
if (c.type !== 'tool_use') continue;
|
|
222
|
+
rec.tools.push(c.name);
|
|
223
|
+
rec.firstTool = rec.firstTool || c.name;
|
|
224
|
+
if (c.name === 'Skill') {
|
|
225
|
+
const inv = String(c.input?.skill || '');
|
|
226
|
+
if (inv === skillName || inv.endsWith(`:${skillName}`)) finish('triggered', { invokedSkill: inv });
|
|
227
|
+
else finish('other_skill', { invokedSkill: inv });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (c.name === 'Read' && String(c.input?.file_path || '').replace(/\\/g, '/').includes(`skills/${skillName}/`)) {
|
|
231
|
+
finish('triggered');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (READ_ONLY.has(c.name) && maxExplore > 0) {
|
|
235
|
+
rec.exploreSteps += 1;
|
|
236
|
+
if (rec.exploreSteps > maxExplore) { finish('not_triggered'); return; }
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
finish('not_triggered');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
} else if (type === 'result') {
|
|
243
|
+
// A usage limit ends the CLI with an is_error result and no tool call, which would otherwise read
|
|
244
|
+
// as "answered without the skill".
|
|
245
|
+
if (!settled && ev.is_error) {
|
|
246
|
+
finish('error', { error: String(ev.result || ev.subtype || 'error result').slice(0, 500) });
|
|
247
|
+
} else if (!settled) finish('not_triggered');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
child.on('error', (err) => { rec.error = String(err.message || err); finish('error'); });
|
|
252
|
+
child.on('close', () => {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
if (!settled) {
|
|
255
|
+
if (timedOut) finish('timeout', { error: `no verdict within ${timeout}s` });
|
|
256
|
+
else if (rec.error) finish('error');
|
|
257
|
+
else if (!rec.events.lines) {
|
|
258
|
+
// exited without a single event: auth, quota or a bad flag, not a model that chose not to call the skill
|
|
259
|
+
finish('error', { error: (stderr.trim().split(LINE_BREAK).pop() || 'no output from the CLI').slice(0, 500) });
|
|
260
|
+
} else finish('not_triggered');
|
|
261
|
+
}
|
|
262
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
263
|
+
resolve(rec);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// One drafting call per set: lists the trigger clauses, then writes queries against them.
|
|
269
|
+
//
|
|
270
|
+
// The negatives are where a set is won or lost. The earlier wording asked for "near misses" sharing
|
|
271
|
+
// "vocabulary or domain", which let the drafter pick a different domain and still comply: the
|
|
272
|
+
// resulting negatives were far away, the false-positive rate came out at zero, and the failure mode
|
|
273
|
+
// the report is meant to expose -- an over-broad description -- could not show up. Three changes:
|
|
274
|
+
// a closed list of families, a mandatory "shares" field naming the borrowed words, and an explicit
|
|
275
|
+
// ban on softening. draftOverlap() then checks the claim mechanically.
|
|
276
|
+
export function draftQueries({ bin, skillDir, skillName, model, perClause = 3, negatives = 10, timeout = 600, host = 'claude-code' }) {
|
|
277
|
+
const text = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8').slice(0, 20000);
|
|
278
|
+
const prompt = `You are building a trigger-test set for an agent skill. Below is the skill's SKILL.md.
|
|
279
|
+
|
|
280
|
+
Step 1. List the trigger clauses: the distinct situations in which the frontmatter description says the skill
|
|
281
|
+
should be used. Use only the description, not the body. Name each clause in a few English words.
|
|
282
|
+
|
|
283
|
+
Step 2. For EACH clause, write exactly ${perClause} SHOULD-TRIGGER user requests that clearly fall under that
|
|
284
|
+
clause and no other. Do not paraphrase the description: write what a user types, not what the description says.
|
|
285
|
+
|
|
286
|
+
Step 3. Write exactly ${negatives} SHOULD-NOT-TRIGGER items, spread across the clauses. These are the hard part
|
|
287
|
+
and the reason the test exists: an easy negative measures nothing. Every negative must be a request that a
|
|
288
|
+
careless reader could mistake for this skill's job. Build them only from these families:
|
|
289
|
+
(a) same words, different job - reuse the clause's own distinctive nouns or verbs, but ask for a
|
|
290
|
+
neighbouring task the skill does not cover;
|
|
291
|
+
(b) sibling's job - a task another skill or a built-in capability would own;
|
|
292
|
+
(c) adjacent domain, different goal - the same files, stack or artefact, but a different intent.
|
|
293
|
+
For every negative also fill "shares" with the exact fragment copied verbatim out of your own request
|
|
294
|
+
that carries the borrowed wording (a substring of "query", same language, same spelling), and
|
|
295
|
+
"borrows" with the clause's own words it corresponds to in the description. Then "note" says in one
|
|
296
|
+
sentence why it must not trigger. If you cannot name a fragment that is really in the request, the item
|
|
297
|
+
is too far away: throw it out and write another one.
|
|
298
|
+
|
|
299
|
+
Never soften a negative into an obviously unrelated request (weather, recipes, small talk), never restate a
|
|
300
|
+
clause in other words as if it were a negative, and never write a negative whose only difference is a
|
|
301
|
+
negation of the clause.
|
|
302
|
+
|
|
303
|
+
Rules for every request:
|
|
304
|
+
- Realistic, varied phrasing as a real user would type to a coding agent: casual, terse, typos, concrete file names.
|
|
305
|
+
- About half in Chinese and half in English, within each clause as far as possible.
|
|
306
|
+
- Answerable on its own; do not reference earlier conversation.
|
|
307
|
+
Output ONLY JSON, no prose:
|
|
308
|
+
{"clauses": ["..."], "queries": [{"query": "...", "should_trigger": true, "clause": "...", "note": "why"}]}
|
|
309
|
+
For negatives, "shares" and "borrows" are required and "note" explains the near miss.
|
|
310
|
+
|
|
311
|
+
--- SKILL.md ---
|
|
312
|
+
${text}`;
|
|
313
|
+
let text2;
|
|
314
|
+
if (host === 'codewhale') {
|
|
315
|
+
text2 = draftWithCodewhale({ bin, prompt, model, timeout });
|
|
316
|
+
} else {
|
|
317
|
+
const args = ['-p', prompt, '--output-format', 'json', '--tools', '', '--no-session-persistence',
|
|
318
|
+
'--strict-mcp-config', '--setting-sources', 'project'];
|
|
319
|
+
if (model) args.push('--model', model);
|
|
320
|
+
const launch = launchSpec(bin, args, HOSTS[host].bin);
|
|
321
|
+
// CodeBuddy reads a custom model from the working directory's .codebuddy/models.json, so a DeepSeek draft
|
|
322
|
+
// runs in a scratch directory that has one.
|
|
323
|
+
const scratch = host === 'codebuddy' && isDeepseekModel(model) ? fs.mkdtempSync(path.join(os.tmpdir(), 'sg-draft-')) : null;
|
|
324
|
+
if (scratch) writeModelsJson(scratch, model);
|
|
325
|
+
let out;
|
|
326
|
+
try {
|
|
327
|
+
out = execFileSync(launch.cmd, launch.args, {
|
|
328
|
+
encoding: 'utf8', timeout: timeout * 1000, maxBuffer: 32 * 1024 * 1024,
|
|
329
|
+
shell: launch.shell, windowsHide: true, ...(scratch ? { cwd: scratch } : {}),
|
|
330
|
+
});
|
|
331
|
+
} catch (err) {
|
|
332
|
+
// The default message of a failed execFileSync is the whole command line, which here is the drafting prompt.
|
|
333
|
+
const why = err.code === 'ETIMEDOUT' || err.killed
|
|
334
|
+
? `no answer within ${timeout}s (a rejected key or an unreachable endpoint retries silently until then)`
|
|
335
|
+
: String(err.stderr || err.message).split(/\r?\n/).map((l) => l.trim()).find(Boolean) || 'exit status ' + err.status;
|
|
336
|
+
throw new Error(`the drafting call failed: ${why}`);
|
|
337
|
+
} finally {
|
|
338
|
+
if (scratch) { try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
339
|
+
}
|
|
340
|
+
const parsed = JSON.parse(out);
|
|
341
|
+
// Claude Code answers with one result object; CodeBuddy Code with the whole message list, whose last
|
|
342
|
+
// element is the result.
|
|
343
|
+
text2 = (Array.isArray(parsed) ? parsed.filter((m) => m?.type === 'result').pop() : parsed)?.result || '';
|
|
344
|
+
}
|
|
345
|
+
const obj = JSON.parse(text2.slice(text2.indexOf('{'), text2.lastIndexOf('}') + 1));
|
|
346
|
+
const items = (obj.queries || []).filter((q) => q && String(q.query || '').trim());
|
|
347
|
+
return { skill: skillName, clauses: obj.clauses || [], queries: items.map((q, i) => ({
|
|
348
|
+
id: `q${String(i + 1).padStart(2, '0')}`,
|
|
349
|
+
query: String(q.query).trim(),
|
|
350
|
+
shouldTrigger: Boolean(q.should_trigger),
|
|
351
|
+
clause: q.clause || '',
|
|
352
|
+
shares: String(q.shares || '').trim(),
|
|
353
|
+
borrows: String(q.borrows || '').trim(),
|
|
354
|
+
note: String(q.note || '').trim(),
|
|
355
|
+
})) };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// A negative is only as hard as the words it borrows. "shares" is demanded verbatim from the request,
|
|
359
|
+
// so it can be checked instead of trusted; "borrows" names the clause's own wording and is recorded
|
|
360
|
+
// but not verified, because a Chinese request borrowing English description words cannot be matched
|
|
361
|
+
// mechanically. A set of negatives that fail the check reports a false-positive rate near zero for
|
|
362
|
+
// reasons that have nothing to do with the skill.
|
|
363
|
+
export function draftQuality(queries) {
|
|
364
|
+
const negs = queries.filter((q) => !q.shouldTrigger);
|
|
365
|
+
const report = { negatives: negs.length, noShares: [], notInQuery: [], noBorrows: [] };
|
|
366
|
+
for (const q of negs) {
|
|
367
|
+
const phrases = String(q.shares || '').split(/[,;、,]/).map((s) => s.trim()).filter(Boolean);
|
|
368
|
+
if (!phrases.length) { report.noShares.push(q.id); continue; }
|
|
369
|
+
const lower = q.query.toLowerCase();
|
|
370
|
+
if (!phrases.some((p) => lower.includes(p.toLowerCase()))) report.notInQuery.push(q.id);
|
|
371
|
+
if (!String(q.borrows || '').trim()) report.noBorrows.push(q.id);
|
|
372
|
+
}
|
|
373
|
+
report.weak = new Set([...report.noShares, ...report.notInQuery]).size;
|
|
374
|
+
report.total = negs.length;
|
|
375
|
+
return report;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// A trial that errored or timed out says nothing about the skill: it is kept in raw.jsonl but never counted.
|
|
379
|
+
export const FAILED = new Set(['error', 'timeout']);
|
|
380
|
+
|
|
381
|
+
export function summarize(allRecords) {
|
|
382
|
+
const records = allRecords.filter((r) => !FAILED.has(r.verdict));
|
|
383
|
+
const pos = records.filter((r) => r.shouldTrigger);
|
|
384
|
+
const neg = records.filter((r) => !r.shouldTrigger);
|
|
385
|
+
const hit = (list) => list.filter((r) => r.verdict === 'triggered').length;
|
|
386
|
+
const q = new Map();
|
|
387
|
+
for (const r of records) {
|
|
388
|
+
const k = `${r.queryId}`;
|
|
389
|
+
const e = q.get(k) || { query: r.query, shouldTrigger: r.shouldTrigger, hits: 0, n: 0 };
|
|
390
|
+
e.n += 1;
|
|
391
|
+
if (r.verdict === 'triggered') e.hits += 1;
|
|
392
|
+
q.set(k, e);
|
|
393
|
+
}
|
|
394
|
+
const noTool = pos.filter((r) => r.verdict === 'not_triggered' && !r.firstTool).length;
|
|
395
|
+
return {
|
|
396
|
+
trials: records.length,
|
|
397
|
+
recall: pos.length ? hit(pos) / pos.length : null,
|
|
398
|
+
fpr: neg.length ? hit(neg) / neg.length : null,
|
|
399
|
+
noToolRate: pos.length ? noTool / pos.length : null,
|
|
400
|
+
questions: [...q.values()],
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Wilson interval over queries (the independent unit), not over trials: three runs of one query
|
|
405
|
+
// are not three independent observations.
|
|
406
|
+
export function wilson(k, n, z = 1.96) {
|
|
407
|
+
if (!n) return [null, null];
|
|
408
|
+
const p = k / n;
|
|
409
|
+
const d = 1 + (z * z) / n;
|
|
410
|
+
const c = (p + (z * z) / (2 * n)) / d;
|
|
411
|
+
const h = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / d;
|
|
412
|
+
return [Math.max(0, c - h), Math.min(1, c + h)];
|
|
413
|
+
}
|
package/lib/ui.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Prompts. Interactive use is meant to be driven by arrow keys and single keys (y/n, enter);
|
|
2
|
+
// when stdin is a pipe there is no terminal to draw on, so every prompt falls back to numbers.
|
|
3
|
+
//
|
|
4
|
+
// Lines are queued rather than read with rl.question(): when stdin is a pipe, every line arrives
|
|
5
|
+
// before the first prompt and readline drops the ones nobody was waiting for, which silently ends
|
|
6
|
+
// the run mid-flow.
|
|
7
|
+
import readline, { emitKeypressEvents } from 'node:readline';
|
|
8
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
9
|
+
import { words } from './i18n.js';
|
|
10
|
+
|
|
11
|
+
let rl = null;
|
|
12
|
+
const queue = [];
|
|
13
|
+
const waiters = [];
|
|
14
|
+
|
|
15
|
+
function getRl() {
|
|
16
|
+
if (!rl) {
|
|
17
|
+
rl = readline.createInterface({ input, output, terminal: Boolean(input.isTTY) });
|
|
18
|
+
rl.on('line', (line) => {
|
|
19
|
+
const w = waiters.shift();
|
|
20
|
+
if (w) w(line);
|
|
21
|
+
else queue.push(line);
|
|
22
|
+
});
|
|
23
|
+
rl.on('close', () => {
|
|
24
|
+
while (waiters.length) waiters.shift()(null);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
input.resume();
|
|
28
|
+
return rl;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Give the terminal back to raw-mode prompts without losing buffered pipe input.
|
|
32
|
+
export function pauseUi() {
|
|
33
|
+
if (rl) {
|
|
34
|
+
rl.removeAllListeners('line');
|
|
35
|
+
rl.close();
|
|
36
|
+
rl = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function closeUi() {
|
|
41
|
+
pauseUi();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function say(line = '') {
|
|
45
|
+
output.write(`${line}\n`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readLine() {
|
|
49
|
+
getRl();
|
|
50
|
+
if (queue.length) return Promise.resolve(queue.shift());
|
|
51
|
+
return new Promise((resolve) => { waiters.push(resolve); });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function ask(question, def = '') {
|
|
55
|
+
const suffix = def === '' ? '' : ` [${def}]`;
|
|
56
|
+
output.write(`${question}${suffix}: `);
|
|
57
|
+
const line = await readLine();
|
|
58
|
+
if (line === null) throw new Error('EOF');
|
|
59
|
+
if (!input.isTTY) output.write('\n'); // a pipe does not echo, so the next prompt would collide
|
|
60
|
+
return line.trim() || def;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function confirm(question, def = false) {
|
|
64
|
+
const hint = def ? 'Y/n' : 'y/N';
|
|
65
|
+
for (;;) {
|
|
66
|
+
const a = (await ask(`${question} (${hint})`, '')).toLowerCase();
|
|
67
|
+
if (!a) return def;
|
|
68
|
+
// Answers are matched against the current language's list (e.g. はい / いいえ) plus plain y / n.
|
|
69
|
+
if (['y', 'yes', ...words('ui.yesWords')].includes(a)) return true;
|
|
70
|
+
if (['n', 'no', ...words('ui.noWords')].includes(a)) return false;
|
|
71
|
+
say(' y / n');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// options: [{ label, hint, value }]. Arrow keys when a terminal is available, numbers otherwise.
|
|
76
|
+
export async function choose(title, options, def = 1) {
|
|
77
|
+
if (input.isTTY) return select(title, options, def - 1);
|
|
78
|
+
say('');
|
|
79
|
+
say(title);
|
|
80
|
+
options.forEach((o, i) => say(` ${i + 1}) ${o.label}${o.hint ? ` ${o.hint}` : ''}`));
|
|
81
|
+
for (;;) {
|
|
82
|
+
const a = await ask('>', String(def));
|
|
83
|
+
const n = Number.parseInt(a, 10);
|
|
84
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1].value;
|
|
85
|
+
say(` 1–${options.length}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function select(title, options, defIndex = 0) {
|
|
90
|
+
if (!input.isTTY) {
|
|
91
|
+
say('');
|
|
92
|
+
say(title);
|
|
93
|
+
options.forEach((o, i) => say(` ${i + 1}) ${o.label}${o.hint ? ` ${o.hint}` : ''}`));
|
|
94
|
+
for (;;) {
|
|
95
|
+
const a = await ask('>', String(defIndex + 1));
|
|
96
|
+
const n = Number.parseInt(a, 10);
|
|
97
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1].value;
|
|
98
|
+
say(` 1–${options.length}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
pauseUi();
|
|
102
|
+
let idx = Math.min(Math.max(defIndex, 0), options.length - 1);
|
|
103
|
+
emitKeypressEvents(input);
|
|
104
|
+
if (input.setRawMode) input.setRawMode(true);
|
|
105
|
+
input.resume();
|
|
106
|
+
// A window keeps the redraw line count constant (wrapping breaks the cursor maths) and keeps a
|
|
107
|
+
// 69-skill list navigable with arrows alone.
|
|
108
|
+
const WINDOW = 8;
|
|
109
|
+
const width = Math.min(48, Math.max(...options.map((o) => o.label.length)));
|
|
110
|
+
let first = true;
|
|
111
|
+
const render = () => {
|
|
112
|
+
const size = Math.min(WINDOW, options.length);
|
|
113
|
+
const start = Math.min(Math.max(0, idx - Math.floor(size / 2)), Math.max(0, options.length - size));
|
|
114
|
+
const lines = [options.length > WINDOW ? `${title} (${start + 1}–${start + size}/${options.length})` : title];
|
|
115
|
+
for (let i = start; i < start + size; i += 1) {
|
|
116
|
+
const o = options[i];
|
|
117
|
+
const label = o.label.length > width ? `${o.label.slice(0, width - 1)}…` : o.label.padEnd(width);
|
|
118
|
+
const body = i === idx ? `\x1b[7m▶ ${label} \x1b[0m` : ` ${label} `;
|
|
119
|
+
lines.push(`${body}${o.hint ? ` ${o.hint}` : ''}`);
|
|
120
|
+
}
|
|
121
|
+
if (first) {
|
|
122
|
+
output.write(`\n${lines.join('\n')}\n`);
|
|
123
|
+
first = false;
|
|
124
|
+
} else {
|
|
125
|
+
output.write(`\x1b[${lines.length}A`);
|
|
126
|
+
for (const line of lines) output.write(`\x1b[2K${line}\n`);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
render();
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const cleanup = () => {
|
|
132
|
+
input.removeListener('keypress', onKey);
|
|
133
|
+
if (input.setRawMode) input.setRawMode(false);
|
|
134
|
+
input.pause();
|
|
135
|
+
};
|
|
136
|
+
const onKey = (ch, key = {}) => {
|
|
137
|
+
if (key.name === 'up' || ch === 'k') {
|
|
138
|
+
idx = (idx - 1 + options.length) % options.length;
|
|
139
|
+
render();
|
|
140
|
+
} else if (key.name === 'down' || ch === 'j') {
|
|
141
|
+
idx = (idx + 1) % options.length;
|
|
142
|
+
render();
|
|
143
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
144
|
+
cleanup();
|
|
145
|
+
resolve(options[idx].value);
|
|
146
|
+
} else if (key.ctrl && key.name === 'c') {
|
|
147
|
+
cleanup();
|
|
148
|
+
reject(new Error('interrupted'));
|
|
149
|
+
} else if (ch && /^[1-9]$/.test(ch) && Number(ch) <= options.length) {
|
|
150
|
+
idx = Number(ch) - 1;
|
|
151
|
+
render();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
input.on('keypress', onKey);
|
|
155
|
+
});
|
|
156
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillgauntlet",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "",
|
|
5
|
-
"
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Check an agent skill before you install it: context cost, permission surface, code risk, instruction hijacking, description quality, trigger rate and gain.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"skillgauntlet": "./bin/skillgauntlet.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"contracts",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"LICENSE-DATA"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
6
20
|
"scripts": {
|
|
7
|
-
"
|
|
21
|
+
"start": "node ./bin/skillgauntlet.js",
|
|
22
|
+
"static": "node ./bin/skillgauntlet.js static",
|
|
23
|
+
"test": "node --test tests/*.test.mjs"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"agent",
|
|
27
|
+
"skills",
|
|
28
|
+
"claude-code",
|
|
29
|
+
"codex",
|
|
30
|
+
"deepseek",
|
|
31
|
+
"skill-evaluation",
|
|
32
|
+
"trigger-rate",
|
|
33
|
+
"prompt-injection"
|
|
34
|
+
],
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/SkillGauntlet/SkillGauntlet.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/SkillGauntlet/SkillGauntlet#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/SkillGauntlet/SkillGauntlet/issues"
|
|
8
42
|
},
|
|
9
|
-
"
|
|
10
|
-
"
|
|
43
|
+
"license": "Apache-2.0",
|
|
44
|
+
"dependencies": {}
|
|
11
45
|
}
|
package/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
console.log('Hello SkillGauntlet');
|