@skill-harness/core 0.22.4 → 0.24.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.
@@ -78,6 +78,8 @@ export interface StructuredRun {
78
78
  * answering badly. `run.ts` turns this into ERROR — never a model verdict.
79
79
  */
80
80
  providerFailure?: string;
81
+ /** Terminal error/abort or missing settlement: unavailable outcome, never a judgeable success. */
82
+ executionFailure?: string;
81
83
  }
82
84
  export interface HarnessAdapter {
83
85
  name: string;
@@ -104,7 +106,11 @@ export interface HarnessAdapter {
104
106
  * field is to identify which CLI produced a transcript.
105
107
  *
106
108
  * Optional so a test double or a future adapter need not implement it; callers
107
- * treat a missing method exactly like a null answer.
109
+ * treat a missing method exactly like a null answer. A qualification probe must
110
+ * use the same cwd and environment as the execution it qualifies.
108
111
  */
109
- version?(): Promise<string | null>;
112
+ version?(options?: {
113
+ cwd?: string;
114
+ env?: NodeJS.ProcessEnv;
115
+ }): Promise<string | null>;
110
116
  }
@@ -92,6 +92,7 @@ export interface TraceMetrics {
92
92
  /** Date of the repository price table used for cost_usd; null when unpriced. */
93
93
  price_as_of: string | null;
94
94
  tool_calls: number;
95
+ /** Legacy field: requested Agent arguments, not verified child launches. */
95
96
  delegated_children: number;
96
97
  /** Maximum simultaneously outstanding tool calls observed in the pi stream. */
97
98
  max_concurrency: number;
@@ -118,6 +119,8 @@ export interface ExecutionTraceV1 {
118
119
  * never contained, changing grades on scenarios nobody edited.
119
120
  */
120
121
  final_text: string;
122
+ /** Observed terminal state; unknown versions are unqualified, historical Pi 0.83.0 has no settlement field. */
123
+ final_status?: "complete" | "error" | "aborted" | "incomplete" | "truncated" | "unavailable" | "unqualified";
121
124
  /** Every tool call in this invocation, in issue order. */
122
125
  tool_calls: TraceToolCall[];
123
126
  /**
@@ -1,5 +1,26 @@
1
1
  import type { ExecutionTraceV1 } from "./capture-trace-types.js";
2
2
  import type { ModelRef, RunMode } from "./adapters/types.js";
3
+ /**
4
+ * Parse pi's `--mode json` event stream into an `ExecutionTraceV1`.
5
+ *
6
+ * Pinned by the fixtures in `packages/adapters/test/fixtures/pi-json/`. Three
7
+ * event-shape constraints are baked in here and each one is a bug if removed:
8
+ *
9
+ * 1. **Line-at-a-time, never buffered.** `message_update` re-sends the entire
10
+ * accumulated message on every delta, so the stream is quadratic in output
11
+ * length — a trivial three-tool-call run emitted 52 MB wrapping 12 KB of
12
+ * terminal events. The parser consumes an iterable of lines and holds only
13
+ * what it keeps.
14
+ * 2. **The final assistant message only.** pi's print mode — what the judge has
15
+ * always been shown — emits exactly that. Concatenating every assistant text
16
+ * block would add interim narration the transcript has never contained and
17
+ * move verdicts on scenarios nobody edited.
18
+ * 3. **Correlate tool calls by `toolCallId`.** Batched calls execute
19
+ * concurrently and their `end` events arrive in completion order.
20
+ */
21
+ /** Current execution is qualified only against this exact Pi release. */
22
+ export declare const QUALIFIED_PI_VERSION = "1.0.4";
23
+ export declare function piQualificationFailure(version: string | null): string | null;
3
24
  export interface TraceMeta {
4
25
  piVersion: string | null;
5
26
  subject: ModelRef;
@@ -20,6 +20,14 @@ import { redactArgs, redactText } from "./redaction.js";
20
20
  * 3. **Correlate tool calls by `toolCallId`.** Batched calls execute
21
21
  * concurrently and their `end` events arrive in completion order.
22
22
  */
23
+ /** Current execution is qualified only against this exact Pi release. */
24
+ export const QUALIFIED_PI_VERSION = "1.0.4";
25
+ /** Historical raw captures from this version remain readable; this is not fresh-run qualification. */
26
+ const LEGACY_PI_TRACE_VERSION = "0.83.0";
27
+ export function piQualificationFailure(version) {
28
+ return version === QUALIFIED_PI_VERSION ? null
29
+ : `Pi ${version ?? "(version unavailable)"} is unqualified; execution requires exact Pi ${QUALIFIED_PI_VERSION}`;
30
+ }
23
31
  /** Events that carry no information a trace keeps, and are large. */
24
32
  const SKIPPED = new Set(["message_update", "tool_execution_update"]);
25
33
  /** `details` larger than this is dropped rather than persisted. */
@@ -40,6 +48,13 @@ export function parseTrace(lines, meta) {
40
48
  let completionCounter = 0;
41
49
  let malformedLines = 0;
42
50
  let sawTerminal = false;
51
+ const settledContract = meta.piVersion !== LEGACY_PI_TRACE_VERSION;
52
+ const qualificationFailure = settledContract ? piQualificationFailure(meta.piVersion) : null;
53
+ let sawSettled = false;
54
+ let lastStopReason;
55
+ let currentAssistantText = "";
56
+ let eligibleFinal = false;
57
+ const captureErrors = [];
43
58
  let finalText = "";
44
59
  let lastAssistantText = "";
45
60
  let inputTokens = null;
@@ -60,13 +75,35 @@ export function parseTrace(lines, meta) {
60
75
  malformedLines++;
61
76
  continue;
62
77
  }
78
+ if (!ev || typeof ev !== "object" || Array.isArray(ev)) {
79
+ malformedLines++;
80
+ continue;
81
+ }
63
82
  const type = ev.type;
64
- if (typeof type !== "string" || SKIPPED.has(type))
83
+ if (typeof type !== "string") {
84
+ malformedLines++;
85
+ continue;
86
+ }
87
+ if (settledContract && ["agent_start", "turn_start", "message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_end"].includes(type)) {
88
+ sawSettled = false;
89
+ if (type !== "tool_execution_end") {
90
+ currentAssistantText = "";
91
+ lastStopReason = undefined;
92
+ eligibleFinal = false;
93
+ }
94
+ }
95
+ if (SKIPPED.has(type))
65
96
  continue;
66
97
  if (type === "tool_execution_start") {
67
98
  const id = str(ev.toolCallId);
68
- if (!id)
99
+ if (!id) {
100
+ captureErrors.push("tool start without an identity");
69
101
  continue;
102
+ }
103
+ if (calls.has(id)) {
104
+ captureErrors.push("duplicate tool start identity");
105
+ continue;
106
+ }
70
107
  calls.set(id, {
71
108
  id,
72
109
  name: str(ev.toolName) ?? "(unknown)",
@@ -83,11 +120,19 @@ export function parseTrace(lines, meta) {
83
120
  }
84
121
  if (type === "tool_execution_end") {
85
122
  const id = str(ev.toolCallId);
86
- if (!id)
123
+ if (!id) {
124
+ captureErrors.push("tool end without an identity");
87
125
  continue;
126
+ }
88
127
  const call = calls.get(id);
89
- if (!call)
90
- continue; // an end with no start is not evidence of a call
128
+ if (!call) {
129
+ captureErrors.push("tool end without a matching start");
130
+ continue;
131
+ }
132
+ if (call.completionIndex >= 0) {
133
+ captureErrors.push("duplicate tool completion identity");
134
+ continue;
135
+ }
91
136
  if (call.completionIndex < 0)
92
137
  activeCalls = Math.max(0, activeCalls - 1);
93
138
  call.completionIndex = completionCounter++;
@@ -100,7 +145,20 @@ export function parseTrace(lines, meta) {
100
145
  if (type === "message_end") {
101
146
  sawTerminal = true;
102
147
  const msg = ev.message;
103
- const at = isoTime(msg?.timestamp);
148
+ if (!msg || typeof msg !== "object" || Array.isArray(msg) || typeof msg.role !== "string") {
149
+ captureErrors.push("malformed message_end message");
150
+ continue;
151
+ }
152
+ // Pi also emits system/user messages; a system message has string content.
153
+ // They invalidate a previous final but are not assistant/tool-result payloads.
154
+ if (msg.role !== "assistant" && msg.role !== "toolResult")
155
+ continue;
156
+ if (!Array.isArray(msg.content) ||
157
+ msg.content.some((block) => !block || typeof block !== "object" || Array.isArray(block) || typeof block.type !== "string" || (block.type === "text" && typeof block.text !== "string"))) {
158
+ captureErrors.push("malformed message_end content");
159
+ continue;
160
+ }
161
+ const at = isoTime(msg.timestamp);
104
162
  if (msg?.role === "assistant" && at) {
105
163
  for (const block of msg.content ?? []) {
106
164
  if (block.type !== "toolCall" || typeof block.id !== "string")
@@ -120,6 +178,9 @@ export function parseTrace(lines, meta) {
120
178
  if (msg?.role !== "assistant")
121
179
  continue;
122
180
  const text = assistantText(msg);
181
+ eligibleFinal = msg.content.every((block) => block.type === "text" || block.type === "thinking");
182
+ currentAssistantText = text;
183
+ lastStopReason = msg.stopReason;
123
184
  if (text) {
124
185
  lastAssistantText = text;
125
186
  // `stop` marks the model's closing message; a `toolUse` message is
@@ -135,6 +196,8 @@ export function parseTrace(lines, meta) {
135
196
  }
136
197
  if (type === "turn_end" || type === "agent_end" || type === "agent_settled") {
137
198
  sawTerminal = true;
199
+ if (type === "agent_settled")
200
+ sawSettled = true;
138
201
  // Deliberately read NOTHING from these. They repeat the same assistant
139
202
  // messages `message_end` already carried (`turn_end` and `agent_end` do;
140
203
  // `agent_settled` carries no keys at all beyond `type`), and reading two
@@ -143,6 +206,23 @@ export function parseTrace(lines, meta) {
143
206
  }
144
207
  }
145
208
  const toolCalls = [...calls.values()].sort((a, b) => a.issueIndex - b.issueIndex);
209
+ if (malformedLines > 0)
210
+ captureErrors.push(`pi JSONL contained ${malformedLines} malformed line(s); trace evidence is incomplete`);
211
+ const finalStatus = qualificationFailure ? "unqualified"
212
+ : !sawSettled || activeCalls > 0 ? "incomplete"
213
+ : captureErrors.length > 0 ? "unavailable"
214
+ : lastStopReason === "error" ? "error"
215
+ : lastStopReason === "aborted" ? "aborted"
216
+ : lastStopReason === "length" ? "truncated"
217
+ : lastStopReason === "stop" && eligibleFinal && currentAssistantText.length > 0 ? "complete" : "unavailable";
218
+ if (settledContract && finalStatus !== "complete") {
219
+ const reason = qualificationFailure
220
+ ?? (finalStatus === "incomplete" ? "current settlement is missing or tool calls remain outstanding"
221
+ : finalStatus === "truncated" ? "settled assistant reached its output length limit"
222
+ : finalStatus === "error" || finalStatus === "aborted" ? `settled assistant stop reason is ${lastStopReason}`
223
+ : "current terminal assistant content is empty, ineligible, or captured evidence is malformed");
224
+ captureErrors.push(`Pi ${meta.piVersion ?? "(version unavailable)"} final delivery is ${finalStatus}; ${reason}`);
225
+ }
146
226
  const metrics = {
147
227
  input_tokens: inputTokens,
148
228
  output_tokens: outputTokens,
@@ -172,7 +252,9 @@ export function parseTrace(lines, meta) {
172
252
  // and `smoke-real-pi.sh` asserts no `/home/` survives into a persisted trace
173
253
  // — an assertion that used to pass only because the smoke model happened not
174
254
  // to echo one.
175
- final_text: redactText(finalText || lastAssistantText, meta.homeDir),
255
+ final_text: redactText(settledContract ? currentAssistantText : finalText || lastAssistantText, meta.homeDir),
256
+ ...(settledContract ? { final_status: finalStatus } : {}),
257
+ ...(captureErrors.length ? { capture_errors: [...new Set(captureErrors)] } : {}),
176
258
  tool_calls: toolCalls,
177
259
  // `null`, not `[]`: the stream says nothing about the filesystem. The runner
178
260
  // overwrites this after observing the workspace. Defaulting to `[]` claimed
@@ -184,7 +266,7 @@ export function parseTrace(lines, meta) {
184
266
  metrics,
185
267
  };
186
268
  trace.trace_sha256 = traceSha256(trace);
187
- return { trace, isComplete: sawTerminal, malformedLines };
269
+ return { trace, isComplete: settledContract ? !qualificationFailure && sawSettled && activeCalls === 0 : sawTerminal, malformedLines };
188
270
  }
189
271
  function addReported(current, value) {
190
272
  return typeof value === "number" && value > 0 ? (current ?? 0) + value : current;
@@ -194,8 +276,7 @@ function assistantText(msg) {
194
276
  return (msg.content ?? [])
195
277
  .filter((b) => b.type === "text" && typeof b.text === "string")
196
278
  .map((b) => b.text)
197
- .join("\n")
198
- .trim();
279
+ .join("");
199
280
  }
200
281
  /**
201
282
  * Bounded metadata about a tool result. The body is never kept: results carry
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
@@ -31,6 +31,10 @@
31
31
  * convention cannot drift apart.
32
32
  */
33
33
  export declare const PROVIDER_FAILURE_MARKER = "[skill-harness] provider failure:";
34
+ /** Failed or unproven terminal delivery, without assuming a provider-side cause. */
35
+ export declare const EXECUTION_FAILURE_MARKER = "[skill-harness] execution failure:";
36
+ export declare function withExecutionFailure(transcript: string, failure: string | null): string;
37
+ export declare function executionFailureFromTranscript(transcript: string): string | null;
34
38
  /**
35
39
  * Prefix `transcript` with the provider-failure marker, or return it unchanged.
36
40
  *
@@ -31,6 +31,14 @@
31
31
  * convention cannot drift apart.
32
32
  */
33
33
  export const PROVIDER_FAILURE_MARKER = "[skill-harness] provider failure:";
34
+ /** Failed or unproven terminal delivery, without assuming a provider-side cause. */
35
+ export const EXECUTION_FAILURE_MARKER = "[skill-harness] execution failure:";
36
+ export function withExecutionFailure(transcript, failure) {
37
+ return failure ? `${EXECUTION_FAILURE_MARKER} ${failure}\n\n${transcript}` : transcript;
38
+ }
39
+ export function executionFailureFromTranscript(transcript) {
40
+ return failureFromPreamble(transcript, EXECUTION_FAILURE_MARKER);
41
+ }
34
42
  /**
35
43
  * The adapters' shared turn-header prefix (`>>> USER:` / `>>> USER (turn n/m):`),
36
44
  * which is where the preamble ends and model-influenced content begins.
@@ -87,11 +95,14 @@ export function providerFailureFromJsonLine(line) {
87
95
  * provider failed — it is evidence the model typed the words.
88
96
  */
89
97
  export function providerFailureFromTranscript(transcript) {
98
+ return failureFromPreamble(transcript, PROVIDER_FAILURE_MARKER);
99
+ }
100
+ function failureFromPreamble(transcript, marker) {
90
101
  for (const line of transcript.split("\n")) {
91
102
  if (line.startsWith(TURN_HEADER_PREFIX))
92
103
  return null;
93
- if (line.startsWith(PROVIDER_FAILURE_MARKER))
94
- return line.slice(PROVIDER_FAILURE_MARKER.length).trim();
104
+ if (line.startsWith(marker))
105
+ return line.slice(marker.length).trim();
95
106
  }
96
107
  return null;
97
108
  }
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,11 +1,11 @@
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";
7
7
  import { rubricDigest, personaDigest, RUBRIC_PREFIX, PERSONA_KEY } from "./sources.js";
8
- import { providerFailureFromTranscript } from "./provider-failure.js";
8
+ import { providerFailureFromTranscript, executionFailureFromTranscript } from "./provider-failure.js";
9
9
  /**
10
10
  * Carry a run's recorded hashes forward, refreshing only the `rubric:` keys this
11
11
  * re-grade actually judged under (plus the persona, which applies to all of them).
@@ -47,8 +47,9 @@ export async function judgeOneRep(opts) {
47
47
  // into a model verdict, spending a judge call to turn infrastructure noise into
48
48
  // a false FAIL.
49
49
  const providerFailure = providerFailureFromTranscript(transcript);
50
- if (providerFailure) {
51
- const reason = `provider failure — ${providerFailure}`;
50
+ const executionFailure = executionFailureFromTranscript(transcript);
51
+ if (providerFailure || executionFailure) {
52
+ const reason = providerFailure ? `provider failure — ${providerFailure}` : `execution failure — ${executionFailure}`;
52
53
  appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: "ERROR", reason, suspect: false, ...repField });
53
54
  return {
54
55
  verdict: "ERROR", reason, suspect: false,
@@ -57,10 +58,10 @@ export async function judgeOneRep(opts) {
57
58
  }
58
59
  const prompt = buildJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript });
59
60
  const g = await judgeInWorkspace(adapter, judge, prompt, specDir, scenario.checklist.length);
60
- writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), g.raw, "utf8");
61
+ writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), formatJudgeRawReplies(g.rawReplies), "utf8");
61
62
  const outcome = normalizeRepOutcome({
62
63
  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 } : {}) },
64
+ 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
65
  metrics: {
65
66
  wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)),
66
67
  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/dist/run.js CHANGED
@@ -16,7 +16,7 @@ import { judgeOneRep } from "./regrade.js";
16
16
  import { runDeliveryCanary, canaryFailure } from "./canary.js";
17
17
  import { boundaryCells, stabilityNote } from "./stability.js";
18
18
  import { aggregateMetrics } from "./metrics.js";
19
- import { providerFailureFromTranscript } from "./provider-failure.js";
19
+ import { providerFailureFromTranscript, executionFailureFromTranscript } from "./provider-failure.js";
20
20
  /**
21
21
  * The pi-daddy arm's delivery proof, by convention: `arms.yaml` points
22
22
  * `PI_GRANTS_LEDGER` at exactly this filename inside the run directory. A fixed
@@ -303,6 +303,7 @@ async function runRep(scenario, rep, repCount, ctx) {
303
303
  // and the artifact disagreeing, and a recovered measurement thrown away.
304
304
  infrastructureFailure = null;
305
305
  adapterFailure = null;
306
+ let executionUnavailable = false;
306
307
  try {
307
308
  if (scenario.mode === "seeded") {
308
309
  const r = await runSeeded(scenario, {
@@ -348,8 +349,11 @@ async function runRep(scenario, rep, repCount, ctx) {
348
349
  const structured = await ctx.adapter.runStructured({ ...req, scenarioId: scenario.id, rep });
349
350
  transcript = structured.transcript;
350
351
  traces = structured.traces;
352
+ executionUnavailable = Boolean(structured.executionFailure);
351
353
  if (structured.providerFailure)
352
354
  infrastructureFailure = `provider failure — ${structured.providerFailure}`;
355
+ else if (structured.executionFailure)
356
+ infrastructureFailure = `execution failure — ${structured.executionFailure}`;
353
357
  }
354
358
  else {
355
359
  transcript = await ctx.adapter.run(req);
@@ -384,9 +388,19 @@ async function runRep(scenario, rep, repCount, ctx) {
384
388
  const provider = providerFailureFromTranscript(transcript);
385
389
  if (provider)
386
390
  infrastructureFailure = `provider failure — ${provider}`;
391
+ else {
392
+ const execution = executionFailureFromTranscript(transcript);
393
+ if (execution)
394
+ infrastructureFailure = `execution failure — ${execution}`;
395
+ }
387
396
  }
388
- noResponse = hasEmptyAssistantTurn(transcript);
389
- if (!noResponse && !adapterFailure)
397
+ executionUnavailable ||= executionFailureFromTranscript(transcript) !== null;
398
+ // Qualified complete traces prove delivered bytes even if those bytes
399
+ // are only whitespace; the legacy text-only timeout heuristic cannot erase them.
400
+ const deliveredText = traces.length > 0 && traces.every((trace) => trace.final_status === "complete" && trace.final_text.length > 0 && !trace.capture_errors?.length);
401
+ noResponse = !deliveredText && hasEmptyAssistantTurn(transcript);
402
+ // Known unavailable execution is not an empty model answer to retry.
403
+ if (executionUnavailable || (!noResponse && !adapterFailure))
390
404
  break;
391
405
  }
392
406
  // Survived the retry: ERROR for this rep, inside a run that still completes
@@ -578,7 +592,7 @@ export function formatScorecard(summary, lift, stability) {
578
592
  const tokens = metrics.input_tokens === null
579
593
  ? "subject tokens unavailable"
580
594
  : `subject tokens ${metrics.input_tokens} in / ${metrics.output_tokens ?? 0} out / ${metrics.cache_read_tokens ?? 0} cache-read`;
581
- const tools = metrics.tool_calls === null ? "tool calls unavailable" : `${metrics.tool_calls} tool call(s), ${metrics.delegated_children ?? 0} delegated, max concurrency ${metrics.max_concurrency ?? 0}`;
595
+ const tools = metrics.tool_calls === null ? "tool calls unavailable" : `${metrics.tool_calls} tool call(s), ${metrics.delegated_children ?? 0} requested Agent task(s), max outstanding tool calls ${metrics.max_concurrency ?? 0}; child launches/concurrency unobserved`;
582
596
  const cost = metrics.cost_source === "subscription"
583
597
  ? " · subscription ($0 marginal cost recorded)"
584
598
  : metrics.cost_source === "unreported"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.22.4",
3
+ "version": "0.24.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",