@skill-harness/core 0.1.1 → 0.2.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.
@@ -14,6 +14,12 @@ export interface RunReq {
14
14
  mode: RunMode;
15
15
  turns: string[];
16
16
  cwd: string;
17
+ /**
18
+ * Abs path to a markdown file to use AS the system prompt, instead of activating
19
+ * skillDir as a skill. Used to test a subagent definition (agents/<name>.md) in the
20
+ * single-shot shape it actually runs in; overrides `mode`'s skill flags.
21
+ */
22
+ systemPromptFile?: string;
17
23
  }
18
24
  /** A judge request: single prompt, no skills, no session. */
19
25
  export interface JudgeReq {
@@ -10,5 +10,9 @@ export interface DiscoveredSkill {
10
10
  * Returns skills sorted by name (testable or not).
11
11
  */
12
12
  export declare function discover(root: string): DiscoveredSkill[];
13
- /** Resolve a single skill by name; throws a helpful error if absent or specless. */
13
+ /**
14
+ * Resolve a single skill by name; throws a helpful error if absent or specless.
15
+ * A directory that exists but lacks a SKILL.md gets a specific error (rather than
16
+ * the generic "no skill") so callers don't reimplement the SKILL.md existence check.
17
+ */
14
18
  export declare function resolveSkill(root: string, name: string): DiscoveredSkill;
package/dist/discover.js CHANGED
@@ -24,10 +24,18 @@ export function discover(root) {
24
24
  skills.sort((a, b) => a.name.localeCompare(b.name));
25
25
  return skills;
26
26
  }
27
- /** Resolve a single skill by name; throws a helpful error if absent or specless. */
27
+ /**
28
+ * Resolve a single skill by name; throws a helpful error if absent or specless.
29
+ * A directory that exists but lacks a SKILL.md gets a specific error (rather than
30
+ * the generic "no skill") so callers don't reimplement the SKILL.md existence check.
31
+ */
28
32
  export function resolveSkill(root, name) {
29
33
  const skill = discover(root).find((s) => s.name === name);
30
34
  if (!skill) {
35
+ const dir = join(root, name);
36
+ if (existsSync(dir) && statSync(dir).isDirectory() && !existsSync(join(dir, "SKILL.md"))) {
37
+ throw new Error(`skill \`${name}\` has no SKILL.md (looked in ${dir})`);
38
+ }
31
39
  throw new Error(`no skill \`${name}\` under ${root}`);
32
40
  }
33
41
  return skill;
package/dist/grade.d.ts CHANGED
@@ -13,7 +13,18 @@ export interface ParsedVerdict {
13
13
  verdict: Verdict;
14
14
  reason: string;
15
15
  }
16
- /** Parse a judge's raw output into a verdict + reason. Unparseable → ERROR. */
16
+ /**
17
+ * Parse a judge's raw output into a verdict + reason.
18
+ *
19
+ * Judges sometimes emit MORE than one verdict block (a first pass, then a restated
20
+ * conclusion). Every block is read, never just the first:
21
+ * - all blocks agree → that verdict, with the reason from the LAST block (the
22
+ * judge's final word) in full
23
+ * - blocks disagree → JUDGE-AMBIGUOUS, which counts as a non-pass and carries both
24
+ * verdicts in the reason so a rejudge can be queued. Silently taking either one
25
+ * would be inventing a grade the judge did not give.
26
+ * Unparseable → ERROR.
27
+ */
17
28
  export declare function parseVerdict(out: string): ParsedVerdict;
18
29
  /**
19
30
  * Judge-≠-subject de-confound guard. True when the judge resembles the model
package/dist/grade.js CHANGED
@@ -15,18 +15,39 @@ Grade each checklist item PASS or FAIL with a <=12-word justification quoting th
15
15
  VERDICT: PASS (only if EVERY item passed) — or — VERDICT: FAIL
16
16
  REASON: <15 words or fewer>`;
17
17
  }
18
- const VERDICT_RE = /VERDICT\**\s*:?\s*\**\s*(PASS|FAIL)/i;
19
- const REASON_RE = /REASON\**\s*:?\s*\**\s*(.*)$/im;
20
- /** Parse a judge's raw output into a verdict + reason. Unparseable ERROR. */
18
+ // Both anchor to the start of a line and REQUIRE the colon. Without those anchors the
19
+ // reason pattern matched any word containing "reason" — git-ops GLM C1 stored
20
+ // "able given no repo present.", a fragment of "Reasonable" in the judge's prose, which
21
+ // then read as a FAIL-verdict-with-passing-reason misfire that never happened.
22
+ const VERDICT_RE = /^\s*\**\s*VERDICT\**\s*:\s*\**\s*(PASS|FAIL)/gim;
23
+ const REASON_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/gim;
24
+ /**
25
+ * Parse a judge's raw output into a verdict + reason.
26
+ *
27
+ * Judges sometimes emit MORE than one verdict block (a first pass, then a restated
28
+ * conclusion). Every block is read, never just the first:
29
+ * - all blocks agree → that verdict, with the reason from the LAST block (the
30
+ * judge's final word) in full
31
+ * - blocks disagree → JUDGE-AMBIGUOUS, which counts as a non-pass and carries both
32
+ * verdicts in the reason so a rejudge can be queued. Silently taking either one
33
+ * would be inventing a grade the judge did not give.
34
+ * Unparseable → ERROR.
35
+ */
21
36
  export function parseVerdict(out) {
22
- const vm = out.match(VERDICT_RE);
23
- if (!vm) {
37
+ const verdicts = [...out.matchAll(VERDICT_RE)].map((m) => m[1].toUpperCase());
38
+ if (verdicts.length === 0) {
24
39
  return { verdict: "ERROR", reason: "judge produced no parseable verdict" };
25
40
  }
26
- const verdict = vm[1].toUpperCase();
27
- const rm = out.match(REASON_RE);
28
- const reason = rm ? rm[1].trim() : "";
29
- return { verdict, reason };
41
+ const reasons = [...out.matchAll(REASON_RE)].map((m) => m[1].trim());
42
+ const reason = reasons.length > 0 ? reasons[reasons.length - 1] : "";
43
+ const unique = [...new Set(verdicts)];
44
+ if (unique.length > 1) {
45
+ return {
46
+ verdict: "JUDGE-AMBIGUOUS",
47
+ reason: `judge emitted conflicting verdicts (${verdicts.join(", ")}) — needs rejudge; last reason: ${reason}`,
48
+ };
49
+ }
50
+ return { verdict: unique[0], reason };
30
51
  }
31
52
  /**
32
53
  * Judge-≠-subject de-confound guard. True when the judge resembles the model
@@ -51,13 +72,30 @@ const ITEM_RE = /^\s*\d+[.)]\s*\**\s*(PASS|FAIL)\b/gim;
51
72
  export function detectMisfire(raw, verdict) {
52
73
  if (verdict === "ERROR")
53
74
  return false;
75
+ // Conflicting verdicts are suspect by construction — there is no consistent grade.
76
+ if (verdict === "JUDGE-AMBIGUOUS")
77
+ return true;
54
78
  const items = [...raw.matchAll(ITEM_RE)].map((m) => m[1].toUpperCase() === "PASS");
55
- if (items.length === 0)
79
+ if (items.length === 0) {
80
+ // No item lines to cross-check, so fall back to the verdict-vs-reason shape: a FAIL
81
+ // whose reason says everything passed is the misfire class from REVIEW-FINDINGS
82
+ // finding 2. Deliberately narrow — an earlier version of this tripwire fired on
83
+ // terse genuine FAILs, so it requires an explicitly total claim ("all items ...
84
+ // pass", "every item ... satisfied") and no negation anywhere in the reason.
85
+ if (verdict === "FAIL") {
86
+ const reason = (raw.match(REASON_LINE_RE)?.[1] ?? "").trim();
87
+ const totalPass = /\b(all|every)\b[^.]*\b(pass(es|ed)?|satisf(y|ies|ied)|hold(s)?|met)\b/i.test(reason);
88
+ const negated = /\b(not|no|n't|fails?|failed|missing|except|but|however)\b/i.test(reason);
89
+ return totalPass && !negated;
90
+ }
56
91
  return false; // fail-open
92
+ }
57
93
  const andItems = items.every((ok) => ok);
58
94
  const verdictBool = verdict === "PASS";
59
95
  return verdictBool !== andItems;
60
96
  }
97
+ // Non-global twin of REASON_RE: matchAll needs /g, a single .match() must not have it.
98
+ const REASON_LINE_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/im;
61
99
  /** Drive the judge for one transcript and parse the result. */
62
100
  export async function gradeTranscript(adapter, judge, prompt, cwd) {
63
101
  const raw = await adapter.judge({ model: judge, prompt, cwd });
package/dist/index.d.ts CHANGED
@@ -8,10 +8,14 @@ export * from "./journal.js";
8
8
  export * from "./scheduler.js";
9
9
  export * from "./reps.js";
10
10
  export * from "./regrade.js";
11
+ export * from "./rescore.js";
11
12
  export * from "./workspace.js";
12
13
  export * from "./seeded.js";
13
14
  export * from "./report.js";
14
15
  export * from "./trends.js";
15
16
  export * from "./lint.js";
17
+ export * from "./lift.js";
16
18
  export * from "./adapters/types.js";
17
19
  export * from "./util/exec.js";
20
+ export * from "./util/env.js";
21
+ export * from "./scaffold.js";
package/dist/index.js CHANGED
@@ -8,11 +8,15 @@ export * from "./journal.js";
8
8
  export * from "./scheduler.js";
9
9
  export * from "./reps.js";
10
10
  export * from "./regrade.js";
11
+ export * from "./rescore.js";
11
12
  export * from "./workspace.js";
12
13
  export * from "./seeded.js";
13
14
  export * from "./report.js";
14
15
  export * from "./trends.js";
15
16
  export * from "./lint.js";
17
+ export * from "./lift.js";
16
18
  export * from "./adapters/types.js";
17
19
  export * from "./util/exec.js";
20
+ export * from "./util/env.js";
21
+ export * from "./scaffold.js";
18
22
  //# sourceMappingURL=index.js.map
package/dist/journal.d.ts CHANGED
@@ -54,6 +54,20 @@ export type JournalEvent = {
54
54
  id: string;
55
55
  reason: string;
56
56
  rep?: number;
57
+ } | {
58
+ event: "empty-response-retry";
59
+ ts: string;
60
+ id: string;
61
+ attempt: number;
62
+ rep?: number;
63
+ } | {
64
+ event: "rescore";
65
+ ts: string;
66
+ changed: string[];
67
+ passed: number;
68
+ total: number;
69
+ pct: number;
70
+ ship: boolean;
57
71
  } | {
58
72
  event: "score";
59
73
  ts: string;
package/dist/lift.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { type ResultsFile } from "./results.js";
2
+ import type { Verdict } from "./score.js";
3
+ /**
4
+ * What a skill did to one scenario, red (skill off) vs green (skill active).
5
+ *
6
+ * `inconclusive` is load-bearing: it is what stops lift from becoming a number
7
+ * that only ever goes up. See `classify`.
8
+ */
9
+ export type LiftClass = "gained" | "regressed" | "kept" | "both-fail" | "inconclusive";
10
+ export interface LiftCell {
11
+ red: Verdict;
12
+ /**
13
+ * Whether the red side's verdict was an unresolved misfire. Carried (not just
14
+ * folded into `class`) because the review UI recomputes the class live as the
15
+ * author overrides green verdicts — it needs the red evidence, not a frozen
16
+ * conclusion. See assets/report.grade.js's liftClass.
17
+ */
18
+ redSuspect: boolean;
19
+ green: Verdict;
20
+ class: LiftClass;
21
+ }
22
+ export interface Lift {
23
+ tag: string;
24
+ model: string;
25
+ redTimestamp: string;
26
+ greenTimestamp: string;
27
+ /** Scenario ids present in both runs — the only ones a lift can speak to. */
28
+ compared: number;
29
+ gained: number;
30
+ regressed: number;
31
+ kept: number;
32
+ bothFail: number;
33
+ inconclusive: number;
34
+ redPassed: number;
35
+ greenPassed: number;
36
+ delta: number;
37
+ /** Ids the green run covered that the red baseline did not (and vice versa). */
38
+ greenOnly: string[];
39
+ redOnly: string[];
40
+ /** True when either side was an `--only` run, so coverage is a subset by construction. */
41
+ partial: boolean;
42
+ cells: Record<string, LiftCell>;
43
+ }
44
+ /**
45
+ * Compare a red (baseline, skill off) run against a green (skill active) run of
46
+ * the same model: the "does this skill actually do anything?" measurement.
47
+ *
48
+ * Both sides go through `effectiveVerdicts`, so an author override is what
49
+ * counts and an override resolves a misfire — the same rule scoring uses. Only
50
+ * the intersection of scenario ids is compared; a lift cannot speak to a
51
+ * scenario one side never ran.
52
+ */
53
+ export declare function computeLift(red: ResultsFile, green: ResultsFile): Lift;
54
+ /** One line for a human: what the skill did, and what it cost. */
55
+ 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
+ export declare function collectLift(skillDir: string): Lift[];
package/dist/lift.js ADDED
@@ -0,0 +1,163 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readResults, effectiveVerdicts } from "./results.js";
4
+ /** A verdict that carries real evidence about the task, rather than about the harness or the judge. */
5
+ function conclusive(verdict, suspect) {
6
+ // ERROR is a harness failure (timeout, empty reply) — it says nothing about
7
+ // whether the agent could do the task. JUDGE-AMBIGUOUS and an unresolved
8
+ // misfire are judge failures. Treating any of them as a FAIL would make
9
+ // "red FAIL -> green PASS" fire on infrastructure noise, and lift would
10
+ // measure flakiness while looking like skill value.
11
+ return !suspect && verdict !== "ERROR" && verdict !== "JUDGE-AMBIGUOUS";
12
+ }
13
+ function classify(red, green) {
14
+ if (!conclusive(red.verdict, red.suspect) || !conclusive(green.verdict, green.suspect))
15
+ return "inconclusive";
16
+ const redPass = red.verdict === "PASS";
17
+ const greenPass = green.verdict === "PASS";
18
+ if (redPass && greenPass)
19
+ return "kept";
20
+ if (!redPass && greenPass)
21
+ return "gained";
22
+ if (redPass && !greenPass)
23
+ return "regressed";
24
+ return "both-fail";
25
+ }
26
+ /**
27
+ * Compare a red (baseline, skill off) run against a green (skill active) run of
28
+ * the same model: the "does this skill actually do anything?" measurement.
29
+ *
30
+ * Both sides go through `effectiveVerdicts`, so an author override is what
31
+ * counts and an override resolves a misfire — the same rule scoring uses. Only
32
+ * the intersection of scenario ids is compared; a lift cannot speak to a
33
+ * scenario one side never ran.
34
+ */
35
+ export function computeLift(red, green) {
36
+ const redV = new Map(effectiveVerdicts(red.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
37
+ const greenV = new Map(effectiveVerdicts(green.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
38
+ const cells = {};
39
+ const counts = { gained: 0, regressed: 0, kept: 0, "both-fail": 0, inconclusive: 0 };
40
+ let redPassed = 0;
41
+ let greenPassed = 0;
42
+ // Green order drives display order (it is the run the author is looking at),
43
+ // restricted to ids the red baseline also covered.
44
+ for (const [id, g] of greenV) {
45
+ const r = redV.get(id);
46
+ if (!r)
47
+ continue;
48
+ const cls = classify(r, g);
49
+ cells[id] = { red: r.verdict, redSuspect: r.suspect, green: g.verdict, class: cls };
50
+ counts[cls]++;
51
+ if (cls !== "inconclusive") {
52
+ if (r.verdict === "PASS")
53
+ redPassed++;
54
+ if (g.verdict === "PASS")
55
+ greenPassed++;
56
+ }
57
+ }
58
+ return {
59
+ tag: "",
60
+ model: green.model,
61
+ redTimestamp: red.timestamp,
62
+ greenTimestamp: green.timestamp,
63
+ compared: Object.keys(cells).length,
64
+ gained: counts.gained,
65
+ regressed: counts.regressed,
66
+ kept: counts.kept,
67
+ bothFail: counts["both-fail"],
68
+ inconclusive: counts.inconclusive,
69
+ redPassed,
70
+ greenPassed,
71
+ delta: greenPassed - redPassed,
72
+ greenOnly: [...greenV.keys()].filter((id) => !redV.has(id)),
73
+ redOnly: [...redV.keys()].filter((id) => !greenV.has(id)),
74
+ partial: Boolean(red.partial || green.partial),
75
+ cells,
76
+ };
77
+ }
78
+ /** One line for a human: what the skill did, and what it cost. */
79
+ export function liftHeadline(lift) {
80
+ if (lift.compared === 0)
81
+ return "no shared scenarios to compare";
82
+ // Everything inconclusive is NOT "no effect" — it is no measurement. Saying
83
+ // "no measured effect" here would be the same not-measured/measured-no-effect
84
+ // conflation this module refuses to make when a red baseline is missing.
85
+ const conclusive = lift.compared - lift.inconclusive;
86
+ if (conclusive === 0) {
87
+ return `nothing conclusive to compare (${lift.inconclusive} inconclusive — fix the harness/judge, then re-run)`;
88
+ }
89
+ const segments = [];
90
+ if (lift.gained === 0 && lift.regressed === 0) {
91
+ segments.push(lift.kept > 0
92
+ ? `no measured effect (${lift.kept} passed without the skill too)`
93
+ : "no measured effect");
94
+ }
95
+ else {
96
+ const sign = lift.delta > 0 ? `+${lift.delta}` : String(lift.delta);
97
+ segments.push(`${sign} net (${lift.gained} gained, ${lift.regressed} regressed)`);
98
+ }
99
+ if (lift.inconclusive > 0)
100
+ segments.push(`${lift.inconclusive} inconclusive`);
101
+ if (lift.partial)
102
+ segments.push("partial run");
103
+ return segments.join(" · ");
104
+ }
105
+ /** A directory that exists right now; false (never throws) if it vanished concurrently. */
106
+ function isDir(p) {
107
+ try {
108
+ return statSync(p).isDirectory();
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ /**
115
+ * Per model-tag under <skillDir>/tests/results/, pair the most recent red run
116
+ * with the most recent green run and compute the lift.
117
+ *
118
+ * Deliberately derived on read rather than persisted into results.yaml: a lift
119
+ * is a fact about a *pair* of runs, so caching it inside one run's file would go
120
+ * stale the moment a new baseline lands — the stale-scorecard failure mode
121
+ * `source_hashes` exists to prevent. Deriving also means lift works
122
+ * retroactively on results already committed by 0.1.x/0.2.0 users.
123
+ *
124
+ * A tag with no red baseline is omitted entirely rather than reported as a zero
125
+ * lift: "not measured" and "measured no effect" are different claims.
126
+ */
127
+ export function collectLift(skillDir) {
128
+ const resultsRoot = join(skillDir, "tests", "results");
129
+ if (!existsSync(resultsRoot))
130
+ return [];
131
+ const lifts = [];
132
+ for (const tag of readdirSync(resultsRoot).filter((n) => isDir(join(resultsRoot, n))).sort()) {
133
+ const tagDir = join(resultsRoot, tag);
134
+ const runDirs = readdirSync(tagDir)
135
+ .map((n) => join(tagDir, n))
136
+ .filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
137
+ .sort(); // timestamp-slug names ⇒ chronological ascending
138
+ // Mode is only knowable after reading results.yaml, so every run in the tag
139
+ // is read; last-wins gives the most recent of each mode.
140
+ let red;
141
+ let green;
142
+ for (const rd of runDirs) {
143
+ let r;
144
+ try {
145
+ r = readResults(rd);
146
+ }
147
+ catch (e) {
148
+ // A corrupt/truncated results.yaml must not take down the whole view.
149
+ console.warn(`skill-harness lift: skipping unreadable run ${rd}: ${e instanceof Error ? e.message : e}`);
150
+ continue;
151
+ }
152
+ if (r.mode === "red")
153
+ red = r;
154
+ else if (r.mode === "green")
155
+ green = r;
156
+ }
157
+ if (!red || !green)
158
+ continue;
159
+ lifts.push({ ...computeLift(red, green), tag });
160
+ }
161
+ return lifts;
162
+ }
163
+ //# sourceMappingURL=lift.js.map
package/dist/lint.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "consistency" | "lint-error";
1
+ export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "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,5 +1,6 @@
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";
3
4
  import yaml from "js-yaml";
4
5
  import { loadSpec, SpecError } from "./spec.js";
5
6
  import { readResults, finalizeResults, findTranscriptFiles, resultsPath } from "./results.js";
@@ -13,6 +14,14 @@ function isDir(p) {
13
14
  return false;
14
15
  }
15
16
  }
17
+ function isFile(p) {
18
+ try {
19
+ return statSync(p).isFile();
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
16
25
  /**
17
26
  * Validate one skill's spec + fixtures statically (and results-consistency when
18
27
  * committed results exist — see the consistency block). Never throws: a bad spec
@@ -64,6 +73,16 @@ export function lintSkill(skillDir) {
64
73
  }
65
74
  }
66
75
  }
76
+ // system_prompt_file must exist — an agent-file scenario silently falling back to
77
+ // skill activation would measure the wrong artifact entirely.
78
+ for (const s of spec.scenarios) {
79
+ if (!s.systemPromptFile)
80
+ continue;
81
+ const abs = isAbsolute(s.systemPromptFile) ? s.systemPromptFile : resolve(specDir, s.systemPromptFile);
82
+ if (!isFile(abs)) {
83
+ findings.push({ skill, scenario: s.id, code: "fixture", message: `system_prompt_file not found: ${s.systemPromptFile}` });
84
+ }
85
+ }
67
86
  // results-consistency — only for committed results.yaml (skipped silently otherwise).
68
87
  // Each run dir gets ONE try: schema-1 is intentionally skipped (continue, no finding —
69
88
  // migrateResults carries a schema-1 grade verbatim, so recomputing it would false-flag).
@@ -78,9 +97,17 @@ export function lintSkill(skillDir) {
78
97
  if (raw?.schema !== 2)
79
98
  continue; // schema-1 intentionally skipped — no finding
80
99
  const r = readResults(runDir);
81
- const ctx = r.mode === "green" ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
82
- const recomputed = finalizeResults({ skill: r.skill, harness: r.harness, model: r.model, judge: r.judge, timestamp: r.timestamp, label: r.label, mode: r.mode, scenarios: r.scenarios }, ctx).effective_grade;
83
- if (JSON.stringify(recomputed) !== JSON.stringify(r.effective_grade)) {
100
+ // A run whose scenario set no longer matches the spec predates a spec reshape
101
+ // (scenarios added/removed). Its grade was computed against the OLD ship bar and
102
+ // cannot be meaningfully recomputed against the new one — recomputing would flag
103
+ // every historical run each time a spec grows. Staleness (source_hashes) is the
104
+ // mechanism that says "re-run"; consistency only polices runs the current spec
105
+ // can actually re-score. Override/transcript rules below still apply.
106
+ const specIds = new Set(spec.scenarios.map((sc) => sc.id));
107
+ const sameSet = r.scenarios.length === specIds.size && r.scenarios.every((sc) => specIds.has(sc.id));
108
+ const ctx = r.mode === "green" && !r.partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
109
+ const recomputed = !sameSet ? null : finalizeResults({ skill: r.skill, harness: r.harness, model: r.model, judge: r.judge, timestamp: r.timestamp, label: r.label, mode: r.mode, partial: r.partial, source_hashes: r.source_hashes, scenarios: r.scenarios }, ctx).effective_grade;
110
+ if (recomputed && JSON.stringify(recomputed) !== JSON.stringify(r.effective_grade)) {
84
111
  findings.push({ skill, code: "consistency", message: `results.yaml effective_grade is stale in ${runDir} (recompute differs)` });
85
112
  }
86
113
  for (const s of r.scenarios) {
@@ -96,8 +123,78 @@ export function lintSkill(skillDir) {
96
123
  findings.push({ skill, code: "consistency", message: `results.yaml unreadable or malformed in ${runDir}: ${e instanceof Error ? e.message : String(e)}` });
97
124
  }
98
125
  }
126
+ // staleness — the newest FULL (non-partial) run per model tag recorded sha256 hashes of
127
+ // every source file it measured. If any of those files has changed since, the committed
128
+ // result describes text that no longer exists — exactly how three regressions hid behind
129
+ // a 100%-SHIP table for four weeks. Runs predating source_hashes are skipped silently
130
+ // (no retroactive noise); partial runs never count as coverage.
131
+ for (const tagDir of enumerateTagDirs(resultsRoot)) {
132
+ // Newest FULL run: partial (--only) runs are iteration artifacts and never count as
133
+ // coverage — a fresh partial must not silence a stale full run underneath it.
134
+ let full = null;
135
+ for (const runDir of runDirsNewestFirst(tagDir)) {
136
+ try {
137
+ const r = readResults(runDir);
138
+ if (r.partial)
139
+ continue;
140
+ full = { runDir, r };
141
+ break;
142
+ }
143
+ catch {
144
+ break;
145
+ } // unreadable → the consistency block already reports it
146
+ }
147
+ const hashes = full?.r.source_hashes;
148
+ if (!full || !hashes)
149
+ continue; // predates source_hashes → silent
150
+ {
151
+ const newest = full.runDir;
152
+ for (const [key, recorded] of Object.entries(hashes)) {
153
+ const abs = key === "SKILL.md" ? join(skillDir, "SKILL.md") : resolve(specDir, key);
154
+ const current = fileSha256(abs);
155
+ if (current === null) {
156
+ findings.push({ skill, code: "stale", message: `${key} no longer exists but the newest ${basename(tagDir)} run measured it (${newest})` });
157
+ }
158
+ else if (current !== recorded) {
159
+ findings.push({ skill, code: "stale", message: `${key} changed since the newest ${basename(tagDir)} run (${newest}) — results are stale; re-run before publishing` });
160
+ }
161
+ }
162
+ }
163
+ }
99
164
  return findings;
100
165
  }
166
+ function fileSha256(p) {
167
+ try {
168
+ return createHash("sha256").update(readFileSync(p)).digest("hex");
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ /** Model-tag dirs under tests/results (each holds timestamped run dirs). */
175
+ function enumerateTagDirs(resultsRoot) {
176
+ if (!existsSync(resultsRoot))
177
+ return [];
178
+ try {
179
+ return readdirSync(resultsRoot).map((t) => join(resultsRoot, t)).filter(isDir);
180
+ }
181
+ catch {
182
+ return [];
183
+ }
184
+ }
185
+ /** Timestamped run dirs holding a results.yaml, newest first (ISO slugs sort lexicographically). */
186
+ function runDirsNewestFirst(tagDir) {
187
+ let timestamps;
188
+ try {
189
+ timestamps = readdirSync(tagDir);
190
+ }
191
+ catch {
192
+ return [];
193
+ }
194
+ return timestamps.sort().reverse()
195
+ .map((ts) => join(tagDir, ts))
196
+ .filter((d) => isDir(d) && existsSync(join(d, "results.yaml")));
197
+ }
101
198
  /**
102
199
  * All committed run dirs under a skill's tests/results (<tag>/<timestamp>/results.yaml).
103
200
  * Empty if none. Never throws: unreadable/dangling entries (e.g. a broken symlink, or a
package/dist/regrade.d.ts CHANGED
@@ -39,6 +39,12 @@ export interface RegradeRunOptions {
39
39
  judge: ModelRef;
40
40
  specDir: string;
41
41
  now?: () => string;
42
+ /**
43
+ * Re-judge ONLY scenarios whose stored verdict is untrustworthy — suspect (misfire) or
44
+ * JUDGE-AMBIGUOUS. Everything else is carried verbatim: their transcripts were judged
45
+ * cleanly, so spending judge calls on them buys nothing.
46
+ */
47
+ onlySuspect?: boolean;
42
48
  }
43
49
  /**
44
50
  * Re-judge every green-transcript scenario in a run dir with `judge` — no
package/dist/regrade.js CHANGED
@@ -65,7 +65,19 @@ export async function regradeRun(opts) {
65
65
  // a recorded verdict or shrink the grade denominator. Fail fast, before
66
66
  // spending any judge calls.
67
67
  const specById = new Map(spec.scenarios.map((s) => [s.id, s]));
68
- const targets = (prev?.scenarios ?? spec.scenarios).map((s) => s.id);
68
+ const recorded = prev?.scenarios ?? spec.scenarios.map((s) => ({ id: s.id }));
69
+ let targets = recorded.map((s) => s.id);
70
+ if (opts.onlySuspect) {
71
+ if (!prev)
72
+ throw new Error(`--suspect-only needs a prior results.yaml in ${runDir}`);
73
+ targets = prev.scenarios
74
+ .filter((s) => s.suspect || s.judge_verdict === "JUDGE-AMBIGUOUS")
75
+ .map((s) => s.id);
76
+ if (targets.length === 0) {
77
+ // Nothing untrustworthy — a no-op, not an error. Return the file as-is.
78
+ return prev;
79
+ }
80
+ }
69
81
  const missing = targets.filter((id) => !specById.has(id) || findTranscriptFiles(runDir, id, "green").length === 0);
70
82
  if (missing.length === targets.length) {
71
83
  throw new Error(`no green transcripts in ${runDir} — nothing to re-grade`);
@@ -73,8 +85,14 @@ export async function regradeRun(opts) {
73
85
  if (missing.length > 0) {
74
86
  throw new Error(`cannot re-grade ${missing.join(", ")} in ${runDir} (transcript missing or scenario no longer in the spec) — re-run instead of grading`);
75
87
  }
88
+ const targetSet = new Set(targets);
76
89
  const scenarioResults = [];
77
- for (const id of targets) {
90
+ for (const rec of recorded) {
91
+ const id = rec.id;
92
+ if (!targetSet.has(id)) {
93
+ scenarioResults.push(rec); // clean verdict, carried verbatim (onlySuspect)
94
+ continue;
95
+ }
78
96
  const scenario = specById.get(id); // guaranteed present by the guard above
79
97
  const prevScenario = prev?.scenarios.find((s) => s.id === id);
80
98
  const threshold = effectiveThreshold(prevScenario, scenario);
@@ -84,7 +102,7 @@ export async function regradeRun(opts) {
84
102
  const carry = overrides.get(id);
85
103
  scenarioResults.push({ ...rr, override: carry?.override ?? null, note: carry?.note ?? "" });
86
104
  }
87
- const ctx = mode === "green" ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
105
+ const ctx = mode === "green" && !prev?.partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
88
106
  const results = writeResults(runDir, {
89
107
  skill: spec.skill,
90
108
  harness: prev?.harness ?? "pi",
@@ -93,6 +111,10 @@ export async function regradeRun(opts) {
93
111
  timestamp: prev?.timestamp ?? now(),
94
112
  label: prev?.label ?? null,
95
113
  mode,
114
+ // A re-grade judges the SAVED transcripts, which were produced by the OLD text —
115
+ // the recorded hashes stay, keeping an honestly-stale run honestly stale.
116
+ partial: prev?.partial,
117
+ source_hashes: prev?.source_hashes,
96
118
  scenarios: scenarioResults,
97
119
  }, ctx);
98
120
  const g = results.effective_grade;