@uinaf/skillcheck 1.0.1 → 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) && !fs.existsSync(attemptPath(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,16 +262,26 @@ 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) {
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}`);
259
276
  const decode = (part) => {
260
277
  if (!part.startsWith("~v2~")) return part;
261
278
  try {
262
- return decodeURIComponent(part.slice(4));
279
+ const decoded = decodeURIComponent(part.slice(4));
280
+ return encodeRunNamePart(decoded) === part ? decoded : part;
263
281
  } catch {
264
282
  return part;
265
283
  }
266
284
  };
267
- const base = file.replace(/\.json$/, "");
268
285
  const suffix = base.match(/--(codex|grok|cursor)$/);
269
286
  const harness = suffix === null ? "claude" : suffix[1];
270
287
  const [skill, ...rest] = base.replace(/--(codex|grok|cursor)$/, "").split("--");
@@ -277,6 +294,7 @@ function resultIdentity(file) {
277
294
  function reduceResults(dir, allowMixed) {
278
295
  const entries = [];
279
296
  const skipped = [];
297
+ const gradedAt = /* @__PURE__ */ new Map();
280
298
  const shas = /* @__PURE__ */ new Set();
281
299
  const files = fs.readdirSync(dir);
282
300
  const incomplete = new Set(files.filter((f) => f.endsWith(".json.attempt")).map((f) => f.replace(/\.attempt$/, "")));
@@ -308,7 +326,13 @@ function reduceResults(dir, allowMixed) {
308
326
  const provider = raw.config?.providers?.[0];
309
327
  const judge = raw.config?.defaultTest?.options?.provider;
310
328
  const base = f.replace(/\.json$/, "");
311
- 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));
312
336
  let sha = "unattested";
313
337
  try {
314
338
  sha = JSON.parse(fs.readFileSync(path.join(dir, `${base}.meta.json`), "utf8")).skills_tree_sha ?? "unattested";
@@ -331,7 +355,8 @@ function reduceResults(dir, allowMixed) {
331
355
  return {
332
356
  treeSha: shas.size === 1 ? [...shas][0] : shas.size === 0 ? "none" : "mixed",
333
357
  entries,
334
- skipped
358
+ skipped,
359
+ gradedAt
335
360
  };
336
361
  }
337
362
  function entryKey(e) {
@@ -375,11 +400,14 @@ function cmdSummarize(argv) {
375
400
  if (positional.length > 0) fail("usage: skillcheck summarize [--root DIR] [--allow-mixed]");
376
401
  const dirs = stateDirs(resolveRoot(flags));
377
402
  if (!fs.existsSync(dirs.results)) fail(`no results directory at ${dirs.results}; run some evals first`);
378
- const { entries, skipped } = reduceResults(dirs.results, flags.get("--allow-mixed") === true);
403
+ const { entries, skipped, gradedAt } = reduceResults(dirs.results, flags.get("--allow-mixed") === true);
379
404
  fs.mkdirSync(dirs.scorecards, { recursive: true });
380
405
  const out = path.join(dirs.scorecards, `${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json`);
381
406
  const existing = readExistingScorecard(out);
382
- 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));
383
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");
384
412
  const merged = mergeScorecard(existing, entries);
385
413
  const treeSha = treeShaOf(merged.entries);
package/dist/scenario.js CHANGED
@@ -40,7 +40,13 @@ function runNameFor(scenarioDir, harness) {
40
40
  const m = path.resolve(scenarioDir).match(/skills\/([^/]+)\/evals\/([^/]+)$/);
41
41
  if (!m) throw new Error(`not a scenario dir (want .../skills/<skill>/evals/<scenario>): ${scenarioDir}`);
42
42
  const name = `${encodeRunNamePart(m[1])}--${encodeRunNamePart(m[2])}`;
43
- return harness === "claude" ? name : `${name}--${harness}`;
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")}`;
44
50
  }
45
51
  function frontmatterRange(text) {
46
52
  const lines = text.split("\n");
@@ -259,7 +265,9 @@ function generateRun(scenarioDir, opts, paths) {
259
265
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
260
266
  return {
261
267
  name,
262
- configPath
268
+ configPath,
269
+ skill: s.skill,
270
+ scenario: s.scenario
263
271
  };
264
272
  }
265
273
  //#endregion
package/docs/usage.md CHANGED
@@ -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.1",
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": {