@skill-harness/core 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lint.js CHANGED
@@ -3,6 +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";
7
+ import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
6
8
  /** True if `p` exists and is a directory. Never throws (TOCTOU-safe: a race or dangling
7
9
  * symlink between the check and the stat is treated as "not a directory", not an error). */
8
10
  function isDir(p) {
@@ -13,6 +15,14 @@ function isDir(p) {
13
15
  return false;
14
16
  }
15
17
  }
18
+ function isFile(p) {
19
+ try {
20
+ return statSync(p).isFile();
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
16
26
  /**
17
27
  * Validate one skill's spec + fixtures statically (and results-consistency when
18
28
  * committed results exist — see the consistency block). Never throws: a bad spec
@@ -56,12 +66,68 @@ export function lintSkill(skillDir) {
56
66
  // relative to the spec's dir, matching workspace.ts resolve(specDir, fixture) where specDir = <skillDir>/tests.
57
67
  const specDir = dirname(specPath);
58
68
  for (const s of spec.scenarios) {
59
- const fx = typeof s.workspace === "object" && s.workspace !== null ? s.workspace.fixture : undefined;
69
+ const fx = effectiveFixture(s); // shared with sources.ts so hashing and linting can't drift
60
70
  if (fx) {
61
71
  const abs = isAbsolute(fx) ? fx : resolve(specDir, fx);
62
72
  if (!isDir(abs)) {
63
73
  findings.push({ skill, scenario: s.id, code: "fixture", message: `fixture not found: ${fx}` });
64
74
  }
75
+ else {
76
+ // A mistyped fixture marker (`_uncommited/`) is rejected at run time by
77
+ // createWorkspace — but only once a run has been started, where it surfaces as
78
+ // a scenario FAIL among real results. lint is free, offline and runs in CI, so
79
+ // it is where an author should meet this. The set flagged here is exactly the
80
+ // set workspace.ts refuses, so lint can never bless a fixture the runtime then
81
+ // rejects.
82
+ // try/catch because lintSkill's contract is "never throws": isDir() proves the
83
+ // path stats, not that it can be read, and an EACCES on readdir would escape
84
+ // to cli.ts, which replaces this skill's ENTIRE finding list with one
85
+ // lint-error — silently discarding the staleness and consistency checks below.
86
+ let markers = [];
87
+ try {
88
+ markers = unknownMarkerDirs(abs);
89
+ }
90
+ catch (e) {
91
+ findings.push({
92
+ skill, scenario: s.id, code: "fixture",
93
+ message: `fixture ${fx} could not be read: ${e instanceof Error ? e.message : String(e)}`,
94
+ });
95
+ }
96
+ for (const dir of markers) {
97
+ const guess = suggestMarker(dir);
98
+ findings.push({
99
+ skill,
100
+ scenario: s.id,
101
+ code: "fixture-marker",
102
+ message: `fixture ${fx} has unknown top-level marker directory \`${dir}/\`` +
103
+ (guess ? ` — did you mean \`${guess}/\`?` : "") +
104
+ ` Known markers are ${MARKERS.map((m) => `\`${m}/\``).join(" and ")};` +
105
+ ` rename it, or move it deeper if it is ordinary content.`,
106
+ });
107
+ }
108
+ }
109
+ }
110
+ }
111
+ // assert.post_test must exist. A post-test that isn't there fails its scenario at
112
+ // run time with a "spec error" message, but that costs a full model run to discover
113
+ // — and lint is the free, offline gate that exists to catch it first.
114
+ for (const s of spec.scenarios) {
115
+ const pt = s.assert?.post_test;
116
+ if (!pt)
117
+ continue;
118
+ const abs = isAbsolute(pt) ? pt : resolve(specDir, pt);
119
+ if (!isFile(abs)) {
120
+ findings.push({ skill, scenario: s.id, code: "fixture", message: `assert.post_test not found: ${pt}` });
121
+ }
122
+ }
123
+ // system_prompt_file must exist — an agent-file scenario silently falling back to
124
+ // skill activation would measure the wrong artifact entirely.
125
+ for (const s of spec.scenarios) {
126
+ if (!s.systemPromptFile)
127
+ continue;
128
+ const abs = isAbsolute(s.systemPromptFile) ? s.systemPromptFile : resolve(specDir, s.systemPromptFile);
129
+ if (!isFile(abs)) {
130
+ findings.push({ skill, scenario: s.id, code: "fixture", message: `system_prompt_file not found: ${s.systemPromptFile}` });
65
131
  }
66
132
  }
67
133
  // results-consistency — only for committed results.yaml (skipped silently otherwise).
@@ -78,9 +144,17 @@ export function lintSkill(skillDir) {
78
144
  if (raw?.schema !== 2)
79
145
  continue; // schema-1 intentionally skipped — no finding
80
146
  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)) {
147
+ // A run whose scenario set no longer matches the spec predates a spec reshape
148
+ // (scenarios added/removed). Its grade was computed against the OLD ship bar and
149
+ // cannot be meaningfully recomputed against the new one — recomputing would flag
150
+ // every historical run each time a spec grows. Staleness (source_hashes) is the
151
+ // mechanism that says "re-run"; consistency only polices runs the current spec
152
+ // can actually re-score. Override/transcript rules below still apply.
153
+ const specIds = new Set(spec.scenarios.map((sc) => sc.id));
154
+ const sameSet = r.scenarios.length === specIds.size && r.scenarios.every((sc) => specIds.has(sc.id));
155
+ const ctx = r.mode === "green" && !r.partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
156
+ 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;
157
+ if (recomputed && JSON.stringify(recomputed) !== JSON.stringify(r.effective_grade)) {
84
158
  findings.push({ skill, code: "consistency", message: `results.yaml effective_grade is stale in ${runDir} (recompute differs)` });
85
159
  }
86
160
  for (const s of r.scenarios) {
@@ -96,8 +170,98 @@ export function lintSkill(skillDir) {
96
170
  findings.push({ skill, code: "consistency", message: `results.yaml unreadable or malformed in ${runDir}: ${e instanceof Error ? e.message : String(e)}` });
97
171
  }
98
172
  }
173
+ // staleness — the newest FULL (non-partial) run per model tag recorded sha256 hashes of
174
+ // every source it measured: SKILL.md, agent files, each scenario's definition and each
175
+ // fixture tree. If any has changed since, the committed result describes inputs that no
176
+ // longer exist — exactly how three regressions hid behind a 100%-SHIP table for four
177
+ // weeks, and how a swapped fixture went unreported by a "7 skills, 0 findings" lint.
178
+ // Runs predating source_hashes (or predating a given key kind) are skipped silently — no
179
+ // retroactive noise; partial runs never count as coverage.
180
+ for (const tagDir of enumerateTagDirs(resultsRoot)) {
181
+ // Newest FULL run: partial (--only) runs are iteration artifacts and never count as
182
+ // coverage — a fresh partial must not silence a stale full run underneath it.
183
+ let full = null;
184
+ for (const runDir of runDirsNewestFirst(tagDir)) {
185
+ try {
186
+ const r = readResults(runDir);
187
+ if (r.partial)
188
+ continue;
189
+ full = { runDir, r };
190
+ break;
191
+ }
192
+ catch {
193
+ break;
194
+ } // unreadable → the consistency block already reports it
195
+ }
196
+ const hashes = full?.r.source_hashes;
197
+ if (!full || !hashes)
198
+ continue; // predates source_hashes → silent
199
+ {
200
+ const newest = full.runDir;
201
+ const ctx = { skillDir, specDir, scenarios: spec.scenarios };
202
+ for (const [key, recorded] of Object.entries(hashes)) {
203
+ const what = describeSourceKey(key);
204
+ const scenario = scenarioIdForKey(key, spec.scenarios);
205
+ // The run itself failed to hash this source, so it was never verified and
206
+ // no comparison here can establish anything about it.
207
+ if (recorded === UNREADABLE) {
208
+ findings.push({ skill, scenario, code: "stale", message: `${what} could not be read when the newest ${basename(tagDir)} run was recorded (${newest}) — that source was never verified; re-run` });
209
+ continue;
210
+ }
211
+ const current = currentHashFor(key, ctx);
212
+ // undefined = not comparable: a scenario the spec no longer has (a reshape,
213
+ // per the scenario-set check above), or a key kind written by a newer
214
+ // skill-harness than this one.
215
+ if (current === undefined)
216
+ continue;
217
+ if (current === null) {
218
+ findings.push({ skill, scenario, code: "stale", message: `${what} no longer exists but the newest ${basename(tagDir)} run measured it (${newest})` });
219
+ }
220
+ 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` });
222
+ }
223
+ }
224
+ // Coverage: a scenario the spec defines that the newest full run never
225
+ // measured. Without this, RENAMING a scenario is invisible — the old key
226
+ // resolves to "not comparable" and the new one was never recorded — so a
227
+ // 100%/SHIP scorecard survives an arbitrary spec rewrite reporting zero
228
+ // findings. Gated on the run having recorded scenario keys at all, so runs
229
+ // predating the key kind stay silent like every other pre-existing run.
230
+ if (Object.keys(hashes).some((k) => k.startsWith(SCENARIO_PREFIX))) {
231
+ for (const s of spec.scenarios) {
232
+ if (!(SCENARIO_PREFIX + s.id in hashes)) {
233
+ findings.push({ skill, scenario: s.id, code: "stale", message: `the newest ${basename(tagDir)} run (${newest}) did not measure scenario \`${s.id}\` — the published result covers a different scenario set than the spec; re-run before publishing` });
234
+ }
235
+ }
236
+ }
237
+ }
238
+ }
99
239
  return findings;
100
240
  }
241
+ /** Model-tag dirs under tests/results (each holds timestamped run dirs). */
242
+ function enumerateTagDirs(resultsRoot) {
243
+ if (!existsSync(resultsRoot))
244
+ return [];
245
+ try {
246
+ return readdirSync(resultsRoot).map((t) => join(resultsRoot, t)).filter(isDir);
247
+ }
248
+ catch {
249
+ return [];
250
+ }
251
+ }
252
+ /** Timestamped run dirs holding a results.yaml, newest first (ISO slugs sort lexicographically). */
253
+ function runDirsNewestFirst(tagDir) {
254
+ let timestamps;
255
+ try {
256
+ timestamps = readdirSync(tagDir);
257
+ }
258
+ catch {
259
+ return [];
260
+ }
261
+ return timestamps.sort().reverse()
262
+ .map((ts) => join(tagDir, ts))
263
+ .filter((d) => isDir(d) && existsSync(join(d, "results.yaml")));
264
+ }
101
265
  /**
102
266
  * All committed run dirs under a skill's tests/results (<tag>/<timestamp>/results.yaml).
103
267
  * 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;
package/dist/report.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ShipBar } from "./spec.js";
2
2
  import { type ResultsFile } from "./results.js";
3
+ import { type Lift } from "./lift.js";
3
4
  export interface RunColumn {
4
5
  index: number;
5
6
  label: string;
@@ -20,6 +21,17 @@ export interface RunColumn {
20
21
  override: string | null;
21
22
  note: string;
22
23
  }>;
24
+ /**
25
+ * Red-vs-green lift for this model, when the tag has both a red baseline and a
26
+ * green run. Undefined means "never measured" — which is not the same claim as
27
+ * a zero lift, so the report must not render a 0 for it.
28
+ *
29
+ * Only set when THIS column is the green run the lift was computed from (see
30
+ * collectReport): the review UI recomputes lift from the column's live cells,
31
+ * which is only valid if those cells are the green side of the comparison.
32
+ */
33
+ lift?: Lift;
34
+ liftHeadline?: string;
23
35
  }
24
36
  export interface ReportData {
25
37
  skill: string;
@@ -48,6 +60,8 @@ export declare function publicView(data: ReportData): {
48
60
  critical: boolean;
49
61
  }[];
50
62
  columns: {
63
+ lift?: Lift | undefined;
64
+ liftHeadline?: string | undefined;
51
65
  index: number;
52
66
  label: string;
53
67
  tag: string;
package/dist/report.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadSpec } from "./spec.js";
4
4
  import { readResults } from "./results.js";
5
+ import { collectLift, liftHeadline } from "./lift.js";
5
6
  /** Most-recent run dir (by name, which is an ISO-ish slug) under a model-tag dir. */
6
7
  function latestRunDir(tagDir) {
7
8
  if (!statSync(tagDir).isDirectory())
@@ -21,6 +22,8 @@ export function collectReport(skillDir) {
21
22
  const spec = loadSpec(specPath);
22
23
  const scenarios = spec.scenarios.map((s) => ({ id: s.id, title: s.title, critical: s.critical }));
23
24
  const resultsRoot = join(skillDir, "tests", "results");
25
+ // Lift is keyed by model tag, the same key columns are built from.
26
+ const liftByTag = new Map(collectLift(skillDir).map((l) => [l.tag, l]));
24
27
  const columns = [];
25
28
  if (existsSync(resultsRoot)) {
26
29
  const tags = readdirSync(resultsRoot)
@@ -46,16 +49,27 @@ export function collectReport(skillDir) {
46
49
  note: s.note,
47
50
  };
48
51
  }
52
+ const tag = tagDir.split("/").pop();
53
+ // A column is the tag's LATEST run, which is not necessarily the green one —
54
+ // record a red baseline after a green run and the newest run in the tag is
55
+ // red. The review UI recomputes lift from `cells` (so author overrides move
56
+ // it live), so attaching a lift to a column whose cells are the RED run
57
+ // would have it compare red against red and report "no effect" for a skill
58
+ // that in fact gained every scenario. Attach only when this column IS the
59
+ // green side of the comparison.
60
+ const tagLift = liftByTag.get(tag);
61
+ const lift = tagLift && tagLift.greenTimestamp === r.timestamp ? tagLift : undefined;
49
62
  columns.push({
50
63
  index: columns.length,
51
64
  label: r.model,
52
- tag: tagDir.split("/").pop(),
65
+ tag,
53
66
  runDir,
54
67
  timestamp: r.timestamp,
55
68
  mode: r.mode,
56
69
  grade: r.effective_grade,
57
70
  judge: r.judge,
58
71
  cells,
72
+ ...(lift ? { lift, liftHeadline: liftHeadline(lift) } : {}),
59
73
  });
60
74
  }
61
75
  }
@@ -77,6 +91,7 @@ export function publicView(data) {
77
91
  grade: c.grade,
78
92
  judge: c.judge,
79
93
  cells: c.cells,
94
+ ...(c.lift ? { lift: c.lift, liftHeadline: c.liftHeadline } : {}),
80
95
  })),
81
96
  };
82
97
  }
@@ -0,0 +1,34 @@
1
+ import type { Spec } from "./spec.js";
2
+ import { type ResultsFile, type ScenarioResult } from "./results.js";
3
+ export interface RescoreOptions {
4
+ runDir: string;
5
+ spec: Spec;
6
+ now?: () => string;
7
+ }
8
+ export interface RescoreChange {
9
+ id: string;
10
+ from: ScenarioResult["judge_verdict"];
11
+ to: ScenarioResult["judge_verdict"];
12
+ passes: number;
13
+ clean: number;
14
+ fromThreshold: number;
15
+ toThreshold: number;
16
+ }
17
+ export interface RescoreResult {
18
+ results: ResultsFile;
19
+ changes: RescoreChange[];
20
+ }
21
+ /**
22
+ * Re-score a run against the CURRENT spec's pass thresholds — no model calls, no judge
23
+ * calls. Reps are the raw measurement (`passes` of `clean`); a threshold is *policy*.
24
+ * When the policy changes, the honest move is to recompute the old measurements under it
25
+ * rather than reconcile two numbers in prose — and to record what moved.
26
+ *
27
+ * Only reps-bearing scenarios can be re-scored: a single-rep verdict has no rate to
28
+ * re-apply a threshold to, and ERROR/JUDGE-AMBIGUOUS carry no trustworthy rate at all —
29
+ * both are carried verbatim. Overrides, notes, and suspect flags are preserved: this
30
+ * changes the collapse rule, nothing about what the judge said.
31
+ */
32
+ export declare function rescoreRun(opts: RescoreOptions): RescoreResult;
33
+ /** Locate the spec for a run dir: results/<tag>/<ts> → ../../../specification.yaml */
34
+ export declare function specPathForRunDir(runDir: string): string;
@@ -0,0 +1,66 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readResults, writeResults } from "./results.js";
4
+ import { appendJournal } from "./journal.js";
5
+ /**
6
+ * Re-score a run against the CURRENT spec's pass thresholds — no model calls, no judge
7
+ * calls. Reps are the raw measurement (`passes` of `clean`); a threshold is *policy*.
8
+ * When the policy changes, the honest move is to recompute the old measurements under it
9
+ * rather than reconcile two numbers in prose — and to record what moved.
10
+ *
11
+ * Only reps-bearing scenarios can be re-scored: a single-rep verdict has no rate to
12
+ * re-apply a threshold to, and ERROR/JUDGE-AMBIGUOUS carry no trustworthy rate at all —
13
+ * both are carried verbatim. Overrides, notes, and suspect flags are preserved: this
14
+ * changes the collapse rule, nothing about what the judge said.
15
+ */
16
+ export function rescoreRun(opts) {
17
+ const now = opts.now ?? (() => new Date().toISOString());
18
+ const prev = readResults(opts.runDir);
19
+ if (!prev)
20
+ throw new Error(`no results.yaml in ${opts.runDir}`);
21
+ const specById = new Map(opts.spec.scenarios.map((s) => [s.id, s]));
22
+ const changes = [];
23
+ const scenarios = prev.scenarios.map((s) => {
24
+ const scenario = specById.get(s.id);
25
+ // no rate to re-apply, or an untrustworthy verdict → carry verbatim
26
+ if (!scenario || s.reps === undefined || s.clean === undefined || s.passes === undefined)
27
+ return s;
28
+ if (s.judge_verdict === "ERROR" || s.judge_verdict === "JUDGE-AMBIGUOUS")
29
+ return s;
30
+ if (s.clean === 0)
31
+ return s;
32
+ const toThreshold = scenario.passThreshold ?? 0.5;
33
+ const fromThreshold = s.pass_threshold ?? toThreshold;
34
+ if (toThreshold === fromThreshold)
35
+ return s;
36
+ const rate = s.passes / s.clean;
37
+ const verdict = rate >= toThreshold ? "PASS" : "FAIL";
38
+ if (verdict !== s.judge_verdict) {
39
+ changes.push({ id: s.id, from: s.judge_verdict, to: verdict, passes: s.passes, clean: s.clean, fromThreshold, toThreshold });
40
+ }
41
+ return { ...s, judge_verdict: verdict, pass_threshold: toThreshold };
42
+ });
43
+ const ctx = prev.mode === "green" && !prev.partial
44
+ ? { shipBar: opts.spec.ship_bar, critical: opts.spec.critical }
45
+ : null;
46
+ const results = writeResults(opts.runDir, {
47
+ skill: prev.skill, harness: prev.harness, model: prev.model, judge: prev.judge,
48
+ timestamp: prev.timestamp, label: prev.label, mode: prev.mode,
49
+ partial: prev.partial, source_hashes: prev.source_hashes, scenarios,
50
+ }, ctx);
51
+ appendJournal(opts.runDir, {
52
+ event: "rescore", ts: now(),
53
+ changed: changes.map((c) => `${c.id}: ${c.from}->${c.to} (${c.passes}/${c.clean} @ ${c.toThreshold})`),
54
+ passed: results.effective_grade.passed, total: results.effective_grade.total,
55
+ pct: results.effective_grade.pct, ship: results.effective_grade.ship,
56
+ });
57
+ return { results, changes };
58
+ }
59
+ /** Locate the spec for a run dir: results/<tag>/<ts> → ../../../specification.yaml */
60
+ export function specPathForRunDir(runDir) {
61
+ const p = join(runDir, "..", "..", "..", "specification.yaml");
62
+ if (!existsSync(p))
63
+ throw new Error(`no specification.yaml above ${runDir}`);
64
+ return p;
65
+ }
66
+ //# sourceMappingURL=rescore.js.map
package/dist/results.d.ts CHANGED
@@ -35,6 +35,18 @@ export interface ResultsFile {
35
35
  timestamp: string;
36
36
  label: string | null;
37
37
  mode: string;
38
+ /** True for an `--only`-filtered run: a scenario subset, never ship-graded, never a release run. */
39
+ partial?: boolean;
40
+ /**
41
+ * sha256 of every source this run measured: SKILL.md, each distinct
42
+ * system_prompt_file, each scenario's definition (`scenario:<id>`) and each
43
+ * fixture tree (`fixture:<path>`). Lint compares the newest run's hashes against
44
+ * the current sources — a mismatch means the published result describes inputs
45
+ * that no longer exist (the stale-scorecard class this field exists to kill).
46
+ * See sources.ts for the key scheme; runs recorded before a key kind existed
47
+ * simply don't carry it, and are never retroactively flagged.
48
+ */
49
+ source_hashes?: Record<string, string>;
38
50
  effective_grade: GradeSummary;
39
51
  scenarios: ScenarioResult[];
40
52
  }
@@ -74,7 +86,7 @@ export declare function applyOverride(results: ResultsFile, scenarioId: string,
74
86
  * `!…` preservation lines added by preserveTranscript.
75
87
  */
76
88
  export declare function ensureResultsGitignore(resultsRoot: string): void;
77
- /** The rep index embedded in a transcript/judge-raw filename (`.rep<k>.`), or null for a plain (non-rep) file. */
89
+ /** The rep index embedded in a transcript / judge-raw / staged-diff filename (`.rep<k>.`), or null for a plain (non-rep) file. */
78
90
  export declare function repIndexOf(filename: string): number | null;
79
91
  /**
80
92
  * ALL transcript files for a scenario in a run dir, sorted deterministically:
@@ -86,23 +98,43 @@ export declare function repIndexOf(filename: string): number | null;
86
98
  * `<id>.<mode>.rep<k>.txt`) — e.g. to detect a green-only condition without
87
99
  * false positives from a red/force transcript of the same scenario. Omitted,
88
100
  * behavior is unchanged: any `<id>.*.txt` regardless of mode, excluding this
89
- * scenario's judge-raw artifacts (`<id>.*.judge.txt` see judgeRawPath).
101
+ * scenario's sibling artifacts, which share the `.txt` extension deliberately
102
+ * (so `results/.gitignore`'s `*.txt` covers them all): judge-raw output
103
+ * (`<id>.*.judge.txt` — see judgeRawPath) and the staged diff
104
+ * (`<id>.*.diff.txt` — see diffPath).
90
105
  */
91
106
  export declare function findTranscriptFiles(runDir: string, scenarioId: string, mode?: string): string[];
92
107
  /** Path of a scenario's raw judge-output artifact within a run dir (rep-suffixed for reps). */
93
108
  export declare function judgeRawPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
94
109
  /** A scenario's raw judge-output files, sorted (plain first, then numeric rep). Mode-scoped when given. */
95
110
  export declare function findJudgeRawFiles(runDir: string, scenarioId: string, mode?: string): string[];
111
+ /**
112
+ * Path of a seeded scenario's staged-diff artifact within a run dir (rep-suffixed
113
+ * for reps).
114
+ *
115
+ * The diff is the only record of what the model actually *did* — the workspace is
116
+ * torn down after every rep, so without this a seeded verdict cannot be audited
117
+ * after the fact. Named `<id>.<mode>[.rep<k>].diff.txt` so it sorts beside its
118
+ * transcript and is covered by the `*.txt` rule in results/.gitignore: diffs are
119
+ * generated evidence, ignored like transcripts, not committed like results.yaml.
120
+ */
121
+ export declare function diffPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
122
+ /** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
123
+ export declare function findDiffFiles(runDir: string, scenarioId: string, mode?: string): string[];
96
124
  /** A single representative transcript file for a scenario in a run dir. Null if none. */
97
125
  export declare function findTranscriptFile(runDir: string, scenarioId: string): string | null;
98
126
  /**
99
- * Un-gitignore ALL of a scenario's transcript AND judge-raw artifact files
100
- * (audit trail for an override — a --reps run has one transcript (and one
101
- * judge-raw file) per rep, and every rep that drove the verdict must survive
102
- * a commit, not just an arbitrary one).
127
+ * Un-gitignore ALL of a scenario's transcript, judge-raw AND staged-diff
128
+ * artifact files (audit trail for an override — a --reps run has one of each
129
+ * per rep, and every rep that drove the verdict must survive a commit, not just
130
+ * an arbitrary one).
103
131
  * Appends `!<tag>/<ts>/<id>.<mode>[.rep<k>].txt` (and the matching
104
- * `.judge.txt`) to results/.gitignore for each, once. The path uses POSIX
105
- * separators so the negation matches on Windows too (git ignore patterns are
106
- * always forward-slashed).
132
+ * `.judge.txt` / `.diff.txt`) to results/.gitignore for each, once. The path
133
+ * uses POSIX separators so the negation matches on Windows too (git ignore
134
+ * patterns are always forward-slashed).
135
+ *
136
+ * The diff belongs here for the same reason the judge-raw output does: an
137
+ * override says the judge got it wrong, and on a seeded scenario the evidence
138
+ * for that claim is the code the model wrote.
107
139
  */
108
140
  export declare function preserveTranscript(resultsRoot: string, runDir: string, scenarioId: string): void;