@skill-harness/cli 0.4.0 → 0.6.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;
@@ -43,6 +43,8 @@
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; }
46
48
  aside { width: 0; transition: width .15s ease; overflow: hidden; border-left: 1px solid var(--line); background: var(--panel); }
47
49
  aside.open { width: 460px; }
48
50
  .panel { width: 460px; padding: 16px 18px; }
@@ -126,10 +128,10 @@ function render() {
126
128
  const grades = DATA.columns.map((col) => gradeColumn(col, DATA.shipBar, DATA.critical));
127
129
  const queue = [];
128
130
  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;
131
+ // A suspect cell in a red column can't be re-judged from here (/rejudge 400s
132
+ // for an unscored baseline) — don't queue it. The matrix below still shows
133
+ // suspect badges for every column regardless of mode.
134
+ if (col.mode && col.mode !== "green" && col.mode !== "force") return;
133
135
  for (const scn of DATA.scenarios) {
134
136
  const cell = col.cells[scn.id];
135
137
  if (cell && cell.suspect && !cell.override) queue.push({ col: col.index, id: scn.id, label: col.label, reason: cell.judge_reason });
@@ -150,8 +152,8 @@ function render() {
150
152
  DATA.columns.forEach((col, i) => {
151
153
  const g = grades[i];
152
154
  let gradeHtml;
153
- if (col.mode && col.mode !== "green") {
154
- // Only green runs are scored; a red/force column has no ship grade.
155
+ if (col.mode && col.mode !== "green" && col.mode !== "force") {
156
+ // Green and force are scored; a red baseline column has no ship grade.
155
157
  gradeHtml = `<span class='badge no'>not scored (${escapeHtml(col.mode)})</span>`;
156
158
  if (g.suspect > 0) gradeHtml += ` — ${g.suspect} suspect`;
157
159
  } else {
@@ -220,7 +222,15 @@ function render() {
220
222
  : lc === "regressed"
221
223
  ? `<span class='lm down' title='red baseline PASS → ${effective(cell)} with the skill'>↓ skill</span>`
222
224
  : "";
223
- html += `<td class='cell ${v}${sel}' data-col='${col.index}' data-id='${scn.id}'>${v}${ov}${suspectBadge}${liftMark}${reps}</td>`;
225
+ // Run-over-run marker. Only on cells that actually flipped, and deliberately
226
+ // NOT folded into the flakiness number beside it: `flaky 0.00` is a within-run
227
+ // measure, and the case this exists for is a cell that was unanimous in every
228
+ // run and still landed on a different side each time.
229
+ const st = cell.stability;
230
+ const stabMark = st
231
+ ? `<span class='lm flip' title='${escapeHtml(st.note)}'>⇄ ${st.flips}/${st.compared}</span>`
232
+ : "";
233
+ html += `<td class='cell ${v}${sel}' data-col='${col.index}' data-id='${scn.id}'>${v}${ov}${suspectBadge}${liftMark}${stabMark}${reps}</td>`;
224
234
  });
225
235
  html += "</tr>";
226
236
  }
@@ -244,9 +254,10 @@ async function openPanel(colIndex, scenarioId) {
244
254
  panel.innerHTML = `
245
255
  <button class="close" id="closeBtn">×</button>
246
256
  <h2>${scn.id} · ${escapeHtml(scn.title)}</h2>
247
- <div class="meta">${escapeHtml(col.label)} · judge ${escapeHtml(col.judge.provider + ":" + col.judge.model)}</div>
257
+ <div class="meta">${escapeHtml(col.label)} · mode ${escapeHtml(col.mode || "green")} · judge ${escapeHtml(col.judge.provider + ":" + col.judge.model)}</div>
248
258
  <div class="reason"><b>judge:</b> ${cell.judge_verdict} — ${escapeHtml(cell.judge_reason || "(no reason)")}</div>
249
259
  ${cell.suspect ? `<div class="reason" style="color:#b45309"><b>⚠ suspect:</b> judge listed no failed item — re-judge before trusting this FAIL</div>` : ""}
260
+ ${cell.stability ? `<div class="reason" style="color:#b45309"><b>⇄ boundary cell:</b> ${escapeHtml(cell.stability.note)}</div>` : ""}
250
261
  <div class="toggle">
251
262
  <button data-v="PASS" class="PASS ${cell.override === 'PASS' ? 'active PASS' : ''}">PASS</button>
252
263
  <button data-v="FAIL" class="FAIL ${cell.override === 'FAIL' ? 'active FAIL' : ''}">FAIL</button>
@@ -358,7 +369,12 @@ function renderTrends(data) {
358
369
  const badge = last.ship ? "<span class='badge ship'>SHIP</span>" : "<span class='badge no'>NOT READY</span>";
359
370
  const trunc = m.truncated ? ` <span class='dim'>(last ${m.runs.length})</span>` : "";
360
371
  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>`;
372
+ // The mode is part of the series identity, not decoration: a tag that moved
373
+ // from green to force delivery has TWO series here, and the same skill text
374
+ // scores differently under each — so an unlabelled pair of sparklines would
375
+ // read as one history that jumped.
376
+ const modeNote = m.mode ? ` <span class='dim'>${escapeHtml(m.mode)}</span>` : "";
377
+ html += `<div class="tmodel"><div class="tmodel-h">${escapeHtml(m.model)}${modeNote} — ${sparkline(m.runs)} ${last.letter} (${last.pct}%) ${badge}${trunc}${skippedNote}</div>`;
362
378
  html += "<table class='tgrid'><thead><tr><th></th>";
363
379
  for (const run of m.runs) html += `<th title="${escapeHtml(run.label || run.timestamp)}">${escapeHtml((run.label || run.timestamp).slice(0, 8))}</th>`;
364
380
  html += "</tr></thead><tbody>";
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
@@ -3,7 +3,7 @@ import { readFileSync, existsSync, appendFileSync, mkdirSync, writeFileSync, mkd
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
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, 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";
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";
@@ -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);
@@ -181,7 +183,11 @@ export async function cmdRun(args) {
181
183
  // any earlier run — the tag dir (<harness>-<modelslug>) is the join key.
182
184
  const tag = basename(dirname(summary.runDir));
183
185
  const lift = collectLift(skill.dir).find((l) => l.tag === tag);
184
- console.log("\n" + formatScorecard(summary, lift) + "\n");
186
+ // Stability is derived from history INCLUDING the run just written, and scoped to
187
+ // this tag + mode: another model's flips under this model's scorecard would be a
188
+ // worse error than not reporting them at all.
189
+ const stability = collectStability(skill.dir).filter((c) => c.tag === tag && c.mode === summary.results.mode);
190
+ console.log("\n" + formatScorecard(summary, lift, stability) + "\n");
185
191
  }
186
192
  }
187
193
  console.log(`\nReview interactively: skill-harness review ${skills[0]?.name ?? "<skill>"} --skills ${root}`);
@@ -300,6 +306,68 @@ export async function cmdRegate(args, adapterOverride) {
300
306
  // flipped reps, which it must not spend silently.
301
307
  console.log(`\n${runDirs.length} run(s) re-gated, ${moved} verdict(s) moved, ${calls} judge call(s) (no model re-runs).`);
302
308
  }
309
+ /**
310
+ * Run-over-run verdict stability, derived from committed results. Free and offline: it
311
+ * reads results.yaml files and computes — no model, no judge, no harness.
312
+ *
313
+ * Exits 0 whatever it finds. A boundary cell is not a defect in the skill or in the
314
+ * spec; it is a statement about how much one run of that cell is worth. Making it a
315
+ * gate would turn "this needs more reps" into "your build is broken".
316
+ */
317
+ async function cmdStability(args) {
318
+ const root = flagStr(args, "skills", process.cwd());
319
+ const target = args._[0] ?? "all";
320
+ const windowRaw = flagStr(args, "window");
321
+ const window = windowRaw ? Number(windowRaw) : undefined;
322
+ if (windowRaw !== undefined && (!Number.isInteger(window) || window < 2)) {
323
+ throw new Error(`--window must be an integer >= 2 (got \`${windowRaw}\`) — one run has no run-over-run step`);
324
+ }
325
+ const showAll = flagBool(args, "all");
326
+ const skills = target === "all" ? discover(root).filter((s) => s.hasSpec) : [resolveSkill(root, target)];
327
+ if (skills.length === 0)
328
+ throw new Error(`no skills with a spec under ${root}`);
329
+ let boundaries = 0;
330
+ for (const skill of skills) {
331
+ const all = collectStability(skill.dir, { window });
332
+ if (all.length === 0) {
333
+ console.log(`\n${skill.name}: no scored runs yet — stability needs at least two runs of the same skill × model × mode`);
334
+ continue;
335
+ }
336
+ // One block per model tag × delivery mode: green and force are different
337
+ // deliveries of the same text, so their histories are never one series.
338
+ const groups = new Map();
339
+ for (const s of all) {
340
+ const key = `${s.tag} · mode=${s.mode}`;
341
+ (groups.get(key) ?? groups.set(key, []).get(key)).push(s);
342
+ }
343
+ console.log(`\n── ${skill.name} ──`);
344
+ for (const [key, cells] of groups) {
345
+ const runs = Math.max(...cells.map((c) => c.points.length));
346
+ console.log(` ${key} (${runs} run(s) in the window)`);
347
+ const boundary = boundaryCells(cells);
348
+ boundaries += boundary.length;
349
+ for (const s of boundary) {
350
+ console.log(` ⇄ ${s.critical ? "CRITICAL " : ""}${stabilityNote(s)}`);
351
+ }
352
+ if (showAll) {
353
+ for (const s of cells) {
354
+ if (s.state !== "boundary")
355
+ console.log(` ${s.state === "stable" ? "=" : "?"} ${stabilityNote(s)}`);
356
+ }
357
+ }
358
+ else {
359
+ const stable = cells.filter((c) => c.state === "stable").length;
360
+ const unmeasured = cells.filter((c) => c.state === "unmeasured").length;
361
+ console.log(` ${stable} held their verdict · ${unmeasured} with no comparable step (--all to list them)`);
362
+ }
363
+ }
364
+ }
365
+ console.log(`\n${boundaries} boundary cell(s). ${PATH_LEGEND}`);
366
+ if (boundaries > 0) {
367
+ console.log(`A boundary cell is worth re-running with more reps (--reps) before you trust one run of it;`);
368
+ console.log(`within-run flakiness cannot see this, because it only ever looks at one run.`);
369
+ }
370
+ }
303
371
  async function cmdReview(args) {
304
372
  const root = flagStr(args, "skills", process.cwd());
305
373
  const target = args._[0];
@@ -433,7 +501,15 @@ export async function cmdSuggest(args, adapterOverride) {
433
501
  rmSync(cwd, { recursive: true, force: true });
434
502
  }
435
503
  }
436
- /** Exit-code contract: 0 = clean (no findings), 1 = >=1 finding, or a resolution error (unknown skill/root, no skills with a spec). */
504
+ /**
505
+ * Exit-code contract: 0 = no gate-failing findings, 1 = >=1 of them, or a resolution
506
+ * error (unknown skill/root, no skills with a spec).
507
+ *
508
+ * `info` findings (run-over-run stability notes) print and annotate but never fail the
509
+ * gate: a boundary cell says how much one run of a scenario is worth, which is not a
510
+ * defect in the spec, the fixtures or the results. A linter that reddens CI for it would
511
+ * teach everyone to stop reading it.
512
+ */
437
513
  export async function cmdLint(args) {
438
514
  const root = flagStr(args, "skills", process.cwd());
439
515
  const target = args._[0] ?? "all";
@@ -464,18 +540,20 @@ export async function cmdLint(args) {
464
540
  f = [{ skill: dir, code: "lint-error", message: e instanceof Error ? e.message : String(e) }];
465
541
  }
466
542
  findings.push(...f);
467
- if (f.length === 0)
543
+ if (f.filter(failsGate).length === 0)
468
544
  console.log(`✓ ${dir}`);
469
- else
470
- for (const x of f) {
471
- const where = x.scenario ? `${dir}/${x.scenario}` : dir; // dir-based label, consistent with the ✓ line
472
- console.log(`✗ ${where}: ${x.code} — ${x.message}`);
473
- if (gha)
474
- console.log(`::error title=skill-harness::${where}: ${x.code} — ${x.message}`);
475
- }
545
+ for (const x of f) {
546
+ const where = x.scenario ? `${dir}/${x.scenario}` : dir; // dir-based label, consistent with the ✓ line
547
+ const fails = failsGate(x);
548
+ console.log(`${fails ? "✗" : "ℹ"} ${where}: ${x.code} — ${x.message}`);
549
+ if (gha)
550
+ console.log(`::${fails ? "error" : "notice"} title=skill-harness::${where}: ${x.code} — ${x.message}`);
551
+ }
476
552
  }
477
- console.log(`\n${skillDirs.length} skill(s), ${findings.length} finding(s)`);
478
- process.exitCode = findings.length > 0 ? 1 : 0;
553
+ const gating = findings.filter(failsGate).length;
554
+ const notes = findings.length - gating;
555
+ console.log(`\n${skillDirs.length} skill(s), ${gating} finding(s)${notes > 0 ? `, ${notes} note(s) (do not fail the gate)` : ""}`);
556
+ process.exitCode = gating > 0 ? 1 : 0;
479
557
  }
480
558
  // ---------------------------------------------------------------- dispatch
481
559
  /**
@@ -491,9 +569,11 @@ export function help() {
491
569
 
492
570
  run <skill|all> --skills <root> [--model prov:model ...] [--models file] [--only A1,D2]
493
571
  [--mode red|green|force] [--judge prov:model] [--harness pi] [--label name] [--parallel N] [--reps N] [--pass-threshold T]
572
+ [--canary] green only: spend ONE probe proving the skill reached the model, and abort the run if it did not
494
573
  grade <run-dir> [--judge prov:model] [--suspect-only] re-grade saved transcripts (neutral judge)
495
574
  rescore <run-dir>... re-score saved reps vs current spec thresholds (free)
496
575
  regate <run-dir>... [--judge prov:model] re-evaluate diff needles against the saved diffs (free; judges only reps whose gate flipped)
576
+ stability <skill|all> --skills <root> [--window N] [--all] run-over-run verdict flips per scenario (free, offline)
497
577
  review <skill> --skills <root> [--port N] serve the interactive review UI
498
578
  add-test <skill> --skills <root> --id ID --title T --turn ... --check ... [--critical] [--mode seeded --fixture path]
499
579
  init <skill> --skills <root> [--force] scaffold a commented template spec (free, offline)
@@ -504,6 +584,9 @@ export function help() {
504
584
  version print ${HARNESS_VERSION} and exit (also --version / -v)
505
585
 
506
586
  defaults: model=${DEFAULT_MODEL} judge=${defaultJudge()} mode=green harness=pi
587
+ green and force are both scored; red is the unscored baseline. green delivery depends on the
588
+ harness version (pi >= 0.83.0 discloses only the skill's description and loads the body on demand),
589
+ so --mode force is the delivery that cannot silently degrade — and --canary proves green per run.
507
590
  the judge default is Opus on your Claude subscription (\`claude-code\` → \`claude -p\`), not a
508
591
  metered API key. Set SKILL_HARNESS_JUDGE to change it for a repo or a shell; --judge wins over both.`;
509
592
  }
@@ -515,6 +598,7 @@ export async function main(argv) {
515
598
  case "grade": return cmdGrade(args);
516
599
  case "rescore": return cmdRescore(args);
517
600
  case "regate": return cmdRegate(args);
601
+ case "stability": return cmdStability(args);
518
602
  case "review": return cmdReview(args);
519
603
  case "add-test": return cmdAddTest(args);
520
604
  case "init": return cmdInit(args);
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.6.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.6.0",
48
+ "@skill-harness/adapters": "0.6.0"
49
49
  }
50
50
  }