skillgauntlet 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/trial.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // answers without calling any tool -> not_triggered, counted apart
9
9
  //
10
10
  // NOT YET VERIFIED against a live host: written from the Python runner, exercised only in dry-run.
11
- import { execFileSync, spawn } from 'node:child_process';
11
+ import { execFileSync, spawn, spawnSync } from 'node:child_process';
12
12
  import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
@@ -31,12 +31,19 @@ export const HOSTS = {
31
31
  export function backendFor(host, model) {
32
32
  if (host === 'codewhale') return 'codewhale:api.deepseek.com';
33
33
  if (host !== 'codebuddy') return backendHost();
34
- return isDeepseekModel(model) ? 'codebuddy:api.deepseek.com' : 'codebuddy';
34
+ return usesOwnDeepseek('codebuddy', model) ? 'codebuddy:api.deepseek.com' : 'codebuddy';
35
35
  }
36
36
 
37
37
  // A model id starting with "deepseek" means the DeepSeek API, whichever host drives it.
38
38
  export const isDeepseekModel = (model) => /^deepseek/i.test(String(model || ''));
39
39
 
40
+ // A DeepSeek model that the host lists itself (CodeBuddy's own deepseek-v4.1-flash) is served by the host's account
41
+ // and needs no key of ours. Only a deepseek-* name the host does not list takes the package's own DeepSeek route
42
+ // (a models.json entry in the trial directory and DEEPSEEK_API_KEY from the environment).
43
+ const nativeModels = new Map();
44
+ export const setNativeModels = (host, list) => nativeModels.set(host, new Set(list || []));
45
+ export const usesOwnDeepseek = (host, model) => isDeepseekModel(model) && !nativeModels.get(host)?.has(model);
46
+
40
47
  // DeepSeek's integration guide for WorkBuddy/CodeBuddy: a models.json entry whose key is read from the
41
48
  // environment (${DEEPSEEK_API_KEY}), so the file holds no secret. It is written into the trial's own
42
49
  // directory (project level), never into the user's ~/.codebuddy.
@@ -66,7 +73,12 @@ const LINE_BREAK = /\r?\n/;
66
73
 
67
74
  // On Windows `where` returns the extension-less shim first (a shell script Node cannot exec), then the
68
75
  // .cmd that it can -- with shell: true. Prefer a real executable, then a cmd shim, then whatever is left.
76
+ // A host CLI found somewhere off PATH (or just installed) and accepted by the user, keyed by its command name.
77
+ const binOverride = new Map();
78
+ export const setHostBin = (name, file) => binOverride.set(name, file);
79
+
69
80
  export function resolveBin(name) {
81
+ if (binOverride.has(name)) return binOverride.get(name);
70
82
  const finder = process.platform === 'win32' ? 'where' : 'which';
71
83
  let found = [];
72
84
  try {
@@ -83,6 +95,7 @@ export function resolveBin(name) {
83
95
  // Whether a host CLI can be found on PATH at all. resolveBin falls back to the bare name, which on Windows
84
96
  // ends in cmd.exe's "not recognized" (or a spawn ENOENT) deep inside a trial; this asks up front.
85
97
  export function binInstalled(name) {
98
+ if (binOverride.has(name)) return true;
86
99
  try {
87
100
  execFileSync(process.platform === 'win32' ? 'where' : 'which', [name], { stdio: 'ignore' });
88
101
  return true;
@@ -103,6 +116,44 @@ export function requireHost(host, probe = binInstalled) {
103
116
  throw err;
104
117
  }
105
118
 
119
+ // One tiny call before the real work: a host that is not signed in retries silently for minutes, so the user
120
+ // would stare at a frozen screen. A short timeout turns that into an immediate, actionable message.
121
+ // The first non-empty line of some CLI output, for an error message.
122
+ const firstLine = (text) => String(text || '').split(LINE_BREAK).map((l) => l.trim()).find(Boolean) || '';
123
+
124
+ // What a host CLI answered, as an error reason: the CLI's own words when it gave any. A CLI can exit 0 with nothing on
125
+ // stdout and its complaint on stderr (CodeBuddy does for a custom model without an endpoint url), so a clean exit
126
+ // status proves nothing; an empty or unreadable stdout is the failure.
127
+ export function hostAnswerProblem(r, timeout) {
128
+ if (r.error) return r.error.code === 'ETIMEDOUT' ? `no answer within ${timeout}s` : String(r.error.message).split(LINE_BREAK)[0];
129
+ if (r.signal) return `no answer within ${timeout}s`;
130
+ const out = String(r.stdout || '');
131
+ if (r.status !== 0) return firstLine(r.stderr) || firstLine(out) || `exit status ${r.status}`;
132
+ if (!out.trim()) return firstLine(r.stderr) || 'the CLI answered with nothing';
133
+ try {
134
+ const parsed = JSON.parse(out);
135
+ const last = Array.isArray(parsed) ? parsed.filter((m) => m?.type === 'result').pop() : parsed;
136
+ if (last?.is_error) return firstLine(last.result) || 'the CLI reported an error';
137
+ } catch { return firstLine(out); }
138
+ return null;
139
+ }
140
+
141
+ export function pingHost(host, { bin, model = null, timeout = 90, spawn = spawnSync } = {}) {
142
+ const spec = HOSTS[host];
143
+ if (!spec || host === 'codewhale') return;
144
+ const args = ['-p', 'Reply with the single word OK.', '--output-format', 'json', '--tools', '', '--no-session-persistence'];
145
+ if (model && !usesOwnDeepseek(host, model)) args.push('--model', model); // the package's own DeepSeek route has no host-side name
146
+ const launch = launchSpec(bin, args, spec.bin);
147
+ const r = spawn(launch.cmd, launch.args, { encoding: 'utf8', timeout: timeout * 1000, shell: launch.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
148
+ const why = hostAnswerProblem(r, timeout);
149
+ if (why) {
150
+ const e = new Error(`host ${host} did not answer: ${why}`);
151
+ e.messageKey = 'hostNotReady';
152
+ e.vars = { host: HOST_NAME[host] || host, why: why.slice(0, 200) };
153
+ throw e;
154
+ }
155
+ }
156
+
106
157
  // Variables a host (the Claude desktop app, the Agent SDK, an outer Claude Code session) sets for its own
107
158
  // child processes. A trial inherits them otherwise, and they change what the model under test sees: under
108
159
  // the desktop app the CLI adds Artifact, ArtifactComments and ArtifactData to the tool list, and the model
@@ -189,7 +240,7 @@ export function runTrial({ bin, skillDir, skillName, query, model, timeout = 180
189
240
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-'));
190
241
  const spec = HOSTS[host];
191
242
  copyDir(skillDir, path.join(tmp, ...spec.skillsDir, skillName));
192
- if (host === 'codebuddy' && isDeepseekModel(model)) writeModelsJson(tmp, model);
243
+ if (host === 'codebuddy' && usesOwnDeepseek('codebuddy', model)) writeModelsJson(tmp, model);
193
244
  const args = host === 'codebuddy'
194
245
  ? ['-p', query, '--output-format', 'stream-json', '--verbose', '--setting-sources', 'project', '--strict-mcp-config',
195
246
  '--no-session-persistence', '--tools', spec.tools]
@@ -362,23 +413,24 @@ ${text}`;
362
413
  const launch = launchSpec(bin, args, HOSTS[host].bin);
363
414
  // CodeBuddy reads a custom model from the working directory's .codebuddy/models.json, so a DeepSeek draft
364
415
  // runs in a scratch directory that has one.
365
- const scratch = host === 'codebuddy' && isDeepseekModel(model) ? fs.mkdtempSync(path.join(os.tmpdir(), 'sg-draft-')) : null;
416
+ const scratch = host === 'codebuddy' && usesOwnDeepseek('codebuddy', model) ? fs.mkdtempSync(path.join(os.tmpdir(), 'sg-draft-')) : null;
366
417
  if (scratch) writeModelsJson(scratch, model);
367
- let out;
418
+ let r;
368
419
  try {
369
- out = execFileSync(launch.cmd, launch.args, {
420
+ r = spawnSync(launch.cmd, launch.args, {
370
421
  encoding: 'utf8', timeout: timeout * 1000, maxBuffer: 32 * 1024 * 1024,
371
- shell: launch.shell, windowsHide: true, ...(scratch ? { cwd: scratch } : {}),
422
+ shell: launch.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], ...(scratch ? { cwd: scratch } : {}),
372
423
  });
373
- } catch (err) {
374
- // The default message of a failed execFileSync is the whole command line, which here is the drafting prompt.
375
- const why = err.code === 'ETIMEDOUT' || err.killed
376
- ? `no answer within ${timeout}s (a rejected key or an unreachable endpoint retries silently until then)`
377
- : String(err.stderr || err.message).split(/\r?\n/).map((l) => l.trim()).find(Boolean) || 'exit status ' + err.status;
378
- throw new Error(`the drafting call failed: ${why}`);
379
424
  } finally {
380
425
  if (scratch) { try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ } }
381
426
  }
427
+ // The CLI's own words, not "Unexpected end of JSON input": an empty answer with a complaint on stderr is common.
428
+ const problem = hostAnswerProblem(r, timeout);
429
+ if (problem) {
430
+ const hint = r.error?.code === 'ETIMEDOUT' || r.signal ? ' (a rejected key or an unreachable endpoint retries silently until then)' : '';
431
+ throw new Error(`the drafting call failed: ${problem}${hint}`);
432
+ }
433
+ const out = r.stdout;
382
434
  const parsed = JSON.parse(out);
383
435
  // Claude Code answers with one result object; CodeBuddy Code with the whole message list, whose last
384
436
  // element is the result.
package/lib/ui.js CHANGED
@@ -6,7 +6,14 @@
6
6
  // the run mid-flow.
7
7
  import readline, { emitKeypressEvents } from 'node:readline';
8
8
  import { stdin as input, stdout as output } from 'node:process';
9
- import { words } from './i18n.js';
9
+ import { words, t } from './i18n.js';
10
+
11
+ // Thrown by a list when the user presses ← or Esc: the caller goes back one question.
12
+ export class Back extends Error {
13
+ constructor() { super('back'); this.name = 'Back'; }
14
+ }
15
+ let backEnabled = false;
16
+ export const setBack = (on) => { backEnabled = on; };
10
17
 
11
18
  let rl = null;
12
19
  const queue = [];
@@ -37,10 +44,22 @@ export function pauseUi() {
37
44
  }
38
45
  }
39
46
 
47
+ // On Windows a terminal stdin that was ever put in raw mode can keep the process alive after the last prompt (the
48
+ // program handled Ctrl+C, then sat there with no prompt back). Let go of it explicitly.
40
49
  export function closeUi() {
41
50
  pauseUi();
51
+ if (input.isTTY) {
52
+ try {
53
+ if (input.setRawMode) input.setRawMode(false);
54
+ input.pause();
55
+ input.unref?.();
56
+ input.destroy?.();
57
+ } catch { /* already closed */ }
58
+ }
42
59
  }
43
60
 
61
+ export const hasTerminal = () => Boolean(input.isTTY);
62
+
44
63
  export function say(line = '') {
45
64
  output.write(`${line}\n`);
46
65
  }
@@ -61,6 +80,9 @@ export async function ask(question, def = '') {
61
80
  }
62
81
 
63
82
  export async function confirm(question, def = false) {
83
+ if (input.isTTY) {
84
+ return select(question, [{ label: t('ui.no'), value: false }, { label: t('ui.yes'), value: true }], def ? 1 : 0, { hotkeys: { y: 1, n: 0 } });
85
+ }
64
86
  const hint = def ? 'Y/n' : 'y/N';
65
87
  for (;;) {
66
88
  const a = (await ask(`${question} (${hint})`, '')).toLowerCase();
@@ -86,7 +108,9 @@ export async function choose(title, options, def = 1) {
86
108
  }
87
109
  }
88
110
 
89
- export async function select(title, options, defIndex = 0) {
111
+ // opts.clear: erase the list once an entry is chosen, so a caller that shows the next level right away redraws
112
+ // in the same place instead of stacking lists down the terminal.
113
+ export async function select(title, options, defIndex = 0, opts = {}) {
90
114
  if (!input.isTTY) {
91
115
  say('');
92
116
  say(title);
@@ -108,6 +132,7 @@ export async function select(title, options, defIndex = 0) {
108
132
  const WINDOW = 8;
109
133
  const width = Math.min(48, Math.max(...options.map((o) => o.label.length)));
110
134
  let first = true;
135
+ let height = 0;
111
136
  const render = () => {
112
137
  const size = Math.min(WINDOW, options.length);
113
138
  const start = Math.min(Math.max(0, idx - Math.floor(size / 2)), Math.max(0, options.length - size));
@@ -118,6 +143,8 @@ export async function select(title, options, defIndex = 0) {
118
143
  const body = i === idx ? `\x1b[7m▶ ${label} \x1b[0m` : ` ${label} `;
119
144
  lines.push(`${body}${o.hint ? ` ${o.hint}` : ''}`);
120
145
  }
146
+ if (backEnabled && !opts.noBack) lines.push(`\x1b[2m${t('backHint')}\x1b[0m`);
147
+ height = lines.length;
121
148
  if (first) {
122
149
  output.write(`\n${lines.join('\n')}\n`);
123
150
  first = false;
@@ -142,10 +169,21 @@ export async function select(title, options, defIndex = 0) {
142
169
  render();
143
170
  } else if (key.name === 'return' || key.name === 'enter') {
144
171
  cleanup();
172
+ if (opts.clear) output.write(`\x1b[${height + 1}A\x1b[J`);
145
173
  resolve(options[idx].value);
174
+ } else if ((key.name === 'escape' || key.name === 'left') && backEnabled && !opts.noBack) {
175
+ cleanup();
176
+ output.write(`\x1b[${height + 1}A\x1b[J`);
177
+ reject(new Back());
146
178
  } else if (key.ctrl && key.name === 'c') {
147
179
  cleanup();
148
180
  reject(new Error('interrupted'));
181
+ } else if (ch && opts.hotkeys && Object.hasOwn(opts.hotkeys, ch.toLowerCase())) {
182
+ // a hotkey (y / n) chooses at once
183
+ idx = opts.hotkeys[ch.toLowerCase()];
184
+ render();
185
+ cleanup();
186
+ resolve(options[idx].value);
149
187
  } else if (ch && /^[1-9]$/.test(ch) && Number(ch) <= options.length) {
150
188
  idx = Number(ch) - 1;
151
189
  render();
package/lib/update.js ADDED
@@ -0,0 +1,73 @@
1
+ // A check for a newer published version before an interactive session. It must never get in the way: any failure
2
+ // (offline, slow registry, odd reply, unwritable cache) means "no update known" and the session goes on with the
3
+ // version that is running.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const REGISTRY = 'https://registry.npmjs.org/skillgauntlet/latest';
10
+ const CHECK_EVERY_MS = 12 * 60 * 60 * 1000;
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ export function currentVersion() {
14
+ try { return JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version; } catch { return null; }
15
+ }
16
+
17
+ // a.b.c with an optional pre-release tail; a pre-release is older than its release.
18
+ export function isNewer(latest, current) {
19
+ const parse = (v) => {
20
+ const m = String(v).trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
21
+ return m ? { n: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null } : null;
22
+ };
23
+ const a = parse(latest);
24
+ const b = parse(current);
25
+ if (!a || !b) return false;
26
+ for (let i = 0; i < 3; i += 1) if (a.n[i] !== b.n[i]) return a.n[i] > b.n[i];
27
+ return Boolean(b.pre) && !a.pre;
28
+ }
29
+
30
+ // A copy that runs from a git checkout is a developer's own; it is not nagged about releases.
31
+ export function isCheckout() {
32
+ return fs.existsSync(path.join(here, '..', '.git'));
33
+ }
34
+
35
+ function cachePath() {
36
+ return path.join(process.env.SKILLGAUNTLET_CACHE || path.join(os.homedir(), '.cache', 'skillgauntlet'), 'update-check.json');
37
+ }
38
+
39
+ function readCache(file) {
40
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
41
+ }
42
+
43
+ function writeCache(file, data) {
44
+ try {
45
+ fs.mkdirSync(path.dirname(file), { recursive: true });
46
+ fs.writeFileSync(file, `${JSON.stringify(data)}\n`);
47
+ } catch { /* the cache is an optimisation */ }
48
+ }
49
+
50
+ // { current, latest } when a newer version is known and has not been declined, else null. Never throws.
51
+ export async function checkForUpdate({ fetchLatest, now = Date.now(), file = cachePath(), current = currentVersion(), env = process.env } = {}) {
52
+ try {
53
+ if (!current || env.SG_NO_UPDATE_CHECK || env.CI) return null;
54
+ const cache = readCache(file);
55
+ let latest = cache.latest;
56
+ if (!latest || !cache.checkedAt || now - cache.checkedAt > CHECK_EVERY_MS) {
57
+ const get = fetchLatest || (async () => {
58
+ const res = await fetch(REGISTRY, { signal: AbortSignal.timeout(3000), headers: { accept: 'application/json' } });
59
+ if (!res.ok) throw new Error(`registry ${res.status}`);
60
+ return (await res.json()).version;
61
+ });
62
+ latest = await get();
63
+ writeCache(file, { ...cache, latest, checkedAt: now });
64
+ }
65
+ if (!isNewer(latest, current) || cache.declined === latest) return null;
66
+ return { current, latest };
67
+ } catch { return null; }
68
+ }
69
+
70
+ // Remember that the user said no to this version, so the question is not asked again until a newer one appears.
71
+ export function declineUpdate(latest, file = cachePath()) {
72
+ writeCache(file, { ...readCache(file), declined: latest });
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillgauntlet",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
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": {
@@ -10,6 +10,7 @@
10
10
  "bin",
11
11
  "lib",
12
12
  "contracts",
13
+ "schemas",
13
14
  "README.md",
14
15
  "LICENSE",
15
16
  "LICENSE-DATA"
@@ -20,7 +21,8 @@
20
21
  "scripts": {
21
22
  "start": "node ./bin/skillgauntlet.js",
22
23
  "static": "node ./bin/skillgauntlet.js static",
23
- "test": "node --test tests/*.test.mjs"
24
+ "test": "node --test tests/*.test.mjs",
25
+ "leaderboard": "node scripts/build-leaderboard.mjs"
24
26
  },
25
27
  "keywords": [
26
28
  "agent",
@@ -0,0 +1,210 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/SkillGauntlet/SkillGauntlet/blob/main/schemas/submission-1.json",
4
+ "title": "SkillGauntlet community submission, version 1",
5
+ "description": "One submission directory holds meta.json, raw.jsonl, summary.json and queries.json. Definitions: meta, rawLine, summary, queries.",
6
+ "$defs": {
7
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
8
+ "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
9
+ "isoTime": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$" },
10
+ "shortText": { "type": "string", "maxLength": 300 },
11
+ "rate": { "type": ["number", "null"], "minimum": 0, "maximum": 1 },
12
+ "interval": {
13
+ "type": "array",
14
+ "minItems": 2,
15
+ "maxItems": 2,
16
+ "items": { "type": ["number", "null"], "minimum": 0, "maximum": 1 }
17
+ },
18
+ "meta": {
19
+ "type": "object",
20
+ "required": [
21
+ "schema", "runId", "runnerVersion", "skill", "host", "backend", "model", "runs", "maxExplore",
22
+ "querySets", "platform", "startedAt", "finishedAt", "trials", "interrupted", "submission"
23
+ ],
24
+ "additionalProperties": false,
25
+ "properties": {
26
+ "schema": { "const": "skillgauntlet/run/1" },
27
+ "runId": { "type": "string", "pattern": "^[0-9]{8}-[0-9]{6}(-[0-9]+)?$" },
28
+ "runnerVersion": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+([-+][0-9A-Za-z.-]+)?$" },
29
+ "skill": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
30
+ "host": { "enum": ["claude-code", "codebuddy", "codewhale"] },
31
+ "backend": { "type": "string", "maxLength": 100 },
32
+ "model": { "type": ["string", "null"], "maxLength": 100 },
33
+ "runs": { "type": "integer", "minimum": 1, "maximum": 20 },
34
+ "maxExplore": { "type": "integer", "minimum": 0, "maximum": 20 },
35
+ "depth": { "type": "object", "maxProperties": 8 },
36
+ "querySets": {
37
+ "type": "array",
38
+ "minItems": 1,
39
+ "maxItems": 4,
40
+ "items": {
41
+ "type": "object",
42
+ "required": ["id", "queries", "sha256"],
43
+ "properties": {
44
+ "id": { "type": "string", "maxLength": 40 },
45
+ "source": { "type": "string", "maxLength": 40 },
46
+ "file": { "type": "string", "maxLength": 120 },
47
+ "queries": { "type": "integer", "minimum": 1 },
48
+ "clauses": { "type": "integer", "minimum": 0 },
49
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{16}$" },
50
+ "negativesQuality": { "type": "object", "maxProperties": 4 }
51
+ },
52
+ "additionalProperties": false
53
+ }
54
+ },
55
+ "skillFingerprint": { "type": ["string", "null"], "pattern": "^[0-9a-f]{16}$" },
56
+ "platform": { "$ref": "#/$defs/shortText" },
57
+ "startedAt": { "$ref": "#/$defs/isoTime" },
58
+ "finishedAt": { "$ref": "#/$defs/isoTime" },
59
+ "trials": { "type": "integer", "minimum": 1 },
60
+ "interrupted": { "const": false },
61
+ "resumedAt": { "type": "array", "maxItems": 50, "items": { "$ref": "#/$defs/isoTime" } },
62
+ "modelResolved": { "type": ["string", "null"], "maxLength": 100 },
63
+ "apiKeySource": { "type": ["string", "null"], "maxLength": 40 },
64
+ "claudeCodeVersion": { "type": ["string", "null"], "maxLength": 100 },
65
+ "loadedSkills": { "type": "array", "maxItems": 300, "items": { "type": "string", "maxLength": 100 } },
66
+ "tools": { "type": "array", "maxItems": 300, "items": { "type": "string", "maxLength": 100 } },
67
+ "incompleteTrials": { "type": "integer", "minimum": 0 },
68
+ "submission": { "$ref": "#/$defs/submission" }
69
+ }
70
+ },
71
+ "submission": {
72
+ "type": "object",
73
+ "required": ["schema", "contributor", "skill", "host", "model", "queryPack", "environment", "submittedAt"],
74
+ "additionalProperties": false,
75
+ "properties": {
76
+ "schema": { "const": "skillgauntlet/submission/1" },
77
+ "contributor": { "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$" },
78
+ "skill": {
79
+ "type": "object",
80
+ "required": ["name", "repo", "commit", "subdir", "skillMdSha256"],
81
+ "additionalProperties": false,
82
+ "properties": {
83
+ "name": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
84
+ "repo": { "type": "string", "pattern": "^https://github\\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" },
85
+ "commit": { "$ref": "#/$defs/commit" },
86
+ "subdir": { "type": "string", "maxLength": 200, "pattern": "^[A-Za-z0-9_./-]*$" },
87
+ "skillMdSha256": { "$ref": "#/$defs/sha256" }
88
+ }
89
+ },
90
+ "host": {
91
+ "type": "object",
92
+ "required": ["id", "version"],
93
+ "additionalProperties": false,
94
+ "properties": {
95
+ "id": { "enum": ["claude-code", "codebuddy", "codewhale"] },
96
+ "version": { "type": ["string", "null"], "maxLength": 100 }
97
+ }
98
+ },
99
+ "model": {
100
+ "type": "object",
101
+ "required": ["requested", "resolved"],
102
+ "additionalProperties": false,
103
+ "properties": {
104
+ "requested": { "type": ["string", "null"], "maxLength": 100 },
105
+ "resolved": { "type": ["string", "null"], "maxLength": 100 }
106
+ }
107
+ },
108
+ "queryPack": {
109
+ "type": "object",
110
+ "required": ["id", "sha256", "queries"],
111
+ "additionalProperties": false,
112
+ "properties": {
113
+ "id": { "type": "string", "maxLength": 40 },
114
+ "sha256": { "$ref": "#/$defs/sha256" },
115
+ "queries": { "type": "integer", "minimum": 1 }
116
+ }
117
+ },
118
+ "environment": {
119
+ "type": "object",
120
+ "description": "Used to group and explain results, never to prove anything: every field is self-reported.",
121
+ "required": ["id", "platform", "hostVersion", "backend", "loadedSkills", "toolCount"],
122
+ "additionalProperties": false,
123
+ "properties": {
124
+ "id": { "type": "string", "pattern": "^[0-9a-f]{12}$" },
125
+ "platform": { "$ref": "#/$defs/shortText" },
126
+ "hostVersion": { "type": ["string", "null"], "maxLength": 100 },
127
+ "backend": { "type": "string", "maxLength": 100 },
128
+ "loadedSkills": { "type": "array", "maxItems": 300, "items": { "type": "string", "maxLength": 100 } },
129
+ "toolCount": { "type": "integer", "minimum": 0 }
130
+ }
131
+ },
132
+ "submittedAt": { "$ref": "#/$defs/isoTime" }
133
+ }
134
+ },
135
+ "rawLine": {
136
+ "type": "object",
137
+ "required": ["host", "verdict", "queryId", "run", "query", "shouldTrigger"],
138
+ "properties": {
139
+ "host": { "enum": ["claude-code", "codebuddy", "codewhale"] },
140
+ "verdict": { "enum": ["triggered", "not_triggered", "other_skill", "error", "timeout"] },
141
+ "invokedSkill": { "type": ["string", "null"], "maxLength": 200 },
142
+ "firstTool": { "type": ["string", "null"], "maxLength": 200 },
143
+ "tools": { "type": "array", "maxItems": 100, "items": { "type": "string", "maxLength": 200 } },
144
+ "exploreSteps": { "type": "integer", "minimum": 0 },
145
+ "error": { "type": ["string", "null"], "maxLength": 400 },
146
+ "events": { "type": "object", "maxProperties": 10 },
147
+ "queryId": { "type": "string", "maxLength": 40 },
148
+ "run": { "type": "integer", "minimum": 0, "maximum": 50 },
149
+ "query": { "type": "string", "minLength": 1, "maxLength": 4000 },
150
+ "shouldTrigger": { "type": "boolean" }
151
+ }
152
+ },
153
+ "summary": {
154
+ "type": "object",
155
+ "required": ["trials", "recall", "fpr", "questions", "host", "runs", "recallCi", "fprCi"],
156
+ "properties": {
157
+ "trials": { "type": "integer", "minimum": 1 },
158
+ "recall": { "$ref": "#/$defs/rate" },
159
+ "fpr": { "$ref": "#/$defs/rate" },
160
+ "noToolRate": { "$ref": "#/$defs/rate" },
161
+ "failedTrials": { "type": "integer", "minimum": 0 },
162
+ "incomplete": { "type": "integer", "minimum": 0 },
163
+ "model": { "type": ["string", "null"], "maxLength": 100 },
164
+ "backend": { "type": "string", "maxLength": 100 },
165
+ "host": { "enum": ["claude-code", "codebuddy", "codewhale"] },
166
+ "runs": { "type": "integer", "minimum": 1, "maximum": 20 },
167
+ "recallCi": { "$ref": "#/$defs/interval" },
168
+ "fprCi": { "$ref": "#/$defs/interval" },
169
+ "questions": {
170
+ "type": "array",
171
+ "minItems": 1,
172
+ "maxItems": 2000,
173
+ "items": {
174
+ "type": "object",
175
+ "required": ["query", "shouldTrigger", "hits", "n"],
176
+ "properties": {
177
+ "query": { "type": "string", "maxLength": 4000 },
178
+ "shouldTrigger": { "type": "boolean" },
179
+ "hits": { "type": "integer", "minimum": 0 },
180
+ "n": { "type": "integer", "minimum": 1 }
181
+ }
182
+ }
183
+ }
184
+ }
185
+ },
186
+ "queries": {
187
+ "type": "object",
188
+ "required": ["schema", "queries"],
189
+ "properties": {
190
+ "schema": { "const": "skillgauntlet/queries/1" },
191
+ "queries": {
192
+ "type": "array",
193
+ "minItems": 1,
194
+ "maxItems": 2000,
195
+ "items": {
196
+ "type": "object",
197
+ "required": ["id", "query", "shouldTrigger"],
198
+ "properties": {
199
+ "id": { "type": "string", "maxLength": 40 },
200
+ "query": { "type": "string", "minLength": 1, "maxLength": 4000 },
201
+ "shouldTrigger": { "type": "boolean" },
202
+ "clause": { "type": ["string", "null"], "maxLength": 300 },
203
+ "note": { "type": ["string", "null"], "maxLength": 600 }
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }