@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/adapters/types.d.ts +10 -0
- package/dist/canary.d.ts +44 -0
- package/dist/canary.js +123 -0
- package/dist/defaults.d.ts +30 -0
- package/dist/defaults.js +34 -0
- package/dist/discover.d.ts +7 -0
- package/dist/discover.js +13 -5
- package/dist/downgrade.d.ts +41 -0
- package/dist/downgrade.js +100 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/journal.d.ts +28 -0
- package/dist/judge-policy.d.ts +42 -0
- package/dist/judge-policy.js +61 -0
- package/dist/lift.d.ts +13 -0
- package/dist/lift.js +13 -8
- package/dist/lint.js +37 -8
- package/dist/regate.d.ts +57 -0
- package/dist/regate.js +199 -0
- package/dist/regrade.d.ts +34 -11
- package/dist/regrade.js +62 -21
- package/dist/report.d.ts +6 -5
- package/dist/report.js +5 -4
- package/dist/rescore.d.ts +6 -0
- package/dist/rescore.js +40 -5
- package/dist/results.d.ts +88 -0
- package/dist/results.js +56 -0
- package/dist/run.d.ts +7 -0
- package/dist/run.js +61 -8
- package/dist/seeded.d.ts +18 -0
- package/dist/seeded.js +40 -15
- package/dist/sources.d.ts +67 -6
- package/dist/sources.js +185 -26
- package/dist/trends.d.ts +23 -9
- package/dist/trends.js +44 -35
- package/dist/version.d.ts +1 -0
- package/dist/version.js +22 -0
- package/dist/workspace.js +32 -1
- package/package.json +1 -1
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
|
75
|
-
continue; //
|
|
76
|
-
|
|
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 (
|
|
82
|
+
if (byMode.size === 0)
|
|
79
83
|
continue;
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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;
|
package/dist/version.js
ADDED
|
@@ -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
|
}
|