@skill-harness/core 0.2.1 → 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/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/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);
package/dist/run.js CHANGED
@@ -1,8 +1,8 @@
1
- import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
2
- import { createHash } from "node:crypto";
1
+ import { mkdirSync, writeFileSync } from "node:fs";
3
2
  import { dirname, resolve } from "node:path";
3
+ import { sourceHashes } from "./sources.js";
4
4
  import { judgeResemblesSubject } from "./grade.js";
5
- import { runDirFor, transcriptPath, writeResults, ensureResultsGitignore, } from "./results.js";
5
+ import { runDirFor, transcriptPath, diffPath, writeResults, ensureResultsGitignore, } from "./results.js";
6
6
  import { appendJournal } from "./journal.js";
7
7
  import { liftHeadline } from "./lift.js";
8
8
  import { runSeeded } from "./seeded.js";
@@ -10,34 +10,6 @@ import { createWorkspace } from "./workspace.js";
10
10
  import { runPool } from "./scheduler.js";
11
11
  import { outcomesToResult } from "./reps.js";
12
12
  import { judgeOneRep } from "./regrade.js";
13
- /** sha256 of a file, or null when it doesn't exist — missing sources are lint's problem, not run's. */
14
- function sha256(path) {
15
- try {
16
- return createHash("sha256").update(readFileSync(path)).digest("hex");
17
- }
18
- catch {
19
- return null;
20
- }
21
- }
22
- /**
23
- * Hash every source file this run measures: SKILL.md + each distinct
24
- * system_prompt_file (agents/<name>.md). Recorded in results.yaml so lint can prove
25
- * a published result still describes the current text.
26
- */
27
- function sourceHashes(skillDir, specPath, scenarios) {
28
- const hashes = {};
29
- const skillMd = sha256(resolve(skillDir, "SKILL.md"));
30
- if (skillMd)
31
- hashes["SKILL.md"] = skillMd;
32
- for (const s of scenarios) {
33
- if (s.systemPromptFile && !(s.systemPromptFile in hashes)) {
34
- const h = sha256(resolve(dirname(specPath), s.systemPromptFile));
35
- if (h)
36
- hashes[s.systemPromptFile] = h;
37
- }
38
- }
39
- return hashes;
40
- }
41
13
  /** Run one skill against one model: run scenarios, grade, score, persist. */
42
14
  export async function runSkillModel(opts) {
43
15
  const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
@@ -99,7 +71,9 @@ export async function runSkillModel(opts) {
99
71
  label: opts.label ?? null,
100
72
  mode,
101
73
  ...(partial ? { partial: true } : {}),
102
- source_hashes: sourceHashes(skillDir, opts.specPath, scenarios),
74
+ // Only the scenarios this run actually measured: a --only run must not claim
75
+ // coverage of scenarios it skipped.
76
+ source_hashes: sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios }),
103
77
  scenarios: scenarioResults,
104
78
  }, ctx);
105
79
  if (ctx) {
@@ -136,6 +110,10 @@ async function runRep(scenario, rep, repCount, ctx) {
136
110
  let ws = null;
137
111
  let transcript = "";
138
112
  let gatePrefix = null;
113
+ // Null until a seeded rep actually reaches its gates: a workspace-setup failure
114
+ // produces no diff, and writing an empty artifact there would misreport "the
115
+ // model changed nothing" for a rep that never ran.
116
+ let stagedDiff = null;
139
117
  try {
140
118
  try {
141
119
  ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
@@ -160,9 +138,11 @@ async function runRep(scenario, rep, repCount, ctx) {
160
138
  if (scenario.mode === "seeded") {
161
139
  const r = await runSeeded(scenario, {
162
140
  skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
141
+ specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
163
142
  });
164
143
  transcript = r.transcript;
165
144
  gatePrefix = r.gateFailure;
145
+ stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
166
146
  }
167
147
  else {
168
148
  transcript = await ctx.adapter.run({
@@ -178,8 +158,16 @@ async function runRep(scenario, rep, repCount, ctx) {
178
158
  break;
179
159
  }
180
160
  }
181
- writeFileSync(transcriptPath(runDir, scenario.id, mode, repCount > 1 ? rep : undefined), transcript, "utf8");
161
+ const repSuffix = repCount > 1 ? rep : undefined;
162
+ writeFileSync(transcriptPath(runDir, scenario.id, mode, repSuffix), transcript, "utf8");
182
163
  if (scenario.mode === "seeded") {
164
+ // The workspace is torn down in the `finally` below, so this is the only
165
+ // chance to keep what the model actually wrote. Persisted uncapped (the
166
+ // transcript's copy is capped for the judge) and for every rep, pass or
167
+ // fail — a gate failure is exactly when you want to read the diff.
168
+ if (stagedDiff !== null) {
169
+ writeFileSync(diffPath(runDir, scenario.id, mode, repSuffix), stagedDiff, "utf8");
170
+ }
183
171
  appendJournal(runDir, { event: "gate-result", ts: now(), id: scenario.id, ok: !gatePrefix, detail: gatePrefix ?? "", ...repField });
184
172
  }
185
173
  let verdict;
package/dist/seeded.d.ts CHANGED
@@ -1,21 +1,101 @@
1
1
  import type { Scenario } from "./spec.js";
2
2
  import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
3
+ import { type ExecResult } from "./util/exec.js";
3
4
  interface SeededOpts {
4
5
  skillDir: string;
5
6
  adapter: HarnessAdapter;
6
7
  model: ModelRef;
7
8
  mode: RunMode;
8
9
  cwd: string;
10
+ specDir: string;
11
+ /**
12
+ * How the vitest gates shell out. Defaults to the real `npx vitest run`.
13
+ *
14
+ * A seam, not a mock: a workspace is a bare temp dir, so a test that exercised
15
+ * the real runner would resolve vitest off the network and be slow and flaky.
16
+ * Injecting it lets the gate LOGIC — pass, fail, nothing-collected — be tested
17
+ * deterministically.
18
+ */
19
+ runVitest?: (args: string[], cwd: string) => Promise<VitestRun>;
9
20
  }
21
+ /**
22
+ * Result of one vitest invocation — deliberately `ExecResult`, not a narrower
23
+ * shape of its own.
24
+ *
25
+ * An earlier version declared `code: number`. That narrowing was a lie the
26
+ * compiler happened not to catch (the default's inferred type silently widened
27
+ * it back), and it is unrepresentable in practice: `exec` SIGKILLs on timeout
28
+ * and a signal-killed child closes with `code === null`. Declaring non-null
29
+ * would have made the vitest gate's timeout path — the one an injected double
30
+ * most needs to reproduce — impossible to express in a test.
31
+ */
32
+ export type VitestRun = ExecResult;
10
33
  export interface SeededOutcome {
11
34
  transcript: string;
12
35
  gateFailure: string | null;
36
+ diff: string;
13
37
  }
38
+ /**
39
+ * The added/removed lines of a unified diff — what the model actually *changed*,
40
+ * with context lines and file headers dropped.
41
+ *
42
+ * This is the difference between "the diff mentions `lastIndex`" and "the model
43
+ * touched `lastIndex`". A unified diff carries three lines of context around every
44
+ * hunk, so an untouched function sitting near the edit site appears in the diff
45
+ * verbatim. `build` A2 is exactly that shape — its checklist notes that `lastIndex`
46
+ * "sits two lines from the edit site" — so a naive substring test against the whole
47
+ * diff would fail the scenario for every model that fixed the right thing, which is
48
+ * worse than the prose-dependent item it replaces.
49
+ *
50
+ * Classification is HUNK-AWARE rather than prefix-based, because `+++`/`---` are
51
+ * only headers *outside* a hunk. Filtering on those prefixes anywhere would eat a
52
+ * changed line whose own source text starts with `++` or `--` — `++counter;` at
53
+ * column zero, a removed SQL/Lua `-- comment`, a YAML `---` separator. Those
54
+ * became `+++counter;` and `--- comment` once the diff marker was prepended, were
55
+ * read as headers, and vanished: `diff_excludes` then reported OK for a diff that
56
+ * touched the forbidden symbol. A false PASS on an objective gate is worse than
57
+ * the subjective check it replaced, so the parse follows the format instead of
58
+ * guessing from prefixes.
59
+ */
60
+ export declare function changedLines(diff: string): string;
61
+ /**
62
+ * Cut a diff to a byte budget on a line boundary, appending an explicit marker
63
+ * naming how much was dropped.
64
+ *
65
+ * The marker is not decoration: a silently truncated diff would let the judge
66
+ * grade "the function is missing" when it was merely cut off, which is the exact
67
+ * class of false-FAIL this whole change exists to remove. Truncation is reported
68
+ * as a fact about the transcript, and the untruncated diff is always on disk.
69
+ */
70
+ export declare function capDiff(diff: string, maxBytes?: number): string;
14
71
  /**
15
72
  * Run a seeded scenario inside a caller-prepared workspace: let the harness edit
16
- * the repo, then evaluate objective gates (staged-diff contains + optional vitest
17
- * pass). A failed gate short-circuits to an auto-FAIL. Workspace creation (fixture
18
- * copy + git baseline) and teardown are the caller's responsibility (run.ts).
73
+ * the repo, then evaluate the objective gates it declares `diff_contains`,
74
+ * `diff_excludes`, `vitest` and `post_test`. Every gate that is configured runs;
75
+ * the FIRST failure is what `gateFailure` reports, and a non-null `gateFailure`
76
+ * makes the scenario an auto-FAIL that never reaches the judge.
77
+ *
78
+ * Returns the full staged diff alongside the transcript: the caller persists it
79
+ * as a run artifact, and a size-capped copy is appended to the transcript under
80
+ * `=== STAGED DIFF ===` so the judge grades the code rather than the model's
81
+ * description of it. Workspace creation (fixture copy + git baseline) and
82
+ * teardown are the caller's responsibility (run.ts).
19
83
  */
20
84
  export declare function runSeeded(scenario: Scenario, opts: SeededOpts): Promise<SeededOutcome>;
85
+ export interface VitestTally {
86
+ passed: number;
87
+ failed: number;
88
+ skipped: number;
89
+ todo: number;
90
+ }
91
+ /**
92
+ * Parse vitest's `Tests N passed | M skipped (T)` summary line.
93
+ *
94
+ * Used to require positive evidence that a hidden `post_test` actually executed
95
+ * assertions, rather than trusting a zero exit code — which vitest also returns
96
+ * when every test in the file is skipped. Returns null when no summary line is
97
+ * present, which the caller treats as "cannot confirm it ran" rather than as a
98
+ * pass.
99
+ */
100
+ export declare function vitestTally(out: string): VitestTally | null;
21
101
  export {};