@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/adapters/types.d.ts
CHANGED
|
@@ -32,4 +32,14 @@ export interface HarnessAdapter {
|
|
|
32
32
|
available(): Promise<boolean>;
|
|
33
33
|
run(req: RunReq): Promise<string>;
|
|
34
34
|
judge(req: JudgeReq): Promise<string>;
|
|
35
|
+
/**
|
|
36
|
+
* The harness CLI's own version, recorded in `results.yaml` as
|
|
37
|
+
* `harness_cli_version`. Null when it cannot be determined — a version this
|
|
38
|
+
* adapter had to guess at is worse than none, because the whole point of the
|
|
39
|
+
* field is to identify which CLI produced a transcript.
|
|
40
|
+
*
|
|
41
|
+
* Optional so a test double or a future adapter need not implement it; callers
|
|
42
|
+
* treat a missing method exactly like a null answer.
|
|
43
|
+
*/
|
|
44
|
+
version?(): Promise<string | null>;
|
|
35
45
|
}
|
package/dist/canary.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The probe target: the longest `## ` heading in the skill's body.
|
|
4
|
+
*
|
|
5
|
+
* Body-only by construction — the frontmatter `description` is always in context
|
|
6
|
+
* under progressive disclosure, so anything quotable from it would pass against a
|
|
7
|
+
* model that never read the instructions. Longest rather than first because the
|
|
8
|
+
* check is "did you see this text", and `## Overview` is guessable while
|
|
9
|
+
* `## Refuse a metered judge, whatever chose it` is not.
|
|
10
|
+
*
|
|
11
|
+
* Null when the body has no `## ` heading: there is then nothing to ask for that a
|
|
12
|
+
* plausible-sounding answer couldn't fake, and a canary that can be bluffed is
|
|
13
|
+
* worse than none.
|
|
14
|
+
*/
|
|
15
|
+
export declare function deliveryAnchor(skillMd: string): string | null;
|
|
16
|
+
export interface CanaryResult {
|
|
17
|
+
/** `pass` = the anchor came back; `fail` = it did not; `skipped` = nothing safe to probe for. */
|
|
18
|
+
status: "pass" | "fail" | "skipped";
|
|
19
|
+
/** What the probe looked for (null when skipped). */
|
|
20
|
+
anchor: string | null;
|
|
21
|
+
/** Why it was skipped, or what the model said instead (trimmed) — for the log and the journal. */
|
|
22
|
+
detail: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function canaryPrompt(skillName: string, anchor: string): string;
|
|
25
|
+
export interface CanaryOptions {
|
|
26
|
+
adapter: HarnessAdapter;
|
|
27
|
+
model: ModelRef;
|
|
28
|
+
skillDir: string;
|
|
29
|
+
skillName: string;
|
|
30
|
+
/** Neutral cwd, same as a scenario gets — the probe must not see a repo either. */
|
|
31
|
+
cwd: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Run the probe in green mode and report whether the skill body was reachable.
|
|
35
|
+
*
|
|
36
|
+
* Never throws for a model-side outcome: an empty or off-format reply is a `fail`
|
|
37
|
+
* with the reply in `detail`, because "the harness answered without the skill" and
|
|
38
|
+
* "the model said something odd" are both reasons not to spend a wave. An adapter
|
|
39
|
+
* that throws (pi missing, the skill-dir tripwire) is left to propagate — those are
|
|
40
|
+
* setup errors with their own messages.
|
|
41
|
+
*/
|
|
42
|
+
export declare function runDeliveryCanary(opts: CanaryOptions): Promise<CanaryResult>;
|
|
43
|
+
/** The abort message for a failed canary: what was measured, and what to do instead. */
|
|
44
|
+
export declare function canaryFailure(skillName: string, result: CanaryResult, cliVersion: string | null): string;
|
package/dist/canary.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* The delivery canary: one cheap probe, before any scenario runs, that the skill
|
|
5
|
+
* under test actually reached the model.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists. `--mode green` asks the harness to activate the skill, and the
|
|
8
|
+
* harness can decline without saying so. Measured on pi: 0.80.x wrapped the prompt
|
|
9
|
+
* with the skill body; 0.83.0 switched to progressive disclosure (only the
|
|
10
|
+
* description is in context, the body loads on demand — "models don't always do
|
|
11
|
+
* this", pi's own docs); and a nonexistent `--skill` path is accepted silently,
|
|
12
|
+
* exit 0 with a normal answer. The reference corpus ran two full waves in that
|
|
13
|
+
* state: `architect` came back 7/14, ≈ its no-skill baseline, and looked entirely
|
|
14
|
+
* plausible — the only tell was a contradictory failure mix (over-ceremony AND
|
|
15
|
+
* capitulation at once) that no single skill edit produces.
|
|
16
|
+
*
|
|
17
|
+
* What it can and cannot prove. A pass means the skill's body is *reachable* in
|
|
18
|
+
* this exact invocation — which is what kills the whole silent-non-delivery class:
|
|
19
|
+
* a dropped flag, a wrong path, a harness that stopped honoring the mode. It does
|
|
20
|
+
* NOT prove the body entered context for every later scenario; under progressive
|
|
21
|
+
* disclosure that is the model's choice per turn, and no probe can promise it. The
|
|
22
|
+
* mode whose delivery needs no promise is `force` (SKILL.md as system prompt),
|
|
23
|
+
* which is why `run` recommends it rather than pretending the canary is equivalent.
|
|
24
|
+
*
|
|
25
|
+
* Cost: exactly one subject call per run, and only when asked for (`--canary`).
|
|
26
|
+
* A run that aborts here has spent one rep instead of a wave.
|
|
27
|
+
*/
|
|
28
|
+
/** Frontmatter-stripped body of a SKILL.md. */
|
|
29
|
+
function skillBody(text) {
|
|
30
|
+
const m = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(text);
|
|
31
|
+
return m ? text.slice(m[0].length) : text;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The probe target: the longest `## ` heading in the skill's body.
|
|
35
|
+
*
|
|
36
|
+
* Body-only by construction — the frontmatter `description` is always in context
|
|
37
|
+
* under progressive disclosure, so anything quotable from it would pass against a
|
|
38
|
+
* model that never read the instructions. Longest rather than first because the
|
|
39
|
+
* check is "did you see this text", and `## Overview` is guessable while
|
|
40
|
+
* `## Refuse a metered judge, whatever chose it` is not.
|
|
41
|
+
*
|
|
42
|
+
* Null when the body has no `## ` heading: there is then nothing to ask for that a
|
|
43
|
+
* plausible-sounding answer couldn't fake, and a canary that can be bluffed is
|
|
44
|
+
* worse than none.
|
|
45
|
+
*/
|
|
46
|
+
export function deliveryAnchor(skillMd) {
|
|
47
|
+
const headings = [...skillBody(skillMd).matchAll(/^##[ \t]+(.+?)[ \t]*$/gm)].map((m) => m[1].trim());
|
|
48
|
+
if (headings.length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
return headings.reduce((a, b) => (b.length > a.length ? b : a));
|
|
51
|
+
}
|
|
52
|
+
/** Normalize for comparison: case, whitespace runs, and markdown emphasis/backticks. */
|
|
53
|
+
function normalize(s) {
|
|
54
|
+
return s.toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " ").trim();
|
|
55
|
+
}
|
|
56
|
+
export function canaryPrompt(skillName, anchor) {
|
|
57
|
+
// Asks for every heading, not just the anchor, because that is a question a model
|
|
58
|
+
// WITH the instructions answers naturally and a model without them can only
|
|
59
|
+
// invent. The instruction to say NOT_AVAILABLE keeps an honest miss from looking
|
|
60
|
+
// like a refusal to follow format.
|
|
61
|
+
return (`Answer from the instructions you have loaded — do not perform any task.\n\n` +
|
|
62
|
+
`List every level-2 markdown heading (lines starting with "## ") in the instructions of ` +
|
|
63
|
+
`the skill named "${skillName}", verbatim, one per line, with no other text.\n` +
|
|
64
|
+
`If you have no such instructions available, reply exactly: NOT_AVAILABLE\n\n` +
|
|
65
|
+
`(The heading text is what matters; keep it exact.)`);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* What the model actually said, per the adapters' shared transcript convention
|
|
69
|
+
* (`>>> USER:` / `<<< ASSISTANT:`). Falls back to the whole text for an adapter
|
|
70
|
+
* that doesn't use the markers.
|
|
71
|
+
*/
|
|
72
|
+
function assistantReply(transcript) {
|
|
73
|
+
const parts = transcript.split(/^<<< ASSISTANT:\s*$/m);
|
|
74
|
+
return (parts.length > 1 ? parts[parts.length - 1] : transcript).trim();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Run the probe in green mode and report whether the skill body was reachable.
|
|
78
|
+
*
|
|
79
|
+
* Never throws for a model-side outcome: an empty or off-format reply is a `fail`
|
|
80
|
+
* with the reply in `detail`, because "the harness answered without the skill" and
|
|
81
|
+
* "the model said something odd" are both reasons not to spend a wave. An adapter
|
|
82
|
+
* that throws (pi missing, the skill-dir tripwire) is left to propagate — those are
|
|
83
|
+
* setup errors with their own messages.
|
|
84
|
+
*/
|
|
85
|
+
export async function runDeliveryCanary(opts) {
|
|
86
|
+
const skillMd = readFileSync(join(opts.skillDir, "SKILL.md"), "utf8");
|
|
87
|
+
const anchor = deliveryAnchor(skillMd);
|
|
88
|
+
if (!anchor) {
|
|
89
|
+
return {
|
|
90
|
+
status: "skipped",
|
|
91
|
+
anchor: null,
|
|
92
|
+
detail: `${opts.skillName}/SKILL.md has no \`## \` heading to probe for — nothing a reply could prove`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const transcript = await opts.adapter.run({
|
|
96
|
+
skillDir: opts.skillDir,
|
|
97
|
+
model: opts.model,
|
|
98
|
+
mode: "green",
|
|
99
|
+
turns: [canaryPrompt(opts.skillName, anchor)],
|
|
100
|
+
cwd: opts.cwd,
|
|
101
|
+
});
|
|
102
|
+
const ok = normalize(transcript).includes(normalize(anchor));
|
|
103
|
+
return {
|
|
104
|
+
status: ok ? "pass" : "fail",
|
|
105
|
+
anchor,
|
|
106
|
+
// The reply, not the transcript: the transcript opens with our own prompt, and a
|
|
107
|
+
// failure report whose first 400 characters are the question is useless.
|
|
108
|
+
detail: ok ? "" : assistantReply(transcript).slice(0, 400),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** The abort message for a failed canary: what was measured, and what to do instead. */
|
|
112
|
+
export function canaryFailure(skillName, result, cliVersion) {
|
|
113
|
+
return (`delivery canary FAILED for ${skillName}: the model could not quote its own skill instructions ` +
|
|
114
|
+
`(looked for the heading \`${result.anchor}\`).\n` +
|
|
115
|
+
` The skill is not reaching the model, so every scenario in this run would measure a naked ` +
|
|
116
|
+
`model and score like a result. Nothing has been spent beyond this one probe.\n` +
|
|
117
|
+
` harness CLI: ${cliVersion ?? "unknown"}. On pi ≥ 0.83.0 \`--skill\` is progressive disclosure ` +
|
|
118
|
+
`(description in context, body on demand) and a nonexistent path is accepted silently.\n` +
|
|
119
|
+
` Fix: re-run with \`--mode force\` (SKILL.md as the system prompt — delivery no version has made ` +
|
|
120
|
+
`conditional), or check that the skill dir is the one you meant.\n` +
|
|
121
|
+
` What the model said instead: ${result.detail || "(nothing)"}`);
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=canary.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The judge used when nothing else says otherwise: Opus through the **`claude-code`
|
|
3
|
+
* provider**, which authenticates with the user's Claude subscription (OAuth via
|
|
4
|
+
* `claude -p`) rather than a metered API key.
|
|
5
|
+
*
|
|
6
|
+
* The model is deliberately the strongest available — judging is the one place
|
|
7
|
+
* where a weak model silently corrupts every number in a scorecard. The *provider*
|
|
8
|
+
* is what changed in 0.3.3: the default was `anthropic:claude-opus-4-8`, a metered
|
|
9
|
+
* API, and it billed a corpus once by accident because nothing in the tool surface
|
|
10
|
+
* distinguishes "the flag I forgot" from "the flag I meant". A default must not be
|
|
11
|
+
* able to spend money that was not asked for. The metered path is still one flag
|
|
12
|
+
* away (`--judge anthropic:claude-opus-4-8`) for anyone who wants it — an API key
|
|
13
|
+
* scales past a subscription's rate limits, which matters for a large `--reps` run.
|
|
14
|
+
*/
|
|
15
|
+
export declare const BAKED_DEFAULT_JUDGE = "claude-code:claude-opus-4-8";
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the default judge: `SKILL_HARNESS_JUDGE` if set, else the baked value.
|
|
18
|
+
* An explicit `--judge` always wins over both — this is only the default.
|
|
19
|
+
*
|
|
20
|
+
* The env layer exists because this harness is built for someone who steers a
|
|
21
|
+
* process rather than typing every flag: judge policy belongs to the repo or the
|
|
22
|
+
* shell, set once. It is also how you opt *into* the metered API deliberately
|
|
23
|
+
* (`SKILL_HARNESS_JUDGE=anthropic:claude-opus-4-8`) instead of by forgetting a
|
|
24
|
+
* flag. Read through `readEnv`, so the pre-rename `SKILL_CHECK_JUDGE` keeps working
|
|
25
|
+
* with the usual one-time notice.
|
|
26
|
+
*
|
|
27
|
+
* Resolved per call, not at module load: tests and long-lived processes (the pi
|
|
28
|
+
* extension) must see an env change without a reload.
|
|
29
|
+
*/
|
|
30
|
+
export declare function defaultJudge(): string;
|
package/dist/defaults.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readEnv } from "./util/env.js";
|
|
2
|
+
/**
|
|
3
|
+
* The judge used when nothing else says otherwise: Opus through the **`claude-code`
|
|
4
|
+
* provider**, which authenticates with the user's Claude subscription (OAuth via
|
|
5
|
+
* `claude -p`) rather than a metered API key.
|
|
6
|
+
*
|
|
7
|
+
* The model is deliberately the strongest available — judging is the one place
|
|
8
|
+
* where a weak model silently corrupts every number in a scorecard. The *provider*
|
|
9
|
+
* is what changed in 0.3.3: the default was `anthropic:claude-opus-4-8`, a metered
|
|
10
|
+
* API, and it billed a corpus once by accident because nothing in the tool surface
|
|
11
|
+
* distinguishes "the flag I forgot" from "the flag I meant". A default must not be
|
|
12
|
+
* able to spend money that was not asked for. The metered path is still one flag
|
|
13
|
+
* away (`--judge anthropic:claude-opus-4-8`) for anyone who wants it — an API key
|
|
14
|
+
* scales past a subscription's rate limits, which matters for a large `--reps` run.
|
|
15
|
+
*/
|
|
16
|
+
export const BAKED_DEFAULT_JUDGE = "claude-code:claude-opus-4-8";
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the default judge: `SKILL_HARNESS_JUDGE` if set, else the baked value.
|
|
19
|
+
* An explicit `--judge` always wins over both — this is only the default.
|
|
20
|
+
*
|
|
21
|
+
* The env layer exists because this harness is built for someone who steers a
|
|
22
|
+
* process rather than typing every flag: judge policy belongs to the repo or the
|
|
23
|
+
* shell, set once. It is also how you opt *into* the metered API deliberately
|
|
24
|
+
* (`SKILL_HARNESS_JUDGE=anthropic:claude-opus-4-8`) instead of by forgetting a
|
|
25
|
+
* flag. Read through `readEnv`, so the pre-rename `SKILL_CHECK_JUDGE` keeps working
|
|
26
|
+
* with the usual one-time notice.
|
|
27
|
+
*
|
|
28
|
+
* Resolved per call, not at module load: tests and long-lived processes (the pi
|
|
29
|
+
* extension) must see an env change without a reload.
|
|
30
|
+
*/
|
|
31
|
+
export function defaultJudge() {
|
|
32
|
+
return readEnv("JUDGE") ?? BAKED_DEFAULT_JUDGE;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=defaults.js.map
|
package/dist/discover.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ export interface DiscoveredSkill {
|
|
|
8
8
|
* Scan a skills root. A "skill" is any immediate subdirectory containing a
|
|
9
9
|
* SKILL.md. It is testable iff `<skill>/tests/specification.yaml` exists.
|
|
10
10
|
* Returns skills sorted by name (testable or not).
|
|
11
|
+
*
|
|
12
|
+
* `dir` and `specPath` are ABSOLUTE, whatever `root` was. They are handed to child
|
|
13
|
+
* processes that run in a neutral cwd of the harness's choosing (`pi --skill
|
|
14
|
+
* <dir>`), so a relative `--skills .` used to produce a path that resolved to
|
|
15
|
+
* nothing over there — and pi accepts a nonexistent `--skill` path silently, exit 0
|
|
16
|
+
* and a normal answer. The adapter refuses such a path too (see requireSkillDir),
|
|
17
|
+
* but the honest fix is here, where the path is built.
|
|
11
18
|
*/
|
|
12
19
|
export declare function discover(root: string): DiscoveredSkill[];
|
|
13
20
|
/**
|
package/dist/discover.js
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
3
|
/**
|
|
4
4
|
* Scan a skills root. A "skill" is any immediate subdirectory containing a
|
|
5
5
|
* SKILL.md. It is testable iff `<skill>/tests/specification.yaml` exists.
|
|
6
6
|
* Returns skills sorted by name (testable or not).
|
|
7
|
+
*
|
|
8
|
+
* `dir` and `specPath` are ABSOLUTE, whatever `root` was. They are handed to child
|
|
9
|
+
* processes that run in a neutral cwd of the harness's choosing (`pi --skill
|
|
10
|
+
* <dir>`), so a relative `--skills .` used to produce a path that resolved to
|
|
11
|
+
* nothing over there — and pi accepts a nonexistent `--skill` path silently, exit 0
|
|
12
|
+
* and a normal answer. The adapter refuses such a path too (see requireSkillDir),
|
|
13
|
+
* but the honest fix is here, where the path is built.
|
|
7
14
|
*/
|
|
8
15
|
export function discover(root) {
|
|
9
|
-
|
|
16
|
+
const absRoot = resolve(root);
|
|
17
|
+
if (!existsSync(absRoot) || !statSync(absRoot).isDirectory()) {
|
|
10
18
|
throw new Error(`skills root is not a directory: ${root}`);
|
|
11
19
|
}
|
|
12
20
|
const skills = [];
|
|
13
|
-
for (const name of readdirSync(
|
|
21
|
+
for (const name of readdirSync(absRoot)) {
|
|
14
22
|
if (name.startsWith("."))
|
|
15
23
|
continue;
|
|
16
|
-
const dir = join(
|
|
24
|
+
const dir = join(absRoot, name);
|
|
17
25
|
if (!statSync(dir).isDirectory())
|
|
18
26
|
continue;
|
|
19
27
|
if (!existsSync(join(dir, "SKILL.md")))
|
|
@@ -32,7 +40,7 @@ export function discover(root) {
|
|
|
32
40
|
export function resolveSkill(root, name) {
|
|
33
41
|
const skill = discover(root).find((s) => s.name === name);
|
|
34
42
|
if (!skill) {
|
|
35
|
-
const dir = join(root, name);
|
|
43
|
+
const dir = join(resolve(root), name);
|
|
36
44
|
if (existsSync(dir) && statSync(dir).isDirectory() && !existsSync(join(dir, "SKILL.md"))) {
|
|
37
45
|
throw new Error(`skill \`${name}\` has no SKILL.md (looked in ${dir})`);
|
|
38
46
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare two semver-ish version strings by numeric component.
|
|
3
|
+
*
|
|
4
|
+
* String comparison is the trap this exists to avoid: `"0.10.0" < "0.9.0"` is true
|
|
5
|
+
* lexically and false in fact, which would make the tripwire fire backwards exactly
|
|
6
|
+
* once — on the release where it mattered most. A prerelease suffix is dropped rather
|
|
7
|
+
* than ordered: `0.4.0-rc.1` vs `0.4.0` is not a distinction worth a refusal, and
|
|
8
|
+
* getting it wrong in either direction is worse than treating them as equal.
|
|
9
|
+
*/
|
|
10
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
11
|
+
/**
|
|
12
|
+
* The newest `harness_version` recorded anywhere under `<skillDir>/tests/results/`, or
|
|
13
|
+
* null when no run records one.
|
|
14
|
+
*
|
|
15
|
+
* Null is the honest answer for a pre-0.3.3 tree and must stay silent: those runs
|
|
16
|
+
* carry no version, so there is nothing to compare and no basis for a warning. The
|
|
17
|
+
* tripwire therefore only sharpens as fresh runs land — the same forward-looking
|
|
18
|
+
* bargain `source_hashes` made.
|
|
19
|
+
*/
|
|
20
|
+
export declare function newestRecordedVersion(skillDir: string): string | null;
|
|
21
|
+
/**
|
|
22
|
+
* Refuse to write a fresh measurement with an older tool than the one that produced
|
|
23
|
+
* the records already in the tree.
|
|
24
|
+
*
|
|
25
|
+
* The failure this kills, measured on the reference corpus: a stale global **0.1.0**
|
|
26
|
+
* install would have spent ~102 rep-executions grading *without showing the judge the
|
|
27
|
+
* staged diff* — the exact defect the run was meant to correct — and every resulting
|
|
28
|
+
* number would have looked entirely plausible. It also emitted 38 spurious findings
|
|
29
|
+
* that the current version does not. Nothing announced any of it.
|
|
30
|
+
*
|
|
31
|
+
* `schema` cannot serve here: 0.2.1 → 0.3.0 kept `schema: 2` while changing what a
|
|
32
|
+
* verdict *means*. Only the writing version distinguishes those measurements.
|
|
33
|
+
*
|
|
34
|
+
* Refusal is for `run` alone, because only `run` mints a new measurement that would sit
|
|
35
|
+
* beside newer ones as if comparable. `grade` and `lint` warn (see `downgradeWarning`):
|
|
36
|
+
* both are how someone diagnoses this in the first place, and blocking diagnosis is a
|
|
37
|
+
* bad trade.
|
|
38
|
+
*/
|
|
39
|
+
export declare function assertNotDowngraded(skillDir: string, command: "run" | "grade" | "lint"): void;
|
|
40
|
+
/** The loud-but-not-fatal version, for `grade` and `lint`. Null when nothing is newer. */
|
|
41
|
+
export declare function downgradeWarning(skillDir: string): string | null;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readResults } from "./results.js";
|
|
4
|
+
import { HARNESS_VERSION } from "./version.js";
|
|
5
|
+
/**
|
|
6
|
+
* Compare two semver-ish version strings by numeric component.
|
|
7
|
+
*
|
|
8
|
+
* String comparison is the trap this exists to avoid: `"0.10.0" < "0.9.0"` is true
|
|
9
|
+
* lexically and false in fact, which would make the tripwire fire backwards exactly
|
|
10
|
+
* once — on the release where it mattered most. A prerelease suffix is dropped rather
|
|
11
|
+
* than ordered: `0.4.0-rc.1` vs `0.4.0` is not a distinction worth a refusal, and
|
|
12
|
+
* getting it wrong in either direction is worse than treating them as equal.
|
|
13
|
+
*/
|
|
14
|
+
export function compareVersions(a, b) {
|
|
15
|
+
const parts = (v) => v.split("-")[0].split(".").map((n) => Number(n) || 0);
|
|
16
|
+
const [pa, pb] = [parts(a), parts(b)];
|
|
17
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
18
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
19
|
+
if (d !== 0)
|
|
20
|
+
return d > 0 ? 1 : -1;
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
function isDir(p) {
|
|
25
|
+
try {
|
|
26
|
+
return statSync(p).isDirectory();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The newest `harness_version` recorded anywhere under `<skillDir>/tests/results/`, or
|
|
34
|
+
* null when no run records one.
|
|
35
|
+
*
|
|
36
|
+
* Null is the honest answer for a pre-0.3.3 tree and must stay silent: those runs
|
|
37
|
+
* carry no version, so there is nothing to compare and no basis for a warning. The
|
|
38
|
+
* tripwire therefore only sharpens as fresh runs land — the same forward-looking
|
|
39
|
+
* bargain `source_hashes` made.
|
|
40
|
+
*/
|
|
41
|
+
export function newestRecordedVersion(skillDir) {
|
|
42
|
+
const root = join(skillDir, "tests", "results");
|
|
43
|
+
if (!existsSync(root))
|
|
44
|
+
return null;
|
|
45
|
+
let newest = null;
|
|
46
|
+
for (const tag of readdirSync(root).filter((n) => isDir(join(root, n)))) {
|
|
47
|
+
const tagDir = join(root, tag);
|
|
48
|
+
for (const run of readdirSync(tagDir).filter((n) => isDir(join(tagDir, n)))) {
|
|
49
|
+
try {
|
|
50
|
+
const v = readResults(join(tagDir, run)).harness_version;
|
|
51
|
+
if (v && (newest === null || compareVersions(v, newest) > 0))
|
|
52
|
+
newest = v;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Unreadable results are the consistency check's problem, not this one's.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return newest;
|
|
60
|
+
}
|
|
61
|
+
function upgradeAdvice(recorded) {
|
|
62
|
+
return (`You are running skill-harness ${HARNESS_VERSION}; this tree holds results recorded by ${recorded}.\n` +
|
|
63
|
+
` A global install goes stale silently — check with \`skill-harness --version\`, and prefer\n` +
|
|
64
|
+
` \`npx skill-harness@${recorded}\` (or \`npm i -g skill-harness@latest\`) so the tool matches the records.`);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Refuse to write a fresh measurement with an older tool than the one that produced
|
|
68
|
+
* the records already in the tree.
|
|
69
|
+
*
|
|
70
|
+
* The failure this kills, measured on the reference corpus: a stale global **0.1.0**
|
|
71
|
+
* install would have spent ~102 rep-executions grading *without showing the judge the
|
|
72
|
+
* staged diff* — the exact defect the run was meant to correct — and every resulting
|
|
73
|
+
* number would have looked entirely plausible. It also emitted 38 spurious findings
|
|
74
|
+
* that the current version does not. Nothing announced any of it.
|
|
75
|
+
*
|
|
76
|
+
* `schema` cannot serve here: 0.2.1 → 0.3.0 kept `schema: 2` while changing what a
|
|
77
|
+
* verdict *means*. Only the writing version distinguishes those measurements.
|
|
78
|
+
*
|
|
79
|
+
* Refusal is for `run` alone, because only `run` mints a new measurement that would sit
|
|
80
|
+
* beside newer ones as if comparable. `grade` and `lint` warn (see `downgradeWarning`):
|
|
81
|
+
* both are how someone diagnoses this in the first place, and blocking diagnosis is a
|
|
82
|
+
* bad trade.
|
|
83
|
+
*/
|
|
84
|
+
export function assertNotDowngraded(skillDir, command) {
|
|
85
|
+
if (command !== "run")
|
|
86
|
+
return;
|
|
87
|
+
const recorded = newestRecordedVersion(skillDir);
|
|
88
|
+
if (!recorded || compareVersions(recorded, HARNESS_VERSION) <= 0)
|
|
89
|
+
return;
|
|
90
|
+
throw new Error(`refusing to run: these results were recorded by a NEWER skill-harness, so a run from this one would not be comparable.\n ` +
|
|
91
|
+
upgradeAdvice(recorded));
|
|
92
|
+
}
|
|
93
|
+
/** The loud-but-not-fatal version, for `grade` and `lint`. Null when nothing is newer. */
|
|
94
|
+
export function downgradeWarning(skillDir) {
|
|
95
|
+
const recorded = newestRecordedVersion(skillDir);
|
|
96
|
+
if (!recorded || compareVersions(recorded, HARNESS_VERSION) <= 0)
|
|
97
|
+
return null;
|
|
98
|
+
return `warning: this skill-harness is older than the tool that recorded these results.\n ${upgradeAdvice(recorded)}`;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=downgrade.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -20,3 +20,9 @@ export * from "./adapters/types.js";
|
|
|
20
20
|
export * from "./util/exec.js";
|
|
21
21
|
export * from "./util/env.js";
|
|
22
22
|
export * from "./scaffold.js";
|
|
23
|
+
export * from "./version.js";
|
|
24
|
+
export * from "./defaults.js";
|
|
25
|
+
export * from "./judge-policy.js";
|
|
26
|
+
export * from "./regate.js";
|
|
27
|
+
export * from "./downgrade.js";
|
|
28
|
+
export * from "./canary.js";
|
package/dist/index.js
CHANGED
|
@@ -20,4 +20,10 @@ export * from "./adapters/types.js";
|
|
|
20
20
|
export * from "./util/exec.js";
|
|
21
21
|
export * from "./util/env.js";
|
|
22
22
|
export * from "./scaffold.js";
|
|
23
|
+
export * from "./version.js";
|
|
24
|
+
export * from "./defaults.js";
|
|
25
|
+
export * from "./judge-policy.js";
|
|
26
|
+
export * from "./regate.js";
|
|
27
|
+
export * from "./downgrade.js";
|
|
28
|
+
export * from "./canary.js";
|
|
23
29
|
//# sourceMappingURL=index.js.map
|
package/dist/journal.d.ts
CHANGED
|
@@ -22,12 +22,26 @@ export type JournalEvent = {
|
|
|
22
22
|
skill: string;
|
|
23
23
|
harness: string;
|
|
24
24
|
model: string;
|
|
25
|
+
/** The harness CLI's own version (`pi --version`), or null when it could not be asked. */
|
|
26
|
+
harness_cli_version?: string | null;
|
|
25
27
|
judge: {
|
|
26
28
|
provider: string;
|
|
27
29
|
model: string;
|
|
28
30
|
};
|
|
29
31
|
mode: string;
|
|
30
32
|
label: string | null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The pre-flight delivery probe (green mode, `--canary`): did the model quote a
|
|
36
|
+
* body-only heading of its own skill back? `fail` aborts the run, so a journal
|
|
37
|
+
* carrying a failed canary is the record of a wave that was NOT spent.
|
|
38
|
+
*/
|
|
39
|
+
| {
|
|
40
|
+
event: "delivery-canary";
|
|
41
|
+
ts: string;
|
|
42
|
+
status: "pass" | "fail" | "skipped";
|
|
43
|
+
anchor: string | null;
|
|
44
|
+
detail: string;
|
|
31
45
|
} | {
|
|
32
46
|
event: "scenario-started";
|
|
33
47
|
ts: string;
|
|
@@ -68,6 +82,20 @@ export type JournalEvent = {
|
|
|
68
82
|
total: number;
|
|
69
83
|
pct: number;
|
|
70
84
|
ship: boolean;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A regate: needle gates re-evaluated against the saved staged diffs. `judge_calls`
|
|
88
|
+
* is on the record because regate is advertised as free apart from the reps whose
|
|
89
|
+
* gate verdict flipped — a claim the journal should be able to settle. `skipped`
|
|
90
|
+
* names scenarios it could not regate (vitest/post_test, or missing diff artifacts).
|
|
91
|
+
*/
|
|
92
|
+
| {
|
|
93
|
+
event: "regate";
|
|
94
|
+
ts: string;
|
|
95
|
+
scenarios: string[];
|
|
96
|
+
changed: string[];
|
|
97
|
+
judge_calls: number;
|
|
98
|
+
skipped?: string[];
|
|
71
99
|
} | {
|
|
72
100
|
event: "score";
|
|
73
101
|
ts: string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ModelRef } from "./adapters/types.js";
|
|
2
|
+
/** Whether judging with this ref can charge a per-token API. */
|
|
3
|
+
export declare function isMeteredJudge(judge: ModelRef): boolean;
|
|
4
|
+
/** Whether the user has explicitly accepted metered judging for this repo/shell. */
|
|
5
|
+
export declare function allowMeteredJudge(): boolean;
|
|
6
|
+
export interface JudgeAllowOpts {
|
|
7
|
+
/**
|
|
8
|
+
* Where the judge came from, in the user's own vocabulary — `--judge`,
|
|
9
|
+
* `SKILL_HARNESS_JUDGE`, `the run's recorded judge`. A refusal has to say which
|
|
10
|
+
* knob to turn, and for a regrade the answer is not the one the user expects:
|
|
11
|
+
* nobody typed anything, the run's own `results.yaml` supplied it.
|
|
12
|
+
*/
|
|
13
|
+
source: string;
|
|
14
|
+
/** Per-invocation opt-in (`--allow-metered-judge`); the env var is read here. */
|
|
15
|
+
allowMetered?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Refuse to judge through a metered API unless the user explicitly asked for it.
|
|
19
|
+
*
|
|
20
|
+
* Why a hard refusal and not a warning: judging is the only cost in this tool that
|
|
21
|
+
* happens *without* a decision. The subject model is chosen per run and is the point
|
|
22
|
+
* of the exercise; the judge is a default, and a default that bills is a bug — it
|
|
23
|
+
* already billed a corpus once. A warning scrolls past inside a run's progress
|
|
24
|
+
* output, and by then the money is spent.
|
|
25
|
+
*
|
|
26
|
+
* Three paths could reach a metered API, and only one of them involves typing a
|
|
27
|
+
* flag: `--judge anthropic:…`; a `SKILL_HARNESS_JUDGE` set (or mistyped) to a
|
|
28
|
+
* metered provider; and `grade`, which re-judges with the judge the run *recorded* —
|
|
29
|
+
* so a run whose `results.yaml` names a metered judge bills on every later regrade,
|
|
30
|
+
* with no flag involved at all.
|
|
31
|
+
*
|
|
32
|
+
* That third path is latent rather than live in the corpus this was built against:
|
|
33
|
+
* checked 2026-08-05, all ~140 committed `results.yaml` in `principal-pi-skills`
|
|
34
|
+
* record `provider: claude-code`, because its owner always passed the subscription
|
|
35
|
+
* judge explicitly. The old default was reachable, not taken. Worth stating
|
|
36
|
+
* precisely — "your whole archive bills on regrade" would have been a scarier claim
|
|
37
|
+
* than the evidence supports.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately not applied to the subject model: paying to run the model under test
|
|
40
|
+
* is what a run *is*.
|
|
41
|
+
*/
|
|
42
|
+
export declare function assertJudgeAllowed(judge: ModelRef, opts: JudgeAllowOpts): void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { envFlag } from "./util/env.js";
|
|
2
|
+
import { BAKED_DEFAULT_JUDGE } from "./defaults.js";
|
|
3
|
+
/**
|
|
4
|
+
* Judge providers that cannot bill a per-token API.
|
|
5
|
+
*
|
|
6
|
+
* `claude-code` shells out to the `claude` CLI, authenticated with the user's
|
|
7
|
+
* Claude subscription (OAuth). `ollama`/`lmstudio`/`llamacpp`/`local` are local
|
|
8
|
+
* runtimes. Everything else is assumed to charge.
|
|
9
|
+
*
|
|
10
|
+
* An **allow-list**, deliberately, not a deny-list of known-paid providers: a
|
|
11
|
+
* provider nobody has classified yet should be treated as able to bill, because
|
|
12
|
+
* being wrong in that direction produces a surprise invoice while being wrong in
|
|
13
|
+
* this direction produces one extra flag.
|
|
14
|
+
*/
|
|
15
|
+
const FREE_JUDGE_PROVIDERS = new Set(["claude-code", "ollama", "lmstudio", "llamacpp", "local"]);
|
|
16
|
+
/** Whether judging with this ref can charge a per-token API. */
|
|
17
|
+
export function isMeteredJudge(judge) {
|
|
18
|
+
return !FREE_JUDGE_PROVIDERS.has(judge.provider);
|
|
19
|
+
}
|
|
20
|
+
/** Whether the user has explicitly accepted metered judging for this repo/shell. */
|
|
21
|
+
export function allowMeteredJudge() {
|
|
22
|
+
return envFlag("ALLOW_METERED_JUDGE");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Refuse to judge through a metered API unless the user explicitly asked for it.
|
|
26
|
+
*
|
|
27
|
+
* Why a hard refusal and not a warning: judging is the only cost in this tool that
|
|
28
|
+
* happens *without* a decision. The subject model is chosen per run and is the point
|
|
29
|
+
* of the exercise; the judge is a default, and a default that bills is a bug — it
|
|
30
|
+
* already billed a corpus once. A warning scrolls past inside a run's progress
|
|
31
|
+
* output, and by then the money is spent.
|
|
32
|
+
*
|
|
33
|
+
* Three paths could reach a metered API, and only one of them involves typing a
|
|
34
|
+
* flag: `--judge anthropic:…`; a `SKILL_HARNESS_JUDGE` set (or mistyped) to a
|
|
35
|
+
* metered provider; and `grade`, which re-judges with the judge the run *recorded* —
|
|
36
|
+
* so a run whose `results.yaml` names a metered judge bills on every later regrade,
|
|
37
|
+
* with no flag involved at all.
|
|
38
|
+
*
|
|
39
|
+
* That third path is latent rather than live in the corpus this was built against:
|
|
40
|
+
* checked 2026-08-05, all ~140 committed `results.yaml` in `principal-pi-skills`
|
|
41
|
+
* record `provider: claude-code`, because its owner always passed the subscription
|
|
42
|
+
* judge explicitly. The old default was reachable, not taken. Worth stating
|
|
43
|
+
* precisely — "your whole archive bills on regrade" would have been a scarier claim
|
|
44
|
+
* than the evidence supports.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately not applied to the subject model: paying to run the model under test
|
|
47
|
+
* is what a run *is*.
|
|
48
|
+
*/
|
|
49
|
+
export function assertJudgeAllowed(judge, opts) {
|
|
50
|
+
if (!isMeteredJudge(judge))
|
|
51
|
+
return;
|
|
52
|
+
if (opts.allowMetered || allowMeteredJudge())
|
|
53
|
+
return;
|
|
54
|
+
const token = `${judge.provider}:${judge.model}`;
|
|
55
|
+
throw new Error(`refusing to judge with ${token}: \`${judge.provider}\` bills a per-token API key, and it came from ${opts.source}.\n` +
|
|
56
|
+
` Judging is meant to cost nothing you did not ask for.\n` +
|
|
57
|
+
` • judge on your Claude subscription instead: --judge ${BAKED_DEFAULT_JUDGE}\n` +
|
|
58
|
+
` • allow the metered API for this command: --allow-metered-judge\n` +
|
|
59
|
+
` • allow it for this repo or shell: export SKILL_HARNESS_ALLOW_METERED_JUDGE=1`);
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=judge-policy.js.map
|
package/dist/lift.d.ts
CHANGED
|
@@ -24,6 +24,19 @@ export interface Lift {
|
|
|
24
24
|
model: string;
|
|
25
25
|
redTimestamp: string;
|
|
26
26
|
greenTimestamp: string;
|
|
27
|
+
/**
|
|
28
|
+
* How the skill was delivered on the non-baseline side: `green` (the harness
|
|
29
|
+
* activated it) or `force` (SKILL.md as the system prompt).
|
|
30
|
+
*
|
|
31
|
+
* The `green*` field names are kept as the *skill-active side*, not as a claim
|
|
32
|
+
* about the mode — they are the wire format the committed report assets and the
|
|
33
|
+
* review UI read, and renaming them would break every published report to say
|
|
34
|
+
* something the `mode` field already says. A red baseline is mode-independent
|
|
35
|
+
* (`--no-skills` either way), so red-vs-force is as valid a comparison as
|
|
36
|
+
* red-vs-green — but which one you are looking at changes what the number means,
|
|
37
|
+
* so it is recorded rather than implied.
|
|
38
|
+
*/
|
|
39
|
+
mode: string;
|
|
27
40
|
/** Scenario ids present in both runs — the only ones a lift can speak to. */
|
|
28
41
|
compared: number;
|
|
29
42
|
gained: number;
|