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.
@@ -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, bar } from '../lib/chart.js';
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: progress(),
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
- function progress() {
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: progress(),
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 chart then shows the totals only */ } }
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
+ }
@@ -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
- export function trialSection(trial) {
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.0",
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": {