pi-warden 0.5.3 → 0.5.4

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
  }
package/dist/extension.js CHANGED
@@ -76,7 +76,6 @@ export function confirmMessage(verdict) {
76
76
  lines.push("Judgments are model output, not authorization. Yes runs the tool; No blocks it and tells the agent.");
77
77
  return lines.join("\n");
78
78
  }
79
- const callKey = (tool, input) => `${tool}\0${JSON.stringify(input)}`;
80
79
  const SLOP_FIXES = {
81
80
  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
81
  comments: "delete comments that restate the code; keep only those that explain intent, constraints, or non-obvious behaviour",
@@ -99,8 +98,13 @@ export default function wardenExtension(pi) {
99
98
  const trace = new Trace();
100
99
  let panel;
101
100
  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();
101
+ /**
102
+ * Steer-mode hold state. After a hold, the next guarded call that runs under a *new* user prompt asks Jev whether that
103
+ * prompt approves it. The retry rarely repeats the held string byte for byte (a `command -v` dropped, a different
104
+ * timeout), so approval is judged against the action itself, not matched against the earlier command text.
105
+ */
106
+ let lastHoldPrompt;
107
+ let holdPending = false;
104
108
  let attempts = new AttemptWindow(defaultConfig().stuck.window);
105
109
  let evidence = emptyEvidence();
106
110
  let doneNudged = false;
@@ -179,7 +183,8 @@ export default function wardenExtension(pi) {
179
183
  widget.clear();
180
184
  trace.clear();
181
185
  panel?.close();
182
- held.clear();
186
+ lastHoldPrompt = undefined;
187
+ holdPending = false;
183
188
  attempts.reset();
184
189
  evidence = emptyEvidence();
185
190
  doneNudged = false;
@@ -215,9 +220,8 @@ export default function wardenExtension(pi) {
215
220
  return;
216
221
  stats.inspected++;
217
222
  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;
223
+ // A hold happened under an earlier prompt and the user has since replied: ask whether the reply approves this action.
224
+ const retryAfterHold = holdPending && lastHoldPrompt !== task;
221
225
  const judge = judgeFor(config);
222
226
  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 });
223
227
  if (verdict.source === "skipped")
@@ -237,7 +241,7 @@ export default function wardenExtension(pi) {
237
241
  }
238
242
  if (verdict.approvedByUser) {
239
243
  stats.approved++;
240
- held.delete(key);
244
+ holdPending = false;
241
245
  }
242
246
  const mode = activeMode(config, ctx.hasUI);
243
247
  const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
@@ -275,7 +279,10 @@ export default function wardenExtension(pi) {
275
279
  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
280
  }
277
281
  stats.held++;
278
- held.set(key, task);
282
+ // A re-hold under the user's reply keeps the original reference prompt; otherwise the reply could never approve anything.
283
+ if (!holdPending)
284
+ lastHoldPrompt = task;
285
+ holdPending = true;
279
286
  if (ctx.hasUI)
280
287
  ctx.ui.notify(`warden · held ${event.toolName}: ${reasons}. The agent was told why and asked to re-plan or ask you.`, "warning");
281
288
  return { block: true, reason: told ?? steerReason(verdict, { canApprove: judge !== undefined }) };
@@ -339,7 +346,7 @@ export default function wardenExtension(pi) {
339
346
  // Checks and repeat detection use the original result, not the excerpts or security banner.
340
347
  const failed = resultFailed(event.isError, event.details, event.content);
341
348
  if (config.done.enabled)
342
- recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed), event.input, event.toolName);
349
+ recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
343
350
  if (!config.stuck.enabled)
344
351
  return patch;
345
352
  attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
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.4",
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",