@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.
@@ -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
- html += `<td class='cell ${v}${sel}' data-col='${col.index}' data-id='${scn.id}'>${v}${ov}${suspectBadge}${liftMark}${stabMark}${reps}</td>`;
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, 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, 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
- 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;
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
- const block = "\n" + yaml.dump({ scenarios: [scenario] }).replace(/^scenarios:\n/, "");
410
- const merged = readFileSync(skill.specPath, "utf8") + block;
411
- parseSpec(merged, skill.specPath); // throws if the append broke the spec
412
- 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 });
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) => 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.6.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.6.0",
48
- "@skill-harness/adapters": "0.6.0"
47
+ "@skill-harness/core": "0.7.0",
48
+ "@skill-harness/adapters": "0.7.0"
49
49
  }
50
50
  }