@skill-harness/cli 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  The command surface for [skill-harness](https://github.com/mojomanyana/skill-harness) —
4
4
  a test/optimize loop for agent skills: run spec'd scenarios on the `pi` harness,
5
5
  LLM-judge each transcript, score against a ship bar, review, and re-run to
6
- measure a `SKILL.md` edit.
6
+ measure a `SKILL.md` edit. Subject, judge, and spec-drafting model calls all run
7
+ through Pi.
7
8
 
8
9
  Most users should install the `skill-harness` meta-package (`npm i -g
9
10
  skill-harness`) instead of this package directly.
@@ -22,8 +23,15 @@ npm i -g @skill-harness/cli
22
23
  | `skill-harness lint <skill\|all> --skills <root>` | validate specs/fixtures; CI gate |
23
24
  | `skill-harness run <skill\|all> --skills <root> [--model p:m ...] [--judge p:m]` | run scenarios, grade, score |
24
25
  | `skill-harness grade <run-dir> [--judge p:m]` | re-grade saved transcripts |
26
+ | `skill-harness rescore <run-dir>...` | re-apply current score thresholds offline |
27
+ | `skill-harness regate <run-dir>...` | re-evaluate saved objective gates |
28
+ | `skill-harness restamp <skill\|all> --skills <root> --from <ref>` | migrate matching model-visible digests |
29
+ | `skill-harness stability <skill\|all> --skills <root>` | report run-over-run verdict flips |
25
30
  | `skill-harness review <skill> --skills <root> [--port N]` | serve the interactive review UI |
26
31
  | `skill-harness add-test <skill> --skills <root> --id ID --title T ...` | scaffold a new scenario |
32
+ | `skill-harness init <skill> --skills <root>` | write an offline spec template |
33
+ | `skill-harness suggest <skill> --skills <root>` | draft a spec through Pi |
34
+ | `skill-harness coverage <skill\|all> --skills <root>` | report declared instruction coverage |
27
35
 
28
36
  ## More
29
37
 
package/dist/cli.d.ts CHANGED
@@ -22,7 +22,7 @@ export declare function releaseExitCode(summaries: Array<{
22
22
  };
23
23
  };
24
24
  }>): 0 | 1;
25
- export declare function cmdRun(args: Args): Promise<void>;
25
+ export declare function cmdRun(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
26
26
  export declare function cmdGrade(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
27
27
  /**
28
28
  * Re-evaluate needle gates against the saved staged diffs — no subject call. A rep
package/dist/cli.js CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, existsSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
3
- import { basename, dirname, join, resolve, relative } from "node:path";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { discover, resolveSkill, loadSpec, parseSpec, appendScenario, parseModelRef, runSkillModel, formatScorecard, readResults, regradeRun, lintSkill, failsGate, renderTemplateSpec, isTemplateSpec, renderDraftSpec, buildSuggestPrompt, parseSuggestDraft, rescoreRun, regateRun, specPathForRunDir, collectLift, collectStability, boundaryCells, stabilityNote, PATH_LEGEND, restampSkill, computeCoverage, formatCoverage, HARNESS_VERSION, defaultJudge, assertJudgeAllowed, assertNotDowngraded, downgradeWarning, isScoredMode, resolveArm, isFreeOfflineCommand, } from "@skill-harness/core";
5
+ import { discover, resolveSkill, loadSpec, parseSpec, appendScenario, parseModelRef, runSkillModel, formatScorecard, readResults, regradeRun, lintSkill, failsGate, renderTemplateSpec, isTemplateSpec, renderDraftSpec, buildSuggestPrompt, parseSuggestDraft, rescoreRun, regateRun, specPathForRunDir, collectLift, collectStability, boundaryCells, stabilityNote, PATH_LEGEND, restampSkill, computeCoverage, formatCoverage, HARNESS_VERSION, defaultJudge, recordedJudgeOrDefault, assertJudgeAllowed, assertNotDowngraded, downgradeWarning, isScoredMode, resolveArm, isFreeOfflineCommand, } from "@skill-harness/core";
6
6
  import { getAdapter } from "@skill-harness/adapters";
7
7
  import { serveReview } from "./serve.js";
8
8
  const DEFAULT_MODEL = "fireworks:accounts/fireworks/models/deepseek-v4-pro";
9
9
  // The judge default lives in core (`defaultJudge()`), which resolves
10
10
  // SKILL_HARNESS_JUDGE over a baked value — it was duplicated in three places
11
11
  // before, and the pi extension's copy could disagree with this one.
12
- const DEFAULT_SUGGEST_MODEL = "claude-code:claude-opus-4-8";
12
+ const DEFAULT_SUGGEST_MODEL = "openai-codex:gpt-5.6-sol";
13
13
  const REPEATABLE = new Set(["model", "turn", "check"]);
14
14
  function parseArgs(argv) {
15
15
  const _ = [];
@@ -82,6 +82,20 @@ function resolveModels(args) {
82
82
  function nowIso() {
83
83
  return new Date().toISOString();
84
84
  }
85
+ function roots(args) {
86
+ return {
87
+ skills: flagStr(args, "skills", process.cwd()),
88
+ specs: flagStr(args, "specs", process.env.SKILL_HARNESS_SPECS) || undefined,
89
+ };
90
+ }
91
+ function discoverUsable(skillsRoot, specsRoot) {
92
+ const skills = discover(skillsRoot, specsRoot);
93
+ const duplicate = skills.find((skill) => skill.specSource === "both");
94
+ if (duplicate) {
95
+ throw new Error(`skill \`${duplicate.name}\` has a specification.yaml in both --skills and --specs; remove one (specs are not merged)`);
96
+ }
97
+ return skills;
98
+ }
85
99
  /** Parse the run's reps + pass-threshold flags. Throws on an invalid provided value. */
86
100
  export function parseRunTuning(args) {
87
101
  let reps = 1;
@@ -104,31 +118,41 @@ export function parseRunTuning(args) {
104
118
  }
105
119
  // ---------------------------------------------------------------- commands
106
120
  async function cmdList(args) {
107
- const root = flagStr(args, "skills", process.cwd());
108
- const skills = discover(root);
121
+ const { skills: root, specs } = roots(args);
122
+ const skills = discover(root, specs);
123
+ let duplicate = false;
109
124
  console.log(`skills under ${root}:`);
110
125
  for (const s of skills) {
126
+ if (s.specSource === "both") {
127
+ duplicate = true;
128
+ console.log(` ✗ ${s.name} INVALID: specification.yaml exists in both --skills and --specs (specs are not merged)`);
129
+ continue;
130
+ }
111
131
  if (!s.hasSpec) {
112
- console.log(` ○ ${s.name} (no spec)`);
132
+ const localOnly = specs && s.specSource === "skills" ? "; spec found under --skills" : "";
133
+ console.log(` ○ ${s.name} (no spec${localOnly})`);
113
134
  continue;
114
135
  }
115
136
  try {
116
137
  const spec = loadSpec(s.specPath);
117
138
  const seeded = spec.scenarios.filter((x) => x.mode === "seeded").length;
118
139
  const seededNote = seeded ? `, ${seeded} seeded` : "";
119
- console.log(` ● ${s.name} (${spec.scenarios.length} scenarios${seededNote})`);
140
+ const sourceNote = specs ? `; spec: --${s.specSource}` : "";
141
+ console.log(` ● ${s.name} (${spec.scenarios.length} scenarios${seededNote}${sourceNote})`);
120
142
  }
121
143
  catch (e) {
122
144
  console.log(` ✗ ${s.name} INVALID: ${e instanceof Error ? e.message : e}`);
123
145
  }
124
146
  }
125
147
  console.log(`\n● = testable · ○ = no spec yet · ✗ = spec present but invalid`);
148
+ if (duplicate)
149
+ process.exitCode = 1;
126
150
  }
127
151
  export function releaseExitCode(summaries) {
128
152
  return summaries.some(({ results }) => isScoredMode(results.mode) && !results.partial && !results.effective_grade.ship) ? 1 : 0;
129
153
  }
130
- export async function cmdRun(args) {
131
- const root = flagStr(args, "skills", process.cwd());
154
+ export async function cmdRun(args, adapterOverride) {
155
+ const { skills: root, specs } = roots(args);
132
156
  const target = args._[0];
133
157
  if (!target)
134
158
  throw new Error("usage: skill-harness run <skill|all> --skills <root>");
@@ -150,7 +174,7 @@ export async function cmdRun(args) {
150
174
  const onlyRaw = flagStr(args, "only");
151
175
  const only = onlyRaw ? onlyRaw.split(",").map((x) => x.trim()).filter(Boolean) : undefined;
152
176
  const harnessName = flagStr(args, "harness", "pi");
153
- const adapter = getAdapter(harnessName);
177
+ const adapter = adapterOverride ?? getAdapter(harnessName);
154
178
  if (!(await adapter.available()))
155
179
  throw new Error(`harness \`${harnessName}\` is not on PATH`);
156
180
  const mode = flagStr(args, "mode", "green") || "green";
@@ -165,8 +189,8 @@ export async function cmdRun(args) {
165
189
  const armName = flagStr(args, "arm") || null;
166
190
  const arm = resolveArm(root, armName);
167
191
  const skills = target === "all"
168
- ? discover(root).filter((s) => s.hasSpec)
169
- : [resolveSkill(root, target)];
192
+ ? discoverUsable(root, specs).filter((s) => s.hasSpec)
193
+ : [resolveSkill(root, target, specs)];
170
194
  const summaries = [];
171
195
  for (const skill of skills) {
172
196
  if (!skill.hasSpec) {
@@ -175,7 +199,7 @@ export async function cmdRun(args) {
175
199
  }
176
200
  // A run from an older tool than the records already here would produce numbers
177
201
  // that look comparable and are not. Checked per skill, before its first token.
178
- assertNotDowngraded(skill.dir, "run");
202
+ assertNotDowngraded(dirname(skill.testsDir), "run");
179
203
  const spec = loadSpec(skill.specPath);
180
204
  for (const token of modelTokens) {
181
205
  const model = parseModelRef(token);
@@ -187,6 +211,8 @@ export async function cmdRun(args) {
187
211
  spec,
188
212
  skillDir: skill.dir,
189
213
  specPath: skill.specPath,
214
+ testsDir: skill.testsDir,
215
+ recordSourceRoots: skill.specSource === "specs",
190
216
  adapter,
191
217
  model,
192
218
  modelToken: token,
@@ -208,15 +234,16 @@ export async function cmdRun(args) {
208
234
  // Lift is derived from what's on disk, so it picks up a red baseline from
209
235
  // any earlier run — the tag dir (<harness>-<modelslug>) is the join key.
210
236
  const tag = basename(dirname(summary.runDir));
211
- const lift = collectLift(skill.dir).find((l) => l.tag === tag);
237
+ const resultsSkillDir = dirname(skill.testsDir);
238
+ const lift = collectLift(resultsSkillDir).find((l) => l.tag === tag);
212
239
  // Stability is derived from history INCLUDING the run just written, and scoped to
213
240
  // this tag + mode: another model's flips under this model's scorecard would be a
214
241
  // worse error than not reporting them at all.
215
- const stability = collectStability(skill.dir).filter((c) => c.tag === tag && c.mode === summary.results.mode);
242
+ const stability = collectStability(resultsSkillDir).filter((c) => c.tag === tag && c.mode === summary.results.mode);
216
243
  console.log("\n" + formatScorecard(summary, lift, stability) + "\n");
217
244
  }
218
245
  }
219
- console.log(`\nReview interactively: skill-harness review ${skills[0]?.name ?? "<skill>"} --skills ${root}`);
246
+ console.log(`\nReview interactively: skill-harness review ${skills[0]?.name ?? "<skill>"} --skills ${root}${specs ? ` --specs ${specs}` : ""}`);
220
247
  // A full delivered run is a release gate. NOT READY — including one critical
221
248
  // failure hidden by a high aggregate — must be machine-visible to CI. Red
222
249
  // baselines and partial branch feedback are deliberately excluded.
@@ -236,14 +263,17 @@ export async function cmdGrade(args, adapterOverride) {
236
263
  const spec = loadSpec(specPath);
237
264
  const prev = existsSync(join(runDir, "results.yaml")) ? readResults(runDir) : null;
238
265
  // Re-judge with the run's RECORDED judge + harness (parity with /rejudge) —
239
- // an explicit --judge flag still wins; with no prior results, fall back to
240
- // the CLI default.
266
+ // an explicit --judge flag still wins; absent or retired recorded judges use
267
+ // the current Pi default.
241
268
  const judgeFlag = flagStr(args, "judge");
242
- const judge = judgeFlag ? parseModelRef(judgeFlag) : (prev?.judge ?? parseModelRef(defaultJudge()));
243
- // A regrade reuses the judge the run RECORDED, so a run that names a metered judge
244
- // bills on every later regrade with no flag typed anywhere. Latent rather than live
245
- // in the reference corpus (all ~140 committed runs there record `claude-code`), but
246
- // it is the one path where the cost decision was made by a file, not a person.
269
+ const resolvedRecordedJudge = judgeFlag ? undefined : recordedJudgeOrDefault(prev?.judge);
270
+ const judge = judgeFlag ? parseModelRef(judgeFlag) : resolvedRecordedJudge.judge;
271
+ if (resolvedRecordedJudge?.migratedFrom) {
272
+ console.error(`skill-harness: recorded judge ${resolvedRecordedJudge.migratedFrom.provider}:${resolvedRecordedJudge.migratedFrom.model} was removed; re-judging through Pi with ${judge.provider}:${judge.model}`);
273
+ }
274
+ // A regrade normally reuses the judge the run RECORDED, so a run that names a
275
+ // metered judge bills on every later regrade with no flag typed anywhere. It is the one path
276
+ // where the cost decision was made by a file, not a person.
247
277
  assertJudgeAllowed(judge, {
248
278
  source: judgeFlag ? "--judge" : prev?.judge ? "the run's recorded judge" : "the default judge",
249
279
  allowMetered: flagBool(args, "allow-metered-judge"),
@@ -312,7 +342,11 @@ export async function cmdRegate(args, adapterOverride) {
312
342
  const specPath = specPathForRunDir(runDir);
313
343
  const spec = loadSpec(specPath);
314
344
  const prev = readResults(runDir);
315
- const judge = judgeFlag ? parseModelRef(judgeFlag) : (prev.judge ?? parseModelRef(defaultJudge()));
345
+ const resolvedRecordedJudge = judgeFlag ? undefined : recordedJudgeOrDefault(prev.judge);
346
+ const judge = judgeFlag ? parseModelRef(judgeFlag) : resolvedRecordedJudge.judge;
347
+ if (resolvedRecordedJudge?.migratedFrom) {
348
+ console.error(`skill-harness: recorded judge ${resolvedRecordedJudge.migratedFrom.provider}:${resolvedRecordedJudge.migratedFrom.model} was removed; re-judging through Pi with ${judge.provider}:${judge.model}`);
349
+ }
316
350
  assertJudgeAllowed(judge, {
317
351
  source: judgeFlag ? "--judge" : "the run's recorded judge",
318
352
  allowMetered: flagBool(args, "allow-metered-judge"),
@@ -356,9 +390,9 @@ export async function cmdRegate(args, adapterOverride) {
356
390
  * inventing freshness is the one thing this gate must never do.
357
391
  */
358
392
  async function cmdRestamp(args) {
359
- const root = flagStr(args, "skills", process.cwd());
393
+ const { skills: root, specs } = roots(args);
360
394
  const target = args._[0] ?? "all";
361
- const skills = target === "all" ? discover(root).filter((s) => s.hasSpec) : [resolveSkill(root, target)];
395
+ const skills = target === "all" ? discoverUsable(root, specs).filter((s) => s.hasSpec) : [resolveSkill(root, target, specs)];
362
396
  if (skills.length === 0)
363
397
  throw new Error(`no skills with a spec under ${root}`);
364
398
  let runs = 0;
@@ -367,7 +401,7 @@ async function cmdRestamp(args) {
367
401
  let unchanged = 0;
368
402
  let partial = 0;
369
403
  for (const skill of skills) {
370
- const r = restampSkill(skill.dir, { from: flagStr(args, "from") });
404
+ const r = restampSkill(skill.dir, { from: flagStr(args, "from"), testsDir: skill.testsDir });
371
405
  runs += r.runs;
372
406
  upgraded += r.upgraded;
373
407
  unprovable += r.unprovable;
@@ -389,7 +423,7 @@ async function cmdRestamp(args) {
389
423
  `${partial > 0 ? ` (${partial} upgraded only in part.)` : ""} No models were called.`);
390
424
  }
391
425
  async function cmdStability(args) {
392
- const root = flagStr(args, "skills", process.cwd());
426
+ const { skills: root, specs } = roots(args);
393
427
  const target = args._[0] ?? "all";
394
428
  const windowRaw = flagStr(args, "window");
395
429
  const window = windowRaw ? Number(windowRaw) : undefined;
@@ -397,12 +431,12 @@ async function cmdStability(args) {
397
431
  throw new Error(`--window must be an integer >= 2 (got \`${windowRaw}\`) — one run has no run-over-run step`);
398
432
  }
399
433
  const showAll = flagBool(args, "all");
400
- const skills = target === "all" ? discover(root).filter((s) => s.hasSpec) : [resolveSkill(root, target)];
434
+ const skills = target === "all" ? discoverUsable(root, specs).filter((s) => s.hasSpec) : [resolveSkill(root, target, specs)];
401
435
  if (skills.length === 0)
402
436
  throw new Error(`no skills with a spec under ${root}`);
403
437
  let boundaries = 0;
404
438
  for (const skill of skills) {
405
- const all = collectStability(skill.dir, { window });
439
+ const all = collectStability(dirname(skill.testsDir), { window });
406
440
  if (all.length === 0) {
407
441
  console.log(`\n${skill.name}: no scored runs yet — stability needs at least two runs of the same skill × model × mode`);
408
442
  continue;
@@ -443,20 +477,20 @@ async function cmdStability(args) {
443
477
  }
444
478
  }
445
479
  async function cmdReview(args) {
446
- const root = flagStr(args, "skills", process.cwd());
480
+ const { skills: root, specs } = roots(args);
447
481
  const target = args._[0];
448
482
  if (!target)
449
483
  throw new Error("usage: skill-harness review <skill> --skills <root>");
450
- const skill = resolveSkill(root, target);
484
+ const skill = resolveSkill(root, target, specs);
451
485
  const port = Number(flagStr(args, "port", "0")) || 0;
452
- await serveReview({ skillDir: skill.dir, skillName: skill.name, port });
486
+ await serveReview({ skillDir: dirname(skill.testsDir), skillName: skill.name, port });
453
487
  }
454
488
  async function cmdAddTest(args) {
455
- const root = flagStr(args, "skills", process.cwd());
489
+ const { skills: root, specs } = roots(args);
456
490
  const target = args._[0];
457
491
  if (!target)
458
492
  throw new Error("usage: skill-harness add-test <skill> --skills <root> --id ... --title ... --turn ... --check ...");
459
- const skill = resolveSkill(root, target);
493
+ const skill = resolveSkill(root, target, specs);
460
494
  if (!skill.hasSpec)
461
495
  throw new Error(`${target} has no spec yet — create tests/specification.yaml first`);
462
496
  const id = flagStr(args, "id");
@@ -490,12 +524,12 @@ async function cmdAddTest(args) {
490
524
  * reddens CI for it teaches people to add a token `covers:` to silence it.
491
525
  */
492
526
  async function cmdCoverage(args) {
493
- const root = flagStr(args, "skills", process.cwd());
527
+ const { skills: root, specs } = roots(args);
494
528
  const target = args._[0];
495
529
  if (!target)
496
530
  throw new Error("usage: skill-harness coverage <skill|all> --skills <root> [--strict]");
497
531
  const strict = flagBool(args, "strict");
498
- const skills = target === "all" ? discover(root).filter((s) => s.hasSpec) : [resolveSkill(root, target)];
532
+ const skills = target === "all" ? discoverUsable(root, specs).filter((s) => s.hasSpec) : [resolveSkill(root, target, specs)];
499
533
  let anyUncovered = false;
500
534
  let anyBroken = false;
501
535
  for (const skill of skills) {
@@ -503,13 +537,13 @@ async function cmdCoverage(args) {
503
537
  continue;
504
538
  const spec = loadSpec(skill.specPath);
505
539
  const specDir = dirname(skill.specPath);
540
+ const skillRef = "../SKILL.md";
506
541
  const report = computeCoverage({
507
542
  specDir,
508
543
  scenarios: spec.scenarios,
509
- // SKILL.md lives one level above tests/, and is the file `covers` almost
510
- // always points at, so report on it even when nothing references it —
511
- // otherwise a skill with zero `covers` reports 0 sections and looks fine.
512
- baseFiles: [relative(specDir, join(skill.dir, "SKILL.md")).split("\\").join("/")],
544
+ // Keep the spec-facing path stable even when SKILL.md lives in another repo.
545
+ baseFiles: [skillRef],
546
+ fileOverrides: { [skillRef]: join(skill.dir, "SKILL.md") },
513
547
  });
514
548
  console.log(formatCoverage(report, spec.skill));
515
549
  if (report.uncovered.length)
@@ -536,11 +570,11 @@ function writeSpecFile(specPath, text) {
536
570
  writeFileSync(specPath, text, "utf8");
537
571
  }
538
572
  export async function cmdInit(args) {
539
- const root = flagStr(args, "skills", process.cwd());
573
+ const { skills: root, specs } = roots(args);
540
574
  const target = args._[0];
541
575
  if (!target)
542
576
  throw new Error("usage: skill-harness init <skill> --skills <root> [--force]");
543
- const skill = resolveSkill(root, target);
577
+ const skill = resolveSkill(root, target, specs);
544
578
  const force = flagStr(args, "force") !== undefined;
545
579
  if (skill.hasSpec && !force) {
546
580
  throw new Error(`${skill.specPath} exists — edit it, or pass --force to overwrite`);
@@ -551,14 +585,14 @@ export async function cmdInit(args) {
551
585
  console.log(`wrote template ${skill.specPath} — fill it in, or run \`skill-harness suggest ${skill.name}\` to LLM-draft it.`);
552
586
  }
553
587
  export async function cmdSuggest(args, adapterOverride) {
554
- const root = flagStr(args, "skills", process.cwd());
588
+ const { skills: root, specs } = roots(args);
555
589
  const target = args._[0];
556
590
  if (!target)
557
591
  throw new Error("usage: skill-harness suggest <skill> --skills <root> [--model prov:model] [--force]");
558
592
  // resolveSkill throws a SKILL.md-specific error when the directory exists but
559
593
  // lacks one, so we don't reimplement that check here; a resolved skill always
560
594
  // has a SKILL.md at skill.dir.
561
- const skill = resolveSkill(root, target);
595
+ const skill = resolveSkill(root, target, specs);
562
596
  const skillMd = readFileSync(join(skill.dir, "SKILL.md"), "utf8");
563
597
  // Overwrite without --force only when the target is absent or an *unedited*
564
598
  // template. A file that still carries the sentinel but no longer matches the
@@ -612,7 +646,7 @@ export async function cmdSuggest(args, adapterOverride) {
612
646
  }
613
647
  writeSpecFile(skill.specPath, text);
614
648
  console.log(`drafted ${count} scenario(s) → ${skill.specPath}`);
615
- console.log(`review it (especially the proposed critical set), then \`skill-harness run ${skill.name} --skills ${root}\``);
649
+ console.log(`review it (especially the proposed critical set), then \`skill-harness run ${skill.name} --skills ${root}${specs ? ` --specs ${specs}` : ""}\``);
616
650
  }
617
651
  finally {
618
652
  rmSync(cwd, { recursive: true, force: true });
@@ -628,30 +662,31 @@ export async function cmdSuggest(args, adapterOverride) {
628
662
  * teach everyone to stop reading it.
629
663
  */
630
664
  export async function cmdLint(args) {
631
- const root = flagStr(args, "skills", process.cwd());
665
+ const { skills: root, specs } = roots(args);
632
666
  const target = args._[0] ?? "all";
633
- let skillDirs;
667
+ let skills;
634
668
  try {
635
- skillDirs = target === "all"
636
- ? discover(root).filter((s) => s.hasSpec).map((s) => s.dir)
637
- : [resolveSkill(root, target).dir];
669
+ skills = target === "all"
670
+ ? discoverUsable(root, specs).filter((s) => s.hasSpec)
671
+ : [resolveSkill(root, target, specs)];
638
672
  }
639
673
  catch (e) {
640
674
  console.error(`error: ${e instanceof Error ? e.message : e}`);
641
675
  process.exitCode = 1;
642
676
  return;
643
677
  }
644
- if (skillDirs.length === 0) {
678
+ if (skills.length === 0) {
645
679
  console.error(`no skills with a spec under ${root}`);
646
680
  process.exitCode = 1;
647
681
  return;
648
682
  }
649
683
  const gha = process.env.GITHUB_ACTIONS === "true";
650
684
  const findings = [];
651
- for (const dir of skillDirs) {
685
+ for (const skill of skills) {
686
+ const dir = skill.dir;
652
687
  let f;
653
688
  try {
654
- f = lintSkill(dir);
689
+ f = lintSkill(dir, skill.testsDir);
655
690
  }
656
691
  catch (e) {
657
692
  f = [{ skill: dir, code: "lint-error", message: e instanceof Error ? e.message : String(e) }];
@@ -669,7 +704,7 @@ export async function cmdLint(args) {
669
704
  }
670
705
  const gating = findings.filter(failsGate).length;
671
706
  const notes = findings.length - gating;
672
- console.log(`\n${skillDirs.length} skill(s), ${gating} finding(s)${notes > 0 ? `, ${notes} note(s) (do not fail the gate)` : ""}`);
707
+ console.log(`\n${skills.length} skill(s), ${gating} finding(s)${notes > 0 ? `, ${notes} note(s) (do not fail the gate)` : ""}`);
673
708
  process.exitCode = gating > 0 ? 1 : 0;
674
709
  }
675
710
  // ---------------------------------------------------------------- dispatch
@@ -708,14 +743,17 @@ export function help() {
708
743
  lint <skill|all> --skills <root> validate specs/fixtures + results-consistency (${free("lint")}; CI gate; exits non-zero on findings)
709
744
  coverage <skill|all> --skills <root> [--strict] which instruction sections have a declared test (${free("coverage")})
710
745
 
746
+ Every command with --skills also accepts --specs <root> (default: SKILL_HARNESS_SPECS).
747
+ Skill text comes from --skills; test assets and results come from --specs.
748
+
711
749
  version print ${HARNESS_VERSION} and exit (also --version / -v)
712
750
 
713
751
  defaults: model=${DEFAULT_MODEL} judge=${defaultJudge()} mode=green harness=pi
714
752
  green and force are both scored; red is the unscored baseline. green delivery depends on the
715
753
  harness version (pi >= 0.83.0 discloses only the skill's description and loads the body on demand),
716
754
  so --mode force is the delivery that cannot silently degrade — and --canary proves green per run.
717
- the judge default is Opus on your Claude subscription (\`claude-code\` → \`claude -p\`), not a
718
- metered API key. Set SKILL_HARNESS_JUDGE to change it for a repo or a shell; --judge wins over both.`;
755
+ the judge default runs through Pi on your ChatGPT subscription (\`openai-codex\`), not a metered
756
+ API key. Set SKILL_HARNESS_JUDGE to change it for a repo or a shell; --judge wins over both.`;
719
757
  }
720
758
  export async function main(argv) {
721
759
  const cmd = argv[0];
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, rebuildScenarioResult, mergeScenarioMetrics, carryRepObjectives, 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, mergeScenarioMetrics, carryRepObjectives, envFlag, recordedJudgeOrDefault, assertJudgeAllowed, } 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/. */
@@ -126,6 +126,17 @@ export async function serveReview(opts) {
126
126
  res.end(JSON.stringify({ ok: false, error: `scenario ${body.scenarioId} is ${delivery?.status ?? "ERROR"}: delivery-gated evidence cannot be re-judged` }));
127
127
  return;
128
128
  }
129
+ const resolvedRecordedJudge = recordedJudgeOrDefault(results.judge);
130
+ try {
131
+ assertJudgeAllowed(resolvedRecordedJudge.judge, {
132
+ source: resolvedRecordedJudge.migratedFrom ? "the current default judge" : "the run's recorded judge",
133
+ });
134
+ }
135
+ catch (e) {
136
+ res.writeHead(400, { "content-type": "application/json" });
137
+ res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
138
+ return;
139
+ }
129
140
  const adapter = opts.adapter ?? getAdapter(results.harness);
130
141
  if (!(await adapter.available())) {
131
142
  res.writeHead(400, { "content-type": "application/json" });
@@ -135,7 +146,7 @@ export async function serveReview(opts) {
135
146
  const threshold = effectiveThreshold(prev, scenario);
136
147
  try {
137
148
  const rr = await regradeScenario({
138
- runDir: column.runDir, spec, scenario, adapter, judge: results.judge,
149
+ runDir: column.runDir, spec, scenario, adapter, judge: resolvedRecordedJudge.judge,
139
150
  specDir: dirname(specPath), threshold, mode: results.mode,
140
151
  expectedReps: prev.reps ?? 1,
141
152
  });
@@ -147,7 +158,7 @@ export async function serveReview(opts) {
147
158
  });
148
159
  const written = writeResults(column.runDir, {
149
160
  schema: results.schema, subject_invocations: results.subject_invocations,
150
- skill: results.skill, harness: results.harness, model: results.model, judge: results.judge,
161
+ skill: results.skill, harness: results.harness, model: results.model, judge: resolvedRecordedJudge.judge,
151
162
  timestamp: results.timestamp, label: results.label, mode: results.mode, scenarios: merged,
152
163
  partial: results.partial,
153
164
  // Provenance survives a UI re-judge, same as it does through `grade`.
@@ -164,12 +175,19 @@ export async function serveReview(opts) {
164
175
  // with the one `rubric:` key this re-judge actually applied refreshed —
165
176
  // the same doctrine `grade` follows (see refreshRubricHashes).
166
177
  source_hashes: refreshRubricHashes(results.source_hashes, spec, [body.scenarioId]),
178
+ source_hash_roots: results.source_hash_roots,
167
179
  }, scoreContextFor(results, spec));
168
180
  ensureResultsGitignore(join(opts.skillDir, "tests", "results"));
169
181
  const g = written.effective_grade;
170
182
  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 });
171
183
  res.writeHead(200, { "content-type": "application/json" });
172
- res.end(JSON.stringify({ ok: true, grade: g }));
184
+ res.end(JSON.stringify({
185
+ ok: true,
186
+ grade: g,
187
+ judgeMigration: resolvedRecordedJudge.migratedFrom
188
+ ? `${resolvedRecordedJudge.migratedFrom.provider}:${resolvedRecordedJudge.migratedFrom.model} → ${resolvedRecordedJudge.judge.provider}:${resolvedRecordedJudge.judge.model}`
189
+ : undefined,
190
+ }));
173
191
  }
174
192
  catch (e) {
175
193
  // regradeScenario (or the write/journal that follows) failed — surface the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/cli",
3
- "version": "0.19.0",
3
+ "version": "0.21.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": "node ../../scripts/release-pack.mjs --guard"
45
45
  },
46
46
  "dependencies": {
47
- "@skill-harness/core": "0.19.0",
48
- "@skill-harness/adapters": "0.19.0"
47
+ "@skill-harness/core": "0.21.0",
48
+ "@skill-harness/adapters": "0.21.0"
49
49
  }
50
50
  }