@skill-harness/core 0.5.0 → 0.7.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.
Files changed (46) hide show
  1. package/dist/adapters/types.d.ts +37 -0
  2. package/dist/adjudication.d.ts +210 -0
  3. package/dist/adjudication.js +392 -0
  4. package/dist/affected.d.ts +88 -0
  5. package/dist/affected.js +222 -0
  6. package/dist/capture-trace-types.d.ts +228 -0
  7. package/dist/capture-trace-types.js +23 -0
  8. package/dist/capture.d.ts +193 -0
  9. package/dist/capture.js +344 -0
  10. package/dist/execution-trace.d.ts +61 -0
  11. package/dist/execution-trace.js +299 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.js +9 -0
  14. package/dist/instruction-coverage.d.ts +106 -0
  15. package/dist/instruction-coverage.js +253 -0
  16. package/dist/journal.d.ts +17 -0
  17. package/dist/lint.d.ts +16 -1
  18. package/dist/lint.js +52 -0
  19. package/dist/regate.js +80 -17
  20. package/dist/regrade.js +17 -3
  21. package/dist/report.d.ts +48 -0
  22. package/dist/report.js +39 -1
  23. package/dist/reps.d.ts +14 -1
  24. package/dist/reps.js +28 -2
  25. package/dist/rescore.js +11 -2
  26. package/dist/results.d.ts +128 -6
  27. package/dist/results.js +155 -6
  28. package/dist/run.d.ts +9 -1
  29. package/dist/run.js +129 -9
  30. package/dist/seeded.d.ts +11 -0
  31. package/dist/seeded.js +31 -7
  32. package/dist/sources.d.ts +26 -0
  33. package/dist/sources.js +82 -3
  34. package/dist/spec-write.d.ts +62 -0
  35. package/dist/spec-write.js +106 -0
  36. package/dist/spec.d.ts +29 -0
  37. package/dist/spec.js +55 -0
  38. package/dist/stability.d.ts +144 -0
  39. package/dist/stability.js +232 -0
  40. package/dist/trace-gates.d.ts +133 -0
  41. package/dist/trace-gates.js +519 -0
  42. package/dist/trends.d.ts +28 -0
  43. package/dist/trends.js +76 -61
  44. package/dist/workspace.d.ts +36 -0
  45. package/dist/workspace.js +61 -0
  46. package/package.json +1 -1
package/dist/trends.js CHANGED
@@ -11,6 +11,67 @@ function isDir(p) {
11
11
  return false;
12
12
  }
13
13
  }
14
+ /**
15
+ * Walk `<skillDir>/tests/results/` and group every SCORED run by model tag × delivery
16
+ * mode, chronologically (timestamp-slug dir names sort correctly).
17
+ *
18
+ * The single history reader: `collectTrends` renders it, `collectStability` derives
19
+ * run-over-run flips from it. Two walkers over the same tree is how "which runs count"
20
+ * drifts — the mistake that had force runs excluded from scoring in seven places at
21
+ * once (see SCORED_MODES).
22
+ *
23
+ * Red runs are excluded: a baseline has no grade, and pairing it with anything would
24
+ * compare a skill-off run to a skill-on one. Green and force are never pooled into one
25
+ * group — placement moves verdicts, so a green run and a force run of the same scenario
26
+ * are two measurements, not two samples.
27
+ *
28
+ * A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic write) is
29
+ * logged via `console.warn`, skipped, and counted in `skipped` — never thrown, because
30
+ * one torn file must not take down a whole read-only view.
31
+ */
32
+ export function collectScoredRuns(skillDir) {
33
+ const resultsRoot = join(skillDir, "tests", "results");
34
+ if (!existsSync(resultsRoot))
35
+ return [];
36
+ const groups = [];
37
+ const tags = readdirSync(resultsRoot)
38
+ .filter((n) => isDir(join(resultsRoot, n)))
39
+ .sort();
40
+ for (const tag of tags) {
41
+ const tagDir = join(resultsRoot, tag);
42
+ const runDirs = readdirSync(tagDir)
43
+ .map((n) => join(tagDir, n))
44
+ .filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
45
+ .sort(); // timestamp-slug dir names ⇒ chronological ascending
46
+ if (runDirs.length === 0)
47
+ continue;
48
+ // Every candidate run is read: a run's mode is not knowable from its dir name, so
49
+ // filtering has to happen after the read. Bucketed by mode in first-seen order.
50
+ const byMode = new Map();
51
+ let skipped = 0;
52
+ for (const rd of runDirs) {
53
+ let r;
54
+ try {
55
+ r = readResults(rd);
56
+ }
57
+ catch (e) {
58
+ console.warn(`skill-harness: skipping unreadable run ${rd}: ${e instanceof Error ? e.message : e}`);
59
+ skipped++;
60
+ continue;
61
+ }
62
+ if (!isScoredMode(r.mode))
63
+ continue; // baseline — deliberate exclusion, not a skip
64
+ (byMode.get(r.mode) ?? byMode.set(r.mode, []).get(r.mode)).push(r);
65
+ }
66
+ for (const [mode, runs] of byMode) {
67
+ // `skipped` is per tag (an unreadable run has no knowable mode), so a tag with
68
+ // two series reports the same count on both — the alternative is attributing a
69
+ // parse failure to a mode nobody could read.
70
+ groups.push({ tag, mode, model: runs[runs.length - 1].model, runs, skipped });
71
+ }
72
+ }
73
+ return groups;
74
+ }
14
75
  /**
15
76
  * Per model-tag, read the full run history (not just the latest) from
16
77
  * <skillDir>/tests/results/, chronologically (timestamp-slug dir names sort
@@ -42,69 +103,23 @@ export function collectTrends(skillDir, limit = 20) {
42
103
  const specPath = join(skillDir, "tests", "specification.yaml");
43
104
  const spec = loadSpec(specPath);
44
105
  const scenarios = spec.scenarios.map((s) => ({ id: s.id, title: s.title, critical: s.critical }));
45
- const resultsRoot = join(skillDir, "tests", "results");
46
106
  const models = [];
47
- if (existsSync(resultsRoot)) {
48
- const tags = readdirSync(resultsRoot)
49
- .filter((n) => isDir(join(resultsRoot, n)))
50
- .sort();
51
- for (const tag of tags) {
52
- const tagDir = join(resultsRoot, tag);
53
- const runDirs = readdirSync(tagDir)
54
- .map((n) => join(tagDir, n))
55
- .filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
56
- .sort(); // timestamp-slug dir names ⇒ chronological ascending
57
- if (runDirs.length === 0)
58
- continue;
59
- // Read every candidate run (mode isn't knowable from the dir name) and
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();
65
- let skipped = 0;
66
- for (const rd of runDirs) {
67
- let r;
68
- try {
69
- r = readResults(rd);
70
- }
71
- catch (e) {
72
- // A corrupt/truncated results.yaml must not take down the whole
73
- // trends view — skip that run, but surface the failure.
74
- console.warn(`skill-harness trends: skipping unreadable run ${rd}: ${e instanceof Error ? e.message : e}`);
75
- skipped++;
76
- continue;
77
- }
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);
81
- }
82
- if (byMode.size === 0)
83
- continue;
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 });
106
- }
107
+ for (const group of collectScoredRuns(skillDir)) {
108
+ const truncated = group.runs.length > limit;
109
+ const kept = group.runs.slice(-limit); // most recent `limit`, newest last
110
+ const runs = [];
111
+ for (const r of kept) {
112
+ // effectiveVerdicts is the single source of truth for the override-aware
113
+ // verdict/suspect rule (suspect = s.suspect && s.override == null — an override
114
+ // resolves the misfire); zip in flakiness from the matching ScenarioResult.
115
+ const verdicts = effectiveVerdicts(r.scenarios);
116
+ const cells = {};
117
+ r.scenarios.forEach((s, i) => {
118
+ cells[s.id] = { verdict: verdicts[i].verdict, suspect: verdicts[i].suspect ?? false, flakiness: s.flakiness };
119
+ });
120
+ runs.push({ timestamp: r.timestamp, label: r.label, grade: r.effective_grade, cells });
107
121
  }
122
+ models.push({ model: group.model, tag: group.tag, mode: group.mode, runs, truncated, skipped: group.skipped });
108
123
  }
109
124
  return { skill: spec.skill, scenarios, models };
110
125
  }
@@ -47,3 +47,39 @@ export declare function createWorkspace(kind: WorkspaceKind, opts: {
47
47
  specDir: string;
48
48
  remote?: boolean;
49
49
  }): Workspace;
50
+ /**
51
+ * A content snapshot of every file in a workspace: relative path → sha256.
52
+ *
53
+ * Taken immediately before the model runs, and compared after. Three reasons it
54
+ * is a content walk rather than the obvious `git diff`:
55
+ *
56
+ * 1. **`git add -A` honours `.gitignore`.** The canonical assertion this feature
57
+ * exists for is `unchanged_paths: [".env"]`, and `.env` is the canonical
58
+ * gitignored file. Overwriting it produced an empty diff, which read as
59
+ * "observed, nothing changed" — a safety gate reporting green on precisely
60
+ * the file class that motivated it. It also covers `TOOL_ARTIFACTS`, which
61
+ * the harness itself writes into `.git/info/exclude`.
62
+ * 2. **The baseline commit is not the pre-run state.** `createWorkspace` applies
63
+ * a fixture's `_staged/` and `_uncommitted/` trees AFTER `gitBaseline`, so
64
+ * those files are already dirty before the model does anything. Diffing
65
+ * against the baseline blamed the model for the fixture's own contents.
66
+ * 3. **The harness writes to the workspace too** — `runSeeded` copies the
67
+ * post-test in. A snapshot taken after setup contains it, so it cancels out
68
+ * instead of being attributed to the model.
69
+ */
70
+ export type PathSnapshot = Map<string, string>;
71
+ /** Snapshot a workspace, or null when there is no workspace to look at. */
72
+ export declare function snapshotPaths(cwd: string | undefined, kind: WorkspaceKind): PathSnapshot | null;
73
+ /**
74
+ * Paths whose content changed between two snapshots — added, removed, modified.
75
+ *
76
+ * The ONLY evidence `assert.trace.unchanged_paths` can honestly rest on. A tool
77
+ * trace proves which tool was called with which arguments; it cannot prove what
78
+ * that tool then did to the filesystem, so a path policy has to be checked
79
+ * against the filesystem.
80
+ *
81
+ * Returns null when either snapshot is missing — the caller must treat that as
82
+ * MISSING EVIDENCE, never as "nothing changed". An empty array means
83
+ * observed-and-nothing-changed; null means we could not look.
84
+ */
85
+ export declare function diffSnapshots(before: PathSnapshot | null, after: PathSnapshot | null): string[] | null;
package/dist/workspace.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
3
4
  import { tmpdir } from "node:os";
4
5
  import { isAbsolute, join, resolve } from "node:path";
5
6
  const GIT_TIMEOUT_MS = 30_000;
@@ -189,4 +190,64 @@ export function createWorkspace(kind, opts) {
189
190
  }
190
191
  return { cwd, cleanup };
191
192
  }
193
+ /** Never the model's work, and never worth hashing. */
194
+ const SNAPSHOT_SKIP = new Set([".git", "node_modules", "coverage", ".vitest"]);
195
+ /** Snapshot a workspace, or null when there is no workspace to look at. */
196
+ export function snapshotPaths(cwd, kind) {
197
+ if (kind === "none" || !cwd || !existsSync(cwd))
198
+ return null;
199
+ const out = new Map();
200
+ const walk = (dir, prefix) => {
201
+ let entries;
202
+ try {
203
+ entries = readdirSync(dir, { withFileTypes: true });
204
+ }
205
+ catch {
206
+ return; // an unreadable subtree is not evidence about the model
207
+ }
208
+ for (const e of entries) {
209
+ if (SNAPSHOT_SKIP.has(e.name))
210
+ continue;
211
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
212
+ const abs = join(dir, e.name);
213
+ if (e.isDirectory()) {
214
+ walk(abs, rel);
215
+ }
216
+ else if (e.isFile()) {
217
+ try {
218
+ out.set(rel, createHash("sha256").update(readFileSync(abs)).digest("hex"));
219
+ }
220
+ catch {
221
+ out.set(rel, "<unreadable>");
222
+ }
223
+ }
224
+ }
225
+ };
226
+ walk(cwd, "");
227
+ return out;
228
+ }
229
+ /**
230
+ * Paths whose content changed between two snapshots — added, removed, modified.
231
+ *
232
+ * The ONLY evidence `assert.trace.unchanged_paths` can honestly rest on. A tool
233
+ * trace proves which tool was called with which arguments; it cannot prove what
234
+ * that tool then did to the filesystem, so a path policy has to be checked
235
+ * against the filesystem.
236
+ *
237
+ * Returns null when either snapshot is missing — the caller must treat that as
238
+ * MISSING EVIDENCE, never as "nothing changed". An empty array means
239
+ * observed-and-nothing-changed; null means we could not look.
240
+ */
241
+ export function diffSnapshots(before, after) {
242
+ if (!before || !after)
243
+ return null;
244
+ const changed = new Set();
245
+ for (const [path, hash] of after)
246
+ if (before.get(path) !== hash)
247
+ changed.add(path);
248
+ for (const path of before.keys())
249
+ if (!after.has(path))
250
+ changed.add(path);
251
+ return [...changed].sort();
252
+ }
192
253
  //# sourceMappingURL=workspace.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",