@skill-harness/core 0.22.0 → 0.22.3

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
@@ -38,6 +38,8 @@ export declare function judgeResemblesSubject(judge: ModelRef, subject: ModelRef
38
38
  export interface GradeResult extends ParsedVerdict {
39
39
  raw: string;
40
40
  criteria: CriterionVote[];
41
+ /** One identical-input retry after missing criterion votes. Absent on the first attempt. */
42
+ judgeRetries?: 1;
41
43
  /** Judge misfire: the overall verdict disagrees with AND(per-item grades). Recorded, never auto-passed; blocks SHIP until re-judged or overridden. */
42
44
  suspect: boolean;
43
45
  }
@@ -50,10 +52,10 @@ export interface GradeResult extends ParsedVerdict {
50
52
  */
51
53
  export declare function detectMisfire(raw: string, verdict: Verdict): boolean;
52
54
  /** Drive the judge for one transcript and parse the result. */
53
- export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef, prompt: string, cwd: string): Promise<GradeResult>;
55
+ export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef, prompt: string, cwd: string, expectedCriteria?: number): Promise<GradeResult>;
54
56
  /**
55
57
  * Grade a transcript in a fresh, isolated, throwaway workspace — never the
56
58
  * subject's scenario dir — so the judge can't ingest repo context the subject
57
59
  * left behind (matters for provider CLIs that read cwd, including Pi).
58
60
  */
59
- export declare function judgeInWorkspace(adapter: HarnessAdapter, judge: ModelRef, prompt: string, specDir: string): Promise<GradeResult>;
61
+ 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 } from "./results.js";
2
+ import { parseCriterionVotes, 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
  /**
@@ -123,8 +123,16 @@ export function detectMisfire(raw, verdict) {
123
123
  // Non-global twin of REASON_RE: matchAll needs /g, a single .match() must not have it.
124
124
  const REASON_LINE_RE = /^\s*\**\s*REASON\**\s*:\s*\**\s*(.*)$/im;
125
125
  /** Drive the judge for one transcript and parse the result. */
126
- export async function gradeTranscript(adapter, judge, prompt, cwd) {
127
- const raw = await adapter.judge({ model: judge, prompt, cwd });
126
+ 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
+ }
128
136
  const parsed = parseVerdict(raw);
129
137
  // On a parse failure, surface what the judge actually emitted (e.g. a provider
130
138
  // error) rather than a generic message — otherwise the cause is invisible.
@@ -134,17 +142,17 @@ export async function gradeTranscript(adapter, judge, prompt, cwd) {
134
142
  parsed.reason = `judge unparseable: ${snippet}`;
135
143
  }
136
144
  const suspect = detectMisfire(raw, parsed.verdict);
137
- return { ...parsed, raw, suspect, criteria: parseCriterionVotes(raw) };
145
+ return { ...parsed, raw, suspect, criteria, ...(judgeRetries ? { judgeRetries } : {}) };
138
146
  }
139
147
  /**
140
148
  * Grade a transcript in a fresh, isolated, throwaway workspace — never the
141
149
  * subject's scenario dir — so the judge can't ingest repo context the subject
142
150
  * left behind (matters for provider CLIs that read cwd, including Pi).
143
151
  */
144
- export async function judgeInWorkspace(adapter, judge, prompt, specDir) {
152
+ export async function judgeInWorkspace(adapter, judge, prompt, specDir, expectedCriteria) {
145
153
  const ws = createWorkspace("none", { specDir });
146
154
  try {
147
- return await gradeTranscript(adapter, judge, prompt, ws.cwd);
155
+ return await gradeTranscript(adapter, judge, prompt, ws.cwd, expectedCriteria);
148
156
  }
149
157
  finally {
150
158
  ws.cleanup();
package/dist/regate.js CHANGED
@@ -290,12 +290,13 @@ export async function regateRun(opts) {
290
290
  }
291
291
  // The gate blocked this rep before, so no judgement of it exists anywhere.
292
292
  const transcript = readFileSync(tPath, "utf8");
293
- outcomes.push({ ...(await judgeOneRep({
294
- runDir: opts.runDir, spec: opts.spec, scenario, transcript,
295
- adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
296
- mode, rep, now, rejudge: true,
297
- })), objective });
298
- judgeCalls++;
293
+ const judged = await judgeOneRep({
294
+ runDir: opts.runDir, spec: opts.spec, scenario, transcript,
295
+ adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir,
296
+ mode, rep, now, rejudge: true,
297
+ });
298
+ outcomes.push({ ...judged, objective });
299
+ judgeCalls += judged.metrics?.judge_calls ?? 0;
299
300
  judgedHere++;
300
301
  }
301
302
  const threshold = effectiveThreshold(rec, scenario);
package/dist/regrade.d.ts CHANGED
@@ -34,6 +34,9 @@ export interface RegradeOptions {
34
34
  mode?: string;
35
35
  /** Recorded repetition count; when supplied, missing/extra rep artifacts fail before judge calls. */
36
36
  expectedReps?: number;
37
+ /** Select only repetitions with retained ERROR criterion votes. */
38
+ onlyUnparsed?: boolean;
39
+ prior?: ScenarioResult;
37
40
  now?: () => string;
38
41
  }
39
42
  /** Judge one saved transcript: writes the judge-raw artifact, emits a `judge-verdict` journal event (plus `misfire-flag` when the verdict is suspect), and returns the outcome. */
@@ -72,6 +75,8 @@ export interface RegradeRunOptions {
72
75
  * cleanly, so spending judge calls on them buys nothing.
73
76
  */
74
77
  onlySuspect?: boolean;
78
+ /** Rejudge only retained repetitions containing ERROR criterion votes, with the recorded judge. */
79
+ onlyUnparsed?: boolean;
75
80
  }
76
81
  /**
77
82
  * Re-judge every scenario in a run dir that has a transcript for the run's own
package/dist/regrade.js CHANGED
@@ -56,18 +56,18 @@ export async function judgeOneRep(opts) {
56
56
  };
57
57
  }
58
58
  const prompt = buildJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript });
59
- const g = await judgeInWorkspace(adapter, judge, prompt, specDir);
59
+ const g = await judgeInWorkspace(adapter, judge, prompt, specDir, scenario.checklist.length);
60
60
  writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), g.raw, "utf8");
61
61
  appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: g.verdict, reason: g.reason, suspect: g.suspect, ...repField });
62
62
  if (g.suspect)
63
63
  appendJournal(runDir, { event: "misfire-flag", ts: now(), id: scenario.id, reason: g.reason, ...repField });
64
64
  return {
65
65
  verdict: g.verdict, reason: g.reason, suspect: g.suspect,
66
- judgment: { ordinal: 1, judge: { ...judge }, verdict: g.verdict, reason: g.reason, suspect: g.suspect, criteria: completeCriterionVotes(g.criteria, scenario.checklist.length) },
66
+ 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 } : {}) },
67
67
  metrics: {
68
68
  wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
69
- judge_calls: 1,
70
- judge_rejudge_calls: opts.rejudge ? 1 : 0,
69
+ judge_calls: 1 + (g.judgeRetries ?? 0),
70
+ judge_rejudge_calls: opts.rejudge ? 1 + (g.judgeRetries ?? 0) : 0,
71
71
  },
72
72
  };
73
73
  }
@@ -81,18 +81,30 @@ export async function judgeOneRep(opts) {
81
81
  export async function regradeScenario(opts) {
82
82
  const now = opts.now ?? (() => new Date().toISOString());
83
83
  const mode = opts.mode ?? "green";
84
- const files = findTranscriptFiles(opts.runDir, opts.scenario.id, mode);
84
+ let files = findTranscriptFiles(opts.runDir, opts.scenario.id, mode);
85
+ if (opts.onlyUnparsed)
86
+ files = files.filter(file => hasUnparsedVotes(opts.prior?.rep_judgments?.find(panel => panel.repetition === (repIndexOf(file) ?? 0))));
85
87
  if (files.length === 0)
86
88
  throw new Error(`no ${mode} transcripts for ${opts.scenario.id} in ${opts.runDir}`);
87
89
  const expected = opts.expectedReps ?? files.length;
88
- const expectedIndices = expected === 1 ? [null] : Array.from({ length: expected }, (_, index) => index);
90
+ const expectedIndices = opts.onlyUnparsed
91
+ ? opts.prior.rep_judgments.filter(hasUnparsedVotes).map(panel => expected === 1 ? null : panel.repetition).sort((a, b) => (a ?? -1) - (b ?? -1))
92
+ : expected === 1 ? [null] : Array.from({ length: expected }, (_, index) => index);
89
93
  const actualIndices = files.map((file) => repIndexOf(file)).sort((a, b) => (a ?? -1) - (b ?? -1));
90
- if (files.length !== expected || JSON.stringify(actualIndices) !== JSON.stringify(expectedIndices)) {
94
+ if (files.length !== expectedIndices.length || JSON.stringify(actualIndices) !== JSON.stringify(expectedIndices)) {
91
95
  throw new Error(`${opts.scenario.id}: transcript artifacts are incomplete for ${expected} recorded rep(s) — re-run instead of grading a smaller repetition set`);
92
96
  }
93
97
  const repCount = expected;
94
98
  const outcomes = [];
95
- for (const file of files) {
99
+ for (let repetition = 0; repetition < expected; repetition++) {
100
+ const panel = opts.prior?.rep_judgments?.find(panel => panel.repetition === repetition);
101
+ if (opts.onlyUnparsed && !hasUnparsedVotes(panel)) {
102
+ if (!panel)
103
+ throw new Error(`missing retained judgments for ${opts.scenario.id}#${repetition}`);
104
+ outcomes.push({ verdict: panel.recorded_verdict, reason: panel.judgments[0]?.reason ?? "", suspect: panel.judgments.length > 0 && !panel.judgments.some(judgment => !judgment.suspect && (judgment.verdict === "PASS" || judgment.verdict === "FAIL")), judgment: panel.judgments[0], objective: panel.objective });
105
+ continue;
106
+ }
107
+ const file = files.find(file => (repIndexOf(file) ?? 0) === repetition);
96
108
  const rep = repIndexOf(file) ?? undefined;
97
109
  const transcript = readFileSync(join(opts.runDir, file), "utf8");
98
110
  outcomes.push(await judgeOneRep({
@@ -100,7 +112,16 @@ export async function regradeScenario(opts) {
100
112
  adapter: opts.adapter, judge: opts.judge, specDir: opts.specDir, mode, rep, now, rejudge: true,
101
113
  }));
102
114
  }
103
- return outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
115
+ const result = outcomesToResult(opts.scenario.id, outcomes, repCount, opts.threshold);
116
+ if (opts.onlyUnparsed)
117
+ result.rep_judgments = result.rep_judgments?.map(panel => {
118
+ const prior = opts.prior.rep_judgments.find(prior => prior.repetition === panel.repetition);
119
+ return hasUnparsedVotes(prior) ? panel : prior;
120
+ });
121
+ return result;
122
+ }
123
+ function hasUnparsedVotes(panel) {
124
+ return panel?.judgments.some(judgment => judgment.criteria?.some(vote => vote.verdict === "ERROR")) ?? false;
104
125
  }
105
126
  /**
106
127
  * Re-judge every scenario in a run dir that has a transcript for the run's own
@@ -114,17 +135,21 @@ export async function regradeScenario(opts) {
114
135
  * ResultsFile. Shared by `cmdGrade` and the pi-extension's `judge` command.
115
136
  */
116
137
  export async function regradeRun(opts) {
117
- const { runDir, spec, adapter, judge, specDir } = opts;
138
+ const { runDir, spec, adapter, specDir } = opts;
118
139
  const now = opts.now ?? (() => new Date().toISOString());
119
140
  const prev = existsSync(join(runDir, "results.yaml")) ? readResults(runDir) : null;
141
+ if (opts.onlyUnparsed && !prev)
142
+ throw new Error(`--unparsed-only needs a prior results.yaml in ${runDir}`);
143
+ if (opts.onlyUnparsed && opts.onlySuspect)
144
+ throw new Error("--unparsed-only cannot be combined with --suspect-only");
145
+ const judge = opts.onlyUnparsed ? prev.judge : opts.judge;
120
146
  // Carried across a re-judge, per field, because the right answer differs:
121
147
  // - `override`/`note` — the author's, never the judge's to discard.
122
148
  // - `objective` — a re-judge does NOT re-evaluate trace gates (that is
123
149
  // `regate`), so the recorded evidence still describes this run. Dropping it
124
150
  // silently downgraded a gated scenario to "no assertions declared".
125
- // `adjudication` is deliberately NOT carried: it describes the judgments this
126
- // re-grade just replaced, and a stale panel beside a fresh verdict is worse
127
- // than none.
151
+ // `adjudication` is dropped when its repetition is rejudged. A selective
152
+ // repair of a different repetition carries that untouched evidence instead.
128
153
  // The whole prior result per id — `rebuildScenarioResult` decides, field by
129
154
  // field, what survives. Passing a hand-picked subset here is how fields got
130
155
  // dropped before.
@@ -148,6 +173,8 @@ export async function regradeRun(opts) {
148
173
  .filter((s) => s.suspect || s.judge_verdict === "JUDGE-AMBIGUOUS")
149
174
  .map((s) => s.id);
150
175
  }
176
+ if (opts.onlyUnparsed)
177
+ targets = recorded.filter(s => s.rep_judgments?.some(hasUnparsedVotes)).map(s => s.id);
151
178
  // Schema-3 delivery is a prerequisite for behavioral judging. A later `grade`
152
179
  // cannot turn an undelivered or unauthenticated transcript into product evidence.
153
180
  if (prev?.schema === 3) {
@@ -161,6 +188,9 @@ export async function regradeRun(opts) {
161
188
  const completeTranscripts = (record) => {
162
189
  const expected = record.reps ?? 1;
163
190
  const files = findTranscriptFiles(runDir, record.id, mode);
191
+ if (opts.onlyUnparsed) {
192
+ return record.rep_judgments?.length === expected && record.rep_judgments.every(panel => !hasUnparsedVotes(panel) || files.some(file => (repIndexOf(file) ?? 0) === panel.repetition)) || false;
193
+ }
164
194
  const expectedIndices = expected === 1 ? [null] : Array.from({ length: expected }, (_, index) => index);
165
195
  const actualIndices = files.map((file) => repIndexOf(file)).sort((a, b) => (a ?? -1) - (b ?? -1));
166
196
  return files.length === expected && JSON.stringify(actualIndices) === JSON.stringify(expectedIndices);
@@ -173,6 +203,14 @@ export async function regradeRun(opts) {
173
203
  if (missing.length > 0) {
174
204
  throw new Error(`cannot re-grade ${missing.join(", ")} in ${runDir} (transcript missing or scenario no longer in the spec) — re-run instead of grading`);
175
205
  }
206
+ if (opts.onlyUnparsed) {
207
+ const drifted = targets.filter(id => {
208
+ const count = recordedById.get(id).criterion_count;
209
+ return count !== undefined && count !== specById.get(id).checklist.length;
210
+ });
211
+ if (drifted.length)
212
+ throw new Error(`criterion count changed for ${drifted.join(", ")} — use full grade instead of --unparsed-only`);
213
+ }
176
214
  const targetSet = new Set(targets);
177
215
  const scenarioResults = [];
178
216
  for (const rec of recorded) {
@@ -186,17 +224,17 @@ export async function regradeRun(opts) {
186
224
  const threshold = effectiveThreshold(prevScenario, scenario);
187
225
  const rr = await regradeScenario({
188
226
  runDir, spec, scenario, adapter, judge, specDir, threshold, mode, now,
189
- expectedReps: prevScenario?.reps ?? 1,
227
+ expectedReps: prevScenario?.reps ?? 1, onlyUnparsed: opts.onlyUnparsed, prior: prevScenario,
190
228
  });
191
229
  const carry = overrides.get(id);
192
230
  if (prev?.schema === 3)
193
231
  rr.criterion_count = scenario.checklist.length;
194
232
  rr.metrics = mergeScenarioMetrics(carry?.metrics, rr.metrics);
195
233
  rr.rep_judgments = carryRepObjectives(rr.rep_judgments, carry?.rep_judgments);
196
- // `grade` re-judges the saved transcript. It does not re-evaluate trace gates
197
- // (that is `regate`), so `objective` still describes this run; and it replaced
198
- // the judgments a prior adjudication described, so that panel must go.
199
- scenarioResults.push(rebuildScenarioResult(rr, carry, { objective: "carry", adjudication: "drop" }));
234
+ // Gates still describe the original subject execution. An adjudication of
235
+ // an untouched repetition also survives, bounded by rebuild's conflict guards.
236
+ const carryAdjudication = opts.onlyUnparsed && carry?.adjudication && !hasUnparsedVotes(carry.rep_judgments?.find(panel => panel.repetition === (carry.adjudication.repetition ?? 0)));
237
+ scenarioResults.push(rebuildScenarioResult(rr, carry, { objective: "carry", adjudication: carryAdjudication ? "carry" : "drop" }));
200
238
  }
201
239
  const ctx = scoreContextFor({ mode, partial: prev?.partial }, spec);
202
240
  const results = writeResults(runDir, {
@@ -229,7 +267,7 @@ export async function regradeRun(opts) {
229
267
  // say so. Doctrine narrowed 0.4.0, from "recorded hashes stay" to "recorded
230
268
  // *stimulus* hashes stay" — see refreshRubricHashes.
231
269
  partial: prev?.partial,
232
- source_hashes: refreshRubricHashes(prev?.source_hashes, spec, targets),
270
+ source_hashes: opts.onlyUnparsed ? prev?.source_hashes : refreshRubricHashes(prev?.source_hashes, spec, targets),
233
271
  source_hash_roots: prev?.source_hash_roots,
234
272
  scenarios: scenarioResults,
235
273
  }, ctx);
package/dist/results.d.ts CHANGED
@@ -99,6 +99,8 @@ export interface Judgment {
99
99
  suspect: boolean;
100
100
  /** Every numbered rubric-item vote parsed from this exact judge response. Required in schema v3. */
101
101
  criteria?: CriterionVote[];
102
+ /** One retry with identical judge input after unparseable criterion votes. */
103
+ judgeRetries?: 1;
102
104
  }
103
105
  export interface AdjudicationResult {
104
106
  /** The saved transcript repetition judged by every panel member (first rep by policy). */
package/dist/results.js CHANGED
@@ -613,7 +613,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
613
613
  const objective = pick(policy.objective, freshObjective, prior?.objective);
614
614
  const pickedAdjudication = pick(policy.adjudication, freshAdjudication, prior?.adjudication);
615
615
  const conflictsWithFreshEvidence = Boolean(pickedAdjudication?.verdict && (objective?.status === "FAIL" || objective?.status === "ERROR" || objective?.status === "NOT-MEASURED" ||
616
- (pass_threshold === 1 && (reps ?? 1) > 1 && judge_verdict !== "PASS")));
616
+ (pickedAdjudication.verdict === "PASS" && pass_threshold === 1 && (reps ?? 1) > 1 && judge_verdict !== "PASS")));
617
617
  // A settled cell-level panel cannot overrule newly evaluated objective gates,
618
618
  // nor can one transcript erase a failed repetition under an all-clean policy.
619
619
  const adjudication = conflictsWithFreshEvidence && pickedAdjudication
package/dist/run.js CHANGED
@@ -521,7 +521,7 @@ async function runRep(scenario, rep, repCount, ctx) {
521
521
  reason = o.reason;
522
522
  suspect = o.suspect;
523
523
  judgment = o.judgment; // judgeOneRep already journaled (verdict + misfire)
524
- judgeCalls = 1;
524
+ judgeCalls = o.metrics?.judge_calls ?? 0;
525
525
  }
526
526
  log(` → ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${verdict}${reason ? `: ${reason}` : ""}${suspect ? " ⚠ suspect" : ""}`);
527
527
  const subject = mergeTraces(traces)?.metrics;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.22.0",
3
+ "version": "0.22.3",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -232,6 +232,7 @@
232
232
  "suspect"
233
233
  ],
234
234
  "properties": {
235
+ "judgeRetries": { "type": "integer", "const": 1 },
235
236
  "ordinal": {
236
237
  "type": "integer",
237
238
  "minimum": 1,
@@ -244,6 +244,7 @@
244
244
  "criteria"
245
245
  ],
246
246
  "properties": {
247
+ "judgeRetries": { "type": "integer", "const": 1 },
247
248
  "ordinal": {
248
249
  "type": "integer",
249
250
  "minimum": 1,