@aefree/pi-plastic 0.4.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/CONTRIBUTING.md +88 -0
  3. package/LICENSE +21 -0
  4. package/README.md +181 -0
  5. package/SECURITY.md +31 -0
  6. package/evals/tool-loading/README.md +38 -0
  7. package/evals/tool-loading/RESULTS.md +57 -0
  8. package/evals/tool-loading/baseline.json +34 -0
  9. package/evals/tool-loading/cases.json +70 -0
  10. package/evals/tool-loading/config.json +19 -0
  11. package/evals/tool-loading/mutation-guard.ts +30 -0
  12. package/evals/tool-loading/provider-capture.ts +22 -0
  13. package/evals/tool-loading/results/.gitignore +3 -0
  14. package/evals/tool-loading/run-eval.ts +372 -0
  15. package/extensions/bash-cm-diff-guard.ts +49 -0
  16. package/extensions/bash-cm-merge-guard.ts +53 -0
  17. package/extensions/plastic-branch-status.ts +285 -0
  18. package/extensions/repository-search-provider.ts +66 -0
  19. package/extensions/shared/bash-command-guards.ts +67 -0
  20. package/index.ts +777 -0
  21. package/package.json +76 -0
  22. package/skills/_shared/references/conventional-commits.md +198 -0
  23. package/skills/using-plastic/SKILL.md +50 -0
  24. package/skills/using-plastic/references/branch-management.md +78 -0
  25. package/skills/using-plastic/references/changeset-operations.md +96 -0
  26. package/skills/using-plastic/references/code-review-creation.md +30 -0
  27. package/skills/using-plastic/references/integration.md +47 -0
  28. package/skills/using-plastic/references/quick-reference.md +32 -0
  29. package/skills/using-plastic/references/resources.md +7 -0
  30. package/skills/using-plastic/references/reviewing-changes.md +71 -0
  31. package/skills/using-plastic/references/shelving.md +25 -0
  32. package/skills/using-plastic/references/troubleshooting.md +115 -0
  33. package/skills/using-plastic/references/workspaces.md +33 -0
  34. package/src/pi-tool-compat.ts +56 -0
  35. package/src/plastic-core.ts +4204 -0
  36. package/src/plastic-tool-loading.ts +234 -0
  37. package/src/plastic-workspace.ts +176 -0
  38. package/src/repository-search-provider.ts +64 -0
  39. package/tests/bash-cm-diff-guard-validation.ts +55 -0
  40. package/tests/fixtures/plastic.selector +3 -0
  41. package/tests/fixtures/status-compact.txt +1 -0
  42. package/tests/fixtures/status-normal.txt +1 -0
  43. package/tests/packed-copy-owner-conflict.test.ts +28 -0
  44. package/tests/pi-tool-harness.ts +116 -0
  45. package/tests/plastic-branch-create-live.test.ts +78 -0
  46. package/tests/plastic-branch-query.test.ts +26 -0
  47. package/tests/plastic-branch-status.test.ts +241 -0
  48. package/tests/plastic-checkin-path-resolution.ts +219 -0
  49. package/tests/plastic-constrained-sampling.test.ts +77 -0
  50. package/tests/plastic-diff-workflow.test.ts +86 -0
  51. package/tests/plastic-dynamic-tool-loading.test.ts +224 -0
  52. package/tests/plastic-extension-registration.test.ts +34 -0
  53. package/tests/plastic-live-smoke.test.ts +37 -0
  54. package/tests/plastic-mutation-execution.test.ts +61 -0
  55. package/tests/plastic-patch-command.test.ts +118 -0
  56. package/tests/plastic-process-lifecycle.test.ts +148 -0
  57. package/tests/plastic-tool-rendering.test.ts +16 -0
  58. package/tests/plastic-tool-validation.ts +153 -0
  59. package/tests/plastic-workspace.test.ts +62 -0
  60. package/tests/repository-search-provider-registration.test.ts +37 -0
  61. package/tests/repository-search-provider.test.ts +27 -0
@@ -0,0 +1,19 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "piVersionPrefix": "0.83.",
4
+ "sandboxCwdEnv": "PI_PLASTIC_EVAL_SANDBOX",
5
+ "sandboxMarkerFile": ".pi-plastic-eval-sandbox",
6
+ "sandboxMarkerContent": "pi-plastic-eval-sandbox\n",
7
+ "sandboxAuthorizationEnv": "PI_PLASTIC_EVAL_ALLOW",
8
+ "sandboxAuthorizationValue": "dedicated-sandbox",
9
+ "timeoutMs": 90000,
10
+ "maxToolCalls": 12,
11
+ "maxOutputChars": 2097152,
12
+ "conditions": ["all-active", "balanced", "loader-only"],
13
+ "destructiveTools": [
14
+ "plastic_add", "plastic_checkin", "plastic_undo", "plastic_resolveDeleteChangeConflict",
15
+ "plastic_branchCreate", "plastic_switchBranch", "plastic_merge", "plastic_mergeToBranch",
16
+ "plastic_finalizeMerge", "plastic_branchDelete", "plastic_shelvesetCreate", "plastic_shelvesetApply",
17
+ "plastic_shelvesetDelete", "plastic_codeReviewCreate", "plastic_codeReviewUpdate", "plastic_codeReviewDelete"
18
+ ]
19
+ }
@@ -0,0 +1,30 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * Eval-only defense in depth. Live trials may exercise only Plastic operations
5
+ * that expose a preflight parameter and explicitly request that preview mode.
6
+ */
7
+ const DESTRUCTIVE_PLASTIC_TOOLS = new Set([
8
+ "plastic_add", "plastic_checkin", "plastic_undo", "plastic_resolveDeleteChangeConflict",
9
+ "plastic_branchCreate", "plastic_switchBranch", "plastic_merge", "plastic_mergeToBranch",
10
+ "plastic_finalizeMerge", "plastic_branchDelete", "plastic_shelvesetCreate", "plastic_shelvesetApply",
11
+ "plastic_shelvesetDelete", "plastic_codeReviewCreate", "plastic_codeReviewUpdate", "plastic_codeReviewDelete",
12
+ ]);
13
+
14
+ function supportsPreflight(parameters: unknown): boolean {
15
+ if (!parameters || typeof parameters !== "object") return false;
16
+ const properties = (parameters as { properties?: unknown }).properties;
17
+ return Boolean(properties && typeof properties === "object" && Object.prototype.hasOwnProperty.call(properties, "preflight"));
18
+ }
19
+
20
+ export default function evalMutationGuard(pi: ExtensionAPI): void {
21
+ pi.on("tool_call", (event) => {
22
+ if (!DESTRUCTIVE_PLASTIC_TOOLS.has(event.toolName)) return;
23
+ const tool = pi.getAllTools().find((candidate) => candidate.name === event.toolName);
24
+ if (supportsPreflight(tool?.parameters) && event.input.preflight === true) return;
25
+ return {
26
+ block: true,
27
+ reason: `Eval mutation guard blocked ${event.toolName}: live evals allow destructive Plastic tools only when their schema supports preflight and the call includes preflight: true.`,
28
+ };
29
+ });
30
+ }
@@ -0,0 +1,22 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+
4
+ /**
5
+ * Eval-only provider payload capture. The runner gives each subprocess a unique
6
+ * temporary file and deletes it unless --keep is requested. Never log payloads:
7
+ * JSON mode stdout must remain a clean Pi event stream.
8
+ */
9
+ const CAPTURE_ENV = "PI_PLASTIC_EVAL_PROVIDER_CAPTURE";
10
+
11
+ export default function providerCapture(pi: ExtensionAPI): void {
12
+ const capturePath = process.env[CAPTURE_ENV];
13
+ if (!capturePath) return;
14
+
15
+ pi.on("before_provider_request", (event) => {
16
+ try {
17
+ appendFileSync(capturePath, `${JSON.stringify({ timestamp: Date.now(), payload: event.payload })}\n`, { encoding: "utf8", mode: 0o600 });
18
+ } catch {
19
+ // Capture must not make a model trial fail or contaminate stdout.
20
+ }
21
+ });
22
+ }
@@ -0,0 +1,3 @@
1
+ *
2
+ !.gitignore
3
+ !.npmignore
@@ -0,0 +1,372 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ type Condition = "all-active" | "balanced" | "loader-only";
8
+ type JsonObject = Record<string, unknown>;
9
+ type EvalCase = { id: string; prompt: string; requiredTools: string[]; minimalActivatedTools: string[]; forbiddenTools: string[]; requiredPreflightTools?: string[] };
10
+ type EvalConfig = {
11
+ piVersionPrefix: string;
12
+ sandboxCwdEnv: string;
13
+ sandboxMarkerFile: string;
14
+ sandboxMarkerContent: string;
15
+ sandboxAuthorizationEnv: string;
16
+ sandboxAuthorizationValue: string;
17
+ timeoutMs: number;
18
+ maxToolCalls: number;
19
+ maxOutputChars: number;
20
+ conditions: Condition[];
21
+ destructiveTools: string[];
22
+ };
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+ const PACKAGE_ROOT = resolve(HERE, "../..");
26
+ const CONFIG_PATH = join(HERE, "config.json");
27
+ const CASES_PATH = join(HERE, "cases.json");
28
+ const BASELINE_PATH = join(HERE, "baseline.json");
29
+ const RESULTS_DIR = join(HERE, "results");
30
+ const MODEL_PATTERN = /^openai-codex\/gpt-5\.6-(luna|terra|sol)(:(off|minimal|low|medium|high|xhigh|max))?$/;
31
+ const LOADER = "plastic_tool_search";
32
+ const ACTIVE_BY_CONDITION: Record<Condition, readonly string[]> = {
33
+ "all-active": ["*"],
34
+ balanced: [LOADER, "plastic_status", "plastic_currentBranch"],
35
+ "loader-only": [LOADER],
36
+ };
37
+
38
+ type Options = { trials: number; conditions: Condition[]; caseIds: string[]; model?: string; keep: boolean; includeEvents: boolean; dryRun: boolean };
39
+
40
+ function readJson<T>(path: string): T {
41
+ return JSON.parse(readFileSync(path, "utf8")) as T;
42
+ }
43
+
44
+ function fail(message: string): never {
45
+ throw new Error(message);
46
+ }
47
+
48
+ function usage(): string {
49
+ return `Usage: npx tsx evals/tool-loading/run-eval.ts --model openai-codex/gpt-5.6-luna[:thinking] [options]
50
+
51
+ Options:
52
+ --trials <1..5> Sequential fresh subprocess trials (default: 1)
53
+ --condition <name[,name]> all-active, balanced, loader-only (default: all)
54
+ --cases <id[,id]> Case IDs from cases.json (default: all)
55
+ --model <provider/model> Required; approved GPT-5.6 Codex model, optionally :<thinking-level>
56
+ --keep Keep raw provider capture files in the system temp directory
57
+ --include-events Include sanitized event crumbs in the JSON summary
58
+ --dry-run Validate config and print planned subprocesses without model calls
59
+ --help Show this help`;
60
+ }
61
+
62
+ function parseCsv(value: string): string[] {
63
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
64
+ }
65
+
66
+ function parseOptions(argv: string[]): Options {
67
+ const options: Options = { trials: 1, conditions: ["all-active", "balanced", "loader-only"], caseIds: [], keep: false, includeEvents: false, dryRun: false };
68
+ for (let i = 0; i < argv.length; i += 1) {
69
+ const arg = argv[i];
70
+ const value = () => argv[++i] ?? fail(`Missing value for ${arg}`);
71
+ if (arg === "--help" || arg === "-h") { console.log(usage()); process.exit(0); }
72
+ if (arg === "--trials") options.trials = Number(value());
73
+ else if (arg === "--condition") options.conditions = parseCsv(value()) as Condition[];
74
+ else if (arg === "--cases") options.caseIds = parseCsv(value());
75
+ else if (arg === "--model") options.model = value();
76
+ else if (arg === "--keep") options.keep = true;
77
+ else if (arg === "--include-events") options.includeEvents = true;
78
+ else if (arg === "--dry-run") options.dryRun = true;
79
+ else fail(`Unknown option: ${arg}`);
80
+ }
81
+ if (!Number.isInteger(options.trials) || options.trials < 1 || options.trials > 5) fail("--trials must be an integer from 1 through 5");
82
+ return options;
83
+ }
84
+
85
+ function validate(config: EvalConfig, cases: EvalCase[], baseline: JsonObject, options: Options): void {
86
+ if (config.piVersionPrefix !== "0.83.") fail("config piVersionPrefix must select the validated Pi 0.83 patch line");
87
+ if (config.sandboxCwdEnv !== "PI_PLASTIC_EVAL_SANDBOX") fail("config sandboxCwdEnv must use the dedicated eval environment variable");
88
+ if (config.sandboxMarkerFile !== ".pi-plastic-eval-sandbox" || config.sandboxMarkerContent !== "pi-plastic-eval-sandbox\n") fail("config sandbox marker attestation is invalid");
89
+ if (config.sandboxAuthorizationEnv !== "PI_PLASTIC_EVAL_ALLOW" || config.sandboxAuthorizationValue !== "dedicated-sandbox") fail("config sandbox authorization attestation is invalid");
90
+ if (!Array.isArray(config.conditions) || !config.conditions.every((value) => ["all-active", "balanced", "loader-only"].includes(value))) fail("Invalid conditions in config.json");
91
+ if (!Number.isInteger(config.timeoutMs) || config.timeoutMs <= 0 || !Number.isInteger(config.maxToolCalls) || config.maxToolCalls <= 0 || !Number.isInteger(config.maxOutputChars) || config.maxOutputChars <= 0) fail("Invalid numeric bounds in config.json");
92
+ const ids = new Set<string>();
93
+ for (const testCase of cases) {
94
+ if (!testCase.id || ids.has(testCase.id) || !testCase.prompt || !Array.isArray(testCase.requiredTools) || !Array.isArray(testCase.minimalActivatedTools) || !Array.isArray(testCase.forbiddenTools) || (testCase.requiredPreflightTools !== undefined && !Array.isArray(testCase.requiredPreflightTools))) fail("Invalid or duplicate case in cases.json");
95
+ if ((testCase.requiredPreflightTools ?? []).some((tool) => !testCase.requiredTools.includes(tool))) fail(`Preflight tool must also be required in case ${testCase.id}`);
96
+ if (new Set(testCase.minimalActivatedTools).size !== testCase.minimalActivatedTools.length || testCase.minimalActivatedTools.some((tool) => !testCase.requiredTools.includes(tool)) || testCase.requiredTools.some((tool) => !testCase.minimalActivatedTools.includes(tool))) fail(`minimalActivatedTools must be the exact smallest sufficient required-tool set in case ${testCase.id}`);
97
+ ids.add(testCase.id);
98
+ }
99
+ for (const condition of options.conditions) if (!config.conditions.includes(condition)) fail(`Unknown condition: ${condition}`);
100
+ for (const id of options.caseIds) if (!ids.has(id)) fail(`Unknown case: ${id}`);
101
+ const legacy = baseline.legacyBaseline as JsonObject | undefined;
102
+ const conditions = baseline.implementedConditions as JsonObject | undefined;
103
+ if (legacy?.toolCount !== 29 || legacy.serializedSchemaChars !== 21192 || legacy.estimatedTokens !== 5298 || legacy.promptMetadataChars !== 0) fail("baseline.json legacy measurement does not match the untouched baseline");
104
+ if (!conditions || (conditions.balanced as JsonObject)?.toolCount !== 3 || (conditions["loader-only"] as JsonObject)?.toolCount !== 1 || (conditions["all-active"] as JsonObject)?.toolCount !== 28) fail("baseline.json implemented condition metadata is incomplete");
105
+ }
106
+
107
+ function isInitiallyActive(condition: Condition, tool: string): boolean {
108
+ return ACTIVE_BY_CONDITION[condition].includes("*") || ACTIVE_BY_CONDITION[condition].includes(tool);
109
+ }
110
+
111
+ function expectedLoaderCalls(condition: Condition, testCase: EvalCase): number {
112
+ if (testCase.id === "negative-no-operation") return 0;
113
+ return testCase.minimalActivatedTools.some((tool) => !isInitiallyActive(condition, tool)) ? 1 : 0;
114
+ }
115
+
116
+ function sameToolSet(left: readonly string[], right: readonly string[]): boolean {
117
+ return left.length === right.length && left.every((tool) => right.includes(tool));
118
+ }
119
+
120
+ function numeric(value: unknown): number {
121
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
122
+ }
123
+
124
+ function readUsage(message: JsonObject): Record<string, number> {
125
+ const usage = (message.usage ?? {}) as JsonObject;
126
+ const input = numeric(usage.input ?? usage.inputTokens);
127
+ const output = numeric(usage.output ?? usage.outputTokens);
128
+ const cacheRead = numeric(usage.cacheRead ?? usage.cache_read_tokens);
129
+ const cacheWrite = numeric(usage.cacheWrite ?? usage.cache_write_tokens);
130
+ return { input, output, cacheRead, cacheWrite, total: numeric(usage.totalTokens) || input + output + cacheRead + cacheWrite };
131
+ }
132
+
133
+ function findTools(payload: unknown): unknown[] | undefined {
134
+ if (!payload || typeof payload !== "object") return undefined;
135
+ if (Array.isArray(payload)) {
136
+ for (const item of payload) { const found = findTools(item); if (found) return found; }
137
+ return undefined;
138
+ }
139
+ const object = payload as JsonObject;
140
+ if (Array.isArray(object.tools)) return object.tools;
141
+ for (const value of Object.values(object)) { const found = findTools(value); if (found) return found; }
142
+ return undefined;
143
+ }
144
+
145
+ function nativeMarkers(value: unknown, markers = { toolSearchCall: 0, toolSearchOutput: 0 }): typeof markers {
146
+ if (Array.isArray(value)) {
147
+ for (const item of value) nativeMarkers(item, markers);
148
+ } else if (value && typeof value === "object") {
149
+ const object = value as JsonObject;
150
+ if (object.type === "tool_search_call") markers.toolSearchCall += 1;
151
+ if (object.type === "tool_search_output") markers.toolSearchOutput += 1;
152
+ for (const item of Object.values(object)) nativeMarkers(item, markers);
153
+ }
154
+ return markers;
155
+ }
156
+
157
+ function sanitizeEvent(event: JsonObject): JsonObject {
158
+ const clean: JsonObject = { type: event.type };
159
+ if (typeof event.toolName === "string") clean.toolName = event.toolName;
160
+ if (typeof event.isError === "boolean") clean.isError = event.isError;
161
+ const message = event.message as JsonObject | undefined;
162
+ if (message && typeof message.role === "string") {
163
+ clean.role = message.role;
164
+ clean.hasError = typeof message.errorMessage === "string" && message.errorMessage.length > 0;
165
+ }
166
+ return clean;
167
+ }
168
+
169
+ async function runProcess(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number, maxOutputChars: number): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; outputLimitExceeded: boolean; wallTimeMs: number }> {
170
+ const started = performance.now();
171
+ return await new Promise((resolveProcess, reject) => {
172
+ const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
173
+ let stdout = "";
174
+ let stderr = "";
175
+ let timedOut = false;
176
+ let outputLimitExceeded = false;
177
+ let settled = false;
178
+ const finish = (exitCode: number | null) => {
179
+ if (settled) return;
180
+ settled = true;
181
+ clearTimeout(timer);
182
+ resolveProcess({ stdout, stderr, exitCode, timedOut, outputLimitExceeded, wallTimeMs: Math.round(performance.now() - started) });
183
+ };
184
+ const terminate = () => {
185
+ if (process.platform === "win32" && child.pid) {
186
+ spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
187
+ } else child.kill("SIGTERM");
188
+ };
189
+ const stopForOutput = () => {
190
+ if (outputLimitExceeded) return;
191
+ outputLimitExceeded = true;
192
+ terminate();
193
+ };
194
+ const timer = setTimeout(() => { timedOut = true; terminate(); }, timeoutMs);
195
+ child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); if (stdout.length + stderr.length > maxOutputChars) stopForOutput(); });
196
+ child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); if (stdout.length + stderr.length > maxOutputChars) stopForOutput(); });
197
+ // Treat launch failures as failed trial results so raw capture cleanup and the
198
+ // sanitized aggregate still happen; never surface child output verbatim.
199
+ child.once("error", () => finish(null));
200
+ child.once("close", finish);
201
+ });
202
+ }
203
+
204
+ function parseCapture(path: string): { providerRequests: number; initialToolCount?: number; initialToolSchemaChars?: number; nativeToolSearch: { toolSearchCall: number; toolSearchOutput: number }; captureParseErrors: number } {
205
+ const answer = { providerRequests: 0, initialToolCount: undefined as number | undefined, initialToolSchemaChars: undefined as number | undefined, nativeToolSearch: { toolSearchCall: 0, toolSearchOutput: 0 }, captureParseErrors: 0 };
206
+ if (!existsSync(path)) return answer;
207
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean)) {
208
+ try {
209
+ const record = JSON.parse(line) as JsonObject;
210
+ answer.providerRequests += 1;
211
+ const payload = record.payload;
212
+ if (answer.initialToolCount === undefined) {
213
+ const tools = findTools(payload);
214
+ if (tools) {
215
+ answer.initialToolCount = tools.length;
216
+ answer.initialToolSchemaChars = JSON.stringify(tools).length;
217
+ }
218
+ }
219
+ const markers = nativeMarkers(payload);
220
+ answer.nativeToolSearch.toolSearchCall += markers.toolSearchCall;
221
+ answer.nativeToolSearch.toolSearchOutput += markers.toolSearchOutput;
222
+ } catch { answer.captureParseErrors += 1; }
223
+ }
224
+ return answer;
225
+ }
226
+
227
+ function parseTrial(stdout: string, stderr: string, processResult: Awaited<ReturnType<typeof runProcess>>, capturePath: string, condition: Condition, testCase: EvalCase, config: EvalConfig, includeEvents: boolean): JsonObject {
228
+ const events: JsonObject[] = [];
229
+ let invalidJsonLines = 0;
230
+ const toolCalls: string[] = [];
231
+ const toolCallRecords: Array<{ name: string; args: JsonObject }> = [];
232
+ const toolErrors: string[] = [];
233
+ const activated: string[] = [];
234
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
235
+ let assistantErrors = 0;
236
+ let assistantMessages = 0;
237
+
238
+ for (const line of stdout.split(/\r?\n/).filter(Boolean)) {
239
+ let event: JsonObject;
240
+ try { event = JSON.parse(line) as JsonObject; } catch { invalidJsonLines += 1; continue; }
241
+ if (includeEvents) events.push(sanitizeEvent(event));
242
+ if (event.type === "tool_execution_start" && typeof event.toolName === "string") {
243
+ toolCalls.push(event.toolName);
244
+ toolCallRecords.push({ name: event.toolName, args: event.args && typeof event.args === "object" ? event.args as JsonObject : {} });
245
+ }
246
+ if (event.type === "tool_execution_end") {
247
+ if (event.isError === true && typeof event.toolName === "string") toolErrors.push(event.toolName);
248
+ const result = event.result as JsonObject | undefined;
249
+ const details = result?.details as JsonObject | undefined;
250
+ if (Array.isArray(details?.added)) activated.push(...details.added.filter((name): name is string => typeof name === "string"));
251
+ }
252
+ if (event.type === "message_end") {
253
+ const message = event.message as JsonObject | undefined;
254
+ if (message?.role === "assistant") {
255
+ assistantMessages += 1;
256
+ const current = readUsage(message);
257
+ for (const key of Object.keys(usage) as Array<keyof typeof usage>) usage[key] += current[key];
258
+ if (typeof message.errorMessage === "string" && message.errorMessage.length > 0) assistantErrors += 1;
259
+ }
260
+ }
261
+ }
262
+
263
+ const capture = parseCapture(capturePath);
264
+ const counts = Object.fromEntries([...new Set(toolCalls)].map((tool) => [tool, toolCalls.filter((entry) => entry === tool).length]));
265
+ const checks: Array<{ name: string; pass: boolean; detail?: string }> = [];
266
+ const loaderExpected = expectedLoaderCalls(condition, testCase);
267
+ for (const tool of testCase.requiredTools) checks.push({ name: `required:${tool}`, pass: counts[tool] === 1, detail: `observed ${counts[tool] ?? 0}` });
268
+ for (const tool of testCase.requiredPreflightTools ?? []) {
269
+ const records = toolCallRecords.filter((call) => call.name === tool);
270
+ checks.push({ name: `preflight:${tool}`, pass: records.length === 1 && records[0].args.preflight === true, detail: records.length === 1 ? `preflight=${String(records[0].args.preflight)}` : `observed ${records.length} calls` });
271
+ }
272
+ for (const tool of testCase.forbiddenTools) checks.push({ name: `forbidden:${tool}`, pass: !counts[tool], detail: `observed ${counts[tool] ?? 0}` });
273
+ checks.push({ name: "loader-count", pass: (counts[LOADER] ?? 0) === loaderExpected, detail: `expected ${loaderExpected}, observed ${counts[LOADER] ?? 0}` });
274
+ if (loaderExpected > 0) {
275
+ const expectedActivated = testCase.minimalActivatedTools.filter((tool) => !isInitiallyActive(condition, tool));
276
+ const actualActivated = [...new Set(activated)];
277
+ checks.push({ name: "exact-smallest-sufficient-activation", pass: sameToolSet(actualActivated, expectedActivated), detail: `expected ${expectedActivated.join(", ") || "none"}; observed ${actualActivated.join(", ") || "none"}` });
278
+ }
279
+ const nativeMarkerPairs = Math.min(capture.nativeToolSearch.toolSearchCall, capture.nativeToolSearch.toolSearchOutput);
280
+ checks.push({
281
+ name: "native-deferred-markers",
282
+ pass: loaderExpected > 0 ? nativeMarkerPairs >= 1 : nativeMarkerPairs === 0,
283
+ detail: `expected ${loaderExpected > 0 ? "at least one" : "zero"}; call=${capture.nativeToolSearch.toolSearchCall}, output=${capture.nativeToolSearch.toolSearchOutput}`,
284
+ });
285
+ checks.push({ name: "max-four-activated", pass: activated.length <= 4, detail: `observed ${activated.length}` });
286
+ if (testCase.id === "negative-no-operation" || testCase.id === "ambiguous-branch-discovery") {
287
+ const destructive = new Set(config.destructiveTools);
288
+ const unsafeActivations = activated.filter((tool) => destructive.has(tool));
289
+ const unsafeCalls = toolCalls.filter((tool) => destructive.has(tool));
290
+ checks.push({ name: "no-destructive-activation", pass: unsafeActivations.length === 0, detail: unsafeActivations.join(", ") || "none" });
291
+ checks.push({ name: "no-destructive-calls", pass: unsafeCalls.length === 0, detail: unsafeCalls.join(", ") || "none" });
292
+ if (testCase.id === "negative-no-operation") checks.push({ name: "no-plastic-operation", pass: toolCalls.length === 0, detail: toolCalls.join(", ") || "none" });
293
+ }
294
+ checks.push({ name: "no-tool-errors", pass: toolErrors.length === 0, detail: toolErrors.join(", ") || "none" });
295
+ checks.push({ name: "no-assistant-errors", pass: assistantErrors === 0, detail: String(assistantErrors) });
296
+ checks.push({ name: "bounded-tool-calls", pass: toolCalls.length <= config.maxToolCalls, detail: `${toolCalls.length}/${config.maxToolCalls}` });
297
+ checks.push({ name: "bounded-output", pass: !processResult.outputLimitExceeded, detail: `${stdout.length + stderr.length}/${config.maxOutputChars}` });
298
+ checks.push({ name: "not-timed-out", pass: !processResult.timedOut, detail: `${processResult.wallTimeMs}ms` });
299
+ checks.push({ name: "process-exit", pass: processResult.exitCode === 0, detail: String(processResult.exitCode) });
300
+ checks.push({ name: "clean-jsonl", pass: invalidJsonLines === 0, detail: String(invalidJsonLines) });
301
+
302
+ return {
303
+ condition, caseId: testCase.id, pass: checks.every((check) => check.pass), checks,
304
+ wallTimeMs: processResult.wallTimeMs, exitCode: processResult.exitCode, timedOut: processResult.timedOut,
305
+ outputChars: stdout.length + stderr.length, toolCalls, toolCallCounts: counts,
306
+ loaderRequests: toolCallRecords.filter((call) => call.name === LOADER).map((call) => ({ query: call.args.query, toolNames: call.args.toolNames, limit: call.args.limit })),
307
+ activated: [...new Set(activated)], toolErrors,
308
+ assistantMessages, providerRequests: capture.providerRequests,
309
+ assistantUsage: usage, initialToolSchema: { toolCount: capture.initialToolCount, serializedChars: capture.initialToolSchemaChars },
310
+ nativeToolSearch: capture.nativeToolSearch, captureParseErrors: capture.captureParseErrors,
311
+ ...(includeEvents ? { events } : {}),
312
+ };
313
+ }
314
+
315
+ function timestamp(): string { return new Date().toISOString().replace(/[:.]/g, "-"); }
316
+
317
+ async function main(): Promise<void> {
318
+ const options = parseOptions(process.argv.slice(2));
319
+ const config = readJson<EvalConfig>(CONFIG_PATH);
320
+ const cases = readJson<{ cases: EvalCase[] }>(CASES_PATH).cases;
321
+ const baseline = readJson<JsonObject>(BASELINE_PATH);
322
+ validate(config, cases, baseline, options);
323
+ const selectedCases = options.caseIds.length ? cases.filter((testCase) => options.caseIds.includes(testCase.id)) : cases;
324
+ if (!options.dryRun && !options.model) fail("--model is required for a live model evaluation");
325
+ if (options.model && !MODEL_PATTERN.test(options.model)) fail("--model must match openai-codex/gpt-5.6-(luna|terra|sol), optionally followed by a supported thinking level");
326
+ const configuredSandbox = process.env[config.sandboxCwdEnv]?.trim();
327
+ if (!options.dryRun && !configuredSandbox) fail(`Set ${config.sandboxCwdEnv} to the dedicated Plastic sandbox path`);
328
+ const sandboxCwd = configuredSandbox ? resolve(configuredSandbox) : `<${config.sandboxCwdEnv}>`;
329
+ if (!options.dryRun) {
330
+ if (!existsSync(sandboxCwd)) fail(`Dedicated sandbox does not exist: ${sandboxCwd}`);
331
+ const markerPath = join(sandboxCwd, config.sandboxMarkerFile);
332
+ if (!existsSync(markerPath) || readFileSync(markerPath, "utf8") !== config.sandboxMarkerContent) fail(`Dedicated sandbox must contain ${config.sandboxMarkerFile} with the documented exact content`);
333
+ if (process.env[config.sandboxAuthorizationEnv] !== config.sandboxAuthorizationValue) fail(`Set ${config.sandboxAuthorizationEnv}=${config.sandboxAuthorizationValue} to attest the dedicated sandbox`);
334
+ }
335
+
336
+ const piPackagePath = join(PACKAGE_ROOT, "node_modules", "@earendil-works", "pi-coding-agent", "package.json");
337
+ const piVersion = existsSync(piPackagePath) ? (readJson<{ version?: string }>(piPackagePath).version ?? "unknown") : "missing";
338
+ if (!piVersion.startsWith(config.piVersionPrefix)) fail(`This eval requires the Pi ${config.piVersionPrefix.slice(0, -1)} patch line; found ${piVersion}`);
339
+ const piCli = join(PACKAGE_ROOT, "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js");
340
+ const baseArgs = ["--mode", "json", "--no-session", "--no-approve", "--no-context-files", "--no-extensions", "-e", join(PACKAGE_ROOT, "index.ts"), "-e", join(HERE, "mutation-guard.ts"), "-e", join(HERE, "provider-capture.ts"), "--no-skills", "--no-prompt-templates", "--no-builtin-tools"];
341
+ if (options.dryRun) {
342
+ console.log(`VALID: ${selectedCases.length} cases × ${options.conditions.length} conditions × ${options.trials} trial(s)`);
343
+ console.log(`Pi ${config.piVersionPrefix.slice(0, -1)} subprocess: ${process.execPath} ${piCli} ${baseArgs.join(" ")} --model <approved-model> <case-prompt>`);
344
+ console.log(`cwd: ${sandboxCwd}`);
345
+ return;
346
+ }
347
+
348
+ mkdirSync(RESULTS_DIR, { recursive: true });
349
+ const trials: JsonObject[] = [];
350
+ for (const condition of options.conditions) {
351
+ for (const testCase of selectedCases) {
352
+ for (let trial = 1; trial <= options.trials; trial += 1) {
353
+ const capturePath = join(tmpdir(), `pi-plastic-tool-loading-${process.pid}-${Date.now()}-${condition}-${testCase.id}-${trial}.jsonl`);
354
+ const args = [...baseArgs, "--model", options.model!, testCase.prompt];
355
+ const result = await runProcess(process.execPath, [piCli, ...args], sandboxCwd, { ...process.env, PI_PLASTIC_TOOL_LOADING_MODE: condition, PI_PLASTIC_EVAL_PROVIDER_CAPTURE: capturePath }, config.timeoutMs, config.maxOutputChars);
356
+ const summary = parseTrial(result.stdout, result.stderr, result, capturePath, condition, testCase, config, options.includeEvents);
357
+ if (options.keep) summary.rawCapturePath = capturePath;
358
+ else rmSync(capturePath, { force: true });
359
+ trials.push({ trial, ...summary });
360
+ console.log(`${condition} ${testCase.id} #${trial}: ${summary.pass ? "PASS" : "FAIL"} (${summary.wallTimeMs}ms)`);
361
+ }
362
+ }
363
+ }
364
+ const passed = trials.filter((trial) => trial.pass === true).length;
365
+ const report = { schemaVersion: 2, generatedAt: new Date().toISOString(), sandbox: "attested dedicated sandbox", model: options.model, options: { trials: options.trials, conditions: options.conditions, cases: selectedCases.map((testCase) => testCase.id), includeEvents: options.includeEvents, rawCapturesKept: options.keep }, baseline, aggregate: { trials: trials.length, passed, failed: trials.length - passed }, trials };
366
+ const reportPath = join(RESULTS_DIR, `tool-loading-${timestamp()}.json`);
367
+ writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
368
+ console.log(`Aggregate: ${passed}/${trials.length} passed; sanitized summary: ${reportPath}`);
369
+ if (passed !== trials.length) process.exitCode = 1;
370
+ }
371
+
372
+ void main().catch((error: unknown) => { console.error(error instanceof Error ? `ERROR: ${error.message}` : "ERROR: evaluation failed"); process.exitCode = 1; });
@@ -0,0 +1,49 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ commandMatches,
4
+ stripAssignments,
5
+ stripLeadingWrappers,
6
+ } from "./shared/bash-command-guards";
7
+
8
+ const BLOCK_MESSAGE =
9
+ "`cm diff` is blocked in Pi because it can launch a GUI window and hang CLI automation. Use `plastic_status` to list changed paths, `plastic_diffFile` for a file diff, or `plastic_diffRevisions` for two explicit revisions.";
10
+
11
+ const DIRECT_CM_DIFF = /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:(?:"[^"]*[\\/]cm(?:\.exe)?"|'[^']*[\\/]cm(?:\.exe)?')|(?:[^"'\s]*[\\/])?cm(?:\.exe)?)\s+diff(?:\s|$)/i;
12
+
13
+ function isBashToolCall(event: { toolName?: string }): event is { toolName: "bash"; input: { command?: string } } {
14
+ return event.toolName === "bash";
15
+ }
16
+
17
+ function segmentRunsCmDiff(segment: string): boolean {
18
+ const stripped = stripLeadingWrappers(stripAssignments(segment));
19
+ return DIRECT_CM_DIFF.test(stripped);
20
+ }
21
+
22
+ function commandRunsCmDiff(command: string): boolean {
23
+ return commandMatches(command, segmentRunsCmDiff);
24
+ }
25
+
26
+ export const __bashCmDiffGuardInternals = {
27
+ segmentRunsCmDiff,
28
+ commandRunsCmDiff,
29
+ stripAssignments,
30
+ stripLeadingWrappers,
31
+ };
32
+
33
+ export default function bashCmDiffGuard(pi: ExtensionAPI) {
34
+ pi.on("tool_call", async (event, ctx) => {
35
+ if (!isBashToolCall(event as { toolName?: string })) return;
36
+
37
+ const command = typeof event.input.command === "string" ? event.input.command : "";
38
+ if (!command || !commandRunsCmDiff(command)) return;
39
+
40
+ if (ctx.hasUI) {
41
+ ctx.ui.notify("Blocked bash command invoking cm diff", "warning");
42
+ }
43
+
44
+ return {
45
+ block: true,
46
+ reason: BLOCK_MESSAGE,
47
+ };
48
+ });
49
+ }
@@ -0,0 +1,53 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { commandMatches, stripAssignments } from "./shared/bash-command-guards";
3
+
4
+ const BLOCK_MESSAGE =
5
+ "Unsafe `cm merge --merge` usage is blocked in Pi. Use `plastic_merge(...)`. If Plastic stops on a source-deleted delete/change conflict, use `plastic_resolveDeleteChangeConflict(...)` before retrying the merge. If files are already manually resolved and Plastic still has merge metadata in progress, use `plastic_finalizeMerge(...)`. Otherwise run `cm merge` with `--nointeractiveresolution` and a safe policy (`--mergetype=try`), or an explicit conflict policy when you intentionally need one (`--keepsource`/`--keepdestination` or `--automaticresolution=all-src|all-dst`).";
6
+
7
+ const DIRECT_CM_MERGE = /^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:["']?[^"'\s]*[\\/])?cm(?:\.exe)?\s+merge(?:\s|$)/i;
8
+ const MERGE_FLAG = /(?:^|\s)--merge(?:\s|$)/i;
9
+ const NO_INTERACTIVE_FLAG = /(?:^|\s)--nointeractiveresolution(?:\s|$)/i;
10
+ const EXPLICIT_POLICY_FLAG = /(?:^|\s)(?:--keepsource|--ks|--keepdestination|--kd)(?:\s|$)|(?:^|\s)--automaticresolution=(?:all-src|all-dst)(?:\s|$)/i;
11
+ const MERGETYPE_TRY_FLAG = /(?:^|\s)--mergetype=try(?:\s|$)/i;
12
+
13
+ function isBashToolCall(event: { toolName?: string }): event is { toolName: "bash"; input: { command?: string } } {
14
+ return event.toolName === "bash";
15
+ }
16
+
17
+ function segmentRunsUnsafeCmMerge(segment: string): boolean {
18
+ const stripped = stripAssignments(segment);
19
+ if (!DIRECT_CM_MERGE.test(stripped)) return false;
20
+ if (!MERGE_FLAG.test(stripped)) return false;
21
+ if (!NO_INTERACTIVE_FLAG.test(stripped)) return true;
22
+
23
+ const hasExplicitPolicy = EXPLICIT_POLICY_FLAG.test(stripped);
24
+ const hasSafeAutoPolicy = MERGETYPE_TRY_FLAG.test(stripped);
25
+ return !hasExplicitPolicy && !hasSafeAutoPolicy;
26
+ }
27
+
28
+ function commandRunsUnsafeCmMerge(command: string): boolean {
29
+ return commandMatches(command, segmentRunsUnsafeCmMerge);
30
+ }
31
+
32
+ export const __bashCmMergeGuardInternals = {
33
+ segmentRunsUnsafeCmMerge,
34
+ commandRunsUnsafeCmMerge,
35
+ };
36
+
37
+ export default function bashCmMergeGuard(pi: ExtensionAPI) {
38
+ pi.on("tool_call", async (event, ctx) => {
39
+ if (!isBashToolCall(event as { toolName?: string })) return;
40
+
41
+ const command = typeof event.input.command === "string" ? event.input.command : "";
42
+ if (!command || !commandRunsUnsafeCmMerge(command)) return;
43
+
44
+ if (ctx.hasUI) {
45
+ ctx.ui.notify("Blocked unsafe bash command invoking cm merge --merge", "warning");
46
+ }
47
+
48
+ return {
49
+ block: true,
50
+ reason: BLOCK_MESSAGE,
51
+ };
52
+ });
53
+ }