@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 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
- /** One identical-input retry after missing criterion votes. Absent on the first attempt. */
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?: number): Promise<GradeResult>;
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?: number): Promise<GradeResult>;
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 { parseCriterionVotes, completeCriterionVotes } from "./results.js";
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. Then output exactly these two lines:
41
- VERDICT: PASS (only if EVERY item passed) — or — VERDICT: FAIL
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 request = { model: judge, prompt, cwd };
128
- let raw = await adapter.judge(request);
129
- let criteria = parseCriterionVotes(raw);
130
- let judgeRetries;
131
- if (expectedCriteria !== undefined && completeCriterionVotes(criteria, expectedCriteria).some(vote => vote.verdict === "ERROR")) {
132
- raw = await adapter.judge(request);
133
- criteria = parseCriterionVotes(raw);
134
- judgeRetries = 1;
135
- }
136
- const parsed = parseVerdict(raw);
137
- // On a parse failure, surface what the judge actually emitted (e.g. a provider
138
- // error) rather than a generic message — otherwise the cause is invisible.
139
- if (parsed.verdict === "ERROR") {
140
- const snippet = raw.trim().replace(/\s+/g, " ").slice(0, 160);
141
- if (snippet)
142
- parsed.reason = `judge unparseable: ${snippet}`;
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 suspect = detectMisfire(raw, parsed.verdict);
145
- return { ...parsed, raw, suspect, criteria, ...(judgeRetries ? { judgeRetries } : {}) };
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 its saved judge-raw artifact — free, and exact. */
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 saved = verdictFromSavedJudgement(opts.runDir, scenario.id, mode, rep);
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.raw, "utf8");
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
- /** One retry with identical judge input after unparseable criterion votes. */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.22.4",
3
+ "version": "0.23.0",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -233,6 +233,7 @@
233
233
  "suspect"
234
234
  ],
235
235
  "properties": {
236
+ "judgeFormat": { "enum": ["json", "prose"] },
236
237
  "judgeRetries": { "type": "integer", "const": 1 },
237
238
  "ordinal": {
238
239
  "type": "integer",
@@ -245,6 +245,7 @@
245
245
  "criteria"
246
246
  ],
247
247
  "properties": {
248
+ "judgeFormat": { "enum": ["json", "prose"] },
248
249
  "judgeRetries": { "type": "integer", "const": 1 },
249
250
  "ordinal": {
250
251
  "type": "integer",