@skill-harness/core 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/grade.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface JudgePromptInput {
7
7
  scenario: Scenario;
8
8
  transcript: string;
9
9
  }
10
+ /** The heading runSeeded writes above the staged diff. Gating on this exact string is what keeps the guidance honest — see below. */
11
+ export declare const STAGED_DIFF_HEADING = "=== STAGED DIFF ===";
10
12
  /** Build the LLM-judge prompt for one transcript (ported from the old grade.sh). */
11
13
  export declare function buildJudgePrompt(input: JudgePromptInput): string;
12
14
  export interface ParsedVerdict {
package/dist/grade.js CHANGED
@@ -1,8 +1,33 @@
1
1
  import { createWorkspace } from "./workspace.js";
2
+ /** The heading runSeeded writes above the staged diff. Gating on this exact string is what keeps the guidance honest — see below. */
3
+ export const STAGED_DIFF_HEADING = "=== STAGED DIFF ===";
4
+ /**
5
+ * Addendum pointing the judge at the code, added only when the code is actually there.
6
+ *
7
+ * A seeded transcript ends with the staged diff, and without this the judge
8
+ * weighs the model's prose about its work equally with the work itself — which
9
+ * is how six reps that all passed the objective gates split PASS/FAIL purely on
10
+ * whether the model wrote "rejects overdrafts" or "subtracts amount".
11
+ *
12
+ * Gated on the transcript CONTAINING the diff section, not on `scenario.mode`.
13
+ * The first sentence is a factual claim about the transcript, and every seeded
14
+ * transcript saved before this feature existed lacks that section — so keying
15
+ * off the mode would tell the judge its primary evidence is at the end of a
16
+ * transcript that has none, while also telling it the gate lines prove nothing.
17
+ * That is a sweep of FAILs justified by absent evidence, and it would land
18
+ * squarely on `grade`, the command AGENTS.md rule 4 recommends as the *cheap*
19
+ * de-confounding step before re-running. Inline scenarios have no diff either,
20
+ * so their prompt stays byte-identical and every published inline verdict
21
+ * remains comparable.
22
+ */
23
+ const SEEDED_DIFF_GUIDANCE = `
24
+ This transcript ends with a "=== STAGED DIFF ===" section: the actual code the assistant wrote, as \`git diff --cached\`. It is the primary evidence. Grade what the diff shows the code DOES, not what the assistant's prose claims it does — a confident description of behavior the diff does not implement is a FAIL, and behavior the diff plainly implements passes even if the assistant described it poorly or not at all. The "=== SEEDED GATES ===" lines above it are keyword and test-run checks only; they do not establish that the required behavior exists. If the diff is marked truncated, judge only what you can see and never infer that cut-off code is missing.
25
+ `;
2
26
  /** Build the LLM-judge prompt for one transcript (ported from the old grade.sh). */
3
27
  export function buildJudgePrompt(input) {
4
28
  const { skill, persona, scenario, transcript } = input;
5
29
  const numbered = scenario.checklist.map((c, i) => `${i + 1}. ${c}`).join("\n");
30
+ const diffGuidance = scenario.mode === "seeded" && transcript.includes(STAGED_DIFF_HEADING) ? SEEDED_DIFF_GUIDANCE : "";
6
31
  return `You are grading ONE response from an AI assistant using a "${skill}" skill — ${persona} Judge it ONLY against the checklist below — do not add requirements beyond it.
7
32
 
8
33
  CHECKLIST (every numbered item must hold for a PASS):
@@ -10,7 +35,7 @@ ${numbered}
10
35
 
11
36
  TRANSCRIPT (the assistant is the model under test):
12
37
  ${transcript}
13
-
38
+ ${diffGuidance}
14
39
  Grade each checklist item PASS or FAIL with a <=12-word justification quoting the transcript. Be skeptical: if an item is not clearly satisfied, mark it FAIL. Then output exactly these two lines:
15
40
  VERDICT: PASS (only if EVERY item passed) — or — VERDICT: FAIL
16
41
  REASON: <15 words or fewer>`;
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export * from "./seeded.js";
14
14
  export * from "./report.js";
15
15
  export * from "./trends.js";
16
16
  export * from "./lint.js";
17
+ export * from "./sources.js";
17
18
  export * from "./lift.js";
18
19
  export * from "./adapters/types.js";
19
20
  export * from "./util/exec.js";
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ export * from "./seeded.js";
14
14
  export * from "./report.js";
15
15
  export * from "./trends.js";
16
16
  export * from "./lint.js";
17
+ export * from "./sources.js";
17
18
  export * from "./lift.js";
18
19
  export * from "./adapters/types.js";
19
20
  export * from "./util/exec.js";
package/dist/lift.d.ts CHANGED
@@ -37,10 +37,30 @@ export interface Lift {
37
37
  /** Ids the green run covered that the red baseline did not (and vice versa). */
38
38
  greenOnly: string[];
39
39
  redOnly: string[];
40
+ /**
41
+ * Ids both runs covered that a lift cannot speak to, because the harness runs
42
+ * them identically in red and green. Reported rather than compared: folding them
43
+ * in would credit the red side with passes the skill itself produced. See
44
+ * `LiftOptions.modeInsensitive`.
45
+ */
46
+ modeInsensitive: string[];
40
47
  /** True when either side was an `--only` run, so coverage is a subset by construction. */
41
48
  partial: boolean;
42
49
  cells: Record<string, LiftCell>;
43
50
  }
51
+ export interface LiftOptions {
52
+ /**
53
+ * Scenario ids whose red and green runs are the same run by construction, so
54
+ * comparing them measures nothing.
55
+ *
56
+ * The case that exists today is `system_prompt_file`: the pi adapter treats an
57
+ * agent-file scenario's file AS the system prompt and passes `--no-skills`
58
+ * *whatever the mode*, so the skill is loaded on both sides. Left in, such a
59
+ * cell lands in `kept` (or `both-fail`) and drags the denominator down —
60
+ * understating lift with evidence that the skill worked.
61
+ */
62
+ modeInsensitive?: Iterable<string>;
63
+ }
44
64
  /**
45
65
  * Compare a red (baseline, skill off) run against a green (skill active) run of
46
66
  * the same model: the "does this skill actually do anything?" measurement.
@@ -48,22 +68,10 @@ export interface Lift {
48
68
  * Both sides go through `effectiveVerdicts`, so an author override is what
49
69
  * counts and an override resolves a misfire — the same rule scoring uses. Only
50
70
  * the intersection of scenario ids is compared; a lift cannot speak to a
51
- * scenario one side never ran.
71
+ * scenario one side never ran, nor to one the harness ran identically in both
72
+ * modes (`opts.modeInsensitive`).
52
73
  */
53
- export declare function computeLift(red: ResultsFile, green: ResultsFile): Lift;
74
+ export declare function computeLift(red: ResultsFile, green: ResultsFile, opts?: LiftOptions): Lift;
54
75
  /** One line for a human: what the skill did, and what it cost. */
55
76
  export declare function liftHeadline(lift: Lift): string;
56
- /**
57
- * Per model-tag under <skillDir>/tests/results/, pair the most recent red run
58
- * with the most recent green run and compute the lift.
59
- *
60
- * Deliberately derived on read rather than persisted into results.yaml: a lift
61
- * is a fact about a *pair* of runs, so caching it inside one run's file would go
62
- * stale the moment a new baseline lands — the stale-scorecard failure mode
63
- * `source_hashes` exists to prevent. Deriving also means lift works
64
- * retroactively on results already committed by 0.1.x/0.2.0 users.
65
- *
66
- * A tag with no red baseline is omitted entirely rather than reported as a zero
67
- * lift: "not measured" and "measured no effect" are different claims.
68
- */
69
77
  export declare function collectLift(skillDir: string): Lift[];
package/dist/lift.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { readResults, effectiveVerdicts } from "./results.js";
4
+ import { loadSpec } from "./spec.js";
4
5
  /** A verdict that carries real evidence about the task, rather than about the harness or the judge. */
5
6
  function conclusive(verdict, suspect) {
6
7
  // ERROR is a harness failure (timeout, empty reply) — it says nothing about
@@ -30,9 +31,11 @@ function classify(red, green) {
30
31
  * Both sides go through `effectiveVerdicts`, so an author override is what
31
32
  * counts and an override resolves a misfire — the same rule scoring uses. Only
32
33
  * the intersection of scenario ids is compared; a lift cannot speak to a
33
- * scenario one side never ran.
34
+ * scenario one side never ran, nor to one the harness ran identically in both
35
+ * modes (`opts.modeInsensitive`).
34
36
  */
35
- export function computeLift(red, green) {
37
+ export function computeLift(red, green, opts = {}) {
38
+ const insensitive = new Set(opts.modeInsensitive ?? []);
36
39
  const redV = new Map(effectiveVerdicts(red.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
37
40
  const greenV = new Map(effectiveVerdicts(green.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
38
41
  const cells = {};
@@ -41,10 +44,15 @@ export function computeLift(red, green) {
41
44
  let greenPassed = 0;
42
45
  // Green order drives display order (it is the run the author is looking at),
43
46
  // restricted to ids the red baseline also covered.
47
+ const modeInsensitive = [];
44
48
  for (const [id, g] of greenV) {
45
49
  const r = redV.get(id);
46
50
  if (!r)
47
51
  continue;
52
+ if (insensitive.has(id)) {
53
+ modeInsensitive.push(id);
54
+ continue;
55
+ }
48
56
  const cls = classify(r, g);
49
57
  cells[id] = { red: r.verdict, redSuspect: r.suspect, green: g.verdict, class: cls };
50
58
  counts[cls]++;
@@ -71,14 +79,21 @@ export function computeLift(red, green) {
71
79
  delta: greenPassed - redPassed,
72
80
  greenOnly: [...greenV.keys()].filter((id) => !redV.has(id)),
73
81
  redOnly: [...redV.keys()].filter((id) => !greenV.has(id)),
82
+ modeInsensitive,
74
83
  partial: Boolean(red.partial || green.partial),
75
84
  cells,
76
85
  };
77
86
  }
78
87
  /** One line for a human: what the skill did, and what it cost. */
79
88
  export function liftHeadline(lift) {
80
- if (lift.compared === 0)
89
+ if (lift.compared === 0) {
90
+ // Excluded-but-shared is not the same as never-shared. Claiming the runs had
91
+ // no scenario in common would hide the reason the lift is empty.
92
+ if (lift.modeInsensitive.length > 0) {
93
+ return `nothing comparable (${lift.modeInsensitive.length} shared, all run identically in both modes)`;
94
+ }
81
95
  return "no shared scenarios to compare";
96
+ }
82
97
  // Everything inconclusive is NOT "no effect" — it is no measurement. Saying
83
98
  // "no measured effect" here would be the same not-measured/measured-no-effect
84
99
  // conflation this module refuses to make when a red baseline is missing.
@@ -98,6 +113,9 @@ export function liftHeadline(lift) {
98
113
  }
99
114
  if (lift.inconclusive > 0)
100
115
  segments.push(`${lift.inconclusive} inconclusive`);
116
+ if (lift.modeInsensitive.length > 0) {
117
+ segments.push(`${lift.modeInsensitive.length} not comparable (same run in both modes)`);
118
+ }
101
119
  if (lift.partial)
102
120
  segments.push("partial run");
103
121
  return segments.join(" · ");
@@ -124,10 +142,31 @@ function isDir(p) {
124
142
  * A tag with no red baseline is omitted entirely rather than reported as a zero
125
143
  * lift: "not measured" and "measured no effect" are different claims.
126
144
  */
145
+ /**
146
+ * Scenario ids the harness runs identically in red and green, read from the spec
147
+ * rather than from results.yaml: a lift is derived on read, so this has to work
148
+ * on runs recorded before the field existed — and `scenario:<id>` source hashes
149
+ * fold the value in without preserving it.
150
+ *
151
+ * Never throws: an unparseable spec must degrade to "nothing excluded" rather
152
+ * than take down a view that is otherwise readable from the results alone.
153
+ */
154
+ function modeInsensitiveIds(skillDir) {
155
+ const specPath = join(skillDir, "tests", "specification.yaml");
156
+ if (!existsSync(specPath))
157
+ return [];
158
+ try {
159
+ return loadSpec(specPath).scenarios.filter((s) => s.systemPromptFile).map((s) => s.id);
160
+ }
161
+ catch {
162
+ return [];
163
+ }
164
+ }
127
165
  export function collectLift(skillDir) {
128
166
  const resultsRoot = join(skillDir, "tests", "results");
129
167
  if (!existsSync(resultsRoot))
130
168
  return [];
169
+ const modeInsensitive = modeInsensitiveIds(skillDir);
131
170
  const lifts = [];
132
171
  for (const tag of readdirSync(resultsRoot).filter((n) => isDir(join(resultsRoot, n))).sort()) {
133
172
  const tagDir = join(resultsRoot, tag);
@@ -156,7 +195,7 @@ export function collectLift(skillDir) {
156
195
  }
157
196
  if (!red || !green)
158
197
  continue;
159
- lifts.push({ ...computeLift(red, green), tag });
198
+ lifts.push({ ...computeLift(red, green, { modeInsensitive }), tag });
160
199
  }
161
200
  return lifts;
162
201
  }
package/dist/lint.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "consistency" | "stale" | "lint-error";
1
+ export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "lint-error";
2
2
  export interface LintFinding {
3
3
  readonly skill: string;
4
4
  readonly scenario?: string;
package/dist/lint.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
- import { createHash } from "node:crypto";
4
3
  import yaml from "js-yaml";
5
4
  import { loadSpec, SpecError } from "./spec.js";
6
5
  import { readResults, finalizeResults, findTranscriptFiles, resultsPath } from "./results.js";
6
+ import { currentHashFor, describeSourceKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, UNREADABLE } from "./sources.js";
7
+ import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
7
8
  /** True if `p` exists and is a directory. Never throws (TOCTOU-safe: a race or dangling
8
9
  * symlink between the check and the stat is treated as "not a directory", not an error). */
9
10
  function isDir(p) {
@@ -65,12 +66,58 @@ export function lintSkill(skillDir) {
65
66
  // relative to the spec's dir, matching workspace.ts resolve(specDir, fixture) where specDir = <skillDir>/tests.
66
67
  const specDir = dirname(specPath);
67
68
  for (const s of spec.scenarios) {
68
- const fx = 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
69
70
  if (fx) {
70
71
  const abs = isAbsolute(fx) ? fx : resolve(specDir, fx);
71
72
  if (!isDir(abs)) {
72
73
  findings.push({ skill, scenario: s.id, code: "fixture", message: `fixture not found: ${fx}` });
73
74
  }
75
+ else {
76
+ // A mistyped fixture marker (`_uncommited/`) is rejected at run time by
77
+ // createWorkspace — but only once a run has been started, where it surfaces as
78
+ // a scenario FAIL among real results. lint is free, offline and runs in CI, so
79
+ // it is where an author should meet this. The set flagged here is exactly the
80
+ // set workspace.ts refuses, so lint can never bless a fixture the runtime then
81
+ // rejects.
82
+ // try/catch because lintSkill's contract is "never throws": isDir() proves the
83
+ // path stats, not that it can be read, and an EACCES on readdir would escape
84
+ // to cli.ts, which replaces this skill's ENTIRE finding list with one
85
+ // lint-error — silently discarding the staleness and consistency checks below.
86
+ let markers = [];
87
+ try {
88
+ markers = unknownMarkerDirs(abs);
89
+ }
90
+ catch (e) {
91
+ findings.push({
92
+ skill, scenario: s.id, code: "fixture",
93
+ message: `fixture ${fx} could not be read: ${e instanceof Error ? e.message : String(e)}`,
94
+ });
95
+ }
96
+ for (const dir of markers) {
97
+ const guess = suggestMarker(dir);
98
+ findings.push({
99
+ skill,
100
+ scenario: s.id,
101
+ code: "fixture-marker",
102
+ message: `fixture ${fx} has unknown top-level marker directory \`${dir}/\`` +
103
+ (guess ? ` — did you mean \`${guess}/\`?` : "") +
104
+ ` Known markers are ${MARKERS.map((m) => `\`${m}/\``).join(" and ")};` +
105
+ ` rename it, or move it deeper if it is ordinary content.`,
106
+ });
107
+ }
108
+ }
109
+ }
110
+ }
111
+ // assert.post_test must exist. A post-test that isn't there fails its scenario at
112
+ // run time with a "spec error" message, but that costs a full model run to discover
113
+ // — and lint is the free, offline gate that exists to catch it first.
114
+ for (const s of spec.scenarios) {
115
+ const pt = s.assert?.post_test;
116
+ if (!pt)
117
+ continue;
118
+ const abs = isAbsolute(pt) ? pt : resolve(specDir, pt);
119
+ if (!isFile(abs)) {
120
+ findings.push({ skill, scenario: s.id, code: "fixture", message: `assert.post_test not found: ${pt}` });
74
121
  }
75
122
  }
76
123
  // system_prompt_file must exist — an agent-file scenario silently falling back to
@@ -124,10 +171,12 @@ export function lintSkill(skillDir) {
124
171
  }
125
172
  }
126
173
  // staleness — the newest FULL (non-partial) run per model tag recorded sha256 hashes of
127
- // every source 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.
174
+ // every source it measured: SKILL.md, agent files, each scenario's definition and each
175
+ // fixture tree. If any has changed since, the committed result describes inputs that no
176
+ // longer exist — exactly how three regressions hid behind a 100%-SHIP table for four
177
+ // weeks, and how a swapped fixture went unreported by a "7 skills, 0 findings" lint.
178
+ // Runs predating source_hashes (or predating a given key kind) are skipped silently — no
179
+ // retroactive noise; partial runs never count as coverage.
131
180
  for (const tagDir of enumerateTagDirs(resultsRoot)) {
132
181
  // Newest FULL run: partial (--only) runs are iteration artifacts and never count as
133
182
  // coverage — a fresh partial must not silence a stale full run underneath it.
@@ -149,28 +198,46 @@ export function lintSkill(skillDir) {
149
198
  continue; // predates source_hashes → silent
150
199
  {
151
200
  const newest = full.runDir;
201
+ const ctx = { skillDir, specDir, scenarios: spec.scenarios };
152
202
  for (const [key, recorded] of Object.entries(hashes)) {
153
- const abs = key === "SKILL.md" ? join(skillDir, "SKILL.md") : resolve(specDir, key);
154
- const current = fileSha256(abs);
203
+ const what = describeSourceKey(key);
204
+ const scenario = scenarioIdForKey(key, spec.scenarios);
205
+ // The run itself failed to hash this source, so it was never verified and
206
+ // no comparison here can establish anything about it.
207
+ if (recorded === UNREADABLE) {
208
+ findings.push({ skill, scenario, code: "stale", message: `${what} could not be read when the newest ${basename(tagDir)} run was recorded (${newest}) — that source was never verified; re-run` });
209
+ continue;
210
+ }
211
+ const current = currentHashFor(key, ctx);
212
+ // undefined = not comparable: a scenario the spec no longer has (a reshape,
213
+ // per the scenario-set check above), or a key kind written by a newer
214
+ // skill-harness than this one.
215
+ if (current === undefined)
216
+ continue;
155
217
  if (current === null) {
156
- findings.push({ skill, code: "stale", message: `${key} no longer exists but the newest ${basename(tagDir)} run measured it (${newest})` });
218
+ findings.push({ skill, scenario, code: "stale", message: `${what} no longer exists but the newest ${basename(tagDir)} run measured it (${newest})` });
157
219
  }
158
220
  else if (current !== recorded) {
159
- findings.push({ skill, code: "stale", message: `${key} changed since the newest ${basename(tagDir)} run (${newest}) — results are stale; re-run before publishing` });
221
+ findings.push({ skill, scenario, code: "stale", message: `${what} changed since the newest ${basename(tagDir)} run (${newest}) — results are stale; re-run before publishing` });
222
+ }
223
+ }
224
+ // Coverage: a scenario the spec defines that the newest full run never
225
+ // measured. Without this, RENAMING a scenario is invisible — the old key
226
+ // resolves to "not comparable" and the new one was never recorded — so a
227
+ // 100%/SHIP scorecard survives an arbitrary spec rewrite reporting zero
228
+ // findings. Gated on the run having recorded scenario keys at all, so runs
229
+ // predating the key kind stay silent like every other pre-existing run.
230
+ if (Object.keys(hashes).some((k) => k.startsWith(SCENARIO_PREFIX))) {
231
+ for (const s of spec.scenarios) {
232
+ if (!(SCENARIO_PREFIX + s.id in hashes)) {
233
+ findings.push({ skill, scenario: s.id, code: "stale", message: `the newest ${basename(tagDir)} run (${newest}) did not measure scenario \`${s.id}\` — the published result covers a different scenario set than the spec; re-run before publishing` });
234
+ }
160
235
  }
161
236
  }
162
237
  }
163
238
  }
164
239
  return findings;
165
240
  }
166
- function fileSha256(p) {
167
- try {
168
- return createHash("sha256").update(readFileSync(p)).digest("hex");
169
- }
170
- catch {
171
- return null;
172
- }
173
- }
174
241
  /** Model-tag dirs under tests/results (each holds timestamped run dirs). */
175
242
  function enumerateTagDirs(resultsRoot) {
176
243
  if (!existsSync(resultsRoot))
package/dist/results.d.ts CHANGED
@@ -38,10 +38,13 @@ export interface ResultsFile {
38
38
  /** True for an `--only`-filtered run: a scenario subset, never ship-graded, never a release run. */
39
39
  partial?: boolean;
40
40
  /**
41
- * sha256 of every source file this run measured: SKILL.md plus each distinct
42
- * system_prompt_file. Lint compares the newest run's hashes against the current
43
- * files a mismatch means the published result describes text that no longer
44
- * exists (the stale-scorecard class this field exists to kill).
41
+ * sha256 of every source this run measured: SKILL.md, each distinct
42
+ * system_prompt_file, each scenario's definition (`scenario:<id>`) and each
43
+ * fixture tree (`fixture:<path>`). Lint compares the newest run's hashes against
44
+ * the current sources a mismatch means the published result describes inputs
45
+ * that no longer exist (the stale-scorecard class this field exists to kill).
46
+ * See sources.ts for the key scheme; runs recorded before a key kind existed
47
+ * simply don't carry it, and are never retroactively flagged.
45
48
  */
46
49
  source_hashes?: Record<string, string>;
47
50
  effective_grade: GradeSummary;
@@ -83,7 +86,7 @@ export declare function applyOverride(results: ResultsFile, scenarioId: string,
83
86
  * `!…` preservation lines added by preserveTranscript.
84
87
  */
85
88
  export declare function ensureResultsGitignore(resultsRoot: string): void;
86
- /** The rep index embedded in a transcript/judge-raw filename (`.rep<k>.`), or null for a plain (non-rep) file. */
89
+ /** The rep index embedded in a transcript / judge-raw / staged-diff filename (`.rep<k>.`), or null for a plain (non-rep) file. */
87
90
  export declare function repIndexOf(filename: string): number | null;
88
91
  /**
89
92
  * ALL transcript files for a scenario in a run dir, sorted deterministically:
@@ -95,23 +98,43 @@ export declare function repIndexOf(filename: string): number | null;
95
98
  * `<id>.<mode>.rep<k>.txt`) — e.g. to detect a green-only condition without
96
99
  * false positives from a red/force transcript of the same scenario. Omitted,
97
100
  * behavior is unchanged: any `<id>.*.txt` regardless of mode, excluding this
98
- * scenario's 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).
99
105
  */
100
106
  export declare function findTranscriptFiles(runDir: string, scenarioId: string, mode?: string): string[];
101
107
  /** Path of a scenario's raw judge-output artifact within a run dir (rep-suffixed for reps). */
102
108
  export declare function judgeRawPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
103
109
  /** A scenario's raw judge-output files, sorted (plain first, then numeric rep). Mode-scoped when given. */
104
110
  export declare function findJudgeRawFiles(runDir: string, scenarioId: string, mode?: string): string[];
111
+ /**
112
+ * Path of a seeded scenario's staged-diff artifact within a run dir (rep-suffixed
113
+ * for reps).
114
+ *
115
+ * The diff is the only record of what the model actually *did* — the workspace is
116
+ * torn down after every rep, so without this a seeded verdict cannot be audited
117
+ * after the fact. Named `<id>.<mode>[.rep<k>].diff.txt` so it sorts beside its
118
+ * transcript and is covered by the `*.txt` rule in results/.gitignore: diffs are
119
+ * generated evidence, ignored like transcripts, not committed like results.yaml.
120
+ */
121
+ export declare function diffPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
122
+ /** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
123
+ export declare function findDiffFiles(runDir: string, scenarioId: string, mode?: string): string[];
105
124
  /** A single representative transcript file for a scenario in a run dir. Null if none. */
106
125
  export declare function findTranscriptFile(runDir: string, scenarioId: string): string | null;
107
126
  /**
108
- * Un-gitignore ALL of a scenario's transcript AND judge-raw artifact files
109
- * (audit trail for an override — a --reps run has one transcript (and one
110
- * judge-raw file) per rep, and every rep that drove the verdict must survive
111
- * 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).
112
131
  * Appends `!<tag>/<ts>/<id>.<mode>[.rep<k>].txt` (and the matching
113
- * `.judge.txt`) to results/.gitignore for each, once. The path uses POSIX
114
- * separators so the negation matches on Windows too (git ignore patterns are
115
- * always forward-slashed).
132
+ * `.judge.txt` / `.diff.txt`) to results/.gitignore for each, once. The path
133
+ * uses POSIX separators so the negation matches on Windows too (git ignore
134
+ * patterns are always forward-slashed).
135
+ *
136
+ * The diff belongs here for the same reason the judge-raw output does: an
137
+ * override says the judge got it wrong, and on a seeded scenario the evidence
138
+ * for that claim is the code the model wrote.
116
139
  */
117
140
  export declare function preserveTranscript(resultsRoot: string, runDir: string, scenarioId: string): void;
package/dist/results.js CHANGED
@@ -150,9 +150,10 @@ export function ensureResultsGitignore(resultsRoot) {
150
150
  .filter((l) => l.startsWith("!") && l.trim() !== "!results.yaml");
151
151
  writeFileSync(giPath, GITIGNORE_BODY + preserved.map((l) => l + "\n").join(""), "utf8");
152
152
  }
153
- // Matches both transcript (`.rep<k>.txt`) and judge-raw (`.rep<k>.judge.txt`) rep suffixes.
154
- const REP_SUFFIX_RE = /\.rep(\d+)\.(?:judge\.)?txt$/;
155
- /** The rep index embedded in a transcript/judge-raw filename (`.rep<k>.`), or null for a plain (non-rep) file. */
153
+ // Matches transcript (`.rep<k>.txt`), judge-raw (`.rep<k>.judge.txt`) and
154
+ // staged-diff (`.rep<k>.diff.txt`) rep suffixes.
155
+ const REP_SUFFIX_RE = /\.rep(\d+)\.(?:judge\.|diff\.)?txt$/;
156
+ /** The rep index embedded in a transcript / judge-raw / staged-diff filename (`.rep<k>.`), or null for a plain (non-rep) file. */
156
157
  export function repIndexOf(filename) {
157
158
  const m = REP_SUFFIX_RE.exec(filename);
158
159
  return m ? Number(m[1]) : null;
@@ -181,7 +182,10 @@ function sortByRep(files) {
181
182
  * `<id>.<mode>.rep<k>.txt`) — e.g. to detect a green-only condition without
182
183
  * false positives from a red/force transcript of the same scenario. Omitted,
183
184
  * behavior is unchanged: any `<id>.*.txt` regardless of mode, excluding this
184
- * scenario's judge-raw artifacts (`<id>.*.judge.txt` see judgeRawPath).
185
+ * scenario's sibling artifacts, which share the `.txt` extension deliberately
186
+ * (so `results/.gitignore`'s `*.txt` covers them all): judge-raw output
187
+ * (`<id>.*.judge.txt` — see judgeRawPath) and the staged diff
188
+ * (`<id>.*.diff.txt` — see diffPath).
185
189
  */
186
190
  export function findTranscriptFiles(runDir, scenarioId, mode) {
187
191
  if (!existsSync(runDir))
@@ -190,7 +194,12 @@ export function findTranscriptFiles(runDir, scenarioId, mode) {
190
194
  const matcher = mode !== undefined
191
195
  ? new RegExp(`^${escapedId}\\.${mode}(\\.rep\\d+)?\\.txt$`)
192
196
  : null;
193
- const files = readdirSync(runDir).filter((f) => matcher ? matcher.test(f) : f.startsWith(`${scenarioId}.`) && f.endsWith(".txt") && !f.endsWith(".judge.txt"));
197
+ const files = readdirSync(runDir).filter((f) => matcher
198
+ ? matcher.test(f)
199
+ : f.startsWith(`${scenarioId}.`) &&
200
+ f.endsWith(".txt") &&
201
+ !f.endsWith(".judge.txt") &&
202
+ !f.endsWith(".diff.txt"));
194
203
  return sortByRep(files);
195
204
  }
196
205
  /** Path of a scenario's raw judge-output artifact within a run dir (rep-suffixed for reps). */
@@ -208,22 +217,54 @@ export function findJudgeRawFiles(runDir, scenarioId, mode) {
208
217
  : new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.judge\\.txt$`);
209
218
  return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
210
219
  }
220
+ /**
221
+ * Path of a seeded scenario's staged-diff artifact within a run dir (rep-suffixed
222
+ * for reps).
223
+ *
224
+ * The diff is the only record of what the model actually *did* — the workspace is
225
+ * torn down after every rep, so without this a seeded verdict cannot be audited
226
+ * after the fact. Named `<id>.<mode>[.rep<k>].diff.txt` so it sorts beside its
227
+ * transcript and is covered by the `*.txt` rule in results/.gitignore: diffs are
228
+ * generated evidence, ignored like transcripts, not committed like results.yaml.
229
+ */
230
+ export function diffPath(runDir, scenarioId, mode, rep) {
231
+ const base = rep === undefined ? `${scenarioId}.${mode}` : `${scenarioId}.${mode}.rep${rep}`;
232
+ return join(runDir, `${base}.diff.txt`);
233
+ }
234
+ /** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
235
+ export function findDiffFiles(runDir, scenarioId, mode) {
236
+ if (!existsSync(runDir))
237
+ return [];
238
+ const esc = scenarioId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
239
+ const re = mode === undefined
240
+ ? new RegExp(`^${esc}\\..*\\.diff\\.txt$`)
241
+ : new RegExp(`^${esc}\\.${mode}(\\.rep\\d+)?\\.diff\\.txt$`);
242
+ return sortByRep(readdirSync(runDir).filter((f) => re.test(f)));
243
+ }
211
244
  /** A single representative transcript file for a scenario in a run dir. Null if none. */
212
245
  export function findTranscriptFile(runDir, scenarioId) {
213
246
  return findTranscriptFiles(runDir, scenarioId)[0] ?? null;
214
247
  }
215
248
  /**
216
- * Un-gitignore ALL of a scenario's transcript AND judge-raw artifact files
217
- * (audit trail for an override — a --reps run has one transcript (and one
218
- * judge-raw file) per rep, and every rep that drove the verdict must survive
219
- * a commit, not just an arbitrary one).
249
+ * Un-gitignore ALL of a scenario's transcript, judge-raw AND staged-diff
250
+ * artifact files (audit trail for an override — a --reps run has one of each
251
+ * per rep, and every rep that drove the verdict must survive a commit, not just
252
+ * an arbitrary one).
220
253
  * Appends `!<tag>/<ts>/<id>.<mode>[.rep<k>].txt` (and the matching
221
- * `.judge.txt`) to results/.gitignore for each, once. The path uses POSIX
222
- * separators so the negation matches on Windows too (git ignore patterns are
223
- * always forward-slashed).
254
+ * `.judge.txt` / `.diff.txt`) to results/.gitignore for each, once. The path
255
+ * uses POSIX separators so the negation matches on Windows too (git ignore
256
+ * patterns are always forward-slashed).
257
+ *
258
+ * The diff belongs here for the same reason the judge-raw output does: an
259
+ * override says the judge got it wrong, and on a seeded scenario the evidence
260
+ * for that claim is the code the model wrote.
224
261
  */
225
262
  export function preserveTranscript(resultsRoot, runDir, scenarioId) {
226
- const files = [...findTranscriptFiles(runDir, scenarioId), ...findJudgeRawFiles(runDir, scenarioId)];
263
+ const files = [
264
+ ...findTranscriptFiles(runDir, scenarioId),
265
+ ...findJudgeRawFiles(runDir, scenarioId),
266
+ ...findDiffFiles(runDir, scenarioId),
267
+ ];
227
268
  if (files.length === 0)
228
269
  return;
229
270
  ensureResultsGitignore(resultsRoot);