@skill-harness/core 0.4.0 → 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
@@ -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
  }
package/dist/index.d.ts CHANGED
@@ -25,3 +25,4 @@ export * from "./defaults.js";
25
25
  export * from "./judge-policy.js";
26
26
  export * from "./regate.js";
27
27
  export * from "./downgrade.js";
28
+ export * from "./canary.js";
package/dist/index.js CHANGED
@@ -25,4 +25,5 @@ export * from "./defaults.js";
25
25
  export * from "./judge-policy.js";
26
26
  export * from "./regate.js";
27
27
  export * from "./downgrade.js";
28
+ export * from "./canary.js";
28
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;
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;
package/dist/lift.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { readResults, effectiveVerdicts } from "./results.js";
3
+ import { readResults, effectiveVerdicts, isScoredMode } from "./results.js";
4
4
  import { loadSpec } from "./spec.js";
5
5
  function aggregationShape(s) {
6
6
  const reps = s.reps ?? 1;
@@ -98,6 +98,7 @@ export function computeLift(red, green, opts = {}) {
98
98
  return {
99
99
  tag: "",
100
100
  model: green.model,
101
+ mode: green.mode,
101
102
  redTimestamp: red.timestamp,
102
103
  greenTimestamp: green.timestamp,
103
104
  compared: Object.keys(cells).length,
@@ -193,7 +194,7 @@ function isDir(p) {
193
194
  }
194
195
  /**
195
196
  * Per model-tag under <skillDir>/tests/results/, pair the most recent red run
196
- * with the most recent green run and compute the lift.
197
+ * with the most recent skill-delivered run (green or force) and compute the lift.
197
198
  *
198
199
  * Deliberately derived on read rather than persisted into results.yaml: a lift
199
200
  * is a fact about a *pair* of runs, so caching it inside one run's file would go
@@ -237,9 +238,13 @@ export function collectLift(skillDir) {
237
238
  .filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
238
239
  .sort(); // timestamp-slug names ⇒ chronological ascending
239
240
  // Mode is only knowable after reading results.yaml, so every run in the tag
240
- // is read; last-wins gives the most recent of each mode.
241
+ // is read; last-wins gives the most recent baseline and the most recent
242
+ // skill-delivered run. The skill side is whichever scored mode ran most
243
+ // recently — a corpus that moved from green to force delivery should see its
244
+ // lift follow, and the baseline it is measured against is the same either way
245
+ // (`--no-skills` in both).
241
246
  let red;
242
- let green;
247
+ let skillOn;
243
248
  for (const rd of runDirs) {
244
249
  let r;
245
250
  try {
@@ -252,12 +257,12 @@ export function collectLift(skillDir) {
252
257
  }
253
258
  if (r.mode === "red")
254
259
  red = r;
255
- else if (r.mode === "green")
256
- green = r;
260
+ else if (isScoredMode(r.mode))
261
+ skillOn = r;
257
262
  }
258
- if (!red || !green)
263
+ if (!red || !skillOn)
259
264
  continue;
260
- lifts.push({ ...computeLift(red, green, { modeInsensitive }), tag });
265
+ lifts.push({ ...computeLift(red, skillOn, { modeInsensitive }), tag });
261
266
  }
262
267
  return lifts;
263
268
  }
package/dist/lint.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
3
  import yaml from "js-yaml";
4
4
  import { loadSpec, SpecError } from "./spec.js";
5
- import { readResults, finalizeResults, findTranscriptFiles, resultsPath } from "./results.js";
5
+ import { readResults, finalizeResults, findTranscriptFiles, resultsPath, scoreContextFor } from "./results.js";
6
6
  import { currentHashFor, describeSourceKey, remedyForKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, STIMULUS_PREFIX, UNREADABLE } from "./sources.js";
7
7
  import { downgradeWarning } from "./downgrade.js";
8
8
  import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
@@ -163,10 +163,17 @@ export function lintSkill(skillDir) {
163
163
  // can actually re-score. Override/transcript rules below still apply.
164
164
  const specIds = new Set(spec.scenarios.map((sc) => sc.id));
165
165
  const sameSet = r.scenarios.length === specIds.size && r.scenarios.every((sc) => specIds.has(sc.id));
166
- const ctx = r.mode === "green" && !r.partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
166
+ const ctx = scoreContextFor(r, spec);
167
167
  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;
168
168
  if (recomputed && JSON.stringify(recomputed) !== JSON.stringify(r.effective_grade)) {
169
- findings.push({ skill, code: "consistency", message: `results.yaml effective_grade is stale in ${runDir} (recompute differs)` });
169
+ // The remedy is named because this finding now has a benign, expected cause
170
+ // as well as a suspicious one: a force run recorded before 0.5.0 carries a
171
+ // "not scored" placeholder, and today's policy scores it (see SCORED_MODES).
172
+ // `rescore` is free and offline, so the fix is never a reason to re-measure.
173
+ findings.push({
174
+ skill, code: "consistency",
175
+ message: `results.yaml effective_grade is stale in ${runDir} (recompute differs) — re-apply the current scoring policy: rescore (free, offline)`,
176
+ });
170
177
  }
171
178
  for (const s of r.scenarios) {
172
179
  if (s.override != null) {
package/dist/regate.js CHANGED
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { parseVerdict, detectMisfire } from "./grade.js";
4
4
  import { evaluateNeedleGates, hasNeedleGates } from "./seeded.js";
5
5
  import { judgeOneRep } from "./regrade.js";
6
- import { readResults, writeResults, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, effectiveThreshold, } from "./results.js";
6
+ import { readResults, writeResults, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, effectiveThreshold, scoreContextFor, } from "./results.js";
7
7
  import { outcomesToResult } from "./reps.js";
8
8
  import { appendJournal } from "./journal.js";
9
9
  import { gatesDigest, GATES_PREFIX } from "./sources.js";
@@ -32,8 +32,8 @@ function rewriteTranscript(path, gateLines) {
32
32
  writeFileSync(path, `${head}${TRAILER}\n${gateLines.join("\n")}\n\n${tail}`, "utf8");
33
33
  }
34
34
  /** Recover a rep's judge verdict from its saved judge-raw artifact — free, and exact. */
35
- function verdictFromSavedJudgement(runDir, id, rep) {
36
- const path = judgeRawPath(runDir, id, "green", rep);
35
+ function verdictFromSavedJudgement(runDir, id, mode, rep) {
36
+ const path = judgeRawPath(runDir, id, mode, rep);
37
37
  if (!existsSync(path))
38
38
  return null;
39
39
  const raw = readFileSync(path, "utf8");
@@ -72,6 +72,10 @@ function verdictFromSavedJudgement(runDir, id, rep) {
72
72
  export async function regateRun(opts) {
73
73
  const now = opts.now ?? (() => new Date().toISOString());
74
74
  const prev = readResults(opts.runDir);
75
+ // The run's own mode names its artifacts (`<id>.<mode>[.rep<k>].diff.txt`). Read
76
+ // from the record rather than assumed green: force runs are scored measurements
77
+ // too, and looking for green artifacts under a force run finds nothing at all.
78
+ const mode = prev.mode;
75
79
  const specById = new Map(opts.spec.scenarios.map((s) => [s.id, s]));
76
80
  // Why a scenario cannot be regated, collected rather than thrown one at a time: a
77
81
  // mixed spec (needles here, vitest there) should regate what it can, and only a run
@@ -87,7 +91,7 @@ export async function regateRun(opts) {
87
91
  `no saved artifact can stand in for it, so this scenario needs a re-run`);
88
92
  continue;
89
93
  }
90
- if (findDiffFiles(opts.runDir, s.id, "green").length === 0) {
94
+ if (findDiffFiles(opts.runDir, s.id, mode).length === 0) {
91
95
  blocked.push(`${s.id}: no staged-diff artifact on disk (\`.diff.txt\` is gitignored — regate needs the run dir that produced it)`);
92
96
  continue;
93
97
  }
@@ -106,7 +110,7 @@ export async function regateRun(opts) {
106
110
  scenarios.push(rec); // untouched: not regatable, or no gates
107
111
  continue;
108
112
  }
109
- const diffFiles = findDiffFiles(opts.runDir, scenario.id, "green");
113
+ const diffFiles = findDiffFiles(opts.runDir, scenario.id, mode);
110
114
  const outcomes = [];
111
115
  // Per scenario, not run-wide: with several regated scenarios, a global counter
112
116
  // would report every change as "re-judged" because some other scenario was.
@@ -116,7 +120,7 @@ export async function regateRun(opts) {
116
120
  const rep = repIndexOf(file) ?? undefined;
117
121
  const diff = readFileSync(join(opts.runDir, file), "utf8");
118
122
  const gate = evaluateNeedleGates(scenario, diff);
119
- const tPath = transcriptPath(opts.runDir, scenario.id, "green", rep);
123
+ const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
120
124
  const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
121
125
  const oldGateFailed = GATE_FAILED_RE.test(before.slice(before.indexOf(TRAILER)));
122
126
  // The trailer is regenerated whatever the outcome: leaving a stale
@@ -132,7 +136,7 @@ export async function regateRun(opts) {
132
136
  if (!oldGateFailed) {
133
137
  // The judge already saw this rep. Its verdict is on disk — re-read it rather
134
138
  // than paying to ask the same question again.
135
- const saved = verdictFromSavedJudgement(opts.runDir, scenario.id, rep);
139
+ const saved = verdictFromSavedJudgement(opts.runDir, scenario.id, mode, rep);
136
140
  outcomes.push(saved ?? { verdict: rec.judge_verdict, reason: rec.judge_reason, suspect: rec.suspect });
137
141
  continue;
138
142
  }
@@ -141,7 +145,7 @@ export async function regateRun(opts) {
141
145
  outcomes.push(await judgeOneRep({
142
146
  runDir: opts.runDir, spec: opts.spec, scenario, transcript,
143
147
  adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
144
- mode: "green", rep, now,
148
+ mode, rep, now,
145
149
  }));
146
150
  judgeCalls++;
147
151
  judgedHere++;
@@ -160,11 +164,11 @@ export async function regateRun(opts) {
160
164
  });
161
165
  }
162
166
  }
163
- const ctx = prev.mode === "green" && !prev.partial
164
- ? { shipBar: opts.spec.ship_bar, critical: opts.spec.critical }
165
- : null;
167
+ const ctx = scoreContextFor(prev, opts.spec);
166
168
  const results = writeResults(opts.runDir, {
167
169
  skill: prev.skill, harness: prev.harness, model: prev.model,
170
+ // Carried verbatim: a regate re-reads saved diffs, it never re-runs the harness.
171
+ harness_cli_version: prev.harness_cli_version, delivery_canary: prev.delivery_canary,
168
172
  judge: { provider: opts.judge.provider, model: opts.judge.model },
169
173
  timestamp: prev.timestamp, label: prev.label, mode: prev.mode, partial: prev.partial,
170
174
  // Only the `gates:` keys of the scenarios actually re-evaluated. Stimulus, rubric
package/dist/regrade.d.ts CHANGED
@@ -23,6 +23,15 @@ export interface RegradeOptions {
23
23
  judge: ModelRef;
24
24
  specDir: string;
25
25
  threshold: number;
26
+ /**
27
+ * Which mode's saved transcripts to re-judge — the run's own mode, since
28
+ * transcript filenames are `<id>.<mode>[.rep<k>].txt`.
29
+ *
30
+ * Defaults to `green` for callers that predate force being a scored mode. A
31
+ * force-mode run whose transcripts were looked up as green found none and failed
32
+ * with "nothing to re-grade", which is how ten scorable runs stayed ungraded.
33
+ */
34
+ mode?: string;
26
35
  now?: () => string;
27
36
  }
28
37
  /** Judge one saved transcript: writes the judge-raw artifact, emits a `judge-verdict` journal event (plus `misfire-flag` when the verdict is suspect), and returns the outcome. */
@@ -39,10 +48,11 @@ export declare function judgeOneRep(opts: {
39
48
  now: () => string;
40
49
  }): Promise<RepOutcome>;
41
50
  /**
42
- * Re-judge a scenario's saved GREEN transcript(s) with `judge` — no harness
43
- * re-run. Rewrites the judge-raw artifact per rep, emits per-rep judge-verdict
44
- * (+ misfire-flag) journal events, and returns the aggregated ScenarioResult
45
- * (override/note empty; the caller merges any prior override + persists).
51
+ * Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
52
+ * harness re-run. Rewrites the judge-raw artifact per rep, emits per-rep
53
+ * judge-verdict (+ misfire-flag) journal events, and returns the aggregated
54
+ * ScenarioResult (override/note empty; the caller merges any prior override +
55
+ * persists).
46
56
  */
47
57
  export declare function regradeScenario(opts: RegradeOptions): Promise<ScenarioResult>;
48
58
  export interface RegradeRunOptions {
@@ -60,13 +70,13 @@ export interface RegradeRunOptions {
60
70
  onlySuspect?: boolean;
61
71
  }
62
72
  /**
63
- * Re-judge every green-transcript scenario in a run dir with `judge` no
64
- * harness re-run. Targets are the run's RECORDED scenarios (falling back to
65
- * the spec for a run with no prior results.yaml), so re-grading rewrites the
66
- * whole results.yaml consistently with what the run actually recorded. Each
67
- * target must still exist in the spec (for its checklist) AND have a green
68
- * transcript on disk; anything missing fails fast before spending any judge
69
- * calls. Preserves each prior scenario's override/note, rewrites
73
+ * Re-judge every scenario in a run dir that has a transcript for the run's own
74
+ * mode, with `judge` — no harness re-run. Targets are the run's RECORDED scenarios
75
+ * (falling back to the spec for a run with no prior results.yaml), so re-grading
76
+ * rewrites the whole results.yaml consistently with what the run actually recorded.
77
+ * Each target must still exist in the spec (for its checklist) AND have a
78
+ * transcript on disk for that mode; anything missing fails fast before spending
79
+ * any judge calls. Preserves each prior scenario's override/note, rewrites
70
80
  * results.yaml, emits the `score` journal event, and returns the new
71
81
  * ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
72
82
  */
package/dist/regrade.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { buildJudgePrompt, judgeInWorkspace } from "./grade.js";
4
- import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, } from "./results.js";
4
+ import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, } from "./results.js";
5
5
  import { outcomesToResult } from "./reps.js";
6
6
  import { appendJournal } from "./journal.js";
7
7
  import { rubricDigest, personaDigest, RUBRIC_PREFIX, PERSONA_KEY } from "./sources.js";
@@ -46,16 +46,18 @@ export async function judgeOneRep(opts) {
46
46
  return { verdict: g.verdict, reason: g.reason, suspect: g.suspect };
47
47
  }
48
48
  /**
49
- * Re-judge a scenario's saved GREEN transcript(s) with `judge` — no harness
50
- * re-run. Rewrites the judge-raw artifact per rep, emits per-rep judge-verdict
51
- * (+ misfire-flag) journal events, and returns the aggregated ScenarioResult
52
- * (override/note empty; the caller merges any prior override + persists).
49
+ * Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
50
+ * harness re-run. Rewrites the judge-raw artifact per rep, emits per-rep
51
+ * judge-verdict (+ misfire-flag) journal events, and returns the aggregated
52
+ * ScenarioResult (override/note empty; the caller merges any prior override +
53
+ * persists).
53
54
  */
54
55
  export async function regradeScenario(opts) {
55
56
  const now = opts.now ?? (() => new Date().toISOString());
56
- const files = findTranscriptFiles(opts.runDir, opts.scenario.id, "green");
57
+ const mode = opts.mode ?? "green";
58
+ const files = findTranscriptFiles(opts.runDir, opts.scenario.id, mode);
57
59
  if (files.length === 0)
58
- throw new Error(`no green transcripts for ${opts.scenario.id} in ${opts.runDir}`);
60
+ throw new Error(`no ${mode} transcripts for ${opts.scenario.id} in ${opts.runDir}`);
59
61
  const repCount = files.length;
60
62
  const outcomes = [];
61
63
  for (const file of files) {
@@ -63,19 +65,19 @@ export async function regradeScenario(opts) {
63
65
  const transcript = readFileSync(join(opts.runDir, file), "utf8");
64
66
  outcomes.push(await judgeOneRep({
65
67
  runDir: opts.runDir, spec: opts.spec, scenario: opts.scenario, transcript,
66
- adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode: "green", rep, now,
68
+ adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode, rep, now,
67
69
  }));
68
70
  }
69
71
  return outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
70
72
  }
71
73
  /**
72
- * Re-judge every green-transcript scenario in a run dir with `judge` no
73
- * harness re-run. Targets are the run's RECORDED scenarios (falling back to
74
- * the spec for a run with no prior results.yaml), so re-grading rewrites the
75
- * whole results.yaml consistently with what the run actually recorded. Each
76
- * target must still exist in the spec (for its checklist) AND have a green
77
- * transcript on disk; anything missing fails fast before spending any judge
78
- * calls. Preserves each prior scenario's override/note, rewrites
74
+ * Re-judge every scenario in a run dir that has a transcript for the run's own
75
+ * mode, with `judge` — no harness re-run. Targets are the run's RECORDED scenarios
76
+ * (falling back to the spec for a run with no prior results.yaml), so re-grading
77
+ * rewrites the whole results.yaml consistently with what the run actually recorded.
78
+ * Each target must still exist in the spec (for its checklist) AND have a
79
+ * transcript on disk for that mode; anything missing fails fast before spending
80
+ * any judge calls. Preserves each prior scenario's override/note, rewrites
79
81
  * results.yaml, emits the `score` journal event, and returns the new
80
82
  * ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
81
83
  */
@@ -107,9 +109,9 @@ export async function regradeRun(opts) {
107
109
  return prev;
108
110
  }
109
111
  }
110
- const missing = targets.filter((id) => !specById.has(id) || findTranscriptFiles(runDir, id, "green").length === 0);
112
+ const missing = targets.filter((id) => !specById.has(id) || findTranscriptFiles(runDir, id, mode).length === 0);
111
113
  if (missing.length === targets.length) {
112
- throw new Error(`no green transcripts in ${runDir} — nothing to re-grade`);
114
+ throw new Error(`no ${mode} transcripts in ${runDir} — nothing to re-grade`);
113
115
  }
114
116
  if (missing.length > 0) {
115
117
  throw new Error(`cannot re-grade ${missing.join(", ")} in ${runDir} (transcript missing or scenario no longer in the spec) — re-run instead of grading`);
@@ -126,15 +128,20 @@ export async function regradeRun(opts) {
126
128
  const prevScenario = prev?.scenarios.find((s) => s.id === id);
127
129
  const threshold = effectiveThreshold(prevScenario, scenario);
128
130
  const rr = await regradeScenario({
129
- runDir, spec, scenario, adapter, judge, specDir, threshold, now,
131
+ runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
130
132
  });
131
133
  const carry = overrides.get(id);
132
134
  scenarioResults.push({ ...rr, override: carry?.override ?? null, note: carry?.note ?? "" });
133
135
  }
134
- const ctx = mode === "green" && !prev?.partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
136
+ const ctx = scoreContextFor({ mode, partial: prev?.partial }, spec);
135
137
  const results = writeResults(runDir, {
136
138
  skill: spec.skill,
137
139
  harness: prev?.harness ?? "pi",
140
+ // The harness CLI that produced these transcripts, carried verbatim: a re-grade
141
+ // re-asks the judge, it does not re-deliver the skill, so stamping today's pi
142
+ // here would credit the old transcripts to a version that never ran them.
143
+ harness_cli_version: prev?.harness_cli_version,
144
+ delivery_canary: prev?.delivery_canary,
138
145
  model: prev?.model ?? "unknown",
139
146
  judge: { provider: judge.provider, model: judge.model },
140
147
  timestamp: prev?.timestamp ?? now(),
package/dist/report.d.ts CHANGED
@@ -22,13 +22,14 @@ export interface RunColumn {
22
22
  note: string;
23
23
  }>;
24
24
  /**
25
- * Red-vs-green lift for this model, when the tag has both a red baseline and a
26
- * green run. Undefined means "never measured" — which is not the same claim as
27
- * a zero lift, so the report must not render a 0 for it.
25
+ * Baseline-vs-skill lift for this model, when the tag has both a red baseline and
26
+ * a skill-delivered run (green or force). Undefined means "never measured" —
27
+ * which is not the same claim as a zero lift, so the report must not render a 0
28
+ * for it.
28
29
  *
29
- * Only set when THIS column is the green run the lift was computed from (see
30
+ * Only set when THIS column is the skill-side run the lift was computed from (see
30
31
  * collectReport): the review UI recomputes lift from the column's live cells,
31
- * which is only valid if those cells are the green side of the comparison.
32
+ * which is only valid if those cells are the skill side of the comparison.
32
33
  */
33
34
  lift?: Lift;
34
35
  liftHeadline?: string;
package/dist/report.js CHANGED
@@ -50,13 +50,14 @@ export function collectReport(skillDir) {
50
50
  };
51
51
  }
52
52
  const tag = tagDir.split("/").pop();
53
- // A column is the tag's LATEST run, which is not necessarily the green one —
54
- // record a red baseline after a green run and the newest run in the tag is
55
- // red. The review UI recomputes lift from `cells` (so author overrides move
53
+ // A column is the tag's LATEST run, which is not necessarily the skill-side
54
+ // one — record a red baseline after a green run and the newest run in the tag
55
+ // is red. The review UI recomputes lift from `cells` (so author overrides move
56
56
  // it live), so attaching a lift to a column whose cells are the RED run
57
57
  // would have it compare red against red and report "no effect" for a skill
58
58
  // that in fact gained every scenario. Attach only when this column IS the
59
- // green side of the comparison.
59
+ // skill side of the comparison — matched on the timestamp, which also keeps a
60
+ // green column from borrowing a force run's lift and vice versa.
60
61
  const tagLift = liftByTag.get(tag);
61
62
  const lift = tagLift && tagLift.greenTimestamp === r.timestamp ? tagLift : undefined;
62
63
  columns.push({
package/dist/rescore.d.ts CHANGED
@@ -24,6 +24,12 @@ export interface RescoreResult {
24
24
  * When the policy changes, the honest move is to recompute the old measurements under it
25
25
  * rather than reconcile two numbers in prose — and to record what moved.
26
26
  *
27
+ * It is also how a run gets a grade it never had: every rescore recomputes
28
+ * `effective_grade` under the current scoring policy, and since 0.5.0 that policy
29
+ * scores force runs too (see SCORED_MODES). A corpus holding force-mode runs
30
+ * recorded as "not scored" turns them into real scorecards with `rescore`, at zero
31
+ * model and zero judge spend — verdict changes are then genuinely optional output.
32
+ *
27
33
  * Only reps-bearing scenarios can be re-scored: a single-rep verdict has no rate to
28
34
  * re-apply a threshold to, and ERROR/JUDGE-AMBIGUOUS carry no trustworthy rate at all —
29
35
  * both are carried verbatim. Overrides, notes, and suspect flags are preserved: this
package/dist/rescore.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { readResults, writeResults } from "./results.js";
3
+ import { readResults, writeResults, scoreContextFor } from "./results.js";
4
4
  import { appendJournal } from "./journal.js";
5
5
  import { policyDigest, POLICY_PREFIX } from "./sources.js";
6
6
  /**
@@ -29,6 +29,12 @@ function refreshPolicyHashes(recorded, spec) {
29
29
  * When the policy changes, the honest move is to recompute the old measurements under it
30
30
  * rather than reconcile two numbers in prose — and to record what moved.
31
31
  *
32
+ * It is also how a run gets a grade it never had: every rescore recomputes
33
+ * `effective_grade` under the current scoring policy, and since 0.5.0 that policy
34
+ * scores force runs too (see SCORED_MODES). A corpus holding force-mode runs
35
+ * recorded as "not scored" turns them into real scorecards with `rescore`, at zero
36
+ * model and zero judge spend — verdict changes are then genuinely optional output.
37
+ *
32
38
  * Only reps-bearing scenarios can be re-scored: a single-rep verdict has no rate to
33
39
  * re-apply a threshold to, and ERROR/JUDGE-AMBIGUOUS carry no trustworthy rate at all —
34
40
  * both are carried verbatim. Overrides, notes, and suspect flags are preserved: this
@@ -61,13 +67,15 @@ export function rescoreRun(opts) {
61
67
  }
62
68
  return { ...s, judge_verdict: verdict, pass_threshold: toThreshold };
63
69
  });
64
- const ctx = prev.mode === "green" && !prev.partial
65
- ? { shipBar: opts.spec.ship_bar, critical: opts.spec.critical }
66
- : null;
70
+ const ctx = scoreContextFor(prev, opts.spec);
67
71
  const results = writeResults(opts.runDir, {
68
72
  skill: prev.skill, harness: prev.harness, model: prev.model, judge: prev.judge,
69
73
  timestamp: prev.timestamp, label: prev.label, mode: prev.mode,
70
74
  partial: prev.partial,
75
+ // Provenance of the measurement, not of this rewrite: a rescore re-applies a
76
+ // threshold to reps the recorded harness already produced.
77
+ harness_cli_version: prev.harness_cli_version,
78
+ delivery_canary: prev.delivery_canary,
71
79
  // A rescore re-applies the CURRENT policy (thresholds, critical set) to the
72
80
  // recorded reps, so `policy:` drift is genuinely resolved by having run this —
73
81
  // that is what makes `rescore` the honest remedy lint names for it. Stimulus,
package/dist/results.d.ts CHANGED
@@ -35,6 +35,37 @@ export interface ResultsFile {
35
35
  * writer can forget it.
36
36
  */
37
37
  harness_version?: string;
38
+ /**
39
+ * The version of the harness CLI that produced the transcripts — `pi --version`
40
+ * for `harness: pi`.
41
+ *
42
+ * Provenance for the *delivery*, not for the tool: pi 0.80.x wrapped a `--skill`
43
+ * prompt with the skill body, pi 0.83.0 switched to progressive disclosure, and
44
+ * that upgrade silently changed what `--mode green` measured. Two waves of runs
45
+ * in the reference corpus are indistinguishable from a naked-model baseline, and
46
+ * the incident is invisible in the artifacts precisely because nothing recorded
47
+ * which pi ran.
48
+ *
49
+ * Written only by `run` (the command that actually invokes the harness) and
50
+ * carried verbatim by every rewriter — `grade`/`rescore`/`regate` re-decide
51
+ * verdicts, they do not re-deliver the skill, so re-stamping this field with
52
+ * today's pi would attribute the old transcripts to a version that never
53
+ * produced them. Optional: runs recorded before the field existed have none, and
54
+ * an adapter that cannot report a version writes none rather than guessing.
55
+ */
56
+ harness_cli_version?: string;
57
+ /**
58
+ * `pass` when this run proved, before spending the wave, that the skill body was
59
+ * reachable in the model's context (see canary.ts). Absent means the probe was
60
+ * not asked for — never that it failed, because a failed canary aborts the run
61
+ * and no results.yaml is written.
62
+ *
63
+ * Only green runs can carry it: red delivers nothing by design and force delivers
64
+ * through the system prompt. It is provenance for the *validity* of a green run,
65
+ * which is why it lives here rather than only in the journal — `journal.jsonl` is
66
+ * gitignored, and this claim has to survive a commit.
67
+ */
68
+ delivery_canary?: "pass";
38
69
  skill: string;
39
70
  harness: string;
40
71
  model: string;
@@ -60,6 +91,53 @@ export interface ResultsFile {
60
91
  effective_grade: GradeSummary;
61
92
  scenarios: ScenarioResult[];
62
93
  }
94
+ /**
95
+ * The run modes whose results carry a real grade: the ones where the skill under
96
+ * test was actually delivered to the model.
97
+ *
98
+ * `green` activates the skill through the harness's own mechanism (`pi --skill`);
99
+ * `force` puts SKILL.md in the system prompt. Both are measurements OF THE SKILL,
100
+ * so both are scored against the ship bar. `red` is the control — the model with
101
+ * no skill — and scoring it would produce a ship grade for the thing the skill is
102
+ * measured against.
103
+ *
104
+ * Force was unscored until 0.5.0, when it stopped being an escape hatch and became
105
+ * a deployment: on pi 0.83.0 `--skill` switched to progressive disclosure (the
106
+ * description is in context, the body loads on demand — "models don't always do
107
+ * this"), so skill-as-system-prompt is the delivery a corpus can actually rely on.
108
+ * Ten committed force runs in the reference corpus read `not scored` for exactly
109
+ * that reason. Scored directly rather than behind a spec flag or a `--score-force`
110
+ * opt-in: "was the skill in front of the model?" is a property of the mode, not a
111
+ * per-repo preference, and a second knob would just be a second thing to forget.
112
+ *
113
+ * Consequence to expect, and it is the intended one: a force run recorded before
114
+ * 0.5.0 carries a "not scored" placeholder grade that a recompute now disagrees
115
+ * with, so `lint` flags it as stale and `rescore` (free) writes the real grade.
116
+ *
117
+ * The two modes are NOT interchangeable measurements of the same thing — placement
118
+ * changes behavior in both directions (measured on identical skill text: `build` A1
119
+ * 0/3 → 3/3, `plan` C2 3/3 → 0/3). Anything that plots or compares runs over time
120
+ * therefore keeps the epochs apart rather than pooling them; see trends.ts.
121
+ */
122
+ export declare const SCORED_MODES: readonly string[];
123
+ /** Whether a run in this mode delivered the skill, and so has a grade worth computing. */
124
+ export declare function isScoredMode(mode: string): boolean;
125
+ /**
126
+ * The one place "does this run get a grade?" is decided: the scoring mode gate plus
127
+ * the `--only` partial gate, in one predicate every writer shares.
128
+ *
129
+ * Before 0.5.0 this ternary was open-coded in seven places (run, grade, rescore,
130
+ * regate, lint, and both review-server writers) — which is how force runs came to
131
+ * be unscored in all seven at once, and how any future mode would have had to be
132
+ * remembered seven times.
133
+ */
134
+ export declare function scoreContextFor(run: {
135
+ mode: string;
136
+ partial?: boolean;
137
+ }, spec: {
138
+ ship_bar: ShipBar;
139
+ critical: string[];
140
+ }): ScoreContext | null;
63
141
  /** The pass-threshold a re-grade uses: the run's persisted value, else the spec's per-scenario value, else 0.5. */
64
142
  export declare function effectiveThreshold(prevScenario: ScenarioResult | undefined, scenario: Scenario): number;
65
143
  /** Everything a caller may set. The grade is computed, never supplied. */
package/dist/results.js CHANGED
@@ -4,6 +4,53 @@ import yaml from "js-yaml";
4
4
  import { modelSlug } from "./adapters/types.js";
5
5
  import { score } from "./score.js";
6
6
  import { HARNESS_VERSION } from "./version.js";
7
+ /**
8
+ * The run modes whose results carry a real grade: the ones where the skill under
9
+ * test was actually delivered to the model.
10
+ *
11
+ * `green` activates the skill through the harness's own mechanism (`pi --skill`);
12
+ * `force` puts SKILL.md in the system prompt. Both are measurements OF THE SKILL,
13
+ * so both are scored against the ship bar. `red` is the control — the model with
14
+ * no skill — and scoring it would produce a ship grade for the thing the skill is
15
+ * measured against.
16
+ *
17
+ * Force was unscored until 0.5.0, when it stopped being an escape hatch and became
18
+ * a deployment: on pi 0.83.0 `--skill` switched to progressive disclosure (the
19
+ * description is in context, the body loads on demand — "models don't always do
20
+ * this"), so skill-as-system-prompt is the delivery a corpus can actually rely on.
21
+ * Ten committed force runs in the reference corpus read `not scored` for exactly
22
+ * that reason. Scored directly rather than behind a spec flag or a `--score-force`
23
+ * opt-in: "was the skill in front of the model?" is a property of the mode, not a
24
+ * per-repo preference, and a second knob would just be a second thing to forget.
25
+ *
26
+ * Consequence to expect, and it is the intended one: a force run recorded before
27
+ * 0.5.0 carries a "not scored" placeholder grade that a recompute now disagrees
28
+ * with, so `lint` flags it as stale and `rescore` (free) writes the real grade.
29
+ *
30
+ * The two modes are NOT interchangeable measurements of the same thing — placement
31
+ * changes behavior in both directions (measured on identical skill text: `build` A1
32
+ * 0/3 → 3/3, `plan` C2 3/3 → 0/3). Anything that plots or compares runs over time
33
+ * therefore keeps the epochs apart rather than pooling them; see trends.ts.
34
+ */
35
+ export const SCORED_MODES = ["green", "force"];
36
+ /** Whether a run in this mode delivered the skill, and so has a grade worth computing. */
37
+ export function isScoredMode(mode) {
38
+ return SCORED_MODES.includes(mode);
39
+ }
40
+ /**
41
+ * The one place "does this run get a grade?" is decided: the scoring mode gate plus
42
+ * the `--only` partial gate, in one predicate every writer shares.
43
+ *
44
+ * Before 0.5.0 this ternary was open-coded in seven places (run, grade, rescore,
45
+ * regate, lint, and both review-server writers) — which is how force runs came to
46
+ * be unscored in all seven at once, and how any future mode would have had to be
47
+ * remembered seven times.
48
+ */
49
+ export function scoreContextFor(run, spec) {
50
+ if (!isScoredMode(run.mode) || run.partial)
51
+ return null;
52
+ return { shipBar: spec.ship_bar, critical: spec.critical };
53
+ }
7
54
  /** The pass-threshold a re-grade uses: the run's persisted value, else the spec's per-scenario value, else 0.5. */
8
55
  export function effectiveThreshold(prevScenario, scenario) {
9
56
  return prevScenario?.pass_threshold ?? scenario.passThreshold ?? 0.5;
@@ -56,6 +103,10 @@ export function finalizeResults(draft, ctx) {
56
103
  // `grade`, `rescore` and the review UI's override save all record which tool
57
104
  // produced the record they leave behind.
58
105
  harness_version: HARNESS_VERSION,
106
+ // Omitted rather than written as null when absent: a run whose adapter could
107
+ // not report a version must not look like one that reported "nothing".
108
+ ...(draft.harness_cli_version ? { harness_cli_version: draft.harness_cli_version } : {}),
109
+ ...(draft.delivery_canary ? { delivery_canary: draft.delivery_canary } : {}),
59
110
  skill: draft.skill,
60
111
  harness: draft.harness,
61
112
  model: draft.model,
package/dist/run.d.ts CHANGED
@@ -24,6 +24,13 @@ export interface RunOptions {
24
24
  * ship-graded: a subset passing says nothing about the ship bar.
25
25
  */
26
26
  only?: string[];
27
+ /**
28
+ * Green mode only: spend ONE probe up front proving the skill reaches the model,
29
+ * and abort the run if it doesn't (see canary.ts). Off by default — it costs a
30
+ * rep, and the deterministic half of this failure class (a skill dir that isn't
31
+ * there) is already refused by the adapter for free.
32
+ */
33
+ canary?: boolean;
27
34
  }
28
35
  export interface RunSummary {
29
36
  runDir: string;
package/dist/run.js CHANGED
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { sourceHashes } from "./sources.js";
4
4
  import { judgeResemblesSubject } from "./grade.js";
5
- import { runDirFor, transcriptPath, diffPath, writeResults, ensureResultsGitignore, } from "./results.js";
5
+ import { runDirFor, transcriptPath, diffPath, writeResults, ensureResultsGitignore, scoreContextFor, isScoredMode, } from "./results.js";
6
6
  import { appendJournal } from "./journal.js";
7
7
  import { liftHeadline } from "./lift.js";
8
8
  import { runSeeded } from "./seeded.js";
@@ -10,6 +10,7 @@ import { createWorkspace } from "./workspace.js";
10
10
  import { runPool } from "./scheduler.js";
11
11
  import { outcomesToResult } from "./reps.js";
12
12
  import { judgeOneRep } from "./regrade.js";
13
+ import { runDeliveryCanary, canaryFailure } from "./canary.js";
13
14
  /** Run one skill against one model: run scenarios, grade, score, persist. */
14
15
  export async function runSkillModel(opts) {
15
16
  const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
@@ -36,12 +37,52 @@ export async function runSkillModel(opts) {
36
37
  const runDir = runDirFor(skillDir, adapter.name, model, timestamp);
37
38
  mkdirSync(runDir, { recursive: true });
38
39
  ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
40
+ // Which harness CLI delivered the skill, asked once per run and recorded with the
41
+ // numbers. A pi upgrade (0.80.x → 0.83.0) silently changed what green mode
42
+ // measures, and the incident was invisible in the artifacts because nothing wrote
43
+ // this down. Never fatal: `null` means the adapter couldn't say.
44
+ const harnessCliVersion = (await adapter.version?.()) ?? null;
39
45
  appendJournal(runDir, {
40
46
  event: "run-started", ts: now(),
41
47
  skill: spec.skill, harness: adapter.name, model: opts.modelToken,
48
+ harness_cli_version: harnessCliVersion,
42
49
  judge: { provider: judge.provider, model: judge.model },
43
50
  mode, label: opts.label ?? null,
44
51
  });
52
+ // The canary spends one probe before the wave, so a run that isn't measuring the
53
+ // skill dies for the price of a rep instead of producing a plausible scorecard.
54
+ // Green only: red delivers nothing by design, and force delivers through the
55
+ // system prompt, which needs no probe.
56
+ let canaryStatus = null;
57
+ if (opts.canary && mode !== "green") {
58
+ // Ignoring a flag silently is a small version of the bug this whole feature is
59
+ // about. Say it, and say why it isn't needed.
60
+ log(` --canary ignored in mode=${mode} — ${mode === "force" ? "the system prompt delivers the skill unconditionally" : "a baseline delivers no skill by design"}`);
61
+ }
62
+ if (opts.canary && mode === "green") {
63
+ const probeCwd = createWorkspace("none", { specDir: dirname(opts.specPath) });
64
+ let canary;
65
+ try {
66
+ canary = await runDeliveryCanary({
67
+ adapter, model, skillDir, skillName: spec.skill, cwd: probeCwd.cwd,
68
+ });
69
+ }
70
+ finally {
71
+ probeCwd.cleanup();
72
+ }
73
+ appendJournal(runDir, {
74
+ event: "delivery-canary", ts: now(),
75
+ status: canary.status, anchor: canary.anchor, detail: canary.detail,
76
+ });
77
+ if (canary.status === "fail")
78
+ throw new Error(canaryFailure(spec.skill, canary, harnessCliVersion));
79
+ if (canary.status === "skipped")
80
+ log(` ⚠ delivery canary skipped — ${canary.detail}`);
81
+ else {
82
+ canaryStatus = "pass";
83
+ log(` ✓ delivery canary — the model quoted its skill instructions back (\`${canary.anchor}\`)`);
84
+ }
85
+ }
45
86
  // scenario × rep tasks; runPool preserves input order so we can slice per scenario.
46
87
  const repCounts = scenarios.map((s) => s.reps ?? opts.reps ?? 1);
47
88
  const owners = [];
@@ -61,10 +102,12 @@ export async function runSkillModel(opts) {
61
102
  const threshold = scenario.passThreshold ?? opts.passThreshold ?? 0.5;
62
103
  return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
63
104
  });
64
- const ctx = mode === "green" && !partial ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
105
+ const ctx = scoreContextFor({ mode, partial }, spec);
65
106
  const results = writeResults(runDir, {
66
107
  skill: spec.skill,
67
108
  harness: adapter.name,
109
+ harness_cli_version: harnessCliVersion ?? undefined,
110
+ delivery_canary: canaryStatus ?? undefined,
68
111
  model: opts.modelToken,
69
112
  judge: { provider: judge.provider, model: judge.model },
70
113
  timestamp,
@@ -226,16 +269,26 @@ export function formatScorecard(summary, lift) {
226
269
  const ship = g.ship ? "SHIP" : "NOT READY";
227
270
  const note = g.note ? ` (${g.note})` : "";
228
271
  lines.push(` GRADE: ${g.letter} (${g.pct}%) — ${g.passed}/${g.total} — ${ship}${note}`);
229
- // Lift is a statement about a green run. On a red run the caller may still have
230
- // a lift in hand (a green run exists in the same tag), but printing it under a
231
- // baseline scorecard reads as if the baseline itself gained something.
232
- if (lift && results.mode === "green") {
272
+ // Lift is a statement about a skill-delivered run (green or force). On a red run
273
+ // the caller may still have a lift in hand (a scored run exists in the same tag),
274
+ // but printing it under a baseline scorecard reads as if the baseline itself
275
+ // gained something.
276
+ if (lift && isScoredMode(results.mode)) {
233
277
  lines.push(` LIFT: ${liftHeadline(lift)} (vs red baseline ${lift.redTimestamp})`);
234
278
  }
235
- else if (results.mode === "green") {
279
+ else if (isScoredMode(results.mode)) {
236
280
  // The grade alone can't answer "does this skill do anything?", so say how.
237
281
  lines.push(` LIFT: no red baseline — run with --mode red to measure what the skill adds`);
238
282
  }
283
+ // Said on the scorecard, not just in the docs: the one thing that can invalidate
284
+ // a green number is invisible in the number. `harness_cli_version` is recorded
285
+ // beside the verdicts so a reader can tell which pi produced them.
286
+ if (results.mode === "green" && !results.delivery_canary) {
287
+ lines.push(` NOTE: green delivery is harness-version-dependent` +
288
+ (results.harness_cli_version ? ` (${results.harness} ${results.harness_cli_version})` : "") +
289
+ ` — on pi ≥ 0.83.0 \`--skill\` only discloses the description and the body loads on demand.` +
290
+ ` Use --mode force for delivery that cannot silently degrade, or --canary to prove it per run.`);
291
+ }
239
292
  return lines.join("\n");
240
293
  }
241
294
  //# sourceMappingURL=run.js.map
package/dist/trends.d.ts CHANGED
@@ -14,6 +14,17 @@ export interface TrendRun {
14
14
  export interface TrendModel {
15
15
  model: string;
16
16
  tag: string;
17
+ /**
18
+ * The delivery mode every run in this series shares (`green` or `force`).
19
+ *
20
+ * A series is per tag AND per mode, never pooled: the two modes are different
21
+ * deliveries of the same text, and placement moves verdicts in both directions at
22
+ * once (measured on identical skill text: `build` A1 0/3 → 3/3 with force, `plan`
23
+ * C2 3/3 → 0/3). A sparkline that ran green then force would draw that epoch
24
+ * change as skill progress — or regression — which is the one thing a trend line
25
+ * must not invent.
26
+ */
27
+ mode: string;
17
28
  runs: TrendRun[];
18
29
  truncated: boolean;
19
30
  skipped: number;
@@ -35,15 +46,18 @@ export interface TrendData {
35
46
  * rule: an override resolves a misfire) + reps flakiness. Read-only; no
36
47
  * absolute paths in the result.
37
48
  *
38
- * Only scored (mode === "green") runs are included in the history — a
39
- * red/force run has no real grade (`effective_grade` is a "not scored"
40
- * placeholder; see run.ts) and would otherwise plot as a misleading 0% dip in
41
- * the sparkline/grid. Non-green runs are deliberately excluded, which is
42
- * distinct from `skipped`: a run's mode can only be known after reading its
43
- * results.yaml, so every candidate run-dir in the tag is read (not just the
44
- * most recent `limit`) before filtering to green and applying the `limit`
45
- * window — trends is a bounded, on-demand, local view, so this extra read
46
- * cost is acceptable. If a tag has zero green runs, it's omitted entirely.
49
+ * Only scored runs are included in the history — a red baseline has no real grade
50
+ * (`effective_grade` is a "not scored" placeholder; see run.ts) and would otherwise
51
+ * plot as a misleading 0% dip in the sparkline/grid. Red runs are deliberately
52
+ * excluded, which is distinct from `skipped`: a run's mode can only be known after
53
+ * reading its results.yaml, so every candidate run-dir in the tag is read (not just
54
+ * the most recent `limit`) before filtering and applying the `limit` window
55
+ * trends is a bounded, on-demand, local view, so this extra read cost is
56
+ * acceptable.
57
+ *
58
+ * Green and force runs both count, but never in the same series: a tag with both
59
+ * yields one TrendModel per mode (see `TrendModel.mode`), each with its own
60
+ * `limit` window. A tag with no scored run at all is omitted entirely.
47
61
  *
48
62
  * A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic
49
63
  * write) is logged via `console.warn` and skipped — never surfaced or thrown —
package/dist/trends.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadSpec } from "./spec.js";
4
- import { readResults, effectiveVerdicts } from "./results.js";
4
+ import { readResults, effectiveVerdicts, isScoredMode } from "./results.js";
5
5
  /** A directory that exists right now; false (never throws) if it vanished concurrently (e.g. ENOENT). */
6
6
  function isDir(p) {
7
7
  try {
@@ -19,15 +19,18 @@ function isDir(p) {
19
19
  * rule: an override resolves a misfire) + reps flakiness. Read-only; no
20
20
  * absolute paths in the result.
21
21
  *
22
- * Only scored (mode === "green") runs are included in the history — a
23
- * red/force run has no real grade (`effective_grade` is a "not scored"
24
- * placeholder; see run.ts) and would otherwise plot as a misleading 0% dip in
25
- * the sparkline/grid. Non-green runs are deliberately excluded, which is
26
- * distinct from `skipped`: a run's mode can only be known after reading its
27
- * results.yaml, so every candidate run-dir in the tag is read (not just the
28
- * most recent `limit`) before filtering to green and applying the `limit`
29
- * window — trends is a bounded, on-demand, local view, so this extra read
30
- * cost is acceptable. If a tag has zero green runs, it's omitted entirely.
22
+ * Only scored runs are included in the history — a red baseline has no real grade
23
+ * (`effective_grade` is a "not scored" placeholder; see run.ts) and would otherwise
24
+ * plot as a misleading 0% dip in the sparkline/grid. Red runs are deliberately
25
+ * excluded, which is distinct from `skipped`: a run's mode can only be known after
26
+ * reading its results.yaml, so every candidate run-dir in the tag is read (not just
27
+ * the most recent `limit`) before filtering and applying the `limit` window
28
+ * trends is a bounded, on-demand, local view, so this extra read cost is
29
+ * acceptable.
30
+ *
31
+ * Green and force runs both count, but never in the same series: a tag with both
32
+ * yields one TrendModel per mode (see `TrendModel.mode`), each with its own
33
+ * `limit` window. A tag with no scored run at all is omitted entirely.
31
34
  *
32
35
  * A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic
33
36
  * write) is logged via `console.warn` and skipped — never surfaced or thrown —
@@ -54,10 +57,11 @@ export function collectTrends(skillDir, limit = 20) {
54
57
  if (runDirs.length === 0)
55
58
  continue;
56
59
  // Read every candidate run (mode isn't knowable from the dir name) and
57
- // filter to green (scored) runs before applying the `limit` window —
58
- // filtering after the slice would let red/force runs consume window
59
- // slots, undercounting the green history even when more exists.
60
- const greenRuns = [];
60
+ // filter to scored runs before applying the `limit` window — filtering
61
+ // after the slice would let red runs consume window slots, undercounting
62
+ // the history even when more exists. Bucketed by mode, in first-seen
63
+ // order, so each delivery epoch gets its own series and its own window.
64
+ const byMode = new Map();
61
65
  let skipped = 0;
62
66
  for (const rd of runDirs) {
63
67
  let r;
@@ -71,30 +75,35 @@ export function collectTrends(skillDir, limit = 20) {
71
75
  skipped++;
72
76
  continue;
73
77
  }
74
- if (r.mode !== "green")
75
- continue; // not scored — deliberate exclusion, not a skip
76
- greenRuns.push(r);
78
+ if (!isScoredMode(r.mode))
79
+ continue; // baseline — deliberate exclusion, not a skip
80
+ (byMode.get(r.mode) ?? byMode.set(r.mode, []).get(r.mode)).push(r);
77
81
  }
78
- if (greenRuns.length === 0)
82
+ if (byMode.size === 0)
79
83
  continue;
80
- const truncated = greenRuns.length > limit;
81
- const kept = greenRuns.slice(-limit); // most recent `limit`, newest last
82
- const runs = [];
83
- let model = "";
84
- for (const r of kept) {
85
- // effectiveVerdicts is the single source of truth for the
86
- // override-aware verdict/suspect rule (suspect = s.suspect &&
87
- // s.override == null an override resolves the misfire); zip in
88
- // flakiness from the matching ScenarioResult.
89
- const verdicts = effectiveVerdicts(r.scenarios);
90
- const cells = {};
91
- r.scenarios.forEach((s, i) => {
92
- cells[s.id] = { verdict: verdicts[i].verdict, suspect: verdicts[i].suspect ?? false, flakiness: s.flakiness };
93
- });
94
- runs.push({ timestamp: r.timestamp, label: r.label, grade: r.effective_grade, cells });
95
- model = r.model; // last successfully-read run (kept is ascending) wins
84
+ for (const [mode, scoredRuns] of byMode) {
85
+ const truncated = scoredRuns.length > limit;
86
+ const kept = scoredRuns.slice(-limit); // most recent `limit`, newest last
87
+ const runs = [];
88
+ let model = "";
89
+ for (const r of kept) {
90
+ // effectiveVerdicts is the single source of truth for the
91
+ // override-aware verdict/suspect rule (suspect = s.suspect &&
92
+ // s.override == null — an override resolves the misfire); zip in
93
+ // flakiness from the matching ScenarioResult.
94
+ const verdicts = effectiveVerdicts(r.scenarios);
95
+ const cells = {};
96
+ r.scenarios.forEach((s, i) => {
97
+ cells[s.id] = { verdict: verdicts[i].verdict, suspect: verdicts[i].suspect ?? false, flakiness: s.flakiness };
98
+ });
99
+ runs.push({ timestamp: r.timestamp, label: r.label, grade: r.effective_grade, cells });
100
+ model = r.model; // last successfully-read run (kept is ascending) wins
101
+ }
102
+ // `skipped` is per tag (an unreadable run has no knowable mode), so a tag with
103
+ // two series reports the same count on both — the alternative is attributing a
104
+ // parse failure to a mode nobody could read.
105
+ models.push({ model, tag, mode, runs, truncated, skipped });
96
106
  }
97
- models.push({ model, tag, runs, truncated, skipped });
98
107
  }
99
108
  }
100
109
  return { skill: spec.skill, scenarios, models };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",