@skill-harness/core 0.1.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,107 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { buildJudgePrompt, judgeInWorkspace } from "./grade.js";
4
+ import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, } from "./results.js";
5
+ import { outcomesToResult } from "./reps.js";
6
+ import { appendJournal } from "./journal.js";
7
+ /** 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. */
8
+ export async function judgeOneRep(opts) {
9
+ const { runDir, spec, scenario, transcript, adapter, judge, specDir, mode, rep, now } = opts;
10
+ const prompt = buildJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript });
11
+ const g = await judgeInWorkspace(adapter, judge, prompt, specDir);
12
+ writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), g.raw, "utf8");
13
+ const repField = rep === undefined ? {} : { rep };
14
+ appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: g.verdict, reason: g.reason, suspect: g.suspect, ...repField });
15
+ if (g.suspect)
16
+ appendJournal(runDir, { event: "misfire-flag", ts: now(), id: scenario.id, reason: g.reason, ...repField });
17
+ return { verdict: g.verdict, reason: g.reason, suspect: g.suspect };
18
+ }
19
+ /**
20
+ * Re-judge a scenario's saved GREEN transcript(s) with `judge` — no harness
21
+ * re-run. Rewrites the judge-raw artifact per rep, emits per-rep judge-verdict
22
+ * (+ misfire-flag) journal events, and returns the aggregated ScenarioResult
23
+ * (override/note empty; the caller merges any prior override + persists).
24
+ */
25
+ export async function regradeScenario(opts) {
26
+ const now = opts.now ?? (() => new Date().toISOString());
27
+ const files = findTranscriptFiles(opts.runDir, opts.scenario.id, "green");
28
+ if (files.length === 0)
29
+ throw new Error(`no green transcripts for ${opts.scenario.id} in ${opts.runDir}`);
30
+ const repCount = files.length;
31
+ const outcomes = [];
32
+ for (const file of files) {
33
+ const rep = repIndexOf(file) ?? undefined;
34
+ const transcript = readFileSync(join(opts.runDir, file), "utf8");
35
+ outcomes.push(await judgeOneRep({
36
+ runDir: opts.runDir, spec: opts.spec, scenario: opts.scenario, transcript,
37
+ adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode: "green", rep, now,
38
+ }));
39
+ }
40
+ return outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
41
+ }
42
+ /**
43
+ * Re-judge every green-transcript scenario in a run dir with `judge` — no
44
+ * harness re-run. Targets are the run's RECORDED scenarios (falling back to
45
+ * the spec for a run with no prior results.yaml), so re-grading rewrites the
46
+ * whole results.yaml consistently with what the run actually recorded. Each
47
+ * target must still exist in the spec (for its checklist) AND have a green
48
+ * transcript on disk; anything missing fails fast before spending any judge
49
+ * calls. Preserves each prior scenario's override/note, rewrites
50
+ * results.yaml, emits the `score` journal event, and returns the new
51
+ * ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
52
+ */
53
+ export async function regradeRun(opts) {
54
+ const { runDir, spec, adapter, judge, specDir } = opts;
55
+ const now = opts.now ?? (() => new Date().toISOString());
56
+ const prev = existsSync(join(runDir, "results.yaml")) ? readResults(runDir) : null;
57
+ const overrides = new Map((prev?.scenarios ?? []).map((s) => [s.id, { override: s.override, note: s.note }]));
58
+ const mode = prev?.mode ?? "green";
59
+ // Re-grading rewrites the WHOLE results.yaml, so re-judge exactly the
60
+ // scenarios the run recorded (falling back to the spec for a run with no
61
+ // prior results). The guard and the loop iterate the SAME `targets` set, so
62
+ // they can't diverge: each target must still exist in the spec (for its
63
+ // checklist) AND have a transcript on disk — only overridden transcripts
64
+ // survive a commit (audit-trail design). Anything missing would silently drop
65
+ // a recorded verdict or shrink the grade denominator. Fail fast, before
66
+ // spending any judge calls.
67
+ const specById = new Map(spec.scenarios.map((s) => [s.id, s]));
68
+ const targets = (prev?.scenarios ?? spec.scenarios).map((s) => s.id);
69
+ const missing = targets.filter((id) => !specById.has(id) || findTranscriptFiles(runDir, id, "green").length === 0);
70
+ if (missing.length === targets.length) {
71
+ throw new Error(`no green transcripts in ${runDir} — nothing to re-grade`);
72
+ }
73
+ if (missing.length > 0) {
74
+ throw new Error(`cannot re-grade ${missing.join(", ")} in ${runDir} (transcript missing or scenario no longer in the spec) — re-run instead of grading`);
75
+ }
76
+ const scenarioResults = [];
77
+ for (const id of targets) {
78
+ const scenario = specById.get(id); // guaranteed present by the guard above
79
+ const prevScenario = prev?.scenarios.find((s) => s.id === id);
80
+ const threshold = effectiveThreshold(prevScenario, scenario);
81
+ const rr = await regradeScenario({
82
+ runDir, spec, scenario, adapter, judge, specDir, threshold, now,
83
+ });
84
+ const carry = overrides.get(id);
85
+ scenarioResults.push({ ...rr, override: carry?.override ?? null, note: carry?.note ?? "" });
86
+ }
87
+ const ctx = mode === "green" ? { shipBar: spec.ship_bar, critical: spec.critical } : null;
88
+ const results = writeResults(runDir, {
89
+ skill: spec.skill,
90
+ harness: prev?.harness ?? "pi",
91
+ model: prev?.model ?? "unknown",
92
+ judge: { provider: judge.provider, model: judge.model },
93
+ timestamp: prev?.timestamp ?? now(),
94
+ label: prev?.label ?? null,
95
+ mode,
96
+ scenarios: scenarioResults,
97
+ }, ctx);
98
+ const g = results.effective_grade;
99
+ if (ctx) {
100
+ appendJournal(runDir, {
101
+ event: "score", ts: now(),
102
+ passed: g.passed, total: g.total, pct: g.pct, letter: g.letter, ship: g.ship, note: g.note,
103
+ });
104
+ }
105
+ return results;
106
+ }
107
+ //# sourceMappingURL=regrade.js.map
@@ -0,0 +1,80 @@
1
+ import { type ShipBar } from "./spec.js";
2
+ import { type ResultsFile } from "./results.js";
3
+ export interface RunColumn {
4
+ index: number;
5
+ label: string;
6
+ tag: string;
7
+ runDir: string;
8
+ timestamp: string;
9
+ mode: string;
10
+ grade: ResultsFile["effective_grade"];
11
+ judge: ResultsFile["judge"];
12
+ cells: Record<string, {
13
+ judge_verdict: string;
14
+ judge_reason: string;
15
+ suspect: boolean;
16
+ reps?: number;
17
+ passes?: number;
18
+ clean?: number;
19
+ flakiness?: number;
20
+ override: string | null;
21
+ note: string;
22
+ }>;
23
+ }
24
+ export interface ReportData {
25
+ skill: string;
26
+ shipBar: ShipBar;
27
+ critical: string[];
28
+ scenarios: {
29
+ id: string;
30
+ title: string;
31
+ critical: boolean;
32
+ }[];
33
+ columns: RunColumn[];
34
+ }
35
+ /**
36
+ * Collect the latest run per model-tag under <skillDir>/tests/results/, plus the
37
+ * scenario list from the spec (for titles + order). One column per model.
38
+ */
39
+ export declare function collectReport(skillDir: string): ReportData;
40
+ /** Client-facing view (no absolute paths leaked). */
41
+ export declare function publicView(data: ReportData): {
42
+ skill: string;
43
+ shipBar: ShipBar;
44
+ critical: string[];
45
+ scenarios: {
46
+ id: string;
47
+ title: string;
48
+ critical: boolean;
49
+ }[];
50
+ columns: {
51
+ index: number;
52
+ label: string;
53
+ tag: string;
54
+ timestamp: string;
55
+ mode: string;
56
+ grade: import("./results.js").GradeSummary;
57
+ judge: {
58
+ provider: string;
59
+ model: string;
60
+ };
61
+ cells: Record<string, {
62
+ judge_verdict: string;
63
+ judge_reason: string;
64
+ suspect: boolean;
65
+ reps?: number;
66
+ passes?: number;
67
+ clean?: number;
68
+ flakiness?: number;
69
+ override: string | null;
70
+ note: string;
71
+ }>;
72
+ }[];
73
+ };
74
+ /**
75
+ * Inject the run JSON and the client-scorer module into the template, at the
76
+ * __DATA__ and __GRADE__ placeholders respectively. `gradeScript` is the raw
77
+ * contents of assets/report.grade.js (sibling of the template) — the single,
78
+ * score.ts-parity-tested copy of the client grading logic.
79
+ */
80
+ export declare function renderReport(template: string, data: ReportData, gradeScript: string): string;
package/dist/report.js ADDED
@@ -0,0 +1,106 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { loadSpec } from "./spec.js";
4
+ import { readResults } from "./results.js";
5
+ /** Most-recent run dir (by name, which is an ISO-ish slug) under a model-tag dir. */
6
+ function latestRunDir(tagDir) {
7
+ if (!statSync(tagDir).isDirectory())
8
+ return null;
9
+ const runs = readdirSync(tagDir)
10
+ .map((n) => join(tagDir, n))
11
+ .filter((p) => statSync(p).isDirectory() && existsSync(join(p, "results.yaml")))
12
+ .sort();
13
+ return runs.length ? runs[runs.length - 1] : null;
14
+ }
15
+ /**
16
+ * Collect the latest run per model-tag under <skillDir>/tests/results/, plus the
17
+ * scenario list from the spec (for titles + order). One column per model.
18
+ */
19
+ export function collectReport(skillDir) {
20
+ const specPath = join(skillDir, "tests", "specification.yaml");
21
+ const spec = loadSpec(specPath);
22
+ const scenarios = spec.scenarios.map((s) => ({ id: s.id, title: s.title, critical: s.critical }));
23
+ const resultsRoot = join(skillDir, "tests", "results");
24
+ const columns = [];
25
+ if (existsSync(resultsRoot)) {
26
+ const tags = readdirSync(resultsRoot)
27
+ .map((n) => join(resultsRoot, n))
28
+ .filter((p) => statSync(p).isDirectory())
29
+ .sort();
30
+ for (const tagDir of tags) {
31
+ const runDir = latestRunDir(tagDir);
32
+ if (!runDir)
33
+ continue;
34
+ const r = readResults(runDir);
35
+ const cells = {};
36
+ for (const s of r.scenarios) {
37
+ cells[s.id] = {
38
+ judge_verdict: s.judge_verdict,
39
+ judge_reason: s.judge_reason,
40
+ suspect: s.suspect ?? false, // suspect defaults false for older results that predate the field
41
+ reps: s.reps,
42
+ passes: s.passes,
43
+ clean: s.clean,
44
+ flakiness: s.flakiness,
45
+ override: s.override,
46
+ note: s.note,
47
+ };
48
+ }
49
+ columns.push({
50
+ index: columns.length,
51
+ label: r.model,
52
+ tag: tagDir.split("/").pop(),
53
+ runDir,
54
+ timestamp: r.timestamp,
55
+ mode: r.mode,
56
+ grade: r.effective_grade,
57
+ judge: r.judge,
58
+ cells,
59
+ });
60
+ }
61
+ }
62
+ return { skill: spec.skill, shipBar: spec.ship_bar, critical: spec.critical, scenarios, columns };
63
+ }
64
+ /** Client-facing view (no absolute paths leaked). */
65
+ export function publicView(data) {
66
+ return {
67
+ skill: data.skill,
68
+ shipBar: data.shipBar,
69
+ critical: data.critical,
70
+ scenarios: data.scenarios,
71
+ columns: data.columns.map((c) => ({
72
+ index: c.index,
73
+ label: c.label,
74
+ tag: c.tag,
75
+ timestamp: c.timestamp,
76
+ mode: c.mode,
77
+ grade: c.grade,
78
+ judge: c.judge,
79
+ cells: c.cells,
80
+ })),
81
+ };
82
+ }
83
+ /**
84
+ * A bare inline <script> (no type="module") can't contain an `export`
85
+ * statement, but assets/report.grade.js is written as real ESM so it can also
86
+ * be imported directly (by Node, in the parity test). Strip the `export `
87
+ * keyword off each exported declaration so the leftover plain function
88
+ * declarations splice cleanly into the template's script scope.
89
+ */
90
+ function stripExports(js) {
91
+ return js.replace(/^export\s+/gm, "");
92
+ }
93
+ /**
94
+ * Inject the run JSON and the client-scorer module into the template, at the
95
+ * __DATA__ and __GRADE__ placeholders respectively. `gradeScript` is the raw
96
+ * contents of assets/report.grade.js (sibling of the template) — the single,
97
+ * score.ts-parity-tested copy of the client grading logic.
98
+ */
99
+ export function renderReport(template, data, gradeScript) {
100
+ const json = JSON.stringify(publicView(data));
101
+ return template
102
+ .replace("/*__DATA__*/null", json)
103
+ .replace("/*__GRADE__*/", stripExports(gradeScript))
104
+ .replace("__SKILL__", data.skill);
105
+ }
106
+ //# sourceMappingURL=report.js.map
package/dist/reps.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { Verdict } from "./score.js";
2
+ import type { ScenarioResult } from "./results.js";
3
+ /** One rep's outcome (subject run + judge). */
4
+ export interface RepOutcome {
5
+ verdict: Verdict;
6
+ reason: string;
7
+ suspect: boolean;
8
+ }
9
+ /** A scenario's aggregated result over N reps. */
10
+ export interface RepAggregate {
11
+ verdict: Verdict;
12
+ reason: string;
13
+ passes: number;
14
+ reps: number;
15
+ clean: number;
16
+ flakiness: number;
17
+ suspect: boolean;
18
+ }
19
+ /**
20
+ * Collapse N rep outcomes into one scenario verdict. A rep is "clean" when its
21
+ * judge did not misfire. If fewer than half the reps are clean the scenario is
22
+ * `suspect` (its verdict is untrustworthy). Otherwise the pass-rate is computed
23
+ * over the clean reps and the scenario PASSes at `pass_rate >= threshold`
24
+ * (default caller threshold 0.5, ties pass). Flakiness = 1 - |2·pass_rate - 1|.
25
+ */
26
+ export declare function aggregateReps(outcomes: RepOutcome[], threshold: number): RepAggregate;
27
+ /**
28
+ * Collapse a scenario's rep outcomes into a ScenarioResult. N=1 preserves the
29
+ * single judge's verdict/reason with no reps fields (byte-identical to a plain
30
+ * run); N>1 aggregates and persists the effective threshold (so a later
31
+ * re-judge reproduces the same pass-rate). override/note are left empty for the
32
+ * caller to merge.
33
+ */
34
+ export declare function outcomesToResult(id: string, outcomes: RepOutcome[], repCount: number, threshold: number): ScenarioResult;
package/dist/reps.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Collapse N rep outcomes into one scenario verdict. A rep is "clean" when its
3
+ * judge did not misfire. If fewer than half the reps are clean the scenario is
4
+ * `suspect` (its verdict is untrustworthy). Otherwise the pass-rate is computed
5
+ * over the clean reps and the scenario PASSes at `pass_rate >= threshold`
6
+ * (default caller threshold 0.5, ties pass). Flakiness = 1 - |2·pass_rate - 1|.
7
+ */
8
+ export function aggregateReps(outcomes, threshold) {
9
+ const reps = outcomes.length;
10
+ const clean = outcomes.filter((o) => !o.suspect);
11
+ const passes = clean.filter((o) => o.verdict === "PASS").length;
12
+ if (clean.length * 2 < reps) {
13
+ // majority of reps misfired → untrustworthy
14
+ return { verdict: "FAIL", reason: `${reps - clean.length}/${reps} reps misfired — re-judge`, passes, reps, clean: clean.length, flakiness: 0, suspect: true };
15
+ }
16
+ const errored = clean.filter((o) => o.verdict === "ERROR").length;
17
+ if (clean.length > 0 && errored === clean.length) {
18
+ return { verdict: "ERROR", reason: `${errored}/${reps} reps errored`, passes: 0, reps, clean: clean.length, flakiness: 0, suspect: false };
19
+ }
20
+ const passRate = passes / clean.length;
21
+ const verdict = passRate >= threshold ? "PASS" : "FAIL";
22
+ const flakiness = 1 - Math.abs(2 * passRate - 1);
23
+ const reason = reps === 1 ? outcomes[0].reason : `${passes}/${clean.length} reps passed (flaky ${flakiness.toFixed(2)})`;
24
+ return { verdict, reason, passes, reps, clean: clean.length, flakiness, suspect: false };
25
+ }
26
+ /**
27
+ * Collapse a scenario's rep outcomes into a ScenarioResult. N=1 preserves the
28
+ * single judge's verdict/reason with no reps fields (byte-identical to a plain
29
+ * run); N>1 aggregates and persists the effective threshold (so a later
30
+ * re-judge reproduces the same pass-rate). override/note are left empty for the
31
+ * caller to merge.
32
+ */
33
+ export function outcomesToResult(id, outcomes, repCount, threshold) {
34
+ if (repCount === 1) {
35
+ const o = outcomes[0];
36
+ return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, override: null, note: "" };
37
+ }
38
+ const agg = aggregateReps(outcomes, threshold);
39
+ return {
40
+ id, judge_verdict: agg.verdict, judge_reason: agg.reason, suspect: agg.suspect,
41
+ reps: agg.reps, passes: agg.passes, clean: agg.clean, flakiness: agg.flakiness,
42
+ pass_threshold: threshold, override: null, note: "",
43
+ };
44
+ }
45
+ //# sourceMappingURL=reps.js.map
@@ -0,0 +1,108 @@
1
+ import { type ModelRef } from "./adapters/types.js";
2
+ import { type ScenarioVerdict } from "./score.js";
3
+ import type { Verdict } from "./score.js";
4
+ import type { ShipBar, Scenario } from "./spec.js";
5
+ export interface ScenarioResult {
6
+ id: string;
7
+ judge_verdict: Verdict;
8
+ judge_reason: string;
9
+ suspect: boolean;
10
+ override: Verdict | null;
11
+ note: string;
12
+ reps?: number;
13
+ passes?: number;
14
+ clean?: number;
15
+ flakiness?: number;
16
+ pass_threshold?: number;
17
+ }
18
+ export interface GradeSummary {
19
+ passed: number;
20
+ total: number;
21
+ pct: number;
22
+ letter: string;
23
+ ship: boolean;
24
+ note: string;
25
+ }
26
+ export interface ResultsFile {
27
+ schema: 2;
28
+ skill: string;
29
+ harness: string;
30
+ model: string;
31
+ judge: {
32
+ provider: string;
33
+ model: string;
34
+ };
35
+ timestamp: string;
36
+ label: string | null;
37
+ mode: string;
38
+ effective_grade: GradeSummary;
39
+ scenarios: ScenarioResult[];
40
+ }
41
+ /** The pass-threshold a re-grade uses: the run's persisted value, else the spec's per-scenario value, else 0.5. */
42
+ export declare function effectiveThreshold(prevScenario: ScenarioResult | undefined, scenario: Scenario): number;
43
+ /** Everything a caller may set. The grade is computed, never supplied. */
44
+ export type ResultsDraft = Omit<ResultsFile, "schema" | "effective_grade">;
45
+ export interface ScoreContext {
46
+ shipBar: ShipBar;
47
+ critical: string[];
48
+ }
49
+ /** <skillDir>/tests/results/<harness>-<model-slug>/<timestamp-slug>/ */
50
+ export declare function runDirFor(skillDir: string, harness: string, model: ModelRef, timestamp: string): string;
51
+ /** Path of a transcript file within a run dir. A rep index (for --reps N>1) is suffixed. */
52
+ export declare function transcriptPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
53
+ export declare function reportPath(runDir: string): string;
54
+ export declare function resultsPath(runDir: string): string;
55
+ /** The verdict that counts: author override when present, else the judge's. */
56
+ export declare function effectiveVerdicts(scenarios: ScenarioResult[]): ScenarioVerdict[];
57
+ /**
58
+ * The ONLY place effective_grade is computed. Every writer goes through here,
59
+ * so a persisted grade can never disagree with verdicts + overrides.
60
+ * ctx is null for unscored (red/force) runs.
61
+ */
62
+ export declare function finalizeResults(draft: ResultsDraft, ctx: ScoreContext | null): ResultsFile;
63
+ /** Finalize + persist results.yaml (creating the run dir). Returns what was written. */
64
+ export declare function writeResults(runDir: string, draft: ResultsDraft, ctx: ScoreContext | null): ResultsFile;
65
+ /** Read-only schema-1 → schema-2 migration. Never rewrites the file on disk. */
66
+ export declare function migrateResults(raw: unknown): ResultsFile;
67
+ /** Read results.yaml from a run dir, migrating schema-1 files in memory. */
68
+ export declare function readResults(runDir: string): ResultsFile;
69
+ /** Pure: return a copy with override + note applied to one scenario. */
70
+ export declare function applyOverride(results: ResultsFile, scenarioId: string, override: Verdict | null, note: string): ResultsFile;
71
+ /**
72
+ * Manage results/.gitignore: transcripts + reports ignored, results.yaml tracked.
73
+ * Rewrites a stale managed body (so new ignore rules roll out) while keeping any
74
+ * `!…` preservation lines added by preserveTranscript.
75
+ */
76
+ export declare function ensureResultsGitignore(resultsRoot: string): void;
77
+ /** The rep index embedded in a transcript/judge-raw filename (`.rep<k>.`), or null for a plain (non-rep) file. */
78
+ export declare function repIndexOf(filename: string): number | null;
79
+ /**
80
+ * ALL transcript files for a scenario in a run dir, sorted deterministically:
81
+ * a plain `<id>.<mode>.txt` first (if present), then rep-suffixed files
82
+ * (`<id>.<mode>.rep<k>.txt`) in numeric rep order. Empty if the run dir or
83
+ * scenario has no transcripts.
84
+ *
85
+ * With `mode` given, only that mode's transcripts match (`<id>.<mode>.txt` /
86
+ * `<id>.<mode>.rep<k>.txt`) — e.g. to detect a green-only condition without
87
+ * false positives from a red/force transcript of the same scenario. Omitted,
88
+ * behavior is unchanged: any `<id>.*.txt` regardless of mode, excluding this
89
+ * scenario's judge-raw artifacts (`<id>.*.judge.txt` — see judgeRawPath).
90
+ */
91
+ export declare function findTranscriptFiles(runDir: string, scenarioId: string, mode?: string): string[];
92
+ /** Path of a scenario's raw judge-output artifact within a run dir (rep-suffixed for reps). */
93
+ export declare function judgeRawPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
94
+ /** A scenario's raw judge-output files, sorted (plain first, then numeric rep). Mode-scoped when given. */
95
+ export declare function findJudgeRawFiles(runDir: string, scenarioId: string, mode?: string): string[];
96
+ /** A single representative transcript file for a scenario in a run dir. Null if none. */
97
+ export declare function findTranscriptFile(runDir: string, scenarioId: string): string | null;
98
+ /**
99
+ * Un-gitignore ALL of a scenario's transcript AND judge-raw artifact files
100
+ * (audit trail for an override — a --reps run has one transcript (and one
101
+ * judge-raw file) per rep, and every rep that drove the verdict must survive
102
+ * a commit, not just an arbitrary one).
103
+ * Appends `!<tag>/<ts>/<id>.<mode>[.rep<k>].txt` (and the matching
104
+ * `.judge.txt`) to results/.gitignore for each, once. The path uses POSIX
105
+ * separators so the negation matches on Windows too (git ignore patterns are
106
+ * always forward-slashed).
107
+ */
108
+ export declare function preserveTranscript(resultsRoot: string, runDir: string, scenarioId: string): void;