@skill-harness/cli 0.20.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/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,6 +1,6 @@
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
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";
@@ -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.
@@ -363,9 +390,9 @@ export async function cmdRegate(args, adapterOverride) {
363
390
  * inventing freshness is the one thing this gate must never do.
364
391
  */
365
392
  async function cmdRestamp(args) {
366
- const root = flagStr(args, "skills", process.cwd());
393
+ const { skills: root, specs } = roots(args);
367
394
  const target = args._[0] ?? "all";
368
- 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)];
369
396
  if (skills.length === 0)
370
397
  throw new Error(`no skills with a spec under ${root}`);
371
398
  let runs = 0;
@@ -374,7 +401,7 @@ async function cmdRestamp(args) {
374
401
  let unchanged = 0;
375
402
  let partial = 0;
376
403
  for (const skill of skills) {
377
- const r = restampSkill(skill.dir, { from: flagStr(args, "from") });
404
+ const r = restampSkill(skill.dir, { from: flagStr(args, "from"), testsDir: skill.testsDir });
378
405
  runs += r.runs;
379
406
  upgraded += r.upgraded;
380
407
  unprovable += r.unprovable;
@@ -396,7 +423,7 @@ async function cmdRestamp(args) {
396
423
  `${partial > 0 ? ` (${partial} upgraded only in part.)` : ""} No models were called.`);
397
424
  }
398
425
  async function cmdStability(args) {
399
- const root = flagStr(args, "skills", process.cwd());
426
+ const { skills: root, specs } = roots(args);
400
427
  const target = args._[0] ?? "all";
401
428
  const windowRaw = flagStr(args, "window");
402
429
  const window = windowRaw ? Number(windowRaw) : undefined;
@@ -404,12 +431,12 @@ async function cmdStability(args) {
404
431
  throw new Error(`--window must be an integer >= 2 (got \`${windowRaw}\`) — one run has no run-over-run step`);
405
432
  }
406
433
  const showAll = flagBool(args, "all");
407
- 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)];
408
435
  if (skills.length === 0)
409
436
  throw new Error(`no skills with a spec under ${root}`);
410
437
  let boundaries = 0;
411
438
  for (const skill of skills) {
412
- const all = collectStability(skill.dir, { window });
439
+ const all = collectStability(dirname(skill.testsDir), { window });
413
440
  if (all.length === 0) {
414
441
  console.log(`\n${skill.name}: no scored runs yet — stability needs at least two runs of the same skill × model × mode`);
415
442
  continue;
@@ -450,20 +477,20 @@ async function cmdStability(args) {
450
477
  }
451
478
  }
452
479
  async function cmdReview(args) {
453
- const root = flagStr(args, "skills", process.cwd());
480
+ const { skills: root, specs } = roots(args);
454
481
  const target = args._[0];
455
482
  if (!target)
456
483
  throw new Error("usage: skill-harness review <skill> --skills <root>");
457
- const skill = resolveSkill(root, target);
484
+ const skill = resolveSkill(root, target, specs);
458
485
  const port = Number(flagStr(args, "port", "0")) || 0;
459
- await serveReview({ skillDir: skill.dir, skillName: skill.name, port });
486
+ await serveReview({ skillDir: dirname(skill.testsDir), skillName: skill.name, port });
460
487
  }
461
488
  async function cmdAddTest(args) {
462
- const root = flagStr(args, "skills", process.cwd());
489
+ const { skills: root, specs } = roots(args);
463
490
  const target = args._[0];
464
491
  if (!target)
465
492
  throw new Error("usage: skill-harness add-test <skill> --skills <root> --id ... --title ... --turn ... --check ...");
466
- const skill = resolveSkill(root, target);
493
+ const skill = resolveSkill(root, target, specs);
467
494
  if (!skill.hasSpec)
468
495
  throw new Error(`${target} has no spec yet — create tests/specification.yaml first`);
469
496
  const id = flagStr(args, "id");
@@ -497,12 +524,12 @@ async function cmdAddTest(args) {
497
524
  * reddens CI for it teaches people to add a token `covers:` to silence it.
498
525
  */
499
526
  async function cmdCoverage(args) {
500
- const root = flagStr(args, "skills", process.cwd());
527
+ const { skills: root, specs } = roots(args);
501
528
  const target = args._[0];
502
529
  if (!target)
503
530
  throw new Error("usage: skill-harness coverage <skill|all> --skills <root> [--strict]");
504
531
  const strict = flagBool(args, "strict");
505
- 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)];
506
533
  let anyUncovered = false;
507
534
  let anyBroken = false;
508
535
  for (const skill of skills) {
@@ -510,13 +537,13 @@ async function cmdCoverage(args) {
510
537
  continue;
511
538
  const spec = loadSpec(skill.specPath);
512
539
  const specDir = dirname(skill.specPath);
540
+ const skillRef = "../SKILL.md";
513
541
  const report = computeCoverage({
514
542
  specDir,
515
543
  scenarios: spec.scenarios,
516
- // SKILL.md lives one level above tests/, and is the file `covers` almost
517
- // always points at, so report on it even when nothing references it —
518
- // otherwise a skill with zero `covers` reports 0 sections and looks fine.
519
- 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") },
520
547
  });
521
548
  console.log(formatCoverage(report, spec.skill));
522
549
  if (report.uncovered.length)
@@ -543,11 +570,11 @@ function writeSpecFile(specPath, text) {
543
570
  writeFileSync(specPath, text, "utf8");
544
571
  }
545
572
  export async function cmdInit(args) {
546
- const root = flagStr(args, "skills", process.cwd());
573
+ const { skills: root, specs } = roots(args);
547
574
  const target = args._[0];
548
575
  if (!target)
549
576
  throw new Error("usage: skill-harness init <skill> --skills <root> [--force]");
550
- const skill = resolveSkill(root, target);
577
+ const skill = resolveSkill(root, target, specs);
551
578
  const force = flagStr(args, "force") !== undefined;
552
579
  if (skill.hasSpec && !force) {
553
580
  throw new Error(`${skill.specPath} exists — edit it, or pass --force to overwrite`);
@@ -558,14 +585,14 @@ export async function cmdInit(args) {
558
585
  console.log(`wrote template ${skill.specPath} — fill it in, or run \`skill-harness suggest ${skill.name}\` to LLM-draft it.`);
559
586
  }
560
587
  export async function cmdSuggest(args, adapterOverride) {
561
- const root = flagStr(args, "skills", process.cwd());
588
+ const { skills: root, specs } = roots(args);
562
589
  const target = args._[0];
563
590
  if (!target)
564
591
  throw new Error("usage: skill-harness suggest <skill> --skills <root> [--model prov:model] [--force]");
565
592
  // resolveSkill throws a SKILL.md-specific error when the directory exists but
566
593
  // lacks one, so we don't reimplement that check here; a resolved skill always
567
594
  // has a SKILL.md at skill.dir.
568
- const skill = resolveSkill(root, target);
595
+ const skill = resolveSkill(root, target, specs);
569
596
  const skillMd = readFileSync(join(skill.dir, "SKILL.md"), "utf8");
570
597
  // Overwrite without --force only when the target is absent or an *unedited*
571
598
  // template. A file that still carries the sentinel but no longer matches the
@@ -619,7 +646,7 @@ export async function cmdSuggest(args, adapterOverride) {
619
646
  }
620
647
  writeSpecFile(skill.specPath, text);
621
648
  console.log(`drafted ${count} scenario(s) → ${skill.specPath}`);
622
- 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}` : ""}\``);
623
650
  }
624
651
  finally {
625
652
  rmSync(cwd, { recursive: true, force: true });
@@ -635,30 +662,31 @@ export async function cmdSuggest(args, adapterOverride) {
635
662
  * teach everyone to stop reading it.
636
663
  */
637
664
  export async function cmdLint(args) {
638
- const root = flagStr(args, "skills", process.cwd());
665
+ const { skills: root, specs } = roots(args);
639
666
  const target = args._[0] ?? "all";
640
- let skillDirs;
667
+ let skills;
641
668
  try {
642
- skillDirs = target === "all"
643
- ? discover(root).filter((s) => s.hasSpec).map((s) => s.dir)
644
- : [resolveSkill(root, target).dir];
669
+ skills = target === "all"
670
+ ? discoverUsable(root, specs).filter((s) => s.hasSpec)
671
+ : [resolveSkill(root, target, specs)];
645
672
  }
646
673
  catch (e) {
647
674
  console.error(`error: ${e instanceof Error ? e.message : e}`);
648
675
  process.exitCode = 1;
649
676
  return;
650
677
  }
651
- if (skillDirs.length === 0) {
678
+ if (skills.length === 0) {
652
679
  console.error(`no skills with a spec under ${root}`);
653
680
  process.exitCode = 1;
654
681
  return;
655
682
  }
656
683
  const gha = process.env.GITHUB_ACTIONS === "true";
657
684
  const findings = [];
658
- for (const dir of skillDirs) {
685
+ for (const skill of skills) {
686
+ const dir = skill.dir;
659
687
  let f;
660
688
  try {
661
- f = lintSkill(dir);
689
+ f = lintSkill(dir, skill.testsDir);
662
690
  }
663
691
  catch (e) {
664
692
  f = [{ skill: dir, code: "lint-error", message: e instanceof Error ? e.message : String(e) }];
@@ -676,7 +704,7 @@ export async function cmdLint(args) {
676
704
  }
677
705
  const gating = findings.filter(failsGate).length;
678
706
  const notes = findings.length - gating;
679
- 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)` : ""}`);
680
708
  process.exitCode = gating > 0 ? 1 : 0;
681
709
  }
682
710
  // ---------------------------------------------------------------- dispatch
@@ -715,6 +743,9 @@ export function help() {
715
743
  lint <skill|all> --skills <root> validate specs/fixtures + results-consistency (${free("lint")}; CI gate; exits non-zero on findings)
716
744
  coverage <skill|all> --skills <root> [--strict] which instruction sections have a declared test (${free("coverage")})
717
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
+
718
749
  version print ${HARNESS_VERSION} and exit (also --version / -v)
719
750
 
720
751
  defaults: model=${DEFAULT_MODEL} judge=${defaultJudge()} mode=green harness=pi
package/dist/serve.js CHANGED
@@ -175,6 +175,7 @@ export async function serveReview(opts) {
175
175
  // with the one `rubric:` key this re-judge actually applied refreshed —
176
176
  // the same doctrine `grade` follows (see refreshRubricHashes).
177
177
  source_hashes: refreshRubricHashes(results.source_hashes, spec, [body.scenarioId]),
178
+ source_hash_roots: results.source_hash_roots,
178
179
  }, scoreContextFor(results, spec));
179
180
  ensureResultsGitignore(join(opts.skillDir, "tests", "results"));
180
181
  const g = written.effective_grade;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/cli",
3
- "version": "0.20.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.20.0",
48
- "@skill-harness/adapters": "0.20.0"
47
+ "@skill-harness/core": "0.21.0",
48
+ "@skill-harness/adapters": "0.21.0"
49
49
  }
50
50
  }