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/execute.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Running trials against an open run archive, and picking a run back up.
|
|
2
|
+
//
|
|
3
|
+
// Ported from the `run` / `--resume` path of step0/trigger_test.py (v0.4.1-v0.4.2). Two rules come
|
|
4
|
+
// with it. A trial that errored or timed out is written to raw.jsonl but never counted, and is
|
|
5
|
+
// retried on resume. And a run stops itself after ERROR_STREAK_STOP failed trials in a row: that is
|
|
6
|
+
// a usage limit, a dead token or a network outage, and carrying on would only burn the queue.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fingerprint } from './queries.js';
|
|
10
|
+
import { readRun, reopenRun, validRecords, querySetHash } from './runs.js';
|
|
11
|
+
import { FAILED, runTrial as realRunTrial, summarize, wilson } from './trial.js';
|
|
12
|
+
|
|
13
|
+
export const ERROR_STREAK_STOP = 8;
|
|
14
|
+
|
|
15
|
+
// trials: [{ q, i }]. doTrial({ q, i }) resolves to a trial record, optionally carrying `init`
|
|
16
|
+
// (the CLI's init event), which is lifted into the run's meta instead of being stored on every row.
|
|
17
|
+
export async function executeTrials({ run, trials, doTrial, workers = 2, streakStop = ERROR_STREAK_STOP, onTrial = () => {} }) {
|
|
18
|
+
const state = { next: 0, done: 0, streak: 0, stopped: null };
|
|
19
|
+
const worker = async () => {
|
|
20
|
+
for (;;) {
|
|
21
|
+
if (state.stopped) return;
|
|
22
|
+
const idx = state.next++;
|
|
23
|
+
if (idx >= trials.length) return;
|
|
24
|
+
const { q, i } = trials[idx];
|
|
25
|
+
const rec = await doTrial({ q, i });
|
|
26
|
+
const { init, ...stored } = rec;
|
|
27
|
+
const row = { ...stored, queryId: q.id, run: i, query: q.query, shouldTrigger: q.shouldTrigger };
|
|
28
|
+
if (init && !run.meta.tools) {
|
|
29
|
+
run.patch({
|
|
30
|
+
modelResolved: init.model,
|
|
31
|
+
apiKeySource: init.apiKeySource,
|
|
32
|
+
claudeCodeVersion: init.claudeCodeVersion,
|
|
33
|
+
loadedSkills: init.skills,
|
|
34
|
+
tools: [...(init.tools || [])].sort(),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
run.append(row);
|
|
38
|
+
state.done += 1;
|
|
39
|
+
state.streak = FAILED.has(row.verdict) ? state.streak + 1 : 0;
|
|
40
|
+
if (state.streak >= streakStop && !state.stopped) {
|
|
41
|
+
state.stopped = (String(row.error || row.verdict).trim().split(/\r?\n/).pop() || '?').slice(0, 200);
|
|
42
|
+
}
|
|
43
|
+
onTrial({ row, done: state.done, total: trials.length });
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
await Promise.all(Array.from({ length: Math.min(workers, trials.length) }, worker));
|
|
47
|
+
return { done: state.done, stopped: state.stopped };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Close a finished run, or park a stopped one. A stopped run writes no summary: it is not complete,
|
|
51
|
+
// and a number computed from half the queue would read as a result.
|
|
52
|
+
export function finishRun(run, { model, backend, host, runs, expected }, stopped) {
|
|
53
|
+
if (stopped) {
|
|
54
|
+
run.patch({ stoppedAt: new Date().toISOString(), stoppedReason: stopped });
|
|
55
|
+
run.close(null, { interrupted: true });
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const all = readRun(run.dir).records;
|
|
59
|
+
const records = validRecords(all);
|
|
60
|
+
const s = summarize(records);
|
|
61
|
+
const posQ = s.questions.filter((q) => q.shouldTrigger);
|
|
62
|
+
const negQ = s.questions.filter((q) => !q.shouldTrigger);
|
|
63
|
+
const result = {
|
|
64
|
+
...s,
|
|
65
|
+
failedTrials: all.length - records.length,
|
|
66
|
+
// trials still owed (errors and timeouts with no valid retry): the numbers cover fewer trials than planned
|
|
67
|
+
incomplete: expected ? Math.max(0, expected - records.length) : 0,
|
|
68
|
+
model,
|
|
69
|
+
backend,
|
|
70
|
+
host,
|
|
71
|
+
runs,
|
|
72
|
+
recallCi: wilson(posQ.filter((q) => q.hits * 2 > q.n).length, posQ.length),
|
|
73
|
+
fprCi: wilson(negQ.filter((q) => q.hits > 0).length, negQ.length),
|
|
74
|
+
};
|
|
75
|
+
if (result.incomplete) run.patch({ incompleteTrials: result.incomplete });
|
|
76
|
+
run.close(result);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function pendingTrials(queries, runs, records) {
|
|
81
|
+
const have = new Set(validRecords(records).map((r) => `${r.queryId}#${r.run}`));
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const q of queries) for (let i = 0; i < runs; i += 1) if (!have.has(`${q.id}#${i}`)) out.push({ q, i });
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mismatch(what, was, got) {
|
|
88
|
+
const err = new Error(`cannot resume: ${what} was ${was}, now ${got}`);
|
|
89
|
+
err.messageKey = 'resumeMismatch';
|
|
90
|
+
err.vars = { what, was, got };
|
|
91
|
+
return err;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The conditions a resumed run must keep. Records from different conditions cannot be pooled, so a
|
|
95
|
+
// change is refused rather than merged. `want` holds only what the caller supplied; anything left out
|
|
96
|
+
// is taken from the run.
|
|
97
|
+
export function checkResume(meta, want) {
|
|
98
|
+
for (const k of ['model', 'runs', 'maxExplore']) {
|
|
99
|
+
if (want[k] !== undefined && want[k] !== null && String(want[k]) !== String(meta[k])) {
|
|
100
|
+
throw mismatch(k, meta[k], want[k]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (want.backend !== undefined && want.backend !== meta.backend) throw mismatch('backend', meta.backend, want.backend);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function loadRunQueries(dir, meta) {
|
|
107
|
+
const skillDir = path.resolve(dir, '..', '..', '..');
|
|
108
|
+
let queries = null;
|
|
109
|
+
try { queries = JSON.parse(fs.readFileSync(path.join(dir, 'queries.json'), 'utf8')).queries; } catch { /* older run */ }
|
|
110
|
+
if (!queries) {
|
|
111
|
+
// Runs written before queries.json existed: fall back to the cache file they were drawn from.
|
|
112
|
+
try { queries = JSON.parse(fs.readFileSync(path.join(skillDir, '.skillgauntlet', meta.querySets[0].file), 'utf8')).queries; } catch { /* gone */ }
|
|
113
|
+
}
|
|
114
|
+
const was = meta.querySets?.[0]?.sha256;
|
|
115
|
+
if (!queries || querySetHash(queries) !== was) {
|
|
116
|
+
const err = new Error('cannot resume: the question set no longer matches the one this run used');
|
|
117
|
+
err.messageKey = 'resumeQueriesChanged';
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
if (meta.skillFingerprint && fingerprint(skillDir) !== meta.skillFingerprint) {
|
|
121
|
+
const err = new Error('cannot resume: SKILL.md changed since this run started');
|
|
122
|
+
err.messageKey = 'resumeSkillChanged';
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
return { skillDir, queries };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Continue a run directory: skip every valid record, retry errors and timeouts.
|
|
129
|
+
// want: { model, runs, maxExplore, backend } as supplied now; mismatches throw with a messageKey.
|
|
130
|
+
export async function resumeRun({ dir, want = {}, bin, runTrialFn = realRunTrial, workers = 2, timeout, onTrial }) {
|
|
131
|
+
const { meta, records } = readRun(dir);
|
|
132
|
+
checkResume(meta, want);
|
|
133
|
+
const { skillDir, queries } = loadRunQueries(dir, meta);
|
|
134
|
+
const todo = pendingTrials(queries, meta.runs, records);
|
|
135
|
+
if (!todo.length) return { nothing: true, meta };
|
|
136
|
+
|
|
137
|
+
const run = reopenRun(dir);
|
|
138
|
+
const doTrial = ({ q }) => runTrialFn({
|
|
139
|
+
bin, skillDir, skillName: meta.skill, query: q.query, model: meta.model, maxExplore: meta.maxExplore, timeout,
|
|
140
|
+
host: meta.host,
|
|
141
|
+
});
|
|
142
|
+
let outcome;
|
|
143
|
+
try {
|
|
144
|
+
outcome = await executeTrials({ run, trials: todo, doTrial, workers, onTrial });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
run.close(null, { interrupted: true });
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
const result = finishRun(run, {
|
|
150
|
+
model: meta.model, backend: meta.backend, host: meta.host, runs: meta.runs, expected: queries.length * meta.runs,
|
|
151
|
+
}, outcome.stopped);
|
|
152
|
+
return { nothing: false, result, stopped: outcome.stopped, dir, meta: run.meta, total: todo.length, done: outcome.done };
|
|
153
|
+
}
|
package/lib/gain.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Layer 2: gain. "Does the skill actually help" can only be answered honestly by pairing a task
|
|
2
|
+
// with a verifier that code can decide -- a test that passes, a file that appears, a value that
|
|
3
|
+
// matches. An LLM judge would give a general answer at the cost of being expensive, biased and
|
|
4
|
+
// not reproducible, so it is deliberately not implemented.
|
|
5
|
+
//
|
|
6
|
+
// Contract DSL (contracts/<skill>.json):
|
|
7
|
+
// { "skill": "tdd",
|
|
8
|
+
// "tasks": [{ "id": "t1", "prompt": "…", "files": { "calc.py": "…" } }],
|
|
9
|
+
// "contracts":[ { "desc": "a test file was added", "type": "files_added_matching", "glob": "**/*test*",
|
|
10
|
+
// "descI18n": { "zh": "新增了测试文件", "ja": "テストファイルが追加された" } },
|
|
11
|
+
// { "desc": "tests pass", "type": "command_succeeds", "cmd": "python -m pytest -q" },
|
|
12
|
+
// { "desc": "output contains a formula", "type": "file_contains", "glob": "out.csv", "regex": "=" } ] }
|
|
13
|
+
//
|
|
14
|
+
// "desc" is English and doubles as the stable id a contract is aggregated and archived under.
|
|
15
|
+
// "descI18n" (optional) holds the same sentence for other UI languages; the report picks it by
|
|
16
|
+
// language and falls back to "desc".
|
|
17
|
+
//
|
|
18
|
+
// A skill with no contracts file is reported as "not measurable", not guessed at.
|
|
19
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const HERE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
26
|
+
const CONTRACT_DIR = path.join(HERE, 'contracts');
|
|
27
|
+
|
|
28
|
+
function copyDir(src, dst) {
|
|
29
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
30
|
+
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
|
31
|
+
if (e.name === '.git') continue;
|
|
32
|
+
const s = path.join(src, e.name);
|
|
33
|
+
const d = path.join(dst, e.name);
|
|
34
|
+
if (e.isDirectory()) copyDir(s, d);
|
|
35
|
+
else if (e.isFile()) fs.copyFileSync(s, d);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function loadContracts(skillName) {
|
|
40
|
+
const file = path.join(CONTRACT_DIR, `${skillName}.json`);
|
|
41
|
+
if (!fs.existsSync(file)) return null;
|
|
42
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function listFiles(root) {
|
|
46
|
+
const out = [];
|
|
47
|
+
const walk = (dir) => {
|
|
48
|
+
let entries = [];
|
|
49
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
if (e.name === '.claude' || e.name === '.git' || e.name === 'node_modules') continue;
|
|
52
|
+
const p = path.join(dir, e.name);
|
|
53
|
+
if (e.isDirectory()) walk(p);
|
|
54
|
+
else out.push(path.relative(root, p).replace(/\\/g, '/'));
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
walk(root);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function globToRe(glob) {
|
|
62
|
+
const body = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*\//g, '(?:.*/)?').replace(/\*/g, '[^/]*');
|
|
63
|
+
return new RegExp(`^${body}$`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function evaluateContract(contract, dir, before) {
|
|
67
|
+
const after = listFiles(dir);
|
|
68
|
+
const added = after.filter((f) => !before.includes(f));
|
|
69
|
+
switch (contract.type) {
|
|
70
|
+
case 'files_added_matching': {
|
|
71
|
+
const re = globToRe(contract.glob);
|
|
72
|
+
return added.some((f) => re.test(f));
|
|
73
|
+
}
|
|
74
|
+
case 'no_files_matching': {
|
|
75
|
+
const re = globToRe(contract.glob);
|
|
76
|
+
return !after.some((f) => re.test(f));
|
|
77
|
+
}
|
|
78
|
+
case 'file_contains': {
|
|
79
|
+
const re = globToRe(contract.glob);
|
|
80
|
+
const target = after.find((f) => re.test(f));
|
|
81
|
+
if (!target) return false;
|
|
82
|
+
try {
|
|
83
|
+
// An .xlsx is a zip, so its entry names sit in the archive uncompressed. Reading latin1 lets
|
|
84
|
+
// the same check assert that a spreadsheet is a real one without adding a zip dependency.
|
|
85
|
+
const enc = contract.encoding === 'latin1' ? 'latin1' : 'utf8';
|
|
86
|
+
return new RegExp(contract.regex).test(fs.readFileSync(path.join(dir, target), enc));
|
|
87
|
+
} catch { return false; }
|
|
88
|
+
}
|
|
89
|
+
case 'command_succeeds': {
|
|
90
|
+
try {
|
|
91
|
+
execFileSync(contract.cmd, { cwd: dir, shell: true, stdio: 'ignore', timeout: 120000 });
|
|
92
|
+
return true;
|
|
93
|
+
} catch { return false; }
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function runTask({ bin, model, task, skillDir, skillName, timeout }) {
|
|
101
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-gain-'));
|
|
102
|
+
for (const [rel, content] of Object.entries(task.files || {})) {
|
|
103
|
+
const p = path.join(tmp, rel);
|
|
104
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
105
|
+
fs.writeFileSync(p, content);
|
|
106
|
+
}
|
|
107
|
+
if (skillDir) copyDir(skillDir, path.join(tmp, '.claude', 'skills', skillName));
|
|
108
|
+
const before = listFiles(tmp);
|
|
109
|
+
const args = ['-p', task.prompt, '--permission-mode', 'dontAsk', '--setting-sources', 'project',
|
|
110
|
+
'--strict-mcp-config', '--no-session-persistence'];
|
|
111
|
+
if (model) args.push('--model', model);
|
|
112
|
+
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const child = spawn(bin, args, {
|
|
115
|
+
cwd: tmp, shell: /\.(cmd|bat)$/i.test(bin), env: process.env, windowsHide: true,
|
|
116
|
+
});
|
|
117
|
+
const timer = setTimeout(() => {
|
|
118
|
+
try {
|
|
119
|
+
if (process.platform === 'win32') execFileSync('taskkill', ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore' });
|
|
120
|
+
else child.kill('SIGKILL');
|
|
121
|
+
} catch { /* ignore */ }
|
|
122
|
+
}, timeout * 1000);
|
|
123
|
+
child.on('close', () => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
resolve({ dir: tmp, before });
|
|
126
|
+
});
|
|
127
|
+
child.on('error', () => { clearTimeout(timer); resolve({ dir: tmp, before }); });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function runGain({ bin, skillDir, skillName, model, timeout = 600, onProgress }) {
|
|
132
|
+
const spec = loadContracts(skillName);
|
|
133
|
+
const hasContracts = spec?.contracts?.length || spec?.tasks?.some((t) => t.contracts?.length);
|
|
134
|
+
if (!spec || !hasContracts || !spec.tasks?.length) {
|
|
135
|
+
return { measurable: false, reasonKey: 'gain.noContracts' };
|
|
136
|
+
}
|
|
137
|
+
const detail = [];
|
|
138
|
+
for (const task of spec.tasks) {
|
|
139
|
+
// Contracts belong to the task that can satisfy them: "a divide function was added" only makes
|
|
140
|
+
// sense for the task that asks for one. A skill-level list is still allowed for contracts that
|
|
141
|
+
// genuinely apply to every task.
|
|
142
|
+
const contracts = task.contracts?.length ? task.contracts : (spec.contracts || []);
|
|
143
|
+
const row = { id: task.id, prompt: task.prompt, with: null, without: null };
|
|
144
|
+
for (const condition of ['with', 'without']) {
|
|
145
|
+
if (onProgress) onProgress(task.id, condition);
|
|
146
|
+
const { dir, before } = await runTask({
|
|
147
|
+
bin, model, task,
|
|
148
|
+
skillDir: condition === 'with' ? skillDir : null,
|
|
149
|
+
skillName, timeout,
|
|
150
|
+
});
|
|
151
|
+
const added = listFiles(dir).filter((f) => !before.includes(f));
|
|
152
|
+
const checks = contracts.map((c) => ({ desc: c.desc, descI18n: c.descI18n, ok: Boolean(evaluateContract(c, dir, before)) }));
|
|
153
|
+
row[condition] = { added, checks };
|
|
154
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
155
|
+
}
|
|
156
|
+
detail.push(row);
|
|
157
|
+
}
|
|
158
|
+
// Aggregate by description across the tasks that use it.
|
|
159
|
+
const agg = new Map();
|
|
160
|
+
for (const row of detail) {
|
|
161
|
+
for (const condition of ['with', 'without']) {
|
|
162
|
+
for (const c of row[condition].checks) {
|
|
163
|
+
const e = agg.get(c.desc) || { with: 0, without: 0, n: 0, descI18n: c.descI18n };
|
|
164
|
+
e[condition] += c.ok ? 1 : 0;
|
|
165
|
+
e.n += 1;
|
|
166
|
+
agg.set(c.desc, e);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
measurable: true,
|
|
172
|
+
localOnly: true,
|
|
173
|
+
tasks: detail,
|
|
174
|
+
contracts: [...agg].map(([desc, e]) => ({ desc, descI18n: e.descI18n, with: e.with / e.n, without: e.without / e.n })),
|
|
175
|
+
taskCount: detail.length,
|
|
176
|
+
};
|
|
177
|
+
}
|