@skill-harness/cli 0.3.1 → 0.4.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.
@@ -127,3 +127,24 @@ export function liftSummary(col) {
127
127
  out.delta = out.greenPassed - out.redPassed;
128
128
  return out;
129
129
  }
130
+
131
+ /**
132
+ * The lift badge for a column with nothing to show in the number: `{text, title}`,
133
+ * or null when the column should carry no badge at all.
134
+ *
135
+ * A *missing* baseline and an *unusable* one are different facts. Nothing is
136
+ * comparable when every shared scenario ran identically in both modes, or when the
137
+ * two sides aggregated differently (red at 1 rep vs green at 3) — a red baseline
138
+ * exists, and "no red baseline" would send the author off to re-run the one thing
139
+ * they already have. The server's headline already states which it is and what to
140
+ * re-run, so it becomes the tooltip verbatim.
141
+ */
142
+ export function liftNoneBadge(col) {
143
+ if (col.lift) {
144
+ return { text: "lift not comparable", title: col.liftHeadline || "nothing in the red baseline could be compared" };
145
+ }
146
+ if (col.mode === "green") {
147
+ return { text: "no red baseline", title: "run the same scenarios with --mode red to get a baseline" };
148
+ }
149
+ return null;
150
+ }
@@ -182,9 +182,11 @@ function render() {
182
182
  `<div class='lift ${cls}' title='vs red baseline ${escapeHtml(col.lift.redTimestamp)} — ${ls.kept} passed without the skill too'>${body}${inc}${part}</div></th>`;
183
183
  return;
184
184
  }
185
- if (col.mode === "green") {
186
- // "not measured" is a different claim from "measured no effect" — say which.
187
- liftHtml = `<div class='lift none' title='run the same scenarios with --mode red to get a baseline'>no red baseline</div>`;
185
+ // "not measured" is a different claim from "measured no effect" — and an
186
+ // unusable baseline is a third (see liftNoneBadge).
187
+ const none = liftNoneBadge(col);
188
+ if (none) {
189
+ liftHtml = `<div class='lift none' title='${escapeHtml(none.title)}'>${escapeHtml(none.text)}</div>`;
188
190
  }
189
191
  html += `<th><div>${escapeHtml(col.label)}</div><div class='grade'>${gradeHtml}</div>${liftHtml}</th>`;
190
192
  });
package/dist/cli.d.ts CHANGED
@@ -6,14 +6,32 @@ export interface Args {
6
6
  multi: Record<string, string[]>;
7
7
  }
8
8
  export declare function flagStr(args: Args, key: string, fallback?: string): string | undefined;
9
+ /** A boolean flag: bare `--flag`, or an explicit `--flag=true` / `--flag=1`. */
10
+ export declare function flagBool(args: Args, key: string): boolean;
9
11
  /** Parse the run's reps + pass-threshold flags. Throws on an invalid provided value. */
10
12
  export declare function parseRunTuning(args: Args): {
11
13
  reps: number;
12
14
  passThreshold: number;
13
15
  };
16
+ export declare function cmdRun(args: Args): Promise<void>;
14
17
  export declare function cmdGrade(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
18
+ /**
19
+ * Re-evaluate needle gates against the saved staged diffs — free, except for the reps
20
+ * whose gate verdict flips from fail to pass, which the judge never saw and must now
21
+ * be shown. Prints the cost before making those calls.
22
+ */
23
+ export declare function cmdRegate(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
15
24
  export declare function cmdInit(args: Args): Promise<void>;
16
25
  export declare function cmdSuggest(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
17
26
  /** Exit-code contract: 0 = clean (no findings), 1 = >=1 finding, or a resolution error (unknown skill/root, no skills with a spec). */
18
27
  export declare function cmdLint(args: Args): Promise<void>;
28
+ /**
29
+ * The help text, rendered per call rather than frozen at module load.
30
+ *
31
+ * The `defaults:` line reports the judge that the *next* command will actually
32
+ * use, which `SKILL_HARNESS_JUDGE` can change after this module was imported. A
33
+ * help screen that prints a default the tool won't use is worse than one that
34
+ * prints none.
35
+ */
36
+ export declare function help(): string;
19
37
  export declare function main(argv: string[]): Promise<void>;
package/dist/cli.js CHANGED
@@ -3,11 +3,13 @@ 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, specPathForRunDir, collectLift, } from "@skill-harness/core";
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";
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";
10
- const DEFAULT_JUDGE = "anthropic:claude-opus-4-8";
10
+ // The judge default lives in core (`defaultJudge()`), which resolves
11
+ // SKILL_HARNESS_JUDGE over a baked value — it was duplicated in three places
12
+ // before, and the pi extension's copy could disagree with this one.
11
13
  const DEFAULT_SUGGEST_MODEL = "claude-code:claude-opus-4-8";
12
14
  const REPEATABLE = new Set(["model", "turn", "check"]);
13
15
  function parseArgs(argv) {
@@ -48,6 +50,11 @@ export function flagStr(args, key, fallback) {
48
50
  return "";
49
51
  return fallback;
50
52
  }
53
+ /** A boolean flag: bare `--flag`, or an explicit `--flag=true` / `--flag=1`. */
54
+ export function flagBool(args, key) {
55
+ const v = args.flags[key];
56
+ return v === true || v === "true" || v === "1";
57
+ }
51
58
  function resolveModels(args) {
52
59
  const models = [...(args.multi.model ?? [])];
53
60
  const file = flagStr(args, "models");
@@ -108,17 +115,25 @@ async function cmdList(args) {
108
115
  }
109
116
  console.log(`\n● = testable · ○ = no spec yet · ✗ = spec present but invalid`);
110
117
  }
111
- async function cmdRun(args) {
118
+ export async function cmdRun(args) {
112
119
  const root = flagStr(args, "skills", process.cwd());
113
120
  const target = args._[0];
114
121
  if (!target)
115
122
  throw new Error("usage: skill-harness run <skill|all> --skills <root>");
123
+ // Judge policy is checked first, ahead of the harness/PATH check and long before
124
+ // any subject tokens are spent: a refusal that arrives after the model has been
125
+ // paid for is a worse version of the problem it exists to prevent.
126
+ const judgeFlagRun = flagStr(args, "judge");
127
+ const judge = parseModelRef(judgeFlagRun ?? defaultJudge());
128
+ assertJudgeAllowed(judge, {
129
+ source: judgeFlagRun ? "--judge" : "the default judge (SKILL_HARNESS_JUDGE or the baked value)",
130
+ allowMetered: flagBool(args, "allow-metered-judge"),
131
+ });
116
132
  const harnessName = flagStr(args, "harness", "pi");
117
133
  const adapter = getAdapter(harnessName);
118
134
  if (!(await adapter.available()))
119
135
  throw new Error(`harness \`${harnessName}\` is not on PATH`);
120
136
  const mode = flagStr(args, "mode", "green") || "green";
121
- const judge = parseModelRef(flagStr(args, "judge", DEFAULT_JUDGE));
122
137
  const label = flagStr(args, "label") || null;
123
138
  const parallel = Math.max(1, Number(flagStr(args, "parallel", "1")) || 1);
124
139
  const { reps, passThreshold } = parseRunTuning(args);
@@ -134,10 +149,16 @@ async function cmdRun(args) {
134
149
  console.log(`skip ${skill.name}: no spec`);
135
150
  continue;
136
151
  }
152
+ // A run from an older tool than the records already here would produce numbers
153
+ // that look comparable and are not. Checked per skill, before its first token.
154
+ assertNotDowngraded(skill.dir, "run");
137
155
  const spec = loadSpec(skill.specPath);
138
156
  for (const token of modelTokens) {
139
157
  const model = parseModelRef(token);
140
- console.log(`\n▶ ${spec.skill} · ${harnessName}:${token} · mode=${mode} · judge=${judge.provider}:${judge.model}`);
158
+ // The version is on the banner because a stale global install is otherwise
159
+ // invisible: a 0.1.0 binary grades a 0.3.x corpus, produces plausible
160
+ // numbers, and nothing on screen says which tool made them.
161
+ console.log(`\n▶ ${spec.skill} · ${harnessName}:${token} · mode=${mode} · judge=${judge.provider}:${judge.model} · skill-harness ${HARNESS_VERSION}`);
141
162
  const summary = await runSkillModel({
142
163
  spec,
143
164
  skillDir: skill.dir,
@@ -181,11 +202,25 @@ export async function cmdGrade(args, adapterOverride) {
181
202
  // an explicit --judge flag still wins; with no prior results, fall back to
182
203
  // the CLI default.
183
204
  const judgeFlag = flagStr(args, "judge");
184
- const judge = judgeFlag ? parseModelRef(judgeFlag) : (prev?.judge ?? parseModelRef(DEFAULT_JUDGE));
205
+ const judge = judgeFlag ? parseModelRef(judgeFlag) : (prev?.judge ?? parseModelRef(defaultJudge()));
206
+ // A regrade reuses the judge the run RECORDED, so a run that names a metered judge
207
+ // bills on every later regrade with no flag typed anywhere. Latent rather than live
208
+ // in the reference corpus (all ~140 committed runs there record `claude-code`), but
209
+ // it is the one path where the cost decision was made by a file, not a person.
210
+ assertJudgeAllowed(judge, {
211
+ source: judgeFlag ? "--judge" : prev?.judge ? "the run's recorded judge" : "the default judge",
212
+ allowMetered: flagBool(args, "allow-metered-judge"),
213
+ });
185
214
  const adapter = adapterOverride ?? getAdapter(prev?.harness ?? "pi");
215
+ // Warn rather than refuse: re-grading is cheap, it writes no new measurement of the
216
+ // model, and it is one of the ways someone diagnoses a stale install in the first
217
+ // place. Blocking the diagnosis would be the wrong trade.
218
+ const stale = downgradeWarning(dirname(testsDir));
219
+ if (stale)
220
+ console.error(stale);
186
221
  const results = await regradeRun({
187
222
  runDir, spec, adapter, judge, specDir: testsDir, now: nowIso,
188
- onlySuspect: args.flags["suspect-only"] === true || args.flags["suspect-only"] === "true",
223
+ onlySuspect: flagBool(args, "suspect-only"),
189
224
  });
190
225
  for (const s of results.scenarios) {
191
226
  console.log(` ${s.id} → ${s.judge_verdict}: ${s.judge_reason}`);
@@ -221,6 +256,50 @@ async function cmdRescore(args) {
221
256
  }
222
257
  console.log(`\n${runDirs.length} run(s) re-scored, ${moved} verdict(s) moved.`);
223
258
  }
259
+ /**
260
+ * Re-evaluate needle gates against the saved staged diffs — free, except for the reps
261
+ * whose gate verdict flips from fail to pass, which the judge never saw and must now
262
+ * be shown. Prints the cost before making those calls.
263
+ */
264
+ export async function cmdRegate(args, adapterOverride) {
265
+ const runDirs = args._;
266
+ if (runDirs.length === 0)
267
+ throw new Error("usage: skill-harness regate <run-dir> [<run-dir> ...] [--judge prov:model]");
268
+ const judgeFlag = flagStr(args, "judge");
269
+ let moved = 0;
270
+ let calls = 0;
271
+ for (const raw of runDirs) {
272
+ const runDir = resolve(raw);
273
+ if (!existsSync(runDir))
274
+ throw new Error(`run dir not found: ${runDir} (relative paths resolve against the cwd)`);
275
+ const specPath = specPathForRunDir(runDir);
276
+ const spec = loadSpec(specPath);
277
+ const prev = readResults(runDir);
278
+ const judge = judgeFlag ? parseModelRef(judgeFlag) : (prev.judge ?? parseModelRef(defaultJudge()));
279
+ assertJudgeAllowed(judge, {
280
+ source: judgeFlag ? "--judge" : "the run's recorded judge",
281
+ allowMetered: flagBool(args, "allow-metered-judge"),
282
+ });
283
+ const { results, changes, judgeCalls } = await regateRun({
284
+ runDir, spec, specDir: dirname(specPath),
285
+ adapter: adapterOverride ?? getAdapter(prev.harness ?? "pi"),
286
+ judge, now: nowIso,
287
+ });
288
+ const g = results.effective_grade;
289
+ console.log(`\n${results.skill} · ${results.model}`);
290
+ for (const c of changes) {
291
+ console.log(` ${c.id}: ${c.from} → ${c.to} (gate ${c.gate}${c.judged ? ", re-judged from the saved transcript" : ", no judge call"})`);
292
+ }
293
+ if (changes.length === 0)
294
+ console.log(" (no verdict changed)");
295
+ console.log(` → ${g.letter} (${g.pct}%) ${g.passed}/${g.total} ${g.ship ? "SHIP" : "NOT READY"}`);
296
+ moved += changes.length;
297
+ calls += judgeCalls;
298
+ }
299
+ // The cost line matters: regate is advertised as free, and it is — except for the
300
+ // flipped reps, which it must not spend silently.
301
+ console.log(`\n${runDirs.length} run(s) re-gated, ${moved} verdict(s) moved, ${calls} judge call(s) (no model re-runs).`);
302
+ }
224
303
  async function cmdReview(args) {
225
304
  const root = flagStr(args, "skills", process.cwd());
226
305
  const target = args._[0];
@@ -399,12 +478,22 @@ export async function cmdLint(args) {
399
478
  process.exitCode = findings.length > 0 ? 1 : 0;
400
479
  }
401
480
  // ---------------------------------------------------------------- dispatch
402
- const HELP = `skill-harness — test/optimize loop for agent skills (pi harness)
481
+ /**
482
+ * The help text, rendered per call rather than frozen at module load.
483
+ *
484
+ * The `defaults:` line reports the judge that the *next* command will actually
485
+ * use, which `SKILL_HARNESS_JUDGE` can change after this module was imported. A
486
+ * help screen that prints a default the tool won't use is worse than one that
487
+ * prints none.
488
+ */
489
+ export function help() {
490
+ return `skill-harness ${HARNESS_VERSION} — test/optimize loop for agent skills (pi harness)
403
491
 
404
492
  run <skill|all> --skills <root> [--model prov:model ...] [--models file] [--only A1,D2]
405
493
  [--mode red|green|force] [--judge prov:model] [--harness pi] [--label name] [--parallel N] [--reps N] [--pass-threshold T]
406
494
  grade <run-dir> [--judge prov:model] [--suspect-only] re-grade saved transcripts (neutral judge)
407
495
  rescore <run-dir>... re-score saved reps vs current spec thresholds (free)
496
+ regate <run-dir>... [--judge prov:model] re-evaluate diff needles against the saved diffs (free; judges only reps whose gate flipped)
408
497
  review <skill> --skills <root> [--port N] serve the interactive review UI
409
498
  add-test <skill> --skills <root> --id ID --title T --turn ... --check ... [--critical] [--mode seeded --fixture path]
410
499
  init <skill> --skills <root> [--force] scaffold a commented template spec (free, offline)
@@ -412,7 +501,12 @@ const HELP = `skill-harness — test/optimize loop for agent skills (pi harness)
412
501
  list --skills <root> discovered skills + spec status
413
502
  lint <skill|all> --skills <root> validate specs/fixtures + results-consistency (CI gate; exits non-zero on findings)
414
503
 
415
- defaults: model=${DEFAULT_MODEL} judge=${DEFAULT_JUDGE} mode=green harness=pi`;
504
+ version print ${HARNESS_VERSION} and exit (also --version / -v)
505
+
506
+ defaults: model=${DEFAULT_MODEL} judge=${defaultJudge()} mode=green harness=pi
507
+ the judge default is Opus on your Claude subscription (\`claude-code\` → \`claude -p\`), not a
508
+ metered API key. Set SKILL_HARNESS_JUDGE to change it for a repo or a shell; --judge wins over both.`;
509
+ }
416
510
  export async function main(argv) {
417
511
  const cmd = argv[0];
418
512
  const args = parseArgs(argv.slice(1));
@@ -420,21 +514,29 @@ export async function main(argv) {
420
514
  case "run": return cmdRun(args);
421
515
  case "grade": return cmdGrade(args);
422
516
  case "rescore": return cmdRescore(args);
517
+ case "regate": return cmdRegate(args);
423
518
  case "review": return cmdReview(args);
424
519
  case "add-test": return cmdAddTest(args);
425
520
  case "init": return cmdInit(args);
426
521
  case "suggest": return cmdSuggest(args);
427
522
  case "list": return cmdList(args);
428
523
  case "lint": return cmdLint(args);
524
+ case "version":
525
+ case "--version":
526
+ case "-v":
527
+ // Bare version, one line, nothing else: this is what a script or a confused
528
+ // user greps to find out whether the binary on PATH is the one they think.
529
+ console.log(HARNESS_VERSION);
530
+ return;
429
531
  case undefined:
430
532
  case "help":
431
533
  case "--help":
432
534
  case "-h":
433
- console.log(HELP);
535
+ console.log(help());
434
536
  return;
435
537
  default:
436
538
  console.error(`unknown command: ${cmd}\n`);
437
- console.log(HELP);
539
+ console.log(help());
438
540
  process.exitCode = 1;
439
541
  }
440
542
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.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.3.1",
48
- "@skill-harness/adapters": "0.3.1"
47
+ "@skill-harness/core": "0.4.0",
48
+ "@skill-harness/adapters": "0.4.0"
49
49
  }
50
50
  }