@uinaf/skillcheck 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { lintSkills } from "./lint.js";
3
- import { generateRun, requiredEvalPackages, resolvePackageDir, runNameFor } from "./scenario.js";
3
+ import { encodeRunNamePart, generateRun, requiredEvalPackages, resolvePackageDir, runNameFor } from "./scenario.js";
4
4
  import { execFileSync, spawnSync } from "node:child_process";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
@@ -134,14 +134,19 @@ function attemptPath(resultPath) {
134
134
  }
135
135
  function runScenario(scenarioDir, opts, root) {
136
136
  const dirs = stateDirs(root);
137
- const { name, configPath } = generateRun(path.resolve(scenarioDir), opts, {
137
+ const { name, configPath, skill, scenario } = generateRun(path.resolve(scenarioDir), opts, {
138
138
  scratchDir: dirs.scratch,
139
139
  transformPath: path.join(here, `transform${selfExt}`),
140
140
  grokProviderPath: path.join(here, `grok-provider${selfExt}`)
141
141
  });
142
142
  fs.mkdirSync(dirs.results, { recursive: true });
143
143
  const resultPath = path.join(dirs.results, `${name}.json`);
144
- fs.writeFileSync(attemptPath(resultPath), "{}\n");
144
+ const identity = {
145
+ skill,
146
+ scenario,
147
+ harness: opts.harness
148
+ };
149
+ fs.writeFileSync(attemptPath(resultPath), JSON.stringify(identity) + "\n");
145
150
  fs.rmSync(resultPath, { force: true });
146
151
  fs.rmSync(metaPath(resultPath), { force: true });
147
152
  const sha = gitHead(root);
@@ -182,7 +187,7 @@ function runScenario(scenarioDir, opts, root) {
182
187
  if (verdict.score !== void 0) {
183
188
  fs.writeFileSync(metaPath(resultPath), JSON.stringify({
184
189
  skills_tree_sha: sha,
185
- harness: opts.harness,
190
+ ...identity,
186
191
  ran_at: (/* @__PURE__ */ new Date()).toISOString(),
187
192
  tool_version: toolVersion()
188
193
  }, null, 2) + "\n");
@@ -235,11 +240,13 @@ function cmdSweep(argv) {
235
240
  for (const dir of discoverScenarios(root)) {
236
241
  const name = runNameFor(dir, opts.harness);
237
242
  const resultPath = path.join(resultsDir, `${name}.json`);
238
- if (!all && fs.existsSync(resultPath)) {
239
- skipped++;
240
- console.log(`SKIP ${name} (results exist; use --all to rerun)`);
241
- continue;
242
- }
243
+ if (!all && fs.existsSync(resultPath) && !fs.existsSync(attemptPath(resultPath))) try {
244
+ if (classifyResult(JSON.parse(fs.readFileSync(resultPath, "utf8"))).score !== void 0) {
245
+ skipped++;
246
+ console.log(`SKIP ${name} (results exist; use --all to rerun)`);
247
+ continue;
248
+ }
249
+ } catch {}
243
250
  const o = runScenario(dir, opts, root);
244
251
  if (o.score === void 0) {
245
252
  errored++;
@@ -255,20 +262,39 @@ function cmdSweep(argv) {
255
262
  console.log(`\nsweep: ${passed} passed, ${failed} failed, ${errored} errored, ${skipped} skipped`);
256
263
  process.exit(errored > 0 ? 2 : failed > 0 ? 1 : 0);
257
264
  }
258
- function resultIdentity(file) {
265
+ function resultIdentity(file, dir) {
259
266
  const base = file.replace(/\.json$/, "");
267
+ for (const sidecar of [`${base}.meta.json`, `${file}.attempt`]) try {
268
+ const identity = JSON.parse(fs.readFileSync(path.join(dir, sidecar), "utf8"));
269
+ if (identity !== null && typeof identity === "object" && "skill" in identity && typeof identity.skill === "string" && "scenario" in identity && typeof identity.scenario === "string" && "harness" in identity && (identity.harness === "claude" || identity.harness === "codex" || identity.harness === "grok")) return {
270
+ skill: identity.skill,
271
+ scenario: identity.scenario,
272
+ harness: identity.harness
273
+ };
274
+ } catch {}
275
+ if (/^~v3~[0-9a-f]{64}$/.test(base)) throw new Error(`missing identity metadata for ${file}`);
276
+ const decode = (part) => {
277
+ if (!part.startsWith("~v2~")) return part;
278
+ try {
279
+ const decoded = decodeURIComponent(part.slice(4));
280
+ return encodeRunNamePart(decoded) === part ? decoded : part;
281
+ } catch {
282
+ return part;
283
+ }
284
+ };
260
285
  const suffix = base.match(/--(codex|grok|cursor)$/);
261
286
  const harness = suffix === null ? "claude" : suffix[1];
262
287
  const [skill, ...rest] = base.replace(/--(codex|grok|cursor)$/, "").split("--");
263
288
  return {
264
- skill,
265
- scenario: rest.join("--"),
289
+ skill: decode(skill),
290
+ scenario: decode(rest.join("--")),
266
291
  harness
267
292
  };
268
293
  }
269
294
  function reduceResults(dir, allowMixed) {
270
295
  const entries = [];
271
296
  const skipped = [];
297
+ const gradedAt = /* @__PURE__ */ new Map();
272
298
  const shas = /* @__PURE__ */ new Set();
273
299
  const files = fs.readdirSync(dir);
274
300
  const incomplete = new Set(files.filter((f) => f.endsWith(".json.attempt")).map((f) => f.replace(/\.attempt$/, "")));
@@ -300,7 +326,13 @@ function reduceResults(dir, allowMixed) {
300
326
  const provider = raw.config?.providers?.[0];
301
327
  const judge = raw.config?.defaultTest?.options?.provider;
302
328
  const base = f.replace(/\.json$/, "");
303
- const { skill, scenario, harness } = resultIdentity(f);
329
+ const { skill, scenario, harness } = resultIdentity(f, dir);
330
+ const key = entryKey({
331
+ skill,
332
+ scenario,
333
+ harness
334
+ });
335
+ gradedAt.set(key, Math.max(gradedAt.get(key) ?? 0, fs.statSync(path.join(dir, f)).mtimeMs));
304
336
  let sha = "unattested";
305
337
  try {
306
338
  sha = JSON.parse(fs.readFileSync(path.join(dir, `${base}.meta.json`), "utf8")).skills_tree_sha ?? "unattested";
@@ -323,7 +355,8 @@ function reduceResults(dir, allowMixed) {
323
355
  return {
324
356
  treeSha: shas.size === 1 ? [...shas][0] : shas.size === 0 ? "none" : "mixed",
325
357
  entries,
326
- skipped
358
+ skipped,
359
+ gradedAt
327
360
  };
328
361
  }
329
362
  function entryKey(e) {
@@ -367,11 +400,14 @@ function cmdSummarize(argv) {
367
400
  if (positional.length > 0) fail("usage: skillcheck summarize [--root DIR] [--allow-mixed]");
368
401
  const dirs = stateDirs(resolveRoot(flags));
369
402
  if (!fs.existsSync(dirs.results)) fail(`no results directory at ${dirs.results}; run some evals first`);
370
- const { entries, skipped } = reduceResults(dirs.results, flags.get("--allow-mixed") === true);
403
+ const { entries, skipped, gradedAt } = reduceResults(dirs.results, flags.get("--allow-mixed") === true);
371
404
  fs.mkdirSync(dirs.scorecards, { recursive: true });
372
405
  const out = path.join(dirs.scorecards, `${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json`);
373
406
  const existing = readExistingScorecard(out);
374
- const skippedKeys = new Set(skipped.map((file) => entryKey(resultIdentity(file))));
407
+ const skippedKeys = new Set(skipped.map((file) => ({
408
+ key: entryKey(resultIdentity(file, dirs.results)),
409
+ modifiedAt: fs.statSync(fs.existsSync(path.join(dirs.results, `${file}.attempt`)) ? path.join(dirs.results, `${file}.attempt`) : path.join(dirs.results, file)).mtimeMs
410
+ })).filter(({ key, modifiedAt }) => (gradedAt.get(key) ?? 0) <= modifiedAt).map(({ key }) => key));
375
411
  if (existing.some((entry) => skippedKeys.has(entryKey(entry)))) throw new Error("skipped rerun matches an existing score; refusing to carry it or overwrite the scorecard");
376
412
  const merged = mergeScorecard(existing, entries);
377
413
  const treeSha = treeShaOf(merged.entries);
@@ -113,6 +113,11 @@ var GrokProvider = class {
113
113
  const abort = () => stop("grok run aborted");
114
114
  callOptions?.abortSignal?.addEventListener("abort", abort, { once: true });
115
115
  if (callOptions?.abortSignal?.aborted) abort();
116
+ child.on("exit", () => {
117
+ if (process.platform !== "win32" && child.pid !== void 0) try {
118
+ process.kill(-child.pid, "SIGKILL");
119
+ } catch {}
120
+ });
116
121
  child.on("close", (code, signal) => {
117
122
  clearTimeout(timer);
118
123
  callOptions?.abortSignal?.removeEventListener("abort", abort);
package/dist/scenario.js CHANGED
@@ -32,10 +32,21 @@ function loadScenario(scenarioDir) {
32
32
  criteria
33
33
  };
34
34
  }
35
+ function encodeRunNamePart(part) {
36
+ if (!part.includes("--") && !part.startsWith("-") && !part.endsWith("-") && !part.startsWith("~v2~")) return part;
37
+ return `~v2~${encodeURIComponent(part).replaceAll("-", "%2D").replaceAll("~", "%7E")}`;
38
+ }
35
39
  function runNameFor(scenarioDir, harness) {
36
40
  const m = path.resolve(scenarioDir).match(/skills\/([^/]+)\/evals\/([^/]+)$/);
37
41
  if (!m) throw new Error(`not a scenario dir (want .../skills/<skill>/evals/<scenario>): ${scenarioDir}`);
38
- return harness === "claude" ? `${m[1]}--${m[2]}` : `${m[1]}--${m[2]}--${harness}`;
42
+ const name = `${encodeRunNamePart(m[1])}--${encodeRunNamePart(m[2])}`;
43
+ const full = harness === "claude" ? name : `${name}--${harness}`;
44
+ if (Buffer.byteLength(`${full}.json.attempt`) <= 255) return full;
45
+ return `~v3~${createHash("sha256").update(JSON.stringify([
46
+ m[1],
47
+ m[2],
48
+ harness
49
+ ])).digest("hex")}`;
39
50
  }
40
51
  function frontmatterRange(text) {
41
52
  const lines = text.split("\n");
@@ -254,8 +265,10 @@ function generateRun(scenarioDir, opts, paths) {
254
265
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
255
266
  return {
256
267
  name,
257
- configPath
268
+ configPath,
269
+ skill: s.skill,
270
+ scenario: s.scenario
258
271
  };
259
272
  }
260
273
  //#endregion
261
- export { buildConfig, generateRun, isHiddenSkill, loadScenario, materialize, requiredEvalPackages, resolvePackageDir, runNameFor, sdkNodeModulesDir, stripHiddenFlag };
274
+ export { buildConfig, encodeRunNamePart, generateRun, isHiddenSkill, loadScenario, materialize, requiredEvalPackages, resolvePackageDir, runNameFor, sdkNodeModulesDir, stripHiddenFlag };
package/docs/scenarios.md CHANGED
@@ -9,7 +9,9 @@ A scenario is two files in a frozen location:
9
9
 
10
10
  The path is the identity: `<skill>--<scenario>` names the run, the result file,
11
11
  and the scorecard entry. Codex and Grok results gain `--codex` and `--grok`
12
- suffixes, so harnesses can hold results side by side.
12
+ suffixes, so harnesses can hold results side by side. Double hyphens and
13
+ leading or trailing hyphens in a skill or scenario name are escaped in the run
14
+ and result filename; the scorecard keeps the original name.
13
15
  A directory missing either file is not discovered.
14
16
 
15
17
  ## task.md
package/docs/usage.md CHANGED
@@ -77,7 +77,7 @@ provider-qualified judge; the Anthropic judge does not take one.
77
77
  ## Sweep
78
78
 
79
79
  ```sh
80
- skillcheck sweep # only scenarios without results
80
+ skillcheck sweep # scenarios without completed results
81
81
  skillcheck sweep --all # rerun everything
82
82
  ```
83
83
 
@@ -91,8 +91,8 @@ within one scenario, not across them.
91
91
  One known failure mode: judge calls through a gateway can drop at the transport
92
92
  layer ([uinaf/zebroid-infra#44](https://github.com/uinaf/zebroid-infra/issues/44)).
93
93
  That surfaces as an ERROR with no usable result, not as a graded FAIL, and the
94
- mitigation is a rerun. `sweep` without `--all` resumes, so a rerun only picks up
95
- what is missing.
94
+ mitigation is a rerun. `sweep` without `--all` resumes, so a rerun picks up
95
+ missing or ungraded results.
96
96
 
97
97
  ## Summarize
98
98
 
@@ -115,14 +115,19 @@ Files that are not promptfoo results and ungraded transport errors are skipped
115
115
  with a warning rather than failing the reduction. Graded assertion failures
116
116
  remain scored results. If a skipped file matches an existing scorecard row,
117
117
  summary generation fails and leaves the scorecard unchanged, so an errored rerun
118
- cannot carry forward its old score. This also applies with `--allow-mixed`.
118
+ cannot carry forward its old score. A graded result for the same identity
119
+ supersedes a skipped attempt only when the result file is newer. This also
120
+ applies with `--allow-mixed`.
119
121
  Results from the retired Cursor harness are skipped with their original identity,
120
122
  so they cannot become Claude scores or silently carry an old Cursor row.
121
123
  Runs keep a `<name>.json.attempt` marker until a graded result and its provenance
122
- are written. An outstanding marker makes `summarize` skip that identity even
124
+ are written. The marker records the original skill, scenario, and harness. An outstanding marker makes `summarize` skip that identity even
123
125
  when the child produced no result file or left partial output. The marker does
124
126
  not count as a result for the sweep's existence check, so no-output failures
125
127
  remain eligible for retry.
128
+ Long escaped names use a short hashed filename; the original identity is kept
129
+ in the attempt marker and result sidecar. `sweep` retries existing results that
130
+ contain no grade, including results written by older versions without a marker.
126
131
 
127
132
  ## Provenance
128
133
 
@@ -131,6 +136,8 @@ Each successful run writes a `<name>.meta.json` sidecar next to its result:
131
136
  ```json
132
137
  {
133
138
  "skills_tree_sha": "<root repo HEAD at run time>",
139
+ "skill": "<skill directory name>",
140
+ "scenario": "<scenario directory name>",
134
141
  "harness": "claude",
135
142
  "ran_at": "<ISO timestamp>",
136
143
  "tool_version": "<skillcheck version>"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uinaf/skillcheck",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Lint and eval harness for agent skills",
5
5
  "homepage": "https://github.com/uinaf/skillcheck#readme",
6
6
  "bugs": {