skillgauntlet 1.1.1 → 1.2.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/README.md +30 -1
- package/bin/skillgauntlet.js +325 -131
- package/lib/browse.js +58 -0
- package/lib/chart.js +157 -0
- package/lib/depth.js +4 -1
- package/lib/discover.js +27 -2
- package/lib/findhost.js +83 -0
- package/lib/i18n.js +384 -36
- package/lib/models.js +38 -0
- package/lib/report.js +1 -1
- package/lib/schema.js +90 -0
- package/lib/scrub.js +186 -0
- package/lib/static.js +22 -11
- package/lib/submission.js +325 -0
- package/lib/submit.js +208 -0
- package/lib/ticker.js +39 -0
- package/lib/trial.js +27 -0
- package/lib/ui.js +40 -2
- package/package.json +4 -2
- package/schemas/submission-1.json +210 -0
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ can be compared.
|
|
|
28
28
|
- [Codex probe](step0/data/2026-10-28_codex_probe/BATCH.md): facts only, no trigger rate (see
|
|
29
29
|
"Hosts and models").
|
|
30
30
|
- [Static layer](step0/data/2026-10-28_all_haiku/static/SUMMARY.md): all 18 skills, no model calls.
|
|
31
|
+
- [Leaderboard](LEADERBOARD.md): the maintainer's batches and community submissions in one generated page, one table per host.
|
|
31
32
|
|
|
32
33
|
## Usage
|
|
33
34
|
|
|
@@ -103,6 +104,34 @@ and appeal is the one thing a rating must allow.
|
|
|
103
104
|
- **E description quality** — presence, whether it states when to use it, over-broad quantifiers,
|
|
104
105
|
rough clause count, name/directory agreement.
|
|
105
106
|
|
|
107
|
+
## Submit your results
|
|
108
|
+
|
|
109
|
+
The leaderboard data has to come from people running the tests, so a finished run can be contributed with one
|
|
110
|
+
command. Results stay on your machine until you run it; nothing is uploaded automatically.
|
|
111
|
+
|
|
112
|
+
1. Run a test the normal way (`npx skillgauntlet`). The run is saved under `<skill>/.skillgauntlet/runs/<timestamp>/`
|
|
113
|
+
and its path is printed. The skill must come from a public git repository, with the commit you tested
|
|
114
|
+
pushed and no uncommitted edits to the skill. To help confirm an existing row of the leaderboard, test the
|
|
115
|
+
commit that row's Skill link points to: a row is for one pinned commit.
|
|
116
|
+
2. Preview what would be submitted, with nothing uploaded: `npx skillgauntlet submit <run-dir> --dry-run`.
|
|
117
|
+
It checks the run against [`schemas/submission-1.json`](schemas/submission-1.json), strips local paths,
|
|
118
|
+
your login name, environment values and anything shaped like a key or token, refuses to continue if
|
|
119
|
+
something secret-looking remains, and prints exactly what would go (the package is also left on disk to read).
|
|
120
|
+
3. Submit: `npx skillgauntlet submit <run-dir>`. After you confirm, it forks this repository under your account,
|
|
121
|
+
pushes a branch and opens a pull request as you, using the GitHub CLI (`gh`). Without `gh`, or when it is not
|
|
122
|
+
logged in, it prints the manual steps instead.
|
|
123
|
+
4. Sign the [CLA](CLA.md) when the bot asks (once). CI then comments on the pull request with a pass, or with
|
|
124
|
+
exactly what is missing.
|
|
125
|
+
|
|
126
|
+
A merged submission is a **community single sample**. It becomes **confirmed** when another independent
|
|
127
|
+
submission, or a maintainer run, lands on the same grade for the same skill version, host and model.
|
|
128
|
+
Disagreeing results are kept side by side. See [`LEADERBOARD.md`](LEADERBOARD.md) for the current rows and what
|
|
129
|
+
each tag means, and [CONTRIBUTING.md](CONTRIBUTING.md#submitting-results) for the details, including what CI
|
|
130
|
+
checks and what it cannot.
|
|
131
|
+
|
|
132
|
+
The runner cannot prevent forgery. CI checks format, hashes and arithmetic; it cannot show that the trials
|
|
133
|
+
were really run. The tags say how many submissions agree, not that any of them is proven.
|
|
134
|
+
|
|
106
135
|
## Status
|
|
107
136
|
|
|
108
137
|
Early stage, and honestly labelled.
|
|
@@ -153,7 +182,7 @@ login it could reuse.
|
|
|
153
182
|
|
|
154
183
|
**CodeBuddy Code** (the command line behind Tencent's WorkBuddy) is a second host with a measurable trigger rate: a skill call appears in its stream as a `tool_use` named `Skill`, the same shape as in Claude Code, which was confirmed on a live run. It spends the quota of its own account (log in once with `/login` inside `codebuddy`), runs the trigger layer only, and is launched with a restricted tool list because its default one has 60+ tools. A rate measured here is a CodeBuddy number and is never merged with a Claude Code one.
|
|
155
184
|
|
|
156
|
-
**Codewhale** (formerly DeepSeek-TUI, runs DeepSeek models) is a third host with a measurable trigger rate. It reads a skill through a `load_skill` tool, so the first `load_skill` call whose `input.name` is the tested skill counts as a trigger; a search (`input.query`) or a catalogue listing does not. Trials run in a throw-away `HOME` that holds only the tested skill, with `load_skill` as the only tool (its `--auto` mode has no execution sandbox on Windows). It needs `codewhale` on `PATH` (
|
|
185
|
+
**Codewhale** (formerly DeepSeek-TUI, runs DeepSeek models) is a third host with a measurable trigger rate. It reads a skill through a `load_skill` tool, so the first `load_skill` call whose `input.name` is the tested skill counts as a trigger; a search (`input.query`) or a catalogue listing does not. Trials run in a throw-away `HOME` that holds only the tested skill, with `load_skill` as the only tool (its `--auto` mode has no execution sandbox on Windows). It needs `codewhale` on `PATH` (`npm install -g codewhale --allow-scripts=codewhale`, which the interactive mode can run for you) and `DEEPSEEK_API_KEY` in the environment. A rate measured here is a Codewhale number and is never merged with another host's.
|
|
157
186
|
|
|
158
187
|
**Codex is supported for what it exposes, which is a smaller surface:**
|
|
159
188
|
|
package/bin/skillgauntlet.js
CHANGED
|
@@ -6,17 +6,24 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { analyze } from '../lib/static.js';
|
|
8
8
|
import { staticMarkdown, trialSection, gainSection, probeSection, scorecard } from '../lib/report.js';
|
|
9
|
-
import { findSkills, resolveSpec } from '../lib/discover.js';
|
|
9
|
+
import { findSkills, resolveSpec, skillNameOf } from '../lib/discover.js';
|
|
10
10
|
import { installedSkills } from '../lib/installed.js';
|
|
11
11
|
import { probeActivation } from '../lib/codex.js';
|
|
12
12
|
import { fingerprint, cacheFileFor, readCachedQueries, writeCachedQueries } from '../lib/queries.js';
|
|
13
|
-
import { openRun, querySetHash } from '../lib/runs.js';
|
|
14
|
-
import { DEPTHS,
|
|
15
|
-
import { ask, choose, select, confirm, say, closeUi } from '../lib/ui.js';
|
|
13
|
+
import { openRun, querySetHash, readRun, validRecords } from '../lib/runs.js';
|
|
14
|
+
import { DEPTHS, minutesFor } from '../lib/depth.js';
|
|
15
|
+
import { ask, choose, select, confirm, say, closeUi, hasTerminal, setBack, Back } from '../lib/ui.js';
|
|
16
|
+
import { hostModels } from '../lib/models.js';
|
|
17
|
+
import { browseDir } from '../lib/browse.js';
|
|
18
|
+
import { withTicker } from '../lib/ticker.js';
|
|
19
|
+
import { renderResult, bar } from '../lib/chart.js';
|
|
16
20
|
import { setLang, detectLang, errorText, t } from '../lib/i18n.js';
|
|
17
|
-
import { resolveBin, backend, backendFor, isDeepseekModel, HOSTS, checkModel, draftQueries, draftQuality, runTrial, requireHost } from '../lib/trial.js';
|
|
21
|
+
import { resolveBin, backend, backendFor, isDeepseekModel, HOSTS, checkModel, draftQueries, draftQuality, runTrial, requireHost, pingHost, setHostBin, binInstalled } from '../lib/trial.js';
|
|
22
|
+
import { HOST_CLI, searchHost, installCommand, installHost } from '../lib/findhost.js';
|
|
18
23
|
import { runGain } from '../lib/gain.js';
|
|
19
24
|
import { ERROR_STREAK_STOP, executeTrials, finishRun, resumeRun } from '../lib/execute.js';
|
|
25
|
+
import { cmdSubmit } from '../lib/submit.js';
|
|
26
|
+
import { gitFacts } from '../lib/submission.js';
|
|
20
27
|
|
|
21
28
|
function relLabel(root, dir) {
|
|
22
29
|
const rel = path.relative(root, dir).replace(/\\/g, '/');
|
|
@@ -39,9 +46,92 @@ function writeOut(skillDir, name, md, sc) {
|
|
|
39
46
|
return out;
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
// The host CLI is not on PATH. Look for it elsewhere on the machine, offer to install it where an installer
|
|
50
|
+
// exists, and otherwise let the user pick another host. True when the host can now be used.
|
|
51
|
+
async function fixMissingHost(hostId, err) {
|
|
52
|
+
const { bin } = HOST_CLI[hostId];
|
|
53
|
+
const label = err.vars?.host || hostId;
|
|
54
|
+
say(`\n${t('hostNotOnPath', { host: label })}`);
|
|
55
|
+
const options = withTicker(waitText(t('waitSearch', { host: label }), { slowAfter: 600 }), () => searchHost(hostId)).slice(0, 3).map((f) => ({ label: t('hostUseFound', { path: f }), value: { use: f } }));
|
|
56
|
+
const cmd = installCommand(hostId);
|
|
57
|
+
if (cmd) options.push({ label: t('hostInstallNow', { cmd }), value: { install: true } });
|
|
58
|
+
else say(t(err.howKey));
|
|
59
|
+
options.push({ label: t('hostOther'), value: { other: true } });
|
|
60
|
+
|
|
61
|
+
const pick = await select(t('hostFixQ'), options, 0);
|
|
62
|
+
if (pick.use) {
|
|
63
|
+
setHostBin(bin, pick.use);
|
|
64
|
+
say(` ${pick.use}`);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (pick.install) {
|
|
68
|
+
say(`\n${t('hostInstalling', { host: label })}`);
|
|
69
|
+
try { installHost(hostId); } catch (e) {
|
|
70
|
+
say(t('hostInstallFailed', { why: e.message }));
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (binInstalled(bin)) return true;
|
|
74
|
+
const again = searchHost(hostId)[0]; // npm's folder may not be on this process's PATH yet
|
|
75
|
+
if (again) { setHostBin(bin, again); return true; }
|
|
76
|
+
say(t('hostStillMissing', { host: label }));
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let approvedFollowing = false; // the user said not to ask again for the remaining skills
|
|
82
|
+
|
|
83
|
+
async function approveTrials({ q, runs, n }) {
|
|
84
|
+
if (approvedFollowing) return true;
|
|
85
|
+
const pick = await choose(t('trialsQ', { q, runs, n, minutes: minutesFor(n) }), [
|
|
86
|
+
{ label: t('trialsGo'), value: 'go' },
|
|
87
|
+
{ label: t('trialsAll'), value: 'all' },
|
|
88
|
+
{ label: t('trialsSkip'), value: 'skip' },
|
|
89
|
+
], 1);
|
|
90
|
+
if (pick === 'all') approvedFollowing = true;
|
|
91
|
+
return pick !== 'skip';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const hostChecked = new Set();
|
|
95
|
+
const finishedRuns = []; // run directories of complete runs, offered for contribution at the end
|
|
96
|
+
const remoteSource = new Map(); // skill directory -> the public repository, commit and subfolder it was cloned from
|
|
97
|
+
|
|
98
|
+
// The arguments `submit` needs for a run. A skill cloned from GitHub in this session already has its repository,
|
|
99
|
+
// commit and subfolder, so the user is not asked for them; other skills are looked up from their own git checkout.
|
|
100
|
+
function submitArgs(runDir) {
|
|
101
|
+
const skillDir = path.resolve(runDir, '..', '..', '..');
|
|
102
|
+
const src = remoteSource.get(skillDir);
|
|
103
|
+
return src ? [runDir, '--repo', src.repo, '--commit', src.commit, ...(src.subdir ? ['--subdir', src.subdir] : [])] : [runDir];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Whether the public repository and commit of a run's skill can be told at all.
|
|
107
|
+
function canSubmit(runDir) {
|
|
108
|
+
const skillDir = path.resolve(runDir, '..', '..', '..');
|
|
109
|
+
if (remoteSource.has(skillDir)) return true;
|
|
110
|
+
const f = gitFacts(skillDir);
|
|
111
|
+
return Boolean(f.repo && f.commit);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const quoteArg = (a) => (/[\s"]/.test(a) ? `"${a}"` : a);
|
|
115
|
+
const waitText = (label, extra = {}) => ({ label, elapsed: t('waitElapsed'), abortHint: t('waitAbort'), ...extra });
|
|
116
|
+
|
|
117
|
+
// Asking a host CLI for its models takes a few seconds (CodeBuddy about three), so it shows a wait line and is
|
|
118
|
+
// remembered for the rest of the run.
|
|
119
|
+
const modelCache = new Map();
|
|
120
|
+
function modelsOf(host, label) {
|
|
121
|
+
if (!modelCache.has(host)) {
|
|
122
|
+
modelCache.set(host, withTicker(waitText(t('waitModels', { host: label }), { slowAfter: 600 }), () => hostModels(host, resolveBin(host))));
|
|
123
|
+
}
|
|
124
|
+
return modelCache.get(host);
|
|
125
|
+
}
|
|
126
|
+
const HOST_LABEL = { 'claude-code': 'Claude Code', codebuddy: 'CodeBuddy', codewhale: 'Codewhale' };
|
|
127
|
+
|
|
42
128
|
async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'claude-code' }) {
|
|
43
129
|
requireHost(host);
|
|
44
130
|
const bin = resolveBin(HOSTS[host].bin);
|
|
131
|
+
if (!hostChecked.has(host)) {
|
|
132
|
+
withTicker(waitText(t('hostPing', { host: HOST_LABEL[host] || host })), () => pingHost(host, { bin, model }));
|
|
133
|
+
hostChecked.add(host);
|
|
134
|
+
}
|
|
45
135
|
const cacheFile = cacheFileFor(skillDir, depth);
|
|
46
136
|
const want = { perClause: depth.perClause, negatives: depth.negatives, skillFingerprint: fingerprint(skillDir) };
|
|
47
137
|
|
|
@@ -50,8 +140,7 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
|
|
|
50
140
|
if (qs) {
|
|
51
141
|
say(` ${t('queriesReused', { n: qs.queries.length, file: path.basename(cacheFile) })}`);
|
|
52
142
|
} else {
|
|
53
|
-
|
|
54
|
-
qs = draftQueries({ bin, skillDir, skillName, model, perClause: depth.perClause, negatives: depth.negatives, host });
|
|
143
|
+
qs = withTicker(waitText(t('drafting')), () => draftQueries({ bin, skillDir, skillName, model, perClause: depth.perClause, negatives: depth.negatives, host }));
|
|
55
144
|
say(` ${t('drafted', { q: qs.queries.length, c: qs.clauses.length })}`);
|
|
56
145
|
writeCachedQueries(cacheFile, {
|
|
57
146
|
schema: 'skillgauntlet/queries/1',
|
|
@@ -74,6 +163,12 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
|
|
|
74
163
|
const trials = [];
|
|
75
164
|
for (const q of qs.queries) for (let i = 0; i < depth.runs; i += 1) trials.push({ q, i });
|
|
76
165
|
|
|
166
|
+
// The number of trials is only known now that the questions exist, so this is where the user agrees to it.
|
|
167
|
+
if (!(await approveTrials({ q: qs.queries.length, runs: depth.runs, n: trials.length }))) {
|
|
168
|
+
say(` ${t('trialsDeclined')}`);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
77
172
|
// Opened before the first trial: an interrupted run keeps its conditions and every finished record,
|
|
78
173
|
// which is what makes a later recompute or comparison possible without paying again.
|
|
79
174
|
const run = openRun({
|
|
@@ -109,7 +204,8 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
|
|
|
109
204
|
say(` ${t('runSaved', { path: run.dir })}`);
|
|
110
205
|
if (outcome.stopped) reportStopped(outcome.stopped, run.dir, model);
|
|
111
206
|
else if (result?.incomplete) reportIncomplete(result.incomplete, run.dir, model);
|
|
112
|
-
|
|
207
|
+
else finishedRuns.push(run.dir);
|
|
208
|
+
return result && { ...result, runDir: run.dir };
|
|
113
209
|
} catch (err) {
|
|
114
210
|
run.close(null, { interrupted: true });
|
|
115
211
|
throw err;
|
|
@@ -144,6 +240,11 @@ function archiveProbe({ skillDir, skillName, model, probe }) {
|
|
|
144
240
|
|
|
145
241
|
function progress() {
|
|
146
242
|
return ({ row, done, total }) => {
|
|
243
|
+
// On a terminal one line is redrawn in place; the verdicts of single trials belong to the result, not the wait.
|
|
244
|
+
if (process.stdout.isTTY) {
|
|
245
|
+
process.stdout.write(`\r\x1b[2K ${t('progressLine')} ${bar(done / total, 24)} ${done}/${total}${done >= total ? '\n' : ''}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
147
248
|
const mark = { triggered: '✓', not_triggered: '·', other_skill: '↷' }[row.verdict] || '!';
|
|
148
249
|
say(` [${done}/${total}] ${mark} ${row.queryId}#${row.run} → ${row.verdict}`);
|
|
149
250
|
};
|
|
@@ -206,6 +307,19 @@ async function pickInstalled() {
|
|
|
206
307
|
return hit ? [{ name: hit.name, dir: hit.dir, root: hit.root }] : [];
|
|
207
308
|
}
|
|
208
309
|
|
|
310
|
+
// A model for hosts that have an account default: pick it, a known name, or type one. Returns null for the default.
|
|
311
|
+
async function pickModel(known = []) {
|
|
312
|
+
if (!hasTerminal()) return (await ask(t('modelOptionalQ'), '')).trim() || null;
|
|
313
|
+
const other = '\u0000other';
|
|
314
|
+
const v = await select(t('modelQ'), [
|
|
315
|
+
{ label: t('modelDefault'), value: '' },
|
|
316
|
+
...known.map((m) => ({ label: m, value: m })),
|
|
317
|
+
{ label: t('modelOther'), value: other },
|
|
318
|
+
], 0);
|
|
319
|
+
if (v === other) return (await ask(t('modelOptionalQ'), '')).trim() || null;
|
|
320
|
+
return v || null;
|
|
321
|
+
}
|
|
322
|
+
|
|
209
323
|
async function pickRemote() {
|
|
210
324
|
for (;;) {
|
|
211
325
|
const raw = await ask(t('askRepo'));
|
|
@@ -216,32 +330,42 @@ async function pickRemote() {
|
|
|
216
330
|
}
|
|
217
331
|
let resolved;
|
|
218
332
|
try {
|
|
219
|
-
resolved = resolveSpec(parsed.repo);
|
|
333
|
+
resolved = withTicker(waitText(t('waitCloning'), { slowAfter: 600 }), () => resolveSpec(parsed.repo));
|
|
220
334
|
} catch (err) {
|
|
221
335
|
say(` ${errorText(err)}`);
|
|
222
336
|
continue;
|
|
223
337
|
}
|
|
224
338
|
const found = findSkills(resolved.dir);
|
|
339
|
+
for (const d of found) {
|
|
340
|
+
remoteSource.set(path.resolve(d), { repo: resolved.source.repo, commit: resolved.source.commit, subdir: path.relative(resolved.dir, d).replace(/\\/g, '/') });
|
|
341
|
+
}
|
|
225
342
|
if (!found.length) {
|
|
226
343
|
say(` ${t('noSkill')}`);
|
|
227
344
|
return [];
|
|
228
345
|
}
|
|
229
346
|
if (parsed.hint) {
|
|
230
|
-
const hit = found.find((d) => relLabel(resolved.dir, d) === parsed.hint || d.endsWith(`/${parsed.hint}`));
|
|
347
|
+
const hit = found.find((d) => relLabel(resolved.dir, d) === parsed.hint || d.replace(/\\/g, '/').endsWith(`/${parsed.hint}`));
|
|
231
348
|
if (hit) return [{ name: path.basename(hit), dir: hit, root: resolved.dir }];
|
|
232
349
|
say(` ${t('notFoundNamed', { name: parsed.hint, list: found.map((d) => relLabel(resolved.dir, d)).slice(0, 8).join(', ') })}`);
|
|
233
350
|
return [];
|
|
234
351
|
}
|
|
235
352
|
if (found.length === 1) return [{ name: path.basename(found[0]), dir: found[0], root: resolved.dir }];
|
|
236
|
-
say(
|
|
237
|
-
|
|
238
|
-
const hit = found.find((d) => path.basename(d) === name.trim());
|
|
239
|
-
if (hit) return [{ name: path.basename(hit), dir: hit, root: resolved.dir }];
|
|
240
|
-
say(` ${t('notFoundNamed', { name: name.trim(), list: found.map((d) => relLabel(resolved.dir, d)).slice(0, 8).join(', ') })}`);
|
|
241
|
-
return [];
|
|
353
|
+
say(`\n${t('multiFound', { n: found.length })}`);
|
|
354
|
+
return pickAmong(found, resolved.dir);
|
|
242
355
|
}
|
|
243
356
|
}
|
|
244
357
|
|
|
358
|
+
// Several skills in one place: all of them, or one picked from the full list.
|
|
359
|
+
async function pickAmong(found, root) {
|
|
360
|
+
const scope = await choose(t('scopeQ'), [
|
|
361
|
+
{ label: t('scopeAll'), hint: t('scopeAllHint', { n: found.length }), value: 'all' },
|
|
362
|
+
{ label: t('scopeOne'), hint: t('scopeOneHint'), value: 'one' },
|
|
363
|
+
], 2);
|
|
364
|
+
if (scope === 'all') return found.map((d) => ({ name: path.basename(d), dir: d, root }));
|
|
365
|
+
const dir = await select(t('pickOneQ'), found.map((d) => ({ label: relLabel(root, d), value: d })), 0);
|
|
366
|
+
return [{ name: path.basename(dir), dir, root }];
|
|
367
|
+
}
|
|
368
|
+
|
|
245
369
|
async function interactive() {
|
|
246
370
|
const langChoice = await select(`${t('langTitle')}`, [
|
|
247
371
|
{ label: t('langZh'), value: 'zh' },
|
|
@@ -254,130 +378,167 @@ async function interactive() {
|
|
|
254
378
|
say(t('title'));
|
|
255
379
|
say(t('rule'));
|
|
256
380
|
|
|
257
|
-
|
|
258
|
-
{ label: t('srcInstalled'), hint: t('srcInstalledHint'), value: 'installed' },
|
|
259
|
-
{ label: t('srcPath'), value: 'path' },
|
|
260
|
-
{ label: t('srcRepo'), hint: 'owner/repo', value: 'repo' },
|
|
261
|
-
], 1);
|
|
381
|
+
setBack(true);
|
|
262
382
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const found = findSkills(root);
|
|
268
|
-
if (!found.length) say(`\n${t('noSkill')}`);
|
|
269
|
-
else if (found.length === 1) targets = [{ name: path.basename(found[0]), dir: found[0], root }];
|
|
270
|
-
else {
|
|
271
|
-
const name = await select(t('pickOneQ'), found.map((d) => ({ label: relLabel(root, d), value: d })), 0);
|
|
272
|
-
targets = [{ name: path.basename(name), dir: name, root }];
|
|
273
|
-
}
|
|
274
|
-
} else targets = await pickRemote();
|
|
275
|
-
if (!targets.length) return;
|
|
276
|
-
|
|
277
|
-
const host = await choose(t('hostQ'), [
|
|
278
|
-
{ label: t('hostClaude'), hint: t('hostClaudeHint'), value: 'claude' },
|
|
279
|
-
{ label: t('hostCodebuddy'), hint: t('hostCodebuddyHint'), value: 'codebuddy' },
|
|
280
|
-
{ label: t('hostCodewhale'), hint: t('hostCodewhaleHint'), value: 'codewhale' },
|
|
281
|
-
{ label: t('hostCodex'), hint: t('hostCodexHint'), value: 'codex' },
|
|
282
|
-
], 1);
|
|
383
|
+
// The questions run as steps so ← or Esc can return to the previous one (a step that ends the run returns END).
|
|
384
|
+
const END = Symbol('end');
|
|
385
|
+
const AGAIN = Symbol('again');
|
|
386
|
+
const S = { targets: [], host: null, depth: null, model: null, wantProbe: false };
|
|
283
387
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const depthId = await choose(t('depthQ'), [
|
|
290
|
-
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
291
|
-
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
292
|
-
{ label: t('depthDeep'), hint: t('depthDeepHint'), value: 'deep' },
|
|
293
|
-
], 2);
|
|
294
|
-
depth = DEPTHS[depthId];
|
|
295
|
-
|
|
296
|
-
const provider = await choose(t('providerQ'), [
|
|
297
|
-
{ label: t('provClaude'), hint: t('provClaudeHint'), value: 'claude' },
|
|
298
|
-
{ label: t('provDeepseek'), hint: t('provDeepseekHint'), value: 'deepseek' },
|
|
299
|
-
{ label: t('provManual'), value: 'manual' },
|
|
388
|
+
const sourceStep = async () => {
|
|
389
|
+
const source = await choose(t('sourceQ'), [
|
|
390
|
+
{ label: t('srcInstalled'), hint: t('srcInstalledHint'), value: 'installed' },
|
|
391
|
+
{ label: t('srcPath'), value: 'path' },
|
|
392
|
+
{ label: t('srcRepo'), hint: 'owner/repo', value: 'repo' },
|
|
300
393
|
], 1);
|
|
301
394
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
395
|
+
let targets = [];
|
|
396
|
+
if (source === 'installed') targets = await pickInstalled();
|
|
397
|
+
else if (source === 'path') {
|
|
398
|
+
// Arrow keys walk the folders; typing a path stays available as the last entry (and is all a pipe has).
|
|
399
|
+
const browsed = hasTerminal() ? await browseDir((a, b, c) => select(a, b, c, { clear: true })) : null;
|
|
400
|
+
if (browsed) say(` ${browsed}`);
|
|
401
|
+
const root = path.resolve(browsed ?? await ask(t('srcPath')));
|
|
402
|
+
const found = findSkills(root);
|
|
403
|
+
if (!found.length) say(`\n${t('noSkill')}`);
|
|
404
|
+
else if (found.length === 1) targets = [{ name: path.basename(found[0]), dir: found[0], root }];
|
|
405
|
+
else {
|
|
406
|
+
say(`\n${t('multiFound', { n: found.length })}`);
|
|
407
|
+
targets = await pickAmong(found, root);
|
|
312
408
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
minutes: est.minutes,
|
|
326
|
-
totalMinutes: est.minutes * targets.length,
|
|
327
|
-
})}`);
|
|
328
|
-
} else if (host === 'codewhale') {
|
|
329
|
-
say(`\n${t('codewhaleNote')}`);
|
|
330
|
-
const depthId = await choose(t('depthQ'), [
|
|
331
|
-
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
332
|
-
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
409
|
+
} else targets = await pickRemote();
|
|
410
|
+
if (!targets.length) return hasTerminal() ? AGAIN : END;
|
|
411
|
+
S.targets = targets.map((tg) => ({ ...tg, name: skillNameOf(tg.dir) }));
|
|
412
|
+
return null;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const hostStep = async () => {
|
|
416
|
+
S.host = await choose(t('hostQ'), [
|
|
417
|
+
{ label: t('hostClaude'), hint: t('hostClaudeHint'), value: 'claude' },
|
|
418
|
+
{ label: t('hostCodebuddy'), hint: t('hostCodebuddyHint'), value: 'codebuddy' },
|
|
419
|
+
{ label: t('hostCodewhale'), hint: t('hostCodewhaleHint'), value: 'codewhale' },
|
|
420
|
+
{ label: t('hostCodex'), hint: t('hostCodexHint'), value: 'codex' },
|
|
333
421
|
], 1);
|
|
334
|
-
|
|
335
|
-
model
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
422
|
+
|
|
423
|
+
// Look for the host CLI before any question about depth or model: a missing one ends the run here, for free.
|
|
424
|
+
const hostId = { claude: 'claude-code', codebuddy: 'codebuddy', codewhale: 'codewhale' }[S.host];
|
|
425
|
+
if (hostId) {
|
|
426
|
+
try { requireHost(hostId); } catch (err) {
|
|
427
|
+
if (!(await fixMissingHost(hostId, err))) return AGAIN;
|
|
428
|
+
}
|
|
339
429
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
430
|
+
return null;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const optionsStep = async () => {
|
|
434
|
+
const { host, targets } = S;
|
|
435
|
+
S.depth = null;
|
|
436
|
+
S.model = null;
|
|
437
|
+
S.wantProbe = false;
|
|
438
|
+
|
|
439
|
+
if (host === 'claude') {
|
|
440
|
+
const depthId = await choose(t('depthQ'), [
|
|
441
|
+
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
442
|
+
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
443
|
+
{ label: t('depthDeep'), hint: t('depthDeepHint'), value: 'deep' },
|
|
444
|
+
], 2);
|
|
445
|
+
S.depth = DEPTHS[depthId];
|
|
446
|
+
|
|
447
|
+
const provider = await choose(t('providerQ'), [
|
|
448
|
+
{ label: t('provClaude'), hint: t('provClaudeHint'), value: 'claude' },
|
|
449
|
+
{ label: t('provDeepseek'), hint: t('provDeepseekHint'), value: 'deepseek' },
|
|
450
|
+
{ label: t('provManual'), value: 'manual' },
|
|
451
|
+
], 1);
|
|
452
|
+
|
|
453
|
+
let model;
|
|
454
|
+
if (provider === 'claude') {
|
|
455
|
+
model = await choose(t('modelQ'), [
|
|
456
|
+
{ label: 'haiku', hint: t('modelCheap'), value: 'haiku' },
|
|
457
|
+
{ label: 'sonnet', hint: t('modelDaily'), value: 'sonnet' },
|
|
458
|
+
{ label: 'opus', hint: t('modelExpensive'), value: 'opus' },
|
|
459
|
+
], 1);
|
|
460
|
+
} else if (provider === 'deepseek') {
|
|
461
|
+
if (!process.env.ANTHROPIC_BASE_URL) {
|
|
462
|
+
say(`\n${t('needBaseUrl')}`);
|
|
463
|
+
return END;
|
|
464
|
+
}
|
|
465
|
+
model = await choose(t('modelQ'), [
|
|
466
|
+
{ label: 'deepseek-v4-flash', value: 'deepseek-v4-flash' },
|
|
467
|
+
{ label: 'deepseek-v4-pro', value: 'deepseek-v4-pro' },
|
|
468
|
+
], 1);
|
|
469
|
+
} else model = await ask(t('modelQ'));
|
|
470
|
+
checkModel(model, false);
|
|
471
|
+
S.model = model;
|
|
472
|
+
say(`\n${t('backendLine', { backend: backend(), model })}`);
|
|
473
|
+
|
|
474
|
+
say(`\n${t('draftFirst')}`);
|
|
475
|
+
} else if (host === 'codewhale') {
|
|
476
|
+
say(`\n${t('codewhaleNote')}`);
|
|
477
|
+
const depthId = await choose(t('depthQ'), [
|
|
478
|
+
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
479
|
+
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
480
|
+
], 1);
|
|
481
|
+
S.depth = DEPTHS[depthId];
|
|
482
|
+
S.model = (await pickModel(['deepseek-flash'])) || 'deepseek-flash';
|
|
483
|
+
if (!process.env.DEEPSEEK_API_KEY) {
|
|
484
|
+
say(`\n${t('needDeepseekKey')}`);
|
|
485
|
+
return END;
|
|
486
|
+
}
|
|
487
|
+
say(`\n${t('draftFirst')}`);
|
|
488
|
+
} else if (host === 'codebuddy') {
|
|
489
|
+
say(`\n${t('codebuddyNote')}`);
|
|
490
|
+
const depthId = await choose(t('depthQ'), [
|
|
491
|
+
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
492
|
+
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
493
|
+
], 1);
|
|
494
|
+
S.depth = DEPTHS[depthId];
|
|
495
|
+
S.model = await pickModel(modelsOf('codebuddy', 'CodeBuddy'));
|
|
496
|
+
if (isDeepseekModel(S.model) && !process.env.DEEPSEEK_API_KEY) {
|
|
497
|
+
say(`\n${t('needDeepseekKey')}`);
|
|
498
|
+
return END;
|
|
499
|
+
}
|
|
500
|
+
say(`\n${t('draftFirst')}`);
|
|
501
|
+
} else {
|
|
502
|
+
say(`\n${t('codexNote')}`);
|
|
503
|
+
S.model = await pickModel(modelsOf('codex', 'Codex'));
|
|
504
|
+
if (isDeepseekModel(S.model) && !process.env.DEEPSEEK_API_KEY) {
|
|
505
|
+
say(`\n${t('needDeepseekKey')}`);
|
|
506
|
+
return END;
|
|
507
|
+
}
|
|
508
|
+
S.wantProbe = await confirm(t('probeQ'), false);
|
|
509
|
+
say(`\n${S.wantProbe
|
|
510
|
+
? t('codexEstimate', { skills: targets.length, runs: 2 })
|
|
511
|
+
: t('codexStaticOnly', { skills: targets.length })}`);
|
|
353
512
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
return;
|
|
513
|
+
return null;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const confirmStep = async () => {
|
|
517
|
+
if (!(await confirm(t('confirmRun'), false))) {
|
|
518
|
+
say(t('cancelled'));
|
|
519
|
+
return END;
|
|
362
520
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
? t('codexEstimate', { skills: targets.length, runs: 2 })
|
|
366
|
-
: t('codexStaticOnly', { skills: targets.length })}`);
|
|
367
|
-
}
|
|
521
|
+
return null;
|
|
522
|
+
};
|
|
368
523
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
524
|
+
const steps = [sourceStep, hostStep, optionsStep, confirmStep];
|
|
525
|
+
for (let i = 0; i < steps.length;) {
|
|
526
|
+
let r;
|
|
527
|
+
try { r = await steps[i](); } catch (err) {
|
|
528
|
+
if (err instanceof Back) { i = Math.max(0, i - 1); continue; }
|
|
529
|
+
throw err;
|
|
530
|
+
}
|
|
531
|
+
if (r === END) return;
|
|
532
|
+
if (r !== AGAIN) i += 1;
|
|
372
533
|
}
|
|
534
|
+
const { targets, host, depth, model, wantProbe } = S;
|
|
373
535
|
|
|
536
|
+
setBack(false); // from here on a ← would have nothing to go back to
|
|
537
|
+
const outcomes = [];
|
|
374
538
|
for (const target of targets) {
|
|
375
539
|
say(`\n──── ${target.name} ────`);
|
|
376
|
-
const result = analyze(target.dir);
|
|
540
|
+
const result = withTicker(waitText(t('waitAnalyze'), { slowAfter: 600 }), () => analyze(target.dir));
|
|
377
541
|
const sc = scorecard(result);
|
|
378
|
-
const verdicts = result.groups
|
|
379
|
-
.map((g) => `${g.id}${g.verdict === 'pass' ? '✓' : g.verdict === 'warn' ? '!' : '✗'}`).join(' ');
|
|
380
|
-
say(` ${t('staticDone', { verdicts })}`);
|
|
381
542
|
|
|
382
543
|
let trial = null;
|
|
383
544
|
let gain = null;
|
|
@@ -421,15 +582,38 @@ async function interactive() {
|
|
|
421
582
|
|
|
422
583
|
const md = `${staticMarkdown(result)}${trialSection(trial)}${gainSection(gain)}${probeSection(probe)}`;
|
|
423
584
|
const out = writeOut(target.dir, target.name, md, sc);
|
|
424
|
-
|
|
585
|
+
let records = null;
|
|
586
|
+
if (trial?.runDir) { try { records = validRecords(readRun(trial.runDir).records); } catch { /* the chart then shows the totals only */ } }
|
|
587
|
+
outcomes.push({ name: target.name, result, trial, records, gain, probe, reportPath: path.join(out, `${target.name}.md`) });
|
|
425
588
|
// The run stopped on a failure streak (usage limit, dead token): the next skill would fail the same way.
|
|
426
589
|
if ((host === 'claude' || host === 'codebuddy' || host === 'codewhale') && process.exitCode === 3) break;
|
|
427
590
|
}
|
|
428
591
|
|
|
429
|
-
// Nothing is uploaded
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
592
|
+
// Nothing is uploaded without a yes here and a second yes after the preview: `submit` scrubs local paths and
|
|
593
|
+
// keys, shows exactly what would go, and only then opens the pull request. Declining leaves the run on disk.
|
|
594
|
+
// The results are drawn only after this decision, so the choice is made on the run, not on how it looks.
|
|
595
|
+
let contributed = false;
|
|
596
|
+
let explained = false;
|
|
597
|
+
const submittable = finishedRuns.filter(canSubmit);
|
|
598
|
+
if (finishedRuns.length && !submittable.length) {
|
|
599
|
+
say(`\n${t('noSubmitNoSource')}`);
|
|
600
|
+
explained = true;
|
|
601
|
+
} else if (submittable.length && process.exitCode !== 3) {
|
|
602
|
+
if (hasTerminal() && await confirm(t('contributeQ', { n: submittable.length }), false)) {
|
|
603
|
+
for (const dir of submittable) await cmdSubmit(submitArgs(dir));
|
|
604
|
+
contributed = true;
|
|
605
|
+
}
|
|
606
|
+
} else if (!finishedRuns.length && outcomes.length) {
|
|
607
|
+
// Say why there is no upload question, instead of silently not asking.
|
|
608
|
+
say(`\n${t(outcomes.some((o) => o.probe) ? 'noSubmitProbe' : 'noSubmitNoRun')}`);
|
|
609
|
+
explained = true;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
for (const o of outcomes) say(renderResult(o));
|
|
613
|
+
if (!contributed && !explained) {
|
|
614
|
+
const cmd = submittable.map((d) => `npx skillgauntlet submit ${submitArgs(d).map(quoteArg).join(' ')}`).join('\n ');
|
|
615
|
+
say(`\n${t('shareState', { cmd: submittable.length > 1 ? `\n ${cmd}\n` : cmd })}`);
|
|
616
|
+
}
|
|
433
617
|
}
|
|
434
618
|
|
|
435
619
|
async function cmdStatic(target, asJson) {
|
|
@@ -469,6 +653,10 @@ async function main() {
|
|
|
469
653
|
await cmdResume(args.slice(1));
|
|
470
654
|
return;
|
|
471
655
|
}
|
|
656
|
+
if (args[0] === 'submit') {
|
|
657
|
+
await cmdSubmit(args.slice(1));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
472
660
|
await interactive();
|
|
473
661
|
}
|
|
474
662
|
|
|
@@ -484,4 +672,10 @@ main()
|
|
|
484
672
|
if (process.env.SG_DEBUG) say(err.stack);
|
|
485
673
|
process.exitCode = 1;
|
|
486
674
|
})
|
|
487
|
-
.finally(() =>
|
|
675
|
+
.finally(() => {
|
|
676
|
+
closeUi();
|
|
677
|
+
// After a ← or Esc on Windows the terminal's stdin can keep the process alive once the program has finished (or
|
|
678
|
+
// handled Ctrl+C), so the shell prompt never comes back. A terminal session is over here: end it explicitly.
|
|
679
|
+
// Output to a terminal is synchronous, so nothing is cut off; a pipe is left to end by itself.
|
|
680
|
+
if (process.stdin.isTTY && process.stdout.isTTY) process.exit(process.exitCode ?? 0);
|
|
681
|
+
});
|