@skill-harness/core 0.3.2 → 0.5.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/sources.js CHANGED
@@ -39,8 +39,44 @@ import { isAbsolute, join, resolve } from "node:path";
39
39
  * reindenting the YAML or reordering scenarios is correctly a no-op, while
40
40
  * changing a single checklist word is correctly a change.
41
41
  */
42
+ /**
43
+ * The pre-0.4.0 combined key: one digest over a scenario's stimulus, rubric, policy
44
+ * and gates together. Still read (runs recorded with it must keep comparing), never
45
+ * written. See `scenarioDigest`.
46
+ */
42
47
  export const SCENARIO_PREFIX = "scenario:";
43
48
  export const FIXTURE_PREFIX = "fixture:";
49
+ /**
50
+ * The split: three (four, with gates) digests per scenario, each mapped to the
51
+ * cheapest tool that can honestly restore freshness.
52
+ *
53
+ * | key | contents | drift means | remedy |
54
+ * |---|---|---|---|
55
+ * | `stimulus:<id>` | mode, turns, workspace, remote, agent-file path, fixture path, `assert.vitest`, `post_test` path | the transcripts answer a different question | `run` (model + judge) |
56
+ * | `rubric:<id>` | title, checklist | transcripts fine, verdicts wrong | `grade` (judge only) |
57
+ * | `policy:<id>` | critical, reps, pass_threshold | only the scoring moved | `rescore` (free) |
58
+ * | `gates:<id>` | `diff_contains`, `diff_excludes` | needle wrong, behavior fine | `regate` (free; judges only flipped reps) |
59
+ * | `rubric:__persona` | spec-level `judge_persona` | every verdict in the skill | `grade` per model |
60
+ *
61
+ * Why this matters more than it looks: with one key, lint had exactly one remedy for
62
+ * any drift — "re-run" — so **correcting a rubric cost model spend**. Measured on the
63
+ * reference corpus, two parked branches (one needle, one checklist rewrite) demanded
64
+ * 135 rep-executions to restore freshness while producing zero new information about
65
+ * the models. A gate that charges that much to fix a known-bad rubric is pressure to
66
+ * leave the rubric in place, which inverts the point of having a gate.
67
+ *
68
+ * The strictness is unchanged: every edit still marks something stale. Only the price
69
+ * of getting back to fresh changed.
70
+ */
71
+ export const STIMULUS_PREFIX = "stimulus:";
72
+ export const RUBRIC_PREFIX = "rubric:";
73
+ export const POLICY_PREFIX = "policy:";
74
+ export const GATES_PREFIX = "gates:";
75
+ /**
76
+ * The spec-level rubric key. `__persona` cannot collide with a scenario id: ids are
77
+ * validated as `[A-Za-z][A-Za-z0-9_-]*`, so none can begin with an underscore.
78
+ */
79
+ export const PERSONA_KEY = `${RUBRIC_PREFIX}__persona`;
44
80
  /**
45
81
  * Recorded in place of a hash when a source existed but could not be read.
46
82
  *
@@ -120,34 +156,87 @@ function walk(dir, prefix = "") {
120
156
  * block a ship; `title` is included because it is what a reader of the scorecard
121
157
  * believes was tested.
122
158
  */
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.
159
+ /**
160
+ * A scenario's fields, sorted into the four buckets, as canonical JSON.
161
+ *
162
+ * One function so the exhaustive-destructure trick below covers all four digests at
163
+ * once: adding a field to `Scenario` or `SeededAssert` fails the build **here** until
164
+ * someone decides which bucket and therefore which remedy it belongs to. A field
165
+ * nobody assigned is a permanent staleness blind spot, and a field assigned to the
166
+ * wrong bucket is worse than that: it would tell a user `rescore` is enough when the
167
+ * transcripts are actually invalid.
168
+ */
169
+ function facets(s) {
131
170
  const { id, title, critical, mode, turns, checklist, fixture, assert, workspace, remote, systemPromptFile, reps, passThreshold, ...restScenario } = s;
132
171
  const _scenarioExhaustive = restScenario;
133
172
  void _scenarioExhaustive;
134
173
  const { vitest, diff_contains, diff_excludes, post_test, ...restAssert } = assert ?? {};
135
174
  const _assertExhaustive = restAssert;
136
175
  void _assertExhaustive;
176
+ const hasGates = diff_contains !== undefined || diff_excludes !== undefined;
177
+ return {
178
+ // `vitest` and the `post_test` PATH are stimulus, not gates: both change what the
179
+ // run executes in the workspace, and neither can be re-evaluated from a saved
180
+ // diff. (`post_test`'s CONTENTS get their own file-path key, hashed separately.)
181
+ stimulus: JSON.stringify([
182
+ id, mode, turns, workspace, remote, systemPromptFile ?? null,
183
+ fixture ?? null, vitest ?? null, post_test ?? null,
184
+ ]),
185
+ rubric: JSON.stringify([id, title, checklist]),
186
+ policy: JSON.stringify([id, critical, reps ?? null, passThreshold ?? null]),
187
+ gates: hasGates ? JSON.stringify([id, diff_contains ?? null, diff_excludes ?? null]) : null,
188
+ };
189
+ }
190
+ function sha(canonical) {
191
+ return createHash("sha256").update(canonical).digest("hex");
192
+ }
193
+ export function stimulusDigest(s) {
194
+ return sha(facets(s).stimulus);
195
+ }
196
+ export function rubricDigest(s) {
197
+ return sha(facets(s).rubric);
198
+ }
199
+ export function policyDigest(s) {
200
+ return sha(facets(s).policy);
201
+ }
202
+ /** Null when the scenario declares no needle gates — no key is recorded for it. */
203
+ export function gatesDigest(s) {
204
+ const g = facets(s).gates;
205
+ return g === null ? null : sha(g);
206
+ }
207
+ /** The spec-level judge persona, which is rubric for every scenario at once. */
208
+ export function personaDigest(persona) {
209
+ return sha(JSON.stringify(["__persona", persona]));
210
+ }
211
+ /**
212
+ * The pre-0.4.0 combined digest: everything about a scenario in one hash.
213
+ *
214
+ * **Read-only now** — `sourceHashes` writes the four split keys instead. Kept because
215
+ * `lint` must still compare runs that recorded `scenario:<id>`, and those runs are
216
+ * every scorecard published before 0.4.0. Deleting it would turn "no findings" into
217
+ * "no comparison" for the entire existing corpus, silently.
218
+ *
219
+ * Its bytes must therefore never change again: this is a stored-hash format, not an
220
+ * implementation detail. The facet digests take new fields; this one is frozen at the
221
+ * 0.3.x field set, which is why it does not go through `facets()`.
222
+ */
223
+ export function scenarioDigest(s) {
137
224
  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,
225
+ s.id,
226
+ s.title,
227
+ s.critical,
228
+ s.mode,
229
+ s.turns,
230
+ s.checklist,
231
+ s.fixture ?? null,
232
+ s.assert
233
+ ? [s.assert.vitest ?? null, s.assert.diff_contains ?? null, s.assert.diff_excludes ?? null, s.assert.post_test ?? null]
234
+ : null,
235
+ s.workspace,
236
+ s.remote,
237
+ s.systemPromptFile ?? null,
238
+ s.reps ?? null,
239
+ s.passThreshold ?? null,
151
240
  ]);
152
241
  return createHash("sha256").update(canonical).digest("hex");
153
242
  }
@@ -178,8 +267,17 @@ export function sourceHashes(ctx) {
178
267
  // UNREADABLE rather than omission on every branch below: a source we failed to
179
268
  // hash must stay visible to lint, not vanish from the record. See UNREADABLE.
180
269
  hashes["SKILL.md"] = fileSha256(resolve(ctx.skillDir, "SKILL.md")) ?? UNREADABLE;
270
+ hashes[PERSONA_KEY] = personaDigest(ctx.judgePersona);
181
271
  for (const s of ctx.scenarios) {
182
- hashes[SCENARIO_PREFIX + s.id] = scenarioDigest(s);
272
+ // Split, not combined: each facet's drift has a different cheapest remedy, and a
273
+ // single key could only ever name the most expensive one. The legacy
274
+ // `scenario:<id>` key is deliberately NOT written any more — see scenarioDigest.
275
+ hashes[STIMULUS_PREFIX + s.id] = stimulusDigest(s);
276
+ hashes[RUBRIC_PREFIX + s.id] = rubricDigest(s);
277
+ hashes[POLICY_PREFIX + s.id] = policyDigest(s);
278
+ const gates = gatesDigest(s);
279
+ if (gates !== null)
280
+ hashes[GATES_PREFIX + s.id] = gates;
183
281
  if (s.systemPromptFile && !(s.systemPromptFile in hashes)) {
184
282
  hashes[s.systemPromptFile] = fileSha256(resolve(ctx.specDir, s.systemPromptFile)) ?? UNREADABLE;
185
283
  }
@@ -213,10 +311,27 @@ export function sourceHashes(ctx) {
213
311
  export function currentHashFor(key, ctx) {
214
312
  if (key === "SKILL.md")
215
313
  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
314
+ if (key === PERSONA_KEY)
315
+ return personaDigest(ctx.judgePersona);
316
+ // Facet keys, and the legacy combined key, all resolve per scenario id. A removed
317
+ // scenario is a reshape, not staleness, on every kind.
318
+ const facetResolvers = [
319
+ [STIMULUS_PREFIX, stimulusDigest],
320
+ [RUBRIC_PREFIX, rubricDigest],
321
+ [POLICY_PREFIX, policyDigest],
322
+ [GATES_PREFIX, gatesDigest],
323
+ [SCENARIO_PREFIX, scenarioDigest],
324
+ ];
325
+ for (const [prefix, digest] of facetResolvers) {
326
+ if (!key.startsWith(prefix))
327
+ continue;
328
+ const s = ctx.scenarios.find((x) => x.id === key.slice(prefix.length));
329
+ if (!s)
330
+ return undefined; // removed → reshape, not stale
331
+ // gatesDigest returns null when the scenario no longer declares needles. That is
332
+ // a real change (the gate the run recorded is gone), so null — "no longer
333
+ // exists" — is the honest answer rather than "not comparable".
334
+ return digest(s);
220
335
  }
221
336
  if (key.startsWith(FIXTURE_PREFIX)) {
222
337
  return dirSha256(fixtureAbs(ctx.specDir, key.slice(FIXTURE_PREFIX.length)));
@@ -231,14 +346,58 @@ export function currentHashFor(key, ctx) {
231
346
  }
232
347
  /** Human label for a recorded key, used in lint messages. */
233
348
  export function describeSourceKey(key) {
349
+ if (key === PERSONA_KEY)
350
+ return "the judge persona";
351
+ if (key.startsWith(STIMULUS_PREFIX))
352
+ return `the stimulus for \`${key.slice(STIMULUS_PREFIX.length)}\``;
353
+ if (key.startsWith(RUBRIC_PREFIX))
354
+ return `the rubric for \`${key.slice(RUBRIC_PREFIX.length)}\``;
355
+ if (key.startsWith(POLICY_PREFIX))
356
+ return `the scoring policy for \`${key.slice(POLICY_PREFIX.length)}\``;
357
+ if (key.startsWith(GATES_PREFIX))
358
+ return `the gates for \`${key.slice(GATES_PREFIX.length)}\``;
234
359
  if (key.startsWith(SCENARIO_PREFIX))
235
360
  return `scenario \`${key.slice(SCENARIO_PREFIX.length)}\``;
236
361
  if (key.startsWith(FIXTURE_PREFIX))
237
362
  return `fixture \`${key.slice(FIXTURE_PREFIX.length)}\``;
238
363
  return key;
239
364
  }
365
+ /**
366
+ * The cheapest command that honestly restores freshness for this key kind.
367
+ *
368
+ * This string is the feature. Before the split, lint's only remedy was "re-run", so a
369
+ * one-word checklist fix cost a full model pass — pressure to leave a known-bad rubric
370
+ * in place. Naming the actual remedy is what converts that into a free command.
371
+ */
372
+ export function remedyForKey(key) {
373
+ if (key === PERSONA_KEY) {
374
+ return "re-grade each model's saved transcripts (`grade <run-dir>`) — judge-only, no model spend";
375
+ }
376
+ if (key.startsWith(RUBRIC_PREFIX)) {
377
+ return "re-grade from the saved transcripts (`grade <run-dir>`) — judge-only, no model spend";
378
+ }
379
+ if (key.startsWith(POLICY_PREFIX)) {
380
+ return "re-score the saved reps (`rescore <run-dir>`) — free, offline";
381
+ }
382
+ if (key.startsWith(GATES_PREFIX)) {
383
+ return "re-evaluate the needles against the saved diffs (`regate <run-dir>`) — free, and it judges only the reps whose gate verdict flipped";
384
+ }
385
+ // A pre-split run recorded one hash over stimulus + rubric + policy + gates, so
386
+ // which of them moved is genuinely unknowable from the record. Naming a cheap
387
+ // remedy here would be a guess dressed as a fact.
388
+ if (key.startsWith(SCENARIO_PREFIX)) {
389
+ return "re-run — this run predates the stimulus/rubric/policy split, so which part changed cannot be told from what it recorded";
390
+ }
391
+ return "re-run"; // stimulus:, SKILL.md, fixture:, agent files, post_test contents
392
+ }
240
393
  /** The scenario id a key belongs to, for per-scenario lint findings. Undefined for skill-wide keys. */
241
394
  export function scenarioIdForKey(key, scenarios) {
395
+ if (key === PERSONA_KEY)
396
+ return undefined; // spec-level: belongs to no single scenario
397
+ for (const p of [STIMULUS_PREFIX, RUBRIC_PREFIX, POLICY_PREFIX, GATES_PREFIX]) {
398
+ if (key.startsWith(p))
399
+ return key.slice(p.length);
400
+ }
242
401
  if (key.startsWith(SCENARIO_PREFIX))
243
402
  return key.slice(SCENARIO_PREFIX.length);
244
403
  if (key.startsWith(FIXTURE_PREFIX)) {
package/dist/trends.d.ts CHANGED
@@ -14,6 +14,17 @@ export interface TrendRun {
14
14
  export interface TrendModel {
15
15
  model: string;
16
16
  tag: string;
17
+ /**
18
+ * The delivery mode every run in this series shares (`green` or `force`).
19
+ *
20
+ * A series is per tag AND per mode, never pooled: the two modes are different
21
+ * deliveries of the same text, and placement moves verdicts in both directions at
22
+ * once (measured on identical skill text: `build` A1 0/3 → 3/3 with force, `plan`
23
+ * C2 3/3 → 0/3). A sparkline that ran green then force would draw that epoch
24
+ * change as skill progress — or regression — which is the one thing a trend line
25
+ * must not invent.
26
+ */
27
+ mode: string;
17
28
  runs: TrendRun[];
18
29
  truncated: boolean;
19
30
  skipped: number;
@@ -35,15 +46,18 @@ export interface TrendData {
35
46
  * rule: an override resolves a misfire) + reps flakiness. Read-only; no
36
47
  * absolute paths in the result.
37
48
  *
38
- * Only scored (mode === "green") runs are included in the history — a
39
- * red/force run has no real grade (`effective_grade` is a "not scored"
40
- * placeholder; see run.ts) and would otherwise plot as a misleading 0% dip in
41
- * the sparkline/grid. Non-green runs are deliberately excluded, which is
42
- * distinct from `skipped`: a run's mode can only be known after reading its
43
- * results.yaml, so every candidate run-dir in the tag is read (not just the
44
- * most recent `limit`) before filtering to green and applying the `limit`
45
- * window — trends is a bounded, on-demand, local view, so this extra read
46
- * cost is acceptable. If a tag has zero green runs, it's omitted entirely.
49
+ * Only scored runs are included in the history — a red baseline has no real grade
50
+ * (`effective_grade` is a "not scored" placeholder; see run.ts) and would otherwise
51
+ * plot as a misleading 0% dip in the sparkline/grid. Red runs are deliberately
52
+ * excluded, which is distinct from `skipped`: a run's mode can only be known after
53
+ * reading its results.yaml, so every candidate run-dir in the tag is read (not just
54
+ * the most recent `limit`) before filtering and applying the `limit` window
55
+ * trends is a bounded, on-demand, local view, so this extra read cost is
56
+ * acceptable.
57
+ *
58
+ * Green and force runs both count, but never in the same series: a tag with both
59
+ * yields one TrendModel per mode (see `TrendModel.mode`), each with its own
60
+ * `limit` window. A tag with no scored run at all is omitted entirely.
47
61
  *
48
62
  * A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic
49
63
  * write) is logged via `console.warn` and skipped — never surfaced or thrown —
package/dist/trends.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadSpec } from "./spec.js";
4
- import { readResults, effectiveVerdicts } from "./results.js";
4
+ import { readResults, effectiveVerdicts, isScoredMode } from "./results.js";
5
5
  /** A directory that exists right now; false (never throws) if it vanished concurrently (e.g. ENOENT). */
6
6
  function isDir(p) {
7
7
  try {
@@ -19,15 +19,18 @@ function isDir(p) {
19
19
  * rule: an override resolves a misfire) + reps flakiness. Read-only; no
20
20
  * absolute paths in the result.
21
21
  *
22
- * Only scored (mode === "green") runs are included in the history — a
23
- * red/force run has no real grade (`effective_grade` is a "not scored"
24
- * placeholder; see run.ts) and would otherwise plot as a misleading 0% dip in
25
- * the sparkline/grid. Non-green runs are deliberately excluded, which is
26
- * distinct from `skipped`: a run's mode can only be known after reading its
27
- * results.yaml, so every candidate run-dir in the tag is read (not just the
28
- * most recent `limit`) before filtering to green and applying the `limit`
29
- * window — trends is a bounded, on-demand, local view, so this extra read
30
- * cost is acceptable. If a tag has zero green runs, it's omitted entirely.
22
+ * Only scored runs are included in the history — a red baseline has no real grade
23
+ * (`effective_grade` is a "not scored" placeholder; see run.ts) and would otherwise
24
+ * plot as a misleading 0% dip in the sparkline/grid. Red runs are deliberately
25
+ * excluded, which is distinct from `skipped`: a run's mode can only be known after
26
+ * reading its results.yaml, so every candidate run-dir in the tag is read (not just
27
+ * the most recent `limit`) before filtering and applying the `limit` window
28
+ * trends is a bounded, on-demand, local view, so this extra read cost is
29
+ * acceptable.
30
+ *
31
+ * Green and force runs both count, but never in the same series: a tag with both
32
+ * yields one TrendModel per mode (see `TrendModel.mode`), each with its own
33
+ * `limit` window. A tag with no scored run at all is omitted entirely.
31
34
  *
32
35
  * A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic
33
36
  * write) is logged via `console.warn` and skipped — never surfaced or thrown —
@@ -54,10 +57,11 @@ export function collectTrends(skillDir, limit = 20) {
54
57
  if (runDirs.length === 0)
55
58
  continue;
56
59
  // Read every candidate run (mode isn't knowable from the dir name) and
57
- // filter to green (scored) runs before applying the `limit` window —
58
- // filtering after the slice would let red/force runs consume window
59
- // slots, undercounting the green history even when more exists.
60
- const greenRuns = [];
60
+ // filter to scored runs before applying the `limit` window — filtering
61
+ // after the slice would let red runs consume window slots, undercounting
62
+ // the history even when more exists. Bucketed by mode, in first-seen
63
+ // order, so each delivery epoch gets its own series and its own window.
64
+ const byMode = new Map();
61
65
  let skipped = 0;
62
66
  for (const rd of runDirs) {
63
67
  let r;
@@ -71,30 +75,35 @@ export function collectTrends(skillDir, limit = 20) {
71
75
  skipped++;
72
76
  continue;
73
77
  }
74
- if (r.mode !== "green")
75
- continue; // not scored — deliberate exclusion, not a skip
76
- greenRuns.push(r);
78
+ if (!isScoredMode(r.mode))
79
+ continue; // baseline — deliberate exclusion, not a skip
80
+ (byMode.get(r.mode) ?? byMode.set(r.mode, []).get(r.mode)).push(r);
77
81
  }
78
- if (greenRuns.length === 0)
82
+ if (byMode.size === 0)
79
83
  continue;
80
- const truncated = greenRuns.length > limit;
81
- const kept = greenRuns.slice(-limit); // most recent `limit`, newest last
82
- const runs = [];
83
- let model = "";
84
- for (const r of kept) {
85
- // effectiveVerdicts is the single source of truth for the
86
- // override-aware verdict/suspect rule (suspect = s.suspect &&
87
- // s.override == null an override resolves the misfire); zip in
88
- // flakiness from the matching ScenarioResult.
89
- const verdicts = effectiveVerdicts(r.scenarios);
90
- const cells = {};
91
- r.scenarios.forEach((s, i) => {
92
- cells[s.id] = { verdict: verdicts[i].verdict, suspect: verdicts[i].suspect ?? false, flakiness: s.flakiness };
93
- });
94
- runs.push({ timestamp: r.timestamp, label: r.label, grade: r.effective_grade, cells });
95
- model = r.model; // last successfully-read run (kept is ascending) wins
84
+ for (const [mode, scoredRuns] of byMode) {
85
+ const truncated = scoredRuns.length > limit;
86
+ const kept = scoredRuns.slice(-limit); // most recent `limit`, newest last
87
+ const runs = [];
88
+ let model = "";
89
+ for (const r of kept) {
90
+ // effectiveVerdicts is the single source of truth for the
91
+ // override-aware verdict/suspect rule (suspect = s.suspect &&
92
+ // s.override == null — an override resolves the misfire); zip in
93
+ // flakiness from the matching ScenarioResult.
94
+ const verdicts = effectiveVerdicts(r.scenarios);
95
+ const cells = {};
96
+ r.scenarios.forEach((s, i) => {
97
+ cells[s.id] = { verdict: verdicts[i].verdict, suspect: verdicts[i].suspect ?? false, flakiness: s.flakiness };
98
+ });
99
+ runs.push({ timestamp: r.timestamp, label: r.label, grade: r.effective_grade, cells });
100
+ model = r.model; // last successfully-read run (kept is ascending) wins
101
+ }
102
+ // `skipped` is per tag (an unreadable run has no knowable mode), so a tag with
103
+ // two series reports the same count on both — the alternative is attributing a
104
+ // parse failure to a mode nobody could read.
105
+ models.push({ model, tag, mode, runs, truncated, skipped });
96
106
  }
97
- models.push({ model, tag, runs, truncated, skipped });
98
107
  }
99
108
  }
100
109
  return { skill: spec.skill, scenarios, models };
@@ -0,0 +1 @@
1
+ export declare const HARNESS_VERSION: string;
@@ -0,0 +1,22 @@
1
+ import { createRequire } from "node:module";
2
+ /**
3
+ * The version of the harness that is running, read from this package's own
4
+ * `package.json`.
5
+ *
6
+ * Why this exists: `schema` is the wrong sentinel for "can these two numbers be
7
+ * compared". 0.2.1 → 0.3.0 kept `results.yaml` at `schema: 2` while changing what
8
+ * a verdict *means* — the judge started seeing the staged diff, and needle gates
9
+ * started matching changed lines rather than raw diff text. A record that says
10
+ * only `schema: 2` cannot tell you which of those it was graded under, and a stale
11
+ * global install produces plausible-looking numbers with no warning.
12
+ *
13
+ * `createRequire` rather than a JSON import: an `import ... from
14
+ * "../package.json"` needs `resolveJsonModule` and changes the emit layout under
15
+ * `tsc -b` (the JSON is copied into `dist/`, shifting relative depths). A runtime
16
+ * require resolves `../package.json` against this module's own location, which is
17
+ * the package root in both the source tree (`src/version.ts`) and the published
18
+ * build (`dist/version.js`).
19
+ */
20
+ const require = createRequire(import.meta.url);
21
+ export const HARNESS_VERSION = require("../package.json").version;
22
+ //# sourceMappingURL=version.js.map
package/dist/workspace.js CHANGED
@@ -1,4 +1,4 @@
1
- import { cpSync, existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
1
+ import { appendFileSync, cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { tmpdir } from "node:os";
4
4
  import { isAbsolute, join, resolve } from "node:path";
@@ -77,6 +77,34 @@ function assertKnownMarkers(src) {
77
77
  `${MARKERS.map((m) => `\`${m}/\``).join(" and ")}. Rename it, or move it deeper if it is ordinary content.`);
78
78
  }
79
79
  }
80
+ /**
81
+ * Paths a *tool* creates during a run, excluded from the workspace repo so they never
82
+ * reach the captured diff.
83
+ *
84
+ * `runSeeded` records what the model did as `git add -A` + `git diff --cached`, which
85
+ * cannot distinguish the model's edits from vitest's cache. Measured in all four
86
+ * `post-diff-remeasure-full` runs of the reference corpus: every diff carried
87
+ * `node_modules/.vite/vitest/<sha>/results.json`, whose contents are test file paths
88
+ * and `"failed":false` booleans. No gate there was affected, but the channel runs both
89
+ * ways — a `diff_contains` needle matching a test filename can pass for free, and a
90
+ * `diff_excludes` needle can false-fail on a path string — and it pads every diff the
91
+ * judge reads.
92
+ */
93
+ const TOOL_ARTIFACTS = ["node_modules/", "coverage/", ".vitest/"];
94
+ /**
95
+ * Exclude tool artifacts via `.git/info/exclude`, deliberately not a `.gitignore`.
96
+ *
97
+ * `.git/info/exclude` is not a worktree file, so: it cannot contaminate a scenario
98
+ * that is *about* `.gitignore`, the model can neither read nor delete it, a fixture's
99
+ * own `.gitignore` is left byte-identical, and it applies to every git call in the
100
+ * workspace rather than to the ones someone remembered to add a pathspec to.
101
+ */
102
+ function excludeToolArtifacts(cwd) {
103
+ const excludeFile = join(cwd, ".git", "info", "exclude");
104
+ const existing = existsSync(excludeFile) ? readFileSync(excludeFile, "utf8") : "";
105
+ const nl = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
106
+ appendFileSync(excludeFile, `${nl}# skill-harness: tool output, never the model's work\n${TOOL_ARTIFACTS.join("\n")}\n`, "utf8");
107
+ }
80
108
  /**
81
109
  * git init + a baseline commit, so a later `git diff --cached` shows only edits.
82
110
  * Pinned to `main`: the host's init.defaultBranch is not ours to depend on, and
@@ -84,6 +112,9 @@ function assertKnownMarkers(src) {
84
112
  */
85
113
  function gitBaseline(cwd) {
86
114
  execFileSync("git", ["init", "-q", "-b", "main"], { cwd, timeout: GIT_TIMEOUT_MS });
115
+ // Before the baseline `add -A`, so a fixture that ships a stray node_modules/ does
116
+ // not commit it either.
117
+ excludeToolArtifacts(cwd);
87
118
  execFileSync("git", ["add", "-A"], { cwd, timeout: GIT_TIMEOUT_MS });
88
119
  execFileSync("git", ["-c", "user.email=sh@local", "-c", "user.name=skill-harness", "commit", "-q", "--allow-empty", "-m", "baseline"], { cwd, timeout: GIT_TIMEOUT_MS });
89
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",