@skill-harness/core 0.5.0 → 0.7.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.
Files changed (46) hide show
  1. package/dist/adapters/types.d.ts +37 -0
  2. package/dist/adjudication.d.ts +210 -0
  3. package/dist/adjudication.js +392 -0
  4. package/dist/affected.d.ts +88 -0
  5. package/dist/affected.js +222 -0
  6. package/dist/capture-trace-types.d.ts +228 -0
  7. package/dist/capture-trace-types.js +23 -0
  8. package/dist/capture.d.ts +193 -0
  9. package/dist/capture.js +344 -0
  10. package/dist/execution-trace.d.ts +61 -0
  11. package/dist/execution-trace.js +299 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.js +9 -0
  14. package/dist/instruction-coverage.d.ts +106 -0
  15. package/dist/instruction-coverage.js +253 -0
  16. package/dist/journal.d.ts +17 -0
  17. package/dist/lint.d.ts +16 -1
  18. package/dist/lint.js +52 -0
  19. package/dist/regate.js +80 -17
  20. package/dist/regrade.js +17 -3
  21. package/dist/report.d.ts +48 -0
  22. package/dist/report.js +39 -1
  23. package/dist/reps.d.ts +14 -1
  24. package/dist/reps.js +28 -2
  25. package/dist/rescore.js +11 -2
  26. package/dist/results.d.ts +128 -6
  27. package/dist/results.js +155 -6
  28. package/dist/run.d.ts +9 -1
  29. package/dist/run.js +129 -9
  30. package/dist/seeded.d.ts +11 -0
  31. package/dist/seeded.js +31 -7
  32. package/dist/sources.d.ts +26 -0
  33. package/dist/sources.js +82 -3
  34. package/dist/spec-write.d.ts +62 -0
  35. package/dist/spec-write.js +106 -0
  36. package/dist/spec.d.ts +29 -0
  37. package/dist/spec.js +55 -0
  38. package/dist/stability.d.ts +144 -0
  39. package/dist/stability.js +232 -0
  40. package/dist/trace-gates.d.ts +133 -0
  41. package/dist/trace-gates.js +519 -0
  42. package/dist/trends.d.ts +28 -0
  43. package/dist/trends.js +76 -61
  44. package/dist/workspace.d.ts +36 -0
  45. package/dist/workspace.js +61 -0
  46. package/package.json +1 -1
package/dist/journal.d.ts CHANGED
@@ -54,6 +54,23 @@ export type JournalEvent = {
54
54
  ok: boolean;
55
55
  detail: string;
56
56
  rep?: number;
57
+ }
58
+ /** Trace-gate outcome. Separate from `gate-result`, which is the seeded diff/vitest gates. */
59
+ | {
60
+ event: "objective-result";
61
+ ts: string;
62
+ id: string;
63
+ ok: boolean;
64
+ detail: string;
65
+ rep?: number;
66
+ }
67
+ /** One adjudication pass: which cells were re-judged, what it cost in CALLS, what stayed unresolved. */
68
+ | {
69
+ event: "adjudication";
70
+ ts: string;
71
+ triggered: string[];
72
+ judge_calls: number;
73
+ unresolved: string[];
57
74
  } | {
58
75
  event: "judge-verdict";
59
76
  ts: string;
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" | "covers" | "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,17 @@ 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 { computeCoverage } from "./instruction-coverage.js";
5
6
  import { readResults, finalizeResults, findTranscriptFiles, resultsPath, scoreContextFor } from "./results.js";
6
7
  import { currentHashFor, describeSourceKey, remedyForKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, STIMULUS_PREFIX, UNREADABLE } from "./sources.js";
7
8
  import { downgradeWarning } from "./downgrade.js";
9
+ import { collectScoredRuns } from "./trends.js";
10
+ import { boundaryCells, stabilityFrom, stabilityNote } from "./stability.js";
8
11
  import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
12
+ /** True when a finding fails the gate. The single place the exit-code rule lives. */
13
+ export function failsGate(f) {
14
+ return (f.severity ?? "error") === "error";
15
+ }
9
16
  /** True if `p` exists and is a directory. Never throws (TOCTOU-safe: a race or dangling
10
17
  * symlink between the check and the stat is treated as "not a directory", not an error). */
11
18
  function isDir(p) {
@@ -134,6 +141,28 @@ export function lintSkill(skillDir) {
134
141
  // system_prompt_file must exist — an agent-file scenario silently falling back to
135
142
  // skill activation would measure the wrong artifact entirely.
136
143
  for (const s of spec.scenarios) {
144
+ // A `covers` reference that names a section which does not exist is a WRONG
145
+ // STATEMENT in the spec, not a coverage gap — so it fails the gate, while an
146
+ // uncovered section is only reported by `coverage --strict`. Renaming a heading
147
+ // is the usual cause, so the finding names the near-misses.
148
+ if (s.covers?.length) {
149
+ const report = computeCoverage({ specDir, scenarios: [s] });
150
+ for (const b of report.broken) {
151
+ const hint = b.didYouMean.length ? ` — did you mean ${b.didYouMean.map((x) => `#${x}`).join(", ")}?` : "";
152
+ findings.push({
153
+ skill, scenario: s.id, code: "covers",
154
+ message: `covers reference \`${b.raw}\` is broken (${b.reason})${hint}`,
155
+ });
156
+ }
157
+ }
158
+ // Extension paths, checked statically so a typo is a free CI failure rather
159
+ // than a wave that ran with no subagent tool and graded the absence.
160
+ for (const ext of s.extensions ?? []) {
161
+ const extAbs = isAbsolute(ext) ? ext : resolve(specDir, ext);
162
+ if (!existsSync(extAbs)) {
163
+ findings.push({ skill, scenario: s.id, code: "fixture", message: `env.extensions not found: ${ext}` });
164
+ }
165
+ }
137
166
  if (!s.systemPromptFile)
138
167
  continue;
139
168
  const abs = isAbsolute(s.systemPromptFile) ? s.systemPromptFile : resolve(specDir, s.systemPromptFile);
@@ -265,6 +294,29 @@ export function lintSkill(skillDir) {
265
294
  }
266
295
  }
267
296
  }
297
+ // Run-over-run stability — INFO, never a gate failure. Derived from committed
298
+ // history at zero cost, and it answers a question no single results.yaml can: a
299
+ // scenario can be internally unanimous in every run and still land on a different
300
+ // side each time. Measured in the reference corpus: two consecutive full runs, one
301
+ // 3/3 PASS and the next 0/3 FAIL, each `flakiness 0.00`.
302
+ //
303
+ // In lint because that is where a repo already looks, and free because it reads what
304
+ // is on disk. Wrapped: lintSkill must never throw, and a stability read touches every
305
+ // run file in the tree.
306
+ try {
307
+ for (const cell of boundaryCells(stabilityFrom(collectScoredRuns(skillDir), spec))) {
308
+ findings.push({
309
+ skill, scenario: cell.id, code: "stability", severity: "info",
310
+ message: `${cell.tag} mode=${cell.mode}: ${stabilityNote(cell)}`,
311
+ });
312
+ }
313
+ }
314
+ catch (e) {
315
+ findings.push({
316
+ skill, code: "stability", severity: "info",
317
+ message: `run-over-run stability could not be derived: ${e instanceof Error ? e.message : String(e)}`,
318
+ });
319
+ }
268
320
  return findings;
269
321
  }
270
322
  /** Model-tag dirs under tests/results (each holds timestamped run dirs). */
package/dist/regate.js CHANGED
@@ -2,8 +2,10 @@ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parseVerdict, detectMisfire } from "./grade.js";
4
4
  import { evaluateNeedleGates, hasNeedleGates } from "./seeded.js";
5
+ import { evaluateTraceGates } from "./trace-gates.js";
6
+ import { mergeTraces, deserializeTrace } from "./execution-trace.js";
5
7
  import { judgeOneRep } from "./regrade.js";
6
- import { readResults, writeResults, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, effectiveThreshold, scoreContextFor, } from "./results.js";
8
+ import { readResults, writeResults, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, findTraceFiles, tracePath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, } from "./results.js";
7
9
  import { outcomesToResult } from "./reps.js";
8
10
  import { appendJournal } from "./journal.js";
9
11
  import { gatesDigest, GATES_PREFIX } from "./sources.js";
@@ -84,22 +86,32 @@ export async function regateRun(opts) {
84
86
  const targets = [];
85
87
  for (const rec of prev.scenarios) {
86
88
  const s = specById.get(rec.id);
87
- if (!s || !hasNeedleGates(s))
89
+ const needles = hasNeedleGates(s ?? {});
90
+ const traceGated = Boolean(s?.traceAssert);
91
+ if (!s || (!needles && !traceGated))
88
92
  continue; // nothing for regate to re-decide
89
93
  if (s.assert?.vitest || s.assert?.post_test) {
90
94
  blocked.push(`${s.id}: declares ${s.assert.vitest ? "assert.vitest" : "assert.post_test"}, which needs the workspace — ` +
91
95
  `no saved artifact can stand in for it, so this scenario needs a re-run`);
92
96
  continue;
93
97
  }
94
- if (findDiffFiles(opts.runDir, s.id, mode).length === 0) {
98
+ if (needles && findDiffFiles(opts.runDir, s.id, mode).length === 0) {
95
99
  blocked.push(`${s.id}: no staged-diff artifact on disk (\`.diff.txt\` is gitignored — regate needs the run dir that produced it)`);
96
100
  continue;
97
101
  }
102
+ // A trace gate is only re-decidable from a saved trace. A run recorded before
103
+ // traces existed has none, and saying so is the whole point — pretending
104
+ // regate can answer would report a verdict derived from no evidence.
105
+ if (traceGated && findTraceFiles(opts.runDir, s.id, mode).length === 0) {
106
+ blocked.push(`${s.id}: declares assert.trace but this run saved no \`.trace.jsonl\` artifact ` +
107
+ `(it predates trace capture, or the trace was not persisted) — it needs a re-run`);
108
+ continue;
109
+ }
98
110
  targets.push(s);
99
111
  }
100
112
  if (targets.length === 0) {
101
113
  throw new Error(`nothing to regate in ${opts.runDir}` +
102
- (blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff_contains/diff_excludes"));
114
+ (blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff_contains/diff_excludes or assert.trace"));
103
115
  }
104
116
  const changes = [];
105
117
  let judgeCalls = 0;
@@ -111,18 +123,66 @@ export async function regateRun(opts) {
111
123
  continue;
112
124
  }
113
125
  const diffFiles = findDiffFiles(opts.runDir, scenario.id, mode);
126
+ const traceFiles = findTraceFiles(opts.runDir, scenario.id, mode);
127
+ // Reps come from whichever artifact this scenario actually has. A trace-only
128
+ // scenario has no `.diff.txt` at all, so iterating diffs would silently
129
+ // regate nothing and report success.
130
+ const repKeys = diffFiles.length > 0
131
+ ? diffFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: f }))
132
+ : traceFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: undefined }));
114
133
  const outcomes = [];
115
134
  // Per scenario, not run-wide: with several regated scenarios, a global counter
116
135
  // would report every change as "re-judged" because some other scenario was.
117
136
  let judgedHere = 0;
118
137
  let gateFailedHere = false;
119
- for (const file of diffFiles) {
120
- const rep = repIndexOf(file) ?? undefined;
121
- const diff = readFileSync(join(opts.runDir, file), "utf8");
122
- const gate = evaluateNeedleGates(scenario, diff);
138
+ for (const { rep, diffFile } of repKeys) {
139
+ const diff = diffFile ? readFileSync(join(opts.runDir, diffFile), "utf8") : "";
140
+ const needleGate = diffFile ? evaluateNeedleGates(scenario, diff) : { lines: [], failure: null };
141
+ // Trace gate, re-decided from the saved trace. Free: no model, no judge.
142
+ let traceFailure = null;
143
+ let objective;
144
+ if (scenario.traceAssert) {
145
+ const tp = tracePath(opts.runDir, scenario.id, mode, rep);
146
+ // A PARTIAL read is refused, not graded. `deserializeTrace` returns null
147
+ // for a malformed line and for a version it declines, and dropping those
148
+ // silently graded whatever survived: a 3-turn trace with a torn middle
149
+ // line reported `forbid_calls: [bash] → PASS` when the lost turn was the
150
+ // one that called bash. The write side already refuses an incomplete
151
+ // stream (`pi.ts` throws on no terminal event) precisely so "called
152
+ // nothing" and "recorded nothing" cannot look the same; the read side
153
+ // has to hold the same line.
154
+ const lines = existsSync(tp)
155
+ ? readFileSync(tp, "utf8").split("\n").filter((l) => l.trim())
156
+ : [];
157
+ const parsed = lines.map((l) => deserializeTrace(l));
158
+ const usable = parsed.filter((t) => t !== null);
159
+ const merged = usable.length === lines.length ? mergeTraces(usable) : null;
160
+ if (merged === null) {
161
+ traceFailure =
162
+ usable.length === lines.length
163
+ ? "objective: saved trace is missing or unreadable — cannot re-evaluate assert.trace"
164
+ : `objective: saved trace is incomplete (${usable.length}/${lines.length} turns readable) — cannot re-evaluate assert.trace`;
165
+ objective = { status: "ERROR", assertions: [] };
166
+ }
167
+ else {
168
+ const g = evaluateTraceGates(scenario.traceAssert, merged);
169
+ objective = { status: g.status, trace_version: merged.trace_version, trace_sha256: merged.trace_sha256, assertions: g.assertions };
170
+ if (g.status === "FAIL" || g.status === "ERROR") {
171
+ const bad = g.assertions.filter((x) => x.status === g.status).map((x) => x.detail);
172
+ traceFailure = `objective: ${bad.join("; ")}`;
173
+ }
174
+ }
175
+ }
176
+ const gate = { lines: needleGate.lines, failure: needleGate.failure ?? traceFailure };
123
177
  const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
124
178
  const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
125
- const oldGateFailed = GATE_FAILED_RE.test(before.slice(before.indexOf(TRAILER)));
179
+ // Two sources, because the two gate kinds record their prior state
180
+ // differently: a seeded needle gate leaves a trailer in the transcript, a
181
+ // trace gate leaves an `objective` block on the result. Reading only the
182
+ // trailer meant a trace gate flipping to PASS never triggered the re-judge
183
+ // it needs, leaving a stale FAIL verdict beside a PASS objective.
184
+ const oldObjectiveFailed = rec.objective?.status === "FAIL" || rec.objective?.status === "ERROR";
185
+ const oldGateFailed = GATE_FAILED_RE.test(before.slice(before.indexOf(TRAILER))) || oldObjectiveFailed;
126
186
  // The trailer is regenerated whatever the outcome: leaving a stale
127
187
  // `MISSING` note beside a corrected verdict would misinform the next reader
128
188
  // (and the next judge, which reads this transcript).
@@ -130,23 +190,23 @@ export async function regateRun(opts) {
130
190
  rewriteTranscript(tPath, gate.lines);
131
191
  if (gate.failure) {
132
192
  gateFailedHere = true;
133
- outcomes.push({ verdict: "FAIL", reason: gate.failure, suspect: false });
193
+ outcomes.push({ verdict: "FAIL", reason: gate.failure, suspect: false, objective });
134
194
  continue;
135
195
  }
136
196
  if (!oldGateFailed) {
137
197
  // The judge already saw this rep. Its verdict is on disk — re-read it rather
138
198
  // than paying to ask the same question again.
139
199
  const saved = verdictFromSavedJudgement(opts.runDir, scenario.id, mode, rep);
140
- outcomes.push(saved ?? { verdict: rec.judge_verdict, reason: rec.judge_reason, suspect: rec.suspect });
200
+ outcomes.push({ ...(saved ?? { verdict: rec.judge_verdict, reason: rec.judge_reason, suspect: rec.suspect }), objective });
141
201
  continue;
142
202
  }
143
203
  // The gate blocked this rep before, so no judgement of it exists anywhere.
144
204
  const transcript = readFileSync(tPath, "utf8");
145
- outcomes.push(await judgeOneRep({
146
- runDir: opts.runDir, spec: opts.spec, scenario, transcript,
147
- adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
148
- mode, rep, now,
149
- }));
205
+ outcomes.push({ ...(await judgeOneRep({
206
+ runDir: opts.runDir, spec: opts.spec, scenario, transcript,
207
+ adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
208
+ mode, rep, now,
209
+ })), objective });
150
210
  judgeCalls++;
151
211
  judgedHere++;
152
212
  }
@@ -154,7 +214,10 @@ export async function regateRun(opts) {
154
214
  const next = outcomesToResult(scenario.id, outcomes, outcomes.length, threshold);
155
215
  // Overrides and their notes survive: a regate re-decides the gate, and an author
156
216
  // override is a statement about the judge, not about the needle.
157
- scenarios.push({ ...next, override: rec.override, note: rec.note });
217
+ // `regate` re-evaluates gates from saved artifacts and asks no judge anything:
218
+ // `objective` is freshly recomputed above, and the recorded judge panel still
219
+ // describes the current judgments.
220
+ scenarios.push(rebuildScenarioResult(next, rec, { objective: "fresh", adjudication: "carry" }));
158
221
  const to = next.judge_verdict;
159
222
  if (to !== rec.judge_verdict) {
160
223
  changes.push({
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, scoreContextFor, } from "./results.js";
4
+ import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, rebuildScenarioResult, } 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";
@@ -85,7 +85,18 @@ export async function regradeRun(opts) {
85
85
  const { runDir, spec, adapter, judge, specDir } = opts;
86
86
  const now = opts.now ?? (() => new Date().toISOString());
87
87
  const prev = existsSync(join(runDir, "results.yaml")) ? readResults(runDir) : null;
88
- const overrides = new Map((prev?.scenarios ?? []).map((s) => [s.id, { override: s.override, note: s.note }]));
88
+ // Carried across a re-judge, per field, because the right answer differs:
89
+ // - `override`/`note` — the author's, never the judge's to discard.
90
+ // - `objective` — a re-judge does NOT re-evaluate trace gates (that is
91
+ // `regate`), so the recorded evidence still describes this run. Dropping it
92
+ // silently downgraded a gated scenario to "no assertions declared".
93
+ // `adjudication` is deliberately NOT carried: it describes the judgments this
94
+ // re-grade just replaced, and a stale panel beside a fresh verdict is worse
95
+ // than none. `grade --auto-rejudge` recomputes it.
96
+ // The whole prior result per id — `rebuildScenarioResult` decides, field by
97
+ // field, what survives. Passing a hand-picked subset here is how fields got
98
+ // dropped before.
99
+ const overrides = new Map((prev?.scenarios ?? []).map((s) => [s.id, s]));
89
100
  const mode = prev?.mode ?? "green";
90
101
  // Re-grading rewrites the WHOLE results.yaml, so re-judge exactly the
91
102
  // scenarios the run recorded (falling back to the spec for a run with no
@@ -131,7 +142,10 @@ export async function regradeRun(opts) {
131
142
  runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
132
143
  });
133
144
  const carry = overrides.get(id);
134
- scenarioResults.push({ ...rr, override: carry?.override ?? null, note: carry?.note ?? "" });
145
+ // `grade` re-judges the saved transcript. It does not re-evaluate trace gates
146
+ // (that is `regate`), so `objective` still describes this run; and it replaced
147
+ // the judgments a prior adjudication described, so that panel must go.
148
+ scenarioResults.push(rebuildScenarioResult(rr, carry, { objective: "carry", adjudication: "drop" }));
135
149
  }
136
150
  const ctx = scoreContextFor({ mode, partial: prev?.partial }, spec);
137
151
  const results = writeResults(runDir, {
package/dist/report.d.ts CHANGED
@@ -14,12 +14,36 @@ export interface RunColumn {
14
14
  judge_verdict: string;
15
15
  judge_reason: string;
16
16
  suspect: boolean;
17
+ /** Objective trace-gate outcome, when the scenario declared `assert.trace`. */
18
+ objective?: {
19
+ status: string;
20
+ detail: string;
21
+ };
22
+ /** Adjudication outcome, when the cell was re-judged. */
23
+ adjudication?: {
24
+ state: string;
25
+ trigger: string;
26
+ count: number;
27
+ detail: string;
28
+ };
17
29
  reps?: number;
18
30
  passes?: number;
19
31
  clean?: number;
20
32
  flakiness?: number;
21
33
  override: string | null;
22
34
  note: string;
35
+ /**
36
+ * Run-over-run history for this cell, derived from the tag's other runs in the
37
+ * SAME mode. Present only for a cell that flipped (`state: "boundary"`) — a
38
+ * marker on every cell would bury the one signal it exists to show, and the
39
+ * per-cell `flakiness` beside it is a within-run number that cannot see this.
40
+ */
41
+ stability?: {
42
+ flips: number;
43
+ compared: number;
44
+ volatility: number | null;
45
+ note: string;
46
+ };
23
47
  }>;
24
48
  /**
25
49
  * Baseline-vs-skill lift for this model, when the tag has both a red baseline and
@@ -77,12 +101,36 @@ export declare function publicView(data: ReportData): {
77
101
  judge_verdict: string;
78
102
  judge_reason: string;
79
103
  suspect: boolean;
104
+ /** Objective trace-gate outcome, when the scenario declared `assert.trace`. */
105
+ objective?: {
106
+ status: string;
107
+ detail: string;
108
+ };
109
+ /** Adjudication outcome, when the cell was re-judged. */
110
+ adjudication?: {
111
+ state: string;
112
+ trigger: string;
113
+ count: number;
114
+ detail: string;
115
+ };
80
116
  reps?: number;
81
117
  passes?: number;
82
118
  clean?: number;
83
119
  flakiness?: number;
84
120
  override: string | null;
85
121
  note: string;
122
+ /**
123
+ * Run-over-run history for this cell, derived from the tag's other runs in the
124
+ * SAME mode. Present only for a cell that flipped (`state: "boundary"`) — a
125
+ * marker on every cell would bury the one signal it exists to show, and the
126
+ * per-cell `flakiness` beside it is a within-run number that cannot see this.
127
+ */
128
+ stability?: {
129
+ flips: number;
130
+ compared: number;
131
+ volatility: number | null;
132
+ note: string;
133
+ };
86
134
  }>;
87
135
  }[];
88
136
  };
package/dist/report.js CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { loadSpec } from "./spec.js";
4
4
  import { readResults } from "./results.js";
5
5
  import { collectLift, liftHeadline } from "./lift.js";
6
+ import { boundaryCells, collectStability, stabilityNote } from "./stability.js";
6
7
  /** Most-recent run dir (by name, which is an ISO-ish slug) under a model-tag dir. */
7
8
  function latestRunDir(tagDir) {
8
9
  if (!statSync(tagDir).isDirectory())
@@ -24,6 +25,9 @@ export function collectReport(skillDir) {
24
25
  const resultsRoot = join(skillDir, "tests", "results");
25
26
  // Lift is keyed by model tag, the same key columns are built from.
26
27
  const liftByTag = new Map(collectLift(skillDir).map((l) => [l.tag, l]));
28
+ // Stability is keyed by tag + mode + scenario: a column shows one delivery mode, and
29
+ // green and force histories are never one series (placement moves verdicts).
30
+ const boundaryByCell = new Map(boundaryCells(collectStability(skillDir)).map((c) => [`${c.tag}\u0000${c.mode}\u0000${c.id}`, c]));
27
31
  const columns = [];
28
32
  if (existsSync(resultsRoot)) {
29
33
  const tags = readdirSync(resultsRoot)
@@ -35,9 +39,43 @@ export function collectReport(skillDir) {
35
39
  if (!runDir)
36
40
  continue;
37
41
  const r = readResults(runDir);
42
+ const tagName = tagDir.split("/").pop();
38
43
  const cells = {};
39
44
  for (const s of r.scenarios) {
45
+ const boundary = boundaryByCell.get(`${tagName}\u0000${r.mode}\u0000${s.id}`);
40
46
  cells[s.id] = {
47
+ ...(boundary
48
+ ? {
49
+ stability: {
50
+ flips: boundary.flips, compared: boundary.compared,
51
+ volatility: boundary.volatility, note: stabilityNote(boundary),
52
+ },
53
+ }
54
+ : {}),
55
+ // Same optional-spread shape as `stability`: absent means "not declared"
56
+ // / "single judge", and the UI must not render either as a clean result.
57
+ ...(s.objective
58
+ ? {
59
+ objective: {
60
+ status: s.objective.status,
61
+ detail: s.objective.assertions.length
62
+ ? s.objective.assertions.map((a) => `${a.status} ${a.detail}`).join(" · ")
63
+ : "no assertion evidence recorded",
64
+ },
65
+ }
66
+ : {}),
67
+ ...(s.adjudication
68
+ ? {
69
+ adjudication: {
70
+ state: s.adjudication.state,
71
+ trigger: s.adjudication.trigger,
72
+ count: s.adjudication.judgments.length,
73
+ detail: s.adjudication.judgments
74
+ .map((j) => `#${j.ordinal} ${j.judge.provider}:${j.judge.model} ${j.verdict}${j.suspect ? " (misfired, not counted)" : ""}`)
75
+ .join(" · "),
76
+ },
77
+ }
78
+ : {}),
41
79
  judge_verdict: s.judge_verdict,
42
80
  judge_reason: s.judge_reason,
43
81
  suspect: s.suspect ?? false, // suspect defaults false for older results that predate the field
@@ -49,7 +87,7 @@ export function collectReport(skillDir) {
49
87
  note: s.note,
50
88
  };
51
89
  }
52
- const tag = tagDir.split("/").pop();
90
+ const tag = tagName;
53
91
  // A column is the tag's LATEST run, which is not necessarily the skill-side
54
92
  // one — record a red baseline after a green run and the newest run in the tag
55
93
  // is red. The review UI recomputes lift from `cells` (so author overrides move
package/dist/reps.d.ts CHANGED
@@ -1,11 +1,24 @@
1
1
  import type { Verdict } from "./score.js";
2
- import type { ScenarioResult } from "./results.js";
2
+ import type { ScenarioResult, ObjectiveResult } from "./results.js";
3
3
  /** One rep's outcome (subject run + judge). */
4
4
  export interface RepOutcome {
5
5
  verdict: Verdict;
6
6
  reason: string;
7
7
  suspect: boolean;
8
+ /** Present only when the scenario declared `assert.trace`. */
9
+ objective?: ObjectiveResult;
8
10
  }
11
+ /**
12
+ * Collapse per-rep objective results.
13
+ *
14
+ * Strict on purpose, and deliberately NOT the same policy as the judge's
15
+ * pass-threshold aggregation: an objective assertion is a statement about what
16
+ * the model DID, so one rep that called a forbidden tool is a real finding, not
17
+ * a minority draw to be voted away. ERROR dominates (missing evidence is never a
18
+ * pass), then FAIL, then PASS. The retained assertion detail comes from the
19
+ * first non-passing rep, since that is the one worth reading.
20
+ */
21
+ export declare function aggregateObjective(outcomes: RepOutcome[]): ObjectiveResult | undefined;
9
22
  /** A scenario's aggregated result over N reps. */
10
23
  export interface RepAggregate {
11
24
  verdict: Verdict;
package/dist/reps.js CHANGED
@@ -1,3 +1,25 @@
1
+ /**
2
+ * Collapse per-rep objective results.
3
+ *
4
+ * Strict on purpose, and deliberately NOT the same policy as the judge's
5
+ * pass-threshold aggregation: an objective assertion is a statement about what
6
+ * the model DID, so one rep that called a forbidden tool is a real finding, not
7
+ * a minority draw to be voted away. ERROR dominates (missing evidence is never a
8
+ * pass), then FAIL, then PASS. The retained assertion detail comes from the
9
+ * first non-passing rep, since that is the one worth reading.
10
+ */
11
+ export function aggregateObjective(outcomes) {
12
+ const present = outcomes.map((o) => o.objective).filter((o) => o !== undefined);
13
+ if (present.length === 0)
14
+ return undefined;
15
+ const errored = present.find((o) => o.status === "ERROR");
16
+ if (errored)
17
+ return errored;
18
+ const failed = present.find((o) => o.status === "FAIL");
19
+ if (failed)
20
+ return failed;
21
+ return present[0];
22
+ }
1
23
  /**
2
24
  * Collapse N rep outcomes into one scenario verdict. A rep is "clean" when its
3
25
  * judge did not misfire. If fewer than half the reps are clean the scenario is
@@ -31,15 +53,19 @@ export function aggregateReps(outcomes, threshold) {
31
53
  * caller to merge.
32
54
  */
33
55
  export function outcomesToResult(id, outcomes, repCount, threshold) {
56
+ // Spread rather than always-set: a scenario with no trace assertions must
57
+ // produce a result byte-identical to one from before this field existed.
58
+ const objective = aggregateObjective(outcomes);
59
+ const objectiveField = objective ? { objective } : {};
34
60
  if (repCount === 1) {
35
61
  const o = outcomes[0];
36
- return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, override: null, note: "" };
62
+ return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, override: null, note: "", ...objectiveField };
37
63
  }
38
64
  const agg = aggregateReps(outcomes, threshold);
39
65
  return {
40
66
  id, judge_verdict: agg.verdict, judge_reason: agg.reason, suspect: agg.suspect,
41
67
  reps: agg.reps, passes: agg.passes, clean: agg.clean, flakiness: agg.flakiness,
42
- pass_threshold: threshold, override: null, note: "",
68
+ pass_threshold: threshold, override: null, note: "", ...objectiveField,
43
69
  };
44
70
  }
45
71
  //# sourceMappingURL=reps.js.map
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, scoreContextFor } from "./results.js";
3
+ import { readResults, writeResults, scoreContextFor, rebuildScenarioResult } from "./results.js";
4
4
  import { appendJournal } from "./journal.js";
5
5
  import { policyDigest, POLICY_PREFIX } from "./sources.js";
6
6
  /**
@@ -65,7 +65,16 @@ export function rescoreRun(opts) {
65
65
  if (verdict !== s.judge_verdict) {
66
66
  changes.push({ id: s.id, from: s.judge_verdict, to: verdict, passes: s.passes, clean: s.clean, fromThreshold, toThreshold });
67
67
  }
68
- return { ...s, judge_verdict: verdict, pass_threshold: toThreshold };
68
+ // Through the choke point, not a spread. This was the FIFTH rewriter of a
69
+ // `ScenarioResult` and the only one still using `{ ...s }` — so it inherited
70
+ // none of the invariants the others get, and adding `objective` and
71
+ // `adjudication` to the type did not fail the build here. Concretely: a cell
72
+ // that adjudication settled FAIL reverted to PASS when a threshold change
73
+ // recomputed it from rep counters that adjudication never updated.
74
+ //
75
+ // Both blocks are CARRIED: a rescore re-applies a threshold to reps that were
76
+ // already measured. It re-measures nothing, so it may discard nothing.
77
+ return rebuildScenarioResult({ ...s, judge_verdict: verdict, pass_threshold: toThreshold }, s, { objective: "carry", adjudication: "carry" });
69
78
  });
70
79
  const ctx = scoreContextFor(prev, opts.spec);
71
80
  const results = writeResults(opts.runDir, {