@skill-harness/core 0.8.0 → 0.10.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.
@@ -1,4 +1,6 @@
1
1
  import type { ExecutionTraceV1 } from "../capture-trace-types.js";
2
+ import type { TrajectoryEventSource } from "../spec.js";
3
+ import type { TrajectoryEventV1 } from "../trajectory-gates.js";
2
4
  export type RunMode = "red" | "green" | "force";
3
5
  /** A provider+model pair, e.g. { provider: "fireworks", model: "accounts/.../deepseek-v4-pro" }. */
4
6
  export interface ModelRef {
@@ -40,6 +42,8 @@ export interface RunReq {
40
42
  * extension the developer happens to have installed cannot join the test.
41
43
  */
42
44
  extensions?: string[];
45
+ /** Workspace-local native ledgers to normalize after the subject finishes. */
46
+ eventSources?: TrajectoryEventSource[];
43
47
  }
44
48
  /** A judge request: single prompt, no skills, no session. */
45
49
  export interface JudgeReq {
@@ -52,6 +56,10 @@ export interface StructuredRun {
52
56
  transcript: string;
53
57
  /** One trace per turn — each `pi` invocation emits an independent event stream. */
54
58
  traces: ExecutionTraceV1[];
59
+ /** Adapter-neutral workflow/tool events. Additive; absent on older adapters. */
60
+ events?: TrajectoryEventV1[];
61
+ /** Native sources that were required but missing/malformed. Never treated as an empty success. */
62
+ eventErrors?: string[];
55
63
  }
56
64
  export interface HarnessAdapter {
57
65
  name: string;
@@ -103,6 +103,7 @@ export declare function collapseJudgments(judgments: Judgment[], trigger: Trigge
103
103
  * resolving this needs to see what each judge actually said, not just the
104
104
  * collapsed answer.
105
105
  */
106
+ export declare function boundAdjudicationToRepetitions(result: ScenarioResult, scenario: Scenario, adj: AdjudicationResult): AdjudicationResult;
106
107
  export declare function projectAdjudication(result: ScenarioResult, adj: AdjudicationResult): ScenarioResult;
107
108
  /** Ask one judge about one cell's saved transcript. Supplied by the caller. */
108
109
  export type RejudgeFn = (id: string, judge: ModelRef) => Promise<{
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { judgeRawPath, writeResults, scoreContextFor, findTranscriptFiles, repIndexOf, rebuildScenarioResult, } from "./results.js";
3
+ import { judgeRawPath, writeResults, scoreContextFor, findTranscriptFiles, repIndexOf, rebuildScenarioResult, mergeScenarioMetrics, effectiveThreshold, } from "./results.js";
4
4
  import { score } from "./score.js";
5
5
  import { buildJudgePrompt, judgeInWorkspace, parseVerdict } from "./grade.js";
6
6
  import { appendJournal } from "./journal.js";
@@ -119,6 +119,14 @@ export function collapseJudgments(judgments, trigger) {
119
119
  * resolving this needs to see what each judge actually said, not just the
120
120
  * collapsed answer.
121
121
  */
122
+ export function boundAdjudicationToRepetitions(result, scenario, adj) {
123
+ const criticalAggregate = (result.reps ?? 1) > 1 && effectiveThreshold(result, scenario) === 1;
124
+ // A cell-level re-judge reads one transcript. It can confirm the aggregate,
125
+ // but cannot erase another clean repetition's failure.
126
+ return criticalAggregate && result.judge_verdict !== "PASS" && adj.verdict === "PASS"
127
+ ? { ...adj, state: "unresolved", verdict: undefined }
128
+ : adj;
129
+ }
122
130
  export function projectAdjudication(result, adj) {
123
131
  if (adj.state === "unresolved") {
124
132
  return {
@@ -231,9 +239,23 @@ export async function adjudicateRun(opts) {
231
239
  // Adjudication asks judges again and re-measures nothing else, so the run's
232
240
  // objective evidence carries. Overrides survive too — a judge panel does not
233
241
  // outvote the author.
234
- return adj
235
- ? rebuildScenarioResult(projectAdjudication(s, adj), s, { objective: "carry", adjudication: "fresh" })
236
- : s;
242
+ if (!adj)
243
+ return s;
244
+ const scenario = byIdScenario.get(s.id);
245
+ const boundedAdj = scenario ? boundAdjudicationToRepetitions(s, scenario, adj) : adj;
246
+ const projected = projectAdjudication(s, boundedAdj);
247
+ if (boundedAdj !== adj) {
248
+ projected.judge_reason = `${adj.judgments.length} judgments on one transcript cannot replace a critical all-repetitions aggregate`;
249
+ }
250
+ const extraCalls = adj.judgments.filter((judgment) => judgment.ordinal > 1).length;
251
+ projected.metrics = mergeScenarioMetrics(s.metrics, {
252
+ wall_time_ms: 0,
253
+ judge_calls: extraCalls,
254
+ judge_rejudge_calls: extraCalls,
255
+ subject_metrics_reps: 0,
256
+ total_reps: s.metrics?.total_reps ?? s.reps ?? 1,
257
+ });
258
+ return rebuildScenarioResult(projected, s, { objective: "carry", adjudication: "fresh" });
237
259
  });
238
260
  appendJournal(opts.runDir, {
239
261
  event: "adjudication",
@@ -41,8 +41,12 @@ export interface TraceToolCall {
41
41
  args: SanitizedArgs;
42
42
  /** 0-based position in ISSUE order (the order pi emitted `tool_execution_start`). */
43
43
  issueIndex: number;
44
+ /** Wall-clock issue time correlated from pi's assistant tool-call message, when available. */
45
+ started_at?: string;
44
46
  /** 0-based position in COMPLETION order. Differs from `issueIndex` under parallelism. */
45
47
  completionIndex: number;
48
+ /** Wall-clock completion time correlated from pi's tool-result message, when available. */
49
+ completed_at?: string;
46
50
  /** pi's `isError` on the `tool_execution_end` event. */
47
51
  isError: boolean;
48
52
  /**
@@ -79,6 +83,17 @@ export interface TraceResultMeta {
79
83
  * conversation carries in the session dir, not in the event stream). This
80
84
  * mirrors the loop the adapter already runs.
81
85
  */
86
+ export interface TraceMetrics {
87
+ input_tokens: number;
88
+ output_tokens: number;
89
+ cache_read_tokens: number;
90
+ cache_write_tokens: number;
91
+ cost_usd: number;
92
+ tool_calls: number;
93
+ delegated_children: number;
94
+ /** Maximum simultaneously outstanding tool calls observed in the pi stream. */
95
+ max_concurrency: number;
96
+ }
82
97
  export interface ExecutionTraceV1 {
83
98
  trace_version: typeof EXECUTION_TRACE_VERSION;
84
99
  /** `pi --version` at capture time. Null when it could not be determined. */
@@ -127,6 +142,10 @@ export interface ExecutionTraceV1 {
127
142
  * disclosure, never for grading.
128
143
  */
129
144
  cost_usd: number | null;
145
+ /** Capture defects that make absence-based assertions unsafe (for example malformed native JSONL). */
146
+ capture_errors?: string[];
147
+ /** Token/tool/concurrency counters reported or directly observed by the adapter. */
148
+ metrics?: TraceMetrics;
130
149
  /** SHA-256 over the deterministic serialization of this trace, minus this field. */
131
150
  trace_sha256?: string;
132
151
  }
@@ -0,0 +1,96 @@
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
+ tool_calls: number | null;
49
+ delegated_children: number | null;
50
+ max_concurrency: number | null;
51
+ }
52
+ export interface ComparisonReport {
53
+ schema: 1;
54
+ skill: string;
55
+ model: string;
56
+ mode: string;
57
+ reps: number;
58
+ judge: string;
59
+ paired_setup: true;
60
+ deterministic_sampling: false;
61
+ sampling_note: string;
62
+ partial: boolean;
63
+ release_eligible: boolean;
64
+ ship: boolean;
65
+ note: string;
66
+ cells: ComparisonCell[];
67
+ behavioral_regressions: string[];
68
+ critical_regressions: string[];
69
+ infrastructure_errors: string[];
70
+ metrics: {
71
+ reference: AggregateMetrics;
72
+ candidate: AggregateMetrics;
73
+ };
74
+ cost_regressions: string[];
75
+ thresholds?: ComparisonThresholds;
76
+ digests: ComparisonDigests;
77
+ reference: ResultsFile;
78
+ candidate: ResultsFile;
79
+ }
80
+ export declare function buildComparison(input: {
81
+ skill: string;
82
+ model: string;
83
+ mode: string;
84
+ reps: number;
85
+ judge: string;
86
+ reference: ResultsFile;
87
+ candidate: ResultsFile;
88
+ critical: string[];
89
+ digests: ComparisonDigests;
90
+ partial?: boolean;
91
+ thresholds?: ComparisonThresholds;
92
+ }): ComparisonReport;
93
+ /** 2 = critical behavioral regression, 1 = ordinary/ship/infra regression, 0 = clean or partial feedback. */
94
+ export declare function comparisonExitCode(report: ComparisonReport): 0 | 1 | 2;
95
+ export declare function formatComparison(report: ComparisonReport): string;
96
+ export declare function aggregateMetrics(scenarios: ScenarioResult[]): AggregateMetrics;
@@ -0,0 +1,183 @@
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 === "JUDGE-AMBIGUOUS" || byReference.get(cell.id)?.suspect ||
55
+ cell.candidate === "ERROR" || 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
+ tool_calls: sumOptional("tool_calls"),
141
+ delegated_children: sumOptional("delegated_children"),
142
+ max_concurrency: maxValues.length ? Math.max(...maxValues) : null,
143
+ };
144
+ }
145
+ function effective(result) {
146
+ if (result.override)
147
+ return result.override;
148
+ if (result.objective?.status === "ERROR")
149
+ return "ERROR";
150
+ if (result.objective?.status === "FAIL")
151
+ return "FAIL";
152
+ return result.judge_verdict;
153
+ }
154
+ function untrustworthy(result, verdict) { return result.suspect || verdict === "ERROR" || verdict === "JUDGE-AMBIGUOUS"; }
155
+ function sorted(value) { return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))); }
156
+ function increase(reference, candidate) { return reference === null || candidate === null || reference <= 0 ? null : (candidate - reference) / reference; }
157
+ function thresholdFindings(reference, candidate, thresholds) {
158
+ if (!thresholds)
159
+ return [];
160
+ const out = [];
161
+ const check = (label, left, right, max) => {
162
+ if (max === undefined)
163
+ return;
164
+ if (left === null || right === null) {
165
+ out.push(`${label} unavailable/incomplete — cannot evaluate configured ${(max * 100).toFixed(1)}% maximum`);
166
+ return;
167
+ }
168
+ if (left === 0) {
169
+ if (right > 0)
170
+ out.push(`${label} increased from 0 to ${right}, exceeding the configured maximum`);
171
+ return;
172
+ }
173
+ const delta = increase(left, right);
174
+ if (delta > max)
175
+ out.push(`${label} +${(delta * 100).toFixed(1)}% exceeds ${(max * 100).toFixed(1)}%`);
176
+ };
177
+ check("subject tokens", reference.input_tokens, candidate.input_tokens, thresholds.max_subject_token_increase);
178
+ check("wall time", reference.wall_time_ms, candidate.wall_time_ms, thresholds.max_wall_time_increase);
179
+ check("tool calls", reference.tool_calls, candidate.tool_calls, thresholds.max_tool_call_increase);
180
+ return out;
181
+ }
182
+ function metricDelta(reference, candidate) { return reference === null || candidate === null ? "unavailable" : `${reference} → ${candidate}`; }
183
+ //# sourceMappingURL=comparison.js.map
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { normalizeSubagentCall } from "./trace-gates.js";
2
3
  import { EXECUTION_TRACE_VERSION } from "./capture-trace-types.js";
3
4
  import { redactArgs, redactText } from "./capture.js";
4
5
  /**
@@ -34,6 +35,8 @@ const MAX_DETAILS_CHARS = 2000;
34
35
  */
35
36
  export function parseTrace(lines, meta) {
36
37
  const calls = new Map();
38
+ const issuedAt = new Map();
39
+ const completedAt = new Map();
37
40
  let issueCounter = 0;
38
41
  let completionCounter = 0;
39
42
  let malformedLines = 0;
@@ -41,6 +44,13 @@ export function parseTrace(lines, meta) {
41
44
  let finalText = "";
42
45
  let lastAssistantText = "";
43
46
  let cost = null;
47
+ let inputTokens = 0;
48
+ let outputTokens = 0;
49
+ let cacheReadTokens = 0;
50
+ let cacheWriteTokens = 0;
51
+ let sawUsage = false;
52
+ let activeCalls = 0;
53
+ let maxConcurrency = 0;
44
54
  for (const line of lines) {
45
55
  const trimmed = line.trim();
46
56
  if (!trimmed)
@@ -65,10 +75,13 @@ export function parseTrace(lines, meta) {
65
75
  name: str(ev.toolName) ?? "(unknown)",
66
76
  args: redactArgs(ev.args, meta.homeDir),
67
77
  issueIndex: issueCounter++,
78
+ ...(issuedAt.get(id) ? { started_at: issuedAt.get(id) } : {}),
68
79
  completionIndex: -1, // filled in on `end`; -1 means it never completed
69
80
  isError: false,
70
81
  result: { bytes: 0, sha256: sha256("") },
71
82
  });
83
+ activeCalls++;
84
+ maxConcurrency = Math.max(maxConcurrency, activeCalls);
72
85
  continue;
73
86
  }
74
87
  if (type === "tool_execution_end") {
@@ -78,7 +91,11 @@ export function parseTrace(lines, meta) {
78
91
  const call = calls.get(id);
79
92
  if (!call)
80
93
  continue; // an end with no start is not evidence of a call
94
+ if (call.completionIndex < 0)
95
+ activeCalls = Math.max(0, activeCalls - 1);
81
96
  call.completionIndex = completionCounter++;
97
+ if (completedAt.get(id))
98
+ call.completed_at = completedAt.get(id);
82
99
  call.isError = ev.isError === true;
83
100
  call.result = resultMeta(ev.result, meta.homeDir);
84
101
  continue;
@@ -86,6 +103,23 @@ export function parseTrace(lines, meta) {
86
103
  if (type === "message_end") {
87
104
  sawTerminal = true;
88
105
  const msg = ev.message;
106
+ const at = isoTime(msg?.timestamp);
107
+ if (msg?.role === "assistant" && at) {
108
+ for (const block of msg.content ?? []) {
109
+ if (block.type !== "toolCall" || typeof block.id !== "string")
110
+ continue;
111
+ issuedAt.set(block.id, at);
112
+ const call = calls.get(block.id);
113
+ if (call)
114
+ call.started_at = at;
115
+ }
116
+ }
117
+ if (msg?.role === "toolResult" && typeof msg.toolCallId === "string" && at) {
118
+ completedAt.set(msg.toolCallId, at);
119
+ const call = calls.get(msg.toolCallId);
120
+ if (call)
121
+ call.completed_at = at;
122
+ }
89
123
  if (msg?.role !== "assistant")
90
124
  continue;
91
125
  const text = assistantText(msg);
@@ -96,9 +130,21 @@ export function parseTrace(lines, meta) {
96
130
  if (msg.stopReason === "stop")
97
131
  finalText = text;
98
132
  }
133
+ if (msg.usage && (typeof msg.usage.input === "number" || typeof msg.usage.output === "number" ||
134
+ typeof msg.usage.cacheRead === "number" || typeof msg.usage.cacheWrite === "number" ||
135
+ typeof msg.usage.cost?.total === "number"))
136
+ sawUsage = true;
99
137
  const total = msg.usage?.cost?.total;
100
138
  if (typeof total === "number")
101
139
  cost = (cost ?? 0) + total;
140
+ if (typeof msg.usage?.input === "number")
141
+ inputTokens += msg.usage.input;
142
+ if (typeof msg.usage?.output === "number")
143
+ outputTokens += msg.usage.output;
144
+ if (typeof msg.usage?.cacheRead === "number")
145
+ cacheReadTokens += msg.usage.cacheRead;
146
+ if (typeof msg.usage?.cacheWrite === "number")
147
+ cacheWriteTokens += msg.usage.cacheWrite;
102
148
  continue;
103
149
  }
104
150
  if (type === "turn_end" || type === "agent_end" || type === "agent_settled") {
@@ -110,6 +156,19 @@ export function parseTrace(lines, meta) {
110
156
  continue;
111
157
  }
112
158
  }
159
+ const toolCalls = [...calls.values()].sort((a, b) => a.issueIndex - b.issueIndex);
160
+ const metrics = {
161
+ input_tokens: inputTokens,
162
+ output_tokens: outputTokens,
163
+ cache_read_tokens: cacheReadTokens,
164
+ cache_write_tokens: cacheWriteTokens,
165
+ cost_usd: cost ?? 0,
166
+ tool_calls: toolCalls.length,
167
+ delegated_children: toolCalls
168
+ .filter((call) => call.name === "Agent")
169
+ .reduce((count, call) => count + normalizeSubagentCall(call.args).length, 0),
170
+ max_concurrency: maxConcurrency,
171
+ };
113
172
  const trace = {
114
173
  trace_version: EXECUTION_TRACE_VERSION,
115
174
  pi_version: meta.piVersion,
@@ -126,12 +185,16 @@ export function parseTrace(lines, meta) {
126
185
  // — an assertion that used to pass only because the smoke model happened not
127
186
  // to echo one.
128
187
  final_text: redactText(finalText || lastAssistantText, meta.homeDir),
129
- tool_calls: [...calls.values()].sort((a, b) => a.issueIndex - b.issueIndex),
188
+ tool_calls: toolCalls,
130
189
  // `null`, not `[]`: the stream says nothing about the filesystem. The runner
131
190
  // overwrites this after observing the workspace. Defaulting to `[]` claimed
132
191
  // "observed, nothing changed" for every trace ever parsed.
133
192
  changed_paths: meta.changedPaths ? [...meta.changedPaths].sort() : null,
134
193
  cost_usd: cost,
194
+ // Tool calls remain in the trace for objective gates. Aggregate usage/cost/
195
+ // tool metrics are published only when pi actually reported usage; otherwise
196
+ // zero would mean "free" instead of "unavailable".
197
+ ...(sawUsage ? { metrics } : {}),
135
198
  };
136
199
  trace.trace_sha256 = traceSha256(trace);
137
200
  return { trace, isComplete: sawTerminal, malformedLines };
@@ -171,6 +234,12 @@ function resultMeta(result, homeDir) {
171
234
  function str(v) {
172
235
  return typeof v === "string" && v.length > 0 ? v : undefined;
173
236
  }
237
+ function isoTime(value) {
238
+ if (typeof value !== "number" && typeof value !== "string")
239
+ return undefined;
240
+ const date = new Date(value);
241
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
242
+ }
174
243
  function sha256(text) {
175
244
  return createHash("sha256").update(text, "utf8").digest("hex");
176
245
  }
@@ -241,7 +310,7 @@ export function mergeTraces(traces) {
241
310
  // without one would be un-regatable — and an adapter is exactly the layer
242
311
  // most likely to forget.
243
312
  const only = traces[0];
244
- return only.trace_sha256 ? only : { ...only, trace_sha256: traceSha256(only) };
313
+ return { ...only, trace_sha256: traceSha256(only) };
245
314
  }
246
315
  const calls = [];
247
316
  const changed = new Set();
@@ -271,15 +340,36 @@ export function mergeTraces(traces) {
271
340
  if (t.cost_usd !== null)
272
341
  cost = (cost ?? 0) + t.cost_usd;
273
342
  }
343
+ const completeMetrics = traces.every((trace) => trace.metrics !== undefined);
344
+ const metrics = completeMetrics
345
+ ? traces.reduce((sum, trace) => ({
346
+ input_tokens: sum.input_tokens + trace.metrics.input_tokens,
347
+ output_tokens: sum.output_tokens + trace.metrics.output_tokens,
348
+ cache_read_tokens: sum.cache_read_tokens + trace.metrics.cache_read_tokens,
349
+ cache_write_tokens: sum.cache_write_tokens + trace.metrics.cache_write_tokens,
350
+ cost_usd: sum.cost_usd + trace.metrics.cost_usd,
351
+ tool_calls: sum.tool_calls + trace.metrics.tool_calls,
352
+ delegated_children: sum.delegated_children + trace.metrics.delegated_children,
353
+ max_concurrency: Math.max(sum.max_concurrency, trace.metrics.max_concurrency),
354
+ }), {
355
+ input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0,
356
+ cost_usd: 0, tool_calls: 0, delegated_children: 0, max_concurrency: 0,
357
+ })
358
+ : undefined;
274
359
  const last = traces[traces.length - 1];
360
+ const captureErrors = traces.flatMap((trace) => trace.capture_errors ?? []);
361
+ const { capture_errors: _lastCaptureErrors, ...lastWithoutCaptureErrors } = last;
362
+ void _lastCaptureErrors;
275
363
  const merged = {
276
- ...last,
364
+ ...lastWithoutCaptureErrors,
277
365
  // The scenario's answer is its LAST turn's answer, matching how the
278
366
  // transcript reads and how the judge is asked to grade it.
279
367
  final_text: last.final_text,
280
368
  tool_calls: calls,
281
369
  changed_paths: anyUnobserved ? null : [...changed].sort(),
282
370
  cost_usd: cost,
371
+ ...(captureErrors.length ? { capture_errors: captureErrors } : {}),
372
+ ...(metrics ? { metrics } : {}),
283
373
  };
284
374
  merged.trace_sha256 = traceSha256(merged);
285
375
  return merged;
package/dist/index.d.ts CHANGED
@@ -33,6 +33,9 @@ export * from "./spec-write.js";
33
33
  export * from "./capture.js";
34
34
  export * from "./execution-trace.js";
35
35
  export * from "./trace-gates.js";
36
+ export * from "./trajectory-gates.js";
36
37
  export * from "./instruction-coverage.js";
37
38
  export * from "./affected.js";
38
39
  export * from "./adjudication.js";
40
+ export * from "./comparison.js";
41
+ export * from "./sandbox.js";
package/dist/index.js CHANGED
@@ -33,7 +33,10 @@ export * from "./spec-write.js";
33
33
  export * from "./capture.js";
34
34
  export * from "./execution-trace.js";
35
35
  export * from "./trace-gates.js";
36
+ export * from "./trajectory-gates.js";
36
37
  export * from "./instruction-coverage.js";
37
38
  export * from "./affected.js";
38
39
  export * from "./adjudication.js";
40
+ export * from "./comparison.js";
41
+ export * from "./sandbox.js";
39
42
  //# sourceMappingURL=index.js.map
package/dist/lint.js CHANGED
@@ -60,6 +60,12 @@ export function lintSkill(skillDir) {
60
60
  findings.push({ skill, code: "consistency", message: stale.replace(/^warning: /, "") });
61
61
  }
62
62
  // ship_bar sanity
63
+ if (!Number.isInteger(spec.ship_bar.total)) {
64
+ findings.push({ skill, code: "ship_bar", message: "ship_bar.total must be an integer" });
65
+ }
66
+ if (!Number.isInteger(spec.ship_bar.min_pass)) {
67
+ findings.push({ skill, code: "ship_bar", message: "ship_bar.min_pass must be an integer" });
68
+ }
63
69
  if (spec.ship_bar.total < 1) {
64
70
  findings.push({ skill, code: "ship_bar", message: "ship_bar.total must be >= 1" });
65
71
  }
package/dist/regate.d.ts CHANGED
@@ -15,7 +15,7 @@ export interface RegateChange {
15
15
  from: Verdict;
16
16
  to: Verdict;
17
17
  /** What the corrected needles say about this scenario now. */
18
- gate: "pass" | "fail";
18
+ gate: "pass" | "fail" | "error";
19
19
  /** Whether re-deciding it required a judge call (only a gate-FAIL → gate-pass flip does). */
20
20
  judged: boolean;
21
21
  }