@skill-harness/adapters 0.5.0 → 0.7.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.
@@ -0,0 +1,22 @@
1
+ import type { ExecutionTraceV1, ModelRef, RunMode } from "@skill-harness/core";
2
+ export interface PiJsonRunOptions {
3
+ args: string[];
4
+ cwd: string;
5
+ timeoutMs: number;
6
+ piVersion: string | null;
7
+ subject: ModelRef;
8
+ scenarioId: string;
9
+ mode: RunMode;
10
+ rep: number;
11
+ turn: number;
12
+ changedPaths?: string[];
13
+ homeDir?: string;
14
+ }
15
+ export interface PiJsonRunResult {
16
+ trace: ExecutionTraceV1;
17
+ isComplete: boolean;
18
+ malformedLines: number;
19
+ code: number | null;
20
+ stderr: string;
21
+ }
22
+ export declare function runPiJson(opts: PiJsonRunOptions): Promise<PiJsonRunResult>;
@@ -0,0 +1,92 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { parseTrace } from "@skill-harness/core";
4
+ /**
5
+ * Run `pi --mode json` and build an execution trace, **streaming**.
6
+ *
7
+ * The streaming is not an optimization, it is the requirement. pi's
8
+ * `message_update` events re-send the entire accumulated message on every delta,
9
+ * so stdout is quadratic in the answer's length — a trivial three-tool-call run
10
+ * measured **52 MB** of stdout wrapping 12 KB of terminal events. Buffering that
11
+ * into a string (which is what the shared `exec()` helper does) would exhaust
12
+ * memory partway through a long wave, taking the whole run with it.
13
+ *
14
+ * So this deliberately does NOT reuse `exec()`. The two quadratic event types are
15
+ * dropped as each line arrives, so the giant ones are never retained; the
16
+ * remainder — a few KB of terminal events — is held until `close` and parsed
17
+ * once. What this bounds is the 52 MB, not the residue.
18
+ */
19
+ /**
20
+ * The two quadratic event types, matched at the head of the object where pi
21
+ * emits `type`. Line-anchored so a value inside the payload cannot masquerade as
22
+ * the event kind.
23
+ */
24
+ const SKIPPED_TYPE_RE = /^\s*\{\s*"type"\s*:\s*"(?:message_update|tool_execution_update)"/;
25
+ /** How much stderr to retain — enough to diagnose, bounded so a loop cannot blow up. */
26
+ const MAX_STDERR_CHARS = 8000;
27
+ export function runPiJson(opts) {
28
+ return new Promise((resolve, reject) => {
29
+ const child = spawn("pi", opts.args, {
30
+ cwd: opts.cwd,
31
+ // stdin from /dev/null: pi hangs waiting on it otherwise, and a hang in a
32
+ // wave is indistinguishable from a slow model until the timeout fires.
33
+ stdio: ["ignore", "pipe", "pipe"],
34
+ });
35
+ const kept = [];
36
+ let stderr = "";
37
+ let settled = false;
38
+ const timer = setTimeout(() => {
39
+ if (settled)
40
+ return;
41
+ settled = true;
42
+ child.kill("SIGKILL");
43
+ reject(new Error(`pi --mode json timed out after ${opts.timeoutMs}ms`));
44
+ }, opts.timeoutMs);
45
+ const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
46
+ rl.on("line", (line) => {
47
+ // Prefilter before the full parse: the events skipped here are both the
48
+ // overwhelming majority of lines and by far the largest.
49
+ //
50
+ // Anchored on the `type` field, NOT a substring of the whole line. A raw
51
+ // `line.includes('"message_update"')` also matched any event whose
52
+ // ARGUMENTS contained that text — so a `tool_execution_start` for, say,
53
+ // `grep '"message_update"' logs/` was dropped before parsing, and a
54
+ // dropped start means the call never enters the trace at all. A
55
+ // `forbid_calls` gate on that tool then passed for want of the evidence.
56
+ if (!line.trim())
57
+ return;
58
+ if (SKIPPED_TYPE_RE.test(line))
59
+ return;
60
+ kept.push(line);
61
+ });
62
+ child.stderr.on("data", (chunk) => {
63
+ if (stderr.length < MAX_STDERR_CHARS)
64
+ stderr += chunk.toString("utf8");
65
+ });
66
+ child.on("error", (err) => {
67
+ if (settled)
68
+ return;
69
+ settled = true;
70
+ clearTimeout(timer);
71
+ reject(err);
72
+ });
73
+ child.on("close", (code) => {
74
+ if (settled)
75
+ return;
76
+ settled = true;
77
+ clearTimeout(timer);
78
+ const parsed = parseTrace(kept, {
79
+ piVersion: opts.piVersion,
80
+ subject: opts.subject,
81
+ scenarioId: opts.scenarioId,
82
+ mode: opts.mode,
83
+ rep: opts.rep,
84
+ turn: opts.turn,
85
+ changedPaths: opts.changedPaths,
86
+ homeDir: opts.homeDir,
87
+ });
88
+ resolve({ ...parsed, code, stderr: stderr.slice(0, MAX_STDERR_CHARS) });
89
+ });
90
+ });
91
+ }
92
+ //# sourceMappingURL=pi-json.js.map
package/dist/pi.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdtempSync, readFileSync, statSync } from "node:fs";
2
- import { tmpdir } from "node:os";
2
+ import { tmpdir, homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
+ import { runPiJson } from "./pi-json.js";
4
5
  import { exec, onPath, envNum } from "@skill-harness/core";
5
6
  const PI_TIMEOUT_MS = envNum("PI_TIMEOUT_MS", 300_000);
6
7
  /**
@@ -51,6 +52,28 @@ function skillFlags(mode, skillDir) {
51
52
  }
52
53
  }
53
54
  }
55
+ /**
56
+ * Extension flags. `--no-extensions` is ALWAYS present (it already was), and each
57
+ * declared path is added with `--extension`.
58
+ *
59
+ * Measured on pi 0.83.0: `--no-extensions --extension <path>` loads exactly the
60
+ * declared extension and nothing discovered, even with `-a` project-local trust
61
+ * active. Paths are resolved by the caller; a relative one handed to a child
62
+ * process running in a neutral cwd would silently resolve to nothing — the same
63
+ * class of failure as the `--skill` incident.
64
+ */
65
+ function extensionFlags(extensions) {
66
+ if (!extensions || extensions.length === 0)
67
+ return [];
68
+ return extensions.flatMap((p) => {
69
+ const abs = resolve(p);
70
+ if (!existsSync(abs)) {
71
+ throw new Error(`env.extensions names ${abs}, which does not exist — pi would start without it and the ` +
72
+ `scenario would silently test an agent with no subagent tool at all.`);
73
+ }
74
+ return ["--extension", abs];
75
+ });
76
+ }
54
77
  function header(turnNo, total, text) {
55
78
  const label = total === 1 ? "USER" : `USER (turn ${turnNo}/${total})`;
56
79
  return `>>> ${label}:\n${text}\n`;
@@ -91,6 +114,7 @@ export const piAdapter = {
91
114
  const common = [
92
115
  "--no-context-files",
93
116
  "--no-extensions",
117
+ ...extensionFlags(req.extensions),
94
118
  "--provider",
95
119
  req.model.provider,
96
120
  "--model",
@@ -123,6 +147,72 @@ export const piAdapter = {
123
147
  }
124
148
  return parts.join("\n");
125
149
  },
150
+ /**
151
+ * Structured run: same flags, same turn loop, plus `--mode json` and a trace
152
+ * per turn.
153
+ *
154
+ * Shares `skillFlags` and the turn structure with `run()` on purpose — if the
155
+ * two drifted, a trace-gated scenario would be measuring a different delivery
156
+ * than an ungated one, and the gate would be attesting to the wrong execution.
157
+ *
158
+ * The transcript is REBUILT from each turn's final assistant message rather
159
+ * than read from stdout, which is byte-identical to print mode's output (proven
160
+ * on a deterministic prompt; see docs/pi-native-capture-design-2026-08-08.md §2).
161
+ */
162
+ async runStructured(req) {
163
+ const common = [
164
+ "--no-context-files",
165
+ "--no-extensions",
166
+ ...extensionFlags(req.extensions),
167
+ "--provider",
168
+ req.model.provider,
169
+ "--model",
170
+ req.model.model,
171
+ ];
172
+ const flags = req.systemPromptFile
173
+ ? ["--no-skills", "--append-system-prompt", readFileSync(req.systemPromptFile, "utf8")]
174
+ : skillFlags(req.mode, req.skillDir);
175
+ const piVersion = await this.version();
176
+ const total = req.turns.length;
177
+ const traces = [];
178
+ const parts = [];
179
+ const session = total === 1 ? null : mkdtempSync(join(tmpdir(), "sc-pi-session-"));
180
+ for (let i = 0; i < total; i++) {
181
+ const turnFlags = session === null
182
+ ? ["--no-session"]
183
+ : i === 0
184
+ ? ["--session-dir", session]
185
+ : ["--session-dir", session, "-c"];
186
+ const args = [...flags, ...common, "--mode", "json", ...turnFlags, "-p", req.turns[i]];
187
+ const r = await runPiJson({
188
+ args,
189
+ cwd: req.cwd,
190
+ timeoutMs: PI_TIMEOUT_MS,
191
+ piVersion,
192
+ subject: req.model,
193
+ scenarioId: req.scenarioId ?? "(unknown)",
194
+ mode: req.mode,
195
+ rep: req.rep ?? 0,
196
+ turn: i,
197
+ homeDir: homedir(),
198
+ });
199
+ // A stream with no terminal events at all is not evidence of a clean run.
200
+ // Fail loudly here rather than let an empty trace satisfy a `forbid_calls`
201
+ // gate — "the model called nothing" and "we recorded nothing" must not
202
+ // reach the scorer looking the same.
203
+ if (!r.isComplete) {
204
+ throw new Error(`pi --mode json produced no terminal events for turn ${i + 1}/${total}` +
205
+ ` (exit ${r.code}${r.malformedLines ? `, ${r.malformedLines} malformed line(s)` : ""})` +
206
+ (r.stderr.trim() ? `: ${r.stderr.trim()}` : ""));
207
+ }
208
+ traces.push(r.trace);
209
+ parts.push(header(i + 1, total, req.turns[i]));
210
+ parts.push(`<<< ASSISTANT:\n${r.trace.final_text.trim()}\n`);
211
+ if (r.code !== 0)
212
+ parts.push(`[pi exited ${r.code} on turn ${i + 1}]\n${r.stderr.trim()}\n`);
213
+ }
214
+ return { transcript: parts.join("\n"), traces };
215
+ },
126
216
  /**
127
217
  * Run the judge: no skills, no context files, no session, single prompt.
128
218
  * Judge provider `claude-code` routes to the Claude Code CLI (`claude -p`),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/adapters",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "skill-harness harness adapters — pi runner + claude-code judge routing (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,6 +40,6 @@
40
40
  "prepack": "cp ../../LICENSE ./LICENSE"
41
41
  },
42
42
  "dependencies": {
43
- "@skill-harness/core": "0.5.0"
43
+ "@skill-harness/core": "0.7.0"
44
44
  }
45
45
  }