@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.
@@ -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
@@ -39,3 +39,5 @@ export * from "./affected.js";
39
39
  export * from "./adjudication.js";
40
40
  export * from "./comparison.js";
41
41
  export * from "./sandbox.js";
42
+ export * from "./provider-failure.js";
43
+ export * from "./arms.js";
package/dist/index.js CHANGED
@@ -39,4 +39,6 @@ export * from "./affected.js";
39
39
  export * from "./adjudication.js";
40
40
  export * from "./comparison.js";
41
41
  export * from "./sandbox.js";
42
+ export * from "./provider-failure.js";
43
+ export * from "./arms.js";
42
44
  //# sourceMappingURL=index.js.map
package/dist/journal.d.ts CHANGED
@@ -90,6 +90,7 @@ export type JournalEvent = {
90
90
  ts: string;
91
91
  id: string;
92
92
  attempt: number;
93
+ reason?: string;
93
94
  rep?: number;
94
95
  } | {
95
96
  event: "rescore";
@@ -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
- /** <skillDir>/tests/results/<harness>-<model-slug>/<timestamp-slug>/ */
231
- export declare function runDirFor(skillDir: string, harness: string, model: ModelRef, timestamp: string): string;
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
- /** <skillDir>/tests/results/<harness>-<model-slug>/<timestamp-slug>/ */
91
- export function runDirFor(skillDir, harness, model, timestamp) {
92
- return join(skillDir, "tests", "results", `${harness}-${modelSlug(model)}`, timestampSlug(timestamp));
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 runDir = runDirFor(skillDir, adapter.name, model, timestamp);
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
- appendJournal(runDir, { event: "empty-response-retry", ts: now(), id: scenario.id, attempt, ...repField });
203
- log(` ${scenario.id}${repCount > 1 ? `#${rep}` : ""} empty response — retrying once`);
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
- if (scenario.mode === "seeded") {
211
- const r = await runSeeded(scenario, {
212
- skillDir: ctx.skillDir, adapter: ctx.adapter, model: ctx.model, mode, cwd: ws.cwd,
213
- specDir: dirname(ctx.specPath), // assert.post_test resolves like a fixture
214
- trace: scenario.traceAssert || scenario.trajectoryAssert ? { scenarioId: scenario.id, rep } : undefined,
215
- });
216
- transcript = r.transcript;
217
- gatePrefix = r.gateFailure;
218
- infrastructureFailure = r.gateError;
219
- stagedDiff = r.diff; // a retry replaces the aborted attempt's diff, as it should
220
- traces = r.traces;
221
- events = r.events;
222
- eventErrors = r.eventErrors;
223
- }
224
- else {
225
- const req = {
226
- skillDir: ctx.skillDir, model: ctx.model, mode, turns: scenario.turns, cwd: ws.cwd,
227
- // resolved like fixtures: relative to the spec's dir
228
- systemPromptFile: scenario.systemPromptFile
229
- ? resolve(dirname(ctx.specPath), scenario.systemPromptFile)
230
- : undefined,
231
- // Absolute before it reaches a child process running in a neutral cwd.
232
- extensions: scenario.extensions?.map((e) => resolve(dirname(ctx.specPath), e)),
233
- eventSources: scenario.eventSources,
234
- };
235
- if (scenario.traceAssert || scenario.trajectoryAssert) {
236
- // Missing required evidence is ERROR, never a silent fallback to the
237
- // unstructured path: a gate with nothing to read must not look like a
238
- // gate that passed.
239
- if (!ctx.adapter.runStructured) {
240
- throw new Error(`scenario \`${scenario.id}\` declares structured objective assertions, but the \`${ctx.adapter.name}\` adapter` +
241
- ` cannot produce execution traces/events — the gate would have no evidence to read.`);
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
- transcript = await ctx.adapter.run(req);
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: scenario.extensions?.map((e) => resolve(opts.specDir, e)),
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",