@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.
- package/dist/adapters/types.d.ts +37 -0
- package/dist/adjudication.d.ts +210 -0
- package/dist/adjudication.js +392 -0
- package/dist/affected.d.ts +88 -0
- package/dist/affected.js +222 -0
- package/dist/capture-trace-types.d.ts +228 -0
- package/dist/capture-trace-types.js +23 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +344 -0
- package/dist/execution-trace.d.ts +61 -0
- package/dist/execution-trace.js +299 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/instruction-coverage.d.ts +106 -0
- package/dist/instruction-coverage.js +253 -0
- package/dist/journal.d.ts +17 -0
- package/dist/lint.d.ts +8 -1
- package/dist/lint.js +31 -2
- package/dist/regate.js +80 -17
- package/dist/regrade.js +17 -3
- package/dist/report.d.ts +24 -0
- package/dist/report.js +24 -0
- package/dist/reps.d.ts +14 -1
- package/dist/reps.js +28 -2
- package/dist/rescore.js +11 -2
- package/dist/restamp.d.ts +68 -0
- package/dist/restamp.js +194 -0
- package/dist/results.d.ts +128 -6
- package/dist/results.js +155 -6
- package/dist/run.js +114 -8
- package/dist/seeded.d.ts +11 -0
- package/dist/seeded.js +31 -7
- package/dist/sources.d.ts +50 -0
- package/dist/sources.js +215 -7
- package/dist/spec-write.d.ts +62 -0
- package/dist/spec-write.js +106 -0
- package/dist/spec.d.ts +29 -0
- package/dist/spec.js +55 -0
- package/dist/stability.js +26 -4
- package/dist/trace-gates.d.ts +133 -0
- package/dist/trace-gates.js +519 -0
- package/dist/workspace.d.ts +36 -0
- package/dist/workspace.js +61 -0
- package/package.json +1 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve, dirname, relative, isAbsolute } from "node:path";
|
|
3
|
+
const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
4
|
+
const ATX = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
5
|
+
const SETEXT_H1 = /^\s{0,3}=+\s*$/;
|
|
6
|
+
const SETEXT_H2 = /^\s{0,3}-+\s*$/;
|
|
7
|
+
/**
|
|
8
|
+
* GitHub-style anchor slug: lowercase, drop punctuation, spaces to hyphens.
|
|
9
|
+
*
|
|
10
|
+
* Matching GitHub matters because the reference an author writes
|
|
11
|
+
* (`SKILL.md#core-principle`) is the anchor they would use in a link, so it is
|
|
12
|
+
* the one they will guess.
|
|
13
|
+
*/
|
|
14
|
+
export function slugify(title) {
|
|
15
|
+
return title
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[`*_~[\]()]/g, "")
|
|
18
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
|
19
|
+
.trim()
|
|
20
|
+
.replace(/\s+/g, "-");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Extract heading sections from Markdown.
|
|
24
|
+
*
|
|
25
|
+
* Fenced code blocks are skipped: a shell comment (`# rebuild the bundle`) inside
|
|
26
|
+
* an example is not a section, and treating it as one both invents coverage
|
|
27
|
+
* targets and shifts every subsequent section's line range — which would then
|
|
28
|
+
* mis-map git hunks to the wrong section, silently selecting the wrong tests.
|
|
29
|
+
*/
|
|
30
|
+
export function parseSections(markdown) {
|
|
31
|
+
const lines = markdown.split("\n");
|
|
32
|
+
const found = [];
|
|
33
|
+
const seen = new Map();
|
|
34
|
+
let fence = null;
|
|
35
|
+
// Skip YAML frontmatter. Every SKILL.md opens with it, and its closing `---`
|
|
36
|
+
// makes the line above look exactly like a Setext h2 underline — so without
|
|
37
|
+
// this, every skill gains a phantom section named after its own `description:`
|
|
38
|
+
// line, and it lands at the top where it is most likely to be "covered" by a
|
|
39
|
+
// careless whole-file reference.
|
|
40
|
+
const start = frontmatterEnd(lines);
|
|
41
|
+
const push = (title, depth, startLine) => {
|
|
42
|
+
const base = slugify(title);
|
|
43
|
+
if (base === "")
|
|
44
|
+
return; // an empty heading is not addressable
|
|
45
|
+
const n = seen.get(base) ?? 0;
|
|
46
|
+
seen.set(base, n + 1);
|
|
47
|
+
found.push({ slug: n === 0 ? base : `${base}-${n}`, title, depth, startLine });
|
|
48
|
+
};
|
|
49
|
+
for (let i = start; i < lines.length; i++) {
|
|
50
|
+
const line = lines[i];
|
|
51
|
+
const fenceMatch = FENCE.exec(line);
|
|
52
|
+
if (fenceMatch) {
|
|
53
|
+
const marker = fenceMatch[1][0];
|
|
54
|
+
if (fence === null)
|
|
55
|
+
fence = marker;
|
|
56
|
+
else if (fence === marker)
|
|
57
|
+
fence = null;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (fence !== null)
|
|
61
|
+
continue;
|
|
62
|
+
const atx = ATX.exec(line);
|
|
63
|
+
if (atx) {
|
|
64
|
+
push(atx[2], atx[1].length, i + 1);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// Setext: the UNDERLINE marks the heading, whose text is the line above.
|
|
68
|
+
const prev = i > start ? lines[i - 1] : "";
|
|
69
|
+
if (prev.trim() !== "" && !ATX.test(prev)) {
|
|
70
|
+
if (SETEXT_H1.test(line))
|
|
71
|
+
push(prev.trim(), 1, i);
|
|
72
|
+
else if (SETEXT_H2.test(line) && /[^-\s]/.test(prev))
|
|
73
|
+
push(prev.trim(), 2, i);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return found.map((s, i) => ({
|
|
77
|
+
...s,
|
|
78
|
+
endLine: i + 1 < found.length ? found[i + 1].startLine - 1 : lines.length,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Index of the first line after YAML frontmatter, or 0 when there is none.
|
|
83
|
+
*
|
|
84
|
+
* Only a `---` on line 1 opens frontmatter — a `---` further down is a horizontal
|
|
85
|
+
* rule, and treating it as a delimiter would swallow the document.
|
|
86
|
+
*/
|
|
87
|
+
function frontmatterEnd(lines) {
|
|
88
|
+
if (lines[0]?.trim() !== "---")
|
|
89
|
+
return 0;
|
|
90
|
+
for (let i = 1; i < lines.length; i++) {
|
|
91
|
+
if (lines[i].trim() === "---")
|
|
92
|
+
return i + 1;
|
|
93
|
+
}
|
|
94
|
+
return 0; // unterminated: treat the whole file as content rather than losing it
|
|
95
|
+
}
|
|
96
|
+
/** Section containing a 1-based line, or undefined for content before the first heading. */
|
|
97
|
+
export function sectionAtLine(sections, line) {
|
|
98
|
+
return sections.find((s) => line >= s.startLine && line <= s.endLine);
|
|
99
|
+
}
|
|
100
|
+
/** Parse `SKILL.md#core-principle` / `../../agents/plan.md` into its parts. */
|
|
101
|
+
export function parseCoversRef(raw) {
|
|
102
|
+
const hash = raw.indexOf("#");
|
|
103
|
+
if (hash < 0)
|
|
104
|
+
return { raw, file: raw.trim() };
|
|
105
|
+
return { raw, file: raw.slice(0, hash).trim(), slug: raw.slice(hash + 1).trim() || undefined };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Build the coverage report.
|
|
109
|
+
*
|
|
110
|
+
* Free and offline — it reads Markdown and the spec, nothing else. That is
|
|
111
|
+
* deliberate: a coverage command that spends tokens would be run once.
|
|
112
|
+
*/
|
|
113
|
+
export function computeCoverage(opts) {
|
|
114
|
+
const fileSections = new Map();
|
|
115
|
+
const readSections = (file) => {
|
|
116
|
+
if (fileSections.has(file))
|
|
117
|
+
return fileSections.get(file);
|
|
118
|
+
const abs = isAbsolute(file) ? file : resolve(opts.specDir, file);
|
|
119
|
+
if (!existsSync(abs))
|
|
120
|
+
return null;
|
|
121
|
+
const sections = parseSections(readFileSync(abs, "utf8"));
|
|
122
|
+
fileSections.set(file, sections);
|
|
123
|
+
return sections;
|
|
124
|
+
};
|
|
125
|
+
for (const f of opts.baseFiles ?? [])
|
|
126
|
+
readSections(f);
|
|
127
|
+
const bySection = new Map();
|
|
128
|
+
const key = (file, slug) => `${file}#${slug}`;
|
|
129
|
+
const ensure = (file, section) => {
|
|
130
|
+
const k = key(file, section.slug);
|
|
131
|
+
let entry = bySection.get(k);
|
|
132
|
+
if (!entry) {
|
|
133
|
+
entry = { file, section, scenarios: [], pendingCaptures: [] };
|
|
134
|
+
bySection.set(k, entry);
|
|
135
|
+
}
|
|
136
|
+
return entry;
|
|
137
|
+
};
|
|
138
|
+
// Seed every section of every known file, so "uncovered" is a real list rather
|
|
139
|
+
// than only what someone happened to reference.
|
|
140
|
+
for (const [file, sections] of fileSections)
|
|
141
|
+
for (const s of sections)
|
|
142
|
+
ensure(file, s);
|
|
143
|
+
const broken = [];
|
|
144
|
+
const unmapped = [];
|
|
145
|
+
const attach = (id, refs, into) => {
|
|
146
|
+
for (const raw of refs) {
|
|
147
|
+
const ref = parseCoversRef(raw);
|
|
148
|
+
const sections = readSections(ref.file);
|
|
149
|
+
if (sections === null) {
|
|
150
|
+
broken.push({ scenarioId: id, raw, reason: "file-missing", didYouMean: [] });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
for (const s of sections)
|
|
154
|
+
ensure(ref.file, s);
|
|
155
|
+
if (ref.slug === undefined) {
|
|
156
|
+
// A whole-file reference covers every section in it.
|
|
157
|
+
for (const s of sections)
|
|
158
|
+
ensure(ref.file, s)[into].push(id);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const match = sections.find((s) => s.slug === ref.slug);
|
|
162
|
+
if (!match) {
|
|
163
|
+
broken.push({
|
|
164
|
+
scenarioId: id,
|
|
165
|
+
raw,
|
|
166
|
+
reason: "section-missing",
|
|
167
|
+
didYouMean: nearest(ref.slug, sections.map((s) => s.slug)),
|
|
168
|
+
});
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
ensure(ref.file, match)[into].push(id);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
for (const s of opts.scenarios) {
|
|
175
|
+
if (!s.covers || s.covers.length === 0) {
|
|
176
|
+
unmapped.push(s.id);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
attach(s.id, s.covers, "scenarios");
|
|
180
|
+
}
|
|
181
|
+
for (const c of opts.pendingCaptures ?? [])
|
|
182
|
+
attach(c.id, c.covers, "pendingCaptures");
|
|
183
|
+
const sections = [...bySection.values()].sort((a, b) => a.file.localeCompare(b.file) || a.section.startLine - b.section.startLine);
|
|
184
|
+
const covered = sections.filter((s) => s.scenarios.length > 0);
|
|
185
|
+
const uncovered = sections.filter((s) => s.scenarios.length === 0);
|
|
186
|
+
return {
|
|
187
|
+
sections,
|
|
188
|
+
covered,
|
|
189
|
+
uncovered,
|
|
190
|
+
broken,
|
|
191
|
+
unmapped,
|
|
192
|
+
pct: sections.length === 0 ? 0 : Math.round((covered.length / sections.length) * 100),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Closest existing slugs, so a broken reference names the likely rename.
|
|
197
|
+
*
|
|
198
|
+
* A renamed heading is the common cause of a broken reference, and
|
|
199
|
+
* "section-missing: core-principle" without a suggestion sends the author
|
|
200
|
+
* hunting through a file they just edited.
|
|
201
|
+
*/
|
|
202
|
+
function nearest(target, candidates, limit = 3) {
|
|
203
|
+
return candidates
|
|
204
|
+
.map((c) => ({ c, d: distance(target, c) }))
|
|
205
|
+
.filter(({ c, d }) => d <= Math.max(3, Math.floor(c.length / 2)))
|
|
206
|
+
.sort((a, b) => a.d - b.d)
|
|
207
|
+
.slice(0, limit)
|
|
208
|
+
.map(({ c }) => c);
|
|
209
|
+
}
|
|
210
|
+
function distance(a, b) {
|
|
211
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
212
|
+
for (let i = 1; i <= a.length; i++) {
|
|
213
|
+
let last = prev[0];
|
|
214
|
+
prev[0] = i;
|
|
215
|
+
for (let j = 1; j <= b.length; j++) {
|
|
216
|
+
const tmp = prev[j];
|
|
217
|
+
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, last + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
218
|
+
last = tmp;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return prev[b.length];
|
|
222
|
+
}
|
|
223
|
+
/** Render the report for a terminal. `declared` wording is deliberate throughout. */
|
|
224
|
+
export function formatCoverage(report, skill) {
|
|
225
|
+
const out = [];
|
|
226
|
+
out.push(`${skill}: ${report.covered.length}/${report.sections.length} sections have a declared test (${report.pct}%)`);
|
|
227
|
+
out.push("");
|
|
228
|
+
if (report.uncovered.length) {
|
|
229
|
+
out.push(" no test declares coverage of:");
|
|
230
|
+
for (const s of report.uncovered)
|
|
231
|
+
out.push(` ${s.file}#${s.section.slug} (${s.section.title})`);
|
|
232
|
+
out.push("");
|
|
233
|
+
}
|
|
234
|
+
if (report.broken.length) {
|
|
235
|
+
out.push(" broken references:");
|
|
236
|
+
for (const b of report.broken) {
|
|
237
|
+
const hint = b.didYouMean.length ? ` — did you mean ${b.didYouMean.map((s) => `#${s}`).join(", ")}?` : "";
|
|
238
|
+
out.push(` ${b.scenarioId}: ${b.raw} (${b.reason})${hint}`);
|
|
239
|
+
}
|
|
240
|
+
out.push("");
|
|
241
|
+
}
|
|
242
|
+
if (report.unmapped.length) {
|
|
243
|
+
out.push(` scenarios with no \`covers\`: ${report.unmapped.join(", ")}`);
|
|
244
|
+
out.push("");
|
|
245
|
+
}
|
|
246
|
+
out.push(" `covers` records a declared link, not proof the behaviour is tested.");
|
|
247
|
+
return out.join("\n");
|
|
248
|
+
}
|
|
249
|
+
/** Path of `file` relative to `specDir`, normalized for comparison with `covers`. */
|
|
250
|
+
export function relativeToSpec(specDir, file) {
|
|
251
|
+
return relative(specDir, isAbsolute(file) ? file : resolve(dirname(specDir), file)).split("\\").join("/");
|
|
252
|
+
}
|
|
253
|
+
//# sourceMappingURL=instruction-coverage.js.map
|
package/dist/journal.d.ts
CHANGED
|
@@ -54,6 +54,23 @@ export type JournalEvent = {
|
|
|
54
54
|
ok: boolean;
|
|
55
55
|
detail: string;
|
|
56
56
|
rep?: number;
|
|
57
|
+
}
|
|
58
|
+
/** Trace-gate outcome. Separate from `gate-result`, which is the seeded diff/vitest gates. */
|
|
59
|
+
| {
|
|
60
|
+
event: "objective-result";
|
|
61
|
+
ts: string;
|
|
62
|
+
id: string;
|
|
63
|
+
ok: boolean;
|
|
64
|
+
detail: string;
|
|
65
|
+
rep?: number;
|
|
66
|
+
}
|
|
67
|
+
/** One adjudication pass: which cells were re-judged, what it cost in CALLS, what stayed unresolved. */
|
|
68
|
+
| {
|
|
69
|
+
event: "adjudication";
|
|
70
|
+
ts: string;
|
|
71
|
+
triggered: string[];
|
|
72
|
+
judge_calls: number;
|
|
73
|
+
unresolved: string[];
|
|
57
74
|
} | {
|
|
58
75
|
event: "judge-verdict";
|
|
59
76
|
ts: string;
|
package/dist/lint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "stability" | "lint-error";
|
|
1
|
+
export type LintCode = "spec" | "ship_bar" | "critical" | "fixture" | "fixture-marker" | "consistency" | "stale" | "stability" | "covers" | "lint-error";
|
|
2
2
|
/**
|
|
3
3
|
* How much a finding means.
|
|
4
4
|
*
|
|
@@ -27,3 +27,10 @@ export declare function failsGate(f: LintFinding): boolean;
|
|
|
27
27
|
* report every problem across every skill.
|
|
28
28
|
*/
|
|
29
29
|
export declare function lintSkill(skillDir: string): LintFinding[];
|
|
30
|
+
/**
|
|
31
|
+
* All committed run dirs under a skill's tests/results (<tag>/<timestamp>/results.yaml).
|
|
32
|
+
* Empty if none. Never throws: unreadable/dangling entries (e.g. a broken symlink, or a
|
|
33
|
+
* TOCTOU removal between readdir and statSync) are skipped rather than propagated, so a
|
|
34
|
+
* single bad entry can't abort lintSkill's "never throws" contract.
|
|
35
|
+
*/
|
|
36
|
+
export declare function enumerateRunDirs(resultsRoot: string): string[];
|
package/dist/lint.js
CHANGED
|
@@ -2,8 +2,9 @@ import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import yaml from "js-yaml";
|
|
4
4
|
import { loadSpec, SpecError } from "./spec.js";
|
|
5
|
+
import { computeCoverage } from "./instruction-coverage.js";
|
|
5
6
|
import { readResults, finalizeResults, findTranscriptFiles, resultsPath, scoreContextFor } from "./results.js";
|
|
6
|
-
import { currentHashFor, describeSourceKey, remedyForKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, STIMULUS_PREFIX, UNREADABLE } from "./sources.js";
|
|
7
|
+
import { currentHashFor, describeSourceKey, isSupersededKey, remedyForKey, scenarioIdForKey, effectiveFixture, SCENARIO_PREFIX, STIMULUS_PREFIX, UNREADABLE } from "./sources.js";
|
|
7
8
|
import { downgradeWarning } from "./downgrade.js";
|
|
8
9
|
import { collectScoredRuns } from "./trends.js";
|
|
9
10
|
import { boundaryCells, stabilityFrom, stabilityNote } from "./stability.js";
|
|
@@ -140,6 +141,28 @@ export function lintSkill(skillDir) {
|
|
|
140
141
|
// system_prompt_file must exist — an agent-file scenario silently falling back to
|
|
141
142
|
// skill activation would measure the wrong artifact entirely.
|
|
142
143
|
for (const s of spec.scenarios) {
|
|
144
|
+
// A `covers` reference that names a section which does not exist is a WRONG
|
|
145
|
+
// STATEMENT in the spec, not a coverage gap — so it fails the gate, while an
|
|
146
|
+
// uncovered section is only reported by `coverage --strict`. Renaming a heading
|
|
147
|
+
// is the usual cause, so the finding names the near-misses.
|
|
148
|
+
if (s.covers?.length) {
|
|
149
|
+
const report = computeCoverage({ specDir, scenarios: [s] });
|
|
150
|
+
for (const b of report.broken) {
|
|
151
|
+
const hint = b.didYouMean.length ? ` — did you mean ${b.didYouMean.map((x) => `#${x}`).join(", ")}?` : "";
|
|
152
|
+
findings.push({
|
|
153
|
+
skill, scenario: s.id, code: "covers",
|
|
154
|
+
message: `covers reference \`${b.raw}\` is broken (${b.reason})${hint}`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Extension paths, checked statically so a typo is a free CI failure rather
|
|
159
|
+
// than a wave that ran with no subagent tool and graded the absence.
|
|
160
|
+
for (const ext of s.extensions ?? []) {
|
|
161
|
+
const extAbs = isAbsolute(ext) ? ext : resolve(specDir, ext);
|
|
162
|
+
if (!existsSync(extAbs)) {
|
|
163
|
+
findings.push({ skill, scenario: s.id, code: "fixture", message: `env.extensions not found: ${ext}` });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
143
166
|
if (!s.systemPromptFile)
|
|
144
167
|
continue;
|
|
145
168
|
const abs = isAbsolute(s.systemPromptFile) ? s.systemPromptFile : resolve(specDir, s.systemPromptFile);
|
|
@@ -224,6 +247,12 @@ export function lintSkill(skillDir) {
|
|
|
224
247
|
const newest = full.runDir;
|
|
225
248
|
const ctx = { skillDir, specDir, scenarios: spec.scenarios, judgePersona: spec.judge_persona };
|
|
226
249
|
for (const [key, recorded] of Object.entries(hashes)) {
|
|
250
|
+
// A raw-bytes key whose model-visible digest this run also recorded is not the
|
|
251
|
+
// honest comparison: it reports a `description`-reword or an `allowed-tools:`
|
|
252
|
+
// line — text no graded run could observe — as a reason to re-run. The pair is
|
|
253
|
+
// recorded together and exactly one of them is compared.
|
|
254
|
+
if (isSupersededKey(key, hashes))
|
|
255
|
+
continue;
|
|
227
256
|
const what = describeSourceKey(key);
|
|
228
257
|
const scenario = scenarioIdForKey(key, spec.scenarios);
|
|
229
258
|
// The run itself failed to hash this source, so it was never verified and
|
|
@@ -326,7 +355,7 @@ function runDirsNewestFirst(tagDir) {
|
|
|
326
355
|
* TOCTOU removal between readdir and statSync) are skipped rather than propagated, so a
|
|
327
356
|
* single bad entry can't abort lintSkill's "never throws" contract.
|
|
328
357
|
*/
|
|
329
|
-
function enumerateRunDirs(resultsRoot) {
|
|
358
|
+
export function enumerateRunDirs(resultsRoot) {
|
|
330
359
|
if (!existsSync(resultsRoot))
|
|
331
360
|
return [];
|
|
332
361
|
const out = [];
|
package/dist/regate.js
CHANGED
|
@@ -2,8 +2,10 @@ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parseVerdict, detectMisfire } from "./grade.js";
|
|
4
4
|
import { evaluateNeedleGates, hasNeedleGates } from "./seeded.js";
|
|
5
|
+
import { evaluateTraceGates } from "./trace-gates.js";
|
|
6
|
+
import { mergeTraces, deserializeTrace } from "./execution-trace.js";
|
|
5
7
|
import { judgeOneRep } from "./regrade.js";
|
|
6
|
-
import { readResults, writeResults, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, effectiveThreshold, scoreContextFor, } from "./results.js";
|
|
8
|
+
import { readResults, writeResults, transcriptPath, judgeRawPath, repIndexOf, findDiffFiles, findTraceFiles, tracePath, effectiveThreshold, scoreContextFor, rebuildScenarioResult, } from "./results.js";
|
|
7
9
|
import { outcomesToResult } from "./reps.js";
|
|
8
10
|
import { appendJournal } from "./journal.js";
|
|
9
11
|
import { gatesDigest, GATES_PREFIX } from "./sources.js";
|
|
@@ -84,22 +86,32 @@ export async function regateRun(opts) {
|
|
|
84
86
|
const targets = [];
|
|
85
87
|
for (const rec of prev.scenarios) {
|
|
86
88
|
const s = specById.get(rec.id);
|
|
87
|
-
|
|
89
|
+
const needles = hasNeedleGates(s ?? {});
|
|
90
|
+
const traceGated = Boolean(s?.traceAssert);
|
|
91
|
+
if (!s || (!needles && !traceGated))
|
|
88
92
|
continue; // nothing for regate to re-decide
|
|
89
93
|
if (s.assert?.vitest || s.assert?.post_test) {
|
|
90
94
|
blocked.push(`${s.id}: declares ${s.assert.vitest ? "assert.vitest" : "assert.post_test"}, which needs the workspace — ` +
|
|
91
95
|
`no saved artifact can stand in for it, so this scenario needs a re-run`);
|
|
92
96
|
continue;
|
|
93
97
|
}
|
|
94
|
-
if (findDiffFiles(opts.runDir, s.id, mode).length === 0) {
|
|
98
|
+
if (needles && findDiffFiles(opts.runDir, s.id, mode).length === 0) {
|
|
95
99
|
blocked.push(`${s.id}: no staged-diff artifact on disk (\`.diff.txt\` is gitignored — regate needs the run dir that produced it)`);
|
|
96
100
|
continue;
|
|
97
101
|
}
|
|
102
|
+
// A trace gate is only re-decidable from a saved trace. A run recorded before
|
|
103
|
+
// traces existed has none, and saying so is the whole point — pretending
|
|
104
|
+
// regate can answer would report a verdict derived from no evidence.
|
|
105
|
+
if (traceGated && findTraceFiles(opts.runDir, s.id, mode).length === 0) {
|
|
106
|
+
blocked.push(`${s.id}: declares assert.trace but this run saved no \`.trace.jsonl\` artifact ` +
|
|
107
|
+
`(it predates trace capture, or the trace was not persisted) — it needs a re-run`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
98
110
|
targets.push(s);
|
|
99
111
|
}
|
|
100
112
|
if (targets.length === 0) {
|
|
101
113
|
throw new Error(`nothing to regate in ${opts.runDir}` +
|
|
102
|
-
(blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff_contains/diff_excludes"));
|
|
114
|
+
(blocked.length > 0 ? `:\n ${blocked.join("\n ")}` : " — no scenario declares diff_contains/diff_excludes or assert.trace"));
|
|
103
115
|
}
|
|
104
116
|
const changes = [];
|
|
105
117
|
let judgeCalls = 0;
|
|
@@ -111,18 +123,66 @@ export async function regateRun(opts) {
|
|
|
111
123
|
continue;
|
|
112
124
|
}
|
|
113
125
|
const diffFiles = findDiffFiles(opts.runDir, scenario.id, mode);
|
|
126
|
+
const traceFiles = findTraceFiles(opts.runDir, scenario.id, mode);
|
|
127
|
+
// Reps come from whichever artifact this scenario actually has. A trace-only
|
|
128
|
+
// scenario has no `.diff.txt` at all, so iterating diffs would silently
|
|
129
|
+
// regate nothing and report success.
|
|
130
|
+
const repKeys = diffFiles.length > 0
|
|
131
|
+
? diffFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: f }))
|
|
132
|
+
: traceFiles.map((f) => ({ rep: repIndexOf(f) ?? undefined, diffFile: undefined }));
|
|
114
133
|
const outcomes = [];
|
|
115
134
|
// Per scenario, not run-wide: with several regated scenarios, a global counter
|
|
116
135
|
// would report every change as "re-judged" because some other scenario was.
|
|
117
136
|
let judgedHere = 0;
|
|
118
137
|
let gateFailedHere = false;
|
|
119
|
-
for (const
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
138
|
+
for (const { rep, diffFile } of repKeys) {
|
|
139
|
+
const diff = diffFile ? readFileSync(join(opts.runDir, diffFile), "utf8") : "";
|
|
140
|
+
const needleGate = diffFile ? evaluateNeedleGates(scenario, diff) : { lines: [], failure: null };
|
|
141
|
+
// Trace gate, re-decided from the saved trace. Free: no model, no judge.
|
|
142
|
+
let traceFailure = null;
|
|
143
|
+
let objective;
|
|
144
|
+
if (scenario.traceAssert) {
|
|
145
|
+
const tp = tracePath(opts.runDir, scenario.id, mode, rep);
|
|
146
|
+
// A PARTIAL read is refused, not graded. `deserializeTrace` returns null
|
|
147
|
+
// for a malformed line and for a version it declines, and dropping those
|
|
148
|
+
// silently graded whatever survived: a 3-turn trace with a torn middle
|
|
149
|
+
// line reported `forbid_calls: [bash] → PASS` when the lost turn was the
|
|
150
|
+
// one that called bash. The write side already refuses an incomplete
|
|
151
|
+
// stream (`pi.ts` throws on no terminal event) precisely so "called
|
|
152
|
+
// nothing" and "recorded nothing" cannot look the same; the read side
|
|
153
|
+
// has to hold the same line.
|
|
154
|
+
const lines = existsSync(tp)
|
|
155
|
+
? readFileSync(tp, "utf8").split("\n").filter((l) => l.trim())
|
|
156
|
+
: [];
|
|
157
|
+
const parsed = lines.map((l) => deserializeTrace(l));
|
|
158
|
+
const usable = parsed.filter((t) => t !== null);
|
|
159
|
+
const merged = usable.length === lines.length ? mergeTraces(usable) : null;
|
|
160
|
+
if (merged === null) {
|
|
161
|
+
traceFailure =
|
|
162
|
+
usable.length === lines.length
|
|
163
|
+
? "objective: saved trace is missing or unreadable — cannot re-evaluate assert.trace"
|
|
164
|
+
: `objective: saved trace is incomplete (${usable.length}/${lines.length} turns readable) — cannot re-evaluate assert.trace`;
|
|
165
|
+
objective = { status: "ERROR", assertions: [] };
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const g = evaluateTraceGates(scenario.traceAssert, merged);
|
|
169
|
+
objective = { status: g.status, trace_version: merged.trace_version, trace_sha256: merged.trace_sha256, assertions: g.assertions };
|
|
170
|
+
if (g.status === "FAIL" || g.status === "ERROR") {
|
|
171
|
+
const bad = g.assertions.filter((x) => x.status === g.status).map((x) => x.detail);
|
|
172
|
+
traceFailure = `objective: ${bad.join("; ")}`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const gate = { lines: needleGate.lines, failure: needleGate.failure ?? traceFailure };
|
|
123
177
|
const tPath = transcriptPath(opts.runDir, scenario.id, mode, rep);
|
|
124
178
|
const before = existsSync(tPath) ? readFileSync(tPath, "utf8") : "";
|
|
125
|
-
|
|
179
|
+
// Two sources, because the two gate kinds record their prior state
|
|
180
|
+
// differently: a seeded needle gate leaves a trailer in the transcript, a
|
|
181
|
+
// trace gate leaves an `objective` block on the result. Reading only the
|
|
182
|
+
// trailer meant a trace gate flipping to PASS never triggered the re-judge
|
|
183
|
+
// it needs, leaving a stale FAIL verdict beside a PASS objective.
|
|
184
|
+
const oldObjectiveFailed = rec.objective?.status === "FAIL" || rec.objective?.status === "ERROR";
|
|
185
|
+
const oldGateFailed = GATE_FAILED_RE.test(before.slice(before.indexOf(TRAILER))) || oldObjectiveFailed;
|
|
126
186
|
// The trailer is regenerated whatever the outcome: leaving a stale
|
|
127
187
|
// `MISSING` note beside a corrected verdict would misinform the next reader
|
|
128
188
|
// (and the next judge, which reads this transcript).
|
|
@@ -130,23 +190,23 @@ export async function regateRun(opts) {
|
|
|
130
190
|
rewriteTranscript(tPath, gate.lines);
|
|
131
191
|
if (gate.failure) {
|
|
132
192
|
gateFailedHere = true;
|
|
133
|
-
outcomes.push({ verdict: "FAIL", reason: gate.failure, suspect: false });
|
|
193
|
+
outcomes.push({ verdict: "FAIL", reason: gate.failure, suspect: false, objective });
|
|
134
194
|
continue;
|
|
135
195
|
}
|
|
136
196
|
if (!oldGateFailed) {
|
|
137
197
|
// The judge already saw this rep. Its verdict is on disk — re-read it rather
|
|
138
198
|
// than paying to ask the same question again.
|
|
139
199
|
const saved = verdictFromSavedJudgement(opts.runDir, scenario.id, mode, rep);
|
|
140
|
-
outcomes.push(saved ?? { verdict: rec.judge_verdict, reason: rec.judge_reason, suspect: rec.suspect });
|
|
200
|
+
outcomes.push({ ...(saved ?? { verdict: rec.judge_verdict, reason: rec.judge_reason, suspect: rec.suspect }), objective });
|
|
141
201
|
continue;
|
|
142
202
|
}
|
|
143
203
|
// The gate blocked this rep before, so no judgement of it exists anywhere.
|
|
144
204
|
const transcript = readFileSync(tPath, "utf8");
|
|
145
|
-
outcomes.push(await judgeOneRep({
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
205
|
+
outcomes.push({ ...(await judgeOneRep({
|
|
206
|
+
runDir: opts.runDir, spec: opts.spec, scenario, transcript,
|
|
207
|
+
adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
|
|
208
|
+
mode, rep, now,
|
|
209
|
+
})), objective });
|
|
150
210
|
judgeCalls++;
|
|
151
211
|
judgedHere++;
|
|
152
212
|
}
|
|
@@ -154,7 +214,10 @@ export async function regateRun(opts) {
|
|
|
154
214
|
const next = outcomesToResult(scenario.id, outcomes, outcomes.length, threshold);
|
|
155
215
|
// Overrides and their notes survive: a regate re-decides the gate, and an author
|
|
156
216
|
// override is a statement about the judge, not about the needle.
|
|
157
|
-
|
|
217
|
+
// `regate` re-evaluates gates from saved artifacts and asks no judge anything:
|
|
218
|
+
// `objective` is freshly recomputed above, and the recorded judge panel still
|
|
219
|
+
// describes the current judgments.
|
|
220
|
+
scenarios.push(rebuildScenarioResult(next, rec, { objective: "fresh", adjudication: "carry" }));
|
|
158
221
|
const to = next.judge_verdict;
|
|
159
222
|
if (to !== rec.judge_verdict) {
|
|
160
223
|
changes.push({
|
package/dist/regrade.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { buildJudgePrompt, judgeInWorkspace } from "./grade.js";
|
|
4
|
-
import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, } from "./results.js";
|
|
4
|
+
import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, rebuildScenarioResult, } from "./results.js";
|
|
5
5
|
import { outcomesToResult } from "./reps.js";
|
|
6
6
|
import { appendJournal } from "./journal.js";
|
|
7
7
|
import { rubricDigest, personaDigest, RUBRIC_PREFIX, PERSONA_KEY } from "./sources.js";
|
|
@@ -85,7 +85,18 @@ export async function regradeRun(opts) {
|
|
|
85
85
|
const { runDir, spec, adapter, judge, specDir } = opts;
|
|
86
86
|
const now = opts.now ?? (() => new Date().toISOString());
|
|
87
87
|
const prev = existsSync(join(runDir, "results.yaml")) ? readResults(runDir) : null;
|
|
88
|
-
|
|
88
|
+
// Carried across a re-judge, per field, because the right answer differs:
|
|
89
|
+
// - `override`/`note` — the author's, never the judge's to discard.
|
|
90
|
+
// - `objective` — a re-judge does NOT re-evaluate trace gates (that is
|
|
91
|
+
// `regate`), so the recorded evidence still describes this run. Dropping it
|
|
92
|
+
// silently downgraded a gated scenario to "no assertions declared".
|
|
93
|
+
// `adjudication` is deliberately NOT carried: it describes the judgments this
|
|
94
|
+
// re-grade just replaced, and a stale panel beside a fresh verdict is worse
|
|
95
|
+
// than none. `grade --auto-rejudge` recomputes it.
|
|
96
|
+
// The whole prior result per id — `rebuildScenarioResult` decides, field by
|
|
97
|
+
// field, what survives. Passing a hand-picked subset here is how fields got
|
|
98
|
+
// dropped before.
|
|
99
|
+
const overrides = new Map((prev?.scenarios ?? []).map((s) => [s.id, s]));
|
|
89
100
|
const mode = prev?.mode ?? "green";
|
|
90
101
|
// Re-grading rewrites the WHOLE results.yaml, so re-judge exactly the
|
|
91
102
|
// scenarios the run recorded (falling back to the spec for a run with no
|
|
@@ -131,7 +142,10 @@ export async function regradeRun(opts) {
|
|
|
131
142
|
runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
|
|
132
143
|
});
|
|
133
144
|
const carry = overrides.get(id);
|
|
134
|
-
|
|
145
|
+
// `grade` re-judges the saved transcript. It does not re-evaluate trace gates
|
|
146
|
+
// (that is `regate`), so `objective` still describes this run; and it replaced
|
|
147
|
+
// the judgments a prior adjudication described, so that panel must go.
|
|
148
|
+
scenarioResults.push(rebuildScenarioResult(rr, carry, { objective: "carry", adjudication: "drop" }));
|
|
135
149
|
}
|
|
136
150
|
const ctx = scoreContextFor({ mode, partial: prev?.partial }, spec);
|
|
137
151
|
const results = writeResults(runDir, {
|
package/dist/report.d.ts
CHANGED
|
@@ -14,6 +14,18 @@ export interface RunColumn {
|
|
|
14
14
|
judge_verdict: string;
|
|
15
15
|
judge_reason: string;
|
|
16
16
|
suspect: boolean;
|
|
17
|
+
/** Objective trace-gate outcome, when the scenario declared `assert.trace`. */
|
|
18
|
+
objective?: {
|
|
19
|
+
status: string;
|
|
20
|
+
detail: string;
|
|
21
|
+
};
|
|
22
|
+
/** Adjudication outcome, when the cell was re-judged. */
|
|
23
|
+
adjudication?: {
|
|
24
|
+
state: string;
|
|
25
|
+
trigger: string;
|
|
26
|
+
count: number;
|
|
27
|
+
detail: string;
|
|
28
|
+
};
|
|
17
29
|
reps?: number;
|
|
18
30
|
passes?: number;
|
|
19
31
|
clean?: number;
|
|
@@ -89,6 +101,18 @@ export declare function publicView(data: ReportData): {
|
|
|
89
101
|
judge_verdict: string;
|
|
90
102
|
judge_reason: string;
|
|
91
103
|
suspect: boolean;
|
|
104
|
+
/** Objective trace-gate outcome, when the scenario declared `assert.trace`. */
|
|
105
|
+
objective?: {
|
|
106
|
+
status: string;
|
|
107
|
+
detail: string;
|
|
108
|
+
};
|
|
109
|
+
/** Adjudication outcome, when the cell was re-judged. */
|
|
110
|
+
adjudication?: {
|
|
111
|
+
state: string;
|
|
112
|
+
trigger: string;
|
|
113
|
+
count: number;
|
|
114
|
+
detail: string;
|
|
115
|
+
};
|
|
92
116
|
reps?: number;
|
|
93
117
|
passes?: number;
|
|
94
118
|
clean?: number;
|
package/dist/report.js
CHANGED
|
@@ -52,6 +52,30 @@ export function collectReport(skillDir) {
|
|
|
52
52
|
},
|
|
53
53
|
}
|
|
54
54
|
: {}),
|
|
55
|
+
// Same optional-spread shape as `stability`: absent means "not declared"
|
|
56
|
+
// / "single judge", and the UI must not render either as a clean result.
|
|
57
|
+
...(s.objective
|
|
58
|
+
? {
|
|
59
|
+
objective: {
|
|
60
|
+
status: s.objective.status,
|
|
61
|
+
detail: s.objective.assertions.length
|
|
62
|
+
? s.objective.assertions.map((a) => `${a.status} ${a.detail}`).join(" · ")
|
|
63
|
+
: "no assertion evidence recorded",
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
: {}),
|
|
67
|
+
...(s.adjudication
|
|
68
|
+
? {
|
|
69
|
+
adjudication: {
|
|
70
|
+
state: s.adjudication.state,
|
|
71
|
+
trigger: s.adjudication.trigger,
|
|
72
|
+
count: s.adjudication.judgments.length,
|
|
73
|
+
detail: s.adjudication.judgments
|
|
74
|
+
.map((j) => `#${j.ordinal} ${j.judge.provider}:${j.judge.model} ${j.verdict}${j.suspect ? " (misfired, not counted)" : ""}`)
|
|
75
|
+
.join(" · "),
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
: {}),
|
|
55
79
|
judge_verdict: s.judge_verdict,
|
|
56
80
|
judge_reason: s.judge_reason,
|
|
57
81
|
suspect: s.suspect ?? false, // suspect defaults false for older results that predate the field
|
package/dist/reps.d.ts
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import type { Verdict } from "./score.js";
|
|
2
|
-
import type { ScenarioResult } from "./results.js";
|
|
2
|
+
import type { ScenarioResult, ObjectiveResult } from "./results.js";
|
|
3
3
|
/** One rep's outcome (subject run + judge). */
|
|
4
4
|
export interface RepOutcome {
|
|
5
5
|
verdict: Verdict;
|
|
6
6
|
reason: string;
|
|
7
7
|
suspect: boolean;
|
|
8
|
+
/** Present only when the scenario declared `assert.trace`. */
|
|
9
|
+
objective?: ObjectiveResult;
|
|
8
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Collapse per-rep objective results.
|
|
13
|
+
*
|
|
14
|
+
* Strict on purpose, and deliberately NOT the same policy as the judge's
|
|
15
|
+
* pass-threshold aggregation: an objective assertion is a statement about what
|
|
16
|
+
* the model DID, so one rep that called a forbidden tool is a real finding, not
|
|
17
|
+
* a minority draw to be voted away. ERROR dominates (missing evidence is never a
|
|
18
|
+
* pass), then FAIL, then PASS. The retained assertion detail comes from the
|
|
19
|
+
* first non-passing rep, since that is the one worth reading.
|
|
20
|
+
*/
|
|
21
|
+
export declare function aggregateObjective(outcomes: RepOutcome[]): ObjectiveResult | undefined;
|
|
9
22
|
/** A scenario's aggregated result over N reps. */
|
|
10
23
|
export interface RepAggregate {
|
|
11
24
|
verdict: Verdict;
|