@skill-harness/core 0.28.0 → 0.30.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.
@@ -0,0 +1,120 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { EXECUTION_TRACE_VERSION } from "./capture-trace-types.js";
3
+ import { deserializeTrace, mergeTraces, traceSha256 } from "./execution-trace.js";
4
+ import { redactText } from "./redaction.js";
5
+ import { finalAssistantMessage } from "./seeded.js";
6
+ const HASH = /^[a-f0-9]{64}$/;
7
+ const MAX_CALLS = 32;
8
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
9
+ const index = (value) => Number.isSafeInteger(value) && value >= 0;
10
+ /** Validate the fields consumed by merging and the evidence projection before using them. */
11
+ function validTrace(trace) {
12
+ if (!object(trace) || trace.trace_version !== EXECUTION_TRACE_VERSION || !object(trace.subject) || typeof trace.subject.provider !== "string" || typeof trace.subject.model !== "string" ||
13
+ !(trace.pi_version === null || typeof trace.pi_version === "string") || typeof trace.final_text !== "string" ||
14
+ !index(trace.turn) || !index(trace.rep) || !Array.isArray(trace.tool_calls) ||
15
+ !(trace.changed_paths === null || Array.isArray(trace.changed_paths) && trace.changed_paths.every(p => typeof p === "string")) ||
16
+ !(trace.cost_usd === null || typeof trace.cost_usd === "number" && Number.isFinite(trace.cost_usd)) ||
17
+ !(trace.capture_errors === undefined || Array.isArray(trace.capture_errors) && trace.capture_errors.every(e => typeof e === "string")))
18
+ return false;
19
+ if (trace.metrics !== undefined && (!object(trace.metrics) ||
20
+ ["input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "cost_usd"].some(key => {
21
+ const value = trace.metrics[key];
22
+ return value !== null && !(typeof value === "number" && Number.isFinite(value) && value >= 0);
23
+ }) || [trace.metrics.tool_calls, trace.metrics.delegated_children, trace.metrics.max_concurrency].some(value => !index(value))))
24
+ return false;
25
+ const ids = new Set();
26
+ const completions = new Set();
27
+ for (const [position, call] of trace.tool_calls.entries()) {
28
+ if (!object(call) || typeof call.id !== "string" || !call.id || ids.has(call.id) ||
29
+ typeof call.name !== "string" || !call.name || !object(call.args) || call.issueIndex !== position ||
30
+ !(call.completionIndex === -1 || index(call.completionIndex)) || typeof call.isError !== "boolean" ||
31
+ !object(call.result) || !index(call.result.bytes) || typeof call.result.sha256 !== "string" || !HASH.test(call.result.sha256))
32
+ return false;
33
+ ids.add(call.id);
34
+ if (call.completionIndex >= 0) {
35
+ if (completions.has(call.completionIndex))
36
+ return false;
37
+ completions.add(call.completionIndex);
38
+ }
39
+ }
40
+ return [...completions].sort((a, b) => a - b).every((value, position) => value === position);
41
+ }
42
+ export function unavailableExecution(reason = "No bound execution trace was supplied.", status = "unknown") {
43
+ return { schema: 1, status, coverage: "unknown", reason, observedCalls: null, omittedCalls: 0, calls: [] };
44
+ }
45
+ /** Tool inputs are untrusted labels. Keep only bounded local read paths, never commands, URLs, tasks or results. */
46
+ function readPath(call) {
47
+ if (call.name !== "read")
48
+ return undefined;
49
+ const path = call.args.path ?? call.args.file_path;
50
+ if (typeof path !== "string")
51
+ return undefined;
52
+ if (path.length > 256 || /[\x00-\x1f\x7f?#]/.test(path) || /^[a-z][a-z0-9+.-]*:\/\//i.test(path))
53
+ return "[path withheld]";
54
+ return redactText(path);
55
+ }
56
+ /** Digest binding establishes artifact continuity, not a signature or proof of file contents. */
57
+ export function executionFacts(traces, identity, binding) {
58
+ if (binding.source === "saved" && !binding.expectedSha256)
59
+ return unavailableExecution("Legacy trace has no recorded digest binding; execution is unverified.");
60
+ const invalid = (reason) => unavailableExecution(reason, "invalid");
61
+ if (!traces.length)
62
+ return binding.source === "live" ? unavailableExecution() : invalid("Recorded execution trace is missing or unreadable.");
63
+ if (traces.some(trace => !validTrace(trace)))
64
+ return invalid("Execution trace has malformed structure.");
65
+ if (traces.some((trace, turn) => trace.scenario_id !== identity.scenarioId || trace.mode !== identity.mode || trace.rep !== identity.repetition || trace.turn !== turn ||
66
+ identity.subject && (trace.subject.provider !== identity.subject.provider || trace.subject.model !== identity.subject.model) ||
67
+ identity.piVersion !== undefined && trace.pi_version !== identity.piVersion ||
68
+ trace.subject.provider !== traces[0].subject.provider || trace.subject.model !== traces[0].subject.model || trace.pi_version !== traces[0].pi_version)) {
69
+ return invalid("Execution trace identity or turn sequence does not match this repetition.");
70
+ }
71
+ if (identity.responses !== undefined && (identity.responses === null || identity.responses.length !== traces.length || traces.some((trace, turn) => trace.final_text.trim() !== identity.responses[turn]))) {
72
+ return invalid("Execution trace final answers do not match the judged transcript.");
73
+ }
74
+ if (traces.some(trace => trace.trace_sha256 !== undefined && trace.trace_sha256 !== traceSha256(trace)))
75
+ return invalid("Execution trace has a mismatching turn digest.");
76
+ // Use the existing canonical merge/digest; only the projection below reaches the judge.
77
+ const merged = mergeTraces(traces);
78
+ const digest = merged?.trace_sha256;
79
+ if (!merged || !digest)
80
+ return invalid("Execution trace could not be merged.");
81
+ if (binding.source === "saved" && binding.expectedSha256 !== digest)
82
+ return invalid("Saved trace no longer matches the digest recorded by the run.");
83
+ const complete = traces.length === identity.turns && traces.every(trace => trace.final_status === "complete" && !trace.capture_errors?.length && trace.tool_calls.every(call => call.completionIndex >= 0));
84
+ const calls = merged.tool_calls.slice(0, MAX_CALLS).map(call => ({
85
+ tool: /^[A-Za-z0-9_.:/-]{1,80}$/.test(call.name) ? call.name : "[tool name withheld]",
86
+ state: call.completionIndex < 0 ? "unfinished" : call.isError ? "error" : "completed",
87
+ ...(readPath(call) === undefined ? {} : { path: readPath(call) }),
88
+ ...(call.completionIndex < 0 ? {} : { resultBytes: call.result.bytes, resultSha256: call.result.sha256 }),
89
+ }));
90
+ const omittedCalls = merged.tool_calls.length - calls.length;
91
+ return {
92
+ schema: 1, status: "verified", coverage: complete && !omittedCalls ? "complete" : "partial",
93
+ reason: complete ? omittedCalls ? "Call list is bounded; omitted calls may include relevant actions." : "All expected turns settled; all recorded calls are listed." : "Turn settlement or capture coverage is incomplete; do not infer that unlisted actions did not occur.",
94
+ traceSha256: digest, observedCalls: merged.tool_calls.length, omittedCalls, calls,
95
+ };
96
+ }
97
+ /** Reuse the transcript ambiguity check, then compare every assistant turn, excluding harness-owned trailers. */
98
+ export function transcriptResponses(transcript, scenario) {
99
+ if (finalAssistantMessage(transcript, scenario) === null)
100
+ return null;
101
+ return transcript.split(/^<<< ASSISTANT:[ \t]*$/m).slice(1).map(section => section.split(/^(?:>>> USER(?: \(turn \d+\/\d+\))?:[ \t]*|=== SEEDED GATES ===[ \t]*)$/m)[0].trim());
102
+ }
103
+ /** Read every JSONL turn or decline the whole artifact; never silently drop malformed lines. */
104
+ export function loadExecutionFacts(path, identity, expectedSha256) {
105
+ if (!expectedSha256)
106
+ return unavailableExecution("Legacy run has no recorded trace digest; execution is unverified.");
107
+ if (!existsSync(path))
108
+ return unavailableExecution("Recorded execution trace is missing.", "invalid");
109
+ try {
110
+ const lines = readFileSync(path, "utf8").split("\n").filter(line => line.trim());
111
+ const traces = lines.map(deserializeTrace);
112
+ if (traces.some(trace => trace === null))
113
+ return unavailableExecution("Execution trace contains unreadable turns.", "invalid");
114
+ return executionFacts(traces, identity, { source: "saved", expectedSha256 });
115
+ }
116
+ catch {
117
+ return unavailableExecution("Execution trace could not be read or validated.", "invalid");
118
+ }
119
+ }
120
+ //# sourceMappingURL=judge-evidence.js.map
@@ -0,0 +1,8 @@
1
+ import type { Scenario } from "./spec.js";
2
+ export interface JudgeReference {
3
+ file: string;
4
+ sha256: string;
5
+ text: string;
6
+ }
7
+ /** Only explicitly selected local UTF-8 files are read. Never discover sibling files. */
8
+ export declare function resolveJudgeReferences(scenario: Scenario, specDir: string): JudgeReference[];
@@ -0,0 +1,34 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { isAbsolute, relative, resolve } from "node:path";
4
+ const LIMIT = 65_536;
5
+ /** Only explicitly selected local UTF-8 files are read. Never discover sibling files. */
6
+ export function resolveJudgeReferences(scenario, specDir) {
7
+ const selected = scenario.judgeReferences ?? [];
8
+ if (!selected.length)
9
+ return [];
10
+ if (selected.length > 16 || new Set(selected).size !== selected.length)
11
+ throw new Error("Invalid judge reference selection");
12
+ const root = realpathSync(specDir);
13
+ let total = 0;
14
+ return selected.map(file => {
15
+ if (!file || /[\\:\x00-\x1f]/.test(file) || file.startsWith("/") || file.split("/").some(part => !part || part === "." || part === ".."))
16
+ throw new Error(`Unsafe judge reference: ${file}`);
17
+ const path = realpathSync(resolve(root, file));
18
+ const rel = relative(root, path);
19
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith("../") || rel.startsWith("..\\"))
20
+ throw new Error(`Judge reference escapes specification directory: ${file}`);
21
+ const info = statSync(path);
22
+ if (!info.isFile() || info.size > LIMIT)
23
+ throw new Error(`Judge reference must be a regular file of at most ${LIMIT} bytes: ${file}`);
24
+ const bytes = readFileSync(path);
25
+ total += bytes.length;
26
+ if (bytes.length > LIMIT || total > LIMIT)
27
+ throw new Error(`Selected judge references exceed ${LIMIT} bytes`);
28
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
29
+ if (text.includes("\0"))
30
+ throw new Error(`Judge reference contains binary content: ${file}`);
31
+ return { file, sha256: createHash("sha256").update(bytes).digest("hex"), text };
32
+ });
33
+ }
34
+ //# sourceMappingURL=judge-references.js.map
package/dist/lint.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "stability" | "covers" | "lint-error";
1
+ export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "stability" | "covers" | "lint-error" | "authoring";
2
2
  /**
3
3
  * How much a finding means.
4
4
  *
package/dist/lint.js CHANGED
@@ -9,6 +9,8 @@ import { downgradeWarning } from "./downgrade.js";
9
9
  import { collectScoredRuns } from "./trends.js";
10
10
  import { boundaryCells, stabilityFrom, stabilityNote } from "./stability.js";
11
11
  import { MARKERS, unknownMarkerDirs, suggestMarker } from "./workspace.js";
12
+ import { fixtureInventory, inspectEvalAuthoring } from "./authoring-preflight.js";
13
+ import { resolveJudgeReferences } from "./judge-references.js";
12
14
  /** True when a finding fails the gate. The single place the exit-code rule lives. */
13
15
  export function failsGate(f) {
14
16
  return (f.severity ?? "error") === "error";
@@ -49,6 +51,20 @@ export function lintSkill(skillDir, testsDir = join(skillDir, "tests")) {
49
51
  return [{ skill: basename(skillDir), code: "spec", message }];
50
52
  }
51
53
  const skill = spec.skill;
54
+ for (const hint of inspectEvalAuthoring(spec, { fixtureFiles: scenario => {
55
+ const fixture = effectiveFixture(scenario);
56
+ if (!fixture)
57
+ return [];
58
+ // Unreadable/missing fixtures are reported by the fixture checks below.
59
+ try {
60
+ return fixtureInventory(resolve(testsDir, fixture));
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ } })) {
66
+ findings.push({ skill, scenario: hint.scenario, code: "authoring", severity: hint.severity ?? "info", message: `${hint.code}: ${hint.message}` });
67
+ }
52
68
  // A lint from an older tool than the records it is checking cannot be trusted to
53
69
  // check them: a 0.1.0 binary reported 38 spurious `consistency` findings against
54
70
  // partial runs that 0.3.x handles correctly, and it cannot read source-hash key
@@ -91,6 +107,12 @@ export function lintSkill(skillDir, testsDir = join(skillDir, "tests")) {
91
107
  // relative to the spec's dir, matching workspace.ts resolve(specDir, fixture) where specDir = <skillDir>/tests.
92
108
  const specDir = dirname(specPath);
93
109
  for (const s of spec.scenarios) {
110
+ try {
111
+ resolveJudgeReferences(s, specDir);
112
+ }
113
+ catch (error) {
114
+ findings.push({ skill, scenario: s.id, code: "fixture", message: String(error) });
115
+ }
94
116
  const fx = effectiveFixture(s); // shared with sources.ts so hashing and linting can't drift
95
117
  if (fx) {
96
118
  const abs = isAbsolute(fx) ? fx : resolve(specDir, fx);
package/dist/regate.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { basename, join } from "node:path";
3
- import { parseVerdict, detectMisfire } from "./grade.js";
3
+ import { parseModelRef } from "./adapters/types.js";
4
+ import { parseVerdict, detectMisfire, resolveJudgeReferences } from "./grade.js";
4
5
  import { evaluateNeedleGates, evaluateOutputGates, hasNeedleGates, hasOutputGates } from "./seeded.js";
5
6
  import { evaluateTraceGates } from "./trace-gates.js";
6
7
  import { mergeTraces, deserializeTrace } from "./execution-trace.js";
7
8
  import { judgeOneRep } from "./regrade.js";
9
+ import { loadExecutionFacts, transcriptResponses, unavailableExecution } from "./judge-evidence.js";
8
10
  import { readResults, writeResults, diffPath, transcriptPath, judgeRawPath, findDiffFiles, findTranscriptFiles, findTraceFiles, tracePath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, deliveryStatusForObservations, } from "./results.js";
9
11
  import { outcomesToResult } from "./reps.js";
10
12
  import { appendJournal } from "./journal.js";
@@ -150,6 +152,7 @@ export async function regateRun(opts) {
150
152
  throw new Error(`nothing to regate in ${opts.runDir}` +
151
153
  (blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff/output assertions or assert.trace"));
152
154
  }
155
+ const references = Object.fromEntries(targets.map(s => [s.id, resolveJudgeReferences(s, opts.specDir)]));
153
156
  const changes = [];
154
157
  let judgeCalls = 0;
155
158
  const scenarios = [];
@@ -176,6 +179,16 @@ export async function regateRun(opts) {
176
179
  const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
177
180
  const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
178
181
  const outputGate = evaluateOutputGates(scenario, before);
182
+ const retainedPanel = rec.rep_judgments?.find(panel => panel.repetition === (rep ?? 0));
183
+ const recordedTraceHashes = [retainedPanel?.judgments[0]?.input?.trace_sha256, retainedPanel?.objective?.trace_sha256,
184
+ rec.objective?.rep_trace_sha256?.[rep ?? 0], rec.reps === undefined ? rec.objective?.trace_sha256 : undefined].filter((hash) => hash !== undefined);
185
+ const execution = new Set(recordedTraceHashes).size > 1
186
+ ? unavailableExecution("Conflicting recorded trace digests.", "invalid")
187
+ : loadExecutionFacts(tracePath(opts.runDir, scenario.id, mode, rep), {
188
+ scenarioId: scenario.id, mode, repetition: rep ?? 0, turns: scenario.turns.length,
189
+ ...(prev.model.includes(":") ? { subject: parseModelRef(prev.model) } : {}), piVersion: prev.harness_cli_version,
190
+ responses: transcriptResponses(before, scenario),
191
+ }, recordedTraceHashes[0]);
179
192
  // Output and trace gates are re-decided from saved artifacts. Free: no model, no judge.
180
193
  let traceFailure = null;
181
194
  const expectedOutputHash = rec.objective?.rep_output_sha256?.[rep ?? 0]
@@ -191,7 +204,11 @@ export async function regateRun(opts) {
191
204
  assertions: [...outputIntegrityAssertion, ...outputGate.assertions],
192
205
  }
193
206
  : undefined;
194
- if (scenario.traceAssert) {
207
+ if (execution.status === "invalid") {
208
+ traceFailure = `objective: ${execution.reason}`;
209
+ objective = { ...(objective ?? {}), status: "ERROR", assertions: [...(objective?.assertions ?? []), { kind: "trace_evidence", status: "ERROR", detail: execution.reason }] };
210
+ }
211
+ else if (scenario.traceAssert) {
195
212
  const tp = tracePath(opts.runDir, scenario.id, mode, rep);
196
213
  // A PARTIAL read is refused, not graded. `deserializeTrace` returns null
197
214
  // for a malformed line and for a version it declines, and dropping those
@@ -303,7 +320,7 @@ export async function regateRun(opts) {
303
320
  const judged = await judgeOneRep({
304
321
  runDir: opts.runDir, spec: opts.spec, scenario, transcript,
305
322
  adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
306
- mode, rep, now, rejudge: true,
323
+ mode, rep, now, rejudge: true, execution, objective, references: references[scenario.id],
307
324
  });
308
325
  outcomes.push({ ...judged, objective });
309
326
  judgeCalls += judged.metrics?.judge_calls ?? 0;
package/dist/regrade.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { Spec, Scenario } from "./spec.js";
2
2
  import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
3
+ import { type JudgeReference } from "./grade.js";
3
4
  import { type ScenarioResult, type ResultsFile } from "./results.js";
4
5
  import { type RepOutcome } from "./reps.js";
6
+ import { type JudgeExecutionFacts } from "./judge-evidence.js";
5
7
  /**
6
8
  * Carry a run's recorded hashes forward, refreshing only the `rubric:` keys this
7
9
  * re-grade actually judged under (plus the persona, which applies to all of them).
@@ -14,8 +16,9 @@ import { type RepOutcome } from "./reps.js";
14
16
  * No hashes recorded (a pre-`source_hashes` run) stays that way: inventing hashes for
15
17
  * a run that never recorded any would claim a coverage it does not have.
16
18
  */
17
- export declare function refreshRubricHashes(recorded: Record<string, string> | undefined, spec: Spec, judgedIds: string[]): Record<string, string> | undefined;
19
+ export declare function refreshRubricHashes(recorded: Record<string, string> | undefined, spec: Spec, judgedIds: string[], specDir?: string, references?: Record<string, JudgeReference[]>): Record<string, string> | undefined;
18
20
  export interface RegradeOptions {
21
+ references?: JudgeReference[];
19
22
  runDir: string;
20
23
  spec: Spec;
21
24
  scenario: Scenario;
@@ -37,6 +40,8 @@ export interface RegradeOptions {
37
40
  /** Select only repetitions with retained ERROR criterion votes. */
38
41
  onlyUnparsed?: boolean;
39
42
  prior?: ScenarioResult;
43
+ subject?: ModelRef;
44
+ piVersion?: string;
40
45
  now?: () => string;
41
46
  }
42
47
  /** 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. */
@@ -55,6 +60,8 @@ export declare function judgeOneRep(opts: {
55
60
  rejudge?: boolean;
56
61
  /** Retained objective evidence must precede missing-vote normalization. */
57
62
  objective?: RepOutcome["objective"];
63
+ execution?: JudgeExecutionFacts;
64
+ references?: JudgeReference[];
58
65
  }): Promise<RepOutcome>;
59
66
  /**
60
67
  * Re-judge a scenario's saved transcript(s) for the run's mode with `judge` — no
package/dist/regrade.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { buildJudgePrompt, formatJudgeRawReplies, judgeInWorkspace } from "./grade.js";
4
- import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, carryRepObjectives, completeCriterionVotes, } from "./results.js";
3
+ import { createHash } from "node:crypto";
4
+ import { parseModelRef } from "./adapters/types.js";
5
+ import { prepareJudgePrompt, resolveJudgeReferences, formatJudgeRawReplies, judgeInWorkspace } from "./grade.js";
6
+ import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, tracePath, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, carryRepObjectives, completeCriterionVotes, } from "./results.js";
5
7
  import { outcomesToResult, normalizeRepOutcome } from "./reps.js";
6
8
  import { appendJournal } from "./journal.js";
7
9
  import { rubricDigest, personaDigest, RUBRIC_PREFIX, PERSONA_KEY } from "./sources.js";
8
10
  import { providerFailureFromTranscript, executionFailureFromTranscript } from "./provider-failure.js";
11
+ import { loadExecutionFacts, transcriptResponses } from "./judge-evidence.js";
9
12
  /**
10
13
  * Carry a run's recorded hashes forward, refreshing only the `rubric:` keys this
11
14
  * re-grade actually judged under (plus the persona, which applies to all of them).
@@ -18,7 +21,7 @@ import { providerFailureFromTranscript, executionFailureFromTranscript } from ".
18
21
  * No hashes recorded (a pre-`source_hashes` run) stays that way: inventing hashes for
19
22
  * a run that never recorded any would claim a coverage it does not have.
20
23
  */
21
- export function refreshRubricHashes(recorded, spec, judgedIds) {
24
+ export function refreshRubricHashes(recorded, spec, judgedIds, specDir, references) {
22
25
  if (!recorded)
23
26
  return undefined;
24
27
  const next = { ...recorded };
@@ -28,12 +31,27 @@ export function refreshRubricHashes(recorded, spec, judgedIds) {
28
31
  // Only refresh a key the run already carried: adding one for a scenario whose
29
32
  // rubric was never hashed would fabricate coverage.
30
33
  if (s && RUBRIC_PREFIX + id in next)
31
- next[RUBRIC_PREFIX + id] = rubricDigest(s);
34
+ next[RUBRIC_PREFIX + id] = rubricDigest(s, specDir, references?.[id]);
32
35
  }
33
36
  if (PERSONA_KEY in next)
34
37
  next[PERSONA_KEY] = personaDigest(spec.judge_persona);
35
38
  return next;
36
39
  }
40
+ function savedExecution(opts, mode, rep, transcript) {
41
+ const repetition = rep ?? 0;
42
+ const panel = opts.prior?.rep_judgments?.find(panel => panel.repetition === repetition);
43
+ const hashes = [panel?.judgments[0]?.input?.trace_sha256, panel?.objective?.trace_sha256, opts.prior?.objective?.rep_trace_sha256?.[repetition],
44
+ (opts.prior?.reps ?? 1) === 1 ? opts.prior?.objective?.trace_sha256 : undefined].filter((value) => value !== undefined);
45
+ if (new Set(hashes).size > 1)
46
+ throw new Error(`${opts.scenario.id}#${repetition}: conflicting recorded trace digests; regrade refused.`);
47
+ const execution = loadExecutionFacts(tracePath(opts.runDir, opts.scenario.id, mode, rep), {
48
+ scenarioId: opts.scenario.id, mode, repetition, turns: opts.scenario.turns.length,
49
+ subject: opts.subject, piVersion: opts.piVersion, responses: transcriptResponses(transcript, opts.scenario),
50
+ }, hashes[0]);
51
+ if (execution.status === "invalid")
52
+ throw new Error(`${opts.scenario.id}#${repetition}: ${execution.reason} Regrade refused; retained results were not replaced.`);
53
+ return execution;
54
+ }
37
55
  /** 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. */
38
56
  export async function judgeOneRep(opts) {
39
57
  const { runDir, spec, scenario, transcript, adapter, judge, specDir, mode, rep, now } = opts;
@@ -56,12 +74,30 @@ export async function judgeOneRep(opts) {
56
74
  metrics: { wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)), judge_calls: 0, judge_rejudge_calls: 0 },
57
75
  };
58
76
  }
59
- const prompt = buildJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript });
77
+ if (opts.execution?.status === "invalid") {
78
+ const reason = `execution evidence: ${opts.execution.reason}`;
79
+ appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: "ERROR", reason, suspect: false, ...repField });
80
+ return { verdict: "ERROR", reason, suspect: false, objective: opts.objective,
81
+ metrics: { wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)), judge_calls: 0, judge_rejudge_calls: 0 } };
82
+ }
83
+ const prompt = prepareJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript, execution: opts.execution, references: opts.references }, specDir);
60
84
  const g = await judgeInWorkspace(adapter, judge, prompt, specDir, scenario.checklist.length);
85
+ const inputBytes = JSON.stringify({ schema: 1, judge, execution: opts.execution ?? null, prompts: g.prompts }) + "\n";
86
+ const inputHash = createHash("sha256").update(inputBytes).digest("hex");
87
+ const artifact = `judge-input.${inputHash}.txt`;
88
+ const inputPath = join(runDir, artifact);
89
+ if (existsSync(inputPath)) {
90
+ if (readFileSync(inputPath, "utf8") !== inputBytes)
91
+ throw new Error("judge input artifact has conflicting bytes");
92
+ }
93
+ else
94
+ writeFileSync(inputPath, inputBytes, { encoding: "utf8", flag: "wx", mode: 0o600 });
95
+ const input = { artifact, sha256: inputHash, execution: opts.execution?.status === "verified" ? "verified" : "unknown",
96
+ ...(opts.execution?.traceSha256 ? { trace_sha256: opts.execution.traceSha256 } : {}) };
61
97
  writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), formatJudgeRawReplies(g.rawReplies), "utf8");
62
98
  const outcome = normalizeRepOutcome({
63
99
  verdict: g.verdict, reason: g.reason, suspect: g.suspect, objective: opts.objective,
64
- judgment: { ordinal: 1, judge: { ...judge }, verdict: g.verdict, reason: g.reason, suspect: g.suspect, criteria: completeCriterionVotes(g.criteria, scenario.checklist.length), judgeFormat: g.judgeFormat, ...(g.judgeRetries ? { judgeRetries: g.judgeRetries } : {}) },
100
+ judgment: { ordinal: 1, judge: { ...judge }, verdict: g.verdict, reason: g.reason, suspect: g.suspect, criteria: completeCriterionVotes(g.criteria, scenario.checklist.length), judgeFormat: g.judgeFormat, input, ...(g.judgeRetries ? { judgeRetries: g.judgeRetries } : {}) },
65
101
  metrics: {
66
102
  wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
67
103
  judge_calls: 1 + (g.judgeRetries ?? 0),
@@ -81,6 +117,7 @@ export async function judgeOneRep(opts) {
81
117
  * persists).
82
118
  */
83
119
  export async function regradeScenario(opts) {
120
+ const references = opts.references ?? resolveJudgeReferences(opts.scenario, opts.specDir);
84
121
  const now = opts.now ?? (() => new Date().toISOString());
85
122
  const mode = opts.mode ?? "green";
86
123
  let files = findTranscriptFiles(opts.runDir, opts.scenario.id, mode);
@@ -109,10 +146,12 @@ export async function regradeScenario(opts) {
109
146
  const file = files.find(file => (repIndexOf(file) ?? 0) === repetition);
110
147
  const rep = repIndexOf(file) ?? undefined;
111
148
  const transcript = readFileSync(join(opts.runDir, file), "utf8");
149
+ const execution = savedExecution(opts, mode, rep, transcript);
112
150
  outcomes.push(await judgeOneRep({
113
151
  runDir: opts.runDir, spec: opts.spec, scenario: opts.scenario, transcript,
114
152
  adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode, rep, now, rejudge: true,
115
153
  objective: panel?.objective ?? (expected === 1 ? opts.prior?.objective : undefined),
154
+ execution, references,
116
155
  }));
117
156
  }
118
157
  const result = outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
@@ -214,6 +253,19 @@ export async function regradeRun(opts) {
214
253
  if (drifted.length)
215
254
  throw new Error(`criterion count changed for ${drifted.join(", ")} — use full grade instead of --unparsed-only`);
216
255
  }
256
+ // Refuse the whole selected batch before spending on any earlier cell. Recheck
257
+ // again immediately before each call so a later filesystem edit is not trusted.
258
+ for (const id of targets) {
259
+ const prior = recordedById.get(id);
260
+ for (const file of findTranscriptFiles(runDir, id, mode)) {
261
+ const rep = repIndexOf(file) ?? undefined;
262
+ if (opts.onlyUnparsed && !hasUnparsedVotes(prior.rep_judgments?.find(panel => panel.repetition === (rep ?? 0))))
263
+ continue;
264
+ savedExecution({ runDir, scenario: specById.get(id), prior,
265
+ ...(prev?.model.includes(":") ? { subject: parseModelRef(prev.model) } : {}), piVersion: prev?.harness_cli_version, }, mode, rep, readFileSync(join(runDir, file), "utf8"));
266
+ }
267
+ }
268
+ const references = Object.fromEntries(targets.map(id => [id, resolveJudgeReferences(specById.get(id), specDir)]));
217
269
  const targetSet = new Set(targets);
218
270
  const scenarioResults = [];
219
271
  for (const rec of recorded) {
@@ -227,7 +279,9 @@ export async function regradeRun(opts) {
227
279
  const threshold = effectiveThreshold(prevScenario, scenario);
228
280
  const rr = await regradeScenario({
229
281
  runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
282
+ references: references[id],
230
283
  expectedReps: prevScenario?.reps ?? 1, onlyUnparsed: opts.onlyUnparsed, prior: prevScenario,
284
+ ...(prev?.model.includes(":") ? { subject: parseModelRef(prev.model) } : {}), piVersion: prev?.harness_cli_version,
231
285
  });
232
286
  const carry = overrides.get(id);
233
287
  if (prev?.schema === 3)
@@ -270,7 +324,7 @@ export async function regradeRun(opts) {
270
324
  // say so. Doctrine narrowed 0.4.0, from "recorded hashes stay" to "recorded
271
325
  // *stimulus* hashes stay" — see refreshRubricHashes.
272
326
  partial: prev?.partial,
273
- source_hashes: opts.onlyUnparsed ? prev?.source_hashes : refreshRubricHashes(prev?.source_hashes, spec, targets),
327
+ source_hashes: opts.onlyUnparsed ? prev?.source_hashes : refreshRubricHashes(prev?.source_hashes, spec, targets, opts.specDir, references),
274
328
  source_hash_roots: prev?.source_hash_roots,
275
329
  scenarios: scenarioResults,
276
330
  }, ctx);
package/dist/results.d.ts CHANGED
@@ -105,6 +105,13 @@ export interface Judgment {
105
105
  judgeFormat?: "json" | "prose";
106
106
  /** One validation-guided retry after an invalid structured reply. */
107
107
  judgeRetries?: 1;
108
+ /** Content-addressed private input artifact; preserves exact prompts and execution coverage. */
109
+ input?: {
110
+ artifact: string;
111
+ sha256: string;
112
+ execution: "verified" | "unknown";
113
+ trace_sha256?: string;
114
+ };
108
115
  }
109
116
  export interface AdjudicationResult {
110
117
  /** The saved transcript repetition judged by every panel member (first rep by policy). */
package/dist/results.js CHANGED
@@ -743,6 +743,11 @@ export function findTranscriptFile(runDir, scenarioId) {
743
743
  * for that claim is the code the model wrote.
744
744
  */
745
745
  export function preserveTranscript(resultsRoot, runDir, scenarioId) {
746
+ const record = existsSync(resultsPath(runDir)) ? readResults(runDir).scenarios.find(scenario => scenario.id === scenarioId) : undefined;
747
+ const judgeInputs = (record?.rep_judgments ?? []).flatMap(panel => panel.judgments.flatMap(judgment => {
748
+ const input = judgment.input;
749
+ return input && input.artifact === `judge-input.${input.sha256}.txt` && /^[a-f0-9]{64}$/.test(input.sha256) && existsSync(join(runDir, input.artifact)) ? [input.artifact] : [];
750
+ }));
746
751
  const files = [
747
752
  ...findTranscriptFiles(runDir, scenarioId),
748
753
  ...findJudgeRawFiles(runDir, scenarioId),
@@ -752,6 +757,7 @@ export function preserveTranscript(resultsRoot, runDir, scenarioId) {
752
757
  // override whose justification was gitignored, and left `regate` with nothing
753
758
  // to re-evaluate on the one cell a human had disputed.
754
759
  ...findTraceFiles(runDir, scenarioId),
760
+ ...judgeInputs,
755
761
  ];
756
762
  if (files.length === 0)
757
763
  return;
package/dist/run.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { sourceHashes, sourceHashRoots } from "./sources.js";
4
- import { judgeResemblesSubject } from "./grade.js";
4
+ import { judgeResemblesSubject, resolveJudgeReferences } from "./grade.js";
5
+ import { inspectEvalAuthoring } from "./authoring-preflight.js";
5
6
  import { NONE_ARM, seedArmDefinitions } from "./arms.js";
6
7
  import { runDirFor, transcriptPath, diffPath, tracePath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, } from "./results.js";
7
8
  import { appendJournal } from "./journal.js";
8
9
  import { liftHeadline } from "./lift.js";
9
10
  import { evaluateOutputGates, hasOutputGates, runSeeded } from "./seeded.js";
10
11
  import { serializeTrace, mergeTraces, traceSha256 } from "./execution-trace.js";
12
+ import { executionFacts, transcriptResponses } from "./judge-evidence.js";
11
13
  import { evaluateTraceGates } from "./trace-gates.js";
12
14
  import { snapshotPaths, diffSnapshots, createWorkspace } from "./workspace.js";
13
15
  import { runPool } from "./scheduler.js";
@@ -63,6 +65,11 @@ export async function runSkillModel(opts) {
63
65
  scenarios = spec.scenarios.filter((s) => wanted.has(s.id));
64
66
  log(` --only ${opts.only.join(",")} — partial run, will not be ship-graded`);
65
67
  }
68
+ const authoringErrors = inspectEvalAuthoring({ ...spec, scenarios }).filter(f => f.severity === "error");
69
+ if (authoringErrors.length)
70
+ throw new Error(authoringErrors.map(f => `${f.scenario}: ${f.message}`).join("\n"));
71
+ const judgeReferences = Object.fromEntries(scenarios.map(s => [s.id, resolveJudgeReferences(s, dirname(opts.specPath))]));
72
+ const hashes = sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona, judgeReferences });
66
73
  if (judgeResemblesSubject(judge, model)) {
67
74
  log(` ⚠ judge (${judge.provider}:${judge.model}) resembles the model under test ` +
68
75
  `(${model.provider}:${model.model}) — verdicts may be inflated. Use a distinct judge.`);
@@ -132,7 +139,7 @@ export async function runSkillModel(opts) {
132
139
  const rep = k;
133
140
  const total = repCounts[si];
134
141
  owners.push(si);
135
- tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log, armDefinitions }));
142
+ tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log, armDefinitions, judgeReferences }));
136
143
  }
137
144
  });
138
145
  const flat = await runPool(tasks, opts.concurrency ?? 1);
@@ -145,7 +152,6 @@ export async function runSkillModel(opts) {
145
152
  return outcomesToResult(scenario.id, grouped[si], repCounts[si], threshold);
146
153
  });
147
154
  const ctx = scoreContextFor({ mode, partial }, spec);
148
- const hashes = sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona });
149
155
  const results = writeResults(runDir, {
150
156
  skill: spec.skill,
151
157
  harness: adapter.name,
@@ -332,6 +338,7 @@ async function runRep(scenario, rep, repCount, ctx) {
332
338
  else {
333
339
  const req = {
334
340
  skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
341
+ tools: scenario.tools,
335
342
  // resolved like fixtures: relative to the spec's dir
336
343
  systemPromptFile: scenario.systemPromptFile
337
344
  ? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
@@ -461,23 +468,26 @@ async function runRep(scenario, rep, repCount, ctx) {
461
468
  // timeout that caused it, and it is that text, not the real reason, that would
462
469
  // reach the results record. The verdict is ERROR either way.
463
470
  let objective;
471
+ if (traces.length > 0)
472
+ writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
473
+ const execution = executionFacts(traces, {
474
+ scenarioId: scenario.id, mode, repetition: rep, turns: scenario.turns.length,
475
+ subject: ctx.model, responses: transcriptResponses(transcript, scenario),
476
+ }, { source: "live" });
464
477
  if ((scenario.traceAssert || hasOutputGates(scenario)) && !adapterFailure && !noResponse && !infrastructureFailure) {
465
478
  const assertionResults = [...outputGate.assertions];
466
479
  let status = outputGate.status;
467
480
  let traceMeta = {};
468
481
  const outputMeta = outputGate.outputSha256 ? { output_sha256: outputGate.outputSha256 } : {};
469
482
  if (scenario.traceAssert) {
470
- if (traces.length > 0) {
471
- writeFileSync(tracePath(runDir, scenario.id, mode, repSuffix), traces.map(serializeTrace).join(""), "utf8");
472
- }
473
- const merged = mergeTraces(traces);
483
+ const merged = execution.status === "invalid" ? null : mergeTraces(traces);
474
484
  if (unobservablePaths) {
475
485
  status = "ERROR";
476
486
  assertionResults.push({ kind: "unchanged_path", status: "ERROR", detail: "workspace changes could not be observed" });
477
487
  }
478
488
  else if (merged === null) {
479
489
  status = "ERROR";
480
- assertionResults.push({ kind: "trace_evidence", status: "ERROR", detail: "no execution trace was produced" });
490
+ assertionResults.push({ kind: "trace_evidence", status: "ERROR", detail: execution.status === "invalid" ? execution.reason : "no execution trace was produced" });
481
491
  }
482
492
  else {
483
493
  const gate = evaluateTraceGates(scenario.traceAssert, merged);
@@ -534,6 +544,7 @@ async function runRep(scenario, rep, repCount, ctx) {
534
544
  const o = await judgeOneRep({
535
545
  runDir, spec, scenario, transcript, adapter: ctx.adapter, judge,
536
546
  specDir: dirname(ctx.specPath), mode, rep: repCount > 1 ? rep : undefined, now,
547
+ execution, objective, references: ctx.judgeReferences[scenario.id],
537
548
  });
538
549
  verdict = o.verdict;
539
550
  reason = o.reason;
@@ -542,7 +553,7 @@ async function runRep(scenario, rep, repCount, ctx) {
542
553
  judgeCalls = o.metrics?.judge_calls ?? 0;
543
554
  }
544
555
  log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
545
- const subject = mergeTraces(traces)?.metrics;
556
+ const subject = execution.status === "invalid" ? undefined : mergeTraces(traces)?.metrics;
546
557
  return {
547
558
  verdict, reason, suspect, objective, judgment,
548
559
  metrics: {
package/dist/seeded.js CHANGED
@@ -234,6 +234,7 @@ export async function runSeeded(scenario, opts) {
234
234
  model: opts.model,
235
235
  mode: opts.mode,
236
236
  turns: scenario.turns,
237
+ tools: scenario.tools,
237
238
  cwd: repo,
238
239
  // Resolved against the spec dir, exactly like fixtures and post-tests —
239
240
  // the arm's extensions (already absolute) join alongside, same as the
package/dist/sources.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Scenario } from "./spec.js";
2
+ import { type JudgeReference } from "./judge-references.js";
2
3
  export { PROMPT_NORMALIZATION_SOURCE_DIGEST, PROMPT_NORMALIZATION_SOURCE_KEY } from "./prompt-normalization.js";
3
4
  /**
4
5
  * What a run measured, as a map of `key -> sha256`, recorded in results.yaml so
@@ -103,7 +104,7 @@ export declare function fileSha256(path: string): string | null;
103
104
  */
104
105
  export declare function dirSha256(dir: string): string | null;
105
106
  export declare function stimulusDigest(s: Scenario): string;
106
- export declare function rubricDigest(s: Scenario): string;
107
+ export declare function rubricDigest(s: Scenario, specDir?: string, references?: JudgeReference[]): string;
107
108
  export declare function policyDigest(s: Scenario): string;
108
109
  /** Null when the scenario declares no offline-replayable gates — no key is recorded for it. */
109
110
  export declare function gatesDigest(s: Scenario): string | null;
@@ -143,6 +144,8 @@ export interface SourceContext {
143
144
  * caller instead of leaving one quietly un-checked.
144
145
  */
145
146
  judgePersona: string;
147
+ /** Snapshot resolved before execution, shared by prompt construction and recorded rubric hashes. */
148
+ judgeReferences?: Record<string, JudgeReference[]>;
146
149
  }
147
150
  /**
148
151
  * Hash every source this run measures: SKILL.md, each distinct