@skill-harness/core 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/dist/adapters/types.d.ts +6 -0
- package/dist/discover.d.ts +5 -1
- package/dist/discover.js +9 -1
- package/dist/grade.d.ts +14 -1
- package/dist/grade.js +74 -11
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/journal.d.ts +14 -0
- package/dist/lift.d.ts +69 -0
- package/dist/lift.js +163 -0
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +168 -4
- package/dist/regrade.d.ts +6 -0
- package/dist/regrade.js +25 -3
- package/dist/report.d.ts +14 -0
- package/dist/report.js +16 -1
- package/dist/rescore.d.ts +34 -0
- package/dist/rescore.js +66 -0
- package/dist/results.d.ts +41 -9
- package/dist/results.js +58 -14
- package/dist/run.d.ts +25 -1
- package/dist/run.js +115 -22
- package/dist/scaffold.d.ts +28 -0
- package/dist/scaffold.js +188 -0
- package/dist/score.d.ts +5 -1
- package/dist/seeded.d.ts +83 -3
- package/dist/seeded.js +315 -15
- package/dist/sources.d.ts +115 -0
- package/dist/sources.js +253 -0
- package/dist/spec.d.ts +17 -0
- package/dist/spec.js +63 -0
- package/dist/util/env.d.ts +32 -0
- package/dist/util/env.js +67 -0
- package/dist/workspace.d.ts +33 -1
- package/dist/workspace.js +125 -6
- package/package.json +1 -1
package/dist/results.js
CHANGED
|
@@ -46,7 +46,8 @@ export function finalizeResults(draft, ctx) {
|
|
|
46
46
|
effective_grade = { passed: s.passed, total: s.total, pct: s.pct, letter: s.letter, ship: s.ship, note: s.note };
|
|
47
47
|
}
|
|
48
48
|
else {
|
|
49
|
-
|
|
49
|
+
const why = draft.partial ? "partial run (--only) — not scored" : `mode=${draft.mode} (not scored)`;
|
|
50
|
+
effective_grade = { passed: 0, total: 0, pct: 0, letter: "-", ship: false, note: why };
|
|
50
51
|
}
|
|
51
52
|
return {
|
|
52
53
|
schema: 2,
|
|
@@ -57,6 +58,8 @@ export function finalizeResults(draft, ctx) {
|
|
|
57
58
|
timestamp: draft.timestamp,
|
|
58
59
|
label: draft.label,
|
|
59
60
|
mode: draft.mode,
|
|
61
|
+
...(draft.partial ? { partial: true } : {}),
|
|
62
|
+
...(draft.source_hashes ? { source_hashes: draft.source_hashes } : {}),
|
|
60
63
|
effective_grade,
|
|
61
64
|
scenarios: draft.scenarios,
|
|
62
65
|
};
|
|
@@ -147,9 +150,10 @@ export function ensureResultsGitignore(resultsRoot) {
|
|
|
147
150
|
.filter((l) => l.startsWith("!") && l.trim() !== "!results.yaml");
|
|
148
151
|
writeFileSync(giPath, GITIGNORE_BODY + preserved.map((l) => l + "\n").join(""), "utf8");
|
|
149
152
|
}
|
|
150
|
-
// Matches
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
// Matches transcript (`.rep<k>.txt`), judge-raw (`.rep<k>.judge.txt`) and
|
|
154
|
+
// staged-diff (`.rep<k>.diff.txt`) rep suffixes.
|
|
155
|
+
const REP_SUFFIX_RE = /\.rep(\d+)\.(?:judge\.|diff\.)?txt$/;
|
|
156
|
+
/** The rep index embedded in a transcript / judge-raw / staged-diff filename (`.rep<k>.`), or null for a plain (non-rep) file. */
|
|
153
157
|
export function repIndexOf(filename) {
|
|
154
158
|
const m = REP_SUFFIX_RE.exec(filename);
|
|
155
159
|
return m ? Number(m[1]) : null;
|
|
@@ -178,7 +182,10 @@ function sortByRep(files) {
|
|
|
178
182
|
* `<id>.<mode>.rep<k>.txt`) — e.g. to detect a green-only condition without
|
|
179
183
|
* false positives from a red/force transcript of the same scenario. Omitted,
|
|
180
184
|
* behavior is unchanged: any `<id>.*.txt` regardless of mode, excluding this
|
|
181
|
-
* scenario's
|
|
185
|
+
* scenario's sibling artifacts, which share the `.txt` extension deliberately
|
|
186
|
+
* (so `results/.gitignore`'s `*.txt` covers them all): judge-raw output
|
|
187
|
+
* (`<id>.*.judge.txt` — see judgeRawPath) and the staged diff
|
|
188
|
+
* (`<id>.*.diff.txt` — see diffPath).
|
|
182
189
|
*/
|
|
183
190
|
export function findTranscriptFiles(runDir, scenarioId, mode) {
|
|
184
191
|
if (!existsSync(runDir))
|
|
@@ -187,7 +194,12 @@ export function findTranscriptFiles(runDir, scenarioId, mode) {
|
|
|
187
194
|
const matcher = mode !== undefined
|
|
188
195
|
? new RegExp(`^${escapedId}\\.${mode}(\\.rep\\d+)?\\.txt$`)
|
|
189
196
|
: null;
|
|
190
|
-
const files = readdirSync(runDir).filter((f) => matcher
|
|
197
|
+
const files = readdirSync(runDir).filter((f) => matcher
|
|
198
|
+
? matcher.test(f)
|
|
199
|
+
: f.startsWith(`${scenarioId}.`) &&
|
|
200
|
+
f.endsWith(".txt") &&
|
|
201
|
+
!f.endsWith(".judge.txt") &&
|
|
202
|
+
!f.endsWith(".diff.txt"));
|
|
191
203
|
return sortByRep(files);
|
|
192
204
|
}
|
|
193
205
|
/** Path of a scenario's raw judge-output artifact within a run dir (rep-suffixed for reps). */
|
|
@@ -205,22 +217,54 @@ export function findJudgeRawFiles(runDir, scenarioId, mode) {
|
|
|
205
217
|
: new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.judge\\.txt$`);
|
|
206
218
|
return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
|
|
207
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Path of a seeded scenario's staged-diff artifact within a run dir (rep-suffixed
|
|
222
|
+
* for reps).
|
|
223
|
+
*
|
|
224
|
+
* The diff is the only record of what the model actually *did* — the workspace is
|
|
225
|
+
* torn down after every rep, so without this a seeded verdict cannot be audited
|
|
226
|
+
* after the fact. Named `<id>.<mode>[.rep<k>].diff.txt` so it sorts beside its
|
|
227
|
+
* transcript and is covered by the `*.txt` rule in results/.gitignore: diffs are
|
|
228
|
+
* generated evidence, ignored like transcripts, not committed like results.yaml.
|
|
229
|
+
*/
|
|
230
|
+
export function diffPath(runDir, scenarioId, mode, rep) {
|
|
231
|
+
const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
|
|
232
|
+
return join(runDir, `${base}.diff.txt`);
|
|
233
|
+
}
|
|
234
|
+
/** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
|
|
235
|
+
export function findDiffFiles(runDir, scenarioId, mode) {
|
|
236
|
+
if (!existsSync(runDir))
|
|
237
|
+
return [];
|
|
238
|
+
const esc = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
239
|
+
const re = mode === undefined
|
|
240
|
+
? new RegExp(`^${esc}\\..*\\.diff\\.txt$`)
|
|
241
|
+
: new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.diff\\.txt$`);
|
|
242
|
+
return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
|
|
243
|
+
}
|
|
208
244
|
/** A single representative transcript file for a scenario in a run dir. Null if none. */
|
|
209
245
|
export function findTranscriptFile(runDir, scenarioId) {
|
|
210
246
|
return findTranscriptFiles(runDir, scenarioId)[0] ?? null;
|
|
211
247
|
}
|
|
212
248
|
/**
|
|
213
|
-
* Un-gitignore ALL of a scenario's transcript
|
|
214
|
-
* (audit trail for an override — a --reps run has one
|
|
215
|
-
*
|
|
216
|
-
*
|
|
249
|
+
* Un-gitignore ALL of a scenario's transcript, judge-raw AND staged-diff
|
|
250
|
+
* artifact files (audit trail for an override — a --reps run has one of each
|
|
251
|
+
* per rep, and every rep that drove the verdict must survive a commit, not just
|
|
252
|
+
* an arbitrary one).
|
|
217
253
|
* Appends `!<tag>/<ts>/<id>.<mode>[.rep<k>].txt` (and the matching
|
|
218
|
-
* `.judge.txt`) to results/.gitignore for each, once. The path
|
|
219
|
-
* separators so the negation matches on Windows too (git ignore
|
|
220
|
-
* always forward-slashed).
|
|
254
|
+
* `.judge.txt` / `.diff.txt`) to results/.gitignore for each, once. The path
|
|
255
|
+
* uses POSIX separators so the negation matches on Windows too (git ignore
|
|
256
|
+
* patterns are always forward-slashed).
|
|
257
|
+
*
|
|
258
|
+
* The diff belongs here for the same reason the judge-raw output does: an
|
|
259
|
+
* override says the judge got it wrong, and on a seeded scenario the evidence
|
|
260
|
+
* for that claim is the code the model wrote.
|
|
221
261
|
*/
|
|
222
262
|
export function preserveTranscript(resultsRoot, runDir, scenarioId) {
|
|
223
|
-
const files = [
|
|
263
|
+
const files = [
|
|
264
|
+
...findTranscriptFiles(runDir, scenarioId),
|
|
265
|
+
...findJudgeRawFiles(runDir, scenarioId),
|
|
266
|
+
...findDiffFiles(runDir, scenarioId),
|
|
267
|
+
];
|
|
224
268
|
if (files.length === 0)
|
|
225
269
|
return;
|
|
226
270
|
ensureResultsGitignore(resultsRoot);
|
package/dist/run.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Spec } from "./spec.js";
|
|
2
2
|
import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
|
|
3
3
|
import { type ResultsFile } from "./results.js";
|
|
4
|
+
import { type Lift } from "./lift.js";
|
|
4
5
|
export interface RunOptions {
|
|
5
6
|
spec: Spec;
|
|
6
7
|
skillDir: string;
|
|
@@ -17,6 +18,12 @@ export interface RunOptions {
|
|
|
17
18
|
concurrency?: number;
|
|
18
19
|
reps?: number;
|
|
19
20
|
passThreshold?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Run only these scenario ids — the iteration tool (re-testing 2 D-scenarios must not
|
|
23
|
+
* cost an 18-scenario run). The result is marked `partial: true` and is NEVER
|
|
24
|
+
* ship-graded: a subset passing says nothing about the ship bar.
|
|
25
|
+
*/
|
|
26
|
+
only?: string[];
|
|
20
27
|
}
|
|
21
28
|
export interface RunSummary {
|
|
22
29
|
runDir: string;
|
|
@@ -24,5 +31,22 @@ export interface RunSummary {
|
|
|
24
31
|
}
|
|
25
32
|
/** Run one skill against one model: run scenarios, grade, score, persist. */
|
|
26
33
|
export declare function runSkillModel(opts: RunOptions): Promise<RunSummary>;
|
|
34
|
+
/**
|
|
35
|
+
* True when any assistant turn in a transcript is blank — the shape a harness timeout
|
|
36
|
+
* leaves behind. Such a transcript must never reach the judge: grading an empty reply
|
|
37
|
+
* produces a confident FAIL about behavior that never happened (round 9 lost two
|
|
38
|
+
* scenarios this way). Sections are delimited by the adapters' shared transcript
|
|
39
|
+
* convention (">>> USER"/"<<< ASSISTANT:"); seeded gate output ("=== SEEDED GATES ===")
|
|
40
|
+
* ends the last assistant section.
|
|
41
|
+
*/
|
|
42
|
+
export declare function hasEmptyAssistantTurn(transcript: string): boolean;
|
|
27
43
|
/** A compact terminal scorecard for one run. */
|
|
28
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The human-facing scorecard for one run.
|
|
46
|
+
*
|
|
47
|
+
* `lift` is the red-vs-green comparison for this model when a red baseline
|
|
48
|
+
* exists. Passing it for a green run turns the scorecard from "the skill scored
|
|
49
|
+
* B" into "the skill *did* this much" — without a baseline the grade alone can't
|
|
50
|
+
* distinguish a skill that works from a model that never needed it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function formatScorecard(summary: RunSummary, lift?: Lift): string;
|
package/dist/run.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname } from "node:path";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { sourceHashes } from "./sources.js";
|
|
3
4
|
import { judgeResemblesSubject } from "./grade.js";
|
|
4
|
-
import { runDirFor, transcriptPath, writeResults, ensureResultsGitignore, } from "./results.js";
|
|
5
|
+
import { runDirFor, transcriptPath, diffPath, writeResults, ensureResultsGitignore, } from "./results.js";
|
|
5
6
|
import { appendJournal } from "./journal.js";
|
|
7
|
+
import { liftHeadline } from "./lift.js";
|
|
6
8
|
import { runSeeded } from "./seeded.js";
|
|
7
9
|
import { createWorkspace } from "./workspace.js";
|
|
8
10
|
import { runPool } from "./scheduler.js";
|
|
@@ -13,6 +15,20 @@ export async function runSkillModel(opts) {
|
|
|
13
15
|
const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
|
|
14
16
|
const log = opts.onProgress ?? (() => { });
|
|
15
17
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
18
|
+
// --only: validate against the spec BEFORE spending anything — a typo'd id must not
|
|
19
|
+
// silently run zero scenarios and report success.
|
|
20
|
+
let scenarios = spec.scenarios;
|
|
21
|
+
const partial = Boolean(opts.only && opts.only.length > 0);
|
|
22
|
+
if (partial) {
|
|
23
|
+
const known = new Set(spec.scenarios.map((s) => s.id));
|
|
24
|
+
const unknown = opts.only.filter((id) => !known.has(id));
|
|
25
|
+
if (unknown.length > 0) {
|
|
26
|
+
throw new Error(`--only names unknown scenario id(s) ${unknown.join(", ")} — spec has: ${[...known].join(", ")}`);
|
|
27
|
+
}
|
|
28
|
+
const wanted = new Set(opts.only);
|
|
29
|
+
scenarios = spec.scenarios.filter((s) => wanted.has(s.id));
|
|
30
|
+
log(` --only ${opts.only.join(",")} — partial run, will not be ship-graded`);
|
|
31
|
+
}
|
|
16
32
|
if (judgeResemblesSubject(judge, model)) {
|
|
17
33
|
log(` ⚠ judge (${judge.provider}:${judge.model}) resembles the model under test ` +
|
|
18
34
|
`(${model.provider}:${model.model}) — verdicts may be inflated. Use a distinct judge.`);
|
|
@@ -27,10 +43,10 @@ export async function runSkillModel(opts) {
|
|
|
27
43
|
mode, label: opts.label ?? null,
|
|
28
44
|
});
|
|
29
45
|
// scenario × rep tasks; runPool preserves input order so we can slice per scenario.
|
|
30
|
-
const repCounts =
|
|
46
|
+
const repCounts = scenarios.map((s) => s.reps ?? opts.reps ?? 1);
|
|
31
47
|
const owners = [];
|
|
32
48
|
const tasks = [];
|
|
33
|
-
|
|
49
|
+
scenarios.forEach((scenario, si) => {
|
|
34
50
|
for (let k = 0; k < repCounts[si]; k++) {
|
|
35
51
|
const rep = k;
|
|
36
52
|
const total = repCounts[si];
|
|
@@ -39,13 +55,13 @@ export async function runSkillModel(opts) {
|
|
|
39
55
|
}
|
|
40
56
|
});
|
|
41
57
|
const flat = await runPool(tasks, opts.concurrency ?? 1);
|
|
42
|
-
const grouped =
|
|
58
|
+
const grouped = scenarios.map(() => []);
|
|
43
59
|
flat.forEach((outcome, i) => grouped[owners[i]].push(outcome));
|
|
44
|
-
const scenarioResults =
|
|
60
|
+
const scenarioResults = scenarios.map((scenario, si) => {
|
|
45
61
|
const threshold = scenario.passThreshold ?? opts.passThreshold ?? 0.5;
|
|
46
62
|
return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
|
|
47
63
|
});
|
|
48
|
-
const ctx = mode === "green" ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
|
|
64
|
+
const ctx = mode === "green" && !partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
|
|
49
65
|
const results = writeResults(runDir, {
|
|
50
66
|
skill: spec.skill,
|
|
51
67
|
harness: adapter.name,
|
|
@@ -54,6 +70,10 @@ export async function runSkillModel(opts) {
|
|
|
54
70
|
timestamp,
|
|
55
71
|
label: opts.label ?? null,
|
|
56
72
|
mode,
|
|
73
|
+
...(partial ? { partial: true } : {}),
|
|
74
|
+
// Only the scenarios this run actually measured: a --only run must not claim
|
|
75
|
+
// coverage of scenarios it skipped.
|
|
76
|
+
source_hashes: sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios }),
|
|
57
77
|
scenarios: scenarioResults,
|
|
58
78
|
}, ctx);
|
|
59
79
|
if (ctx) {
|
|
@@ -62,6 +82,23 @@ export async function runSkillModel(opts) {
|
|
|
62
82
|
}
|
|
63
83
|
return { runDir, results };
|
|
64
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* True when any assistant turn in a transcript is blank — the shape a harness timeout
|
|
87
|
+
* leaves behind. Such a transcript must never reach the judge: grading an empty reply
|
|
88
|
+
* produces a confident FAIL about behavior that never happened (round 9 lost two
|
|
89
|
+
* scenarios this way). Sections are delimited by the adapters' shared transcript
|
|
90
|
+
* convention (">>> USER"/"<<< ASSISTANT:"); seeded gate output ("=== SEEDED GATES ===")
|
|
91
|
+
* ends the last assistant section.
|
|
92
|
+
*/
|
|
93
|
+
export function hasEmptyAssistantTurn(transcript) {
|
|
94
|
+
const sections = transcript.split(/^<<< ASSISTANT:\s*$/m).slice(1);
|
|
95
|
+
if (sections.length === 0)
|
|
96
|
+
return false;
|
|
97
|
+
return sections.some((sec) => {
|
|
98
|
+
const body = sec.split(/^(?:>>> |=== SEEDED GATES ===|\[pi exited )/m)[0];
|
|
99
|
+
return body.trim() === "";
|
|
100
|
+
});
|
|
101
|
+
}
|
|
65
102
|
/** Run ONE rep of a scenario in its own isolated workspace. */
|
|
66
103
|
async function runRep(scenario, rep, repCount, ctx) {
|
|
67
104
|
const { spec, judge, mode, runDir, now, log } = ctx;
|
|
@@ -73,37 +110,75 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
73
110
|
let ws = null;
|
|
74
111
|
let transcript = "";
|
|
75
112
|
let gatePrefix = null;
|
|
113
|
+
// Null until a seeded rep actually reaches its gates: a workspace-setup failure
|
|
114
|
+
// produces no diff, and writing an empty artifact there would misreport "the
|
|
115
|
+
// model changed nothing" for a rep that never ran.
|
|
116
|
+
let stagedDiff = null;
|
|
76
117
|
try {
|
|
77
118
|
try {
|
|
78
|
-
ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath) });
|
|
119
|
+
ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
|
|
79
120
|
}
|
|
80
121
|
catch (e) {
|
|
81
122
|
// A setup failure (e.g. missing fixture) is an objective FAIL, not an infra abort.
|
|
82
123
|
gatePrefix = e instanceof Error ? e.message : String(e);
|
|
83
124
|
transcript = `[workspace setup failed] ${gatePrefix}`;
|
|
84
125
|
}
|
|
126
|
+
let noResponse = false;
|
|
85
127
|
if (ws) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
128
|
+
// A blank assistant turn is a harness timeout, not model behavior: retry ONCE in a
|
|
129
|
+
// fresh workspace (the first attempt may have half-mutated a seeded repo), and if
|
|
130
|
+
// it happens again the verdict is ERROR — never a judged FAIL on an empty reply.
|
|
131
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
132
|
+
if (attempt > 0) {
|
|
133
|
+
appendJournal(runDir, { event: "empty-response-retry", ts: now(), id: scenario.id, attempt, ...repField });
|
|
134
|
+
log(` ${scenario.id}${repCount > 1 ? `#${rep}` : ""} empty response — retrying once`);
|
|
135
|
+
ws.cleanup();
|
|
136
|
+
ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
|
|
137
|
+
}
|
|
138
|
+
if (scenario.mode === "seeded") {
|
|
139
|
+
const r = await runSeeded(scenario, {
|
|
140
|
+
skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
|
|
141
|
+
specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
|
|
142
|
+
});
|
|
143
|
+
transcript = r.transcript;
|
|
144
|
+
gatePrefix = r.gateFailure;
|
|
145
|
+
stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
transcript = await ctx.adapter.run({
|
|
149
|
+
skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
|
|
150
|
+
// resolved like fixtures: relative to the spec's dir
|
|
151
|
+
systemPromptFile: scenario.systemPromptFile
|
|
152
|
+
? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
|
|
153
|
+
: undefined,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
noResponse = hasEmptyAssistantTurn(transcript);
|
|
157
|
+
if (!noResponse)
|
|
158
|
+
break;
|
|
97
159
|
}
|
|
98
160
|
}
|
|
99
|
-
|
|
161
|
+
const repSuffix = repCount > 1 ? rep : undefined;
|
|
162
|
+
writeFileSync(transcriptPath(runDir, scenario.id, mode, repSuffix), transcript, "utf8");
|
|
100
163
|
if (scenario.mode === "seeded") {
|
|
164
|
+
// The workspace is torn down in the `finally` below, so this is the only
|
|
165
|
+
// chance to keep what the model actually wrote. Persisted uncapped (the
|
|
166
|
+
// transcript's copy is capped for the judge) and for every rep, pass or
|
|
167
|
+
// fail — a gate failure is exactly when you want to read the diff.
|
|
168
|
+
if (stagedDiff !== null) {
|
|
169
|
+
writeFileSync(diffPath(runDir, scenario.id, mode, repSuffix), stagedDiff, "utf8");
|
|
170
|
+
}
|
|
101
171
|
appendJournal(runDir, { event: "gate-result", ts: now(), id: scenario.id, ok: !gatePrefix, detail: gatePrefix ?? "", ...repField });
|
|
102
172
|
}
|
|
103
173
|
let verdict;
|
|
104
174
|
let reason;
|
|
105
175
|
let suspect = false;
|
|
106
|
-
if (
|
|
176
|
+
if (noResponse) {
|
|
177
|
+
verdict = "ERROR";
|
|
178
|
+
reason = "model produced no response after a retry (harness timeout?) — infra, not skill behavior";
|
|
179
|
+
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict, reason, suspect, ...repField });
|
|
180
|
+
}
|
|
181
|
+
else if (gatePrefix) {
|
|
107
182
|
verdict = "FAIL";
|
|
108
183
|
reason = gatePrefix;
|
|
109
184
|
// gate failures don't invoke the judge, but still record a judge-verdict event (as before)
|
|
@@ -126,7 +201,15 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
126
201
|
}
|
|
127
202
|
}
|
|
128
203
|
/** A compact terminal scorecard for one run. */
|
|
129
|
-
|
|
204
|
+
/**
|
|
205
|
+
* The human-facing scorecard for one run.
|
|
206
|
+
*
|
|
207
|
+
* `lift` is the red-vs-green comparison for this model when a red baseline
|
|
208
|
+
* exists. Passing it for a green run turns the scorecard from "the skill scored
|
|
209
|
+
* B" into "the skill *did* this much" — without a baseline the grade alone can't
|
|
210
|
+
* distinguish a skill that works from a model that never needed it.
|
|
211
|
+
*/
|
|
212
|
+
export function formatScorecard(summary, lift) {
|
|
130
213
|
const { results } = summary;
|
|
131
214
|
const g = results.effective_grade;
|
|
132
215
|
const lines = [];
|
|
@@ -143,6 +226,16 @@ export function formatScorecard(summary) {
|
|
|
143
226
|
const ship = g.ship ? "SHIP" : "NOT READY";
|
|
144
227
|
const note = g.note ? ` (${g.note})` : "";
|
|
145
228
|
lines.push(` GRADE: ${g.letter} (${g.pct}%) — ${g.passed}/${g.total} — ${ship}${note}`);
|
|
229
|
+
// Lift is a statement about a green run. On a red run the caller may still have
|
|
230
|
+
// a lift in hand (a green run exists in the same tag), but printing it under a
|
|
231
|
+
// baseline scorecard reads as if the baseline itself gained something.
|
|
232
|
+
if (lift && results.mode === "green") {
|
|
233
|
+
lines.push(` LIFT: ${liftHeadline(lift)} (vs red baseline ${lift.redTimestamp})`);
|
|
234
|
+
}
|
|
235
|
+
else if (results.mode === "green") {
|
|
236
|
+
// The grade alone can't answer "does this skill do anything?", so say how.
|
|
237
|
+
lines.push(` LIFT: no red baseline — run with --mode red to measure what the skill adds`);
|
|
238
|
+
}
|
|
146
239
|
return lines.join("\n");
|
|
147
240
|
}
|
|
148
241
|
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Marker written into an `init` template's first comment. Its presence tells
|
|
2
|
+
* `suggest` the file is an unadopted template it may overwrite without --force. */
|
|
3
|
+
export declare const TEMPLATE_SENTINEL = "skill-harness: generated template";
|
|
4
|
+
/** Render a commented, empty-but-valid specification.yaml for a skill. */
|
|
5
|
+
export declare function renderTemplateSpec(skillName: string): string;
|
|
6
|
+
/** True if the text still carries the template sentinel (i.e. an unadopted template). */
|
|
7
|
+
export declare function isTemplateSpec(text: string): boolean;
|
|
8
|
+
export interface DraftScenario {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
turns: string[];
|
|
12
|
+
checklist: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface SuggestDraft {
|
|
15
|
+
judge_persona: string;
|
|
16
|
+
ship_bar: {
|
|
17
|
+
total: number;
|
|
18
|
+
min_pass: number;
|
|
19
|
+
no_critical_fail: boolean;
|
|
20
|
+
};
|
|
21
|
+
proposed_critical: string[];
|
|
22
|
+
scenarios: DraftScenario[];
|
|
23
|
+
}
|
|
24
|
+
/** Render a populated spec from an LLM draft. Strings are JSON-encoded (valid YAML
|
|
25
|
+
* flow scalars) so colons/quotes never break the file. Carries no sentinel. */
|
|
26
|
+
export declare function renderDraftSpec(skillName: string, draft: SuggestDraft): string;
|
|
27
|
+
export declare function buildSuggestPrompt(skillName: string, skillMd: string): string;
|
|
28
|
+
export declare function parseSuggestDraft(raw: string): SuggestDraft;
|
package/dist/scaffold.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/** Marker written into an `init` template's first comment. Its presence tells
|
|
2
|
+
* `suggest` the file is an unadopted template it may overwrite without --force. */
|
|
3
|
+
export const TEMPLATE_SENTINEL = "skill-harness: generated template";
|
|
4
|
+
/** Render a commented, empty-but-valid specification.yaml for a skill. */
|
|
5
|
+
export function renderTemplateSpec(skillName) {
|
|
6
|
+
return `# ${TEMPLATE_SENTINEL} — \`suggest\` will overwrite this file while
|
|
7
|
+
# this line is present; delete it once you start editing by hand.
|
|
8
|
+
skill: ${skillName}
|
|
9
|
+
|
|
10
|
+
# How the LLM judge should role-play when grading transcripts.
|
|
11
|
+
judge_persona: >
|
|
12
|
+
a careful, fair reviewer.
|
|
13
|
+
|
|
14
|
+
# The ship bar: what it takes to SHIP.
|
|
15
|
+
# total = scenarios counted toward the bar
|
|
16
|
+
# min_pass = minimum passes required
|
|
17
|
+
# no_critical_fail = a critical-id fail blocks SHIP even if min_pass is met
|
|
18
|
+
ship_bar:
|
|
19
|
+
total: 1
|
|
20
|
+
min_pass: 1
|
|
21
|
+
no_critical_fail: true
|
|
22
|
+
|
|
23
|
+
# Scenario ids that block the ship if they fail (or set \`critical: true\` per scenario).
|
|
24
|
+
critical: []
|
|
25
|
+
|
|
26
|
+
scenarios:
|
|
27
|
+
# A* = baseline capability · B* = under-pressure / adversarial
|
|
28
|
+
- id: A1
|
|
29
|
+
title: describe what this scenario checks
|
|
30
|
+
# critical: true # uncomment to gate the ship on this scenario
|
|
31
|
+
turns:
|
|
32
|
+
- "the user's first message"
|
|
33
|
+
# - "a follow-up message for a multi-turn scenario"
|
|
34
|
+
checklist:
|
|
35
|
+
- "an observable thing the response must do"
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
/** True if the text still carries the template sentinel (i.e. an unadopted template). */
|
|
39
|
+
export function isTemplateSpec(text) {
|
|
40
|
+
return text.includes(TEMPLATE_SENTINEL);
|
|
41
|
+
}
|
|
42
|
+
/** Render a populated spec from an LLM draft. Strings are JSON-encoded (valid YAML
|
|
43
|
+
* flow scalars) so colons/quotes never break the file. Carries no sentinel. */
|
|
44
|
+
export function renderDraftSpec(skillName, draft) {
|
|
45
|
+
const scenarioBlocks = draft.scenarios
|
|
46
|
+
.map((s) => {
|
|
47
|
+
const turns = s.turns.map((t) => ` - ${JSON.stringify(t)}`).join("\n");
|
|
48
|
+
const checks = s.checklist.map((c) => ` - ${JSON.stringify(c)}`).join("\n");
|
|
49
|
+
return ` - id: ${s.id}\n title: ${JSON.stringify(s.title)}\n turns:\n${turns}\n checklist:\n${checks}`;
|
|
50
|
+
})
|
|
51
|
+
.join("\n");
|
|
52
|
+
const proposed = draft.proposed_critical.length
|
|
53
|
+
? `# proposed critical: [${draft.proposed_critical.join(", ")}] — move ids into \`critical: []\` below after review.`
|
|
54
|
+
: `# proposed critical: (none) — mark any ship-gating scenarios in \`critical: []\` below.`;
|
|
55
|
+
return `skill: ${skillName}
|
|
56
|
+
|
|
57
|
+
# REVIEW: does this judge persona fit the skill? Edit freely.
|
|
58
|
+
judge_persona: ${JSON.stringify(draft.judge_persona)}
|
|
59
|
+
|
|
60
|
+
# REVIEW: tune the ship bar before your first run.
|
|
61
|
+
ship_bar:
|
|
62
|
+
total: ${draft.ship_bar.total}
|
|
63
|
+
min_pass: ${draft.ship_bar.min_pass}
|
|
64
|
+
no_critical_fail: ${draft.ship_bar.no_critical_fail}
|
|
65
|
+
|
|
66
|
+
${proposed}
|
|
67
|
+
critical: []
|
|
68
|
+
|
|
69
|
+
scenarios:
|
|
70
|
+
${scenarioBlocks}
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
export function buildSuggestPrompt(skillName, skillMd) {
|
|
74
|
+
return `You are drafting a test specification for an agent skill named "${skillName}".
|
|
75
|
+
Below is its SKILL.md. Propose scenarios that check whether an agent following this
|
|
76
|
+
skill behaves correctly, including at least one adversarial / under-pressure case.
|
|
77
|
+
|
|
78
|
+
Return ONLY a JSON object (no prose, no markdown fences) with exactly this shape:
|
|
79
|
+
{
|
|
80
|
+
"judge_persona": "<how a judge should role-play when grading transcripts>",
|
|
81
|
+
"ship_bar": { "total": <int>, "min_pass": <int>, "no_critical_fail": true },
|
|
82
|
+
"proposed_critical": ["<scenario id you think should gate the ship>", ...],
|
|
83
|
+
"scenarios": [
|
|
84
|
+
{ "id": "A1", "title": "<short title>",
|
|
85
|
+
"turns": ["<the user's message>", "<optional follow-up turns>"],
|
|
86
|
+
"checklist": ["<an observable thing the response must do>", ...] }
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
Use ids A1, A2, ... for baseline scenarios and B1, B2, ... for adversarial ones.
|
|
90
|
+
Every scenario needs at least one turn and one checklist item.
|
|
91
|
+
|
|
92
|
+
--- SKILL.md ---
|
|
93
|
+
${skillMd}`;
|
|
94
|
+
}
|
|
95
|
+
/** Ids are interpolated raw into YAML (see renderDraftSpec); restrict the character
|
|
96
|
+
* set so a crafted id can never inject extra YAML keys (e.g. `critical: true`). */
|
|
97
|
+
const SAFE_ID = /^[A-Za-z0-9_-]+$/;
|
|
98
|
+
function asStringArray(v, ctx) {
|
|
99
|
+
if (!Array.isArray(v) || v.length === 0 || v.some((x) => typeof x !== "string")) {
|
|
100
|
+
throw new Error(`${ctx} must be a non-empty array of strings`);
|
|
101
|
+
}
|
|
102
|
+
return v;
|
|
103
|
+
}
|
|
104
|
+
/** Extract the first complete top-level JSON object from a model reply, tolerating
|
|
105
|
+
* surrounding prose or ```json fences — including trailing text that itself
|
|
106
|
+
* contains braces (e.g. "…} Does {this} work?"). Scans brace depth while skipping
|
|
107
|
+
* string contents, so the object ends at its own matching `}`, not the last `}`
|
|
108
|
+
* anywhere in the reply. */
|
|
109
|
+
function extractJsonObject(raw) {
|
|
110
|
+
const start = raw.indexOf("{");
|
|
111
|
+
if (start < 0)
|
|
112
|
+
throw new Error("no JSON object in model output");
|
|
113
|
+
let depth = 0;
|
|
114
|
+
let inStr = false;
|
|
115
|
+
let escaped = false;
|
|
116
|
+
for (let i = start; i < raw.length; i++) {
|
|
117
|
+
const ch = raw[i];
|
|
118
|
+
if (inStr) {
|
|
119
|
+
if (escaped)
|
|
120
|
+
escaped = false;
|
|
121
|
+
else if (ch === "\\")
|
|
122
|
+
escaped = true;
|
|
123
|
+
else if (ch === '"')
|
|
124
|
+
inStr = false;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (ch === '"')
|
|
128
|
+
inStr = true;
|
|
129
|
+
else if (ch === "{")
|
|
130
|
+
depth++;
|
|
131
|
+
else if (ch === "}" && --depth === 0)
|
|
132
|
+
return raw.slice(start, i + 1);
|
|
133
|
+
}
|
|
134
|
+
throw new Error("no complete JSON object in model output");
|
|
135
|
+
}
|
|
136
|
+
export function parseSuggestDraft(raw) {
|
|
137
|
+
let obj;
|
|
138
|
+
try {
|
|
139
|
+
obj = JSON.parse(extractJsonObject(raw));
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
if (e instanceof Error && e.message.includes("JSON object in model output"))
|
|
143
|
+
throw e;
|
|
144
|
+
throw new Error(`model output is not valid JSON — ${e.message}`);
|
|
145
|
+
}
|
|
146
|
+
if (typeof obj.judge_persona !== "string" || !obj.judge_persona.trim()) {
|
|
147
|
+
throw new Error("judge_persona must be a non-empty string");
|
|
148
|
+
}
|
|
149
|
+
const sb = obj.ship_bar;
|
|
150
|
+
if (!sb || typeof sb.total !== "number" || typeof sb.min_pass !== "number") {
|
|
151
|
+
throw new Error("ship_bar must have numeric total and min_pass");
|
|
152
|
+
}
|
|
153
|
+
if (sb.min_pass > sb.total) {
|
|
154
|
+
throw new Error(`ship_bar.min_pass (${sb.min_pass}) cannot exceed total (${sb.total})`);
|
|
155
|
+
}
|
|
156
|
+
const proposed = Array.isArray(obj.proposed_critical)
|
|
157
|
+
? obj.proposed_critical.filter((x) => typeof x === "string" && SAFE_ID.test(x))
|
|
158
|
+
: [];
|
|
159
|
+
if (!Array.isArray(obj.scenarios) || obj.scenarios.length === 0) {
|
|
160
|
+
throw new Error("scenarios must be a non-empty array");
|
|
161
|
+
}
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
const scenarios = obj.scenarios.map((raw2, i) => {
|
|
164
|
+
const s = raw2;
|
|
165
|
+
if (typeof s.id !== "string" || !s.id.trim())
|
|
166
|
+
throw new Error(`scenario #${i + 1} needs a string id`);
|
|
167
|
+
if (!SAFE_ID.test(s.id))
|
|
168
|
+
throw new Error(`scenario id \`${s.id}\` must be alphanumeric (A-Z a-z 0-9 _ -)`);
|
|
169
|
+
if (seen.has(s.id))
|
|
170
|
+
throw new Error(`duplicate scenario id \`${s.id}\``);
|
|
171
|
+
seen.add(s.id);
|
|
172
|
+
if (typeof s.title !== "string" || !s.title.trim())
|
|
173
|
+
throw new Error(`scenario ${s.id} needs a title`);
|
|
174
|
+
return {
|
|
175
|
+
id: s.id,
|
|
176
|
+
title: s.title,
|
|
177
|
+
turns: asStringArray(s.turns, `scenario ${s.id} turns`),
|
|
178
|
+
checklist: asStringArray(s.checklist, `scenario ${s.id} checklist`),
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
judge_persona: obj.judge_persona,
|
|
183
|
+
ship_bar: { total: sb.total, min_pass: sb.min_pass, no_critical_fail: sb.no_critical_fail !== false },
|
|
184
|
+
proposed_critical: proposed,
|
|
185
|
+
scenarios,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=scaffold.js.map
|
package/dist/score.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { ShipBar } from "./spec.js";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* "JUDGE-AMBIGUOUS": the judge emitted conflicting verdicts for one transcript. It is
|
|
4
|
+
* never a pass and never silently resolved — it marks the run for a rejudge.
|
|
5
|
+
*/
|
|
6
|
+
export type Verdict = "PASS" | "FAIL" | "ERROR" | "JUDGE-AMBIGUOUS";
|
|
3
7
|
export interface ScenarioVerdict {
|
|
4
8
|
id: string;
|
|
5
9
|
verdict: Verdict;
|