@skill-harness/core 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/types.d.ts +6 -0
- package/dist/discover.d.ts +5 -1
- package/dist/discover.js +9 -1
- package/dist/grade.d.ts +14 -1
- package/dist/grade.js +74 -11
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/journal.d.ts +14 -0
- package/dist/lift.d.ts +69 -0
- package/dist/lift.js +163 -0
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +168 -4
- package/dist/regrade.d.ts +6 -0
- package/dist/regrade.js +25 -3
- package/dist/report.d.ts +14 -0
- package/dist/report.js +16 -1
- package/dist/rescore.d.ts +34 -0
- package/dist/rescore.js +66 -0
- package/dist/results.d.ts +41 -9
- package/dist/results.js +58 -14
- package/dist/run.d.ts +25 -1
- package/dist/run.js +115 -22
- package/dist/scaffold.d.ts +28 -0
- package/dist/scaffold.js +188 -0
- package/dist/score.d.ts +5 -1
- package/dist/seeded.d.ts +83 -3
- package/dist/seeded.js +315 -15
- package/dist/sources.d.ts +115 -0
- package/dist/sources.js +253 -0
- package/dist/spec.d.ts +17 -0
- package/dist/spec.js +63 -0
- package/dist/util/env.d.ts +32 -0
- package/dist/util/env.js +67 -0
- package/dist/workspace.d.ts +33 -1
- package/dist/workspace.js +125 -6
- package/package.json +1 -1
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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,10 +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";
|
|
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
|
+
}
|
|
3
112
|
/**
|
|
4
113
|
* Run a seeded scenario inside a caller-prepared workspace: let the harness edit
|
|
5
|
-
* the repo, then evaluate objective gates
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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).
|
|
8
124
|
*/
|
|
9
125
|
export async function runSeeded(scenario, opts) {
|
|
10
126
|
const repo = opts.cwd;
|
|
@@ -15,26 +131,157 @@ export async function runSeeded(scenario, opts) {
|
|
|
15
131
|
turns: scenario.turns,
|
|
16
132
|
cwd: repo,
|
|
17
133
|
});
|
|
18
|
-
await git(repo, ["add", "-A"]);
|
|
19
|
-
const diff = (await git(repo, ["diff", "--cached"])).stdout;
|
|
20
134
|
const parts = [harnessOut, "", "=== SEEDED GATES ==="];
|
|
21
135
|
let gateFailure = null;
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
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);
|
|
25
173
|
parts.push(` diff_contains ${JSON.stringify(needle)}: ${ok ? "OK" : "MISSING"}`);
|
|
26
174
|
if (!ok && !gateFailure)
|
|
27
175
|
gateFailure = `staged diff missing ${JSON.stringify(needle)}`;
|
|
28
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
|
+
}
|
|
29
185
|
if (scenario.assert?.vitest) {
|
|
30
|
-
const v = await
|
|
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;
|
|
31
190
|
const passed = v.code === 0;
|
|
32
|
-
parts.push(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
}
|
|
36
200
|
}
|
|
37
|
-
|
|
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);
|
|
38
285
|
}
|
|
39
286
|
function git(cwd, args) {
|
|
40
287
|
return exec("git", args, { cwd, timeoutMs: 30_000 });
|
|
@@ -42,4 +289,57 @@ function git(cwd, args) {
|
|
|
42
289
|
function indent(s) {
|
|
43
290
|
return s.split("\n").map((l) => ` ${l}`).join("\n");
|
|
44
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
|
+
}
|
|
45
345
|
//# sourceMappingURL=seeded.js.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Scenario } from "./spec.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a run measured, as a map of `key -> sha256`, recorded in results.yaml so
|
|
4
|
+
* lint can prove a published result still describes the current inputs.
|
|
5
|
+
*
|
|
6
|
+
* A run measures more than the skill text. It measures the SKILL.md, any agent
|
|
7
|
+
* file, **the scenario definition itself** (turns, checklist, gates) and **the
|
|
8
|
+
* fixture** the scenario starts from. Hashing only the first two — which is what
|
|
9
|
+
* shipped through 0.2.1 — meant editing a checklist or swapping a fixture left
|
|
10
|
+
* every published result looking current.
|
|
11
|
+
*
|
|
12
|
+
* ## Key scheme
|
|
13
|
+
*
|
|
14
|
+
* | key | means |
|
|
15
|
+
* |---|---|
|
|
16
|
+
* | `SKILL.md` | the skill text, resolved against the skill dir |
|
|
17
|
+
* | `scenario:<id>` | the semantic content of one scenario in the spec |
|
|
18
|
+
* | `fixture:<path>` | every file under one fixture dir |
|
|
19
|
+
* | anything else | a file path resolved against the spec's dir (`system_prompt_file`) |
|
|
20
|
+
*
|
|
21
|
+
* Separation from bare-path keys is conventional, not guaranteed: `scenario:A1`
|
|
22
|
+
* is a legal POSIX filename, so nothing stops someone naming an agent file that.
|
|
23
|
+
* In practice `system_prompt_file` and `post_test` values are ordinary relative
|
|
24
|
+
* paths, and a `<name>:` prefix is reserved for this scheme. Old results carrying
|
|
25
|
+
* only bare-path keys keep resolving exactly as before.
|
|
26
|
+
*
|
|
27
|
+
* ## Why per-scenario, not one hash of specification.yaml
|
|
28
|
+
*
|
|
29
|
+
* Hashing the whole spec file would mark **every** historical run stale the
|
|
30
|
+
* moment a spec grows by one scenario — the precise noise `lint`'s scenario-set
|
|
31
|
+
* check already exists to prevent ("a spec reshape must not consistency-flag
|
|
32
|
+
* every historical run"). A per-scenario digest says what actually changed:
|
|
33
|
+
* editing A1's checklist marks A1 stale and leaves A2 alone; appending a new
|
|
34
|
+
* scenario marks nothing stale, because nothing already measured changed.
|
|
35
|
+
*
|
|
36
|
+
* It also ignores formatting: the digest is built from the *parsed* scenario, so
|
|
37
|
+
* reindenting the YAML or reordering scenarios is correctly a no-op, while
|
|
38
|
+
* changing a single checklist word is correctly a change.
|
|
39
|
+
*/
|
|
40
|
+
export declare const SCENARIO_PREFIX = "scenario:";
|
|
41
|
+
export declare const FIXTURE_PREFIX = "fixture:";
|
|
42
|
+
/**
|
|
43
|
+
* Recorded in place of a hash when a source existed but could not be read.
|
|
44
|
+
*
|
|
45
|
+
* Omitting it instead — which is what an early version did — is the worst
|
|
46
|
+
* available option: `lint` only ever iterates the keys a run recorded, so a
|
|
47
|
+
* source dropped at record time is never compared again for the life of that
|
|
48
|
+
* result. A fixture briefly unreadable during a run could then be replaced
|
|
49
|
+
* wholesale and `lint` would still report 0 findings, which is verbatim the miss
|
|
50
|
+
* this module was written to close.
|
|
51
|
+
*
|
|
52
|
+
* Not valid sha256 hex, so it can never equal a real digest and always surfaces.
|
|
53
|
+
*/
|
|
54
|
+
export declare const UNREADABLE = "unreadable";
|
|
55
|
+
/** sha256 of a file, or null when it doesn't exist / isn't readable. */
|
|
56
|
+
export declare function fileSha256(path: string): string | null;
|
|
57
|
+
/**
|
|
58
|
+
* Stable sha256 over a directory tree: every file's relative path (POSIX-slashed,
|
|
59
|
+
* sorted) and contents. Null if the directory is missing or unreadable.
|
|
60
|
+
*
|
|
61
|
+
* Sorting is what makes it stable — readdir order is filesystem-dependent, so an
|
|
62
|
+
* unsorted walk would produce different digests for identical trees on different
|
|
63
|
+
* machines and turn CI into a staleness alarm. Paths are hashed alongside
|
|
64
|
+
* contents so that renaming a fixture file is a change, and separators are
|
|
65
|
+
* normalised so a Linux-recorded hash still matches on Windows.
|
|
66
|
+
*/
|
|
67
|
+
export declare function dirSha256(dir: string): string | null;
|
|
68
|
+
/**
|
|
69
|
+
* A scenario's semantic digest: everything that changes what the scenario
|
|
70
|
+
* measures, and nothing that doesn't.
|
|
71
|
+
*
|
|
72
|
+
* Built from the parsed scenario rather than its YAML text, so formatting is
|
|
73
|
+
* irrelevant. `critical` is included because it changes whether the scenario can
|
|
74
|
+
* block a ship; `title` is included because it is what a reader of the scorecard
|
|
75
|
+
* believes was tested.
|
|
76
|
+
*/
|
|
77
|
+
export declare function scenarioDigest(s: Scenario): string;
|
|
78
|
+
/**
|
|
79
|
+
* The fixture path a scenario actually runs in, or undefined.
|
|
80
|
+
*
|
|
81
|
+
* The EFFECTIVE workspace fixture, which is not always `scenario.fixture`: an
|
|
82
|
+
* inline scenario with `env.workspace: fixture:PATH` sets `workspace.fixture`
|
|
83
|
+
* and leaves `scenario.fixture` unset. Exported and shared with lint, which
|
|
84
|
+
* needs the identical rule — a second copy of this expression is how the hashed
|
|
85
|
+
* set and the checked set drift apart.
|
|
86
|
+
*/
|
|
87
|
+
export declare function effectiveFixture(s: Scenario): string | undefined;
|
|
88
|
+
export interface SourceContext {
|
|
89
|
+
skillDir: string;
|
|
90
|
+
specDir: string;
|
|
91
|
+
scenarios: Scenario[];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Hash every source this run measures: SKILL.md, each distinct
|
|
95
|
+
* `system_prompt_file`, each scenario's definition, and each distinct fixture
|
|
96
|
+
* tree. Entries that can't be read are omitted — a missing source is lint's
|
|
97
|
+
* problem to report, not run's to crash on.
|
|
98
|
+
*/
|
|
99
|
+
export declare function sourceHashes(ctx: SourceContext): Record<string, string>;
|
|
100
|
+
/**
|
|
101
|
+
* The current hash for a recorded key, or null when the source is gone.
|
|
102
|
+
*
|
|
103
|
+
* `undefined` is distinct from `null` and means "not comparable": the key names a
|
|
104
|
+
* scenario the spec no longer has. That is a spec *reshape*, not staleness — the
|
|
105
|
+
* same stance lint's scenario-set check already takes — so the caller stays quiet
|
|
106
|
+
* rather than reporting a removed scenario as a stale measurement.
|
|
107
|
+
*
|
|
108
|
+
* Sharing this resolver with `sourceHashes` is what keeps recording and checking
|
|
109
|
+
* from drifting: a new key kind is defined once, for both sides.
|
|
110
|
+
*/
|
|
111
|
+
export declare function currentHashFor(key: string, ctx: SourceContext): string | null | undefined;
|
|
112
|
+
/** Human label for a recorded key, used in lint messages. */
|
|
113
|
+
export declare function describeSourceKey(key: string): string;
|
|
114
|
+
/** The scenario id a key belongs to, for per-scenario lint findings. Undefined for skill-wide keys. */
|
|
115
|
+
export declare function scenarioIdForKey(key: string, scenarios: Scenario[]): string | undefined;
|