@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.
@@ -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
  }
@@ -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;
@@ -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
@@ -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
- if (!existsSync(root) || !statSync(root).isDirectory()) {
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(root)) {
21
+ for (const name of readdirSync(absRoot)) {
14
22
  if (name.startsWith("."))
15
23
  continue;
16
- const dir = join(root, name);
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;