pi-warden 0.5.3 → 0.5.5

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/dist/done.d.ts CHANGED
@@ -5,7 +5,12 @@ export type ToolOutcome = "read" | "mutation" | "check-pass" | "check-fail" | "u
5
5
  * What a finished tool call contributes to the run's evidence. Only write/edit count as code changes: shell side effects
6
6
  * (deleting a temp dir, installing a package) are too varied to demand a test run for. Custom tools are unknown.
7
7
  */
8
- export declare function classifyToolResult(tool: string, input: Record<string, unknown>, failed: boolean): ToolOutcome;
8
+ export declare function classifyToolResult(tool: string, input: Record<string, unknown>, failed: boolean, output?: string): ToolOutcome;
9
+ /**
10
+ * Recognise a test/type-check runner's own summary in tool output: node:test, jest/vitest, pytest, cargo, go test,
11
+ * tsc. Returns the outcome the summary reports, or undefined when no runner summary is present.
12
+ */
13
+ export declare function checkSummary(output: string): "pass" | "fail" | undefined;
9
14
  export interface RunEvidence {
10
15
  mutations: number;
11
16
  checks: Array<{
package/dist/done.js CHANGED
@@ -9,7 +9,7 @@ const CHECK_COMMAND = /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|lin
9
9
  * What a finished tool call contributes to the run's evidence. Only write/edit count as code changes: shell side effects
10
10
  * (deleting a temp dir, installing a package) are too varied to demand a test run for. Custom tools are unknown.
11
11
  */
12
- export function classifyToolResult(tool, input, failed) {
12
+ export function classifyToolResult(tool, input, failed, output) {
13
13
  if (tool === "write" || tool === "edit")
14
14
  return "mutation";
15
15
  if (tool === "read" || tool === "grep" || tool === "find" || tool === "ls")
@@ -19,8 +19,35 @@ export function classifyToolResult(tool, input, failed) {
19
19
  return "unknown";
20
20
  if (CHECK_COMMAND.test(view.command))
21
21
  return failed ? "check-fail" : "check-pass";
22
+ // A test runner launched from inside a script (ctx_execute JavaScript, a Python wrapper) leaves no runner name in the
23
+ // command text, but its output still carries the runner's summary. Judge that summary instead.
24
+ const summary = output === undefined ? undefined : checkSummary(output);
25
+ if (summary)
26
+ return summary === "fail" || failed ? "check-fail" : "check-pass";
22
27
  return view.shell && isReadOnlyCommand(view.command) ? "read" : "unknown";
23
28
  }
29
+ /**
30
+ * Recognise a test/type-check runner's own summary in tool output: node:test, jest/vitest, pytest, cargo, go test,
31
+ * tsc. Returns the outcome the summary reports, or undefined when no runner summary is present.
32
+ */
33
+ export function checkSummary(output) {
34
+ const tail = output.slice(-6000);
35
+ const nodeTest = /\u2139 (?:tests|pass|fail) \d+/.test(tail) && /\u2139 fail (\d+)/.exec(tail);
36
+ if (nodeTest)
37
+ return Number(nodeTest[1]) > 0 ? "fail" : "pass";
38
+ const jest = /^Tests:\s+(?:(\d+) failed, )?.*?\d+ total/m.exec(tail);
39
+ if (jest)
40
+ return jest[1] && Number(jest[1]) > 0 ? "fail" : "pass";
41
+ const pytest = /^=+ .*?(?:(\d+) failed|(\d+) error).*?in [\d.]+s/m.exec(tail) ?? /^=+ (\d+) passed.*? in [\d.]+s =+$/m.exec(tail);
42
+ if (pytest)
43
+ return /\d+ (?:failed|error)/.test(pytest[0]) ? "fail" : "pass";
44
+ const cargoOrGo = /^test result: (ok|FAILED)\./m.exec(tail) ?? /^(ok|FAIL)\s+\S+\s+[\d.]+s$/m.exec(tail);
45
+ if (cargoOrGo)
46
+ return cargoOrGo[1] === "ok" ? "pass" : "fail";
47
+ if (/\berror TS\d{4}:/.test(tail))
48
+ return "fail";
49
+ return undefined;
50
+ }
24
51
  export function emptyEvidence() {
25
52
  return { mutations: 0, checks: [] };
26
53
  }
@@ -1,6 +1,15 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { SlopSymptom, Verdict } from "./guard.js";
3
3
  export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; and redacted tool-output samples for security and tail compression. Compression stores an exact, owner-only copy in a temporary file on this machine. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
4
+ /**
5
+ * Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
6
+ * so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
7
+ */
8
+ export declare function siblingToolCalls(ctx: ExtensionContext): Array<{
9
+ id: string;
10
+ name: string;
11
+ input: Record<string, unknown>;
12
+ }>;
4
13
  export declare function confirmMessage(verdict: Verdict): string;
5
14
  /** Names each symptom and its fix; repeats in the session turn the note into a standing rule. */
6
15
  export declare function slopSteer(where: string, symptoms: readonly SlopSymptom[], counts: Record<SlopSymptom, number>): string;
package/dist/extension.js CHANGED
@@ -53,6 +53,27 @@ function recentTaskContext(ctx) {
53
53
  }
54
54
  return messages.reverse();
55
55
  }
56
+ /**
57
+ * Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
58
+ * so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
59
+ */
60
+ export function siblingToolCalls(ctx) {
61
+ const entries = ctx.sessionManager.getBranch();
62
+ for (let index = entries.length - 1; index >= 0; index--) {
63
+ const entry = entries[index];
64
+ if (entry?.type !== "message")
65
+ continue;
66
+ if (entry.message.role !== "assistant")
67
+ return [];
68
+ const content = entry.message.content;
69
+ if (!Array.isArray(content))
70
+ return [];
71
+ return content.flatMap(part => part.type === "toolCall" && typeof part.id === "string" && typeof part.name === "string"
72
+ ? [{ id: part.id, name: part.name, input: (part.arguments ?? {}) }]
73
+ : []);
74
+ }
75
+ return [];
76
+ }
56
77
  function activeMode(config, hasUI) {
57
78
  const env = process.env.PI_WARDEN_MODE?.trim();
58
79
  const mode = isMode(env) ? env : config.mode;
@@ -76,7 +97,6 @@ export function confirmMessage(verdict) {
76
97
  lines.push("Judgments are model output, not authorization. Yes runs the tool; No blocks it and tells the agent.");
77
98
  return lines.join("\n");
78
99
  }
79
- const callKey = (tool, input) => `${tool}\0${JSON.stringify(input)}`;
80
100
  const SLOP_FIXES = {
81
101
  stub: "replace stubs, placeholders, and hard-coded fake data with the working implementation, or state in your reply exactly what is left unimplemented and why",
82
102
  comments: "delete comments that restate the code; keep only those that explain intent, constraints, or non-obvious behaviour",
@@ -99,8 +119,15 @@ export default function wardenExtension(pi) {
99
119
  const trace = new Trace();
100
120
  let panel;
101
121
  let lastUi;
102
- /** Calls held in steer mode, with the user prompt current at that time; a retry after the user replies asks Jev about approval. */
103
- const held = new Map();
122
+ /** Judgments for tool calls of the current assistant message, keyed by tool call id; `used` marks preflighted ones. */
123
+ const prejudged = new Map();
124
+ /**
125
+ * Steer-mode hold state. After a hold, the next guarded call that runs under a *new* user prompt asks Jev whether that
126
+ * prompt approves it. The retry rarely repeats the held string byte for byte (a `command -v` dropped, a different
127
+ * timeout), so approval is judged against the action itself, not matched against the earlier command text.
128
+ */
129
+ let lastHoldPrompt;
130
+ let holdPending = false;
104
131
  let attempts = new AttemptWindow(defaultConfig().stuck.window);
105
132
  let evidence = emptyEvidence();
106
133
  let doneNudged = false;
@@ -179,7 +206,8 @@ export default function wardenExtension(pi) {
179
206
  widget.clear();
180
207
  trace.clear();
181
208
  panel?.close();
182
- held.clear();
209
+ lastHoldPrompt = undefined;
210
+ holdPending = false;
183
211
  attempts.reset();
184
212
  evidence = emptyEvidence();
185
213
  doneNudged = false;
@@ -194,6 +222,7 @@ export default function wardenExtension(pi) {
194
222
  pi.on("before_agent_start", async (_event, ctx) => {
195
223
  attempts = new AttemptWindow(configFor(ctx).stuck.window);
196
224
  doneNudged = false;
225
+ prejudged.clear();
197
226
  });
198
227
  // Each low-level run collects its own evidence of changes and checks.
199
228
  pi.on("agent_start", async () => {
@@ -202,6 +231,8 @@ export default function wardenExtension(pi) {
202
231
  // Every turn that runs after a compression is a turn that did not carry the removed text.
203
232
  pi.on("turn_end", async () => {
204
233
  ledger.turnEnd();
234
+ // Siblings that were never preflighted (an earlier one terminated the batch, or Esc) do not outlive their turn.
235
+ prejudged.clear();
205
236
  });
206
237
  pi.on("tool_call", async (event, ctx) => {
207
238
  const config = configFor(ctx);
@@ -215,11 +246,29 @@ export default function wardenExtension(pi) {
215
246
  return;
216
247
  stats.inspected++;
217
248
  const task = latestUserPrompt(ctx);
218
- const key = callKey(event.toolName, event.input);
219
- const heldAt = held.get(key);
220
- const retryAfterHold = held.has(key) && heldAt !== task;
249
+ // A hold happened under an earlier prompt and the user has since replied: ask whether the reply approves this action.
250
+ const retryAfterHold = holdPending && lastHoldPrompt !== task;
221
251
  const judge = judgeFor(config);
222
- const verdict = await evaluateAction({ tool: event.toolName, input: event.input, cwd: ctx.cwd, task, context: recentTaskContext(ctx) }, { config: config.action, judge, signal: ctx.signal, slop: config.slop, security: config.security, retryAfterHold });
252
+ const context = recentTaskContext(ctx);
253
+ const options = { config: config.action, judge, signal: ctx.signal, slop: config.slop, security: config.security, retryAfterHold };
254
+ const judgeCall = (tool, input) => evaluateAction({ tool, input, cwd: ctx.cwd, task, context }, options);
255
+ // Sibling calls in the same assistant message are preflighted one after another; their requests go out together.
256
+ // A retry after a hold stays sequential because an approval consumed by one sibling changes the question for the next.
257
+ if (judge && !retryAfterHold) {
258
+ for (const sibling of siblingToolCalls(ctx)) {
259
+ if (sibling.id === event.toolCallId || prejudged.has(sibling.id) || !config.action.tools.includes(sibling.name))
260
+ continue;
261
+ const verdict = judgeCall(sibling.name, sibling.input);
262
+ verdict.catch(() => undefined);
263
+ prejudged.set(sibling.id, { key: JSON.stringify(sibling.input), verdict, used: false });
264
+ }
265
+ }
266
+ // An earlier hook may have changed this call's input; a stale judgment is discarded, not reused.
267
+ const key = JSON.stringify(event.input);
268
+ const ready = prejudged.get(event.toolCallId);
269
+ const pending = ready && !ready.used && ready.key === key && !retryAfterHold ? ready.verdict : judgeCall(event.toolName, event.input);
270
+ prejudged.set(event.toolCallId, { key, verdict: pending, used: true });
271
+ const verdict = await pending;
223
272
  if (verdict.source === "skipped")
224
273
  return;
225
274
  if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
@@ -237,7 +286,7 @@ export default function wardenExtension(pi) {
237
286
  }
238
287
  if (verdict.approvedByUser) {
239
288
  stats.approved++;
240
- held.delete(key);
289
+ holdPending = false;
241
290
  }
242
291
  const mode = activeMode(config, ctx.hasUI);
243
292
  const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
@@ -275,7 +324,10 @@ export default function wardenExtension(pi) {
275
324
  return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${reasons}). Do not retry it unchanged; ask the user how to proceed.` };
276
325
  }
277
326
  stats.held++;
278
- held.set(key, task);
327
+ // A re-hold under the user's reply keeps the original reference prompt; otherwise the reply could never approve anything.
328
+ if (!holdPending)
329
+ lastHoldPrompt = task;
330
+ holdPending = true;
279
331
  if (ctx.hasUI)
280
332
  ctx.ui.notify(`warden · held ${event.toolName}: ${reasons}. The agent was told why and asked to re-plan or ask you.`, "warning");
281
333
  return { block: true, reason: told ?? steerReason(verdict, { canApprove: judge !== undefined }) };
@@ -286,6 +338,17 @@ export default function wardenExtension(pi) {
286
338
  return;
287
339
  const textBlocks = event.content.filter(part => part.type === "text");
288
340
  const text = textBlocks.map(part => part.text).join("\n");
341
+ // Repeat detection uses the original result, so its request goes out together with the output check.
342
+ const failed = resultFailed(event.isError, event.details, event.content);
343
+ const stuckCheck = (() => {
344
+ if (!config.stuck.enabled)
345
+ return undefined;
346
+ attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
347
+ if (!attempts.shouldJudge(config.stuck))
348
+ return undefined;
349
+ stats.stuckChecks++;
350
+ return evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
351
+ })();
289
352
  const output = await evaluateOutput(event.toolName, text, latestUserPrompt(ctx), {
290
353
  security: config.security, context: config.context, judge: judgeFor(config), timeoutMs: config.timeoutMs,
291
354
  signal: ctx.signal, compressible: textBlocks.length === 1, taskContext: recentTaskContext(ctx),
@@ -336,17 +399,12 @@ export default function wardenExtension(pi) {
336
399
  ]);
337
400
  }
338
401
  const patch = content === event.content ? undefined : { content };
339
- // Checks and repeat detection use the original result, not the excerpts or security banner.
340
- const failed = resultFailed(event.isError, event.details, event.content);
402
+ // Checks use the original result, not the excerpts or security banner.
341
403
  if (config.done.enabled)
342
- recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed), event.input, event.toolName);
343
- if (!config.stuck.enabled)
404
+ recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
405
+ const verdict = await stuckCheck;
406
+ if (!verdict)
344
407
  return patch;
345
- attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
346
- if (!attempts.shouldJudge(config.stuck))
347
- return patch;
348
- stats.stuckChecks++;
349
- const verdict = await evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
350
408
  if (verdict.error)
351
409
  noteError(ctx, verdict.error, undefined);
352
410
  if (verdict.source === "repeat" && !verdict.stuck)
@@ -370,9 +428,17 @@ export default function wardenExtension(pi) {
370
428
  const judge = judgeFor(config);
371
429
  if (!finalMessage || !judge)
372
430
  return;
373
- if (config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars) {
431
+ // Pi shows the agent as working until this hook returns, so the two independent checks share one round trip.
432
+ const task = latestUserPrompt(ctx);
433
+ const proseCheck = config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars
434
+ ? evaluateProse(task, finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
435
+ : undefined;
436
+ const doneCheck = config.done.enabled && !doneNudged && needsDoneCheck(evidence)
437
+ ? evaluateDone(task, finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
438
+ : undefined;
439
+ if (proseCheck) {
374
440
  stats.proseChecks++;
375
- const verdict = await evaluateProse(latestUserPrompt(ctx), finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal });
441
+ const verdict = await proseCheck;
376
442
  if (verdict.error)
377
443
  noteError(ctx, verdict.error, undefined);
378
444
  else
@@ -391,10 +457,10 @@ export default function wardenExtension(pi) {
391
457
  steer(config, nudge, { deliverAs: "nextTurn" });
392
458
  }
393
459
  }
394
- if (!config.done.enabled || doneNudged || !needsDoneCheck(evidence))
460
+ if (!doneCheck)
395
461
  return;
396
462
  stats.doneChecks++;
397
- const verdict = await evaluateDone(latestUserPrompt(ctx), finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal });
463
+ const verdict = await doneCheck;
398
464
  if (verdict.error)
399
465
  noteError(ctx, verdict.error, undefined);
400
466
  const nudge = verdict.unverified && config.done.nudge ? doneNudge(verdict) : undefined;
package/dist/guard.js CHANGED
@@ -287,7 +287,12 @@ export async function evaluateAction(action, options) {
287
287
  const patterns = matchPatterns(action.tool, action.input, action.cwd);
288
288
  const reasons = [];
289
289
  let level = "allow";
290
+ // A shell command that merely mentions a secrets file (grep for key names, cat .env.example) is decided after Jev
291
+ // says whether it can write; write/edit on such a path, and offline runs, keep the immediate warning.
292
+ const deferSensitive = judge !== undefined && (action.tool !== "write" && action.tool !== "edit");
290
293
  for (const hit of patterns) {
294
+ if (hit.severity === "sensitive" && deferSensitive)
295
+ continue;
291
296
  level = higher(level, hit.severity === "destructive" ? "confirm" : "warn");
292
297
  reasons.push(`${hit.severity}: ${hit.label}`);
293
298
  }
@@ -323,6 +328,18 @@ export async function evaluateAction(action, options) {
323
328
  judgment.approved = answers.approved.noul;
324
329
  if (typeof answers.mutates?.noul === "number")
325
330
  judgment.mutates = answers.mutates.noul;
331
+ if (deferSensitive) {
332
+ for (const hit of patterns) {
333
+ if (hit.severity !== "sensitive")
334
+ continue;
335
+ if ((judgment.mutates ?? 1) >= 0.5) {
336
+ level = higher(level, "warn");
337
+ reasons.push(`${hit.severity}: ${hit.label}`);
338
+ }
339
+ else
340
+ reasons.push(`${hit.label} (read-only, not warned)`);
341
+ }
342
+ }
326
343
  // write/edit always change something; a command that Jev judges read-only is warned about, never held, for scope alone.
327
344
  const canChange = summary.tool === "write" || summary.tool === "edit" || (judgment.mutates ?? 1) >= 0.5;
328
345
  if (judgment.irreversible >= config.irreversible.confirm) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Guardrails for Pi built on pi-typesafe that steer the agent instead of interrupting you: Jev judges irreversible and off-task tool calls before they run, detects stuck loops, checks unverified done claims, and flags slop.",
5
5
  "type": "module",
6
6
  "license": "MIT",