pi-extensible-workflows 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/src/agent-execution.d.ts +213 -0
- package/dist/src/agent-execution.js +537 -0
- package/dist/src/ambient-workflow-evals.d.ts +112 -0
- package/dist/src/ambient-workflow-evals.js +375 -0
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.js +26 -0
- package/dist/src/doctor.d.ts +59 -0
- package/dist/src/doctor.js +269 -0
- package/dist/src/eval-capture-extension.d.ts +5 -0
- package/dist/src/eval-capture-extension.js +48 -0
- package/dist/src/index.d.ts +362 -0
- package/dist/src/index.js +2839 -0
- package/dist/src/persistence.d.ts +90 -0
- package/dist/src/persistence.js +530 -0
- package/dist/src/session-inspector.d.ts +59 -0
- package/dist/src/session-inspector.js +396 -0
- package/dist/src/workflow-evals-child.d.ts +1 -0
- package/dist/src/workflow-evals-child.js +13 -0
- package/dist/src/workflow-evals.d.ts +290 -0
- package/dist/src/workflow-evals.js +1221 -0
- package/evals/cases/custom-model-read.yaml +15 -0
- package/evals/cases/direct-answer.yaml +6 -0
- package/evals/cases/mixed-parallel-pipeline.yaml +18 -0
- package/evals/cases/output-schema.yaml +22 -0
- package/evals/cases/parallel.yaml +15 -0
- package/evals/cases/pipeline.yaml +10 -0
- package/evals/cases/ready-for-agent-parallel-merge.yaml +32 -0
- package/evals/cases/required-role.yaml +14 -0
- package/evals/cases/role-model-mixed.yaml +18 -0
- package/evals/cases/two-agents.yaml +10 -0
- package/package.json +65 -0
- package/skills/pi-extensible-workflows/SKILL.md +109 -0
- package/src/agent-execution.ts +529 -0
- package/src/ambient-workflow-evals.ts +452 -0
- package/src/cli.ts +23 -0
- package/src/doctor.ts +285 -0
- package/src/eval-capture-extension.ts +54 -0
- package/src/index.ts +2519 -0
- package/src/persistence.ts +470 -0
- package/src/session-inspector.ts +370 -0
- package/src/workflow-evals-child.ts +15 -0
- package/src/workflow-evals.ts +876 -0
- package/test/fixtures/ready-for-agent-tasks.md +9 -0
- package/test/fixtures/workflow-eval-roles/developer.md +18 -0
- package/test/fixtures/workflow-eval-roles/reviewer.md +18 -0
- package/test/fixtures/workflow-eval-roles/scout.md +17 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { join, relative } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { CAPTURE_IDENTITY } from "./eval-capture-extension.js";
|
|
9
|
+
import { extractCapturedWorkflows, extractParentOracleFile } from "./workflow-evals.js";
|
|
10
|
+
export const AMBIENT_OPT_IN = "PI_WORKFLOW_EVAL_AMBIENT";
|
|
11
|
+
export const AMBIENT_CAPTURE_NOTE = "Ambient Tier D uses the explicit capture extension. Pi 0.80.6 orders CLI extensions before discovered extensions and the first tool registration wins, so ambient tools and skills remain available while workflow execution stays capture-only.";
|
|
12
|
+
export const AMBIENT_INVOCATION_MODE = "ambient-capture-only";
|
|
13
|
+
export const AMBIENT_WORKFLOW_EVAL_CASES = Object.freeze([
|
|
14
|
+
{ id: "ambient-fix-bug", prompt: "Inspect this repository, find the deliberate bug, fix it, and verify the fix with the existing test and lint scripts. Use the normal ambient Pi resources. If you would delegate work, the workflow call is capture-only.", timeoutMs: 30_000, maxCost: 0.1 },
|
|
15
|
+
{ id: "ambient-review", prompt: "Inspect the repository's source, tests, and configuration. Report the bug and the smallest safe fix, using normal ambient Pi resources. If you would delegate work, the workflow call is capture-only.", timeoutMs: 30_000, maxCost: 0.1 },
|
|
16
|
+
]);
|
|
17
|
+
const FIXTURE_FILES = Object.freeze({
|
|
18
|
+
"package.json": `{
|
|
19
|
+
"name": "ambient-fixture",
|
|
20
|
+
"private": true,
|
|
21
|
+
"type": "module",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test test/*.test.js",
|
|
24
|
+
"lint": "node -p 1"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
`,
|
|
28
|
+
"README.md": `# Ambient fixture
|
|
29
|
+
|
|
30
|
+
This small repository is safe to inspect and edit. The source contains one deliberate bug.
|
|
31
|
+
|
|
32
|
+
Use npm test and npm run lint. Neither script starts a server.
|
|
33
|
+
`,
|
|
34
|
+
"tsconfig.json": `{
|
|
35
|
+
"compilerOptions": {
|
|
36
|
+
"target": "ES2022",
|
|
37
|
+
"module": "NodeNext",
|
|
38
|
+
"strict": true
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
`,
|
|
42
|
+
"config/project.json": `{
|
|
43
|
+
"name": "ambient-fixture",
|
|
44
|
+
"checks": ["test", "lint"]
|
|
45
|
+
}
|
|
46
|
+
`,
|
|
47
|
+
"src/score.js": `export function isPassing(score) {
|
|
48
|
+
return score >= 0;
|
|
49
|
+
}
|
|
50
|
+
`,
|
|
51
|
+
"src/summary.js": `export function summarize(scores) {
|
|
52
|
+
return { count: scores.length, total: scores.reduce((sum, score) => sum + score, 0) };
|
|
53
|
+
}
|
|
54
|
+
`,
|
|
55
|
+
"test/score.test.js": `import assert from "node:assert/strict";
|
|
56
|
+
import test from "node:test";
|
|
57
|
+
import { isPassing } from "../src/score.js";
|
|
58
|
+
|
|
59
|
+
test("zero is not a passing score", () => {
|
|
60
|
+
assert.equal(isPassing(0), false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("positive scores pass", () => {
|
|
64
|
+
assert.equal(isPassing(3), true);
|
|
65
|
+
});
|
|
66
|
+
`,
|
|
67
|
+
"test/summary.test.js": `import assert from "node:assert/strict";
|
|
68
|
+
import test from "node:test";
|
|
69
|
+
import { summarize } from "../src/summary.js";
|
|
70
|
+
|
|
71
|
+
test("summarizes scores", () => {
|
|
72
|
+
assert.deepEqual(summarize([2, 3]), { count: 2, total: 5 });
|
|
73
|
+
});
|
|
74
|
+
`,
|
|
75
|
+
});
|
|
76
|
+
const FIXED_GIT_NAME = "pi-extensible-workflows ambient fixture";
|
|
77
|
+
const FIXED_GIT_EMAIL = "ambient-fixture@example.invalid";
|
|
78
|
+
function git(cwd, args) {
|
|
79
|
+
return execFileSync("git", [...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
80
|
+
}
|
|
81
|
+
function gitStatus(cwd) {
|
|
82
|
+
const output = git(cwd, ["status", "--short"]);
|
|
83
|
+
return output ? output.split("\n") : [];
|
|
84
|
+
}
|
|
85
|
+
function fixtureFileList(root) {
|
|
86
|
+
const files = [];
|
|
87
|
+
const visit = (directory) => {
|
|
88
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
89
|
+
if (entry.name === ".git")
|
|
90
|
+
continue;
|
|
91
|
+
const path = join(directory, entry.name);
|
|
92
|
+
if (entry.isDirectory())
|
|
93
|
+
visit(path);
|
|
94
|
+
else
|
|
95
|
+
files.push(relative(root, path));
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
visit(root);
|
|
99
|
+
return files;
|
|
100
|
+
}
|
|
101
|
+
export function createAmbientFixtureRepository(parent = tmpdir()) {
|
|
102
|
+
const root = mkdtempSync(join(parent, "pi-workflow-ambient-"));
|
|
103
|
+
const fixtureRoot = join(root, "fixture");
|
|
104
|
+
const worktreesRoot = join(root, "worktrees");
|
|
105
|
+
mkdirSync(join(fixtureRoot, "config"), { recursive: true });
|
|
106
|
+
mkdirSync(join(fixtureRoot, "src"), { recursive: true });
|
|
107
|
+
mkdirSync(join(fixtureRoot, "test"), { recursive: true });
|
|
108
|
+
mkdirSync(worktreesRoot, { recursive: true });
|
|
109
|
+
for (const [path, content] of Object.entries(FIXTURE_FILES))
|
|
110
|
+
writeFileSync(join(fixtureRoot, path), content, { mode: 0o600 });
|
|
111
|
+
git(fixtureRoot, ["init", "--quiet"]);
|
|
112
|
+
git(fixtureRoot, ["config", "user.name", FIXED_GIT_NAME]);
|
|
113
|
+
git(fixtureRoot, ["config", "user.email", FIXED_GIT_EMAIL]);
|
|
114
|
+
git(fixtureRoot, ["add", "."]);
|
|
115
|
+
execFileSync("git", ["commit", "--quiet", "--no-gpg-sign", "-m", "Initial ambient fixture"], { cwd: fixtureRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: FIXED_GIT_NAME, GIT_AUTHOR_EMAIL: FIXED_GIT_EMAIL, GIT_COMMITTER_NAME: FIXED_GIT_NAME, GIT_COMMITTER_EMAIL: FIXED_GIT_EMAIL } });
|
|
116
|
+
return { root, fixtureRoot, worktreesRoot, fixtureFiles: fixtureFileList(fixtureRoot) };
|
|
117
|
+
}
|
|
118
|
+
export function createAmbientCaseWorktree(repository, id) {
|
|
119
|
+
const safeId = id.replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
120
|
+
const path = join(repository.worktreesRoot, `${safeId}-${randomUUID()}`);
|
|
121
|
+
mkdirSync(path, { recursive: true });
|
|
122
|
+
rmSync(path, { recursive: true, force: true });
|
|
123
|
+
git(repository.fixtureRoot, ["worktree", "add", "--detach", "--quiet", path, "HEAD"]);
|
|
124
|
+
return { id, path, fixtureFiles: repository.fixtureFiles, gitStatusBefore: gitStatus(path) };
|
|
125
|
+
}
|
|
126
|
+
export function removeAmbientCaseWorktree(repository, worktree) {
|
|
127
|
+
try {
|
|
128
|
+
git(repository.fixtureRoot, ["worktree", "remove", "--force", worktree.path]);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
rmSync(worktree.path, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
git(repository.fixtureRoot, ["worktree", "prune", "--expire", "now"]);
|
|
135
|
+
}
|
|
136
|
+
catch { /* Best effort after a forced removal. */ }
|
|
137
|
+
return !existsSync(worktree.path);
|
|
138
|
+
}
|
|
139
|
+
export function removeAmbientFixtureRepository(repository) {
|
|
140
|
+
rmSync(repository.root, { recursive: true, force: true });
|
|
141
|
+
return !existsSync(repository.root);
|
|
142
|
+
}
|
|
143
|
+
function terminateProcess(child, signal) {
|
|
144
|
+
try {
|
|
145
|
+
if (child.pid && process.platform !== "win32")
|
|
146
|
+
process.kill(-child.pid, signal);
|
|
147
|
+
else
|
|
148
|
+
child.kill(signal);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function killProcessGroup(child) {
|
|
156
|
+
let terminated = terminateProcess(child, "SIGTERM");
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
158
|
+
if (child.exitCode === null)
|
|
159
|
+
terminated = terminateProcess(child, "SIGKILL") || terminated;
|
|
160
|
+
return terminated;
|
|
161
|
+
}
|
|
162
|
+
function usageCost(message) {
|
|
163
|
+
if (typeof message !== "object" || message === null || !("usage" in message))
|
|
164
|
+
return 0;
|
|
165
|
+
const usage = message.usage;
|
|
166
|
+
if (typeof usage !== "object" || usage === null || !("cost" in usage))
|
|
167
|
+
return 0;
|
|
168
|
+
const cost = usage.cost;
|
|
169
|
+
return typeof cost === "object" && cost !== null && "total" in cost && typeof cost.total === "number" ? cost.total : 0;
|
|
170
|
+
}
|
|
171
|
+
export async function runAmbientPiProcess(input) {
|
|
172
|
+
const captureExtension = fileURLToPath(new URL("./eval-capture-extension.js", import.meta.url));
|
|
173
|
+
const args = ["--mode", "json", "--session-dir", input.sessionDir, "--session-id", input.sessionId ?? randomUUID(), "--extension", captureExtension, "--provider", input.provider, "--model", input.model, "--thinking", input.thinking ?? "off", "--print", input.prompt];
|
|
174
|
+
mkdirSync(input.sessionDir, { recursive: true });
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
let timedOut = false;
|
|
177
|
+
let budgetExceeded = false;
|
|
178
|
+
let processGroupTerminated = false;
|
|
179
|
+
let totalCost = 0;
|
|
180
|
+
let stdout = "";
|
|
181
|
+
let stderr = "";
|
|
182
|
+
let killPromise;
|
|
183
|
+
const child = spawn(input.piCommand ?? process.env.PI_WORKFLOW_EVAL_PI ?? "pi", args, {
|
|
184
|
+
cwd: input.worktree,
|
|
185
|
+
env: { ...process.env, ...input.environment, PI_CODING_AGENT_SESSION_DIR: input.sessionDir },
|
|
186
|
+
detached: process.platform !== "win32",
|
|
187
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
188
|
+
signal: controller.signal,
|
|
189
|
+
});
|
|
190
|
+
const requestKill = () => { killPromise ??= killProcessGroup(child); return killPromise; };
|
|
191
|
+
const inspect = (line) => {
|
|
192
|
+
try {
|
|
193
|
+
const event = JSON.parse(line);
|
|
194
|
+
if (typeof event !== "object" || event === null || !("type" in event) || event.type !== "message_end" || !("message" in event))
|
|
195
|
+
return;
|
|
196
|
+
totalCost += usageCost(event.message);
|
|
197
|
+
if (totalCost > input.maxCost && !budgetExceeded) {
|
|
198
|
+
budgetExceeded = true;
|
|
199
|
+
controller.abort();
|
|
200
|
+
void requestKill().then((terminated) => { processGroupTerminated ||= terminated; });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch { /* Ignore non-JSON diagnostics in print mode. */ }
|
|
204
|
+
};
|
|
205
|
+
let lineBuffer = "";
|
|
206
|
+
child.stdout.on("data", (chunk) => {
|
|
207
|
+
stdout = `${stdout}${chunk.toString()}`.slice(-64_000);
|
|
208
|
+
lineBuffer += chunk.toString();
|
|
209
|
+
const lines = lineBuffer.split("\n");
|
|
210
|
+
lineBuffer = lines.pop() ?? "";
|
|
211
|
+
for (const line of lines)
|
|
212
|
+
if (line)
|
|
213
|
+
inspect(line);
|
|
214
|
+
});
|
|
215
|
+
child.stderr.on("data", (chunk) => { stderr = `${stderr}${chunk.toString()}`.slice(-64_000); });
|
|
216
|
+
child.once("error", () => { });
|
|
217
|
+
const close = new Promise((resolve) => { child.once("close", (code) => { resolve(code); }); });
|
|
218
|
+
const timer = setTimeout(() => {
|
|
219
|
+
timedOut = true;
|
|
220
|
+
controller.abort();
|
|
221
|
+
void requestKill().then((terminated) => { processGroupTerminated ||= terminated; });
|
|
222
|
+
}, input.timeoutMs);
|
|
223
|
+
const exitCode = await close;
|
|
224
|
+
clearTimeout(timer);
|
|
225
|
+
if (lineBuffer)
|
|
226
|
+
inspect(lineBuffer);
|
|
227
|
+
if (killPromise)
|
|
228
|
+
processGroupTerminated ||= await killPromise;
|
|
229
|
+
return { exitCode, timedOut, budgetExceeded, processGroupTerminated, stdout, stderr };
|
|
230
|
+
}
|
|
231
|
+
function emptyAccounting() {
|
|
232
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: 0, models: [] };
|
|
233
|
+
}
|
|
234
|
+
function ambientAgentDir(environment) {
|
|
235
|
+
return environment.PI_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
236
|
+
}
|
|
237
|
+
function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
238
|
+
function findSessionFile(directory, sessionId) {
|
|
239
|
+
if (!existsSync(directory))
|
|
240
|
+
return undefined;
|
|
241
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
242
|
+
const path = join(directory, entry.name);
|
|
243
|
+
if (entry.isDirectory()) {
|
|
244
|
+
const found = findSessionFile(path, sessionId);
|
|
245
|
+
if (found)
|
|
246
|
+
return found;
|
|
247
|
+
}
|
|
248
|
+
else if (entry.name.endsWith(".jsonl")) {
|
|
249
|
+
try {
|
|
250
|
+
const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}");
|
|
251
|
+
if (isObject(header) && header.id === sessionId)
|
|
252
|
+
return path;
|
|
253
|
+
}
|
|
254
|
+
catch { /* Ignore incomplete sessions. */ }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
function emptyResult(candidate, repository, worktree, environment, error) {
|
|
260
|
+
const accounting = emptyAccounting();
|
|
261
|
+
return {
|
|
262
|
+
id: candidate.id, status: "failed", workflows: [], accounting, accountingTrustworthy: false, diagnostics: [], errors: [error],
|
|
263
|
+
manifest: {
|
|
264
|
+
invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, gitStatusAfter: worktree.gitStatusBefore, parentToolSequence: [], skillReads: [], workflowCalls: [], workflowCallCount: 0, tokenCost: accounting,
|
|
265
|
+
cleanup: { processExited: false, processGroupTerminated: false, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified: false, realWorkflowAgentsLaunched: 0 },
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
async function runAmbientCase(repository, candidate, artifactsDir, environment, provider, model, thinking, piCommand) {
|
|
270
|
+
const worktree = createAmbientCaseWorktree(repository, candidate.id);
|
|
271
|
+
const sessionId = randomUUID();
|
|
272
|
+
const sessionDir = join(repository.root, "sessions", candidate.id);
|
|
273
|
+
let result;
|
|
274
|
+
let gitStatusAfter = worktree.gitStatusBefore;
|
|
275
|
+
try {
|
|
276
|
+
const pi = await runAmbientPiProcess({ worktree: worktree.path, sessionDir, sessionId, prompt: candidate.prompt, provider, model, ...(thinking ? { thinking } : {}), ...(piCommand ? { piCommand } : {}), timeoutMs: candidate.timeoutMs, maxCost: candidate.maxCost, environment });
|
|
277
|
+
const sessionFile = findSessionFile(sessionDir, sessionId);
|
|
278
|
+
if (!sessionFile)
|
|
279
|
+
result = emptyResult(candidate, repository, worktree, environment, "Ambient parent session was not written.");
|
|
280
|
+
else {
|
|
281
|
+
const oracle = extractParentOracleFile(sessionFile);
|
|
282
|
+
const workflows = extractCapturedWorkflows(oracle);
|
|
283
|
+
const captureIdentityVerified = oracle.workflowToolResults.length === oracle.workflowCallCount && oracle.workflowToolResults.every(({ details, isError }) => isObject(details) && details.captureIdentity === CAPTURE_IDENTITY && details.realWorkflowAgentsLaunched === 0 && isError !== true);
|
|
284
|
+
const errors = captureIdentityVerified ? [] : ["Ambient workflow tool results did not prove capture-only execution."];
|
|
285
|
+
const status = pi.timedOut ? "timed_out" : pi.budgetExceeded ? "budget_exceeded" : pi.exitCode !== 0 || errors.length ? "failed" : "passed";
|
|
286
|
+
result = {
|
|
287
|
+
id: candidate.id, status, workflows, accounting: oracle.usage, accountingTrustworthy: !pi.timedOut && !pi.budgetExceeded && pi.exitCode === 0, diagnostics: [pi.stderr].filter(Boolean), errors,
|
|
288
|
+
manifest: {
|
|
289
|
+
invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, gitStatusAfter, parentToolSequence: oracle.parentToolSequence, skillReads: oracle.skillReads, workflowCalls: workflows, workflowCallCount: oracle.workflowCallCount, tokenCost: oracle.usage,
|
|
290
|
+
cleanup: { processExited: pi.exitCode !== null, processGroupTerminated: pi.processGroupTerminated, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified, realWorkflowAgentsLaunched: 0 },
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
result = emptyResult(candidate, repository, worktree, environment, error instanceof Error ? error.message : String(error));
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
try {
|
|
300
|
+
gitStatusAfter = gitStatus(worktree.path);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
gitStatusAfter = ["<unavailable>"];
|
|
304
|
+
}
|
|
305
|
+
const worktreeRemoved = removeAmbientCaseWorktree(repository, worktree);
|
|
306
|
+
if (!result)
|
|
307
|
+
result = emptyResult(candidate, repository, worktree, environment, "Ambient case produced no result.");
|
|
308
|
+
const cleanup = { ...result.manifest.cleanup, worktreeRemoved };
|
|
309
|
+
result = { ...result, manifest: { ...result.manifest, gitStatusAfter, cleanup } };
|
|
310
|
+
writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
|
|
311
|
+
}
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
export function assertAmbientOptIn(environment = process.env) {
|
|
315
|
+
if (environment[AMBIENT_OPT_IN] !== "1")
|
|
316
|
+
throw new Error(`Ambient Tier D evals are opt-in. Set ${AMBIENT_OPT_IN}=1 to run them.`);
|
|
317
|
+
}
|
|
318
|
+
export async function runAmbientWorkflowEvals(options = {}) {
|
|
319
|
+
const environment = { ...process.env, ...options.environment };
|
|
320
|
+
assertAmbientOptIn(environment);
|
|
321
|
+
const provider = options.provider ?? environment.PI_WORKFLOW_EVAL_PROVIDER;
|
|
322
|
+
const model = options.model ?? environment.PI_WORKFLOW_EVAL_MODEL;
|
|
323
|
+
if (!provider || !model)
|
|
324
|
+
throw new Error("Set --provider and --model (or PI_WORKFLOW_EVAL_PROVIDER and PI_WORKFLOW_EVAL_MODEL) before running ambient evals.");
|
|
325
|
+
const candidates = (options.cases ?? AMBIENT_WORKFLOW_EVAL_CASES).filter((candidate) => !options.caseIds?.length || options.caseIds.includes(candidate.id));
|
|
326
|
+
const artifactsDir = options.artifactsDir ?? join(process.cwd(), ".tmp", "workflow-evals-ambient", new Date().toISOString().replace(/[:.]/g, "-"));
|
|
327
|
+
mkdirSync(artifactsDir, { recursive: true, mode: 0o700 });
|
|
328
|
+
const repository = createAmbientFixtureRepository();
|
|
329
|
+
const results = [];
|
|
330
|
+
try {
|
|
331
|
+
for (const candidate of candidates)
|
|
332
|
+
results.push(await runAmbientCase(repository, candidate, artifactsDir, environment, provider, model, options.thinking, options.piCommand));
|
|
333
|
+
return { artifactDir: artifactsDir, cases: results, spent: results.reduce((sum, result) => sum + result.accounting.cost, 0), mode: AMBIENT_INVOCATION_MODE };
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
const fixtureRepoRemoved = removeAmbientFixtureRepository(repository);
|
|
337
|
+
for (const [index, current] of results.entries()) {
|
|
338
|
+
const cleanup = { ...current.manifest.cleanup, fixtureRepoRemoved, tempRootRemoved: fixtureRepoRemoved };
|
|
339
|
+
const updated = { ...current, manifest: { ...current.manifest, cleanup } };
|
|
340
|
+
results[index] = updated;
|
|
341
|
+
writeFileSync(join(artifactsDir, `${current.id}.json`), `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
export function formatAmbientSummary(result) {
|
|
346
|
+
const rows = result.cases.map((item) => `${item.id}: ${item.status} (${item.accounting.cost.toFixed(4)} USD, ${String(item.accounting.totalTokens)} tok, ${String(item.manifest.workflowCallCount)} workflow calls)`);
|
|
347
|
+
return [`Ambient Tier D (${result.mode}): ${String(result.cases.length)} cases, ${result.spent.toFixed(4)} USD spent`, AMBIENT_CAPTURE_NOTE, ...rows, `Artifacts: ${result.artifactDir}`].join("\n");
|
|
348
|
+
}
|
|
349
|
+
function option(args, name) {
|
|
350
|
+
const index = args.indexOf(name);
|
|
351
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
352
|
+
}
|
|
353
|
+
async function main() {
|
|
354
|
+
assertAmbientOptIn();
|
|
355
|
+
const args = process.argv.slice(2);
|
|
356
|
+
const provider = option(args, "--provider");
|
|
357
|
+
const model = option(args, "--model");
|
|
358
|
+
const thinking = option(args, "--thinking");
|
|
359
|
+
const piCommand = option(args, "--pi");
|
|
360
|
+
const artifactsDir = option(args, "--artifacts");
|
|
361
|
+
const caseIds = option(args, "--case")?.split(",").map((item) => item.trim()).filter(Boolean);
|
|
362
|
+
const result = await runAmbientWorkflowEvals({
|
|
363
|
+
...(provider ? { provider } : {}),
|
|
364
|
+
...(model ? { model } : {}),
|
|
365
|
+
...(thinking ? { thinking } : {}),
|
|
366
|
+
...(piCommand ? { piCommand } : {}),
|
|
367
|
+
...(artifactsDir ? { artifactsDir } : {}),
|
|
368
|
+
...(caseIds?.length ? { caseIds } : {}),
|
|
369
|
+
});
|
|
370
|
+
process.stdout.write(`${formatAmbientSummary(result)}\n`);
|
|
371
|
+
if (result.cases.some((item) => item.status !== "passed"))
|
|
372
|
+
process.exitCode = 1;
|
|
373
|
+
}
|
|
374
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1])
|
|
375
|
+
void main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { type DoctorOptions } from "./doctor.js";
|
|
3
|
+
export interface CliOptions extends DoctorOptions {
|
|
4
|
+
inspect?: (sessionId?: string) => Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export declare function runCli(args: readonly string[], options?: CliOptions, write?: (text: string) => void): Promise<number>;
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { doctor, doctorExitCode, formatDoctorReport } from "./doctor.js";
|
|
5
|
+
import { runSessionInspector } from "./session-inspector.js";
|
|
6
|
+
export async function runCli(args, options = {}, write = (text) => { process.stdout.write(text); }) {
|
|
7
|
+
if (args[0] === "doctor" && args.length === 1) {
|
|
8
|
+
const report = await doctor(options);
|
|
9
|
+
write(formatDoctorReport(report));
|
|
10
|
+
return doctorExitCode(report);
|
|
11
|
+
}
|
|
12
|
+
if (args[0] === "inspect" && args.length <= 2) {
|
|
13
|
+
try {
|
|
14
|
+
await (options.inspect ?? runSessionInspector)(args[1]);
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
write("Usage: pi-extensible-workflows doctor | inspect [session-id]\n");
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href)
|
|
26
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type WorkflowScriptDefinition, type WorkflowSettings } from "./index.js";
|
|
2
|
+
export type DoctorSeverity = "error" | "warning";
|
|
3
|
+
export interface DoctorDiagnostic {
|
|
4
|
+
severity: DoctorSeverity;
|
|
5
|
+
code: string;
|
|
6
|
+
message: string;
|
|
7
|
+
source?: string;
|
|
8
|
+
hint?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface DoctorRole {
|
|
11
|
+
name: string;
|
|
12
|
+
path: string;
|
|
13
|
+
scope: "global" | "project";
|
|
14
|
+
active: boolean;
|
|
15
|
+
overrides?: string;
|
|
16
|
+
overriddenBy?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface DoctorWorkflow {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
valid: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface DoctorTrust {
|
|
24
|
+
required: boolean;
|
|
25
|
+
trusted: boolean;
|
|
26
|
+
source: string;
|
|
27
|
+
}
|
|
28
|
+
export interface DoctorPiState {
|
|
29
|
+
trust: DoctorTrust;
|
|
30
|
+
activeTools: readonly string[];
|
|
31
|
+
knownModels: readonly string[];
|
|
32
|
+
availableModels: readonly string[];
|
|
33
|
+
extensionErrors: readonly {
|
|
34
|
+
path?: string;
|
|
35
|
+
message: string;
|
|
36
|
+
}[];
|
|
37
|
+
workflows: Readonly<Record<string, WorkflowScriptDefinition>>;
|
|
38
|
+
}
|
|
39
|
+
export interface DoctorReport {
|
|
40
|
+
cwd: string;
|
|
41
|
+
agentDir: string;
|
|
42
|
+
settingsPath: string;
|
|
43
|
+
settings: Readonly<WorkflowSettings>;
|
|
44
|
+
trust: DoctorTrust;
|
|
45
|
+
activeTools: readonly string[];
|
|
46
|
+
roles: readonly DoctorRole[];
|
|
47
|
+
workflows: readonly DoctorWorkflow[];
|
|
48
|
+
diagnostics: readonly DoctorDiagnostic[];
|
|
49
|
+
}
|
|
50
|
+
export interface DoctorOptions {
|
|
51
|
+
cwd?: string;
|
|
52
|
+
agentDir?: string;
|
|
53
|
+
settingsPath?: string;
|
|
54
|
+
discoverPi?: (cwd: string, agentDir: string) => Promise<DoctorPiState>;
|
|
55
|
+
activeTools?: readonly string[];
|
|
56
|
+
}
|
|
57
|
+
export declare function doctor(options?: DoctorOptions): Promise<DoctorReport>;
|
|
58
|
+
export declare function doctorExitCode(report: DoctorReport): 0 | 1;
|
|
59
|
+
export declare function formatDoctorReport(report: DoctorReport): string;
|