@skill-harness/core 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/grade.d.ts +2 -0
- package/dist/grade.js +26 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lift.d.ts +23 -15
- package/dist/lift.js +43 -4
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +85 -18
- package/dist/results.d.ts +36 -13
- package/dist/results.js +54 -13
- package/dist/run.js +21 -33
- package/dist/seeded.d.ts +83 -3
- package/dist/seeded.js +313 -14
- package/dist/sources.d.ts +115 -0
- package/dist/sources.js +253 -0
- package/dist/spec.d.ts +15 -0
- package/dist/spec.js +31 -0
- package/dist/workspace.d.ts +28 -0
- package/dist/workspace.js +50 -7
- package/package.json +34 -8
|
@@ -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;
|
package/dist/sources.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* What a run measured, as a map of `key -> sha256`, recorded in results.yaml so
|
|
6
|
+
* lint can prove a published result still describes the current inputs.
|
|
7
|
+
*
|
|
8
|
+
* A run measures more than the skill text. It measures the SKILL.md, any agent
|
|
9
|
+
* file, **the scenario definition itself** (turns, checklist, gates) and **the
|
|
10
|
+
* fixture** the scenario starts from. Hashing only the first two — which is what
|
|
11
|
+
* shipped through 0.2.1 — meant editing a checklist or swapping a fixture left
|
|
12
|
+
* every published result looking current.
|
|
13
|
+
*
|
|
14
|
+
* ## Key scheme
|
|
15
|
+
*
|
|
16
|
+
* | key | means |
|
|
17
|
+
* |---|---|
|
|
18
|
+
* | `SKILL.md` | the skill text, resolved against the skill dir |
|
|
19
|
+
* | `scenario:<id>` | the semantic content of one scenario in the spec |
|
|
20
|
+
* | `fixture:<path>` | every file under one fixture dir |
|
|
21
|
+
* | anything else | a file path resolved against the spec's dir (`system_prompt_file`) |
|
|
22
|
+
*
|
|
23
|
+
* Separation from bare-path keys is conventional, not guaranteed: `scenario:A1`
|
|
24
|
+
* is a legal POSIX filename, so nothing stops someone naming an agent file that.
|
|
25
|
+
* In practice `system_prompt_file` and `post_test` values are ordinary relative
|
|
26
|
+
* paths, and a `<name>:` prefix is reserved for this scheme. Old results carrying
|
|
27
|
+
* only bare-path keys keep resolving exactly as before.
|
|
28
|
+
*
|
|
29
|
+
* ## Why per-scenario, not one hash of specification.yaml
|
|
30
|
+
*
|
|
31
|
+
* Hashing the whole spec file would mark **every** historical run stale the
|
|
32
|
+
* moment a spec grows by one scenario — the precise noise `lint`'s scenario-set
|
|
33
|
+
* check already exists to prevent ("a spec reshape must not consistency-flag
|
|
34
|
+
* every historical run"). A per-scenario digest says what actually changed:
|
|
35
|
+
* editing A1's checklist marks A1 stale and leaves A2 alone; appending a new
|
|
36
|
+
* scenario marks nothing stale, because nothing already measured changed.
|
|
37
|
+
*
|
|
38
|
+
* It also ignores formatting: the digest is built from the *parsed* scenario, so
|
|
39
|
+
* reindenting the YAML or reordering scenarios is correctly a no-op, while
|
|
40
|
+
* changing a single checklist word is correctly a change.
|
|
41
|
+
*/
|
|
42
|
+
export const SCENARIO_PREFIX = "scenario:";
|
|
43
|
+
export const FIXTURE_PREFIX = "fixture:";
|
|
44
|
+
/**
|
|
45
|
+
* Recorded in place of a hash when a source existed but could not be read.
|
|
46
|
+
*
|
|
47
|
+
* Omitting it instead — which is what an early version did — is the worst
|
|
48
|
+
* available option: `lint` only ever iterates the keys a run recorded, so a
|
|
49
|
+
* source dropped at record time is never compared again for the life of that
|
|
50
|
+
* result. A fixture briefly unreadable during a run could then be replaced
|
|
51
|
+
* wholesale and `lint` would still report 0 findings, which is verbatim the miss
|
|
52
|
+
* this module was written to close.
|
|
53
|
+
*
|
|
54
|
+
* Not valid sha256 hex, so it can never equal a real digest and always surfaces.
|
|
55
|
+
*/
|
|
56
|
+
export const UNREADABLE = "unreadable";
|
|
57
|
+
/** sha256 of a file, or null when it doesn't exist / isn't readable. */
|
|
58
|
+
export function fileSha256(path) {
|
|
59
|
+
try {
|
|
60
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Stable sha256 over a directory tree: every file's relative path (POSIX-slashed,
|
|
68
|
+
* sorted) and contents. Null if the directory is missing or unreadable.
|
|
69
|
+
*
|
|
70
|
+
* Sorting is what makes it stable — readdir order is filesystem-dependent, so an
|
|
71
|
+
* unsorted walk would produce different digests for identical trees on different
|
|
72
|
+
* machines and turn CI into a staleness alarm. Paths are hashed alongside
|
|
73
|
+
* contents so that renaming a fixture file is a change, and separators are
|
|
74
|
+
* normalised so a Linux-recorded hash still matches on Windows.
|
|
75
|
+
*/
|
|
76
|
+
export function dirSha256(dir) {
|
|
77
|
+
let files;
|
|
78
|
+
try {
|
|
79
|
+
files = walk(dir).sort();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const h = createHash("sha256");
|
|
85
|
+
for (const rel of files) {
|
|
86
|
+
h.update(rel);
|
|
87
|
+
h.update("\0");
|
|
88
|
+
try {
|
|
89
|
+
h.update(readFileSync(join(dir, rel)));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null; // a file that vanished mid-walk makes the whole digest untrustworthy
|
|
93
|
+
}
|
|
94
|
+
h.update("\0");
|
|
95
|
+
}
|
|
96
|
+
return h.digest("hex");
|
|
97
|
+
}
|
|
98
|
+
/** Relative POSIX paths of every file under `dir`, recursively. Throws if `dir` is unreadable. */
|
|
99
|
+
function walk(dir, prefix = "") {
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
102
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
103
|
+
if (e.isDirectory()) {
|
|
104
|
+
out.push(...walk(join(dir, e.name), rel));
|
|
105
|
+
}
|
|
106
|
+
else if (e.isFile()) {
|
|
107
|
+
out.push(rel);
|
|
108
|
+
}
|
|
109
|
+
// symlinks/sockets/etc. are deliberately skipped: a fixture is plain files,
|
|
110
|
+
// and following links could hash something outside the fixture entirely.
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A scenario's semantic digest: everything that changes what the scenario
|
|
116
|
+
* measures, and nothing that doesn't.
|
|
117
|
+
*
|
|
118
|
+
* Built from the parsed scenario rather than its YAML text, so formatting is
|
|
119
|
+
* irrelevant. `critical` is included because it changes whether the scenario can
|
|
120
|
+
* block a ship; `title` is included because it is what a reader of the scorecard
|
|
121
|
+
* believes was tested.
|
|
122
|
+
*/
|
|
123
|
+
export function scenarioDigest(s) {
|
|
124
|
+
// Destructured, with the remainder pinned to `Record<string, never>`, so that
|
|
125
|
+
// adding a field to `Scenario` or `SeededAssert` FAILS THE BUILD here instead of
|
|
126
|
+
// silently escaping the digest. A field nobody remembered to add is a permanent
|
|
127
|
+
// staleness blind spot — edit it and every published result still looks current,
|
|
128
|
+
// which is precisely the bug this module exists to kill. This PR added two
|
|
129
|
+
// SeededAssert fields and remembered both; that only proves the discipline works
|
|
130
|
+
// while someone is looking, so the compiler now does the looking.
|
|
131
|
+
const { id, title, critical, mode, turns, checklist, fixture, assert, workspace, remote, systemPromptFile, reps, passThreshold, ...restScenario } = s;
|
|
132
|
+
const _scenarioExhaustive = restScenario;
|
|
133
|
+
void _scenarioExhaustive;
|
|
134
|
+
const { vitest, diff_contains, diff_excludes, post_test, ...restAssert } = assert ?? {};
|
|
135
|
+
const _assertExhaustive = restAssert;
|
|
136
|
+
void _assertExhaustive;
|
|
137
|
+
const canonical = JSON.stringify([
|
|
138
|
+
id,
|
|
139
|
+
title,
|
|
140
|
+
critical,
|
|
141
|
+
mode,
|
|
142
|
+
turns,
|
|
143
|
+
checklist,
|
|
144
|
+
fixture ?? null,
|
|
145
|
+
assert ? [vitest ?? null, diff_contains ?? null, diff_excludes ?? null, post_test ?? null] : null,
|
|
146
|
+
workspace,
|
|
147
|
+
remote,
|
|
148
|
+
systemPromptFile ?? null,
|
|
149
|
+
reps ?? null,
|
|
150
|
+
passThreshold ?? null,
|
|
151
|
+
]);
|
|
152
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
153
|
+
}
|
|
154
|
+
/** Absolute path a fixture key refers to. Fixtures resolve against the spec's dir, like workspace.ts. */
|
|
155
|
+
function fixtureAbs(specDir, fixture) {
|
|
156
|
+
return isAbsolute(fixture) ? fixture : resolve(specDir, fixture);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The fixture path a scenario actually runs in, or undefined.
|
|
160
|
+
*
|
|
161
|
+
* The EFFECTIVE workspace fixture, which is not always `scenario.fixture`: an
|
|
162
|
+
* inline scenario with `env.workspace: fixture:PATH` sets `workspace.fixture`
|
|
163
|
+
* and leaves `scenario.fixture` unset. Exported and shared with lint, which
|
|
164
|
+
* needs the identical rule — a second copy of this expression is how the hashed
|
|
165
|
+
* set and the checked set drift apart.
|
|
166
|
+
*/
|
|
167
|
+
export function effectiveFixture(s) {
|
|
168
|
+
return typeof s.workspace === "object" && s.workspace !== null ? s.workspace.fixture : undefined;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Hash every source this run measures: SKILL.md, each distinct
|
|
172
|
+
* `system_prompt_file`, each scenario's definition, and each distinct fixture
|
|
173
|
+
* tree. Entries that can't be read are omitted — a missing source is lint's
|
|
174
|
+
* problem to report, not run's to crash on.
|
|
175
|
+
*/
|
|
176
|
+
export function sourceHashes(ctx) {
|
|
177
|
+
const hashes = {};
|
|
178
|
+
// UNREADABLE rather than omission on every branch below: a source we failed to
|
|
179
|
+
// hash must stay visible to lint, not vanish from the record. See UNREADABLE.
|
|
180
|
+
hashes["SKILL.md"] = fileSha256(resolve(ctx.skillDir, "SKILL.md")) ?? UNREADABLE;
|
|
181
|
+
for (const s of ctx.scenarios) {
|
|
182
|
+
hashes[SCENARIO_PREFIX + s.id] = scenarioDigest(s);
|
|
183
|
+
if (s.systemPromptFile && !(s.systemPromptFile in hashes)) {
|
|
184
|
+
hashes[s.systemPromptFile] = fileSha256(resolve(ctx.specDir, s.systemPromptFile)) ?? UNREADABLE;
|
|
185
|
+
}
|
|
186
|
+
// The post-test IS the gate on a post_test scenario, and it lives outside the
|
|
187
|
+
// fixture tree by convention (`fixture: fixtures/A1`, `post_test: post/A1.test.ts`),
|
|
188
|
+
// so neither the fixture digest nor the scenario digest — which holds only the
|
|
189
|
+
// path string — covers its contents. Tightening an assertion in it changes what
|
|
190
|
+
// the scorecard measured; without this it would change nothing lint can see.
|
|
191
|
+
const pt = s.assert?.post_test;
|
|
192
|
+
if (pt && !(pt in hashes)) {
|
|
193
|
+
hashes[pt] = fileSha256(isAbsolute(pt) ? pt : resolve(ctx.specDir, pt)) ?? UNREADABLE;
|
|
194
|
+
}
|
|
195
|
+
const fx = effectiveFixture(s);
|
|
196
|
+
if (fx && !(FIXTURE_PREFIX + fx in hashes)) {
|
|
197
|
+
hashes[FIXTURE_PREFIX + fx] = dirSha256(fixtureAbs(ctx.specDir, fx)) ?? UNREADABLE;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return hashes;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The current hash for a recorded key, or null when the source is gone.
|
|
204
|
+
*
|
|
205
|
+
* `undefined` is distinct from `null` and means "not comparable": the key names a
|
|
206
|
+
* scenario the spec no longer has. That is a spec *reshape*, not staleness — the
|
|
207
|
+
* same stance lint's scenario-set check already takes — so the caller stays quiet
|
|
208
|
+
* rather than reporting a removed scenario as a stale measurement.
|
|
209
|
+
*
|
|
210
|
+
* Sharing this resolver with `sourceHashes` is what keeps recording and checking
|
|
211
|
+
* from drifting: a new key kind is defined once, for both sides.
|
|
212
|
+
*/
|
|
213
|
+
export function currentHashFor(key, ctx) {
|
|
214
|
+
if (key === "SKILL.md")
|
|
215
|
+
return fileSha256(resolve(ctx.skillDir, "SKILL.md"));
|
|
216
|
+
if (key.startsWith(SCENARIO_PREFIX)) {
|
|
217
|
+
const id = key.slice(SCENARIO_PREFIX.length);
|
|
218
|
+
const s = ctx.scenarios.find((x) => x.id === id);
|
|
219
|
+
return s ? scenarioDigest(s) : undefined; // removed → reshape, not stale
|
|
220
|
+
}
|
|
221
|
+
if (key.startsWith(FIXTURE_PREFIX)) {
|
|
222
|
+
return dirSha256(fixtureAbs(ctx.specDir, key.slice(FIXTURE_PREFIX.length)));
|
|
223
|
+
}
|
|
224
|
+
// A prefixed key this version doesn't know is written by a NEWER skill-harness.
|
|
225
|
+
// Falling through to the path branch would resolve `agent:foo` as a filename,
|
|
226
|
+
// find nothing, and report a confident "agent:foo no longer exists" — a wrong
|
|
227
|
+
// finding about a source that is fine. Not comparable is the honest answer.
|
|
228
|
+
if (/^[a-z][a-z0-9-]*:/.test(key))
|
|
229
|
+
return undefined;
|
|
230
|
+
return fileSha256(resolve(ctx.specDir, key)); // system_prompt_file, post_test
|
|
231
|
+
}
|
|
232
|
+
/** Human label for a recorded key, used in lint messages. */
|
|
233
|
+
export function describeSourceKey(key) {
|
|
234
|
+
if (key.startsWith(SCENARIO_PREFIX))
|
|
235
|
+
return `scenario \`${key.slice(SCENARIO_PREFIX.length)}\``;
|
|
236
|
+
if (key.startsWith(FIXTURE_PREFIX))
|
|
237
|
+
return `fixture \`${key.slice(FIXTURE_PREFIX.length)}\``;
|
|
238
|
+
return key;
|
|
239
|
+
}
|
|
240
|
+
/** The scenario id a key belongs to, for per-scenario lint findings. Undefined for skill-wide keys. */
|
|
241
|
+
export function scenarioIdForKey(key, scenarios) {
|
|
242
|
+
if (key.startsWith(SCENARIO_PREFIX))
|
|
243
|
+
return key.slice(SCENARIO_PREFIX.length);
|
|
244
|
+
if (key.startsWith(FIXTURE_PREFIX)) {
|
|
245
|
+
const fx = key.slice(FIXTURE_PREFIX.length);
|
|
246
|
+
const owners = scenarios.filter((s) => effectiveFixture(s) === fx);
|
|
247
|
+
// Only attribute a shared fixture to a scenario when exactly one owns it —
|
|
248
|
+
// naming an arbitrary one of several would misdirect the re-run.
|
|
249
|
+
return owners.length === 1 ? owners[0].id : undefined;
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
//# sourceMappingURL=sources.js.map
|
package/dist/spec.d.ts
CHANGED
|
@@ -3,6 +3,21 @@ export type ScenarioMode = "inline" | "seeded";
|
|
|
3
3
|
export interface SeededAssert {
|
|
4
4
|
vitest?: boolean;
|
|
5
5
|
diff_contains?: string[];
|
|
6
|
+
/**
|
|
7
|
+
* Needles that must NOT appear in the staged diff. The negative twin of
|
|
8
|
+
* diff_contains: it makes a scope-discipline requirement ("fix sliceRange, do
|
|
9
|
+
* not touch lastIndex") objective instead of something the judge has to infer
|
|
10
|
+
* from the model's prose.
|
|
11
|
+
*/
|
|
12
|
+
diff_excludes?: string[];
|
|
13
|
+
/**
|
|
14
|
+
* A test file copied into the workspace AFTER the agent finishes, then run.
|
|
15
|
+
* The model never sees it, so it cannot write code shaped to pass it — this
|
|
16
|
+
* checks the behavior the task actually required. Orthogonal to `vitest`,
|
|
17
|
+
* which runs the model's OWN tests and therefore grades a claim the model
|
|
18
|
+
* gets to make about itself.
|
|
19
|
+
*/
|
|
20
|
+
post_test?: string;
|
|
6
21
|
}
|
|
7
22
|
export interface Scenario {
|
|
8
23
|
id: string;
|
package/dist/spec.js
CHANGED
|
@@ -158,8 +158,39 @@ export function parseSpec(text, file) {
|
|
|
158
158
|
if (!isStringArray(a.diff_contains)) {
|
|
159
159
|
throw new SpecError(`seeded scenario \`${id}\` \`assert.diff_contains\` must be strings`, file);
|
|
160
160
|
}
|
|
161
|
+
if (a.diff_contains.some((n) => n === "")) {
|
|
162
|
+
// Every string contains "", so an empty positive needle makes the gate
|
|
163
|
+
// pass on ANY diff, including an empty one. This is the more dangerous
|
|
164
|
+
// twin of the diff_excludes check below: that one fails forever and gets
|
|
165
|
+
// investigated, this one passes forever and nobody ever looks.
|
|
166
|
+
throw new SpecError(`seeded scenario \`${id}\` \`assert.diff_contains\` contains an empty string — it would match every diff, so the gate could never fail`, file);
|
|
167
|
+
}
|
|
161
168
|
assertObj.diff_contains = a.diff_contains;
|
|
162
169
|
}
|
|
170
|
+
if (a.diff_excludes !== undefined) {
|
|
171
|
+
if (!isStringArray(a.diff_excludes)) {
|
|
172
|
+
throw new SpecError(`seeded scenario \`${id}\` \`assert.diff_excludes\` must be strings`, file);
|
|
173
|
+
}
|
|
174
|
+
if (a.diff_excludes.some((n) => n === "")) {
|
|
175
|
+
// "" is in every string, so the gate could never pass — and the failure
|
|
176
|
+
// would read as a mysterious diff problem rather than a spec typo.
|
|
177
|
+
throw new SpecError(`seeded scenario \`${id}\` \`assert.diff_excludes\` contains an empty string — it would match every diff`, file);
|
|
178
|
+
}
|
|
179
|
+
assertObj.diff_excludes = a.diff_excludes;
|
|
180
|
+
}
|
|
181
|
+
// A needle required AND forbidden can never pass. Catching it here turns a
|
|
182
|
+
// scenario that always fails for an invisible reason into an authoring error.
|
|
183
|
+
const both = (assertObj.diff_contains ?? []).filter((n) => (assertObj.diff_excludes ?? []).includes(n));
|
|
184
|
+
if (both.length > 0) {
|
|
185
|
+
throw new SpecError(`seeded scenario \`${id}\` lists ${both.map((n) => JSON.stringify(n)).join(", ")} in both ` +
|
|
186
|
+
`\`assert.diff_contains\` and \`assert.diff_excludes\` — the gate could never pass`, file);
|
|
187
|
+
}
|
|
188
|
+
if (a.post_test !== undefined) {
|
|
189
|
+
if (typeof a.post_test !== "string" || !a.post_test.trim()) {
|
|
190
|
+
throw new SpecError(`seeded scenario \`${id}\` \`assert.post_test\` must be a non-empty path`, file);
|
|
191
|
+
}
|
|
192
|
+
assertObj.post_test = a.post_test.trim();
|
|
193
|
+
}
|
|
163
194
|
scenario.assert = assertObj;
|
|
164
195
|
}
|
|
165
196
|
}
|
package/dist/workspace.d.ts
CHANGED
|
@@ -6,6 +6,34 @@ export interface Workspace {
|
|
|
6
6
|
cwd: string;
|
|
7
7
|
cleanup(): void;
|
|
8
8
|
}
|
|
9
|
+
export declare const MARKERS: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Top-level directories in a fixture that claim to be markers but aren't one.
|
|
12
|
+
*
|
|
13
|
+
* A misspelled marker (`_uncommited/`) used to be copied into the baseline commit as a
|
|
14
|
+
* literal directory: the tree came up clean, the scenario silently measured the opposite
|
|
15
|
+
* of its intent, and nothing reported it. Any top-level `_name/` is therefore treated as
|
|
16
|
+
* a marker claim and must be a real one.
|
|
17
|
+
*
|
|
18
|
+
* The predicate is `_` followed by a LETTER, so `__tests__` and `__pycache__` — ordinary
|
|
19
|
+
* directories a fixture may legitimately contain — are not marker claims, and neither are
|
|
20
|
+
* oddities like `_2fa/` or `_-tmp/`, which no marker could plausibly be mistaken for.
|
|
21
|
+
* Nested `pkg/_staged/` is likewise ordinary content: markers are top-level only.
|
|
22
|
+
*
|
|
23
|
+
* Exported so `lint` can report the same set this module refuses to run. If the two
|
|
24
|
+
* disagreed, lint would hand out a clean bill of health for a fixture the runtime then
|
|
25
|
+
* rejects — which is worse than not checking at all.
|
|
26
|
+
*/
|
|
27
|
+
export declare function unknownMarkerDirs(src: string): string[];
|
|
28
|
+
/**
|
|
29
|
+
* The known marker a misspelling was probably reaching for, or null.
|
|
30
|
+
*
|
|
31
|
+
* Case-insensitive within an edit distance of 2 — enough for `_uncommited` (one
|
|
32
|
+
* dropped `t`), `_Staged` (case) and `_uncommmitted` (doubled letter), while
|
|
33
|
+
* `_fixtures` or `_helpers` correctly suggest nothing rather than a confident
|
|
34
|
+
* wrong guess.
|
|
35
|
+
*/
|
|
36
|
+
export declare function suggestMarker(name: string): string | null;
|
|
9
37
|
/**
|
|
10
38
|
* Create an isolated temp-dir working directory for one scenario. `none` is an
|
|
11
39
|
* empty dir (no git); `empty-git` initialises a clean repo; `{ fixture }` copies
|
package/dist/workspace.js
CHANGED
|
@@ -13,21 +13,64 @@ const GIT_TIMEOUT_MS = 30_000;
|
|
|
13
13
|
*/
|
|
14
14
|
const UNCOMMITTED_DIR = "_uncommitted";
|
|
15
15
|
const STAGED_DIR = "_staged";
|
|
16
|
-
const MARKERS = [STAGED_DIR, UNCOMMITTED_DIR];
|
|
16
|
+
export const MARKERS = [STAGED_DIR, UNCOMMITTED_DIR];
|
|
17
17
|
/**
|
|
18
|
+
* Top-level directories in a fixture that claim to be markers but aren't one.
|
|
19
|
+
*
|
|
18
20
|
* A misspelled marker (`_uncommited/`) used to be copied into the baseline commit as a
|
|
19
21
|
* literal directory: the tree came up clean, the scenario silently measured the opposite
|
|
20
22
|
* of its intent, and nothing reported it. Any top-level `_name/` is therefore treated as
|
|
21
23
|
* a marker claim and must be a real one.
|
|
22
24
|
*
|
|
23
|
-
*
|
|
24
|
-
* directories a fixture may legitimately contain — are not marker claims
|
|
25
|
-
* `
|
|
25
|
+
* The predicate is `_` followed by a LETTER, so `__tests__` and `__pycache__` — ordinary
|
|
26
|
+
* directories a fixture may legitimately contain — are not marker claims, and neither are
|
|
27
|
+
* oddities like `_2fa/` or `_-tmp/`, which no marker could plausibly be mistaken for.
|
|
28
|
+
* Nested `pkg/_staged/` is likewise ordinary content: markers are top-level only.
|
|
29
|
+
*
|
|
30
|
+
* Exported so `lint` can report the same set this module refuses to run. If the two
|
|
31
|
+
* disagreed, lint would hand out a clean bill of health for a fixture the runtime then
|
|
32
|
+
* rejects — which is worse than not checking at all.
|
|
26
33
|
*/
|
|
27
|
-
function
|
|
28
|
-
|
|
34
|
+
export function unknownMarkerDirs(src) {
|
|
35
|
+
return readdirSync(src, { withFileTypes: true })
|
|
29
36
|
.filter((e) => e.isDirectory() && /^_[A-Za-z]/.test(e.name) && !MARKERS.includes(e.name))
|
|
30
|
-
.map((e) => e.name)
|
|
37
|
+
.map((e) => e.name)
|
|
38
|
+
.sort();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The known marker a misspelling was probably reaching for, or null.
|
|
42
|
+
*
|
|
43
|
+
* Case-insensitive within an edit distance of 2 — enough for `_uncommited` (one
|
|
44
|
+
* dropped `t`), `_Staged` (case) and `_uncommmitted` (doubled letter), while
|
|
45
|
+
* `_fixtures` or `_helpers` correctly suggest nothing rather than a confident
|
|
46
|
+
* wrong guess.
|
|
47
|
+
*/
|
|
48
|
+
export function suggestMarker(name) {
|
|
49
|
+
let best = null;
|
|
50
|
+
let bestDistance = 3;
|
|
51
|
+
for (const m of MARKERS) {
|
|
52
|
+
const d = editDistance(name.toLowerCase(), m.toLowerCase());
|
|
53
|
+
if (d < bestDistance) {
|
|
54
|
+
bestDistance = d;
|
|
55
|
+
best = m;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return best;
|
|
59
|
+
}
|
|
60
|
+
/** Levenshtein distance, iterative single-row. */
|
|
61
|
+
function editDistance(a, b) {
|
|
62
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
63
|
+
for (let i = 1; i <= a.length; i++) {
|
|
64
|
+
const curr = [i];
|
|
65
|
+
for (let j = 1; j <= b.length; j++) {
|
|
66
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
67
|
+
}
|
|
68
|
+
prev = curr;
|
|
69
|
+
}
|
|
70
|
+
return prev[b.length];
|
|
71
|
+
}
|
|
72
|
+
function assertKnownMarkers(src) {
|
|
73
|
+
const suspects = unknownMarkerDirs(src);
|
|
31
74
|
if (suspects.length > 0) {
|
|
32
75
|
throw new Error(`fixture ${src}: unknown marker director${suspects.length > 1 ? "ies" : "y"} ` +
|
|
33
76
|
`${suspects.map((s) => `\`${s}/\``).join(", ")} — known markers are ` +
|
package/package.json
CHANGED
|
@@ -1,19 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skill-harness/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
|
-
"exports": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"agent-skills",
|
|
14
|
+
"skill",
|
|
15
|
+
"llm",
|
|
16
|
+
"testing",
|
|
17
|
+
"eval",
|
|
18
|
+
"grading",
|
|
19
|
+
"pi",
|
|
20
|
+
"ci",
|
|
21
|
+
"harness"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"dist/**/*.js",
|
|
25
|
+
"dist/**/*.d.ts",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/mojomanyana/skill-harness.git"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
15
39
|
"scripts": {
|
|
16
40
|
"prepack": "cp ../../LICENSE ./LICENSE"
|
|
17
41
|
},
|
|
18
|
-
"dependencies": {
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"js-yaml": "^4.1.0"
|
|
44
|
+
}
|
|
19
45
|
}
|