@skill-harness/core 0.9.0 → 0.11.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/dist/adapters/types.d.ts +11 -0
- package/dist/arms.d.ts +51 -0
- package/dist/arms.js +255 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/journal.d.ts +1 -0
- package/dist/provider-failure.d.ts +56 -0
- package/dist/provider-failure.js +98 -0
- package/dist/regate.js +7 -0
- package/dist/regrade.js +25 -1
- package/dist/rescore.js +7 -0
- package/dist/results.d.ts +48 -2
- package/dist/results.js +18 -3
- package/dist/run.d.ts +14 -0
- package/dist/run.js +201 -47
- package/dist/seeded.d.ts +10 -0
- package/dist/seeded.js +8 -2
- package/dist/workspace.js +30 -2
- package/package.json +1 -1
package/dist/adapters/types.d.ts
CHANGED
|
@@ -44,6 +44,12 @@ export interface RunReq {
|
|
|
44
44
|
extensions?: string[];
|
|
45
45
|
/** Workspace-local native ledgers to normalize after the subject finishes. */
|
|
46
46
|
eventSources?: TrajectoryEventSource[];
|
|
47
|
+
/**
|
|
48
|
+
* Extra env for the subject process, from the arm. Merged over `process.env` by
|
|
49
|
+
* the adapter — an arm must be able to add `PI_GRANTS_*` without the harness
|
|
50
|
+
* inheriting a caller's copy of them.
|
|
51
|
+
*/
|
|
52
|
+
armEnv?: Record<string, string>;
|
|
47
53
|
}
|
|
48
54
|
/** A judge request: single prompt, no skills, no session. */
|
|
49
55
|
export interface JudgeReq {
|
|
@@ -60,6 +66,11 @@ export interface StructuredRun {
|
|
|
60
66
|
events?: TrajectoryEventV1[];
|
|
61
67
|
/** Native sources that were required but missing/malformed. Never treated as an empty success. */
|
|
62
68
|
eventErrors?: string[];
|
|
69
|
+
/**
|
|
70
|
+
* Set when pi failed provider-side (auth, transport) rather than the model
|
|
71
|
+
* answering badly. `run.ts` turns this into ERROR — never a model verdict.
|
|
72
|
+
*/
|
|
73
|
+
providerFailure?: string;
|
|
63
74
|
}
|
|
64
75
|
export interface HarnessAdapter {
|
|
65
76
|
name: string;
|
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>` is substituted per run. */
|
|
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
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/journal.d.ts
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-side failure detection.
|
|
3
|
+
*
|
|
4
|
+
* A provider outage is INFRASTRUCTURE, never a model verdict. Measured 2026-08-22:
|
|
5
|
+
* an invalidated `openai-codex` OAuth token makes pi report
|
|
6
|
+
* `stopReason: "error"` with a `provider_transport_failure` diagnostic and **exit 0**
|
|
7
|
+
* in `--mode json`, and exit 1 with `Encountered invalidated oauth token for user,
|
|
8
|
+
* failing request` on stderr in text mode. Neither path was classified, so a wave
|
|
9
|
+
* against a dead token produced model FAILs shaped exactly like findings.
|
|
10
|
+
*
|
|
11
|
+
* Two entry points because the two run paths surface it differently, and the
|
|
12
|
+
* structured one is the WEAKER signal — it hides the real message behind a generic
|
|
13
|
+
* transport error and exits 0. Do not assume `--structured` improves diagnosis.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Written into a transcript by the adapter when pi failed provider-side, so the
|
|
17
|
+
* text path can carry a machine-readable signal through a `string` return without
|
|
18
|
+
* widening `HarnessAdapter.run`. Read back by `providerFailureFromTranscript`.
|
|
19
|
+
*
|
|
20
|
+
* The marker must not be forgeable by a model that types the same words into its
|
|
21
|
+
* answer — and a verdict is what hangs on it, since a forged marker converts a
|
|
22
|
+
* would-be FAIL into ERROR and (via `judgeOneRep`'s short-circuit) permanently
|
|
23
|
+
* suppresses the judge on every later re-grade. Line-anchoring alone does not do
|
|
24
|
+
* that: nothing stops a model emitting a newline and then the marker.
|
|
25
|
+
*
|
|
26
|
+
* So position, not spelling, is the guarantee. The marker is written ONLY into the
|
|
27
|
+
* transcript PREAMBLE — everything before the first `>>> ` turn header — which the
|
|
28
|
+
* adapter composes before any model output exists. There is no ordering by which a
|
|
29
|
+
* model's text can land there. See `withProviderFailure` (write) and
|
|
30
|
+
* `providerFailureFromTranscript` (read); both live here so the two halves of the
|
|
31
|
+
* convention cannot drift apart.
|
|
32
|
+
*/
|
|
33
|
+
export declare const PROVIDER_FAILURE_MARKER = "[skill-harness] provider failure:";
|
|
34
|
+
/**
|
|
35
|
+
* Prefix `transcript` with the provider-failure marker, or return it unchanged.
|
|
36
|
+
*
|
|
37
|
+
* The one supported way to write the marker: it must precede the first turn header
|
|
38
|
+
* to be readable back, and putting the rule next to the reader keeps a future
|
|
39
|
+
* adapter from re-introducing the forgeable placement.
|
|
40
|
+
*/
|
|
41
|
+
export declare function withProviderFailure(transcript: string, failure: string | null): string;
|
|
42
|
+
/**
|
|
43
|
+
* A provider failure named by one `pi --mode json` line, or null.
|
|
44
|
+
*
|
|
45
|
+
* Fail-closed on the diagnostic, fail-open on the parse: an unreadable line is not
|
|
46
|
+
* evidence of a failure, and throwing here would abort a wave over one bad line.
|
|
47
|
+
*/
|
|
48
|
+
export declare function providerFailureFromJsonLine(line: string): string | null;
|
|
49
|
+
/**
|
|
50
|
+
* The failure the adapter recorded in a transcript's preamble, or null.
|
|
51
|
+
*
|
|
52
|
+
* Scanning STOPS at the first turn header. Everything from there on is model
|
|
53
|
+
* output or interleaved with it, so a marker found there is not evidence the
|
|
54
|
+
* provider failed — it is evidence the model typed the words.
|
|
55
|
+
*/
|
|
56
|
+
export declare function providerFailureFromTranscript(transcript: string): string | null;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-side failure detection.
|
|
3
|
+
*
|
|
4
|
+
* A provider outage is INFRASTRUCTURE, never a model verdict. Measured 2026-08-22:
|
|
5
|
+
* an invalidated `openai-codex` OAuth token makes pi report
|
|
6
|
+
* `stopReason: "error"` with a `provider_transport_failure` diagnostic and **exit 0**
|
|
7
|
+
* in `--mode json`, and exit 1 with `Encountered invalidated oauth token for user,
|
|
8
|
+
* failing request` on stderr in text mode. Neither path was classified, so a wave
|
|
9
|
+
* against a dead token produced model FAILs shaped exactly like findings.
|
|
10
|
+
*
|
|
11
|
+
* Two entry points because the two run paths surface it differently, and the
|
|
12
|
+
* structured one is the WEAKER signal — it hides the real message behind a generic
|
|
13
|
+
* transport error and exits 0. Do not assume `--structured` improves diagnosis.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Written into a transcript by the adapter when pi failed provider-side, so the
|
|
17
|
+
* text path can carry a machine-readable signal through a `string` return without
|
|
18
|
+
* widening `HarnessAdapter.run`. Read back by `providerFailureFromTranscript`.
|
|
19
|
+
*
|
|
20
|
+
* The marker must not be forgeable by a model that types the same words into its
|
|
21
|
+
* answer — and a verdict is what hangs on it, since a forged marker converts a
|
|
22
|
+
* would-be FAIL into ERROR and (via `judgeOneRep`'s short-circuit) permanently
|
|
23
|
+
* suppresses the judge on every later re-grade. Line-anchoring alone does not do
|
|
24
|
+
* that: nothing stops a model emitting a newline and then the marker.
|
|
25
|
+
*
|
|
26
|
+
* So position, not spelling, is the guarantee. The marker is written ONLY into the
|
|
27
|
+
* transcript PREAMBLE — everything before the first `>>> ` turn header — which the
|
|
28
|
+
* adapter composes before any model output exists. There is no ordering by which a
|
|
29
|
+
* model's text can land there. See `withProviderFailure` (write) and
|
|
30
|
+
* `providerFailureFromTranscript` (read); both live here so the two halves of the
|
|
31
|
+
* convention cannot drift apart.
|
|
32
|
+
*/
|
|
33
|
+
export const PROVIDER_FAILURE_MARKER = "[skill-harness] provider failure:";
|
|
34
|
+
/**
|
|
35
|
+
* The adapters' shared turn-header prefix (`>>> USER:` / `>>> USER (turn n/m):`),
|
|
36
|
+
* which is where the preamble ends and model-influenced content begins.
|
|
37
|
+
*/
|
|
38
|
+
const TURN_HEADER_PREFIX = ">>> ";
|
|
39
|
+
/**
|
|
40
|
+
* Prefix `transcript` with the provider-failure marker, or return it unchanged.
|
|
41
|
+
*
|
|
42
|
+
* The one supported way to write the marker: it must precede the first turn header
|
|
43
|
+
* to be readable back, and putting the rule next to the reader keeps a future
|
|
44
|
+
* adapter from re-introducing the forgeable placement.
|
|
45
|
+
*/
|
|
46
|
+
export function withProviderFailure(transcript, failure) {
|
|
47
|
+
return failure ? `${PROVIDER_FAILURE_MARKER} ${failure}\n\n${transcript}` : transcript;
|
|
48
|
+
}
|
|
49
|
+
/** Diagnostic types that mean the provider never ran the request. */
|
|
50
|
+
const FAILURE_DIAGNOSTICS = new Set(["provider_transport_failure"]);
|
|
51
|
+
/**
|
|
52
|
+
* A provider failure named by one `pi --mode json` line, or null.
|
|
53
|
+
*
|
|
54
|
+
* Fail-closed on the diagnostic, fail-open on the parse: an unreadable line is not
|
|
55
|
+
* evidence of a failure, and throwing here would abort a wave over one bad line.
|
|
56
|
+
*/
|
|
57
|
+
export function providerFailureFromJsonLine(line) {
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(line);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const message = parsed?.message;
|
|
66
|
+
if (!message || typeof message !== "object")
|
|
67
|
+
return null;
|
|
68
|
+
const diagnostics = message.diagnostics;
|
|
69
|
+
if (!Array.isArray(diagnostics))
|
|
70
|
+
return null;
|
|
71
|
+
for (const raw of diagnostics) {
|
|
72
|
+
if (typeof raw?.type !== "string" || !FAILURE_DIAGNOSTICS.has(raw.type))
|
|
73
|
+
continue;
|
|
74
|
+
const provider = typeof message.provider === "string"
|
|
75
|
+
? message.provider
|
|
76
|
+
: "unknown provider";
|
|
77
|
+
const detail = typeof raw.error?.message === "string" ? raw.error.message : raw.type;
|
|
78
|
+
return `${provider}: ${detail}`;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The failure the adapter recorded in a transcript's preamble, or null.
|
|
84
|
+
*
|
|
85
|
+
* Scanning STOPS at the first turn header. Everything from there on is model
|
|
86
|
+
* output or interleaved with it, so a marker found there is not evidence the
|
|
87
|
+
* provider failed — it is evidence the model typed the words.
|
|
88
|
+
*/
|
|
89
|
+
export function providerFailureFromTranscript(transcript) {
|
|
90
|
+
for (const line of transcript.split("\n")) {
|
|
91
|
+
if (line.startsWith(TURN_HEADER_PREFIX))
|
|
92
|
+
return null;
|
|
93
|
+
if (line.startsWith(PROVIDER_FAILURE_MARKER))
|
|
94
|
+
return line.slice(PROVIDER_FAILURE_MARKER.length).trim();
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=provider-failure.js.map
|
package/dist/regate.js
CHANGED
|
@@ -305,6 +305,13 @@ export async function regateRun(opts) {
|
|
|
305
305
|
skill: prev.skill, harness: prev.harness, model: prev.model,
|
|
306
306
|
// Carried verbatim: a regate re-reads saved diffs, it never re-runs the harness.
|
|
307
307
|
harness_cli_version: prev.harness_cli_version, delivery_canary: prev.delivery_canary,
|
|
308
|
+
// The arm is provenance of the MEASUREMENT, not of this rewrite, and it is the
|
|
309
|
+
// only record that a `+<arm>` run actually delegated: rebuilding the draft
|
|
310
|
+
// field-by-field without it silently deleted `definitions`/`ledger_events`
|
|
311
|
+
// from any arm run that was ever re-graded, leaving a record
|
|
312
|
+
// indistinguishable from a vacuous arm. Same reason `harness_cli_version`,
|
|
313
|
+
// `delivery_canary` and `source_hashes` are carried here.
|
|
314
|
+
arm: prev.arm,
|
|
308
315
|
judge: { provider: opts.judge.provider, model: opts.judge.model },
|
|
309
316
|
timestamp: prev.timestamp, label: prev.label, mode: prev.mode, partial: prev.partial,
|
|
310
317
|
// Only the `gates:` keys of the scenarios actually re-evaluated. Stimulus, rubric
|
package/dist/regrade.js
CHANGED
|
@@ -5,6 +5,7 @@ import { findTranscriptFiles, judgeRawPath, repIndexOf, readResults, writeResult
|
|
|
5
5
|
import { outcomesToResult } from "./reps.js";
|
|
6
6
|
import { appendJournal } from "./journal.js";
|
|
7
7
|
import { rubricDigest, personaDigest, RUBRIC_PREFIX, PERSONA_KEY } from "./sources.js";
|
|
8
|
+
import { providerFailureFromTranscript } from "./provider-failure.js";
|
|
8
9
|
/**
|
|
9
10
|
* Carry a run's recorded hashes forward, refreshing only the `rubric:` keys this
|
|
10
11
|
* re-grade actually judged under (plus the persona, which applies to all of them).
|
|
@@ -37,10 +38,26 @@ export function refreshRubricHashes(recorded, spec, judgedIds) {
|
|
|
37
38
|
export async function judgeOneRep(opts) {
|
|
38
39
|
const { runDir, spec, scenario, transcript, adapter, judge, specDir, mode, rep, now } = opts;
|
|
39
40
|
const startedAt = performance.now();
|
|
41
|
+
const repField = rep === undefined ? {} : { rep };
|
|
42
|
+
// A provider outage recorded in the transcript (either by `run()`'s marker or,
|
|
43
|
+
// since the fix for I2, by `runStructured` too) must stay ERROR through a
|
|
44
|
+
// re-judge. Without this check, `grade`/`regrade` reads the saved transcript
|
|
45
|
+
// with no infrastructure-failure guard at all — the one `run.ts` applies BEFORE
|
|
46
|
+
// ever calling this function — and re-judges "provider never ran the request"
|
|
47
|
+
// into a model verdict, spending a judge call to turn infrastructure noise into
|
|
48
|
+
// a false FAIL.
|
|
49
|
+
const providerFailure = providerFailureFromTranscript(transcript);
|
|
50
|
+
if (providerFailure) {
|
|
51
|
+
const reason = `provider failure — ${providerFailure}`;
|
|
52
|
+
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: "ERROR", reason, suspect: false, ...repField });
|
|
53
|
+
return {
|
|
54
|
+
verdict: "ERROR", reason, suspect: false,
|
|
55
|
+
metrics: { wall_time_ms: Math.max(0, Math.round(performance.now() - startedAt)), judge_calls: 0, judge_rejudge_calls: 0 },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
40
58
|
const prompt = buildJudgePrompt({ skill: spec.skill, persona: spec.judge_persona, scenario, transcript });
|
|
41
59
|
const g = await judgeInWorkspace(adapter, judge, prompt, specDir);
|
|
42
60
|
writeFileSync(judgeRawPath(runDir, scenario.id, mode, rep), g.raw, "utf8");
|
|
43
|
-
const repField = rep === undefined ? {} : { rep };
|
|
44
61
|
appendJournal(runDir, { event: "judge-verdict", ts: now(), id: scenario.id, verdict: g.verdict, reason: g.reason, suspect: g.suspect, ...repField });
|
|
45
62
|
if (g.suspect)
|
|
46
63
|
appendJournal(runDir, { event: "misfire-flag", ts: now(), id: scenario.id, reason: g.reason, ...repField });
|
|
@@ -180,6 +197,13 @@ export async function regradeRun(opts) {
|
|
|
180
197
|
// here would credit the old transcripts to a version that never ran them.
|
|
181
198
|
harness_cli_version: prev?.harness_cli_version,
|
|
182
199
|
delivery_canary: prev?.delivery_canary,
|
|
200
|
+
// The arm is provenance of the MEASUREMENT, not of this rewrite, and it is the
|
|
201
|
+
// only record that a `+<arm>` run actually delegated: rebuilding the draft
|
|
202
|
+
// field-by-field without it silently deleted `definitions`/`ledger_events`
|
|
203
|
+
// from any arm run that was ever re-graded, leaving a record
|
|
204
|
+
// indistinguishable from a vacuous arm. Same reason `harness_cli_version`,
|
|
205
|
+
// `delivery_canary` and `source_hashes` are carried here.
|
|
206
|
+
arm: prev?.arm,
|
|
183
207
|
model: prev?.model ?? "unknown",
|
|
184
208
|
judge: { provider: judge.provider, model: judge.model },
|
|
185
209
|
timestamp: prev?.timestamp ?? now(),
|
package/dist/rescore.js
CHANGED
|
@@ -85,6 +85,13 @@ export function rescoreRun(opts) {
|
|
|
85
85
|
// threshold to reps the recorded harness already produced.
|
|
86
86
|
harness_cli_version: prev.harness_cli_version,
|
|
87
87
|
delivery_canary: prev.delivery_canary,
|
|
88
|
+
// The arm is provenance of the MEASUREMENT, not of this rewrite, and it is the
|
|
89
|
+
// only record that a `+<arm>` run actually delegated: rebuilding the draft
|
|
90
|
+
// field-by-field without it silently deleted `definitions`/`ledger_events`
|
|
91
|
+
// from any arm run that was ever re-graded, leaving a record
|
|
92
|
+
// indistinguishable from a vacuous arm. Same reason `harness_cli_version`,
|
|
93
|
+
// `delivery_canary` and `source_hashes` are carried here.
|
|
94
|
+
arm: prev.arm,
|
|
88
95
|
// A rescore re-applies the CURRENT policy (thresholds, critical set) to the
|
|
89
96
|
// recorded reps, so `policy:` drift is genuinely resolved by having run this —
|
|
90
97
|
// that is what makes `rescore` the honest remedy lint names for it. Stimulus,
|
package/dist/results.d.ts
CHANGED
|
@@ -157,6 +157,39 @@ export interface ResultsFile {
|
|
|
157
157
|
timestamp: string;
|
|
158
158
|
label: string | null;
|
|
159
159
|
mode: string;
|
|
160
|
+
/**
|
|
161
|
+
* The arm this run was measured under, absent for the control.
|
|
162
|
+
*
|
|
163
|
+
* Recorded rather than inferred from the tag: the tag says which arm, this says
|
|
164
|
+
* what the arm actually loaded — a run whose extension list or definition count
|
|
165
|
+
* differed from today's `arms.yaml` is not comparable, and only the record can
|
|
166
|
+
* show that.
|
|
167
|
+
*
|
|
168
|
+
* `ledger_events` is the arm's delivery proof, `--canary`'s equivalent for this
|
|
169
|
+
* axis: the pi-daddy ledger itself is gitignored (`*.jsonl`), so without a
|
|
170
|
+
* count here a vacuous arm run (extension loaded, nothing ever delegated)
|
|
171
|
+
* commits a record indistinguishable from one that actually exercised
|
|
172
|
+
* delegation. 0 is a reportable outcome, not an absent field.
|
|
173
|
+
*
|
|
174
|
+
* `env` is the arm's declared env for the subject process (`PI_GRANTS_GRANT`,
|
|
175
|
+
* `PI_GRANTS_MAX_DEPTH`, …) — recorded because it IS the experimental
|
|
176
|
+
* condition, not decoration around it. Without it, changing `PI_GRANTS_MAX_DEPTH`
|
|
177
|
+
* from 1 to 3 and re-running lands both runs in the same `+<arm>` tag with
|
|
178
|
+
* byte-identical `arm` records, and `stability` reads a verdict flip between two
|
|
179
|
+
* different conditions as one lineage being bimodal — the exact misreading the
|
|
180
|
+
* tag placement exists to prevent. Recorded as DECLARED, with `<run-dir>`
|
|
181
|
+
* unsubstituted: the substituted form differs every run for the same condition,
|
|
182
|
+
* which would make two identical conditions look like two.
|
|
183
|
+
*
|
|
184
|
+
* Optional only for records written before it was added; every writer emits it.
|
|
185
|
+
*/
|
|
186
|
+
arm?: {
|
|
187
|
+
name: string;
|
|
188
|
+
extensions: string[];
|
|
189
|
+
definitions: number;
|
|
190
|
+
ledger_events: number;
|
|
191
|
+
env?: Record<string, string>;
|
|
192
|
+
};
|
|
160
193
|
/** True for an `--only`-filtered run: a scenario subset, never ship-graded, never a release run. */
|
|
161
194
|
partial?: boolean;
|
|
162
195
|
/**
|
|
@@ -227,8 +260,21 @@ export interface ScoreContext {
|
|
|
227
260
|
shipBar: ShipBar;
|
|
228
261
|
critical: string[];
|
|
229
262
|
}
|
|
230
|
-
/**
|
|
231
|
-
|
|
263
|
+
/**
|
|
264
|
+
* `<skillDir>/tests/results/<harness>-<model-slug>[+<arm>]/<timestamp-slug>/`
|
|
265
|
+
*
|
|
266
|
+
* The arm is part of the run's IDENTITY, not of the spec's digest. `lint` and
|
|
267
|
+
* `stability` both key on this tag, so two arms become separate lineages that can
|
|
268
|
+
* never be misread as one lineage flipping run-over-run — and no
|
|
269
|
+
* `specification.yaml` byte moves, so every committed run stays valid.
|
|
270
|
+
*
|
|
271
|
+
* `+` is deliberate and is appended OUTSIDE `modelSlug`, whose character class
|
|
272
|
+
* (`[^A-Za-z0-9._-]`) cannot emit one. So the separator can never occur inside a
|
|
273
|
+
* slug and the tag stays unambiguously splittable back into model and arm. The
|
|
274
|
+
* control arm appends nothing, so its paths are byte-identical to before arms
|
|
275
|
+
* existed.
|
|
276
|
+
*/
|
|
277
|
+
export declare function runDirFor(skillDir: string, harness: string, model: ModelRef, timestamp: string, armName?: string): string;
|
|
232
278
|
/** Path of a transcript file within a run dir. A rep index (for --reps N>1) is suffixed. */
|
|
233
279
|
export declare function transcriptPath(runDir: string, scenarioId: string, mode: string, rep?: number): string;
|
|
234
280
|
export declare function reportPath(runDir: string): string;
|
package/dist/results.js
CHANGED
|
@@ -87,9 +87,23 @@ export function effectiveThreshold(prevScenario, scenario) {
|
|
|
87
87
|
function timestampSlug(iso) {
|
|
88
88
|
return iso.replace(/[:.]/g, "-");
|
|
89
89
|
}
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
/**
|
|
91
|
+
* `<skillDir>/tests/results/<harness>-<model-slug>[+<arm>]/<timestamp-slug>/`
|
|
92
|
+
*
|
|
93
|
+
* The arm is part of the run's IDENTITY, not of the spec's digest. `lint` and
|
|
94
|
+
* `stability` both key on this tag, so two arms become separate lineages that can
|
|
95
|
+
* never be misread as one lineage flipping run-over-run — and no
|
|
96
|
+
* `specification.yaml` byte moves, so every committed run stays valid.
|
|
97
|
+
*
|
|
98
|
+
* `+` is deliberate and is appended OUTSIDE `modelSlug`, whose character class
|
|
99
|
+
* (`[^A-Za-z0-9._-]`) cannot emit one. So the separator can never occur inside a
|
|
100
|
+
* slug and the tag stays unambiguously splittable back into model and arm. The
|
|
101
|
+
* control arm appends nothing, so its paths are byte-identical to before arms
|
|
102
|
+
* existed.
|
|
103
|
+
*/
|
|
104
|
+
export function runDirFor(skillDir, harness, model, timestamp, armName) {
|
|
105
|
+
const arm = armName && armName !== "none" ? `+${armName}` : "";
|
|
106
|
+
return join(skillDir, "tests", "results", `${harness}-${modelSlug(model)}${arm}`, timestampSlug(timestamp));
|
|
93
107
|
}
|
|
94
108
|
/** Path of a transcript file within a run dir. A rep index (for --reps N>1) is suffixed. */
|
|
95
109
|
export function transcriptPath(runDir, scenarioId, mode, rep) {
|
|
@@ -182,6 +196,7 @@ export function finalizeResults(draft, ctx) {
|
|
|
182
196
|
timestamp: draft.timestamp,
|
|
183
197
|
label: draft.label,
|
|
184
198
|
mode: draft.mode,
|
|
199
|
+
...(draft.arm ? { arm: draft.arm } : {}),
|
|
185
200
|
...(draft.partial ? { partial: true } : {}),
|
|
186
201
|
...(draft.source_hashes ? { source_hashes: draft.source_hashes } : {}),
|
|
187
202
|
effective_grade,
|
package/dist/run.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Spec } from "./spec.js";
|
|
2
2
|
import type { HarnessAdapter, ModelRef, RunMode } from "./adapters/types.js";
|
|
3
|
+
import { type Arm } from "./arms.js";
|
|
3
4
|
import { type ResultsFile } from "./results.js";
|
|
4
5
|
import { type Lift } from "./lift.js";
|
|
5
6
|
import { type ScenarioStability } from "./stability.js";
|
|
@@ -32,6 +33,19 @@ export interface RunOptions {
|
|
|
32
33
|
* there) is already refused by the adapter for free.
|
|
33
34
|
*/
|
|
34
35
|
canary?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Take the structured path even when no scenario declares a trace or trajectory
|
|
38
|
+
* assertion. The subject half of `ScenarioMetrics` — tokens, `subject_cost_usd` —
|
|
39
|
+
* is only ever populated from traces, so without this a run records no cost or
|
|
40
|
+
* token data at all, which is why the reference corpus has none.
|
|
41
|
+
*/
|
|
42
|
+
structured?: boolean;
|
|
43
|
+
/** The arm this run is measured under. Absent means the control (`none`). */
|
|
44
|
+
arm?: Arm;
|
|
45
|
+
/** Skills root — `seed_skills` paths resolve against it. Defaults to the skill's parent. */
|
|
46
|
+
skillsRoot?: string;
|
|
47
|
+
/** Injectable ambient skill root, for tests. Production reads `~/.pi/agent/skills`. */
|
|
48
|
+
ambientSkillsDir?: string;
|
|
35
49
|
}
|
|
36
50
|
export interface RunSummary {
|
|
37
51
|
runDir: string;
|
package/dist/run.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, resolve } from "node:path";
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { sourceHashes } from "./sources.js";
|
|
4
4
|
import { judgeResemblesSubject } from "./grade.js";
|
|
5
|
+
import { NONE_ARM, seedArmDefinitions } from "./arms.js";
|
|
5
6
|
import { runDirFor, transcriptPath, diffPath, tracePath, trajectoryPath, writeResults, ensureResultsGitignore, scoreContextFor, effectiveThreshold, isScoredMode, } from "./results.js";
|
|
6
7
|
import { appendJournal } from "./journal.js";
|
|
7
8
|
import { liftHeadline } from "./lift.js";
|
|
@@ -16,6 +17,36 @@ import { judgeOneRep } from "./regrade.js";
|
|
|
16
17
|
import { runDeliveryCanary, canaryFailure } from "./canary.js";
|
|
17
18
|
import { boundaryCells, stabilityNote } from "./stability.js";
|
|
18
19
|
import { aggregateMetrics } from "./comparison.js";
|
|
20
|
+
import { providerFailureFromTranscript } from "./provider-failure.js";
|
|
21
|
+
/**
|
|
22
|
+
* The pi-daddy arm's delivery proof, by convention: `arms.yaml` points
|
|
23
|
+
* `PI_GRANTS_LEDGER` at exactly this filename inside the run dir (see
|
|
24
|
+
* `../principal-pi-skills/tests/arms.yaml` and CODEX-ARMS-RUNBOOK.md §4.3), so
|
|
25
|
+
* a fixed name here is what makes the count readable without parsing the arm's
|
|
26
|
+
* own `env` map back out.
|
|
27
|
+
*/
|
|
28
|
+
const LEDGER_FILENAME = "pi-daddy.ledger.jsonl";
|
|
29
|
+
/**
|
|
30
|
+
* Count the arm's ledger events after the run, or 0 when the file was never
|
|
31
|
+
* written.
|
|
32
|
+
*
|
|
33
|
+
* This is `--canary`'s lesson applied to the arm: the ledger is the arm's
|
|
34
|
+
* delivery proof, and a delivery claim has to survive a commit. A missing file
|
|
35
|
+
* records `0`, not an absent field — "the extension never wrote a ledger" and
|
|
36
|
+
* "the extension wrote one with nothing in it" are both real, reportable
|
|
37
|
+
* outcomes, and a vacuous arm run must not commit a record indistinguishable
|
|
38
|
+
* from one that actually delegated.
|
|
39
|
+
*/
|
|
40
|
+
function countLedgerEvents(runDir) {
|
|
41
|
+
let text;
|
|
42
|
+
try {
|
|
43
|
+
text = readFileSync(join(runDir, LEDGER_FILENAME), "utf8");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
return text.split("\n").filter((line) => line.trim().length > 0).length;
|
|
49
|
+
}
|
|
19
50
|
/** Run one skill against one model: run scenarios, grade, score, persist. */
|
|
20
51
|
export async function runSkillModel(opts) {
|
|
21
52
|
const { spec, skillDir, adapter, model, judge, mode, timestamp } = opts;
|
|
@@ -39,7 +70,8 @@ export async function runSkillModel(opts) {
|
|
|
39
70
|
log(` ⚠ judge (${judge.provider}:${judge.model}) resembles the model under test ` +
|
|
40
71
|
`(${model.provider}:${model.model}) — verdicts may be inflated. Use a distinct judge.`);
|
|
41
72
|
}
|
|
42
|
-
const
|
|
73
|
+
const arm = opts.arm ?? NONE_ARM;
|
|
74
|
+
const runDir = runDirFor(skillDir, adapter.name, model, timestamp, arm.name);
|
|
43
75
|
mkdirSync(runDir, { recursive: true });
|
|
44
76
|
ensureResultsGitignore(dirname(dirname(runDir))); // .../tests/results/.gitignore
|
|
45
77
|
// Which harness CLI delivered the skill, asked once per run and recorded with the
|
|
@@ -97,12 +129,13 @@ export async function runSkillModel(opts) {
|
|
|
97
129
|
const repCounts = scenarios.map((s) => s.reps ?? opts.reps ?? 1);
|
|
98
130
|
const owners = [];
|
|
99
131
|
const tasks = [];
|
|
132
|
+
const armDefinitions = { count: 0 };
|
|
100
133
|
scenarios.forEach((scenario, si) => {
|
|
101
134
|
for (let k = 0; k < repCounts[si]; k++) {
|
|
102
135
|
const rep = k;
|
|
103
136
|
const total = repCounts[si];
|
|
104
137
|
owners.push(si);
|
|
105
|
-
tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log }));
|
|
138
|
+
tasks.push(() => runRep(scenario, rep, total, { ...opts, runDir, now, log, armDefinitions }));
|
|
106
139
|
}
|
|
107
140
|
});
|
|
108
141
|
const flat = await runPool(tasks, opts.concurrency ?? 1);
|
|
@@ -130,6 +163,22 @@ export async function runSkillModel(opts) {
|
|
|
130
163
|
// coverage of scenarios it skipped.
|
|
131
164
|
source_hashes: sourceHashes({ skillDir, specDir: dirname(opts.specPath), scenarios, judgePersona: spec.judge_persona }),
|
|
132
165
|
scenarios: scenarioResults,
|
|
166
|
+
...(arm.name === NONE_ARM.name ? {} : {
|
|
167
|
+
arm: {
|
|
168
|
+
name: arm.name,
|
|
169
|
+
extensions: arm.extensions,
|
|
170
|
+
definitions: armDefinitions.count,
|
|
171
|
+
ledger_events: countLedgerEvents(runDir),
|
|
172
|
+
// The DECLARED env, `<run-dir>` left unsubstituted. It is the condition
|
|
173
|
+
// being measured (grant, max depth), so leaving it out made two runs at
|
|
174
|
+
// different settings byte-identical here and inside the same `+<arm>`
|
|
175
|
+
// tag — `stability` then reads the verdict difference between two
|
|
176
|
+
// conditions as one lineage flipping. The substituted form would be the
|
|
177
|
+
// opposite error: it embeds this run's temp path, so re-running the SAME
|
|
178
|
+
// condition would record two different-looking arms.
|
|
179
|
+
env: arm.env,
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
133
182
|
}, ctx);
|
|
134
183
|
if (ctx) {
|
|
135
184
|
const g = results.effective_grade;
|
|
@@ -159,6 +208,14 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
159
208
|
const startedAt = performance.now();
|
|
160
209
|
const { spec, judge, mode, runDir, now, log } = ctx;
|
|
161
210
|
const repField = repCount > 1 ? { rep } : {};
|
|
211
|
+
const arm = ctx.arm ?? NONE_ARM;
|
|
212
|
+
const skillsRoot = ctx.skillsRoot ?? dirname(ctx.skillDir);
|
|
213
|
+
// `<run-dir>` in an arm's env values is a real path (temp dirs on this box
|
|
214
|
+
// routinely contain characters a regex would treat specially), so this is a
|
|
215
|
+
// plain split/join, not a `.replace(/<run-dir>/g, …)`.
|
|
216
|
+
const armEnv = Object.keys(arm.env).length
|
|
217
|
+
? Object.fromEntries(Object.entries(arm.env).map(([k, v]) => [k, v.split("<run-dir>").join(runDir)]))
|
|
218
|
+
: undefined;
|
|
162
219
|
if (rep === 0) {
|
|
163
220
|
log(` ${scenario.id} (${scenario.title})${repCount > 1 ? ` ×${repCount}` : ""} …`);
|
|
164
221
|
appendJournal(runDir, { event: "scenario-started", ts: now(), id: scenario.id, title: scenario.title });
|
|
@@ -194,66 +251,158 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
194
251
|
// a committed results.yaml, naming files the model never touched.
|
|
195
252
|
let before = ws ? snapshotPaths(ws.cwd, scenario.workspace) : null;
|
|
196
253
|
if (ws) {
|
|
254
|
+
// Seed the arm's definitions into THIS workspace before the subject runs.
|
|
255
|
+
// Control (`none`) short-circuits inside `seedArmDefinitions` with no
|
|
256
|
+
// filesystem side effects at all.
|
|
257
|
+
ctx.armDefinitions.count = seedArmDefinitions(arm, skillsRoot, ws.cwd, { ambientSkillsDir: ctx.ambientSkillsDir });
|
|
258
|
+
}
|
|
259
|
+
// Set when the adapter itself threw. Rep-scoped rather than loop-scoped: the
|
|
260
|
+
// evidence blocks below have to know this rep never ran.
|
|
261
|
+
let adapterFailure = null;
|
|
262
|
+
if (ws) {
|
|
263
|
+
// Decided ONCE, before any attempt, and for both the seeded and non-seeded
|
|
264
|
+
// branches — these are spec/adapter facts, not per-attempt outcomes, and
|
|
265
|
+
// deciding them here is what lets the execution below be wrapped in a catch
|
|
266
|
+
// that must not swallow a configuration error.
|
|
267
|
+
//
|
|
268
|
+
// `needsStructuredEvidence` is a gate with nothing to read if the adapter
|
|
269
|
+
// cannot produce traces: ERROR, never a silent fallback to the unstructured
|
|
270
|
+
// path, because a gate that could not be evaluated must not look like one
|
|
271
|
+
// that passed. A bare `--structured` request with no gate depending on it
|
|
272
|
+
// degrades to the plain path instead — on BOTH branches. The seeded branch
|
|
273
|
+
// used to pass `trace: {...}` unconditionally and let `runSeeded` throw
|
|
274
|
+
// ``scenario `X` declares `assert.trace`…`` at a scenario that declares no
|
|
275
|
+
// such thing.
|
|
276
|
+
const needsStructuredEvidence = Boolean(scenario.traceAssert || scenario.trajectoryAssert);
|
|
277
|
+
if (needsStructuredEvidence && !ctx.adapter.runStructured) {
|
|
278
|
+
throw new Error(`scenario \`${scenario.id}\` declares structured objective assertions, but the \`${ctx.adapter.name}\` adapter` +
|
|
279
|
+
` cannot produce execution traces/events — the gate would have no evidence to read.`);
|
|
280
|
+
}
|
|
281
|
+
const useStructured = (Boolean(ctx.structured) || needsStructuredEvidence) && Boolean(ctx.adapter.runStructured);
|
|
197
282
|
// A blank assistant turn is a harness timeout, not model behavior: retry ONCE in a
|
|
198
283
|
// fresh workspace (the first attempt may have half-mutated a seeded repo), and if
|
|
199
284
|
// it happens again the verdict is ERROR — never a judged FAIL on an empty reply.
|
|
285
|
+
// An adapter that THREW is the same class of event and takes the same retry.
|
|
200
286
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
201
287
|
if (attempt > 0) {
|
|
202
|
-
|
|
203
|
-
|
|
288
|
+
const why = adapterFailure ? `adapter failed (${adapterFailure})` : "empty response";
|
|
289
|
+
appendJournal(runDir, { event: "empty-response-retry", ts: now(), id: scenario.id, attempt, reason: why, ...repField });
|
|
290
|
+
log(` ${scenario.id}${repCount > 1 ? `#${rep}` : ""} ${why} — retrying once`);
|
|
204
291
|
ws.cleanup();
|
|
205
292
|
ws = createWorkspace(scenario.workspace, { specDir: dirname(ctx.specPath), remote: scenario.remote });
|
|
206
293
|
// A fresh workspace needs a fresh baseline, or the retry's diff would
|
|
207
294
|
// be taken against a directory that no longer exists.
|
|
208
295
|
before = snapshotPaths(ws.cwd, scenario.workspace);
|
|
296
|
+
// And a fresh workspace has nothing to spawn until it, too, is seeded —
|
|
297
|
+
// a retry that skipped this would silently run the arm with no
|
|
298
|
+
// definitions at all.
|
|
299
|
+
ctx.armDefinitions.count = seedArmDefinitions(arm, skillsRoot, ws.cwd, { ambientSkillsDir: ctx.ambientSkillsDir });
|
|
209
300
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
?
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const structured = await ctx.adapter.runStructured({ ...req, scenarioId: scenario.id, rep });
|
|
244
|
-
transcript = structured.transcript;
|
|
245
|
-
traces = structured.traces;
|
|
246
|
-
events = structured.events ?? [];
|
|
247
|
-
eventErrors = structured.eventErrors ?? [];
|
|
301
|
+
// Cleared per attempt, never carried over. A provider failure always
|
|
302
|
+
// leaves a blank assistant turn, so attempt 0's reason used to survive a
|
|
303
|
+
// retry that then succeeded: the rep was recorded ERROR citing an outage
|
|
304
|
+
// while the persisted transcript was attempt 1's clean one — the record
|
|
305
|
+
// and the artifact disagreeing, and a recovered measurement thrown away.
|
|
306
|
+
infrastructureFailure = null;
|
|
307
|
+
adapterFailure = null;
|
|
308
|
+
try {
|
|
309
|
+
if (scenario.mode === "seeded") {
|
|
310
|
+
const r = await runSeeded(scenario, {
|
|
311
|
+
skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
|
|
312
|
+
specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
|
|
313
|
+
// `ctx.structured` (a bare `--structured` request, with no gate depending
|
|
314
|
+
// on it) must route through the structured path here too, exactly as it
|
|
315
|
+
// does for the non-seeded branch below — without it, `--structured` on a
|
|
316
|
+
// `mode: seeded` scenario silently called the adapter's plain `run()` and
|
|
317
|
+
// recorded zero subject tokens/cost, which is the one thing the flag exists
|
|
318
|
+
// to capture. `useStructured` (not the raw request) so an adapter with no
|
|
319
|
+
// `runStructured` degrades here exactly as it does below.
|
|
320
|
+
trace: useStructured ? { scenarioId: scenario.id, rep } : undefined,
|
|
321
|
+
// `runSeeded` merges these with `scenario.extensions` itself when it
|
|
322
|
+
// builds the RunReq — the arm's extensions and env (with `<run-dir>`
|
|
323
|
+
// already substituted) both must reach pi.
|
|
324
|
+
armExtensions: arm.extensions,
|
|
325
|
+
...(armEnv ? { armEnv } : {}),
|
|
326
|
+
});
|
|
327
|
+
transcript = r.transcript;
|
|
328
|
+
gatePrefix = r.gateFailure;
|
|
329
|
+
infrastructureFailure = r.gateError;
|
|
330
|
+
stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
|
|
331
|
+
traces = r.traces;
|
|
332
|
+
events = r.events;
|
|
333
|
+
eventErrors = r.eventErrors;
|
|
248
334
|
}
|
|
249
335
|
else {
|
|
250
|
-
|
|
336
|
+
const req = {
|
|
337
|
+
skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
|
|
338
|
+
// resolved like fixtures: relative to the spec's dir
|
|
339
|
+
systemPromptFile: scenario.systemPromptFile
|
|
340
|
+
? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
|
|
341
|
+
: undefined,
|
|
342
|
+
// Absolute before it reaches a child process running in a neutral cwd.
|
|
343
|
+
// The arm's extensions are added alongside whatever the scenario
|
|
344
|
+
// itself declares — both must reach pi.
|
|
345
|
+
extensions: [
|
|
346
|
+
...(scenario.extensions?.map((e) => resolve(dirname(ctx.specPath), e)) ?? []),
|
|
347
|
+
...arm.extensions,
|
|
348
|
+
],
|
|
349
|
+
eventSources: scenario.eventSources,
|
|
350
|
+
...(armEnv ? { armEnv } : {}),
|
|
351
|
+
};
|
|
352
|
+
if (useStructured) {
|
|
353
|
+
const structured = await ctx.adapter.runStructured({ ...req, scenarioId: scenario.id, rep });
|
|
354
|
+
transcript = structured.transcript;
|
|
355
|
+
traces = structured.traces;
|
|
356
|
+
events = structured.events ?? [];
|
|
357
|
+
eventErrors = structured.eventErrors ?? [];
|
|
358
|
+
if (structured.providerFailure)
|
|
359
|
+
infrastructureFailure = `provider failure — ${structured.providerFailure}`;
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
transcript = await ctx.adapter.run(req);
|
|
363
|
+
}
|
|
251
364
|
}
|
|
252
365
|
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
// An adapter that THREW is one rep's infrastructure failure, not the
|
|
368
|
+
// wave's. `runPool` is fail-fast and `runRep` had only a `finally`, so
|
|
369
|
+
// a single pi crash or `PI_TIMEOUT_MS` timeout unwound the whole run:
|
|
370
|
+
// no results.yaml written and every already-graded scenario's verdict
|
|
371
|
+
// lost. `--structured` made that reachable for EVERY scenario, because
|
|
372
|
+
// `runStructured` throws on a stream with no terminal events where the
|
|
373
|
+
// text path merely recorded `[pi exited N]` and carried on.
|
|
374
|
+
//
|
|
375
|
+
// Configuration errors do not arrive here: the two that used to be
|
|
376
|
+
// thrown from inside this block (adapter cannot produce traces for a
|
|
377
|
+
// gated scenario) are decided above, before the loop.
|
|
378
|
+
adapterFailure = e instanceof Error ? e.message : String(e);
|
|
379
|
+
transcript = `[adapter failure] ${adapterFailure}`;
|
|
380
|
+
// Nothing partial from the failed attempt may be read as evidence: an
|
|
381
|
+
// empty trace list satisfies a `forbid_calls` gate, and a stale diff
|
|
382
|
+
// from a previous attempt would be attributed to this one.
|
|
383
|
+
gatePrefix = null;
|
|
384
|
+
stagedDiff = null;
|
|
385
|
+
traces = [];
|
|
386
|
+
events = [];
|
|
387
|
+
eventErrors = [];
|
|
388
|
+
}
|
|
389
|
+
// A provider outage is infrastructure, never a model verdict. Checked on
|
|
390
|
+
// every path: the text path carries the marker in the transcript, the
|
|
391
|
+
// structured path sets `providerFailure` above and exits 0 while doing it.
|
|
392
|
+
if (!infrastructureFailure) {
|
|
393
|
+
const provider = providerFailureFromTranscript(transcript);
|
|
394
|
+
if (provider)
|
|
395
|
+
infrastructureFailure = `provider failure — ${provider}`;
|
|
396
|
+
}
|
|
253
397
|
noResponse = hasEmptyAssistantTurn(transcript);
|
|
254
|
-
if (!noResponse)
|
|
398
|
+
if (!noResponse && !adapterFailure)
|
|
255
399
|
break;
|
|
256
400
|
}
|
|
401
|
+
// Survived the retry: ERROR for this rep, inside a run that still completes
|
|
402
|
+
// and still writes every other scenario's verdict.
|
|
403
|
+
if (adapterFailure && !infrastructureFailure) {
|
|
404
|
+
infrastructureFailure = `adapter failure — ${adapterFailure}`;
|
|
405
|
+
}
|
|
257
406
|
}
|
|
258
407
|
const repSuffix = repCount > 1 ? rep : undefined;
|
|
259
408
|
writeFileSync(transcriptPath(runDir, scenario.id, mode, repSuffix), transcript, "utf8");
|
|
@@ -294,8 +443,13 @@ async function runRep(scenario, rep, repCount, ctx) {
|
|
|
294
443
|
}
|
|
295
444
|
// Objective evidence is persisted for every rep, pass or fail — a failing gate
|
|
296
445
|
// is exactly when someone wants to read what the model actually did.
|
|
446
|
+
// `!adapterFailure`: a rep whose adapter threw never ran, so there is nothing
|
|
447
|
+
// to evaluate. Evaluating anyway reports "no execution trace was produced",
|
|
448
|
+
// which is the symptom — it names the missing artifact instead of the crash or
|
|
449
|
+
// timeout that caused it, and it is that text, not the real reason, that would
|
|
450
|
+
// reach the results record. The verdict is ERROR either way.
|
|
297
451
|
let objective;
|
|
298
|
-
if (scenario.traceAssert || scenario.trajectoryAssert) {
|
|
452
|
+
if ((scenario.traceAssert || scenario.trajectoryAssert) && !adapterFailure) {
|
|
299
453
|
const assertionResults = [];
|
|
300
454
|
let status = "PASS";
|
|
301
455
|
let traceMeta = {};
|
package/dist/seeded.d.ts
CHANGED
|
@@ -27,6 +27,16 @@ interface SeededOpts {
|
|
|
27
27
|
scenarioId: string;
|
|
28
28
|
rep: number;
|
|
29
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* Absolute paths of an arm's pi extensions, merged with `scenario.extensions`
|
|
32
|
+
* when the `RunReq` is built below — the same shape the non-seeded path in
|
|
33
|
+
* `run.ts` produces, so a trace-gated seeded scenario measures the same
|
|
34
|
+
* delivery as an ungated one (the reason `runStructured` shares `skillFlags`
|
|
35
|
+
* with `run()` instead of inventing a second convention applies here too).
|
|
36
|
+
*/
|
|
37
|
+
armExtensions?: string[];
|
|
38
|
+
/** The arm's env for the subject process; `<run-dir>` already substituted by the caller. */
|
|
39
|
+
armEnv?: Record<string, string>;
|
|
30
40
|
}
|
|
31
41
|
/**
|
|
32
42
|
* Result of one vitest invocation — deliberately `ExecResult`, not a narrower
|
package/dist/seeded.js
CHANGED
|
@@ -166,9 +166,15 @@ export async function runSeeded(scenario, opts) {
|
|
|
166
166
|
mode: opts.mode,
|
|
167
167
|
turns: scenario.turns,
|
|
168
168
|
cwd: repo,
|
|
169
|
-
// Resolved against the spec dir, exactly like fixtures and post-tests
|
|
170
|
-
extensions
|
|
169
|
+
// Resolved against the spec dir, exactly like fixtures and post-tests —
|
|
170
|
+
// the arm's extensions (already absolute) join alongside, same as the
|
|
171
|
+
// non-seeded path in run.ts.
|
|
172
|
+
extensions: [
|
|
173
|
+
...(scenario.extensions?.map((e) => resolve(opts.specDir, e)) ?? []),
|
|
174
|
+
...(opts.armExtensions ?? []),
|
|
175
|
+
],
|
|
171
176
|
eventSources: scenario.eventSources,
|
|
177
|
+
...(opts.armEnv ? { armEnv: opts.armEnv } : {}),
|
|
172
178
|
};
|
|
173
179
|
// A trace-gated seeded scenario runs through the structured path so the tool
|
|
174
180
|
// calls are recorded; everything downstream (gates, diff, transcript) is
|
package/dist/workspace.js
CHANGED
|
@@ -90,8 +90,22 @@ function assertKnownMarkers(src) {
|
|
|
90
90
|
* ways — a `diff_contains` needle matching a test filename can pass for free, and a
|
|
91
91
|
* `diff_excludes` needle can false-fail on a path string — and it pads every diff the
|
|
92
92
|
* judge reads.
|
|
93
|
+
*
|
|
94
|
+
* `.pi/skills/` is the same class from a different writer: `seedArmDefinitions` copies
|
|
95
|
+
* an arm's definitions there for pi-daddy to spawn, and that happens in the same
|
|
96
|
+
* workspace `runSeeded` later runs `git add -A` + `git diff --cached` against.
|
|
97
|
+
* Without this exclusion those seeded `.md` files land in the diff as
|
|
98
|
+
* harness-injected `+` lines — a `diff_contains` needle can match text the model
|
|
99
|
+
* never wrote (a false objective PASS), the judge is shown the seeded definitions
|
|
100
|
+
* as if they were the model's edit, and `.pi/skills/*` reads as a model change for
|
|
101
|
+
* `unchanged_paths`.
|
|
102
|
+
*
|
|
103
|
+
* `.pi/skills/` and NOT `.pi/`: only that one directory is harness-written. `.pi/`
|
|
104
|
+
* at large is content the model can legitimately be asked to author, and excluding
|
|
105
|
+
* all of it would make that work invisible to every gate — including for the
|
|
106
|
+
* control arm, which seeds nothing.
|
|
93
107
|
*/
|
|
94
|
-
const TOOL_ARTIFACTS = ["node_modules/", "coverage/", ".vitest/"];
|
|
108
|
+
const TOOL_ARTIFACTS = ["node_modules/", "coverage/", ".vitest/", ".pi/skills/"];
|
|
95
109
|
/**
|
|
96
110
|
* Exclude tool artifacts via `.git/info/exclude`, deliberately not a `.gitignore`.
|
|
97
111
|
*
|
|
@@ -190,8 +204,20 @@ export function createWorkspace(kind, opts) {
|
|
|
190
204
|
}
|
|
191
205
|
return { cwd, cleanup };
|
|
192
206
|
}
|
|
193
|
-
/** Never the model's work, and never worth hashing. */
|
|
207
|
+
/** Never the model's work, and never worth hashing — matched on the entry NAME, at any depth. */
|
|
194
208
|
const SNAPSHOT_SKIP = new Set([".git", "node_modules", "coverage", ".vitest"]);
|
|
209
|
+
/**
|
|
210
|
+
* Skipped by RELATIVE PATH, not by name: `.pi/skills/` is where an arm's seeded
|
|
211
|
+
* definitions land, and only that directory is the harness's writing.
|
|
212
|
+
*
|
|
213
|
+
* Scoped to `skills/` rather than all of `.pi/` on purpose. `.pi/` is also a
|
|
214
|
+
* perfectly ordinary thing for the model to be asked to write — a skill that
|
|
215
|
+
* authors pi agent definitions does exactly that, and the corpus keeps a `.pi/`
|
|
216
|
+
* at its own root — so skipping the whole directory would hide that work from
|
|
217
|
+
* `unchanged_paths` (a vacuous pass) as well as from `diff_contains` (a false
|
|
218
|
+
* FAIL), for the control arm too, which seeds nothing at all.
|
|
219
|
+
*/
|
|
220
|
+
const SNAPSHOT_SKIP_RELS = new Set([".pi/skills"]);
|
|
195
221
|
/** Snapshot a workspace, or null when there is no workspace to look at. */
|
|
196
222
|
export function snapshotPaths(cwd, kind) {
|
|
197
223
|
if (kind === "none" || !cwd || !existsSync(cwd))
|
|
@@ -209,6 +235,8 @@ export function snapshotPaths(cwd, kind) {
|
|
|
209
235
|
if (SNAPSHOT_SKIP.has(e.name))
|
|
210
236
|
continue;
|
|
211
237
|
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
238
|
+
if (SNAPSHOT_SKIP_RELS.has(rel))
|
|
239
|
+
continue;
|
|
212
240
|
const abs = join(dir, e.name);
|
|
213
241
|
if (e.isDirectory()) {
|
|
214
242
|
walk(abs, rel);
|