@skill-harness/cli 0.6.0 → 0.8.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 +204 -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, restampSkill, 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
|
/**
|
|
@@ -314,6 +358,49 @@ export async function cmdRegate(args, adapterOverride) {
|
|
|
314
358
|
* spec; it is a statement about how much one run of that cell is worth. Making it a
|
|
315
359
|
* gate would turn "this needs more reps" into "your build is broken".
|
|
316
360
|
*/
|
|
361
|
+
/**
|
|
362
|
+
* Upgrade committed runs to the model-visible skill digest — free, offline, no re-runs.
|
|
363
|
+
*
|
|
364
|
+
* Run it once on a board that lints clean. Every record whose SKILL.md (or agent file)
|
|
365
|
+
* still matches the bytes it measured gains a digest of that same file's model-visible
|
|
366
|
+
* text, after which editing frontmatter the model never receives — `allowed-tools:`, a
|
|
367
|
+
* tool ceiling — stops demanding a paid re-run. Records whose file has already moved are
|
|
368
|
+
* left alone: nothing in a one-way hash can say whether that edit touched the body, and
|
|
369
|
+
* inventing freshness is the one thing this gate must never do.
|
|
370
|
+
*/
|
|
371
|
+
async function cmdRestamp(args) {
|
|
372
|
+
const root = flagStr(args, "skills", process.cwd());
|
|
373
|
+
const target = args._[0] ?? "all";
|
|
374
|
+
const skills = target === "all" ? discover(root).filter((s) => s.hasSpec) : [resolveSkill(root, target)];
|
|
375
|
+
if (skills.length === 0)
|
|
376
|
+
throw new Error(`no skills with a spec under ${root}`);
|
|
377
|
+
let runs = 0;
|
|
378
|
+
let upgraded = 0;
|
|
379
|
+
let unprovable = 0;
|
|
380
|
+
let unchanged = 0;
|
|
381
|
+
let partial = 0;
|
|
382
|
+
for (const skill of skills) {
|
|
383
|
+
const r = restampSkill(skill.dir, { from: flagStr(args, "from") });
|
|
384
|
+
runs += r.runs;
|
|
385
|
+
upgraded += r.upgraded;
|
|
386
|
+
unprovable += r.unprovable;
|
|
387
|
+
unchanged += r.unchanged;
|
|
388
|
+
partial += r.partial;
|
|
389
|
+
console.log(`\n${skill.name}: ${r.upgraded} upgraded, ${r.unprovable} left alone, ${r.unchanged} already current (${r.runs} run(s))`);
|
|
390
|
+
for (const a of r.added)
|
|
391
|
+
console.log(` + ${a}`);
|
|
392
|
+
if (r.unprovable > 0) {
|
|
393
|
+
console.log(` ${r.unprovable} left alone — a document they measured has already moved, so no digest of it can be proven; \`lint\` names the remedy`);
|
|
394
|
+
}
|
|
395
|
+
if (r.partial > 0) {
|
|
396
|
+
console.log(` ${r.partial} of the upgraded still carry a document that could not be proven`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// The three buckets sum to the records examined, on purpose: a total that does not add
|
|
400
|
+
// up reads like the command skipped something rather than like there being nothing to do.
|
|
401
|
+
console.log(`\n${runs} run(s) examined: ${upgraded} upgraded, ${unprovable} left alone, ${unchanged} already current.` +
|
|
402
|
+
`${partial > 0 ? ` (${partial} upgraded only in part.)` : ""} No models were called.`);
|
|
403
|
+
}
|
|
317
404
|
async function cmdStability(args) {
|
|
318
405
|
const root = flagStr(args, "skills", process.cwd());
|
|
319
406
|
const target = args._[0] ?? "all";
|
|
@@ -392,10 +479,6 @@ async function cmdAddTest(args) {
|
|
|
392
479
|
if (!id || !title || turns.length === 0 || checks.length === 0) {
|
|
393
480
|
throw new Error("add-test requires --id, --title, at least one --turn and one --check");
|
|
394
481
|
}
|
|
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
482
|
const scenario = { id, title };
|
|
400
483
|
if (flagStr(args, "critical") !== undefined)
|
|
401
484
|
scenario.critical = true;
|
|
@@ -406,12 +489,108 @@ async function cmdAddTest(args) {
|
|
|
406
489
|
}
|
|
407
490
|
scenario.turns = turns;
|
|
408
491
|
scenario.checklist = checks;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
492
|
+
// Duplicate-id rejection, merged-spec validation and the atomic write all live
|
|
493
|
+
// in appendScenario — shared with capture promotion so the two paths cannot
|
|
494
|
+
// disagree about what a valid write is.
|
|
495
|
+
appendScenario({ specPath: skill.specPath, scenario });
|
|
413
496
|
console.log(`added scenario ${id} to ${skill.specPath}`);
|
|
414
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* `coverage` — which instruction sections have a test declared against them.
|
|
500
|
+
*
|
|
501
|
+
* Free and offline. `--strict` turns uncovered sections into a non-zero exit, and
|
|
502
|
+
* is opt-in: an uncovered section is information, not a defect, and a linter that
|
|
503
|
+
* reddens CI for it teaches people to add a token `covers:` to silence it.
|
|
504
|
+
*/
|
|
505
|
+
async function cmdCoverage(args) {
|
|
506
|
+
const root = flagStr(args, "skills", process.cwd());
|
|
507
|
+
const target = args._[0];
|
|
508
|
+
if (!target)
|
|
509
|
+
throw new Error("usage: skill-harness coverage <skill|all> --skills <root> [--strict]");
|
|
510
|
+
const strict = flagBool(args, "strict");
|
|
511
|
+
const skills = target === "all" ? discover(root).filter((s) => s.hasSpec) : [resolveSkill(root, target)];
|
|
512
|
+
let anyUncovered = false;
|
|
513
|
+
let anyBroken = false;
|
|
514
|
+
for (const skill of skills) {
|
|
515
|
+
if (!skill.hasSpec)
|
|
516
|
+
continue;
|
|
517
|
+
const spec = loadSpec(skill.specPath);
|
|
518
|
+
const specDir = dirname(skill.specPath);
|
|
519
|
+
const report = computeCoverage({
|
|
520
|
+
specDir,
|
|
521
|
+
scenarios: spec.scenarios,
|
|
522
|
+
// SKILL.md lives one level above tests/, and is the file `covers` almost
|
|
523
|
+
// always points at, so report on it even when nothing references it —
|
|
524
|
+
// otherwise a skill with zero `covers` reports 0 sections and looks fine.
|
|
525
|
+
baseFiles: [relative(specDir, join(skill.dir, "SKILL.md")).split("\\").join("/")],
|
|
526
|
+
pendingCaptures: readPendingCaptures(specDir),
|
|
527
|
+
});
|
|
528
|
+
console.log(formatCoverage(report, spec.skill));
|
|
529
|
+
if (report.uncovered.length)
|
|
530
|
+
anyUncovered = true;
|
|
531
|
+
if (report.broken.length)
|
|
532
|
+
anyBroken = true;
|
|
533
|
+
}
|
|
534
|
+
// A broken reference fails regardless of --strict: it is a wrong statement in
|
|
535
|
+
// the spec, not a gap in coverage.
|
|
536
|
+
if (anyBroken) {
|
|
537
|
+
console.error("\nbroken `covers` references above — fix the reference or the heading");
|
|
538
|
+
process.exitCode = 1;
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (strict && anyUncovered) {
|
|
542
|
+
console.error("\n--strict: some sections have no declared test");
|
|
543
|
+
process.exitCode = 1;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/** Pending captures and the sections they are parked against. Free, offline, tolerant. */
|
|
547
|
+
function readPendingCaptures(specDir) {
|
|
548
|
+
const dir = join(specDir, "captures");
|
|
549
|
+
if (!existsSync(dir))
|
|
550
|
+
return [];
|
|
551
|
+
const out = [];
|
|
552
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".yaml"))) {
|
|
553
|
+
try {
|
|
554
|
+
const raw = yamlLoad(readFileSync(join(dir, file), "utf8"));
|
|
555
|
+
if (!raw || raw.status === "promoted")
|
|
556
|
+
continue;
|
|
557
|
+
const covers = Array.isArray(raw.covers) ? raw.covers.filter((c) => typeof c === "string") : [];
|
|
558
|
+
if (covers.length)
|
|
559
|
+
out.push({ id: String(raw.id ?? file.replace(/\.yaml$/, "")), covers });
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
// A malformed capture is the capture command's problem to report; coverage
|
|
563
|
+
// must not fail because a draft file is mid-edit.
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
/** `affected` — which scenarios a change could plausibly touch. Free and offline. */
|
|
569
|
+
async function cmdAffected(args) {
|
|
570
|
+
const root = flagStr(args, "skills", process.cwd());
|
|
571
|
+
const target = args._[0];
|
|
572
|
+
if (!target)
|
|
573
|
+
throw new Error("usage: skill-harness affected <skill> --skills <root> [--base <git-ref>]");
|
|
574
|
+
const skill = resolveSkill(root, target);
|
|
575
|
+
if (!skill.hasSpec)
|
|
576
|
+
throw new Error(`${target} has no spec`);
|
|
577
|
+
const spec = loadSpec(skill.specPath);
|
|
578
|
+
const result = await computeAffected(args, spec.scenarios, skill.specPath);
|
|
579
|
+
console.log(formatAffected(result, spec.scenarios.length));
|
|
580
|
+
}
|
|
581
|
+
/** Shared by `affected` and `run --affected`, so the two can never disagree. */
|
|
582
|
+
async function computeAffected(args, scenarios, specPath) {
|
|
583
|
+
const base = flagStr(args, "base", "HEAD");
|
|
584
|
+
const repoRoot = await gitRepoRoot(dirname(specPath));
|
|
585
|
+
const diff = await gitDiff(repoRoot, base);
|
|
586
|
+
return selectAffected({ scenarios, specDir: dirname(specPath), diff, repoRoot });
|
|
587
|
+
}
|
|
588
|
+
async function gitRepoRoot(from) {
|
|
589
|
+
const r = await exec("git", ["rev-parse", "--show-toplevel"], { cwd: from, timeoutMs: 30_000 });
|
|
590
|
+
if (r.code !== 0)
|
|
591
|
+
throw new Error(`not a git repository (from ${from}) — --affected needs one to diff against`);
|
|
592
|
+
return r.stdout.trim();
|
|
593
|
+
}
|
|
415
594
|
/** Write a spec to disk, creating its tests/ dir. The single choke point for spec
|
|
416
595
|
* writes (init/suggest) so a future atomic-write/backup/audit change lands in one place. */
|
|
417
596
|
function writeSpecFile(specPath, text) {
|
|
@@ -568,11 +747,16 @@ export function help() {
|
|
|
568
747
|
return `skill-harness ${HARNESS_VERSION} — test/optimize loop for agent skills (pi harness)
|
|
569
748
|
|
|
570
749
|
run <skill|all> --skills <root> [--model prov:model ...] [--models file] [--only A1,D2]
|
|
750
|
+
[--affected --base <git-ref>] run only the scenarios a change could touch (partial; never SHIPs)
|
|
571
751
|
[--mode red|green|force] [--judge prov:model] [--harness pi] [--label name] [--parallel N] [--reps N] [--pass-threshold T]
|
|
572
752
|
[--canary] green only: spend ONE probe proving the skill reached the model, and abort the run if it did not
|
|
573
753
|
grade <run-dir> [--judge prov:model] [--suspect-only] re-grade saved transcripts (neutral judge)
|
|
754
|
+
[--auto-rejudge] [--secondary-judge p:m] [--tie-break-judge p:m]
|
|
755
|
+
ask again about untrustworthy cells (ambiguous / contradictory / non-unanimous /
|
|
756
|
+
ship-deciding). OFF by default; prints the exact MAX extra call count first.
|
|
574
757
|
rescore <run-dir>... re-score saved reps vs current spec thresholds (free)
|
|
575
758
|
regate <run-dir>... [--judge prov:model] re-evaluate diff needles against the saved diffs (free; judges only reps whose gate flipped)
|
|
759
|
+
restamp <skill|all> --skills <root> [--from <git-ref>] record the model-visible skill digest on runs that still match (free, offline; one-time migration)
|
|
576
760
|
stability <skill|all> --skills <root> [--window N] [--all] run-over-run verdict flips per scenario (free, offline)
|
|
577
761
|
review <skill> --skills <root> [--port N] serve the interactive review UI
|
|
578
762
|
add-test <skill> --skills <root> --id ID --title T --turn ... --check ... [--critical] [--mode seeded --fixture path]
|
|
@@ -580,6 +764,8 @@ export function help() {
|
|
|
580
764
|
suggest <skill> --skills <root> [--model prov:model] [--force] LLM-draft a spec from SKILL.md (spends tokens)
|
|
581
765
|
list --skills <root> discovered skills + spec status
|
|
582
766
|
lint <skill|all> --skills <root> validate specs/fixtures + results-consistency (CI gate; exits non-zero on findings)
|
|
767
|
+
coverage <skill|all> --skills <root> [--strict] which instruction sections have a declared test (free, offline)
|
|
768
|
+
affected <skill> --skills <root> [--base ref] which scenarios a change could touch (free, offline)
|
|
583
769
|
|
|
584
770
|
version print ${HARNESS_VERSION} and exit (also --version / -v)
|
|
585
771
|
|
|
@@ -598,6 +784,7 @@ export async function main(argv) {
|
|
|
598
784
|
case "grade": return cmdGrade(args);
|
|
599
785
|
case "rescore": return cmdRescore(args);
|
|
600
786
|
case "regate": return cmdRegate(args);
|
|
787
|
+
case "restamp": return cmdRestamp(args);
|
|
601
788
|
case "stability": return cmdStability(args);
|
|
602
789
|
case "review": return cmdReview(args);
|
|
603
790
|
case "add-test": return cmdAddTest(args);
|
|
@@ -605,6 +792,8 @@ export async function main(argv) {
|
|
|
605
792
|
case "suggest": return cmdSuggest(args);
|
|
606
793
|
case "list": return cmdList(args);
|
|
607
794
|
case "lint": return cmdLint(args);
|
|
795
|
+
case "coverage": return cmdCoverage(args);
|
|
796
|
+
case "affected": return cmdAffected(args);
|
|
608
797
|
case "version":
|
|
609
798
|
case "--version":
|
|
610
799
|
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.8.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.8.0",
|
|
48
|
+
"@skill-harness/adapters": "0.8.0"
|
|
49
49
|
}
|
|
50
50
|
}
|