ai: evaluate skill text with nested pi runs
Reviewing a skill only checks whether its text reads well. Trigger and
adherence cases in evals.yml check what the text does, using nested
`pi -p --mode json` processes in place of subagents.
Assisted-by: Claude Opus 5 via pi
diff --git a/AGENTS.md b/AGENTS.md
index 21cc3e0c..1e824b08 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -46,6 +46,8 @@ Skills live in `ai/skills/<name>/SKILL.md`. See the `skill-notes` skill for auth
Pi loads these via `ai/pi/settings.json`, which points to `~/.dotfiles/ai/skills`. Claude reaches them through `ai/claude/skills` (a symlink to `../skills`), so both agents read the same files — only ever edit `ai/skills/`.
+A skill can declare behavioral evals in `ai/skills/<name>/evals.yml` — prompts that should (and should not) load it, plus tasks with expectations to grade once it is loaded. Run them with `/skill-eval run <name>`; see `ai/pi/extensions/skill-eval/README.md`.
+
## Pi Packages
Pi manages plugins natively via `pi install` / `pi remove` / `pi update` / `pi list`. Global packages are declared in `~/.pi/agent/settings.json` under `packages`. Project-scoped packages use `.pi/settings.json` with the `-l` flag.
diff --git a/ai/pi/extensions/skill-eval/README.md b/ai/pi/extensions/skill-eval/README.md
new file mode 100644
index 00000000..fed98f91
--- /dev/null
+++ b/ai/pi/extensions/skill-eval/README.md
@@ -0,0 +1,116 @@
+# Skill Eval
+
+Three ways to judge a skill or `AGENTS.md` file, from cheapest to most
+expensive:
+
+| Command | What it does | Cost |
+|---------|--------------|------|
+| `/skill-eval` or `/skill-eval triage` | Ranks in-context skills and `AGENTS.md` files by line count and 90-day usage from session logs | Free |
+| `/skill-eval review <target>` | Queues the file's text into the conversation with a trim rubric | One turn |
+| `/skill-eval run <skill>` | Runs the skill's `evals.yml` through nested `pi` processes | API credit |
+
+Triage and review are also available to the model through the `skill_eval`
+tool. Evals are not: they spend real money, so only you start them.
+
+## Behavioral evals
+
+Reviewing a skill asks a model whether the text *looks* right. An eval checks
+what the text actually does, in two parts.
+
+Trigger fidelity answers whether the `description` frontmatter makes the agent
+reach for the skill. Each case runs a fresh `pi` with ordinary skill discovery
+on — competing descriptions are part of the test — and only the `read` tool
+available. A case passes when the run reads a file in the skill's directory
+(positive cases) or leaves it alone (negative cases).
+
+Instruction adherence answers whether the body changes behavior once loaded.
+Each case forces the skill in with `/skill:<name>`, runs the task in a fresh
+temporary directory, then hands the transcript to a second `pi` run that grades
+every declared expectation as pass, fail, or unclear. A case passes only when
+all of its expectations pass.
+
+Runs use the model and thinking level of the session you launch them from, so
+scores describe the setup you actually work in. Everything else is hermetic:
+no session file, no extensions, no `AGENTS.md`, no prompt templates, and a
+scratch working directory per case. Globally discovered skills are the one
+exception, and they are deliberate.
+
+## Writing evals.yml
+
+Put `evals.yml` next to `SKILL.md`:
+
+```yaml
+trigger:
+ positive:
+ # Tasks the skill should be loaded for. Phrase them the way you would.
+ - prompt: I need to squash this change into its parent
+ - prompt: |
+ The description of my last commit is wrong.
+ Fix it.
+ note: covers the describe-versus-commit distinction
+ negative:
+ # Nearby tasks the skill should stay out of.
+ - prompt: Add fzf-git as a submodule with a shallow clone
+
+adherence:
+ - prompt: Commit the change in this repo
+ expect:
+ - runs `jj commit` rather than `jj describe`
+ - the commit message subject is a plain sentence with no conventional-commit prefix
+ - the message carries an `Assisted-by` trailer
+ tools: [read, bash]
+ setup: |
+ jj git init
+ echo "hello" > file.txt
+```
+
+Fields:
+
+`trigger.positive` and `trigger.negative` take a list of cases. A case is
+either a bare string or a mapping with `prompt` and an optional `note`.
+
+`adherence` takes a list of mappings. `prompt` and `expect` are required;
+`expect` holds one checkable claim per entry, since the judge grades each
+separately. `tools` defaults to `[read]`, which keeps a case read-only and
+grades what the agent says it would do; add `bash`, `edit`, or `write` when the
+task only means something if the agent carries it out. `setup` is a bash script
+run in the sandbox before the agent starts, for scaffolding fixtures.
+
+A prompt cannot start with `-` or `@`, which `pi` reads as a flag or a file
+include. Validation rejects those with the case's path, along with every other
+problem in the file, so one run of the command surfaces all of them.
+
+## Running
+
+```
+/skill-eval run jj
+/skill-eval run jj --only trigger # skip the expensive half
+/skill-eval run jj --repeat 3 # attempts per case, to expose flakiness
+/skill-eval run jj --jobs 1 # cases in flight at once (default 3)
+```
+
+The command confirms the run count first. Trigger cases cost one model run
+each; adherence cases cost two, because grading is its own run. A report lands
+in `$XDG_STATE_HOME/pi/skill-eval/reports/` and, when anything failed, the
+failures arrive as a follow-up message so the conversation can fix the text.
+
+Trigger misses usually mean the `description` field needs the vocabulary of the
+prompt that missed. False positives on negative cases mean it claims too much
+ground. Adherence failures point at the body: an instruction stated once in
+passing, buried under prose, or contradicted elsewhere in the file.
+
+## Layout
+
+| File | Contents |
+|------|----------|
+| `index.ts` | Command and tool registration, triage, review, target resolution |
+| `evals.ts` | `evals.yml` loading and validation |
+| `subagent.ts` | Spawning nested `pi` processes and parsing their JSON event stream |
+| `run.ts` | Case orchestration, judging, scoring, report formatting |
+
+Tests run without spending API credit; a stub entrypoint injected through
+`PI_BIN` stands in for `pi`.
+
+```bash
+node --test "ai/pi/extensions/skill-eval/*.test.ts"
+```
diff --git a/ai/pi/extensions/skill-eval/evals.test.ts b/ai/pi/extensions/skill-eval/evals.test.ts
new file mode 100644
index 00000000..f9d89332
--- /dev/null
+++ b/ai/pi/extensions/skill-eval/evals.test.ts
@@ -0,0 +1,133 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { evalsPathFor, loadEvalSuite, parseEvalSuite } from "./evals.ts";
+
+test("parseEvalSuite accepts bare strings and mappings for trigger cases", () => {
+ const result = parseEvalSuite({
+ trigger: {
+ positive: ["squash this into its parent", { prompt: "describe my change", note: "phrasing check" }],
+ negative: [{ prompt: "add a git submodule" }],
+ },
+ });
+
+ assert.equal(result.ok, true);
+ if (!result.ok) return;
+ assert.deepEqual(result.suite.triggerPositive, [
+ { prompt: "squash this into its parent" },
+ { prompt: "describe my change", note: "phrasing check" },
+ ]);
+ assert.deepEqual(result.suite.triggerNegative, [{ prompt: "add a git submodule" }]);
+ assert.deepEqual(result.suite.adherence, []);
+});
+
+test("parseEvalSuite defaults adherence tools to read and keeps setup scripts", () => {
+ const result = parseEvalSuite({
+ adherence: [
+ { prompt: "commit my work", expect: ["uses jj commit"] },
+ { prompt: "rebase onto main", expect: ["uses jj rebase"], tools: ["read", "bash"], setup: "jj git init\n" },
+ ],
+ });
+
+ assert.equal(result.ok, true);
+ if (!result.ok) return;
+ assert.deepEqual(result.suite.adherence[0].tools, ["read"]);
+ assert.equal(result.suite.adherence[0].setup, undefined);
+ assert.deepEqual(result.suite.adherence[1].tools, ["read", "bash"]);
+ assert.equal(result.suite.adherence[1].setup, "jj git init\n");
+});
+
+test("parseEvalSuite rejects prompts pi would read as a flag or file include", () => {
+ const result = parseEvalSuite({ trigger: { positive: ["--torque is wrong, fix it"] } }, "evals.yml");
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.errors.length, 1);
+ assert.match(result.errors[0], /evals\.yml:trigger\.positive\[0\]/);
+ assert.match(result.errors[0], /cannot start with/);
+});
+
+test("parseEvalSuite requires at least one expectation per adherence case", () => {
+ const result = parseEvalSuite({ adherence: [{ prompt: "commit my work" }] });
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.match(result.errors[0], /needs at least one expectation/);
+});
+
+test("parseEvalSuite collects every problem rather than stopping at the first", () => {
+ const result = parseEvalSuite({
+ trigger: { positive: [{ note: "no prompt here" }], sideways: [] },
+ adherence: [{ prompt: "do a thing", expect: ["fine"], tools: "bash" }],
+ extra: true,
+ });
+
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal(result.errors.length, 4);
+ assert.ok(result.errors.some((e) => /unknown top-level key "extra"/.test(e)));
+ assert.ok(result.errors.some((e) => /trigger: unknown key "sideways"/.test(e)));
+ assert.ok(result.errors.some((e) => /missing a non-empty "prompt"/.test(e)));
+ assert.ok(result.errors.some((e) => /tools: expected a list of tool names/.test(e)));
+});
+
+test("parseEvalSuite reports an empty or non-mapping file", () => {
+ const empty = parseEvalSuite(null, "evals.yml");
+ assert.equal(empty.ok, false);
+ if (!empty.ok) assert.match(empty.errors[0], /file is empty/);
+
+ const list = parseEvalSuite([1, 2], "evals.yml");
+ assert.equal(list.ok, false);
+ if (!list.ok) assert.match(list.errors[0], /expected a mapping/);
+
+ const noCases = parseEvalSuite({ trigger: { positive: [] } }, "evals.yml");
+ assert.equal(noCases.ok, false);
+ if (!noCases.ok) assert.match(noCases.errors[0], /no cases found/);
+});
+
+test("evalsPathFor puts the eval file next to SKILL.md", () => {
+ assert.equal(evalsPathFor("/skills/jj/SKILL.md"), "/skills/jj/evals.yml");
+});
+
+test("loadEvalSuite reads the file next to the skill and validates the decoded data", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "skill-eval-evals-"));
+ try {
+ await writeFile(join(dir, "evals.yml"), "trigger:\n positive:\n - squash it\n");
+ const seen: string[] = [];
+ const result = await loadEvalSuite(join(dir, "SKILL.md"), async (text) => {
+ seen.push(text);
+ return { trigger: { positive: ["squash it"] } };
+ });
+
+ assert.equal(result.ok, true);
+ if (!result.ok) return;
+ assert.deepEqual(result.suite.triggerPositive, [{ prompt: "squash it" }]);
+ assert.equal(seen[0], "trigger:\n positive:\n - squash it\n");
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("loadEvalSuite reports a missing file with a template to copy", async () => {
+ const result = await loadEvalSuite("/nonexistent/skill/SKILL.md");
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.equal((result as { missing?: true }).missing, true);
+ assert.match(result.errors[0], /No eval file at \/nonexistent\/skill\/evals\.yml/);
+ assert.ok(result.errors.some((e) => /trigger:/.test(e)));
+});
+
+test("loadEvalSuite surfaces YAML decoding failures", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "skill-eval-evals-"));
+ try {
+ await writeFile(join(dir, "evals.yml"), "trigger: [\n");
+ const result = await loadEvalSuite(join(dir, "SKILL.md"), async () => {
+ throw new Error("unexpected end of stream");
+ });
+ assert.equal(result.ok, false);
+ if (result.ok) return;
+ assert.match(result.errors[0], /could not parse YAML — unexpected end of stream/);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/ai/pi/extensions/skill-eval/evals.ts b/ai/pi/extensions/skill-eval/evals.ts
new file mode 100644
index 00000000..a2245ba6
--- /dev/null
+++ b/ai/pi/extensions/skill-eval/evals.ts
@@ -0,0 +1,266 @@
+/**
+ * evals.yml parsing and validation.
+ *
+ * An eval file sits next to SKILL.md and declares two kinds of cases:
+ * trigger cases (does the description make the agent reach for the skill?)
+ * and adherence cases (with the skill loaded, does the agent follow it?).
+ *
+ * `parseEvalSuite` is pure and works on already-parsed YAML data;
+ * `loadEvalSuite` handles reading and YAML decoding.
+ */
+
+import { readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+
+export const EVALS_FILENAME = "evals.yml";
+
+export const DEFAULT_ADHERENCE_TOOLS = ["read"];
+
+export interface TriggerCase {
+ prompt: string;
+ note?: string;
+}
+
+export interface AdherenceCase {
+ prompt: string;
+ expect: string[];
+ tools: string[];
+ setup?: string;
+ note?: string;
+}
+
+export interface EvalSuite {
+ triggerPositive: TriggerCase[];
+ triggerNegative: TriggerCase[];
+ adherence: AdherenceCase[];
+}
+
+export type ParseResult =
+ | { ok: true; suite: EvalSuite }
+ | { ok: false; errors: string[] };
+
+export const EVALS_TEMPLATE = `trigger:
+ positive:
+ # Tasks the skill should be loaded for.
+ - prompt: a task phrased the way you'd actually phrase it
+ negative:
+ # Nearby tasks the skill should stay out of.
+ - prompt: a task that sounds similar but needs a different skill
+
+adherence:
+ - prompt: a task to carry out with the skill loaded
+ expect:
+ # One checkable claim per line, graded against the transcript.
+ - names the specific command the skill prescribes
+ # Optional. Defaults to ["read"]; add tools the task genuinely needs.
+ tools: [read, bash]
+ # Optional shell script run in the sandbox before the agent starts.
+ setup: |
+ echo "scaffold fixtures here"
+`;
+
+/** Path of the eval file belonging to a SKILL.md. */
+export function evalsPathFor(skillPath: string): string {
+ return join(dirname(skillPath), EVALS_FILENAME);
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** A prompt is passed to pi as an argv message, where these prefixes are consumed as flags or file includes. */
+function checkPromptPrefix(prompt: string, path: string, errors: string[]): void {
+ if (prompt.startsWith("-") || prompt.startsWith("@")) {
+ errors.push(
+ `${path}: prompt cannot start with "-" or "@" — pi reads those as a flag or a file include. Reword the prompt.`,
+ );
+ }
+}
+
+function parseTriggerCases(raw: unknown, path: string, errors: string[]): TriggerCase[] {
+ if (raw === undefined || raw === null) return [];
+ if (!Array.isArray(raw)) {
+ errors.push(`${path}: expected a list of cases, got ${describe(raw)}.`);
+ return [];
+ }
+
+ const cases: TriggerCase[] = [];
+ raw.forEach((entry, index) => {
+ const entryPath = `${path}[${index}]`;
+ if (typeof entry === "string") {
+ const prompt = entry.trim();
+ if (!prompt) {
+ errors.push(`${entryPath}: prompt is empty.`);
+ return;
+ }
+ checkPromptPrefix(prompt, entryPath, errors);
+ cases.push({ prompt });
+ return;
+ }
+ if (!isRecord(entry)) {
+ errors.push(`${entryPath}: expected a string or a mapping with a "prompt" key, got ${describe(entry)}.`);
+ return;
+ }
+ const prompt = typeof entry.prompt === "string" ? entry.prompt.trim() : "";
+ if (!prompt) {
+ errors.push(`${entryPath}: missing a non-empty "prompt".`);
+ return;
+ }
+ checkPromptPrefix(prompt, entryPath, errors);
+ const note = typeof entry.note === "string" ? entry.note : undefined;
+ cases.push(note ? { prompt, note } : { prompt });
+ });
+ return cases;
+}
+
+function parseAdherenceCases(raw: unknown, path: string, errors: string[]): AdherenceCase[] {
+ if (raw === undefined || raw === null) return [];
+ if (!Array.isArray(raw)) {
+ errors.push(`${path}: expected a list of cases, got ${describe(raw)}.`);
+ return [];
+ }
+
+ const cases: AdherenceCase[] = [];
+ raw.forEach((entry, index) => {
+ const entryPath = `${path}[${index}]`;
+ if (!isRecord(entry)) {
+ errors.push(`${entryPath}: expected a mapping with "prompt" and "expect" keys, got ${describe(entry)}.`);
+ return;
+ }
+
+ const prompt = typeof entry.prompt === "string" ? entry.prompt.trim() : "";
+ if (!prompt) errors.push(`${entryPath}: missing a non-empty "prompt".`);
+ else checkPromptPrefix(prompt, entryPath, errors);
+
+ const expect: string[] = [];
+ if (!Array.isArray(entry.expect) || entry.expect.length === 0) {
+ errors.push(`${entryPath}.expect: needs at least one expectation to grade against.`);
+ } else {
+ entry.expect.forEach((item, i) => {
+ if (typeof item !== "string" || !item.trim()) {
+ errors.push(`${entryPath}.expect[${i}]: expected a non-empty string, got ${describe(item)}.`);
+ return;
+ }
+ expect.push(item.trim());
+ });
+ }
+
+ let tools = DEFAULT_ADHERENCE_TOOLS;
+ if (entry.tools !== undefined) {
+ if (!Array.isArray(entry.tools) || entry.tools.some((t) => typeof t !== "string" || !t.trim())) {
+ errors.push(`${entryPath}.tools: expected a list of tool names, got ${describe(entry.tools)}.`);
+ } else {
+ tools = (entry.tools as string[]).map((t) => t.trim());
+ }
+ }
+
+ let setup: string | undefined;
+ if (entry.setup !== undefined) {
+ if (typeof entry.setup !== "string") {
+ errors.push(`${entryPath}.setup: expected a shell script string, got ${describe(entry.setup)}.`);
+ } else if (entry.setup.trim()) {
+ setup = entry.setup;
+ }
+ }
+
+ if (!prompt || expect.length === 0) return;
+ const note = typeof entry.note === "string" ? entry.note : undefined;
+ cases.push({ prompt, expect, tools, ...(setup ? { setup } : {}), ...(note ? { note } : {}) });
+ });
+ return cases;
+}
+
+function describe(value: unknown): string {
+ if (value === null) return "null";
+ if (Array.isArray(value)) return "a list";
+ return typeof value;
+}
+
+/** Validate decoded evals.yml data. Collects every problem rather than failing on the first. */
+export function parseEvalSuite(data: unknown, source = EVALS_FILENAME): ParseResult {
+ const errors: string[] = [];
+
+ if (data === null || data === undefined) {
+ return { ok: false, errors: [`${source}: file is empty.`] };
+ }
+ if (!isRecord(data)) {
+ return { ok: false, errors: [`${source}: expected a mapping with "trigger" and/or "adherence" keys, got ${describe(data)}.`] };
+ }
+
+ const known = new Set(["trigger", "adherence"]);
+ for (const key of Object.keys(data)) {
+ if (!known.has(key)) errors.push(`${source}: unknown top-level key "${key}" (expected "trigger" or "adherence").`);
+ }
+
+ let triggerPositive: TriggerCase[] = [];
+ let triggerNegative: TriggerCase[] = [];
+ if (data.trigger !== undefined && data.trigger !== null) {
+ if (!isRecord(data.trigger)) {
+ errors.push(`${source}:trigger: expected a mapping with "positive" and/or "negative" keys, got ${describe(data.trigger)}.`);
+ } else {
+ for (const key of Object.keys(data.trigger)) {
+ if (key !== "positive" && key !== "negative") {
+ errors.push(`${source}:trigger: unknown key "${key}" (expected "positive" or "negative").`);
+ }
+ }
+ triggerPositive = parseTriggerCases(data.trigger.positive, `${source}:trigger.positive`, errors);
+ triggerNegative = parseTriggerCases(data.trigger.negative, `${source}:trigger.negative`, errors);
+ }
+ }
+
+ const adherence = parseAdherenceCases(data.adherence, `${source}:adherence`, errors);
+
+ if (triggerPositive.length + triggerNegative.length + adherence.length === 0 && errors.length === 0) {
+ errors.push(`${source}: no cases found. Add trigger cases, adherence cases, or both.`);
+ }
+
+ if (errors.length > 0) return { ok: false, errors };
+ return { ok: true, suite: { triggerPositive, triggerNegative, adherence } };
+}
+
+/**
+ * The `yaml` package ships inside pi but isn't resolvable from an extension
+ * directory, so decode through pi's own frontmatter parser, which wraps it.
+ */
+async function defaultYamlParser(text: string): Promise<unknown> {
+ // The wrapper below ends at the first unindented `---`, which would silently
+ // truncate the file, so reject that line outright.
+ if (text.split("\n").some((line) => line.trimEnd() === "---")) {
+ throw new Error('a line containing only "---" is not supported; indent it or quote it');
+ }
+ const mod: any = await import("@earendil-works/pi-coding-agent");
+ const wrapped = text.endsWith("\n") ? text : `${text}\n`;
+ return mod.parseFrontmatter(`---\n${wrapped}---\n`).frontmatter;
+}
+
+export type LoadResult = ParseResult | { ok: false; errors: string[]; missing: true };
+
+/** Read and validate the evals.yml next to a SKILL.md. */
+export async function loadEvalSuite(
+ skillPath: string,
+ parseYaml: (text: string) => Promise<unknown> = defaultYamlParser,
+): Promise<LoadResult> {
+ const path = evalsPathFor(skillPath);
+ let text: string;
+ try {
+ text = await readFile(path, "utf8");
+ } catch {
+ return {
+ ok: false,
+ missing: true,
+ errors: [
+ `No eval file at ${path}. Create one to describe what this skill should do:`,
+ "",
+ EVALS_TEMPLATE,
+ ],
+ };
+ }
+
+ let data: unknown;
+ try {
+ data = await parseYaml(text);
+ } catch (error) {
+ return { ok: false, errors: [`${path}: could not parse YAML — ${(error as Error).message}`] };
+ }
+ return parseEvalSuite(data, path);
+}
diff --git a/ai/pi/extensions/skill-eval/index.ts b/ai/pi/extensions/skill-eval/index.ts
index 90e1b650..d4ae4269 100644
--- a/ai/pi/extensions/skill-eval/index.ts
+++ b/ai/pi/extensions/skill-eval/index.ts
@@ -6,6 +6,8 @@
* trim/removal candidates, using Pi session logs as a usage signal.
* Deep-reviews a single target by handing its content and usage
* stats to the current conversation with a trim-focused rubric.
+ * Also runs behavioral evals declared in a skill's evals.yml through nested pi
+ * processes; see run.ts and README.md.
*/
import { existsSync, statSync } from "node:fs";
@@ -17,6 +19,8 @@ import type {
ExtensionContext,
ExtensionCommandContext,
} from "@mariozechner/pi-coding-agent";
+import { EVALS_FILENAME, loadEvalSuite } from "./evals.ts";
+import { formatEvalReport, runEvals, summarizeFailures, type EvalRunConfig } from "./run.ts";
// ---------- Types ----------
@@ -299,10 +303,11 @@ export function formatTriageMarkdown(
return lines.join("\n");
}
-export function resolveReportPath(now: Date, stateBaseDir?: string): string {
+export function resolveReportPath(now: Date, stateBaseDir?: string, label?: string): string {
const base = stateBaseDir ?? process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
const timestamp = now.toISOString().replace(/[:.]/g, "-");
- return join(base, "pi", "skill-eval", "reports", `${timestamp}.md`);
+ const suffix = label ? `-${label.replace(/[^a-zA-Z0-9._-]+/g, "_")}` : "";
+ return join(base, "pi", "skill-eval", "reports", `${timestamp}${suffix}.md`);
}
export async function writeReport(markdown: string, reportPath: string): Promise<void> {
@@ -380,9 +385,81 @@ export function buildReviewPrompt(
return [`# Trim review: ${name}`, "", usageLine, "", rubric, "", "```", content, "```"].join("\n");
}
+// ---------- Command arguments ----------
+
+export type ParsedCommand =
+ | { action: "triage" }
+ | { action: "review"; target: string }
+ | { action: "run"; target: string; repeat: number; jobs: number; only?: "trigger" | "adherence" }
+ | { action: "error"; message: string };
+
+const DEFAULT_REPEAT = 1;
+const DEFAULT_JOBS = 3;
+
+/**
+ * `/skill-eval` and `/skill-eval triage` triage; `/skill-eval run <skill>` runs
+ * behavioral evals; anything else is treated as a review target, which keeps the
+ * original bare-target form working.
+ */
+export function parseCommandArgs(args: string): ParsedCommand {
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
+ if (tokens.length === 0) return { action: "triage" };
+
+ const [head, ...rest] = tokens;
+ if (head === "triage") {
+ if (rest.length > 0) return { action: "error", message: "triage takes no arguments." };
+ return { action: "triage" };
+ }
+
+ if (head === "review") {
+ if (rest.length !== 1) return { action: "error", message: "review takes exactly one target." };
+ return { action: "review", target: rest[0] };
+ }
+
+ if (head === "run") {
+ let target: string | undefined;
+ let repeat = DEFAULT_REPEAT;
+ let jobs = DEFAULT_JOBS;
+ let only: "trigger" | "adherence" | undefined;
+
+ for (let i = 0; i < rest.length; i++) {
+ const token = rest[i];
+ if (token === "--repeat" || token === "--jobs") {
+ const value = Number(rest[++i]);
+ if (!Number.isInteger(value) || value < 1) {
+ return { action: "error", message: `${token} needs a positive integer.` };
+ }
+ if (token === "--repeat") repeat = value;
+ else jobs = value;
+ continue;
+ }
+ if (token === "--only") {
+ const value = rest[++i];
+ if (value !== "trigger" && value !== "adherence") {
+ return { action: "error", message: "--only takes either trigger or adherence." };
+ }
+ only = value;
+ continue;
+ }
+ if (token.startsWith("-")) return { action: "error", message: `Unknown flag ${token}.` };
+ if (target !== undefined) return { action: "error", message: "run takes exactly one skill." };
+ target = token;
+ }
+
+ if (target === undefined) {
+ return { action: "error", message: "run needs a skill name: /skill-eval run <skill>." };
+ }
+ return { action: "run", target, repeat, jobs, ...(only ? { only } : {}) };
+ }
+
+ if (tokens.length > 1) return { action: "error", message: `Unknown subcommand "${head}".` };
+ return { action: "review", target: head };
+}
+
// ---------- Wiring ----------
const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
+const RUN_TIMEOUT_MS = 5 * 60 * 1000;
function sessionsDirFor(): string {
return join(homedir(), ".pi", "agent", "sessions");
@@ -441,30 +518,182 @@ async function runReview(
return { ok: true, prompt, name: resolved.meta.path };
}
+async function resolveEvalTarget(
+ ctx: ExtensionContext,
+ target: string,
+): Promise<{ ok: true; meta: SkillMeta } | { ok: false; message: string }> {
+ const contextSkills = parseAvailableSkillsBlock(ctx.getSystemPrompt());
+ const skills = await loadSkillsFromContext(contextSkills);
+ const agentsFiles = await discoverAgentsFiles(ctx.cwd);
+ const resolved = resolveTarget(target, skills, agentsFiles);
+
+ if (!resolved.ok) {
+ const label =
+ resolved.reason === "ambiguous" ? "Ambiguous target" : "No matching skill";
+ return {
+ ok: false,
+ message: `${label} for "${target}". Candidates: ${resolved.candidates.join(", ") || "none"}`,
+ };
+ }
+ if (resolved.kind !== "skill") {
+ return {
+ ok: false,
+ message: `"${target}" is an AGENTS.md file. Behavioral evals only work on skills, since they measure whether a skill loads and gets followed. Use /skill-eval review ${target} instead.`,
+ };
+ }
+ return { ok: true, meta: resolved.meta };
+}
+
+function evalConfig(ctx: ExtensionContext, repeat: number, jobs: number): EvalRunConfig | null {
+ const model = ctx.model;
+ if (!model) return null;
+ return {
+ provider: model.provider,
+ model: model.id,
+ thinking: ctx.thinkingLevel,
+ repeat,
+ jobs,
+ timeoutMs: RUN_TIMEOUT_MS,
+ };
+}
+
+/** UI methods are no-ops without a UI, so fall back to the console for headless runs. */
+function announce(ctx: ExtensionCommandContext, text: string, level: "info" | "warning" | "error"): void {
+ ctx.ui.notify(text, level);
+ if (!ctx.hasUI) {
+ if (level === "error") console.error(text);
+ else console.log(text);
+ }
+}
+
+async function handleRun(
+ pi: ExtensionAPI,
+ ctx: ExtensionCommandContext,
+ parsed: { target: string; repeat: number; jobs: number; only?: "trigger" | "adherence" },
+): Promise<void> {
+ const target = await resolveEvalTarget(ctx, parsed.target);
+ if (!target.ok) {
+ announce(ctx, target.message, "error");
+ return;
+ }
+
+ const loaded = await loadEvalSuite(target.meta.path);
+ if (!loaded.ok) {
+ announce(ctx, loaded.errors.join("\n"), "error");
+ return;
+ }
+ const suite = loaded.suite;
+
+ const config = evalConfig(ctx, parsed.repeat, parsed.jobs);
+ if (!config) {
+ announce(ctx, "No active model, so there's nothing to run evals with. Pick a model with /model first.", "error");
+ return;
+ }
+
+ const triggerCases = parsed.only === "adherence" ? 0 : suite.triggerPositive.length + suite.triggerNegative.length;
+ const adherenceCases = parsed.only === "trigger" ? 0 : suite.adherence.length;
+ if (triggerCases + adherenceCases === 0) {
+ const kind = parsed.only ? `${parsed.only} ` : "";
+ announce(ctx, `${EVALS_FILENAME} for ${target.meta.name} has no ${kind}cases to run.`, "error");
+ return;
+ }
+
+ // Each adherence case costs a judge run on top of the agent run.
+ const modelRuns = (triggerCases + adherenceCases * 2) * parsed.repeat;
+ if (ctx.hasUI) {
+ const proceed = await ctx.ui.confirm(
+ `Run evals for ${target.meta.name}?`,
+ `${modelRuns} nested ${config.provider}/${config.model} runs, ${parsed.jobs} at a time. This spends API credit.`,
+ );
+ if (!proceed) return;
+ }
+
+ ctx.ui.setStatus("skill-eval", `evals: ${target.meta.name} starting`);
+ let report;
+ try {
+ report = await runEvals({
+ skillName: target.meta.name,
+ skillPath: target.meta.path,
+ suite,
+ only: parsed.only,
+ config: {
+ ...config,
+ onProgress: (done, total, label) => {
+ ctx.ui.setStatus("skill-eval", `evals: ${target.meta.name} ${done}/${total} — ${label.slice(0, 40)}`);
+ },
+ },
+ });
+ } finally {
+ ctx.ui.setStatus("skill-eval", undefined);
+ }
+
+ const markdown = formatEvalReport(report);
+ const reportPath = resolveReportPath(report.finishedAt, undefined, `eval-${report.skillName}`);
+ await writeReport(markdown, reportPath);
+
+ const failures = summarizeFailures(report);
+ announce(ctx, `${markdown}\n\nReport saved to ${reportPath}`, failures.length > 0 ? "warning" : "info");
+
+ if (failures.length === 0) return;
+ pi.sendUserMessage(
+ [
+ `# Eval failures: ${report.skillName}`,
+ "",
+ `Behavioral evals for ${target.meta.path} came back with failures. Full report: ${reportPath}`,
+ "",
+ ...failures.map((note) => `- ${note}`),
+ "",
+ "Read the skill and propose specific edits to its text that would fix these, or say why a case is",
+ "wrong about the skill rather than the skill being wrong.",
+ ].join("\n"),
+ { deliverAs: "followUp" },
+ );
+}
+
export default async function (pi: ExtensionAPI) {
const { Type } = await import("typebox");
pi.registerCommand("skill-eval", {
- description: "Triage in-context skills/AGENTS.md for trim candidates, or deep-review one target",
+ description:
+ "triage | review <target> | run <skill> [--repeat N] [--jobs N] [--only trigger|adherence]",
+ getArgumentCompletions: (prefix: string) => {
+ const items = [
+ { value: "triage", label: "triage — rank skills and AGENTS.md files by trim potential" },
+ { value: "review ", label: "review <target> — critique one file's text" },
+ { value: "run ", label: "run <skill> — run its evals.yml through subagents" },
+ ].filter((item) => item.value.startsWith(prefix));
+ return items.length > 0 ? items : null;
+ },
handler: async (args: string, ctx: ExtensionCommandContext) => {
- const target = args.trim();
- if (!target) {
+ const parsed = parseCommandArgs(args);
+
+ if (parsed.action === "error") {
+ announce(ctx, `${parsed.message} Usage: /skill-eval [triage | review <target> | run <skill>]`, "error");
+ return;
+ }
+
+ if (parsed.action === "triage") {
const result = await runTriage(ctx);
if (!result.ok) {
- ctx.ui.notify(result.message, "error");
+ announce(ctx, result.message, "error");
return;
}
- ctx.ui.notify(`${result.markdown}\n\nReport saved to ${result.reportPath}`, "info");
+ announce(ctx, `${result.markdown}\n\nReport saved to ${result.reportPath}`, "info");
return;
}
- const result = await runReview(ctx, target);
- if (!result.ok) {
- ctx.ui.notify(result.message, "error");
+ if (parsed.action === "review") {
+ const result = await runReview(ctx, parsed.target);
+ if (!result.ok) {
+ announce(ctx, result.message, "error");
+ return;
+ }
+ pi.sendUserMessage(result.prompt, { deliverAs: "followUp" });
+ announce(ctx, `Queued review for ${result.name}`, "info");
return;
}
- pi.sendUserMessage(result.prompt, { deliverAs: "followUp" });
- ctx.ui.notify(`Queued review for ${result.name}`, "info");
+
+ await handleRun(pi, ctx, parsed);
},
});
@@ -476,6 +705,7 @@ export default async function (pi: ExtensionAPI) {
promptGuidelines: [
"Use skill_eval with action 'triage' when asked to find skills or AGENTS.md files worth trimming.",
"Use skill_eval with action 'review' and a target name when asked to critique a specific skill or AGENTS.md file.",
+ "Behavioral evals spend API credit, so they are user-driven: tell the user to run /skill-eval run <skill> rather than trying to run them yourself.",
],
parameters: Type.Object({
action: Type.Union([Type.Literal("triage"), Type.Literal("review")]),
diff --git a/ai/pi/extensions/skill-eval/run.test.ts b/ai/pi/extensions/skill-eval/run.test.ts
new file mode 100644
index 00000000..8aa0565f
--- /dev/null
+++ b/ai/pi/extensions/skill-eval/run.test.ts
@@ -0,0 +1,327 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ buildJudgePrompt,
+ formatEvalReport,
+ loadedSkill,
+ mapWithConcurrency,
+ parseJudgeVerdicts,
+ runEvals,
+ skillLoadPath,
+ summarizeFailures,
+ type EvalReport,
+} from "./run.ts";
+
+test("loadedSkill matches reads anywhere inside the skill directory", () => {
+ const skillPath = "/skills/jj/SKILL.md";
+ assert.equal(loadedSkill([{ name: "read", args: { path: "/skills/jj/SKILL.md" } }], skillPath), true);
+ assert.equal(loadedSkill([{ name: "read", args: { path: "/skills/jj/references/rebase.md" } }], skillPath), true);
+ assert.equal(loadedSkill([{ name: "read", args: { path: "/skills/jj/../jj/SKILL.md" } }], skillPath), true);
+});
+
+test("loadedSkill ignores other skills, other tools, and malformed arguments", () => {
+ const skillPath = "/skills/jj/SKILL.md";
+ assert.equal(loadedSkill([{ name: "read", args: { path: "/skills/jj-workspaces/SKILL.md" } }], skillPath), false);
+ assert.equal(loadedSkill([{ name: "bash", args: { command: "cat /skills/jj/SKILL.md" } }], skillPath), false);
+ assert.equal(loadedSkill([{ name: "read", args: {} }], skillPath), false);
+ assert.equal(loadedSkill([], skillPath), false);
+});
+
+test("loadedSkill counts only the file itself for a bare .md skill", () => {
+ const skillPath = "/skills/jj.md";
+ assert.equal(loadedSkill([{ name: "read", args: { path: "/skills/jj.md" } }], skillPath), true);
+ assert.equal(loadedSkill([{ name: "read", args: { path: "/skills/bash.md" } }], skillPath), false);
+});
+
+test("skillLoadPath passes the bundle directory for SKILL.md and the file otherwise", () => {
+ assert.equal(skillLoadPath("/skills/jj/SKILL.md"), "/skills/jj");
+ assert.equal(skillLoadPath("/skills/jj.md"), "/skills/jj.md");
+});
+
+test("buildJudgePrompt numbers expectations and carries task and transcript", () => {
+ const prompt = buildJudgePrompt("jj", "commit my work", "[assistant] jj commit", ["uses jj commit", "adds a trailer"]);
+ assert.match(prompt, /"jj" skill/);
+ assert.match(prompt, /1\. uses jj commit/);
+ assert.match(prompt, /2\. adds a trailer/);
+ assert.match(prompt, /commit my work/);
+ assert.match(prompt, /\[assistant\] jj commit/);
+});
+
+test("buildJudgePrompt notes an empty transcript rather than sending nothing", () => {
+ assert.match(buildJudgePrompt("jj", "task", "", ["something"]), /the agent produced no output/);
+});
+
+test("parseJudgeVerdicts reads a fenced array and matches verdicts by index", () => {
+ const reply = [
+ "Here are my verdicts:",
+ "```json",
+ '[{"index": 2, "verdict": "fail", "reason": "no trailer"},',
+ ' {"index": 1, "verdict": "pass", "reason": "ran jj commit"}]',
+ "```",
+ ].join("\n");
+ const results = parseJudgeVerdicts(reply, ["uses jj commit", "adds a trailer"]);
+ assert.deepEqual(results, [
+ { expectation: "uses jj commit", verdict: "pass", reason: "ran jj commit" },
+ { expectation: "adds a trailer", verdict: "fail", reason: "no trailer" },
+ ]);
+});
+
+test("parseJudgeVerdicts falls back to unclear when the reply is unusable", () => {
+ for (const reply of ["I could not tell.", "[not json]", '{"verdict":"pass"}']) {
+ const results = parseJudgeVerdicts(reply, ["a", "b"]);
+ assert.equal(results.length, 2);
+ assert.ok(results.every((r) => r.verdict === "unclear"));
+ }
+});
+
+test("parseJudgeVerdicts treats unknown verdict values as unclear", () => {
+ const results = parseJudgeVerdicts('[{"index":1,"verdict":"maybe","reason":"hmm"}]', ["a"]);
+ assert.deepEqual(results, [{ expectation: "a", verdict: "unclear", reason: "hmm" }]);
+});
+
+test("mapWithConcurrency preserves order and respects the limit", async () => {
+ let inFlight = 0;
+ let peak = 0;
+ const results = await mapWithConcurrency([10, 20, 30, 40, 50], 2, async (item) => {
+ inFlight++;
+ peak = Math.max(peak, inFlight);
+ await new Promise((r) => setTimeout(r, item % 30));
+ inFlight--;
+ return item * 2;
+ });
+ assert.deepEqual(results, [20, 40, 60, 80, 100]);
+ assert.equal(peak, 2);
+});
+
+const report: EvalReport = {
+ skillName: "widget-wrangler",
+ skillPath: "/skills/widget-wrangler/SKILL.md",
+ model: "anthropic/claude-opus-5",
+ thinking: "high",
+ repeat: 1,
+ trigger: [
+ { kind: "trigger", expected: "load", prompt: "calibrate the bench widget", attempts: [{ loaded: true }], passes: 1, costUsd: 0.01 },
+ { kind: "trigger", expected: "load", prompt: "check widget torque", attempts: [{ loaded: false }], passes: 0, costUsd: 0.01 },
+ { kind: "trigger", expected: "skip", prompt: "what is 2 + 2", attempts: [{ loaded: true }], passes: 0, costUsd: 0.01 },
+ ],
+ adherence: [
+ {
+ kind: "adherence",
+ prompt: "calibrate it and report the torque",
+ attempts: [
+ {
+ transcript: "[assistant] wrangle --torque 42",
+ expectations: [
+ { expectation: "names the torque value 42", verdict: "pass", reason: "said 42" },
+ { expectation: "reports newton-metres", verdict: "fail", reason: "no units given" },
+ ],
+ },
+ ],
+ passes: 0,
+ costUsd: 0.05,
+ },
+ ],
+ costUsd: 0.08,
+ runs: 4,
+ startedAt: new Date("2026-01-01T00:00:00.000Z"),
+ finishedAt: new Date("2026-01-01T00:01:00.000Z"),
+};
+
+test("formatEvalReport scores both sections and lists misses", () => {
+ const markdown = formatEvalReport(report);
+ assert.match(markdown, /# Skill eval: widget-wrangler/);
+ assert.match(markdown, /anthropic\/claude-opus-5 \(thinking: high\).*\$0\.0800.*60s/);
+ assert.match(markdown, /## Trigger fidelity — 1\/3/);
+ assert.match(markdown, /\| load \| 0\/1 \| check widget torque \|/);
+ assert.match(markdown, /did not load: check widget torque/);
+ assert.match(markdown, /loaded when it should not have: what is 2 \+ 2/);
+ assert.match(markdown, /## Instruction adherence — 0\/1/);
+ assert.match(markdown, /- fail: reports newton-metres — no units given/);
+});
+
+test("summarizeFailures reports each miss and each unmet expectation", () => {
+ const notes = summarizeFailures(report);
+ assert.equal(notes.length, 3);
+ assert.match(notes[0], /did not fire for "check widget torque"/);
+ assert.match(notes[1], /fired on the near-miss prompt "what is 2 \+ 2"/);
+ assert.match(notes[2], /"reports newton-metres" came back fail: no units given/);
+});
+
+test("summarizeFailures says nothing when every case passed", () => {
+ const clean: EvalReport = {
+ ...report,
+ trigger: [{ kind: "trigger", expected: "load", prompt: "p", attempts: [{ loaded: true }], passes: 1, costUsd: 0 }],
+ adherence: [],
+ };
+ assert.deepEqual(summarizeFailures(clean), []);
+});
+
+/**
+ * End-to-end orchestration against a stub pi entrypoint: no API credit, but it
+ * exercises argument construction, sandboxing, setup scripts, and judging.
+ */
+const FAKE_PI = `
+const args = process.argv.slice(2);
+let stdin = "";
+process.stdin.setEncoding("utf8");
+process.stdin.on("data", (c) => (stdin += c));
+process.stdin.on("end", () => {
+ const emit = (o) => process.stdout.write(JSON.stringify(o) + "\\n");
+ if (args.includes("--no-tools")) {
+ // Judge run: pass the first expectation, fail the rest.
+ const count = (stdin.match(/^\\d+\\. /gm) || []).length;
+ const verdicts = Array.from({ length: count }, (_, i) => ({
+ index: i + 1,
+ verdict: i === 0 ? "pass" : "fail",
+ reason: "stub verdict",
+ }));
+ emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: JSON.stringify(verdicts) }], usage: { cost: { total: 0.001 } } }] });
+ return;
+ }
+ const skillDir = args[args.indexOf("--skill") + 1];
+ if (args.some((a) => a.startsWith("/skill:"))) {
+ // Adherence run: record the sandbox contents so setup scripts are observable.
+ const fs = require("node:fs");
+ emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "sandbox: " + fs.readdirSync(process.cwd()).join(",") }], usage: { cost: { total: 0.002 } } }] });
+ return;
+ }
+ // Trigger run: load the skill only when the prompt mentions widgets.
+ if (/widget/.test(stdin)) emit({ type: "tool_execution_start", toolName: "read", args: { path: skillDir + "/SKILL.md" } });
+ emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }], usage: { cost: { total: 0.003 } } }] });
+});
+`;
+
+test("runEvals scores trigger and adherence cases end to end", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "skill-eval-run-"));
+ const previous = process.env.PI_BIN;
+ try {
+ const fakePi = join(dir, "fake-pi.js");
+ await writeFile(fakePi, FAKE_PI);
+ process.env.PI_BIN = fakePi;
+
+ const result = await runEvals({
+ skillName: "widget-wrangler",
+ skillPath: join(dir, "SKILL.md"),
+ suite: {
+ triggerPositive: [{ prompt: "calibrate the widget" }],
+ triggerNegative: [{ prompt: "what is 2 + 2" }, { prompt: "the widget question again" }],
+ adherence: [
+ { prompt: "calibrate it", expect: ["first", "second"], tools: ["read"], setup: "touch fixture.txt" },
+ ],
+ },
+ config: { provider: "anthropic", model: "test-model", thinking: "high", repeat: 1, jobs: 2, timeoutMs: 10_000 },
+ });
+
+ assert.equal(result.runs, 4);
+ assert.deepEqual(
+ result.trigger.map((r) => [r.expected, r.passes]),
+ [
+ ["load", 1],
+ ["skip", 1],
+ ["skip", 0],
+ ],
+ );
+ assert.deepEqual(
+ result.adherence[0].attempts[0].expectations.map((e) => e.verdict),
+ ["pass", "fail"],
+ );
+ assert.match(result.adherence[0].attempts[0].transcript, /sandbox: fixture\.txt/);
+ assert.ok(result.costUsd > 0);
+ } finally {
+ if (previous === undefined) delete process.env.PI_BIN;
+ else process.env.PI_BIN = previous;
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("runEvals marks a case unclear when its setup script fails", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "skill-eval-run-"));
+ const previous = process.env.PI_BIN;
+ try {
+ const fakePi = join(dir, "fake-pi.js");
+ await writeFile(fakePi, FAKE_PI);
+ process.env.PI_BIN = fakePi;
+
+ const result = await runEvals({
+ skillName: "widget-wrangler",
+ skillPath: join(dir, "SKILL.md"),
+ suite: {
+ triggerPositive: [],
+ triggerNegative: [],
+ adherence: [{ prompt: "calibrate it", expect: ["first"], tools: ["read"], setup: "exit 7" }],
+ },
+ config: { provider: "anthropic", model: "test-model", repeat: 1, jobs: 1, timeoutMs: 10_000 },
+ });
+
+ const attempt = result.adherence[0].attempts[0];
+ assert.match(attempt.error ?? "", /setup script failed/);
+ assert.equal(attempt.expectations[0].verdict, "unclear");
+ } finally {
+ if (previous === undefined) delete process.env.PI_BIN;
+ else process.env.PI_BIN = previous;
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("runEvals honors --only by skipping the other section", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "skill-eval-run-"));
+ const previous = process.env.PI_BIN;
+ try {
+ const fakePi = join(dir, "fake-pi.js");
+ await writeFile(fakePi, FAKE_PI);
+ process.env.PI_BIN = fakePi;
+
+ const suite = {
+ triggerPositive: [{ prompt: "calibrate the widget" }],
+ triggerNegative: [],
+ adherence: [{ prompt: "calibrate it", expect: ["first"], tools: ["read"] }],
+ };
+ const config = { provider: "anthropic", model: "test-model", repeat: 1, jobs: 1, timeoutMs: 10_000 };
+
+ const triggerOnly = await runEvals({ skillName: "w", skillPath: join(dir, "SKILL.md"), suite, config, only: "trigger" });
+ assert.equal(triggerOnly.trigger.length, 1);
+ assert.equal(triggerOnly.adherence.length, 0);
+
+ const adherenceOnly = await runEvals({ skillName: "w", skillPath: join(dir, "SKILL.md"), suite, config, only: "adherence" });
+ assert.equal(adherenceOnly.trigger.length, 0);
+ assert.equal(adherenceOnly.adherence.length, 1);
+ } finally {
+ if (previous === undefined) delete process.env.PI_BIN;
+ else process.env.PI_BIN = previous;
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
+test("runEvals reports progress for every attempt", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "skill-eval-run-"));
+ const previous = process.env.PI_BIN;
+ try {
+ const fakePi = join(dir, "fake-pi.js");
+ await writeFile(fakePi, FAKE_PI);
+ process.env.PI_BIN = fakePi;
+
+ const seen: string[] = [];
+ await runEvals({
+ skillName: "widget-wrangler",
+ skillPath: join(dir, "SKILL.md"),
+ suite: { triggerPositive: [{ prompt: "calibrate the widget" }], triggerNegative: [], adherence: [] },
+ config: {
+ provider: "anthropic",
+ model: "test-model",
+ repeat: 2,
+ jobs: 1,
+ timeoutMs: 10_000,
+ onProgress: (done, total, label) => seen.push(`${done}/${total} ${label}`),
+ },
+ });
+
+ assert.deepEqual(seen, ["1/2 trigger: calibrate the widget", "2/2 trigger: calibrate the widget"]);
+ } finally {
+ if (previous === undefined) delete process.env.PI_BIN;
+ else process.env.PI_BIN = previous;
+ await rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/ai/pi/extensions/skill-eval/run.ts b/ai/pi/extensions/skill-eval/run.ts
new file mode 100644
index 00000000..4fc06a7f
--- /dev/null
+++ b/ai/pi/extensions/skill-eval/run.ts
@@ -0,0 +1,515 @@
+/**
+ * Behavioral evals: run a skill's text through nested pi processes.
+ *
+ * Trigger cases measure whether the description makes the agent reach for the
+ * skill, so the run keeps ordinary skill discovery on (competing descriptions
+ * are part of the test) and allows only `read`, then checks whether the skill
+ * files were read.
+ *
+ * Adherence cases force the skill in with `/skill:<name>`, let the agent work
+ * in a scratch directory, and hand the transcript to a judge run that grades
+ * each declared expectation.
+ *
+ * Runs are hermetic apart from globally discovered skills: no context files, no
+ * extensions, no session, and a fresh sandbox directory per case.
+ */
+
+import { mkdtemp, mkdir, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { basename, dirname, join, relative, resolve, sep } from "node:path";
+import type { AdherenceCase, EvalSuite, TriggerCase } from "./evals.ts";
+import { formatTranscript, runSetupScript, runSubagent, type SubagentResult } from "./subagent.ts";
+
+export interface EvalRunConfig {
+ provider: string;
+ model: string;
+ thinking?: string;
+ /** Attempts per case. Repeats surface flaky trigger behavior. */
+ repeat: number;
+ /** Cases in flight at once. */
+ jobs: number;
+ timeoutMs: number;
+ signal?: AbortSignal;
+ onProgress?: (done: number, total: number, label: string) => void;
+}
+
+export type Verdict = "pass" | "fail" | "unclear";
+
+export interface ExpectationResult {
+ expectation: string;
+ verdict: Verdict;
+ reason: string;
+}
+
+export interface TriggerAttempt {
+ loaded: boolean;
+ error?: string;
+}
+
+export interface TriggerResult {
+ kind: "trigger";
+ expected: "load" | "skip";
+ prompt: string;
+ note?: string;
+ attempts: TriggerAttempt[];
+ passes: number;
+ costUsd: number;
+}
+
+export interface AdherenceAttempt {
+ expectations: ExpectationResult[];
+ transcript: string;
+ error?: string;
+}
+
+export interface AdherenceResult {
+ kind: "adherence";
+ prompt: string;
+ note?: string;
+ attempts: AdherenceAttempt[];
+ passes: number;
+ costUsd: number;
+}
+
+export interface EvalReport {
+ skillName: string;
+ skillPath: string;
+ model: string;
+ thinking?: string;
+ repeat: number;
+ trigger: TriggerResult[];
+ adherence: AdherenceResult[];
+ costUsd: number;
+ runs: number;
+ startedAt: Date;
+ finishedAt: Date;
+}
+
+/**
+ * What to hand `--skill`, so the target loads even when it isn't discoverable
+ * from the sandbox. Pi accepts either a file or a directory, and a bare `.md`
+ * skill has no directory of its own.
+ */
+export function skillLoadPath(skillPath: string): string {
+ return basename(skillPath) === "SKILL.md" ? dirname(skillPath) : skillPath;
+}
+
+/**
+ * True when the run read the skill itself, or one of its bundled references.
+ * A bare `.md` skill shares its directory with unrelated skills, so only the
+ * file itself counts there.
+ */
+export function loadedSkill(toolCalls: { name: string; args: Record<string, unknown> }[], skillPath: string): boolean {
+ const target = resolve(skillPath);
+ const bundleDir = basename(skillPath) === "SKILL.md" ? resolve(dirname(skillPath)) : undefined;
+ return toolCalls.some((call) => {
+ if (call.name !== "read") return false;
+ const raw = call.args?.path;
+ if (typeof raw !== "string") return false;
+ const read = resolve(raw);
+ if (read === target) return true;
+ if (!bundleDir) return false;
+ const rel = relative(bundleDir, read);
+ return rel !== "" && !rel.startsWith("..") && !rel.startsWith(`..${sep}`);
+ });
+}
+
+export function buildJudgePrompt(
+ skillName: string,
+ task: string,
+ transcript: string,
+ expectations: string[],
+): string {
+ return [
+ `You are grading one run of a coding agent that was given the "${skillName}" skill and then a task.`,
+ "",
+ "Grade each expectation independently against the transcript. Judge only what the transcript",
+ 'shows: "pass" when the transcript clearly meets the expectation, "fail" when it clearly does not,',
+ '"unclear" when the transcript does not settle it. Do not credit intentions the agent never acted on.',
+ "",
+ "Task given to the agent:",
+ task,
+ "",
+ "Expectations:",
+ ...expectations.map((item, i) => `${i + 1}. ${item}`),
+ "",
+ "Transcript:",
+ "<<<TRANSCRIPT",
+ transcript || "(the agent produced no output)",
+ "TRANSCRIPT",
+ "",
+ "Reply with only a JSON array, one object per expectation, in the same order:",
+ '[{"index": 1, "verdict": "pass" | "fail" | "unclear", "reason": "one sentence"}]',
+ ].join("\n");
+}
+
+/** Pull the verdict array out of a judge reply, tolerating code fences and surrounding prose. */
+export function parseJudgeVerdicts(text: string, expectations: string[]): ExpectationResult[] {
+ const fallback = (reason: string): ExpectationResult[] =>
+ expectations.map((expectation) => ({ expectation, verdict: "unclear" as Verdict, reason }));
+
+ const start = text.indexOf("[");
+ const end = text.lastIndexOf("]");
+ if (start === -1 || end <= start) return fallback("judge did not return a JSON array");
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text.slice(start, end + 1));
+ } catch {
+ return fallback("judge returned unparseable JSON");
+ }
+ if (!Array.isArray(parsed)) return fallback("judge did not return a JSON array");
+
+ return expectations.map((expectation, i) => {
+ const entry: any = parsed.find((item: any) => Number(item?.index) === i + 1) ?? parsed[i];
+ const verdict: Verdict =
+ entry?.verdict === "pass" || entry?.verdict === "fail" || entry?.verdict === "unclear"
+ ? entry.verdict
+ : "unclear";
+ const reason = typeof entry?.reason === "string" ? entry.reason : "judge gave no reason";
+ return { expectation, verdict, reason };
+ });
+}
+
+async function withSandbox<T>(prefix: string, fn: (dir: string) => Promise<T>): Promise<T> {
+ const dir = await mkdtemp(join(tmpdir(), `skill-eval-${prefix}-`));
+ try {
+ return await fn(dir);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
+async function runTriggerAttempt(
+ testCase: TriggerCase,
+ skillPath: string,
+ config: EvalRunConfig,
+): Promise<{ attempt: TriggerAttempt; costUsd: number }> {
+ return await withSandbox("trigger", async (cwd) => {
+ const result = await runSubagent({
+ cwd,
+ provider: config.provider,
+ model: config.model,
+ thinking: config.thinking,
+ tools: ["read"],
+ skillDirs: [skillLoadPath(skillPath)],
+ discoverSkills: true,
+ stdin: testCase.prompt,
+ timeoutMs: config.timeoutMs,
+ signal: config.signal,
+ });
+ return {
+ attempt: {
+ loaded: loadedSkill(result.toolCalls, skillPath),
+ ...(result.error ? { error: result.error } : {}),
+ },
+ costUsd: result.costUsd,
+ };
+ });
+}
+
+async function judge(
+ skillName: string,
+ testCase: AdherenceCase,
+ transcript: string,
+ config: EvalRunConfig,
+): Promise<{ expectations: ExpectationResult[]; costUsd: number }> {
+ return await withSandbox("judge", async (cwd) => {
+ const result = await runSubagent({
+ cwd,
+ provider: config.provider,
+ model: config.model,
+ thinking: config.thinking,
+ tools: [],
+ discoverSkills: false,
+ stdin: buildJudgePrompt(skillName, testCase.prompt, transcript, testCase.expect),
+ timeoutMs: config.timeoutMs,
+ signal: config.signal,
+ });
+ if (result.error) {
+ return {
+ expectations: testCase.expect.map((expectation) => ({
+ expectation,
+ verdict: "unclear" as Verdict,
+ reason: `judge run failed: ${result.error}`,
+ })),
+ costUsd: result.costUsd,
+ };
+ }
+ return { expectations: parseJudgeVerdicts(result.finalText, testCase.expect), costUsd: result.costUsd };
+ });
+}
+
+async function runAdherenceAttempt(
+ testCase: AdherenceCase,
+ skillName: string,
+ skillPath: string,
+ config: EvalRunConfig,
+): Promise<{ attempt: AdherenceAttempt; costUsd: number }> {
+ return await withSandbox("adherence", async (cwd) => {
+ const work = join(cwd, "work");
+ await mkdir(work, { recursive: true });
+
+ if (testCase.setup) {
+ const setup = await runSetupScript(testCase.setup, work);
+ if (!setup.ok) {
+ return {
+ attempt: {
+ expectations: unclearAll(testCase.expect, `setup script failed: ${setup.output || "no output"}`),
+ transcript: "",
+ error: `setup script failed: ${setup.output || "no output"}`,
+ },
+ costUsd: 0,
+ };
+ }
+ }
+
+ let result: SubagentResult;
+ try {
+ result = await runSubagent({
+ cwd: work,
+ provider: config.provider,
+ model: config.model,
+ thinking: config.thinking,
+ tools: testCase.tools,
+ skillDirs: [skillLoadPath(skillPath)],
+ discoverSkills: false,
+ messages: [`/skill:${skillName}`, testCase.prompt],
+ timeoutMs: config.timeoutMs,
+ signal: config.signal,
+ });
+ } catch (error) {
+ const message = (error as Error).message;
+ return {
+ attempt: { expectations: unclearAll(testCase.expect, message), transcript: "", error: message },
+ costUsd: 0,
+ };
+ }
+
+ const transcript = formatTranscript(result.events);
+ if (result.error) {
+ return {
+ attempt: {
+ expectations: unclearAll(testCase.expect, `run failed: ${result.error}`),
+ transcript,
+ error: result.error,
+ },
+ costUsd: result.costUsd,
+ };
+ }
+
+ const graded = await judge(skillName, testCase, transcript, config);
+ return {
+ attempt: { expectations: graded.expectations, transcript },
+ costUsd: result.costUsd + graded.costUsd,
+ };
+ });
+}
+
+function unclearAll(expectations: string[], reason: string): ExpectationResult[] {
+ return expectations.map((expectation) => ({ expectation, verdict: "unclear" as Verdict, reason }));
+}
+
+/** Run tasks with bounded concurrency, preserving input order in the results. */
+export async function mapWithConcurrency<T, R>(
+ items: T[],
+ limit: number,
+ fn: (item: T, index: number) => Promise<R>,
+): Promise<R[]> {
+ const results = new Array<R>(items.length);
+ let next = 0;
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
+ while (next < items.length) {
+ const index = next++;
+ results[index] = await fn(items[index], index);
+ }
+ });
+ await Promise.all(workers);
+ return results;
+}
+
+type Unit =
+ | { kind: "trigger"; expected: "load" | "skip"; testCase: TriggerCase }
+ | { kind: "adherence"; testCase: AdherenceCase };
+
+export interface RunEvalsOptions {
+ skillName: string;
+ skillPath: string;
+ suite: EvalSuite;
+ config: EvalRunConfig;
+ only?: "trigger" | "adherence";
+}
+
+export async function runEvals({ skillName, skillPath, suite, config, only }: RunEvalsOptions): Promise<EvalReport> {
+ const startedAt = new Date();
+
+ const units: Unit[] = [];
+ if (only !== "adherence") {
+ for (const testCase of suite.triggerPositive) units.push({ kind: "trigger", expected: "load", testCase });
+ for (const testCase of suite.triggerNegative) units.push({ kind: "trigger", expected: "skip", testCase });
+ }
+ if (only !== "trigger") {
+ for (const testCase of suite.adherence) units.push({ kind: "adherence", testCase });
+ }
+
+ const total = units.length * config.repeat;
+ let done = 0;
+ const tick = (label: string) => config.onProgress?.(++done, total, label);
+
+ const results = await mapWithConcurrency(units, config.jobs, async (unit) => {
+ if (unit.kind === "trigger") {
+ const attempts: TriggerAttempt[] = [];
+ let costUsd = 0;
+ for (let i = 0; i < config.repeat; i++) {
+ const { attempt, costUsd: cost } = await runTriggerAttempt(unit.testCase, skillPath, config);
+ attempts.push(attempt);
+ costUsd += cost;
+ tick(`trigger: ${unit.testCase.prompt}`);
+ }
+ const passes = attempts.filter((a) => (unit.expected === "load" ? a.loaded : !a.loaded)).length;
+ const result: TriggerResult = {
+ kind: "trigger",
+ expected: unit.expected,
+ prompt: unit.testCase.prompt,
+ ...(unit.testCase.note ? { note: unit.testCase.note } : {}),
+ attempts,
+ passes,
+ costUsd,
+ };
+ return result;
+ }
+
+ const attempts: AdherenceAttempt[] = [];
+ let costUsd = 0;
+ for (let i = 0; i < config.repeat; i++) {
+ const { attempt, costUsd: cost } = await runAdherenceAttempt(unit.testCase, skillName, skillPath, config);
+ attempts.push(attempt);
+ costUsd += cost;
+ tick(`adherence: ${unit.testCase.prompt}`);
+ }
+ const passes = attempts.filter((a) => a.expectations.every((e) => e.verdict === "pass")).length;
+ const result: AdherenceResult = {
+ kind: "adherence",
+ prompt: unit.testCase.prompt,
+ ...(unit.testCase.note ? { note: unit.testCase.note } : {}),
+ attempts,
+ passes,
+ costUsd,
+ };
+ return result;
+ });
+
+ const trigger = results.filter((r): r is TriggerResult => r.kind === "trigger");
+ const adherence = results.filter((r): r is AdherenceResult => r.kind === "adherence");
+
+ return {
+ skillName,
+ skillPath,
+ model: `${config.provider}/${config.model}`,
+ thinking: config.thinking,
+ repeat: config.repeat,
+ trigger,
+ adherence,
+ costUsd: results.reduce((sum, r) => sum + r.costUsd, 0),
+ runs: total,
+ startedAt,
+ finishedAt: new Date(),
+ };
+}
+
+// ---------- Reporting ----------
+
+function firstLine(text: string, max = 80): string {
+ const line = text.split("\n")[0].trim();
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
+}
+
+export function formatEvalReport(report: EvalReport): string {
+ const lines: string[] = [];
+ const triggerPasses = report.trigger.reduce((sum, r) => sum + r.passes, 0);
+ const triggerTotal = report.trigger.length * report.repeat;
+ const adherencePasses = report.adherence.reduce((sum, r) => sum + r.passes, 0);
+ const adherenceTotal = report.adherence.length * report.repeat;
+ const seconds = Math.round((report.finishedAt.getTime() - report.startedAt.getTime()) / 1000);
+
+ lines.push(`# Skill eval: ${report.skillName}`);
+ lines.push("");
+ lines.push(`${report.skillPath}`);
+ lines.push("");
+ lines.push(
+ `${report.model}${report.thinking ? ` (thinking: ${report.thinking})` : ""} · ${report.runs} case run(s) · ` +
+ `${report.repeat} attempt(s) per case · $${report.costUsd.toFixed(4)} · ${seconds}s`,
+ );
+ lines.push("");
+
+ if (report.trigger.length > 0) {
+ lines.push(`## Trigger fidelity — ${triggerPasses}/${triggerTotal}`);
+ lines.push("");
+ lines.push("| Expected | Loaded | Prompt |");
+ lines.push("|----------|--------|--------|");
+ for (const row of report.trigger) {
+ const loads = row.attempts.filter((a) => a.loaded).length;
+ lines.push(
+ `| ${row.expected === "load" ? "load" : "skip"} | ${loads}/${row.attempts.length} | ${firstLine(row.prompt)} |`,
+ );
+ }
+ const failures = report.trigger.filter((r) => r.passes < r.attempts.length);
+ if (failures.length > 0) {
+ lines.push("");
+ lines.push("Misses:");
+ lines.push("");
+ for (const row of failures) {
+ const verb = row.expected === "load" ? "did not load" : "loaded when it should not have";
+ lines.push(`- ${verb}: ${firstLine(row.prompt, 120)}`);
+ for (const attempt of row.attempts) {
+ if (attempt.error) lines.push(` - run error: ${attempt.error}`);
+ }
+ }
+ }
+ lines.push("");
+ }
+
+ if (report.adherence.length > 0) {
+ lines.push(`## Instruction adherence — ${adherencePasses}/${adherenceTotal}`);
+ lines.push("");
+ for (const row of report.adherence) {
+ lines.push(`### ${firstLine(row.prompt, 120)}`);
+ lines.push("");
+ row.attempts.forEach((attempt, i) => {
+ if (row.attempts.length > 1) lines.push(`Attempt ${i + 1}:`);
+ if (attempt.error) lines.push(`- run error: ${attempt.error}`);
+ for (const expectation of attempt.expectations) {
+ lines.push(`- ${expectation.verdict}: ${expectation.expectation} — ${expectation.reason}`);
+ }
+ lines.push("");
+ });
+ }
+ }
+
+ return lines.join("\n");
+}
+
+/** Failure detail worth pulling into the conversation so the skill text can be fixed. */
+export function summarizeFailures(report: EvalReport): string[] {
+ const notes: string[] = [];
+ for (const row of report.trigger) {
+ if (row.passes === row.attempts.length) continue;
+ const loads = row.attempts.filter((a) => a.loaded).length;
+ notes.push(
+ row.expected === "load"
+ ? `The description did not fire for "${firstLine(row.prompt, 120)}" (${loads}/${row.attempts.length} loads).`
+ : `The description fired on the near-miss prompt "${firstLine(row.prompt, 120)}" (${loads}/${row.attempts.length} loads).`,
+ );
+ }
+ for (const row of report.adherence) {
+ for (const attempt of row.attempts) {
+ for (const expectation of attempt.expectations) {
+ if (expectation.verdict === "pass") continue;
+ notes.push(
+ `On "${firstLine(row.prompt, 80)}", expectation "${expectation.expectation}" came back ${expectation.verdict}: ${expectation.reason}`,
+ );
+ }
+ }
+ }
+ return notes;
+}
diff --git a/ai/pi/extensions/skill-eval/skill-eval.test.ts b/ai/pi/extensions/skill-eval/skill-eval.test.ts
index d1a157e9..56fadeca 100644
--- a/ai/pi/extensions/skill-eval/skill-eval.test.ts
+++ b/ai/pi/extensions/skill-eval/skill-eval.test.ts
@@ -244,6 +244,14 @@ test("resolveReportPath builds a path under the given state base dir", () => {
assert.equal(path, join("/fake/state", "pi", "skill-eval", "reports", "2026-01-01T00-00-00-000Z.md"));
});
+test("resolveReportPath appends a filename-safe label", () => {
+ const path = resolveReportPath(new Date("2026-01-01T00:00:00.000Z"), "/fake/state", "eval-jj workspaces");
+ assert.equal(
+ path,
+ join("/fake/state", "pi", "skill-eval", "reports", "2026-01-01T00-00-00-000Z-eval-jj_workspaces.md"),
+ );
+});
+
test("writeReport creates parent directories and writes content", async () => {
const dir = await mkdtemp(join(tmpdir(), "skill-eval-report-"));
try {
@@ -310,3 +318,47 @@ test("buildReviewPrompt notes no usage signal for AGENTS.md", () => {
const prompt = buildReviewPrompt("agents", "/home/user/AGENTS.md", "# instructions", null);
assert.match(prompt, /No usage signal available/);
});
+
+import { parseCommandArgs } from "./index.ts";
+
+test("parseCommandArgs treats no arguments and an explicit subcommand as triage", () => {
+ assert.deepEqual(parseCommandArgs(""), { action: "triage" });
+ assert.deepEqual(parseCommandArgs(" "), { action: "triage" });
+ assert.deepEqual(parseCommandArgs("triage"), { action: "triage" });
+});
+
+test("parseCommandArgs keeps the bare-target form working as a review", () => {
+ assert.deepEqual(parseCommandArgs("jj"), { action: "review", target: "jj" });
+ assert.deepEqual(parseCommandArgs("review jj"), { action: "review", target: "jj" });
+});
+
+test("parseCommandArgs reads run flags and applies defaults", () => {
+ assert.deepEqual(parseCommandArgs("run jj"), { action: "run", target: "jj", repeat: 1, jobs: 3 });
+ assert.deepEqual(parseCommandArgs("run jj --repeat 3 --jobs 1 --only trigger"), {
+ action: "run",
+ target: "jj",
+ repeat: 3,
+ jobs: 1,
+ only: "trigger",
+ });
+});
+
+test("parseCommandArgs rejects malformed input with a specific message", () => {
+ const cases: [string, RegExp][] = [
+ ["run", /needs a skill name/],
+ ["run jj bash", /exactly one skill/],
+ ["run jj --repeat 0", /positive integer/],
+ ["run jj --jobs two", /positive integer/],
+ ["run jj --only sideways", /trigger or adherence/],
+ ["run jj --wat", /Unknown flag --wat/],
+ ["triage jj", /takes no arguments/],
+ ["review", /exactly one target/],
+ ["review a b", /exactly one target/],
+ ["frobnicate jj", /Unknown subcommand "frobnicate"/],
+ ];
+ for (const [args, pattern] of cases) {
+ const parsed = parseCommandArgs(args);
+ assert.equal(parsed.action, "error", `expected an error for "${args}"`);
+ if (parsed.action === "error") assert.match(parsed.message, pattern);
+ }
+});
diff --git a/ai/pi/extensions/skill-eval/subagent.test.ts b/ai/pi/extensions/skill-eval/subagent.test.ts
new file mode 100644
index 00000000..df399e9f
--- /dev/null
+++ b/ai/pi/extensions/skill-eval/subagent.test.ts
@@ -0,0 +1,194 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ buildSubagentArgs,
+ collectEvents,
+ extractCost,
+ extractFinalText,
+ extractToolCalls,
+ formatTranscript,
+ runSetupScript,
+ runSubagent,
+} from "./subagent.ts";
+
+const baseOptions = {
+ cwd: "/tmp/sandbox",
+ provider: "anthropic",
+ model: "claude-opus-5",
+ discoverSkills: true,
+ tools: ["read"],
+};
+
+test("buildSubagentArgs isolates the run and passes model, tools, and skills", () => {
+ const args = buildSubagentArgs({ ...baseOptions, thinking: "high", skillDirs: ["/skills/jj"] });
+
+ for (const flag of [
+ "-p",
+ "--no-session",
+ "--no-extensions",
+ "--no-context-files",
+ "--no-prompt-templates",
+ "--no-approve",
+ ]) {
+ assert.ok(args.includes(flag), `expected ${flag}`);
+ }
+ assert.deepEqual(args.slice(args.indexOf("--mode"), args.indexOf("--mode") + 2), ["--mode", "json"]);
+ assert.deepEqual(args.slice(args.indexOf("--tools"), args.indexOf("--tools") + 2), ["--tools", "read"]);
+ assert.deepEqual(args.slice(args.indexOf("--skill"), args.indexOf("--skill") + 2), ["--skill", "/skills/jj"]);
+ assert.deepEqual(args.slice(args.indexOf("--thinking"), args.indexOf("--thinking") + 2), ["--thinking", "high"]);
+ assert.ok(!args.includes("--no-skills"));
+});
+
+test("buildSubagentArgs disables tools and skill discovery when asked", () => {
+ const args = buildSubagentArgs({ ...baseOptions, tools: [], discoverSkills: false });
+ assert.ok(args.includes("--no-tools"));
+ assert.ok(args.includes("--no-skills"));
+ assert.ok(!args.includes("--tools"));
+});
+
+test("buildSubagentArgs appends messages in order after the flags", () => {
+ const args = buildSubagentArgs({ ...baseOptions, messages: ["/skill:jj", "commit my work"] });
+ assert.deepEqual(args.slice(-2), ["/skill:jj", "commit my work"]);
+});
+
+test("collectEvents parses JSON lines and skips noise", () => {
+ const events = collectEvents(['{"type":"agent_start"}', "not json", "", '{"type":"agent_end","messages":[]}'].join("\n"));
+ assert.deepEqual(
+ events.map((e) => e.type),
+ ["agent_start", "agent_end"],
+ );
+});
+
+test("extractToolCalls returns tool executions in order", () => {
+ const events = [
+ { type: "tool_execution_start", toolName: "read", args: { path: "/skills/jj/SKILL.md" } },
+ { type: "message_end" },
+ { type: "tool_execution_start", toolName: "bash", args: { command: "jj st" } },
+ ];
+ assert.deepEqual(extractToolCalls(events), [
+ { name: "read", args: { path: "/skills/jj/SKILL.md" } },
+ { name: "bash", args: { command: "jj st" } },
+ ]);
+});
+
+const agentEnd = {
+ type: "agent_end",
+ messages: [
+ { role: "user", content: [{ type: "text", text: "commit my work" }] },
+ {
+ role: "assistant",
+ content: [
+ { type: "text", text: "Checking status." },
+ { type: "toolCall", name: "bash", arguments: { command: "jj st" } },
+ ],
+ usage: { cost: { total: 0.01 } },
+ },
+ { role: "toolResult", toolName: "bash", isError: false, content: [{ type: "text", text: "Working copy changes" }] },
+ { role: "assistant", content: [{ type: "text", text: "Done." }], usage: { cost: { total: 0.02 } } },
+ ],
+};
+
+test("extractFinalText returns the last assistant text", () => {
+ assert.equal(extractFinalText([{ type: "agent_start" }, agentEnd]), "Done.");
+});
+
+test("extractFinalText returns an empty string when no assistant text exists", () => {
+ assert.equal(extractFinalText([{ type: "agent_end", messages: [] }]), "");
+ assert.equal(extractFinalText([]), "");
+});
+
+test("extractCost sums per-message cost", () => {
+ assert.equal(Number(extractCost([agentEnd]).toFixed(4)), 0.03);
+});
+
+test("formatTranscript renders prompts, assistant text, tool calls, and results", () => {
+ const transcript = formatTranscript([agentEnd]);
+ assert.match(transcript, /\[user\]\ncommit my work/);
+ assert.match(transcript, /\[assistant\]\nChecking status\./);
+ assert.match(transcript, /\[tool call: bash\]\n\{"command":"jj st"\}/);
+ assert.match(transcript, /\[tool result: bash\]\nWorking copy changes/);
+});
+
+test("formatTranscript truncates long tool results", () => {
+ const events = [
+ {
+ type: "agent_end",
+ messages: [
+ { role: "toolResult", toolName: "bash", isError: false, content: [{ type: "text", text: "x".repeat(50) }] },
+ ],
+ },
+ ];
+ const transcript = formatTranscript(events, 10);
+ assert.match(transcript, /x{10}\n… \[40 more characters\]/);
+});
+
+test("runSetupScript reports success and failure with output", async () => {
+ const ok = await runSetupScript("echo hello", "/tmp");
+ assert.equal(ok.ok, true);
+ assert.equal(ok.output, "hello");
+
+ const failed = await runSetupScript("echo boom >&2; exit 3", "/tmp");
+ assert.equal(failed.ok, false);
+ assert.match(failed.output, /boom/);
+});
+
+/** Stand in for the pi entrypoint via PI_BIN so these tests spend no API credit. */
+async function withFakePi<T>(script: string, fn: () => Promise<T>): Promise<T> {
+ const dir = await mkdtemp(join(tmpdir(), "skill-eval-fakepi-"));
+ const path = join(dir, "fake-pi.js");
+ await writeFile(path, script);
+ const previous = process.env.PI_BIN;
+ process.env.PI_BIN = path;
+ try {
+ return await fn();
+ } finally {
+ if (previous === undefined) delete process.env.PI_BIN;
+ else process.env.PI_BIN = previous;
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
+test("runSubagent parses the event stream, forwards stdin, and reports cost", async () => {
+ const script = `
+let input = "";
+process.stdin.setEncoding("utf8");
+process.stdin.on("data", (chunk) => (input += chunk));
+process.stdin.on("end", () => {
+ process.stdout.write(JSON.stringify({ type: "tool_execution_start", toolName: "read", args: { path: input } }) + "\\n");
+ process.stdout.write(JSON.stringify({
+ type: "agent_end",
+ messages: [{ role: "assistant", content: [{ type: "text", text: "ok: " + process.argv.includes("--no-tools") }], usage: { cost: { total: 0.5 } } }],
+ }) + "\\n");
+});
+`;
+ const result = await withFakePi(script, () =>
+ runSubagent({ ...baseOptions, cwd: "/tmp", tools: [], discoverSkills: false, stdin: "/skills/jj/SKILL.md" }),
+ );
+
+ assert.equal(result.exitCode, 0);
+ assert.equal(result.error, undefined);
+ assert.deepEqual(result.toolCalls, [{ name: "read", args: { path: "/skills/jj/SKILL.md" } }]);
+ assert.equal(result.finalText, "ok: true");
+ assert.equal(result.costUsd, 0.5);
+});
+
+test("runSubagent reports a nonzero exit with stderr instead of throwing", async () => {
+ const result = await withFakePi('process.stderr.write("no API key\\n"); process.exit(2);', () =>
+ runSubagent({ ...baseOptions, cwd: "/tmp" }),
+ );
+
+ assert.equal(result.exitCode, 2);
+ assert.match(result.error ?? "", /pi exited with code 2: no API key/);
+});
+
+test("runSubagent kills a run that outlives its timeout", async () => {
+ const result = await withFakePi("setTimeout(() => {}, 60000);", () =>
+ runSubagent({ ...baseOptions, cwd: "/tmp", timeoutMs: 200 }),
+ );
+
+ assert.equal(result.timedOut, true);
+ assert.match(result.error ?? "", /exceeded 200ms/);
+});
diff --git a/ai/pi/extensions/skill-eval/subagent.ts b/ai/pi/extensions/skill-eval/subagent.ts
new file mode 100644
index 00000000..caab00a1
--- /dev/null
+++ b/ai/pi/extensions/skill-eval/subagent.ts
@@ -0,0 +1,236 @@
+/**
+ * Subagent runner.
+ *
+ * Pi has no subagents, so a "subagent" here is a nested `pi -p --mode json`
+ * process: no session file, no extensions, no context files, an explicit tool
+ * allowlist, and a scratch cwd. Everything the caller needs — tool calls,
+ * final text, spend — comes from parsing the JSON event stream on stdout.
+ */
+
+import { spawn } from "node:child_process";
+
+export interface SubagentOptions {
+ cwd: string;
+ provider: string;
+ model: string;
+ thinking?: string;
+ /** Tool allowlist. An empty list disables tools entirely. */
+ tools: string[];
+ /** Extra skill paths to load on top of ordinary discovery. */
+ skillDirs?: string[];
+ /** Whether ordinary skill discovery runs, which keeps description competition realistic. */
+ discoverSkills: boolean;
+ /** Messages processed in order, each as its own turn. Must not start with "-" or "@". */
+ messages?: string[];
+ /** Prompt text piped in, which avoids argv escaping limits entirely. */
+ stdin?: string;
+ timeoutMs?: number;
+ signal?: AbortSignal;
+}
+
+export interface SubagentToolCall {
+ name: string;
+ args: Record<string, unknown>;
+}
+
+export interface SubagentResult {
+ exitCode: number | null;
+ timedOut: boolean;
+ stderr: string;
+ events: any[];
+ toolCalls: SubagentToolCall[];
+ finalText: string;
+ costUsd: number;
+ /** Set when the run itself failed, as opposed to the graded behavior failing. */
+ error?: string;
+}
+
+export function buildSubagentArgs(opts: SubagentOptions): string[] {
+ const args = [
+ "-p",
+ "--mode",
+ "json",
+ "--no-session",
+ "--no-extensions",
+ "--no-context-files",
+ "--no-prompt-templates",
+ "--no-approve",
+ "--offline",
+ "--provider",
+ opts.provider,
+ "--model",
+ opts.model,
+ ];
+ if (opts.thinking) args.push("--thinking", opts.thinking);
+ if (opts.tools.length === 0) args.push("--no-tools");
+ else args.push("--tools", opts.tools.join(","));
+ if (!opts.discoverSkills) args.push("--no-skills");
+ for (const dir of opts.skillDirs ?? []) args.push("--skill", dir);
+ for (const message of opts.messages ?? []) args.push(message);
+ return args;
+}
+
+/** Resolve the pi entrypoint of the running process so subagents match this build. */
+export function piExecutable(): { command: string; prefixArgs: string[] } {
+ const cli = process.env.PI_BIN ?? process.argv[1];
+ if (cli) return { command: process.execPath, prefixArgs: [cli] };
+ return { command: "pi", prefixArgs: [] };
+}
+
+export function collectEvents(stdout: string): any[] {
+ const events: any[] = [];
+ for (const line of stdout.split("\n")) {
+ if (!line.trim()) continue;
+ try {
+ events.push(JSON.parse(line));
+ } catch {
+ // Non-JSON noise on stdout isn't fatal; the stream is line-oriented.
+ }
+ }
+ return events;
+}
+
+export function extractToolCalls(events: any[]): SubagentToolCall[] {
+ return events
+ .filter((event) => event.type === "tool_execution_start")
+ .map((event) => ({ name: event.toolName, args: event.args ?? {} }));
+}
+
+function finalMessages(events: any[]): any[] {
+ for (let i = events.length - 1; i >= 0; i--) {
+ if (events[i].type === "agent_end" && Array.isArray(events[i].messages)) return events[i].messages;
+ }
+ return [];
+}
+
+export function extractFinalText(events: any[]): string {
+ const messages = finalMessages(events);
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
+ const text = message.content
+ .filter((block: any) => block.type === "text" && typeof block.text === "string")
+ .map((block: any) => block.text)
+ .join("\n")
+ .trim();
+ if (text) return text;
+ }
+ return "";
+}
+
+export function extractCost(events: any[]): number {
+ let total = 0;
+ for (const message of finalMessages(events)) {
+ const cost = message?.usage?.cost?.total;
+ if (typeof cost === "number") total += cost;
+ }
+ return total;
+}
+
+/** Readable transcript of a run, for handing to a judge. */
+export function formatTranscript(events: any[], maxResultChars = 1500): string {
+ const lines: string[] = [];
+ for (const message of finalMessages(events)) {
+ if (message.role === "user" && Array.isArray(message.content)) {
+ const text = message.content
+ .filter((block: any) => block.type === "text")
+ .map((block: any) => block.text)
+ .join("\n")
+ .trim();
+ if (text) lines.push(`[user]\n${text}`);
+ } else if (message.role === "assistant" && Array.isArray(message.content)) {
+ for (const block of message.content) {
+ if (block.type === "text" && block.text?.trim()) lines.push(`[assistant]\n${block.text.trim()}`);
+ if (block.type === "toolCall") {
+ lines.push(`[tool call: ${block.name}]\n${truncate(JSON.stringify(block.arguments ?? {}), maxResultChars)}`);
+ }
+ }
+ } else if (message.role === "toolResult" && Array.isArray(message.content)) {
+ const text = message.content
+ .filter((block: any) => block.type === "text")
+ .map((block: any) => block.text)
+ .join("\n");
+ lines.push(`[tool result: ${message.toolName}${message.isError ? " (error)" : ""}]\n${truncate(text, maxResultChars)}`);
+ }
+ }
+ return lines.join("\n\n");
+}
+
+function truncate(text: string, max: number): string {
+ if (text.length <= max) return text;
+ return `${text.slice(0, max)}\n… [${text.length - max} more characters]`;
+}
+
+export async function runSubagent(opts: SubagentOptions): Promise<SubagentResult> {
+ const { command, prefixArgs } = piExecutable();
+ const args = [...prefixArgs, ...buildSubagentArgs(opts)];
+
+ const env = { ...process.env, NO_COLOR: "1", PI_OFFLINE: "1" };
+ // A nested run must not inherit this session's identity.
+ delete env.PI_SESSION_FILE;
+ delete env.PI_SESSION_ID;
+
+ return await new Promise<SubagentResult>((resolve) => {
+ const child = spawn(command, args, { cwd: opts.cwd, env, stdio: ["pipe", "pipe", "pipe"] });
+ let stdout = "";
+ let stderr = "";
+ let timedOut = false;
+ let settled = false;
+
+ const timer = opts.timeoutMs
+ ? setTimeout(() => {
+ timedOut = true;
+ child.kill("SIGKILL");
+ }, opts.timeoutMs)
+ : undefined;
+
+ const onAbort = () => child.kill("SIGKILL");
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
+
+ const finish = (exitCode: number | null, error?: string) => {
+ if (settled) return;
+ settled = true;
+ if (timer) clearTimeout(timer);
+ opts.signal?.removeEventListener("abort", onAbort);
+ const events = collectEvents(stdout);
+ resolve({
+ exitCode,
+ timedOut,
+ stderr: stderr.trim(),
+ events,
+ toolCalls: extractToolCalls(events),
+ finalText: extractFinalText(events),
+ costUsd: extractCost(events),
+ ...(error ? { error } : {}),
+ });
+ };
+
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => (stdout += chunk));
+ child.stderr.setEncoding("utf8");
+ child.stderr.on("data", (chunk) => (stderr += chunk));
+ child.on("error", (error) => finish(null, error.message));
+ child.on("close", (code) => {
+ if (timedOut) finish(code, `run exceeded ${opts.timeoutMs}ms and was killed`);
+ else if (code !== 0) finish(code, `pi exited with code ${code}${stderr.trim() ? `: ${stderr.trim()}` : ""}`);
+ else finish(code);
+ });
+
+ if (opts.stdin !== undefined) child.stdin.end(opts.stdin);
+ else child.stdin.end();
+ });
+}
+
+/** Run a case's setup script in its sandbox. */
+export async function runSetupScript(script: string, cwd: string): Promise<{ ok: boolean; output: string }> {
+ return await new Promise((resolve) => {
+ const child = spawn("bash", ["-euo", "pipefail", "-c", script], { cwd, stdio: ["ignore", "pipe", "pipe"] });
+ let output = "";
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => (output += chunk));
+ child.stderr.setEncoding("utf8");
+ child.stderr.on("data", (chunk) => (output += chunk));
+ child.on("error", (error) => resolve({ ok: false, output: error.message }));
+ child.on("close", (code) => resolve({ ok: code === 0, output: output.trim() }));
+ });
+}