@skill-harness/core 0.18.0 → 0.20.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.
@@ -81,6 +81,8 @@ export interface StructuredRun {
81
81
  }
82
82
  export interface HarnessAdapter {
83
83
  name: string;
84
+ /** Prefer structured execution for ordinary runs so subject usage is retained. */
85
+ preferStructured?: boolean;
84
86
  available(): Promise<boolean>;
85
87
  run(req: RunReq): Promise<string>;
86
88
  /**
@@ -1,10 +1,8 @@
1
1
  /**
2
- * Data contracts for the pi-native regression capture program.
2
+ * Persisted data contracts for Pi execution traces.
3
3
  *
4
- * Types only — no behavior. Phase 0 of
5
- * `docs/superpowers/plans/2026-08-07-pi-native-regression-capture-program.md`
6
- * fixes the shapes before any parser, capture UI, or gate evaluator is written,
7
- * because both shapes get persisted and a persisted shape is expensive to move.
4
+ * Types only — no behavior. Both shapes are versioned because persisted formats
5
+ * are expensive to move and readers must fail closed on unsupported versions.
8
6
  *
9
7
  * Both carry an explicit version. The pi event stream they derive from is not a
10
8
  * stable public contract (measured against pi 0.83.0; see
@@ -83,14 +81,16 @@ export interface TraceResultMeta {
83
81
  * conversation carries in the session dir, not in the event stream). This
84
82
  * mirrors the loop the adapter already runs.
85
83
  */
86
- export type CostSource = "provider-reported" | "subscription" | "unreported";
84
+ export type CostSource = "provider-reported" | "subscription" | "price-table" | "unreported";
87
85
  export interface TraceMetrics {
88
- input_tokens: number;
89
- output_tokens: number;
90
- cache_read_tokens: number;
91
- cache_write_tokens: number;
92
- cost_usd: number;
86
+ input_tokens: number | null;
87
+ output_tokens: number | null;
88
+ cache_read_tokens: number | null;
89
+ cache_write_tokens: number | null;
90
+ cost_usd: number | null;
93
91
  cost_source: CostSource;
92
+ /** Date of the repository price table used for cost_usd; null when unpriced. */
93
+ price_as_of: string | null;
94
94
  tool_calls: number;
95
95
  delegated_children: number;
96
96
  /** Maximum simultaneously outstanding tool calls observed in the pi stream. */
@@ -1,10 +1,8 @@
1
1
  /**
2
- * Data contracts for the pi-native regression capture program.
2
+ * Persisted data contracts for Pi execution traces.
3
3
  *
4
- * Types only — no behavior. Phase 0 of
5
- * `docs/superpowers/plans/2026-08-07-pi-native-regression-capture-program.md`
6
- * fixes the shapes before any parser, capture UI, or gate evaluator is written,
7
- * because both shapes get persisted and a persisted shape is expensive to move.
4
+ * Types only — no behavior. Both shapes are versioned because persisted formats
5
+ * are expensive to move and readers must fail closed on unsupported versions.
8
6
  *
9
7
  * Both carry an explicit version. The pi event stream they derive from is not a
10
8
  * stable public contract (measured against pi 0.83.0; see
@@ -1,30 +1,35 @@
1
+ import { type ModelRef } from "./adapters/types.js";
1
2
  /**
2
- * The judge used when nothing else says otherwise: Opus through the **`claude-code`
3
- * provider**, which authenticates with the user's Claude subscription (OAuth via
4
- * `claude -p`) rather than a metered API key.
3
+ * The judge used when nothing else says otherwise: a Pi `openai-codex` model,
4
+ * authenticated through the user's ChatGPT subscription rather than a metered API
5
+ * key. Subjects and judges now share the sole supported harness executable: `pi`.
5
6
  *
6
- * The model is deliberately the strongest available — judging is the one place
7
- * where a weak model silently corrupts every number in a scorecard. The *provider*
8
- * is what changed in 0.3.3: the default was `anthropic:claude-opus-4-8`, a metered
9
- * API, and it billed a corpus once by accident because nothing in the tool surface
10
- * distinguishes "the flag I forgot" from "the flag I meant". A default must not be
11
- * able to spend money that was not asked for. The metered path is still one flag
12
- * away (`--judge anthropic:claude-opus-4-8`) for anyone who wants it — an API key
13
- * scales past a subscription's rate limits, which matters for a large `--reps` run.
7
+ * A default must not be able to spend money that was not explicitly authorized.
8
+ * Direct API providers remain available only through the metered-judge opt-in.
14
9
  */
15
- export declare const BAKED_DEFAULT_JUDGE = "claude-code:claude-opus-4-8";
10
+ export declare const BAKED_DEFAULT_JUDGE = "openai-codex:gpt-5.6-sol";
16
11
  /**
17
12
  * Resolve the default judge: `SKILL_HARNESS_JUDGE` if set, else the baked value.
18
13
  * An explicit `--judge` always wins over both — this is only the default.
19
14
  *
20
15
  * The env layer exists because this harness is built for someone who steers a
21
16
  * process rather than typing every flag: judge policy belongs to the repo or the
22
- * shell, set once. It is also how you opt *into* the metered API deliberately
23
- * (`SKILL_HARNESS_JUDGE=anthropic:claude-opus-4-8`) instead of by forgetting a
24
- * flag. Read through `readEnv`, so the pre-rename `SKILL_CHECK_JUDGE` keeps working
17
+ * shell, set once. It is also how you opt *into* a different judge deliberately
18
+ * instead of repeating a flag. Read through `readEnv`, so the pre-rename
19
+ * `SKILL_CHECK_JUDGE` keeps working
25
20
  * with the usual one-time notice.
26
21
  *
27
22
  * Resolved per call, not at module load: tests and long-lived processes (the pi
28
23
  * extension) must see an env change without a reload.
29
24
  */
30
25
  export declare function defaultJudge(): string;
26
+ /**
27
+ * Choose a judge for an artifact-only operation when the caller supplied no
28
+ * explicit override. Retained results may name the removed direct Claude
29
+ * adapter; keep those files readable, but execute their next judge call through
30
+ * today's Pi default and let the rewrite record that current judge.
31
+ */
32
+ export declare function recordedJudgeOrDefault(recorded?: ModelRef): {
33
+ judge: ModelRef;
34
+ migratedFrom?: ModelRef;
35
+ };
package/dist/defaults.js CHANGED
@@ -1,28 +1,23 @@
1
+ import { parseModelRef } from "./adapters/types.js";
1
2
  import { readEnv } from "./util/env.js";
2
3
  /**
3
- * The judge used when nothing else says otherwise: Opus through the **`claude-code`
4
- * provider**, which authenticates with the user's Claude subscription (OAuth via
5
- * `claude -p`) rather than a metered API key.
4
+ * The judge used when nothing else says otherwise: a Pi `openai-codex` model,
5
+ * authenticated through the user's ChatGPT subscription rather than a metered API
6
+ * key. Subjects and judges now share the sole supported harness executable: `pi`.
6
7
  *
7
- * The model is deliberately the strongest available — judging is the one place
8
- * where a weak model silently corrupts every number in a scorecard. The *provider*
9
- * is what changed in 0.3.3: the default was `anthropic:claude-opus-4-8`, a metered
10
- * API, and it billed a corpus once by accident because nothing in the tool surface
11
- * distinguishes "the flag I forgot" from "the flag I meant". A default must not be
12
- * able to spend money that was not asked for. The metered path is still one flag
13
- * away (`--judge anthropic:claude-opus-4-8`) for anyone who wants it — an API key
14
- * scales past a subscription's rate limits, which matters for a large `--reps` run.
8
+ * A default must not be able to spend money that was not explicitly authorized.
9
+ * Direct API providers remain available only through the metered-judge opt-in.
15
10
  */
16
- export const BAKED_DEFAULT_JUDGE = "claude-code:claude-opus-4-8";
11
+ export const BAKED_DEFAULT_JUDGE = "openai-codex:gpt-5.6-sol";
17
12
  /**
18
13
  * Resolve the default judge: `SKILL_HARNESS_JUDGE` if set, else the baked value.
19
14
  * An explicit `--judge` always wins over both — this is only the default.
20
15
  *
21
16
  * The env layer exists because this harness is built for someone who steers a
22
17
  * process rather than typing every flag: judge policy belongs to the repo or the
23
- * shell, set once. It is also how you opt *into* the metered API deliberately
24
- * (`SKILL_HARNESS_JUDGE=anthropic:claude-opus-4-8`) instead of by forgetting a
25
- * flag. Read through `readEnv`, so the pre-rename `SKILL_CHECK_JUDGE` keeps working
18
+ * shell, set once. It is also how you opt *into* a different judge deliberately
19
+ * instead of repeating a flag. Read through `readEnv`, so the pre-rename
20
+ * `SKILL_CHECK_JUDGE` keeps working
26
21
  * with the usual one-time notice.
27
22
  *
28
23
  * Resolved per call, not at module load: tests and long-lived processes (the pi
@@ -31,4 +26,17 @@ export const BAKED_DEFAULT_JUDGE = "claude-code:claude-opus-4-8";
31
26
  export function defaultJudge() {
32
27
  return readEnv("JUDGE") ?? BAKED_DEFAULT_JUDGE;
33
28
  }
29
+ /**
30
+ * Choose a judge for an artifact-only operation when the caller supplied no
31
+ * explicit override. Retained results may name the removed direct Claude
32
+ * adapter; keep those files readable, but execute their next judge call through
33
+ * today's Pi default and let the rewrite record that current judge.
34
+ */
35
+ export function recordedJudgeOrDefault(recorded) {
36
+ if (!recorded)
37
+ return { judge: parseModelRef(defaultJudge()) };
38
+ if (recorded.provider !== "claude-code")
39
+ return { judge: recorded };
40
+ return { judge: parseModelRef(defaultJudge()), migratedFrom: recorded };
41
+ }
34
42
  //# sourceMappingURL=defaults.js.map
@@ -40,22 +40,6 @@ export declare function traceSha256(trace: ExecutionTraceV1): string;
40
40
  export declare function serializeTrace(trace: ExecutionTraceV1): string;
41
41
  /** Read a saved trace artifact back, for `regate`. Returns null when unusable. */
42
42
  export declare function deserializeTrace(text: string): ExecutionTraceV1 | null;
43
- /**
44
- * Collapse a scenario's per-turn traces into one view for gate evaluation.
45
- *
46
- * Assertions are written about the scenario ("it delegated to `plan` at least
47
- * once"), not about turn 3 — a multi-turn scenario would otherwise need the
48
- * author to know which turn a tool call landed in, which is a property of the
49
- * model's choices, not of the test.
50
- *
51
- * Indices are renumbered across the whole scenario so `issueIndex` stays a total
52
- * order. `completionIndex` is renumbered within the concatenation too: turns are
53
- * strictly sequential (each is a separate `pi` invocation), so no completion in
54
- * turn 2 can precede one in turn 1.
55
- *
56
- * Returns null for an empty list — "no turns produced evidence" must not look
57
- * like "a run in which nothing happened".
58
- */
59
43
  export declare function mergeTraces(traces: ExecutionTraceV1[]): ExecutionTraceV1 | null;
60
44
  /** Split a raw stdout blob into lines. Prefer streaming; this is for saved blobs. */
61
45
  export declare function lines(text: string): Generator<string>;
@@ -5,9 +5,8 @@ import { redactArgs, redactText } from "./redaction.js";
5
5
  /**
6
6
  * Parse pi's `--mode json` event stream into an `ExecutionTraceV1`.
7
7
  *
8
- * Measured against pi 0.83.0; see `docs/pi-native-capture-design-2026-08-08.md`
9
- * and the fixtures in `packages/adapters/test/fixtures/pi-json/`. Three findings
10
- * from that spike are baked in here and each one is a bug if removed:
8
+ * Pinned by the fixtures in `packages/adapters/test/fixtures/pi-json/`. Three
9
+ * event-shape constraints are baked in here and each one is a bug if removed:
11
10
  *
12
11
  * 1. **Line-at-a-time, never buffered.** `message_update` re-sends the entire
13
12
  * accumulated message on every delta, so the stream is quadratic in output
@@ -43,12 +42,10 @@ export function parseTrace(lines, meta) {
43
42
  let sawTerminal = false;
44
43
  let finalText = "";
45
44
  let lastAssistantText = "";
46
- let cost = null;
47
- let inputTokens = 0;
48
- let outputTokens = 0;
49
- let cacheReadTokens = 0;
50
- let cacheWriteTokens = 0;
51
- let sawUsage = false;
45
+ let inputTokens = null;
46
+ let outputTokens = null;
47
+ let cacheReadTokens = null;
48
+ let cacheWriteTokens = null;
52
49
  let activeCalls = 0;
53
50
  let maxConcurrency = 0;
54
51
  for (const line of lines) {
@@ -130,21 +127,10 @@ export function parseTrace(lines, meta) {
130
127
  if (msg.stopReason === "stop")
131
128
  finalText = text;
132
129
  }
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;
137
- const total = msg.usage?.cost?.total;
138
- if (typeof total === "number")
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;
130
+ inputTokens = addReported(inputTokens, msg.usage?.input);
131
+ outputTokens = addReported(outputTokens, msg.usage?.output);
132
+ cacheReadTokens = addReported(cacheReadTokens, msg.usage?.cacheRead);
133
+ cacheWriteTokens = addReported(cacheWriteTokens, msg.usage?.cacheWrite);
148
134
  continue;
149
135
  }
150
136
  if (type === "turn_end" || type === "agent_end" || type === "agent_settled") {
@@ -157,19 +143,14 @@ export function parseTrace(lines, meta) {
157
143
  }
158
144
  }
159
145
  const toolCalls = [...calls.values()].sort((a, b) => a.issueIndex - b.issueIndex);
160
- const subscription = meta.subject.provider === "openai-codex" || meta.subject.provider === "claude-code";
161
- const costSource = subscription
162
- ? "subscription"
163
- : cost !== null
164
- ? "provider-reported"
165
- : "unreported";
166
146
  const metrics = {
167
147
  input_tokens: inputTokens,
168
148
  output_tokens: outputTokens,
169
149
  cache_read_tokens: cacheReadTokens,
170
150
  cache_write_tokens: cacheWriteTokens,
171
- cost_usd: cost ?? 0,
172
- cost_source: costSource,
151
+ cost_usd: null,
152
+ cost_source: "unreported",
153
+ price_as_of: null,
173
154
  tool_calls: toolCalls.length,
174
155
  delegated_children: toolCalls
175
156
  .filter((call) => call.name === "Agent")
@@ -197,15 +178,17 @@ export function parseTrace(lines, meta) {
197
178
  // overwrites this after observing the workspace. Defaulting to `[]` claimed
198
179
  // "observed, nothing changed" for every trace ever parsed.
199
180
  changed_paths: meta.changedPaths ? [...meta.changedPaths].sort() : null,
200
- cost_usd: cost,
201
- // Tool calls remain in the trace for objective gates. Aggregate usage/cost/
202
- // tool metrics are published only when pi actually reported usage; otherwise
203
- // zero would mean "free" instead of "unavailable".
204
- ...(sawUsage ? { metrics } : {}),
181
+ cost_usd: null,
182
+ // Tool metrics are observed directly. Token and cost fields stay null when
183
+ // Pi does not report them; zero would falsely mean "free".
184
+ metrics,
205
185
  };
206
186
  trace.trace_sha256 = traceSha256(trace);
207
187
  return { trace, isComplete: sawTerminal, malformedLines };
208
188
  }
189
+ function addReported(current, value) {
190
+ return typeof value === "number" && value > 0 ? (current ?? 0) + value : current;
191
+ }
209
192
  /** Visible assistant text. Thinking is dropped here, and at every other reader. */
210
193
  function assistantText(msg) {
211
194
  return (msg.content ?? [])
@@ -308,6 +291,9 @@ export function deserializeTrace(text) {
308
291
  * Returns null for an empty list — "no turns produced evidence" must not look
309
292
  * like "a run in which nothing happened".
310
293
  */
294
+ function sumNullable(left, right) {
295
+ return left === null ? right : right === null ? left : left + right;
296
+ }
311
297
  export function mergeTraces(traces) {
312
298
  if (traces.length === 0)
313
299
  return null;
@@ -350,18 +336,20 @@ export function mergeTraces(traces) {
350
336
  const completeMetrics = traces.every((trace) => trace.metrics !== undefined);
351
337
  const metrics = completeMetrics
352
338
  ? traces.reduce((sum, trace) => ({
353
- input_tokens: sum.input_tokens + trace.metrics.input_tokens,
354
- output_tokens: sum.output_tokens + trace.metrics.output_tokens,
355
- cache_read_tokens: sum.cache_read_tokens + trace.metrics.cache_read_tokens,
356
- cache_write_tokens: sum.cache_write_tokens + trace.metrics.cache_write_tokens,
357
- cost_usd: sum.cost_usd + trace.metrics.cost_usd,
339
+ input_tokens: sumNullable(sum.input_tokens, trace.metrics.input_tokens),
340
+ output_tokens: sumNullable(sum.output_tokens, trace.metrics.output_tokens),
341
+ cache_read_tokens: sumNullable(sum.cache_read_tokens, trace.metrics.cache_read_tokens),
342
+ cache_write_tokens: sumNullable(sum.cache_write_tokens, trace.metrics.cache_write_tokens),
343
+ cost_usd: sumNullable(sum.cost_usd, trace.metrics.cost_usd),
358
344
  cost_source: sum.cost_source === trace.metrics.cost_source ? sum.cost_source : "unreported",
345
+ price_as_of: sum.price_as_of === trace.metrics.price_as_of ? sum.price_as_of : null,
359
346
  tool_calls: sum.tool_calls + trace.metrics.tool_calls,
360
347
  delegated_children: sum.delegated_children + trace.metrics.delegated_children,
361
348
  max_concurrency: Math.max(sum.max_concurrency, trace.metrics.max_concurrency),
362
349
  }), {
363
- input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0,
364
- cost_usd: 0, cost_source: traces[0].metrics.cost_source, tool_calls: 0, delegated_children: 0, max_concurrency: 0,
350
+ input_tokens: null, output_tokens: null, cache_read_tokens: null, cache_write_tokens: null,
351
+ cost_usd: null, cost_source: traces[0].metrics.cost_source, price_as_of: traces[0].metrics.price_as_of,
352
+ tool_calls: 0, delegated_children: 0, max_concurrency: 0,
365
353
  })
366
354
  : undefined;
367
355
  const last = traces[traces.length - 1];
package/dist/grade.d.ts CHANGED
@@ -54,6 +54,6 @@ export declare function gradeTranscript(adapter: HarnessAdapter, judge: ModelRef
54
54
  /**
55
55
  * Grade a transcript in a fresh, isolated, throwaway workspace — never the
56
56
  * subject's scenario dir — so the judge can't ingest repo context the subject
57
- * left behind (matters for CLI judges that read cwd, e.g. claude-code).
57
+ * left behind (matters for provider CLIs that read cwd, including Pi).
58
58
  */
59
59
  export declare function judgeInWorkspace(adapter: HarnessAdapter, judge: ModelRef, prompt: string, specDir: string): Promise<GradeResult>;
package/dist/grade.js CHANGED
@@ -139,7 +139,7 @@ export async function gradeTranscript(adapter, judge, prompt, cwd) {
139
139
  /**
140
140
  * Grade a transcript in a fresh, isolated, throwaway workspace — never the
141
141
  * subject's scenario dir — so the judge can't ingest repo context the subject
142
- * left behind (matters for CLI judges that read cwd, e.g. claude-code).
142
+ * left behind (matters for provider CLIs that read cwd, including Pi).
143
143
  */
144
144
  export async function judgeInWorkspace(adapter, judge, prompt, specDir) {
145
145
  const ws = createWorkspace("none", { specDir });
@@ -31,8 +31,8 @@ export interface JudgeAllowOpts {
31
31
  *
32
32
  * That third path is latent rather than live in the corpus this was built against:
33
33
  * checked 2026-08-05, all ~140 committed `results.yaml` in `principal-pi-skills`
34
- * record `provider: claude-code`, because its owner always passed the subscription
35
- * judge explicitly. The old default was reachable, not taken. Worth stating
34
+ * recorded the then-supported subscription judge explicitly. The old default was
35
+ * reachable, not taken. Worth stating
36
36
  * precisely — "your whole archive bills on regrade" would have been a scarier claim
37
37
  * than the evidence supports.
38
38
  *
@@ -3,9 +3,8 @@ import { BAKED_DEFAULT_JUDGE } from "./defaults.js";
3
3
  /**
4
4
  * Judge providers that cannot bill a per-token API.
5
5
  *
6
- * `claude-code` shells out to the `claude` CLI, authenticated with the user's
7
- * Claude subscription (OAuth). `openai-codex` stays in Pi and authenticates with
8
- * the user's ChatGPT subscription (OAuth). `ollama`/`lmstudio`/`llamacpp`/`local`
6
+ * `openai-codex` runs through Pi and authenticates with the user's ChatGPT
7
+ * subscription (OAuth). `ollama`/`lmstudio`/`llamacpp`/`local`
9
8
  * are local runtimes. Everything else — including direct `openai` — is assumed
10
9
  * to charge.
11
10
  *
@@ -14,7 +13,7 @@ import { BAKED_DEFAULT_JUDGE } from "./defaults.js";
14
13
  * being wrong in that direction produces a surprise invoice while being wrong in
15
14
  * this direction produces one extra flag.
16
15
  */
17
- const FREE_JUDGE_PROVIDERS = new Set(["claude-code", "openai-codex", "ollama", "lmstudio", "llamacpp", "local"]);
16
+ const FREE_JUDGE_PROVIDERS = new Set(["openai-codex", "ollama", "lmstudio", "llamacpp", "local"]);
18
17
  /** Whether judging with this ref can charge a per-token API. */
19
18
  export function isMeteredJudge(judge) {
20
19
  return !FREE_JUDGE_PROVIDERS.has(judge.provider);
@@ -40,8 +39,8 @@ export function allowMeteredJudge() {
40
39
  *
41
40
  * That third path is latent rather than live in the corpus this was built against:
42
41
  * checked 2026-08-05, all ~140 committed `results.yaml` in `principal-pi-skills`
43
- * record `provider: claude-code`, because its owner always passed the subscription
44
- * judge explicitly. The old default was reachable, not taken. Worth stating
42
+ * recorded the then-supported subscription judge explicitly. The old default was
43
+ * reachable, not taken. Worth stating
45
44
  * precisely — "your whole archive bills on regrade" would have been a scarier claim
46
45
  * than the evidence supports.
47
46
  *
@@ -49,6 +48,9 @@ export function allowMeteredJudge() {
49
48
  * is what a run *is*.
50
49
  */
51
50
  export function assertJudgeAllowed(judge, opts) {
51
+ if (judge.provider === "claude-code") {
52
+ throw new Error("judge provider `claude-code` was removed; choose a provider configured in Pi, such as `openai-codex`");
53
+ }
52
54
  if (!isMeteredJudge(judge))
53
55
  return;
54
56
  if (opts.allowMetered || allowMeteredJudge())
@@ -56,7 +58,7 @@ export function assertJudgeAllowed(judge, opts) {
56
58
  const token = `${judge.provider}:${judge.model}`;
57
59
  throw new Error(`refusing to judge with ${token}: \`${judge.provider}\` bills a per-token API key, and it came from ${opts.source}.\n` +
58
60
  ` Judging is meant to cost nothing you did not ask for.\n` +
59
- ` • judge on your Claude subscription instead: --judge ${BAKED_DEFAULT_JUDGE}\n` +
61
+ ` • judge through Pi on your subscription: --judge ${BAKED_DEFAULT_JUDGE}\n` +
60
62
  ` • allow the metered API for this command: --allow-metered-judge\n` +
61
63
  ` • allow it for this repo or shell: export SKILL_HARNESS_ALLOW_METERED_JUDGE=1`);
62
64
  }
package/dist/metrics.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ScenarioResult } from "./results.js";
2
+ import type { CostSource } from "./capture-trace-types.js";
2
3
  export interface AggregateMetrics {
3
4
  wall_time_ms: number;
4
5
  judge_calls: number;
@@ -10,7 +11,7 @@ export interface AggregateMetrics {
10
11
  cache_read_tokens: number | null;
11
12
  cache_write_tokens: number | null;
12
13
  subject_cost_usd: number | null;
13
- cost_source: "provider-reported" | "subscription" | "unreported" | null;
14
+ cost_source: CostSource | null;
14
15
  tool_calls: number | null;
15
16
  delegated_children: number | null;
16
17
  max_concurrency: number | null;
package/dist/reps.js CHANGED
@@ -67,17 +67,32 @@ export function outcomesToResult(id, outcomes, repCount, threshold) {
67
67
  const objectiveField = objective ? { objective } : {};
68
68
  const metrics = aggregateMetrics(outcomes);
69
69
  const metricsField = metrics ? { metrics } : {};
70
+ const usage = outcomes.flatMap((outcome, repetition) => {
71
+ const subject = outcome.metrics?.subject;
72
+ if (!subject)
73
+ return [];
74
+ const reported = (value) => value !== null && value > 0 ? value : null;
75
+ return [{
76
+ repetition,
77
+ inputTokens: reported(subject.input_tokens),
78
+ outputTokens: reported(subject.output_tokens),
79
+ cacheReadTokens: reported(subject.cache_read_tokens),
80
+ costUsd: reported(subject.cost_usd),
81
+ priceAsOf: subject.price_as_of,
82
+ }];
83
+ });
84
+ const usageField = usage.length ? { usage } : {};
70
85
  const repJudgments = outcomes.map((outcome, repetition) => ({ repetition, judgments: outcome.judgment ? [outcome.judgment] : [], recorded_verdict: outcome.verdict, ...(outcome.objective ? { objective: outcome.objective } : {}) }));
71
86
  const repJudgmentField = outcomes.some(outcome => outcome.judgment) ? { rep_judgments: repJudgments } : {};
72
87
  if (repCount === 1) {
73
88
  const o = outcomes[0];
74
- return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, ...metricsField, override: null, note: "", ...objectiveField, ...repJudgmentField };
89
+ return { id, judge_verdict: o.verdict, judge_reason: o.reason, suspect: o.suspect, ...metricsField, ...usageField, override: null, note: "", ...objectiveField, ...repJudgmentField };
75
90
  }
76
91
  const agg = aggregateReps(outcomes, threshold);
77
92
  return {
78
93
  id, judge_verdict: agg.verdict, judge_reason: agg.reason, suspect: agg.suspect,
79
94
  reps: agg.reps, passes: agg.passes, clean: agg.clean, flakiness: agg.flakiness,
80
- pass_threshold: threshold, ...metricsField, override: null, note: "", ...objectiveField, ...repJudgmentField,
95
+ pass_threshold: threshold, ...metricsField, ...usageField, override: null, note: "", ...objectiveField, ...repJudgmentField,
81
96
  };
82
97
  }
83
98
  function aggregateMetrics(outcomes) {
@@ -85,22 +100,32 @@ function aggregateMetrics(outcomes) {
85
100
  if (present.length === 0)
86
101
  return undefined;
87
102
  const subjects = present.map((metrics) => metrics.subject).filter((metrics) => metrics !== undefined);
103
+ const reportedSubjects = subjects.filter(metrics => metrics.input_tokens !== null || metrics.output_tokens !== null || metrics.cache_read_tokens !== null);
88
104
  const base = {
89
105
  wall_time_ms: present.reduce((sum, metrics) => sum + metrics.wall_time_ms, 0),
90
106
  judge_calls: present.reduce((sum, metrics) => sum + metrics.judge_calls, 0),
91
107
  judge_rejudge_calls: present.reduce((sum, metrics) => sum + metrics.judge_rejudge_calls, 0),
92
- subject_metrics_reps: subjects.length,
108
+ subject_metrics_reps: reportedSubjects.length,
93
109
  total_reps: outcomes.length,
94
110
  };
95
111
  if (subjects.length === 0)
96
112
  return base;
113
+ const sumReported = (field) => {
114
+ const values = subjects.map(metrics => metrics[field]).filter((value) => value !== null && value > 0);
115
+ return values.length ? values.reduce((sum, value) => sum + value, 0) : undefined;
116
+ };
117
+ const inputTokens = sumReported("input_tokens");
118
+ const outputTokens = sumReported("output_tokens");
119
+ const cacheReadTokens = sumReported("cache_read_tokens");
120
+ const cacheWriteTokens = sumReported("cache_write_tokens");
121
+ const subjectCost = sumReported("cost_usd");
97
122
  return {
98
123
  ...base,
99
- input_tokens: subjects.reduce((sum, metrics) => sum + metrics.input_tokens, 0),
100
- output_tokens: subjects.reduce((sum, metrics) => sum + metrics.output_tokens, 0),
101
- cache_read_tokens: subjects.reduce((sum, metrics) => sum + metrics.cache_read_tokens, 0),
102
- cache_write_tokens: subjects.reduce((sum, metrics) => sum + metrics.cache_write_tokens, 0),
103
- subject_cost_usd: subjects.reduce((sum, metrics) => sum + metrics.cost_usd, 0),
124
+ ...(inputTokens === undefined ? {} : { input_tokens: inputTokens }),
125
+ ...(outputTokens === undefined ? {} : { output_tokens: outputTokens }),
126
+ ...(cacheReadTokens === undefined ? {} : { cache_read_tokens: cacheReadTokens }),
127
+ ...(cacheWriteTokens === undefined ? {} : { cache_write_tokens: cacheWriteTokens }),
128
+ ...(subjectCost === undefined ? {} : { subject_cost_usd: subjectCost }),
104
129
  cost_source: subjects.every((metrics) => metrics.cost_source === subjects[0].cost_source)
105
130
  ? subjects[0].cost_source
106
131
  : "unreported",
package/dist/results.d.ts CHANGED
@@ -3,6 +3,14 @@ import { type ScenarioVerdict } from "./score.js";
3
3
  import type { Verdict } from "./score.js";
4
4
  import type { ShipBar, Scenario } from "./spec.js";
5
5
  import type { CostSource } from "./capture-trace-types.js";
6
+ export interface SubjectUsage {
7
+ repetition: number;
8
+ inputTokens: number | null;
9
+ outputTokens: number | null;
10
+ cacheReadTokens: number | null;
11
+ costUsd: number | null;
12
+ priceAsOf: string | null;
13
+ }
6
14
  export interface ScenarioMetrics {
7
15
  wall_time_ms: number;
8
16
  judge_calls: number;
@@ -56,6 +64,8 @@ export interface ScenarioResult {
56
64
  pass_threshold?: number;
57
65
  /** Cost/latency counters. Separate from verdicts: cheaper failure is still failure. */
58
66
  metrics?: ScenarioMetrics;
67
+ /** Per-repetition subject usage; null values mean Pi did not report that counter. */
68
+ usage?: SubjectUsage[];
59
69
  /**
60
70
  * Objective trace-gate evidence. ADDITIVE and optional.
61
71
  *
package/dist/results.js CHANGED
@@ -599,7 +599,7 @@ export function diffPath(runDir, scenarioId, mode, rep) {
599
599
  export function rebuildScenarioResult(fresh, prior, policy) {
600
600
  // Exhaustive destructure. Do not replace with a spread: the spread is what
601
601
  // allowed a new field to pass through unconsidered in the first place.
602
- const { id, criterion_count: freshCriterionCount, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, metrics: freshMetrics, objective: freshObjective, adjudication: freshAdjudication, rep_judgments: freshRepJudgments, ...rest } = fresh;
602
+ const { id, criterion_count: freshCriterionCount, judge_verdict, judge_reason, suspect, override: _freshOverride, note: _freshNote, reps, passes, clean, flakiness, pass_threshold, metrics: freshMetrics, usage: freshUsage, objective: freshObjective, adjudication: freshAdjudication, rep_judgments: freshRepJudgments, ...rest } = fresh;
603
603
  const _exhaustive = rest;
604
604
  void _exhaustive;
605
605
  void _freshOverride;
@@ -652,6 +652,7 @@ export function rebuildScenarioResult(fresh, prior, policy) {
652
652
  ...(flakiness === undefined ? {} : { flakiness }),
653
653
  ...(pass_threshold === undefined ? {} : { pass_threshold }),
654
654
  ...((freshMetrics ?? prior?.metrics) ? { metrics: freshMetrics ?? prior.metrics } : {}),
655
+ ...((freshUsage ?? prior?.usage) ? { usage: freshUsage ?? prior.usage } : {}),
655
656
  // The author owns the verdict; a re-measurement never discards their call.
656
657
  override: prior?.override ?? null,
657
658
  note: prior?.note ?? "",
package/dist/run.js CHANGED
@@ -19,10 +19,8 @@ import { aggregateMetrics } from "./metrics.js";
19
19
  import { providerFailureFromTranscript } from "./provider-failure.js";
20
20
  /**
21
21
  * The pi-daddy arm's delivery proof, by convention: `arms.yaml` points
22
- * `PI_GRANTS_LEDGER` at exactly this filename inside the run dir (see
23
- * `../principal-pi-skills/tests/arms.yaml` and CODEX-ARMS-RUNBOOK.md §4.3), so
24
- * a fixed name here is what makes the count readable without parsing the arm's
25
- * own `env` map back out.
22
+ * `PI_GRANTS_LEDGER` at exactly this filename inside the run directory. A fixed
23
+ * name keeps the count readable without parsing the arm's `env` map back out.
26
24
  */
27
25
  const LEDGER_FILENAME = "pi-daddy.ledger.jsonl";
28
26
  /**
@@ -276,7 +274,7 @@ async function runRep(scenario, rep, repCount, ctx) {
276
274
  throw new Error(`scenario \`${scenario.id}\` declares structured objective assertions, but the \`${ctx.adapter.name}\` adapter` +
277
275
  ` cannot produce execution traces — the gate would have no evidence to read.`);
278
276
  }
279
- const useStructured = (Boolean(ctx.structured) || needsStructuredEvidence) && Boolean(ctx.adapter.runStructured);
277
+ const useStructured = (Boolean(ctx.structured) || needsStructuredEvidence || Boolean(ctx.adapter.preferStructured)) && Boolean(ctx.adapter.runStructured);
280
278
  // A blank assistant turn is a harness timeout, not model behavior: retry ONCE in a
281
279
  // fresh workspace (the first attempt may have half-mutated a seeded repo), and if
282
280
  // it happens again the verdict is ERROR — never a judged FAIL on an empty reply.
@@ -581,7 +579,9 @@ export function formatScorecard(summary, lift, stability) {
581
579
  ? " · WARNING: subject tokens were used but the provider reported $0 cost"
582
580
  : metrics.subject_cost_usd === null
583
581
  ? " · subject cost unavailable"
584
- : ` · $${metrics.subject_cost_usd.toFixed(6)} provider-reported`;
582
+ : metrics.cost_source === "price-table"
583
+ ? ` · $${metrics.subject_cost_usd.toFixed(6)} price table`
584
+ : ` · $${metrics.subject_cost_usd.toFixed(6)} provider-reported`;
585
585
  lines.push(` COST: ${tokens} (${metrics.subject_metrics_reps}/${metrics.total_reps} reps reported)${cost} · ${metrics.judge_calls} judge + ${metrics.judge_rejudge_calls} re-judge call(s) · ${metrics.wall_time_ms}ms · ${tools}`);
586
586
  }
587
587
  // Lift is a statement about a skill-delivered run (green or force). On a red run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.18.0",
3
+ "version": "0.20.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",
@@ -130,6 +130,19 @@
130
130
  }
131
131
  }
132
132
  },
133
+ "usage": {
134
+ "type": "object",
135
+ "additionalProperties": false,
136
+ "required": ["repetition", "inputTokens", "outputTokens", "cacheReadTokens", "costUsd", "priceAsOf"],
137
+ "properties": {
138
+ "repetition": { "type": "integer", "minimum": 0 },
139
+ "inputTokens": { "type": ["integer", "null"], "minimum": 1 },
140
+ "outputTokens": { "type": ["integer", "null"], "minimum": 1 },
141
+ "cacheReadTokens": { "type": ["integer", "null"], "minimum": 1 },
142
+ "costUsd": { "type": ["number", "null"], "exclusiveMinimum": 0 },
143
+ "priceAsOf": { "type": ["string", "null"] }
144
+ }
145
+ },
133
146
  "metrics": {
134
147
  "type": "object",
135
148
  "required": [
@@ -184,6 +197,7 @@
184
197
  "enum": [
185
198
  "provider-reported",
186
199
  "subscription",
200
+ "price-table",
187
201
  "unreported"
188
202
  ]
189
203
  },
@@ -308,6 +322,10 @@
308
322
  "metrics": {
309
323
  "$ref": "#/$defs/metrics"
310
324
  },
325
+ "usage": {
326
+ "type": "array",
327
+ "items": { "$ref": "#/$defs/usage" }
328
+ },
311
329
  "objective": {
312
330
  "type": "object",
313
331
  "additionalProperties": false,
@@ -141,6 +141,19 @@
141
141
  }
142
142
  }
143
143
  },
144
+ "usage": {
145
+ "type": "object",
146
+ "additionalProperties": false,
147
+ "required": ["repetition", "inputTokens", "outputTokens", "cacheReadTokens", "costUsd", "priceAsOf"],
148
+ "properties": {
149
+ "repetition": { "type": "integer", "minimum": 0 },
150
+ "inputTokens": { "type": ["integer", "null"], "minimum": 1 },
151
+ "outputTokens": { "type": ["integer", "null"], "minimum": 1 },
152
+ "cacheReadTokens": { "type": ["integer", "null"], "minimum": 1 },
153
+ "costUsd": { "type": ["number", "null"], "exclusiveMinimum": 0 },
154
+ "priceAsOf": { "type": ["string", "null"] }
155
+ }
156
+ },
144
157
  "metrics": {
145
158
  "type": "object",
146
159
  "required": [
@@ -195,6 +208,7 @@
195
208
  "enum": [
196
209
  "provider-reported",
197
210
  "subscription",
211
+ "price-table",
198
212
  "unreported"
199
213
  ]
200
214
  },
@@ -334,6 +348,10 @@
334
348
  "metrics": {
335
349
  "$ref": "#/$defs/metrics"
336
350
  },
351
+ "usage": {
352
+ "type": "array",
353
+ "items": { "$ref": "#/$defs/usage" }
354
+ },
337
355
  "objective": {
338
356
  "type": "object",
339
357
  "additionalProperties": false,