@skill-harness/core 0.3.1 → 0.4.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/defaults.d.ts +30 -0
- package/dist/defaults.js +34 -0
- package/dist/downgrade.d.ts +41 -0
- package/dist/downgrade.js +100 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/journal.d.ts +14 -0
- package/dist/judge-policy.d.ts +42 -0
- package/dist/judge-policy.js +61 -0
- package/dist/lift.d.ts +19 -0
- package/dist/lift.js +64 -2
- package/dist/lint.js +27 -5
- package/dist/regate.d.ts +57 -0
- package/dist/regate.js +195 -0
- package/dist/regrade.d.ts +13 -0
- package/dist/regrade.js +36 -2
- package/dist/rescore.js +28 -1
- package/dist/results.d.ts +10 -0
- package/dist/results.js +5 -0
- package/dist/run.js +1 -1
- package/dist/seeded.d.ts +18 -0
- package/dist/seeded.js +40 -15
- package/dist/sources.d.ts +67 -6
- package/dist/sources.js +185 -26
- package/dist/version.d.ts +1 -0
- package/dist/version.js +22 -0
- package/dist/workspace.js +32 -1
- package/package.json +1 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The judge used when nothing else says otherwise: Opus through the **`claude-code`
|
|
3
|
+
* provider**, which authenticates with the user's Claude subscription (OAuth via
|
|
4
|
+
* `claude -p`) rather than a metered API key.
|
|
5
|
+
*
|
|
6
|
+
* The model is deliberately the strongest available — judging is the one place
|
|
7
|
+
* where a weak model silently corrupts every number in a scorecard. The *provider*
|
|
8
|
+
* is what changed in 0.3.3: the default was `anthropic:claude-opus-4-8`, a metered
|
|
9
|
+
* API, and it billed a corpus once by accident because nothing in the tool surface
|
|
10
|
+
* distinguishes "the flag I forgot" from "the flag I meant". A default must not be
|
|
11
|
+
* able to spend money that was not asked for. The metered path is still one flag
|
|
12
|
+
* away (`--judge anthropic:claude-opus-4-8`) for anyone who wants it — an API key
|
|
13
|
+
* scales past a subscription's rate limits, which matters for a large `--reps` run.
|
|
14
|
+
*/
|
|
15
|
+
export declare const BAKED_DEFAULT_JUDGE = "claude-code:claude-opus-4-8";
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the default judge: `SKILL_HARNESS_JUDGE` if set, else the baked value.
|
|
18
|
+
* An explicit `--judge` always wins over both — this is only the default.
|
|
19
|
+
*
|
|
20
|
+
* The env layer exists because this harness is built for someone who steers a
|
|
21
|
+
* process rather than typing every flag: judge policy belongs to the repo or the
|
|
22
|
+
* shell, set once. It is also how you opt *into* the metered API deliberately
|
|
23
|
+
* (`SKILL_HARNESS_JUDGE=anthropic:claude-opus-4-8`) instead of by forgetting a
|
|
24
|
+
* flag. Read through `readEnv`, so the pre-rename `SKILL_CHECK_JUDGE` keeps working
|
|
25
|
+
* with the usual one-time notice.
|
|
26
|
+
*
|
|
27
|
+
* Resolved per call, not at module load: tests and long-lived processes (the pi
|
|
28
|
+
* extension) must see an env change without a reload.
|
|
29
|
+
*/
|
|
30
|
+
export declare function defaultJudge(): string;
|
package/dist/defaults.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readEnv } from "./util/env.js";
|
|
2
|
+
/**
|
|
3
|
+
* The judge used when nothing else says otherwise: Opus through the **`claude-code`
|
|
4
|
+
* provider**, which authenticates with the user's Claude subscription (OAuth via
|
|
5
|
+
* `claude -p`) rather than a metered API key.
|
|
6
|
+
*
|
|
7
|
+
* The model is deliberately the strongest available — judging is the one place
|
|
8
|
+
* where a weak model silently corrupts every number in a scorecard. The *provider*
|
|
9
|
+
* is what changed in 0.3.3: the default was `anthropic:claude-opus-4-8`, a metered
|
|
10
|
+
* API, and it billed a corpus once by accident because nothing in the tool surface
|
|
11
|
+
* distinguishes "the flag I forgot" from "the flag I meant". A default must not be
|
|
12
|
+
* able to spend money that was not asked for. The metered path is still one flag
|
|
13
|
+
* away (`--judge anthropic:claude-opus-4-8`) for anyone who wants it — an API key
|
|
14
|
+
* scales past a subscription's rate limits, which matters for a large `--reps` run.
|
|
15
|
+
*/
|
|
16
|
+
export const BAKED_DEFAULT_JUDGE = "claude-code:claude-opus-4-8";
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the default judge: `SKILL_HARNESS_JUDGE` if set, else the baked value.
|
|
19
|
+
* An explicit `--judge` always wins over both — this is only the default.
|
|
20
|
+
*
|
|
21
|
+
* The env layer exists because this harness is built for someone who steers a
|
|
22
|
+
* process rather than typing every flag: judge policy belongs to the repo or the
|
|
23
|
+
* shell, set once. It is also how you opt *into* the metered API deliberately
|
|
24
|
+
* (`SKILL_HARNESS_JUDGE=anthropic:claude-opus-4-8`) instead of by forgetting a
|
|
25
|
+
* flag. Read through `readEnv`, so the pre-rename `SKILL_CHECK_JUDGE` keeps working
|
|
26
|
+
* with the usual one-time notice.
|
|
27
|
+
*
|
|
28
|
+
* Resolved per call, not at module load: tests and long-lived processes (the pi
|
|
29
|
+
* extension) must see an env change without a reload.
|
|
30
|
+
*/
|
|
31
|
+
export function defaultJudge() {
|
|
32
|
+
return readEnv("JUDGE") ?? BAKED_DEFAULT_JUDGE;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=defaults.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare two semver-ish version strings by numeric component.
|
|
3
|
+
*
|
|
4
|
+
* String comparison is the trap this exists to avoid: `"0.10.0" < "0.9.0"` is true
|
|
5
|
+
* lexically and false in fact, which would make the tripwire fire backwards exactly
|
|
6
|
+
* once — on the release where it mattered most. A prerelease suffix is dropped rather
|
|
7
|
+
* than ordered: `0.4.0-rc.1` vs `0.4.0` is not a distinction worth a refusal, and
|
|
8
|
+
* getting it wrong in either direction is worse than treating them as equal.
|
|
9
|
+
*/
|
|
10
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
11
|
+
/**
|
|
12
|
+
* The newest `harness_version` recorded anywhere under `<skillDir>/tests/results/`, or
|
|
13
|
+
* null when no run records one.
|
|
14
|
+
*
|
|
15
|
+
* Null is the honest answer for a pre-0.3.3 tree and must stay silent: those runs
|
|
16
|
+
* carry no version, so there is nothing to compare and no basis for a warning. The
|
|
17
|
+
* tripwire therefore only sharpens as fresh runs land — the same forward-looking
|
|
18
|
+
* bargain `source_hashes` made.
|
|
19
|
+
*/
|
|
20
|
+
export declare function newestRecordedVersion(skillDir: string): string | null;
|
|
21
|
+
/**
|
|
22
|
+
* Refuse to write a fresh measurement with an older tool than the one that produced
|
|
23
|
+
* the records already in the tree.
|
|
24
|
+
*
|
|
25
|
+
* The failure this kills, measured on the reference corpus: a stale global **0.1.0**
|
|
26
|
+
* install would have spent ~102 rep-executions grading *without showing the judge the
|
|
27
|
+
* staged diff* — the exact defect the run was meant to correct — and every resulting
|
|
28
|
+
* number would have looked entirely plausible. It also emitted 38 spurious findings
|
|
29
|
+
* that the current version does not. Nothing announced any of it.
|
|
30
|
+
*
|
|
31
|
+
* `schema` cannot serve here: 0.2.1 → 0.3.0 kept `schema: 2` while changing what a
|
|
32
|
+
* verdict *means*. Only the writing version distinguishes those measurements.
|
|
33
|
+
*
|
|
34
|
+
* Refusal is for `run` alone, because only `run` mints a new measurement that would sit
|
|
35
|
+
* beside newer ones as if comparable. `grade` and `lint` warn (see `downgradeWarning`):
|
|
36
|
+
* both are how someone diagnoses this in the first place, and blocking diagnosis is a
|
|
37
|
+
* bad trade.
|
|
38
|
+
*/
|
|
39
|
+
export declare function assertNotDowngraded(skillDir: string, command: "run" | "grade" | "lint"): void;
|
|
40
|
+
/** The loud-but-not-fatal version, for `grade` and `lint`. Null when nothing is newer. */
|
|
41
|
+
export declare function downgradeWarning(skillDir: string): string | null;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readResults } from "./results.js";
|
|
4
|
+
import { HARNESS_VERSION } from "./version.js";
|
|
5
|
+
/**
|
|
6
|
+
* Compare two semver-ish version strings by numeric component.
|
|
7
|
+
*
|
|
8
|
+
* String comparison is the trap this exists to avoid: `"0.10.0" < "0.9.0"` is true
|
|
9
|
+
* lexically and false in fact, which would make the tripwire fire backwards exactly
|
|
10
|
+
* once — on the release where it mattered most. A prerelease suffix is dropped rather
|
|
11
|
+
* than ordered: `0.4.0-rc.1` vs `0.4.0` is not a distinction worth a refusal, and
|
|
12
|
+
* getting it wrong in either direction is worse than treating them as equal.
|
|
13
|
+
*/
|
|
14
|
+
export function compareVersions(a, b) {
|
|
15
|
+
const parts = (v) => v.split("-")[0].split(".").map((n) => Number(n) || 0);
|
|
16
|
+
const [pa, pb] = [parts(a), parts(b)];
|
|
17
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
18
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
19
|
+
if (d !== 0)
|
|
20
|
+
return d > 0 ? 1 : -1;
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
function isDir(p) {
|
|
25
|
+
try {
|
|
26
|
+
return statSync(p).isDirectory();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The newest `harness_version` recorded anywhere under `<skillDir>/tests/results/`, or
|
|
34
|
+
* null when no run records one.
|
|
35
|
+
*
|
|
36
|
+
* Null is the honest answer for a pre-0.3.3 tree and must stay silent: those runs
|
|
37
|
+
* carry no version, so there is nothing to compare and no basis for a warning. The
|
|
38
|
+
* tripwire therefore only sharpens as fresh runs land — the same forward-looking
|
|
39
|
+
* bargain `source_hashes` made.
|
|
40
|
+
*/
|
|
41
|
+
export function newestRecordedVersion(skillDir) {
|
|
42
|
+
const root = join(skillDir, "tests", "results");
|
|
43
|
+
if (!existsSync(root))
|
|
44
|
+
return null;
|
|
45
|
+
let newest = null;
|
|
46
|
+
for (const tag of readdirSync(root).filter((n) => isDir(join(root, n)))) {
|
|
47
|
+
const tagDir = join(root, tag);
|
|
48
|
+
for (const run of readdirSync(tagDir).filter((n) => isDir(join(tagDir, n)))) {
|
|
49
|
+
try {
|
|
50
|
+
const v = readResults(join(tagDir, run)).harness_version;
|
|
51
|
+
if (v && (newest === null || compareVersions(v, newest) > 0))
|
|
52
|
+
newest = v;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Unreadable results are the consistency check's problem, not this one's.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return newest;
|
|
60
|
+
}
|
|
61
|
+
function upgradeAdvice(recorded) {
|
|
62
|
+
return (`You are running skill-harness ${HARNESS_VERSION}; this tree holds results recorded by ${recorded}.\n` +
|
|
63
|
+
` A global install goes stale silently — check with \`skill-harness --version\`, and prefer\n` +
|
|
64
|
+
` \`npx skill-harness@${recorded}\` (or \`npm i -g skill-harness@latest\`) so the tool matches the records.`);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Refuse to write a fresh measurement with an older tool than the one that produced
|
|
68
|
+
* the records already in the tree.
|
|
69
|
+
*
|
|
70
|
+
* The failure this kills, measured on the reference corpus: a stale global **0.1.0**
|
|
71
|
+
* install would have spent ~102 rep-executions grading *without showing the judge the
|
|
72
|
+
* staged diff* — the exact defect the run was meant to correct — and every resulting
|
|
73
|
+
* number would have looked entirely plausible. It also emitted 38 spurious findings
|
|
74
|
+
* that the current version does not. Nothing announced any of it.
|
|
75
|
+
*
|
|
76
|
+
* `schema` cannot serve here: 0.2.1 → 0.3.0 kept `schema: 2` while changing what a
|
|
77
|
+
* verdict *means*. Only the writing version distinguishes those measurements.
|
|
78
|
+
*
|
|
79
|
+
* Refusal is for `run` alone, because only `run` mints a new measurement that would sit
|
|
80
|
+
* beside newer ones as if comparable. `grade` and `lint` warn (see `downgradeWarning`):
|
|
81
|
+
* both are how someone diagnoses this in the first place, and blocking diagnosis is a
|
|
82
|
+
* bad trade.
|
|
83
|
+
*/
|
|
84
|
+
export function assertNotDowngraded(skillDir, command) {
|
|
85
|
+
if (command !== "run")
|
|
86
|
+
return;
|
|
87
|
+
const recorded = newestRecordedVersion(skillDir);
|
|
88
|
+
if (!recorded || compareVersions(recorded, HARNESS_VERSION) <= 0)
|
|
89
|
+
return;
|
|
90
|
+
throw new Error(`refusing to run: these results were recorded by a NEWER skill-harness, so a run from this one would not be comparable.\n ` +
|
|
91
|
+
upgradeAdvice(recorded));
|
|
92
|
+
}
|
|
93
|
+
/** The loud-but-not-fatal version, for `grade` and `lint`. Null when nothing is newer. */
|
|
94
|
+
export function downgradeWarning(skillDir) {
|
|
95
|
+
const recorded = newestRecordedVersion(skillDir);
|
|
96
|
+
if (!recorded || compareVersions(recorded, HARNESS_VERSION) <= 0)
|
|
97
|
+
return null;
|
|
98
|
+
return `warning: this skill-harness is older than the tool that recorded these results.\n ${upgradeAdvice(recorded)}`;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=downgrade.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -20,3 +20,8 @@ export * from "./adapters/types.js";
|
|
|
20
20
|
export * from "./util/exec.js";
|
|
21
21
|
export * from "./util/env.js";
|
|
22
22
|
export * from "./scaffold.js";
|
|
23
|
+
export * from "./version.js";
|
|
24
|
+
export * from "./defaults.js";
|
|
25
|
+
export * from "./judge-policy.js";
|
|
26
|
+
export * from "./regate.js";
|
|
27
|
+
export * from "./downgrade.js";
|
package/dist/index.js
CHANGED
|
@@ -20,4 +20,9 @@ export * from "./adapters/types.js";
|
|
|
20
20
|
export * from "./util/exec.js";
|
|
21
21
|
export * from "./util/env.js";
|
|
22
22
|
export * from "./scaffold.js";
|
|
23
|
+
export * from "./version.js";
|
|
24
|
+
export * from "./defaults.js";
|
|
25
|
+
export * from "./judge-policy.js";
|
|
26
|
+
export * from "./regate.js";
|
|
27
|
+
export * from "./downgrade.js";
|
|
23
28
|
//# sourceMappingURL=index.js.map
|
package/dist/journal.d.ts
CHANGED
|
@@ -68,6 +68,20 @@ export type JournalEvent = {
|
|
|
68
68
|
total: number;
|
|
69
69
|
pct: number;
|
|
70
70
|
ship: boolean;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A regate: needle gates re-evaluated against the saved staged diffs. `judge_calls`
|
|
74
|
+
* is on the record because regate is advertised as free apart from the reps whose
|
|
75
|
+
* gate verdict flipped — a claim the journal should be able to settle. `skipped`
|
|
76
|
+
* names scenarios it could not regate (vitest/post_test, or missing diff artifacts).
|
|
77
|
+
*/
|
|
78
|
+
| {
|
|
79
|
+
event: "regate";
|
|
80
|
+
ts: string;
|
|
81
|
+
scenarios: string[];
|
|
82
|
+
changed: string[];
|
|
83
|
+
judge_calls: number;
|
|
84
|
+
skipped?: string[];
|
|
71
85
|
} | {
|
|
72
86
|
event: "score";
|
|
73
87
|
ts: string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ModelRef } from "./adapters/types.js";
|
|
2
|
+
/** Whether judging with this ref can charge a per-token API. */
|
|
3
|
+
export declare function isMeteredJudge(judge: ModelRef): boolean;
|
|
4
|
+
/** Whether the user has explicitly accepted metered judging for this repo/shell. */
|
|
5
|
+
export declare function allowMeteredJudge(): boolean;
|
|
6
|
+
export interface JudgeAllowOpts {
|
|
7
|
+
/**
|
|
8
|
+
* Where the judge came from, in the user's own vocabulary — `--judge`,
|
|
9
|
+
* `SKILL_HARNESS_JUDGE`, `the run's recorded judge`. A refusal has to say which
|
|
10
|
+
* knob to turn, and for a regrade the answer is not the one the user expects:
|
|
11
|
+
* nobody typed anything, the run's own `results.yaml` supplied it.
|
|
12
|
+
*/
|
|
13
|
+
source: string;
|
|
14
|
+
/** Per-invocation opt-in (`--allow-metered-judge`); the env var is read here. */
|
|
15
|
+
allowMetered?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Refuse to judge through a metered API unless the user explicitly asked for it.
|
|
19
|
+
*
|
|
20
|
+
* Why a hard refusal and not a warning: judging is the only cost in this tool that
|
|
21
|
+
* happens *without* a decision. The subject model is chosen per run and is the point
|
|
22
|
+
* of the exercise; the judge is a default, and a default that bills is a bug — it
|
|
23
|
+
* already billed a corpus once. A warning scrolls past inside a run's progress
|
|
24
|
+
* output, and by then the money is spent.
|
|
25
|
+
*
|
|
26
|
+
* Three paths could reach a metered API, and only one of them involves typing a
|
|
27
|
+
* flag: `--judge anthropic:…`; a `SKILL_HARNESS_JUDGE` set (or mistyped) to a
|
|
28
|
+
* metered provider; and `grade`, which re-judges with the judge the run *recorded* —
|
|
29
|
+
* so a run whose `results.yaml` names a metered judge bills on every later regrade,
|
|
30
|
+
* with no flag involved at all.
|
|
31
|
+
*
|
|
32
|
+
* That third path is latent rather than live in the corpus this was built against:
|
|
33
|
+
* checked 2026-08-05, all ~140 committed `results.yaml` in `principal-pi-skills`
|
|
34
|
+
* record `provider: claude-code`, because its owner always passed the subscription
|
|
35
|
+
* judge explicitly. The old default was reachable, not taken. Worth stating
|
|
36
|
+
* precisely — "your whole archive bills on regrade" would have been a scarier claim
|
|
37
|
+
* than the evidence supports.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately not applied to the subject model: paying to run the model under test
|
|
40
|
+
* is what a run *is*.
|
|
41
|
+
*/
|
|
42
|
+
export declare function assertJudgeAllowed(judge: ModelRef, opts: JudgeAllowOpts): void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { envFlag } from "./util/env.js";
|
|
2
|
+
import { BAKED_DEFAULT_JUDGE } from "./defaults.js";
|
|
3
|
+
/**
|
|
4
|
+
* Judge providers that cannot bill a per-token API.
|
|
5
|
+
*
|
|
6
|
+
* `claude-code` shells out to the `claude` CLI, authenticated with the user's
|
|
7
|
+
* Claude subscription (OAuth). `ollama`/`lmstudio`/`llamacpp`/`local` are local
|
|
8
|
+
* runtimes. Everything else is assumed to charge.
|
|
9
|
+
*
|
|
10
|
+
* An **allow-list**, deliberately, not a deny-list of known-paid providers: a
|
|
11
|
+
* provider nobody has classified yet should be treated as able to bill, because
|
|
12
|
+
* being wrong in that direction produces a surprise invoice while being wrong in
|
|
13
|
+
* this direction produces one extra flag.
|
|
14
|
+
*/
|
|
15
|
+
const FREE_JUDGE_PROVIDERS = new Set(["claude-code", "ollama", "lmstudio", "llamacpp", "local"]);
|
|
16
|
+
/** Whether judging with this ref can charge a per-token API. */
|
|
17
|
+
export function isMeteredJudge(judge) {
|
|
18
|
+
return !FREE_JUDGE_PROVIDERS.has(judge.provider);
|
|
19
|
+
}
|
|
20
|
+
/** Whether the user has explicitly accepted metered judging for this repo/shell. */
|
|
21
|
+
export function allowMeteredJudge() {
|
|
22
|
+
return envFlag("ALLOW_METERED_JUDGE");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Refuse to judge through a metered API unless the user explicitly asked for it.
|
|
26
|
+
*
|
|
27
|
+
* Why a hard refusal and not a warning: judging is the only cost in this tool that
|
|
28
|
+
* happens *without* a decision. The subject model is chosen per run and is the point
|
|
29
|
+
* of the exercise; the judge is a default, and a default that bills is a bug — it
|
|
30
|
+
* already billed a corpus once. A warning scrolls past inside a run's progress
|
|
31
|
+
* output, and by then the money is spent.
|
|
32
|
+
*
|
|
33
|
+
* Three paths could reach a metered API, and only one of them involves typing a
|
|
34
|
+
* flag: `--judge anthropic:…`; a `SKILL_HARNESS_JUDGE` set (or mistyped) to a
|
|
35
|
+
* metered provider; and `grade`, which re-judges with the judge the run *recorded* —
|
|
36
|
+
* so a run whose `results.yaml` names a metered judge bills on every later regrade,
|
|
37
|
+
* with no flag involved at all.
|
|
38
|
+
*
|
|
39
|
+
* That third path is latent rather than live in the corpus this was built against:
|
|
40
|
+
* checked 2026-08-05, all ~140 committed `results.yaml` in `principal-pi-skills`
|
|
41
|
+
* record `provider: claude-code`, because its owner always passed the subscription
|
|
42
|
+
* judge explicitly. The old default was reachable, not taken. Worth stating
|
|
43
|
+
* precisely — "your whole archive bills on regrade" would have been a scarier claim
|
|
44
|
+
* than the evidence supports.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately not applied to the subject model: paying to run the model under test
|
|
47
|
+
* is what a run *is*.
|
|
48
|
+
*/
|
|
49
|
+
export function assertJudgeAllowed(judge, opts) {
|
|
50
|
+
if (!isMeteredJudge(judge))
|
|
51
|
+
return;
|
|
52
|
+
if (opts.allowMetered || allowMeteredJudge())
|
|
53
|
+
return;
|
|
54
|
+
const token = `${judge.provider}:${judge.model}`;
|
|
55
|
+
throw new Error(`refusing to judge with ${token}: \`${judge.provider}\` bills a per-token API key, and it came from ${opts.source}.\n` +
|
|
56
|
+
` Judging is meant to cost nothing you did not ask for.\n` +
|
|
57
|
+
` • judge on your Claude subscription instead: --judge ${BAKED_DEFAULT_JUDGE}\n` +
|
|
58
|
+
` • allow the metered API for this command: --allow-metered-judge\n` +
|
|
59
|
+
` • allow it for this repo or shell: export SKILL_HARNESS_ALLOW_METERED_JUDGE=1`);
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=judge-policy.js.map
|
package/dist/lift.d.ts
CHANGED
|
@@ -44,10 +44,29 @@ export interface Lift {
|
|
|
44
44
|
* `LiftOptions.modeInsensitive`.
|
|
45
45
|
*/
|
|
46
46
|
modeInsensitive: string[];
|
|
47
|
+
/**
|
|
48
|
+
* Ids both runs covered whose two verdicts were produced by different
|
|
49
|
+
* aggregations, so the comparison is not like-for-like. Excluded rather than
|
|
50
|
+
* compared: see `comparableAggregation`.
|
|
51
|
+
*/
|
|
52
|
+
aggregationMismatch: LiftAggregationMismatch[];
|
|
47
53
|
/** True when either side was an `--only` run, so coverage is a subset by construction. */
|
|
48
54
|
partial: boolean;
|
|
49
55
|
cells: Record<string, LiftCell>;
|
|
50
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* How one side produced a scenario's verdict: over how many reps, and under which
|
|
59
|
+
* majority threshold (null when no aggregation happened).
|
|
60
|
+
*/
|
|
61
|
+
export interface AggregationShape {
|
|
62
|
+
reps: number;
|
|
63
|
+
threshold: number | null;
|
|
64
|
+
}
|
|
65
|
+
export interface LiftAggregationMismatch {
|
|
66
|
+
id: string;
|
|
67
|
+
red: AggregationShape;
|
|
68
|
+
green: AggregationShape;
|
|
69
|
+
}
|
|
51
70
|
export interface LiftOptions {
|
|
52
71
|
/**
|
|
53
72
|
* Scenario ids whose red and green runs are the same run by construction, so
|
package/dist/lift.js
CHANGED
|
@@ -2,6 +2,26 @@ import { existsSync, readdirSync, statSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { readResults, effectiveVerdicts } from "./results.js";
|
|
4
4
|
import { loadSpec } from "./spec.js";
|
|
5
|
+
function aggregationShape(s) {
|
|
6
|
+
const reps = s.reps ?? 1;
|
|
7
|
+
// At one rep `outcomesToResult` keeps the single judge verdict and never calls
|
|
8
|
+
// `aggregateReps`, so a `pass_threshold` sitting beside it was applied to
|
|
9
|
+
// nothing. Normalizing it away keeps a stray field from faking a mismatch.
|
|
10
|
+
return { reps, threshold: reps > 1 ? s.pass_threshold ?? null : null };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Whether two verdicts were produced the same way, and so mean the same thing.
|
|
14
|
+
*
|
|
15
|
+
* A one-rep verdict is a single draw; a three-rep verdict is a majority over
|
|
16
|
+
* three. Across that gap `red FAIL -> green PASS` can be sampling alone, and
|
|
17
|
+
* `gained` would be reporting the harness's own asymmetry as skill value — the
|
|
18
|
+
* inverse of the `modeInsensitive` error, and pointing the number the *other*
|
|
19
|
+
* way. The threshold counts too: 1-of-3 versus 3-of-3 is a different majority
|
|
20
|
+
* policy at the same N, so the aggregate is not the same measurement.
|
|
21
|
+
*/
|
|
22
|
+
function comparableAggregation(red, green) {
|
|
23
|
+
return red.reps === green.reps && red.threshold === green.threshold;
|
|
24
|
+
}
|
|
5
25
|
/** A verdict that carries real evidence about the task, rather than about the harness or the judge. */
|
|
6
26
|
function conclusive(verdict, suspect) {
|
|
7
27
|
// ERROR is a harness failure (timeout, empty reply) — it says nothing about
|
|
@@ -38,6 +58,8 @@ export function computeLift(red, green, opts = {}) {
|
|
|
38
58
|
const insensitive = new Set(opts.modeInsensitive ?? []);
|
|
39
59
|
const redV = new Map(effectiveVerdicts(red.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
|
|
40
60
|
const greenV = new Map(effectiveVerdicts(green.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
|
|
61
|
+
const redShape = new Map(red.scenarios.map((s) => [s.id, aggregationShape(s)]));
|
|
62
|
+
const greenShape = new Map(green.scenarios.map((s) => [s.id, aggregationShape(s)]));
|
|
41
63
|
const cells = {};
|
|
42
64
|
const counts = { gained: 0, regressed: 0, kept: 0, "both-fail": 0, inconclusive: 0 };
|
|
43
65
|
let redPassed = 0;
|
|
@@ -45,6 +67,7 @@ export function computeLift(red, green, opts = {}) {
|
|
|
45
67
|
// Green order drives display order (it is the run the author is looking at),
|
|
46
68
|
// restricted to ids the red baseline also covered.
|
|
47
69
|
const modeInsensitive = [];
|
|
70
|
+
const aggregationMismatch = [];
|
|
48
71
|
for (const [id, g] of greenV) {
|
|
49
72
|
const r = redV.get(id);
|
|
50
73
|
if (!r)
|
|
@@ -53,6 +76,15 @@ export function computeLift(red, green, opts = {}) {
|
|
|
53
76
|
modeInsensitive.push(id);
|
|
54
77
|
continue;
|
|
55
78
|
}
|
|
79
|
+
// Checked before classification, and reported separately, for the reason
|
|
80
|
+
// modeInsensitive is: there is no honest bucket for two verdicts that were
|
|
81
|
+
// not measured the same way.
|
|
82
|
+
const rShape = redShape.get(id) ?? { reps: 1, threshold: null };
|
|
83
|
+
const gShape = greenShape.get(id) ?? { reps: 1, threshold: null };
|
|
84
|
+
if (!comparableAggregation(rShape, gShape)) {
|
|
85
|
+
aggregationMismatch.push({ id, red: rShape, green: gShape });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
56
88
|
const cls = classify(r, g);
|
|
57
89
|
cells[id] = { red: r.verdict, redSuspect: r.suspect, green: g.verdict, class: cls };
|
|
58
90
|
counts[cls]++;
|
|
@@ -80,17 +112,44 @@ export function computeLift(red, green, opts = {}) {
|
|
|
80
112
|
greenOnly: [...greenV.keys()].filter((id) => !redV.has(id)),
|
|
81
113
|
redOnly: [...redV.keys()].filter((id) => !greenV.has(id)),
|
|
82
114
|
modeInsensitive,
|
|
115
|
+
aggregationMismatch,
|
|
83
116
|
partial: Boolean(red.partial || green.partial),
|
|
84
117
|
cells,
|
|
85
118
|
};
|
|
86
119
|
}
|
|
120
|
+
function reps(n) {
|
|
121
|
+
return n === 1 ? "1 rep" : `${n} reps`;
|
|
122
|
+
}
|
|
123
|
+
/** What differs between the two sides, in the words of the flag that caused it. */
|
|
124
|
+
function describeMismatch(ms) {
|
|
125
|
+
const distinct = new Set(ms.map((m) => m.red.reps !== m.green.reps
|
|
126
|
+
? `red ${reps(m.red.reps)} vs ${reps(m.green.reps)}`
|
|
127
|
+
: `red pass threshold ${m.red.threshold} vs ${m.green.threshold}`));
|
|
128
|
+
return distinct.size === 1 ? [...distinct][0] : "red and green aggregated differently";
|
|
129
|
+
}
|
|
130
|
+
/** The one command that would make the comparison measurable. */
|
|
131
|
+
function mismatchRemedy(ms) {
|
|
132
|
+
const greenReps = new Set(ms.map((m) => m.green.reps));
|
|
133
|
+
if (greenReps.size === 1 && ms.every((m) => m.red.reps !== m.green.reps)) {
|
|
134
|
+
return `re-run the baseline with --reps ${[...greenReps][0]}`;
|
|
135
|
+
}
|
|
136
|
+
return "re-measure both sides the same way";
|
|
137
|
+
}
|
|
87
138
|
/** One line for a human: what the skill did, and what it cost. */
|
|
88
139
|
export function liftHeadline(lift) {
|
|
89
140
|
if (lift.compared === 0) {
|
|
90
141
|
// Excluded-but-shared is not the same as never-shared. Claiming the runs had
|
|
91
142
|
// no scenario in common would hide the reason the lift is empty.
|
|
92
|
-
|
|
93
|
-
|
|
143
|
+
const mismatched = lift.aggregationMismatch.length;
|
|
144
|
+
const insensitive = lift.modeInsensitive.length;
|
|
145
|
+
if (mismatched > 0 && insensitive > 0) {
|
|
146
|
+
return `nothing comparable (${insensitive} run identically in both modes, ${mismatched} ${describeMismatch(lift.aggregationMismatch)})`;
|
|
147
|
+
}
|
|
148
|
+
if (mismatched > 0) {
|
|
149
|
+
return `nothing comparable (${mismatched} shared, ${describeMismatch(lift.aggregationMismatch)} — ${mismatchRemedy(lift.aggregationMismatch)})`;
|
|
150
|
+
}
|
|
151
|
+
if (insensitive > 0) {
|
|
152
|
+
return `nothing comparable (${insensitive} shared, all run identically in both modes)`;
|
|
94
153
|
}
|
|
95
154
|
return "no shared scenarios to compare";
|
|
96
155
|
}
|
|
@@ -116,6 +175,9 @@ export function liftHeadline(lift) {
|
|
|
116
175
|
if (lift.modeInsensitive.length > 0) {
|
|
117
176
|
segments.push(`${lift.modeInsensitive.length} not comparable (same run in both modes)`);
|
|
118
177
|
}
|
|
178
|
+
if (lift.aggregationMismatch.length > 0) {
|
|
179
|
+
segments.push(`${lift.aggregationMismatch.length} not comparable (${describeMismatch(lift.aggregationMismatch)})`);
|
|
180
|
+
}
|
|
119
181
|
if (lift.partial)
|
|
120
182
|
segments.push("partial run");
|
|
121
183
|
return segments.join(" · ");
|
package/dist/lint.js
CHANGED
|
@@ -3,7 +3,8 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
3
3
|
import yaml from "js-yaml";
|
|
4
4
|
import { loadSpec, SpecError } from "./spec.js";
|
|
5
5
|
import { readResults, finalizeResults, findTranscriptFiles, resultsPath } from "./results.js";
|
|
6
|
-
import { currentHashFor, describeSourceKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, UNREADABLE } from "./sources.js";
|
|
6
|
+
import { currentHashFor, describeSourceKey, remedyForKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, STIMULUS_PREFIX, UNREADABLE } from "./sources.js";
|
|
7
|
+
import { downgradeWarning } from "./downgrade.js";
|
|
7
8
|
import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
|
|
8
9
|
/** True if `p` exists and is a directory. Never throws (TOCTOU-safe: a race or dangling
|
|
9
10
|
* symlink between the check and the stat is treated as "not a directory", not an error). */
|
|
@@ -41,6 +42,16 @@ export function lintSkill(skillDir) {
|
|
|
41
42
|
return [{ skill: basename(skillDir), code: "spec", message }];
|
|
42
43
|
}
|
|
43
44
|
const skill = spec.skill;
|
|
45
|
+
// A lint from an older tool than the records it is checking cannot be trusted to
|
|
46
|
+
// check them: a 0.1.0 binary reported 38 spurious `consistency` findings against
|
|
47
|
+
// partial runs that 0.3.x handles correctly, and it cannot read source-hash key
|
|
48
|
+
// kinds added after it shipped. Reported as a finding (so CI sees it) rather than
|
|
49
|
+
// thrown, because refusing to lint is worse than linting with a caveat, and it is
|
|
50
|
+
// one of the few ways this situation announces itself at all.
|
|
51
|
+
const stale = downgradeWarning(skillDir);
|
|
52
|
+
if (stale) {
|
|
53
|
+
findings.push({ skill, code: "consistency", message: stale.replace(/^warning: /, "") });
|
|
54
|
+
}
|
|
44
55
|
// ship_bar sanity
|
|
45
56
|
if (spec.ship_bar.total < 1) {
|
|
46
57
|
findings.push({ skill, code: "ship_bar", message: "ship_bar.total must be >= 1" });
|
|
@@ -198,7 +209,7 @@ export function lintSkill(skillDir) {
|
|
|
198
209
|
continue; // predates source_hashes → silent
|
|
199
210
|
{
|
|
200
211
|
const newest = full.runDir;
|
|
201
|
-
const ctx = { skillDir, specDir, scenarios: spec.scenarios };
|
|
212
|
+
const ctx = { skillDir, specDir, scenarios: spec.scenarios, judgePersona: spec.judge_persona };
|
|
202
213
|
for (const [key, recorded] of Object.entries(hashes)) {
|
|
203
214
|
const what = describeSourceKey(key);
|
|
204
215
|
const scenario = scenarioIdForKey(key, spec.scenarios);
|
|
@@ -218,7 +229,12 @@ export function lintSkill(skillDir) {
|
|
|
218
229
|
findings.push({ skill, scenario, code: "stale", message: `${what} no longer exists but the newest ${basename(tagDir)} run measured it (${newest})` });
|
|
219
230
|
}
|
|
220
231
|
else if (current !== recorded) {
|
|
221
|
-
|
|
232
|
+
// The remedy is per key kind, and it is the whole point of the split: a
|
|
233
|
+
// rubric edit is re-gradeable from saved transcripts, a policy edit is a
|
|
234
|
+
// free rescore, a needle edit is a free regate. Only stimulus drift costs
|
|
235
|
+
// model spend. One message saying "re-run" for all four is what made
|
|
236
|
+
// correcting a known-bad rubric expensive enough to skip.
|
|
237
|
+
findings.push({ skill, scenario, code: "stale", message: `${what} changed since the newest ${basename(tagDir)} run (${newest}) — results are stale; ${remedyForKey(key)}` });
|
|
222
238
|
}
|
|
223
239
|
}
|
|
224
240
|
// Coverage: a scenario the spec defines that the newest full run never
|
|
@@ -227,9 +243,15 @@ export function lintSkill(skillDir) {
|
|
|
227
243
|
// 100%/SHIP scorecard survives an arbitrary spec rewrite reporting zero
|
|
228
244
|
// findings. Gated on the run having recorded scenario keys at all, so runs
|
|
229
245
|
// predating the key kind stay silent like every other pre-existing run.
|
|
230
|
-
|
|
246
|
+
// Either key kind counts as "this run recorded per-scenario hashes": 0.4.0+ runs
|
|
247
|
+
// carry `stimulus:<id>`, older ones the combined `scenario:<id>`. Checking only
|
|
248
|
+
// the legacy prefix would silently drop coverage checking for every new run.
|
|
249
|
+
const scenarioKeyPrefix = Object.keys(hashes).some((k) => k.startsWith(STIMULUS_PREFIX))
|
|
250
|
+
? STIMULUS_PREFIX
|
|
251
|
+
: SCENARIO_PREFIX;
|
|
252
|
+
if (Object.keys(hashes).some((k) => k.startsWith(scenarioKeyPrefix))) {
|
|
231
253
|
for (const s of spec.scenarios) {
|
|
232
|
-
if (!(
|
|
254
|
+
if (!(scenarioKeyPrefix + s.id in hashes)) {
|
|
233
255
|
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
256
|
}
|
|
235
257
|
}
|
package/dist/regate.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Spec } from "./spec.js";
|
|
2
|
+
import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
|
|
3
|
+
import { type ResultsFile } from "./results.js";
|
|
4
|
+
import type { Verdict } from "./score.js";
|
|
5
|
+
export interface RegateOptions {
|
|
6
|
+
runDir: string;
|
|
7
|
+
spec: Spec;
|
|
8
|
+
specDir: string;
|
|
9
|
+
adapter: HarnessAdapter;
|
|
10
|
+
judge: ModelRef;
|
|
11
|
+
now?: () => string;
|
|
12
|
+
}
|
|
13
|
+
export interface RegateChange {
|
|
14
|
+
id: string;
|
|
15
|
+
from: Verdict;
|
|
16
|
+
to: Verdict;
|
|
17
|
+
/** What the corrected needles say about this scenario now. */
|
|
18
|
+
gate: "pass" | "fail";
|
|
19
|
+
/** Whether re-deciding it required a judge call (only a gate-FAIL → gate-pass flip does). */
|
|
20
|
+
judged: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface RegateResult {
|
|
23
|
+
results: ResultsFile;
|
|
24
|
+
changes: RegateChange[];
|
|
25
|
+
/** Judge calls actually made, for the cost line the CLI prints. */
|
|
26
|
+
judgeCalls: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Re-evaluate needle gates against a run's **saved staged diffs** and re-decide the
|
|
30
|
+
* verdicts they determined — without re-running the model.
|
|
31
|
+
*
|
|
32
|
+
* `diff_contains` / `diff_excludes` are pure functions of the diff, and since
|
|
33
|
+
* `f6a5f6c` every seeded rep persists its diff as a run artifact. So the defect class
|
|
34
|
+
* "the gate was wrong, the behavior wasn't" — hit three times in the reference corpus
|
|
35
|
+
* (a context needle, a baseline-satisfied needle, a filename needle) — no longer costs
|
|
36
|
+
* a re-run. Measured on the C2 needle fix: **9 judge calls instead of 81
|
|
37
|
+
* rep-executions across three models.**
|
|
38
|
+
*
|
|
39
|
+
* Per rep, exactly one of four things happens:
|
|
40
|
+
*
|
|
41
|
+
* | old gate | new gate | outcome | cost |
|
|
42
|
+
* |---|---|---|---|
|
|
43
|
+
* | fail | fail | FAIL, with the corrected reason | free |
|
|
44
|
+
* | pass | fail | FAIL — the gate is objective and it says no | free |
|
|
45
|
+
* | pass | pass | the rep's saved judgement, re-parsed from its judge-raw artifact | free |
|
|
46
|
+
* | fail | pass | judged now: the judge never saw this rep, because the gate blocked it | 1 judge call |
|
|
47
|
+
*
|
|
48
|
+
* That third row is what keeps this cheap without guessing: a rep the judge already
|
|
49
|
+
* saw has its verdict on disk, so regate re-reads it rather than re-asking.
|
|
50
|
+
*
|
|
51
|
+
* **Limits, deliberately hard failures rather than partial work:** `assert.vitest` and
|
|
52
|
+
* `assert.post_test` need the workspace and cannot be re-evaluated from any artifact,
|
|
53
|
+
* so a scenario carrying either is not regatable. Diffs and judge-raw files are
|
|
54
|
+
* gitignored, so this works for whoever holds the run dirs — the repo owner, or CI that
|
|
55
|
+
* just ran — which is exactly the situation it is needed in.
|
|
56
|
+
*/
|
|
57
|
+
export declare function regateRun(opts: RegateOptions): Promise<RegateResult>;
|