@icntswm/skillcheck 1.0.1 → 1.0.3

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/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  # skillcheck
2
2
 
3
+ [![CI](https://github.com/icntswm/skillcheck/actions/workflows/ci.yml/badge.svg)](https://github.com/icntswm/skillcheck/actions/workflows/ci.yml)
3
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
5
  [![npm](https://img.shields.io/npm/v/@icntswm/skillcheck.svg)](https://www.npmjs.com/package/@icntswm/skillcheck)
5
6
  ![Node 20+](https://img.shields.io/badge/node-20%2B-339933.svg)
@@ -12,6 +12,23 @@ export const DEFAULT_DIRECTIVE =
12
12
  function stringList(v) {
13
13
  return Array.isArray(v) ? v.filter((s) => typeof s === "string") : null;
14
14
  }
15
+ function count(v) {
16
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
17
+ }
18
+ /** Sum API `usage` objects; cache writes without a 1h/5m split count as 5m. */
19
+ function sumUsage(model, usages) {
20
+ const total = { model, input: 0, output: 0, cacheRead: 0, cacheWrite5m: 0, cacheWrite1h: 0 };
21
+ for (const u of usages) {
22
+ const split = u.cache_creation;
23
+ const write1h = count(split?.ephemeral_1h_input_tokens);
24
+ total.input += count(u.input_tokens);
25
+ total.output += count(u.output_tokens);
26
+ total.cacheRead += count(u.cache_read_input_tokens);
27
+ total.cacheWrite1h += write1h;
28
+ total.cacheWrite5m += Math.max(0, count(u.cache_creation_input_tokens) - write1h);
29
+ }
30
+ return total;
31
+ }
15
32
  /**
16
33
  * Incremental parser of claude's stream-json stdout. One event per line.
17
34
  * Pushing lines also computes the early-stop signal, so the adapter can
@@ -23,6 +40,10 @@ export class ClaudeStream {
23
40
  resultText = "";
24
41
  sawResult = false;
25
42
  costUsd = null;
43
+ model = null;
44
+ /** latest usage per API message; a message streams as several events */
45
+ messageUsage = new Map();
46
+ resultUsage = null;
26
47
  availableSkills = null;
27
48
  initSkillList = null;
28
49
  initSlashList = null;
@@ -88,12 +109,20 @@ export class ClaudeStream {
88
109
  }
89
110
  if (typeof ev.total_cost_usd === "number")
90
111
  this.costUsd = ev.total_cost_usd;
112
+ if (typeof ev.usage === "object" && ev.usage !== null)
113
+ this.resultUsage = ev.usage;
91
114
  return true;
92
115
  }
93
116
  return false;
94
117
  }
95
118
  handleAssistant(ev) {
96
119
  const message = ev.message;
120
+ if (typeof message?.model === "string")
121
+ this.model = message.model;
122
+ if (typeof message?.usage === "object" && message.usage !== null) {
123
+ const id = typeof message.id === "string" ? message.id : `#${this.messageUsage.size}`;
124
+ this.messageUsage.set(id, message.usage);
125
+ }
97
126
  const blocks = message?.content;
98
127
  if (!Array.isArray(blocks))
99
128
  return false;
@@ -118,6 +147,18 @@ export class ClaudeStream {
118
147
  this.sawSkill = true;
119
148
  return sawSkillHere;
120
149
  }
150
+ /**
151
+ * Billed tokens: the result event's total, or the sum over assistant
152
+ * messages when the run was killed first. Streamed output counts are
153
+ * partial, so the latter runs a little low.
154
+ */
155
+ get usage() {
156
+ if (this.resultUsage)
157
+ return sumUsage(this.model, [this.resultUsage]);
158
+ if (this.messageUsage.size === 0)
159
+ return null;
160
+ return sumUsage(this.model, [...this.messageUsage.values()]);
161
+ }
121
162
  /** Error reported by claude itself in the result event, null if none. */
122
163
  get error() {
123
164
  return this.failure;
@@ -202,22 +243,45 @@ function spawnErrorMessage(e, bin) {
202
243
  }
203
244
  /** Live runs by process group id, with their tmp dirs; see abortActiveRuns. */
204
245
  const active = new Map();
246
+ function groupAlive(pid) {
247
+ try {
248
+ process.kill(-pid, 0);
249
+ return true;
250
+ }
251
+ catch {
252
+ return false;
253
+ }
254
+ }
205
255
  /**
206
256
  * Kill every running claude group and remove its tmp dir. Runs are detached
207
257
  * process groups, so Ctrl+C on skillcheck does not reach them: without this
208
- * they keep running (and billing) after skillcheck exits.
258
+ * they keep running (and billing) after skillcheck exits. Groups still alive
259
+ * after graceMs get SIGKILL, so a process that ignores SIGTERM dies too.
209
260
  */
210
- export function abortActiveRuns() {
211
- for (const [pid, workdir] of active) {
261
+ export async function abortActiveRuns(graceMs = KILL_GRACE_MS) {
262
+ const runs = [...active];
263
+ active.clear();
264
+ for (const [pid] of runs) {
212
265
  try {
213
266
  process.kill(-pid, "SIGTERM");
214
267
  }
215
268
  catch {
216
269
  // already exited
217
270
  }
271
+ }
272
+ const deadline = Date.now() + graceMs;
273
+ while (runs.some(([pid]) => groupAlive(pid)) && Date.now() < deadline) {
274
+ await new Promise((r) => setTimeout(r, 50));
275
+ }
276
+ for (const [pid, workdir] of runs) {
277
+ try {
278
+ process.kill(-pid, "SIGKILL");
279
+ }
280
+ catch {
281
+ // already exited
282
+ }
218
283
  rmSync(workdir, { recursive: true, force: true });
219
284
  }
220
- active.clear();
221
285
  }
222
286
  const LOGIN_HINT = " — with --config-dir, auth comes from ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN (claude setup-token)";
223
287
  /** A fresh config dir has no credentials; point the user at the env vars. */
@@ -242,7 +306,8 @@ export class ClaudeAdapter {
242
306
  });
243
307
  const { loaded, text, costUsd, availableSkills } = out.stream.result;
244
308
  const error = withLoginHint(out.error, opts.configDir);
245
- return { loaded, text, costUsd, availableSkills, error, stoppedEarly: out.stoppedEarly, durationMs: out.durationMs };
309
+ const usage = out.stream.usage;
310
+ return { loaded, text, costUsd, usage, availableSkills, error, stoppedEarly: out.stoppedEarly, durationMs: out.durationMs };
246
311
  }
247
312
  finally {
248
313
  await rm(workdir, { recursive: true, force: true });
package/dist/budget.js CHANGED
@@ -1,27 +1,63 @@
1
+ /**
2
+ * Tokens in input-token equivalents. Anthropic prices every model the same
3
+ * way relative to its input price: output 5x, cache reads 0.1x, 5-minute
4
+ * cache writes 1.25x, 1-hour cache writes 2x.
5
+ */
6
+ export function inputEquivalent(u) {
7
+ return u.input + 5 * u.output + 0.1 * u.cacheRead + 1.25 * u.cacheWrite5m + 2 * u.cacheWrite1h;
8
+ }
9
+ /**
10
+ * USD per input token until a finished run shows the real rate. Rounded up
11
+ * where unsure: the budget is a cap, so overestimating is the safe side.
12
+ */
13
+ function listInputPrice(model) {
14
+ if (model?.includes("haiku"))
15
+ return 1e-6;
16
+ if (model?.includes("sonnet"))
17
+ return 3e-6;
18
+ return 5e-6;
19
+ }
1
20
  /**
2
21
  * Running spend estimate for --budget. Runs killed by early-stop never report
3
- * a cost, so unknown costs are estimated at the average known cost (0 until
4
- * the first known cost arrives); without this the budget could never trigger.
22
+ * a cost, so they are priced from their token usage: at the rate seen in
23
+ * finished runs, or at list price before any run finishes. Runs with neither
24
+ * cost nor usage count at the average known cost.
5
25
  */
6
26
  export class Budget {
7
27
  limitUsd;
8
28
  knownSum = 0;
9
29
  knownCount = 0;
10
30
  unknownCount = 0;
31
+ rateCost = 0;
32
+ rateTokens = 0;
33
+ unpriced = [];
11
34
  constructor(limitUsd) {
12
35
  this.limitUsd = limitUsd;
13
36
  }
14
- add(cost) {
15
- if (cost === null)
16
- this.unknownCount++;
17
- else {
37
+ add(cost, usage = null) {
38
+ const tokens = usage ? inputEquivalent(usage) : 0;
39
+ if (cost !== null) {
18
40
  this.knownSum += cost;
19
41
  this.knownCount++;
42
+ if (tokens > 0) {
43
+ this.rateCost += cost;
44
+ this.rateTokens += tokens;
45
+ }
46
+ }
47
+ else if (usage && tokens > 0) {
48
+ this.unpriced.push(usage);
49
+ }
50
+ else {
51
+ this.unknownCount++;
20
52
  }
21
53
  }
22
54
  get spent() {
23
55
  const avg = this.knownCount > 0 ? this.knownSum / this.knownCount : 0;
24
- return this.knownSum + this.unknownCount * avg;
56
+ const estimated = this.unpriced.reduce((sum, u) => {
57
+ const rate = this.rateTokens > 0 ? this.rateCost / this.rateTokens : listInputPrice(u.model);
58
+ return sum + inputEquivalent(u) * rate;
59
+ }, 0);
60
+ return this.knownSum + estimated + this.unknownCount * avg;
25
61
  }
26
62
  get exceeded() {
27
63
  return this.spent >= this.limitUsd;
package/dist/cli.js CHANGED
@@ -440,7 +440,7 @@ class FatalStop {
440
440
  this.message = error;
441
441
  }
442
442
  canStart(budget) {
443
- return this.message === null && (!budget || !budget.exceeded);
443
+ return this.message === null && !budget.exceeded;
444
444
  }
445
445
  }
446
446
  async function runCommand(file, flags, io, deps) {
@@ -466,7 +466,8 @@ async function runCommand(file, flags, io, deps) {
466
466
  const n = c.repeat ?? repeat;
467
467
  return { c, jobIndex: i, threshold: c.threshold ?? threshold, runs: Array.from({ length: n }), done: 0 };
468
468
  });
469
- const budget = flags.budget !== undefined ? new Budget(flags.budget) : null;
469
+ // without --budget it only estimates the spend for the report
470
+ const budget = new Budget(flags.budget ?? Infinity);
470
471
  // "--json -" owns stdout, so the human report has to go to stderr
471
472
  const reporter = new Reporter(flags.json === "-" ? io.stderr : io.stdout);
472
473
  const startedAtMs = Date.now();
@@ -492,7 +493,8 @@ async function runCommand(file, flags, io, deps) {
492
493
  unavailable,
493
494
  confusion: pairs,
494
495
  skipped: skipped.length,
495
- budget: skipped.length > 0 && budget ? { limitUsd: flags.budget, spent: budget.spent, notStartedRuns } : undefined,
496
+ budget: skipped.length > 0 ? { limitUsd: flags.budget, spent: budget.spent, notStartedRuns } : undefined,
497
+ estimatedUsd: budget.spent,
496
498
  });
497
499
  if (flags.batch) {
498
500
  // cases that only errored have no answer to confirm
@@ -511,6 +513,7 @@ async function runCommand(file, flags, io, deps) {
511
513
  cases: items.map((item) => ({ c: item.c, threshold: item.threshold, result: outcome.results[item.jobIndex] ?? null })),
512
514
  unavailable,
513
515
  confusion: pairs,
516
+ estimatedCostUsd: budget.spent,
514
517
  budgetUsd: flags.budget ?? null,
515
518
  budgetReached: skipped.length > 0,
516
519
  });
@@ -558,7 +561,7 @@ async function runIndividually(adapter, items, flags, model, directive, repeat,
558
561
  available.add(name);
559
562
  }
560
563
  const verdict = judge(job.item.c, r);
561
- budget?.add(verdict.costUsd);
564
+ budget.add(r.costUsd, r.usage);
562
565
  job.item.runs[job.runIndex] = verdict;
563
566
  job.item.done++;
564
567
  if (job.item.done === job.item.runs.length) {
@@ -606,7 +609,7 @@ async function runBatched(runBatch, items, flags, model, repeat, reporter, budge
606
609
  durationMs: br.durationMs,
607
610
  };
608
611
  const verdict = judge(item.c, r);
609
- budget?.add(verdict.costUsd);
612
+ budget.add(verdict.costUsd);
610
613
  item.runs[job.round] = verdict;
611
614
  item.done++;
612
615
  if (item.done === item.runs.length) {
@@ -670,8 +673,9 @@ if (invokedDirectly()) {
670
673
  // agent runs are detached process groups that Ctrl+C does not reach
671
674
  for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143]]) {
672
675
  process.once(signal, () => {
673
- abortActiveRuns();
674
- process.exit(code);
676
+ // a second Ctrl+C during the grace period exits at once
677
+ process.once(signal, () => process.exit(code));
678
+ void abortActiveRuns().finally(() => process.exit(code));
675
679
  });
676
680
  }
677
681
  main(process.argv.slice(2)).then((code) => {
package/dist/report.js CHANGED
@@ -68,7 +68,7 @@ export class Reporter {
68
68
  const failed = results.filter((r) => !r.ok).length;
69
69
  const skipped = extra?.skipped ?? 0;
70
70
  const skippedPart = skipped > 0 ? `, ${skipped} skipped` : "";
71
- this.write(`${failed} failed${skippedPart} of ${results.length + skipped} · runs ${verdicts.length} · ${costLine(verdicts.map((v) => v.costUsd))}\n`);
71
+ this.write(`${failed} failed${skippedPart} of ${results.length + skipped} · runs ${verdicts.length} · ${costLine(verdicts.map((v) => v.costUsd), extra?.estimatedUsd)}\n`);
72
72
  }
73
73
  paint(code, text) {
74
74
  return this.useColor ? `\u001b[${code}m${text}${RESET}` : text;
@@ -77,12 +77,15 @@ export class Reporter {
77
77
  this.out.write(text);
78
78
  }
79
79
  }
80
- function costLine(costs) {
80
+ function costLine(costs, estimated) {
81
81
  const known = costs.filter((c) => c !== null);
82
- if (known.length === 0)
83
- return "cost ?";
84
82
  const sum = known.reduce((a, b) => a + b, 0);
85
83
  const unknown = costs.length - known.length;
84
+ if (unknown > 0 && estimated !== undefined && estimated > sum) {
85
+ return `cost ~$${estimated.toFixed(2)} (${unknown} runs estimated from tokens)`;
86
+ }
87
+ if (known.length === 0)
88
+ return "cost ?";
86
89
  const tail = unknown > 0 ? ` (+? for ${unknown} runs)` : "";
87
90
  return `cost $${sum.toFixed(2)}${tail}`;
88
91
  }
package/dist/results.js CHANGED
@@ -19,6 +19,7 @@ export function buildReport(input) {
19
19
  runs: verdicts.length,
20
20
  costUsd: known.reduce((a, b) => a + b, 0),
21
21
  unknownCostRuns: costs.length - known.length,
22
+ estimatedCostUsd: input.estimatedCostUsd,
22
23
  diagnoses: verdicts.filter((v) => v.diagnosis).length,
23
24
  budgetUsd: input.budgetUsd,
24
25
  budgetReached: input.budgetReached,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icntswm/skillcheck",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Regression tests for Claude Code skills: check that every request still loads the right skill",
5
5
  "type": "module",
6
6
  "bin": {