@skill-harness/cli 0.1.2 → 0.3.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/assets/report.grade.js +52 -0
- package/assets/report.template.html +50 -3
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +147 -9
- package/dist/serve.js +2 -2
- package/package.json +3 -3
package/assets/report.grade.js
CHANGED
|
@@ -75,3 +75,55 @@ export function gradeColumn(col, shipBar, critical) {
|
|
|
75
75
|
|
|
76
76
|
return { passed, total, pct, letter, ship, criticalFails, bFails, suspect };
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Red-vs-green class for ONE scenario — mirrors `classify` in
|
|
81
|
+
* packages/core/src/lift.ts exactly. Kept in the client because the author
|
|
82
|
+
* flips green verdicts live in this UI: a server-computed class would freeze at
|
|
83
|
+
* page load and could show "gained" beside a cell the author just marked FAIL.
|
|
84
|
+
*
|
|
85
|
+
* `liftCell` supplies the red side (verdict + redSuspect, from the baseline run,
|
|
86
|
+
* which this view never edits); `greenCell` is the live report cell, so its
|
|
87
|
+
* override and suspect state are read through the same effective()/suspect rule
|
|
88
|
+
* the grader uses.
|
|
89
|
+
*/
|
|
90
|
+
export function liftClass(liftCell, greenCell) {
|
|
91
|
+
const conclusive = (verdict, suspect) =>
|
|
92
|
+
!suspect && verdict !== "ERROR" && verdict !== "JUDGE-AMBIGUOUS";
|
|
93
|
+
|
|
94
|
+
const redOk = conclusive(liftCell.red, liftCell.redSuspect);
|
|
95
|
+
const greenOk = conclusive(effective(greenCell), !!greenCell.suspect && !greenCell.override);
|
|
96
|
+
if (!redOk || !greenOk) return "inconclusive";
|
|
97
|
+
|
|
98
|
+
const redPass = liftCell.red === "PASS";
|
|
99
|
+
const greenPass = effective(greenCell) === "PASS";
|
|
100
|
+
if (redPass && greenPass) return "kept";
|
|
101
|
+
if (!redPass && greenPass) return "gained";
|
|
102
|
+
if (redPass && !greenPass) return "regressed";
|
|
103
|
+
return "both-fail";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Aggregate a column's lift over the live cells — mirrors computeLift's counters
|
|
108
|
+
* in packages/core/src/lift.ts. Only scenarios the baseline also covered are
|
|
109
|
+
* counted, matching the server's intersection rule.
|
|
110
|
+
*/
|
|
111
|
+
export function liftSummary(col) {
|
|
112
|
+
const out = { gained: 0, regressed: 0, kept: 0, bothFail: 0, inconclusive: 0, compared: 0, redPassed: 0, greenPassed: 0, delta: 0 };
|
|
113
|
+
if (!col.lift) return null;
|
|
114
|
+
for (const id of Object.keys(col.lift.cells)) {
|
|
115
|
+
const greenCell = col.cells[id];
|
|
116
|
+
if (!greenCell) continue;
|
|
117
|
+
const liftCell = col.lift.cells[id];
|
|
118
|
+
const cls = liftClass(liftCell, greenCell);
|
|
119
|
+
out.compared++;
|
|
120
|
+
if (cls === "both-fail") out.bothFail++;
|
|
121
|
+
else out[cls]++;
|
|
122
|
+
if (cls !== "inconclusive") {
|
|
123
|
+
if (liftCell.red === "PASS") out.redPassed++;
|
|
124
|
+
if (effective(greenCell) === "PASS") out.greenPassed++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
out.delta = out.greenPassed - out.redPassed;
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
@@ -35,6 +35,14 @@
|
|
|
35
35
|
.badge { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; }
|
|
36
36
|
.badge.ship { background: var(--pass); color: #fff; }
|
|
37
37
|
.badge.no { background: var(--fail); color: #fff; }
|
|
38
|
+
/* red-vs-green lift: what the skill itself changed */
|
|
39
|
+
.lift { font-size: 11px; margin-top: 3px; font-weight: 600; }
|
|
40
|
+
.lift.up { color: var(--pass); }
|
|
41
|
+
.lift.down { color: var(--fail); }
|
|
42
|
+
.lift.flat, .lift.none { color: var(--dim); font-weight: 500; }
|
|
43
|
+
.cell .lm { display: block; font-size: 10px; font-weight: 700; }
|
|
44
|
+
.cell .lm.up { color: var(--pass); }
|
|
45
|
+
.cell .lm.down { color: var(--fail); }
|
|
38
46
|
aside { width: 0; transition: width .15s ease; overflow: hidden; border-left: 1px solid var(--line); background: var(--panel); }
|
|
39
47
|
aside.open { width: 460px; }
|
|
40
48
|
.panel { width: 460px; padding: 16px 18px; }
|
|
@@ -153,14 +161,43 @@ function render() {
|
|
|
153
161
|
const suspectNote = g.suspect > 0 ? ` — ${g.suspect} suspect` : "";
|
|
154
162
|
gradeHtml = `${g.letter} (${g.pct}%) · ${g.passed}/${g.total}${suspectNote} ${badge}`;
|
|
155
163
|
}
|
|
156
|
-
|
|
164
|
+
// Lift answers "does this skill do anything?", which the grade alone cannot:
|
|
165
|
+
// a column can score A because the model never needed the skill. Recomputed
|
|
166
|
+
// from the live cells (see liftSummary) so overrides move it immediately.
|
|
167
|
+
const ls = liftSummary(col);
|
|
168
|
+
let liftHtml = "";
|
|
169
|
+
if (ls && ls.compared > 0) {
|
|
170
|
+
const moved = ls.gained > 0 || ls.regressed > 0;
|
|
171
|
+
const cls = !moved ? "flat" : ls.delta > 0 ? "up" : ls.delta < 0 ? "down" : "flat";
|
|
172
|
+
// All-inconclusive is no measurement, not a zero effect — don't claim "no
|
|
173
|
+
// effect" when nothing was measurable (mirrors liftHeadline in lift.ts).
|
|
174
|
+
const body = ls.compared === ls.inconclusive
|
|
175
|
+
? `lift — nothing conclusive`
|
|
176
|
+
: moved
|
|
177
|
+
? `lift ${ls.delta > 0 ? "+" : ""}${ls.delta} · ${ls.gained}↑ ${ls.regressed}↓`
|
|
178
|
+
: `lift 0 · no effect`;
|
|
179
|
+
const inc = ls.inconclusive > 0 ? ` · ${ls.inconclusive} inconclusive` : "";
|
|
180
|
+
const part = col.lift.partial ? " · partial" : "";
|
|
181
|
+
html += `<th><div>${escapeHtml(col.label)}</div><div class='grade'>${gradeHtml}</div>` +
|
|
182
|
+
`<div class='lift ${cls}' title='vs red baseline ${escapeHtml(col.lift.redTimestamp)} — ${ls.kept} passed without the skill too'>${body}${inc}${part}</div></th>`;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (col.mode === "green") {
|
|
186
|
+
// "not measured" is a different claim from "measured no effect" — say which.
|
|
187
|
+
liftHtml = `<div class='lift none' title='run the same scenarios with --mode red to get a baseline'>no red baseline</div>`;
|
|
188
|
+
}
|
|
189
|
+
html += `<th><div>${escapeHtml(col.label)}</div><div class='grade'>${gradeHtml}</div>${liftHtml}</th>`;
|
|
157
190
|
});
|
|
158
191
|
html += "</tr></thead><tbody>";
|
|
159
192
|
for (const scn of DATA.scenarios) {
|
|
160
193
|
html += `<tr><td class='scn'>${scn.id}${scn.critical ? "<span class='crit' title='critical'>⚠</span>" : ""}<div class='grade'>${escapeHtml(scn.title)}</div></td>`;
|
|
161
194
|
DATA.columns.forEach((col) => {
|
|
162
195
|
const cell = col.cells[scn.id];
|
|
163
|
-
|
|
196
|
+
// `return`, not `continue`: this is a forEach callback, so `continue` is a
|
|
197
|
+
// SyntaxError that takes the entire inline script — the whole review UI —
|
|
198
|
+
// down with it. Guarded by report.test.ts's "rendered report is valid
|
|
199
|
+
// JavaScript".
|
|
200
|
+
if (!cell) { html += `<td class='cell empty'>–</td>`; return; }
|
|
164
201
|
const v = effective(cell);
|
|
165
202
|
const sel = selected && selected.colIndex === col.index && selected.scenarioId === scn.id ? " sel" : "";
|
|
166
203
|
// _unsaved wins regardless of override value, so a failed *clear*
|
|
@@ -171,7 +208,17 @@ function render() {
|
|
|
171
208
|
const misfired = cell.clean != null && cell.clean < cell.reps ? ` · ${cell.reps - cell.clean} misfired` : "";
|
|
172
209
|
const reps = cell.reps ? `<span class='reps'>${cell.passes}/${cell.clean}${misfired}${cell.flakiness ? ` · flaky ${cell.flakiness.toFixed(2)}` : ""}</span>` : "";
|
|
173
210
|
const suspectBadge = cell.suspect && !cell.override ? `<span class='ov suspect'>suspect</span>` : "";
|
|
174
|
-
|
|
211
|
+
// Per-cell lift marker: only the two classes that mean the skill changed
|
|
212
|
+
// the outcome. kept/both-fail/inconclusive are left unmarked — marking
|
|
213
|
+
// every cell would bury the signal this column exists to show.
|
|
214
|
+
const lc = col.lift && col.lift.cells[scn.id] ? liftClass(col.lift.cells[scn.id], cell) : null;
|
|
215
|
+
const liftMark =
|
|
216
|
+
lc === "gained"
|
|
217
|
+
? `<span class='lm up' title='red baseline ${col.lift.cells[scn.id].red} → PASS with the skill'>↑ skill</span>`
|
|
218
|
+
: lc === "regressed"
|
|
219
|
+
? `<span class='lm down' title='red baseline PASS → ${effective(cell)} with the skill'>↓ skill</span>`
|
|
220
|
+
: "";
|
|
221
|
+
html += `<td class='cell ${v}${sel}' data-col='${col.index}' data-id='${scn.id}'>${v}${ov}${suspectBadge}${liftMark}${reps}</td>`;
|
|
175
222
|
});
|
|
176
223
|
html += "</tr>";
|
|
177
224
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export declare function parseRunTuning(args: Args): {
|
|
|
12
12
|
passThreshold: number;
|
|
13
13
|
};
|
|
14
14
|
export declare function cmdGrade(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
|
|
15
|
+
export declare function cmdInit(args: Args): Promise<void>;
|
|
16
|
+
export declare function cmdSuggest(args: Args, adapterOverride?: HarnessAdapter): Promise<void>;
|
|
15
17
|
/** Exit-code contract: 0 = clean (no findings), 1 = >=1 finding, or a resolution error (unknown skill/root, no skills with a spec). */
|
|
16
18
|
export declare function cmdLint(args: Args): Promise<void>;
|
|
17
19
|
export declare function main(argv: string[]): Promise<void>;
|
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, existsSync, appendFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
2
|
+
import { readFileSync, existsSync, appendFileSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
4
5
|
import yaml from "js-yaml";
|
|
5
|
-
import { discover, resolveSkill, loadSpec, parseSpec, parseModelRef, runSkillModel, formatScorecard, readResults, regradeRun, lintSkill, } from "@skill-harness/core";
|
|
6
|
+
import { discover, resolveSkill, loadSpec, parseSpec, parseModelRef, runSkillModel, formatScorecard, readResults, regradeRun, lintSkill, renderTemplateSpec, isTemplateSpec, renderDraftSpec, buildSuggestPrompt, parseSuggestDraft, rescoreRun, specPathForRunDir, collectLift, } from "@skill-harness/core";
|
|
6
7
|
import { getAdapter } from "@skill-harness/adapters";
|
|
7
8
|
import { serveReview } from "./serve.js";
|
|
8
9
|
const DEFAULT_MODEL = "fireworks:accounts/fireworks/models/deepseek-v4-pro";
|
|
9
10
|
const DEFAULT_JUDGE = "anthropic:claude-opus-4-8";
|
|
11
|
+
const DEFAULT_SUGGEST_MODEL = "claude-code:claude-opus-4-8";
|
|
10
12
|
const REPEATABLE = new Set(["model", "turn", "check"]);
|
|
11
13
|
function parseArgs(argv) {
|
|
12
14
|
const _ = [];
|
|
@@ -120,6 +122,8 @@ async function cmdRun(args) {
|
|
|
120
122
|
const label = flagStr(args, "label") || null;
|
|
121
123
|
const parallel = Math.max(1, Number(flagStr(args, "parallel", "1")) || 1);
|
|
122
124
|
const { reps, passThreshold } = parseRunTuning(args);
|
|
125
|
+
const onlyRaw = flagStr(args, "only");
|
|
126
|
+
const only = onlyRaw ? onlyRaw.split(",").map((x) => x.trim()).filter(Boolean) : undefined;
|
|
123
127
|
const modelTokens = resolveModels(args);
|
|
124
128
|
const skills = target === "all"
|
|
125
129
|
? discover(root).filter((s) => s.hasSpec)
|
|
@@ -148,18 +152,26 @@ async function cmdRun(args) {
|
|
|
148
152
|
concurrency: parallel,
|
|
149
153
|
reps,
|
|
150
154
|
passThreshold,
|
|
155
|
+
only,
|
|
151
156
|
onProgress: (m) => console.log(m),
|
|
152
157
|
});
|
|
153
158
|
summaries.push(summary);
|
|
154
|
-
|
|
159
|
+
// Lift is derived from what's on disk, so it picks up a red baseline from
|
|
160
|
+
// any earlier run — the tag dir (<harness>-<modelslug>) is the join key.
|
|
161
|
+
const tag = basename(dirname(summary.runDir));
|
|
162
|
+
const lift = collectLift(skill.dir).find((l) => l.tag === tag);
|
|
163
|
+
console.log("\n" + formatScorecard(summary, lift) + "\n");
|
|
155
164
|
}
|
|
156
165
|
}
|
|
157
166
|
console.log(`\nReview interactively: skill-harness review ${skills[0]?.name ?? "<skill>"} --skills ${root}`);
|
|
158
167
|
}
|
|
159
168
|
export async function cmdGrade(args, adapterOverride) {
|
|
160
169
|
const runDir = args._[0];
|
|
161
|
-
if (!runDir
|
|
162
|
-
throw new Error("usage: skill-harness grade <run-dir> [--judge prov:model]");
|
|
170
|
+
if (!runDir)
|
|
171
|
+
throw new Error("usage: skill-harness grade <run-dir> [--judge prov:model] [--suspect-only]");
|
|
172
|
+
if (!existsSync(runDir)) {
|
|
173
|
+
throw new Error(`run dir not found: ${resolve(runDir)} (relative paths resolve against the cwd)`);
|
|
174
|
+
}
|
|
163
175
|
// spec lives at <runDir>/../../../specification.yaml (results/<tag>/<ts> -> tests/)
|
|
164
176
|
const testsDir = dirname(dirname(dirname(runDir)));
|
|
165
177
|
const specPath = join(testsDir, "specification.yaml");
|
|
@@ -171,13 +183,44 @@ export async function cmdGrade(args, adapterOverride) {
|
|
|
171
183
|
const judgeFlag = flagStr(args, "judge");
|
|
172
184
|
const judge = judgeFlag ? parseModelRef(judgeFlag) : (prev?.judge ?? parseModelRef(DEFAULT_JUDGE));
|
|
173
185
|
const adapter = adapterOverride ?? getAdapter(prev?.harness ?? "pi");
|
|
174
|
-
const results = await regradeRun({
|
|
186
|
+
const results = await regradeRun({
|
|
187
|
+
runDir, spec, adapter, judge, specDir: testsDir, now: nowIso,
|
|
188
|
+
onlySuspect: args.flags["suspect-only"] === true || args.flags["suspect-only"] === "true",
|
|
189
|
+
});
|
|
175
190
|
for (const s of results.scenarios) {
|
|
176
191
|
console.log(` ${s.id} → ${s.judge_verdict}: ${s.judge_reason}`);
|
|
177
192
|
}
|
|
178
193
|
const g = results.effective_grade;
|
|
179
194
|
console.log(`\n re-graded with ${judge.provider}:${judge.model} → ${g.letter} (${g.pct}%) ${g.ship ? "SHIP" : "NOT READY"}`);
|
|
180
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Re-score saved runs against the current spec's thresholds — no model or judge calls.
|
|
198
|
+
* Reps are the measurement; thresholds are policy. When policy changes, recompute rather
|
|
199
|
+
* than reconcile two numbers in prose.
|
|
200
|
+
*/
|
|
201
|
+
async function cmdRescore(args) {
|
|
202
|
+
const runDirs = args._;
|
|
203
|
+
if (runDirs.length === 0)
|
|
204
|
+
throw new Error("usage: skill-harness rescore <run-dir> [<run-dir> ...]");
|
|
205
|
+
let moved = 0;
|
|
206
|
+
for (const raw of runDirs) {
|
|
207
|
+
const runDir = resolve(raw);
|
|
208
|
+
if (!existsSync(runDir))
|
|
209
|
+
throw new Error(`run dir not found: ${runDir} (relative paths resolve against the cwd)`);
|
|
210
|
+
const spec = loadSpec(specPathForRunDir(runDir));
|
|
211
|
+
const { results, changes } = rescoreRun({ runDir, spec, now: nowIso });
|
|
212
|
+
const g = results.effective_grade;
|
|
213
|
+
console.log(`\n${results.skill} · ${results.model}`);
|
|
214
|
+
for (const c of changes) {
|
|
215
|
+
console.log(` ${c.id}: ${c.from} → ${c.to} (${c.passes}/${c.clean} @ threshold ${c.toThreshold})`);
|
|
216
|
+
}
|
|
217
|
+
if (changes.length === 0)
|
|
218
|
+
console.log(" (no verdict changed)");
|
|
219
|
+
moved += changes.length;
|
|
220
|
+
console.log(` → ${g.letter} (${g.pct}%) ${g.passed}/${g.total} ${g.ship ? "SHIP" : "NOT READY"}`);
|
|
221
|
+
}
|
|
222
|
+
console.log(`\n${runDirs.length} run(s) re-scored, ${moved} verdict(s) moved.`);
|
|
223
|
+
}
|
|
181
224
|
async function cmdReview(args) {
|
|
182
225
|
const root = flagStr(args, "skills", process.cwd());
|
|
183
226
|
const target = args._[0];
|
|
@@ -222,6 +265,95 @@ async function cmdAddTest(args) {
|
|
|
222
265
|
appendFileSync(skill.specPath, block, "utf8");
|
|
223
266
|
console.log(`added scenario ${id} to ${skill.specPath}`);
|
|
224
267
|
}
|
|
268
|
+
/** Write a spec to disk, creating its tests/ dir. The single choke point for spec
|
|
269
|
+
* writes (init/suggest) so a future atomic-write/backup/audit change lands in one place. */
|
|
270
|
+
function writeSpecFile(specPath, text) {
|
|
271
|
+
mkdirSync(dirname(specPath), { recursive: true });
|
|
272
|
+
writeFileSync(specPath, text, "utf8");
|
|
273
|
+
}
|
|
274
|
+
export async function cmdInit(args) {
|
|
275
|
+
const root = flagStr(args, "skills", process.cwd());
|
|
276
|
+
const target = args._[0];
|
|
277
|
+
if (!target)
|
|
278
|
+
throw new Error("usage: skill-harness init <skill> --skills <root> [--force]");
|
|
279
|
+
const skill = resolveSkill(root, target);
|
|
280
|
+
const force = flagStr(args, "force") !== undefined;
|
|
281
|
+
if (skill.hasSpec && !force) {
|
|
282
|
+
throw new Error(`${skill.specPath} exists — edit it, or pass --force to overwrite`);
|
|
283
|
+
}
|
|
284
|
+
const text = renderTemplateSpec(skill.name);
|
|
285
|
+
parseSpec(text, skill.specPath); // guard: the template must always be valid
|
|
286
|
+
writeSpecFile(skill.specPath, text);
|
|
287
|
+
console.log(`wrote template ${skill.specPath} — fill it in, or run \`skill-harness suggest ${skill.name}\` to LLM-draft it.`);
|
|
288
|
+
}
|
|
289
|
+
export async function cmdSuggest(args, adapterOverride) {
|
|
290
|
+
const root = flagStr(args, "skills", process.cwd());
|
|
291
|
+
const target = args._[0];
|
|
292
|
+
if (!target)
|
|
293
|
+
throw new Error("usage: skill-harness suggest <skill> --skills <root> [--model prov:model] [--force]");
|
|
294
|
+
// resolveSkill throws a SKILL.md-specific error when the directory exists but
|
|
295
|
+
// lacks one, so we don't reimplement that check here; a resolved skill always
|
|
296
|
+
// has a SKILL.md at skill.dir.
|
|
297
|
+
const skill = resolveSkill(root, target);
|
|
298
|
+
const skillMd = readFileSync(join(skill.dir, "SKILL.md"), "utf8");
|
|
299
|
+
// Overwrite without --force only when the target is absent or an *unedited*
|
|
300
|
+
// template. A file that still carries the sentinel but no longer matches the
|
|
301
|
+
// pristine template has been hand-edited — refuse it so we never clobber work.
|
|
302
|
+
const force = flagStr(args, "force") !== undefined;
|
|
303
|
+
if (skill.hasSpec && !force) {
|
|
304
|
+
const existing = readFileSync(skill.specPath, "utf8");
|
|
305
|
+
if (existing !== renderTemplateSpec(skill.name)) {
|
|
306
|
+
const hint = isTemplateSpec(existing)
|
|
307
|
+
? "looks like an edited template — pass --force to overwrite (or delete your edits)"
|
|
308
|
+
: "already has real content — pass --force to overwrite";
|
|
309
|
+
throw new Error(`${skill.specPath} ${hint}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const model = parseModelRef(flagStr(args, "model", DEFAULT_SUGGEST_MODEL));
|
|
313
|
+
const adapter = adapterOverride ?? getAdapter("pi");
|
|
314
|
+
const cwd = mkdtempSync(join(tmpdir(), "sh-suggest-cwd-"));
|
|
315
|
+
try {
|
|
316
|
+
const basePrompt = buildSuggestPrompt(skill.name, skillMd);
|
|
317
|
+
let text = null;
|
|
318
|
+
let count = 0;
|
|
319
|
+
let lastErr = "";
|
|
320
|
+
for (let attempt = 0; attempt < 2 && text === null; attempt++) {
|
|
321
|
+
const prompt = attempt === 0
|
|
322
|
+
? basePrompt
|
|
323
|
+
: `${basePrompt}\n\nYour previous reply was rejected: ${lastErr}. Return corrected JSON only.`;
|
|
324
|
+
const raw = await adapter.judge({ model, prompt, cwd });
|
|
325
|
+
// A `[judge error` prefix is a hard adapter failure (auth, exec, credits) —
|
|
326
|
+
// retrying won't help, so fail fast with a model hint. An empty reply is
|
|
327
|
+
// treated as a transient miss and gets the same retry as a bad-JSON reply.
|
|
328
|
+
if (raw.startsWith("[judge error")) {
|
|
329
|
+
throw new Error(`model ${model.provider}:${model.model} failed — ${raw.trim()} (try --model fireworks:...)`);
|
|
330
|
+
}
|
|
331
|
+
if (!raw.trim()) {
|
|
332
|
+
lastErr = "model produced no output";
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const draft = parseSuggestDraft(raw);
|
|
337
|
+
const candidate = renderDraftSpec(skill.name, draft);
|
|
338
|
+
parseSpec(candidate, skill.specPath); // validate before writing
|
|
339
|
+
text = candidate;
|
|
340
|
+
count = draft.scenarios.length;
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
lastErr = e instanceof Error ? e.message : String(e);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (text === null) {
|
|
347
|
+
throw new Error(`could not get a valid spec from ${model.provider}:${model.model} after 2 attempts (${lastErr}) — try \`skill-harness init ${skill.name}\` for a manual template`);
|
|
348
|
+
}
|
|
349
|
+
writeSpecFile(skill.specPath, text);
|
|
350
|
+
console.log(`drafted ${count} scenario(s) → ${skill.specPath}`);
|
|
351
|
+
console.log(`review it (especially the proposed critical set), then \`skill-harness run ${skill.name} --skills ${root}\``);
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
225
357
|
/** Exit-code contract: 0 = clean (no findings), 1 = >=1 finding, or a resolution error (unknown skill/root, no skills with a spec). */
|
|
226
358
|
export async function cmdLint(args) {
|
|
227
359
|
const root = flagStr(args, "skills", process.cwd());
|
|
@@ -269,11 +401,14 @@ export async function cmdLint(args) {
|
|
|
269
401
|
// ---------------------------------------------------------------- dispatch
|
|
270
402
|
const HELP = `skill-harness — test/optimize loop for agent skills (pi harness)
|
|
271
403
|
|
|
272
|
-
run <skill|all> --skills <root> [--model prov:model ...] [--models file]
|
|
404
|
+
run <skill|all> --skills <root> [--model prov:model ...] [--models file] [--only A1,D2]
|
|
273
405
|
[--mode red|green|force] [--judge prov:model] [--harness pi] [--label name] [--parallel N] [--reps N] [--pass-threshold T]
|
|
274
|
-
grade <run-dir> [--judge prov:model]
|
|
406
|
+
grade <run-dir> [--judge prov:model] [--suspect-only] re-grade saved transcripts (neutral judge)
|
|
407
|
+
rescore <run-dir>... re-score saved reps vs current spec thresholds (free)
|
|
275
408
|
review <skill> --skills <root> [--port N] serve the interactive review UI
|
|
276
409
|
add-test <skill> --skills <root> --id ID --title T --turn ... --check ... [--critical] [--mode seeded --fixture path]
|
|
410
|
+
init <skill> --skills <root> [--force] scaffold a commented template spec (free, offline)
|
|
411
|
+
suggest <skill> --skills <root> [--model prov:model] [--force] LLM-draft a spec from SKILL.md (spends tokens)
|
|
277
412
|
list --skills <root> discovered skills + spec status
|
|
278
413
|
lint <skill|all> --skills <root> validate specs/fixtures + results-consistency (CI gate; exits non-zero on findings)
|
|
279
414
|
|
|
@@ -284,8 +419,11 @@ export async function main(argv) {
|
|
|
284
419
|
switch (cmd) {
|
|
285
420
|
case "run": return cmdRun(args);
|
|
286
421
|
case "grade": return cmdGrade(args);
|
|
422
|
+
case "rescore": return cmdRescore(args);
|
|
287
423
|
case "review": return cmdReview(args);
|
|
288
424
|
case "add-test": return cmdAddTest(args);
|
|
425
|
+
case "init": return cmdInit(args);
|
|
426
|
+
case "suggest": return cmdSuggest(args);
|
|
289
427
|
case "list": return cmdList(args);
|
|
290
428
|
case "lint": return cmdLint(args);
|
|
291
429
|
case undefined:
|
package/dist/serve.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { collectReport, renderReport, collectTrends, readResults, writeResults, applyOverride, preserveTranscript, findTranscriptFiles, ensureResultsGitignore, appendJournal, loadSpec, regradeScenario, findJudgeRawFiles, effectiveThreshold, } from "@skill-harness/core";
|
|
6
|
+
import { collectReport, renderReport, collectTrends, readResults, writeResults, applyOverride, preserveTranscript, findTranscriptFiles, ensureResultsGitignore, appendJournal, loadSpec, regradeScenario, findJudgeRawFiles, effectiveThreshold, envFlag, } from "@skill-harness/core";
|
|
7
7
|
import { getAdapter } from "@skill-harness/adapters";
|
|
8
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
/** Locate assets/report.template.html relative to dist/ or src/. */
|
|
@@ -202,7 +202,7 @@ export async function serveReview(opts) {
|
|
|
202
202
|
console.log(` → ${link}`);
|
|
203
203
|
console.log(` flip verdicts + add notes in the browser; saves persist to results.yaml.`);
|
|
204
204
|
console.log(` Ctrl-C to stop.\n`);
|
|
205
|
-
if (opts.open !== false && !
|
|
205
|
+
if (opts.open !== false && !envFlag("NO_OPEN"))
|
|
206
206
|
tryOpen(link);
|
|
207
207
|
return { port: port, close: () => server.close() };
|
|
208
208
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skill-harness/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"prepack": "rm -rf ./assets && cp -r ../../assets ./assets && cp ../../LICENSE ./LICENSE"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@skill-harness/core": "0.
|
|
38
|
-
"@skill-harness/adapters": "0.
|
|
37
|
+
"@skill-harness/core": "0.3.0",
|
|
38
|
+
"@skill-harness/adapters": "0.3.0"
|
|
39
39
|
}
|
|
40
40
|
}
|