skillgauntlet 1.1.0 → 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 +328 -132
- 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/gain.js +10 -6
- package/lib/i18n.js +395 -34
- 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 +69 -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 } 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,8 +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' }) {
|
|
129
|
+
requireHost(host);
|
|
43
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
|
+
}
|
|
44
135
|
const cacheFile = cacheFileFor(skillDir, depth);
|
|
45
136
|
const want = { perClause: depth.perClause, negatives: depth.negatives, skillFingerprint: fingerprint(skillDir) };
|
|
46
137
|
|
|
@@ -49,8 +140,7 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
|
|
|
49
140
|
if (qs) {
|
|
50
141
|
say(` ${t('queriesReused', { n: qs.queries.length, file: path.basename(cacheFile) })}`);
|
|
51
142
|
} else {
|
|
52
|
-
|
|
53
|
-
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 }));
|
|
54
144
|
say(` ${t('drafted', { q: qs.queries.length, c: qs.clauses.length })}`);
|
|
55
145
|
writeCachedQueries(cacheFile, {
|
|
56
146
|
schema: 'skillgauntlet/queries/1',
|
|
@@ -73,6 +163,12 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
|
|
|
73
163
|
const trials = [];
|
|
74
164
|
for (const q of qs.queries) for (let i = 0; i < depth.runs; i += 1) trials.push({ q, i });
|
|
75
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
|
+
|
|
76
172
|
// Opened before the first trial: an interrupted run keeps its conditions and every finished record,
|
|
77
173
|
// which is what makes a later recompute or comparison possible without paying again.
|
|
78
174
|
const run = openRun({
|
|
@@ -108,7 +204,8 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
|
|
|
108
204
|
say(` ${t('runSaved', { path: run.dir })}`);
|
|
109
205
|
if (outcome.stopped) reportStopped(outcome.stopped, run.dir, model);
|
|
110
206
|
else if (result?.incomplete) reportIncomplete(result.incomplete, run.dir, model);
|
|
111
|
-
|
|
207
|
+
else finishedRuns.push(run.dir);
|
|
208
|
+
return result && { ...result, runDir: run.dir };
|
|
112
209
|
} catch (err) {
|
|
113
210
|
run.close(null, { interrupted: true });
|
|
114
211
|
throw err;
|
|
@@ -143,6 +240,11 @@ function archiveProbe({ skillDir, skillName, model, probe }) {
|
|
|
143
240
|
|
|
144
241
|
function progress() {
|
|
145
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
|
+
}
|
|
146
248
|
const mark = { triggered: '✓', not_triggered: '·', other_skill: '↷' }[row.verdict] || '!';
|
|
147
249
|
say(` [${done}/${total}] ${mark} ${row.queryId}#${row.run} → ${row.verdict}`);
|
|
148
250
|
};
|
|
@@ -171,6 +273,7 @@ async function cmdResume(args) {
|
|
|
171
273
|
const full = path.resolve(dir);
|
|
172
274
|
const runMeta = JSON.parse(fs.readFileSync(path.join(full, 'meta.json'), 'utf8'));
|
|
173
275
|
const runHost = runMeta.host || 'claude-code';
|
|
276
|
+
requireHost(runHost);
|
|
174
277
|
const result = await resumeRun({
|
|
175
278
|
dir: full,
|
|
176
279
|
bin: resolveBin((HOSTS[runHost] || HOSTS['claude-code']).bin),
|
|
@@ -204,6 +307,19 @@ async function pickInstalled() {
|
|
|
204
307
|
return hit ? [{ name: hit.name, dir: hit.dir, root: hit.root }] : [];
|
|
205
308
|
}
|
|
206
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
|
+
|
|
207
323
|
async function pickRemote() {
|
|
208
324
|
for (;;) {
|
|
209
325
|
const raw = await ask(t('askRepo'));
|
|
@@ -214,32 +330,42 @@ async function pickRemote() {
|
|
|
214
330
|
}
|
|
215
331
|
let resolved;
|
|
216
332
|
try {
|
|
217
|
-
resolved = resolveSpec(parsed.repo);
|
|
333
|
+
resolved = withTicker(waitText(t('waitCloning'), { slowAfter: 600 }), () => resolveSpec(parsed.repo));
|
|
218
334
|
} catch (err) {
|
|
219
335
|
say(` ${errorText(err)}`);
|
|
220
336
|
continue;
|
|
221
337
|
}
|
|
222
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
|
+
}
|
|
223
342
|
if (!found.length) {
|
|
224
343
|
say(` ${t('noSkill')}`);
|
|
225
344
|
return [];
|
|
226
345
|
}
|
|
227
346
|
if (parsed.hint) {
|
|
228
|
-
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}`));
|
|
229
348
|
if (hit) return [{ name: path.basename(hit), dir: hit, root: resolved.dir }];
|
|
230
349
|
say(` ${t('notFoundNamed', { name: parsed.hint, list: found.map((d) => relLabel(resolved.dir, d)).slice(0, 8).join(', ') })}`);
|
|
231
350
|
return [];
|
|
232
351
|
}
|
|
233
352
|
if (found.length === 1) return [{ name: path.basename(found[0]), dir: found[0], root: resolved.dir }];
|
|
234
|
-
say(
|
|
235
|
-
|
|
236
|
-
const hit = found.find((d) => path.basename(d) === name.trim());
|
|
237
|
-
if (hit) return [{ name: path.basename(hit), dir: hit, root: resolved.dir }];
|
|
238
|
-
say(` ${t('notFoundNamed', { name: name.trim(), list: found.map((d) => relLabel(resolved.dir, d)).slice(0, 8).join(', ') })}`);
|
|
239
|
-
return [];
|
|
353
|
+
say(`\n${t('multiFound', { n: found.length })}`);
|
|
354
|
+
return pickAmong(found, resolved.dir);
|
|
240
355
|
}
|
|
241
356
|
}
|
|
242
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
|
+
|
|
243
369
|
async function interactive() {
|
|
244
370
|
const langChoice = await select(`${t('langTitle')}`, [
|
|
245
371
|
{ label: t('langZh'), value: 'zh' },
|
|
@@ -252,130 +378,167 @@ async function interactive() {
|
|
|
252
378
|
say(t('title'));
|
|
253
379
|
say(t('rule'));
|
|
254
380
|
|
|
255
|
-
|
|
256
|
-
{ label: t('srcInstalled'), hint: t('srcInstalledHint'), value: 'installed' },
|
|
257
|
-
{ label: t('srcPath'), value: 'path' },
|
|
258
|
-
{ label: t('srcRepo'), hint: 'owner/repo', value: 'repo' },
|
|
259
|
-
], 1);
|
|
381
|
+
setBack(true);
|
|
260
382
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const found = findSkills(root);
|
|
266
|
-
if (!found.length) say(`\n${t('noSkill')}`);
|
|
267
|
-
else if (found.length === 1) targets = [{ name: path.basename(found[0]), dir: found[0], root }];
|
|
268
|
-
else {
|
|
269
|
-
const name = await select(t('pickOneQ'), found.map((d) => ({ label: relLabel(root, d), value: d })), 0);
|
|
270
|
-
targets = [{ name: path.basename(name), dir: name, root }];
|
|
271
|
-
}
|
|
272
|
-
} else targets = await pickRemote();
|
|
273
|
-
if (!targets.length) return;
|
|
274
|
-
|
|
275
|
-
const host = await choose(t('hostQ'), [
|
|
276
|
-
{ label: t('hostClaude'), hint: t('hostClaudeHint'), value: 'claude' },
|
|
277
|
-
{ label: t('hostCodebuddy'), hint: t('hostCodebuddyHint'), value: 'codebuddy' },
|
|
278
|
-
{ label: t('hostCodewhale'), hint: t('hostCodewhaleHint'), value: 'codewhale' },
|
|
279
|
-
{ label: t('hostCodex'), hint: t('hostCodexHint'), value: 'codex' },
|
|
280
|
-
], 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 };
|
|
281
387
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const depthId = await choose(t('depthQ'), [
|
|
288
|
-
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
289
|
-
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
290
|
-
{ label: t('depthDeep'), hint: t('depthDeepHint'), value: 'deep' },
|
|
291
|
-
], 2);
|
|
292
|
-
depth = DEPTHS[depthId];
|
|
293
|
-
|
|
294
|
-
const provider = await choose(t('providerQ'), [
|
|
295
|
-
{ label: t('provClaude'), hint: t('provClaudeHint'), value: 'claude' },
|
|
296
|
-
{ label: t('provDeepseek'), hint: t('provDeepseekHint'), value: 'deepseek' },
|
|
297
|
-
{ 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' },
|
|
298
393
|
], 1);
|
|
299
394
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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);
|
|
310
408
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
minutes: est.minutes,
|
|
324
|
-
totalMinutes: est.minutes * targets.length,
|
|
325
|
-
})}`);
|
|
326
|
-
} else if (host === 'codewhale') {
|
|
327
|
-
say(`\n${t('codewhaleNote')}`);
|
|
328
|
-
const depthId = await choose(t('depthQ'), [
|
|
329
|
-
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
330
|
-
{ 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' },
|
|
331
421
|
], 1);
|
|
332
|
-
|
|
333
|
-
model
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
+
}
|
|
337
429
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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 })}`);
|
|
351
512
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
return;
|
|
513
|
+
return null;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const confirmStep = async () => {
|
|
517
|
+
if (!(await confirm(t('confirmRun'), false))) {
|
|
518
|
+
say(t('cancelled'));
|
|
519
|
+
return END;
|
|
360
520
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
? t('codexEstimate', { skills: targets.length, runs: 2 })
|
|
364
|
-
: t('codexStaticOnly', { skills: targets.length })}`);
|
|
365
|
-
}
|
|
521
|
+
return null;
|
|
522
|
+
};
|
|
366
523
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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;
|
|
370
533
|
}
|
|
534
|
+
const { targets, host, depth, model, wantProbe } = S;
|
|
371
535
|
|
|
536
|
+
setBack(false); // from here on a ← would have nothing to go back to
|
|
537
|
+
const outcomes = [];
|
|
372
538
|
for (const target of targets) {
|
|
373
539
|
say(`\n──── ${target.name} ────`);
|
|
374
|
-
const result = analyze(target.dir);
|
|
540
|
+
const result = withTicker(waitText(t('waitAnalyze'), { slowAfter: 600 }), () => analyze(target.dir));
|
|
375
541
|
const sc = scorecard(result);
|
|
376
|
-
const verdicts = result.groups
|
|
377
|
-
.map((g) => `${g.id}${g.verdict === 'pass' ? '✓' : g.verdict === 'warn' ? '!' : '✗'}`).join(' ');
|
|
378
|
-
say(` ${t('staticDone', { verdicts })}`);
|
|
379
542
|
|
|
380
543
|
let trial = null;
|
|
381
544
|
let gain = null;
|
|
@@ -387,7 +550,7 @@ async function interactive() {
|
|
|
387
550
|
skillDir: target.dir, skillName: target.name, depth, model, host: { claude: 'claude-code', codebuddy: 'codebuddy', codewhale: 'codewhale' }[host],
|
|
388
551
|
});
|
|
389
552
|
} catch (err) {
|
|
390
|
-
say(` ${t('trialFail', { err: err
|
|
553
|
+
say(` ${t('trialFail', { err: errorText(err) })}`);
|
|
391
554
|
}
|
|
392
555
|
if (host === 'claude' && depth.layers.includes('gain')) {
|
|
393
556
|
say(` ${t('gainNote')}`);
|
|
@@ -419,15 +582,38 @@ async function interactive() {
|
|
|
419
582
|
|
|
420
583
|
const md = `${staticMarkdown(result)}${trialSection(trial)}${gainSection(gain)}${probeSection(probe)}`;
|
|
421
584
|
const out = writeOut(target.dir, target.name, md, sc);
|
|
422
|
-
|
|
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`) });
|
|
423
588
|
// The run stopped on a failure streak (usage limit, dead token): the next skill would fail the same way.
|
|
424
589
|
if ((host === 'claude' || host === 'codebuddy' || host === 'codewhale') && process.exitCode === 3) break;
|
|
425
590
|
}
|
|
426
591
|
|
|
427
|
-
// Nothing is uploaded
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
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
|
+
}
|
|
431
617
|
}
|
|
432
618
|
|
|
433
619
|
async function cmdStatic(target, asJson) {
|
|
@@ -467,6 +653,10 @@ async function main() {
|
|
|
467
653
|
await cmdResume(args.slice(1));
|
|
468
654
|
return;
|
|
469
655
|
}
|
|
656
|
+
if (args[0] === 'submit') {
|
|
657
|
+
await cmdSubmit(args.slice(1));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
470
660
|
await interactive();
|
|
471
661
|
}
|
|
472
662
|
|
|
@@ -482,4 +672,10 @@ main()
|
|
|
482
672
|
if (process.env.SG_DEBUG) say(err.stack);
|
|
483
673
|
process.exitCode = 1;
|
|
484
674
|
})
|
|
485
|
-
.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
|
+
});
|