@skill-harness/cli 0.8.0 → 0.9.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 +22 -4
- package/assets/report.template.html +12 -3
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +116 -1
- package/dist/compare.d.ts +26 -0
- package/dist/compare.js +232 -0
- package/dist/serve.js +3 -2
- package/package.json +3 -3
package/assets/report.grade.js
CHANGED
|
@@ -24,7 +24,12 @@
|
|
|
24
24
|
// a plain <script> in the browser (for the review UI).
|
|
25
25
|
|
|
26
26
|
export function effective(cell) {
|
|
27
|
-
|
|
27
|
+
if (cell.override) return cell.override;
|
|
28
|
+
// Mechanical evidence outranks a prose judge. Objective PASS deliberately
|
|
29
|
+
// forces nothing — the checklist judge still decides the behavioral rubric.
|
|
30
|
+
if (cell.objective && cell.objective.status === "ERROR") return "ERROR";
|
|
31
|
+
if (cell.objective && cell.objective.status === "FAIL") return "FAIL";
|
|
32
|
+
return cell.judge_verdict;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
function letterFor(pct) {
|
|
@@ -47,6 +52,7 @@ export function gradeColumn(col, shipBar, critical) {
|
|
|
47
52
|
let criticalFails = 0;
|
|
48
53
|
let bFails = 0;
|
|
49
54
|
let suspect = 0;
|
|
55
|
+
let errors = 0;
|
|
50
56
|
|
|
51
57
|
for (const id of Object.keys(col.cells)) {
|
|
52
58
|
const cell = col.cells[id];
|
|
@@ -55,8 +61,13 @@ export function gradeColumn(col, shipBar, critical) {
|
|
|
55
61
|
suspect++;
|
|
56
62
|
continue; // excluded, blocks ship
|
|
57
63
|
}
|
|
64
|
+
const verdict = effective(cell);
|
|
65
|
+
if (verdict === "ERROR" || verdict === "JUDGE-AMBIGUOUS") {
|
|
66
|
+
errors++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
58
69
|
total++;
|
|
59
|
-
if (
|
|
70
|
+
if (verdict === "PASS") {
|
|
60
71
|
passed++;
|
|
61
72
|
continue;
|
|
62
73
|
}
|
|
@@ -64,16 +75,23 @@ export function gradeColumn(col, shipBar, critical) {
|
|
|
64
75
|
if (/^B/i.test(id)) bFails++;
|
|
65
76
|
}
|
|
66
77
|
|
|
78
|
+
if (col.partial === true) {
|
|
79
|
+
return { passed: 0, total: 0, pct: 0, letter: "-", ship: false, criticalFails, bFails, suspect, errors };
|
|
80
|
+
}
|
|
81
|
+
|
|
67
82
|
const pct = total > 0 ? Math.round((passed * 100) / total) : 0;
|
|
68
83
|
const letter = letterFor(pct);
|
|
84
|
+
const validBar = Number.isInteger(shipBar.total) && shipBar.total >= 1 && Number.isInteger(shipBar.min_pass) && shipBar.min_pass >= 1 && shipBar.min_pass <= shipBar.total;
|
|
69
85
|
const ship =
|
|
86
|
+
validBar &&
|
|
70
87
|
total >= shipBar.total &&
|
|
71
88
|
passed >= shipBar.min_pass &&
|
|
72
89
|
(!shipBar.no_critical_fail || criticalFails === 0) &&
|
|
73
90
|
bFails === 0 &&
|
|
74
|
-
suspect === 0
|
|
91
|
+
suspect === 0 &&
|
|
92
|
+
errors === 0;
|
|
75
93
|
|
|
76
|
-
return { passed, total, pct, letter, ship, criticalFails, bFails, suspect };
|
|
94
|
+
return { passed, total, pct, letter, ship, criticalFails, bFails, suspect, errors };
|
|
77
95
|
}
|
|
78
96
|
|
|
79
97
|
/**
|
|
@@ -165,13 +165,21 @@ function render() {
|
|
|
165
165
|
// Green and force are scored; a red baseline column has no ship grade.
|
|
166
166
|
gradeHtml = `<span class='badge no'>not scored (${escapeHtml(col.mode)})</span>`;
|
|
167
167
|
if (g.suspect > 0) gradeHtml += ` — ${g.suspect} suspect`;
|
|
168
|
+
if (g.errors > 0) gradeHtml += ` — ${g.errors} infrastructure error`;
|
|
168
169
|
} else {
|
|
169
170
|
// g.ship is already false whenever g.suspect > 0 (gradeColumn's suspect gate), so
|
|
170
171
|
// the NOT READY badge is forced automatically; we just surface the count here.
|
|
171
172
|
const badge = g.ship ? "<span class='badge ship'>SHIP</span>" : "<span class='badge no'>NOT READY</span>";
|
|
172
173
|
const suspectNote = g.suspect > 0 ? ` — ${g.suspect} suspect` : "";
|
|
173
|
-
|
|
174
|
+
const errorNote = g.errors > 0 ? ` — ${g.errors} infrastructure error${g.errors === 1 ? "" : "s"}` : "";
|
|
175
|
+
gradeHtml = `${g.letter} (${g.pct}%) · ${g.passed}/${g.total}${suspectNote}${errorNote} ${badge}`;
|
|
174
176
|
}
|
|
177
|
+
const m = col.metrics;
|
|
178
|
+
const metricHtml = m && m.total_reps > 0
|
|
179
|
+
? `<div class='grade' title='subject metrics reported by ${m.subject_metrics_reps}/${m.total_reps} reps'>` +
|
|
180
|
+
`${m.input_tokens == null ? "tokens n/a" : `${m.input_tokens}in/${m.output_tokens || 0}out`} · ` +
|
|
181
|
+
`${m.judge_calls} judge · ${m.wall_time_ms}ms · ${m.tool_calls == null ? "tools n/a" : `${m.tool_calls} tools`}</div>`
|
|
182
|
+
: "";
|
|
175
183
|
// Lift answers "does this skill do anything?", which the grade alone cannot:
|
|
176
184
|
// a column can score A because the model never needed the skill. Recomputed
|
|
177
185
|
// from the live cells (see liftSummary) so overrides move it immediately.
|
|
@@ -189,7 +197,7 @@ function render() {
|
|
|
189
197
|
: `lift 0 · no effect`;
|
|
190
198
|
const inc = ls.inconclusive > 0 ? ` · ${ls.inconclusive} inconclusive` : "";
|
|
191
199
|
const part = col.lift.partial ? " · partial" : "";
|
|
192
|
-
html += `<th><div>${escapeHtml(col.label)}</div><div class='grade'>${gradeHtml}</div
|
|
200
|
+
html += `<th><div>${escapeHtml(col.label)}</div><div class='grade'>${gradeHtml}</div>${metricHtml}` +
|
|
193
201
|
`<div class='lift ${cls}' title='vs red baseline ${escapeHtml(col.lift.redTimestamp)} — ${ls.kept} passed without the skill too'>${body}${inc}${part}</div></th>`;
|
|
194
202
|
return;
|
|
195
203
|
}
|
|
@@ -199,7 +207,7 @@ function render() {
|
|
|
199
207
|
if (none) {
|
|
200
208
|
liftHtml = `<div class='lift none' title='${escapeHtml(none.title)}'>${escapeHtml(none.text)}</div>`;
|
|
201
209
|
}
|
|
202
|
-
html += `<th><div>${escapeHtml(col.label)}</div><div class='grade'>${gradeHtml}</div>${liftHtml}</th>`;
|
|
210
|
+
html += `<th><div>${escapeHtml(col.label)}</div><div class='grade'>${gradeHtml}</div>${metricHtml}${liftHtml}</th>`;
|
|
203
211
|
});
|
|
204
212
|
html += "</tr></thead><tbody>";
|
|
205
213
|
for (const scn of DATA.scenarios) {
|
|
@@ -281,6 +289,7 @@ async function openPanel(colIndex, scenarioId) {
|
|
|
281
289
|
${cell.stability ? `<div class="reason" style="color:#b45309"><b>⇄ boundary cell:</b> ${escapeHtml(cell.stability.note)}</div>` : ""}
|
|
282
290
|
${cell.objective ? `<div class="reason"><b>◉ objective ${escapeHtml(cell.objective.status)}:</b> ${escapeHtml(cell.objective.detail)}</div>` : ""}
|
|
283
291
|
${cell.adjudication ? `<div class="reason"${cell.adjudication.state === "unresolved" ? ' style="color:#b45309"' : ""}><b>⚖ adjudication ${escapeHtml(cell.adjudication.state)}</b> (${escapeHtml(cell.adjudication.trigger)}): ${escapeHtml(cell.adjudication.detail)}</div>` : ""}
|
|
292
|
+
${cell.metrics ? `<div class="reason"><b>cost/latency:</b> ${cell.metrics.input_tokens == null ? "subject tokens unavailable" : `${cell.metrics.input_tokens} input / ${cell.metrics.output_tokens || 0} output / ${cell.metrics.cache_read_tokens || 0} cache-read`} (${cell.metrics.subject_metrics_reps}/${cell.metrics.total_reps} reps) · ${cell.metrics.judge_calls} judge + ${cell.metrics.judge_rejudge_calls} re-judge · ${cell.metrics.wall_time_ms}ms · ${cell.metrics.tool_calls == null ? "tool calls unavailable" : `${cell.metrics.tool_calls} tools, ${cell.metrics.delegated_children || 0} delegated, max concurrency ${cell.metrics.max_concurrency || 0}`}</div>` : ""}
|
|
284
293
|
<div class="toggle">
|
|
285
294
|
<button data-v="PASS" class="PASS ${cell.override === 'PASS' ? 'active PASS' : ''}">PASS</button>
|
|
286
295
|
<button data-v="FAIL" class="FAIL ${cell.override === 'FAIL' ? 'active FAIL' : ''}">FAIL</button>
|
package/dist/cli.d.ts
CHANGED
|
@@ -13,8 +13,24 @@ export declare function parseRunTuning(args: Args): {
|
|
|
13
13
|
reps: number;
|
|
14
14
|
passThreshold: number;
|
|
15
15
|
};
|
|
16
|
+
export declare function releaseExitCode(summaries: Array<{
|
|
17
|
+
results: {
|
|
18
|
+
mode: string;
|
|
19
|
+
partial?: boolean;
|
|
20
|
+
effective_grade: {
|
|
21
|
+
ship: boolean;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
}>): 0 | 1;
|
|
16
25
|
export declare function cmdRun(args: Args): Promise<void>;
|
|
26
|
+
export declare function cmdCompare(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
|
|
17
27
|
export declare function cmdGrade(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Re-score saved runs against the current spec's thresholds — no model or judge calls.
|
|
30
|
+
* Reps are the measurement; thresholds are policy. When policy changes, recompute rather
|
|
31
|
+
* than reconcile two numbers in prose.
|
|
32
|
+
*/
|
|
33
|
+
export declare function cmdMutationTest(): Promise<void>;
|
|
18
34
|
/**
|
|
19
35
|
* Re-evaluate needle gates against the saved staged diffs — free, except for the reps
|
|
20
36
|
* whose gate verdict flips from fail to pass, which the judge never saw and must now
|
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,10 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, mkdtempSync, rmSync
|
|
|
3
3
|
import { load as yamlLoad } from "js-yaml";
|
|
4
4
|
import { basename, dirname, join, resolve, relative } from "node:path";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import { discover, resolveSkill, loadSpec, parseSpec, appendScenario, parseModelRef, runSkillModel, formatScorecard, readResults, regradeRun, lintSkill, failsGate, renderTemplateSpec, isTemplateSpec, renderDraftSpec, buildSuggestPrompt, parseSuggestDraft, rescoreRun, regateRun, specPathForRunDir, collectLift, collectStability, boundaryCells, stabilityNote, PATH_LEGEND, restampSkill, resolveAdjudicationJudges, adjudicateRun, judgeResemblesSubject, computeCoverage, formatCoverage, selectAffected, formatAffected, gitDiff, exec, HARNESS_VERSION, defaultJudge, assertJudgeAllowed, assertNotDowngraded, downgradeWarning, } from "@skill-harness/core";
|
|
6
|
+
import { discover, resolveSkill, loadSpec, parseSpec, appendScenario, parseModelRef, runSkillModel, formatScorecard, readResults, regradeRun, lintSkill, failsGate, renderTemplateSpec, isTemplateSpec, renderDraftSpec, buildSuggestPrompt, parseSuggestDraft, rescoreRun, regateRun, specPathForRunDir, collectLift, collectStability, boundaryCells, stabilityNote, PATH_LEGEND, restampSkill, resolveAdjudicationJudges, adjudicateRun, judgeResemblesSubject, computeCoverage, formatCoverage, selectAffected, formatAffected, gitDiff, exec, HARNESS_VERSION, defaultJudge, assertJudgeAllowed, assertNotDowngraded, downgradeWarning, isScoredMode, runTrajectoryMutationSelfTest, } from "@skill-harness/core";
|
|
7
7
|
import { getAdapter } from "@skill-harness/adapters";
|
|
8
8
|
import { serveReview } from "./serve.js";
|
|
9
|
+
import { runCompareCommand } from "./compare.js";
|
|
9
10
|
const DEFAULT_MODEL = "fireworks:accounts/fireworks/models/deepseek-v4-pro";
|
|
10
11
|
// The judge default lives in core (`defaultJudge()`), which resolves
|
|
11
12
|
// SKILL_HARNESS_JUDGE over a baked value — it was duplicated in three places
|
|
@@ -115,6 +116,9 @@ async function cmdList(args) {
|
|
|
115
116
|
}
|
|
116
117
|
console.log(`\n● = testable · ○ = no spec yet · ✗ = spec present but invalid`);
|
|
117
118
|
}
|
|
119
|
+
export function releaseExitCode(summaries) {
|
|
120
|
+
return summaries.some(({ results }) => isScoredMode(results.mode) && !results.partial && !results.effective_grade.ship) ? 1 : 0;
|
|
121
|
+
}
|
|
118
122
|
export async function cmdRun(args) {
|
|
119
123
|
const root = flagStr(args, "skills", process.cwd());
|
|
120
124
|
const target = args._[0];
|
|
@@ -212,6 +216,99 @@ export async function cmdRun(args) {
|
|
|
212
216
|
}
|
|
213
217
|
}
|
|
214
218
|
console.log(`\nReview interactively: skill-harness review ${skills[0]?.name ?? "<skill>"} --skills ${root}`);
|
|
219
|
+
// A full delivered run is a release gate. NOT READY — including one critical
|
|
220
|
+
// failure hidden by a high aggregate — must be machine-visible to CI. Red
|
|
221
|
+
// baselines and partial/affected branch feedback are deliberately excluded.
|
|
222
|
+
if (releaseExitCode(summaries) !== 0)
|
|
223
|
+
process.exitCode = 1;
|
|
224
|
+
}
|
|
225
|
+
export async function cmdCompare(args, adapterOverride) {
|
|
226
|
+
const target = args._[0];
|
|
227
|
+
const reference = flagStr(args, "reference");
|
|
228
|
+
const candidateRoot = flagStr(args, "candidate");
|
|
229
|
+
if (!target || !reference || !candidateRoot) {
|
|
230
|
+
throw new Error("usage: skill-harness compare <skill|all> --reference <git-ref-or-skills-root> --candidate <skills-root> --model <provider:model> --reps N");
|
|
231
|
+
}
|
|
232
|
+
const judgeFlag = flagStr(args, "judge");
|
|
233
|
+
const judgeToken = judgeFlag ?? defaultJudge();
|
|
234
|
+
const judge = parseModelRef(judgeToken);
|
|
235
|
+
assertJudgeAllowed(judge, {
|
|
236
|
+
source: judgeFlag ? "--judge" : "the default judge (SKILL_HARNESS_JUDGE or the baked value)",
|
|
237
|
+
allowMetered: flagBool(args, "allow-metered-judge"),
|
|
238
|
+
});
|
|
239
|
+
const harnessName = flagStr(args, "harness", "pi");
|
|
240
|
+
const adapter = adapterOverride ?? getAdapter(harnessName);
|
|
241
|
+
if (!(await adapter.available()))
|
|
242
|
+
throw new Error(`harness \`${harnessName}\` is not on PATH`);
|
|
243
|
+
const mode = (flagStr(args, "mode", "force") || "force");
|
|
244
|
+
if (mode === "red")
|
|
245
|
+
throw new Error("compare measures a skill candidate, so --mode must be green or force (red is a no-skill baseline)");
|
|
246
|
+
const tuning = parseRunTuning(args);
|
|
247
|
+
const onlyRaw = flagStr(args, "only");
|
|
248
|
+
let only = onlyRaw ? onlyRaw.split(",").map((id) => id.trim()).filter(Boolean) : undefined;
|
|
249
|
+
const affected = flagBool(args, "affected");
|
|
250
|
+
if (affected && only)
|
|
251
|
+
throw new Error("--affected and --only both choose the comparison scenario set — pass one, not both");
|
|
252
|
+
if (affected) {
|
|
253
|
+
if (target === "all")
|
|
254
|
+
throw new Error("compare --affected currently requires one skill so each selected ID has an unambiguous spec");
|
|
255
|
+
const skill = resolveSkill(candidateRoot, target);
|
|
256
|
+
const spec = loadSpec(skill.specPath);
|
|
257
|
+
const selectionArgs = {
|
|
258
|
+
...args,
|
|
259
|
+
flags: {
|
|
260
|
+
...args.flags,
|
|
261
|
+
// A git-ref reference is the natural diff base. A reference directory
|
|
262
|
+
// has no common history, so the caller must supply --base explicitly.
|
|
263
|
+
...(!flagStr(args, "base") && !existsSync(reference) ? { base: reference } : {}),
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
if (existsSync(reference) && !flagStr(args, "base")) {
|
|
267
|
+
throw new Error("compare --affected with a reference directory needs --base <git-ref> for candidate change selection");
|
|
268
|
+
}
|
|
269
|
+
const selected = await computeAffected(selectionArgs, spec.scenarios, skill.specPath);
|
|
270
|
+
console.log(formatAffected(selected, spec.scenarios.length));
|
|
271
|
+
only = selected.selected.map((entry) => entry.id);
|
|
272
|
+
if (only.length === 0) {
|
|
273
|
+
console.log("compare: no affected scenarios — 0 subject calls, 0 judge calls; no release claim produced");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const threshold = (name) => {
|
|
278
|
+
const raw = flagStr(args, name);
|
|
279
|
+
if (raw === undefined)
|
|
280
|
+
return undefined;
|
|
281
|
+
const value = Number(raw);
|
|
282
|
+
if (!Number.isFinite(value) || value < 0)
|
|
283
|
+
throw new Error(`--${name} must be a non-negative ratio (got \`${raw}\`)`);
|
|
284
|
+
return value;
|
|
285
|
+
};
|
|
286
|
+
const thresholds = {
|
|
287
|
+
max_subject_token_increase: threshold("max-subject-token-increase"),
|
|
288
|
+
max_wall_time_increase: threshold("max-wall-time-increase"),
|
|
289
|
+
max_tool_call_increase: threshold("max-tool-call-increase"),
|
|
290
|
+
};
|
|
291
|
+
const hasThreshold = Object.values(thresholds).some((value) => value !== undefined);
|
|
292
|
+
const result = await runCompareCommand({
|
|
293
|
+
target,
|
|
294
|
+
reference,
|
|
295
|
+
candidateRoot,
|
|
296
|
+
models: resolveModels(args),
|
|
297
|
+
judgeToken,
|
|
298
|
+
mode,
|
|
299
|
+
reps: tuning.reps,
|
|
300
|
+
passThreshold: tuning.passThreshold,
|
|
301
|
+
parallel: Math.max(1, Number(flagStr(args, "parallel", "1")) || 1),
|
|
302
|
+
only,
|
|
303
|
+
canary: flagBool(args, "canary"),
|
|
304
|
+
output: flagStr(args, "output") || undefined,
|
|
305
|
+
thresholds: hasThreshold ? thresholds : undefined,
|
|
306
|
+
adapter,
|
|
307
|
+
now: nowIso,
|
|
308
|
+
});
|
|
309
|
+
console.log(`\ncomparison artifacts: ${result.outputDir}`);
|
|
310
|
+
if (result.exitCode !== 0)
|
|
311
|
+
process.exitCode = result.exitCode;
|
|
215
312
|
}
|
|
216
313
|
export async function cmdGrade(args, adapterOverride) {
|
|
217
314
|
const runDir = args._[0];
|
|
@@ -283,6 +380,17 @@ export async function cmdGrade(args, adapterOverride) {
|
|
|
283
380
|
* Reps are the measurement; thresholds are policy. When policy changes, recompute rather
|
|
284
381
|
* than reconcile two numbers in prose.
|
|
285
382
|
*/
|
|
383
|
+
export async function cmdMutationTest() {
|
|
384
|
+
const report = runTrajectoryMutationSelfTest();
|
|
385
|
+
console.log(`trajectory assertion mutation self-test: baseline ${report.baseline}`);
|
|
386
|
+
for (const test of report.cases) {
|
|
387
|
+
console.log(` ${test.detected ? "✓" : "✗"} ${test.id}: ${test.status} — ${test.detail}`);
|
|
388
|
+
}
|
|
389
|
+
const missed = report.cases.filter((test) => !test.detected);
|
|
390
|
+
console.log(`\n${report.cases.length - missed.length}/${report.cases.length} mutations detected; no model or judge calls.`);
|
|
391
|
+
if (missed.length)
|
|
392
|
+
process.exitCode = 1;
|
|
393
|
+
}
|
|
286
394
|
async function cmdRescore(args) {
|
|
287
395
|
const runDirs = args._;
|
|
288
396
|
if (runDirs.length === 0)
|
|
@@ -750,10 +858,15 @@ export function help() {
|
|
|
750
858
|
[--affected --base <git-ref>] run only the scenarios a change could touch (partial; never SHIPs)
|
|
751
859
|
[--mode red|green|force] [--judge prov:model] [--harness pi] [--label name] [--parallel N] [--reps N] [--pass-threshold T]
|
|
752
860
|
[--canary] green only: spend ONE probe proving the skill reached the model, and abort the run if it did not
|
|
861
|
+
compare <skill|all> --reference <git-ref-or-skills-root> --candidate <skills-root>
|
|
862
|
+
[--model prov:model ...] [--mode green|force] [--judge prov:model] [--reps N] [--only IDs | --affected --base ref]
|
|
863
|
+
[--output dir] [--max-subject-token-increase R] [--max-wall-time-increase R] [--max-tool-call-increase R]
|
|
864
|
+
paired setup (not seeded sampling); critical regression exit 2, ordinary regression exit 1
|
|
753
865
|
grade <run-dir> [--judge prov:model] [--suspect-only] re-grade saved transcripts (neutral judge)
|
|
754
866
|
[--auto-rejudge] [--secondary-judge p:m] [--tie-break-judge p:m]
|
|
755
867
|
ask again about untrustworthy cells (ambiguous / contradictory / non-unanimous /
|
|
756
868
|
ship-deciding). OFF by default; prints the exact MAX extra call count first.
|
|
869
|
+
mutation-test prove trajectory assertions turn red (free, offline)
|
|
757
870
|
rescore <run-dir>... re-score saved reps vs current spec thresholds (free)
|
|
758
871
|
regate <run-dir>... [--judge prov:model] re-evaluate diff needles against the saved diffs (free; judges only reps whose gate flipped)
|
|
759
872
|
restamp <skill|all> --skills <root> [--from <git-ref>] record the model-visible skill digest on runs that still match (free, offline; one-time migration)
|
|
@@ -781,7 +894,9 @@ export async function main(argv) {
|
|
|
781
894
|
const args = parseArgs(argv.slice(1));
|
|
782
895
|
switch (cmd) {
|
|
783
896
|
case "run": return cmdRun(args);
|
|
897
|
+
case "compare": return cmdCompare(args);
|
|
784
898
|
case "grade": return cmdGrade(args);
|
|
899
|
+
case "mutation-test": return cmdMutationTest();
|
|
785
900
|
case "rescore": return cmdRescore(args);
|
|
786
901
|
case "regate": return cmdRegate(args);
|
|
787
902
|
case "restamp": return cmdRestamp(args);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { buildComparison, type ComparisonThresholds, type HarnessAdapter, type RunMode } from "@skill-harness/core";
|
|
2
|
+
export interface CompareCommandOptions {
|
|
3
|
+
target: string;
|
|
4
|
+
reference: string;
|
|
5
|
+
candidateRoot: string;
|
|
6
|
+
models: string[];
|
|
7
|
+
judgeToken: string;
|
|
8
|
+
mode: RunMode;
|
|
9
|
+
reps: number;
|
|
10
|
+
passThreshold: number;
|
|
11
|
+
parallel: number;
|
|
12
|
+
only?: string[];
|
|
13
|
+
canary?: boolean;
|
|
14
|
+
output?: string;
|
|
15
|
+
thresholds?: ComparisonThresholds;
|
|
16
|
+
adapter: HarnessAdapter;
|
|
17
|
+
now: () => string;
|
|
18
|
+
log?: (line: string) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface CompareCommandResult {
|
|
21
|
+
outputDir: string;
|
|
22
|
+
reports: ReturnType<typeof buildComparison>[];
|
|
23
|
+
exitCode: 0 | 1 | 2;
|
|
24
|
+
}
|
|
25
|
+
/** Execute the same immutable plan against reference and candidate snapshots. */
|
|
26
|
+
export declare function runCompareCommand(options: CompareCommandOptions): Promise<CompareCommandResult>;
|
package/dist/compare.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { dump as yamlDump } from "js-yaml";
|
|
9
|
+
import { buildComparison, comparisonExitCode, discover, fileSha256, FIXTURE_PREFIX, formatComparison, HARNESS_VERSION, loadSpec, modelSlug, parseModelRef, resolveSkill, runSkillModel, SKILL_KEY, SKILL_PROMPT_KEY, sourceHashes, } from "@skill-harness/core";
|
|
10
|
+
/** Execute the same immutable plan against reference and candidate snapshots. */
|
|
11
|
+
export async function runCompareCommand(options) {
|
|
12
|
+
const log = options.log ?? console.log;
|
|
13
|
+
const temp = mkdtempSync(join(tmpdir(), "skill-harness-compare-"));
|
|
14
|
+
try {
|
|
15
|
+
const candidate = snapshotExisting(options.candidateRoot, join(temp, "candidate"));
|
|
16
|
+
const reference = existsSync(options.reference)
|
|
17
|
+
? snapshotExisting(options.reference, join(temp, "reference"))
|
|
18
|
+
: snapshotGitRef(options.candidateRoot, options.reference, join(temp, "reference"));
|
|
19
|
+
const candidateSkills = options.target === "all"
|
|
20
|
+
? discover(candidate.skillsRoot).filter((skill) => skill.hasSpec)
|
|
21
|
+
: [resolveSkill(candidate.skillsRoot, options.target)];
|
|
22
|
+
const referenceSkills = new Map((options.target === "all"
|
|
23
|
+
? discover(reference.skillsRoot).filter((skill) => skill.hasSpec)
|
|
24
|
+
: [resolveSkill(reference.skillsRoot, options.target)])
|
|
25
|
+
.map((skill) => [skill.name, skill]));
|
|
26
|
+
if (!candidateSkills.length)
|
|
27
|
+
throw new Error(`no candidate skills with specs under ${options.candidateRoot}`);
|
|
28
|
+
const outputDir = resolve(options.output ?? join(options.candidateRoot, ".skill-harness", "comparisons", timestampSlug(options.now())));
|
|
29
|
+
mkdirSync(outputDir, { recursive: true });
|
|
30
|
+
const reports = [];
|
|
31
|
+
const fixtures = (sources) => Object.fromEntries(Object.entries(sources).filter(([key]) => key.startsWith(FIXTURE_PREFIX)));
|
|
32
|
+
const inputs = (sources) => Object.fromEntries(Object.entries(sources).filter(([key]) => key !== SKILL_KEY && key !== SKILL_PROMPT_KEY));
|
|
33
|
+
// Materialize and validate the ENTIRE skill × model plan before the first
|
|
34
|
+
// paid call. `compare all` must not spend on skill 1 and only then discover
|
|
35
|
+
// that skill 7 is not a valid pair.
|
|
36
|
+
const plans = candidateSkills.map((candidateSkill) => {
|
|
37
|
+
const referenceSkill = referenceSkills.get(candidateSkill.name);
|
|
38
|
+
if (!referenceSkill)
|
|
39
|
+
throw new Error(`reference has no matching skill \`${candidateSkill.name}\``);
|
|
40
|
+
const candidateSpec = loadSpec(candidateSkill.specPath);
|
|
41
|
+
const referenceSpec = loadSpec(referenceSkill.specPath);
|
|
42
|
+
const candidateIds = candidateSpec.scenarios.map((scenario) => scenario.id).sort();
|
|
43
|
+
const referenceIds = referenceSpec.scenarios.map((scenario) => scenario.id).sort();
|
|
44
|
+
if (JSON.stringify(candidateIds) !== JSON.stringify(referenceIds))
|
|
45
|
+
throw new Error(`${candidateSkill.name}: reference/candidate scenario IDs differ`);
|
|
46
|
+
const refSpecDigest = fileSha256(referenceSkill.specPath);
|
|
47
|
+
const candSpecDigest = fileSha256(candidateSkill.specPath);
|
|
48
|
+
if (!refSpecDigest || !candSpecDigest || refSpecDigest !== candSpecDigest) {
|
|
49
|
+
throw new Error(`${candidateSkill.name}: reference/candidate specification.yaml differs — paired comparison changes only the skill under test`);
|
|
50
|
+
}
|
|
51
|
+
const refSources = sourceHashes({ skillDir: referenceSkill.dir, specDir: dirname(referenceSkill.specPath), scenarios: referenceSpec.scenarios, judgePersona: referenceSpec.judge_persona });
|
|
52
|
+
const candSources = sourceHashes({ skillDir: candidateSkill.dir, specDir: dirname(candidateSkill.specPath), scenarios: candidateSpec.scenarios, judgePersona: candidateSpec.judge_persona });
|
|
53
|
+
if (JSON.stringify(Object.entries(inputs(refSources)).sort()) !== JSON.stringify(Object.entries(inputs(candSources)).sort())) {
|
|
54
|
+
throw new Error(`${candidateSkill.name}: reference/candidate fixtures or other test inputs differ — refusing before model calls`);
|
|
55
|
+
}
|
|
56
|
+
return { candidateSkill, referenceSkill, candidateSpec, referenceSpec, refSpecDigest, candSpecDigest, refSources, candSources };
|
|
57
|
+
});
|
|
58
|
+
const modelPlans = options.models.map((token) => ({ token, model: parseModelRef(token) }));
|
|
59
|
+
const judge = parseModelRef(options.judgeToken);
|
|
60
|
+
for (const { candidateSkill, referenceSkill, candidateSpec, referenceSpec, refSpecDigest, candSpecDigest, refSources, candSources } of plans) {
|
|
61
|
+
for (const { token: modelToken, model } of modelPlans) {
|
|
62
|
+
log(`\n▶ compare ${candidateSkill.name} · ${modelToken} · reference then candidate`);
|
|
63
|
+
const timestamp = options.now();
|
|
64
|
+
const referenceRun = await runSkillModel({
|
|
65
|
+
spec: referenceSpec,
|
|
66
|
+
skillDir: referenceSkill.dir,
|
|
67
|
+
specPath: referenceSkill.specPath,
|
|
68
|
+
adapter: options.adapter,
|
|
69
|
+
model,
|
|
70
|
+
modelToken,
|
|
71
|
+
judge,
|
|
72
|
+
mode: options.mode,
|
|
73
|
+
timestamp,
|
|
74
|
+
label: "compare-reference",
|
|
75
|
+
concurrency: options.parallel,
|
|
76
|
+
reps: options.reps,
|
|
77
|
+
passThreshold: options.passThreshold,
|
|
78
|
+
only: options.only,
|
|
79
|
+
canary: options.canary,
|
|
80
|
+
onProgress: (line) => log(` reference ${line}`),
|
|
81
|
+
});
|
|
82
|
+
const candidateRun = await runSkillModel({
|
|
83
|
+
spec: candidateSpec,
|
|
84
|
+
skillDir: candidateSkill.dir,
|
|
85
|
+
specPath: candidateSkill.specPath,
|
|
86
|
+
adapter: options.adapter,
|
|
87
|
+
model,
|
|
88
|
+
modelToken,
|
|
89
|
+
judge,
|
|
90
|
+
mode: options.mode,
|
|
91
|
+
timestamp,
|
|
92
|
+
label: "compare-candidate",
|
|
93
|
+
concurrency: options.parallel,
|
|
94
|
+
reps: options.reps,
|
|
95
|
+
passThreshold: options.passThreshold,
|
|
96
|
+
only: options.only,
|
|
97
|
+
canary: options.canary,
|
|
98
|
+
onProgress: (line) => log(` candidate ${line}`),
|
|
99
|
+
});
|
|
100
|
+
const digests = {
|
|
101
|
+
reference: {
|
|
102
|
+
skill: mustDigest(join(referenceSkill.dir, "SKILL.md")),
|
|
103
|
+
spec: refSpecDigest,
|
|
104
|
+
fixtures: fixtures(refSources),
|
|
105
|
+
inputs: inputs(refSources),
|
|
106
|
+
},
|
|
107
|
+
candidate: {
|
|
108
|
+
skill: mustDigest(join(candidateSkill.dir, "SKILL.md")),
|
|
109
|
+
spec: candSpecDigest,
|
|
110
|
+
fixtures: fixtures(candSources),
|
|
111
|
+
inputs: inputs(candSources),
|
|
112
|
+
},
|
|
113
|
+
harness: runtimeHarnessDigest(),
|
|
114
|
+
model: sha256(modelToken),
|
|
115
|
+
judge: sha256(options.judgeToken),
|
|
116
|
+
environment: {
|
|
117
|
+
node: process.version,
|
|
118
|
+
platform: process.platform,
|
|
119
|
+
arch: process.arch,
|
|
120
|
+
harness_version: HARNESS_VERSION,
|
|
121
|
+
harness_cli_reference: referenceRun.results.harness_cli_version ?? "unavailable",
|
|
122
|
+
harness_cli_candidate: candidateRun.results.harness_cli_version ?? "unavailable",
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const report = buildComparison({
|
|
126
|
+
skill: candidateSpec.skill,
|
|
127
|
+
model: modelToken,
|
|
128
|
+
mode: options.mode,
|
|
129
|
+
reps: options.reps,
|
|
130
|
+
judge: options.judgeToken,
|
|
131
|
+
reference: referenceRun.results,
|
|
132
|
+
candidate: candidateRun.results,
|
|
133
|
+
critical: candidateSpec.critical,
|
|
134
|
+
digests,
|
|
135
|
+
partial: Boolean(options.only?.length),
|
|
136
|
+
thresholds: options.thresholds,
|
|
137
|
+
});
|
|
138
|
+
reports.push(report);
|
|
139
|
+
const cellDir = join(outputDir, candidateSkill.name, modelSlug(model));
|
|
140
|
+
mkdirSync(cellDir, { recursive: true });
|
|
141
|
+
cpSync(referenceRun.runDir, join(cellDir, "reference"), { recursive: true });
|
|
142
|
+
cpSync(candidateRun.runDir, join(cellDir, "candidate"), { recursive: true });
|
|
143
|
+
writeFileSync(join(cellDir, "comparison.yaml"), yamlDump(report, { lineWidth: 120 }), "utf8");
|
|
144
|
+
writeFileSync(join(cellDir, "comparison.txt"), `${formatComparison(report)}\n`, "utf8");
|
|
145
|
+
log(`\n${formatComparison(report)}\n artifacts: ${cellDir}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const exitCode = reports.reduce((highest, report) => Math.max(highest, comparisonExitCode(report)), 0);
|
|
149
|
+
return { outputDir, reports, exitCode };
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
rmSync(temp, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function snapshotExisting(skillsRoot, destination) {
|
|
156
|
+
const absolute = realpathSync(resolve(skillsRoot));
|
|
157
|
+
const repo = gitRoot(absolute);
|
|
158
|
+
const sourceRoot = repo ? realpathSync(repo) : absolute;
|
|
159
|
+
const inside = relativeInside(sourceRoot, absolute);
|
|
160
|
+
copyTree(sourceRoot, destination);
|
|
161
|
+
return { skillsRoot: join(destination, inside) };
|
|
162
|
+
}
|
|
163
|
+
function snapshotGitRef(candidateSkillsRoot, ref, destination) {
|
|
164
|
+
const absolute = realpathSync(resolve(candidateSkillsRoot));
|
|
165
|
+
const rawRepo = gitRoot(absolute);
|
|
166
|
+
const repo = rawRepo ? realpathSync(rawRepo) : null;
|
|
167
|
+
if (!repo)
|
|
168
|
+
throw new Error(`--reference ${ref} is not a directory and --candidate is not inside a git repository`);
|
|
169
|
+
execFileSync("git", ["clone", "--quiet", "--no-checkout", repo, destination], { stdio: ["ignore", "ignore", "pipe"] });
|
|
170
|
+
try {
|
|
171
|
+
execFileSync("git", ["checkout", "--quiet", "--detach", ref], { cwd: destination, stdio: ["ignore", "ignore", "pipe"] });
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw new Error(`cannot materialize reference git ref \`${ref}\`: ${error instanceof Error ? error.message : String(error)}`);
|
|
175
|
+
}
|
|
176
|
+
return { skillsRoot: join(destination, relativeInside(repo, absolute)) };
|
|
177
|
+
}
|
|
178
|
+
function relativeInside(root, target) {
|
|
179
|
+
const rel = relative(root, target);
|
|
180
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || resolve(root, rel) !== target) {
|
|
181
|
+
throw new Error(`skills root ${target} is outside snapshot root ${root}`);
|
|
182
|
+
}
|
|
183
|
+
return rel;
|
|
184
|
+
}
|
|
185
|
+
function gitRoot(path) {
|
|
186
|
+
try {
|
|
187
|
+
return execFileSync("git", ["-C", path, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function copyTree(source, destination) {
|
|
194
|
+
cpSync(source, destination, {
|
|
195
|
+
recursive: true,
|
|
196
|
+
filter: (path) => {
|
|
197
|
+
const rel = relative(source, path).replace(/\\/g, "/");
|
|
198
|
+
if (!rel)
|
|
199
|
+
return true;
|
|
200
|
+
return !rel.split("/").some((part) => part === ".git" || part === "node_modules" || part === ".skill-harness") && !/(^|\/)tests\/results(\/|$)/.test(rel);
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function mustDigest(path) { const digest = fileSha256(path); if (!digest)
|
|
205
|
+
throw new Error(`cannot digest ${path}`); return digest; }
|
|
206
|
+
function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
|
|
207
|
+
function timestampSlug(value) { return value.replace(/[:.]/g, "-"); }
|
|
208
|
+
function runtimeHarnessDigest() {
|
|
209
|
+
const hash = createHash("sha256").update(`skill-harness/${HARNESS_VERSION}\0`);
|
|
210
|
+
const require = createRequire(import.meta.url);
|
|
211
|
+
const cliDir = dirname(fileURLToPath(import.meta.url));
|
|
212
|
+
const roots = [
|
|
213
|
+
cliDir,
|
|
214
|
+
join(dirname(cliDir), "assets"),
|
|
215
|
+
dirname(require.resolve("@skill-harness/core")),
|
|
216
|
+
dirname(require.resolve("@skill-harness/adapters")),
|
|
217
|
+
].filter((root, index, all) => existsSync(root) && all.indexOf(root) === index);
|
|
218
|
+
if (!roots.length)
|
|
219
|
+
throw new Error("cannot locate running harness artifacts for comparison digest");
|
|
220
|
+
roots.forEach((root, rootIndex) => {
|
|
221
|
+
for (const file of filesUnder(root).sort()) {
|
|
222
|
+
hash.update(`${rootIndex}/${relative(root, file).replace(/\\/g, "/")}`).update("\0").update(readFileSync(file)).update("\0");
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
return hash.digest("hex");
|
|
226
|
+
}
|
|
227
|
+
function filesUnder(path) {
|
|
228
|
+
if (statSync(path).isFile())
|
|
229
|
+
return [path];
|
|
230
|
+
return readdirSync(path, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? filesUnder(join(path, entry.name)) : entry.isFile() ? [join(path, entry.name)] : []);
|
|
231
|
+
}
|
|
232
|
+
//# sourceMappingURL=compare.js.map
|
package/dist/serve.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { collectReport, renderReport, collectTrends, readResults, writeResults, applyOverride, preserveTranscript, findTranscriptFiles, ensureResultsGitignore, appendJournal, loadSpec, regradeScenario, refreshRubricHashes, findJudgeRawFiles, effectiveThreshold, scoreContextFor, isScoredMode, rebuildScenarioResult, envFlag, planAdjudication, adjudicateRun, assertJudgeAllowed, cellsFromResults, } from "@skill-harness/core";
|
|
6
|
+
import { collectReport, renderReport, collectTrends, readResults, writeResults, applyOverride, preserveTranscript, findTranscriptFiles, ensureResultsGitignore, appendJournal, loadSpec, regradeScenario, refreshRubricHashes, findJudgeRawFiles, effectiveThreshold, scoreContextFor, isScoredMode, rebuildScenarioResult, mergeScenarioMetrics, envFlag, planAdjudication, adjudicateRun, assertJudgeAllowed, cellsFromResults, } from "@skill-harness/core";
|
|
7
7
|
import { getAdapter } from "@skill-harness/adapters";
|
|
8
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
/** Locate assets/report.template.html relative to dist/ or src/. */
|
|
@@ -131,11 +131,12 @@ export async function serveReview(opts) {
|
|
|
131
131
|
const rr = await regradeScenario({
|
|
132
132
|
runDir: column.runDir, spec, scenario, adapter, judge: results.judge,
|
|
133
133
|
specDir: dirname(specPath), threshold, mode: results.mode,
|
|
134
|
+
expectedReps: prev.reps ?? 1,
|
|
134
135
|
});
|
|
135
136
|
const merged = results.scenarios.map((s) =>
|
|
136
137
|
// Same contract as `grade`, through the same choke point.
|
|
137
138
|
s.id === body.scenarioId
|
|
138
|
-
? rebuildScenarioResult(rr, s, { objective: "carry", adjudication: "drop" })
|
|
139
|
+
? rebuildScenarioResult({ ...rr, metrics: mergeScenarioMetrics(s.metrics, rr.metrics) }, s, { objective: "carry", adjudication: "drop" })
|
|
139
140
|
: s);
|
|
140
141
|
const written = writeResults(column.runDir, {
|
|
141
142
|
skill: results.skill, harness: results.harness, model: results.model, judge: results.judge,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skill-harness/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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.9.0",
|
|
48
|
+
"@skill-harness/adapters": "0.9.0"
|
|
49
49
|
}
|
|
50
50
|
}
|