@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 +9 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +95 -57
- package/dist/serve.js +22 -4
- package/package.json +3 -3
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
|
|
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 = "
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
?
|
|
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.
|
|
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
|
|
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(
|
|
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;
|
|
240
|
-
// the
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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 =
|
|
393
|
+
const { skills: root, specs } = roots(args);
|
|
360
394
|
const target = args._[0] ?? "all";
|
|
361
|
-
const skills = target === "all" ?
|
|
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 =
|
|
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" ?
|
|
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.
|
|
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 =
|
|
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.
|
|
486
|
+
await serveReview({ skillDir: dirname(skill.testsDir), skillName: skill.name, port });
|
|
453
487
|
}
|
|
454
488
|
async function cmdAddTest(args) {
|
|
455
|
-
const root =
|
|
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 =
|
|
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" ?
|
|
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
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
665
|
+
const { skills: root, specs } = roots(args);
|
|
632
666
|
const target = args._[0] ?? "all";
|
|
633
|
-
let
|
|
667
|
+
let skills;
|
|
634
668
|
try {
|
|
635
|
-
|
|
636
|
-
?
|
|
637
|
-
: [resolveSkill(root, target)
|
|
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 (
|
|
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
|
|
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${
|
|
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
|
|
718
|
-
|
|
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:
|
|
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:
|
|
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({
|
|
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.
|
|
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.
|
|
48
|
-
"@skill-harness/adapters": "0.
|
|
47
|
+
"@skill-harness/core": "0.21.0",
|
|
48
|
+
"@skill-harness/adapters": "0.21.0"
|
|
49
49
|
}
|
|
50
50
|
}
|