@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.
@@ -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
- html += `<td class='cell ${v}${sel}' data-col='${col.index}' data-id='${scn.id}'>${v}${ov}${suspectBadge}${liftMark}${reps}</td>`;
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
- /** Exit-code contract: 0 = clean (no findings), 1 = >=1 finding, or a resolution error (unknown skill/root, no skills with a spec). */
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, appendFileSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
3
- import { basename, dirname, join, resolve } from "node:path";
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 yaml from "js-yaml";
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
- console.log("\n" + formatScorecard(summary, lift) + "\n");
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
- const g = results.effective_grade;
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
- const block = "\n" + yaml.dump({ scenarios: [scenario] }).replace(/^scenarios:\n/, "");
344
- const merged = readFileSync(skill.specPath, "utf8") + block;
345
- parseSpec(merged, skill.specPath); // throws if the append broke the spec
346
- appendFileSync(skill.specPath, block, "utf8");
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
- /** Exit-code contract: 0 = clean (no findings), 1 = >=1 finding, or a resolution error (unknown skill/root, no skills with a spec). */
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
- else
472
- for (const x of f) {
473
- const where = x.scenario ? `${dir}/${x.scenario}` : dir; // dir-based label, consistent with the ✓ line
474
- console.log(`✗ ${where}: ${x.code} — ${x.message}`);
475
- if (gha)
476
- console.log(`::error title=skill-harness::${where}: ${x.code} — ${x.message}`);
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
- console.log(`\n${skillDirs.length} skill(s), ${findings.length} finding(s)`);
480
- process.exitCode = findings.length > 0 ? 1 : 0;
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) => s.id === body.scenarioId ? { ...rr, override: s.override, note: s.note } : 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.5.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.5.0",
48
- "@skill-harness/adapters": "0.5.0"
47
+ "@skill-harness/core": "0.7.0",
48
+ "@skill-harness/adapters": "0.7.0"
49
49
  }
50
50
  }