skillgauntlet 1.2.0 → 1.3.1

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.
@@ -0,0 +1,107 @@
1
+ // The decision rules of the submission auto-merge, as pure functions: no network, no files, no clock.
2
+ // scripts/automerge.mjs gathers the facts (through the GitHub API, as data) and applies the verdict.
3
+ //
4
+ // A pull request is merged automatically only when every rule here holds. Anything else is not an
5
+ // error: it is sent to a maintainer with the reasons written on the pull request.
6
+ import { FILES, LIMITS } from './submission.js';
7
+
8
+ export const POLICY = {
9
+ fileBytes: LIMITS.fileBytes, // per file (the same cap the validator enforces)
10
+ totalBytes: 10 * 1024 * 1024, // all four files of one submission
11
+ maxFiles: FILES.length,
12
+ perAccountPerDay: 5, // auto-merged submissions per account in the last 24 hours
13
+ minAccountAgeDays: 30, // younger accounts go to manual review
14
+ trustedAssociations: ['OWNER', 'MEMBER', 'COLLABORATOR'], // skip the account-age and recent-failure rules only
15
+ claContext: 'license/cla',
16
+ label: 'needs-review',
17
+ };
18
+
19
+ const DIR_RE = new RegExp(`^(submissions/[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*/[0-9]{8}T[0-9]{6}Z-[A-Za-z0-9][A-Za-z0-9-]*)/(${FILES.map((f) => f.replace('.', '\\.')).join('|')})$`);
20
+
21
+ // The text goes into a pull-request comment: no @mentions, one line, bounded.
22
+ const safe = (s) => String(s).replace(/@/g, '@​').replace(/[\r\n`]+/g, ' ').slice(0, 200);
23
+
24
+ // changed: [{ filename, status }] from the pull request's file list.
25
+ // entries: { [path]: { type, size } } from the contents API at the head commit (type is 'file' for a plain
26
+ // file, 'symlink' / 'submodule' / 'dir' otherwise). Returns { ok, reasons, dir }.
27
+ export function gateFiles(changed, entries = {}) {
28
+ const reasons = [];
29
+ const dirs = new Set();
30
+ if (!changed.length) reasons.push('the pull request lists no changed files');
31
+ if (changed.length > POLICY.maxFiles) reasons.push(`it changes ${changed.length} files; a submission is exactly ${POLICY.maxFiles}`);
32
+ let total = 0;
33
+ const seen = new Set();
34
+ for (const { filename, status } of changed) {
35
+ const name = String(filename);
36
+ if (name.split('/').some((seg) => seg === '..' || seg === '.' || seg === '') || name.includes('\\')) {
37
+ reasons.push(`\`${safe(name)}\` is not a plain relative path`);
38
+ continue;
39
+ }
40
+ const m = DIR_RE.exec(name);
41
+ if (!m) { reasons.push(`\`${safe(name)}\` is outside a submission directory or is not one of ${FILES.join(', ')}`); continue; }
42
+ dirs.add(m[1]);
43
+ seen.add(m[2]);
44
+ if (status !== 'added') { reasons.push(`\`${safe(name)}\` is ${safe(status)}, not added`); continue; }
45
+ const e = entries[name];
46
+ if (!e) { reasons.push(`\`${safe(name)}\` could not be read at the head commit`); continue; }
47
+ if (e.type !== 'file') { reasons.push(`\`${safe(name)}\` is a ${safe(e.type)}, not a regular file`); continue; }
48
+ if (!Number.isInteger(e.size) || e.size < 0) { reasons.push(`\`${safe(name)}\` has no usable size`); continue; }
49
+ if (e.size > POLICY.fileBytes) reasons.push(`\`${safe(name)}\` is ${e.size} bytes; the limit is ${POLICY.fileBytes}`);
50
+ total += e.size;
51
+ }
52
+ if (dirs.size > 1) reasons.push(`it touches ${dirs.size} submission directories; automatic merging takes exactly one`);
53
+ if (dirs.size === 1 && !reasons.length) {
54
+ const missing = FILES.filter((f) => !seen.has(f));
55
+ if (missing.length) reasons.push(`missing file(s): ${missing.join(', ')}`);
56
+ }
57
+ if (total > POLICY.totalBytes) reasons.push(`the files total ${total} bytes; the limit is ${POLICY.totalBytes}`);
58
+ return { ok: reasons.length === 0 && dirs.size === 1, reasons, dir: dirs.size === 1 ? [...dirs][0] : null };
59
+ }
60
+
61
+ // account: { createdAt, association, mergedLastDay, flaggedLastDay } (counts exclude this pull request).
62
+ export function gateAccount(account, now, policy = POLICY) {
63
+ const reasons = [];
64
+ if (account.mergedLastDay >= policy.perAccountPerDay) {
65
+ reasons.push(`this account already has ${account.mergedLastDay} auto-merged submissions in the last 24 hours (limit ${policy.perAccountPerDay})`);
66
+ }
67
+ if (!policy.trustedAssociations.includes(account.association)) {
68
+ const created = Date.parse(account.createdAt);
69
+ if (!Number.isFinite(created)) reasons.push('the account creation date is unknown');
70
+ else if ((now - created) / 86400000 < policy.minAccountAgeDays) reasons.push(`the account is younger than ${policy.minAccountAgeDays} days`);
71
+ if (account.flaggedLastDay > 0) reasons.push('a submission from this account was refused in the last 24 hours');
72
+ }
73
+ return { ok: reasons.length === 0, reasons };
74
+ }
75
+
76
+ // facts: {
77
+ // validatedSha, currentSha – the head commit the validation read, and the head right now
78
+ // files: gateFiles() result, validation: { ok, errors }, cla: 'success' | other | null,
79
+ // account: gateAccount() result
80
+ // }
81
+ // Returns { merge, reasons, waitForCla }. waitForCla marks a refusal that is only "not yet": the maintainer or
82
+ // contributor fixes it by signing, and no needs-review label is warranted.
83
+ export function decide({ validatedSha, currentSha, files, validation, cla, account }) {
84
+ const reasons = [];
85
+ if (!validatedSha || validatedSha !== currentSha) {
86
+ return { merge: false, reasons: ['the pull request changed after it was checked; the next run will check the new commit'], stale: true, waitForCla: false };
87
+ }
88
+ if (!files.ok) reasons.push(...files.reasons);
89
+ if (files.ok && !validation.ok) reasons.push(...validation.errors.map((e) => `check: ${e}`));
90
+ if (files.ok && validation.ok && validation.dir && validation.dir !== files.dir) reasons.push('the validated directory differs from the changed directory');
91
+ const claOk = cla === 'success';
92
+ if (!claOk) reasons.push(`the \`${POLICY.claContext}\` check is ${cla || 'missing'}; sign the CLA (see the comment from the CLA bot) and a maintainer can re-run this`);
93
+ if (!account.ok) reasons.push(...account.reasons);
94
+ const onlyCla = !claOk && reasons.length === 1;
95
+ return { merge: reasons.length === 0, reasons, stale: false, waitForCla: onlyCla };
96
+ }
97
+
98
+ export function renderVerdict({ merge, reasons, stale }, { dir } = {}) {
99
+ const marker = '<!-- skillgauntlet-automerge -->';
100
+ if (merge) return `${marker}\n### ✅ Merged automatically\n\n\`${safe(dir)}\` passed every check and was merged as a **community single sample**.\n`;
101
+ if (stale) return null;
102
+ return `${marker}\n### ⏸️ Not merged automatically\n\n${reasons.map((r) => `- ${r}`).join('\n')}\n\nA maintainer will look at this by hand. Nothing is wrong with asking: this is a safety rule, not a verdict on your results.\n`;
103
+ }
104
+
105
+ export function mergeTitle(dir) {
106
+ return `Add submission ${dir.replace(/^submissions\//, '')}`;
107
+ }
package/lib/chart.js CHANGED
@@ -31,9 +31,12 @@ function staticLines(result) {
31
31
  for (const g of result.groups) {
32
32
  const mark = g.verdict === 'pass' ? green('✓') : g.verdict === 'warn' ? yellow('!') : red('✗');
33
33
  L.push(` ${mark} ${g.id} ${t(g.titleKey)}`);
34
- // A group that is not clean says which checks and why: a letter and a mark alone cannot be acted on.
35
- for (const c of g.checks.filter((x) => x.status === 'warn' || x.status === 'fail')) {
36
- L.push(` ${c.status === 'fail' ? red('✗') : yellow('!')} ${c.id} ${t(c.titleKey)}${dim(` — ${cut(t(c.detailKey, c.detailVars), 70)}`)}`);
34
+ // Every check of the group with its reason: a letter and a mark alone cannot be acted on. Clean ones are dimmed.
35
+ for (const c of g.checks) {
36
+ const flagged = c.status === 'warn' || c.status === 'fail';
37
+ const sym = c.status === 'fail' ? red('✗') : c.status === 'warn' ? yellow('!') : c.status === 'info' ? dim('·') : green('✓');
38
+ const text = `${c.id} ${t(c.titleKey)} — ${cut(t(c.detailKey, c.detailVars), 70)}`;
39
+ L.push(` ${sym} ${flagged ? text : dim(text)}`);
37
40
  for (const e of (c.evidence || []).slice(0, 2)) L.push(` ${dim(`${cut(`${e.file || ''}:${e.line}`, 56)} ${cut(e.match, 40)}`)}`);
38
41
  }
39
42
  }
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
@@ -13,6 +13,8 @@ const MESSAGES = {
13
13
  srcInstalledHint: '扫 .agents/skills、.claude/skills、.codex/skills',
14
14
  srcPath: '另一个本地目录',
15
15
  srcRepo: 'GitHub 仓库(未安装)',
16
+ srcRepoHint: 'owner/repo · 可分享结果',
17
+ sourceNote: '提示:想把测试结果分享到公开榜单,最省事的是选“GitHub 仓库”:程序会自动知道技能来自哪个仓库的哪个提交。已安装的技能和本地目录也可以分享,但前提是它在带 GitHub origin 的 git 仓库里、改动已提交并推送;否则要自己补上仓库地址和提交,结果页会给出命令。',
16
18
  noInstalled: '本机没有找到已安装的技能。',
17
19
  foundInstalled: '找到 {n} 个已安装的技能。',
18
20
  scopeQ: '测哪些?',
@@ -36,7 +38,7 @@ const MESSAGES = {
36
38
  needDeepseekKey: '要用 DeepSeek 模型,请先在环境变量里设 DEEPSEEK_API_KEY(只放变量,不要把密钥写进命令行或文件)。',
37
39
  hostCodewhale: 'Codewhale',
38
40
  hostCodewhaleHint: '能测触发率(原 DeepSeek-TUI,用 DeepSeek 模型)',
39
- codewhaleNote: 'Codewhale 的技能调用在事件流里是名为 load_skill 的工具调用:第一个 input.name 等于被测技能才算触发(查询和列目录不算)。试跑在一个只装了被测技能的临时 HOME 里进行,只开放 load_skill 这一个工具;用 DEEPSEEK_API_KEY,花 DeepSeek 现金。这是 Codewhale 的数字,不与其他宿主的合并比较。需要 codewhale 在 PATH 里(npm install -g codewhale --allow-scripts=codewhale)。',
41
+ codewhaleNote: 'Codewhale 的技能调用在事件流里是名为 load_skill 的工具调用:第一个 input.name 等于被测技能才算触发(查询和列目录不算)。试跑在一个只装了被测技能的临时 HOME 里进行,只开放 load_skill 这一个工具;用 DEEPSEEK_API_KEY。费用取决于后端和你的计划。这是 Codewhale 的数字,不与其他宿主的合并比较。需要 codewhale 在 PATH 里(npm install -g codewhale --allow-scripts=codewhale)。',
40
42
  hostCodex: 'Codex',
41
43
  hostCodexHint: '触发不可观测:只测静态与激活探针',
42
44
  codexNote: 'Codex 把技能当作宿主侧上下文注入,事件流里没有"技能被调用"这一项(只有 command_execution / file_change / mcp_tool_call 等)。所以这里不给触发率——给了就是编的。替代方案是激活探针:同一请求装与不装各跑一次,比首个动作与改动文件。',
@@ -67,9 +69,7 @@ const MESSAGES = {
67
69
  depthDeepHint: '~10 分钟 · 加只读探索与配对增益',
68
70
  providerQ: '用哪个后端?',
69
71
  provClaude: 'Claude Code 订阅',
70
- provClaudeHint: '占周额度',
71
72
  provDeepseek: 'DeepSeek',
72
- provDeepseekHint: '按 DeepSeek 用量计费',
73
73
  provManual: '手动输入模型名',
74
74
  modelQ: '模型',
75
75
  modelOptionalQ: '模型(直接回车 = 默认模型)',
@@ -90,7 +90,6 @@ const MESSAGES = {
90
90
  modelExpensive: '最贵',
91
91
  needBaseUrl: '需要先设置后端:\n ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic\n ANTHROPIC_AUTH_TOKEN=<你的 key>',
92
92
  backendLine: '后端:{backend} 模型:{model}',
93
- estimate: '即将运行:{skills} 个技能 × {trials} 次试跑 ≈ 每个 {minutes} 分钟,合计约 {totalMinutes} 分钟\n费用取决于后端和计划:订阅走额度,DeepSeek 按量计费(这里不估金额)',
94
93
  confirmRun: '继续?',
95
94
  cancelled: '已取消。免费的静态层不需要确认:npx skillgauntlet static <目录>',
96
95
  staticDone: '静态层:{verdicts}',
@@ -132,6 +131,13 @@ const MESSAGES = {
132
131
  'chart.otherSkill': '改用别的技能:{name}',
133
132
  'chart.more': '…还有 {n} 题,见完整报告',
134
133
  'chart.wrongTitle': '不该触发却触发的 {n} 次',
134
+ modelUnknown: '{host} 的模型清单里没有“{model}”。它现在支持:{list}。请重新选一个,或选“默认模型”。',
135
+ updateAvail: '有新版本 {latest}(你现在用的是 {current})。',
136
+ updateQ: '要更新吗?',
137
+ updateNow: '更新并重新启动',
138
+ updateSkip: '先用当前版本(这个版本不再提醒)',
139
+ updating: '正在下载新版 {latest}…',
140
+ updateFailed: '更新没有成功:{why}。继续使用当前版本 {current}。',
135
141
  waitCloning: '正在下载仓库(大仓库可能要几十秒)…',
136
142
  waitAnalyze: '正在做静态检查…',
137
143
  waitModels: '正在读取 {host} 的模型列表…',
@@ -146,7 +152,7 @@ const MESSAGES = {
146
152
  'chart.probeMixed': '读法:第一步和产出只有一项不同,信号弱;只跑了一次,不能下结论。',
147
153
  'chart.static': '静态检查(不调用模型)',
148
154
  'chart.staticLegend': '✓ 通过 ! 警告(值得看一眼) ✗ 失败(建议处理);每项的完整说明见完整报告',
149
- noSubmitNoSource: '这个技能不是从公开的 GitHub 仓库测的(本地目录或已安装的技能),榜单需要知道它来自哪个仓库的哪个提交,所以不能提交。想提交,请用“GitHub 仓库”这个来源再测一次。',
155
+ noSubmitNoSource: '这次的结果暂时不能自动提交:程序看不出这个技能来自哪个公开仓库的哪个提交。\n有两种办法:\n 1. 如果技能在带 GitHub origin 的 git 仓库里,把改动提交并推送到 GitHub,再对同一目录重测(榜单会核对公开仓库里的 SKILL.md 与你测的一致)。\n 2. 你知道它的仓库和提交,直接用下面的命令补上(--subdir 是技能在仓库里的子目录,在仓库根目录就省略):\n {cmd}',
150
156
  'chart.trigger': '触发率:该用这个技能的时候,它被用上了吗',
151
157
  'chart.verdict': '结论',
152
158
  'chart.grade.well': '触发准确',
@@ -166,7 +172,7 @@ const MESSAGES = {
166
172
  'chart.gainLegend': '每项是一条程序能判分的要求,条越长越符合',
167
173
  'chart.full': '完整报告:{path}',
168
174
  progressLine: '试跑',
169
- draftFirst: '接下来先为每个技能出题(每个技能 1 次模型调用),出完会告诉你确切的试跑次数,你同意后才开始试跑。费用取决于后端和你的计划(订阅走额度,DeepSeek 按用量计费)。',
175
+ draftFirst: '接下来先为每个技能出题(每个技能 1 次模型调用),出完会告诉你确切的试跑次数,你同意后才开始试跑。费用取决于后端和你的计划。',
170
176
  trialsQ: '{q} 道题 × {runs} 次 = {n} 次试跑,约 {minutes} 分钟。',
171
177
  trialsGo: '开始试跑',
172
178
  trialsAll: '开始,后面的技能不再逐个询问',
@@ -329,6 +335,36 @@ const MESSAGES = {
329
335
  'poll.body': '1) Claude Code 2) Codex 3) 其他 —— 回一个数字就行。',
330
336
  'poll.why': '为什么问:触发率只在支持显式技能调用的宿主上可测。Codex 的事件流里没有 skill item,那一列是空的,不是没测。所以读者用什么宿主,决定这份报告对谁有用、下一步该补哪一层。答案只用作信号,不做比例统计:回帖的人是自我选择的,算不出比例。',
331
337
  'trial.title': '触发率',
338
+ 'trial.answersTitle': '逐题详情:模型说了什么、先做了什么',
339
+ 'trial.stopNote': '试跑在模型做出第一个动作时就停止(那时已能判定有没有用上技能),所以这里只到“第一步”,不是完整回答;也不会让模型真的去执行命令。',
340
+ 'trial.runN': '第 {n} 轮:',
341
+ 'trial.saidFirst': '动作之前它先说:',
342
+ 'trial.saidNothing': '动作之前没说任何话,直接行动。',
343
+ 'trial.glance': '一眼看结果',
344
+ 'trial.glanceRecall': '应触发的题:触发了 ',
345
+ 'trial.glanceFpr': '不该触发的题:误触发',
346
+ 'trial.legend': '读法:“期望”是出题时的设定;“实际”是模型真的做了什么;✓ 表示两者一致,✗ 表示不一致(该触发却没触发,或不该触发却触发了)。',
347
+ 'trial.byClause': '题目与结果对照(按触发条款)',
348
+ 'trial.clauseTitle': '条款:{clause}',
349
+ 'trial.noClause': '未归类',
350
+ 'trial.hExpect': '期望',
351
+ 'trial.hActual': '实际',
352
+ 'trial.hMatch': '一致',
353
+ 'trial.hQuery': '题目',
354
+ 'trial.hFirst': '模型先做了什么',
355
+ 'trial.hNote': '出题说明',
356
+ 'trial.actTriggered': '触发了',
357
+ 'trial.actNot': '没触发',
358
+ 'trial.actPartial': '部分触发 {k}/{n}',
359
+ 'trial.actWrong': '误触发 {k}/{n}',
360
+ 'trial.borrows': '(借用的词:{words})',
361
+ 'trial.didSkill': '调用了技能 {name}',
362
+ 'trial.otherSkill': '改用别的技能 {name}',
363
+ 'trial.noToolRun': '没调用工具',
364
+ 'trial.missTitle': '该触发却没触发的 {n} 次:模型改做了什么',
365
+ 'trial.hFirstTool': '先做的事',
366
+ 'trial.hCount': '次数',
367
+ 'trial.allZero': '一次都没触发:更可能是宿主没读到这个技能(路径、安装或权限),而不是技能写得差;请先核对技能装在宿主能读到的位置。',
332
368
  'trial.modelLine': '模型:{model}({backend});宿主:{host};每题 {runs} 轮,共 {trials} 次试跑',
333
369
  'trial.hostUnknown': '未记录',
334
370
  'trial.recall': '召回率:{p}(95% CI {lo}–{hi})',
@@ -373,6 +409,8 @@ const MESSAGES = {
373
409
  srcInstalledHint: 'scans .agents/skills, .claude/skills, .codex/skills',
374
410
  srcPath: 'Another local directory',
375
411
  srcRepo: 'GitHub repository (not installed)',
412
+ srcRepoHint: 'owner/repo · results can be shared',
413
+ sourceNote: 'Tip: to share the results on the public leaderboard, the easiest is "GitHub repository": the program then knows which repository and commit the skill came from. An installed skill or a local folder can be shared too, but only if it is in a git repository with a GitHub origin and its changes are committed and pushed; otherwise you supply the repository and commit yourself, and the results page gives you the command.',
376
414
  noInstalled: 'No installed skills found on this machine.',
377
415
  foundInstalled: 'Found {n} installed skills.',
378
416
  scopeQ: 'Which ones?',
@@ -396,7 +434,7 @@ const MESSAGES = {
396
434
  needDeepseekKey: 'To use a DeepSeek model, set DEEPSEEK_API_KEY in the environment first (a variable only; keep the key out of the command line and files).',
397
435
  hostCodewhale: 'Codewhale',
398
436
  hostCodewhaleHint: 'trigger rate is measurable (formerly DeepSeek-TUI, runs DeepSeek models)',
399
- codewhaleNote: 'In Codewhale a skill call is a tool call named load_skill: the first one whose input.name equals the tested skill counts as a trigger (a query or a catalogue listing does not). Trials run in a throw-away HOME holding only the tested skill, with load_skill as the only tool; it uses DEEPSEEK_API_KEY and spends DeepSeek cash. The number is a Codewhale number and is not merged with another host. It needs codewhale on PATH (npm install -g codewhale --allow-scripts=codewhale).',
437
+ codewhaleNote: 'In Codewhale a skill call is a tool call named load_skill: the first one whose input.name equals the tested skill counts as a trigger (a query or a catalogue listing does not). Trials run in a throw-away HOME holding only the tested skill, with load_skill as the only tool; it uses DEEPSEEK_API_KEY. Cost depends on the backend and your plan. The number is a Codewhale number and is not merged with another host. It needs codewhale on PATH (npm install -g codewhale --allow-scripts=codewhale).',
400
438
  hostCodex: 'Codex',
401
439
  hostCodexHint: 'trigger is not observable: static plus an activation probe',
402
440
  codexNote: 'Codex injects a skill as host-side context; the event stream has no "skill was used" item (only command_execution, file_change, mcp_tool_call). A trigger rate here would have to be invented, so instead there is an activation probe: the same request with and without the skill, comparing the first action and the files changed.',
@@ -427,9 +465,7 @@ const MESSAGES = {
427
465
  depthDeepHint: '~10 min · adds read-only exploration and paired gain',
428
466
  providerQ: 'Which backend?',
429
467
  provClaude: 'Claude Code subscription',
430
- provClaudeHint: 'spends weekly quota',
431
468
  provDeepseek: 'DeepSeek',
432
- provDeepseekHint: 'billed by DeepSeek usage',
433
469
  provManual: 'Type a model name',
434
470
  modelQ: 'Model',
435
471
  modelOptionalQ: 'Model (press Enter for the default)',
@@ -450,7 +486,6 @@ const MESSAGES = {
450
486
  modelExpensive: 'most expensive',
451
487
  needBaseUrl: 'Set the backend first:\n ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic\n ANTHROPIC_AUTH_TOKEN=<your key>',
452
488
  backendLine: 'Backend: {backend} model: {model}',
453
- estimate: 'About to run: {skills} skill(s) × {trials} trials ≈ {minutes} min each, {totalMinutes} min in total\nCost depends on the backend and plan: a subscription spends quota, DeepSeek bills per use (no amount is estimated here)',
454
489
  confirmRun: 'Continue?',
455
490
  cancelled: 'Cancelled. The free static layer needs no confirmation: npx skillgauntlet static <dir>',
456
491
  staticDone: 'Static: {verdicts}',
@@ -492,6 +527,13 @@ const MESSAGES = {
492
527
  'chart.otherSkill': 'used another skill: {name}',
493
528
  'chart.more': '…{n} more, see the full report',
494
529
  'chart.wrongTitle': '{n} times it triggered when it should not have',
530
+ modelUnknown: '{host} does not list a model called "{model}". It currently supports: {list}. Pick another, or choose the default model.',
531
+ updateAvail: 'A newer version {latest} is available (you are on {current}).',
532
+ updateQ: 'Update?',
533
+ updateNow: 'Update and restart',
534
+ updateSkip: 'Keep this version (do not ask about this one again)',
535
+ updating: 'Downloading {latest}…',
536
+ updateFailed: 'The update did not work: {why}. Continuing with {current}.',
495
537
  waitCloning: 'Downloading the repository (a big one can take tens of seconds)…',
496
538
  waitAnalyze: 'Running the static checks…',
497
539
  waitModels: 'Reading the model list from {host}…',
@@ -506,7 +548,7 @@ const MESSAGES = {
506
548
  'chart.probeMixed': 'Reading: only one of the first step and the output differs, a weak signal; one run each is not a conclusion.',
507
549
  'chart.static': 'Static checks (no model calls)',
508
550
  'chart.staticLegend': '✓ pass ! warning (worth a look) ✗ fail (should be handled); each check is explained in the full report',
509
- noSubmitNoSource: 'This skill was not tested from a public GitHub repository (a local folder or an installed skill). The leaderboard needs to know which repository and commit it came from, so it cannot be submitted. To submit, test it again using the "GitHub repository" source.',
551
+ noSubmitNoSource: 'These results cannot be submitted automatically yet: the program cannot tell which public repository and commit this skill came from.\nTwo ways forward:\n 1. If the skill is in a git repository with a GitHub origin, commit and push your changes, then test the same folder again (the leaderboard checks that SKILL.md in the public repository matches what you tested).\n 2. If you know its repository and commit, add them with the command below (--subdir is the skill\'s folder inside the repository; leave it out at the repository root):\n {cmd}',
510
552
  'chart.trigger': 'Trigger rate: is the skill used when it should be?',
511
553
  'chart.verdict': 'Verdict',
512
554
  'chart.grade.well': 'triggers accurately',
@@ -526,7 +568,7 @@ const MESSAGES = {
526
568
  'chart.gainLegend': 'Each item is a requirement a program can grade; a longer bar means more of it is met',
527
569
  'chart.full': 'Full report: {path}',
528
570
  progressLine: 'Trials',
529
- draftFirst: 'Next, questions are drafted for each skill (one model call per skill). You will then be told the exact number of trials and nothing is run until you agree. Cost depends on the backend and your plan (a subscription spends quota, DeepSeek bills per use).',
571
+ draftFirst: 'Next, questions are drafted for each skill (one model call per skill). You will then be told the exact number of trials and nothing is run until you agree. Cost depends on the backend and your plan.',
530
572
  trialsQ: '{q} questions × {runs} run(s) = {n} trials, about {minutes} min.',
531
573
  trialsGo: 'Start the trials',
532
574
  trialsAll: 'Start, and do not ask again for the next skills',
@@ -685,6 +727,36 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
685
727
  'poll.body': '1) Claude Code 2) Codex 3) Other — reply with a single digit.',
686
728
  '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.',
687
729
  'trial.title': 'Trigger rate',
730
+ 'trial.answersTitle': 'Per question: what the model said and did first',
731
+ '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.',
732
+ 'trial.runN': 'Run {n}:',
733
+ 'trial.saidFirst': 'Before acting it said:',
734
+ 'trial.saidNothing': 'It said nothing before acting.',
735
+ 'trial.glance': 'At a glance',
736
+ 'trial.glanceRecall': 'Should trigger: triggered ',
737
+ 'trial.glanceFpr': 'Should not: triggered ',
738
+ '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).',
739
+ 'trial.byClause': 'Questions and results, by trigger clause',
740
+ 'trial.clauseTitle': 'Clause: {clause}',
741
+ 'trial.noClause': 'Unassigned',
742
+ 'trial.hExpect': 'Expected',
743
+ 'trial.hActual': 'Actual',
744
+ 'trial.hMatch': 'Agree',
745
+ 'trial.hQuery': 'Question',
746
+ 'trial.hFirst': 'What the model did first',
747
+ 'trial.hNote': 'Why it was written',
748
+ 'trial.actTriggered': 'triggered',
749
+ 'trial.actNot': 'did not trigger',
750
+ 'trial.actPartial': 'triggered {k}/{n}',
751
+ 'trial.actWrong': 'false trigger {k}/{n}',
752
+ 'trial.borrows': '(borrowed words: {words})',
753
+ 'trial.didSkill': 'invoked skill {name}',
754
+ 'trial.otherSkill': 'used another skill: {name}',
755
+ 'trial.noToolRun': 'no tool',
756
+ 'trial.missTitle': 'The {n} misses: what the model did instead',
757
+ 'trial.hFirstTool': 'First thing it did',
758
+ 'trial.hCount': 'Count',
759
+ '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.',
688
760
  'trial.modelLine': 'Model: {model} ({backend}); host: {host}; {runs} runs per query, {trials} trials',
689
761
  'trial.hostUnknown': 'not recorded',
690
762
  'trial.recall': 'Recall: {p} (95% CI {lo}–{hi})',
@@ -728,6 +800,8 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
728
800
  srcInstalledHint: '.agents/skills、.claude/skills、.codex/skills を走査',
729
801
  srcPath: '別のローカルディレクトリ',
730
802
  srcRepo: 'GitHub リポジトリ(未インストール)',
803
+ srcRepoHint: 'owner/repo · 結果を共有できます',
804
+ sourceNote: 'ヒント:結果を公開リーダーボードで共有するなら「GitHub リポジトリ」が最も簡単です。スキルがどのリポジトリのどのコミットかをプログラムが自動で把握します。インストール済みのスキルやローカルフォルダも共有できますが、GitHub の origin を持つ git リポジトリにあり、変更がコミット・プッシュ済みであることが条件です。そうでなければリポジトリとコミットを自分で指定します(結果画面にコマンドが表示されます)。',
731
805
  noInstalled: 'このマシンにインストール済みのスキルが見つかりません。',
732
806
  foundInstalled: 'インストール済みのスキルが {n} 件見つかりました。',
733
807
  scopeQ: 'どれをテストしますか?',
@@ -753,7 +827,7 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
753
827
  needDeepseekKey: 'DeepSeek のモデルを使うには、先に環境変数 DEEPSEEK_API_KEY を設定してください(変数にのみ置き、キーをコマンドラインやファイルに書かないでください)。',
754
828
  hostCodewhale: 'Codewhale',
755
829
  hostCodewhaleHint: '発動率を測定できます(旧 DeepSeek-TUI、DeepSeek のモデルで動作)',
756
- codewhaleNote: 'Codewhale では、スキルの呼び出しは load_skill という名前のツール呼び出しです。最初の呼び出しの input.name が対象スキルと一致した場合のみ発動とみなします(検索や一覧表示は含みません)。試行は対象スキルだけを入れた使い捨ての HOME で行い、使えるツールは load_skill のみです。DEEPSEEK_API_KEY を使い、DeepSeek の現金がかかります。これは Codewhale の数値であり、他のホストの数値とは合算・比較しません。codewhale が PATH 上に必要です(npm install -g codewhale --allow-scripts=codewhale)。',
830
+ codewhaleNote: 'Codewhale では、スキルの呼び出しは load_skill という名前のツール呼び出しです。最初の呼び出しの input.name が対象スキルと一致した場合のみ発動とみなします(検索や一覧表示は含みません)。試行は対象スキルだけを入れた使い捨ての HOME で行い、使えるツールは load_skill のみです。DEEPSEEK_API_KEY を使います。費用はバックエンドとプランによります。これは Codewhale の数値であり、他のホストの数値とは合算・比較しません。codewhale が PATH 上に必要です(npm install -g codewhale --allow-scripts=codewhale)。',
757
831
  codexNote: 'Codex はスキルをホスト側のコンテキストとして注入するため、イベントストリームに「スキルが使われた」という項目がありません(command_execution / file_change / mcp_tool_call などのみ)。ここで発動率を出すと作り話になるので出しません。代わりに有効化プローブを使います:同じリクエストをスキルあり・なしで 1 回ずつ実行し、最初の動作と変更されたファイルを比べます。',
758
832
  probeQ: '有効化プローブを実行しますか?(2 回実行)',
759
833
  probeRun: 'プローブ実行中(スキルあり、次にスキルなし)…',
@@ -782,9 +856,7 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
782
856
  depthDeepHint: '約 10 分 · 読み取り専用の探索とペア比較の効果測定を追加',
783
857
  providerQ: 'どのバックエンドを使いますか?',
784
858
  provClaude: 'Claude Code サブスクリプション',
785
- provClaudeHint: '週間の利用枠を消費',
786
859
  provDeepseek: 'DeepSeek',
787
- provDeepseekHint: 'DeepSeek の従量課金',
788
860
  provManual: 'モデル名を手入力',
789
861
  modelQ: 'モデル',
790
862
  modelOptionalQ: 'モデル(Enter で既定のモデル)',
@@ -805,7 +877,6 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
805
877
  modelExpensive: '最も高価',
806
878
  needBaseUrl: '先にバックエンドを設定してください:\n ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic\n ANTHROPIC_AUTH_TOKEN=<あなたの key>',
807
879
  backendLine: 'バックエンド:{backend} モデル:{model}',
808
- estimate: '実行予定:{skills} 件のスキル × {trials} 回の試行 ≈ 1 件あたり {minutes} 分、合計約 {totalMinutes} 分\n費用はバックエンドとプランによります:サブスクリプションは利用枠を消費し、DeepSeek は従量課金です(ここでは金額を見積もりません)',
809
880
  confirmRun: '続行しますか?',
810
881
  cancelled: 'キャンセルしました。無料の静的チェックは確認不要です:npx skillgauntlet static <ディレクトリ>',
811
882
  staticDone: '静的チェック:{verdicts}',
@@ -900,6 +971,13 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
900
971
  'chart.otherSkill': '別のスキルを使用:{name}',
901
972
  'chart.more': '…ほか {n} 件は詳細レポートへ',
902
973
  'chart.wrongTitle': '発動すべきでないのに発動した {n} 回',
974
+ modelUnknown: '{host} のモデル一覧に「{model}」はありません。現在対応:{list}。別のものを選ぶか、既定のモデルを選んでください。',
975
+ updateAvail: '新しいバージョン {latest} があります(現在は {current})。',
976
+ updateQ: '更新しますか?',
977
+ updateNow: '更新して再起動',
978
+ updateSkip: '今のバージョンのまま使う(このバージョンは再通知しない)',
979
+ updating: '{latest} をダウンロード中…',
980
+ updateFailed: '更新に失敗しました:{why}。現在の {current} で続行します。',
903
981
  waitCloning: 'リポジトリをダウンロード中(大きいものは数十秒かかります)…',
904
982
  waitAnalyze: '静的チェックを実行中…',
905
983
  waitModels: '{host} のモデル一覧を読み込み中…',
@@ -914,7 +992,7 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
914
992
  'chart.probeMixed': '読み方:最初の動作と成果物のどちらか一方だけが異なり、弱い信号です。各 1 回では結論は出せません。',
915
993
  'chart.static': '静的チェック(モデル呼び出しなし)',
916
994
  'chart.staticLegend': '✓ 合格 ! 警告(確認推奨) ✗ 不合格(対処推奨)。各項目の説明は詳細レポートに',
917
- noSubmitNoSource: 'このスキルは公開 GitHub リポジトリからテストされていません(ローカルフォルダまたはインストール済み)。リーダーボードにはどのリポジトリのどのコミットかが必要なため提出できません。提出するには「GitHub リポジトリ」から選んでもう一度テストしてください。',
995
+ noSubmitNoSource: '今回の結果はまだ自動提出できません:このスキルがどの公開リポジトリのどのコミットか、プログラムには分かりません。\n方法は 2 つあります:\n 1. スキルが GitHub の origin を持つ git リポジトリにあるなら、変更をコミットして GitHub にプッシュし、同じフォルダをもう一度テストします(リーダーボードは公開リポジトリの SKILL.md がテストした内容と一致するか確認します)。\n 2. リポジトリとコミットが分かっているなら、次のコマンドで補います(--subdir はリポジトリ内のスキルのフォルダ。リポジトリ直下なら省略):\n {cmd}',
918
996
  'chart.trigger': '発動率:使うべき時にスキルが使われるか',
919
997
  'chart.verdict': '評価',
920
998
  'chart.grade.well': '正確に発動',
@@ -934,7 +1012,7 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
934
1012
  'chart.gainLegend': '各項目はプログラムが採点できる要件です。バーが長いほど満たしています',
935
1013
  'chart.full': '詳細レポート:{path}',
936
1014
  progressLine: '試行',
937
- draftFirst: 'まず各スキルのテスト用質問を作成します(スキルごとにモデル呼び出し 1 回)。その後、試行回数を正確にお知らせし、同意いただいてから試行を開始します。費用はバックエンドとプランによります(サブスクリプションは利用枠を消費、DeepSeek は従量課金)。',
1015
+ draftFirst: 'まず各スキルのテスト用質問を作成します(スキルごとにモデル呼び出し 1 回)。その後、試行回数を正確にお知らせし、同意いただいてから試行を開始します。費用はバックエンドとプランによります。',
938
1016
  trialsQ: '質問 {q} 件 × {runs} 回 = {n} 回の試行、約 {minutes} 分。',
939
1017
  trialsGo: '試行を開始する',
940
1018
  trialsAll: '開始し、以降のスキルでは確認しない',
@@ -1040,6 +1118,36 @@ Layer 0 always runs first: it is free, and it disqualifies most problems before
1040
1118
  'poll.body': '1) Claude Code 2) Codex 3) その他 —— 数字 1 つで答えてください。',
1041
1119
  'poll.why': '質問の理由:発動率は、スキルの明示的な呼び出しを公開しているホストでしか測れません。Codex のイベントストリームには skill 項目がないため、その列は「未測定」ではなく空欄です。読者がどのホストを使っているかで、このレポートが誰の役に立つか、次にどの層を作るべきかが決まります。回答はシグナルとしてのみ読み、割合としては扱いません。回答する人は自己選択であり、そこから比率は計算できないためです。',
1042
1120
  'trial.title': '発動率',
1121
+ 'trial.answersTitle': '質問ごとの詳細:モデルが言ったこと・最初にしたこと',
1122
+ 'trial.stopNote': '試行はモデルの最初の動作で止まります(その時点でスキルが使われたか分かるため)。ここは最初のステップまでで、完全な回答ではなく、モデルにコマンドを実行させることもありません。',
1123
+ 'trial.runN': '{n} 回目:',
1124
+ 'trial.saidFirst': '動作の前に言ったこと:',
1125
+ 'trial.saidNothing': '動作の前には何も言わず、すぐ行動しました。',
1126
+ 'trial.glance': '結果の概要',
1127
+ 'trial.glanceRecall': '発動すべき質問:発動 ',
1128
+ 'trial.glanceFpr': '発動すべきでない質問:誤発動',
1129
+ 'trial.legend': '読み方:「期待」は質問の設計意図、「実際」はモデルが実際に行ったこと。✓ は一致、✗ は不一致(未発動、または誤発動)。',
1130
+ 'trial.byClause': '質問と結果の対照(発動条件ごと)',
1131
+ 'trial.clauseTitle': '条件:{clause}',
1132
+ 'trial.noClause': '未分類',
1133
+ 'trial.hExpect': '期待',
1134
+ 'trial.hActual': '実際',
1135
+ 'trial.hMatch': '一致',
1136
+ 'trial.hQuery': '質問',
1137
+ 'trial.hFirst': 'モデルが最初にしたこと',
1138
+ 'trial.hNote': '出題の意図',
1139
+ 'trial.actTriggered': '発動',
1140
+ 'trial.actNot': '発動せず',
1141
+ 'trial.actPartial': '一部発動 {k}/{n}',
1142
+ 'trial.actWrong': '誤発動 {k}/{n}',
1143
+ 'trial.borrows': '(借用した語:{words})',
1144
+ 'trial.didSkill': 'スキル {name} を呼び出し',
1145
+ 'trial.otherSkill': '別のスキルを使用:{name}',
1146
+ 'trial.noToolRun': 'ツールなし',
1147
+ 'trial.missTitle': '発動すべきなのに発動しなかった {n} 回:代わりに何をしたか',
1148
+ 'trial.hFirstTool': '最初にしたこと',
1149
+ 'trial.hCount': '回数',
1150
+ 'trial.allZero': '一度も発動しませんでした。スキルの出来が悪いというより、ホストがこのスキルを読めていない(パス・インストール・権限)可能性が高いです。ホストが読める場所にあるか確認してください。',
1043
1151
  'trial.modelLine': 'モデル:{model}({backend});ホスト:{host};1 問あたり {runs} 回、計 {trials} 回の試行',
1044
1152
  'trial.hostUnknown': '記録なし',
1045
1153
  'trial.recall': '再現率(発動すべき質問で実際に発動した割合):{p}(95% 信頼区間 {lo}–{hi})',
package/lib/models.js CHANGED
@@ -36,3 +36,11 @@ export function hostModels(host, bin, exec = run) {
36
36
  } catch { /* the caller falls back to typing */ }
37
37
  return [];
38
38
  }
39
+
40
+ // A model the user typed that the host does not list would fail only after the question-drafting call started. Names the
41
+ // package turns into a DeepSeek model of its own (deepseek-*) are exempt: they are not in the host's list by design.
42
+ // Returns the host's current list when the name is not in it, else null (also null when the list could not be read).
43
+ export function unknownModel(model, known) {
44
+ if (!model || !known?.length || known.includes(model) || /^deepseek/i.test(model)) return null;
45
+ return known;
46
+ }
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('|---|---|---|');