skillgauntlet 1.2.0 → 1.3.1
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 -201
- package/README.md +16 -2
- package/bin/skillgauntlet.js +70 -14
- package/lib/automerge.js +107 -0
- package/lib/chart.js +6 -3
- package/lib/codewhale.js +2 -0
- package/lib/execute.js +2 -1
- package/lib/i18n.js +126 -18
- package/lib/models.js +8 -0
- package/lib/report.js +96 -1
- package/lib/runs.js +17 -0
- package/lib/static.js +2 -2
- package/lib/trial.js +64 -21
- package/lib/update.js +73 -0
- package/package.json +1 -1
package/lib/runs.js
CHANGED
|
@@ -118,6 +118,11 @@ function handle(dir, meta) {
|
|
|
118
118
|
meta.trials += 1;
|
|
119
119
|
if (meta.trials % 25 === 0) flushMeta();
|
|
120
120
|
},
|
|
121
|
+
// What the model said and did first, per trial. Local only: submissions copy a fixed list of files, and this
|
|
122
|
+
// is not one of them.
|
|
123
|
+
appendAnswer(row) {
|
|
124
|
+
fs.appendFileSync(path.join(dir, 'answers.jsonl'), `${JSON.stringify(row)}\n`);
|
|
125
|
+
},
|
|
121
126
|
close(summary, { interrupted = false } = {}) {
|
|
122
127
|
meta.finishedAt = new Date().toISOString();
|
|
123
128
|
meta.interrupted = interrupted;
|
|
@@ -165,3 +170,15 @@ export function readRun(dir) {
|
|
|
165
170
|
try { summary = JSON.parse(fs.readFileSync(path.join(dir, 'summary.json'), 'utf8')); } catch { /* unfinished */ }
|
|
166
171
|
return { meta, records: raw, summary };
|
|
167
172
|
}
|
|
173
|
+
|
|
174
|
+
// Answers kept beside a run, keyed "queryId#run". A run made before they were kept has none: an empty map.
|
|
175
|
+
export function readAnswers(dir) {
|
|
176
|
+
const out = new Map();
|
|
177
|
+
try {
|
|
178
|
+
for (const line of fs.readFileSync(path.join(dir, 'answers.jsonl'), 'utf8').split(/\r?\n/).filter(Boolean)) {
|
|
179
|
+
const row = JSON.parse(line);
|
|
180
|
+
out.set(`${row.queryId}#${row.run}`, row);
|
|
181
|
+
}
|
|
182
|
+
} catch { /* none kept */ }
|
|
183
|
+
return out;
|
|
184
|
+
}
|
package/lib/static.js
CHANGED
|
@@ -158,9 +158,9 @@ export function analyze(skillDir) {
|
|
|
158
158
|
// E. description quality -- the only layer that is also the trigger layer's input
|
|
159
159
|
const E = [];
|
|
160
160
|
E.push(check('E1', desc ? 'pass' : 'fail', desc ? 'check.E1.detail' : 'check.E1.missing', { chars: desc.length }));
|
|
161
|
-
const tw = hits(desc, RE.triggerWords, 1);
|
|
161
|
+
const tw = hits(desc, RE.triggerWords, 1, 'SKILL.md (description)');
|
|
162
162
|
E.push(check('E2', tw.length ? 'pass' : 'warn', tw.length ? 'check.E2.hit' : 'check.E2.miss'));
|
|
163
|
-
const wide = hits(desc, RE.wide, 1);
|
|
163
|
+
const wide = hits(desc, RE.wide, 1, 'SKILL.md (description)');
|
|
164
164
|
E.push(check('E3', wide.length ? 'warn' : 'pass', wide.length ? 'check.E3.hit' : 'check.E3.none', {}, wide));
|
|
165
165
|
const clauses = (desc.match(/\bor\b|、|,|,|\//g) || []).length + 1;
|
|
166
166
|
E.push(check('E4', 'info', 'check.E4.detail', { n: clauses }));
|
package/lib/trial.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// answers without calling any tool -> not_triggered, counted apart
|
|
9
9
|
//
|
|
10
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';
|
|
11
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
@@ -31,12 +31,19 @@ export const HOSTS = {
|
|
|
31
31
|
export function backendFor(host, model) {
|
|
32
32
|
if (host === 'codewhale') return 'codewhale:api.deepseek.com';
|
|
33
33
|
if (host !== 'codebuddy') return backendHost();
|
|
34
|
-
return
|
|
34
|
+
return usesOwnDeepseek('codebuddy', model) ? 'codebuddy:api.deepseek.com' : 'codebuddy';
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
// A model id starting with "deepseek" means the DeepSeek API, whichever host drives it.
|
|
38
38
|
export const isDeepseekModel = (model) => /^deepseek/i.test(String(model || ''));
|
|
39
39
|
|
|
40
|
+
// A DeepSeek model that the host lists itself (CodeBuddy's own deepseek-v4.1-flash) is served by the host's account
|
|
41
|
+
// and needs no key of ours. Only a deepseek-* name the host does not list takes the package's own DeepSeek route
|
|
42
|
+
// (a models.json entry in the trial directory and DEEPSEEK_API_KEY from the environment).
|
|
43
|
+
const nativeModels = new Map();
|
|
44
|
+
export const setNativeModels = (host, list) => nativeModels.set(host, new Set(list || []));
|
|
45
|
+
export const usesOwnDeepseek = (host, model) => isDeepseekModel(model) && !nativeModels.get(host)?.has(model);
|
|
46
|
+
|
|
40
47
|
// DeepSeek's integration guide for WorkBuddy/CodeBuddy: a models.json entry whose key is read from the
|
|
41
48
|
// environment (${DEEPSEEK_API_KEY}), so the file holds no secret. It is written into the trial's own
|
|
42
49
|
// directory (project level), never into the user's ~/.codebuddy.
|
|
@@ -111,18 +118,35 @@ export function requireHost(host, probe = binInstalled) {
|
|
|
111
118
|
|
|
112
119
|
// One tiny call before the real work: a host that is not signed in retries silently for minutes, so the user
|
|
113
120
|
// would stare at a frozen screen. A short timeout turns that into an immediate, actionable message.
|
|
114
|
-
|
|
121
|
+
// The first non-empty line of some CLI output, for an error message.
|
|
122
|
+
const firstLine = (text) => String(text || '').split(LINE_BREAK).map((l) => l.trim()).find(Boolean) || '';
|
|
123
|
+
|
|
124
|
+
// What a host CLI answered, as an error reason: the CLI's own words when it gave any. A CLI can exit 0 with nothing on
|
|
125
|
+
// stdout and its complaint on stderr (CodeBuddy does for a custom model without an endpoint url), so a clean exit
|
|
126
|
+
// status proves nothing; an empty or unreadable stdout is the failure.
|
|
127
|
+
export function hostAnswerProblem(r, timeout) {
|
|
128
|
+
if (r.error) return r.error.code === 'ETIMEDOUT' ? `no answer within ${timeout}s` : String(r.error.message).split(LINE_BREAK)[0];
|
|
129
|
+
if (r.signal) return `no answer within ${timeout}s`;
|
|
130
|
+
const out = String(r.stdout || '');
|
|
131
|
+
if (r.status !== 0) return firstLine(r.stderr) || firstLine(out) || `exit status ${r.status}`;
|
|
132
|
+
if (!out.trim()) return firstLine(r.stderr) || 'the CLI answered with nothing';
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(out);
|
|
135
|
+
const last = Array.isArray(parsed) ? parsed.filter((m) => m?.type === 'result').pop() : parsed;
|
|
136
|
+
if (last?.is_error) return firstLine(last.result) || 'the CLI reported an error';
|
|
137
|
+
} catch { return firstLine(out); }
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function pingHost(host, { bin, model = null, timeout = 90, spawn = spawnSync } = {}) {
|
|
115
142
|
const spec = HOSTS[host];
|
|
116
143
|
if (!spec || host === 'codewhale') return;
|
|
117
144
|
const args = ['-p', 'Reply with the single word OK.', '--output-format', 'json', '--tools', '', '--no-session-persistence'];
|
|
118
|
-
if (model && !
|
|
145
|
+
if (model && !usesOwnDeepseek(host, model)) args.push('--model', model); // the package's own DeepSeek route has no host-side name
|
|
119
146
|
const launch = launchSpec(bin, args, spec.bin);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const why = err.code === 'ETIMEDOUT' || err.killed
|
|
124
|
-
? `no answer within ${timeout}s`
|
|
125
|
-
: String(err.stderr || err.message).split(LINE_BREAK).map((l) => l.trim()).find(Boolean) || `exit status ${err.status}`;
|
|
147
|
+
const r = spawn(launch.cmd, launch.args, { encoding: 'utf8', timeout: timeout * 1000, shell: launch.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
148
|
+
const why = hostAnswerProblem(r, timeout);
|
|
149
|
+
if (why) {
|
|
126
150
|
const e = new Error(`host ${host} did not answer: ${why}`);
|
|
127
151
|
e.messageKey = 'hostNotReady';
|
|
128
152
|
e.vars = { host: HOST_NAME[host] || host, why: why.slice(0, 200) };
|
|
@@ -130,6 +154,21 @@ export function pingHost(host, { bin, model = null, timeout = 90, run = execFile
|
|
|
130
154
|
}
|
|
131
155
|
}
|
|
132
156
|
|
|
157
|
+
// What a trial shows of the model, kept apart from the verdict: the words it said before its first action and what that
|
|
158
|
+
// action was. A trial ends at the first action (the verdict is known then), so this is "up to the first step", never a
|
|
159
|
+
// complete answer. It goes to a local file, not into the run's raw records, which are what gets submitted.
|
|
160
|
+
export const SAID_LIMIT = 2000;
|
|
161
|
+
export function inputSummary(name, input) {
|
|
162
|
+
if (!input || typeof input !== 'object' || !Object.keys(input).length) return '';
|
|
163
|
+
const pick = input.command ?? input.file_path ?? input.path ?? input.pattern ?? input.skill ?? input.name ?? input.query;
|
|
164
|
+
const text = typeof pick === 'string' ? pick : JSON.stringify(input);
|
|
165
|
+
return `${text}`.slice(0, 300);
|
|
166
|
+
}
|
|
167
|
+
export function addSaid(rec, text) {
|
|
168
|
+
if (typeof text !== 'string' || !text) return;
|
|
169
|
+
rec.said = `${rec.said || ''}${text}`.slice(0, SAID_LIMIT);
|
|
170
|
+
}
|
|
171
|
+
|
|
133
172
|
// Variables a host (the Claude desktop app, the Agent SDK, an outer Claude Code session) sets for its own
|
|
134
173
|
// child processes. A trial inherits them otherwise, and they change what the model under test sees: under
|
|
135
174
|
// the desktop app the CLI adds Artifact, ArtifactComments and ArtifactData to the tool list, and the model
|
|
@@ -216,7 +255,7 @@ export function runTrial({ bin, skillDir, skillName, query, model, timeout = 180
|
|
|
216
255
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-'));
|
|
217
256
|
const spec = HOSTS[host];
|
|
218
257
|
copyDir(skillDir, path.join(tmp, ...spec.skillsDir, skillName));
|
|
219
|
-
if (host === 'codebuddy' &&
|
|
258
|
+
if (host === 'codebuddy' && usesOwnDeepseek('codebuddy', model)) writeModelsJson(tmp, model);
|
|
220
259
|
const args = host === 'codebuddy'
|
|
221
260
|
? ['-p', query, '--output-format', 'stream-json', '--verbose', '--setting-sources', 'project', '--strict-mcp-config',
|
|
222
261
|
'--no-session-persistence', '--tools', spec.tools]
|
|
@@ -275,6 +314,7 @@ export function runTrial({ bin, skillDir, skillName, query, model, timeout = 180
|
|
|
275
314
|
rec.init = { model: ev.model, apiKeySource: ev.apiKeySource, claudeCodeVersion: ev.claude_code_version,
|
|
276
315
|
skills: ev.skills || [], tools: ev.tools || [] };
|
|
277
316
|
} else if (type === 'stream_event') {
|
|
317
|
+
if (ev.event?.delta?.type === 'text_delta') addSaid(rec, ev.event.delta.text);
|
|
278
318
|
const block = ev.event?.content_block;
|
|
279
319
|
if (block?.type === 'tool_use') {
|
|
280
320
|
rec.events.toolUse += 1;
|
|
@@ -287,9 +327,11 @@ export function runTrial({ bin, skillDir, skillName, query, model, timeout = 180
|
|
|
287
327
|
}
|
|
288
328
|
} else if (type === 'assistant') {
|
|
289
329
|
for (const c of ev.message?.content || []) {
|
|
330
|
+
if (c.type === 'text') addSaid(rec, c.text);
|
|
290
331
|
if (c.type !== 'tool_use') continue;
|
|
291
332
|
rec.tools.push(c.name);
|
|
292
333
|
rec.firstTool = rec.firstTool || c.name;
|
|
334
|
+
rec.firstInput = rec.firstInput || inputSummary(c.name, c.input);
|
|
293
335
|
if (c.name === 'Skill') {
|
|
294
336
|
const inv = String(c.input?.skill || '');
|
|
295
337
|
if (inv === skillName || inv.endsWith(`:${skillName}`)) finish('triggered', { invokedSkill: inv });
|
|
@@ -389,23 +431,24 @@ ${text}`;
|
|
|
389
431
|
const launch = launchSpec(bin, args, HOSTS[host].bin);
|
|
390
432
|
// CodeBuddy reads a custom model from the working directory's .codebuddy/models.json, so a DeepSeek draft
|
|
391
433
|
// runs in a scratch directory that has one.
|
|
392
|
-
const scratch = host === 'codebuddy' &&
|
|
434
|
+
const scratch = host === 'codebuddy' && usesOwnDeepseek('codebuddy', model) ? fs.mkdtempSync(path.join(os.tmpdir(), 'sg-draft-')) : null;
|
|
393
435
|
if (scratch) writeModelsJson(scratch, model);
|
|
394
|
-
let
|
|
436
|
+
let r;
|
|
395
437
|
try {
|
|
396
|
-
|
|
438
|
+
r = spawnSync(launch.cmd, launch.args, {
|
|
397
439
|
encoding: 'utf8', timeout: timeout * 1000, maxBuffer: 32 * 1024 * 1024,
|
|
398
|
-
shell: launch.shell, windowsHide: true, ...(scratch ? { cwd: scratch } : {}),
|
|
440
|
+
shell: launch.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], ...(scratch ? { cwd: scratch } : {}),
|
|
399
441
|
});
|
|
400
|
-
} catch (err) {
|
|
401
|
-
// The default message of a failed execFileSync is the whole command line, which here is the drafting prompt.
|
|
402
|
-
const why = err.code === 'ETIMEDOUT' || err.killed
|
|
403
|
-
? `no answer within ${timeout}s (a rejected key or an unreachable endpoint retries silently until then)`
|
|
404
|
-
: String(err.stderr || err.message).split(/\r?\n/).map((l) => l.trim()).find(Boolean) || 'exit status ' + err.status;
|
|
405
|
-
throw new Error(`the drafting call failed: ${why}`);
|
|
406
442
|
} finally {
|
|
407
443
|
if (scratch) { try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
408
444
|
}
|
|
445
|
+
// The CLI's own words, not "Unexpected end of JSON input": an empty answer with a complaint on stderr is common.
|
|
446
|
+
const problem = hostAnswerProblem(r, timeout);
|
|
447
|
+
if (problem) {
|
|
448
|
+
const hint = r.error?.code === 'ETIMEDOUT' || r.signal ? ' (a rejected key or an unreachable endpoint retries silently until then)' : '';
|
|
449
|
+
throw new Error(`the drafting call failed: ${problem}${hint}`);
|
|
450
|
+
}
|
|
451
|
+
const out = r.stdout;
|
|
409
452
|
const parsed = JSON.parse(out);
|
|
410
453
|
// Claude Code answers with one result object; CodeBuddy Code with the whole message list, whose last
|
|
411
454
|
// element is the result.
|
package/lib/update.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// A check for a newer published version before an interactive session. It must never get in the way: any failure
|
|
2
|
+
// (offline, slow registry, odd reply, unwritable cache) means "no update known" and the session goes on with the
|
|
3
|
+
// version that is running.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const REGISTRY = 'https://registry.npmjs.org/skillgauntlet/latest';
|
|
10
|
+
const CHECK_EVERY_MS = 12 * 60 * 60 * 1000;
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
export function currentVersion() {
|
|
14
|
+
try { return JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version; } catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// a.b.c with an optional pre-release tail; a pre-release is older than its release.
|
|
18
|
+
export function isNewer(latest, current) {
|
|
19
|
+
const parse = (v) => {
|
|
20
|
+
const m = String(v).trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
|
|
21
|
+
return m ? { n: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null } : null;
|
|
22
|
+
};
|
|
23
|
+
const a = parse(latest);
|
|
24
|
+
const b = parse(current);
|
|
25
|
+
if (!a || !b) return false;
|
|
26
|
+
for (let i = 0; i < 3; i += 1) if (a.n[i] !== b.n[i]) return a.n[i] > b.n[i];
|
|
27
|
+
return Boolean(b.pre) && !a.pre;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A copy that runs from a git checkout is a developer's own; it is not nagged about releases.
|
|
31
|
+
export function isCheckout() {
|
|
32
|
+
return fs.existsSync(path.join(here, '..', '.git'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cachePath() {
|
|
36
|
+
return path.join(process.env.SKILLGAUNTLET_CACHE || path.join(os.homedir(), '.cache', 'skillgauntlet'), 'update-check.json');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readCache(file) {
|
|
40
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeCache(file, data) {
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
46
|
+
fs.writeFileSync(file, `${JSON.stringify(data)}\n`);
|
|
47
|
+
} catch { /* the cache is an optimisation */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// { current, latest } when a newer version is known and has not been declined, else null. Never throws.
|
|
51
|
+
export async function checkForUpdate({ fetchLatest, now = Date.now(), file = cachePath(), current = currentVersion(), env = process.env } = {}) {
|
|
52
|
+
try {
|
|
53
|
+
if (!current || env.SG_NO_UPDATE_CHECK || env.CI) return null;
|
|
54
|
+
const cache = readCache(file);
|
|
55
|
+
let latest = cache.latest;
|
|
56
|
+
if (!latest || !cache.checkedAt || now - cache.checkedAt > CHECK_EVERY_MS) {
|
|
57
|
+
const get = fetchLatest || (async () => {
|
|
58
|
+
const res = await fetch(REGISTRY, { signal: AbortSignal.timeout(3000), headers: { accept: 'application/json' } });
|
|
59
|
+
if (!res.ok) throw new Error(`registry ${res.status}`);
|
|
60
|
+
return (await res.json()).version;
|
|
61
|
+
});
|
|
62
|
+
latest = await get();
|
|
63
|
+
writeCache(file, { ...cache, latest, checkedAt: now });
|
|
64
|
+
}
|
|
65
|
+
if (!isNewer(latest, current) || cache.declined === latest) return null;
|
|
66
|
+
return { current, latest };
|
|
67
|
+
} catch { return null; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Remember that the user said no to this version, so the question is not asked again until a newer one appears.
|
|
71
|
+
export function declineUpdate(latest, file = cachePath()) {
|
|
72
|
+
writeCache(file, { ...readCache(file), declined: latest });
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillgauntlet",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|