skillgauntlet 1.1.1 → 1.3.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.
@@ -6,17 +6,26 @@ 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, unknownModel } 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, usesOwnDeepseek, setNativeModels, 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';
27
+ import { checkForUpdate, declineUpdate, currentVersion, isCheckout } from '../lib/update.js';
28
+ import { spawnSync as runSync, execSync } from 'node:child_process';
20
29
 
21
30
  function relLabel(root, dir) {
22
31
  const rel = path.relative(root, dir).replace(/\\/g, '/');
@@ -39,9 +48,102 @@ function writeOut(skillDir, name, md, sc) {
39
48
  return out;
40
49
  }
41
50
 
51
+ // The host CLI is not on PATH. Look for it elsewhere on the machine, offer to install it where an installer
52
+ // exists, and otherwise let the user pick another host. True when the host can now be used.
53
+ async function fixMissingHost(hostId, err) {
54
+ const { bin } = HOST_CLI[hostId];
55
+ const label = err.vars?.host || hostId;
56
+ say(`\n${t('hostNotOnPath', { host: label })}`);
57
+ 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 } }));
58
+ const cmd = installCommand(hostId);
59
+ if (cmd) options.push({ label: t('hostInstallNow', { cmd }), value: { install: true } });
60
+ else say(t(err.howKey));
61
+ options.push({ label: t('hostOther'), value: { other: true } });
62
+
63
+ const pick = await select(t('hostFixQ'), options, 0);
64
+ if (pick.use) {
65
+ setHostBin(bin, pick.use);
66
+ say(` ${pick.use}`);
67
+ return true;
68
+ }
69
+ if (pick.install) {
70
+ say(`\n${t('hostInstalling', { host: label })}`);
71
+ try { installHost(hostId); } catch (e) {
72
+ say(t('hostInstallFailed', { why: e.message }));
73
+ return false;
74
+ }
75
+ if (binInstalled(bin)) return true;
76
+ const again = searchHost(hostId)[0]; // npm's folder may not be on this process's PATH yet
77
+ if (again) { setHostBin(bin, again); return true; }
78
+ say(t('hostStillMissing', { host: label }));
79
+ }
80
+ return false;
81
+ }
82
+
83
+ let approvedFollowing = false; // the user said not to ask again for the remaining skills
84
+
85
+ async function approveTrials({ q, runs, n }) {
86
+ if (approvedFollowing) return true;
87
+ const pick = await choose(t('trialsQ', { q, runs, n, minutes: minutesFor(n) }), [
88
+ { label: t('trialsGo'), value: 'go' },
89
+ { label: t('trialsAll'), value: 'all' },
90
+ { label: t('trialsSkip'), value: 'skip' },
91
+ ], 1);
92
+ if (pick === 'all') approvedFollowing = true;
93
+ return pick !== 'skip';
94
+ }
95
+
96
+ const hostChecked = new Set();
97
+ const finishedRuns = []; // run directories of complete runs, offered for contribution at the end
98
+ const remoteSource = new Map(); // skill directory -> the public repository, commit and subfolder it was cloned from
99
+
100
+ // The arguments `submit` needs for a run. A skill cloned from GitHub in this session already has its repository,
101
+ // commit and subfolder, so the user is not asked for them; other skills are looked up from their own git checkout.
102
+ function submitArgs(runDir) {
103
+ const skillDir = path.resolve(runDir, '..', '..', '..');
104
+ const src = remoteSource.get(skillDir);
105
+ return src ? [runDir, '--repo', src.repo, '--commit', src.commit, ...(src.subdir ? ['--subdir', src.subdir] : [])] : [runDir];
106
+ }
107
+
108
+ // Whether the public repository and commit of a run's skill can be told at all.
109
+ function canSubmit(runDir) {
110
+ const skillDir = path.resolve(runDir, '..', '..', '..');
111
+ if (remoteSource.has(skillDir)) return true;
112
+ const f = gitFacts(skillDir);
113
+ return Boolean(f.repo && f.commit);
114
+ }
115
+
116
+ const quoteArg = (a) => (/[\s"]/.test(a) ? `"${a}"` : a);
117
+ const waitText = (label, extra = {}) => ({ label, elapsed: t('waitElapsed'), abortHint: t('waitAbort'), ...extra });
118
+
119
+ // Asking a host CLI for its models takes a few seconds (CodeBuddy about three), so it shows a wait line and is
120
+ // remembered for the rest of the run.
121
+ const modelCache = new Map();
122
+
123
+ // A typed model the host does not list: say so now, while nothing has been spent, and ask again.
124
+ function notListed(model, known, label) {
125
+ const list = unknownModel(model, known);
126
+ if (!list) return false;
127
+ say(`\n${t('modelUnknown', { model, host: label, list: list.slice(0, 8).join(', ') })}`);
128
+ return true;
129
+ }
130
+ function modelsOf(host, label) {
131
+ if (!modelCache.has(host)) {
132
+ const list = withTicker(waitText(t('waitModels', { host: label }), { slowAfter: 600 }), () => hostModels(host, resolveBin(host)));
133
+ setNativeModels(host, list);
134
+ modelCache.set(host, list);
135
+ }
136
+ return modelCache.get(host);
137
+ }
138
+ const HOST_LABEL = { 'claude-code': 'Claude Code', codebuddy: 'CodeBuddy', codewhale: 'Codewhale' };
139
+
42
140
  async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'claude-code' }) {
43
141
  requireHost(host);
44
142
  const bin = resolveBin(HOSTS[host].bin);
143
+ if (!hostChecked.has(host)) {
144
+ withTicker(waitText(t('hostPing', { host: HOST_LABEL[host] || host })), () => pingHost(host, { bin, model }));
145
+ hostChecked.add(host);
146
+ }
45
147
  const cacheFile = cacheFileFor(skillDir, depth);
46
148
  const want = { perClause: depth.perClause, negatives: depth.negatives, skillFingerprint: fingerprint(skillDir) };
47
149
 
@@ -50,8 +152,7 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
50
152
  if (qs) {
51
153
  say(` ${t('queriesReused', { n: qs.queries.length, file: path.basename(cacheFile) })}`);
52
154
  } else {
53
- say(` ${t('drafting')}`);
54
- qs = draftQueries({ bin, skillDir, skillName, model, perClause: depth.perClause, negatives: depth.negatives, host });
155
+ qs = withTicker(waitText(t('drafting')), () => draftQueries({ bin, skillDir, skillName, model, perClause: depth.perClause, negatives: depth.negatives, host }));
55
156
  say(` ${t('drafted', { q: qs.queries.length, c: qs.clauses.length })}`);
56
157
  writeCachedQueries(cacheFile, {
57
158
  schema: 'skillgauntlet/queries/1',
@@ -74,6 +175,12 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
74
175
  const trials = [];
75
176
  for (const q of qs.queries) for (let i = 0; i < depth.runs; i += 1) trials.push({ q, i });
76
177
 
178
+ // The number of trials is only known now that the questions exist, so this is where the user agrees to it.
179
+ if (!(await approveTrials({ q: qs.queries.length, runs: depth.runs, n: trials.length }))) {
180
+ say(` ${t('trialsDeclined')}`);
181
+ return null;
182
+ }
183
+
77
184
  // Opened before the first trial: an interrupted run keeps its conditions and every finished record,
78
185
  // which is what makes a later recompute or comparison possible without paying again.
79
186
  const run = openRun({
@@ -109,7 +216,8 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
109
216
  say(` ${t('runSaved', { path: run.dir })}`);
110
217
  if (outcome.stopped) reportStopped(outcome.stopped, run.dir, model);
111
218
  else if (result?.incomplete) reportIncomplete(result.incomplete, run.dir, model);
112
- return result;
219
+ else finishedRuns.push(run.dir);
220
+ return result && { ...result, runDir: run.dir };
113
221
  } catch (err) {
114
222
  run.close(null, { interrupted: true });
115
223
  throw err;
@@ -144,6 +252,11 @@ function archiveProbe({ skillDir, skillName, model, probe }) {
144
252
 
145
253
  function progress() {
146
254
  return ({ row, done, total }) => {
255
+ // On a terminal one line is redrawn in place; the verdicts of single trials belong to the result, not the wait.
256
+ if (process.stdout.isTTY) {
257
+ process.stdout.write(`\r\x1b[2K ${t('progressLine')} ${bar(done / total, 24)} ${done}/${total}${done >= total ? '\n' : ''}`);
258
+ return;
259
+ }
147
260
  const mark = { triggered: '✓', not_triggered: '·', other_skill: '↷' }[row.verdict] || '!';
148
261
  say(` [${done}/${total}] ${mark} ${row.queryId}#${row.run} → ${row.verdict}`);
149
262
  };
@@ -206,6 +319,19 @@ async function pickInstalled() {
206
319
  return hit ? [{ name: hit.name, dir: hit.dir, root: hit.root }] : [];
207
320
  }
208
321
 
322
+ // A model for hosts that have an account default: pick it, a known name, or type one. Returns null for the default.
323
+ async function pickModel(known = []) {
324
+ if (!hasTerminal()) return (await ask(t('modelOptionalQ'), '')).trim() || null;
325
+ const other = '\u0000other';
326
+ const v = await select(t('modelQ'), [
327
+ { label: t('modelDefault'), value: '' },
328
+ ...known.map((m) => ({ label: m, value: m })),
329
+ { label: t('modelOther'), value: other },
330
+ ], 0);
331
+ if (v === other) return (await ask(t('modelOptionalQ'), '')).trim() || null;
332
+ return v || null;
333
+ }
334
+
209
335
  async function pickRemote() {
210
336
  for (;;) {
211
337
  const raw = await ask(t('askRepo'));
@@ -216,30 +342,74 @@ async function pickRemote() {
216
342
  }
217
343
  let resolved;
218
344
  try {
219
- resolved = resolveSpec(parsed.repo);
345
+ resolved = withTicker(waitText(t('waitCloning'), { slowAfter: 600 }), () => resolveSpec(parsed.repo));
220
346
  } catch (err) {
221
347
  say(` ${errorText(err)}`);
222
348
  continue;
223
349
  }
224
350
  const found = findSkills(resolved.dir);
351
+ for (const d of found) {
352
+ remoteSource.set(path.resolve(d), { repo: resolved.source.repo, commit: resolved.source.commit, subdir: path.relative(resolved.dir, d).replace(/\\/g, '/') });
353
+ }
225
354
  if (!found.length) {
226
355
  say(` ${t('noSkill')}`);
227
356
  return [];
228
357
  }
229
358
  if (parsed.hint) {
230
- const hit = found.find((d) => relLabel(resolved.dir, d) === parsed.hint || d.endsWith(`/${parsed.hint}`));
359
+ const hit = found.find((d) => relLabel(resolved.dir, d) === parsed.hint || d.replace(/\\/g, '/').endsWith(`/${parsed.hint}`));
231
360
  if (hit) return [{ name: path.basename(hit), dir: hit, root: resolved.dir }];
232
361
  say(` ${t('notFoundNamed', { name: parsed.hint, list: found.map((d) => relLabel(resolved.dir, d)).slice(0, 8).join(', ') })}`);
233
362
  return [];
234
363
  }
235
364
  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 [];
365
+ say(`\n${t('multiFound', { n: found.length })}`);
366
+ return pickAmong(found, resolved.dir);
367
+ }
368
+ }
369
+
370
+ // Several skills in one place: all of them, or one picked from the full list.
371
+ async function pickAmong(found, root) {
372
+ const scope = await choose(t('scopeQ'), [
373
+ { label: t('scopeAll'), hint: t('scopeAllHint', { n: found.length }), value: 'all' },
374
+ { label: t('scopeOne'), hint: t('scopeOneHint'), value: 'one' },
375
+ ], 2);
376
+ if (scope === 'all') return found.map((d) => ({ name: path.basename(d), dir: d, root }));
377
+ const dir = await select(t('pickOneQ'), found.map((d) => ({ label: relLabel(root, d), value: d })), 0);
378
+ return [{ name: path.basename(dir), dir, root }];
379
+ }
380
+
381
+ // Before the questions: is there a newer published version? The user chooses; if the update cannot be fetched or
382
+ // does not run, the session carries on with the version that is already running.
383
+ async function offerUpdate() {
384
+ if (!hasTerminal() || (isCheckout() && !process.env.SG_FORCE_UPDATE_CHECK)) return; // a checkout is not nagged unless asked
385
+ const found = await checkForUpdate();
386
+ if (!found) return;
387
+ say(`\n${t('updateAvail', found)}`);
388
+ const pick = await choose(t('updateQ'), [
389
+ { label: t('updateNow'), value: 'now' },
390
+ { label: t('updateSkip'), value: 'skip' },
391
+ ], 1);
392
+ if (pick !== 'now') {
393
+ declineUpdate(found.latest);
394
+ return;
395
+ }
396
+ const spec = `skillgauntlet@${found.latest}`;
397
+ const env = { ...process.env, SG_NO_UPDATE_CHECK: '1' };
398
+ try {
399
+ // fetch it first and check it answers as the version asked for, so a failed download never costs the session
400
+ const said = withTicker(waitText(t('updating', found), { slowAfter: 600 }), () => execSync(`npx -y ${spec} --version`, { encoding: 'utf8', timeout: 180000, windowsHide: true, env, stdio: ['ignore', 'pipe', 'pipe'] }));
401
+ if (String(said).trim() !== found.latest) throw new Error(`it reported ${String(said).trim() || 'nothing'}`);
402
+ } catch (err) {
403
+ // npm's own words when it gave any ("... is not in this registry"), else the error's first line
404
+ const lines = String(err.stderr || err.message).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
405
+ const why = (lines.find((l) => /no matching version|not in this registry|404/i.test(l)) || lines.find((l) => /error|ERR/.test(l)) || lines[0] || 'unknown')
406
+ .replace(/^npm error\s*(notarget\s*)?/i, '').replace(/[.。]+$/, '');
407
+ say(t('updateFailed', { why: why.slice(0, 160), current: found.current }));
408
+ return;
242
409
  }
410
+ closeUi();
411
+ const r = runSync(`npx -y ${spec}`, process.argv.slice(2), { stdio: 'inherit', env, shell: true });
412
+ process.exit(r.status ?? 0);
243
413
  }
244
414
 
245
415
  async function interactive() {
@@ -249,135 +419,176 @@ async function interactive() {
249
419
  { label: t('langJa'), value: 'ja' },
250
420
  ], 0);
251
421
  setLang(langChoice);
422
+ await offerUpdate();
252
423
 
253
424
  say('');
254
425
  say(t('title'));
255
426
  say(t('rule'));
256
427
 
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);
428
+ setBack(true);
262
429
 
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);
430
+ // The questions run as steps so ← or Esc can return to the previous one (a step that ends the run returns END).
431
+ const END = Symbol('end');
432
+ const AGAIN = Symbol('again');
433
+ const S = { targets: [], host: null, depth: null, model: null, wantProbe: false };
283
434
 
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' },
435
+ const sourceStep = async () => {
436
+ say(`\n${t('sourceNote')}`);
437
+ const source = await choose(t('sourceQ'), [
438
+ { label: t('srcInstalled'), hint: t('srcInstalledHint'), value: 'installed' },
439
+ { label: t('srcPath'), value: 'path' },
440
+ { label: t('srcRepo'), hint: t('srcRepoHint'), value: 'repo' },
300
441
  ], 1);
301
442
 
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;
443
+ let targets = [];
444
+ if (source === 'installed') targets = await pickInstalled();
445
+ else if (source === 'path') {
446
+ // Arrow keys walk the folders; typing a path stays available as the last entry (and is all a pipe has).
447
+ const browsed = hasTerminal() ? await browseDir((a, b, c) => select(a, b, c, { clear: true })) : null;
448
+ if (browsed) say(` ${browsed}`);
449
+ const root = path.resolve(browsed ?? await ask(t('srcPath')));
450
+ const found = findSkills(root);
451
+ if (!found.length) say(`\n${t('noSkill')}`);
452
+ else if (found.length === 1) targets = [{ name: path.basename(found[0]), dir: found[0], root }];
453
+ else {
454
+ say(`\n${t('multiFound', { n: found.length })}`);
455
+ targets = await pickAmong(found, root);
312
456
  }
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' },
457
+ } else targets = await pickRemote();
458
+ if (!targets.length) return hasTerminal() ? AGAIN : END;
459
+ S.targets = targets.map((tg) => ({ ...tg, name: skillNameOf(tg.dir) }));
460
+ return null;
461
+ };
462
+
463
+ const hostStep = async () => {
464
+ S.host = await choose(t('hostQ'), [
465
+ { label: t('hostClaude'), hint: t('hostClaudeHint'), value: 'claude' },
466
+ { label: t('hostCodebuddy'), hint: t('hostCodebuddyHint'), value: 'codebuddy' },
467
+ { label: t('hostCodewhale'), hint: t('hostCodewhaleHint'), value: 'codewhale' },
468
+ { label: t('hostCodex'), hint: t('hostCodexHint'), value: 'codex' },
333
469
  ], 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;
470
+
471
+ // Look for the host CLI before any question about depth or model: a missing one ends the run here, for free.
472
+ const hostId = { claude: 'claude-code', codebuddy: 'codebuddy', codewhale: 'codewhale' }[S.host];
473
+ if (hostId) {
474
+ try { requireHost(hostId); } catch (err) {
475
+ if (!(await fixMissingHost(hostId, err))) return AGAIN;
476
+ }
339
477
  }
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;
478
+ return null;
479
+ };
480
+
481
+ const optionsStep = async () => {
482
+ const { host, targets } = S;
483
+ S.depth = null;
484
+ S.model = null;
485
+ S.wantProbe = false;
486
+
487
+ if (host === 'claude') {
488
+ const depthId = await choose(t('depthQ'), [
489
+ { label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
490
+ { label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
491
+ { label: t('depthDeep'), hint: t('depthDeepHint'), value: 'deep' },
492
+ ], 2);
493
+ S.depth = DEPTHS[depthId];
494
+
495
+ const provider = await choose(t('providerQ'), [
496
+ { label: t('provClaude'), value: 'claude' },
497
+ { label: t('provDeepseek'), value: 'deepseek' },
498
+ { label: t('provManual'), value: 'manual' },
499
+ ], 1);
500
+
501
+ let model;
502
+ if (provider === 'claude') {
503
+ model = await choose(t('modelQ'), [
504
+ { label: 'haiku', hint: t('modelCheap'), value: 'haiku' },
505
+ { label: 'sonnet', hint: t('modelDaily'), value: 'sonnet' },
506
+ { label: 'opus', hint: t('modelExpensive'), value: 'opus' },
507
+ ], 1);
508
+ } else if (provider === 'deepseek') {
509
+ if (!process.env.ANTHROPIC_BASE_URL) {
510
+ say(`\n${t('needBaseUrl')}`);
511
+ return END;
512
+ }
513
+ model = await choose(t('modelQ'), [
514
+ { label: 'deepseek-v4-flash', value: 'deepseek-v4-flash' },
515
+ { label: 'deepseek-v4-pro', value: 'deepseek-v4-pro' },
516
+ ], 1);
517
+ } else model = await ask(t('modelQ'));
518
+ checkModel(model, false);
519
+ S.model = model;
520
+ say(`\n${t('backendLine', { backend: backend(), model })}`);
521
+
522
+ say(`\n${t('draftFirst')}`);
523
+ } else if (host === 'codewhale') {
524
+ say(`\n${t('codewhaleNote')}`);
525
+ const depthId = await choose(t('depthQ'), [
526
+ { label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
527
+ { label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
528
+ ], 1);
529
+ S.depth = DEPTHS[depthId];
530
+ S.model = (await pickModel(['deepseek-flash'])) || 'deepseek-flash';
531
+ if (!process.env.DEEPSEEK_API_KEY) {
532
+ say(`\n${t('needDeepseekKey')}`);
533
+ return END;
534
+ }
535
+ say(`\n${t('draftFirst')}`);
536
+ } else if (host === 'codebuddy') {
537
+ say(`\n${t('codebuddyNote')}`);
538
+ const depthId = await choose(t('depthQ'), [
539
+ { label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
540
+ { label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
541
+ ], 1);
542
+ S.depth = DEPTHS[depthId];
543
+ S.model = await pickModel(modelsOf('codebuddy', 'CodeBuddy'));
544
+ if (notListed(S.model, modelsOf('codebuddy', 'CodeBuddy'), 'CodeBuddy')) return AGAIN;
545
+ if (usesOwnDeepseek('codebuddy', S.model) && !process.env.DEEPSEEK_API_KEY) {
546
+ say(`\n${t('needDeepseekKey')}`);
547
+ return END;
548
+ }
549
+ say(`\n${t('draftFirst')}`);
550
+ } else {
551
+ say(`\n${t('codexNote')}`);
552
+ S.model = await pickModel(modelsOf('codex', 'Codex'));
553
+ if (notListed(S.model, modelsOf('codex', 'Codex'), 'Codex')) return AGAIN;
554
+ if (usesOwnDeepseek('codex', S.model) && !process.env.DEEPSEEK_API_KEY) {
555
+ say(`\n${t('needDeepseekKey')}`);
556
+ return END;
557
+ }
558
+ S.wantProbe = await confirm(t('probeQ'), false);
559
+ say(`\n${S.wantProbe
560
+ ? t('codexEstimate', { skills: targets.length, runs: 2 })
561
+ : t('codexStaticOnly', { skills: targets.length })}`);
353
562
  }
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;
563
+ return null;
564
+ };
565
+
566
+ const confirmStep = async () => {
567
+ if (!(await confirm(t('confirmRun'), false))) {
568
+ say(t('cancelled'));
569
+ return END;
362
570
  }
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
- }
571
+ return null;
572
+ };
368
573
 
369
- if (!(await confirm(t('confirmRun'), false))) {
370
- say(t('cancelled'));
371
- return;
574
+ const steps = [sourceStep, hostStep, optionsStep, confirmStep];
575
+ for (let i = 0; i < steps.length;) {
576
+ let r;
577
+ try { r = await steps[i](); } catch (err) {
578
+ if (err instanceof Back) { i = Math.max(0, i - 1); continue; }
579
+ throw err;
580
+ }
581
+ if (r === END) return;
582
+ if (r !== AGAIN) i += 1;
372
583
  }
584
+ const { targets, host, depth, model, wantProbe } = S;
373
585
 
586
+ setBack(false); // from here on a ← would have nothing to go back to
587
+ const outcomes = [];
374
588
  for (const target of targets) {
375
589
  say(`\n──── ${target.name} ────`);
376
- const result = analyze(target.dir);
590
+ const result = withTicker(waitText(t('waitAnalyze'), { slowAfter: 600 }), () => analyze(target.dir));
377
591
  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
592
 
382
593
  let trial = null;
383
594
  let gain = null;
@@ -421,15 +632,39 @@ async function interactive() {
421
632
 
422
633
  const md = `${staticMarkdown(result)}${trialSection(trial)}${gainSection(gain)}${probeSection(probe)}`;
423
634
  const out = writeOut(target.dir, target.name, md, sc);
424
- say(` ${t('reportAt', { path: path.join(out, `${target.name}.md`) })}`);
635
+ let records = null;
636
+ if (trial?.runDir) { try { records = validRecords(readRun(trial.runDir).records); } catch { /* the chart then shows the totals only */ } }
637
+ outcomes.push({ name: target.name, result, trial, records, gain, probe, reportPath: path.join(out, `${target.name}.md`) });
425
638
  // The run stopped on a failure streak (usage limit, dead token): the next skill would fail the same way.
426
639
  if ((host === 'claude' || host === 'codebuddy' || host === 'codewhale') && process.exitCode === 3) break;
427
640
  }
428
641
 
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')}`);
642
+ // Nothing is uploaded without a yes here and a second yes after the preview: `submit` scrubs local paths and
643
+ // keys, shows exactly what would go, and only then opens the pull request. Declining leaves the run on disk.
644
+ // The results are drawn only after this decision, so the choice is made on the run, not on how it looks.
645
+ let contributed = false;
646
+ let explained = false;
647
+ const submittable = finishedRuns.filter(canSubmit);
648
+ if (finishedRuns.length && !submittable.length) {
649
+ const manual = finishedRuns.map((d) => `npx skillgauntlet submit "${d}" --repo https://github.com/owner/repo --commit <sha> [--subdir path]`).join('\n ');
650
+ say(`\n${t('noSubmitNoSource', { cmd: manual })}`);
651
+ explained = true;
652
+ } else if (submittable.length && process.exitCode !== 3) {
653
+ if (hasTerminal() && await confirm(t('contributeQ', { n: submittable.length }), false)) {
654
+ for (const dir of submittable) await cmdSubmit(submitArgs(dir));
655
+ contributed = true;
656
+ }
657
+ } else if (!finishedRuns.length && outcomes.length) {
658
+ // Say why there is no upload question, instead of silently not asking.
659
+ say(`\n${t(outcomes.some((o) => o.probe) ? 'noSubmitProbe' : 'noSubmitNoRun')}`);
660
+ explained = true;
661
+ }
662
+
663
+ for (const o of outcomes) say(renderResult(o));
664
+ if (!contributed && !explained) {
665
+ const cmd = submittable.map((d) => `npx skillgauntlet submit ${submitArgs(d).map(quoteArg).join(' ')}`).join('\n ');
666
+ say(`\n${t('shareState', { cmd: submittable.length > 1 ? `\n ${cmd}\n` : cmd })}`);
667
+ }
433
668
  }
434
669
 
435
670
  async function cmdStatic(target, asJson) {
@@ -461,6 +696,10 @@ async function main() {
461
696
  say(t('usage'));
462
697
  return;
463
698
  }
699
+ if (args.includes('--version') || args.includes('-v')) {
700
+ say(currentVersion() || 'unknown');
701
+ return;
702
+ }
464
703
  if (args[0] === 'static') {
465
704
  await cmdStatic(args[1] || '.', args.includes('--json'));
466
705
  return;
@@ -469,6 +708,10 @@ async function main() {
469
708
  await cmdResume(args.slice(1));
470
709
  return;
471
710
  }
711
+ if (args[0] === 'submit') {
712
+ await cmdSubmit(args.slice(1));
713
+ return;
714
+ }
472
715
  await interactive();
473
716
  }
474
717
 
@@ -484,4 +727,10 @@ main()
484
727
  if (process.env.SG_DEBUG) say(err.stack);
485
728
  process.exitCode = 1;
486
729
  })
487
- .finally(() => closeUi());
730
+ .finally(() => {
731
+ closeUi();
732
+ // After a ← or Esc on Windows the terminal's stdin can keep the process alive once the program has finished (or
733
+ // handled Ctrl+C), so the shell prompt never comes back. A terminal session is over here: end it explicitly.
734
+ // Output to a terminal is synchronous, so nothing is cut off; a pipe is left to end by itself.
735
+ if (process.stdin.isTTY && process.stdout.isTTY) process.exit(process.exitCode ?? 0);
736
+ });