@skill-harness/core 0.17.0 → 0.18.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.
Files changed (93) hide show
  1. package/README.md +1 -2
  2. package/dist/adapters/types.d.ts +0 -12
  3. package/dist/capture-trace-types.d.ts +0 -96
  4. package/dist/capture-trace-types.js +0 -4
  5. package/dist/command-cost.d.ts +1 -1
  6. package/dist/command-cost.js +0 -5
  7. package/dist/execution-trace.js +1 -1
  8. package/dist/index.d.ts +3 -23
  9. package/dist/index.js +3 -23
  10. package/dist/instruction-coverage.d.ts +0 -7
  11. package/dist/instruction-coverage.js +5 -7
  12. package/dist/journal.d.ts +0 -8
  13. package/dist/metrics.d.ts +18 -0
  14. package/dist/metrics.js +28 -0
  15. package/dist/redaction.d.ts +6 -0
  16. package/dist/redaction.js +55 -0
  17. package/dist/regate.js +6 -71
  18. package/dist/regrade.d.ts +1 -3
  19. package/dist/regrade.js +1 -21
  20. package/dist/report.d.ts +1 -15
  21. package/dist/report.js +1 -13
  22. package/dist/reps.d.ts +1 -2
  23. package/dist/reps.js +0 -2
  24. package/dist/results.d.ts +0 -9
  25. package/dist/results.js +2 -19
  26. package/dist/run.js +11 -72
  27. package/dist/seeded.d.ts +1 -7
  28. package/dist/seeded.js +5 -11
  29. package/dist/sources.js +4 -9
  30. package/dist/spec.d.ts +0 -13
  31. package/dist/spec.js +6 -48
  32. package/dist/trajectory-events.d.ts +58 -0
  33. package/dist/trajectory-events.js +119 -0
  34. package/package.json +1 -1
  35. package/schemas/results-v2.schema.json +376 -75
  36. package/schemas/specification-v1.schema.json +128 -174
  37. package/dist/adjudication.d.ts +0 -219
  38. package/dist/adjudication.js +0 -422
  39. package/dist/adoption.d.ts +0 -128
  40. package/dist/adoption.js +0 -102
  41. package/dist/affected.d.ts +0 -88
  42. package/dist/affected.js +0 -222
  43. package/dist/capture.d.ts +0 -193
  44. package/dist/capture.js +0 -344
  45. package/dist/comparison.d.ts +0 -97
  46. package/dist/comparison.js +0 -189
  47. package/dist/factory-calibration.d.ts +0 -63
  48. package/dist/factory-calibration.js +0 -115
  49. package/dist/intervention-results.d.ts +0 -6
  50. package/dist/intervention-results.js +0 -52
  51. package/dist/intervention.d.ts +0 -123
  52. package/dist/intervention.js +0 -218
  53. package/dist/investigation.d.ts +0 -94
  54. package/dist/investigation.js +0 -148
  55. package/dist/judge-agreement.d.ts +0 -19
  56. package/dist/judge-agreement.js +0 -31
  57. package/dist/qualification-capture.d.ts +0 -44
  58. package/dist/qualification-capture.js +0 -163
  59. package/dist/qualification-config.d.ts +0 -233
  60. package/dist/qualification-config.js +0 -694
  61. package/dist/qualification-lock.d.ts +0 -15
  62. package/dist/qualification-lock.js +0 -87
  63. package/dist/qualification-oauth-directory.d.ts +0 -79
  64. package/dist/qualification-oauth-directory.js +0 -444
  65. package/dist/qualification-panel-store.d.ts +0 -14
  66. package/dist/qualification-panel-store.js +0 -188
  67. package/dist/qualification-panels.d.ts +0 -99
  68. package/dist/qualification-panels.js +0 -91
  69. package/dist/qualification-process.d.ts +0 -13
  70. package/dist/qualification-process.js +0 -190
  71. package/dist/qualification-runner.d.ts +0 -203
  72. package/dist/qualification-runner.js +0 -2037
  73. package/dist/qualification-store.d.ts +0 -187
  74. package/dist/qualification-store.js +0 -833
  75. package/dist/qualification-validation.d.ts +0 -12
  76. package/dist/qualification-validation.js +0 -8
  77. package/dist/screen.d.ts +0 -31
  78. package/dist/screen.js +0 -105
  79. package/dist/trajectory-gates.d.ts +0 -137
  80. package/dist/trajectory-gates.js +0 -576
  81. package/dist/trusted-host-supervision.d.ts +0 -25
  82. package/dist/trusted-host-supervision.js +0 -94
  83. package/dist/work-capture-schema.d.ts +0 -38
  84. package/dist/work-capture-schema.js +0 -33
  85. package/dist/work-capture.d.ts +0 -43
  86. package/dist/work-capture.js +0 -81
  87. package/dist/work-signals.d.ts +0 -55
  88. package/dist/work-signals.js +0 -71
  89. package/schemas/qualification-cell-result-v1.schema.json +0 -32
  90. package/schemas/qualification-config-v1.schema.json +0 -244
  91. package/schemas/qualification-invocation-request-v1.schema.json +0 -59
  92. package/schemas/qualification-judge-panel-result-v1.schema.json +0 -42
  93. package/schemas/trajectory-event-v1.schema.json +0 -80
package/dist/capture.js DELETED
@@ -1,344 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { sep } from "node:path";
3
- import { CAPTURE_SCHEMA_VERSION } from "./capture-trace-types.js";
4
- /**
5
- * The chain of entries ending at `leafId`, in root→leaf order.
6
- *
7
- * With no `leafId`, the leaf is the last entry in file order — which is what
8
- * "the conversation I am currently in" means. Entries with no `id` (the
9
- * `session` header) are not part of the chain and are dropped.
10
- *
11
- * Cycles cannot occur in a well-formed file, but a corrupted one must not hang
12
- * the agent, so the walk is bounded by the entry count.
13
- */
14
- export function activeBranch(entries, leafId) {
15
- const byId = new Map();
16
- for (const e of entries)
17
- if (typeof e.id === "string")
18
- byId.set(e.id, e);
19
- let cursor = leafId;
20
- if (cursor === undefined) {
21
- for (let i = entries.length - 1; i >= 0; i--) {
22
- if (typeof entries[i].id === "string") {
23
- cursor = entries[i].id;
24
- break;
25
- }
26
- }
27
- }
28
- const chain = [];
29
- const seen = new Set();
30
- while (cursor !== undefined && cursor !== null && byId.has(cursor) && chain.length <= entries.length) {
31
- if (seen.has(cursor))
32
- break;
33
- seen.add(cursor);
34
- const entry = byId.get(cursor);
35
- chain.push(entry);
36
- cursor = entry.parentId ?? undefined;
37
- }
38
- return chain.reverse();
39
- }
40
- /** Extract visible text from content blocks. Thinking is dropped unconditionally. */
41
- export function visibleText(blocks) {
42
- if (!blocks)
43
- return "";
44
- const parts = [];
45
- for (const b of blocks) {
46
- if (b.type === "thinking")
47
- continue; // never, at any call site
48
- if (b.type === "text" && typeof b.text === "string")
49
- parts.push(b.text);
50
- else if (b.type === "image")
51
- parts.push("[image omitted]");
52
- }
53
- return parts.join("\n").trim();
54
- }
55
- /**
56
- * Group an already-branch-resolved entry list into logical turns.
57
- *
58
- * Entries that are not messages (`model_change`, `thinking_level_change`,
59
- * compaction markers, custom extension entries) are skipped rather than
60
- * guessed at: an unknown entry type is not evidence about the conversation.
61
- * Anything before the first user message is dropped — it belongs to no turn.
62
- */
63
- export function projectTurns(entries, homeDir) {
64
- const turns = [];
65
- let current = null;
66
- for (const entry of entries) {
67
- if (entry.type !== "message" || !entry.message)
68
- continue;
69
- const msg = entry.message;
70
- const id = typeof entry.id === "string" ? entry.id : "";
71
- if (msg.role === "user") {
72
- current = {
73
- index: turns.length,
74
- user: visibleText(msg.content),
75
- assistantText: "",
76
- toolCalls: [],
77
- entryIds: id ? [id] : [],
78
- };
79
- turns.push(current);
80
- continue;
81
- }
82
- if (!current)
83
- continue; // pre-conversation noise belongs to no turn
84
- if (id)
85
- current.entryIds.push(id);
86
- if (msg.role === "assistant") {
87
- const text = visibleText(msg.content);
88
- if (text)
89
- current.assistantText = current.assistantText ? `${current.assistantText}\n${text}` : text;
90
- for (const b of msg.content ?? []) {
91
- if (b.type !== "toolCall")
92
- continue;
93
- current.toolCalls.push({
94
- name: typeof b.name === "string" ? b.name : "(unknown)",
95
- // Without `homeDir` this scrubbed secrets but left absolute home paths
96
- // intact — and these args are what the evidence sidecar records.
97
- args: redactArgs(b.arguments, homeDir),
98
- isError: false,
99
- ...(typeof b.id === "string" ? { id: b.id } : {}),
100
- });
101
- }
102
- continue;
103
- }
104
- if (msg.role === "toolResult") {
105
- // Attach the outcome to the matching call. Result BODIES are never kept —
106
- // they routinely carry file contents, command output and absolute paths.
107
- const body = JSON.stringify(msg.content ?? []);
108
- const callId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
109
- // By id when pi gave us one; otherwise the FIRST still-unmatched call of
110
- // that name, since results arrive in order for a sequential conversation.
111
- const target = callId
112
- ? current.toolCalls.find((c) => c.id === callId)
113
- : current.toolCalls.find((c) => c.name === msg.toolName && c.resultBytes === undefined);
114
- if (target) {
115
- target.isError = msg.isError === true;
116
- target.resultBytes = Buffer.byteLength(body, "utf8");
117
- target.resultSha256 = sha256(body);
118
- }
119
- }
120
- }
121
- return turns;
122
- }
123
- // ---------------------------------------------------------------------------
124
- // Redaction
125
- // ---------------------------------------------------------------------------
126
- /** Values longer than this are truncated rather than persisted whole. */
127
- export const MAX_VALUE_CHARS = 2000;
128
- const REDACTED = "[redacted]";
129
- /** Argument/field names whose VALUE is always dropped, whatever it looks like. */
130
- const SECRET_KEY = /^(.*[-_])?(password|passwd|secret|token|api[-_]?key|apikey|auth|authorization|credential|private[-_]?key|access[-_]?key|session[-_]?key)([-_].*)?$/i;
131
- /** Value-shaped secrets, caught even under an innocuous key name. */
132
- const SECRET_VALUE = [
133
- /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
134
- /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/g,
135
- /\bsk-[A-Za-z0-9]{16,}\b/g,
136
- /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
137
- /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/g,
138
- /\bAKIA[0-9A-Z]{16}\b/g,
139
- /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, // JWT
140
- // Credentials embedded in a URL — `postgres://user:pass@host/db`. Caught by
141
- // shape rather than by key name: the key is usually something like `DB_URL`,
142
- // which no list of secret-sounding names will ever match.
143
- /\b([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s@]+@/gi,
144
- ];
145
- /**
146
- * Redact secrets and machine paths from a free-text string.
147
- *
148
- * Home directories are replaced because a capture is meant to be committed, and
149
- * `/home/<name>/…` identifies a person as surely as a token identifies an
150
- * account. `homeDir` is a parameter rather than a read of `process.env.HOME` so
151
- * the behavior is testable and does not differ between a developer's machine
152
- * and CI.
153
- */
154
- export function redactText(input, homeDir) {
155
- let out = input;
156
- // The URL pattern keeps its scheme so the value stays recognisable as a URL;
157
- // every other pattern replaces the whole match.
158
- out = out.replace(SECRET_VALUE[SECRET_VALUE.length - 1], `$1${REDACTED}@`);
159
- for (const re of SECRET_VALUE.slice(0, -1))
160
- out = out.replace(re, REDACTED);
161
- if (homeDir && homeDir.length > 1) {
162
- out = out.split(homeDir).join("~");
163
- }
164
- return out;
165
- }
166
- /** Truncate with an explicit marker — silent truncation reads as complete evidence. */
167
- export function truncate(input, max = MAX_VALUE_CHARS) {
168
- if (input.length <= max)
169
- return input;
170
- return `${input.slice(0, max)}… [truncated ${input.length - max} chars]`;
171
- }
172
- /**
173
- * Sanitize tool-call arguments: drop secret-named values, redact secret-shaped
174
- * ones, truncate the oversized, and refuse to recurse without bound.
175
- */
176
- export function redactArgs(args, homeDir, depth = 0) {
177
- if (args === null || typeof args !== "object" || Array.isArray(args))
178
- return {};
179
- const out = {};
180
- for (const [key, value] of Object.entries(args)) {
181
- if (SECRET_KEY.test(key)) {
182
- out[key] = REDACTED;
183
- continue;
184
- }
185
- out[key] = redactValue(value, homeDir, depth);
186
- }
187
- return out;
188
- }
189
- function redactValue(value, homeDir, depth) {
190
- if (typeof value === "string")
191
- return truncate(redactText(value, homeDir));
192
- if (typeof value === "number" || typeof value === "boolean" || value === null)
193
- return value;
194
- if (depth >= 3)
195
- return "[nested]"; // bounded: a deep object is not review evidence
196
- if (Array.isArray(value))
197
- return value.slice(0, 20).map((v) => redactValue(v, homeDir, depth + 1));
198
- if (typeof value === "object")
199
- return redactArgs(value, homeDir, depth + 1);
200
- return String(value);
201
- }
202
- function sha256(text) {
203
- return createHash("sha256").update(text, "utf8").digest("hex");
204
- }
205
- // ---------------------------------------------------------------------------
206
- // Building a capture case
207
- // ---------------------------------------------------------------------------
208
- /** Stable, sortable, collision-resistant enough for a per-skill directory. */
209
- export function captureId(seed, existing = []) {
210
- const taken = new Set(existing);
211
- const base = `CAP-${sha256(seed).slice(0, 6).toUpperCase()}`;
212
- if (!taken.has(base))
213
- return base;
214
- for (let n = 2;; n++) {
215
- const candidate = `${base}-${n}`;
216
- if (!taken.has(candidate))
217
- return candidate;
218
- }
219
- }
220
- /**
221
- * Assemble a reviewed capture case.
222
- *
223
- * Only the USER turns are carried into the case: they are the stimulus, and they
224
- * are the only part that can be replayed. The assistant's historical prose is
225
- * evidence for the human writing the expectation, never an exact-output oracle —
226
- * so it lives in the git-ignored local sidecar, not here.
227
- *
228
- * The session path is hashed rather than stored: an absolute path names a
229
- * machine and a user, and a hash is enough to recognize the same session again.
230
- */
231
- export function buildCaptureCase(opts) {
232
- const { start, end } = opts.range;
233
- if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= opts.turns.length) {
234
- throw new Error(`invalid capture range ${start}..${end} over ${opts.turns.length} turn(s)`);
235
- }
236
- if (opts.expectedBehavior.trim() === "") {
237
- throw new Error("a capture needs a written expected behavior — it is what makes the case reviewable");
238
- }
239
- const checklist = opts.checklist.map((c) => c.trim()).filter(Boolean);
240
- if (checklist.length === 0) {
241
- throw new Error("a capture needs at least one checklist item");
242
- }
243
- const selected = opts.turns.slice(start, end + 1);
244
- const turns = selected.map((t) => truncate(redactText(t.user, opts.homeDir)));
245
- const id = captureId(`${opts.sessionPath}:${start}:${end}:${opts.created}`, opts.existingIds ?? []);
246
- return {
247
- capture_schema: CAPTURE_SCHEMA_VERSION,
248
- id,
249
- created: opts.created,
250
- classification: opts.classification,
251
- turns,
252
- expected_behavior: truncate(redactText(opts.expectedBehavior, opts.homeDir)),
253
- checklist: checklist.map((c) => truncate(redactText(c, opts.homeDir), 300)),
254
- target: opts.target,
255
- // `covers` refs resolve against the SPEC dir, and `target.path` is relative
256
- // to the skill root — one level up. A subagent path (`.pi/agents/x.md`) is
257
- // carried the same way; both are instruction files a section walk can read.
258
- covers: [`../${opts.target.path.split(sep).join("/")}`],
259
- provenance: {
260
- session_sha256: sha256(opts.sessionPath),
261
- turn_range: { start, end },
262
- ...(opts.subject ? { subject: opts.subject } : {}),
263
- ...(opts.gitCommit ? { git_commit: opts.gitCommit } : {}),
264
- ...(opts.gitDirty === undefined ? {} : { git_dirty: opts.gitDirty }),
265
- },
266
- status: "pending",
267
- };
268
- }
269
- /**
270
- * Project a reviewed capture into a scenario object for `appendScenario`.
271
- *
272
- * Deliberately minimal: id, title, turns, checklist. No `critical`, no gates, no
273
- * fixture. Promotion is the moment a human takes responsibility for a test, and
274
- * a capture cannot know whether the behavior it saw is ship-blocking — guessing
275
- * `critical: true` here would let a captured one-off silently gate a release.
276
- */
277
- export function captureToScenario(capture, scenarioId, title) {
278
- return {
279
- id: scenarioId,
280
- title,
281
- turns: capture.turns,
282
- checklist: capture.checklist,
283
- };
284
- }
285
- /**
286
- * Draft an orchestration assertion from subagent calls seen in the captured range.
287
- *
288
- * Returns null when the range contains none, so the capture UI can skip the
289
- * question entirely rather than offering an empty form.
290
- *
291
- * Deliberately proposes only `agent` and `count` — never `task_contains`. The
292
- * task text that happened to be sent is not the task text that is *required*;
293
- * turning one observed handoff into a required substring would manufacture a
294
- * brittle assertion the author never reasoned about. Required context is a
295
- * judgement, so the UI asks.
296
- */
297
- export function draftSubagentAssertion(turns, toolNames = ["Agent", "subagent", "task"]) {
298
- const names = new Set(toolNames.map((n) => n.toLowerCase()));
299
- for (const turn of turns) {
300
- for (const call of turn.toolCalls) {
301
- if (!names.has(call.name.toLowerCase()))
302
- continue;
303
- const invocations = subagentInvocationsOf(call.args);
304
- if (invocations.length === 0)
305
- continue;
306
- return { tool: call.name, agent: invocations[0], count: { min: 1 } };
307
- }
308
- }
309
- return null;
310
- }
311
- /** Agent names visible in a call's arguments, across the known shapes. */
312
- function subagentInvocationsOf(args) {
313
- const nameOf = (v) => {
314
- if (v === null || typeof v !== "object" || Array.isArray(v))
315
- return null;
316
- const o = v;
317
- if (typeof o.agent === "string")
318
- return o.agent;
319
- if (typeof o.name === "string")
320
- return o.name;
321
- return null;
322
- };
323
- for (const key of ["tasks", "chain"]) {
324
- const list = args[key];
325
- if (Array.isArray(list))
326
- return list.map(nameOf).filter((x) => x !== null);
327
- }
328
- const single = nameOf(args);
329
- return single ? [single] : [];
330
- }
331
- /**
332
- * Offline first-draft checklist from the human's expectation.
333
- *
334
- * Splits on sentence and bullet boundaries. This is a typing shortcut for the
335
- * editor step, not an understanding of the text — the UI always opens the result
336
- * for correction, and the plan makes LLM drafting a separate, paid, opt-in path.
337
- */
338
- export function draftChecklist(expectedBehavior) {
339
- return expectedBehavior
340
- .split(/\n\s*[-*]\s+|\n{2,}|(?<=[.!?])\s+(?=[A-Z])/)
341
- .map((s) => s.replace(/^[-*]\s+/, "").replace(/\s+/g, " ").trim().replace(/[.]$/, ""))
342
- .filter((s) => s.length > 3);
343
- }
344
- //# sourceMappingURL=capture.js.map
@@ -1,97 +0,0 @@
1
- import type { ResultsFile, ScenarioResult } from "./results.js";
2
- import type { Verdict } from "./score.js";
3
- export interface ComparisonSideDigests {
4
- skill: string;
5
- spec: string;
6
- fixtures: Record<string, string>;
7
- /** Every non-candidate test input: scenario facets, persona, fixtures, extensions, post-tests, and system prompts. */
8
- inputs: Record<string, string>;
9
- }
10
- export interface ComparisonDigests {
11
- reference: ComparisonSideDigests;
12
- candidate: ComparisonSideDigests;
13
- harness: string;
14
- model: string;
15
- judge: string;
16
- environment: {
17
- node: string;
18
- platform: string;
19
- arch: string;
20
- [key: string]: string;
21
- };
22
- }
23
- export interface ComparisonThresholds {
24
- max_subject_token_increase?: number;
25
- max_wall_time_increase?: number;
26
- max_tool_call_increase?: number;
27
- }
28
- export interface ComparisonCell {
29
- id: string;
30
- reference: Verdict;
31
- candidate: Verdict;
32
- change: "regression" | "lift" | "held-pass" | "held-fail" | "inconclusive";
33
- critical: boolean;
34
- reference_flakiness: number | null;
35
- candidate_flakiness: number | null;
36
- }
37
- export interface AggregateMetrics {
38
- wall_time_ms: number;
39
- judge_calls: number;
40
- judge_rejudge_calls: number;
41
- subject_metrics_reps: number;
42
- total_reps: number;
43
- input_tokens: number | null;
44
- output_tokens: number | null;
45
- cache_read_tokens: number | null;
46
- cache_write_tokens: number | null;
47
- subject_cost_usd: number | null;
48
- cost_source: "provider-reported" | "subscription" | "unreported" | null;
49
- tool_calls: number | null;
50
- delegated_children: number | null;
51
- max_concurrency: number | null;
52
- }
53
- export interface ComparisonReport {
54
- schema: 1;
55
- skill: string;
56
- model: string;
57
- mode: string;
58
- reps: number;
59
- judge: string;
60
- paired_setup: true;
61
- deterministic_sampling: false;
62
- sampling_note: string;
63
- partial: boolean;
64
- release_eligible: boolean;
65
- ship: boolean;
66
- note: string;
67
- cells: ComparisonCell[];
68
- behavioral_regressions: string[];
69
- critical_regressions: string[];
70
- infrastructure_errors: string[];
71
- metrics: {
72
- reference: AggregateMetrics;
73
- candidate: AggregateMetrics;
74
- };
75
- cost_regressions: string[];
76
- thresholds?: ComparisonThresholds;
77
- digests: ComparisonDigests;
78
- reference: ResultsFile;
79
- candidate: ResultsFile;
80
- }
81
- export declare function buildComparison(input: {
82
- skill: string;
83
- model: string;
84
- mode: string;
85
- reps: number;
86
- judge: string;
87
- reference: ResultsFile;
88
- candidate: ResultsFile;
89
- critical: string[];
90
- digests: ComparisonDigests;
91
- partial?: boolean;
92
- thresholds?: ComparisonThresholds;
93
- }): ComparisonReport;
94
- /** 2 = critical behavioral regression, 1 = ordinary/ship/infra regression, 0 = clean or partial feedback. */
95
- export declare function comparisonExitCode(report: ComparisonReport): 0 | 1 | 2;
96
- export declare function formatComparison(report: ComparisonReport): string;
97
- export declare function aggregateMetrics(scenarios: ScenarioResult[]): AggregateMetrics;
@@ -1,189 +0,0 @@
1
- export function buildComparison(input) {
2
- const { reference, candidate } = input;
3
- if (reference.model !== candidate.model || reference.model !== input.model)
4
- throw new Error("comparison subject models differ");
5
- if (reference.mode !== candidate.mode || reference.mode !== input.mode)
6
- throw new Error("comparison delivery modes differ");
7
- const referenceJudge = `${reference.judge.provider}:${reference.judge.model}`;
8
- const candidateJudge = `${candidate.judge.provider}:${candidate.judge.model}`;
9
- if (referenceJudge !== candidateJudge || referenceJudge !== input.judge)
10
- throw new Error("comparison judge policies differ");
11
- const referenceIds = reference.scenarios.map((scenario) => scenario.id);
12
- const candidateIds = candidate.scenarios.map((scenario) => scenario.id);
13
- if (JSON.stringify([...referenceIds].sort()) !== JSON.stringify([...candidateIds].sort())) {
14
- throw new Error("comparison scenario IDs differ — paired comparisons require the same scenario plan");
15
- }
16
- if (input.digests.reference.spec !== input.digests.candidate.spec ||
17
- JSON.stringify(sorted(input.digests.reference.fixtures)) !== JSON.stringify(sorted(input.digests.candidate.fixtures)) ||
18
- JSON.stringify(sorted(input.digests.reference.inputs)) !== JSON.stringify(sorted(input.digests.candidate.inputs))) {
19
- throw new Error("comparison spec/test-input digests differ — only the skill candidate may change in a paired run");
20
- }
21
- const referencePlan = Object.fromEntries(reference.scenarios.map((scenario) => [scenario.id, [scenario.reps ?? 1, scenario.pass_threshold ?? null]]));
22
- const candidatePlan = Object.fromEntries(candidate.scenarios.map((scenario) => [scenario.id, [scenario.reps ?? 1, scenario.pass_threshold ?? null]]));
23
- if (JSON.stringify(sorted(referencePlan)) !== JSON.stringify(sorted(candidatePlan))) {
24
- throw new Error("comparison repetition/threshold plans differ");
25
- }
26
- const critical = new Set(input.critical);
27
- const byCandidate = new Map(candidate.scenarios.map((scenario) => [scenario.id, scenario]));
28
- const cells = reference.scenarios.map((left) => {
29
- const right = byCandidate.get(left.id);
30
- const a = effective(left), b = effective(right);
31
- const conclusive = !untrustworthy(left, a) && !untrustworthy(right, b);
32
- const change = !conclusive
33
- ? "inconclusive"
34
- : a === "PASS" && b !== "PASS"
35
- ? "regression"
36
- : a !== "PASS" && b === "PASS"
37
- ? "lift"
38
- : a === "PASS"
39
- ? "held-pass"
40
- : "held-fail";
41
- return {
42
- id: left.id,
43
- reference: a,
44
- candidate: b,
45
- change,
46
- critical: critical.has(left.id),
47
- reference_flakiness: left.flakiness ?? null,
48
- candidate_flakiness: right.flakiness ?? null,
49
- };
50
- });
51
- const behavioral = cells.filter((cell) => cell.change === "regression").map((cell) => cell.id);
52
- const criticalRegressions = cells.filter((cell) => cell.change === "regression" && cell.critical).map((cell) => cell.id);
53
- const byReference = new Map(reference.scenarios.map((scenario) => [scenario.id, scenario]));
54
- const infrastructureErrors = cells.filter((cell) => cell.reference === "ERROR" || cell.reference === "NOT-MEASURED" || cell.reference === "JUDGE-AMBIGUOUS" || byReference.get(cell.id)?.suspect ||
55
- cell.candidate === "ERROR" || cell.candidate === "NOT-MEASURED" || cell.candidate === "JUDGE-AMBIGUOUS" || byCandidate.get(cell.id)?.suspect).map((cell) => cell.id);
56
- const metrics = { reference: aggregateMetrics(reference.scenarios), candidate: aggregateMetrics(candidate.scenarios) };
57
- const costRegressions = thresholdFindings(metrics.reference, metrics.candidate, input.thresholds);
58
- const partial = input.partial === true || reference.partial === true || candidate.partial === true;
59
- const releaseEligible = !partial;
60
- const ship = releaseEligible && candidate.effective_grade.ship && behavioral.length === 0 && infrastructureErrors.length === 0 && costRegressions.length === 0;
61
- const note = partial
62
- ? "branch feedback only — partial/affected comparisons never SHIP; run the full paired plan for a release decision"
63
- : ship
64
- ? "candidate clears its ship bar with no paired behavioral regression"
65
- : "candidate is NOT READY under the paired behavioral/cost release policy";
66
- return {
67
- schema: 1,
68
- skill: input.skill,
69
- model: input.model,
70
- mode: input.mode,
71
- reps: input.reps,
72
- judge: input.judge,
73
- paired_setup: true,
74
- deterministic_sampling: false,
75
- sampling_note: "paired setup only: prompts/fixtures/models/policy/repetition plan match; provider-deterministic seeding is not claimed",
76
- partial,
77
- release_eligible: releaseEligible,
78
- ship,
79
- note,
80
- cells,
81
- behavioral_regressions: behavioral,
82
- critical_regressions: criticalRegressions,
83
- infrastructure_errors: [...new Set(infrastructureErrors)],
84
- metrics,
85
- cost_regressions: costRegressions,
86
- ...(input.thresholds ? { thresholds: input.thresholds } : {}),
87
- digests: input.digests,
88
- reference,
89
- candidate,
90
- };
91
- }
92
- /** 2 = critical behavioral regression, 1 = ordinary/ship/infra regression, 0 = clean or partial feedback. */
93
- export function comparisonExitCode(report) {
94
- if (report.critical_regressions.length)
95
- return 2;
96
- if (report.behavioral_regressions.length || report.infrastructure_errors.length || report.cost_regressions.length || (report.release_eligible && !report.ship))
97
- return 1;
98
- return 0;
99
- }
100
- export function formatComparison(report) {
101
- const lines = [
102
- `── compare ${report.skill} · ${report.model} · mode=${report.mode} ×${report.reps} ──`,
103
- " sampling: paired setup; provider-deterministic seeding NOT claimed",
104
- ];
105
- for (const cell of report.cells) {
106
- const mark = cell.change === "regression" ? "↓" : cell.change === "lift" ? "↑" : cell.change === "inconclusive" ? "?" : "=";
107
- const variance = cell.reference_flakiness === null && cell.candidate_flakiness === null
108
- ? ""
109
- : ` · flakiness ${cell.reference_flakiness ?? "n/a"} → ${cell.candidate_flakiness ?? "n/a"}`;
110
- lines.push(` ${mark} ${cell.id}${cell.critical ? " CRITICAL" : ""}: ${cell.reference} → ${cell.candidate} (${cell.change})${variance}`);
111
- }
112
- lines.push(` behavior: ${report.behavioral_regressions.length} regression(s), ${report.cells.filter((cell) => cell.change === "lift").length} lift(s), ${report.infrastructure_errors.length} infrastructure/inconclusive error(s)`);
113
- lines.push(` subject tokens: input ${metricDelta(report.metrics.reference.input_tokens, report.metrics.candidate.input_tokens)} · output ${metricDelta(report.metrics.reference.output_tokens, report.metrics.candidate.output_tokens)} · cache-read ${metricDelta(report.metrics.reference.cache_read_tokens, report.metrics.candidate.cache_read_tokens)} (${report.metrics.candidate.subject_metrics_reps}/${report.metrics.candidate.total_reps} candidate reps reported)`);
114
- lines.push(` judge calls: ${report.metrics.reference.judge_calls} → ${report.metrics.candidate.judge_calls} (re-judge ${report.metrics.reference.judge_rejudge_calls} → ${report.metrics.candidate.judge_rejudge_calls})`);
115
- lines.push(` wall time: ${report.metrics.reference.wall_time_ms}ms → ${report.metrics.candidate.wall_time_ms}ms`);
116
- lines.push(` tool calls: ${metricDelta(report.metrics.reference.tool_calls, report.metrics.candidate.tool_calls)}`);
117
- if (report.cost_regressions.length)
118
- lines.push(` cost/latency thresholds (separate from behavior): ${report.cost_regressions.join("; ")}`);
119
- lines.push(` ${report.ship ? "SHIP" : "NOT READY"}: ${report.note}`);
120
- return lines.join("\n");
121
- }
122
- export function aggregateMetrics(scenarios) {
123
- const metrics = scenarios.map((scenario) => scenario.metrics).filter((value) => value !== undefined);
124
- const sumOptional = (field) => {
125
- const values = metrics.map((value) => value[field]).filter((value) => typeof value === "number");
126
- return values.length ? values.reduce((sum, value) => sum + value, 0) : null;
127
- };
128
- const maxValues = metrics.map((value) => value.max_concurrency).filter((value) => typeof value === "number");
129
- return {
130
- wall_time_ms: metrics.reduce((sum, value) => sum + value.wall_time_ms, 0),
131
- judge_calls: metrics.reduce((sum, value) => sum + value.judge_calls, 0),
132
- judge_rejudge_calls: metrics.reduce((sum, value) => sum + value.judge_rejudge_calls, 0),
133
- subject_metrics_reps: metrics.reduce((sum, value) => sum + value.subject_metrics_reps, 0),
134
- total_reps: metrics.reduce((sum, value) => sum + value.total_reps, 0),
135
- input_tokens: sumOptional("input_tokens"),
136
- output_tokens: sumOptional("output_tokens"),
137
- cache_read_tokens: sumOptional("cache_read_tokens"),
138
- cache_write_tokens: sumOptional("cache_write_tokens"),
139
- subject_cost_usd: sumOptional("subject_cost_usd"),
140
- cost_source: (() => {
141
- const sources = scenarios.map((scenario) => scenario.metrics?.cost_source).filter((source) => source !== undefined);
142
- return sources.length === 0 ? null : sources.every((source) => source === sources[0]) ? sources[0] : "unreported";
143
- })(),
144
- tool_calls: sumOptional("tool_calls"),
145
- delegated_children: sumOptional("delegated_children"),
146
- max_concurrency: maxValues.length ? Math.max(...maxValues) : null,
147
- };
148
- }
149
- function effective(result) {
150
- if (result.override)
151
- return result.override;
152
- if (result.objective?.status === "ERROR")
153
- return "ERROR";
154
- if (result.objective?.status === "NOT-MEASURED")
155
- return "NOT-MEASURED";
156
- if (result.objective?.status === "FAIL")
157
- return "FAIL";
158
- return result.judge_verdict;
159
- }
160
- function untrustworthy(result, verdict) { return result.suspect || verdict === "ERROR" || verdict === "NOT-MEASURED" || verdict === "JUDGE-AMBIGUOUS"; }
161
- function sorted(value) { return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))); }
162
- function increase(reference, candidate) { return reference === null || candidate === null || reference <= 0 ? null : (candidate - reference) / reference; }
163
- function thresholdFindings(reference, candidate, thresholds) {
164
- if (!thresholds)
165
- return [];
166
- const out = [];
167
- const check = (label, left, right, max) => {
168
- if (max === undefined)
169
- return;
170
- if (left === null || right === null) {
171
- out.push(`${label} unavailable/incomplete — cannot evaluate configured ${(max * 100).toFixed(1)}% maximum`);
172
- return;
173
- }
174
- if (left === 0) {
175
- if (right > 0)
176
- out.push(`${label} increased from 0 to ${right}, exceeding the configured maximum`);
177
- return;
178
- }
179
- const delta = increase(left, right);
180
- if (delta > max)
181
- out.push(`${label} +${(delta * 100).toFixed(1)}% exceeds ${(max * 100).toFixed(1)}%`);
182
- };
183
- check("subject tokens", reference.input_tokens, candidate.input_tokens, thresholds.max_subject_token_increase);
184
- check("wall time", reference.wall_time_ms, candidate.wall_time_ms, thresholds.max_wall_time_increase);
185
- check("tool calls", reference.tool_calls, candidate.tool_calls, thresholds.max_tool_call_increase);
186
- return out;
187
- }
188
- function metricDelta(reference, candidate) { return reference === null || candidate === null ? "unavailable" : `${reference} → ${candidate}`; }
189
- //# sourceMappingURL=comparison.js.map