@skill-harness/core 0.10.0 → 0.12.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.
- package/README.md +2 -1
- package/dist/adapters/types.d.ts +30 -0
- package/dist/adjudication.d.ts +9 -1
- package/dist/adjudication.js +41 -33
- package/dist/arms.d.ts +51 -0
- package/dist/arms.js +255 -0
- package/dist/capture-trace-types.d.ts +2 -0
- package/dist/command-cost.d.ts +9 -0
- package/dist/command-cost.js +23 -0
- package/dist/comparison.d.ts +1 -0
- package/dist/comparison.js +9 -3
- package/dist/execution-trace.js +9 -1
- package/dist/grade.d.ts +2 -0
- package/dist/grade.js +2 -1
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/journal.d.ts +2 -1
- package/dist/judge-agreement.d.ts +19 -0
- package/dist/judge-agreement.js +31 -0
- package/dist/judge-policy.js +5 -3
- package/dist/lift.js +1 -1
- package/dist/lint.js +1 -1
- package/dist/prompt-normalization.d.ts +6 -0
- package/dist/prompt-normalization.js +14 -0
- package/dist/provider-failure.d.ts +56 -0
- package/dist/provider-failure.js +98 -0
- package/dist/qualification-capture.d.ts +44 -0
- package/dist/qualification-capture.js +163 -0
- package/dist/qualification-config.d.ts +233 -0
- package/dist/qualification-config.js +694 -0
- package/dist/qualification-lock.d.ts +15 -0
- package/dist/qualification-lock.js +87 -0
- package/dist/qualification-oauth-directory.d.ts +79 -0
- package/dist/qualification-oauth-directory.js +444 -0
- package/dist/qualification-panel-store.d.ts +14 -0
- package/dist/qualification-panel-store.js +188 -0
- package/dist/qualification-panels.d.ts +99 -0
- package/dist/qualification-panels.js +91 -0
- package/dist/qualification-process.d.ts +13 -0
- package/dist/qualification-process.js +188 -0
- package/dist/qualification-runner.d.ts +203 -0
- package/dist/qualification-runner.js +2037 -0
- package/dist/qualification-store.d.ts +187 -0
- package/dist/qualification-store.js +833 -0
- package/dist/qualification-validation.d.ts +12 -0
- package/dist/qualification-validation.js +8 -0
- package/dist/regate.js +49 -10
- package/dist/regrade.d.ts +3 -1
- package/dist/regrade.js +62 -6
- package/dist/reps.d.ts +4 -2
- package/dist/reps.js +13 -4
- package/dist/rescore.js +9 -1
- package/dist/results.d.ts +99 -7
- package/dist/results.js +228 -10
- package/dist/run.d.ts +14 -0
- package/dist/run.js +260 -57
- package/dist/score.d.ts +4 -3
- package/dist/score.js +13 -4
- package/dist/screen.d.ts +31 -0
- package/dist/screen.js +105 -0
- package/dist/seeded.d.ts +12 -1
- package/dist/seeded.js +9 -2
- package/dist/sources.d.ts +7 -1
- package/dist/sources.js +13 -5
- package/dist/spec.d.ts +1 -1
- package/dist/spec.js +1 -1
- package/dist/stability.js +1 -1
- package/dist/trajectory-gates.d.ts +14 -2
- package/dist/trajectory-gates.js +26 -6
- package/dist/vote-panel.d.ts +33 -0
- package/dist/vote-panel.js +41 -0
- package/dist/workspace.js +34 -3
- package/package.json +3 -3
- package/schemas/qualification-cell-result-v1.schema.json +32 -0
- package/schemas/qualification-config-v1.schema.json +244 -0
- package/schemas/qualification-invocation-request-v1.schema.json +59 -0
- package/schemas/qualification-judge-panel-result-v1.schema.json +42 -0
- package/schemas/results-v2.schema.json +19 -21
- package/schemas/results-v3.schema.json +654 -0
- package/schemas/specification-v1.schema.json +1 -1
- package/schemas/trajectory-event-v1.schema.json +20 -1
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# @skill-harness/core
|
|
2
2
|
|
|
3
3
|
The engine behind [skill-harness](https://github.com/mojomanyana/skill-harness):
|
|
4
|
-
spec parsing, discovery, run orchestration, LLM-judge grading, scoring, results
|
|
4
|
+
spec parsing, discovery, run orchestration, LLM-judge grading, scoring, results,
|
|
5
|
+
and the separately versioned durable qualification-runner-v1 primitives.
|
|
5
6
|
|
|
6
7
|
Internal API — use the `skill-harness` CLI. See the repo for docs.
|
|
7
8
|
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -11,6 +11,21 @@ export interface ModelRef {
|
|
|
11
11
|
export declare function parseModelRef(token: string): ModelRef;
|
|
12
12
|
/** Stable, filesystem-safe slug for a model ref (used in run-dir names). */
|
|
13
13
|
export declare function modelSlug(ref: ModelRef): string;
|
|
14
|
+
export type PromptMechanism = "none" | "pi-skill" | "append-system-prompt" | "system-prompt-file";
|
|
15
|
+
export interface PromptProvenance {
|
|
16
|
+
capture_version: "prompt-provenance-v1";
|
|
17
|
+
request_index: number;
|
|
18
|
+
raw_sha256: string;
|
|
19
|
+
normalized_sha256: string;
|
|
20
|
+
normalization_rule: "cwd-line-v1";
|
|
21
|
+
bytes: number;
|
|
22
|
+
contract_sha256: string;
|
|
23
|
+
contract_bytes: number;
|
|
24
|
+
contract_occurrences: number;
|
|
25
|
+
mechanism: PromptMechanism;
|
|
26
|
+
status: "PASS" | "NOT-MEASURED" | "ERROR";
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
14
29
|
export interface RunReq {
|
|
15
30
|
skillDir: string;
|
|
16
31
|
model: ModelRef;
|
|
@@ -44,6 +59,14 @@ export interface RunReq {
|
|
|
44
59
|
extensions?: string[];
|
|
45
60
|
/** Workspace-local native ledgers to normalize after the subject finishes. */
|
|
46
61
|
eventSources?: TrajectoryEventSource[];
|
|
62
|
+
/**
|
|
63
|
+
* Extra env for the subject process, from the arm. Merged over `process.env` by
|
|
64
|
+
* the adapter — an arm must be able to add `PI_GRANTS_*` without the harness
|
|
65
|
+
* inheriting a caller's copy of them.
|
|
66
|
+
*/
|
|
67
|
+
armEnv?: Record<string, string>;
|
|
68
|
+
/** Adapter-produced observation callback. Callers receive evidence; they never supply delivery status. */
|
|
69
|
+
onPromptObservation?: (observation: PromptProvenance) => void;
|
|
47
70
|
}
|
|
48
71
|
/** A judge request: single prompt, no skills, no session. */
|
|
49
72
|
export interface JudgeReq {
|
|
@@ -60,9 +83,16 @@ export interface StructuredRun {
|
|
|
60
83
|
events?: TrajectoryEventV1[];
|
|
61
84
|
/** Native sources that were required but missing/malformed. Never treated as an empty success. */
|
|
62
85
|
eventErrors?: string[];
|
|
86
|
+
/**
|
|
87
|
+
* Set when pi failed provider-side (auth, transport) rather than the model
|
|
88
|
+
* answering badly. `run.ts` turns this into ERROR — never a model verdict.
|
|
89
|
+
*/
|
|
90
|
+
providerFailure?: string;
|
|
63
91
|
}
|
|
64
92
|
export interface HarnessAdapter {
|
|
65
93
|
name: string;
|
|
94
|
+
/** True only when the adapter computes final provider-payload observations. */
|
|
95
|
+
observesPrompts?: boolean;
|
|
66
96
|
available(): Promise<boolean>;
|
|
67
97
|
run(req: RunReq): Promise<string>;
|
|
68
98
|
/**
|
package/dist/adjudication.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HarnessAdapter, ModelRef } from "./adapters/types.js";
|
|
2
|
-
import type { ScenarioResult, AdjudicationResult, Judgment, ResultsFile } from "./results.js";
|
|
2
|
+
import type { ScenarioResult, AdjudicationResult, Judgment, ResultsFile, CriterionVote } from "./results.js";
|
|
3
3
|
import type { Scenario, ShipBar, Spec } from "./spec.js";
|
|
4
4
|
import type { Verdict } from "./score.js";
|
|
5
5
|
/**
|
|
@@ -33,6 +33,11 @@ export interface CellState {
|
|
|
33
33
|
suspect: boolean;
|
|
34
34
|
/** Per-rep verdicts, when the cell ran with reps. Empty for a single-rep cell. */
|
|
35
35
|
repVerdicts?: Verdict[];
|
|
36
|
+
criteria?: CriterionVote[];
|
|
37
|
+
/** Delivery prerequisite for schema-3 cells; non-PASS cells are never judged. */
|
|
38
|
+
deliveryStatus?: "PASS" | "NOT-MEASURED" | "ERROR";
|
|
39
|
+
/** The actual selected repetition's primary vote; aggregate fields remain for planning only. */
|
|
40
|
+
primaryJudgment?: Judgment;
|
|
36
41
|
}
|
|
37
42
|
export interface TriggerDecision {
|
|
38
43
|
id: string;
|
|
@@ -105,11 +110,14 @@ export declare function collapseJudgments(judgments: Judgment[], trigger: Trigge
|
|
|
105
110
|
*/
|
|
106
111
|
export declare function boundAdjudicationToRepetitions(result: ScenarioResult, scenario: Scenario, adj: AdjudicationResult): AdjudicationResult;
|
|
107
112
|
export declare function projectAdjudication(result: ScenarioResult, adj: AdjudicationResult): ScenarioResult;
|
|
113
|
+
/** Apply the one-transcript bound and label it only when the bound changed the panel result. */
|
|
114
|
+
export declare function projectAdjudicationForScenario(result: ScenarioResult, scenario: Scenario, adj: AdjudicationResult): ScenarioResult;
|
|
108
115
|
/** Ask one judge about one cell's saved transcript. Supplied by the caller. */
|
|
109
116
|
export type RejudgeFn = (id: string, judge: ModelRef) => Promise<{
|
|
110
117
|
verdict: Verdict;
|
|
111
118
|
reason: string;
|
|
112
119
|
suspect: boolean;
|
|
120
|
+
criteria?: CriterionVote[];
|
|
113
121
|
}>;
|
|
114
122
|
export interface RunAdjudicationOptions {
|
|
115
123
|
plan: AdjudicationPlan;
|
package/dist/adjudication.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { judgeRawPath, writeResults, scoreContextFor, findTranscriptFiles, repIndexOf, rebuildScenarioResult, mergeScenarioMetrics, effectiveThreshold, } from "./results.js";
|
|
3
|
+
import { judgeRawPath, writeResults, scoreContextFor, findTranscriptFiles, repIndexOf, rebuildScenarioResult, mergeScenarioMetrics, effectiveThreshold, completeCriterionVotes, } 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";
|
|
7
|
+
import { collapseVotePanel } from "./vote-panel.js";
|
|
7
8
|
/** Maximum judgments per cell, first wave included. Hard cap, not a default. */
|
|
8
9
|
export const MAX_JUDGMENTS = 3;
|
|
9
10
|
/**
|
|
@@ -18,6 +19,9 @@ export function planAdjudication(input) {
|
|
|
18
19
|
const enabled = new Set(input.enabled ?? ["ambiguous", "contradictory", "non_unanimous", "ship_deciding"]);
|
|
19
20
|
const decisions = [];
|
|
20
21
|
for (const cell of input.cells) {
|
|
22
|
+
// Delivery-gated schema-3 cells carry no behavioral claim to adjudicate.
|
|
23
|
+
if (cell.deliveryStatus && cell.deliveryStatus !== "PASS")
|
|
24
|
+
continue;
|
|
21
25
|
const triggers = [];
|
|
22
26
|
// `JUDGE-AMBIGUOUS` is what the parser emits when a judge's verdict blocks
|
|
23
27
|
// disagree. `ERROR` is what it emits when nothing parsed at all — and that
|
|
@@ -92,24 +96,13 @@ function flipsShipDecision(cell, input) {
|
|
|
92
96
|
* cast a deciding vote would launder exactly the unreliability being measured.
|
|
93
97
|
*/
|
|
94
98
|
export function collapseJudgments(judgments, trigger) {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const fails = clean.length - passes;
|
|
103
|
-
if (passes === 0 || fails === 0) {
|
|
104
|
-
return { ...base, state: "confirmed", verdict: clean[0].verdict };
|
|
105
|
-
}
|
|
106
|
-
// A clean strict majority breaks the tie. With exactly 2 clean votes that
|
|
107
|
-
// disagree there is no majority, so this correctly falls through.
|
|
108
|
-
if (passes > fails)
|
|
109
|
-
return { ...base, state: "tie_broken", verdict: "PASS" };
|
|
110
|
-
if (fails > passes)
|
|
111
|
-
return { ...base, state: "tie_broken", verdict: "FAIL" };
|
|
112
|
-
return { ...base, state: "unresolved" };
|
|
99
|
+
const collapsed = collapseVotePanel(judgments);
|
|
100
|
+
return {
|
|
101
|
+
trigger,
|
|
102
|
+
judgments,
|
|
103
|
+
state: collapsed.state,
|
|
104
|
+
...(collapsed.verdict ? { verdict: collapsed.verdict } : {}),
|
|
105
|
+
};
|
|
113
106
|
}
|
|
114
107
|
/**
|
|
115
108
|
* Project an adjudication onto the compatibility fields the scorer reads.
|
|
@@ -149,6 +142,15 @@ export function projectAdjudication(result, adj) {
|
|
|
149
142
|
adjudication: adj,
|
|
150
143
|
};
|
|
151
144
|
}
|
|
145
|
+
/** Apply the one-transcript bound and label it only when the bound changed the panel result. */
|
|
146
|
+
export function projectAdjudicationForScenario(result, scenario, adj) {
|
|
147
|
+
const bounded = boundAdjudicationToRepetitions(result, scenario, adj);
|
|
148
|
+
const projected = projectAdjudication(result, bounded);
|
|
149
|
+
if (bounded !== adj) {
|
|
150
|
+
projected.judge_reason = `${adj.judgments.length} judgments on one transcript cannot replace a critical all-repetitions aggregate`;
|
|
151
|
+
}
|
|
152
|
+
return projected;
|
|
153
|
+
}
|
|
152
154
|
function reasonFor(adj) {
|
|
153
155
|
const n = adj.judgments.length;
|
|
154
156
|
const verb = adj.state === "confirmed" ? "confirmed by" : "resolved by majority of";
|
|
@@ -179,7 +181,9 @@ export async function runAdjudication(opts) {
|
|
|
179
181
|
// in the fixed order `planAdjudication` evaluates them.
|
|
180
182
|
const trigger = decision.triggers[0];
|
|
181
183
|
const judgments = [
|
|
182
|
-
|
|
184
|
+
cell.primaryJudgment
|
|
185
|
+
? { ...cell.primaryJudgment, ordinal: 1 }
|
|
186
|
+
: { ordinal: 1, judge: { ...opts.primaryJudge }, verdict: cell.verdict, reason: cell.reason, suspect: cell.suspect, criteria: cell.criteria ?? [] },
|
|
183
187
|
];
|
|
184
188
|
const second = await opts.rejudge(decision.id, opts.secondaryJudge);
|
|
185
189
|
callsMade++;
|
|
@@ -242,11 +246,8 @@ export async function adjudicateRun(opts) {
|
|
|
242
246
|
if (!adj)
|
|
243
247
|
return s;
|
|
244
248
|
const scenario = byIdScenario.get(s.id);
|
|
245
|
-
const
|
|
246
|
-
const projected = projectAdjudication(s,
|
|
247
|
-
if (boundedAdj !== adj) {
|
|
248
|
-
projected.judge_reason = `${adj.judgments.length} judgments on one transcript cannot replace a critical all-repetitions aggregate`;
|
|
249
|
-
}
|
|
249
|
+
const withRepetition = { ...adj, repetition: s.rep_judgments?.[0]?.repetition ?? 0 };
|
|
250
|
+
const projected = scenario ? projectAdjudicationForScenario(s, scenario, withRepetition) : projectAdjudication(s, withRepetition);
|
|
250
251
|
const extraCalls = adj.judgments.filter((judgment) => judgment.ordinal > 1).length;
|
|
251
252
|
projected.metrics = mergeScenarioMetrics(s.metrics, {
|
|
252
253
|
wall_time_ms: 0,
|
|
@@ -299,7 +300,7 @@ async function judgeCell(opts) {
|
|
|
299
300
|
event: "judge-verdict", ts: opts.now(), id: opts.scenario.id,
|
|
300
301
|
verdict: g.verdict, reason: g.reason, suspect: g.suspect,
|
|
301
302
|
});
|
|
302
|
-
return { verdict: g.verdict, reason: g.reason, suspect: g.suspect };
|
|
303
|
+
return { verdict: g.verdict, reason: g.reason, suspect: g.suspect, criteria: completeCriterionVotes(g.criteria, opts.scenario.checklist.length) };
|
|
303
304
|
}
|
|
304
305
|
/**
|
|
305
306
|
* Build the plan's input cells from a run.
|
|
@@ -311,13 +312,20 @@ async function judgeCell(opts) {
|
|
|
311
312
|
* about — inverting the one invariant this feature actually promises.
|
|
312
313
|
*/
|
|
313
314
|
export function cellsFromResults(runDir, results) {
|
|
314
|
-
return results.scenarios.map((s) =>
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
315
|
+
return results.scenarios.map((s) => {
|
|
316
|
+
const primaryJudgment = s.rep_judgments?.find(panel => panel.repetition === 0)?.judgments[0];
|
|
317
|
+
const deliveryStatus = s.objective?.assertions.find(assertion => assertion.kind === "skill_delivered")?.status;
|
|
318
|
+
return {
|
|
319
|
+
id: s.id,
|
|
320
|
+
verdict: s.judge_verdict,
|
|
321
|
+
reason: s.judge_reason,
|
|
322
|
+
suspect: s.suspect,
|
|
323
|
+
repVerdicts: repVerdictsOf(runDir, s, results.mode),
|
|
324
|
+
criteria: primaryJudgment?.criteria,
|
|
325
|
+
...(deliveryStatus === "PASS" || deliveryStatus === "NOT-MEASURED" || deliveryStatus === "ERROR" ? { deliveryStatus } : {}),
|
|
326
|
+
...(primaryJudgment ? { primaryJudgment } : {}),
|
|
327
|
+
};
|
|
328
|
+
});
|
|
321
329
|
}
|
|
322
330
|
/** Per-rep verdicts from saved judge artifacts, for the non-unanimous trigger. */
|
|
323
331
|
function repVerdictsOf(runDir, s, mode) {
|
package/dist/arms.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An arm: a named bundle of harness-side conditions a run is measured under.
|
|
3
|
+
*
|
|
4
|
+
* Why arms live HERE and not in `specification.yaml`. An arm is meant to be
|
|
5
|
+
* A/B'd — the control and the treatment must be the same experiment run twice.
|
|
6
|
+
* Declaring it in the spec would move the spec digest, which both stales every
|
|
7
|
+
* committed run in the corpus and makes control and treatment textually
|
|
8
|
+
* different experiments. So `arms.yaml` is deliberately part of NO digest, and
|
|
9
|
+
* the arm is carried in the run-dir TAG instead: `lint` and `stability` key on
|
|
10
|
+
* the tag, so two arms are separate lineages that can never be misread as one
|
|
11
|
+
* lineage flipping its verdict run-over-run.
|
|
12
|
+
*/
|
|
13
|
+
export interface Arm {
|
|
14
|
+
name: string;
|
|
15
|
+
/** Absolute paths, one `--extension` each. */
|
|
16
|
+
extensions: string[];
|
|
17
|
+
/** Dirs (relative to the skills root) copied into `<workspace>/.pi/skills/`. */
|
|
18
|
+
seedSkills: string[];
|
|
19
|
+
/** Minimum definitions the seeding must produce, else the run ERRORs. */
|
|
20
|
+
requireDefinitions: number;
|
|
21
|
+
/** Env for the subject process. `<run-dir>` and `<workspace>` are substituted per rep. */
|
|
22
|
+
env: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
/** The implicit control: today's behaviour, byte-identical, no extensions. */
|
|
25
|
+
export declare const NONE_ARM: Arm;
|
|
26
|
+
/** `<skills-root>/tests/arms.yaml`, or an empty map when the corpus declares none. */
|
|
27
|
+
export declare function loadArms(skillsRoot: string): Map<string, Arm>;
|
|
28
|
+
/** The named arm, or the control when no `--arm` was given. */
|
|
29
|
+
export declare function resolveArm(skillsRoot: string, name: string | null): Arm;
|
|
30
|
+
/**
|
|
31
|
+
* Copy an arm's definitions into `<workspace>/.pi/skills/` and return the count.
|
|
32
|
+
*
|
|
33
|
+
* Three refusals, each with a negative control in the suite, because a
|
|
34
|
+
* positive-only check measures less than it claims:
|
|
35
|
+
*
|
|
36
|
+
* 1. **Fewer definitions than required.** pi-daddy spawns definitions by path.
|
|
37
|
+
* Zero definitions means the arm has nothing to spawn, so it would run green
|
|
38
|
+
* and measure nothing — a vacuous result shaped exactly like a finding.
|
|
39
|
+
* 2. **A missing seed directory** — the `--skill <nonexistent>` class: pi accepts
|
|
40
|
+
* silently, so the run would look fine and measure a different thing.
|
|
41
|
+
* 3. **A non-empty ambient skills root.** pi-daddy reads `~/.pi/agent/skills` as
|
|
42
|
+
* well as the workspace, so anything there is an uncontrolled variable in the
|
|
43
|
+
* measurement. Empty on the reference box today — that is luck, not a
|
|
44
|
+
* guarantee, and this check is what turns it into one. It runs for any
|
|
45
|
+
* non-control arm, even one that declares no `seed_skills` — pi-daddy
|
|
46
|
+
* reads that root independently of whether the workspace was seeded, so
|
|
47
|
+
* an arm can't opt out of the guarantee just by not seeding.
|
|
48
|
+
*/
|
|
49
|
+
export declare function seedArmDefinitions(arm: Arm, skillsRoot: string, workspaceCwd: string, opts?: {
|
|
50
|
+
ambientSkillsDir?: string;
|
|
51
|
+
}): number;
|
package/dist/arms.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
/** The implicit control: today's behaviour, byte-identical, no extensions. */
|
|
6
|
+
export const NONE_ARM = { name: "none", extensions: [], seedSkills: [], requireDefinitions: 0, env: {} };
|
|
7
|
+
/** `none` is the control's name; an arm may not shadow it. */
|
|
8
|
+
const RESERVED = new Set(["none"]);
|
|
9
|
+
/**
|
|
10
|
+
* Arm names become a path segment, so they are restricted to what survives one
|
|
11
|
+
* unambiguously — no separators, no dots, nothing needing quoting.
|
|
12
|
+
*/
|
|
13
|
+
const NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
14
|
+
function expandHome(p) {
|
|
15
|
+
return p === "~" || p.startsWith("~/") ? join(homedir(), p.slice(1)) : p;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `realpathSync(p)`, or `p` when it cannot be resolved.
|
|
19
|
+
*
|
|
20
|
+
* A path that does not exist has no real path, and refusing here would replace
|
|
21
|
+
* the two callers' specific messages ("outside the skills root", "cannot be read
|
|
22
|
+
* — pi would start with nothing to spawn") with a bare ENOENT. The lexical path
|
|
23
|
+
* is the strictly safer fallback for the containment check: an absent path
|
|
24
|
+
* cannot be a symlink out of the corpus.
|
|
25
|
+
*/
|
|
26
|
+
function realpathOr(p) {
|
|
27
|
+
try {
|
|
28
|
+
return realpathSync(p);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `require_definitions`, refusing anything that is not a non-negative integer.
|
|
36
|
+
*
|
|
37
|
+
* A tolerant `Number(x) || 0` reads `require_definitions: six` (and any value
|
|
38
|
+
* YAML hands over as a string) as 0, which does not disable a typo — it disables
|
|
39
|
+
* the REFUSAL the key exists to make, so an arm that seeds nothing runs green and
|
|
40
|
+
* measures nothing. The one failure mode this key defends against is the one a
|
|
41
|
+
* silent default reintroduces.
|
|
42
|
+
*/
|
|
43
|
+
function parseRequireDefinitions(file, name, raw) {
|
|
44
|
+
if (raw === undefined || raw === null)
|
|
45
|
+
return 0;
|
|
46
|
+
const n = typeof raw === "number" ? raw : Number(String(raw).trim());
|
|
47
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
48
|
+
throw new Error(`${file}: arm \`${name}\` require_definitions is ${JSON.stringify(raw)} — it must be a non-negative integer, ` +
|
|
49
|
+
`and a value that silently read as 0 would disable the refusal it exists to make.`);
|
|
50
|
+
}
|
|
51
|
+
return n;
|
|
52
|
+
}
|
|
53
|
+
/** `<skills-root>/tests/arms.yaml`, or an empty map when the corpus declares none. */
|
|
54
|
+
export function loadArms(skillsRoot) {
|
|
55
|
+
const file = join(skillsRoot, "tests", "arms.yaml");
|
|
56
|
+
const out = new Map();
|
|
57
|
+
if (!existsSync(file))
|
|
58
|
+
return out;
|
|
59
|
+
const doc = yaml.load(readFileSync(file, "utf8"));
|
|
60
|
+
const raw = Array.isArray(doc?.arms) ? doc.arms : [];
|
|
61
|
+
for (const entry of raw) {
|
|
62
|
+
const name = typeof entry?.name === "string" ? entry.name.trim() : "";
|
|
63
|
+
if (!NAME_RE.test(name)) {
|
|
64
|
+
throw new Error(`${file}: arm name \`${name}\` must match ${NAME_RE} — it becomes a run-directory segment`);
|
|
65
|
+
}
|
|
66
|
+
if (RESERVED.has(name))
|
|
67
|
+
throw new Error(`${file}: arm name \`${name}\` is reserved for the implicit control`);
|
|
68
|
+
if (out.has(name))
|
|
69
|
+
throw new Error(`${file}: two arms are both named \`${name}\``);
|
|
70
|
+
// Resolved here, but NOT existence-checked here: `loadArms` reads every
|
|
71
|
+
// declared arm, so validating all of them would make one arm's
|
|
72
|
+
// machine-specific path (the corpus's `pi-daddy` points into a checkout that
|
|
73
|
+
// exists on one box) fail `--arm <some-other-arm>` — and `resolveArm`'s
|
|
74
|
+
// "unknown arm" message — for an arm the caller never asked for. The refusal
|
|
75
|
+
// moved to `resolveArm`, where only the SELECTED arm is checked.
|
|
76
|
+
const extensions = (Array.isArray(entry.extensions) ? entry.extensions : []).map((p) => {
|
|
77
|
+
const expanded = expandHome(String(p));
|
|
78
|
+
return isAbsolute(expanded) ? expanded : resolve(skillsRoot, expanded);
|
|
79
|
+
});
|
|
80
|
+
const env = {};
|
|
81
|
+
for (const [k, v] of Object.entries((entry.env ?? {}))) {
|
|
82
|
+
// A nested object/array value stringifies as the literal text
|
|
83
|
+
// "[object Object]" (or a comma-joined array) via `String(v)`, which would
|
|
84
|
+
// reach the subject process as a corrupted env var with no indication
|
|
85
|
+
// anything went wrong. Refused instead — env values must be scalars.
|
|
86
|
+
if (v !== null && typeof v === "object") {
|
|
87
|
+
throw new Error(`${file}: arm \`${name}\` env.${k} is not a scalar value — got ${JSON.stringify(v)}`);
|
|
88
|
+
}
|
|
89
|
+
env[k] = String(v);
|
|
90
|
+
}
|
|
91
|
+
out.set(name, {
|
|
92
|
+
name,
|
|
93
|
+
extensions,
|
|
94
|
+
seedSkills: (Array.isArray(entry.seed_skills) ? entry.seed_skills : []).map(String),
|
|
95
|
+
requireDefinitions: parseRequireDefinitions(file, name, entry.require_definitions),
|
|
96
|
+
env,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** The named arm, or the control when no `--arm` was given. */
|
|
102
|
+
export function resolveArm(skillsRoot, name) {
|
|
103
|
+
if (!name || name === NONE_ARM.name)
|
|
104
|
+
return NONE_ARM;
|
|
105
|
+
const arms = loadArms(skillsRoot);
|
|
106
|
+
const arm = arms.get(name);
|
|
107
|
+
if (!arm) {
|
|
108
|
+
const known = [...arms.keys()];
|
|
109
|
+
throw new Error(`unknown arm \`${name}\` — ${known.length ? `declared arms: ${known.join(", ")}` : `no arms declared in ${join(skillsRoot, "tests", "arms.yaml")}`}`);
|
|
110
|
+
}
|
|
111
|
+
// Same refusal `extensionFlags` makes, made earlier and for the same reason:
|
|
112
|
+
// pi accepts a missing `--extension` path by starting without it, so the
|
|
113
|
+
// scenario would silently test an agent with no delegation tool at all.
|
|
114
|
+
for (const abs of arm.extensions) {
|
|
115
|
+
if (!existsSync(abs)) {
|
|
116
|
+
throw new Error(`arm \`${arm.name}\` names extension ${abs}, which does not exist — pi would start without it`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return arm;
|
|
120
|
+
}
|
|
121
|
+
/** pi's second skill root, which pi-daddy reads too. */
|
|
122
|
+
function defaultAmbientSkillsDir() {
|
|
123
|
+
return join(homedir(), ".pi", "agent", "skills");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Copy an arm's definitions into `<workspace>/.pi/skills/` and return the count.
|
|
127
|
+
*
|
|
128
|
+
* Three refusals, each with a negative control in the suite, because a
|
|
129
|
+
* positive-only check measures less than it claims:
|
|
130
|
+
*
|
|
131
|
+
* 1. **Fewer definitions than required.** pi-daddy spawns definitions by path.
|
|
132
|
+
* Zero definitions means the arm has nothing to spawn, so it would run green
|
|
133
|
+
* and measure nothing — a vacuous result shaped exactly like a finding.
|
|
134
|
+
* 2. **A missing seed directory** — the `--skill <nonexistent>` class: pi accepts
|
|
135
|
+
* silently, so the run would look fine and measure a different thing.
|
|
136
|
+
* 3. **A non-empty ambient skills root.** pi-daddy reads `~/.pi/agent/skills` as
|
|
137
|
+
* well as the workspace, so anything there is an uncontrolled variable in the
|
|
138
|
+
* measurement. Empty on the reference box today — that is luck, not a
|
|
139
|
+
* guarantee, and this check is what turns it into one. It runs for any
|
|
140
|
+
* non-control arm, even one that declares no `seed_skills` — pi-daddy
|
|
141
|
+
* reads that root independently of whether the workspace was seeded, so
|
|
142
|
+
* an arm can't opt out of the guarantee just by not seeding.
|
|
143
|
+
*/
|
|
144
|
+
export function seedArmDefinitions(arm, skillsRoot, workspaceCwd, opts = {}) {
|
|
145
|
+
// The control arm loads no extension at all, so nothing can read the
|
|
146
|
+
// ambient root on its behalf — it's the only arm exempt from the check
|
|
147
|
+
// below. Every other arm must pass it even when it declares no
|
|
148
|
+
// seed_skills: pi-daddy reads ~/.pi/agent/skills independently of seeding,
|
|
149
|
+
// so an arm that loads pi-daddy via `extensions` alone must not be able to
|
|
150
|
+
// skip the guarantee that root is empty.
|
|
151
|
+
if (arm.name === NONE_ARM.name)
|
|
152
|
+
return 0;
|
|
153
|
+
const ambient = opts.ambientSkillsDir ?? defaultAmbientSkillsDir();
|
|
154
|
+
let ambientEntries = [];
|
|
155
|
+
try {
|
|
156
|
+
ambientEntries = readdirSync(ambient);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
// Absent is as good as empty: nothing can leak from a directory that
|
|
160
|
+
// isn't there. Anything else (e.g. EACCES) is not equivalent to empty —
|
|
161
|
+
// it means we couldn't verify the guarantee, so it must surface, not be
|
|
162
|
+
// silently read as "clean".
|
|
163
|
+
if (err.code === "ENOENT") {
|
|
164
|
+
ambientEntries = [];
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
throw new Error(`arm \`${arm.name}\`: could not read the ambient skill root ${ambient}: ${err.message} — ` +
|
|
168
|
+
`pi-daddy reads it as well as the workspace, so this can't be verified empty.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (ambientEntries.length > 0) {
|
|
172
|
+
throw new Error(`arm \`${arm.name}\`: the ambient skill root ${ambient} is not empty (${ambientEntries.slice(0, 5).join(", ")}) — ` +
|
|
173
|
+
`pi-daddy reads it as well as the workspace, so those definitions would be an uncontrolled variable in the measurement. ` +
|
|
174
|
+
`Move them aside for the run.`);
|
|
175
|
+
}
|
|
176
|
+
// Checked BEFORE the early return below, not after: an empty (or vacuously
|
|
177
|
+
// typo'd — `seed_skill:` instead of `seed_skills:`) list must hit the same
|
|
178
|
+
// `require_definitions` refusal as a directory that seeds too few. This is
|
|
179
|
+
// the same bypass shape the ambient-root check above was already hoisted to
|
|
180
|
+
// avoid — an arm with `require_definitions: 6` and no `seed_skills` would
|
|
181
|
+
// otherwise seed 0, return early, and run tagged `+pi-daddy` with nothing to
|
|
182
|
+
// spawn: the exact vacuous arm this refusal exists to prevent.
|
|
183
|
+
if (arm.seedSkills.length === 0) {
|
|
184
|
+
if (arm.requireDefinitions > 0) {
|
|
185
|
+
throw new Error(`arm \`${arm.name}\`: seeded 0 definition(s) (no \`seed_skills\` declared) but require_definitions is ${arm.requireDefinitions} — ` +
|
|
186
|
+
`pi-daddy would have nothing to spawn, and the arm would measure nothing while looking green.`);
|
|
187
|
+
}
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
const dest = join(workspaceCwd, ".pi", "skills");
|
|
191
|
+
mkdirSync(dest, { recursive: true });
|
|
192
|
+
// `realpathSync` and not `resolve`: `resolve` is pure lexical arithmetic on the
|
|
193
|
+
// path string, so a `seed_skills` entry naming a SYMLINK inside the corpus that
|
|
194
|
+
// points outside it passes a `startsWith` check on the lexical path and seeds
|
|
195
|
+
// from outside the corpus anyway — the containment guarantee held for `../..`
|
|
196
|
+
// and not for the equivalent written as a link.
|
|
197
|
+
const resolvedRoot = realpathOr(resolve(skillsRoot));
|
|
198
|
+
const seen = new Set();
|
|
199
|
+
for (const rel of arm.seedSkills) {
|
|
200
|
+
const src = realpathOr(resolve(skillsRoot, rel));
|
|
201
|
+
// `seed_skills` entries are meant to name directories WITHIN the corpus —
|
|
202
|
+
// `resolve(skillsRoot, "../..")` is accepted by `resolve()` without complaint,
|
|
203
|
+
// and would seed the workspace from whatever happens to sit outside the
|
|
204
|
+
// corpus root. Refused rather than silently followed.
|
|
205
|
+
if (src !== resolvedRoot && !src.startsWith(resolvedRoot + sep)) {
|
|
206
|
+
throw new Error(`arm \`${arm.name}\`: seed_skills entry ${JSON.stringify(rel)} resolves to ${src}, which is outside the skills root ${resolvedRoot} — refusing to seed from outside the corpus`);
|
|
207
|
+
}
|
|
208
|
+
let names;
|
|
209
|
+
try {
|
|
210
|
+
names = readdirSync(src);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
throw new Error(`arm \`${arm.name}\`: seed_skills names ${src}, which cannot be read — pi would start with nothing to spawn`);
|
|
214
|
+
}
|
|
215
|
+
for (const name of names) {
|
|
216
|
+
const from = join(src, name);
|
|
217
|
+
if (!name.endsWith(".md"))
|
|
218
|
+
continue;
|
|
219
|
+
let isFile;
|
|
220
|
+
try {
|
|
221
|
+
isFile = statSync(from).isFile();
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
// A dangling symlink (or a file removed between readdir and stat) throws
|
|
225
|
+
// here uncaught otherwise — an unclassified crash instead of a message
|
|
226
|
+
// naming the offending file.
|
|
227
|
+
throw new Error(`arm \`${arm.name}\`: seed_skills entry ${JSON.stringify(rel)} contains ${from}, which cannot be read (${err.message}) — likely a dangling symlink`);
|
|
228
|
+
}
|
|
229
|
+
if (!isFile)
|
|
230
|
+
continue;
|
|
231
|
+
// `dest` is ONE flat directory, so two seed dirs shipping the same basename
|
|
232
|
+
// resolve to the same destination path: the second copy silently overwrites
|
|
233
|
+
// the first. Counting copies rather than files then lets `require_definitions:
|
|
234
|
+
// 6` be satisfied by 6 copies that left 3 files on disk — the vacuous arm the
|
|
235
|
+
// refusal exists to prevent, reached through the refusal itself. Refused,
|
|
236
|
+
// because the alternative is that whichever entry is listed last wins and
|
|
237
|
+
// nothing says so.
|
|
238
|
+
if (seen.has(name)) {
|
|
239
|
+
throw new Error(`arm \`${arm.name}\`: two seed_skills entries both provide \`${name}\` (latest: ${from}) — ` +
|
|
240
|
+
`they are copied into the one flat directory ${dest}, so one would silently overwrite the other. ` +
|
|
241
|
+
`Rename one, or drop the duplicate entry.`);
|
|
242
|
+
}
|
|
243
|
+
seen.add(name);
|
|
244
|
+
copyFileSync(from, join(dest, name));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Distinct definitions ON DISK, which is what pi-daddy can actually spawn.
|
|
248
|
+
const count = seen.size;
|
|
249
|
+
if (count < arm.requireDefinitions) {
|
|
250
|
+
throw new Error(`arm \`${arm.name}\`: seeded ${count} definition(s) into ${dest} but require_definitions is ${arm.requireDefinitions} — ` +
|
|
251
|
+
`pi-daddy would have nothing (or too little) to spawn, and the arm would measure nothing while looking green.`);
|
|
252
|
+
}
|
|
253
|
+
return count;
|
|
254
|
+
}
|
|
255
|
+
//# sourceMappingURL=arms.js.map
|
|
@@ -83,12 +83,14 @@ export interface TraceResultMeta {
|
|
|
83
83
|
* conversation carries in the session dir, not in the event stream). This
|
|
84
84
|
* mirrors the loop the adapter already runs.
|
|
85
85
|
*/
|
|
86
|
+
export type CostSource = "provider-reported" | "subscription" | "unreported";
|
|
86
87
|
export interface TraceMetrics {
|
|
87
88
|
input_tokens: number;
|
|
88
89
|
output_tokens: number;
|
|
89
90
|
cache_read_tokens: number;
|
|
90
91
|
cache_write_tokens: number;
|
|
91
92
|
cost_usd: number;
|
|
93
|
+
cost_source: CostSource;
|
|
92
94
|
tool_calls: number;
|
|
93
95
|
delegated_children: number;
|
|
94
96
|
/** Maximum simultaneously outstanding tool calls observed in the pi stream. */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI commands guaranteed to make no subject or judge calls.
|
|
3
|
+
*
|
|
4
|
+
* Keep this list narrow: commands that can conditionally spend (notably `regate`)
|
|
5
|
+
* do not belong here even when their common path only replays saved artifacts.
|
|
6
|
+
*/
|
|
7
|
+
export declare const FREE_OFFLINE_COMMANDS: readonly ["affected", "coverage", "init", "judge-agreement", "lint", "list", "mutation-test", "rescore", "restamp", "screen", "stability"];
|
|
8
|
+
export type FreeOfflineCommand = typeof FREE_OFFLINE_COMMANDS[number];
|
|
9
|
+
export declare function isFreeOfflineCommand(command: string): command is FreeOfflineCommand;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI commands guaranteed to make no subject or judge calls.
|
|
3
|
+
*
|
|
4
|
+
* Keep this list narrow: commands that can conditionally spend (notably `regate`)
|
|
5
|
+
* do not belong here even when their common path only replays saved artifacts.
|
|
6
|
+
*/
|
|
7
|
+
export const FREE_OFFLINE_COMMANDS = [
|
|
8
|
+
"affected",
|
|
9
|
+
"coverage",
|
|
10
|
+
"init",
|
|
11
|
+
"judge-agreement",
|
|
12
|
+
"lint",
|
|
13
|
+
"list",
|
|
14
|
+
"mutation-test",
|
|
15
|
+
"rescore",
|
|
16
|
+
"restamp",
|
|
17
|
+
"screen",
|
|
18
|
+
"stability",
|
|
19
|
+
];
|
|
20
|
+
export function isFreeOfflineCommand(command) {
|
|
21
|
+
return FREE_OFFLINE_COMMANDS.some((candidate) => candidate === command);
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=command-cost.js.map
|
package/dist/comparison.d.ts
CHANGED
|
@@ -45,6 +45,7 @@ export interface AggregateMetrics {
|
|
|
45
45
|
cache_read_tokens: number | null;
|
|
46
46
|
cache_write_tokens: number | null;
|
|
47
47
|
subject_cost_usd: number | null;
|
|
48
|
+
cost_source: "provider-reported" | "subscription" | "unreported" | null;
|
|
48
49
|
tool_calls: number | null;
|
|
49
50
|
delegated_children: number | null;
|
|
50
51
|
max_concurrency: number | null;
|
package/dist/comparison.js
CHANGED
|
@@ -51,8 +51,8 @@ export function buildComparison(input) {
|
|
|
51
51
|
const behavioral = cells.filter((cell) => cell.change === "regression").map((cell) => cell.id);
|
|
52
52
|
const criticalRegressions = cells.filter((cell) => cell.change === "regression" && cell.critical).map((cell) => cell.id);
|
|
53
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);
|
|
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
56
|
const metrics = { reference: aggregateMetrics(reference.scenarios), candidate: aggregateMetrics(candidate.scenarios) };
|
|
57
57
|
const costRegressions = thresholdFindings(metrics.reference, metrics.candidate, input.thresholds);
|
|
58
58
|
const partial = input.partial === true || reference.partial === true || candidate.partial === true;
|
|
@@ -137,6 +137,10 @@ export function aggregateMetrics(scenarios) {
|
|
|
137
137
|
cache_read_tokens: sumOptional("cache_read_tokens"),
|
|
138
138
|
cache_write_tokens: sumOptional("cache_write_tokens"),
|
|
139
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
|
+
})(),
|
|
140
144
|
tool_calls: sumOptional("tool_calls"),
|
|
141
145
|
delegated_children: sumOptional("delegated_children"),
|
|
142
146
|
max_concurrency: maxValues.length ? Math.max(...maxValues) : null,
|
|
@@ -147,11 +151,13 @@ function effective(result) {
|
|
|
147
151
|
return result.override;
|
|
148
152
|
if (result.objective?.status === "ERROR")
|
|
149
153
|
return "ERROR";
|
|
154
|
+
if (result.objective?.status === "NOT-MEASURED")
|
|
155
|
+
return "NOT-MEASURED";
|
|
150
156
|
if (result.objective?.status === "FAIL")
|
|
151
157
|
return "FAIL";
|
|
152
158
|
return result.judge_verdict;
|
|
153
159
|
}
|
|
154
|
-
function untrustworthy(result, verdict) { return result.suspect || verdict === "ERROR" || verdict === "JUDGE-AMBIGUOUS"; }
|
|
160
|
+
function untrustworthy(result, verdict) { return result.suspect || verdict === "ERROR" || verdict === "NOT-MEASURED" || verdict === "JUDGE-AMBIGUOUS"; }
|
|
155
161
|
function sorted(value) { return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))); }
|
|
156
162
|
function increase(reference, candidate) { return reference === null || candidate === null || reference <= 0 ? null : (candidate - reference) / reference; }
|
|
157
163
|
function thresholdFindings(reference, candidate, thresholds) {
|