@skill-harness/cli 0.4.0 → 0.5.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.
@@ -143,7 +143,9 @@ export function liftNoneBadge(col) {
143
143
  if (col.lift) {
144
144
  return { text: "lift not comparable", title: col.liftHeadline || "nothing in the red baseline could be compared" };
145
145
  }
146
- if (col.mode === "green") {
146
+ // Green and force are both skill-delivered, so both can be missing a baseline.
147
+ // A red column is the baseline and gets no badge at all.
148
+ if (col.mode === "green" || col.mode === "force") {
147
149
  return { text: "no red baseline", title: "run the same scenarios with --mode red to get a baseline" };
148
150
  }
149
151
  return null;
@@ -126,10 +126,10 @@ function render() {
126
126
  const grades = DATA.columns.map((col) => gradeColumn(col, DATA.shipBar, DATA.critical));
127
127
  const queue = [];
128
128
  DATA.columns.forEach((col) => {
129
- // A suspect cell in a red/force column can't be re-judged (/rejudge 400s
130
- // "only green runs can be re-judged") — don't queue it. The matrix below
131
- // still shows suspect badges for every column regardless of mode.
132
- if (col.mode && col.mode !== "green") return;
129
+ // A suspect cell in a red column can't be re-judged from here (/rejudge 400s
130
+ // for an unscored baseline) — don't queue it. The matrix below still shows
131
+ // suspect badges for every column regardless of mode.
132
+ if (col.mode && col.mode !== "green" && col.mode !== "force") return;
133
133
  for (const scn of DATA.scenarios) {
134
134
  const cell = col.cells[scn.id];
135
135
  if (cell && cell.suspect && !cell.override) queue.push({ col: col.index, id: scn.id, label: col.label, reason: cell.judge_reason });
@@ -150,8 +150,8 @@ function render() {
150
150
  DATA.columns.forEach((col, i) => {
151
151
  const g = grades[i];
152
152
  let gradeHtml;
153
- if (col.mode && col.mode !== "green") {
154
- // Only green runs are scored; a red/force column has no ship grade.
153
+ if (col.mode && col.mode !== "green" && col.mode !== "force") {
154
+ // Green and force are scored; a red baseline column has no ship grade.
155
155
  gradeHtml = `<span class='badge no'>not scored (${escapeHtml(col.mode)})</span>`;
156
156
  if (g.suspect > 0) gradeHtml += ` — ${g.suspect} suspect`;
157
157
  } else {
@@ -244,7 +244,7 @@ async function openPanel(colIndex, scenarioId) {
244
244
  panel.innerHTML = `
245
245
  <button class="close" id="closeBtn">×</button>
246
246
  <h2>${scn.id} · ${escapeHtml(scn.title)}</h2>
247
- <div class="meta">${escapeHtml(col.label)} · judge ${escapeHtml(col.judge.provider + ":" + col.judge.model)}</div>
247
+ <div class="meta">${escapeHtml(col.label)} · mode ${escapeHtml(col.mode || "green")} · judge ${escapeHtml(col.judge.provider + ":" + col.judge.model)}</div>
248
248
  <div class="reason"><b>judge:</b> ${cell.judge_verdict} — ${escapeHtml(cell.judge_reason || "(no reason)")}</div>
249
249
  ${cell.suspect ? `<div class="reason" style="color:#b45309"><b>⚠ suspect:</b> judge listed no failed item — re-judge before trusting this FAIL</div>` : ""}
250
250
  <div class="toggle">
@@ -358,7 +358,12 @@ function renderTrends(data) {
358
358
  const badge = last.ship ? "<span class='badge ship'>SHIP</span>" : "<span class='badge no'>NOT READY</span>";
359
359
  const trunc = m.truncated ? ` <span class='dim'>(last ${m.runs.length})</span>` : "";
360
360
  const skippedNote = m.skipped > 0 ? ` <span class='dim'>(${m.skipped} unreadable)</span>` : "";
361
- html += `<div class="tmodel"><div class="tmodel-h">${escapeHtml(m.model)} ${sparkline(m.runs)} ${last.letter} (${last.pct}%) ${badge}${trunc}${skippedNote}</div>`;
361
+ // The mode is part of the series identity, not decoration: a tag that moved
362
+ // from green to force delivery has TWO series here, and the same skill text
363
+ // scores differently under each — so an unlabelled pair of sparklines would
364
+ // read as one history that jumped.
365
+ const modeNote = m.mode ? ` <span class='dim'>${escapeHtml(m.mode)}</span>` : "";
366
+ html += `<div class="tmodel"><div class="tmodel-h">${escapeHtml(m.model)}${modeNote} — ${sparkline(m.runs)} ${last.letter} (${last.pct}%) ${badge}${trunc}${skippedNote}</div>`;
362
367
  html += "<table class='tgrid'><thead><tr><th></th>";
363
368
  for (const run of m.runs) html += `<th title="${escapeHtml(run.label || run.timestamp)}">${escapeHtml((run.label || run.timestamp).slice(0, 8))}</th>`;
364
369
  html += "</tr></thead><tbody>";
package/dist/cli.js CHANGED
@@ -134,6 +134,7 @@ export async function cmdRun(args) {
134
134
  if (!(await adapter.available()))
135
135
  throw new Error(`harness \`${harnessName}\` is not on PATH`);
136
136
  const mode = flagStr(args, "mode", "green") || "green";
137
+ const canary = flagBool(args, "canary");
137
138
  const label = flagStr(args, "label") || null;
138
139
  const parallel = Math.max(1, Number(flagStr(args, "parallel", "1")) || 1);
139
140
  const { reps, passThreshold } = parseRunTuning(args);
@@ -174,6 +175,7 @@ export async function cmdRun(args) {
174
175
  reps,
175
176
  passThreshold,
176
177
  only,
178
+ canary,
177
179
  onProgress: (m) => console.log(m),
178
180
  });
179
181
  summaries.push(summary);
@@ -491,6 +493,7 @@ export function help() {
491
493
 
492
494
  run <skill|all> --skills <root> [--model prov:model ...] [--models file] [--only A1,D2]
493
495
  [--mode red|green|force] [--judge prov:model] [--harness pi] [--label name] [--parallel N] [--reps N] [--pass-threshold T]
496
+ [--canary] green only: spend ONE probe proving the skill reached the model, and abort the run if it did not
494
497
  grade <run-dir> [--judge prov:model] [--suspect-only] re-grade saved transcripts (neutral judge)
495
498
  rescore <run-dir>... re-score saved reps vs current spec thresholds (free)
496
499
  regate <run-dir>... [--judge prov:model] re-evaluate diff needles against the saved diffs (free; judges only reps whose gate flipped)
@@ -504,6 +507,9 @@ export function help() {
504
507
  version print ${HARNESS_VERSION} and exit (also --version / -v)
505
508
 
506
509
  defaults: model=${DEFAULT_MODEL} judge=${defaultJudge()} mode=green harness=pi
510
+ green and force are both scored; red is the unscored baseline. green delivery depends on the
511
+ harness version (pi >= 0.83.0 discloses only the skill's description and loads the body on demand),
512
+ so --mode force is the delivery that cannot silently degrade — and --canary proves green per run.
507
513
  the judge default is Opus on your Claude subscription (\`claude-code\` → \`claude -p\`), not a
508
514
  metered API key. Set SKILL_HARNESS_JUDGE to change it for a repo or a shell; --judge wins over both.`;
509
515
  }
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, findJudgeRawFiles, effectiveThreshold, envFlag, } from "@skill-harness/core";
6
+ import { collectReport, renderReport, collectTrends, readResults, writeResults, applyOverride, preserveTranscript, findTranscriptFiles, ensureResultsGitignore, appendJournal, loadSpec, regradeScenario, refreshRubricHashes, findJudgeRawFiles, effectiveThreshold, scoreContextFor, isScoredMode, envFlag, } 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/. */
@@ -99,9 +99,13 @@ export async function serveReview(opts) {
99
99
  return;
100
100
  }
101
101
  const results = readResults(column.runDir);
102
- if (results.mode !== "green") {
102
+ // Skill-delivered runs only (green or force). A red baseline's transcripts
103
+ // can be re-judged too — `skill-harness grade <run-dir>` does it — but a
104
+ // baseline has no grade for this endpoint to report back, and the button
105
+ // sits under a scorecard.
106
+ if (!isScoredMode(results.mode)) {
103
107
  res.writeHead(400, { "content-type": "application/json" });
104
- res.end(JSON.stringify({ ok: false, error: "only green runs can be re-judged" }));
108
+ res.end(JSON.stringify({ ok: false, error: `only scored runs (green/force) can be re-judged here — for a ${results.mode} run use \`skill-harness grade\`` }));
105
109
  return;
106
110
  }
107
111
  const specPath = join(opts.skillDir, "tests", "specification.yaml");
@@ -126,13 +130,21 @@ export async function serveReview(opts) {
126
130
  try {
127
131
  const rr = await regradeScenario({
128
132
  runDir: column.runDir, spec, scenario, adapter, judge: results.judge,
129
- specDir: dirname(specPath), threshold,
133
+ specDir: dirname(specPath), threshold, mode: results.mode,
130
134
  });
131
135
  const merged = results.scenarios.map((s) => s.id === body.scenarioId ? { ...rr, override: s.override, note: s.note } : s);
132
136
  const written = writeResults(column.runDir, {
133
137
  skill: results.skill, harness: results.harness, model: results.model, judge: results.judge,
134
138
  timestamp: results.timestamp, label: results.label, mode: results.mode, scenarios: merged,
135
- }, { shipBar: spec.ship_bar, critical: spec.critical });
139
+ partial: results.partial,
140
+ // Provenance survives a UI re-judge, same as it does through `grade`.
141
+ harness_cli_version: results.harness_cli_version, delivery_canary: results.delivery_canary,
142
+ // Recorded hashes were being dropped here entirely, which silently
143
+ // retired the staleness gate for any run re-judged from the UI. Carried,
144
+ // with the one `rubric:` key this re-judge actually applied refreshed —
145
+ // the same doctrine `grade` follows (see refreshRubricHashes).
146
+ source_hashes: refreshRubricHashes(results.source_hashes, spec, [body.scenarioId]),
147
+ }, scoreContextFor(results, spec));
136
148
  ensureResultsGitignore(join(opts.skillDir, "tests", "results"));
137
149
  const g = written.effective_grade;
138
150
  appendJournal(column.runDir, { event: "score", ts: new Date().toISOString(), passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note });
@@ -167,11 +179,11 @@ export async function serveReview(opts) {
167
179
  return;
168
180
  }
169
181
  // writeResults recomputes effective_grade override-aware against the CURRENT
170
- // spec's ship bar — a saved override can never leave a stale grade. Only
171
- // green runs are scored (PR #1 finding: /save must not grade red/force runs).
182
+ // spec's ship bar — a saved override can never leave a stale grade. Scored
183
+ // modes only: /save must not put a grade on a red baseline (PR #1 finding),
184
+ // and since 0.5.0 "scored" includes force.
172
185
  const spec = loadSpec(join(opts.skillDir, "tests", "specification.yaml"));
173
- const ctx = patched.mode === "green" ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
174
- writeResults(column.runDir, patched, ctx);
186
+ writeResults(column.runDir, patched, scoreContextFor(patched, spec));
175
187
  // Unconditional: a results root created before schema-2/journal.jsonl existed
176
188
  // may still have a stale .gitignore body — every save (not just overrides)
177
189
  // must roll it forward so journal.jsonl doesn't end up tracked.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.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.4.0",
48
- "@skill-harness/adapters": "0.4.0"
47
+ "@skill-harness/core": "0.5.0",
48
+ "@skill-harness/adapters": "0.5.0"
49
49
  }
50
50
  }