@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/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 {};
package/dist/seeded.js CHANGED
@@ -1,11 +1,126 @@
1
+ import { copyFileSync, statSync } from "node:fs";
2
+ import { extname, isAbsolute, join, resolve } from "node:path";
1
3
  import { exec } from "./util/exec.js";
2
4
  import { envNum } from "./util/env.js";
5
+ /**
6
+ * Filename STEM the post-test is copied to at the workspace root; the extension
7
+ * (`.test.ts`) is appended at the copy site, which is the part that decides
8
+ * whether vitest collects it at all.
9
+ *
10
+ * Harness-owned rather than the author's basename, for two reasons: it cannot
11
+ * collide with a fixture file by accident, and a model cannot shadow the check by
12
+ * creating a *file* at the path it guesses we will use — the copy happens after
13
+ * the model is done and overwrites unconditionally. (A *directory* there makes
14
+ * the copy fail; that is handled as an infrastructure error, not a model FAIL.)
15
+ */
16
+ const POST_TEST_BASE = "skill-harness.post";
3
17
  const VITEST_TIMEOUT_MS = envNum("VITEST_TIMEOUT_MS", 120_000);
18
+ /**
19
+ * The added/removed lines of a unified diff — what the model actually *changed*,
20
+ * with context lines and file headers dropped.
21
+ *
22
+ * This is the difference between "the diff mentions `lastIndex`" and "the model
23
+ * touched `lastIndex`". A unified diff carries three lines of context around every
24
+ * hunk, so an untouched function sitting near the edit site appears in the diff
25
+ * verbatim. `build` A2 is exactly that shape — its checklist notes that `lastIndex`
26
+ * "sits two lines from the edit site" — so a naive substring test against the whole
27
+ * diff would fail the scenario for every model that fixed the right thing, which is
28
+ * worse than the prose-dependent item it replaces.
29
+ *
30
+ * Classification is HUNK-AWARE rather than prefix-based, because `+++`/`---` are
31
+ * only headers *outside* a hunk. Filtering on those prefixes anywhere would eat a
32
+ * changed line whose own source text starts with `++` or `--` — `++counter;` at
33
+ * column zero, a removed SQL/Lua `-- comment`, a YAML `---` separator. Those
34
+ * became `+++counter;` and `--- comment` once the diff marker was prepended, were
35
+ * read as headers, and vanished: `diff_excludes` then reported OK for a diff that
36
+ * touched the forbidden symbol. A false PASS on an objective gate is worse than
37
+ * the subjective check it replaced, so the parse follows the format instead of
38
+ * guessing from prefixes.
39
+ */
40
+ export function changedLines(diff) {
41
+ const out = [];
42
+ let inHunk = false;
43
+ for (const line of diff.split("\n")) {
44
+ if (line.startsWith("@@")) {
45
+ inHunk = true; // a hunk header opens the region where +/- mean "changed"
46
+ continue;
47
+ }
48
+ if (line.startsWith("diff --git ")) {
49
+ inHunk = false; // a new file section closes it; its ---/+++ are headers again
50
+ continue;
51
+ }
52
+ if (!inHunk)
53
+ continue; // index/mode/---/+++ preamble lines
54
+ if (line.startsWith("+") || line.startsWith("-"))
55
+ out.push(line);
56
+ }
57
+ return out.join("\n");
58
+ }
59
+ /**
60
+ * Byte cap on the diff copy embedded in the judged transcript. The judge prompt
61
+ * is a single request, so an unbounded diff (a fixture-wide refactor, a
62
+ * regenerated lockfile) could overflow the context window and turn a gradeable
63
+ * run into a judge ERROR. The artifact on disk is never capped — only the copy
64
+ * the judge reads.
65
+ */
66
+ const DIFF_MAX_BYTES = envNum("DIFF_MAX_BYTES", 64_000);
67
+ /**
68
+ * Cut a diff to a byte budget on a line boundary, appending an explicit marker
69
+ * naming how much was dropped.
70
+ *
71
+ * The marker is not decoration: a silently truncated diff would let the judge
72
+ * grade "the function is missing" when it was merely cut off, which is the exact
73
+ * class of false-FAIL this whole change exists to remove. Truncation is reported
74
+ * as a fact about the transcript, and the untruncated diff is always on disk.
75
+ */
76
+ export function capDiff(diff, maxBytes = DIFF_MAX_BYTES) {
77
+ const total = Buffer.byteLength(diff, "utf8");
78
+ if (total <= maxBytes)
79
+ return diff;
80
+ // Accumulate whole lines until the next one would exceed the budget. `used`
81
+ // counts the separators actually emitted by join() — n lines carry n-1 of
82
+ // them — so the omitted figure in the marker is exact rather than one byte
83
+ // short. This file's whole thesis is telling the judge accurately what it was
84
+ // not shown, so an off-by-one here is a small lie in the wrong place.
85
+ const kept = [];
86
+ let used = 0;
87
+ for (const line of diff.split("\n")) {
88
+ const cost = Buffer.byteLength(line, "utf8") + (kept.length > 0 ? 1 : 0);
89
+ if (used + cost > maxBytes)
90
+ break;
91
+ kept.push(line);
92
+ used += cost;
93
+ }
94
+ // A single line longer than the whole budget (a minified bundle, a lockfile,
95
+ // a generated blob) would otherwise keep nothing at all and hand the judge a
96
+ // marker with zero code under it. Show a byte-safe prefix instead: some
97
+ // evidence beats none, and the marker still says what was cut.
98
+ if (kept.length === 0) {
99
+ const head = Buffer.from(diff, "utf8").subarray(0, maxBytes).toString("utf8");
100
+ // toString() on a boundary-split multibyte sequence yields U+FFFD; drop a
101
+ // trailing one rather than show the judge a corrupted character.
102
+ const clean = head.endsWith("�") ? head.slice(0, -1) : head;
103
+ kept.push(clean);
104
+ used = Buffer.byteLength(clean, "utf8");
105
+ }
106
+ const omitted = total - used;
107
+ return (kept.join("\n") +
108
+ `\n[… diff truncated: ${omitted} of ${total} bytes omitted (cap ${maxBytes}). ` +
109
+ `The complete diff is saved beside this transcript as this scenario's .diff.txt artifact. ` +
110
+ `Do not treat anything below the cut as absent — it was not shown to you. …]`);
111
+ }
4
112
  /**
5
113
  * Run a seeded scenario inside a caller-prepared workspace: let the harness edit
6
- * the repo, then evaluate objective gates (staged-diff contains + optional vitest
7
- * pass). A failed gate short-circuits to an auto-FAIL. Workspace creation (fixture
8
- * copy + git baseline) and teardown are the caller's responsibility (run.ts).
114
+ * the repo, then evaluate the objective gates it declares `diff_contains`,
115
+ * `diff_excludes`, `vitest` and `post_test`. Every gate that is configured runs;
116
+ * the FIRST failure is what `gateFailure` reports, and a non-null `gateFailure`
117
+ * makes the scenario an auto-FAIL that never reaches the judge.
118
+ *
119
+ * Returns the full staged diff alongside the transcript: the caller persists it
120
+ * as a run artifact, and a size-capped copy is appended to the transcript under
121
+ * `=== STAGED DIFF ===` so the judge grades the code rather than the model's
122
+ * description of it. Workspace creation (fixture copy + git baseline) and
123
+ * teardown are the caller's responsibility (run.ts).
9
124
  */
10
125
  export async function runSeeded(scenario, opts) {
11
126
  const repo = opts.cwd;
@@ -16,26 +131,157 @@ export async function runSeeded(scenario, opts) {
16
131
  turns: scenario.turns,
17
132
  cwd: repo,
18
133
  });
19
- await git(repo, ["add", "-A"]);
20
- const diff = (await git(repo, ["diff", "--cached"])).stdout;
21
134
  const parts = [harnessOut, "", "=== SEEDED GATES ==="];
22
135
  let gateFailure = null;
23
- const wantDiff = scenario.assert?.diff_contains ?? [];
24
- for (const needle of wantDiff) {
25
- const ok = diff.includes(needle);
136
+ const runVitest = opts.runVitest ??
137
+ ((args, cwd) => exec("npx", ["vitest", "run", ...args], { cwd, timeoutMs: VITEST_TIMEOUT_MS }));
138
+ // `exec` never throws on a non-zero exit and, on timeout, SIGKILLs the child and
139
+ // resolves with whatever stdout accumulated (code === null). Ignoring that here
140
+ // let a truncated or failed capture flow into every downstream consumer as
141
+ // evidence: diff_contains FAILs blamed on the model, diff_excludes silently
142
+ // passing, the transcript asserting "the model left no staged changes", and a
143
+ // partial diff persisted as the "complete" artifact — under the cap, so the
144
+ // truncation marker never fires. A capture we cannot trust is infrastructure,
145
+ // and must say so instead of being graded.
146
+ const add = await git(repo, ["add", "-A"]);
147
+ const show = await git(repo, ["diff", "--cached"]);
148
+ const diff = show.stdout;
149
+ const gitFailure = [add, show].find((r) => r.code !== 0);
150
+ if (gitFailure) {
151
+ const why = gitFailure.code === null ? "timed out and was killed" : `exited ${gitFailure.code}`;
152
+ const msg = `staged diff could not be captured — git ${why} — infrastructure, not model behavior` +
153
+ (gitFailure.stderr.trim() ? `: ${gitFailure.stderr.trim().split("\n")[0]}` : "");
154
+ parts.push(` staged diff: ERROR (${msg})`);
155
+ gateFailure = msg;
156
+ return finish(parts, gateFailure, diff);
157
+ }
158
+ // BOTH needle gates read the changed lines only, never context. A unified diff
159
+ // carries three lines of context per hunk, so an untouched symbol near the edit
160
+ // site appears verbatim in the diff text — and matching that means neither gate
161
+ // is answering the question it was asked.
162
+ //
163
+ // The positive gate is the one this was measured on. `build` A4 asserts
164
+ // diff_contains ["divide", "ok"], and its fixture's baseline already contains
165
+ // both — `ok` in `{ ok: true; value: T }`, `divide` inside `divideByZero` — so
166
+ // any edit near those lines satisfied the gate whether or not the model wrote
167
+ // either token. Every published A4 result recorded that as an objective pass.
168
+ // Read against changed lines it means what the checklist means: the model
169
+ // returned a Result.
170
+ const changed = changedLines(diff);
171
+ for (const needle of scenario.assert?.diff_contains ?? []) {
172
+ const ok = changed.includes(needle);
26
173
  parts.push(` diff_contains ${JSON.stringify(needle)}: ${ok ? "OK" : "MISSING"}`);
27
174
  if (!ok && !gateFailure)
28
175
  gateFailure = `staged diff missing ${JSON.stringify(needle)}`;
29
176
  }
177
+ // Scope discipline, stated as a fact about the diff rather than inferred from
178
+ // whether the model remembered to say "I left lastIndex alone".
179
+ for (const needle of scenario.assert?.diff_excludes ?? []) {
180
+ const ok = !changed.includes(needle);
181
+ parts.push(` diff_excludes ${JSON.stringify(needle)}: ${ok ? "OK" : "PRESENT"}`);
182
+ if (!ok && !gateFailure)
183
+ gateFailure = `staged diff touches forbidden ${JSON.stringify(needle)}`;
184
+ }
30
185
  if (scenario.assert?.vitest) {
31
- const v = await exec("npx", ["vitest", "run"], { cwd: repo, timeoutMs: VITEST_TIMEOUT_MS });
186
+ const v = await runVitest([], repo);
187
+ // code === null means exec SIGKILLed it at the timeout. That is infrastructure,
188
+ // not a failing test, and must not be reported as the model's fault.
189
+ const killed = v.code === null;
32
190
  const passed = v.code === 0;
33
- parts.push(` vitest run: ${passed ? "PASS" : `FAIL (exit ${v.code})`}`);
34
- parts.push(indent(v.stdout.trim() || v.stderr.trim()));
35
- if (!passed && !gateFailure)
36
- gateFailure = `vitest failed (exit ${v.code})`;
191
+ parts.push(killed
192
+ ? ` vitest run: ERROR (timed out after ${VITEST_TIMEOUT_MS}ms — infrastructure, not model behavior)`
193
+ : ` vitest run: ${passed ? "PASS" : `FAIL (exit ${v.code})`}`);
194
+ parts.push(indent(bothStreams(v)));
195
+ if (!passed && !gateFailure) {
196
+ gateFailure = killed
197
+ ? `vitest timed out after ${VITEST_TIMEOUT_MS}ms — infrastructure, not model behavior`
198
+ : `vitest failed (exit ${v.code})`;
199
+ }
37
200
  }
38
- return { transcript: parts.join("\n"), gateFailure };
201
+ const postTest = scenario.assert?.post_test;
202
+ if (postTest) {
203
+ const src = isAbsolute(postTest) ? postTest : resolve(opts.specDir, postTest);
204
+ // statSync().isFile(), not existsSync: a DIRECTORY "exists", and copyFileSync
205
+ // then throws EISDIR. That rejection escapes runSeeded, and runRep guards only
206
+ // workspace setup — so it reaches runSkillModel, writeResults never runs, and a
207
+ // paid multi-scenario run loses every scenario already completed. One spec typo
208
+ // (`post_test: post` for `post/A1.test.ts`) must cost a scenario, not a run.
209
+ if (!isReadableFile(src)) {
210
+ // Not model behavior. It still fails the scenario — a silently skipped gate is
211
+ // worse than a loud one — but the message says whose fault it is so nobody
212
+ // reads it as "the model broke the test".
213
+ const msg = `post_test is not a readable file: ${postTest} — spec error, not model behavior`;
214
+ parts.push(` post_test: ERROR (${msg})`);
215
+ if (!gateFailure)
216
+ gateFailure = msg;
217
+ }
218
+ else {
219
+ // `.test` is part of the name we build, never taken from the source: extname()
220
+ // of "A2.test.ts" is ".ts", so appending only that produced
221
+ // "skill-harness.post.ts" — a file vitest does not collect as a test at all,
222
+ // which would have made the gate silently vacuous.
223
+ const dest = join(repo, `${POST_TEST_BASE}.test${extname(src) || ".ts"}`);
224
+ try {
225
+ copyFileSync(src, dest);
226
+ }
227
+ catch (e) {
228
+ // Permissions, a race, a full disk. Same reasoning as the branch above:
229
+ // degrade this scenario, never take the whole run down with it.
230
+ const msg = `post_test could not be copied into the workspace ` +
231
+ `(${e instanceof Error ? e.message : String(e)}) — infrastructure, not model behavior`;
232
+ parts.push(` post_test: ERROR (${msg})`);
233
+ if (!gateFailure)
234
+ gateFailure = msg;
235
+ return finish(parts, gateFailure, diff);
236
+ }
237
+ const v = await runVitest([POST_TEST_BASE], repo);
238
+ const out = `${v.stdout}\n${v.stderr}`;
239
+ // This gate must never pass without positive evidence that assertions ran.
240
+ // Absence-of-failure is not enough: a `.skip`/`.todo` left in the file exits
241
+ // ZERO and prints "Tests 1 skipped", so keying off the exit code reported
242
+ // PASS for a hidden gate that executed nothing — the exact vacuous-gate
243
+ // shape post_test exists to prevent, and one nobody would ever notice
244
+ // because a passing gate produces output no one reads.
245
+ const tally = vitestTally(out);
246
+ // Anchored to line start: the unanchored form also matched this string
247
+ // appearing anywhere in test output or a model-authored console.log, which
248
+ // would flip a genuine pass into a phantom "fixture is broken". The exact
249
+ // wording is vitest's ("No test files found, exiting with code 1", verified
250
+ // against 2.1.9) — recheck it when the vitest major changes.
251
+ const notCollected = /^\s*No test files found/im.test(out);
252
+ const killed = v.code === null;
253
+ let problem = null;
254
+ if (killed) {
255
+ problem = `post_test ${JSON.stringify(postTest)} timed out after ${VITEST_TIMEOUT_MS}ms — infrastructure, not model behavior`;
256
+ }
257
+ else if (notCollected) {
258
+ problem = `post_test ${JSON.stringify(postTest)} was never collected by vitest — spec/fixture error, not model behavior`;
259
+ }
260
+ else if (tally === null) {
261
+ problem = `post_test ${JSON.stringify(postTest)} produced no parseable vitest summary (exit ${v.code}) — cannot confirm it ran`;
262
+ }
263
+ else if (v.code !== 0 || tally.failed > 0) {
264
+ // A real failure is checked BEFORE the vacuity conditions below: a run
265
+ // where everything failed also has zero passes, and reporting that as
266
+ // "ran no assertions" would point the author at their spec instead of at
267
+ // the model's code, which is the actual news.
268
+ problem = `post_test ${JSON.stringify(postTest)} failed (exit ${v.code})`;
269
+ }
270
+ else if (tally.skipped > 0 || tally.todo > 0) {
271
+ problem = `post_test ${JSON.stringify(postTest)} has ${tally.skipped + tally.todo} skipped/todo test(s) — a hidden gate must actually run; spec error, not model behavior`;
272
+ }
273
+ else if (tally.passed === 0) {
274
+ problem = `post_test ${JSON.stringify(postTest)} ran no assertions — spec error, not model behavior`;
275
+ }
276
+ parts.push(problem === null
277
+ ? ` post_test ${JSON.stringify(postTest)}: PASS (${tally.passed} assertion-bearing test(s))`
278
+ : ` post_test ${JSON.stringify(postTest)}: ${v.code === 0 && !killed ? "ERROR" : "FAIL"} (${problem})`);
279
+ parts.push(indent(bothStreams(v)));
280
+ if (problem && !gateFailure)
281
+ gateFailure = problem;
282
+ }
283
+ }
284
+ return finish(parts, gateFailure, diff);
39
285
  }
40
286
  function git(cwd, args) {
41
287
  return exec("git", args, { cwd, timeoutMs: 30_000 });
@@ -43,4 +289,57 @@ function git(cwd, args) {
43
289
  function indent(s) {
44
290
  return s.split("\n").map((l) => ` ${l}`).join("\n");
45
291
  }
292
+ /** True only for a regular file we can stat. Never throws — a directory, a dangling symlink or EACCES all read as "not usable". */
293
+ function isReadableFile(p) {
294
+ try {
295
+ return statSync(p).isFile();
296
+ }
297
+ catch {
298
+ return false;
299
+ }
300
+ }
301
+ /**
302
+ * Both streams, labelled.
303
+ *
304
+ * The previous `stdout.trim() || stderr.trim()` dropped stderr entirely whenever
305
+ * stdout was non-empty — which it always is once vitest prints its banner. That
306
+ * discarded exactly the diagnostics worth keeping, including exec's own
307
+ * `[skill-harness] killed after …ms timeout` notice, leaving a transcript that
308
+ * showed partial test output and an unexplained failure.
309
+ */
310
+ function bothStreams(v) {
311
+ const o = v.stdout.trim();
312
+ const e = v.stderr.trim();
313
+ if (o && e)
314
+ return `${o}\n[stderr]\n${e}`;
315
+ return o || e;
316
+ }
317
+ /** Append the staged diff and return the outcome. Every exit path goes through here, so the judge always sees the same sections in the same order. */
318
+ function finish(parts, gateFailure, diff) {
319
+ // The code itself, last — the gates above only prove that keywords appeared.
320
+ // Without this section a seeded checklist item about what the code *does* is
321
+ // graded from the model's own description of its work.
322
+ parts.push("", "=== STAGED DIFF ===");
323
+ parts.push(diff.trim() === "" ? " (empty — the model left no staged changes)" : capDiff(diff));
324
+ return { transcript: parts.join("\n"), gateFailure, diff };
325
+ }
326
+ /**
327
+ * Parse vitest's `Tests N passed | M skipped (T)` summary line.
328
+ *
329
+ * Used to require positive evidence that a hidden `post_test` actually executed
330
+ * assertions, rather than trusting a zero exit code — which vitest also returns
331
+ * when every test in the file is skipped. Returns null when no summary line is
332
+ * present, which the caller treats as "cannot confirm it ran" rather than as a
333
+ * pass.
334
+ */
335
+ export function vitestTally(out) {
336
+ const line = /^\s*Tests\s+(.+)$/m.exec(out);
337
+ if (!line)
338
+ return null;
339
+ const read = (word) => {
340
+ const m = new RegExp(`(\\d+)\\s+${word}`).exec(line[1]);
341
+ return m ? Number(m[1]) : 0;
342
+ };
343
+ return { passed: read("passed"), failed: read("failed"), skipped: read("skipped"), todo: read("todo") };
344
+ }
46
345
  //# sourceMappingURL=seeded.js.map