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 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` (the single exe from the project's release page, renamed `codewhale.exe`) and `DEEPSEEK_API_KEY` in the environment. A rate measured here is a Codewhale number and is never merged with another host's.
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
 
@@ -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, estimate } from '../lib/depth.js';
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
- say(` ${t('drafting')}`);
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
- return result;
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(` ${t('multiFound', { n: found.length })}`);
237
- const name = await ask(t('pickOneQ'));
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
- const source = await choose(t('sourceQ'), [
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
- let targets = [];
264
- if (source === 'installed') targets = await pickInstalled();
265
- else if (source === 'path') {
266
- const root = path.resolve(await ask(t('srcPath')));
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
- let depth = null;
285
- let model = null;
286
- let wantProbe = false;
287
-
288
- if (host === 'claude') {
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
- if (provider === 'claude') {
303
- model = await choose(t('modelQ'), [
304
- { label: 'haiku', hint: t('modelCheap'), value: 'haiku' },
305
- { label: 'sonnet', hint: t('modelDaily'), value: 'sonnet' },
306
- { label: 'opus', hint: t('modelExpensive'), value: 'opus' },
307
- ], 1);
308
- } else if (provider === 'deepseek') {
309
- if (!process.env.ANTHROPIC_BASE_URL) {
310
- say(`\n${t('needBaseUrl')}`);
311
- return;
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
- model = await choose(t('modelQ'), [
314
- { label: 'deepseek-v4-flash', value: 'deepseek-v4-flash' },
315
- { label: 'deepseek-v4-pro', value: 'deepseek-v4-pro' },
316
- ], 1);
317
- } else model = await ask(t('modelQ'));
318
- checkModel(model, false);
319
- say(`\n${t('backendLine', { backend: backend(), model })}`);
320
-
321
- const est = estimate(depthId, 3);
322
- say(`\n${t('estimate', {
323
- skills: targets.length,
324
- trials: est.trials,
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
- depth = DEPTHS[depthId];
335
- model = (await ask(t('modelQ'), 'deepseek-flash')).trim() || 'deepseek-flash';
336
- if (!process.env.DEEPSEEK_API_KEY) {
337
- say(`\n${t('needDeepseekKey')}`);
338
- return;
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
- const est = estimate(depthId, 3);
341
- say(`\n${t('codewhaleEstimate', { skills: targets.length, trials: est.trials * targets.length })}`);
342
- } else if (host === 'codebuddy') {
343
- say(`\n${t('codebuddyNote')}`);
344
- const depthId = await choose(t('depthQ'), [
345
- { label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
346
- { label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
347
- ], 1);
348
- depth = DEPTHS[depthId];
349
- model = (await ask(t('modelQ'), '')).trim() || null;
350
- if (isDeepseekModel(model) && !process.env.DEEPSEEK_API_KEY) {
351
- say(`\n${t('needDeepseekKey')}`);
352
- return;
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
- const est = estimate(depthId, 3);
355
- say(`\n${t('codebuddyEstimate', { skills: targets.length, trials: est.trials * targets.length })}`);
356
- } else {
357
- say(`\n${t('codexNote')}`);
358
- model = (await ask(t('modelQ'), '')).trim() || null;
359
- if (isDeepseekModel(model) && !process.env.DEEPSEEK_API_KEY) {
360
- say(`\n${t('needDeepseekKey')}`);
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
- wantProbe = await confirm(t('probeQ'), false);
364
- say(`\n${wantProbe
365
- ? t('codexEstimate', { skills: targets.length, runs: 2 })
366
- : t('codexStaticOnly', { skills: targets.length })}`);
367
- }
521
+ return null;
522
+ };
368
523
 
369
- if (!(await confirm(t('confirmRun'), false))) {
370
- say(t('cancelled'));
371
- return;
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
- say(` ${t('reportAt', { path: path.join(out, `${target.name}.md`) })}`);
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 and nothing is offered for upload: there is no board to submit to yet. The
430
- // run archives are already on disk, so opening one later needs no rerun. Saying this plainly is
431
- // worth more than a yes/no prompt that would have done nothing.
432
- say(`\n${t('shareState')}`);
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(() => closeUi());
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
+ });