@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.
@@ -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;
@@ -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
- if (lift.modeInsensitive.length > 0) {
93
- return `nothing comparable (${lift.modeInsensitive.length} shared, all run identically in both modes)`;
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
- findings.push({ skill, scenario, code: "stale", message: `${what} changed since the newest ${basename(tagDir)} run (${newest}) results are stale; re-run before publishing` });
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
- if (Object.keys(hashes).some((k) => k.startsWith(SCENARIO_PREFIX))) {
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 (!(SCENARIO_PREFIX + s.id in hashes)) {
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
  }
@@ -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>;