@skill-harness/cli 0.5.0 → 0.7.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.template.html +70 -1
- package/dist/cli.d.ts +9 -1
- package/dist/cli.js +249 -27
- package/dist/serve.js +79 -2
- package/package.json +3 -3
|
@@ -43,6 +43,17 @@
|
|
|
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; }
|
|
48
|
+
/* Objective trace gates: pass/fail are facts about what the model did. ERROR is
|
|
49
|
+
amber rather than red — missing evidence is not a failing gate. */
|
|
50
|
+
.cell .lm.obj-pass { color: var(--pass); }
|
|
51
|
+
.cell .lm.obj-fail { color: var(--fail); }
|
|
52
|
+
.cell .lm.obj-error { color: #b45309; }
|
|
53
|
+
/* Adjudication. `unresolved` is amber for the same reason `suspect` is: it must
|
|
54
|
+
never read as a clean PASS or FAIL. */
|
|
55
|
+
.cell .lm.adj-settled { color: var(--dim); }
|
|
56
|
+
.cell .lm.adj-unresolved { color: #b45309; }
|
|
46
57
|
aside { width: 0; transition: width .15s ease; overflow: hidden; border-left: 1px solid var(--line); background: var(--panel); }
|
|
47
58
|
aside.open { width: 460px; }
|
|
48
59
|
.panel { width: 460px; padding: 16px 18px; }
|
|
@@ -220,7 +231,27 @@ function render() {
|
|
|
220
231
|
: lc === "regressed"
|
|
221
232
|
? `<span class='lm down' title='red baseline PASS → ${effective(cell)} with the skill'>↓ skill</span>`
|
|
222
233
|
: "";
|
|
223
|
-
|
|
234
|
+
// Run-over-run marker. Only on cells that actually flipped, and deliberately
|
|
235
|
+
// NOT folded into the flakiness number beside it: `flaky 0.00` is a within-run
|
|
236
|
+
// measure, and the case this exists for is a cell that was unanimous in every
|
|
237
|
+
// run and still landed on a different side each time.
|
|
238
|
+
const st = cell.stability;
|
|
239
|
+
const stabMark = st
|
|
240
|
+
? `<span class='lm flip' title='${escapeHtml(st.note)}'>⇄ ${st.flips}/${st.compared}</span>`
|
|
241
|
+
: "";
|
|
242
|
+
// Objective trace gates. Only rendered when the scenario DECLARED them —
|
|
243
|
+
// absent must read as "not declared", never as an objective pass.
|
|
244
|
+
const obj = cell.objective;
|
|
245
|
+
const objMark = obj
|
|
246
|
+
? `<span class='lm obj-${obj.status.toLowerCase()}' title='${escapeHtml(`objective ${obj.status}: ${obj.detail}`)}'>◉ ${escapeHtml(obj.status.toLowerCase())}</span>`
|
|
247
|
+
: "";
|
|
248
|
+
// Adjudication. `unresolved` is amber and says so in words — a disagreement
|
|
249
|
+
// that renders as a clean verdict is the whole failure this feature prevents.
|
|
250
|
+
const adj = cell.adjudication;
|
|
251
|
+
const adjMark = adj
|
|
252
|
+
? `<span class='lm adj-${adj.state === "unresolved" ? "unresolved" : "settled"}' title='${escapeHtml(`adjudication ${adj.state} (${adj.trigger}) · ${adj.detail}`)}'>⚖ ${adj.state === "unresolved" ? "unresolved" : `${adj.count} agree`}</span>`
|
|
253
|
+
: "";
|
|
254
|
+
html += `<td class='cell ${v}${sel}' data-col='${col.index}' data-id='${scn.id}'>${v}${ov}${suspectBadge}${liftMark}${stabMark}${objMark}${adjMark}${reps}</td>`;
|
|
224
255
|
});
|
|
225
256
|
html += "</tr>";
|
|
226
257
|
}
|
|
@@ -247,12 +278,16 @@ async function openPanel(colIndex, scenarioId) {
|
|
|
247
278
|
<div class="meta">${escapeHtml(col.label)} · mode ${escapeHtml(col.mode || "green")} · judge ${escapeHtml(col.judge.provider + ":" + col.judge.model)}</div>
|
|
248
279
|
<div class="reason"><b>judge:</b> ${cell.judge_verdict} — ${escapeHtml(cell.judge_reason || "(no reason)")}</div>
|
|
249
280
|
${cell.suspect ? `<div class="reason" style="color:#b45309"><b>⚠ suspect:</b> judge listed no failed item — re-judge before trusting this FAIL</div>` : ""}
|
|
281
|
+
${cell.stability ? `<div class="reason" style="color:#b45309"><b>⇄ boundary cell:</b> ${escapeHtml(cell.stability.note)}</div>` : ""}
|
|
282
|
+
${cell.objective ? `<div class="reason"><b>◉ objective ${escapeHtml(cell.objective.status)}:</b> ${escapeHtml(cell.objective.detail)}</div>` : ""}
|
|
283
|
+
${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>` : ""}
|
|
250
284
|
<div class="toggle">
|
|
251
285
|
<button data-v="PASS" class="PASS ${cell.override === 'PASS' ? 'active PASS' : ''}">PASS</button>
|
|
252
286
|
<button data-v="FAIL" class="FAIL ${cell.override === 'FAIL' ? 'active FAIL' : ''}">FAIL</button>
|
|
253
287
|
<button data-v="" class="JUDGE ${!cell.override ? 'active JUDGE' : ''}">use judge (${cell.judge_verdict})</button>
|
|
254
288
|
</div>
|
|
255
289
|
<div class="rejudge"><button id="rejudgeBtn">Re-judge (${escapeHtml(col.judge.provider + ":" + col.judge.model)})</button> <span class="saved" id="rejudged"></span></div>
|
|
290
|
+
<div class="rejudge"><button id="adjBtn">Adjudicate column…</button> <span class="saved" id="adjudged"></span></div>
|
|
256
291
|
<label class="fld">note <span class="saved" id="saved">saved ✓</span></label>
|
|
257
292
|
<textarea id="note" placeholder="why you overrode / what to fix in SKILL.md">${escapeHtml(cell.note || "")}</textarea>
|
|
258
293
|
<label class="fld">transcript</label>
|
|
@@ -277,6 +312,40 @@ async function openPanel(colIndex, scenarioId) {
|
|
|
277
312
|
location.reload(); // re-judge rewrote results.yaml; reload the fresh matrix + grades
|
|
278
313
|
} catch (e) { rj.disabled = false; rj.textContent = "Re-judge"; }
|
|
279
314
|
};
|
|
315
|
+
|
|
316
|
+
// Two requests on purpose. The first prices the work and spends nothing; the
|
|
317
|
+
// confirm shows that exact ceiling; only then does anything call a judge. A
|
|
318
|
+
// single endpoint could not disclose a count before charging for it.
|
|
319
|
+
const adjBtn = document.getElementById("adjBtn");
|
|
320
|
+
if (adjBtn) adjBtn.onclick = async () => {
|
|
321
|
+
const status = document.getElementById("adjudged");
|
|
322
|
+
const show = (msg, err) => { if (status) { status.textContent = msg; status.classList.toggle("err", !!err); status.classList.add("show"); } };
|
|
323
|
+
adjBtn.disabled = true;
|
|
324
|
+
try {
|
|
325
|
+
const pr = await fetch("/adjudicate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ col: colIndex, step: "plan" }) });
|
|
326
|
+
const plan = await pr.json().catch(() => ({}));
|
|
327
|
+
if (!pr.ok || !plan.ok) { show(plan.error || "could not plan adjudication", true); adjBtn.disabled = false; return; }
|
|
328
|
+
if (!plan.triggered.length) { show("no cell triggered — nothing to adjudicate", false); adjBtn.disabled = false; return; }
|
|
329
|
+
|
|
330
|
+
// Counts, never dollars: the default judge is a Claude subscription and
|
|
331
|
+
// reports no per-call usage, so a cost figure here would be invented.
|
|
332
|
+
const ok = window.confirm(
|
|
333
|
+
`Adjudicate ${plan.triggered.length} cell(s) with ${plan.judge}?\n\n` +
|
|
334
|
+
`Up to ${plan.maxAdditionalCalls} additional judge call(s).\n` +
|
|
335
|
+
`No tie-break judge from the browser — a disagreement stays unresolved and blocks SHIP.\n\n` +
|
|
336
|
+
plan.detail.join("\n")
|
|
337
|
+
);
|
|
338
|
+
if (!ok) { show("cancelled — nothing spent", false); adjBtn.disabled = false; return; }
|
|
339
|
+
|
|
340
|
+
adjBtn.textContent = "Adjudicating…";
|
|
341
|
+
const rr = await fetch("/adjudicate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ col: colIndex, step: "run" }) });
|
|
342
|
+
const done = await rr.json().catch(() => ({}));
|
|
343
|
+
if (!rr.ok || !done.ok) { show(done.error || "adjudication failed", true); adjBtn.disabled = false; adjBtn.textContent = "Adjudicate column…"; return; }
|
|
344
|
+
location.reload();
|
|
345
|
+
} catch (e) {
|
|
346
|
+
show("adjudication failed", true); adjBtn.disabled = false; adjBtn.textContent = "Adjudicate column…";
|
|
347
|
+
}
|
|
348
|
+
};
|
|
280
349
|
let t;
|
|
281
350
|
document.getElementById("note").addEventListener("input", (e) => {
|
|
282
351
|
cell.note = e.target.value;
|
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
|
-
/**
|
|
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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, existsSync,
|
|
3
|
-
import {
|
|
2
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, mkdtempSync, rmSync, readdirSync } from "node:fs";
|
|
3
|
+
import { load as yamlLoad } from "js-yaml";
|
|
4
|
+
import { basename, dirname, join, resolve, relative } from "node:path";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
|
-
import
|
|
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, appendScenario, parseModelRef, runSkillModel, formatScorecard, readResults, regradeRun, lintSkill, failsGate, renderTemplateSpec, isTemplateSpec, renderDraftSpec, buildSuggestPrompt, parseSuggestDraft, rescoreRun, regateRun, specPathForRunDir, collectLift, collectStability, boundaryCells, stabilityNote, PATH_LEGEND, resolveAdjudicationJudges, adjudicateRun, judgeResemblesSubject, computeCoverage, formatCoverage, selectAffected, formatAffected, gitDiff, exec, 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";
|
|
@@ -129,6 +129,18 @@ export async function cmdRun(args) {
|
|
|
129
129
|
source: judgeFlagRun ? "--judge" : "the default judge (SKILL_HARNESS_JUDGE or the baked value)",
|
|
130
130
|
allowMetered: flagBool(args, "allow-metered-judge"),
|
|
131
131
|
});
|
|
132
|
+
// Contradictory ARGUMENTS are refused before the environment is probed. Both
|
|
133
|
+
// checks reject the same command, but only one of them tells the user something
|
|
134
|
+
// they can act on: a machine without `pi` installed reported "harness `pi` is
|
|
135
|
+
// not on PATH" for a plain flag typo, sending the reader off to fix an install
|
|
136
|
+
// that was never the problem. Free, offline validation first — it is also why
|
|
137
|
+
// CI, which has no `pi`, could not test this refusal at all.
|
|
138
|
+
const onlyRaw = flagStr(args, "only");
|
|
139
|
+
let only = onlyRaw ? onlyRaw.split(",").map((x) => x.trim()).filter(Boolean) : undefined;
|
|
140
|
+
const affected = flagBool(args, "affected");
|
|
141
|
+
if (affected && only) {
|
|
142
|
+
throw new Error("--affected and --only both choose the scenario set — pass one, not both");
|
|
143
|
+
}
|
|
132
144
|
const harnessName = flagStr(args, "harness", "pi");
|
|
133
145
|
const adapter = getAdapter(harnessName);
|
|
134
146
|
if (!(await adapter.available()))
|
|
@@ -138,8 +150,6 @@ export async function cmdRun(args) {
|
|
|
138
150
|
const label = flagStr(args, "label") || null;
|
|
139
151
|
const parallel = Math.max(1, Number(flagStr(args, "parallel", "1")) || 1);
|
|
140
152
|
const { reps, passThreshold } = parseRunTuning(args);
|
|
141
|
-
const onlyRaw = flagStr(args, "only");
|
|
142
|
-
const only = onlyRaw ? onlyRaw.split(",").map((x) => x.trim()).filter(Boolean) : undefined;
|
|
143
153
|
const modelTokens = resolveModels(args);
|
|
144
154
|
const skills = target === "all"
|
|
145
155
|
? discover(root).filter((s) => s.hasSpec)
|
|
@@ -154,6 +164,17 @@ export async function cmdRun(args) {
|
|
|
154
164
|
// that look comparable and are not. Checked per skill, before its first token.
|
|
155
165
|
assertNotDowngraded(skill.dir, "run");
|
|
156
166
|
const spec = loadSpec(skill.specPath);
|
|
167
|
+
if (affected) {
|
|
168
|
+
// Reuses the exact `--only` machinery, so an affected run is partial and
|
|
169
|
+
// cannot report SHIP — the same guarantee, through the same code path.
|
|
170
|
+
const result = await computeAffected(args, spec.scenarios, skill.specPath);
|
|
171
|
+
console.log(formatAffected(result, spec.scenarios.length));
|
|
172
|
+
only = result.selected.map((sel) => sel.id);
|
|
173
|
+
if (only.length === 0) {
|
|
174
|
+
console.log(`skip ${skill.name}: no scenario is affected by this change`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
157
178
|
for (const token of modelTokens) {
|
|
158
179
|
const model = parseModelRef(token);
|
|
159
180
|
// The version is on the banner because a stale global install is otherwise
|
|
@@ -183,7 +204,11 @@ export async function cmdRun(args) {
|
|
|
183
204
|
// any earlier run — the tag dir (<harness>-<modelslug>) is the join key.
|
|
184
205
|
const tag = basename(dirname(summary.runDir));
|
|
185
206
|
const lift = collectLift(skill.dir).find((l) => l.tag === tag);
|
|
186
|
-
|
|
207
|
+
// Stability is derived from history INCLUDING the run just written, and scoped to
|
|
208
|
+
// this tag + mode: another model's flips under this model's scorecard would be a
|
|
209
|
+
// worse error than not reporting them at all.
|
|
210
|
+
const stability = collectStability(skill.dir).filter((c) => c.tag === tag && c.mode === summary.results.mode);
|
|
211
|
+
console.log("\n" + formatScorecard(summary, lift, stability) + "\n");
|
|
187
212
|
}
|
|
188
213
|
}
|
|
189
214
|
console.log(`\nReview interactively: skill-harness review ${skills[0]?.name ?? "<skill>"} --skills ${root}`);
|
|
@@ -227,7 +252,30 @@ export async function cmdGrade(args, adapterOverride) {
|
|
|
227
252
|
for (const s of results.scenarios) {
|
|
228
253
|
console.log(` ${s.id} → ${s.judge_verdict}: ${s.judge_reason}`);
|
|
229
254
|
}
|
|
230
|
-
|
|
255
|
+
let final = results;
|
|
256
|
+
// Adjudication is opt-in. Without --auto-rejudge nothing below runs and not one
|
|
257
|
+
// extra call is made — a spec may declare triggers, but spec configuration alone
|
|
258
|
+
// never authorizes spending.
|
|
259
|
+
const judges = resolveAdjudicationJudges({
|
|
260
|
+
enabled: flagBool(args, "auto-rejudge"),
|
|
261
|
+
primary: judge,
|
|
262
|
+
secondaryToken: flagStr(args, "secondary-judge"),
|
|
263
|
+
tieBreakToken: flagStr(args, "tie-break-judge"),
|
|
264
|
+
subjectToken: results.model,
|
|
265
|
+
parseRef: parseModelRef,
|
|
266
|
+
assertAllowed: (j, source) => assertJudgeAllowed(j, { source, allowMetered: flagBool(args, "allow-metered-judge") }),
|
|
267
|
+
resemblesSubject: judgeResemblesSubject,
|
|
268
|
+
warn: (m) => console.error(m),
|
|
269
|
+
});
|
|
270
|
+
if (judges) {
|
|
271
|
+
final = await adjudicateRun({
|
|
272
|
+
runDir, spec, adapter, results, primaryJudge: judge,
|
|
273
|
+
secondaryJudge: judges.secondary, tieBreakJudge: judges.tieBreak,
|
|
274
|
+
specDir: testsDir, now: nowIso,
|
|
275
|
+
log: (m) => console.log(m),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const g = final.effective_grade;
|
|
231
279
|
console.log(`\n re-graded with ${judge.provider}:${judge.model} → ${g.letter} (${g.pct}%) ${g.ship ? "SHIP" : "NOT READY"}`);
|
|
232
280
|
}
|
|
233
281
|
/**
|
|
@@ -302,6 +350,68 @@ export async function cmdRegate(args, adapterOverride) {
|
|
|
302
350
|
// flipped reps, which it must not spend silently.
|
|
303
351
|
console.log(`\n${runDirs.length} run(s) re-gated, ${moved} verdict(s) moved, ${calls} judge call(s) (no model re-runs).`);
|
|
304
352
|
}
|
|
353
|
+
/**
|
|
354
|
+
* Run-over-run verdict stability, derived from committed results. Free and offline: it
|
|
355
|
+
* reads results.yaml files and computes — no model, no judge, no harness.
|
|
356
|
+
*
|
|
357
|
+
* Exits 0 whatever it finds. A boundary cell is not a defect in the skill or in the
|
|
358
|
+
* spec; it is a statement about how much one run of that cell is worth. Making it a
|
|
359
|
+
* gate would turn "this needs more reps" into "your build is broken".
|
|
360
|
+
*/
|
|
361
|
+
async function cmdStability(args) {
|
|
362
|
+
const root = flagStr(args, "skills", process.cwd());
|
|
363
|
+
const target = args._[0] ?? "all";
|
|
364
|
+
const windowRaw = flagStr(args, "window");
|
|
365
|
+
const window = windowRaw ? Number(windowRaw) : undefined;
|
|
366
|
+
if (windowRaw !== undefined && (!Number.isInteger(window) || window < 2)) {
|
|
367
|
+
throw new Error(`--window must be an integer >= 2 (got \`${windowRaw}\`) — one run has no run-over-run step`);
|
|
368
|
+
}
|
|
369
|
+
const showAll = flagBool(args, "all");
|
|
370
|
+
const skills = target === "all" ? discover(root).filter((s) => s.hasSpec) : [resolveSkill(root, target)];
|
|
371
|
+
if (skills.length === 0)
|
|
372
|
+
throw new Error(`no skills with a spec under ${root}`);
|
|
373
|
+
let boundaries = 0;
|
|
374
|
+
for (const skill of skills) {
|
|
375
|
+
const all = collectStability(skill.dir, { window });
|
|
376
|
+
if (all.length === 0) {
|
|
377
|
+
console.log(`\n${skill.name}: no scored runs yet — stability needs at least two runs of the same skill × model × mode`);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
// One block per model tag × delivery mode: green and force are different
|
|
381
|
+
// deliveries of the same text, so their histories are never one series.
|
|
382
|
+
const groups = new Map();
|
|
383
|
+
for (const s of all) {
|
|
384
|
+
const key = `${s.tag} · mode=${s.mode}`;
|
|
385
|
+
(groups.get(key) ?? groups.set(key, []).get(key)).push(s);
|
|
386
|
+
}
|
|
387
|
+
console.log(`\n── ${skill.name} ──`);
|
|
388
|
+
for (const [key, cells] of groups) {
|
|
389
|
+
const runs = Math.max(...cells.map((c) => c.points.length));
|
|
390
|
+
console.log(` ${key} (${runs} run(s) in the window)`);
|
|
391
|
+
const boundary = boundaryCells(cells);
|
|
392
|
+
boundaries += boundary.length;
|
|
393
|
+
for (const s of boundary) {
|
|
394
|
+
console.log(` ⇄ ${s.critical ? "CRITICAL " : ""}${stabilityNote(s)}`);
|
|
395
|
+
}
|
|
396
|
+
if (showAll) {
|
|
397
|
+
for (const s of cells) {
|
|
398
|
+
if (s.state !== "boundary")
|
|
399
|
+
console.log(` ${s.state === "stable" ? "=" : "?"} ${stabilityNote(s)}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
const stable = cells.filter((c) => c.state === "stable").length;
|
|
404
|
+
const unmeasured = cells.filter((c) => c.state === "unmeasured").length;
|
|
405
|
+
console.log(` ${stable} held their verdict · ${unmeasured} with no comparable step (--all to list them)`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
console.log(`\n${boundaries} boundary cell(s). ${PATH_LEGEND}`);
|
|
410
|
+
if (boundaries > 0) {
|
|
411
|
+
console.log(`A boundary cell is worth re-running with more reps (--reps) before you trust one run of it;`);
|
|
412
|
+
console.log(`within-run flakiness cannot see this, because it only ever looks at one run.`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
305
415
|
async function cmdReview(args) {
|
|
306
416
|
const root = flagStr(args, "skills", process.cwd());
|
|
307
417
|
const target = args._[0];
|
|
@@ -326,10 +436,6 @@ async function cmdAddTest(args) {
|
|
|
326
436
|
if (!id || !title || turns.length === 0 || checks.length === 0) {
|
|
327
437
|
throw new Error("add-test requires --id, --title, at least one --turn and one --check");
|
|
328
438
|
}
|
|
329
|
-
// Validate the merged spec before writing.
|
|
330
|
-
const existing = loadSpec(skill.specPath);
|
|
331
|
-
if (existing.scenarios.some((s) => s.id === id))
|
|
332
|
-
throw new Error(`scenario id \`${id}\` already exists`);
|
|
333
439
|
const scenario = { id, title };
|
|
334
440
|
if (flagStr(args, "critical") !== undefined)
|
|
335
441
|
scenario.critical = true;
|
|
@@ -340,12 +446,108 @@ async function cmdAddTest(args) {
|
|
|
340
446
|
}
|
|
341
447
|
scenario.turns = turns;
|
|
342
448
|
scenario.checklist = checks;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
449
|
+
// Duplicate-id rejection, merged-spec validation and the atomic write all live
|
|
450
|
+
// in appendScenario — shared with capture promotion so the two paths cannot
|
|
451
|
+
// disagree about what a valid write is.
|
|
452
|
+
appendScenario({ specPath: skill.specPath, scenario });
|
|
347
453
|
console.log(`added scenario ${id} to ${skill.specPath}`);
|
|
348
454
|
}
|
|
455
|
+
/**
|
|
456
|
+
* `coverage` — which instruction sections have a test declared against them.
|
|
457
|
+
*
|
|
458
|
+
* Free and offline. `--strict` turns uncovered sections into a non-zero exit, and
|
|
459
|
+
* is opt-in: an uncovered section is information, not a defect, and a linter that
|
|
460
|
+
* reddens CI for it teaches people to add a token `covers:` to silence it.
|
|
461
|
+
*/
|
|
462
|
+
async function cmdCoverage(args) {
|
|
463
|
+
const root = flagStr(args, "skills", process.cwd());
|
|
464
|
+
const target = args._[0];
|
|
465
|
+
if (!target)
|
|
466
|
+
throw new Error("usage: skill-harness coverage <skill|all> --skills <root> [--strict]");
|
|
467
|
+
const strict = flagBool(args, "strict");
|
|
468
|
+
const skills = target === "all" ? discover(root).filter((s) => s.hasSpec) : [resolveSkill(root, target)];
|
|
469
|
+
let anyUncovered = false;
|
|
470
|
+
let anyBroken = false;
|
|
471
|
+
for (const skill of skills) {
|
|
472
|
+
if (!skill.hasSpec)
|
|
473
|
+
continue;
|
|
474
|
+
const spec = loadSpec(skill.specPath);
|
|
475
|
+
const specDir = dirname(skill.specPath);
|
|
476
|
+
const report = computeCoverage({
|
|
477
|
+
specDir,
|
|
478
|
+
scenarios: spec.scenarios,
|
|
479
|
+
// SKILL.md lives one level above tests/, and is the file `covers` almost
|
|
480
|
+
// always points at, so report on it even when nothing references it —
|
|
481
|
+
// otherwise a skill with zero `covers` reports 0 sections and looks fine.
|
|
482
|
+
baseFiles: [relative(specDir, join(skill.dir, "SKILL.md")).split("\\").join("/")],
|
|
483
|
+
pendingCaptures: readPendingCaptures(specDir),
|
|
484
|
+
});
|
|
485
|
+
console.log(formatCoverage(report, spec.skill));
|
|
486
|
+
if (report.uncovered.length)
|
|
487
|
+
anyUncovered = true;
|
|
488
|
+
if (report.broken.length)
|
|
489
|
+
anyBroken = true;
|
|
490
|
+
}
|
|
491
|
+
// A broken reference fails regardless of --strict: it is a wrong statement in
|
|
492
|
+
// the spec, not a gap in coverage.
|
|
493
|
+
if (anyBroken) {
|
|
494
|
+
console.error("\nbroken `covers` references above — fix the reference or the heading");
|
|
495
|
+
process.exitCode = 1;
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (strict && anyUncovered) {
|
|
499
|
+
console.error("\n--strict: some sections have no declared test");
|
|
500
|
+
process.exitCode = 1;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** Pending captures and the sections they are parked against. Free, offline, tolerant. */
|
|
504
|
+
function readPendingCaptures(specDir) {
|
|
505
|
+
const dir = join(specDir, "captures");
|
|
506
|
+
if (!existsSync(dir))
|
|
507
|
+
return [];
|
|
508
|
+
const out = [];
|
|
509
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".yaml"))) {
|
|
510
|
+
try {
|
|
511
|
+
const raw = yamlLoad(readFileSync(join(dir, file), "utf8"));
|
|
512
|
+
if (!raw || raw.status === "promoted")
|
|
513
|
+
continue;
|
|
514
|
+
const covers = Array.isArray(raw.covers) ? raw.covers.filter((c) => typeof c === "string") : [];
|
|
515
|
+
if (covers.length)
|
|
516
|
+
out.push({ id: String(raw.id ?? file.replace(/\.yaml$/, "")), covers });
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
// A malformed capture is the capture command's problem to report; coverage
|
|
520
|
+
// must not fail because a draft file is mid-edit.
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return out;
|
|
524
|
+
}
|
|
525
|
+
/** `affected` — which scenarios a change could plausibly touch. Free and offline. */
|
|
526
|
+
async function cmdAffected(args) {
|
|
527
|
+
const root = flagStr(args, "skills", process.cwd());
|
|
528
|
+
const target = args._[0];
|
|
529
|
+
if (!target)
|
|
530
|
+
throw new Error("usage: skill-harness affected <skill> --skills <root> [--base <git-ref>]");
|
|
531
|
+
const skill = resolveSkill(root, target);
|
|
532
|
+
if (!skill.hasSpec)
|
|
533
|
+
throw new Error(`${target} has no spec`);
|
|
534
|
+
const spec = loadSpec(skill.specPath);
|
|
535
|
+
const result = await computeAffected(args, spec.scenarios, skill.specPath);
|
|
536
|
+
console.log(formatAffected(result, spec.scenarios.length));
|
|
537
|
+
}
|
|
538
|
+
/** Shared by `affected` and `run --affected`, so the two can never disagree. */
|
|
539
|
+
async function computeAffected(args, scenarios, specPath) {
|
|
540
|
+
const base = flagStr(args, "base", "HEAD");
|
|
541
|
+
const repoRoot = await gitRepoRoot(dirname(specPath));
|
|
542
|
+
const diff = await gitDiff(repoRoot, base);
|
|
543
|
+
return selectAffected({ scenarios, specDir: dirname(specPath), diff, repoRoot });
|
|
544
|
+
}
|
|
545
|
+
async function gitRepoRoot(from) {
|
|
546
|
+
const r = await exec("git", ["rev-parse", "--show-toplevel"], { cwd: from, timeoutMs: 30_000 });
|
|
547
|
+
if (r.code !== 0)
|
|
548
|
+
throw new Error(`not a git repository (from ${from}) — --affected needs one to diff against`);
|
|
549
|
+
return r.stdout.trim();
|
|
550
|
+
}
|
|
349
551
|
/** Write a spec to disk, creating its tests/ dir. The single choke point for spec
|
|
350
552
|
* writes (init/suggest) so a future atomic-write/backup/audit change lands in one place. */
|
|
351
553
|
function writeSpecFile(specPath, text) {
|
|
@@ -435,7 +637,15 @@ export async function cmdSuggest(args, adapterOverride) {
|
|
|
435
637
|
rmSync(cwd, { recursive: true, force: true });
|
|
436
638
|
}
|
|
437
639
|
}
|
|
438
|
-
/**
|
|
640
|
+
/**
|
|
641
|
+
* Exit-code contract: 0 = no gate-failing findings, 1 = >=1 of them, or a resolution
|
|
642
|
+
* error (unknown skill/root, no skills with a spec).
|
|
643
|
+
*
|
|
644
|
+
* `info` findings (run-over-run stability notes) print and annotate but never fail the
|
|
645
|
+
* gate: a boundary cell says how much one run of a scenario is worth, which is not a
|
|
646
|
+
* defect in the spec, the fixtures or the results. A linter that reddens CI for it would
|
|
647
|
+
* teach everyone to stop reading it.
|
|
648
|
+
*/
|
|
439
649
|
export async function cmdLint(args) {
|
|
440
650
|
const root = flagStr(args, "skills", process.cwd());
|
|
441
651
|
const target = args._[0] ?? "all";
|
|
@@ -466,18 +676,20 @@ export async function cmdLint(args) {
|
|
|
466
676
|
f = [{ skill: dir, code: "lint-error", message: e instanceof Error ? e.message : String(e) }];
|
|
467
677
|
}
|
|
468
678
|
findings.push(...f);
|
|
469
|
-
if (f.length === 0)
|
|
679
|
+
if (f.filter(failsGate).length === 0)
|
|
470
680
|
console.log(`✓ ${dir}`);
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
681
|
+
for (const x of f) {
|
|
682
|
+
const where = x.scenario ? `${dir}/${x.scenario}` : dir; // dir-based label, consistent with the ✓ line
|
|
683
|
+
const fails = failsGate(x);
|
|
684
|
+
console.log(`${fails ? "✗" : "ℹ"} ${where}: ${x.code} — ${x.message}`);
|
|
685
|
+
if (gha)
|
|
686
|
+
console.log(`::${fails ? "error" : "notice"} title=skill-harness::${where}: ${x.code} — ${x.message}`);
|
|
687
|
+
}
|
|
478
688
|
}
|
|
479
|
-
|
|
480
|
-
|
|
689
|
+
const gating = findings.filter(failsGate).length;
|
|
690
|
+
const notes = findings.length - gating;
|
|
691
|
+
console.log(`\n${skillDirs.length} skill(s), ${gating} finding(s)${notes > 0 ? `, ${notes} note(s) (do not fail the gate)` : ""}`);
|
|
692
|
+
process.exitCode = gating > 0 ? 1 : 0;
|
|
481
693
|
}
|
|
482
694
|
// ---------------------------------------------------------------- dispatch
|
|
483
695
|
/**
|
|
@@ -492,17 +704,24 @@ export function help() {
|
|
|
492
704
|
return `skill-harness ${HARNESS_VERSION} — test/optimize loop for agent skills (pi harness)
|
|
493
705
|
|
|
494
706
|
run <skill|all> --skills <root> [--model prov:model ...] [--models file] [--only A1,D2]
|
|
707
|
+
[--affected --base <git-ref>] run only the scenarios a change could touch (partial; never SHIPs)
|
|
495
708
|
[--mode red|green|force] [--judge prov:model] [--harness pi] [--label name] [--parallel N] [--reps N] [--pass-threshold T]
|
|
496
709
|
[--canary] green only: spend ONE probe proving the skill reached the model, and abort the run if it did not
|
|
497
710
|
grade <run-dir> [--judge prov:model] [--suspect-only] re-grade saved transcripts (neutral judge)
|
|
711
|
+
[--auto-rejudge] [--secondary-judge p:m] [--tie-break-judge p:m]
|
|
712
|
+
ask again about untrustworthy cells (ambiguous / contradictory / non-unanimous /
|
|
713
|
+
ship-deciding). OFF by default; prints the exact MAX extra call count first.
|
|
498
714
|
rescore <run-dir>... re-score saved reps vs current spec thresholds (free)
|
|
499
715
|
regate <run-dir>... [--judge prov:model] re-evaluate diff needles against the saved diffs (free; judges only reps whose gate flipped)
|
|
716
|
+
stability <skill|all> --skills <root> [--window N] [--all] run-over-run verdict flips per scenario (free, offline)
|
|
500
717
|
review <skill> --skills <root> [--port N] serve the interactive review UI
|
|
501
718
|
add-test <skill> --skills <root> --id ID --title T --turn ... --check ... [--critical] [--mode seeded --fixture path]
|
|
502
719
|
init <skill> --skills <root> [--force] scaffold a commented template spec (free, offline)
|
|
503
720
|
suggest <skill> --skills <root> [--model prov:model] [--force] LLM-draft a spec from SKILL.md (spends tokens)
|
|
504
721
|
list --skills <root> discovered skills + spec status
|
|
505
722
|
lint <skill|all> --skills <root> validate specs/fixtures + results-consistency (CI gate; exits non-zero on findings)
|
|
723
|
+
coverage <skill|all> --skills <root> [--strict] which instruction sections have a declared test (free, offline)
|
|
724
|
+
affected <skill> --skills <root> [--base ref] which scenarios a change could touch (free, offline)
|
|
506
725
|
|
|
507
726
|
version print ${HARNESS_VERSION} and exit (also --version / -v)
|
|
508
727
|
|
|
@@ -521,12 +740,15 @@ export async function main(argv) {
|
|
|
521
740
|
case "grade": return cmdGrade(args);
|
|
522
741
|
case "rescore": return cmdRescore(args);
|
|
523
742
|
case "regate": return cmdRegate(args);
|
|
743
|
+
case "stability": return cmdStability(args);
|
|
524
744
|
case "review": return cmdReview(args);
|
|
525
745
|
case "add-test": return cmdAddTest(args);
|
|
526
746
|
case "init": return cmdInit(args);
|
|
527
747
|
case "suggest": return cmdSuggest(args);
|
|
528
748
|
case "list": return cmdList(args);
|
|
529
749
|
case "lint": return cmdLint(args);
|
|
750
|
+
case "coverage": return cmdCoverage(args);
|
|
751
|
+
case "affected": return cmdAffected(args);
|
|
530
752
|
case "version":
|
|
531
753
|
case "--version":
|
|
532
754
|
case "-v":
|
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, envFlag, } from "@skill-harness/core";
|
|
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";
|
|
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/. */
|
|
@@ -132,7 +132,11 @@ export async function serveReview(opts) {
|
|
|
132
132
|
runDir: column.runDir, spec, scenario, adapter, judge: results.judge,
|
|
133
133
|
specDir: dirname(specPath), threshold, mode: results.mode,
|
|
134
134
|
});
|
|
135
|
-
const merged = results.scenarios.map((s) =>
|
|
135
|
+
const merged = results.scenarios.map((s) =>
|
|
136
|
+
// Same contract as `grade`, through the same choke point.
|
|
137
|
+
s.id === body.scenarioId
|
|
138
|
+
? rebuildScenarioResult(rr, s, { objective: "carry", adjudication: "drop" })
|
|
139
|
+
: s);
|
|
136
140
|
const written = writeResults(column.runDir, {
|
|
137
141
|
skill: results.skill, harness: results.harness, model: results.model, judge: results.judge,
|
|
138
142
|
timestamp: results.timestamp, label: results.label, mode: results.mode, scenarios: merged,
|
|
@@ -160,6 +164,79 @@ export async function serveReview(opts) {
|
|
|
160
164
|
}
|
|
161
165
|
return;
|
|
162
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Adjudication, in two steps: `plan` returns the exact call ceiling and
|
|
169
|
+
* spends nothing; `run` executes it.
|
|
170
|
+
*
|
|
171
|
+
* Two requests rather than one because the ceiling has to be disclosed
|
|
172
|
+
* BEFORE anything is spent, and a single endpoint that both prices and
|
|
173
|
+
* charges cannot do that — the UI would be showing a count for work that
|
|
174
|
+
* had already happened.
|
|
175
|
+
*/
|
|
176
|
+
if (req.method === "POST" && url.pathname === "/adjudicate") {
|
|
177
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
178
|
+
const data = collectReport(opts.skillDir);
|
|
179
|
+
const column = data.columns.find((c) => c.index === body.col);
|
|
180
|
+
if (!column) {
|
|
181
|
+
res.writeHead(404).end("unknown column");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const results = readResults(column.runDir);
|
|
185
|
+
if (!isScoredMode(results.mode)) {
|
|
186
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
187
|
+
res.end(JSON.stringify({ ok: false, error: `only scored runs (green/force) can be adjudicated — for a ${results.mode} run use \`skill-harness grade\`` }));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const specPath = join(opts.skillDir, "tests", "specification.yaml");
|
|
191
|
+
const spec = loadSpec(specPath);
|
|
192
|
+
const adapter = opts.adapter ?? getAdapter(results.harness);
|
|
193
|
+
// Priced exactly as `adjudicateRun` will perform it — including per-rep
|
|
194
|
+
// verdicts, without which the quoted ceiling can be lower than the spend.
|
|
195
|
+
const cells = cellsFromResults(column.runDir, results);
|
|
196
|
+
// No tie-break judge from the browser: adding a third judge is a judge
|
|
197
|
+
// CHOICE, and the UI has nowhere honest to make one. A disagreement here
|
|
198
|
+
// stays unresolved and blocks SHIP, which is the safe direction.
|
|
199
|
+
const plan = planAdjudication({
|
|
200
|
+
cells, scenarios: spec.scenarios, shipBar: spec.ship_bar, critical: spec.critical,
|
|
201
|
+
tieBreakAvailable: false,
|
|
202
|
+
});
|
|
203
|
+
if (body.step !== "run") {
|
|
204
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
205
|
+
res.end(JSON.stringify({
|
|
206
|
+
ok: true, step: "plan",
|
|
207
|
+
triggered: plan.triggered,
|
|
208
|
+
maxAdditionalCalls: plan.maxAdditionalCalls,
|
|
209
|
+
judge: `${results.judge.provider}:${results.judge.model}`,
|
|
210
|
+
detail: plan.decisions.filter((d) => d.triggers.length).map((d) => `${d.id}: ${d.triggers.join(", ")}`),
|
|
211
|
+
}));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!(await adapter.available())) {
|
|
215
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
216
|
+
res.end(JSON.stringify({ ok: false, error: `harness \`${results.harness}\` is not on PATH` }));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
assertJudgeAllowed(results.judge, { source: "the run's recorded judge", allowMetered: envFlag("SKILL_HARNESS_ALLOW_METERED_JUDGE") });
|
|
221
|
+
const written = await adjudicateRun({
|
|
222
|
+
runDir: column.runDir, spec, adapter, results,
|
|
223
|
+
primaryJudge: results.judge,
|
|
224
|
+
// Asked again as an independent draw. The judge-variance study measured
|
|
225
|
+
// ~2% self-disagreement on identical transcripts, so this is a real
|
|
226
|
+
// second opinion rather than a no-op.
|
|
227
|
+
secondaryJudge: results.judge,
|
|
228
|
+
specDir: dirname(specPath), now: () => new Date().toISOString(),
|
|
229
|
+
});
|
|
230
|
+
ensureResultsGitignore(join(opts.skillDir, "tests", "results"));
|
|
231
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
232
|
+
res.end(JSON.stringify({ ok: true, step: "run", grade: written.effective_grade }));
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
236
|
+
res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
163
240
|
if (req.method === "POST" && url.pathname === "/save") {
|
|
164
241
|
const body = JSON.parse((await readBody(req)) || "{}");
|
|
165
242
|
const data = collectReport(opts.skillDir);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skill-harness/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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.7.0",
|
|
48
|
+
"@skill-harness/adapters": "0.7.0"
|
|
49
49
|
}
|
|
50
50
|
}
|