@skill-harness/core 0.7.0 → 0.9.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 (44) hide show
  1. package/dist/adapters/types.d.ts +8 -0
  2. package/dist/adjudication.d.ts +1 -0
  3. package/dist/adjudication.js +26 -4
  4. package/dist/capture-trace-types.d.ts +19 -0
  5. package/dist/comparison.d.ts +96 -0
  6. package/dist/comparison.js +183 -0
  7. package/dist/execution-trace.js +93 -3
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +4 -0
  10. package/dist/lint.d.ts +7 -0
  11. package/dist/lint.js +14 -2
  12. package/dist/regate.d.ts +1 -1
  13. package/dist/regate.js +94 -21
  14. package/dist/regrade.d.ts +4 -0
  15. package/dist/regrade.js +29 -5
  16. package/dist/report.d.ts +7 -0
  17. package/dist/report.js +6 -0
  18. package/dist/reps.d.ts +8 -1
  19. package/dist/reps.js +47 -13
  20. package/dist/rescore.js +2 -2
  21. package/dist/restamp.d.ts +68 -0
  22. package/dist/restamp.js +194 -0
  23. package/dist/results.d.ts +28 -0
  24. package/dist/results.js +56 -4
  25. package/dist/run.js +96 -35
  26. package/dist/sandbox.d.ts +32 -0
  27. package/dist/sandbox.js +12 -0
  28. package/dist/score.d.ts +3 -1
  29. package/dist/score.js +20 -5
  30. package/dist/seeded.d.ts +7 -1
  31. package/dist/seeded.js +29 -8
  32. package/dist/sources.d.ts +50 -0
  33. package/dist/sources.js +186 -7
  34. package/dist/spec.d.ts +15 -0
  35. package/dist/spec.js +53 -1
  36. package/dist/stability.js +26 -4
  37. package/dist/trace-gates.d.ts +2 -1
  38. package/dist/trace-gates.js +4 -1
  39. package/dist/trajectory-gates.d.ts +137 -0
  40. package/dist/trajectory-gates.js +592 -0
  41. package/package.json +5 -3
  42. package/schemas/results-v2.schema.json +133 -0
  43. package/schemas/specification-v1.schema.json +217 -0
  44. package/schemas/trajectory-event-v1.schema.json +61 -0
package/dist/lint.js CHANGED
@@ -4,7 +4,7 @@ import yaml from "js-yaml";
4
4
  import { loadSpec, SpecError } from "./spec.js";
5
5
  import { computeCoverage } from "./instruction-coverage.js";
6
6
  import { readResults, finalizeResults, findTranscriptFiles, resultsPath, scoreContextFor } from "./results.js";
7
- import { currentHashFor, describeSourceKey, remedyForKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, STIMULUS_PREFIX, UNREADABLE } from "./sources.js";
7
+ import { currentHashFor, describeSourceKey, isSupersededKey, remedyForKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, STIMULUS_PREFIX, UNREADABLE } from "./sources.js";
8
8
  import { downgradeWarning } from "./downgrade.js";
9
9
  import { collectScoredRuns } from "./trends.js";
10
10
  import { boundaryCells, stabilityFrom, stabilityNote } from "./stability.js";
@@ -60,6 +60,12 @@ export function lintSkill(skillDir) {
60
60
  findings.push({ skill, code: "consistency", message: stale.replace(/^warning: /, "") });
61
61
  }
62
62
  // ship_bar sanity
63
+ if (!Number.isInteger(spec.ship_bar.total)) {
64
+ findings.push({ skill, code: "ship_bar", message: "ship_bar.total must be an integer" });
65
+ }
66
+ if (!Number.isInteger(spec.ship_bar.min_pass)) {
67
+ findings.push({ skill, code: "ship_bar", message: "ship_bar.min_pass must be an integer" });
68
+ }
63
69
  if (spec.ship_bar.total < 1) {
64
70
  findings.push({ skill, code: "ship_bar", message: "ship_bar.total must be >= 1" });
65
71
  }
@@ -247,6 +253,12 @@ export function lintSkill(skillDir) {
247
253
  const newest = full.runDir;
248
254
  const ctx = { skillDir, specDir, scenarios: spec.scenarios, judgePersona: spec.judge_persona };
249
255
  for (const [key, recorded] of Object.entries(hashes)) {
256
+ // A raw-bytes key whose model-visible digest this run also recorded is not the
257
+ // honest comparison: it reports a `description`-reword or an `allowed-tools:`
258
+ // line — text no graded run could observe — as a reason to re-run. The pair is
259
+ // recorded together and exactly one of them is compared.
260
+ if (isSupersededKey(key, hashes))
261
+ continue;
250
262
  const what = describeSourceKey(key);
251
263
  const scenario = scenarioIdForKey(key, spec.scenarios);
252
264
  // The run itself failed to hash this source, so it was never verified and
@@ -349,7 +361,7 @@ function runDirsNewestFirst(tagDir) {
349
361
  * TOCTOU removal between readdir and statSync) are skipped rather than propagated, so a
350
362
  * single bad entry can't abort lintSkill's "never throws" contract.
351
363
  */
352
- function enumerateRunDirs(resultsRoot) {
364
+ export function enumerateRunDirs(resultsRoot) {
353
365
  if (!existsSync(resultsRoot))
354
366
  return [];
355
367
  const out = [];
package/dist/regate.d.ts CHANGED
@@ -15,7 +15,7 @@ export interface RegateChange {
15
15
  from: Verdict;
16
16
  to: Verdict;
17
17
  /** What the corrected needles say about this scenario now. */
18
- gate: "pass" | "fail";
18
+ gate: "pass" | "fail" | "error";
19
19
  /** Whether re-deciding it required a judge call (only a gate-FAIL → gate-pass flip does). */
20
20
  judged: boolean;
21
21
  }
package/dist/regate.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { parseVerdict, detectMisfire } from "./grade.js";
4
4
  import { evaluateNeedleGates, hasNeedleGates } from "./seeded.js";
5
5
  import { evaluateTraceGates } from "./trace-gates.js";
6
6
  import { mergeTraces, deserializeTrace } from "./execution-trace.js";
7
+ import { deserializeTrajectoryEvents, evaluateTrajectoryGates } from "./trajectory-gates.js";
7
8
  import { judgeOneRep } from "./regrade.js";
8
- import { readResults, writeResults, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, findTraceFiles, tracePath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, } from "./results.js";
9
+ import { readResults, writeResults, diffPath, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, findTraceFiles, findTrajectoryFiles, tracePath, trajectoryPath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, } from "./results.js";
9
10
  import { outcomesToResult } from "./reps.js";
10
11
  import { appendJournal } from "./journal.js";
11
12
  import { gatesDigest, GATES_PREFIX } from "./sources.js";
@@ -88,30 +89,39 @@ export async function regateRun(opts) {
88
89
  const s = specById.get(rec.id);
89
90
  const needles = hasNeedleGates(s ?? {});
90
91
  const traceGated = Boolean(s?.traceAssert);
91
- if (!s || (!needles && !traceGated))
92
+ const trajectoryGated = Boolean(s?.trajectoryAssert);
93
+ if (!s || (!needles && !traceGated && !trajectoryGated))
92
94
  continue; // nothing for regate to re-decide
93
95
  if (s.assert?.vitest || s.assert?.post_test) {
94
96
  blocked.push(`${s.id}: declares ${s.assert.vitest ? "assert.vitest" : "assert.post_test"}, which needs the workspace — ` +
95
97
  `no saved artifact can stand in for it, so this scenario needs a re-run`);
96
98
  continue;
97
99
  }
98
- if (needles && findDiffFiles(opts.runDir, s.id, mode).length === 0) {
99
- blocked.push(`${s.id}: no staged-diff artifact on disk (\`.diff.txt\` is gitignored — regate needs the run dir that produced it)`);
100
+ const expectedReps = rec.reps ?? 1;
101
+ const expectedSuffixes = expectedReps === 1 ? [undefined] : Array.from({ length: expectedReps }, (_, index) => index);
102
+ const complete = (paths, expectedPaths) => paths.length === expectedPaths.length && expectedPaths.every((path) => paths.includes(basename(path)));
103
+ if (needles && !complete(findDiffFiles(opts.runDir, s.id, mode), expectedSuffixes.map((rep) => diffPath(opts.runDir, s.id, mode, rep)))) {
104
+ blocked.push(`${s.id}: staged-diff artifacts are incomplete for ${expectedReps} recorded rep(s) — regate refuses a partial repetition set`);
100
105
  continue;
101
106
  }
102
107
  // A trace gate is only re-decidable from a saved trace. A run recorded before
103
108
  // traces existed has none, and saying so is the whole point — pretending
104
109
  // 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`);
110
+ if (traceGated && !complete(findTraceFiles(opts.runDir, s.id, mode), expectedSuffixes.map((rep) => tracePath(opts.runDir, s.id, mode, rep)))) {
111
+ blocked.push(`${s.id}: assert.trace artifacts are incomplete for ${expectedReps} recorded rep(s) ` +
112
+ `(traces are gitignored, or capture failed) — it needs a re-run`);
113
+ continue;
114
+ }
115
+ if (trajectoryGated && !complete(findTrajectoryFiles(opts.runDir, s.id, mode), expectedSuffixes.map((rep) => trajectoryPath(opts.runDir, s.id, mode, rep)))) {
116
+ blocked.push(`${s.id}: assert.trajectory artifacts are incomplete for ${expectedReps} recorded rep(s) ` +
117
+ `(events are gitignored, or required native evidence was missing) — it needs a re-run`);
108
118
  continue;
109
119
  }
110
120
  targets.push(s);
111
121
  }
112
122
  if (targets.length === 0) {
113
123
  throw new Error(`nothing to regate in ${opts.runDir}` +
114
- (blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff_contains/diff_excludes or assert.trace"));
124
+ (blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff_contains/diff_excludes, assert.trace, or assert.trajectory"));
115
125
  }
116
126
  const changes = [];
117
127
  let judgeCalls = 0;
@@ -124,17 +134,21 @@ export async function regateRun(opts) {
124
134
  }
125
135
  const diffFiles = findDiffFiles(opts.runDir, scenario.id, mode);
126
136
  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.
137
+ const eventFiles = findTrajectoryFiles(opts.runDir, scenario.id, mode);
138
+ // Reps come from whichever artifact this scenario actually has. A trajectory-only
139
+ // scenario has neither diff nor trace, so falling back only once would regate
140
+ // nothing and report success.
130
141
  const repKeys = diffFiles.length > 0
131
142
  ? diffFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: f }))
132
- : traceFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: undefined }));
143
+ : traceFiles.length > 0
144
+ ? traceFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: undefined }))
145
+ : eventFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: undefined }));
133
146
  const outcomes = [];
134
147
  // Per scenario, not run-wide: with several regated scenarios, a global counter
135
148
  // would report every change as "re-judged" because some other scenario was.
136
149
  let judgedHere = 0;
137
150
  let gateFailedHere = false;
151
+ let gateErroredHere = false;
138
152
  for (const { rep, diffFile } of repKeys) {
139
153
  const diff = diffFile ? readFileSync(join(opts.runDir, diffFile), "utf8") : "";
140
154
  const needleGate = diffFile ? evaluateNeedleGates(scenario, diff) : { lines: [], failure: null };
@@ -166,14 +180,69 @@ export async function regateRun(opts) {
166
180
  }
167
181
  else {
168
182
  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);
183
+ const expectedHash = rec.objective?.rep_trace_sha256?.[rep ?? 0] ?? (rec.reps === undefined ? rec.objective?.trace_sha256 : undefined);
184
+ const digestMismatch = expectedHash !== undefined && expectedHash !== merged.trace_sha256;
185
+ const integrityAssertion = digestMismatch
186
+ ? [{ kind: "trace_evidence", status: "ERROR", detail: "saved trace no longer matches the hash recorded by the run" }]
187
+ : [];
188
+ objective = { status: digestMismatch ? "ERROR" : g.status, trace_version: merged.trace_version, trace_sha256: merged.trace_sha256, assertions: [...integrityAssertion, ...g.assertions] };
189
+ if (g.status === "FAIL" || g.status === "ERROR" || digestMismatch) {
190
+ const bad = [...integrityAssertion.map((x) => x.detail), ...g.assertions.filter((x) => x.status === g.status && g.status !== "PASS").map((x) => x.detail)];
172
191
  traceFailure = `objective: ${bad.join("; ")}`;
173
192
  }
174
193
  }
175
194
  }
176
- const gate = { lines: needleGate.lines, failure: needleGate.failure ?? traceFailure };
195
+ // Adapter-neutral trajectory gate, replayed from the saved normalized
196
+ // event artifact. Native ledgers are deliberately not re-read here: the
197
+ // artifact is the immutable evidence this run actually captured.
198
+ let trajectoryFailure = null;
199
+ if (scenario.trajectoryAssert) {
200
+ const ep = trajectoryPath(opts.runDir, scenario.id, mode, rep);
201
+ const events = existsSync(ep) ? deserializeTrajectoryEvents(readFileSync(ep, "utf8")) : null;
202
+ // The normalized artifact contains only events that were successfully captured.
203
+ // Source-read/normalization failures are persisted on the original objective;
204
+ // dropping them here would let a partial ledger become PASS on replay.
205
+ const priorEvidenceErrors = (rec.objective?.assertions ?? [])
206
+ .filter((result) => result.kind === "trajectory_evidence" && result.status === "ERROR");
207
+ if (!events) {
208
+ trajectoryFailure = "objective: saved normalized events are missing, malformed, or from an unsupported version";
209
+ objective = {
210
+ ...(objective ?? { assertions: [] }),
211
+ status: "ERROR",
212
+ assertions: [...(objective?.assertions ?? []), { kind: "trajectory_evidence", status: "ERROR", detail: trajectoryFailure }],
213
+ };
214
+ }
215
+ else {
216
+ const g = evaluateTrajectoryGates(scenario.trajectoryAssert, events);
217
+ const expectedHash = rec.objective?.rep_events_sha256?.[rep ?? 0] ?? (rec.reps === undefined ? rec.objective?.events_sha256 : undefined);
218
+ const digestMismatch = expectedHash !== undefined && expectedHash !== g.events_sha256;
219
+ const integrityAssertion = digestMismatch
220
+ ? [{ kind: "trajectory_evidence", status: "ERROR", detail: "saved normalized events no longer match the hash recorded by the run" }]
221
+ : [];
222
+ const priorStatus = objective?.status ?? "PASS";
223
+ const status = priorStatus === "ERROR" || g.status === "ERROR" || priorEvidenceErrors.length || digestMismatch
224
+ ? "ERROR"
225
+ : priorStatus === "FAIL" || g.status === "FAIL"
226
+ ? "FAIL"
227
+ : "PASS";
228
+ objective = {
229
+ ...(objective ?? {}),
230
+ status,
231
+ trajectory_version: g.event_version,
232
+ events_sha256: g.events_sha256,
233
+ assertions: [...(objective?.assertions ?? []), ...priorEvidenceErrors, ...integrityAssertion, ...g.assertions],
234
+ };
235
+ if (g.status !== "PASS" || priorEvidenceErrors.length || digestMismatch) {
236
+ const details = [
237
+ ...priorEvidenceErrors.map((result) => result.detail),
238
+ ...integrityAssertion.map((result) => result.detail),
239
+ ...g.assertions.filter((result) => result.status === g.status && g.status !== "PASS").map((result) => result.detail),
240
+ ];
241
+ trajectoryFailure = `objective: ${details.join("; ")}`;
242
+ }
243
+ }
244
+ }
245
+ const gate = { lines: needleGate.lines, failure: needleGate.failure ?? traceFailure ?? trajectoryFailure };
177
246
  const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
178
247
  const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
179
248
  // Two sources, because the two gate kinds record their prior state
@@ -189,8 +258,11 @@ export async function regateRun(opts) {
189
258
  if (existsSync(tPath))
190
259
  rewriteTranscript(tPath, gate.lines);
191
260
  if (gate.failure) {
192
- gateFailedHere = true;
193
- outcomes.push({ verdict: "FAIL", reason: gate.failure, suspect: false, objective });
261
+ if (objective?.status === "ERROR")
262
+ gateErroredHere = true;
263
+ else
264
+ gateFailedHere = true;
265
+ outcomes.push({ verdict: objective?.status === "ERROR" ? "ERROR" : "FAIL", reason: gate.failure, suspect: false, objective });
194
266
  continue;
195
267
  }
196
268
  if (!oldGateFailed) {
@@ -205,13 +277,14 @@ export async function regateRun(opts) {
205
277
  outcomes.push({ ...(await judgeOneRep({
206
278
  runDir: opts.runDir, spec: opts.spec, scenario, transcript,
207
279
  adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
208
- mode, rep, now,
280
+ mode, rep, now, rejudge: true,
209
281
  })), objective });
210
282
  judgeCalls++;
211
283
  judgedHere++;
212
284
  }
213
285
  const threshold = effectiveThreshold(rec, scenario);
214
286
  const next = outcomesToResult(scenario.id, outcomes, outcomes.length, threshold);
287
+ next.metrics = mergeScenarioMetrics(rec.metrics, next.metrics);
215
288
  // Overrides and their notes survive: a regate re-decides the gate, and an author
216
289
  // override is a statement about the judge, not about the needle.
217
290
  // `regate` re-evaluates gates from saved artifacts and asks no judge anything:
@@ -222,7 +295,7 @@ export async function regateRun(opts) {
222
295
  if (to !== rec.judge_verdict) {
223
296
  changes.push({
224
297
  id: scenario.id, from: rec.judge_verdict, to,
225
- gate: gateFailedHere ? "fail" : "pass",
298
+ gate: gateErroredHere ? "error" : gateFailedHere ? "fail" : "pass",
226
299
  judged: judgedHere > 0,
227
300
  });
228
301
  }
package/dist/regrade.d.ts CHANGED
@@ -32,6 +32,8 @@ export interface RegradeOptions {
32
32
  * with "nothing to re-grade", which is how ten scorable runs stayed ungraded.
33
33
  */
34
34
  mode?: string;
35
+ /** Recorded repetition count; when supplied, missing/extra rep artifacts fail before judge calls. */
36
+ expectedReps?: number;
35
37
  now?: () => string;
36
38
  }
37
39
  /** 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. */
@@ -46,6 +48,8 @@ export declare function judgeOneRep(opts: {
46
48
  mode: string;
47
49
  rep: number | undefined;
48
50
  now: () => string;
51
+ /** True when this call revisits a saved subject transcript. */
52
+ rejudge?: boolean;
49
53
  }): Promise<RepOutcome>;
50
54
  /**
51
55
  * Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
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, rebuildScenarioResult, } from "./results.js";
4
+ import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, } 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";
@@ -36,6 +36,7 @@ export function refreshRubricHashes(recorded, spec, judgedIds) {
36
36
  /** 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. */
37
37
  export async function judgeOneRep(opts) {
38
38
  const { runDir, spec, scenario, transcript, adapter, judge, specDir, mode, rep, now } = opts;
39
+ const startedAt = performance.now();
39
40
  const prompt = buildJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript });
40
41
  const g = await judgeInWorkspace(adapter, judge, prompt, specDir);
41
42
  writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), g.raw, "utf8");
@@ -43,7 +44,14 @@ export async function judgeOneRep(opts) {
43
44
  appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: g.verdict, reason: g.reason, suspect: g.suspect, ...repField });
44
45
  if (g.suspect)
45
46
  appendJournal(runDir, { event: "misfire-flag", ts: now(), id: scenario.id, reason: g.reason, ...repField });
46
- return { verdict: g.verdict, reason: g.reason, suspect: g.suspect };
47
+ return {
48
+ verdict: g.verdict, reason: g.reason, suspect: g.suspect,
49
+ metrics: {
50
+ wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
51
+ judge_calls: 1,
52
+ judge_rejudge_calls: opts.rejudge ? 1 : 0,
53
+ },
54
+ };
47
55
  }
48
56
  /**
49
57
  * Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
@@ -58,14 +66,20 @@ export async function regradeScenario(opts) {
58
66
  const files = findTranscriptFiles(opts.runDir, opts.scenario.id, mode);
59
67
  if (files.length === 0)
60
68
  throw new Error(`no ${mode} transcripts for ${opts.scenario.id} in ${opts.runDir}`);
61
- const repCount = files.length;
69
+ const expected = opts.expectedReps ?? files.length;
70
+ const expectedIndices = expected === 1 ? [null] : Array.from({ length: expected }, (_, index) => index);
71
+ const actualIndices = files.map((file) => repIndexOf(file)).sort((a, b) => (a ?? -1) - (b ?? -1));
72
+ if (files.length !== expected || JSON.stringify(actualIndices) !== JSON.stringify(expectedIndices)) {
73
+ throw new Error(`${opts.scenario.id}: transcript artifacts are incomplete for ${expected} recorded rep(s) — re-run instead of grading a smaller repetition set`);
74
+ }
75
+ const repCount = expected;
62
76
  const outcomes = [];
63
77
  for (const file of files) {
64
78
  const rep = repIndexOf(file) ?? undefined;
65
79
  const transcript = readFileSync(join(opts.runDir, file), "utf8");
66
80
  outcomes.push(await judgeOneRep({
67
81
  runDir: opts.runDir, spec: opts.spec, scenario: opts.scenario, transcript,
68
- adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode, rep, now,
82
+ adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode, rep, now, rejudge: true,
69
83
  }));
70
84
  }
71
85
  return outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
@@ -120,7 +134,15 @@ export async function regradeRun(opts) {
120
134
  return prev;
121
135
  }
122
136
  }
123
- const missing = targets.filter((id) => !specById.has(id) || findTranscriptFiles(runDir, id, mode).length === 0);
137
+ const completeTranscripts = (record) => {
138
+ const expected = record.reps ?? 1;
139
+ const files = findTranscriptFiles(runDir, record.id, mode);
140
+ const expectedIndices = expected === 1 ? [null] : Array.from({ length: expected }, (_, index) => index);
141
+ const actualIndices = files.map((file) => repIndexOf(file)).sort((a, b) => (a ?? -1) - (b ?? -1));
142
+ return files.length === expected && JSON.stringify(actualIndices) === JSON.stringify(expectedIndices);
143
+ };
144
+ const recordedById = new Map(recorded.map((record) => [record.id, record]));
145
+ const missing = targets.filter((id) => !specById.has(id) || !completeTranscripts(recordedById.get(id)));
124
146
  if (missing.length === targets.length) {
125
147
  throw new Error(`no ${mode} transcripts in ${runDir} — nothing to re-grade`);
126
148
  }
@@ -140,8 +162,10 @@ export async function regradeRun(opts) {
140
162
  const threshold = effectiveThreshold(prevScenario, scenario);
141
163
  const rr = await regradeScenario({
142
164
  runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
165
+ expectedReps: prevScenario?.reps ?? 1,
143
166
  });
144
167
  const carry = overrides.get(id);
168
+ rr.metrics = mergeScenarioMetrics(carry?.metrics, rr.metrics);
145
169
  // `grade` re-judges the saved transcript. It does not re-evaluate trace gates
146
170
  // (that is `regate`), so `objective` still describes this run; and it replaced
147
171
  // the judgments a prior adjudication described, so that panel must go.
package/dist/report.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ShipBar } from "./spec.js";
2
2
  import { type ResultsFile } from "./results.js";
3
3
  import { type Lift } from "./lift.js";
4
+ import { type AggregateMetrics } from "./comparison.js";
4
5
  export interface RunColumn {
5
6
  index: number;
6
7
  label: string;
@@ -8,8 +9,10 @@ export interface RunColumn {
8
9
  runDir: string;
9
10
  timestamp: string;
10
11
  mode: string;
12
+ partial: boolean;
11
13
  grade: ResultsFile["effective_grade"];
12
14
  judge: ResultsFile["judge"];
15
+ metrics: AggregateMetrics;
13
16
  cells: Record<string, {
14
17
  judge_verdict: string;
15
18
  judge_reason: string;
@@ -30,6 +33,7 @@ export interface RunColumn {
30
33
  passes?: number;
31
34
  clean?: number;
32
35
  flakiness?: number;
36
+ metrics?: import("./results.js").ScenarioMetrics;
33
37
  override: string | null;
34
38
  note: string;
35
39
  /**
@@ -92,11 +96,13 @@ export declare function publicView(data: ReportData): {
92
96
  tag: string;
93
97
  timestamp: string;
94
98
  mode: string;
99
+ partial: boolean;
95
100
  grade: import("./results.js").GradeSummary;
96
101
  judge: {
97
102
  provider: string;
98
103
  model: string;
99
104
  };
105
+ metrics: AggregateMetrics;
100
106
  cells: Record<string, {
101
107
  judge_verdict: string;
102
108
  judge_reason: string;
@@ -117,6 +123,7 @@ export declare function publicView(data: ReportData): {
117
123
  passes?: number;
118
124
  clean?: number;
119
125
  flakiness?: number;
126
+ metrics?: import("./results.js").ScenarioMetrics;
120
127
  override: string | null;
121
128
  note: string;
122
129
  /**
package/dist/report.js CHANGED
@@ -4,6 +4,7 @@ import { loadSpec } from "./spec.js";
4
4
  import { readResults } from "./results.js";
5
5
  import { collectLift, liftHeadline } from "./lift.js";
6
6
  import { boundaryCells, collectStability, stabilityNote } from "./stability.js";
7
+ import { aggregateMetrics } from "./comparison.js";
7
8
  /** Most-recent run dir (by name, which is an ISO-ish slug) under a model-tag dir. */
8
9
  function latestRunDir(tagDir) {
9
10
  if (!statSync(tagDir).isDirectory())
@@ -83,6 +84,7 @@ export function collectReport(skillDir) {
83
84
  passes: s.passes,
84
85
  clean: s.clean,
85
86
  flakiness: s.flakiness,
87
+ metrics: s.metrics,
86
88
  override: s.override,
87
89
  note: s.note,
88
90
  };
@@ -105,8 +107,10 @@ export function collectReport(skillDir) {
105
107
  runDir,
106
108
  timestamp: r.timestamp,
107
109
  mode: r.mode,
110
+ partial: r.partial === true,
108
111
  grade: r.effective_grade,
109
112
  judge: r.judge,
113
+ metrics: aggregateMetrics(r.scenarios),
110
114
  cells,
111
115
  ...(lift ? { lift, liftHeadline: liftHeadline(lift) } : {}),
112
116
  });
@@ -127,8 +131,10 @@ export function publicView(data) {
127
131
  tag: c.tag,
128
132
  timestamp: c.timestamp,
129
133
  mode: c.mode,
134
+ partial: c.partial,
130
135
  grade: c.grade,
131
136
  judge: c.judge,
137
+ metrics: c.metrics,
132
138
  cells: c.cells,
133
139
  ...(c.lift ? { lift: c.lift, liftHeadline: c.liftHeadline } : {}),
134
140
  })),
package/dist/reps.d.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import type { Verdict } from "./score.js";
2
2
  import type { ScenarioResult, ObjectiveResult } from "./results.js";
3
+ import type { TraceMetrics } from "./capture-trace-types.js";
3
4
  /** One rep's outcome (subject run + judge). */
4
5
  export interface RepOutcome {
5
6
  verdict: Verdict;
6
7
  reason: string;
7
8
  suspect: boolean;
8
- /** Present only when the scenario declared `assert.trace`. */
9
+ /** Present only when the scenario declared structured objective assertions. */
9
10
  objective?: ObjectiveResult;
11
+ metrics?: {
12
+ wall_time_ms: number;
13
+ judge_calls: number;
14
+ judge_rejudge_calls: number;
15
+ subject?: TraceMetrics;
16
+ };
10
17
  }
11
18
  /**
12
19
  * Collapse per-rep objective results.
package/dist/reps.js CHANGED
@@ -12,13 +12,16 @@ export function aggregateObjective(outcomes) {
12
12
  const present = outcomes.map((o) => o.objective).filter((o) => o !== undefined);
13
13
  if (present.length === 0)
14
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];
15
+ const picked = present.find((o) => o.status === "ERROR") ?? present.find((o) => o.status === "FAIL") ?? present[0];
16
+ if (present.length === 1)
17
+ return picked;
18
+ const eventHashes = present.map((objective) => objective.events_sha256);
19
+ const traceHashes = present.map((objective) => objective.trace_sha256);
20
+ return {
21
+ ...picked,
22
+ ...(eventHashes.every((hash) => typeof hash === "string") ? { rep_events_sha256: eventHashes } : {}),
23
+ ...(traceHashes.every((hash) => typeof hash === "string") ? { rep_trace_sha256: traceHashes } : {}),
24
+ };
22
25
  }
23
26
  /**
24
27
  * Collapse N rep outcomes into one scenario verdict. A rep is "clean" when its
@@ -31,14 +34,17 @@ export function aggregateReps(outcomes, threshold) {
31
34
  const reps = outcomes.length;
32
35
  const clean = outcomes.filter((o) => !o.suspect);
33
36
  const passes = clean.filter((o) => o.verdict === "PASS").length;
37
+ const errored = outcomes.filter((o) => o.verdict === "ERROR").length;
38
+ // Infrastructure/judge/tooling errors are not behavioral failures, and they
39
+ // cannot be voted into a pass by successful siblings. Preserve the third
40
+ // state so a release blocks for missing evidence without blaming the skill.
41
+ if (errored > 0) {
42
+ return { verdict: "ERROR", reason: `${errored}/${reps} reps errored — infrastructure, not behavior`, passes, reps, clean: clean.length, flakiness: 0, suspect: false };
43
+ }
34
44
  if (clean.length * 2 < reps) {
35
45
  // majority of reps misfired → untrustworthy
36
46
  return { verdict: "FAIL", reason: `${reps - clean.length}/${reps} reps misfired — re-judge`, passes, reps, clean: clean.length, flakiness: 0, suspect: true };
37
47
  }
38
- const errored = clean.filter((o) => o.verdict === "ERROR").length;
39
- if (clean.length > 0 && errored === clean.length) {
40
- return { verdict: "ERROR", reason: `${errored}/${reps} reps errored`, passes: 0, reps, clean: clean.length, flakiness: 0, suspect: false };
41
- }
42
48
  const passRate = passes / clean.length;
43
49
  const verdict = passRate >= threshold ? "PASS" : "FAIL";
44
50
  const flakiness = 1 - Math.abs(2 * passRate - 1);
@@ -57,15 +63,43 @@ export function outcomesToResult(id, outcomes, repCount, threshold) {
57
63
  // produce a result byte-identical to one from before this field existed.
58
64
  const objective = aggregateObjective(outcomes);
59
65
  const objectiveField = objective ? { objective } : {};
66
+ const metrics = aggregateMetrics(outcomes);
67
+ const metricsField = metrics ? { metrics } : {};
60
68
  if (repCount === 1) {
61
69
  const o = outcomes[0];
62
- return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, override: null, note: "", ...objectiveField };
70
+ return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, ...metricsField, override: null, note: "", ...objectiveField };
63
71
  }
64
72
  const agg = aggregateReps(outcomes, threshold);
65
73
  return {
66
74
  id, judge_verdict: agg.verdict, judge_reason: agg.reason, suspect: agg.suspect,
67
75
  reps: agg.reps, passes: agg.passes, clean: agg.clean, flakiness: agg.flakiness,
68
- pass_threshold: threshold, override: null, note: "", ...objectiveField,
76
+ pass_threshold: threshold, ...metricsField, override: null, note: "", ...objectiveField,
77
+ };
78
+ }
79
+ function aggregateMetrics(outcomes) {
80
+ const present = outcomes.map((outcome) => outcome.metrics).filter((metrics) => metrics !== undefined);
81
+ if (present.length === 0)
82
+ return undefined;
83
+ const subjects = present.map((metrics) => metrics.subject).filter((metrics) => metrics !== undefined);
84
+ const base = {
85
+ wall_time_ms: present.reduce((sum, metrics) => sum + metrics.wall_time_ms, 0),
86
+ judge_calls: present.reduce((sum, metrics) => sum + metrics.judge_calls, 0),
87
+ judge_rejudge_calls: present.reduce((sum, metrics) => sum + metrics.judge_rejudge_calls, 0),
88
+ subject_metrics_reps: subjects.length,
89
+ total_reps: outcomes.length,
90
+ };
91
+ if (subjects.length === 0)
92
+ return base;
93
+ return {
94
+ ...base,
95
+ input_tokens: subjects.reduce((sum, metrics) => sum + metrics.input_tokens, 0),
96
+ output_tokens: subjects.reduce((sum, metrics) => sum + metrics.output_tokens, 0),
97
+ cache_read_tokens: subjects.reduce((sum, metrics) => sum + metrics.cache_read_tokens, 0),
98
+ cache_write_tokens: subjects.reduce((sum, metrics) => sum + metrics.cache_write_tokens, 0),
99
+ subject_cost_usd: subjects.reduce((sum, metrics) => sum + metrics.cost_usd, 0),
100
+ tool_calls: subjects.reduce((sum, metrics) => sum + metrics.tool_calls, 0),
101
+ delegated_children: subjects.reduce((sum, metrics) => sum + metrics.delegated_children, 0),
102
+ max_concurrency: Math.max(...subjects.map((metrics) => metrics.max_concurrency)),
69
103
  };
70
104
  }
71
105
  //# 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, rebuildScenarioResult } from "./results.js";
3
+ import { readResults, writeResults, scoreContextFor, rebuildScenarioResult, effectiveThreshold } from "./results.js";
4
4
  import { appendJournal } from "./journal.js";
5
5
  import { policyDigest, POLICY_PREFIX } from "./sources.js";
6
6
  /**
@@ -56,7 +56,7 @@ export function rescoreRun(opts) {
56
56
  return s;
57
57
  if (s.clean === 0)
58
58
  return s;
59
- const toThreshold = scenario.passThreshold ?? 0.5;
59
+ const toThreshold = effectiveThreshold(undefined, scenario);
60
60
  const fromThreshold = s.pass_threshold ?? toThreshold;
61
61
  if (toThreshold === fromThreshold)
62
62
  return s;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Upgrade already-committed runs to the model-visible digests, without re-running anything.
3
+ *
4
+ * ## Why a command and not a rule inside lint
5
+ *
6
+ * A stored hash is one-way. A run recorded before 0.8.0 carries only sha256 of SKILL.md's
7
+ * RAW BYTES, so once that file has been edited there is nothing in the record — and
8
+ * nothing saved beside it; run dirs hold transcripts, not a copy of the skill — that can
9
+ * say whether the edit touched the body or only the frontmatter. Lint therefore cannot
10
+ * retroactively forgive a frontmatter-only edit on a legacy record, and pretending
11
+ * otherwise would mean dropping the check that catches real body edits across the whole
12
+ * published corpus.
13
+ *
14
+ * What CAN be established, and is the entire proof this command rests on: when the
15
+ * recorded raw-bytes hash still equals the file's current raw-bytes hash, that run
16
+ * measured the exact bytes on disk right now. The model-visible digest of those same
17
+ * bytes is then a true statement about what that run measured, and recording it is
18
+ * bookkeeping rather than a claim. Any record that fails the check is left untouched —
19
+ * it stays honestly stale, and `run` is the only thing that can clear it.
20
+ *
21
+ * So the migration is: upgrade while the board is green, and every later
22
+ * frontmatter-only edit is free. Skipping it costs nothing either — a legacy record with
23
+ * an unedited skill keeps matching on the raw-bytes key exactly as before.
24
+ *
25
+ * ## `--from <ref>`, for when the edit already landed
26
+ *
27
+ * Nobody discovers a gate over-fires until it has over-fired, so the green worktree the
28
+ * paragraph above assumes is usually already gone. The pre-edit bytes are still in git,
29
+ * and they carry the same proof in two steps: the recorded hash matches `<ref>`'s bytes
30
+ * (so the run measured THOSE), and `<ref>`'s model-visible digest equals today's (so the
31
+ * text the model receives has not moved since). Only then is the digest recorded.
32
+ *
33
+ * It is the same proof, not a weaker one — with no `--from`, "the ref" is just the
34
+ * working tree, which is why both go through one code path.
35
+ *
36
+ * Free and offline: no model, no judge, no network. It rewrites only `source_hashes`.
37
+ */
38
+ /**
39
+ * Three exclusive buckets that SUM to `runs`, plus `partial` as a subset of `upgraded`.
40
+ *
41
+ * Summing is the point. These counters are the only thing an operator can judge a
42
+ * migration by, and "18 upgraded, 20 left alone" out of 140 leaves 102 records
43
+ * unaccounted for — which reads like the command quietly skipped them rather than like
44
+ * the 102 having nothing to upgrade.
45
+ */
46
+ export interface RestampReport {
47
+ /** Run records examined. `upgraded + unprovable + unchanged`. */
48
+ runs: number;
49
+ /** Records that gained at least one model-visible digest, and were rewritten. */
50
+ upgraded: number;
51
+ /**
52
+ * Records that gained NOTHING because a document they measured has already moved —
53
+ * honestly stale, and `run` is the only remedy.
54
+ */
55
+ unprovable: number;
56
+ /** Records with nothing to do: no `source_hashes`, unreadable, or already upgraded. */
57
+ unchanged: number;
58
+ /** Of `upgraded`, those still carrying a document that could not be proven. */
59
+ partial: number;
60
+ /** `<runDir>: <key>` for each digest added, for the CLI to print. */
61
+ added: string[];
62
+ }
63
+ export interface RestampOptions {
64
+ /** Git ref holding the bytes the runs measured, for a tree that has already been edited. */
65
+ from?: string;
66
+ }
67
+ /** Upgrade every provable record under one skill's tests/results. */
68
+ export declare function restampSkill(skillDir: string, opts?: RestampOptions): RestampReport;