pi-warden 0.47.0 → 0.60.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 (48) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/README.md +24 -4
  3. package/dist/action-guard.d.ts +2 -0
  4. package/dist/action-guard.js +1 -1
  5. package/dist/compact.d.ts +52 -11
  6. package/dist/compact.js +108 -29
  7. package/dist/config.d.ts +33 -1
  8. package/dist/config.js +44 -6
  9. package/dist/conscience.d.ts +16 -1
  10. package/dist/conscience.js +50 -4
  11. package/dist/done.d.ts +14 -1
  12. package/dist/done.js +88 -6
  13. package/dist/extension.d.ts +11 -5
  14. package/dist/extension.js +243 -61
  15. package/dist/extension.js.bak +263 -76
  16. package/dist/guard.d.ts +47 -3
  17. package/dist/guard.js +886 -62
  18. package/dist/holds.d.ts +1 -0
  19. package/dist/holds.js +2 -0
  20. package/dist/index-cmd.d.ts +2 -0
  21. package/dist/index-cmd.js +9 -7
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.js +1 -1
  24. package/dist/judge-cooldown.d.ts +1 -0
  25. package/dist/judge-cooldown.js +2 -1
  26. package/dist/learning.d.ts +2 -0
  27. package/dist/learning.js +2 -2
  28. package/dist/output.d.ts +7 -2
  29. package/dist/output.js +11 -4
  30. package/dist/prefs.d.ts +105 -0
  31. package/dist/prefs.js +394 -0
  32. package/dist/redact.d.ts +14 -2
  33. package/dist/redact.js +68 -5
  34. package/dist/rules.d.ts +15 -3
  35. package/dist/rules.js +22 -7
  36. package/dist/saver.d.ts +15 -2
  37. package/dist/saver.js +19 -4
  38. package/dist/shape.d.ts +1 -1
  39. package/dist/shape.js +12 -6
  40. package/dist/shell-writes.d.ts +36 -0
  41. package/dist/shell-writes.js +606 -0
  42. package/dist/stuck.d.ts +22 -0
  43. package/dist/stuck.js +56 -1
  44. package/dist/trace.js +2 -2
  45. package/dist/widget.d.ts +1 -1
  46. package/dist/widget.js +2 -1
  47. package/examples/README.md +2 -2
  48. package/package.json +1 -1
package/dist/config.d.ts CHANGED
@@ -127,6 +127,8 @@ export interface StuckGuardConfig {
127
127
  churnThreshold: number;
128
128
  /** Also steer the agent with a short message, not only the user. */
129
129
  nudge: boolean;
130
+ /** Steer on the 2nd identical call when nothing changed between and it failed the same way or re-read the same output. Code only. */
131
+ repeatSteer: boolean;
130
132
  /** Maximum characters of the unified line diff in a stuck-loop diff note. */
131
133
  diffLimit: number;
132
134
  /** Characters of the current output tail shown after the diff in a stuck-loop diff note. */
@@ -138,7 +140,25 @@ export interface DoneGuardConfig {
138
140
  claimsDone: number;
139
141
  /** Also send the agent a follow-up asking it to verify. Triggers one more LLM turn. */
140
142
  nudge: boolean;
143
+ /** After a change to a `uiFiles` path, only a `visualTools` call counts as proof; passing tests do not. */
144
+ uiProof: boolean;
145
+ /** Globs for files whose change shows on screen. `{a,b}` alternatives are allowed; a `!` glob excludes. */
146
+ uiFiles: string[];
147
+ visualTools: VisualToolsConfig;
141
148
  }
149
+ /** Tool calls that show the rendered UI. Matching is case-insensitive. */
150
+ export interface VisualToolsConfig {
151
+ /** Heads of a shell command segment: `agent-browser`, `npx playwright`. */
152
+ commands: string[];
153
+ /** Words that make a shell command visual when one stands alone as an argument or flag: `idb screenshot`, `--screenshot`. */
154
+ commandWords: string[];
155
+ /** Text in a tool name, or in the `tool` an MCP proxy calls: `take_screenshot`, `navigate_page`. */
156
+ tools: string[];
157
+ /** Image extensions whose `read` counts as looking at the result. */
158
+ images: string[];
159
+ }
160
+ export declare const DEFAULT_UI_FILES: string[];
161
+ export declare function defaultVisualTools(): VisualToolsConfig;
142
162
  export interface ProseConfig {
143
163
  enabled: boolean;
144
164
  /** Who reads the agent's replies: "technical", "plain", or a free-text description. Drives the jargon question. */
@@ -160,6 +180,8 @@ export interface SecurityConfig {
160
180
  enabled: boolean;
161
181
  /** P(injection or exfiltration) at or above this adds an untrusted-output notice. */
162
182
  threshold: number;
183
+ /** Replace high-confidence credential values in tool results with `[redacted]` before the model sees them. */
184
+ maskOutput: boolean;
163
185
  }
164
186
  export interface RulesConfig {
165
187
  /** Judge each write and edit against the project's Markdown rules on its own Jev request; steer, never hold. */
@@ -242,6 +264,12 @@ export interface SubagentConfig {
242
264
  export type RecallTool = "auto" | "rg" | "ag" | "ugrep" | "git-grep" | "grep" | "select-string" | "findstr" | "none";
243
265
  export declare function isRecallTool(value: unknown): value is RecallTool;
244
266
  export type WardenMode = "steer" | "confirm" | "advise";
267
+ export interface PrefsConfig {
268
+ /** Read this project's earlier session files for preferences the user repeated (`/warden prefs`). Local, code only. */
269
+ enabled: boolean;
270
+ /** At session start, send the standing preferences to the agent as one context message. Off by default. */
271
+ inject: boolean;
272
+ }
245
273
  export interface LearningConfig {
246
274
  /** Enable adaptive thresholds based on learning data. */
247
275
  adaptiveThresholds: boolean;
@@ -274,6 +302,8 @@ export interface ConscienceConfig {
274
302
  skills: ConscienceSkillConfig;
275
303
  /** Tool suggestion settings. */
276
304
  tools: ConscienceToolConfig;
305
+ /** Exact tool names never recommended: core tools the agent already uses. Skills are never skipped by this list. */
306
+ skipTools: string[];
277
307
  /** Total wall-clock deadline for one assessment (ms). Effective deadline is min(this, shared timeoutMs). */
278
308
  timeoutMs: number;
279
309
  /** Max assessments per admitted operator prompt, including the initial. */
@@ -334,10 +364,12 @@ export interface WardenConfig {
334
364
  learning: LearningConfig;
335
365
  /** Broad-consideration coach: recommends or loads skills and tools before the agent acts. */
336
366
  conscience: ConscienceConfig;
367
+ /** Standing preferences: corrections the user repeated in earlier sessions of this project. */
368
+ prefs: PrefsConfig;
337
369
  }
338
370
  export declare const PACKAGE_NAME = "pi-warden";
339
371
  /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
340
- export declare const CONFIG_SCHEMA = 7;
372
+ export declare const CONFIG_SCHEMA = 8;
341
373
  export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
342
374
  export declare function defaultConfig(): WardenConfig;
343
375
  /** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
package/dist/config.js CHANGED
@@ -4,13 +4,26 @@ import { dirname, join } from "node:path";
4
4
  import { resolveBackend } from "./backend.js";
5
5
  import { COMMAND_TOOLS } from "./tools.js";
6
6
  import { defaultWidgetConfig } from "./widget.js";
7
+ // Tests of UI code are proven by running them, not by looking at them.
8
+ export const DEFAULT_UI_FILES = [
9
+ "**/*.{css,scss,sass,less,html,htm,vue,svelte,jsx,tsx,astro,dart}", "**/web/**/*.js", "**/public/**/*.js",
10
+ "!**/*.{test,spec}.*", "!**/*_test.dart", "!**/{test,tests,__tests__}/**",
11
+ ];
12
+ export function defaultVisualTools() {
13
+ return {
14
+ commands: ["agent-browser", "playwright", "npx playwright", "flutter test", "fvm flutter test", "idb", "xcrun simctl io", "chrome", "chromium", "google-chrome"],
15
+ commandWords: ["screenshot"],
16
+ tools: ["screenshot", "take_snapshot", "navigate"],
17
+ images: ["png", "jpg", "jpeg", "webp"],
18
+ };
19
+ }
7
20
  const RECALL_TOOLS = ["auto", "rg", "ag", "ugrep", "git-grep", "grep", "select-string", "findstr", "none"];
8
21
  export function isRecallTool(value) {
9
22
  return typeof value === "string" && RECALL_TOOLS.includes(value);
10
23
  }
11
24
  export const PACKAGE_NAME = "pi-warden";
12
25
  /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
13
- export const CONFIG_SCHEMA = 7;
26
+ export const CONFIG_SCHEMA = 8;
14
27
  export const PROJECT_CONFIG_FILE = `${PACKAGE_NAME}.json`;
15
28
  export function defaultConfig() {
16
29
  return {
@@ -39,10 +52,10 @@ export function defaultConfig() {
39
52
  escalationThreshold: 0.85,
40
53
  floor: "evidence",
41
54
  },
42
- stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true, diffLimit: 3000, tailLimit: 1000 },
43
- done: { enabled: true, claimsDone: 0.7, nudge: true },
55
+ stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true, repeatSteer: true, diffLimit: 3000, tailLimit: 1000 },
56
+ done: { enabled: true, claimsDone: 0.7, nudge: true, uiProof: true, uiFiles: [...DEFAULT_UI_FILES], visualTools: defaultVisualTools() },
44
57
  slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
45
- security: { enabled: true, threshold: 0.7 },
58
+ security: { enabled: true, threshold: 0.7, maskOutput: true },
46
59
  rules: { enabled: true, threshold: 0.7, files: [], fallback: true, maxChars: 8000, exclude: [], skip: [], sensitivePaths: {} },
47
60
  context: { enabled: true, tailMinChars: 12000, confidence: 0.8, duplicateMinChars: 2000, recallTool: "auto", formatConfidence: 0.7, compactAppendix: true, largeOutput: { enabled: true, threshold: 0.85 } },
48
61
  runaway: { enabled: true, repeats: 4, thinkingRepeats: 10, minChars: 400, recover: true },
@@ -58,6 +71,7 @@ export function defaultConfig() {
58
71
  enabled: false,
59
72
  skills: { mode: "recommend", exclude: [] },
60
73
  tools: { enabled: true, exclude: [] },
74
+ skipTools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
61
75
  timeoutMs: 3000,
62
76
  maxAssessments: 3,
63
77
  maxNudges: 2,
@@ -68,6 +82,7 @@ export function defaultConfig() {
68
82
  advanceThreshold: 0.70,
69
83
  loadThreshold: 1.0,
70
84
  },
85
+ prefs: { enabled: true, inject: false },
71
86
  };
72
87
  }
73
88
  /** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
@@ -299,6 +314,7 @@ function applyStuck(base, raw) {
299
314
  sameStrategy: probability(raw.sameStrategy, base.sameStrategy),
300
315
  churnThreshold: Math.min(window, positiveInteger(raw.churnThreshold, base.churnThreshold)),
301
316
  nudge: boolean(raw.nudge, base.nudge),
317
+ repeatSteer: boolean(raw.repeatSteer, base.repeatSteer),
302
318
  diffLimit: positiveInteger(raw.diffLimit, base.diffLimit),
303
319
  tailLimit: positiveInteger(raw.tailLimit, base.tailLimit),
304
320
  };
@@ -340,7 +356,20 @@ function applySubagent(base, raw) {
340
356
  function applyDone(base, raw) {
341
357
  if (!isObject(raw))
342
358
  return base;
343
- return { enabled: boolean(raw.enabled, base.enabled), claimsDone: probability(raw.claimsDone, base.claimsDone), nudge: boolean(raw.nudge, base.nudge) };
359
+ const visual = isObject(raw.visualTools) ? raw.visualTools : {};
360
+ return {
361
+ enabled: boolean(raw.enabled, base.enabled),
362
+ claimsDone: probability(raw.claimsDone, base.claimsDone),
363
+ nudge: boolean(raw.nudge, base.nudge),
364
+ uiProof: boolean(raw.uiProof, base.uiProof),
365
+ uiFiles: globList(raw.uiFiles, base.uiFiles),
366
+ visualTools: {
367
+ commands: globList(visual.commands, base.visualTools.commands),
368
+ commandWords: globList(visual.commandWords, base.visualTools.commandWords),
369
+ tools: globList(visual.tools, base.visualTools.tools),
370
+ images: globList(visual.images, base.visualTools.images).map(extension => extension.replace(/^\./, "")),
371
+ },
372
+ };
344
373
  }
345
374
  function applyProse(base, raw) {
346
375
  if (!isObject(raw))
@@ -424,6 +453,8 @@ function applyGuards(base, raw, timeoutMs, source) {
424
453
  security: isObject(raw.security) ? {
425
454
  enabled: boolean(raw.security.enabled, base.security.enabled),
426
455
  threshold: probability(raw.security.threshold, base.security.threshold),
456
+ // Only the user file can turn masking off: a repository must not unmask credentials in its own agent's output.
457
+ maskOutput: source === "user" ? boolean(raw.security.maskOutput, base.security.maskOutput) : base.security.maskOutput,
427
458
  } : base.security,
428
459
  context: isObject(raw.context) ? {
429
460
  enabled: boolean(raw.context.enabled, base.context.enabled),
@@ -458,8 +489,14 @@ export function applyUserOverrides(base, raw) {
458
489
  steerBudget: typeof raw.steerBudget === "number" && Number.isInteger(raw.steerBudget) && raw.steerBudget >= 0 ? raw.steerBudget : base.steerBudget,
459
490
  learning: applyLearning(base.learning, raw.learning),
460
491
  conscience: applyConscience(base.conscience, raw.conscience),
492
+ prefs: applyPrefs(base.prefs, raw.prefs),
461
493
  };
462
494
  }
495
+ function applyPrefs(base, raw) {
496
+ if (!isObject(raw))
497
+ return base;
498
+ return { enabled: boolean(raw.enabled, base.enabled), inject: boolean(raw.inject, base.inject) };
499
+ }
463
500
  function applyLearning(base, raw) {
464
501
  if (!isObject(raw))
465
502
  return base;
@@ -487,6 +524,7 @@ function applyConscience(base, raw) {
487
524
  enabled: boolean(toolsRaw?.enabled, base.tools.enabled),
488
525
  exclude: Array.isArray(toolsRaw?.exclude) ? toolsRaw.exclude.filter((x) => typeof x === "string") : base.tools.exclude,
489
526
  },
527
+ skipTools: Array.isArray(raw.skipTools) ? raw.skipTools.filter((x) => typeof x === "string") : base.skipTools,
490
528
  timeoutMs: Math.max(100, Math.min(10000, typeof raw.timeoutMs === "number" ? raw.timeoutMs : base.timeoutMs)),
491
529
  maxAssessments: Math.max(1, Math.min(10, typeof raw.maxAssessments === "number" ? raw.maxAssessments : base.maxAssessments)),
492
530
  maxNudges: Math.max(1, Math.min(5, typeof raw.maxNudges === "number" ? raw.maxNudges : base.maxNudges)),
@@ -501,7 +539,7 @@ function applyConscience(base, raw) {
501
539
  export function applyProjectOverrides(base, raw) {
502
540
  if (!isObject(raw))
503
541
  return base;
504
- return { ...base, enabled: boolean(raw.enabled, base.enabled), ...applyGuards(base, raw, base.timeoutMs, "project") };
542
+ return { ...base, enabled: boolean(raw.enabled, base.enabled), ...applyGuards(base, raw, base.timeoutMs, "project"), prefs: applyPrefs(base.prefs, raw.prefs) };
505
543
  }
506
544
  export function loadConfig(options = {}) {
507
545
  let config = applyUserOverrides(defaultConfig(), readJson(userConfigPath()));
@@ -117,6 +117,19 @@ export declare function buildState(task: string, recentContext: string, activeSk
117
117
  * batches happened to serialize identically — an accident of batch order, not a guarantee.
118
118
  */
119
119
  export declare function questionHash(questions: Questions): string;
120
+ /**
121
+ * True when a marker is a whole word or `_`-separated part of the name, or the first word of the description.
122
+ * Only the leading verb of a description counts: a description that mentions removal in passing
123
+ * ("edit files; remove text") does not make a tool destructive.
124
+ */
125
+ export declare function isDestructiveTool(name: string, ...descriptions: Array<string | undefined>): boolean;
126
+ /**
127
+ * Tools that only run on one platform. A recommendation to use one elsewhere costs the agent a turn to find out
128
+ * it is missing, and it then answers about the tool instead of the task.
129
+ */
130
+ export declare const PLATFORM_BOUND_TOOLS: Readonly<Record<string, NodeJS.Platform>>;
131
+ /** True when the tool name or a description binds the tool to a platform other than `platform`. */
132
+ export declare function isPlatformIneligibleTool(name: string, platform: NodeJS.Platform, ...descriptions: Array<string | undefined>): boolean;
120
133
  /** Filter candidates by eligibility rules, capped at MAX_ELIGIBLE per category. Attaches index entries when available. */
121
134
  export declare function eligibleCandidates(skills: Skill[], tools: {
122
135
  name: string;
@@ -125,7 +138,7 @@ export declare function eligibleCandidates(skills: Skill[], tools: {
125
138
  entries: IndexEntry[];
126
139
  } | undefined, projectIndex?: {
127
140
  entries: IndexEntry[];
128
- } | undefined): {
141
+ } | undefined, platform?: NodeJS.Platform): {
129
142
  candidates: Candidate[];
130
143
  skillOverflow: boolean;
131
144
  toolOverflow: boolean;
@@ -157,6 +170,8 @@ export interface ConscienceDeps {
157
170
  projectIndex?: {
158
171
  entries: IndexEntry[];
159
172
  } | undefined;
173
+ /** Platform the agent's tools run on. Injected for testability; defaults to `process.platform`. */
174
+ platform?: NodeJS.Platform;
160
175
  }
161
176
  /**
162
177
  * Run assessment for the given prompt revision.
@@ -141,8 +141,47 @@ function isExcluded(id, exclude) {
141
141
  }
142
142
  return false;
143
143
  }
144
+ /**
145
+ * Name parts that mark a tool as destructive. A recommendation to run such a tool is never worth the risk
146
+ * of the agent calling it on a prompt that only mentioned the subject.
147
+ */
148
+ const DESTRUCTIVE_MARKERS = new Set(["delete", "drop", "destroy", "remove", "purge", "wipe", "reset", "truncate", "kill", "force", "uninstall", "revoke", "erase", "clear"]);
149
+ /** camelCase is split before lowercasing, so `deleteIssue` and `mcp__db__truncateTable` show their verb. */
150
+ function words(text) {
151
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
152
+ }
153
+ /**
154
+ * True when a marker is a whole word or `_`-separated part of the name, or the first word of the description.
155
+ * Only the leading verb of a description counts: a description that mentions removal in passing
156
+ * ("edit files; remove text") does not make a tool destructive.
157
+ */
158
+ export function isDestructiveTool(name, ...descriptions) {
159
+ if (words(name).some(w => DESTRUCTIVE_MARKERS.has(w)))
160
+ return true;
161
+ return descriptions.some(d => d !== undefined && DESTRUCTIVE_MARKERS.has(words(d)[0] ?? ""));
162
+ }
163
+ /**
164
+ * Tools that only run on one platform. A recommendation to use one elsewhere costs the agent a turn to find out
165
+ * it is missing, and it then answers about the tool instead of the task.
166
+ */
167
+ export const PLATFORM_BOUND_TOOLS = {
168
+ powershell: "win32",
169
+ pwsh: "win32",
170
+ cmd: "win32",
171
+ };
172
+ const PLATFORM_ONLY_DESCRIPTIONS = [
173
+ { platform: "win32", pattern: /\bwindows[- ]only\b|\bonly (?:on|for) windows\b/i },
174
+ { platform: "darwin", pattern: /\b(?:macos|mac os|os x)[- ]only\b|\bonly (?:on|for) (?:macos|mac os|os x)\b/i },
175
+ ];
176
+ /** True when the tool name or a description binds the tool to a platform other than `platform`. */
177
+ export function isPlatformIneligibleTool(name, platform, ...descriptions) {
178
+ const bound = PLATFORM_BOUND_TOOLS[name.toLowerCase()];
179
+ if (bound !== undefined && bound !== platform)
180
+ return true;
181
+ return PLATFORM_ONLY_DESCRIPTIONS.some(({ platform: only, pattern }) => only !== platform && descriptions.some(d => d !== undefined && pattern.test(d)));
182
+ }
144
183
  /** Filter candidates by eligibility rules, capped at MAX_ELIGIBLE per category. Attaches index entries when available. */
145
- export function eligibleCandidates(skills, tools, config, activeSkills, suppliedSkills, globalIndex, projectIndex) {
184
+ export function eligibleCandidates(skills, tools, config, activeSkills, suppliedSkills, globalIndex, projectIndex, platform = process.platform) {
146
185
  const candidates = [];
147
186
  let skillOverflow = false;
148
187
  let toolOverflow = false;
@@ -186,12 +225,19 @@ export function eligibleCandidates(skills, tools, config, activeSkills, supplied
186
225
  for (const tool of tools) {
187
226
  if (isExcluded(tool.name, config.tools.exclude))
188
227
  continue;
228
+ // Core tools the agent uses on nearly every turn: a recommendation to use one tells it nothing new.
229
+ if (config.skipTools.includes(tool.name))
230
+ continue;
231
+ const sourceHash = toolSourceHash(tool.name, tool.description);
232
+ const entry = findEntry(tool.name, sourceHash);
233
+ if (isDestructiveTool(tool.name, tool.description, entry?.lead))
234
+ continue;
235
+ if (isPlatformIneligibleTool(tool.name, platform, tool.description, entry?.lead))
236
+ continue;
189
237
  if (count >= MAX_ELIGIBLE) {
190
238
  toolOverflow = true;
191
239
  break;
192
240
  }
193
- const sourceHash = toolSourceHash(tool.name, tool.description);
194
- const entry = findEntry(tool.name, sourceHash);
195
241
  candidates.push({
196
242
  kind: "tool",
197
243
  id: tool.name,
@@ -294,7 +340,7 @@ export async function assess(prompt, recentContext, skills, tools, activeSkills,
294
340
  if (!judge) {
295
341
  return { disposition: "no_gap", selected: null, usefulness: 0, pAdvance: 0, questionHash: "", elapsedMs: 0, requestCount: 0, skipReason: deps.judgmentsOff ?? "no_consent" };
296
342
  }
297
- const { candidates, skillOverflow, toolOverflow } = eligibleCandidates(skills, tools, config, activeSkills, suppliedSkills, deps.globalIndex, deps.projectIndex);
343
+ const { candidates, skillOverflow, toolOverflow } = eligibleCandidates(skills, tools, config, activeSkills, suppliedSkills, deps.globalIndex, deps.projectIndex, deps.platform);
298
344
  if (candidates.length === 0) {
299
345
  return { disposition: "no_gap", selected: null, usefulness: 0, pAdvance: 0, questionHash: "", elapsedMs: 0, requestCount: 0, skipReason: "no_match" };
300
346
  }
package/dist/done.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { IntegrationErrorCode, Judge } from "pi-typesafe";
2
- import type { DoneGuardConfig } from "./config.js";
2
+ import type { DoneGuardConfig, VisualToolsConfig } from "./config.js";
3
3
  export type ToolOutcome = "read" | "mutation" | "check-pass" | "check-fail" | "unknown";
4
4
  /**
5
5
  * What a finished tool call contributes to the run's evidence. Only write/edit count as code changes: shell side effects
@@ -18,9 +18,20 @@ export interface RunEvidence {
18
18
  passed: boolean;
19
19
  }>;
20
20
  checksBeforeMutation?: number;
21
+ /** The last UI file changed with no visual check after it. Tests and builds do not show what a page looks like. */
22
+ unseenUi?: string;
21
23
  }
22
24
  export declare function emptyEvidence(): RunEvidence;
23
25
  export declare function recordOutcome(evidence: RunEvidence, outcome: ToolOutcome, input: Record<string, unknown>, tool?: string): void;
26
+ /** A path matches when some glob matches it and no `!` glob does. */
27
+ export declare function isUiFile(path: string, globs: readonly string[]): boolean;
28
+ /**
29
+ * Whether a successful tool call showed the rendered UI: a browser or device command, a screenshot, a browser MCP tool, or
30
+ * reading an image. A test run proves the code runs; only this proves what the user will see.
31
+ */
32
+ export declare function isVisualCheck(tool: string, input: Record<string, unknown>, failed: boolean, visual: VisualToolsConfig): boolean;
33
+ /** Paths a successful call changed that match the UI globs: `write`/`edit` paths, plus files a shell command writes. */
34
+ export declare function recordUi(evidence: RunEvidence, changed: readonly string[], visual: boolean, globs: readonly string[]): void;
24
35
  interface MessageLike {
25
36
  role: string;
26
37
  content?: unknown;
@@ -58,6 +69,8 @@ export interface DoneVerdict {
58
69
  reasons: string[];
59
70
  evidence: RunEvidence;
60
71
  judgment?: DoneJudgment;
72
+ /** Set when the claim follows a UI change that nothing showed on screen. */
73
+ unseenUi?: string;
61
74
  error?: string;
62
75
  errorCode?: IntegrationErrorCode;
63
76
  }
package/dist/done.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ask, choice, noul } from "pi-typesafe";
2
- import { isReadOnlyCommand } from "./guard.js";
2
+ import { isReadOnlyCommand, stripDataText } from "./guard.js";
3
3
  import { redact } from "./redact.js";
4
+ import { matchGlob } from "./rules.js";
4
5
  import { commandOf } from "./tools.js";
5
6
  import { DEFAULT_TEMPLATES, doneTokens, renderTemplate } from "./widget.js";
6
7
  /** Commands whose success is evidence that the work was verified. */
@@ -62,6 +63,77 @@ export function recordOutcome(evidence, outcome, input, tool = "bash") {
62
63
  evidence.checks.push({ call, passed: outcome === "check-pass" });
63
64
  }
64
65
  }
66
+ // `{a,b}` alternatives, which the rules glob matcher does not read, as in the default `*.{css,html,…}` UI glob.
67
+ function expandBraces(pattern) {
68
+ const match = /\{([^{}]*)\}/.exec(pattern);
69
+ if (!match)
70
+ return [pattern];
71
+ return match[1].split(",").flatMap(option => expandBraces(pattern.slice(0, match.index) + option + pattern.slice(match.index + match[0].length)));
72
+ }
73
+ /** A path matches when some glob matches it and no `!` glob does. */
74
+ export function isUiFile(path, globs) {
75
+ const normalised = path.replace(/\\/g, "/");
76
+ const include = globs.filter(glob => !glob.startsWith("!")).flatMap(expandBraces);
77
+ const exclude = globs.filter(glob => glob.startsWith("!")).map(glob => glob.slice(1)).flatMap(expandBraces);
78
+ return matchGlob(normalised, include) !== undefined && matchGlob(normalised, exclude) === undefined;
79
+ }
80
+ function shellSegments(command) {
81
+ return command.split(/\n|;|&&|\|\||\||&/).map(part => part.trim().replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, "")).filter(Boolean);
82
+ }
83
+ const BROWSER_HEADS = new Set(["chrome", "chromium", "google-chrome"]);
84
+ /**
85
+ * Heads that also run work the user never sees. `flutter test` shows the UI only for integration or golden tests, and
86
+ * `idb` only through its `screenshot` and `ui` subcommands; `idb list-targets` or a unit test proves nothing on screen.
87
+ * A browser binary shows the page only when a command word asks for it (`chrome --headless --screenshot`), not for `--version`.
88
+ */
89
+ function headShows(head, args) {
90
+ if (/(?:^|\s)flutter test$/.test(head))
91
+ return args.some(arg => /(?:^|\/)integration_test(?:\/|$)/.test(arg) || arg.includes("golden"));
92
+ if (head === "idb")
93
+ return args.some(arg => arg === "screenshot" || arg === "ui");
94
+ if (BROWSER_HEADS.has(head))
95
+ return false;
96
+ return true;
97
+ }
98
+ /**
99
+ * Whether a successful tool call showed the rendered UI: a browser or device command, a screenshot, a browser MCP tool, or
100
+ * reading an image. A test run proves the code runs; only this proves what the user will see.
101
+ */
102
+ export function isVisualCheck(tool, input, failed, visual) {
103
+ if (failed)
104
+ return false;
105
+ if (tool === "read") {
106
+ const path = typeof input.path === "string" ? input.path.toLowerCase() : "";
107
+ return visual.images.some(extension => path.endsWith(`.${extension.toLowerCase()}`));
108
+ }
109
+ const view = commandOf(tool, input);
110
+ if (view) {
111
+ if (!view.shell)
112
+ return false;
113
+ // A PR body, commit message, or heredoc note that mentions a screenshot is not one; nor is a `screenshots/` path.
114
+ const segments = shellSegments(stripDataText(view.command).text.toLowerCase());
115
+ const heads = visual.commands.map(command => command.toLowerCase());
116
+ const words = new Set(visual.commandWords.map(word => word.toLowerCase()));
117
+ // A command word counts only after a visual head: `grep -rn screenshot src` searches for the word.
118
+ return segments.some(segment => heads.some(head => {
119
+ if (segment !== head && !segment.startsWith(`${head} `))
120
+ return false;
121
+ const args = segment.slice(head.length).split(/\s+/).filter(Boolean);
122
+ return headShows(head, args) || args.some(arg => words.has(arg.replace(/^-+/, "").replace(/=.*$/, "")));
123
+ }));
124
+ }
125
+ // An MCP proxy (`mcp`, `mcp__chrome_devtools`) names the real tool in its input.
126
+ const names = [tool, /^mcp(?:__|$)/.test(tool) && typeof input.tool === "string" ? input.tool : ""].map(name => name.toLowerCase());
127
+ return names.some(name => name && visual.tools.some(word => name.includes(word.toLowerCase())));
128
+ }
129
+ /** Paths a successful call changed that match the UI globs: `write`/`edit` paths, plus files a shell command writes. */
130
+ export function recordUi(evidence, changed, visual, globs) {
131
+ const ui = changed.filter(path => isUiFile(path, globs));
132
+ if (ui.length)
133
+ evidence.unseenUi = ui[ui.length - 1];
134
+ else if (visual)
135
+ delete evidence.unseenUi;
136
+ }
65
137
  /** Text of the run's final assistant message, when it ended normally with text (not a tool call, error, or abort). */
66
138
  export function finalAssistantText(messages) {
67
139
  for (let index = messages.length - 1; index >= 0; index--) {
@@ -84,7 +156,7 @@ export function freshChecks(evidence) {
84
156
  }
85
157
  /** The check only makes sense when something changed and nothing proved that change works. */
86
158
  export function needsDoneCheck(evidence) {
87
- return evidence.mutations > 0 && !freshChecks(evidence).some(check => check.passed);
159
+ return (evidence.mutations > 0 && !freshChecks(evidence).some(check => check.passed)) || evidence.unseenUi !== undefined;
88
160
  }
89
161
  export const doneQuestions = {
90
162
  claims_done: noul("Does `final_message` present the requested work as finished or working?", {
@@ -126,24 +198,34 @@ export async function evaluateDone(task, finalMessage, evidence, options) {
126
198
  model: result.model,
127
199
  elapsedMs: result.elapsedMs,
128
200
  };
129
- const unverified = judgment.claimsDone >= options.config.claimsDone && judgment.outcome !== "blocked" && judgment.verificationApplies >= APPLIES_THRESHOLD;
201
+ const claimed = judgment.claimsDone >= options.config.claimsDone && judgment.outcome !== "blocked";
130
202
  const checks = freshChecks(evidence);
203
+ const codeUnverified = claimed && evidence.mutations > 0 && !checks.some(check => check.passed) && judgment.verificationApplies >= APPLIES_THRESHOLD;
204
+ // The file type already says a visual check applies, so `verification_applies` (about tests and builds) does not gate it.
205
+ const unseenUi = claimed ? evidence.unseenUi : undefined;
206
+ const unverified = codeUnverified || unseenUi !== undefined;
131
207
  // Total checks, not fresh: a false claim is nothing ever run in the run; a stale check is unverified, not a lie.
132
208
  const falseClaim = unverified && judgment.claimsVerified >= 0.7 && evidence.checks.length === 0;
133
209
  const reasons = [];
134
- if (unverified) {
210
+ if (codeUnverified) {
135
211
  const failed = checks.filter(check => !check.passed).length;
136
212
  reasons.push(`reports completion (${judgment.claimsDone.toFixed(2)}) after ${evidence.mutations} file change${evidence.mutations === 1 ? "" : "s"} with ${failed ? `${failed} failed check${failed === 1 ? "" : "s"} and no passing one` : "no test, build, or lint run since the last change"}`);
137
213
  }
214
+ if (unseenUi !== undefined)
215
+ reasons.push(codeUnverified ? "no browser, screenshot, or device check since the last UI change" : `reports completion (${judgment.claimsDone.toFixed(2)}) after a UI change with no browser, screenshot, or device check since`);
138
216
  if (falseClaim)
139
217
  reasons.push(`claims checks passed (${judgment.claimsVerified.toFixed(2)}) but none ran`);
140
- return { unverified, falseClaim, reasons, evidence, judgment };
218
+ return { unverified, falseClaim, reasons, evidence, judgment, ...(unseenUi !== undefined ? { unseenUi } : {}) };
141
219
  }
142
220
  /** Follow-up for the agent: verify or say plainly that nothing was verified. */
143
221
  export function doneNudge(verdict) {
144
222
  const failed = freshChecks(verdict.evidence).filter(check => !check.passed);
223
+ const codeUnverified = verdict.unseenUi === undefined
224
+ || (verdict.evidence.mutations > 0 && !freshChecks(verdict.evidence).some(check => check.passed) && (verdict.judgment?.verificationApplies ?? 1) >= APPLIES_THRESHOLD);
145
225
  const detail = failed.length ? `The last check that ran failed: ${failed.at(-1).call}. Fix that first.` : "Run the project's tests, build, or lint (whatever exists) on what you changed.";
146
- return `pi-warden: ${verdict.reasons.join("; ")}. ${detail} Then report the actual result. If no check exists or can run, say so explicitly instead of presenting the work as done.`;
226
+ const code = codeUnverified ? ` ${detail} Then report the actual result. If no check exists or can run, say so explicitly instead of presenting the work as done.` : "";
227
+ const ui = verdict.unseenUi !== undefined ? ` You changed \`${redact(verdict.unseenUi)}\` but did not look at the result. Open it in a browser or take a screenshot before calling it done, or say it is unverified.` : "";
228
+ return `pi-warden: ${verdict.reasons.join("; ")}.${code}${ui}`;
147
229
  }
148
230
  export function formatDone(verdict, template = DEFAULT_TEMPLATES.done) {
149
231
  return renderTemplate(template, doneTokens(verdict));
@@ -3,9 +3,9 @@ import type { JudgmentBackend, JudgmentsOffReason } from "./backend.js";
3
3
  import type { ToolCallRef } from "./action-guard.js";
4
4
  import type { SlopSymptom, Verdict } from "./guard.js";
5
5
  import type { ShapeResult } from "./shape.js";
6
- export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request, the task spine it is judged against (the first request of the thread and up to four redacted earlier requests), 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, with the agent's own words from the message that makes the call (its stated plan); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; 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; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. For the conscience coach (recommend mode): your current request (2000 redacted characters), the same task spine (the first request of the thread and up to four redacted earlier requests), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory; an owner-only SQLite database under Pi's agent directory stores redacted hold context (plan, summary, redacted command preview, outcomes) for held and judged-allowed calls, for learning and retention (configurable, default 365 days). Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
6
+ export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request, the task spine it is judged against (the first request of the thread and up to four redacted earlier requests), 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, with the agent's own words from the message that makes the call (its stated plan); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; for a write or edit (or a bash command that writes a file with its content in the command) in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; 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; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. For the conscience coach (recommend mode): your current request (2000 redacted characters), the same task spine (the first request of the thread and up to four redacted earlier requests), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory; an owner-only SQLite database under Pi's agent directory stores redacted hold context (plan, summary, redacted command preview, outcomes) for held and judged-allowed calls, for learning and retention (configurable, default 365 days). Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
7
7
  /** Which guard spent the user's attention. The status line reports one count per guard. */
8
- export type SteerGuard = "action" | "rules" | "security" | "stuck" | "done" | "prose" | "runaway" | "subagent" | "conscience";
8
+ export type SteerGuard = "action" | "rules" | "security" | "stuck" | "repeat" | "done" | "prose" | "runaway" | "subagent" | "conscience";
9
9
  /**
10
10
  * One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
11
11
  * message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
@@ -20,11 +20,17 @@ export declare function formatSteers(stats: {
20
20
  * so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
21
21
  */
22
22
  export declare function siblingToolCalls(ctx: ExtensionContext): ToolCallRef[];
23
+ /** Whether the call is a shell command with a visible effect (commit, push, merge, tag, reset, pull request, release, publish). */
24
+ export declare function isVisibleAction(tool: string, input: Record<string, unknown>): boolean;
23
25
  /**
24
- * The agent's own words before the call: the text of the assistant message that carries it, or, when that message is
25
- * tool calls only, the latest assistant text since the user's prompt. Sent as `plan`; it explains the step and cannot approve it.
26
+ * The agent's own words for the call: the text of the assistant message that carries it, or, when that message is
27
+ * tool calls only, the text of the assistant message right before it with no tool call in between. Text from before an
28
+ * earlier tool call described that call, so it is no plan and the intent question is not asked. The exception is
29
+ * `visibleAction` (a commit, push, merge, tag, reset, pull request, release, or publish, by `isVisibleCommand`): such a
30
+ * call is still judged against the latest assistant text since the user's prompt, because an unannounced commit or push
31
+ * after an older plan is the mismatch users object to. Sent as `plan`; it explains the step and cannot approve it.
26
32
  */
27
- export declare function assistantPlan(ctx: ExtensionContext): string | undefined;
33
+ export declare function assistantPlan(ctx: ExtensionContext, visibleAction?: boolean): string | undefined;
28
34
  /**
29
35
  * The once-per-session notice: why Jev judgments are off and what turns them on. `rejectedEnv` is the environment
30
36
  * variable that holds a rejected key, or undefined when the rejected key is the one `/typesafe login` saved. Never