skillgauntlet 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/LICENSE-DATA +438 -0
- package/README.md +237 -2
- package/bin/skillgauntlet.js +485 -0
- package/contracts/diagnosing-bugs.json +37 -0
- package/contracts/tdd.json +33 -0
- package/contracts/webapp-testing.json +38 -0
- package/contracts/xlsx.json +33 -0
- package/lib/codewhale.js +174 -0
- package/lib/codex.js +318 -0
- package/lib/depth.js +55 -0
- package/lib/discover.js +83 -0
- package/lib/execute.js +153 -0
- package/lib/gain.js +177 -0
- package/lib/i18n.js +754 -0
- package/lib/installed.js +43 -0
- package/lib/queries.js +37 -0
- package/lib/report.js +145 -0
- package/lib/runs.js +167 -0
- package/lib/spawn.js +84 -0
- package/lib/static.js +187 -0
- package/lib/trial.js +413 -0
- package/lib/ui.js +156 -0
- package/package.json +43 -4
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SkillGauntlet CLI. Two ways in:
|
|
3
|
+
// npx skillgauntlet interactive: language first, then arrow keys / y / n / enter
|
|
4
|
+
// npx skillgauntlet static <dir> one skill, layer 0 only, no model calls
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { analyze } from '../lib/static.js';
|
|
8
|
+
import { staticMarkdown, trialSection, gainSection, probeSection, scorecard } from '../lib/report.js';
|
|
9
|
+
import { findSkills, resolveSpec } from '../lib/discover.js';
|
|
10
|
+
import { installedSkills } from '../lib/installed.js';
|
|
11
|
+
import { probeActivation } from '../lib/codex.js';
|
|
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';
|
|
16
|
+
import { setLang, detectLang, errorText, t } from '../lib/i18n.js';
|
|
17
|
+
import { resolveBin, backend, backendFor, isDeepseekModel, HOSTS, checkModel, draftQueries, draftQuality, runTrial } from '../lib/trial.js';
|
|
18
|
+
import { runGain } from '../lib/gain.js';
|
|
19
|
+
import { ERROR_STREAK_STOP, executeTrials, finishRun, resumeRun } from '../lib/execute.js';
|
|
20
|
+
|
|
21
|
+
function relLabel(root, dir) {
|
|
22
|
+
const rel = path.relative(root, dir).replace(/\\/g, '/');
|
|
23
|
+
return rel || path.basename(dir);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseSource(input) {
|
|
27
|
+
let s = String(input).trim().replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/, '');
|
|
28
|
+
s = s.replace(/^\/+|\/+$/g, '');
|
|
29
|
+
const parts = s.split('/').filter(Boolean);
|
|
30
|
+
if (parts.length < 2) return null;
|
|
31
|
+
return { repo: `${parts[0]}/${parts[1]}`, hint: parts.slice(2).join('/') || null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeOut(skillDir, name, md, sc) {
|
|
35
|
+
const out = path.join(skillDir, '.skillgauntlet');
|
|
36
|
+
fs.mkdirSync(out, { recursive: true });
|
|
37
|
+
fs.writeFileSync(path.join(out, `${name}.md`), md);
|
|
38
|
+
fs.writeFileSync(path.join(out, `${name}.scorecard.json`), `${JSON.stringify(sc, null, 2)}\n`);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'claude-code' }) {
|
|
43
|
+
const bin = resolveBin(HOSTS[host].bin);
|
|
44
|
+
const cacheFile = cacheFileFor(skillDir, depth);
|
|
45
|
+
const want = { perClause: depth.perClause, negatives: depth.negatives, skillFingerprint: fingerprint(skillDir) };
|
|
46
|
+
|
|
47
|
+
let qs = readCachedQueries(cacheFile, want);
|
|
48
|
+
const reused = Boolean(qs);
|
|
49
|
+
if (qs) {
|
|
50
|
+
say(` ${t('queriesReused', { n: qs.queries.length, file: path.basename(cacheFile) })}`);
|
|
51
|
+
} else {
|
|
52
|
+
say(` ${t('drafting')}`);
|
|
53
|
+
qs = draftQueries({ bin, skillDir, skillName, model, perClause: depth.perClause, negatives: depth.negatives, host });
|
|
54
|
+
say(` ${t('drafted', { q: qs.queries.length, c: qs.clauses.length })}`);
|
|
55
|
+
writeCachedQueries(cacheFile, {
|
|
56
|
+
schema: 'skillgauntlet/queries/1',
|
|
57
|
+
skill: skillName,
|
|
58
|
+
skillFingerprint: want.skillFingerprint,
|
|
59
|
+
perClause: depth.perClause,
|
|
60
|
+
negatives: depth.negatives,
|
|
61
|
+
model,
|
|
62
|
+
draftedAt: new Date().toISOString(),
|
|
63
|
+
clauses: qs.clauses,
|
|
64
|
+
queries: qs.queries,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// Reported either way: a set whose negatives borrow nothing measures nothing, whether it was just
|
|
68
|
+
// drafted or read from the cache.
|
|
69
|
+
const quality = draftQuality(qs.queries);
|
|
70
|
+
if (quality.weak) say(` ${t('weakNegatives', { weak: quality.weak, total: quality.total })}`);
|
|
71
|
+
else say(` ${t('negativesChecked', { n: quality.total })}`);
|
|
72
|
+
|
|
73
|
+
const trials = [];
|
|
74
|
+
for (const q of qs.queries) for (let i = 0; i < depth.runs; i += 1) trials.push({ q, i });
|
|
75
|
+
|
|
76
|
+
// Opened before the first trial: an interrupted run keeps its conditions and every finished record,
|
|
77
|
+
// which is what makes a later recompute or comparison possible without paying again.
|
|
78
|
+
const run = openRun({
|
|
79
|
+
skillDir,
|
|
80
|
+
skillName,
|
|
81
|
+
host,
|
|
82
|
+
backend: backendFor(host, model),
|
|
83
|
+
model,
|
|
84
|
+
runs: depth.runs,
|
|
85
|
+
maxExplore: depth.maxExplore,
|
|
86
|
+
depth: { id: depth.id, perClause: depth.perClause, negatives: depth.negatives },
|
|
87
|
+
skillFingerprint: want.skillFingerprint,
|
|
88
|
+
querySets: [{
|
|
89
|
+
id: 'main',
|
|
90
|
+
source: reused ? 'cache' : 'drafted',
|
|
91
|
+
file: path.basename(cacheFile),
|
|
92
|
+
queries: qs.queries.length,
|
|
93
|
+
clauses: qs.clauses.length,
|
|
94
|
+
sha256: querySetHash(qs.queries),
|
|
95
|
+
negativesQuality: { total: quality.total, weak: quality.weak },
|
|
96
|
+
}],
|
|
97
|
+
queries: { schema: 'skillgauntlet/queries/1', queries: qs.queries },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const outcome = await executeTrials({
|
|
102
|
+
run,
|
|
103
|
+
trials,
|
|
104
|
+
doTrial: ({ q }) => runTrial({ bin, skillDir, skillName, query: q.query, model, maxExplore: depth.maxExplore, host }),
|
|
105
|
+
onTrial: progress(),
|
|
106
|
+
});
|
|
107
|
+
const result = finishRun(run, { model, backend: backendFor(host, model), host, runs: depth.runs, expected: trials.length }, outcome.stopped);
|
|
108
|
+
say(` ${t('runSaved', { path: run.dir })}`);
|
|
109
|
+
if (outcome.stopped) reportStopped(outcome.stopped, run.dir, model);
|
|
110
|
+
else if (result?.incomplete) reportIncomplete(result.incomplete, run.dir, model);
|
|
111
|
+
return result;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
run.close(null, { interrupted: true });
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The probe is two Codex runs on quota that cannot be read back, so its records are archived in the same
|
|
119
|
+
// run format as a trigger run: the report is a rendering of them and cannot be rebuilt without them.
|
|
120
|
+
function archiveProbe({ skillDir, skillName, model, probe }) {
|
|
121
|
+
const run = openRun({
|
|
122
|
+
skillDir,
|
|
123
|
+
skillName,
|
|
124
|
+
host: 'codex',
|
|
125
|
+
backend: 'codex',
|
|
126
|
+
model,
|
|
127
|
+
runs: 1,
|
|
128
|
+
maxExplore: 0,
|
|
129
|
+
depth: { id: 'probe' },
|
|
130
|
+
querySets: [{ id: 'probe', queries: 1 }],
|
|
131
|
+
skillFingerprint: fingerprint(skillDir),
|
|
132
|
+
});
|
|
133
|
+
run.patch({ invocation: probe.withSkill.invocation });
|
|
134
|
+
run.append({ queryId: 'probe', run: 0, side: 'with-skill', ...probe.withSkill });
|
|
135
|
+
run.append({ queryId: 'probe', run: 0, side: 'without-skill', ...probe.without });
|
|
136
|
+
run.close({
|
|
137
|
+
probe: true,
|
|
138
|
+
actionKindIdentical: probe.actionKindIdentical,
|
|
139
|
+
artifactsIdentical: probe.artifactsIdentical,
|
|
140
|
+
});
|
|
141
|
+
say(` ${t('runSaved', { path: run.dir })}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function progress() {
|
|
145
|
+
return ({ row, done, total }) => {
|
|
146
|
+
const mark = { triggered: '✓', not_triggered: '·', other_skill: '↷' }[row.verdict] || '!';
|
|
147
|
+
say(` [${done}/${total}] ${mark} ${row.queryId}#${row.run} → ${row.verdict}`);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function reportIncomplete(n, dir, model) {
|
|
152
|
+
say(`
|
|
153
|
+
${t('runIncomplete', { n })}`);
|
|
154
|
+
say(` ${t('resumeWith', { cmd: `npx skillgauntlet resume "${dir}"${model ? ` --model ${model}` : ''}` })}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function reportStopped(reason, dir, model) {
|
|
158
|
+
say(`
|
|
159
|
+
${t('runStopped', { n: ERROR_STREAK_STOP, reason })}`);
|
|
160
|
+
say(` ${t('resumeWith', { cmd: `npx skillgauntlet resume "${dir}"${model ? ` --model ${model}` : ''}` })}`);
|
|
161
|
+
process.exitCode = 3;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Continue a run directory. Flags that are given must equal what the run used; flags left out are taken
|
|
165
|
+
// from the run, so `resume <dir>` alone is enough.
|
|
166
|
+
async function cmdResume(args) {
|
|
167
|
+
const withValue = new Set(['--model', '--runs', '--max-explore']);
|
|
168
|
+
const dir = args.find((a, i) => !a.startsWith('--') && !withValue.has(args[i - 1]));
|
|
169
|
+
if (!dir) throw new Error(t('resumeUsage'));
|
|
170
|
+
const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
171
|
+
const full = path.resolve(dir);
|
|
172
|
+
const runMeta = JSON.parse(fs.readFileSync(path.join(full, 'meta.json'), 'utf8'));
|
|
173
|
+
const runHost = runMeta.host || 'claude-code';
|
|
174
|
+
const result = await resumeRun({
|
|
175
|
+
dir: full,
|
|
176
|
+
bin: resolveBin((HOSTS[runHost] || HOSTS['claude-code']).bin),
|
|
177
|
+
want: { model: flag('--model'), runs: flag('--runs'), maxExplore: flag('--max-explore'), backend: backendFor(runHost, flag('--model') ?? runMeta.model) },
|
|
178
|
+
onTrial: progress(),
|
|
179
|
+
});
|
|
180
|
+
if (result.nothing) {
|
|
181
|
+
say(t('resumeNothing'));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
say(` ${t('runSaved', { path: full })}`);
|
|
185
|
+
if (result.stopped) reportStopped(result.stopped, full, result.meta.model);
|
|
186
|
+
else if (result.result?.incomplete) reportIncomplete(result.result.incomplete, full, result.meta.model);
|
|
187
|
+
else say(` ${t('resumeDone', { n: result.done })}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function pickInstalled() {
|
|
191
|
+
const list = installedSkills();
|
|
192
|
+
if (!list.length) {
|
|
193
|
+
say(`\n${t('noInstalled')}`);
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
say(`\n${t('foundInstalled', { n: list.length })}`);
|
|
197
|
+
const scope = await choose(t('scopeQ'), [
|
|
198
|
+
{ label: t('scopeAll'), hint: t('scopeAllHint', { n: list.length }), value: 'all' },
|
|
199
|
+
{ label: t('scopeOne'), hint: t('scopeOneHint'), value: 'one' },
|
|
200
|
+
], 2);
|
|
201
|
+
if (scope === 'all') return list.map((s) => ({ name: s.name, dir: s.dir, root: s.root }));
|
|
202
|
+
const name = await select(t('pickOneQ'), list.map((s) => ({ label: s.name, hint: path.basename(s.root), value: s.name })), 0);
|
|
203
|
+
const hit = list.find((s) => s.name === name);
|
|
204
|
+
return hit ? [{ name: hit.name, dir: hit.dir, root: hit.root }] : [];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function pickRemote() {
|
|
208
|
+
for (;;) {
|
|
209
|
+
const raw = await ask(t('askRepo'));
|
|
210
|
+
const parsed = parseSource(raw);
|
|
211
|
+
if (!parsed) {
|
|
212
|
+
say(` ${t('needRepo')}`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
let resolved;
|
|
216
|
+
try {
|
|
217
|
+
resolved = resolveSpec(parsed.repo);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
say(` ${errorText(err)}`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const found = findSkills(resolved.dir);
|
|
223
|
+
if (!found.length) {
|
|
224
|
+
say(` ${t('noSkill')}`);
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
if (parsed.hint) {
|
|
228
|
+
const hit = found.find((d) => relLabel(resolved.dir, d) === parsed.hint || d.endsWith(`/${parsed.hint}`));
|
|
229
|
+
if (hit) return [{ name: path.basename(hit), dir: hit, root: resolved.dir }];
|
|
230
|
+
say(` ${t('notFoundNamed', { name: parsed.hint, list: found.map((d) => relLabel(resolved.dir, d)).slice(0, 8).join(', ') })}`);
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
if (found.length === 1) return [{ name: path.basename(found[0]), dir: found[0], root: resolved.dir }];
|
|
234
|
+
say(` ${t('multiFound', { n: found.length })}`);
|
|
235
|
+
const name = await ask(t('pickOneQ'));
|
|
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 [];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function interactive() {
|
|
244
|
+
const langChoice = await select(`${t('langTitle')}`, [
|
|
245
|
+
{ label: t('langZh'), value: 'zh' },
|
|
246
|
+
{ label: t('langEn'), value: 'en' },
|
|
247
|
+
{ label: t('langJa'), value: 'ja' },
|
|
248
|
+
], 0);
|
|
249
|
+
setLang(langChoice);
|
|
250
|
+
|
|
251
|
+
say('');
|
|
252
|
+
say(t('title'));
|
|
253
|
+
say(t('rule'));
|
|
254
|
+
|
|
255
|
+
const source = await choose(t('sourceQ'), [
|
|
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);
|
|
260
|
+
|
|
261
|
+
let targets = [];
|
|
262
|
+
if (source === 'installed') targets = await pickInstalled();
|
|
263
|
+
else if (source === 'path') {
|
|
264
|
+
const root = path.resolve(await ask(t('srcPath')));
|
|
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);
|
|
281
|
+
|
|
282
|
+
let depth = null;
|
|
283
|
+
let model = null;
|
|
284
|
+
let wantProbe = false;
|
|
285
|
+
|
|
286
|
+
if (host === 'claude') {
|
|
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' },
|
|
298
|
+
], 1);
|
|
299
|
+
|
|
300
|
+
if (provider === 'claude') {
|
|
301
|
+
model = await choose(t('modelQ'), [
|
|
302
|
+
{ label: 'haiku', hint: t('modelCheap'), value: 'haiku' },
|
|
303
|
+
{ label: 'sonnet', hint: t('modelDaily'), value: 'sonnet' },
|
|
304
|
+
{ label: 'opus', hint: t('modelExpensive'), value: 'opus' },
|
|
305
|
+
], 1);
|
|
306
|
+
} else if (provider === 'deepseek') {
|
|
307
|
+
if (!process.env.ANTHROPIC_BASE_URL) {
|
|
308
|
+
say(`\n${t('needBaseUrl')}`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
model = await choose(t('modelQ'), [
|
|
312
|
+
{ label: 'deepseek-v4-flash', value: 'deepseek-v4-flash' },
|
|
313
|
+
{ label: 'deepseek-v4-pro', value: 'deepseek-v4-pro' },
|
|
314
|
+
], 1);
|
|
315
|
+
} else model = await ask(t('modelQ'));
|
|
316
|
+
checkModel(model, false);
|
|
317
|
+
say(`\n${t('backendLine', { backend: backend(), model })}`);
|
|
318
|
+
|
|
319
|
+
const est = estimate(depthId, 3);
|
|
320
|
+
say(`\n${t('estimate', {
|
|
321
|
+
skills: targets.length,
|
|
322
|
+
trials: est.trials,
|
|
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' },
|
|
331
|
+
], 1);
|
|
332
|
+
depth = DEPTHS[depthId];
|
|
333
|
+
model = (await ask(t('modelQ'), 'deepseek-flash')).trim() || 'deepseek-flash';
|
|
334
|
+
if (!process.env.DEEPSEEK_API_KEY) {
|
|
335
|
+
say(`\n${t('needDeepseekKey')}`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const est = estimate(depthId, 3);
|
|
339
|
+
say(`\n${t('codewhaleEstimate', { skills: targets.length, trials: est.trials * targets.length })}`);
|
|
340
|
+
} else if (host === 'codebuddy') {
|
|
341
|
+
say(`\n${t('codebuddyNote')}`);
|
|
342
|
+
const depthId = await choose(t('depthQ'), [
|
|
343
|
+
{ label: t('depthQuick'), hint: t('depthQuickHint'), value: 'quick' },
|
|
344
|
+
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
345
|
+
], 1);
|
|
346
|
+
depth = DEPTHS[depthId];
|
|
347
|
+
model = (await ask(t('modelQ'), '')).trim() || null;
|
|
348
|
+
if (isDeepseekModel(model) && !process.env.DEEPSEEK_API_KEY) {
|
|
349
|
+
say(`\n${t('needDeepseekKey')}`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const est = estimate(depthId, 3);
|
|
353
|
+
say(`\n${t('codebuddyEstimate', { skills: targets.length, trials: est.trials * targets.length })}`);
|
|
354
|
+
} else {
|
|
355
|
+
say(`\n${t('codexNote')}`);
|
|
356
|
+
model = (await ask(t('modelQ'), '')).trim() || null;
|
|
357
|
+
if (isDeepseekModel(model) && !process.env.DEEPSEEK_API_KEY) {
|
|
358
|
+
say(`\n${t('needDeepseekKey')}`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
wantProbe = await confirm(t('probeQ'), false);
|
|
362
|
+
say(`\n${wantProbe
|
|
363
|
+
? t('codexEstimate', { skills: targets.length, runs: 2 })
|
|
364
|
+
: t('codexStaticOnly', { skills: targets.length })}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!(await confirm(t('confirmRun'), false))) {
|
|
368
|
+
say(t('cancelled'));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
for (const target of targets) {
|
|
373
|
+
say(`\n──── ${target.name} ────`);
|
|
374
|
+
const result = analyze(target.dir);
|
|
375
|
+
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
|
+
|
|
380
|
+
let trial = null;
|
|
381
|
+
let gain = null;
|
|
382
|
+
let probe = null;
|
|
383
|
+
|
|
384
|
+
if (host === 'claude' || host === 'codebuddy' || host === 'codewhale') {
|
|
385
|
+
try {
|
|
386
|
+
trial = await runTriggerLayer({
|
|
387
|
+
skillDir: target.dir, skillName: target.name, depth, model, host: { claude: 'claude-code', codebuddy: 'codebuddy', codewhale: 'codewhale' }[host],
|
|
388
|
+
});
|
|
389
|
+
} catch (err) {
|
|
390
|
+
say(` ${t('trialFail', { err: err.message })}`);
|
|
391
|
+
}
|
|
392
|
+
if (host === 'claude' && depth.layers.includes('gain')) {
|
|
393
|
+
say(` ${t('gainNote')}`);
|
|
394
|
+
const g = await runGain({
|
|
395
|
+
bin: resolveBin('claude'),
|
|
396
|
+
skillDir: target.dir,
|
|
397
|
+
skillName: target.name,
|
|
398
|
+
model,
|
|
399
|
+
onProgress: (id, cond) => say(` · ${id} / ${cond}`),
|
|
400
|
+
});
|
|
401
|
+
if (g.measurable) gain = g;
|
|
402
|
+
else say(` ${t('gainSkip', { reason: t(g.reasonKey) })}`);
|
|
403
|
+
}
|
|
404
|
+
} else if (wantProbe) {
|
|
405
|
+
say(` ${t('probeRun')}`);
|
|
406
|
+
probe = await probeActivation({
|
|
407
|
+
bin: resolveBin('codex'),
|
|
408
|
+
skillDir: target.dir,
|
|
409
|
+
skillName: target.name,
|
|
410
|
+
// The request must force action: a one-sentence answer ends both runs with no command and no file, and a
|
|
411
|
+
// comparison of two empty runs says nothing. The skill is named but its description is not quoted, so
|
|
412
|
+
// the run without it gets no hint of what the skill does.
|
|
413
|
+
query: `Use whatever guidance you have for "${target.name}" on a small made-up example. Create the input files it needs in the current directory, do the job, and save the result as files there. Do not ask questions. Stop when the files are written.`,
|
|
414
|
+
model,
|
|
415
|
+
});
|
|
416
|
+
if (!probe.withSkill.eventCount) say(` ${t('probeEmpty')}`);
|
|
417
|
+
archiveProbe({ skillDir: target.dir, skillName: target.name, model, probe });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const md = `${staticMarkdown(result)}${trialSection(trial)}${gainSection(gain)}${probeSection(probe)}`;
|
|
421
|
+
const out = writeOut(target.dir, target.name, md, sc);
|
|
422
|
+
say(` ${t('reportAt', { path: path.join(out, `${target.name}.md`) })}`);
|
|
423
|
+
// The run stopped on a failure streak (usage limit, dead token): the next skill would fail the same way.
|
|
424
|
+
if ((host === 'claude' || host === 'codebuddy' || host === 'codewhale') && process.exitCode === 3) break;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Nothing is uploaded and nothing is offered for upload: there is no board to submit to yet. The
|
|
428
|
+
// run archives are already on disk, so opening one later needs no rerun. Saying this plainly is
|
|
429
|
+
// worth more than a yes/no prompt that would have done nothing.
|
|
430
|
+
say(`\n${t('shareState')}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function cmdStatic(target, asJson) {
|
|
434
|
+
const dir = path.resolve(target);
|
|
435
|
+
if (!fs.existsSync(path.join(dir, 'SKILL.md'))) {
|
|
436
|
+
const subs = findSkills(dir);
|
|
437
|
+
if (!subs.length) throw new Error(t('noSkillUnder', { target }));
|
|
438
|
+
for (const s of subs) {
|
|
439
|
+
const result = analyze(s);
|
|
440
|
+
say(asJson ? JSON.stringify(scorecard(result), null, 2) : staticMarkdown(result));
|
|
441
|
+
say('');
|
|
442
|
+
}
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const result = analyze(dir);
|
|
446
|
+
say(asJson ? JSON.stringify(scorecard(result), null, 2) : staticMarkdown(result));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// --lang zh|ja|en, else SG_LANG, else the OS locale. Interactive mode asks instead.
|
|
450
|
+
function applyLang(args) {
|
|
451
|
+
const i = args.indexOf('--lang');
|
|
452
|
+
setLang(i >= 0 ? args[i + 1] : detectLang());
|
|
453
|
+
return i >= 0 ? [...args.slice(0, i), ...args.slice(i + 2)] : args;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function main() {
|
|
457
|
+
const args = applyLang(process.argv.slice(2));
|
|
458
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
459
|
+
say(t('usage'));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (args[0] === 'static') {
|
|
463
|
+
await cmdStatic(args[1] || '.', args.includes('--json'));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (args[0] === 'resume') {
|
|
467
|
+
await cmdResume(args.slice(1));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
await interactive();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
main()
|
|
474
|
+
.catch((err) => {
|
|
475
|
+
if (err.message === 'interrupted') {
|
|
476
|
+
say('');
|
|
477
|
+
process.exitCode = 130;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const msg = err.message === 'EOF' ? t('inputEnded') : errorText(err);
|
|
481
|
+
say(`\n${t('err', { msg })}`);
|
|
482
|
+
if (process.env.SG_DEBUG) say(err.stack);
|
|
483
|
+
process.exitCode = 1;
|
|
484
|
+
})
|
|
485
|
+
.finally(() => closeUi());
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill": "diagnosing-bugs",
|
|
3
|
+
"tasks": [
|
|
4
|
+
{
|
|
5
|
+
"id": "t1",
|
|
6
|
+
"prompt": "buggy.py 里的 div 返回的是乘积,而我们要的是商。找出原因并修好。",
|
|
7
|
+
"files": {
|
|
8
|
+
"buggy.py": "def div(a, b):\n return a * b\n"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "t2",
|
|
13
|
+
"prompt": "这段代码应该返回列表里第二大的数,但输入 [5, 5, 3] 时返回了 5,应该是 3。修一下。",
|
|
14
|
+
"files": {
|
|
15
|
+
"second.py": "def second_largest(xs):\n xs = sorted(xs)\n return xs[-2]\n"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"contracts": [
|
|
20
|
+
{
|
|
21
|
+
"desc": "div now returns the quotient (6/3 == 2)", "descI18n": {"zh": "div 现在返回商(6/3 == 2)", "ja": "div が商を返す(6/3 == 2)"},
|
|
22
|
+
"type": "command_succeeds",
|
|
23
|
+
"cmd": "python -c \"from buggy import div; assert div(6,3) == 2, div(6,3)\""
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"desc": "div's source is no longer a multiplication", "descI18n": {"zh": "div 的源码不再是乘法", "ja": "div のソースが掛け算ではなくなった"},
|
|
27
|
+
"type": "file_contains",
|
|
28
|
+
"glob": "buggy.py",
|
|
29
|
+
"regex": "return\\s+a\\s*/\\s*b"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"desc": "second_largest returns 3 on [5,5,3]", "descI18n": {"zh": "second_largest 在 [5,5,3] 上返回 3", "ja": "second_largest が [5,5,3] で 3 を返す"},
|
|
33
|
+
"type": "command_succeeds",
|
|
34
|
+
"cmd": "python -c \"from second import second_largest; assert second_largest([5,5,3]) == 3, second_largest([5,5,3])\""
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill": "tdd",
|
|
3
|
+
"tasks": [
|
|
4
|
+
{
|
|
5
|
+
"id": "t1",
|
|
6
|
+
"prompt": "给 calc.py 加一个 divide(a, b),除数为 0 时抛 ValueError。别把现有 add 弄坏。",
|
|
7
|
+
"files": {
|
|
8
|
+
"calc.py": "def add(a, b):\n return a + b\n"
|
|
9
|
+
},
|
|
10
|
+
"contracts": [
|
|
11
|
+
{ "desc": "a test file was added", "descI18n": {"zh": "新增了测试文件", "ja": "テストファイルが追加された"}, "type": "files_added_matching", "glob": "**/*test*.py" },
|
|
12
|
+
{ "desc": "tests pass (pytest exits non-zero when there are none)", "descI18n": {"zh": "测试能跑通(无测试则 pytest 返回非零)", "ja": "テストが通る(テストがなければ pytest は非ゼロで終了)"}, "type": "command_succeeds", "cmd": "python -m pytest -q" },
|
|
13
|
+
{ "desc": "divide was actually added", "descI18n": {"zh": "divide 真的被加进去了", "ja": "divide が実際に追加された"}, "type": "file_contains", "glob": "calc.py", "regex": "def\\s+divide" }
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "t2",
|
|
18
|
+
"prompt": "calc.py 里的 multiply 对负数返回了错误的符号,修一下。",
|
|
19
|
+
"files": {
|
|
20
|
+
"calc.py": "def multiply(a, b):\n return abs(a * b)\n"
|
|
21
|
+
},
|
|
22
|
+
"contracts": [
|
|
23
|
+
{ "desc": "a test file was added", "descI18n": {"zh": "新增了测试文件", "ja": "テストファイルが追加された"}, "type": "files_added_matching", "glob": "**/*test*.py" },
|
|
24
|
+
{ "desc": "tests pass (pytest exits non-zero when there are none)", "descI18n": {"zh": "测试能跑通(无测试则 pytest 返回非零)", "ja": "テストが通る(テストがなければ pytest は非ゼロで終了)"}, "type": "command_succeeds", "cmd": "python -m pytest -q" },
|
|
25
|
+
{
|
|
26
|
+
"desc": "multiply returns a negative value on negatives (-2 * 3 == -6)", "descI18n": {"zh": "multiply 在负数上返回负值(-2 * 3 == -6)", "ja": "multiply が負数に対して負の値を返す(-2 * 3 == -6)"},
|
|
27
|
+
"type": "command_succeeds",
|
|
28
|
+
"cmd": "python -c \"from calc import multiply; assert multiply(-2,3) == -6, multiply(-2,3)\""
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill": "webapp-testing",
|
|
3
|
+
"tasks": [
|
|
4
|
+
{
|
|
5
|
+
"id": "t1",
|
|
6
|
+
"prompt": "给 index.html 写一个 Playwright 测试,断言页面上有 Hello 这个标题。",
|
|
7
|
+
"files": {
|
|
8
|
+
"index.html": "<!doctype html>\n<html><head><title>demo</title></head><body><h1>Hello</h1></body></html>\n"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "t2",
|
|
13
|
+
"prompt": "给 login.html 写一个 Playwright 测试,断言点提交按钮后出现“已登录”。",
|
|
14
|
+
"files": {
|
|
15
|
+
"login.html": "<!doctype html>\n<html><body><button id=\"go\">submit</button><p id=\"out\"></p><script>document.getElementById('go').onclick=()=>{document.getElementById('out').textContent='已登录'}</script></body></html>\n"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"contracts": [
|
|
20
|
+
{
|
|
21
|
+
"desc": "a test file was added", "descI18n": {"zh": "新增了测试文件", "ja": "テストファイルが追加された"},
|
|
22
|
+
"type": "files_added_matching",
|
|
23
|
+
"glob": "**/*.spec.*"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"desc": "the test file uses Playwright", "descI18n": {"zh": "测试文件用到了 Playwright", "ja": "テストファイルが Playwright を使っている"},
|
|
27
|
+
"type": "file_contains",
|
|
28
|
+
"glob": "**/*.spec.*",
|
|
29
|
+
"regex": "[Pp]laywright"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"desc": "the test contains an assertion", "descI18n": {"zh": "测试里有断言", "ja": "テストにアサーションがある"},
|
|
33
|
+
"type": "file_contains",
|
|
34
|
+
"glob": "**/*.spec.*",
|
|
35
|
+
"regex": "expect\\s*\\("
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill": "xlsx",
|
|
3
|
+
"tasks": [
|
|
4
|
+
{
|
|
5
|
+
"id": "t1",
|
|
6
|
+
"prompt": "把 people.csv 转成 xlsx,保留表头,放在同一个目录下。",
|
|
7
|
+
"files": {
|
|
8
|
+
"people.csv": "name,age\nAlice,30\nBob,25\nCara,41\n"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "t2",
|
|
13
|
+
"prompt": "在 sales.csv 里加一列 total,等于 price 乘 qty,然后存成 xlsx。",
|
|
14
|
+
"files": {
|
|
15
|
+
"sales.csv": "item,price,qty\npen,2,10\ncup,5,3\n"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"contracts": [
|
|
20
|
+
{
|
|
21
|
+
"desc": "an .xlsx file was produced", "descI18n": {"zh": "产出了 .xlsx 文件", "ja": ".xlsx ファイルが生成された"},
|
|
22
|
+
"type": "files_added_matching",
|
|
23
|
+
"glob": "**/*.xlsx"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"desc": "the output is a real xlsx (the zip container holds a worksheet)", "descI18n": {"zh": "产出的确实是 xlsx(zip 容器里有工作表)", "ja": "出力は本物の xlsx(zip コンテナ内にワークシートがある)"},
|
|
27
|
+
"type": "file_contains",
|
|
28
|
+
"glob": "**/*.xlsx",
|
|
29
|
+
"encoding": "latin1",
|
|
30
|
+
"regex": "xl/worksheets/sheet[0-9]*\\.xml"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|