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.
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/static.js CHANGED
@@ -158,9 +158,9 @@ export function analyze(skillDir) {
158
158
  // E. description quality -- the only layer that is also the trigger layer's input
159
159
  const E = [];
160
160
  E.push(check('E1', desc ? 'pass' : 'fail', desc ? 'check.E1.detail' : 'check.E1.missing', { chars: desc.length }));
161
- const tw = hits(desc, RE.triggerWords, 1);
161
+ const tw = hits(desc, RE.triggerWords, 1, 'SKILL.md (description)');
162
162
  E.push(check('E2', tw.length ? 'pass' : 'warn', tw.length ? 'check.E2.hit' : 'check.E2.miss'));
163
- const wide = hits(desc, RE.wide, 1);
163
+ const wide = hits(desc, RE.wide, 1, 'SKILL.md (description)');
164
164
  E.push(check('E3', wide.length ? 'warn' : 'pass', wide.length ? 'check.E3.hit' : 'check.E3.none', {}, wide));
165
165
  const clauses = (desc.match(/\bor\b|、|,|,|\//g) || []).length + 1;
166
166
  E.push(check('E4', 'info', 'check.E4.detail', { n: clauses }));
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.
@@ -111,18 +118,35 @@ export function requireHost(host, probe = binInstalled) {
111
118
 
112
119
  // One tiny call before the real work: a host that is not signed in retries silently for minutes, so the user
113
120
  // would stare at a frozen screen. A short timeout turns that into an immediate, actionable message.
114
- export function pingHost(host, { bin, model = null, timeout = 90, run = execFileSync } = {}) {
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 } = {}) {
115
142
  const spec = HOSTS[host];
116
143
  if (!spec || host === 'codewhale') return;
117
144
  const args = ['-p', 'Reply with the single word OK.', '--output-format', 'json', '--tools', '', '--no-session-persistence'];
118
- if (model && !isDeepseekModel(model)) args.push('--model', model);
145
+ if (model && !usesOwnDeepseek(host, model)) args.push('--model', model); // the package's own DeepSeek route has no host-side name
119
146
  const launch = launchSpec(bin, args, spec.bin);
120
- try {
121
- run(launch.cmd, launch.args, { encoding: 'utf8', timeout: timeout * 1000, shell: launch.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
122
- } catch (err) {
123
- const why = err.code === 'ETIMEDOUT' || err.killed
124
- ? `no answer within ${timeout}s`
125
- : String(err.stderr || err.message).split(LINE_BREAK).map((l) => l.trim()).find(Boolean) || `exit status ${err.status}`;
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) {
126
150
  const e = new Error(`host ${host} did not answer: ${why}`);
127
151
  e.messageKey = 'hostNotReady';
128
152
  e.vars = { host: HOST_NAME[host] || host, why: why.slice(0, 200) };
@@ -130,6 +154,21 @@ export function pingHost(host, { bin, model = null, timeout = 90, run = execFile
130
154
  }
131
155
  }
132
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
+
133
172
  // Variables a host (the Claude desktop app, the Agent SDK, an outer Claude Code session) sets for its own
134
173
  // child processes. A trial inherits them otherwise, and they change what the model under test sees: under
135
174
  // the desktop app the CLI adds Artifact, ArtifactComments and ArtifactData to the tool list, and the model
@@ -216,7 +255,7 @@ export function runTrial({ bin, skillDir, skillName, query, model, timeout = 180
216
255
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-'));
217
256
  const spec = HOSTS[host];
218
257
  copyDir(skillDir, path.join(tmp, ...spec.skillsDir, skillName));
219
- if (host === 'codebuddy' && isDeepseekModel(model)) writeModelsJson(tmp, model);
258
+ if (host === 'codebuddy' && usesOwnDeepseek('codebuddy', model)) writeModelsJson(tmp, model);
220
259
  const args = host === 'codebuddy'
221
260
  ? ['-p', query, '--output-format', 'stream-json', '--verbose', '--setting-sources', 'project', '--strict-mcp-config',
222
261
  '--no-session-persistence', '--tools', spec.tools]
@@ -275,6 +314,7 @@ export function runTrial({ bin, skillDir, skillName, query, model, timeout = 180
275
314
  rec.init = { model: ev.model, apiKeySource: ev.apiKeySource, claudeCodeVersion: ev.claude_code_version,
276
315
  skills: ev.skills || [], tools: ev.tools || [] };
277
316
  } else if (type === 'stream_event') {
317
+ if (ev.event?.delta?.type === 'text_delta') addSaid(rec, ev.event.delta.text);
278
318
  const block = ev.event?.content_block;
279
319
  if (block?.type === 'tool_use') {
280
320
  rec.events.toolUse += 1;
@@ -287,9 +327,11 @@ export function runTrial({ bin, skillDir, skillName, query, model, timeout = 180
287
327
  }
288
328
  } else if (type === 'assistant') {
289
329
  for (const c of ev.message?.content || []) {
330
+ if (c.type === 'text') addSaid(rec, c.text);
290
331
  if (c.type !== 'tool_use') continue;
291
332
  rec.tools.push(c.name);
292
333
  rec.firstTool = rec.firstTool || c.name;
334
+ rec.firstInput = rec.firstInput || inputSummary(c.name, c.input);
293
335
  if (c.name === 'Skill') {
294
336
  const inv = String(c.input?.skill || '');
295
337
  if (inv === skillName || inv.endsWith(`:${skillName}`)) finish('triggered', { invokedSkill: inv });
@@ -389,23 +431,24 @@ ${text}`;
389
431
  const launch = launchSpec(bin, args, HOSTS[host].bin);
390
432
  // CodeBuddy reads a custom model from the working directory's .codebuddy/models.json, so a DeepSeek draft
391
433
  // runs in a scratch directory that has one.
392
- const scratch = host === 'codebuddy' && isDeepseekModel(model) ? fs.mkdtempSync(path.join(os.tmpdir(), 'sg-draft-')) : null;
434
+ const scratch = host === 'codebuddy' && usesOwnDeepseek('codebuddy', model) ? fs.mkdtempSync(path.join(os.tmpdir(), 'sg-draft-')) : null;
393
435
  if (scratch) writeModelsJson(scratch, model);
394
- let out;
436
+ let r;
395
437
  try {
396
- out = execFileSync(launch.cmd, launch.args, {
438
+ r = spawnSync(launch.cmd, launch.args, {
397
439
  encoding: 'utf8', timeout: timeout * 1000, maxBuffer: 32 * 1024 * 1024,
398
- shell: launch.shell, windowsHide: true, ...(scratch ? { cwd: scratch } : {}),
440
+ shell: launch.shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], ...(scratch ? { cwd: scratch } : {}),
399
441
  });
400
- } catch (err) {
401
- // The default message of a failed execFileSync is the whole command line, which here is the drafting prompt.
402
- const why = err.code === 'ETIMEDOUT' || err.killed
403
- ? `no answer within ${timeout}s (a rejected key or an unreachable endpoint retries silently until then)`
404
- : String(err.stderr || err.message).split(/\r?\n/).map((l) => l.trim()).find(Boolean) || 'exit status ' + err.status;
405
- throw new Error(`the drafting call failed: ${why}`);
406
442
  } finally {
407
443
  if (scratch) { try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ } }
408
444
  }
445
+ // The CLI's own words, not "Unexpected end of JSON input": an empty answer with a complaint on stderr is common.
446
+ const problem = hostAnswerProblem(r, timeout);
447
+ if (problem) {
448
+ const hint = r.error?.code === 'ETIMEDOUT' || r.signal ? ' (a rejected key or an unreachable endpoint retries silently until then)' : '';
449
+ throw new Error(`the drafting call failed: ${problem}${hint}`);
450
+ }
451
+ const out = r.stdout;
409
452
  const parsed = JSON.parse(out);
410
453
  // Claude Code answers with one result object; CodeBuddy Code with the whole message list, whose last
411
454
  // element is the result.
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.2.0",
3
+ "version": "1.3.1",
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": {