@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.
- package/assets/report.grade.js +21 -0
- package/assets/report.template.html +5 -3
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +113 -11
- package/package.json +3 -3
package/assets/report.grade.js
CHANGED
|
@@ -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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
535
|
+
console.log(help());
|
|
434
536
|
return;
|
|
435
537
|
default:
|
|
436
538
|
console.error(`unknown command: ${cmd}\n`);
|
|
437
|
-
console.log(
|
|
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
|
+
"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.
|
|
48
|
-
"@skill-harness/adapters": "0.
|
|
47
|
+
"@skill-harness/core": "0.4.0",
|
|
48
|
+
"@skill-harness/adapters": "0.4.0"
|
|
49
49
|
}
|
|
50
50
|
}
|