@skill-harness/core 0.1.1 → 0.2.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/adapters/types.d.ts +6 -0
- package/dist/discover.d.ts +5 -1
- package/dist/discover.js +9 -1
- package/dist/grade.d.ts +12 -1
- package/dist/grade.js +48 -10
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/journal.d.ts +14 -0
- package/dist/lift.d.ts +69 -0
- package/dist/lift.js +163 -0
- package/dist/lint.d.ts +1 -1
- package/dist/lint.js +100 -3
- package/dist/regrade.d.ts +6 -0
- package/dist/regrade.js +25 -3
- package/dist/report.d.ts +14 -0
- package/dist/report.js +16 -1
- package/dist/rescore.d.ts +34 -0
- package/dist/rescore.js +66 -0
- package/dist/results.d.ts +9 -0
- package/dist/results.js +4 -1
- package/dist/run.d.ts +25 -1
- package/dist/run.js +126 -21
- package/dist/scaffold.d.ts +28 -0
- package/dist/scaffold.js +188 -0
- package/dist/score.d.ts +5 -1
- package/dist/seeded.js +2 -1
- package/dist/spec.d.ts +2 -0
- package/dist/spec.js +32 -0
- package/dist/util/env.d.ts +32 -0
- package/dist/util/env.js +67 -0
- package/dist/workspace.d.ts +5 -1
- package/dist/workspace.js +83 -7
- package/package.json +1 -1
package/dist/adapters/types.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface RunReq {
|
|
|
14
14
|
mode: RunMode;
|
|
15
15
|
turns: string[];
|
|
16
16
|
cwd: string;
|
|
17
|
+
/**
|
|
18
|
+
* Abs path to a markdown file to use AS the system prompt, instead of activating
|
|
19
|
+
* skillDir as a skill. Used to test a subagent definition (agents/<name>.md) in the
|
|
20
|
+
* single-shot shape it actually runs in; overrides `mode`'s skill flags.
|
|
21
|
+
*/
|
|
22
|
+
systemPromptFile?: string;
|
|
17
23
|
}
|
|
18
24
|
/** A judge request: single prompt, no skills, no session. */
|
|
19
25
|
export interface JudgeReq {
|
package/dist/discover.d.ts
CHANGED
|
@@ -10,5 +10,9 @@ export interface DiscoveredSkill {
|
|
|
10
10
|
* Returns skills sorted by name (testable or not).
|
|
11
11
|
*/
|
|
12
12
|
export declare function discover(root: string): DiscoveredSkill[];
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a single skill by name; throws a helpful error if absent or specless.
|
|
15
|
+
* A directory that exists but lacks a SKILL.md gets a specific error (rather than
|
|
16
|
+
* the generic "no skill") so callers don't reimplement the SKILL.md existence check.
|
|
17
|
+
*/
|
|
14
18
|
export declare function resolveSkill(root: string, name: string): DiscoveredSkill;
|
package/dist/discover.js
CHANGED
|
@@ -24,10 +24,18 @@ export function discover(root) {
|
|
|
24
24
|
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
25
25
|
return skills;
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a single skill by name; throws a helpful error if absent or specless.
|
|
29
|
+
* A directory that exists but lacks a SKILL.md gets a specific error (rather than
|
|
30
|
+
* the generic "no skill") so callers don't reimplement the SKILL.md existence check.
|
|
31
|
+
*/
|
|
28
32
|
export function resolveSkill(root, name) {
|
|
29
33
|
const skill = discover(root).find((s) => s.name === name);
|
|
30
34
|
if (!skill) {
|
|
35
|
+
const dir = join(root, name);
|
|
36
|
+
if (existsSync(dir) && statSync(dir).isDirectory() && !existsSync(join(dir, "SKILL.md"))) {
|
|
37
|
+
throw new Error(`skill \`${name}\` has no SKILL.md (looked in ${dir})`);
|
|
38
|
+
}
|
|
31
39
|
throw new Error(`no skill \`${name}\` under ${root}`);
|
|
32
40
|
}
|
|
33
41
|
return skill;
|
package/dist/grade.d.ts
CHANGED
|
@@ -13,7 +13,18 @@ export interface ParsedVerdict {
|
|
|
13
13
|
verdict: Verdict;
|
|
14
14
|
reason: string;
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Parse a judge's raw output into a verdict + reason.
|
|
18
|
+
*
|
|
19
|
+
* Judges sometimes emit MORE than one verdict block (a first pass, then a restated
|
|
20
|
+
* conclusion). Every block is read, never just the first:
|
|
21
|
+
* - all blocks agree → that verdict, with the reason from the LAST block (the
|
|
22
|
+
* judge's final word) in full
|
|
23
|
+
* - blocks disagree → JUDGE-AMBIGUOUS, which counts as a non-pass and carries both
|
|
24
|
+
* verdicts in the reason so a rejudge can be queued. Silently taking either one
|
|
25
|
+
* would be inventing a grade the judge did not give.
|
|
26
|
+
* Unparseable → ERROR.
|
|
27
|
+
*/
|
|
17
28
|
export declare function parseVerdict(out: string): ParsedVerdict;
|
|
18
29
|
/**
|
|
19
30
|
* Judge-≠-subject de-confound guard. True when the judge resembles the model
|
package/dist/grade.js
CHANGED
|
@@ -15,18 +15,39 @@ Grade each checklist item PASS or FAIL with a <=12-word justification quoting th
|
|
|
15
15
|
VERDICT: PASS (only if EVERY item passed) — or — VERDICT: FAIL
|
|
16
16
|
REASON: <15 words or fewer>`;
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// Both anchor to the start of a line and REQUIRE the colon. Without those anchors the
|
|
19
|
+
// reason pattern matched any word containing "reason" — git-ops GLM C1 stored
|
|
20
|
+
// "able given no repo present.", a fragment of "Reasonable" in the judge's prose, which
|
|
21
|
+
// then read as a FAIL-verdict-with-passing-reason misfire that never happened.
|
|
22
|
+
const VERDICT_RE = /^\s*\**\s*VERDICT\**\s*:\s*\**\s*(PASS|FAIL)/gim;
|
|
23
|
+
const REASON_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/gim;
|
|
24
|
+
/**
|
|
25
|
+
* Parse a judge's raw output into a verdict + reason.
|
|
26
|
+
*
|
|
27
|
+
* Judges sometimes emit MORE than one verdict block (a first pass, then a restated
|
|
28
|
+
* conclusion). Every block is read, never just the first:
|
|
29
|
+
* - all blocks agree → that verdict, with the reason from the LAST block (the
|
|
30
|
+
* judge's final word) in full
|
|
31
|
+
* - blocks disagree → JUDGE-AMBIGUOUS, which counts as a non-pass and carries both
|
|
32
|
+
* verdicts in the reason so a rejudge can be queued. Silently taking either one
|
|
33
|
+
* would be inventing a grade the judge did not give.
|
|
34
|
+
* Unparseable → ERROR.
|
|
35
|
+
*/
|
|
21
36
|
export function parseVerdict(out) {
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
37
|
+
const verdicts = [...out.matchAll(VERDICT_RE)].map((m) => m[1].toUpperCase());
|
|
38
|
+
if (verdicts.length === 0) {
|
|
24
39
|
return { verdict: "ERROR", reason: "judge produced no parseable verdict" };
|
|
25
40
|
}
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
41
|
+
const reasons = [...out.matchAll(REASON_RE)].map((m) => m[1].trim());
|
|
42
|
+
const reason = reasons.length > 0 ? reasons[reasons.length - 1] : "";
|
|
43
|
+
const unique = [...new Set(verdicts)];
|
|
44
|
+
if (unique.length > 1) {
|
|
45
|
+
return {
|
|
46
|
+
verdict: "JUDGE-AMBIGUOUS",
|
|
47
|
+
reason: `judge emitted conflicting verdicts (${verdicts.join(", ")}) — needs rejudge; last reason: ${reason}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { verdict: unique[0], reason };
|
|
30
51
|
}
|
|
31
52
|
/**
|
|
32
53
|
* Judge-≠-subject de-confound guard. True when the judge resembles the model
|
|
@@ -51,13 +72,30 @@ const ITEM_RE = /^\s*\d+[.)]\s*\**\s*(PASS|FAIL)\b/gim;
|
|
|
51
72
|
export function detectMisfire(raw, verdict) {
|
|
52
73
|
if (verdict === "ERROR")
|
|
53
74
|
return false;
|
|
75
|
+
// Conflicting verdicts are suspect by construction — there is no consistent grade.
|
|
76
|
+
if (verdict === "JUDGE-AMBIGUOUS")
|
|
77
|
+
return true;
|
|
54
78
|
const items = [...raw.matchAll(ITEM_RE)].map((m) => m[1].toUpperCase() === "PASS");
|
|
55
|
-
if (items.length === 0)
|
|
79
|
+
if (items.length === 0) {
|
|
80
|
+
// No item lines to cross-check, so fall back to the verdict-vs-reason shape: a FAIL
|
|
81
|
+
// whose reason says everything passed is the misfire class from REVIEW-FINDINGS
|
|
82
|
+
// finding 2. Deliberately narrow — an earlier version of this tripwire fired on
|
|
83
|
+
// terse genuine FAILs, so it requires an explicitly total claim ("all items ...
|
|
84
|
+
// pass", "every item ... satisfied") and no negation anywhere in the reason.
|
|
85
|
+
if (verdict === "FAIL") {
|
|
86
|
+
const reason = (raw.match(REASON_LINE_RE)?.[1] ?? "").trim();
|
|
87
|
+
const totalPass = /\b(all|every)\b[^.]*\b(pass(es|ed)?|satisf(y|ies|ied)|hold(s)?|met)\b/i.test(reason);
|
|
88
|
+
const negated = /\b(not|no|n't|fails?|failed|missing|except|but|however)\b/i.test(reason);
|
|
89
|
+
return totalPass && !negated;
|
|
90
|
+
}
|
|
56
91
|
return false; // fail-open
|
|
92
|
+
}
|
|
57
93
|
const andItems = items.every((ok) => ok);
|
|
58
94
|
const verdictBool = verdict === "PASS";
|
|
59
95
|
return verdictBool !== andItems;
|
|
60
96
|
}
|
|
97
|
+
// Non-global twin of REASON_RE: matchAll needs /g, a single .match() must not have it.
|
|
98
|
+
const REASON_LINE_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/im;
|
|
61
99
|
/** Drive the judge for one transcript and parse the result. */
|
|
62
100
|
export async function gradeTranscript(adapter, judge, prompt, cwd) {
|
|
63
101
|
const raw = await adapter.judge({ model: judge, prompt, cwd });
|
package/dist/index.d.ts
CHANGED
|
@@ -8,10 +8,14 @@ export * from "./journal.js";
|
|
|
8
8
|
export * from "./scheduler.js";
|
|
9
9
|
export * from "./reps.js";
|
|
10
10
|
export * from "./regrade.js";
|
|
11
|
+
export * from "./rescore.js";
|
|
11
12
|
export * from "./workspace.js";
|
|
12
13
|
export * from "./seeded.js";
|
|
13
14
|
export * from "./report.js";
|
|
14
15
|
export * from "./trends.js";
|
|
15
16
|
export * from "./lint.js";
|
|
17
|
+
export * from "./lift.js";
|
|
16
18
|
export * from "./adapters/types.js";
|
|
17
19
|
export * from "./util/exec.js";
|
|
20
|
+
export * from "./util/env.js";
|
|
21
|
+
export * from "./scaffold.js";
|
package/dist/index.js
CHANGED
|
@@ -8,11 +8,15 @@ export * from "./journal.js";
|
|
|
8
8
|
export * from "./scheduler.js";
|
|
9
9
|
export * from "./reps.js";
|
|
10
10
|
export * from "./regrade.js";
|
|
11
|
+
export * from "./rescore.js";
|
|
11
12
|
export * from "./workspace.js";
|
|
12
13
|
export * from "./seeded.js";
|
|
13
14
|
export * from "./report.js";
|
|
14
15
|
export * from "./trends.js";
|
|
15
16
|
export * from "./lint.js";
|
|
17
|
+
export * from "./lift.js";
|
|
16
18
|
export * from "./adapters/types.js";
|
|
17
19
|
export * from "./util/exec.js";
|
|
20
|
+
export * from "./util/env.js";
|
|
21
|
+
export * from "./scaffold.js";
|
|
18
22
|
//# sourceMappingURL=index.js.map
|
package/dist/journal.d.ts
CHANGED
|
@@ -54,6 +54,20 @@ export type JournalEvent = {
|
|
|
54
54
|
id: string;
|
|
55
55
|
reason: string;
|
|
56
56
|
rep?: number;
|
|
57
|
+
} | {
|
|
58
|
+
event: "empty-response-retry";
|
|
59
|
+
ts: string;
|
|
60
|
+
id: string;
|
|
61
|
+
attempt: number;
|
|
62
|
+
rep?: number;
|
|
63
|
+
} | {
|
|
64
|
+
event: "rescore";
|
|
65
|
+
ts: string;
|
|
66
|
+
changed: string[];
|
|
67
|
+
passed: number;
|
|
68
|
+
total: number;
|
|
69
|
+
pct: number;
|
|
70
|
+
ship: boolean;
|
|
57
71
|
} | {
|
|
58
72
|
event: "score";
|
|
59
73
|
ts: string;
|
package/dist/lift.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type ResultsFile } from "./results.js";
|
|
2
|
+
import type { Verdict } from "./score.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a skill did to one scenario, red (skill off) vs green (skill active).
|
|
5
|
+
*
|
|
6
|
+
* `inconclusive` is load-bearing: it is what stops lift from becoming a number
|
|
7
|
+
* that only ever goes up. See `classify`.
|
|
8
|
+
*/
|
|
9
|
+
export type LiftClass = "gained" | "regressed" | "kept" | "both-fail" | "inconclusive";
|
|
10
|
+
export interface LiftCell {
|
|
11
|
+
red: Verdict;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the red side's verdict was an unresolved misfire. Carried (not just
|
|
14
|
+
* folded into `class`) because the review UI recomputes the class live as the
|
|
15
|
+
* author overrides green verdicts — it needs the red evidence, not a frozen
|
|
16
|
+
* conclusion. See assets/report.grade.js's liftClass.
|
|
17
|
+
*/
|
|
18
|
+
redSuspect: boolean;
|
|
19
|
+
green: Verdict;
|
|
20
|
+
class: LiftClass;
|
|
21
|
+
}
|
|
22
|
+
export interface Lift {
|
|
23
|
+
tag: string;
|
|
24
|
+
model: string;
|
|
25
|
+
redTimestamp: string;
|
|
26
|
+
greenTimestamp: string;
|
|
27
|
+
/** Scenario ids present in both runs — the only ones a lift can speak to. */
|
|
28
|
+
compared: number;
|
|
29
|
+
gained: number;
|
|
30
|
+
regressed: number;
|
|
31
|
+
kept: number;
|
|
32
|
+
bothFail: number;
|
|
33
|
+
inconclusive: number;
|
|
34
|
+
redPassed: number;
|
|
35
|
+
greenPassed: number;
|
|
36
|
+
delta: number;
|
|
37
|
+
/** Ids the green run covered that the red baseline did not (and vice versa). */
|
|
38
|
+
greenOnly: string[];
|
|
39
|
+
redOnly: string[];
|
|
40
|
+
/** True when either side was an `--only` run, so coverage is a subset by construction. */
|
|
41
|
+
partial: boolean;
|
|
42
|
+
cells: Record<string, LiftCell>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Compare a red (baseline, skill off) run against a green (skill active) run of
|
|
46
|
+
* the same model: the "does this skill actually do anything?" measurement.
|
|
47
|
+
*
|
|
48
|
+
* Both sides go through `effectiveVerdicts`, so an author override is what
|
|
49
|
+
* counts and an override resolves a misfire — the same rule scoring uses. Only
|
|
50
|
+
* the intersection of scenario ids is compared; a lift cannot speak to a
|
|
51
|
+
* scenario one side never ran.
|
|
52
|
+
*/
|
|
53
|
+
export declare function computeLift(red: ResultsFile, green: ResultsFile): Lift;
|
|
54
|
+
/** One line for a human: what the skill did, and what it cost. */
|
|
55
|
+
export declare function liftHeadline(lift: Lift): string;
|
|
56
|
+
/**
|
|
57
|
+
* Per model-tag under <skillDir>/tests/results/, pair the most recent red run
|
|
58
|
+
* with the most recent green run and compute the lift.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately derived on read rather than persisted into results.yaml: a lift
|
|
61
|
+
* is a fact about a *pair* of runs, so caching it inside one run's file would go
|
|
62
|
+
* stale the moment a new baseline lands — the stale-scorecard failure mode
|
|
63
|
+
* `source_hashes` exists to prevent. Deriving also means lift works
|
|
64
|
+
* retroactively on results already committed by 0.1.x/0.2.0 users.
|
|
65
|
+
*
|
|
66
|
+
* A tag with no red baseline is omitted entirely rather than reported as a zero
|
|
67
|
+
* lift: "not measured" and "measured no effect" are different claims.
|
|
68
|
+
*/
|
|
69
|
+
export declare function collectLift(skillDir: string): Lift[];
|
package/dist/lift.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readResults, effectiveVerdicts } from "./results.js";
|
|
4
|
+
/** A verdict that carries real evidence about the task, rather than about the harness or the judge. */
|
|
5
|
+
function conclusive(verdict, suspect) {
|
|
6
|
+
// ERROR is a harness failure (timeout, empty reply) — it says nothing about
|
|
7
|
+
// whether the agent could do the task. JUDGE-AMBIGUOUS and an unresolved
|
|
8
|
+
// misfire are judge failures. Treating any of them as a FAIL would make
|
|
9
|
+
// "red FAIL -> green PASS" fire on infrastructure noise, and lift would
|
|
10
|
+
// measure flakiness while looking like skill value.
|
|
11
|
+
return !suspect && verdict !== "ERROR" && verdict !== "JUDGE-AMBIGUOUS";
|
|
12
|
+
}
|
|
13
|
+
function classify(red, green) {
|
|
14
|
+
if (!conclusive(red.verdict, red.suspect) || !conclusive(green.verdict, green.suspect))
|
|
15
|
+
return "inconclusive";
|
|
16
|
+
const redPass = red.verdict === "PASS";
|
|
17
|
+
const greenPass = green.verdict === "PASS";
|
|
18
|
+
if (redPass && greenPass)
|
|
19
|
+
return "kept";
|
|
20
|
+
if (!redPass && greenPass)
|
|
21
|
+
return "gained";
|
|
22
|
+
if (redPass && !greenPass)
|
|
23
|
+
return "regressed";
|
|
24
|
+
return "both-fail";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Compare a red (baseline, skill off) run against a green (skill active) run of
|
|
28
|
+
* the same model: the "does this skill actually do anything?" measurement.
|
|
29
|
+
*
|
|
30
|
+
* Both sides go through `effectiveVerdicts`, so an author override is what
|
|
31
|
+
* counts and an override resolves a misfire — the same rule scoring uses. Only
|
|
32
|
+
* the intersection of scenario ids is compared; a lift cannot speak to a
|
|
33
|
+
* scenario one side never ran.
|
|
34
|
+
*/
|
|
35
|
+
export function computeLift(red, green) {
|
|
36
|
+
const redV = new Map(effectiveVerdicts(red.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
|
|
37
|
+
const greenV = new Map(effectiveVerdicts(green.scenarios).map((v) => [v.id, { verdict: v.verdict, suspect: v.suspect ?? false }]));
|
|
38
|
+
const cells = {};
|
|
39
|
+
const counts = { gained: 0, regressed: 0, kept: 0, "both-fail": 0, inconclusive: 0 };
|
|
40
|
+
let redPassed = 0;
|
|
41
|
+
let greenPassed = 0;
|
|
42
|
+
// Green order drives display order (it is the run the author is looking at),
|
|
43
|
+
// restricted to ids the red baseline also covered.
|
|
44
|
+
for (const [id, g] of greenV) {
|
|
45
|
+
const r = redV.get(id);
|
|
46
|
+
if (!r)
|
|
47
|
+
continue;
|
|
48
|
+
const cls = classify(r, g);
|
|
49
|
+
cells[id] = { red: r.verdict, redSuspect: r.suspect, green: g.verdict, class: cls };
|
|
50
|
+
counts[cls]++;
|
|
51
|
+
if (cls !== "inconclusive") {
|
|
52
|
+
if (r.verdict === "PASS")
|
|
53
|
+
redPassed++;
|
|
54
|
+
if (g.verdict === "PASS")
|
|
55
|
+
greenPassed++;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
tag: "",
|
|
60
|
+
model: green.model,
|
|
61
|
+
redTimestamp: red.timestamp,
|
|
62
|
+
greenTimestamp: green.timestamp,
|
|
63
|
+
compared: Object.keys(cells).length,
|
|
64
|
+
gained: counts.gained,
|
|
65
|
+
regressed: counts.regressed,
|
|
66
|
+
kept: counts.kept,
|
|
67
|
+
bothFail: counts["both-fail"],
|
|
68
|
+
inconclusive: counts.inconclusive,
|
|
69
|
+
redPassed,
|
|
70
|
+
greenPassed,
|
|
71
|
+
delta: greenPassed - redPassed,
|
|
72
|
+
greenOnly: [...greenV.keys()].filter((id) => !redV.has(id)),
|
|
73
|
+
redOnly: [...redV.keys()].filter((id) => !greenV.has(id)),
|
|
74
|
+
partial: Boolean(red.partial || green.partial),
|
|
75
|
+
cells,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** One line for a human: what the skill did, and what it cost. */
|
|
79
|
+
export function liftHeadline(lift) {
|
|
80
|
+
if (lift.compared === 0)
|
|
81
|
+
return "no shared scenarios to compare";
|
|
82
|
+
// Everything inconclusive is NOT "no effect" — it is no measurement. Saying
|
|
83
|
+
// "no measured effect" here would be the same not-measured/measured-no-effect
|
|
84
|
+
// conflation this module refuses to make when a red baseline is missing.
|
|
85
|
+
const conclusive = lift.compared - lift.inconclusive;
|
|
86
|
+
if (conclusive === 0) {
|
|
87
|
+
return `nothing conclusive to compare (${lift.inconclusive} inconclusive — fix the harness/judge, then re-run)`;
|
|
88
|
+
}
|
|
89
|
+
const segments = [];
|
|
90
|
+
if (lift.gained === 0 && lift.regressed === 0) {
|
|
91
|
+
segments.push(lift.kept > 0
|
|
92
|
+
? `no measured effect (${lift.kept} passed without the skill too)`
|
|
93
|
+
: "no measured effect");
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const sign = lift.delta > 0 ? `+${lift.delta}` : String(lift.delta);
|
|
97
|
+
segments.push(`${sign} net (${lift.gained} gained, ${lift.regressed} regressed)`);
|
|
98
|
+
}
|
|
99
|
+
if (lift.inconclusive > 0)
|
|
100
|
+
segments.push(`${lift.inconclusive} inconclusive`);
|
|
101
|
+
if (lift.partial)
|
|
102
|
+
segments.push("partial run");
|
|
103
|
+
return segments.join(" · ");
|
|
104
|
+
}
|
|
105
|
+
/** A directory that exists right now; false (never throws) if it vanished concurrently. */
|
|
106
|
+
function isDir(p) {
|
|
107
|
+
try {
|
|
108
|
+
return statSync(p).isDirectory();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Per model-tag under <skillDir>/tests/results/, pair the most recent red run
|
|
116
|
+
* with the most recent green run and compute the lift.
|
|
117
|
+
*
|
|
118
|
+
* Deliberately derived on read rather than persisted into results.yaml: a lift
|
|
119
|
+
* is a fact about a *pair* of runs, so caching it inside one run's file would go
|
|
120
|
+
* stale the moment a new baseline lands — the stale-scorecard failure mode
|
|
121
|
+
* `source_hashes` exists to prevent. Deriving also means lift works
|
|
122
|
+
* retroactively on results already committed by 0.1.x/0.2.0 users.
|
|
123
|
+
*
|
|
124
|
+
* A tag with no red baseline is omitted entirely rather than reported as a zero
|
|
125
|
+
* lift: "not measured" and "measured no effect" are different claims.
|
|
126
|
+
*/
|
|
127
|
+
export function collectLift(skillDir) {
|
|
128
|
+
const resultsRoot = join(skillDir, "tests", "results");
|
|
129
|
+
if (!existsSync(resultsRoot))
|
|
130
|
+
return [];
|
|
131
|
+
const lifts = [];
|
|
132
|
+
for (const tag of readdirSync(resultsRoot).filter((n) => isDir(join(resultsRoot, n))).sort()) {
|
|
133
|
+
const tagDir = join(resultsRoot, tag);
|
|
134
|
+
const runDirs = readdirSync(tagDir)
|
|
135
|
+
.map((n) => join(tagDir, n))
|
|
136
|
+
.filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
|
|
137
|
+
.sort(); // timestamp-slug names ⇒ chronological ascending
|
|
138
|
+
// Mode is only knowable after reading results.yaml, so every run in the tag
|
|
139
|
+
// is read; last-wins gives the most recent of each mode.
|
|
140
|
+
let red;
|
|
141
|
+
let green;
|
|
142
|
+
for (const rd of runDirs) {
|
|
143
|
+
let r;
|
|
144
|
+
try {
|
|
145
|
+
r = readResults(rd);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
// A corrupt/truncated results.yaml must not take down the whole view.
|
|
149
|
+
console.warn(`skill-harness lift: skipping unreadable run ${rd}: ${e instanceof Error ? e.message : e}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (r.mode === "red")
|
|
153
|
+
red = r;
|
|
154
|
+
else if (r.mode === "green")
|
|
155
|
+
green = r;
|
|
156
|
+
}
|
|
157
|
+
if (!red || !green)
|
|
158
|
+
continue;
|
|
159
|
+
lifts.push({ ...computeLift(red, green), tag });
|
|
160
|
+
}
|
|
161
|
+
return lifts;
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=lift.js.map
|
package/dist/lint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "consistency" | "lint-error";
|
|
1
|
+
export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "consistency" | "stale" | "lint-error";
|
|
2
2
|
export interface LintFinding {
|
|
3
3
|
readonly skill: string;
|
|
4
4
|
readonly scenario?: string;
|
package/dist/lint.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import yaml from "js-yaml";
|
|
4
5
|
import { loadSpec, SpecError } from "./spec.js";
|
|
5
6
|
import { readResults, finalizeResults, findTranscriptFiles, resultsPath } from "./results.js";
|
|
@@ -13,6 +14,14 @@ function isDir(p) {
|
|
|
13
14
|
return false;
|
|
14
15
|
}
|
|
15
16
|
}
|
|
17
|
+
function isFile(p) {
|
|
18
|
+
try {
|
|
19
|
+
return statSync(p).isFile();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
16
25
|
/**
|
|
17
26
|
* Validate one skill's spec + fixtures statically (and results-consistency when
|
|
18
27
|
* committed results exist — see the consistency block). Never throws: a bad spec
|
|
@@ -64,6 +73,16 @@ export function lintSkill(skillDir) {
|
|
|
64
73
|
}
|
|
65
74
|
}
|
|
66
75
|
}
|
|
76
|
+
// system_prompt_file must exist — an agent-file scenario silently falling back to
|
|
77
|
+
// skill activation would measure the wrong artifact entirely.
|
|
78
|
+
for (const s of spec.scenarios) {
|
|
79
|
+
if (!s.systemPromptFile)
|
|
80
|
+
continue;
|
|
81
|
+
const abs = isAbsolute(s.systemPromptFile) ? s.systemPromptFile : resolve(specDir, s.systemPromptFile);
|
|
82
|
+
if (!isFile(abs)) {
|
|
83
|
+
findings.push({ skill, scenario: s.id, code: "fixture", message: `system_prompt_file not found: ${s.systemPromptFile}` });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
67
86
|
// results-consistency — only for committed results.yaml (skipped silently otherwise).
|
|
68
87
|
// Each run dir gets ONE try: schema-1 is intentionally skipped (continue, no finding —
|
|
69
88
|
// migrateResults carries a schema-1 grade verbatim, so recomputing it would false-flag).
|
|
@@ -78,9 +97,17 @@ export function lintSkill(skillDir) {
|
|
|
78
97
|
if (raw?.schema !== 2)
|
|
79
98
|
continue; // schema-1 intentionally skipped — no finding
|
|
80
99
|
const r = readResults(runDir);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
100
|
+
// A run whose scenario set no longer matches the spec predates a spec reshape
|
|
101
|
+
// (scenarios added/removed). Its grade was computed against the OLD ship bar and
|
|
102
|
+
// cannot be meaningfully recomputed against the new one — recomputing would flag
|
|
103
|
+
// every historical run each time a spec grows. Staleness (source_hashes) is the
|
|
104
|
+
// mechanism that says "re-run"; consistency only polices runs the current spec
|
|
105
|
+
// can actually re-score. Override/transcript rules below still apply.
|
|
106
|
+
const specIds = new Set(spec.scenarios.map((sc) => sc.id));
|
|
107
|
+
const sameSet = r.scenarios.length === specIds.size && r.scenarios.every((sc) => specIds.has(sc.id));
|
|
108
|
+
const ctx = r.mode === "green" && !r.partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
|
|
109
|
+
const recomputed = !sameSet ? null : finalizeResults({ skill: r.skill, harness: r.harness, model: r.model, judge: r.judge, timestamp: r.timestamp, label: r.label, mode: r.mode, partial: r.partial, source_hashes: r.source_hashes, scenarios: r.scenarios }, ctx).effective_grade;
|
|
110
|
+
if (recomputed && JSON.stringify(recomputed) !== JSON.stringify(r.effective_grade)) {
|
|
84
111
|
findings.push({ skill, code: "consistency", message: `results.yaml effective_grade is stale in ${runDir} (recompute differs)` });
|
|
85
112
|
}
|
|
86
113
|
for (const s of r.scenarios) {
|
|
@@ -96,8 +123,78 @@ export function lintSkill(skillDir) {
|
|
|
96
123
|
findings.push({ skill, code: "consistency", message: `results.yaml unreadable or malformed in ${runDir}: ${e instanceof Error ? e.message : String(e)}` });
|
|
97
124
|
}
|
|
98
125
|
}
|
|
126
|
+
// staleness — the newest FULL (non-partial) run per model tag recorded sha256 hashes of
|
|
127
|
+
// every source file it measured. If any of those files has changed since, the committed
|
|
128
|
+
// result describes text that no longer exists — exactly how three regressions hid behind
|
|
129
|
+
// a 100%-SHIP table for four weeks. Runs predating source_hashes are skipped silently
|
|
130
|
+
// (no retroactive noise); partial runs never count as coverage.
|
|
131
|
+
for (const tagDir of enumerateTagDirs(resultsRoot)) {
|
|
132
|
+
// Newest FULL run: partial (--only) runs are iteration artifacts and never count as
|
|
133
|
+
// coverage — a fresh partial must not silence a stale full run underneath it.
|
|
134
|
+
let full = null;
|
|
135
|
+
for (const runDir of runDirsNewestFirst(tagDir)) {
|
|
136
|
+
try {
|
|
137
|
+
const r = readResults(runDir);
|
|
138
|
+
if (r.partial)
|
|
139
|
+
continue;
|
|
140
|
+
full = { runDir, r };
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
break;
|
|
145
|
+
} // unreadable → the consistency block already reports it
|
|
146
|
+
}
|
|
147
|
+
const hashes = full?.r.source_hashes;
|
|
148
|
+
if (!full || !hashes)
|
|
149
|
+
continue; // predates source_hashes → silent
|
|
150
|
+
{
|
|
151
|
+
const newest = full.runDir;
|
|
152
|
+
for (const [key, recorded] of Object.entries(hashes)) {
|
|
153
|
+
const abs = key === "SKILL.md" ? join(skillDir, "SKILL.md") : resolve(specDir, key);
|
|
154
|
+
const current = fileSha256(abs);
|
|
155
|
+
if (current === null) {
|
|
156
|
+
findings.push({ skill, code: "stale", message: `${key} no longer exists but the newest ${basename(tagDir)} run measured it (${newest})` });
|
|
157
|
+
}
|
|
158
|
+
else if (current !== recorded) {
|
|
159
|
+
findings.push({ skill, code: "stale", message: `${key} changed since the newest ${basename(tagDir)} run (${newest}) — results are stale; re-run before publishing` });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
99
164
|
return findings;
|
|
100
165
|
}
|
|
166
|
+
function fileSha256(p) {
|
|
167
|
+
try {
|
|
168
|
+
return createHash("sha256").update(readFileSync(p)).digest("hex");
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Model-tag dirs under tests/results (each holds timestamped run dirs). */
|
|
175
|
+
function enumerateTagDirs(resultsRoot) {
|
|
176
|
+
if (!existsSync(resultsRoot))
|
|
177
|
+
return [];
|
|
178
|
+
try {
|
|
179
|
+
return readdirSync(resultsRoot).map((t) => join(resultsRoot, t)).filter(isDir);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Timestamped run dirs holding a results.yaml, newest first (ISO slugs sort lexicographically). */
|
|
186
|
+
function runDirsNewestFirst(tagDir) {
|
|
187
|
+
let timestamps;
|
|
188
|
+
try {
|
|
189
|
+
timestamps = readdirSync(tagDir);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
return timestamps.sort().reverse()
|
|
195
|
+
.map((ts) => join(tagDir, ts))
|
|
196
|
+
.filter((d) => isDir(d) && existsSync(join(d, "results.yaml")));
|
|
197
|
+
}
|
|
101
198
|
/**
|
|
102
199
|
* All committed run dirs under a skill's tests/results (<tag>/<timestamp>/results.yaml).
|
|
103
200
|
* Empty if none. Never throws: unreadable/dangling entries (e.g. a broken symlink, or a
|
package/dist/regrade.d.ts
CHANGED
|
@@ -39,6 +39,12 @@ export interface RegradeRunOptions {
|
|
|
39
39
|
judge: ModelRef;
|
|
40
40
|
specDir: string;
|
|
41
41
|
now?: () => string;
|
|
42
|
+
/**
|
|
43
|
+
* Re-judge ONLY scenarios whose stored verdict is untrustworthy — suspect (misfire) or
|
|
44
|
+
* JUDGE-AMBIGUOUS. Everything else is carried verbatim: their transcripts were judged
|
|
45
|
+
* cleanly, so spending judge calls on them buys nothing.
|
|
46
|
+
*/
|
|
47
|
+
onlySuspect?: boolean;
|
|
42
48
|
}
|
|
43
49
|
/**
|
|
44
50
|
* Re-judge every green-transcript scenario in a run dir with `judge` — no
|
package/dist/regrade.js
CHANGED
|
@@ -65,7 +65,19 @@ export async function regradeRun(opts) {
|
|
|
65
65
|
// a recorded verdict or shrink the grade denominator. Fail fast, before
|
|
66
66
|
// spending any judge calls.
|
|
67
67
|
const specById = new Map(spec.scenarios.map((s) => [s.id, s]));
|
|
68
|
-
const
|
|
68
|
+
const recorded = prev?.scenarios ?? spec.scenarios.map((s) => ({ id: s.id }));
|
|
69
|
+
let targets = recorded.map((s) => s.id);
|
|
70
|
+
if (opts.onlySuspect) {
|
|
71
|
+
if (!prev)
|
|
72
|
+
throw new Error(`--suspect-only needs a prior results.yaml in ${runDir}`);
|
|
73
|
+
targets = prev.scenarios
|
|
74
|
+
.filter((s) => s.suspect || s.judge_verdict === "JUDGE-AMBIGUOUS")
|
|
75
|
+
.map((s) => s.id);
|
|
76
|
+
if (targets.length === 0) {
|
|
77
|
+
// Nothing untrustworthy — a no-op, not an error. Return the file as-is.
|
|
78
|
+
return prev;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
69
81
|
const missing = targets.filter((id) => !specById.has(id) || findTranscriptFiles(runDir, id, "green").length === 0);
|
|
70
82
|
if (missing.length === targets.length) {
|
|
71
83
|
throw new Error(`no green transcripts in ${runDir} — nothing to re-grade`);
|
|
@@ -73,8 +85,14 @@ export async function regradeRun(opts) {
|
|
|
73
85
|
if (missing.length > 0) {
|
|
74
86
|
throw new Error(`cannot re-grade ${missing.join(", ")} in ${runDir} (transcript missing or scenario no longer in the spec) — re-run instead of grading`);
|
|
75
87
|
}
|
|
88
|
+
const targetSet = new Set(targets);
|
|
76
89
|
const scenarioResults = [];
|
|
77
|
-
for (const
|
|
90
|
+
for (const rec of recorded) {
|
|
91
|
+
const id = rec.id;
|
|
92
|
+
if (!targetSet.has(id)) {
|
|
93
|
+
scenarioResults.push(rec); // clean verdict, carried verbatim (onlySuspect)
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
78
96
|
const scenario = specById.get(id); // guaranteed present by the guard above
|
|
79
97
|
const prevScenario = prev?.scenarios.find((s) => s.id === id);
|
|
80
98
|
const threshold = effectiveThreshold(prevScenario, scenario);
|
|
@@ -84,7 +102,7 @@ export async function regradeRun(opts) {
|
|
|
84
102
|
const carry = overrides.get(id);
|
|
85
103
|
scenarioResults.push({ ...rr, override: carry?.override ?? null, note: carry?.note ?? "" });
|
|
86
104
|
}
|
|
87
|
-
const ctx = mode === "green" ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
|
|
105
|
+
const ctx = mode === "green" && !prev?.partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
|
|
88
106
|
const results = writeResults(runDir, {
|
|
89
107
|
skill: spec.skill,
|
|
90
108
|
harness: prev?.harness ?? "pi",
|
|
@@ -93,6 +111,10 @@ export async function regradeRun(opts) {
|
|
|
93
111
|
timestamp: prev?.timestamp ?? now(),
|
|
94
112
|
label: prev?.label ?? null,
|
|
95
113
|
mode,
|
|
114
|
+
// A re-grade judges the SAVED transcripts, which were produced by the OLD text —
|
|
115
|
+
// the recorded hashes stay, keeping an honestly-stale run honestly stale.
|
|
116
|
+
partial: prev?.partial,
|
|
117
|
+
source_hashes: prev?.source_hashes,
|
|
96
118
|
scenarios: scenarioResults,
|
|
97
119
|
}, ctx);
|
|
98
120
|
const g = results.effective_grade;
|