skillgauntlet 1.3.0 → 1.3.2
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/bin/skillgauntlet.js +30 -24
- package/lib/codewhale.js +2 -0
- package/lib/execute.js +2 -1
- package/lib/i18n.js +96 -0
- package/lib/models.js +35 -0
- package/lib/progress.js +48 -0
- package/lib/report.js +96 -1
- package/lib/runs.js +17 -0
- package/lib/trial.js +18 -0
- package/package.json +1 -1
package/bin/skillgauntlet.js
CHANGED
|
@@ -10,13 +10,14 @@ 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, readRun, validRecords } from '../lib/runs.js';
|
|
13
|
+
import { openRun, querySetHash, readRun, readAnswers, validRecords } from '../lib/runs.js';
|
|
14
14
|
import { DEPTHS, minutesFor } from '../lib/depth.js';
|
|
15
15
|
import { ask, choose, select, confirm, say, closeUi, hasTerminal, setBack, Back } from '../lib/ui.js';
|
|
16
|
-
import { hostModels, unknownModel } from '../lib/models.js';
|
|
16
|
+
import { hostModels, unknownModel, codebuddyCatalog } from '../lib/models.js';
|
|
17
17
|
import { browseDir } from '../lib/browse.js';
|
|
18
18
|
import { withTicker } from '../lib/ticker.js';
|
|
19
|
-
import { renderResult
|
|
19
|
+
import { renderResult } from '../lib/chart.js';
|
|
20
|
+
import { makeProgress } from '../lib/progress.js';
|
|
20
21
|
import { setLang, detectLang, errorText, t } from '../lib/i18n.js';
|
|
21
22
|
import { resolveBin, backend, backendFor, usesOwnDeepseek, setNativeModels, HOSTS, checkModel, draftQueries, draftQuality, runTrial, requireHost, pingHost, setHostBin, binInstalled } from '../lib/trial.js';
|
|
22
23
|
import { HOST_CLI, searchHost, installCommand, installHost } from '../lib/findhost.js';
|
|
@@ -127,6 +128,13 @@ function notListed(model, known, label) {
|
|
|
127
128
|
say(`\n${t('modelUnknown', { model, host: label, list: list.slice(0, 8).join(', ') })}`);
|
|
128
129
|
return true;
|
|
129
130
|
}
|
|
131
|
+
// Models in CodeBuddy's cached catalog that its --help does not list for this account right now.
|
|
132
|
+
function catalogOnly(host) {
|
|
133
|
+
if (host !== 'codebuddy') return [];
|
|
134
|
+
const listed = new Set(modelsOf('codebuddy', 'CodeBuddy'));
|
|
135
|
+
return codebuddyCatalog().filter((m) => !listed.has(m) && !m.startsWith('custom-local:'));
|
|
136
|
+
}
|
|
137
|
+
|
|
130
138
|
function modelsOf(host, label) {
|
|
131
139
|
if (!modelCache.has(host)) {
|
|
132
140
|
const list = withTicker(waitText(t('waitModels', { host: label }), { slowAfter: 600 }), () => hostModels(host, resolveBin(host)));
|
|
@@ -205,20 +213,23 @@ async function runTriggerLayer({ skillDir, skillName, depth, model, host = 'clau
|
|
|
205
213
|
queries: { schema: 'skillgauntlet/queries/1', queries: qs.queries },
|
|
206
214
|
});
|
|
207
215
|
|
|
216
|
+
const prog = progress(trials.length);
|
|
208
217
|
try {
|
|
209
218
|
const outcome = await executeTrials({
|
|
210
219
|
run,
|
|
211
220
|
trials,
|
|
212
221
|
doTrial: ({ q }) => runTrial({ bin, skillDir, skillName, query: q.query, model, maxExplore: depth.maxExplore, host }),
|
|
213
|
-
onTrial:
|
|
222
|
+
onTrial: prog,
|
|
214
223
|
});
|
|
224
|
+
prog.finish();
|
|
215
225
|
const result = finishRun(run, { model, backend: backendFor(host, model), host, runs: depth.runs, expected: trials.length }, outcome.stopped);
|
|
216
226
|
say(` ${t('runSaved', { path: run.dir })}`);
|
|
217
227
|
if (outcome.stopped) reportStopped(outcome.stopped, run.dir, model);
|
|
218
228
|
else if (result?.incomplete) reportIncomplete(result.incomplete, run.dir, model);
|
|
219
229
|
else finishedRuns.push(run.dir);
|
|
220
|
-
return result && { ...result, runDir: run.dir };
|
|
230
|
+
return result && { ...result, runDir: run.dir, queries: qs.queries };
|
|
221
231
|
} catch (err) {
|
|
232
|
+
prog.finish();
|
|
222
233
|
run.close(null, { interrupted: true });
|
|
223
234
|
throw err;
|
|
224
235
|
}
|
|
@@ -250,17 +261,7 @@ function archiveProbe({ skillDir, skillName, model, probe }) {
|
|
|
250
261
|
say(` ${t('runSaved', { path: run.dir })}`);
|
|
251
262
|
}
|
|
252
263
|
|
|
253
|
-
|
|
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
|
-
}
|
|
260
|
-
const mark = { triggered: '✓', not_triggered: '·', other_skill: '↷' }[row.verdict] || '!';
|
|
261
|
-
say(` [${done}/${total}] ${mark} ${row.queryId}#${row.run} → ${row.verdict}`);
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
+
const progress = makeProgress;
|
|
264
265
|
|
|
265
266
|
function reportIncomplete(n, dir, model) {
|
|
266
267
|
say(`
|
|
@@ -286,12 +287,14 @@ async function cmdResume(args) {
|
|
|
286
287
|
const runMeta = JSON.parse(fs.readFileSync(path.join(full, 'meta.json'), 'utf8'));
|
|
287
288
|
const runHost = runMeta.host || 'claude-code';
|
|
288
289
|
requireHost(runHost);
|
|
290
|
+
const resumeProgress = progress();
|
|
289
291
|
const result = await resumeRun({
|
|
290
292
|
dir: full,
|
|
291
293
|
bin: resolveBin((HOSTS[runHost] || HOSTS['claude-code']).bin),
|
|
292
294
|
want: { model: flag('--model'), runs: flag('--runs'), maxExplore: flag('--max-explore'), backend: backendFor(runHost, flag('--model') ?? runMeta.model) },
|
|
293
|
-
onTrial:
|
|
295
|
+
onTrial: resumeProgress,
|
|
294
296
|
});
|
|
297
|
+
resumeProgress.finish();
|
|
295
298
|
if (result.nothing) {
|
|
296
299
|
say(t('resumeNothing'));
|
|
297
300
|
return;
|
|
@@ -320,12 +323,14 @@ async function pickInstalled() {
|
|
|
320
323
|
}
|
|
321
324
|
|
|
322
325
|
// 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 = []) {
|
|
326
|
+
async function pickModel(known = [], more = []) {
|
|
324
327
|
if (!hasTerminal()) return (await ask(t('modelOptionalQ'), '')).trim() || null;
|
|
325
328
|
const other = '\u0000other';
|
|
329
|
+
// `known` is what the account can use now; `more` is in the host's catalog but not promised to the account
|
|
326
330
|
const v = await select(t('modelQ'), [
|
|
327
331
|
{ label: t('modelDefault'), value: '' },
|
|
328
|
-
...known.map((m) => ({ label: m, value: m })),
|
|
332
|
+
...known.map((m) => ({ label: m, hint: more.length ? t('modelAccountHint') : '', value: m })),
|
|
333
|
+
...more.map((m) => ({ label: m, hint: t('modelCatalogHint'), value: m })),
|
|
329
334
|
{ label: t('modelOther'), value: other },
|
|
330
335
|
], 0);
|
|
331
336
|
if (v === other) return (await ask(t('modelOptionalQ'), '')).trim() || null;
|
|
@@ -540,8 +545,8 @@ async function interactive() {
|
|
|
540
545
|
{ label: t('depthStandard'), hint: t('depthStandardHint'), value: 'standard' },
|
|
541
546
|
], 1);
|
|
542
547
|
S.depth = DEPTHS[depthId];
|
|
543
|
-
S.model = await pickModel(modelsOf('codebuddy', 'CodeBuddy'));
|
|
544
|
-
if (notListed(S.model, modelsOf('codebuddy', 'CodeBuddy'), 'CodeBuddy')) return AGAIN;
|
|
548
|
+
S.model = await pickModel(modelsOf('codebuddy', 'CodeBuddy'), catalogOnly('codebuddy'));
|
|
549
|
+
if (notListed(S.model, [...modelsOf('codebuddy', 'CodeBuddy'), ...catalogOnly('codebuddy')], 'CodeBuddy')) return AGAIN;
|
|
545
550
|
if (usesOwnDeepseek('codebuddy', S.model) && !process.env.DEEPSEEK_API_KEY) {
|
|
546
551
|
say(`\n${t('needDeepseekKey')}`);
|
|
547
552
|
return END;
|
|
@@ -630,10 +635,11 @@ async function interactive() {
|
|
|
630
635
|
archiveProbe({ skillDir: target.dir, skillName: target.name, model, probe });
|
|
631
636
|
}
|
|
632
637
|
|
|
633
|
-
const md = `${staticMarkdown(result)}${trialSection(trial)}${gainSection(gain)}${probeSection(probe)}`;
|
|
634
|
-
const out = writeOut(target.dir, target.name, md, sc);
|
|
635
638
|
let records = null;
|
|
636
|
-
if (trial?.runDir) { try { records = validRecords(readRun(trial.runDir).records); } catch { /* the
|
|
639
|
+
if (trial?.runDir) { try { records = validRecords(readRun(trial.runDir).records); } catch { /* the report then shows the totals only */ } }
|
|
640
|
+
const answers = trial?.runDir ? readAnswers(trial.runDir) : null;
|
|
641
|
+
const md = `${staticMarkdown(result)}${trialSection(trial, records, answers)}${gainSection(gain)}${probeSection(probe)}`;
|
|
642
|
+
const out = writeOut(target.dir, target.name, md, sc);
|
|
637
643
|
outcomes.push({ name: target.name, result, trial, records, gain, probe, reportPath: path.join(out, `${target.name}.md`) });
|
|
638
644
|
// The run stopped on a failure streak (usage limit, dead token): the next skill would fail the same way.
|
|
639
645
|
if ((host === 'claude' || host === 'codebuddy' || host === 'codewhale') && process.exitCode === 3) break;
|
package/lib/codewhale.js
CHANGED
|
@@ -65,6 +65,8 @@ function telemetryOff(bin, env) {
|
|
|
65
65
|
|
|
66
66
|
// The verdict for one stream line, or null to keep reading.
|
|
67
67
|
export function judgeLine(ev, skillName, rec) {
|
|
68
|
+
if (ev.type === 'content' && typeof ev.content === 'string') rec.said = `${rec.said || ''}${ev.content}`.slice(0, 2000);
|
|
69
|
+
if (ev.type === 'tool_use') rec.firstInput = rec.firstInput || String(ev.input?.name ?? ev.input?.query ?? ev.input?.command ?? JSON.stringify(ev.input ?? '')).slice(0, 300);
|
|
68
70
|
if (ev.type === 'tool_use' && ev.name === 'load_skill') {
|
|
69
71
|
rec.tools.push('load_skill');
|
|
70
72
|
rec.firstTool = rec.firstTool || 'load_skill';
|
package/lib/execute.js
CHANGED
|
@@ -23,7 +23,7 @@ export async function executeTrials({ run, trials, doTrial, workers = 2, streakS
|
|
|
23
23
|
if (idx >= trials.length) return;
|
|
24
24
|
const { q, i } = trials[idx];
|
|
25
25
|
const rec = await doTrial({ q, i });
|
|
26
|
-
const { init, ...stored } = rec;
|
|
26
|
+
const { init, said, firstInput, ...stored } = rec;
|
|
27
27
|
const row = { ...stored, queryId: q.id, run: i, query: q.query, shouldTrigger: q.shouldTrigger };
|
|
28
28
|
if (init && !run.meta.tools) {
|
|
29
29
|
run.patch({
|
|
@@ -35,6 +35,7 @@ export async function executeTrials({ run, trials, doTrial, workers = 2, streakS
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
run.append(row);
|
|
38
|
+
if (said || firstInput) run.appendAnswer({ queryId: q.id, run: i, said: said || '', firstInput: firstInput || '' });
|
|
38
39
|
state.done += 1;
|
|
39
40
|
state.streak = FAILED.has(row.verdict) ? state.streak + 1 : 0;
|
|
40
41
|
if (state.streak >= streakStop && !state.stopped) {
|
package/lib/i18n.js
CHANGED
|
@@ -131,6 +131,8 @@ const MESSAGES = {
|
|
|
131
131
|
'chart.otherSkill': '改用别的技能:{name}',
|
|
132
132
|
'chart.more': '…还有 {n} 题,见完整报告',
|
|
133
133
|
'chart.wrongTitle': '不该触发却触发的 {n} 次',
|
|
134
|
+
modelAccountHint: '你的账号现在可用',
|
|
135
|
+
modelCatalogHint: 'CodeBuddy 目录里有,账号不一定能用',
|
|
134
136
|
modelUnknown: '{host} 的模型清单里没有“{model}”。它现在支持:{list}。请重新选一个,或选“默认模型”。',
|
|
135
137
|
updateAvail: '有新版本 {latest}(你现在用的是 {current})。',
|
|
136
138
|
updateQ: '要更新吗?',
|
|
@@ -335,6 +337,36 @@ const MESSAGES = {
|
|
|
335
337
|
'poll.body': '1) Claude Code 2) Codex 3) 其他 —— 回一个数字就行。',
|
|
336
338
|
'poll.why': '为什么问:触发率只在支持显式技能调用的宿主上可测。Codex 的事件流里没有 skill item,那一列是空的,不是没测。所以读者用什么宿主,决定这份报告对谁有用、下一步该补哪一层。答案只用作信号,不做比例统计:回帖的人是自我选择的,算不出比例。',
|
|
337
339
|
'trial.title': '触发率',
|
|
340
|
+
'trial.answersTitle': '逐题详情:模型说了什么、先做了什么',
|
|
341
|
+
'trial.stopNote': '试跑在模型做出第一个动作时就停止(那时已能判定有没有用上技能),所以这里只到“第一步”,不是完整回答;也不会让模型真的去执行命令。',
|
|
342
|
+
'trial.runN': '第 {n} 轮:',
|
|
343
|
+
'trial.saidFirst': '动作之前它先说:',
|
|
344
|
+
'trial.saidNothing': '动作之前没说任何话,直接行动。',
|
|
345
|
+
'trial.glance': '一眼看结果',
|
|
346
|
+
'trial.glanceRecall': '应触发的题:触发了 ',
|
|
347
|
+
'trial.glanceFpr': '不该触发的题:误触发',
|
|
348
|
+
'trial.legend': '读法:“期望”是出题时的设定;“实际”是模型真的做了什么;✓ 表示两者一致,✗ 表示不一致(该触发却没触发,或不该触发却触发了)。',
|
|
349
|
+
'trial.byClause': '题目与结果对照(按触发条款)',
|
|
350
|
+
'trial.clauseTitle': '条款:{clause}',
|
|
351
|
+
'trial.noClause': '未归类',
|
|
352
|
+
'trial.hExpect': '期望',
|
|
353
|
+
'trial.hActual': '实际',
|
|
354
|
+
'trial.hMatch': '一致',
|
|
355
|
+
'trial.hQuery': '题目',
|
|
356
|
+
'trial.hFirst': '模型先做了什么',
|
|
357
|
+
'trial.hNote': '出题说明',
|
|
358
|
+
'trial.actTriggered': '触发了',
|
|
359
|
+
'trial.actNot': '没触发',
|
|
360
|
+
'trial.actPartial': '部分触发 {k}/{n}',
|
|
361
|
+
'trial.actWrong': '误触发 {k}/{n}',
|
|
362
|
+
'trial.borrows': '(借用的词:{words})',
|
|
363
|
+
'trial.didSkill': '调用了技能 {name}',
|
|
364
|
+
'trial.otherSkill': '改用别的技能 {name}',
|
|
365
|
+
'trial.noToolRun': '没调用工具',
|
|
366
|
+
'trial.missTitle': '该触发却没触发的 {n} 次:模型改做了什么',
|
|
367
|
+
'trial.hFirstTool': '先做的事',
|
|
368
|
+
'trial.hCount': '次数',
|
|
369
|
+
'trial.allZero': '一次都没触发:更可能是宿主没读到这个技能(路径、安装或权限),而不是技能写得差;请先核对技能装在宿主能读到的位置。',
|
|
338
370
|
'trial.modelLine': '模型:{model}({backend});宿主:{host};每题 {runs} 轮,共 {trials} 次试跑',
|
|
339
371
|
'trial.hostUnknown': '未记录',
|
|
340
372
|
'trial.recall': '召回率:{p}(95% CI {lo}–{hi})',
|
|
@@ -497,6 +529,8 @@ const MESSAGES = {
|
|
|
497
529
|
'chart.otherSkill': 'used another skill: {name}',
|
|
498
530
|
'chart.more': '…{n} more, see the full report',
|
|
499
531
|
'chart.wrongTitle': '{n} times it triggered when it should not have',
|
|
532
|
+
modelAccountHint: 'usable with your account now',
|
|
533
|
+
modelCatalogHint: 'in CodeBuddy\'s catalog; your account may not have it',
|
|
500
534
|
modelUnknown: '{host} does not list a model called "{model}". It currently supports: {list}. Pick another, or choose the default model.',
|
|
501
535
|
updateAvail: 'A newer version {latest} is available (you are on {current}).',
|
|
502
536
|
updateQ: 'Update?',
|
|
@@ -697,6 +731,36 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
|
|
|
697
731
|
'poll.body': '1) Claude Code 2) Codex 3) Other — reply with a single digit.',
|
|
698
732
|
'poll.why': 'Why ask: a trigger rate is only measurable on a host that exposes explicit skill invocation. Codex has no skill item in its event stream, so its column is empty rather than unmeasured. Which host a reader uses therefore decides who the report serves and which layer to build next. The answers are read as a signal, never as a proportion: whoever replies has selected themselves, so no ratio can be computed from it.',
|
|
699
733
|
'trial.title': 'Trigger rate',
|
|
734
|
+
'trial.answersTitle': 'Per question: what the model said and did first',
|
|
735
|
+
'trial.stopNote': 'A trial stops at the model\'s first action (by then it is known whether the skill was used), so this goes only up to the first step. It is not a complete answer, and the model is never let run its commands.',
|
|
736
|
+
'trial.runN': 'Run {n}:',
|
|
737
|
+
'trial.saidFirst': 'Before acting it said:',
|
|
738
|
+
'trial.saidNothing': 'It said nothing before acting.',
|
|
739
|
+
'trial.glance': 'At a glance',
|
|
740
|
+
'trial.glanceRecall': 'Should trigger: triggered ',
|
|
741
|
+
'trial.glanceFpr': 'Should not: triggered ',
|
|
742
|
+
'trial.legend': 'How to read: "Expected" is what the question was written to do; "Actual" is what the model really did; ✓ means they agree, ✗ that they do not (a miss, or a false trigger).',
|
|
743
|
+
'trial.byClause': 'Questions and results, by trigger clause',
|
|
744
|
+
'trial.clauseTitle': 'Clause: {clause}',
|
|
745
|
+
'trial.noClause': 'Unassigned',
|
|
746
|
+
'trial.hExpect': 'Expected',
|
|
747
|
+
'trial.hActual': 'Actual',
|
|
748
|
+
'trial.hMatch': 'Agree',
|
|
749
|
+
'trial.hQuery': 'Question',
|
|
750
|
+
'trial.hFirst': 'What the model did first',
|
|
751
|
+
'trial.hNote': 'Why it was written',
|
|
752
|
+
'trial.actTriggered': 'triggered',
|
|
753
|
+
'trial.actNot': 'did not trigger',
|
|
754
|
+
'trial.actPartial': 'triggered {k}/{n}',
|
|
755
|
+
'trial.actWrong': 'false trigger {k}/{n}',
|
|
756
|
+
'trial.borrows': '(borrowed words: {words})',
|
|
757
|
+
'trial.didSkill': 'invoked skill {name}',
|
|
758
|
+
'trial.otherSkill': 'used another skill: {name}',
|
|
759
|
+
'trial.noToolRun': 'no tool',
|
|
760
|
+
'trial.missTitle': 'The {n} misses: what the model did instead',
|
|
761
|
+
'trial.hFirstTool': 'First thing it did',
|
|
762
|
+
'trial.hCount': 'Count',
|
|
763
|
+
'trial.allZero': 'Nothing triggered at all: more likely the host could not read this skill (path, install or permissions) than that the skill is badly written. Check that it sits where the host can read it.',
|
|
700
764
|
'trial.modelLine': 'Model: {model} ({backend}); host: {host}; {runs} runs per query, {trials} trials',
|
|
701
765
|
'trial.hostUnknown': 'not recorded',
|
|
702
766
|
'trial.recall': 'Recall: {p} (95% CI {lo}–{hi})',
|
|
@@ -911,6 +975,8 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
|
|
|
911
975
|
'chart.otherSkill': '別のスキルを使用:{name}',
|
|
912
976
|
'chart.more': '…ほか {n} 件は詳細レポートへ',
|
|
913
977
|
'chart.wrongTitle': '発動すべきでないのに発動した {n} 回',
|
|
978
|
+
modelAccountHint: 'このアカウントで今使えます',
|
|
979
|
+
modelCatalogHint: 'CodeBuddy のカタログにあります(アカウントで使えるとは限りません)',
|
|
914
980
|
modelUnknown: '{host} のモデル一覧に「{model}」はありません。現在対応:{list}。別のものを選ぶか、既定のモデルを選んでください。',
|
|
915
981
|
updateAvail: '新しいバージョン {latest} があります(現在は {current})。',
|
|
916
982
|
updateQ: '更新しますか?',
|
|
@@ -1058,6 +1124,36 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
|
|
|
1058
1124
|
'poll.body': '1) Claude Code 2) Codex 3) その他 —— 数字 1 つで答えてください。',
|
|
1059
1125
|
'poll.why': '質問の理由:発動率は、スキルの明示的な呼び出しを公開しているホストでしか測れません。Codex のイベントストリームには skill 項目がないため、その列は「未測定」ではなく空欄です。読者がどのホストを使っているかで、このレポートが誰の役に立つか、次にどの層を作るべきかが決まります。回答はシグナルとしてのみ読み、割合としては扱いません。回答する人は自己選択であり、そこから比率は計算できないためです。',
|
|
1060
1126
|
'trial.title': '発動率',
|
|
1127
|
+
'trial.answersTitle': '質問ごとの詳細:モデルが言ったこと・最初にしたこと',
|
|
1128
|
+
'trial.stopNote': '試行はモデルの最初の動作で止まります(その時点でスキルが使われたか分かるため)。ここは最初のステップまでで、完全な回答ではなく、モデルにコマンドを実行させることもありません。',
|
|
1129
|
+
'trial.runN': '{n} 回目:',
|
|
1130
|
+
'trial.saidFirst': '動作の前に言ったこと:',
|
|
1131
|
+
'trial.saidNothing': '動作の前には何も言わず、すぐ行動しました。',
|
|
1132
|
+
'trial.glance': '結果の概要',
|
|
1133
|
+
'trial.glanceRecall': '発動すべき質問:発動 ',
|
|
1134
|
+
'trial.glanceFpr': '発動すべきでない質問:誤発動',
|
|
1135
|
+
'trial.legend': '読み方:「期待」は質問の設計意図、「実際」はモデルが実際に行ったこと。✓ は一致、✗ は不一致(未発動、または誤発動)。',
|
|
1136
|
+
'trial.byClause': '質問と結果の対照(発動条件ごと)',
|
|
1137
|
+
'trial.clauseTitle': '条件:{clause}',
|
|
1138
|
+
'trial.noClause': '未分類',
|
|
1139
|
+
'trial.hExpect': '期待',
|
|
1140
|
+
'trial.hActual': '実際',
|
|
1141
|
+
'trial.hMatch': '一致',
|
|
1142
|
+
'trial.hQuery': '質問',
|
|
1143
|
+
'trial.hFirst': 'モデルが最初にしたこと',
|
|
1144
|
+
'trial.hNote': '出題の意図',
|
|
1145
|
+
'trial.actTriggered': '発動',
|
|
1146
|
+
'trial.actNot': '発動せず',
|
|
1147
|
+
'trial.actPartial': '一部発動 {k}/{n}',
|
|
1148
|
+
'trial.actWrong': '誤発動 {k}/{n}',
|
|
1149
|
+
'trial.borrows': '(借用した語:{words})',
|
|
1150
|
+
'trial.didSkill': 'スキル {name} を呼び出し',
|
|
1151
|
+
'trial.otherSkill': '別のスキルを使用:{name}',
|
|
1152
|
+
'trial.noToolRun': 'ツールなし',
|
|
1153
|
+
'trial.missTitle': '発動すべきなのに発動しなかった {n} 回:代わりに何をしたか',
|
|
1154
|
+
'trial.hFirstTool': '最初にしたこと',
|
|
1155
|
+
'trial.hCount': '回数',
|
|
1156
|
+
'trial.allZero': '一度も発動しませんでした。スキルの出来が悪いというより、ホストがこのスキルを読めていない(パス・インストール・権限)可能性が高いです。ホストが読める場所にあるか確認してください。',
|
|
1061
1157
|
'trial.modelLine': 'モデル:{model}({backend});ホスト:{host};1 問あたり {runs} 回、計 {trials} 回の試行',
|
|
1062
1158
|
'trial.hostUnknown': '記録なし',
|
|
1063
1159
|
'trial.recall': '再現率(発動すべき質問で実際に発動した割合):{p}(95% 信頼区間 {lo}–{hi})',
|
package/lib/models.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Model names a host will accept, read from the host itself so the list is the user's own (it depends on the
|
|
2
2
|
// account and the custom models they set up) and never goes stale inside this package. Any failure gives [].
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
3
6
|
import { execFileSync, execSync } from 'node:child_process';
|
|
4
7
|
import { launchSpec } from './spawn.js';
|
|
5
8
|
|
|
@@ -44,3 +47,35 @@ export function unknownModel(model, known) {
|
|
|
44
47
|
if (!model || !known?.length || known.includes(model) || /^deepseek/i.test(model)) return null;
|
|
45
48
|
return known;
|
|
46
49
|
}
|
|
50
|
+
|
|
51
|
+
// `codebuddy --help` lists only what the account is signed in for right now (it can shrink to two entries), while the
|
|
52
|
+
// model catalog CodeBuddy last fetched stays in its local cache. Every string array called "models" inside those cache
|
|
53
|
+
// files is part of that catalog; the union with the help list is what the user remembers having been offered. A name
|
|
54
|
+
// from the catalog only is not promised to work for the account, so the list marks it and a failure shows the CLI's words.
|
|
55
|
+
export function catalogFromTexts(texts) {
|
|
56
|
+
const names = new Set();
|
|
57
|
+
const walk = (node, depth) => {
|
|
58
|
+
if (!node || depth > 8) return;
|
|
59
|
+
if (Array.isArray(node)) { node.forEach((n) => walk(n, depth + 1)); return; }
|
|
60
|
+
if (typeof node !== 'object') return;
|
|
61
|
+
for (const [k, v] of Object.entries(node)) {
|
|
62
|
+
if (k === 'models' && Array.isArray(v)) v.filter((x) => typeof x === 'string' && /^[\w.:-]{2,80}$/.test(x)).forEach((x) => names.add(x));
|
|
63
|
+
else walk(v, depth + 1);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
for (const text of texts) { try { walk(JSON.parse(text), 0); } catch { /* not JSON */ } }
|
|
67
|
+
return [...names];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function codebuddyCatalog(dir = path.join(os.homedir(), '.codebuddy', 'local_storage')) {
|
|
71
|
+
const texts = [];
|
|
72
|
+
try {
|
|
73
|
+
for (const f of fs.readdirSync(dir)) {
|
|
74
|
+
if (!f.endsWith('.info')) continue;
|
|
75
|
+
const file = path.join(dir, f);
|
|
76
|
+
if (fs.statSync(file).size > 2 * 1024 * 1024) continue;
|
|
77
|
+
texts.push(fs.readFileSync(file, 'utf8'));
|
|
78
|
+
}
|
|
79
|
+
} catch { /* no cache: only the help list */ }
|
|
80
|
+
return catalogFromTexts(texts);
|
|
81
|
+
}
|
package/lib/progress.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The wait for the trials. On a terminal the bar is drawn at once (0 of N) and redrawn every second with the seconds
|
|
2
|
+
// elapsed, so there is something on screen from the moment the trials start rather than after the first one finishes;
|
|
3
|
+
// a trial can take many seconds. `total` is known when a run starts; a resumed run learns it from its first trial.
|
|
4
|
+
// Without a terminal each finished trial is one line. finish() stops the redraw and ends the line.
|
|
5
|
+
import { t } from './i18n.js';
|
|
6
|
+
import { bar } from './chart.js';
|
|
7
|
+
|
|
8
|
+
export function makeProgress(total = 0, {
|
|
9
|
+
tty = Boolean(process.stdout.isTTY),
|
|
10
|
+
write = (s) => process.stdout.write(s),
|
|
11
|
+
line = (s) => process.stdout.write(`${s}\n`),
|
|
12
|
+
now = Date.now,
|
|
13
|
+
every = setInterval,
|
|
14
|
+
stopEvery = clearInterval,
|
|
15
|
+
} = {}) {
|
|
16
|
+
const started = now();
|
|
17
|
+
let finished = 0;
|
|
18
|
+
let of = total;
|
|
19
|
+
let timer = null;
|
|
20
|
+
let open = false;
|
|
21
|
+
const draw = () => {
|
|
22
|
+
open = true;
|
|
23
|
+
write(`\r\x1b[2K ${t('progressLine')} ${bar(of ? finished / of : 0, 24)} ${finished}/${of || '?'} ${t('waitElapsed', { s: Math.floor((now() - started) / 1000) })}`);
|
|
24
|
+
};
|
|
25
|
+
const stop = () => {
|
|
26
|
+
if (timer) stopEvery(timer);
|
|
27
|
+
timer = null;
|
|
28
|
+
if (tty && open) { write('\n'); open = false; }
|
|
29
|
+
};
|
|
30
|
+
if (tty && total) {
|
|
31
|
+
draw();
|
|
32
|
+
timer = every(draw, 1000);
|
|
33
|
+
}
|
|
34
|
+
const onTrial = ({ row, done, total: n }) => {
|
|
35
|
+
finished = done;
|
|
36
|
+
of = n;
|
|
37
|
+
if (tty) {
|
|
38
|
+
if (!timer) timer = every(draw, 1000);
|
|
39
|
+
draw();
|
|
40
|
+
if (done >= n) stop();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const mark = { triggered: '✓', not_triggered: '·', other_skill: '↷' }[row.verdict] || '!';
|
|
44
|
+
line(` [${done}/${n}] ${mark} ${row.queryId}#${row.run} → ${row.verdict}`);
|
|
45
|
+
};
|
|
46
|
+
onTrial.finish = stop;
|
|
47
|
+
return onTrial;
|
|
48
|
+
}
|
package/lib/report.js
CHANGED
|
@@ -39,7 +39,98 @@ export function staticMarkdown(result) {
|
|
|
39
39
|
return L.join('\n');
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
const bar = (v, w = 20) => {
|
|
43
|
+
const n = Math.round(Math.min(1, Math.max(0, v ?? 0)) * w);
|
|
44
|
+
return '█'.repeat(n) + '░'.repeat(w - n);
|
|
45
|
+
};
|
|
46
|
+
const cell = (x) => String(x ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
|
|
47
|
+
|
|
48
|
+
// What the model did in one run, in words.
|
|
49
|
+
function didWhat(r) {
|
|
50
|
+
if (r.verdict === 'triggered') return t('trial.didSkill', { name: r.invokedSkill || '?' });
|
|
51
|
+
if (r.verdict === 'other_skill') return t('trial.otherSkill', { name: r.invokedSkill || '?' });
|
|
52
|
+
return r.firstTool || t('trial.noToolRun');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// One row per question, grouped under the trigger clause it was written for: what was expected, what happened, whether
|
|
56
|
+
// they agree, what the model did instead, and why the question was written (for a near miss, the words it borrows).
|
|
57
|
+
function trialDetail(trial, records, answers) {
|
|
58
|
+
const byQuery = new Map();
|
|
59
|
+
for (const r of records) {
|
|
60
|
+
if (!byQuery.has(r.queryId)) byQuery.set(r.queryId, []);
|
|
61
|
+
byQuery.get(r.queryId).push(r);
|
|
62
|
+
}
|
|
63
|
+
const L = [];
|
|
64
|
+
const pos = trial.questions.filter((q) => q.shouldTrigger);
|
|
65
|
+
const neg = trial.questions.filter((q) => !q.shouldTrigger);
|
|
66
|
+
const hitP = pos.reduce((a, q) => a + q.hits, 0);
|
|
67
|
+
const nP = pos.reduce((a, q) => a + q.n, 0);
|
|
68
|
+
const hitN = neg.reduce((a, q) => a + q.hits, 0);
|
|
69
|
+
const nN = neg.reduce((a, q) => a + q.n, 0);
|
|
70
|
+
L.push('', `### ${t('trial.glance')}`, '', '```');
|
|
71
|
+
L.push(`${t('trial.glanceRecall')} ${bar(trial.recall)} ${pct(trial.recall)} (${hitP}/${nP})`);
|
|
72
|
+
L.push(`${t('trial.glanceFpr')} ${bar(trial.fpr)} ${pct(trial.fpr)} (${hitN}/${nN})`);
|
|
73
|
+
L.push('```', '', t('trial.legend'));
|
|
74
|
+
|
|
75
|
+
// group by clause, in the order the clauses were drafted
|
|
76
|
+
const clauses = [];
|
|
77
|
+
for (const q of trial.queries || []) if (!clauses.includes(q.clause || '')) clauses.push(q.clause || '');
|
|
78
|
+
L.push('', `### ${t('trial.byClause')}`);
|
|
79
|
+
for (const clause of clauses) {
|
|
80
|
+
const qs = (trial.queries || []).filter((q) => (q.clause || '') === clause);
|
|
81
|
+
L.push('', `#### ${clause ? t('trial.clauseTitle', { clause: cell(clause) }) : t('trial.noClause')}`, '');
|
|
82
|
+
L.push(`| ${t('trial.hExpect')} | ${t('trial.hActual')} | ${t('trial.hMatch')} | ${t('trial.hQuery')} | ${t('trial.hFirst')} | ${t('trial.hNote')} |`);
|
|
83
|
+
L.push('|---|---|---|---|---|---|');
|
|
84
|
+
for (const q of [...qs.filter((x) => x.shouldTrigger), ...qs.filter((x) => !x.shouldTrigger)]) {
|
|
85
|
+
const runs = byQuery.get(q.id) || [];
|
|
86
|
+
const hits = runs.filter((r) => r.verdict === 'triggered').length;
|
|
87
|
+
const n = runs.length;
|
|
88
|
+
let actual;
|
|
89
|
+
let ok;
|
|
90
|
+
if (!n) { actual = '—'; ok = '—'; } else if (q.shouldTrigger) {
|
|
91
|
+
actual = hits === n ? t('trial.actTriggered') : hits === 0 ? t('trial.actNot') : t('trial.actPartial', { k: hits, n });
|
|
92
|
+
ok = hits * 2 > n ? '✓' : '✗';
|
|
93
|
+
} else {
|
|
94
|
+
actual = hits === 0 ? t('trial.actNot') : t('trial.actWrong', { k: hits, n });
|
|
95
|
+
ok = hits === 0 ? '✓' : '✗';
|
|
96
|
+
}
|
|
97
|
+
const first = [...new Set(runs.map(didWhat))].join('; ') || '—';
|
|
98
|
+
const note = [cell(q.note), q.shares ? t('trial.borrows', { words: cell(q.shares) }) : ''].filter(Boolean).join(' ');
|
|
99
|
+
L.push(`| ${q.shouldTrigger ? t('trial.should') : t('trial.shouldNot')} | ${actual} | ${ok} | ${cell(q.query)} | ${cell(first)} | ${note} |`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const missed = records.filter((r) => r.shouldTrigger && r.verdict !== 'triggered');
|
|
104
|
+
if (missed.length) {
|
|
105
|
+
const kinds = new Map();
|
|
106
|
+
for (const r of missed) kinds.set(didWhat(r), (kinds.get(didWhat(r)) || 0) + 1);
|
|
107
|
+
L.push('', `### ${t('trial.missTitle', { n: missed.length })}`, '');
|
|
108
|
+
L.push(`| ${t('trial.hFirstTool')} | ${t('trial.hCount')} | |`, '|---|---|---|');
|
|
109
|
+
for (const [k, c] of [...kinds].sort((a, b) => b[1] - a[1])) L.push(`| ${cell(k)} | ${c} | ${bar(c / missed.length, 16)} |`);
|
|
110
|
+
}
|
|
111
|
+
if (trial.recall === 0 && trial.fpr === 0) L.push('', `> ${t('trial.allZero')}`);
|
|
112
|
+
|
|
113
|
+
// What the model said and did first, question by question. A trial stops at the first action, so this is not a
|
|
114
|
+
// complete answer, and the report says so.
|
|
115
|
+
if (answers?.size) {
|
|
116
|
+
L.push('', `### ${t('trial.answersTitle')}`, '', `> ${t('trial.stopNote')}`);
|
|
117
|
+
for (const q of trial.queries || []) {
|
|
118
|
+
const runs = (byQuery.get(q.id) || []);
|
|
119
|
+
if (!runs.length) continue;
|
|
120
|
+
L.push('', `**${q.id}** · ${q.shouldTrigger ? t('trial.should') : t('trial.shouldNot')} · ${cell(q.query)}`);
|
|
121
|
+
for (const r of runs) {
|
|
122
|
+
const a = answers.get(`${r.queryId}#${r.run}`);
|
|
123
|
+
const tag = runs.length > 1 ? `${t('trial.runN', { n: r.run + 1 })} ` : '';
|
|
124
|
+
L.push('', `- ${tag}${didWhat(r)}${a?.firstInput ? `: \`${cell(a.firstInput).replace(/`/g, "'")}\`` : ''}`);
|
|
125
|
+
if (a?.said?.trim()) L.push(` - ${t('trial.saidFirst')}`, '', ...a.said.trim().split(/\r?\n/).map((l) => ` > ${l}`));
|
|
126
|
+
else L.push(` - ${t('trial.saidNothing')}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return L;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function trialSection(trial, records = null, answers = null) {
|
|
43
134
|
if (!trial) return '';
|
|
44
135
|
const L = ['', `## ${t('trial.title')}`, ''];
|
|
45
136
|
L.push(`- ${t('trial.modelLine', {
|
|
@@ -54,6 +145,10 @@ export function trialSection(trial) {
|
|
|
54
145
|
if (trial.noToolRate != null) {
|
|
55
146
|
L.push(`- ${t('trial.noTool', { p: pct(trial.noToolRate) })}${trial.noToolRate >= 0.5 ? t('trial.noToolWarn') : ''}`);
|
|
56
147
|
}
|
|
148
|
+
if (records?.length && trial.queries?.length) {
|
|
149
|
+
L.push(...trialDetail(trial, records, answers));
|
|
150
|
+
return L.join('\n');
|
|
151
|
+
}
|
|
57
152
|
L.push('');
|
|
58
153
|
L.push(`| ${t('trial.stateHeader')} | ${t('trial.queryHeader')} | ${t('trial.hitsHeader')} |`);
|
|
59
154
|
L.push('|---|---|---|');
|
package/lib/runs.js
CHANGED
|
@@ -118,6 +118,11 @@ function handle(dir, meta) {
|
|
|
118
118
|
meta.trials += 1;
|
|
119
119
|
if (meta.trials % 25 === 0) flushMeta();
|
|
120
120
|
},
|
|
121
|
+
// What the model said and did first, per trial. Local only: submissions copy a fixed list of files, and this
|
|
122
|
+
// is not one of them.
|
|
123
|
+
appendAnswer(row) {
|
|
124
|
+
fs.appendFileSync(path.join(dir, 'answers.jsonl'), `${JSON.stringify(row)}\n`);
|
|
125
|
+
},
|
|
121
126
|
close(summary, { interrupted = false } = {}) {
|
|
122
127
|
meta.finishedAt = new Date().toISOString();
|
|
123
128
|
meta.interrupted = interrupted;
|
|
@@ -165,3 +170,15 @@ export function readRun(dir) {
|
|
|
165
170
|
try { summary = JSON.parse(fs.readFileSync(path.join(dir, 'summary.json'), 'utf8')); } catch { /* unfinished */ }
|
|
166
171
|
return { meta, records: raw, summary };
|
|
167
172
|
}
|
|
173
|
+
|
|
174
|
+
// Answers kept beside a run, keyed "queryId#run". A run made before they were kept has none: an empty map.
|
|
175
|
+
export function readAnswers(dir) {
|
|
176
|
+
const out = new Map();
|
|
177
|
+
try {
|
|
178
|
+
for (const line of fs.readFileSync(path.join(dir, 'answers.jsonl'), 'utf8').split(/\r?\n/).filter(Boolean)) {
|
|
179
|
+
const row = JSON.parse(line);
|
|
180
|
+
out.set(`${row.queryId}#${row.run}`, row);
|
|
181
|
+
}
|
|
182
|
+
} catch { /* none kept */ }
|
|
183
|
+
return out;
|
|
184
|
+
}
|
package/lib/trial.js
CHANGED
|
@@ -154,6 +154,21 @@ export function pingHost(host, { bin, model = null, timeout = 90, spawn = spawnS
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// What a trial shows of the model, kept apart from the verdict: the words it said before its first action and what that
|
|
158
|
+
// action was. A trial ends at the first action (the verdict is known then), so this is "up to the first step", never a
|
|
159
|
+
// complete answer. It goes to a local file, not into the run's raw records, which are what gets submitted.
|
|
160
|
+
export const SAID_LIMIT = 2000;
|
|
161
|
+
export function inputSummary(name, input) {
|
|
162
|
+
if (!input || typeof input !== 'object' || !Object.keys(input).length) return '';
|
|
163
|
+
const pick = input.command ?? input.file_path ?? input.path ?? input.pattern ?? input.skill ?? input.name ?? input.query;
|
|
164
|
+
const text = typeof pick === 'string' ? pick : JSON.stringify(input);
|
|
165
|
+
return `${text}`.slice(0, 300);
|
|
166
|
+
}
|
|
167
|
+
export function addSaid(rec, text) {
|
|
168
|
+
if (typeof text !== 'string' || !text) return;
|
|
169
|
+
rec.said = `${rec.said || ''}${text}`.slice(0, SAID_LIMIT);
|
|
170
|
+
}
|
|
171
|
+
|
|
157
172
|
// Variables a host (the Claude desktop app, the Agent SDK, an outer Claude Code session) sets for its own
|
|
158
173
|
// child processes. A trial inherits them otherwise, and they change what the model under test sees: under
|
|
159
174
|
// the desktop app the CLI adds Artifact, ArtifactComments and ArtifactData to the tool list, and the model
|
|
@@ -299,6 +314,7 @@ export function runTrial({ bin, skillDir, skillName, query, model, timeout = 180
|
|
|
299
314
|
rec.init = { model: ev.model, apiKeySource: ev.apiKeySource, claudeCodeVersion: ev.claude_code_version,
|
|
300
315
|
skills: ev.skills || [], tools: ev.tools || [] };
|
|
301
316
|
} else if (type === 'stream_event') {
|
|
317
|
+
if (ev.event?.delta?.type === 'text_delta') addSaid(rec, ev.event.delta.text);
|
|
302
318
|
const block = ev.event?.content_block;
|
|
303
319
|
if (block?.type === 'tool_use') {
|
|
304
320
|
rec.events.toolUse += 1;
|
|
@@ -311,9 +327,11 @@ export function runTrial({ bin, skillDir, skillName, query, model, timeout = 180
|
|
|
311
327
|
}
|
|
312
328
|
} else if (type === 'assistant') {
|
|
313
329
|
for (const c of ev.message?.content || []) {
|
|
330
|
+
if (c.type === 'text') addSaid(rec, c.text);
|
|
314
331
|
if (c.type !== 'tool_use') continue;
|
|
315
332
|
rec.tools.push(c.name);
|
|
316
333
|
rec.firstTool = rec.firstTool || c.name;
|
|
334
|
+
rec.firstInput = rec.firstInput || inputSummary(c.name, c.input);
|
|
317
335
|
if (c.name === 'Skill') {
|
|
318
336
|
const inv = String(c.input?.skill || '');
|
|
319
337
|
if (inv === skillName || inv.endsWith(`:${skillName}`)) finish('triggered', { invokedSkill: inv });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillgauntlet",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "Check an agent skill before you install it: context cost, permission surface, code risk, instruction hijacking, description quality, trigger rate and gain.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|