@skill-harness/core 0.4.0 → 0.6.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,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";
29
+ export * from "./stability.js";
package/dist/index.js CHANGED
@@ -25,4 +25,6 @@ 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";
29
+ export * from "./stability.js";
28
30
  //# 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.d.ts CHANGED
@@ -1,10 +1,25 @@
1
- export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "lint-error";
1
+ export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "stability" | "lint-error";
2
+ /**
3
+ * How much a finding means.
4
+ *
5
+ * `error` (the default, and every code that existed through 0.5.0) fails the gate.
6
+ * `info` reports something a reader should know that is NOT a defect: a boundary cell
7
+ * is a statement about how much one run of a scenario is worth, not a broken spec, and
8
+ * turning it red would make "this cell needs more reps" indistinguishable from "your
9
+ * fixture is missing". Omitted rather than written on every finding so the shape stays
10
+ * backward-compatible for anything already reading this list.
11
+ */
12
+ export type LintSeverity = "error" | "info";
2
13
  export interface LintFinding {
3
14
  readonly skill: string;
4
15
  readonly scenario?: string;
5
16
  readonly code: LintCode;
6
17
  readonly message: string;
18
+ /** Absent means `error` — only findings that must not fail CI carry this. */
19
+ readonly severity?: LintSeverity;
7
20
  }
21
+ /** True when a finding fails the gate. The single place the exit-code rule lives. */
22
+ export declare function failsGate(f: LintFinding): boolean;
8
23
  /**
9
24
  * Validate one skill's spec + fixtures statically (and results-consistency when
10
25
  * committed results exist — see the consistency block). Never throws: a bad spec
package/dist/lint.js CHANGED
@@ -2,10 +2,16 @@ 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
+ import { collectScoredRuns } from "./trends.js";
9
+ import { boundaryCells, stabilityFrom, stabilityNote } from "./stability.js";
8
10
  import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
11
+ /** True when a finding fails the gate. The single place the exit-code rule lives. */
12
+ export function failsGate(f) {
13
+ return (f.severity ?? "error") === "error";
14
+ }
9
15
  /** True if `p` exists and is a directory. Never throws (TOCTOU-safe: a race or dangling
10
16
  * symlink between the check and the stat is treated as "not a directory", not an error). */
11
17
  function isDir(p) {
@@ -163,10 +169,17 @@ export function lintSkill(skillDir) {
163
169
  // can actually re-score. Override/transcript rules below still apply.
164
170
  const specIds = new Set(spec.scenarios.map((sc) => sc.id));
165
171
  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;
172
+ const ctx = scoreContextFor(r, spec);
167
173
  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
174
  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)` });
175
+ // The remedy is named because this finding now has a benign, expected cause
176
+ // as well as a suspicious one: a force run recorded before 0.5.0 carries a
177
+ // "not scored" placeholder, and today's policy scores it (see SCORED_MODES).
178
+ // `rescore` is free and offline, so the fix is never a reason to re-measure.
179
+ findings.push({
180
+ skill, code: "consistency",
181
+ message: `results.yaml effective_grade is stale in ${runDir} (recompute differs) — re-apply the current scoring policy: rescore (free, offline)`,
182
+ });
170
183
  }
171
184
  for (const s of r.scenarios) {
172
185
  if (s.override != null) {
@@ -258,6 +271,29 @@ export function lintSkill(skillDir) {
258
271
  }
259
272
  }
260
273
  }
274
+ // Run-over-run stability — INFO, never a gate failure. Derived from committed
275
+ // history at zero cost, and it answers a question no single results.yaml can: a
276
+ // scenario can be internally unanimous in every run and still land on a different
277
+ // side each time. Measured in the reference corpus: two consecutive full runs, one
278
+ // 3/3 PASS and the next 0/3 FAIL, each `flakiness 0.00`.
279
+ //
280
+ // In lint because that is where a repo already looks, and free because it reads what
281
+ // is on disk. Wrapped: lintSkill must never throw, and a stability read touches every
282
+ // run file in the tree.
283
+ try {
284
+ for (const cell of boundaryCells(stabilityFrom(collectScoredRuns(skillDir), spec))) {
285
+ findings.push({
286
+ skill, scenario: cell.id, code: "stability", severity: "info",
287
+ message: `${cell.tag} mode=${cell.mode}: ${stabilityNote(cell)}`,
288
+ });
289
+ }
290
+ }
291
+ catch (e) {
292
+ findings.push({
293
+ skill, code: "stability", severity: "info",
294
+ message: `run-over-run stability could not be derived: ${e instanceof Error ? e.message : String(e)}`,
295
+ });
296
+ }
261
297
  return findings;
262
298
  }
263
299
  /** Model-tag dirs under tests/results (each holds timestamped run dirs). */
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
  */