@skill-harness/core 0.6.0 → 0.8.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 +37 -0
  2. package/dist/adjudication.d.ts +210 -0
  3. package/dist/adjudication.js +392 -0
  4. package/dist/affected.d.ts +88 -0
  5. package/dist/affected.js +222 -0
  6. package/dist/capture-trace-types.d.ts +228 -0
  7. package/dist/capture-trace-types.js +23 -0
  8. package/dist/capture.d.ts +193 -0
  9. package/dist/capture.js +344 -0
  10. package/dist/execution-trace.d.ts +61 -0
  11. package/dist/execution-trace.js +299 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.js +9 -0
  14. package/dist/instruction-coverage.d.ts +106 -0
  15. package/dist/instruction-coverage.js +253 -0
  16. package/dist/journal.d.ts +17 -0
  17. package/dist/lint.d.ts +8 -1
  18. package/dist/lint.js +31 -2
  19. package/dist/regate.js +80 -17
  20. package/dist/regrade.js +17 -3
  21. package/dist/report.d.ts +24 -0
  22. package/dist/report.js +24 -0
  23. package/dist/reps.d.ts +14 -1
  24. package/dist/reps.js +28 -2
  25. package/dist/rescore.js +11 -2
  26. package/dist/restamp.d.ts +68 -0
  27. package/dist/restamp.js +194 -0
  28. package/dist/results.d.ts +128 -6
  29. package/dist/results.js +155 -6
  30. package/dist/run.js +114 -8
  31. package/dist/seeded.d.ts +11 -0
  32. package/dist/seeded.js +31 -7
  33. package/dist/sources.d.ts +50 -0
  34. package/dist/sources.js +215 -7
  35. package/dist/spec-write.d.ts +62 -0
  36. package/dist/spec-write.js +106 -0
  37. package/dist/spec.d.ts +29 -0
  38. package/dist/spec.js +55 -0
  39. package/dist/stability.js +26 -4
  40. package/dist/trace-gates.d.ts +133 -0
  41. package/dist/trace-gates.js +519 -0
  42. package/dist/workspace.d.ts +36 -0
  43. package/dist/workspace.js +61 -0
  44. package/package.json +1 -1
package/dist/reps.js CHANGED
@@ -1,3 +1,25 @@
1
+ /**
2
+ * Collapse per-rep objective results.
3
+ *
4
+ * Strict on purpose, and deliberately NOT the same policy as the judge's
5
+ * pass-threshold aggregation: an objective assertion is a statement about what
6
+ * the model DID, so one rep that called a forbidden tool is a real finding, not
7
+ * a minority draw to be voted away. ERROR dominates (missing evidence is never a
8
+ * pass), then FAIL, then PASS. The retained assertion detail comes from the
9
+ * first non-passing rep, since that is the one worth reading.
10
+ */
11
+ export function aggregateObjective(outcomes) {
12
+ const present = outcomes.map((o) => o.objective).filter((o) => o !== undefined);
13
+ if (present.length === 0)
14
+ return undefined;
15
+ const errored = present.find((o) => o.status === "ERROR");
16
+ if (errored)
17
+ return errored;
18
+ const failed = present.find((o) => o.status === "FAIL");
19
+ if (failed)
20
+ return failed;
21
+ return present[0];
22
+ }
1
23
  /**
2
24
  * Collapse N rep outcomes into one scenario verdict. A rep is "clean" when its
3
25
  * judge did not misfire. If fewer than half the reps are clean the scenario is
@@ -31,15 +53,19 @@ export function aggregateReps(outcomes, threshold) {
31
53
  * caller to merge.
32
54
  */
33
55
  export function outcomesToResult(id, outcomes, repCount, threshold) {
56
+ // Spread rather than always-set: a scenario with no trace assertions must
57
+ // produce a result byte-identical to one from before this field existed.
58
+ const objective = aggregateObjective(outcomes);
59
+ const objectiveField = objective ? { objective } : {};
34
60
  if (repCount === 1) {
35
61
  const o = outcomes[0];
36
- return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, override: null, note: "" };
62
+ return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, override: null, note: "", ...objectiveField };
37
63
  }
38
64
  const agg = aggregateReps(outcomes, threshold);
39
65
  return {
40
66
  id, judge_verdict: agg.verdict, judge_reason: agg.reason, suspect: agg.suspect,
41
67
  reps: agg.reps, passes: agg.passes, clean: agg.clean, flakiness: agg.flakiness,
42
- pass_threshold: threshold, override: null, note: "",
68
+ pass_threshold: threshold, override: null, note: "", ...objectiveField,
43
69
  };
44
70
  }
45
71
  //# sourceMappingURL=reps.js.map
package/dist/rescore.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { readResults, writeResults, scoreContextFor } from "./results.js";
3
+ import { readResults, writeResults, scoreContextFor, rebuildScenarioResult } from "./results.js";
4
4
  import { appendJournal } from "./journal.js";
5
5
  import { policyDigest, POLICY_PREFIX } from "./sources.js";
6
6
  /**
@@ -65,7 +65,16 @@ export function rescoreRun(opts) {
65
65
  if (verdict !== s.judge_verdict) {
66
66
  changes.push({ id: s.id, from: s.judge_verdict, to: verdict, passes: s.passes, clean: s.clean, fromThreshold, toThreshold });
67
67
  }
68
- return { ...s, judge_verdict: verdict, pass_threshold: toThreshold };
68
+ // Through the choke point, not a spread. This was the FIFTH rewriter of a
69
+ // `ScenarioResult` and the only one still using `{ ...s }` — so it inherited
70
+ // none of the invariants the others get, and adding `objective` and
71
+ // `adjudication` to the type did not fail the build here. Concretely: a cell
72
+ // that adjudication settled FAIL reverted to PASS when a threshold change
73
+ // recomputed it from rep counters that adjudication never updated.
74
+ //
75
+ // Both blocks are CARRIED: a rescore re-applies a threshold to reps that were
76
+ // already measured. It re-measures nothing, so it may discard nothing.
77
+ return rebuildScenarioResult({ ...s, judge_verdict: verdict, pass_threshold: toThreshold }, s, { objective: "carry", adjudication: "carry" });
69
78
  });
70
79
  const ctx = scoreContextFor(prev, opts.spec);
71
80
  const results = writeResults(opts.runDir, {
@@ -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;
@@ -0,0 +1,194 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import yaml from "js-yaml";
6
+ import { loadSpec } from "./spec.js";
7
+ import { resultsPath } from "./results.js";
8
+ import { enumerateRunDirs } from "./lint.js";
9
+ import { promptDocDigest, SKILL_KEY, SKILL_PROMPT_KEY, PROMPT_PREFIX, UNREADABLE, } from "./sources.js";
10
+ /**
11
+ * The `source_hashes` map of a raw-loaded results doc, or null when the doc has none.
12
+ * Read from the RAW yaml rather than through `readResults` on purpose: this rewrites the
13
+ * file, and `readResults` migrates schema 1 → 2 in memory, so writing its output back
14
+ * would silently upgrade the schema of records nobody asked to touch.
15
+ */
16
+ function rawHashes(doc) {
17
+ if (!doc || typeof doc !== "object")
18
+ return null;
19
+ const h = doc.source_hashes;
20
+ if (!h || typeof h !== "object" || Array.isArray(h))
21
+ return null;
22
+ return h;
23
+ }
24
+ /**
25
+ * `git show <ref>:<path>` as BYTES, or null when the ref, the repo, or the path isn't there.
26
+ *
27
+ * `execFileSync` with a buffer encoding rather than the shared `exec` helper: that one
28
+ * accumulates stdout as decoded strings, so a multi-byte character landing on a chunk
29
+ * boundary would come back with a replacement character and hash to something the run
30
+ * never recorded. This comparison is byte-exact by definition, so it reads bytes.
31
+ */
32
+ function bytesAtRef(abs, ref) {
33
+ const cwd = dirname(abs);
34
+ try {
35
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }).trim();
36
+ const rel = relative(top, abs).split("\\").join("/");
37
+ return execFileSync("git", ["show", `${ref}:${rel}`], { cwd, maxBuffer: 64 * 1024 * 1024 });
38
+ }
39
+ catch {
40
+ return null; // no git, no repo, no such ref, or the file did not exist there
41
+ }
42
+ }
43
+ /**
44
+ * The bytes a run is claimed to have measured: `<ref>`'s copy, or the working tree's when
45
+ * no ref was given. Null when they cannot be read at all, which proves nothing and so
46
+ * upgrades nothing.
47
+ */
48
+ function measuredBytes(abs, from) {
49
+ if (from !== undefined)
50
+ return bytesAtRef(abs, from);
51
+ try {
52
+ return readFileSync(abs);
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * Fail loudly when `--from` names something git cannot resolve.
60
+ *
61
+ * Without this the whole migration degrades into "0 upgraded, everything left alone",
62
+ * which is indistinguishable from a corpus that genuinely cannot be proven — measured on
63
+ * a clone whose only local branch was the feature branch, where `--from main` silently
64
+ * did nothing and read as the tool being broken. A ref the operator got wrong is their
65
+ * mistake to see, not one to absorb.
66
+ */
67
+ function assertRefResolves(dir, ref) {
68
+ try {
69
+ execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd: dir, stdio: "ignore" });
70
+ }
71
+ catch {
72
+ throw new Error(`--from \`${ref}\` does not resolve to a commit in the repo holding ${dir} — ` +
73
+ `check the ref exists locally (a fresh clone often has no local \`main\`; try \`origin/main\`)`);
74
+ }
75
+ }
76
+ /**
77
+ * Write via a temp file in the same directory, then rename.
78
+ *
79
+ * `rename(2)` is atomic within a filesystem, so a reader sees the old file or the new one
80
+ * and never a half-written one. It matters more here than in the writers that produce a
81
+ * run: this loops over an entire published corpus rewriting records that already hold
82
+ * measurements nobody can reproduce without spending model tokens, so an interrupt or a
83
+ * full disk partway through would truncate exactly the artifacts the command exists to
84
+ * preserve. The temp file is `.tmp`-suffixed beside the target rather than in the system
85
+ * temp dir, because a cross-device rename is not atomic — and is not even permitted.
86
+ */
87
+ function writeAtomically(path, text) {
88
+ const tmp = `${path}.restamp.tmp`;
89
+ writeFileSync(tmp, text, "utf8");
90
+ try {
91
+ renameSync(tmp, path);
92
+ }
93
+ catch (e) {
94
+ try {
95
+ rmSync(tmp, { force: true });
96
+ }
97
+ catch { /* the rename failure is what matters */ }
98
+ throw e;
99
+ }
100
+ }
101
+ /** Upgrade every provable record under one skill's tests/results. */
102
+ export function restampSkill(skillDir, opts = {}) {
103
+ const report = { runs: 0, upgraded: 0, unprovable: 0, unchanged: 0, partial: 0, added: [] };
104
+ const specPath = join(skillDir, "tests", "specification.yaml");
105
+ const spec = loadSpec(specPath);
106
+ const specDir = dirname(specPath);
107
+ // Only `system_prompt_file` paths, resolved from the CURRENT spec. The other
108
+ // path-shaped keys (`post_test`, `env.extensions`) name code, not prompt documents:
109
+ // every byte of those reaches the run, so they keep hashing raw and are left alone.
110
+ const promptDocs = new Map([[SKILL_KEY, resolve(skillDir, "SKILL.md")]]);
111
+ for (const s of spec.scenarios) {
112
+ if (s.systemPromptFile)
113
+ promptDocs.set(s.systemPromptFile, resolve(specDir, s.systemPromptFile));
114
+ }
115
+ if (opts.from !== undefined)
116
+ assertRefResolves(skillDir, opts.from);
117
+ // Resolved once per document, not once per run: with `--from`, each resolution is two
118
+ // git invocations, and a corpus of 140 runs would otherwise pay for them 140 times over.
119
+ const provable = new Map();
120
+ for (const [legacyKey, abs] of promptDocs) {
121
+ const measured = measuredBytes(abs, opts.from);
122
+ let currentPrompt = null;
123
+ try {
124
+ currentPrompt = promptDocDigest(readFileSync(abs, "utf8"));
125
+ }
126
+ catch {
127
+ currentPrompt = null;
128
+ }
129
+ provable.set(legacyKey, {
130
+ measuredRaw: measured === null ? null : createHash("sha256").update(measured).digest("hex"),
131
+ measuredPrompt: measured === null ? null : promptDocDigest(measured.toString("utf8")),
132
+ currentPrompt,
133
+ });
134
+ }
135
+ for (const runDir of enumerateRunDirs(join(skillDir, "tests", "results"))) {
136
+ report.runs++;
137
+ let doc;
138
+ try {
139
+ doc = yaml.load(readFileSync(resultsPath(runDir), "utf8"));
140
+ }
141
+ catch {
142
+ // Unreadable/malformed → lint already reports it; never rewrite what we can't read.
143
+ // Still bucketed: every record counted in `runs` has to be accounted for somewhere.
144
+ report.unchanged++;
145
+ continue;
146
+ }
147
+ const hashes = rawHashes(doc);
148
+ if (!hashes) {
149
+ report.unchanged++; // predates source_hashes → nothing to upgrade from
150
+ continue;
151
+ }
152
+ let blocked = false;
153
+ const additions = [];
154
+ for (const legacyKey of promptDocs.keys()) {
155
+ const recorded = hashes[legacyKey];
156
+ if (recorded === undefined || recorded === UNREADABLE)
157
+ continue; // never measured → nothing proven
158
+ const upgradedKey = legacyKey === SKILL_KEY ? SKILL_PROMPT_KEY : PROMPT_PREFIX + legacyKey;
159
+ if (hashes[upgradedKey] !== undefined)
160
+ continue; // already upgraded
161
+ const p = provable.get(legacyKey);
162
+ if (p.currentPrompt === null)
163
+ continue; // the file is gone; lint reports that itself
164
+ // Two steps, and both are required. First: this run measured the reference bytes —
165
+ // otherwise the reference says nothing about THIS run. Second: those bytes and
166
+ // today's carry the same model-visible text — otherwise what the model reads has
167
+ // genuinely moved and only a re-run can speak for it.
168
+ if (p.measuredRaw === null || recorded !== p.measuredRaw || p.measuredPrompt !== p.currentPrompt) {
169
+ blocked = true;
170
+ continue;
171
+ }
172
+ additions.push([upgradedKey, p.currentPrompt]);
173
+ }
174
+ if (additions.length === 0) {
175
+ // Exclusive: a record that gained nothing is `unprovable` only if something
176
+ // actually blocked it, and `unchanged` otherwise.
177
+ if (blocked)
178
+ report.unprovable++;
179
+ else
180
+ report.unchanged++;
181
+ continue;
182
+ }
183
+ for (const [k, v] of additions) {
184
+ hashes[k] = v;
185
+ report.added.push(`${runDir}: ${k}`);
186
+ }
187
+ writeAtomically(resultsPath(runDir), yaml.dump(doc, { lineWidth: 100 }));
188
+ report.upgraded++;
189
+ if (blocked)
190
+ report.partial++;
191
+ }
192
+ return report;
193
+ }
194
+ //# sourceMappingURL=restamp.js.map
package/dist/results.d.ts CHANGED
@@ -14,6 +14,56 @@ export interface ScenarioResult {
14
14
  clean?: number;
15
15
  flakiness?: number;
16
16
  pass_threshold?: number;
17
+ /**
18
+ * Objective trace-gate evidence. ADDITIVE and optional.
19
+ *
20
+ * Absent means "the scenario declared no trace assertions" — NOT an objective
21
+ * pass. Anything reading this must treat the two as different; collapsing them
22
+ * would silently upgrade every legacy result to "objectively verified".
23
+ */
24
+ objective?: ObjectiveResult;
25
+ /**
26
+ * Confidence-aware adjudication. ADDITIVE and optional.
27
+ *
28
+ * Absent means historical single-judge behavior — NOT that judges agreed. An
29
+ * unresolved adjudication additionally sets `suspect: true`, which is what
30
+ * actually blocks SHIP; this field is the audit trail behind that flag.
31
+ */
32
+ adjudication?: AdjudicationResult;
33
+ }
34
+ /** One judge's answer for a cell, kept verbatim however the collapse turned out. */
35
+ export interface Judgment {
36
+ /** 1-based: judgment 1 is the first-wave judge, 2 the secondary, 3 the tie-break. */
37
+ ordinal: number;
38
+ judge: {
39
+ provider: string;
40
+ model: string;
41
+ };
42
+ verdict: Verdict;
43
+ reason: string;
44
+ /** The judge misfired — recorded, never counted as a clean vote. */
45
+ suspect: boolean;
46
+ }
47
+ export interface AdjudicationResult {
48
+ state: "confirmed" | "tie_broken" | "unresolved";
49
+ /** Why the cell was re-judged. */
50
+ trigger: "ambiguous" | "contradictory" | "non_unanimous" | "ship_deciding";
51
+ /** Every judgment, in order. Never pruned — an author resolving this needs all of them. */
52
+ judgments: Judgment[];
53
+ /** The collapsed answer. Absent when unresolved. */
54
+ verdict?: "PASS" | "FAIL";
55
+ }
56
+ /** Objective gate outcome for one scenario cell. */
57
+ export interface ObjectiveResult {
58
+ /** ERROR means the evidence was missing or malformed — never a pass. */
59
+ status: "PASS" | "FAIL" | "ERROR";
60
+ trace_version?: number;
61
+ trace_sha256?: string;
62
+ assertions: {
63
+ kind: string;
64
+ status: "PASS" | "FAIL" | "ERROR";
65
+ detail: string;
66
+ }[];
17
67
  }
18
68
  export interface GradeSummary {
19
69
  passed: number;
@@ -56,16 +106,23 @@ export interface ResultsFile {
56
106
  harness_cli_version?: string;
57
107
  /**
58
108
  * `pass` when this run proved, before spending the wave, that the skill body was
59
- * reachable in the model's context (see canary.ts). Absent means the probe was
60
- * not asked for — never that it failed, because a failed canary aborts the run
61
- * and no results.yaml is written.
109
+ * reachable in the model's context (see canary.ts). `skipped` when the probe was
110
+ * asked for but could not be performed — SKILL.md has no `## ` heading to quote
111
+ * back, so no reply could prove anything. Absent means the probe was not asked
112
+ * for — never that it failed, because a failed canary aborts the run and no
113
+ * results.yaml is written.
114
+ *
115
+ * `skipped` exists because absent and skipped were the same value: a user who
116
+ * passed `--canary` precisely because pi ≥ 0.83.0 delivery is unreliable got a
117
+ * silently degraded probe, a fully billed wave, and a committed results.yaml
118
+ * byte-identical to a run where delivery was never checked at all.
62
119
  *
63
120
  * Only green runs can carry it: red delivers nothing by design and force delivers
64
121
  * through the system prompt. It is provenance for the *validity* of a green run,
65
122
  * which is why it lives here rather than only in the journal — `journal.jsonl` is
66
123
  * gitignored, and this claim has to survive a commit.
67
124
  */
68
- delivery_canary?: "pass";
125
+ delivery_canary?: "pass" | "skipped";
69
126
  skill: string;
70
127
  harness: string;
71
128
  model: string;
@@ -152,12 +209,35 @@ export declare function runDirFor(skillDir: string, harness: string, model: Mode
152
209
  export declare function transcriptPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
153
210
  export declare function reportPath(runDir: string): string;
154
211
  export declare function resultsPath(runDir: string): string;
155
- /** The verdict that counts: author override when present, else the judge's. */
212
+ /**
213
+ * The verdict that counts: author override when present, else the objective
214
+ * gate, else the judge's.
215
+ *
216
+ * **The objective gate outranks the judge.** `assert.trace` is a mechanical
217
+ * statement about what the model DID — it called `write`, it touched `.env`.
218
+ * The judge is an LLM reading prose. When they disagree, the measurement wins.
219
+ *
220
+ * This is the only place that ordering is enforced, and it has to be here.
221
+ * `objective` used to reach the ship decision solely through `gatePrefix` in
222
+ * `run.ts`, which forces a single rep's verdict — so every path that recomputed
223
+ * a verdict afterwards silently dropped the gate while keeping the `objective`
224
+ * block that claimed it was enforced. Three of them did: `--reps N` out-voted an
225
+ * objective FAIL 2-to-1 (100%, grade A, SHIP, on a CRITICAL scenario that called
226
+ * a forbidden tool), `regrade` re-judged from a transcript the tool calls are
227
+ * absent from, and `regate` recomputed it. `reps.ts` already states the policy —
228
+ * "one rep that called a forbidden tool is a real finding, not a minority draw
229
+ * to be voted away" — and nothing enforced it.
230
+ *
231
+ * An author override still wins, exactly as it does over `suspect`. Overriding a
232
+ * deterministic assertion is a deliberate, recorded human act — and the failure
233
+ * this guards against was never a human deciding, it was nobody deciding.
234
+ */
156
235
  export declare function effectiveVerdicts(scenarios: ScenarioResult[]): ScenarioVerdict[];
157
236
  /**
158
237
  * The ONLY place effective_grade is computed. Every writer goes through here,
159
238
  * so a persisted grade can never disagree with verdicts + overrides.
160
- * ctx is null for unscored (red/force) runs.
239
+ * ctx is null for unscored runs — `red` only, since 0.5.0: `force` is a real
240
+ * deployment and is scored (see SCORED_MODES directly above).
161
241
  */
162
242
  export declare function finalizeResults(draft: ResultsDraft, ctx: ScoreContext | null): ResultsFile;
163
243
  /** Finalize + persist results.yaml (creating the run dir). Returns what was written. */
@@ -207,8 +287,50 @@ export declare function findJudgeRawFiles(runDir: string, scenarioId: string, mo
207
287
  * generated evidence, ignored like transcripts, not committed like results.yaml.
208
288
  */
209
289
  export declare function diffPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
290
+ /**
291
+ * What happens to one piece of recorded evidence when a command rebuilds a result.
292
+ *
293
+ * - `carry` — the command did not re-measure this, so the prior value still
294
+ * describes the run and must survive.
295
+ * - `fresh` — the command re-measured it; take the new value.
296
+ * - `drop` — the command invalidated it; a stale value would misinform.
297
+ */
298
+ export type EvidencePolicy = "carry" | "fresh" | "drop";
299
+ export interface RebuildPolicy {
300
+ objective: EvidencePolicy;
301
+ adjudication: EvidencePolicy;
302
+ }
303
+ /**
304
+ * Rebuild a `ScenarioResult` after a command re-measured part of it.
305
+ *
306
+ * **The single choke point for every rewriter**, and exhaustive by construction:
307
+ * every field is destructured below, so adding one to `ScenarioResult` fails the
308
+ * build HERE until someone decides whether it is carried, taken fresh, or
309
+ * dropped. That guard is the whole point of the function.
310
+ *
311
+ * It exists because the ad-hoc version — `{ ...fresh, override: prior.override,
312
+ * note: prior.note }`, written independently in three places — silently dropped
313
+ * `objective` from `grade` and `adjudication` from `regate`. Both failures ran in
314
+ * the dangerous direction: a trace-gated scenario re-read as "no assertions
315
+ * declared", and an unresolved judge disagreement as a settled verdict. 1,036
316
+ * tests passed through it; a real smoke run caught it.
317
+ *
318
+ * The author's `override` and `note` are always carried and are not policy —
319
+ * no command re-measures a human's judgement.
320
+ */
321
+ export declare function rebuildScenarioResult(fresh: ScenarioResult, prior: ScenarioResult | undefined, policy: RebuildPolicy): ScenarioResult;
322
+ /**
323
+ * Where a rep's execution trace is saved: `<id>.<mode>[.rep<k>].trace.jsonl`.
324
+ *
325
+ * `.jsonl` rather than `.txt` so it is distinguishable at a glance from a
326
+ * transcript, and one JSON object per line so a multi-turn scenario's per-turn
327
+ * traces append without a wrapper.
328
+ */
329
+ export declare function tracePath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
210
330
  /** A scenario's staged-diff files, sorted (plain first, then numeric rep). Mode-scoped when given. */
211
331
  export declare function findDiffFiles(runDir: string, scenarioId: string, mode?: string): string[];
332
+ /** A scenario's execution-trace files, sorted (plain first, then numeric rep). Mode-scoped when given. */
333
+ export declare function findTraceFiles(runDir: string, scenarioId: string, mode?: string): string[];
212
334
  /** A single representative transcript file for a scenario in a run dir. Null if none. */
213
335
  export declare function findTranscriptFile(runDir: string, scenarioId: string): string | null;
214
336
  /**