@skill-harness/core 0.2.1 → 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/grade.d.ts +2 -0
- package/dist/grade.js +26 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +85 -18
- package/dist/results.d.ts +36 -13
- package/dist/results.js +54 -13
- package/dist/run.js +21 -33
- package/dist/seeded.d.ts +83 -3
- package/dist/seeded.js +313 -14
- package/dist/sources.d.ts +115 -0
- package/dist/sources.js +253 -0
- package/dist/spec.d.ts +15 -0
- package/dist/spec.js +31 -0
- package/dist/workspace.d.ts +28 -0
- package/dist/workspace.js +50 -7
- package/package.json +1 -1
package/dist/grade.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface JudgePromptInput {
|
|
|
7
7
|
scenario: Scenario;
|
|
8
8
|
transcript: string;
|
|
9
9
|
}
|
|
10
|
+
/** The heading runSeeded writes above the staged diff. Gating on this exact string is what keeps the guidance honest — see below. */
|
|
11
|
+
export declare const STAGED_DIFF_HEADING = "=== STAGED DIFF ===";
|
|
10
12
|
/** Build the LLM-judge prompt for one transcript (ported from the old grade.sh). */
|
|
11
13
|
export declare function buildJudgePrompt(input: JudgePromptInput): string;
|
|
12
14
|
export interface ParsedVerdict {
|
package/dist/grade.js
CHANGED
|
@@ -1,8 +1,33 @@
|
|
|
1
1
|
import { createWorkspace } from "./workspace.js";
|
|
2
|
+
/** The heading runSeeded writes above the staged diff. Gating on this exact string is what keeps the guidance honest — see below. */
|
|
3
|
+
export const STAGED_DIFF_HEADING = "=== STAGED DIFF ===";
|
|
4
|
+
/**
|
|
5
|
+
* Addendum pointing the judge at the code, added only when the code is actually there.
|
|
6
|
+
*
|
|
7
|
+
* A seeded transcript ends with the staged diff, and without this the judge
|
|
8
|
+
* weighs the model's prose about its work equally with the work itself — which
|
|
9
|
+
* is how six reps that all passed the objective gates split PASS/FAIL purely on
|
|
10
|
+
* whether the model wrote "rejects overdrafts" or "subtracts amount".
|
|
11
|
+
*
|
|
12
|
+
* Gated on the transcript CONTAINING the diff section, not on `scenario.mode`.
|
|
13
|
+
* The first sentence is a factual claim about the transcript, and every seeded
|
|
14
|
+
* transcript saved before this feature existed lacks that section — so keying
|
|
15
|
+
* off the mode would tell the judge its primary evidence is at the end of a
|
|
16
|
+
* transcript that has none, while also telling it the gate lines prove nothing.
|
|
17
|
+
* That is a sweep of FAILs justified by absent evidence, and it would land
|
|
18
|
+
* squarely on `grade`, the command AGENTS.md rule 4 recommends as the *cheap*
|
|
19
|
+
* de-confounding step before re-running. Inline scenarios have no diff either,
|
|
20
|
+
* so their prompt stays byte-identical and every published inline verdict
|
|
21
|
+
* remains comparable.
|
|
22
|
+
*/
|
|
23
|
+
const SEEDED_DIFF_GUIDANCE = `
|
|
24
|
+
This transcript ends with a "=== STAGED DIFF ===" section: the actual code the assistant wrote, as \`git diff --cached\`. It is the primary evidence. Grade what the diff shows the code DOES, not what the assistant's prose claims it does — a confident description of behavior the diff does not implement is a FAIL, and behavior the diff plainly implements passes even if the assistant described it poorly or not at all. The "=== SEEDED GATES ===" lines above it are keyword and test-run checks only; they do not establish that the required behavior exists. If the diff is marked truncated, judge only what you can see and never infer that cut-off code is missing.
|
|
25
|
+
`;
|
|
2
26
|
/** Build the LLM-judge prompt for one transcript (ported from the old grade.sh). */
|
|
3
27
|
export function buildJudgePrompt(input) {
|
|
4
28
|
const { skill, persona, scenario, transcript } = input;
|
|
5
29
|
const numbered = scenario.checklist.map((c, i) => `${i + 1}. ${c}`).join("\n");
|
|
30
|
+
const diffGuidance = scenario.mode === "seeded" && transcript.includes(STAGED_DIFF_HEADING) ? SEEDED_DIFF_GUIDANCE : "";
|
|
6
31
|
return `You are grading ONE response from an AI assistant using a "${skill}" skill — ${persona} Judge it ONLY against the checklist below — do not add requirements beyond it.
|
|
7
32
|
|
|
8
33
|
CHECKLIST (every numbered item must hold for a PASS):
|
|
@@ -10,7 +35,7 @@ ${numbered}
|
|
|
10
35
|
|
|
11
36
|
TRANSCRIPT (the assistant is the model under test):
|
|
12
37
|
${transcript}
|
|
13
|
-
|
|
38
|
+
${diffGuidance}
|
|
14
39
|
Grade each checklist item PASS or FAIL with a <=12-word justification quoting the transcript. Be skeptical: if an item is not clearly satisfied, mark it FAIL. Then output exactly these two lines:
|
|
15
40
|
VERDICT: PASS (only if EVERY item passed) — or — VERDICT: FAIL
|
|
16
41
|
REASON: <15 words or fewer>`;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/lint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "consistency" | "stale" | "lint-error";
|
|
1
|
+
export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "lint-error";
|
|
2
2
|
export interface LintFinding {
|
|
3
3
|
readonly skill: string;
|
|
4
4
|
readonly scenario?: string;
|
package/dist/lint.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
3
|
import yaml from "js-yaml";
|
|
5
4
|
import { loadSpec, SpecError } from "./spec.js";
|
|
6
5
|
import { readResults, finalizeResults, findTranscriptFiles, resultsPath } from "./results.js";
|
|
6
|
+
import { currentHashFor, describeSourceKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, UNREADABLE } from "./sources.js";
|
|
7
|
+
import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
|
|
7
8
|
/** True if `p` exists and is a directory. Never throws (TOCTOU-safe: a race or dangling
|
|
8
9
|
* symlink between the check and the stat is treated as "not a directory", not an error). */
|
|
9
10
|
function isDir(p) {
|
|
@@ -65,12 +66,58 @@ export function lintSkill(skillDir) {
|
|
|
65
66
|
// relative to the spec's dir, matching workspace.ts resolve(specDir, fixture) where specDir = <skillDir>/tests.
|
|
66
67
|
const specDir = dirname(specPath);
|
|
67
68
|
for (const s of spec.scenarios) {
|
|
68
|
-
const fx =
|
|
69
|
+
const fx = effectiveFixture(s); // shared with sources.ts so hashing and linting can't drift
|
|
69
70
|
if (fx) {
|
|
70
71
|
const abs = isAbsolute(fx) ? fx : resolve(specDir, fx);
|
|
71
72
|
if (!isDir(abs)) {
|
|
72
73
|
findings.push({ skill, scenario: s.id, code: "fixture", message: `fixture not found: ${fx}` });
|
|
73
74
|
}
|
|
75
|
+
else {
|
|
76
|
+
// A mistyped fixture marker (`_uncommited/`) is rejected at run time by
|
|
77
|
+
// createWorkspace — but only once a run has been started, where it surfaces as
|
|
78
|
+
// a scenario FAIL among real results. lint is free, offline and runs in CI, so
|
|
79
|
+
// it is where an author should meet this. The set flagged here is exactly the
|
|
80
|
+
// set workspace.ts refuses, so lint can never bless a fixture the runtime then
|
|
81
|
+
// rejects.
|
|
82
|
+
// try/catch because lintSkill's contract is "never throws": isDir() proves the
|
|
83
|
+
// path stats, not that it can be read, and an EACCES on readdir would escape
|
|
84
|
+
// to cli.ts, which replaces this skill's ENTIRE finding list with one
|
|
85
|
+
// lint-error — silently discarding the staleness and consistency checks below.
|
|
86
|
+
let markers = [];
|
|
87
|
+
try {
|
|
88
|
+
markers = unknownMarkerDirs(abs);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
findings.push({
|
|
92
|
+
skill, scenario: s.id, code: "fixture",
|
|
93
|
+
message: `fixture ${fx} could not be read: ${e instanceof Error ? e.message : String(e)}`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
for (const dir of markers) {
|
|
97
|
+
const guess = suggestMarker(dir);
|
|
98
|
+
findings.push({
|
|
99
|
+
skill,
|
|
100
|
+
scenario: s.id,
|
|
101
|
+
code: "fixture-marker",
|
|
102
|
+
message: `fixture ${fx} has unknown top-level marker directory \`${dir}/\`` +
|
|
103
|
+
(guess ? ` — did you mean \`${guess}/\`?` : "") +
|
|
104
|
+
` Known markers are ${MARKERS.map((m) => `\`${m}/\``).join(" and ")};` +
|
|
105
|
+
` rename it, or move it deeper if it is ordinary content.`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// assert.post_test must exist. A post-test that isn't there fails its scenario at
|
|
112
|
+
// run time with a "spec error" message, but that costs a full model run to discover
|
|
113
|
+
// — and lint is the free, offline gate that exists to catch it first.
|
|
114
|
+
for (const s of spec.scenarios) {
|
|
115
|
+
const pt = s.assert?.post_test;
|
|
116
|
+
if (!pt)
|
|
117
|
+
continue;
|
|
118
|
+
const abs = isAbsolute(pt) ? pt : resolve(specDir, pt);
|
|
119
|
+
if (!isFile(abs)) {
|
|
120
|
+
findings.push({ skill, scenario: s.id, code: "fixture", message: `assert.post_test not found: ${pt}` });
|
|
74
121
|
}
|
|
75
122
|
}
|
|
76
123
|
// system_prompt_file must exist — an agent-file scenario silently falling back to
|
|
@@ -124,10 +171,12 @@ export function lintSkill(skillDir) {
|
|
|
124
171
|
}
|
|
125
172
|
}
|
|
126
173
|
// staleness — the newest FULL (non-partial) run per model tag recorded sha256 hashes of
|
|
127
|
-
// every source
|
|
128
|
-
//
|
|
129
|
-
// a 100%-SHIP table for four
|
|
130
|
-
//
|
|
174
|
+
// every source it measured: SKILL.md, agent files, each scenario's definition and each
|
|
175
|
+
// fixture tree. If any has changed since, the committed result describes inputs that no
|
|
176
|
+
// longer exist — exactly how three regressions hid behind a 100%-SHIP table for four
|
|
177
|
+
// weeks, and how a swapped fixture went unreported by a "7 skills, 0 findings" lint.
|
|
178
|
+
// Runs predating source_hashes (or predating a given key kind) are skipped silently — no
|
|
179
|
+
// retroactive noise; partial runs never count as coverage.
|
|
131
180
|
for (const tagDir of enumerateTagDirs(resultsRoot)) {
|
|
132
181
|
// Newest FULL run: partial (--only) runs are iteration artifacts and never count as
|
|
133
182
|
// coverage — a fresh partial must not silence a stale full run underneath it.
|
|
@@ -149,28 +198,46 @@ export function lintSkill(skillDir) {
|
|
|
149
198
|
continue; // predates source_hashes → silent
|
|
150
199
|
{
|
|
151
200
|
const newest = full.runDir;
|
|
201
|
+
const ctx = { skillDir, specDir, scenarios: spec.scenarios };
|
|
152
202
|
for (const [key, recorded] of Object.entries(hashes)) {
|
|
153
|
-
const
|
|
154
|
-
const
|
|
203
|
+
const what = describeSourceKey(key);
|
|
204
|
+
const scenario = scenarioIdForKey(key, spec.scenarios);
|
|
205
|
+
// The run itself failed to hash this source, so it was never verified and
|
|
206
|
+
// no comparison here can establish anything about it.
|
|
207
|
+
if (recorded === UNREADABLE) {
|
|
208
|
+
findings.push({ skill, scenario, code: "stale", message: `${what} could not be read when the newest ${basename(tagDir)} run was recorded (${newest}) — that source was never verified; re-run` });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const current = currentHashFor(key, ctx);
|
|
212
|
+
// undefined = not comparable: a scenario the spec no longer has (a reshape,
|
|
213
|
+
// per the scenario-set check above), or a key kind written by a newer
|
|
214
|
+
// skill-harness than this one.
|
|
215
|
+
if (current === undefined)
|
|
216
|
+
continue;
|
|
155
217
|
if (current === null) {
|
|
156
|
-
findings.push({ skill, code: "stale", message: `${
|
|
218
|
+
findings.push({ skill, scenario, code: "stale", message: `${what} no longer exists but the newest ${basename(tagDir)} run measured it (${newest})` });
|
|
157
219
|
}
|
|
158
220
|
else if (current !== recorded) {
|
|
159
|
-
findings.push({ skill, code: "stale", message: `${
|
|
221
|
+
findings.push({ skill, scenario, code: "stale", message: `${what} changed since the newest ${basename(tagDir)} run (${newest}) — results are stale; re-run before publishing` });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Coverage: a scenario the spec defines that the newest full run never
|
|
225
|
+
// measured. Without this, RENAMING a scenario is invisible — the old key
|
|
226
|
+
// resolves to "not comparable" and the new one was never recorded — so a
|
|
227
|
+
// 100%/SHIP scorecard survives an arbitrary spec rewrite reporting zero
|
|
228
|
+
// findings. Gated on the run having recorded scenario keys at all, so runs
|
|
229
|
+
// predating the key kind stay silent like every other pre-existing run.
|
|
230
|
+
if (Object.keys(hashes).some((k) => k.startsWith(SCENARIO_PREFIX))) {
|
|
231
|
+
for (const s of spec.scenarios) {
|
|
232
|
+
if (!(SCENARIO_PREFIX + s.id in hashes)) {
|
|
233
|
+
findings.push({ skill, scenario: s.id, code: "stale", message: `the newest ${basename(tagDir)} run (${newest}) did not measure scenario \`${s.id}\` — the published result covers a different scenario set than the spec; re-run before publishing` });
|
|
234
|
+
}
|
|
160
235
|
}
|
|
161
236
|
}
|
|
162
237
|
}
|
|
163
238
|
}
|
|
164
239
|
return findings;
|
|
165
240
|
}
|
|
166
|
-
function fileSha256(p) {
|
|
167
|
-
try {
|
|
168
|
-
return createHash("sha256").update(readFileSync(p)).digest("hex");
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
241
|
/** Model-tag dirs under tests/results (each holds timestamped run dirs). */
|
|
175
242
|
function enumerateTagDirs(resultsRoot) {
|
|
176
243
|
if (!existsSync(resultsRoot))
|
package/dist/results.d.ts
CHANGED
|
@@ -38,10 +38,13 @@ export interface ResultsFile {
|
|
|
38
38
|
/** True for an `--only`-filtered run: a scenario subset, never ship-graded, never a release run. */
|
|
39
39
|
partial?: boolean;
|
|
40
40
|
/**
|
|
41
|
-
* sha256 of every source
|
|
42
|
-
* system_prompt_file
|
|
43
|
-
*
|
|
44
|
-
*
|
|
41
|
+
* sha256 of every source this run measured: SKILL.md, each distinct
|
|
42
|
+
* system_prompt_file, each scenario's definition (`scenario:<id>`) and each
|
|
43
|
+
* fixture tree (`fixture:<path>`). Lint compares the newest run's hashes against
|
|
44
|
+
* the current sources — a mismatch means the published result describes inputs
|
|
45
|
+
* that no longer exist (the stale-scorecard class this field exists to kill).
|
|
46
|
+
* See sources.ts for the key scheme; runs recorded before a key kind existed
|
|
47
|
+
* simply don't carry it, and are never retroactively flagged.
|
|
45
48
|
*/
|
|
46
49
|
source_hashes?: Record<string, string>;
|
|
47
50
|
effective_grade: GradeSummary;
|
|
@@ -83,7 +86,7 @@ export declare function applyOverride(results: ResultsFile, scenarioId: string,
|
|
|
83
86
|
* `!…` preservation lines added by preserveTranscript.
|
|
84
87
|
*/
|
|
85
88
|
export declare function ensureResultsGitignore(resultsRoot: string): void;
|
|
86
|
-
/** The rep index embedded in a transcript/judge-raw filename (`.rep<k>.`), or null for a plain (non-rep) file. */
|
|
89
|
+
/** The rep index embedded in a transcript / judge-raw / staged-diff filename (`.rep<k>.`), or null for a plain (non-rep) file. */
|
|
87
90
|
export declare function repIndexOf(filename: string): number | null;
|
|
88
91
|
/**
|
|
89
92
|
* ALL transcript files for a scenario in a run dir, sorted deterministically:
|
|
@@ -95,23 +98,43 @@ export declare function repIndexOf(filename: string): number | null;
|
|
|
95
98
|
* `<id>.<mode>.rep<k>.txt`) — e.g. to detect a green-only condition without
|
|
96
99
|
* false positives from a red/force transcript of the same scenario. Omitted,
|
|
97
100
|
* behavior is unchanged: any `<id>.*.txt` regardless of mode, excluding this
|
|
98
|
-
* scenario's
|
|
101
|
+
* scenario's sibling artifacts, which share the `.txt` extension deliberately
|
|
102
|
+
* (so `results/.gitignore`'s `*.txt` covers them all): judge-raw output
|
|
103
|
+
* (`<id>.*.judge.txt` — see judgeRawPath) and the staged diff
|
|
104
|
+
* (`<id>.*.diff.txt` — see diffPath).
|
|
99
105
|
*/
|
|
100
106
|
export declare function findTranscriptFiles(runDir: string, scenarioId: string, mode?: string): string[];
|
|
101
107
|
/** Path of a scenario's raw judge-output artifact within a run dir (rep-suffixed for reps). */
|
|
102
108
|
export declare function judgeRawPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
|
|
103
109
|
/** A scenario's raw judge-output files, sorted (plain first, then numeric rep). Mode-scoped when given. */
|
|
104
110
|
export declare function findJudgeRawFiles(runDir: string, scenarioId: string, mode?: string): string[];
|
|
111
|
+
/**
|
|
112
|
+
* Path of a seeded scenario's staged-diff artifact within a run dir (rep-suffixed
|
|
113
|
+
* for reps).
|
|
114
|
+
*
|
|
115
|
+
* The diff is the only record of what the model actually *did* — the workspace is
|
|
116
|
+
* torn down after every rep, so without this a seeded verdict cannot be audited
|
|
117
|
+
* after the fact. Named `<id>.<mode>[.rep<k>].diff.txt` so it sorts beside its
|
|
118
|
+
* transcript and is covered by the `*.txt` rule in results/.gitignore: diffs are
|
|
119
|
+
* generated evidence, ignored like transcripts, not committed like results.yaml.
|
|
120
|
+
*/
|
|
121
|
+
export declare function diffPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
|
|
122
|
+
/** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
|
|
123
|
+
export declare function findDiffFiles(runDir: string, scenarioId: string, mode?: string): string[];
|
|
105
124
|
/** A single representative transcript file for a scenario in a run dir. Null if none. */
|
|
106
125
|
export declare function findTranscriptFile(runDir: string, scenarioId: string): string | null;
|
|
107
126
|
/**
|
|
108
|
-
* Un-gitignore ALL of a scenario's transcript
|
|
109
|
-
* (audit trail for an override — a --reps run has one
|
|
110
|
-
*
|
|
111
|
-
*
|
|
127
|
+
* Un-gitignore ALL of a scenario's transcript, judge-raw AND staged-diff
|
|
128
|
+
* artifact files (audit trail for an override — a --reps run has one of each
|
|
129
|
+
* per rep, and every rep that drove the verdict must survive a commit, not just
|
|
130
|
+
* an arbitrary one).
|
|
112
131
|
* Appends `!<tag>/<ts>/<id>.<mode>[.rep<k>].txt` (and the matching
|
|
113
|
-
* `.judge.txt`) to results/.gitignore for each, once. The path
|
|
114
|
-
* separators so the negation matches on Windows too (git ignore
|
|
115
|
-
* always forward-slashed).
|
|
132
|
+
* `.judge.txt` / `.diff.txt`) to results/.gitignore for each, once. The path
|
|
133
|
+
* uses POSIX separators so the negation matches on Windows too (git ignore
|
|
134
|
+
* patterns are always forward-slashed).
|
|
135
|
+
*
|
|
136
|
+
* The diff belongs here for the same reason the judge-raw output does: an
|
|
137
|
+
* override says the judge got it wrong, and on a seeded scenario the evidence
|
|
138
|
+
* for that claim is the code the model wrote.
|
|
116
139
|
*/
|
|
117
140
|
export declare function preserveTranscript(resultsRoot: string, runDir: string, scenarioId: string): void;
|
package/dist/results.js
CHANGED
|
@@ -150,9 +150,10 @@ export function ensureResultsGitignore(resultsRoot) {
|
|
|
150
150
|
.filter((l) => l.startsWith("!") && l.trim() !== "!results.yaml");
|
|
151
151
|
writeFileSync(giPath, GITIGNORE_BODY + preserved.map((l) => l + "\n").join(""), "utf8");
|
|
152
152
|
}
|
|
153
|
-
// Matches
|
|
154
|
-
|
|
155
|
-
|
|
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. */
|
|
156
157
|
export function repIndexOf(filename) {
|
|
157
158
|
const m = REP_SUFFIX_RE.exec(filename);
|
|
158
159
|
return m ? Number(m[1]) : null;
|
|
@@ -181,7 +182,10 @@ function sortByRep(files) {
|
|
|
181
182
|
* `<id>.<mode>.rep<k>.txt`) — e.g. to detect a green-only condition without
|
|
182
183
|
* false positives from a red/force transcript of the same scenario. Omitted,
|
|
183
184
|
* behavior is unchanged: any `<id>.*.txt` regardless of mode, excluding this
|
|
184
|
-
* 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).
|
|
185
189
|
*/
|
|
186
190
|
export function findTranscriptFiles(runDir, scenarioId, mode) {
|
|
187
191
|
if (!existsSync(runDir))
|
|
@@ -190,7 +194,12 @@ export function findTranscriptFiles(runDir, scenarioId, mode) {
|
|
|
190
194
|
const matcher = mode !== undefined
|
|
191
195
|
? new RegExp(`^${escapedId}\\.${mode}(\\.rep\\d+)?\\.txt$`)
|
|
192
196
|
: null;
|
|
193
|
-
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"));
|
|
194
203
|
return sortByRep(files);
|
|
195
204
|
}
|
|
196
205
|
/** Path of a scenario's raw judge-output artifact within a run dir (rep-suffixed for reps). */
|
|
@@ -208,22 +217,54 @@ export function findJudgeRawFiles(runDir, scenarioId, mode) {
|
|
|
208
217
|
: new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.judge\\.txt$`);
|
|
209
218
|
return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
|
|
210
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
|
+
}
|
|
211
244
|
/** A single representative transcript file for a scenario in a run dir. Null if none. */
|
|
212
245
|
export function findTranscriptFile(runDir, scenarioId) {
|
|
213
246
|
return findTranscriptFiles(runDir, scenarioId)[0] ?? null;
|
|
214
247
|
}
|
|
215
248
|
/**
|
|
216
|
-
* Un-gitignore ALL of a scenario's transcript
|
|
217
|
-
* (audit trail for an override — a --reps run has one
|
|
218
|
-
*
|
|
219
|
-
*
|
|
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).
|
|
220
253
|
* Appends `!<tag>/<ts>/<id>.<mode>[.rep<k>].txt` (and the matching
|
|
221
|
-
* `.judge.txt`) to results/.gitignore for each, once. The path
|
|
222
|
-
* separators so the negation matches on Windows too (git ignore
|
|
223
|
-
* 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.
|
|
224
261
|
*/
|
|
225
262
|
export function preserveTranscript(resultsRoot, runDir, scenarioId) {
|
|
226
|
-
const files = [
|
|
263
|
+
const files = [
|
|
264
|
+
...findTranscriptFiles(runDir, scenarioId),
|
|
265
|
+
...findJudgeRawFiles(runDir, scenarioId),
|
|
266
|
+
...findDiffFiles(runDir, scenarioId),
|
|
267
|
+
];
|
|
227
268
|
if (files.length === 0)
|
|
228
269
|
return;
|
|
229
270
|
ensureResultsGitignore(resultsRoot);
|
package/dist/run.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
2
|
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { sourceHashes } from "./sources.js";
|
|
4
4
|
import { judgeResemblesSubject } from "./grade.js";
|
|
5
|
-
import { runDirFor, transcriptPath, writeResults, ensureResultsGitignore, } from "./results.js";
|
|
5
|
+
import { runDirFor, transcriptPath, diffPath, writeResults, ensureResultsGitignore, } from "./results.js";
|
|
6
6
|
import { appendJournal } from "./journal.js";
|
|
7
7
|
import { liftHeadline } from "./lift.js";
|
|
8
8
|
import { runSeeded } from "./seeded.js";
|
|
@@ -10,34 +10,6 @@ import { createWorkspace } from "./workspace.js";
|
|
|
10
10
|
import { runPool } from "./scheduler.js";
|
|
11
11
|
import { outcomesToResult } from "./reps.js";
|
|
12
12
|
import { judgeOneRep } from "./regrade.js";
|
|
13
|
-
/** sha256 of a file, or null when it doesn't exist — missing sources are lint's problem, not run's. */
|
|
14
|
-
function sha256(path) {
|
|
15
|
-
try {
|
|
16
|
-
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Hash every source file this run measures: SKILL.md + each distinct
|
|
24
|
-
* system_prompt_file (agents/<name>.md). Recorded in results.yaml so lint can prove
|
|
25
|
-
* a published result still describes the current text.
|
|
26
|
-
*/
|
|
27
|
-
function sourceHashes(skillDir, specPath, scenarios) {
|
|
28
|
-
const hashes = {};
|
|
29
|
-
const skillMd = sha256(resolve(skillDir, "SKILL.md"));
|
|
30
|
-
if (skillMd)
|
|
31
|
-
hashes["SKILL.md"] = skillMd;
|
|
32
|
-
for (const s of scenarios) {
|
|
33
|
-
if (s.systemPromptFile && !(s.systemPromptFile in hashes)) {
|
|
34
|
-
const h = sha256(resolve(dirname(specPath), s.systemPromptFile));
|
|
35
|
-
if (h)
|
|
36
|
-
hashes[s.systemPromptFile] = h;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return hashes;
|
|
40
|
-
}
|
|
41
13
|
/** Run one skill against one model: run scenarios, grade, score, persist. */
|
|
42
14
|
export async function runSkillModel(opts) {
|
|
43
15
|
const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
|
|
@@ -99,7 +71,9 @@ export async function runSkillModel(opts) {
|
|
|
99
71
|
label: opts.label ?? null,
|
|
100
72
|
mode,
|
|
101
73
|
...(partial ? { partial: true } : {}),
|
|
102
|
-
|
|
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 }),
|
|
103
77
|
scenarios: scenarioResults,
|
|
104
78
|
}, ctx);
|
|
105
79
|
if (ctx) {
|
|
@@ -136,6 +110,10 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
136
110
|
let ws = null;
|
|
137
111
|
let transcript = "";
|
|
138
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;
|
|
139
117
|
try {
|
|
140
118
|
try {
|
|
141
119
|
ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
|
|
@@ -160,9 +138,11 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
160
138
|
if (scenario.mode === "seeded") {
|
|
161
139
|
const r = await runSeeded(scenario, {
|
|
162
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
|
|
163
142
|
});
|
|
164
143
|
transcript = r.transcript;
|
|
165
144
|
gatePrefix = r.gateFailure;
|
|
145
|
+
stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
|
|
166
146
|
}
|
|
167
147
|
else {
|
|
168
148
|
transcript = await ctx.adapter.run({
|
|
@@ -178,8 +158,16 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
178
158
|
break;
|
|
179
159
|
}
|
|
180
160
|
}
|
|
181
|
-
|
|
161
|
+
const repSuffix = repCount > 1 ? rep : undefined;
|
|
162
|
+
writeFileSync(transcriptPath(runDir, scenario.id, mode, repSuffix), transcript, "utf8");
|
|
182
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
|
+
}
|
|
183
171
|
appendJournal(runDir, { event: "gate-result", ts: now(), id: scenario.id, ok: !gatePrefix, detail: gatePrefix ?? "", ...repField });
|
|
184
172
|
}
|
|
185
173
|
let verdict;
|
package/dist/seeded.d.ts
CHANGED
|
@@ -1,21 +1,101 @@
|
|
|
1
1
|
import type { Scenario } from "./spec.js";
|
|
2
2
|
import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
|
|
3
|
+
import { type ExecResult } from "./util/exec.js";
|
|
3
4
|
interface SeededOpts {
|
|
4
5
|
skillDir: string;
|
|
5
6
|
adapter: HarnessAdapter;
|
|
6
7
|
model: ModelRef;
|
|
7
8
|
mode: RunMode;
|
|
8
9
|
cwd: string;
|
|
10
|
+
specDir: string;
|
|
11
|
+
/**
|
|
12
|
+
* How the vitest gates shell out. Defaults to the real `npx vitest run`.
|
|
13
|
+
*
|
|
14
|
+
* A seam, not a mock: a workspace is a bare temp dir, so a test that exercised
|
|
15
|
+
* the real runner would resolve vitest off the network and be slow and flaky.
|
|
16
|
+
* Injecting it lets the gate LOGIC — pass, fail, nothing-collected — be tested
|
|
17
|
+
* deterministically.
|
|
18
|
+
*/
|
|
19
|
+
runVitest?: (args: string[], cwd: string) => Promise<VitestRun>;
|
|
9
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Result of one vitest invocation — deliberately `ExecResult`, not a narrower
|
|
23
|
+
* shape of its own.
|
|
24
|
+
*
|
|
25
|
+
* An earlier version declared `code: number`. That narrowing was a lie the
|
|
26
|
+
* compiler happened not to catch (the default's inferred type silently widened
|
|
27
|
+
* it back), and it is unrepresentable in practice: `exec` SIGKILLs on timeout
|
|
28
|
+
* and a signal-killed child closes with `code === null`. Declaring non-null
|
|
29
|
+
* would have made the vitest gate's timeout path — the one an injected double
|
|
30
|
+
* most needs to reproduce — impossible to express in a test.
|
|
31
|
+
*/
|
|
32
|
+
export type VitestRun = ExecResult;
|
|
10
33
|
export interface SeededOutcome {
|
|
11
34
|
transcript: string;
|
|
12
35
|
gateFailure: string | null;
|
|
36
|
+
diff: string;
|
|
13
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The added/removed lines of a unified diff — what the model actually *changed*,
|
|
40
|
+
* with context lines and file headers dropped.
|
|
41
|
+
*
|
|
42
|
+
* This is the difference between "the diff mentions `lastIndex`" and "the model
|
|
43
|
+
* touched `lastIndex`". A unified diff carries three lines of context around every
|
|
44
|
+
* hunk, so an untouched function sitting near the edit site appears in the diff
|
|
45
|
+
* verbatim. `build` A2 is exactly that shape — its checklist notes that `lastIndex`
|
|
46
|
+
* "sits two lines from the edit site" — so a naive substring test against the whole
|
|
47
|
+
* diff would fail the scenario for every model that fixed the right thing, which is
|
|
48
|
+
* worse than the prose-dependent item it replaces.
|
|
49
|
+
*
|
|
50
|
+
* Classification is HUNK-AWARE rather than prefix-based, because `+++`/`---` are
|
|
51
|
+
* only headers *outside* a hunk. Filtering on those prefixes anywhere would eat a
|
|
52
|
+
* changed line whose own source text starts with `++` or `--` — `++counter;` at
|
|
53
|
+
* column zero, a removed SQL/Lua `-- comment`, a YAML `---` separator. Those
|
|
54
|
+
* became `+++counter;` and `--- comment` once the diff marker was prepended, were
|
|
55
|
+
* read as headers, and vanished: `diff_excludes` then reported OK for a diff that
|
|
56
|
+
* touched the forbidden symbol. A false PASS on an objective gate is worse than
|
|
57
|
+
* the subjective check it replaced, so the parse follows the format instead of
|
|
58
|
+
* guessing from prefixes.
|
|
59
|
+
*/
|
|
60
|
+
export declare function changedLines(diff: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Cut a diff to a byte budget on a line boundary, appending an explicit marker
|
|
63
|
+
* naming how much was dropped.
|
|
64
|
+
*
|
|
65
|
+
* The marker is not decoration: a silently truncated diff would let the judge
|
|
66
|
+
* grade "the function is missing" when it was merely cut off, which is the exact
|
|
67
|
+
* class of false-FAIL this whole change exists to remove. Truncation is reported
|
|
68
|
+
* as a fact about the transcript, and the untruncated diff is always on disk.
|
|
69
|
+
*/
|
|
70
|
+
export declare function capDiff(diff: string, maxBytes?: number): string;
|
|
14
71
|
/**
|
|
15
72
|
* Run a seeded scenario inside a caller-prepared workspace: let the harness edit
|
|
16
|
-
* the repo, then evaluate objective gates
|
|
17
|
-
*
|
|
18
|
-
*
|
|
73
|
+
* the repo, then evaluate the objective gates it declares — `diff_contains`,
|
|
74
|
+
* `diff_excludes`, `vitest` and `post_test`. Every gate that is configured runs;
|
|
75
|
+
* the FIRST failure is what `gateFailure` reports, and a non-null `gateFailure`
|
|
76
|
+
* makes the scenario an auto-FAIL that never reaches the judge.
|
|
77
|
+
*
|
|
78
|
+
* Returns the full staged diff alongside the transcript: the caller persists it
|
|
79
|
+
* as a run artifact, and a size-capped copy is appended to the transcript under
|
|
80
|
+
* `=== STAGED DIFF ===` so the judge grades the code rather than the model's
|
|
81
|
+
* description of it. Workspace creation (fixture copy + git baseline) and
|
|
82
|
+
* teardown are the caller's responsibility (run.ts).
|
|
19
83
|
*/
|
|
20
84
|
export declare function runSeeded(scenario: Scenario, opts: SeededOpts): Promise<SeededOutcome>;
|
|
85
|
+
export interface VitestTally {
|
|
86
|
+
passed: number;
|
|
87
|
+
failed: number;
|
|
88
|
+
skipped: number;
|
|
89
|
+
todo: number;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Parse vitest's `Tests N passed | M skipped (T)` summary line.
|
|
93
|
+
*
|
|
94
|
+
* Used to require positive evidence that a hidden `post_test` actually executed
|
|
95
|
+
* assertions, rather than trusting a zero exit code — which vitest also returns
|
|
96
|
+
* when every test in the file is skipped. Returns null when no summary line is
|
|
97
|
+
* present, which the caller treats as "cannot confirm it ran" rather than as a
|
|
98
|
+
* pass.
|
|
99
|
+
*/
|
|
100
|
+
export declare function vitestTally(out: string): VitestTally | null;
|
|
21
101
|
export {};
|