@skill-harness/cli 0.29.0 → 0.31.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
@@ -9,7 +9,7 @@ through Pi.
9
9
  Most users should install the `skill-harness` meta-package (`npm i -g
10
10
  skill-harness`) instead of this package directly.
11
11
 
12
- Fresh subject execution requires **exactly Pi 1.0.4**. Different or unavailable
12
+ Fresh subject execution supports **Pi 1.0.4 and 1.1.0**. Other or unavailable
13
13
  versions produce execution ERROR before a subject call. Retained results remain
14
14
  readable; the historical Pi 0.83.0 raw parser is not current qualification.
15
15
 
@@ -19,12 +19,30 @@ readable; the historical Pi 0.83.0 raw parser is not current qualification.
19
19
  npm i -g @skill-harness/cli
20
20
  ```
21
21
 
22
+ ## Create a skill in Pi
23
+
24
+ Install the `skill-harness` Pi package, select a model, then use
25
+ `/skill-harness create <your idea>`. Authoring runs in Pi and asks one necessary
26
+ question at a time; the standalone CLI does not substitute a direct model API.
27
+ The workflow previews a skill, optional pi-daddy wrapper, and evals in the same
28
+ or a separate repository before applying files. Generated skills need only Pi;
29
+ the wrapper is optional and principal-pi-skills is not required.
30
+
31
+ The CLI supports the subsequent offline preflight, reviewed judge calibration,
32
+ scenario execution and saved-result diagnosis. See
33
+ [EVAL-AUTHORING.md](https://github.com/mojomanyana/skill-harness/blob/main/docs/EVAL-AUTHORING.md)
34
+ for the complete workflow and its evidence limits.
35
+
22
36
  ## Commands
23
37
 
24
38
  | command | does |
25
39
  |---|---|
26
40
  | `skill-harness list --skills <root>` | discovered skills + spec status |
27
41
  | `skill-harness lint <skill\|all> --skills <root>` | validate specs/fixtures; CI gate |
42
+ | `skill-harness preflight <skill\|all> --skills <root> [--specs <root>]` | inspect eval configuration and discoverability offline |
43
+ | `skill-harness calibrate <manifest.json>` | validate selected calibration evidence offline |
44
+ | `skill-harness calibrate <manifest.json> --run --judge p:m --out NEW_DIR` | judge reviewed controls through Pi; preserve a new result directory |
45
+ | `skill-harness diagnose <run-dir> --skills <root> [--specs <root>]` | inspect saved evidence without changing historical scores |
28
46
  | `skill-harness run <skill\|all> --skills <root> [--model p:m ...] [--judge p:m]` | run scenarios, grade, score |
29
47
  | `skill-harness grade <run-dir> [--judge p:m]` | re-grade saved transcripts |
30
48
  | `skill-harness rescore <run-dir>...` | re-apply current score thresholds offline |
package/dist/cli.d.ts CHANGED
@@ -51,4 +51,7 @@ export declare function cmdLint(args: Args): Promise<void>;
51
51
  * prints none.
52
52
  */
53
53
  export declare function help(): string;
54
+ export declare function cmdPreflight(args: Args): Promise<void>;
55
+ export declare function cmdDiagnose(args: Args): Promise<void>;
56
+ export declare function cmdCalibrate(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
54
57
  export declare function main(argv: string[]): Promise<void>;
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { readFileSync, existsSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
3
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, collectReport, collectStability, boundaryCells, stabilityNote, PATH_LEGEND, restampSkill, computeCoverage, formatCoverage, HARNESS_VERSION, defaultJudge, recordedJudgeOrDefault, 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, collectReport, collectStability, boundaryCells, stabilityNote, PATH_LEGEND, restampSkill, computeCoverage, formatCoverage, HARNESS_VERSION, defaultJudge, recordedJudgeOrDefault, assertJudgeAllowed, assertNotDowngraded, downgradeWarning, isScoredMode, resolveArm, isFreeOfflineCommand, diagnoseRun, loadCalibration, runCalibration, planCalibration, forkSavedRun, withProcessScope, } from "@skill-harness/core";
6
6
  import { getAdapter } from "@skill-harness/adapters";
7
7
  import { serveReview } from "./serve.js";
8
8
  import { cmdDecision } from "./decision.js";
@@ -11,7 +11,7 @@ const DEFAULT_MODEL = "fireworks:accounts/fireworks/models/deepseek-v4-pro";
11
11
  // SKILL_HARNESS_JUDGE over a baked value — it was duplicated in three places
12
12
  // before, and the pi extension's copy could disagree with this one.
13
13
  const DEFAULT_SUGGEST_MODEL = "openai-codex:gpt-5.6-sol";
14
- const REPEATABLE = new Set(["model", "turn", "check"]);
14
+ const REPEATABLE = new Set(["model", "turn", "check", "reuse"]);
15
15
  function parseArgs(argv) {
16
16
  const _ = [];
17
17
  const flags = {};
@@ -65,6 +65,15 @@ export function flagBool(args, key) {
65
65
  const v = args.flags[key];
66
66
  return v === true || v === "true" || v === "1";
67
67
  }
68
+ function selectedIds(args) {
69
+ const raw = flagStr(args, "only");
70
+ if (raw === undefined)
71
+ return undefined;
72
+ const ids = raw.split(",").map(s => s.trim());
73
+ if (ids.some(id => !id) || new Set(ids).size !== ids.length)
74
+ throw new Error("--only requires a nonempty, unique comma-separated ID list");
75
+ return ids;
76
+ }
68
77
  function resolveModels(args) {
69
78
  const models = [...(args.multi.model ?? [])];
70
79
  const file = flagStr(args, "models");
@@ -174,8 +183,7 @@ export async function cmdRun(args, adapterOverride) {
174
183
  // not on PATH" for a plain flag typo, sending the reader off to fix an install
175
184
  // that was never the problem. Free, offline validation first — it is also why
176
185
  // CI, which has no `pi`, could not test this refusal at all.
177
- const onlyRaw = flagStr(args, "only");
178
- const only = onlyRaw ? onlyRaw.split(",").map((x) => x.trim()).filter(Boolean) : undefined;
186
+ const only = selectedIds(args);
179
187
  const harnessName = flagStr(args, "harness", "pi");
180
188
  const adapter = adapterOverride ?? getAdapter(harnessName);
181
189
  if (!(await adapter.available()))
@@ -254,15 +262,16 @@ export async function cmdRun(args, adapterOverride) {
254
262
  process.exitCode = 1;
255
263
  }
256
264
  export async function cmdGrade(args, adapterOverride) {
257
- const runDir = args._[0];
265
+ const only = selectedIds(args);
266
+ let runDir = args._[0];
258
267
  if (!runDir)
259
268
  throw new Error("usage: skill-harness grade <run-dir> [--judge prov:model] [--suspect-only | --unparsed-only]");
260
269
  if (!existsSync(runDir)) {
261
270
  throw new Error(`run dir not found: ${resolve(runDir)} (relative paths resolve against the cwd)`);
262
271
  }
263
272
  // spec lives at <runDir>/../../../specification.yaml (results/<tag>/<ts> -> tests/)
264
- const testsDir = dirname(dirname(dirname(runDir)));
265
- const specPath = join(testsDir, "specification.yaml");
273
+ const specPath = flagStr(args, "spec") ? resolve(flagStr(args, "spec")) : join(dirname(dirname(dirname(runDir))), "specification.yaml");
274
+ const testsDir = dirname(specPath);
266
275
  const spec = loadSpec(specPath);
267
276
  const prev = existsSync(join(runDir, "results.yaml")) ? readResults(runDir) : null;
268
277
  // Re-judge with the run's RECORDED judge + harness (parity with /rejudge) —
@@ -293,9 +302,17 @@ export async function cmdGrade(args, adapterOverride) {
293
302
  const stale = downgradeWarning(dirname(testsDir));
294
303
  if (stale)
295
304
  console.error(stale);
305
+ const output = flagStr(args, "out");
306
+ if (output !== undefined) {
307
+ if (!output.trim())
308
+ throw new Error("--out requires a new directory");
309
+ runDir = forkSavedRun(runDir, output);
310
+ console.log(`Reusing saved answers in ${runDir}; original run preserved.`);
311
+ }
296
312
  const results = await regradeRun({
297
313
  runDir, spec, adapter, judge, specDir: testsDir, now: nowIso,
298
314
  onlySuspect: flagBool(args, "suspect-only"), onlyUnparsed,
315
+ only,
299
316
  });
300
317
  for (const s of results.scenarios) {
301
318
  console.log(` ${s.id} → ${s.judge_verdict}: ${s.judge_reason}`);
@@ -734,11 +751,12 @@ export function help() {
734
751
 
735
752
  run <skill|all> --skills <root> [--model prov:model ...] [--models file] [--only A1,D2]
736
753
  [--mode red|green|force] [--judge prov:model] [--harness pi] [--label name] [--parallel N] [--reps N] [--pass-threshold T]
737
- [--canary] green only: spend ONE probe proving the skill reached the model, and abort the run if it did not
754
+ [--canary] green only: one instructed-loading probe; autonomous selection stays untested
738
755
  [--structured] request structured capture (Pi uses it by default for subject usage)
739
756
  [--arm <name>] measure under a named arm from <skills-root>/tests/arms.yaml
740
757
  (loads its extensions, seeds pi-daddy definitions, tags the run dir)
741
758
  grade <run-dir> [--judge prov:model] [--suspect-only] re-grade saved transcripts (neutral judge)
759
+ [--only A1,A2] [--out NEW_DIR] [--spec path] preserve originals and judge selected saved answers
742
760
  regrade <run-dir> --unparsed-only re-judge ERROR criterion reps with recorded judge
743
761
  rescore <run-dir>... re-score saved reps vs current spec thresholds (${free("rescore")})
744
762
  regate <run-dir>... [--judge prov:model] re-evaluate saved gates (no subject call; judges fail→pass reps)
@@ -747,6 +765,13 @@ export function help() {
747
765
  review <skill> --skills <root> [--port N] serve the interactive review UI
748
766
  add-test <skill> --skills <root> --id ID --title T --turn ... --check ... [--critical] [--mode seeded --fixture path]
749
767
  init <skill> --skills <root> [--force] scaffold a commented template spec (${free("init")})
768
+ preflight <skill|all> --skills <root> [--specs <root>] eval checks (${free("preflight")})
769
+ calibrate <manifest.json> [--run --judge p:m --out NEW_DIR] validate or judge reviewed controls
770
+ [--plan --judge p:m] [--only c1,c2] [--reuse PRIOR_DIR ...] exact historical reuse, no new subject calls
771
+ diagnose <run-dir> --skills <root> [--specs <root>] inspect saved evidence (${free("diagnose")})
772
+ Paid commands accept --max-memory-mb N / --max-write-mb N for sampled Linux process limits.
773
+ run/grade/calibrate save bounded private resources-*.json; unsupported host metrics stay unknown.
774
+ In Pi: /skill-harness create <idea> guided skill + optional agent + eval creation
750
775
  suggest <skill> --skills <root> [--model prov:model] [--force] LLM-draft a spec from SKILL.md (spends tokens)
751
776
  list --skills <root> discovered skills + spec status (${free("list")})
752
777
  lint <skill|all> --skills <root> validate specs/fixtures + results-consistency (${free("lint")}; CI gate; exits non-zero on findings)
@@ -762,17 +787,105 @@ export function help() {
762
787
  defaults: model=${DEFAULT_MODEL} judge=${defaultJudge()} mode=green harness=pi
763
788
  green and force are both scored; red is the unscored baseline. green delivery depends on the
764
789
  harness version (pi >= 0.83.0 discloses only the skill's description and loads the body on demand),
765
- so --mode force is the delivery that cannot silently degrade — and --canary proves green per run.
790
+ --mode force supplies the skill body; --canary checks instructed loading separately from autonomous selection.
766
791
  the judge default runs through Pi on your ChatGPT subscription (\`openai-codex\`), not a metered
767
792
  API key. Set SKILL_HARNESS_JUDGE to change it for a repo or a shell; --judge wins over both.`;
768
793
  }
794
+ export async function cmdPreflight(args) {
795
+ const { skills, specs } = roots(args);
796
+ const target = args._[0];
797
+ if (!target)
798
+ throw new Error("usage: skill-harness preflight <skill|all> --skills <root> [--specs <root>]");
799
+ const selected = target === "all" ? discoverUsable(skills, specs).filter(s => s.hasSpec) : [resolveSkill(skills, target, specs)];
800
+ if (!selected.length)
801
+ throw new Error("No specifications found; in Pi use /skill-harness create to author a skill and its evals.");
802
+ const findings = selected.flatMap(s => lintSkill(s.dir, s.testsDir));
803
+ const errors = findings.filter(failsGate);
804
+ console.log(JSON.stringify({ status: errors.length ? "blocked" : "checked", providerCalls: 0, semanticValidity: "requires-reviewed-controls", findings }, null, 2));
805
+ if (errors.length)
806
+ process.exitCode = 1;
807
+ }
808
+ export async function cmdDiagnose(args) {
809
+ if (!args._[0])
810
+ throw new Error("usage: skill-harness diagnose <run-dir> --skills <root> [--specs <root>]");
811
+ const runDir = resolve(args._[0]);
812
+ const recorded = readResults(runDir);
813
+ const { skills, specs } = roots(args);
814
+ const selected = resolveSkill(skills, recorded.skill, specs);
815
+ console.log(JSON.stringify(diagnoseRun(runDir, selected.dir, selected.specPath), null, 2));
816
+ }
817
+ export async function cmdCalibrate(args, adapterOverride) {
818
+ if (!args._[0])
819
+ throw new Error("usage: skill-harness calibrate <manifest.json> [--run --judge provider:model --out NEW_DIR]");
820
+ const calibration = loadCalibration(resolve(args._[0]));
821
+ const judgeToken = flagStr(args, "judge");
822
+ const only = selectedIds(args);
823
+ const reuse = (args.multi.reuse ?? []).map(p => { if (!p.trim())
824
+ throw new Error("--reuse requires a prior calibration directory"); return resolve(p); });
825
+ if (flagBool(args, "plan")) {
826
+ if (!judgeToken)
827
+ throw new Error("Calibration --plan needs an explicit --judge; it makes zero provider calls.");
828
+ const plan = planCalibration(calibration, { judge: parseModelRef(judgeToken), only, reuse });
829
+ console.log(JSON.stringify({ ...plan, providerCalls: 0 }, null, 2));
830
+ if (!plan.ready)
831
+ process.exitCode = 1;
832
+ return;
833
+ }
834
+ if (!flagBool(args, "run")) {
835
+ console.log(JSON.stringify({ status: calibration.ready ? "validated" : "needs-review", purpose: "judge-calibration-only", executionGates: "not-evaluated", providerCalls: 0, cases: calibration.controls.length, issues: calibration.issues }));
836
+ if (!calibration.ready)
837
+ process.exitCode = 1;
838
+ return;
839
+ }
840
+ const output = flagStr(args, "out");
841
+ if (!judgeToken || !output)
842
+ throw new Error("Calibration --run needs an explicitly selected --judge and a new --out directory.");
843
+ const adapter = adapterOverride ?? getAdapter("pi");
844
+ if (!await adapter.available())
845
+ throw new Error("Pi is required for judge calibration. Configure the selected model in Pi first.");
846
+ const result = await runCalibration(calibration, { adapter, judge: parseModelRef(judgeToken), only, reuse, output: resolve(output), allowMeteredJudge: flagBool(args, "allow-metered-judge"), onProgress: p => console.log(`Judging control ${p.index + 1}/${p.total}: ${p.id}`) });
847
+ console.log(JSON.stringify(result, null, 2));
848
+ if (result.status !== "matched")
849
+ process.exitCode = 1;
850
+ }
769
851
  export async function main(argv) {
852
+ if (!["run", "grade", "regrade", "calibrate", "regate", "suggest"].includes(argv[0]))
853
+ return dispatch(argv);
854
+ const args = parseArgs(argv.slice(1));
855
+ const limit = (name) => {
856
+ const raw = flagStr(args, name);
857
+ if (raw === undefined)
858
+ return undefined;
859
+ const number = Number(raw);
860
+ if (!Number.isFinite(number) || number <= 0 || !Number.isSafeInteger(number * 1048576))
861
+ throw new Error(`--${name} requires a positive MiB value`);
862
+ return number * 1048576;
863
+ };
864
+ const controller = new AbortController();
865
+ const interrupt = () => { process.exitCode = 130; controller.abort(new Error("SIGINT")); };
866
+ const terminate = () => { process.exitCode = 143; controller.abort(new Error("SIGTERM")); };
867
+ process.once("SIGINT", interrupt);
868
+ process.once("SIGTERM", terminate);
869
+ try {
870
+ await withProcessScope({ signal: controller.signal, maxRssBytes: limit("max-memory-mb"), maxWriteBytes: limit("max-write-mb") }, () => dispatch(argv));
871
+ }
872
+ finally {
873
+ process.removeListener("SIGINT", interrupt);
874
+ process.removeListener("SIGTERM", terminate);
875
+ }
876
+ }
877
+ async function dispatch(argv) {
770
878
  const cmd = argv[0];
771
879
  if (cmd === "decision")
772
880
  return cmdDecision(argv.slice(1));
773
881
  const args = parseArgs(argv.slice(1));
774
882
  assertNoRetiredFlags(cmd, args);
775
883
  switch (cmd) {
884
+ case "create":
885
+ case "author": throw new Error("Skill creation uses your current Pi model. Start Pi with skill-harness installed, then use /skill-harness create followed by your idea; it asks one necessary question at a time.");
886
+ case "preflight": return cmdPreflight(args);
887
+ case "diagnose": return cmdDiagnose(args);
888
+ case "calibrate": return cmdCalibrate(args);
776
889
  case "run": return cmdRun(args);
777
890
  case "grade":
778
891
  case "regrade": return cmdGrade(args);
@@ -813,7 +926,7 @@ export async function main(argv) {
813
926
  if (!process.env.VITEST) {
814
927
  main(process.argv.slice(2)).catch((e) => {
815
928
  console.error(`error: ${e instanceof Error ? e.message : e}`);
816
- process.exitCode = 1;
929
+ process.exitCode ||= 1;
817
930
  });
818
931
  }
819
932
  //# sourceMappingURL=cli.js.map
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, recordedJudgeOrDefault, assertJudgeAllowed, } 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, parseModelRef, resolveJudgeReferences, } 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/. */
@@ -145,10 +145,16 @@ export async function serveReview(opts) {
145
145
  }
146
146
  const threshold = effectiveThreshold(prev, scenario);
147
147
  try {
148
+ // The prompt and refreshed rubric must name the same selected bytes,
149
+ // even when a reference is edited while a multi-repetition judge runs.
150
+ const specDir = dirname(specPath);
151
+ const references = resolveJudgeReferences(scenario, specDir);
148
152
  const rr = await regradeScenario({
149
153
  runDir: column.runDir, spec, scenario, adapter, judge: resolvedRecordedJudge.judge,
150
- specDir: dirname(specPath), threshold, mode: results.mode,
154
+ specDir, threshold, mode: results.mode, references,
151
155
  expectedReps: prev.reps ?? 1,
156
+ prior: prev, piVersion: results.harness_cli_version,
157
+ ...(results.model.includes(":") ? { subject: parseModelRef(results.model) } : {}),
152
158
  });
153
159
  const merged = results.scenarios.map((s) => {
154
160
  if (s.id !== body.scenarioId)
@@ -174,7 +180,7 @@ export async function serveReview(opts) {
174
180
  // retired the staleness gate for any run re-judged from the UI. Carried,
175
181
  // with the one `rubric:` key this re-judge actually applied refreshed —
176
182
  // the same doctrine `grade` follows (see refreshRubricHashes).
177
- source_hashes: refreshRubricHashes(results.source_hashes, spec, [body.scenarioId]),
183
+ source_hashes: refreshRubricHashes(results.source_hashes, spec, [body.scenarioId], specDir, { [body.scenarioId]: references }),
178
184
  source_hash_roots: results.source_hash_roots,
179
185
  }, scoreContextFor(results, spec));
180
186
  ensureResultsGitignore(join(opts.skillDir, "tests", "results"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/cli",
3
- "version": "0.29.0",
3
+ "version": "0.31.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.29.0",
48
- "@skill-harness/adapters": "0.29.0"
47
+ "@skill-harness/core": "0.31.0",
48
+ "@skill-harness/adapters": "0.31.0"
49
49
  }
50
50
  }