@skill-harness/cli 0.6.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 +59 -1
- package/dist/cli.js +159 -15
- package/dist/serve.js +79 -2
- package/package.json +3 -3
|
@@ -45,6 +45,15 @@
|
|
|
45
45
|
.cell .lm.down { color: var(--fail); }
|
|
46
46
|
/* Amber, like the suspect badge: both mean "this verdict is worth less than it looks". */
|
|
47
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; }
|
|
48
57
|
aside { width: 0; transition: width .15s ease; overflow: hidden; border-left: 1px solid var(--line); background: var(--panel); }
|
|
49
58
|
aside.open { width: 460px; }
|
|
50
59
|
.panel { width: 460px; padding: 16px 18px; }
|
|
@@ -230,7 +239,19 @@ function render() {
|
|
|
230
239
|
const stabMark = st
|
|
231
240
|
? `<span class='lm flip' title='${escapeHtml(st.note)}'>⇄ ${st.flips}/${st.compared}</span>`
|
|
232
241
|
: "";
|
|
233
|
-
|
|
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>`;
|
|
234
255
|
});
|
|
235
256
|
html += "</tr>";
|
|
236
257
|
}
|
|
@@ -258,12 +279,15 @@ async function openPanel(colIndex, scenarioId) {
|
|
|
258
279
|
<div class="reason"><b>judge:</b> ${cell.judge_verdict} — ${escapeHtml(cell.judge_reason || "(no reason)")}</div>
|
|
259
280
|
${cell.suspect ? `<div class="reason" style="color:#b45309"><b>⚠ suspect:</b> judge listed no failed item — re-judge before trusting this FAIL</div>` : ""}
|
|
260
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>` : ""}
|
|
261
284
|
<div class="toggle">
|
|
262
285
|
<button data-v="PASS" class="PASS ${cell.override === 'PASS' ? 'active PASS' : ''}">PASS</button>
|
|
263
286
|
<button data-v="FAIL" class="FAIL ${cell.override === 'FAIL' ? 'active FAIL' : ''}">FAIL</button>
|
|
264
287
|
<button data-v="" class="JUDGE ${!cell.override ? 'active JUDGE' : ''}">use judge (${cell.judge_verdict})</button>
|
|
265
288
|
</div>
|
|
266
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>
|
|
267
291
|
<label class="fld">note <span class="saved" id="saved">saved ✓</span></label>
|
|
268
292
|
<textarea id="note" placeholder="why you overrode / what to fix in SKILL.md">${escapeHtml(cell.note || "")}</textarea>
|
|
269
293
|
<label class="fld">transcript</label>
|
|
@@ -288,6 +312,40 @@ async function openPanel(colIndex, scenarioId) {
|
|
|
288
312
|
location.reload(); // re-judge rewrote results.yaml; reload the fresh matrix + grades
|
|
289
313
|
} catch (e) { rj.disabled = false; rj.textContent = "Re-judge"; }
|
|
290
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
|
+
};
|
|
291
349
|
let t;
|
|
292
350
|
document.getElementById("note").addEventListener("input", (e) => {
|
|
293
351
|
cell.note = e.target.value;
|
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, failsGate, renderTemplateSpec, isTemplateSpec, renderDraftSpec, buildSuggestPrompt, parseSuggestDraft, rescoreRun, regateRun, specPathForRunDir, collectLift, collectStability, boundaryCells, stabilityNote, PATH_LEGEND, HARNESS_VERSION, defaultJudge, assertJudgeAllowed, assertNotDowngraded, downgradeWarning, } from "@skill-harness/core";
|
|
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
|
|
@@ -231,7 +252,30 @@ export async function cmdGrade(args, adapterOverride) {
|
|
|
231
252
|
for (const s of results.scenarios) {
|
|
232
253
|
console.log(` ${s.id} → ${s.judge_verdict}: ${s.judge_reason}`);
|
|
233
254
|
}
|
|
234
|
-
|
|
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;
|
|
235
279
|
console.log(`\n re-graded with ${judge.provider}:${judge.model} → ${g.letter} (${g.pct}%) ${g.ship ? "SHIP" : "NOT READY"}`);
|
|
236
280
|
}
|
|
237
281
|
/**
|
|
@@ -392,10 +436,6 @@ async function cmdAddTest(args) {
|
|
|
392
436
|
if (!id || !title || turns.length === 0 || checks.length === 0) {
|
|
393
437
|
throw new Error("add-test requires --id, --title, at least one --turn and one --check");
|
|
394
438
|
}
|
|
395
|
-
// Validate the merged spec before writing.
|
|
396
|
-
const existing = loadSpec(skill.specPath);
|
|
397
|
-
if (existing.scenarios.some((s) => s.id === id))
|
|
398
|
-
throw new Error(`scenario id \`${id}\` already exists`);
|
|
399
439
|
const scenario = { id, title };
|
|
400
440
|
if (flagStr(args, "critical") !== undefined)
|
|
401
441
|
scenario.critical = true;
|
|
@@ -406,12 +446,108 @@ async function cmdAddTest(args) {
|
|
|
406
446
|
}
|
|
407
447
|
scenario.turns = turns;
|
|
408
448
|
scenario.checklist = checks;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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 });
|
|
413
453
|
console.log(`added scenario ${id} to ${skill.specPath}`);
|
|
414
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
|
+
}
|
|
415
551
|
/** Write a spec to disk, creating its tests/ dir. The single choke point for spec
|
|
416
552
|
* writes (init/suggest) so a future atomic-write/backup/audit change lands in one place. */
|
|
417
553
|
function writeSpecFile(specPath, text) {
|
|
@@ -568,9 +704,13 @@ export function help() {
|
|
|
568
704
|
return `skill-harness ${HARNESS_VERSION} — test/optimize loop for agent skills (pi harness)
|
|
569
705
|
|
|
570
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)
|
|
571
708
|
[--mode red|green|force] [--judge prov:model] [--harness pi] [--label name] [--parallel N] [--reps N] [--pass-threshold T]
|
|
572
709
|
[--canary] green only: spend ONE probe proving the skill reached the model, and abort the run if it did not
|
|
573
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.
|
|
574
714
|
rescore <run-dir>... re-score saved reps vs current spec thresholds (free)
|
|
575
715
|
regate <run-dir>... [--judge prov:model] re-evaluate diff needles against the saved diffs (free; judges only reps whose gate flipped)
|
|
576
716
|
stability <skill|all> --skills <root> [--window N] [--all] run-over-run verdict flips per scenario (free, offline)
|
|
@@ -580,6 +720,8 @@ export function help() {
|
|
|
580
720
|
suggest <skill> --skills <root> [--model prov:model] [--force] LLM-draft a spec from SKILL.md (spends tokens)
|
|
581
721
|
list --skills <root> discovered skills + spec status
|
|
582
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)
|
|
583
725
|
|
|
584
726
|
version print ${HARNESS_VERSION} and exit (also --version / -v)
|
|
585
727
|
|
|
@@ -605,6 +747,8 @@ export async function main(argv) {
|
|
|
605
747
|
case "suggest": return cmdSuggest(args);
|
|
606
748
|
case "list": return cmdList(args);
|
|
607
749
|
case "lint": return cmdLint(args);
|
|
750
|
+
case "coverage": return cmdCoverage(args);
|
|
751
|
+
case "affected": return cmdAffected(args);
|
|
608
752
|
case "version":
|
|
609
753
|
case "--version":
|
|
610
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
|
}
|