@skill-harness/core 0.2.1 → 0.3.1
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/lift.d.ts +23 -15
- package/dist/lift.js +43 -4
- 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 +34 -8
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/lift.d.ts
CHANGED
|
@@ -37,10 +37,30 @@ export interface Lift {
|
|
|
37
37
|
/** Ids the green run covered that the red baseline did not (and vice versa). */
|
|
38
38
|
greenOnly: string[];
|
|
39
39
|
redOnly: string[];
|
|
40
|
+
/**
|
|
41
|
+
* Ids both runs covered that a lift cannot speak to, because the harness runs
|
|
42
|
+
* them identically in red and green. Reported rather than compared: folding them
|
|
43
|
+
* in would credit the red side with passes the skill itself produced. See
|
|
44
|
+
* `LiftOptions.modeInsensitive`.
|
|
45
|
+
*/
|
|
46
|
+
modeInsensitive: string[];
|
|
40
47
|
/** True when either side was an `--only` run, so coverage is a subset by construction. */
|
|
41
48
|
partial: boolean;
|
|
42
49
|
cells: Record<string, LiftCell>;
|
|
43
50
|
}
|
|
51
|
+
export interface LiftOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Scenario ids whose red and green runs are the same run by construction, so
|
|
54
|
+
* comparing them measures nothing.
|
|
55
|
+
*
|
|
56
|
+
* The case that exists today is `system_prompt_file`: the pi adapter treats an
|
|
57
|
+
* agent-file scenario's file AS the system prompt and passes `--no-skills`
|
|
58
|
+
* *whatever the mode*, so the skill is loaded on both sides. Left in, such a
|
|
59
|
+
* cell lands in `kept` (or `both-fail`) and drags the denominator down —
|
|
60
|
+
* understating lift with evidence that the skill worked.
|
|
61
|
+
*/
|
|
62
|
+
modeInsensitive?: Iterable<string>;
|
|
63
|
+
}
|
|
44
64
|
/**
|
|
45
65
|
* Compare a red (baseline, skill off) run against a green (skill active) run of
|
|
46
66
|
* the same model: the "does this skill actually do anything?" measurement.
|
|
@@ -48,22 +68,10 @@ export interface Lift {
|
|
|
48
68
|
* Both sides go through `effectiveVerdicts`, so an author override is what
|
|
49
69
|
* counts and an override resolves a misfire — the same rule scoring uses. Only
|
|
50
70
|
* the intersection of scenario ids is compared; a lift cannot speak to a
|
|
51
|
-
* scenario one side never ran
|
|
71
|
+
* scenario one side never ran, nor to one the harness ran identically in both
|
|
72
|
+
* modes (`opts.modeInsensitive`).
|
|
52
73
|
*/
|
|
53
|
-
export declare function computeLift(red: ResultsFile, green: ResultsFile): Lift;
|
|
74
|
+
export declare function computeLift(red: ResultsFile, green: ResultsFile, opts?: LiftOptions): Lift;
|
|
54
75
|
/** One line for a human: what the skill did, and what it cost. */
|
|
55
76
|
export declare function liftHeadline(lift: Lift): string;
|
|
56
|
-
/**
|
|
57
|
-
* Per model-tag under <skillDir>/tests/results/, pair the most recent red run
|
|
58
|
-
* with the most recent green run and compute the lift.
|
|
59
|
-
*
|
|
60
|
-
* Deliberately derived on read rather than persisted into results.yaml: a lift
|
|
61
|
-
* is a fact about a *pair* of runs, so caching it inside one run's file would go
|
|
62
|
-
* stale the moment a new baseline lands — the stale-scorecard failure mode
|
|
63
|
-
* `source_hashes` exists to prevent. Deriving also means lift works
|
|
64
|
-
* retroactively on results already committed by 0.1.x/0.2.0 users.
|
|
65
|
-
*
|
|
66
|
-
* A tag with no red baseline is omitted entirely rather than reported as a zero
|
|
67
|
-
* lift: "not measured" and "measured no effect" are different claims.
|
|
68
|
-
*/
|
|
69
77
|
export declare function collectLift(skillDir: string): Lift[];
|
package/dist/lift.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { readResults, effectiveVerdicts } from "./results.js";
|
|
4
|
+
import { loadSpec } from "./spec.js";
|
|
4
5
|
/** A verdict that carries real evidence about the task, rather than about the harness or the judge. */
|
|
5
6
|
function conclusive(verdict, suspect) {
|
|
6
7
|
// ERROR is a harness failure (timeout, empty reply) — it says nothing about
|
|
@@ -30,9 +31,11 @@ function classify(red, green) {
|
|
|
30
31
|
* Both sides go through `effectiveVerdicts`, so an author override is what
|
|
31
32
|
* counts and an override resolves a misfire — the same rule scoring uses. Only
|
|
32
33
|
* the intersection of scenario ids is compared; a lift cannot speak to a
|
|
33
|
-
* scenario one side never ran
|
|
34
|
+
* scenario one side never ran, nor to one the harness ran identically in both
|
|
35
|
+
* modes (`opts.modeInsensitive`).
|
|
34
36
|
*/
|
|
35
|
-
export function computeLift(red, green) {
|
|
37
|
+
export function computeLift(red, green, opts = {}) {
|
|
38
|
+
const insensitive = new Set(opts.modeInsensitive ?? []);
|
|
36
39
|
const redV = new Map(effectiveVerdicts(red.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
|
|
37
40
|
const greenV = new Map(effectiveVerdicts(green.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
|
|
38
41
|
const cells = {};
|
|
@@ -41,10 +44,15 @@ export function computeLift(red, green) {
|
|
|
41
44
|
let greenPassed = 0;
|
|
42
45
|
// Green order drives display order (it is the run the author is looking at),
|
|
43
46
|
// restricted to ids the red baseline also covered.
|
|
47
|
+
const modeInsensitive = [];
|
|
44
48
|
for (const [id, g] of greenV) {
|
|
45
49
|
const r = redV.get(id);
|
|
46
50
|
if (!r)
|
|
47
51
|
continue;
|
|
52
|
+
if (insensitive.has(id)) {
|
|
53
|
+
modeInsensitive.push(id);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
48
56
|
const cls = classify(r, g);
|
|
49
57
|
cells[id] = { red: r.verdict, redSuspect: r.suspect, green: g.verdict, class: cls };
|
|
50
58
|
counts[cls]++;
|
|
@@ -71,14 +79,21 @@ export function computeLift(red, green) {
|
|
|
71
79
|
delta: greenPassed - redPassed,
|
|
72
80
|
greenOnly: [...greenV.keys()].filter((id) => !redV.has(id)),
|
|
73
81
|
redOnly: [...redV.keys()].filter((id) => !greenV.has(id)),
|
|
82
|
+
modeInsensitive,
|
|
74
83
|
partial: Boolean(red.partial || green.partial),
|
|
75
84
|
cells,
|
|
76
85
|
};
|
|
77
86
|
}
|
|
78
87
|
/** One line for a human: what the skill did, and what it cost. */
|
|
79
88
|
export function liftHeadline(lift) {
|
|
80
|
-
if (lift.compared === 0)
|
|
89
|
+
if (lift.compared === 0) {
|
|
90
|
+
// Excluded-but-shared is not the same as never-shared. Claiming the runs had
|
|
91
|
+
// no scenario in common would hide the reason the lift is empty.
|
|
92
|
+
if (lift.modeInsensitive.length > 0) {
|
|
93
|
+
return `nothing comparable (${lift.modeInsensitive.length} shared, all run identically in both modes)`;
|
|
94
|
+
}
|
|
81
95
|
return "no shared scenarios to compare";
|
|
96
|
+
}
|
|
82
97
|
// Everything inconclusive is NOT "no effect" — it is no measurement. Saying
|
|
83
98
|
// "no measured effect" here would be the same not-measured/measured-no-effect
|
|
84
99
|
// conflation this module refuses to make when a red baseline is missing.
|
|
@@ -98,6 +113,9 @@ export function liftHeadline(lift) {
|
|
|
98
113
|
}
|
|
99
114
|
if (lift.inconclusive > 0)
|
|
100
115
|
segments.push(`${lift.inconclusive} inconclusive`);
|
|
116
|
+
if (lift.modeInsensitive.length > 0) {
|
|
117
|
+
segments.push(`${lift.modeInsensitive.length} not comparable (same run in both modes)`);
|
|
118
|
+
}
|
|
101
119
|
if (lift.partial)
|
|
102
120
|
segments.push("partial run");
|
|
103
121
|
return segments.join(" · ");
|
|
@@ -124,10 +142,31 @@ function isDir(p) {
|
|
|
124
142
|
* A tag with no red baseline is omitted entirely rather than reported as a zero
|
|
125
143
|
* lift: "not measured" and "measured no effect" are different claims.
|
|
126
144
|
*/
|
|
145
|
+
/**
|
|
146
|
+
* Scenario ids the harness runs identically in red and green, read from the spec
|
|
147
|
+
* rather than from results.yaml: a lift is derived on read, so this has to work
|
|
148
|
+
* on runs recorded before the field existed — and `scenario:<id>` source hashes
|
|
149
|
+
* fold the value in without preserving it.
|
|
150
|
+
*
|
|
151
|
+
* Never throws: an unparseable spec must degrade to "nothing excluded" rather
|
|
152
|
+
* than take down a view that is otherwise readable from the results alone.
|
|
153
|
+
*/
|
|
154
|
+
function modeInsensitiveIds(skillDir) {
|
|
155
|
+
const specPath = join(skillDir, "tests", "specification.yaml");
|
|
156
|
+
if (!existsSync(specPath))
|
|
157
|
+
return [];
|
|
158
|
+
try {
|
|
159
|
+
return loadSpec(specPath).scenarios.filter((s) => s.systemPromptFile).map((s) => s.id);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
127
165
|
export function collectLift(skillDir) {
|
|
128
166
|
const resultsRoot = join(skillDir, "tests", "results");
|
|
129
167
|
if (!existsSync(resultsRoot))
|
|
130
168
|
return [];
|
|
169
|
+
const modeInsensitive = modeInsensitiveIds(skillDir);
|
|
131
170
|
const lifts = [];
|
|
132
171
|
for (const tag of readdirSync(resultsRoot).filter((n) => isDir(join(resultsRoot, n))).sort()) {
|
|
133
172
|
const tagDir = join(resultsRoot, tag);
|
|
@@ -156,7 +195,7 @@ export function collectLift(skillDir) {
|
|
|
156
195
|
}
|
|
157
196
|
if (!red || !green)
|
|
158
197
|
continue;
|
|
159
|
-
lifts.push({ ...computeLift(red, green), tag });
|
|
198
|
+
lifts.push({ ...computeLift(red, green, { modeInsensitive }), tag });
|
|
160
199
|
}
|
|
161
200
|
return lifts;
|
|
162
201
|
}
|
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);
|