@skill-harness/cli 0.5.0 → 0.6.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.
@@ -43,6 +43,8 @@
43
43
  .cell .lm { display: block; font-size: 10px; font-weight: 700; }
44
44
  .cell .lm.up { color: var(--pass); }
45
45
  .cell .lm.down { color: var(--fail); }
46
+ /* Amber, like the suspect badge: both mean "this verdict is worth less than it looks". */
47
+ .cell .lm.flip { color: #b45309; }
46
48
  aside { width: 0; transition: width .15s ease; overflow: hidden; border-left: 1px solid var(--line); background: var(--panel); }
47
49
  aside.open { width: 460px; }
48
50
  .panel { width: 460px; padding: 16px 18px; }
@@ -220,7 +222,15 @@ function render() {
220
222
  : lc === "regressed"
221
223
  ? `<span class='lm down' title='red baseline PASS → ${effective(cell)} with the skill'>↓ skill</span>`
222
224
  : "";
223
- html += `<td class='cell ${v}${sel}' data-col='${col.index}' data-id='${scn.id}'>${v}${ov}${suspectBadge}${liftMark}${reps}</td>`;
225
+ // Run-over-run marker. Only on cells that actually flipped, and deliberately
226
+ // NOT folded into the flakiness number beside it: `flaky 0.00` is a within-run
227
+ // measure, and the case this exists for is a cell that was unanimous in every
228
+ // run and still landed on a different side each time.
229
+ const st = cell.stability;
230
+ const stabMark = st
231
+ ? `<span class='lm flip' title='${escapeHtml(st.note)}'>⇄ ${st.flips}/${st.compared}</span>`
232
+ : "";
233
+ html += `<td class='cell ${v}${sel}' data-col='${col.index}' data-id='${scn.id}'>${v}${ov}${suspectBadge}${liftMark}${stabMark}${reps}</td>`;
224
234
  });
225
235
  html += "</tr>";
226
236
  }
@@ -247,6 +257,7 @@ async function openPanel(colIndex, scenarioId) {
247
257
  <div class="meta">${escapeHtml(col.label)} · mode ${escapeHtml(col.mode || "green")} · judge ${escapeHtml(col.judge.provider + ":" + col.judge.model)}</div>
248
258
  <div class="reason"><b>judge:</b> ${cell.judge_verdict} — ${escapeHtml(cell.judge_reason || "(no reason)")}</div>
249
259
  ${cell.suspect ? `<div class="reason" style="color:#b45309"><b>⚠ suspect:</b> judge listed no failed item — re-judge before trusting this FAIL</div>` : ""}
260
+ ${cell.stability ? `<div class="reason" style="color:#b45309"><b>⇄ boundary cell:</b> ${escapeHtml(cell.stability.note)}</div>` : ""}
250
261
  <div class="toggle">
251
262
  <button data-v="PASS" class="PASS ${cell.override === 'PASS' ? 'active PASS' : ''}">PASS</button>
252
263
  <button data-v="FAIL" class="FAIL ${cell.override === 'FAIL' ? 'active FAIL' : ''}">FAIL</button>
package/dist/cli.d.ts CHANGED
@@ -23,7 +23,15 @@ export declare function cmdGrade(args: Args, adapterOverride?: HarnessAdapter):
23
23
  export declare function cmdRegate(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
24
24
  export declare function cmdInit(args: Args): Promise<void>;
25
25
  export declare function cmdSuggest(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
26
- /** Exit-code contract: 0 = clean (no findings), 1 = >=1 finding, or a resolution error (unknown skill/root, no skills with a spec). */
26
+ /**
27
+ * Exit-code contract: 0 = no gate-failing findings, 1 = >=1 of them, or a resolution
28
+ * error (unknown skill/root, no skills with a spec).
29
+ *
30
+ * `info` findings (run-over-run stability notes) print and annotate but never fail the
31
+ * gate: a boundary cell says how much one run of a scenario is worth, which is not a
32
+ * defect in the spec, the fixtures or the results. A linter that reddens CI for it would
33
+ * teach everyone to stop reading it.
34
+ */
27
35
  export declare function cmdLint(args: Args): Promise<void>;
28
36
  /**
29
37
  * The help text, rendered per call rather than frozen at module load.
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { readFileSync, existsSync, appendFileSync, mkdirSync, writeFileSync, mkd
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import yaml from "js-yaml";
6
- import { discover, resolveSkill, loadSpec, parseSpec, parseModelRef, runSkillModel, formatScorecard, readResults, regradeRun, lintSkill, renderTemplateSpec, isTemplateSpec, renderDraftSpec, buildSuggestPrompt, parseSuggestDraft, rescoreRun, regateRun, specPathForRunDir, collectLift, HARNESS_VERSION, defaultJudge, assertJudgeAllowed, assertNotDowngraded, downgradeWarning, } from "@skill-harness/core";
6
+ import { discover, resolveSkill, loadSpec, parseSpec, parseModelRef, runSkillModel, formatScorecard, readResults, regradeRun, lintSkill, failsGate, renderTemplateSpec, isTemplateSpec, renderDraftSpec, buildSuggestPrompt, parseSuggestDraft, rescoreRun, regateRun, specPathForRunDir, collectLift, collectStability, boundaryCells, stabilityNote, PATH_LEGEND, HARNESS_VERSION, defaultJudge, assertJudgeAllowed, assertNotDowngraded, downgradeWarning, } from "@skill-harness/core";
7
7
  import { getAdapter } from "@skill-harness/adapters";
8
8
  import { serveReview } from "./serve.js";
9
9
  const DEFAULT_MODEL = "fireworks:accounts/fireworks/models/deepseek-v4-pro";
@@ -183,7 +183,11 @@ export async function cmdRun(args) {
183
183
  // any earlier run — the tag dir (<harness>-<modelslug>) is the join key.
184
184
  const tag = basename(dirname(summary.runDir));
185
185
  const lift = collectLift(skill.dir).find((l) => l.tag === tag);
186
- console.log("\n" + formatScorecard(summary, lift) + "\n");
186
+ // Stability is derived from history INCLUDING the run just written, and scoped to
187
+ // this tag + mode: another model's flips under this model's scorecard would be a
188
+ // worse error than not reporting them at all.
189
+ const stability = collectStability(skill.dir).filter((c) => c.tag === tag && c.mode === summary.results.mode);
190
+ console.log("\n" + formatScorecard(summary, lift, stability) + "\n");
187
191
  }
188
192
  }
189
193
  console.log(`\nReview interactively: skill-harness review ${skills[0]?.name ?? "<skill>"} --skills ${root}`);
@@ -302,6 +306,68 @@ export async function cmdRegate(args, adapterOverride) {
302
306
  // flipped reps, which it must not spend silently.
303
307
  console.log(`\n${runDirs.length} run(s) re-gated, ${moved} verdict(s) moved, ${calls} judge call(s) (no model re-runs).`);
304
308
  }
309
+ /**
310
+ * Run-over-run verdict stability, derived from committed results. Free and offline: it
311
+ * reads results.yaml files and computes — no model, no judge, no harness.
312
+ *
313
+ * Exits 0 whatever it finds. A boundary cell is not a defect in the skill or in the
314
+ * spec; it is a statement about how much one run of that cell is worth. Making it a
315
+ * gate would turn "this needs more reps" into "your build is broken".
316
+ */
317
+ async function cmdStability(args) {
318
+ const root = flagStr(args, "skills", process.cwd());
319
+ const target = args._[0] ?? "all";
320
+ const windowRaw = flagStr(args, "window");
321
+ const window = windowRaw ? Number(windowRaw) : undefined;
322
+ if (windowRaw !== undefined && (!Number.isInteger(window) || window < 2)) {
323
+ throw new Error(`--window must be an integer >= 2 (got \`${windowRaw}\`) — one run has no run-over-run step`);
324
+ }
325
+ const showAll = flagBool(args, "all");
326
+ const skills = target === "all" ? discover(root).filter((s) => s.hasSpec) : [resolveSkill(root, target)];
327
+ if (skills.length === 0)
328
+ throw new Error(`no skills with a spec under ${root}`);
329
+ let boundaries = 0;
330
+ for (const skill of skills) {
331
+ const all = collectStability(skill.dir, { window });
332
+ if (all.length === 0) {
333
+ console.log(`\n${skill.name}: no scored runs yet — stability needs at least two runs of the same skill × model × mode`);
334
+ continue;
335
+ }
336
+ // One block per model tag × delivery mode: green and force are different
337
+ // deliveries of the same text, so their histories are never one series.
338
+ const groups = new Map();
339
+ for (const s of all) {
340
+ const key = `${s.tag} · mode=${s.mode}`;
341
+ (groups.get(key) ?? groups.set(key, []).get(key)).push(s);
342
+ }
343
+ console.log(`\n── ${skill.name} ──`);
344
+ for (const [key, cells] of groups) {
345
+ const runs = Math.max(...cells.map((c) => c.points.length));
346
+ console.log(` ${key} (${runs} run(s) in the window)`);
347
+ const boundary = boundaryCells(cells);
348
+ boundaries += boundary.length;
349
+ for (const s of boundary) {
350
+ console.log(` ⇄ ${s.critical ? "CRITICAL " : ""}${stabilityNote(s)}`);
351
+ }
352
+ if (showAll) {
353
+ for (const s of cells) {
354
+ if (s.state !== "boundary")
355
+ console.log(` ${s.state === "stable" ? "=" : "?"} ${stabilityNote(s)}`);
356
+ }
357
+ }
358
+ else {
359
+ const stable = cells.filter((c) => c.state === "stable").length;
360
+ const unmeasured = cells.filter((c) => c.state === "unmeasured").length;
361
+ console.log(` ${stable} held their verdict · ${unmeasured} with no comparable step (--all to list them)`);
362
+ }
363
+ }
364
+ }
365
+ console.log(`\n${boundaries} boundary cell(s). ${PATH_LEGEND}`);
366
+ if (boundaries > 0) {
367
+ console.log(`A boundary cell is worth re-running with more reps (--reps) before you trust one run of it;`);
368
+ console.log(`within-run flakiness cannot see this, because it only ever looks at one run.`);
369
+ }
370
+ }
305
371
  async function cmdReview(args) {
306
372
  const root = flagStr(args, "skills", process.cwd());
307
373
  const target = args._[0];
@@ -435,7 +501,15 @@ export async function cmdSuggest(args, adapterOverride) {
435
501
  rmSync(cwd, { recursive: true, force: true });
436
502
  }
437
503
  }
438
- /** Exit-code contract: 0 = clean (no findings), 1 = >=1 finding, or a resolution error (unknown skill/root, no skills with a spec). */
504
+ /**
505
+ * Exit-code contract: 0 = no gate-failing findings, 1 = >=1 of them, or a resolution
506
+ * error (unknown skill/root, no skills with a spec).
507
+ *
508
+ * `info` findings (run-over-run stability notes) print and annotate but never fail the
509
+ * gate: a boundary cell says how much one run of a scenario is worth, which is not a
510
+ * defect in the spec, the fixtures or the results. A linter that reddens CI for it would
511
+ * teach everyone to stop reading it.
512
+ */
439
513
  export async function cmdLint(args) {
440
514
  const root = flagStr(args, "skills", process.cwd());
441
515
  const target = args._[0] ?? "all";
@@ -466,18 +540,20 @@ export async function cmdLint(args) {
466
540
  f = [{ skill: dir, code: "lint-error", message: e instanceof Error ? e.message : String(e) }];
467
541
  }
468
542
  findings.push(...f);
469
- if (f.length === 0)
543
+ if (f.filter(failsGate).length === 0)
470
544
  console.log(`✓ ${dir}`);
471
- else
472
- for (const x of f) {
473
- const where = x.scenario ? `${dir}/${x.scenario}` : dir; // dir-based label, consistent with the ✓ line
474
- console.log(`✗ ${where}: ${x.code} — ${x.message}`);
475
- if (gha)
476
- console.log(`::error title=skill-harness::${where}: ${x.code} — ${x.message}`);
477
- }
545
+ for (const x of f) {
546
+ const where = x.scenario ? `${dir}/${x.scenario}` : dir; // dir-based label, consistent with the ✓ line
547
+ const fails = failsGate(x);
548
+ console.log(`${fails ? "✗" : "ℹ"} ${where}: ${x.code} — ${x.message}`);
549
+ if (gha)
550
+ console.log(`::${fails ? "error" : "notice"} title=skill-harness::${where}: ${x.code} — ${x.message}`);
551
+ }
478
552
  }
479
- console.log(`\n${skillDirs.length} skill(s), ${findings.length} finding(s)`);
480
- process.exitCode = findings.length > 0 ? 1 : 0;
553
+ const gating = findings.filter(failsGate).length;
554
+ const notes = findings.length - gating;
555
+ console.log(`\n${skillDirs.length} skill(s), ${gating} finding(s)${notes > 0 ? `, ${notes} note(s) (do not fail the gate)` : ""}`);
556
+ process.exitCode = gating > 0 ? 1 : 0;
481
557
  }
482
558
  // ---------------------------------------------------------------- dispatch
483
559
  /**
@@ -497,6 +573,7 @@ export function help() {
497
573
  grade <run-dir> [--judge prov:model] [--suspect-only] re-grade saved transcripts (neutral judge)
498
574
  rescore <run-dir>... re-score saved reps vs current spec thresholds (free)
499
575
  regate <run-dir>... [--judge prov:model] re-evaluate diff needles against the saved diffs (free; judges only reps whose gate flipped)
576
+ stability <skill|all> --skills <root> [--window N] [--all] run-over-run verdict flips per scenario (free, offline)
500
577
  review <skill> --skills <root> [--port N] serve the interactive review UI
501
578
  add-test <skill> --skills <root> --id ID --title T --turn ... --check ... [--critical] [--mode seeded --fixture path]
502
579
  init <skill> --skills <root> [--force] scaffold a commented template spec (free, offline)
@@ -521,6 +598,7 @@ export async function main(argv) {
521
598
  case "grade": return cmdGrade(args);
522
599
  case "rescore": return cmdRescore(args);
523
600
  case "regate": return cmdRegate(args);
601
+ case "stability": return cmdStability(args);
524
602
  case "review": return cmdReview(args);
525
603
  case "add-test": return cmdAddTest(args);
526
604
  case "init": return cmdInit(args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "skill-harness CLI — run, grade, review, and lint agent-skill scenarios on the pi harness",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,7 +44,7 @@
44
44
  "prepack": "rm -rf ./assets && mkdir -p ./assets && cp ../../assets/report.* ./assets/ && cp ../../LICENSE ./LICENSE"
45
45
  },
46
46
  "dependencies": {
47
- "@skill-harness/core": "0.5.0",
48
- "@skill-harness/adapters": "0.5.0"
47
+ "@skill-harness/core": "0.6.0",
48
+ "@skill-harness/adapters": "0.6.0"
49
49
  }
50
50
  }