@skill-harness/core 0.22.4 → 0.23.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/grade.d.ts +14 -4
- package/dist/grade.js +122 -23
- package/dist/regate.js +13 -3
- package/dist/regrade.js +3 -3
- package/dist/results.d.ts +3 -1
- package/package.json +1 -1
- package/schemas/results-v2.schema.json +1 -0
- package/schemas/results-v3.schema.json +1 -0
package/dist/grade.d.ts
CHANGED
|
@@ -36,13 +36,23 @@ export declare function parseVerdict(out: string): ParsedVerdict;
|
|
|
36
36
|
*/
|
|
37
37
|
export declare function judgeResemblesSubject(judge: ModelRef, subject: ModelRef): boolean;
|
|
38
38
|
export interface GradeResult extends ParsedVerdict {
|
|
39
|
+
/** The final judge reply, retained for callers that predate structured judging. */
|
|
39
40
|
raw: string;
|
|
41
|
+
/** Every attempt in call order; two entries mean the structured retry also ran. */
|
|
42
|
+
rawReplies: string[];
|
|
40
43
|
criteria: CriterionVote[];
|
|
41
|
-
|
|
44
|
+
judgeFormat: "json";
|
|
45
|
+
/** One validation-guided retry after an invalid structured reply. Absent on the first attempt. */
|
|
42
46
|
judgeRetries?: 1;
|
|
43
47
|
/** Judge misfire: the overall verdict disagrees with AND(per-item grades). Recorded, never auto-passed; blocks SHIP until re-judged or overridden. */
|
|
44
48
|
suspect: boolean;
|
|
45
49
|
}
|
|
50
|
+
/** Parse and validate the JSON-only contract used for new judge calls. */
|
|
51
|
+
export declare function parseStructuredJudgeReply(raw: string, expectedCriteria: number): ParsedVerdict & {
|
|
52
|
+
criteria: CriterionVote[];
|
|
53
|
+
};
|
|
54
|
+
/** Preserve one raw reply byte-for-byte, or both retry attempts with explicit boundaries. */
|
|
55
|
+
export declare function formatJudgeRawReplies(replies: string[]): string;
|
|
46
56
|
/**
|
|
47
57
|
* Judge-misfire detector: parse the judge's per-checklist-item grades and assert
|
|
48
58
|
* the overall verdict equals AND(items). A mismatch in EITHER direction — verdict
|
|
@@ -51,11 +61,11 @@ export interface GradeResult extends ParsedVerdict {
|
|
|
51
61
|
* parse, or the verdict is ERROR, return false (never block a run on a parse miss).
|
|
52
62
|
*/
|
|
53
63
|
export declare function detectMisfire(raw: string, verdict: Verdict): boolean;
|
|
54
|
-
/** Drive the judge for one transcript and parse the result. */
|
|
55
|
-
export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef, prompt: string, cwd: string, expectedCriteria
|
|
64
|
+
/** Drive the judge for one transcript and parse the JSON-only result. */
|
|
65
|
+
export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef, prompt: string, cwd: string, expectedCriteria: number): Promise<GradeResult>;
|
|
56
66
|
/**
|
|
57
67
|
* Grade a transcript in a fresh, isolated, throwaway workspace — never the
|
|
58
68
|
* subject's scenario dir — so the judge can't ingest repo context the subject
|
|
59
69
|
* left behind (matters for provider CLIs that read cwd, including Pi).
|
|
60
70
|
*/
|
|
61
|
-
export declare function judgeInWorkspace(adapter: HarnessAdapter, judge: ModelRef, prompt: string, specDir: string, expectedCriteria
|
|
71
|
+
export declare function judgeInWorkspace(adapter: HarnessAdapter, judge: ModelRef, prompt: string, specDir: string, expectedCriteria: number): Promise<GradeResult>;
|
package/dist/grade.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createWorkspace } from "./workspace.js";
|
|
2
|
-
import {
|
|
2
|
+
import { completeCriterionVotes } from "./results.js";
|
|
3
3
|
/** The heading runSeeded writes above the staged diff. Gating on this exact string is what keeps the guidance honest — see below. */
|
|
4
4
|
export const STAGED_DIFF_HEADING = "=== STAGED DIFF ===";
|
|
5
5
|
/**
|
|
@@ -37,9 +37,8 @@ ${numbered}
|
|
|
37
37
|
TRANSCRIPT (the assistant is the model under test):
|
|
38
38
|
${transcript}
|
|
39
39
|
${diffGuidance}
|
|
40
|
-
Grade each checklist item PASS or FAIL with a <=12-word justification quoting the transcript. Be skeptical: if an item is not clearly satisfied, mark it FAIL.
|
|
41
|
-
|
|
42
|
-
REASON: <15 words or fewer>`;
|
|
40
|
+
Grade each checklist item PASS or FAIL with a <=12-word justification quoting the transcript. Be skeptical: if an item is not clearly satisfied, mark it FAIL. Reply with exactly one JSON object and nothing else, with one vote per criterion in checklist order:
|
|
41
|
+
{"votes":[{"criterion":1,"vote":"PASS"|"FAIL","reason":"..."},...],"verdict":"PASS"|"FAIL","reason":"..."}`;
|
|
43
42
|
}
|
|
44
43
|
// Both anchor to the start of a line and REQUIRE the colon. Without those anchors the
|
|
45
44
|
// reason pattern matched any word containing "reason" — git-ops GLM C1 stored
|
|
@@ -87,6 +86,85 @@ export function judgeResemblesSubject(judge, subject) {
|
|
|
87
86
|
const b = subject.model;
|
|
88
87
|
return a === b || a.includes(b) || b.includes(a);
|
|
89
88
|
}
|
|
89
|
+
/** Extract the first complete top-level JSON object, tolerating prose or fences around it. */
|
|
90
|
+
function extractFirstJsonObject(raw) {
|
|
91
|
+
const start = raw.indexOf("{");
|
|
92
|
+
if (start < 0)
|
|
93
|
+
throw new Error("no JSON object in judge reply");
|
|
94
|
+
let depth = 0;
|
|
95
|
+
let inString = false;
|
|
96
|
+
let escaped = false;
|
|
97
|
+
for (let i = start; i < raw.length; i++) {
|
|
98
|
+
const ch = raw[i];
|
|
99
|
+
if (inString) {
|
|
100
|
+
if (escaped)
|
|
101
|
+
escaped = false;
|
|
102
|
+
else if (ch === "\\")
|
|
103
|
+
escaped = true;
|
|
104
|
+
else if (ch === '"')
|
|
105
|
+
inString = false;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (ch === '"')
|
|
109
|
+
inString = true;
|
|
110
|
+
else if (ch === "{")
|
|
111
|
+
depth++;
|
|
112
|
+
else if (ch === "}" && --depth === 0)
|
|
113
|
+
return raw.slice(start, i + 1);
|
|
114
|
+
}
|
|
115
|
+
throw new Error("incomplete JSON object in judge reply");
|
|
116
|
+
}
|
|
117
|
+
function exactKeys(value, expected, context) {
|
|
118
|
+
const actual = Object.keys(value).sort();
|
|
119
|
+
const wanted = [...expected].sort();
|
|
120
|
+
if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
|
|
121
|
+
throw new Error(`${context} must contain exactly ${wanted.join(", ")}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Parse and validate the JSON-only contract used for new judge calls. */
|
|
125
|
+
export function parseStructuredJudgeReply(raw, expectedCriteria) {
|
|
126
|
+
let value;
|
|
127
|
+
try {
|
|
128
|
+
value = JSON.parse(extractFirstJsonObject(raw));
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (error instanceof Error && /JSON object in judge reply/.test(error.message))
|
|
132
|
+
throw error;
|
|
133
|
+
throw new Error(`invalid JSON: ${error.message}`);
|
|
134
|
+
}
|
|
135
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
136
|
+
throw new Error("judge reply must be a JSON object");
|
|
137
|
+
const object = value;
|
|
138
|
+
exactKeys(object, ["votes", "verdict", "reason"], "judge reply");
|
|
139
|
+
if (!Array.isArray(object.votes))
|
|
140
|
+
throw new Error("votes must be an array");
|
|
141
|
+
if (object.votes.length !== expectedCriteria)
|
|
142
|
+
throw new Error(`expected ${expectedCriteria} votes, got ${object.votes.length}`);
|
|
143
|
+
const criteria = object.votes.map((rawVote, offset) => {
|
|
144
|
+
if (!rawVote || typeof rawVote !== "object" || Array.isArray(rawVote))
|
|
145
|
+
throw new Error(`vote ${offset + 1} must be an object`);
|
|
146
|
+
const vote = rawVote;
|
|
147
|
+
exactKeys(vote, ["criterion", "vote", "reason"], `vote ${offset + 1}`);
|
|
148
|
+
if (vote.criterion !== offset + 1)
|
|
149
|
+
throw new Error(`vote ${offset + 1} must have criterion ${offset + 1}`);
|
|
150
|
+
if (vote.vote !== "PASS" && vote.vote !== "FAIL")
|
|
151
|
+
throw new Error(`vote ${offset + 1} must be PASS or FAIL`);
|
|
152
|
+
if (typeof vote.reason !== "string")
|
|
153
|
+
throw new Error(`vote ${offset + 1} reason must be a string`);
|
|
154
|
+
return { index: offset + 1, verdict: vote.vote, reason: vote.reason };
|
|
155
|
+
});
|
|
156
|
+
if (object.verdict !== "PASS" && object.verdict !== "FAIL")
|
|
157
|
+
throw new Error("verdict must be PASS or FAIL");
|
|
158
|
+
if (typeof object.reason !== "string")
|
|
159
|
+
throw new Error("reason must be a string");
|
|
160
|
+
return { criteria, verdict: object.verdict, reason: object.reason };
|
|
161
|
+
}
|
|
162
|
+
/** Preserve one raw reply byte-for-byte, or both retry attempts with explicit boundaries. */
|
|
163
|
+
export function formatJudgeRawReplies(replies) {
|
|
164
|
+
if (replies.length === 1)
|
|
165
|
+
return replies[0];
|
|
166
|
+
return replies.map((reply, index) => `=== JUDGE REPLY ${index + 1} ===\n${reply}`).join("\n\n");
|
|
167
|
+
}
|
|
90
168
|
const ITEM_RE = /^\s*\d+[.)]\s*\**\s*(PASS|FAIL)\b/gim;
|
|
91
169
|
/**
|
|
92
170
|
* Judge-misfire detector: parse the judge's per-checklist-item grades and assert
|
|
@@ -122,27 +200,48 @@ export function detectMisfire(raw, verdict) {
|
|
|
122
200
|
}
|
|
123
201
|
// Non-global twin of REASON_RE: matchAll needs /g, a single .match() must not have it.
|
|
124
202
|
const REASON_LINE_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/im;
|
|
125
|
-
/** Drive the judge for one transcript and parse the result. */
|
|
203
|
+
/** Drive the judge for one transcript and parse the JSON-only result. */
|
|
126
204
|
export async function gradeTranscript(adapter, judge, prompt, cwd, expectedCriteria) {
|
|
127
|
-
const
|
|
128
|
-
let
|
|
129
|
-
let
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
205
|
+
const rawReplies = [];
|
|
206
|
+
let validationError = "";
|
|
207
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
208
|
+
const retry = attempt === 0 ? "" : `\n\nYour reply was not valid: ${JSON.stringify(validationError)}; reply with only the JSON object.`;
|
|
209
|
+
const raw = await adapter.judge({ model: judge, prompt: prompt + retry, cwd });
|
|
210
|
+
rawReplies.push(raw);
|
|
211
|
+
// Pi adapter failures are infrastructure errors, not malformed model votes.
|
|
212
|
+
if (/^\[judge error:/i.test(raw.trim())) {
|
|
213
|
+
const snippet = raw.trim().replace(/\s+/g, " ").slice(0, 160);
|
|
214
|
+
return {
|
|
215
|
+
verdict: "ERROR", reason: `judge unparseable: ${snippet}`, suspect: false,
|
|
216
|
+
raw, rawReplies, criteria: completeCriterionVotes([], expectedCriteria), judgeFormat: "json",
|
|
217
|
+
...(attempt === 1 ? { judgeRetries: 1 } : {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const parsed = parseStructuredJudgeReply(raw, expectedCriteria);
|
|
222
|
+
const suspect = parsed.verdict === "FAIL"
|
|
223
|
+
? parsed.criteria.every(vote => vote.verdict === "PASS")
|
|
224
|
+
: parsed.criteria.some(vote => vote.verdict === "FAIL");
|
|
225
|
+
return {
|
|
226
|
+
...parsed, raw, rawReplies, suspect, judgeFormat: "json",
|
|
227
|
+
...(attempt === 1 ? { judgeRetries: 1 } : {}),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
validationError = error.message;
|
|
232
|
+
}
|
|
143
233
|
}
|
|
144
|
-
const
|
|
145
|
-
return {
|
|
234
|
+
const raw = rawReplies[rawReplies.length - 1];
|
|
235
|
+
return {
|
|
236
|
+
verdict: "UNGRADED",
|
|
237
|
+
reason: `judge structured reply invalid after retry: ${validationError}`,
|
|
238
|
+
suspect: false,
|
|
239
|
+
raw,
|
|
240
|
+
rawReplies,
|
|
241
|
+
criteria: completeCriterionVotes([], expectedCriteria),
|
|
242
|
+
judgeFormat: "json",
|
|
243
|
+
judgeRetries: 1,
|
|
244
|
+
};
|
|
146
245
|
}
|
|
147
246
|
/**
|
|
148
247
|
* Grade a transcript in a fresh, isolated, throwaway workspace — never the
|
package/dist/regate.js
CHANGED
|
@@ -49,11 +49,19 @@ function rewriteTranscript(path, gateLines) {
|
|
|
49
49
|
renameSync(path, path.replace(/\.txt$/, ".pre-regate.txt"));
|
|
50
50
|
writeFileSync(path, `${head}${TRAILER}\n${gateLines.join("\n")}\n\n${tail}`, "utf8");
|
|
51
51
|
}
|
|
52
|
-
/** Recover a rep's judge verdict from
|
|
53
|
-
function verdictFromSavedJudgement(runDir, id, mode, rep) {
|
|
52
|
+
/** Recover a rep's judge verdict from retained structured evidence or its legacy prose artifact. */
|
|
53
|
+
function verdictFromSavedJudgement(runDir, id, mode, rep, retained) {
|
|
54
54
|
const path = judgeRawPath(runDir, id, mode, rep);
|
|
55
55
|
if (!existsSync(path))
|
|
56
56
|
return null;
|
|
57
|
+
if (retained?.judgeFormat === "json") {
|
|
58
|
+
return {
|
|
59
|
+
verdict: retained.verdict,
|
|
60
|
+
reason: retained.reason,
|
|
61
|
+
suspect: retained.suspect,
|
|
62
|
+
judgment: retained,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
57
65
|
const raw = readFileSync(path, "utf8");
|
|
58
66
|
const parsed = parseVerdict(raw);
|
|
59
67
|
return { verdict: parsed.verdict, reason: parsed.reason, suspect: detectMisfire(raw, parsed.verdict) };
|
|
@@ -284,7 +292,9 @@ export async function regateRun(opts) {
|
|
|
284
292
|
if (!oldGateFailed) {
|
|
285
293
|
// The judge already saw this rep. Its verdict is on disk — re-read it rather
|
|
286
294
|
// than paying to ask the same question again.
|
|
287
|
-
const
|
|
295
|
+
const panel = rec.rep_judgments?.find(candidate => candidate.repetition === (rep ?? 0));
|
|
296
|
+
const retained = panel?.judgments.find(judgment => judgment.ordinal === 1) ?? panel?.judgments[0];
|
|
297
|
+
const saved = verdictFromSavedJudgement(opts.runDir, scenario.id, mode, rep, retained);
|
|
288
298
|
outcomes.push({ ...(saved ?? { verdict: rec.judge_verdict, reason: rec.judge_reason, suspect: rec.suspect }), objective });
|
|
289
299
|
continue;
|
|
290
300
|
}
|
package/dist/regrade.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { buildJudgePrompt, judgeInWorkspace } from "./grade.js";
|
|
3
|
+
import { buildJudgePrompt, formatJudgeRawReplies, judgeInWorkspace } from "./grade.js";
|
|
4
4
|
import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResults, effectiveThreshold, scoreContextFor, rebuildScenarioResult, mergeScenarioMetrics, carryRepObjectives, completeCriterionVotes, } from "./results.js";
|
|
5
5
|
import { outcomesToResult, normalizeRepOutcome } from "./reps.js";
|
|
6
6
|
import { appendJournal } from "./journal.js";
|
|
@@ -57,10 +57,10 @@ export async function judgeOneRep(opts) {
|
|
|
57
57
|
}
|
|
58
58
|
const prompt = buildJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript });
|
|
59
59
|
const g = await judgeInWorkspace(adapter, judge, prompt, specDir, scenario.checklist.length);
|
|
60
|
-
writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), g.
|
|
60
|
+
writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), formatJudgeRawReplies(g.rawReplies), "utf8");
|
|
61
61
|
const outcome = normalizeRepOutcome({
|
|
62
62
|
verdict: g.verdict, reason: g.reason, suspect: g.suspect, objective: opts.objective,
|
|
63
|
-
judgment: { ordinal: 1, judge: { ...judge }, verdict: g.verdict, reason: g.reason, suspect: g.suspect, criteria: completeCriterionVotes(g.criteria, scenario.checklist.length), ...(g.judgeRetries ? { judgeRetries: g.judgeRetries } : {}) },
|
|
63
|
+
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 } : {}) },
|
|
64
64
|
metrics: {
|
|
65
65
|
wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
|
|
66
66
|
judge_calls: 1 + (g.judgeRetries ?? 0),
|
package/dist/results.d.ts
CHANGED
|
@@ -101,7 +101,9 @@ export interface Judgment {
|
|
|
101
101
|
suspect: boolean;
|
|
102
102
|
/** Every numbered rubric-item vote parsed from this exact judge response. Required in schema v3. */
|
|
103
103
|
criteria?: CriterionVote[];
|
|
104
|
-
/**
|
|
104
|
+
/** Structured format for new judgments; prose is retained compatibility evidence. */
|
|
105
|
+
judgeFormat?: "json" | "prose";
|
|
106
|
+
/** One validation-guided retry after an invalid structured reply. */
|
|
105
107
|
judgeRetries?: 1;
|
|
106
108
|
}
|
|
107
109
|
export interface AdjudicationResult {
|
package/package.json
CHANGED