pi-warden 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/extension.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { createTypeSafe, resolveApiKey } from "pi-typesafe";
2
2
  import { ensureApiKey } from "pi-typesafe/ui";
3
- import { applyUserOverrides, defaultConfig, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
4
- import { evaluateAction, formatVerdict } from "./guard.js";
5
- export const disclosure = "With TypeSafe judgments enabled, pi-warden sends the latest user request plus a redacted, truncated summary of each guarded bash, write, or edit call (command text, file path, content excerpt) to api.typesafe.ai before the tool runs. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
3
+ import { applyUserOverrides, defaultConfig, isMode, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
4
+ import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome } from "./done.js";
5
+ import { evaluateAction, formatVerdict, steerReason, textApproves } from "./guard.js";
6
+ import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
7
+ export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request 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; and the agent's final message when it reports completion without running checks. 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
8
  const WIDGET = PACKAGE_NAME;
7
9
  const CONFIRM_TEXT_LIMIT = 500;
8
- const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, confirmed: 0, blocked: 0, errors: 0 });
10
+ const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, slop: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, errors: 0 });
9
11
  function latestUserPrompt(ctx) {
10
12
  const entries = ctx.sessionManager.getBranch();
11
13
  for (let index = entries.length - 1; index >= 0; index--) {
@@ -19,14 +21,15 @@ function latestUserPrompt(ctx) {
19
21
  }
20
22
  return undefined;
21
23
  }
22
- function headlessPolicy(config) {
23
- const env = process.env.PI_WARDEN_HEADLESS?.trim();
24
- return env === "allow" || env === "block" ? env : config.headless;
24
+ function activeMode(config, hasUI) {
25
+ const env = process.env.PI_WARDEN_MODE?.trim();
26
+ const mode = isMode(env) ? env : config.mode;
27
+ return mode === "confirm" && !hasUI ? "steer" : mode;
25
28
  }
26
29
  function clip(text) {
27
30
  return text.length <= CONFIRM_TEXT_LIMIT ? text : `${text.slice(0, CONFIRM_TEXT_LIMIT)}…`;
28
31
  }
29
- function confirmMessage(verdict) {
32
+ export function confirmMessage(verdict) {
30
33
  const { summary } = verdict;
31
34
  const lines = [];
32
35
  if (summary.command !== undefined)
@@ -41,46 +44,96 @@ function confirmMessage(verdict) {
41
44
  lines.push("Judgments are model output, not authorization. Yes runs the tool; No blocks it and tells the agent.");
42
45
  return lines.join("\n");
43
46
  }
47
+ const callKey = (tool, input) => `${tool}\0${JSON.stringify(input)}`;
44
48
  /** Native Pi registration; importing the root library does not load this module. */
45
49
  export default function wardenExtension(pi) {
46
50
  let client;
47
51
  let budgetExhausted = false;
48
52
  let stats = freshStats();
49
- let lastVerdict;
53
+ const widget = new Map();
54
+ /** Calls held in steer mode, with the user prompt current at that time; a retry after the user replies asks Jev about approval. */
55
+ const held = new Map();
56
+ let attempts = new AttemptWindow(defaultConfig().stuck.window);
57
+ let evidence = emptyEvidence();
58
+ let doneNudged = false;
50
59
  const configFor = (ctx) => loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
51
60
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
52
61
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
53
62
  const judgeFor = (config) => {
54
63
  if (!consentGiven(config) || budgetExhausted || !resolveApiKey())
55
64
  return undefined;
56
- return client ??= createTypeSafe({ maxRequests: config.action.maxRequests, timeoutMs: config.action.timeoutMs });
65
+ return client ??= createTypeSafe({ maxRequests: config.maxRequests, timeoutMs: config.timeoutMs });
57
66
  };
67
+ const noteError = (ctx, message, code) => {
68
+ stats.errors++;
69
+ if (code === "budget")
70
+ budgetExhausted = true;
71
+ if (ctx.hasUI)
72
+ ctx.ui.notify(`warden: ${message}${budgetExhausted ? " Pattern checks continue without TypeSafe for the rest of this session." : ""}`, "warning");
73
+ };
74
+ const show = (ctx, guard, line) => {
75
+ widget.set(guard, line);
76
+ if (ctx.hasUI)
77
+ ctx.ui.setWidget(WIDGET, [...widget.values()]);
78
+ };
79
+ const steer = (content) => pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: true }, { deliverAs: "steer" });
58
80
  pi.on("session_start", async (_event, ctx) => {
59
81
  client = undefined;
60
82
  budgetExhausted = false;
61
83
  stats = freshStats();
62
- lastVerdict = undefined;
84
+ widget.clear();
85
+ held.clear();
86
+ attempts.reset();
87
+ evidence = emptyEvidence();
88
+ doneNudged = false;
63
89
  if (ctx.hasUI)
64
90
  ctx.ui.setWidget(WIDGET, undefined);
65
91
  });
92
+ // A new user prompt starts a new attempt history and a new done-check budget.
93
+ pi.on("before_agent_start", async (_event, ctx) => {
94
+ attempts = new AttemptWindow(configFor(ctx).stuck.window);
95
+ doneNudged = false;
96
+ });
97
+ // Each low-level run collects its own evidence of changes and checks.
98
+ pi.on("agent_start", async () => {
99
+ evidence = emptyEvidence();
100
+ });
66
101
  pi.on("tool_call", async (event, ctx) => {
67
102
  const config = configFor(ctx);
68
103
  if (!config.enabled || !config.action.enabled || !config.action.tools.includes(event.toolName))
69
104
  return;
70
105
  stats.inspected++;
71
- const verdict = await evaluateAction({ tool: event.toolName, input: event.input, cwd: ctx.cwd, task: latestUserPrompt(ctx) }, { config: config.action, judge: judgeFor(config), signal: ctx.signal });
72
- lastVerdict = verdict;
106
+ const task = latestUserPrompt(ctx);
107
+ const key = callKey(event.toolName, event.input);
108
+ const heldAt = held.get(key);
109
+ const retryAfterHold = held.has(key) && heldAt !== task;
110
+ const judge = judgeFor(config);
111
+ const verdict = await evaluateAction({ tool: event.toolName, input: event.input, cwd: ctx.cwd, task }, { config: config.action, judge, signal: ctx.signal, slop: config.slop, retryAfterHold });
112
+ if (verdict.source === "skipped")
113
+ return;
73
114
  if (verdict.judgment)
74
115
  stats.judged++;
75
- if (verdict.source === "error") {
76
- stats.errors++;
77
- if (verdict.errorCode === "budget")
78
- budgetExhausted = true;
116
+ if (verdict.source === "error")
117
+ noteError(ctx, verdict.error ?? "TypeSafe request failed.", verdict.errorCode);
118
+ // Offline stand-in for the approval question: the user has replied since the hold and the reply reads as approval.
119
+ if (retryAfterHold && !judge && verdict.level === "confirm" && textApproves(task)) {
120
+ verdict.level = "allow";
121
+ verdict.approvedByUser = true;
122
+ verdict.reasons = ["user approved in the latest message", ...verdict.reasons];
123
+ }
124
+ if (verdict.approvedByUser) {
125
+ stats.approved++;
126
+ held.delete(key);
127
+ }
128
+ if (verdict.source !== "read-only")
129
+ show(ctx, "action", formatVerdict(verdict));
130
+ if (verdict.slopReasons?.length) {
131
+ stats.slop++;
132
+ const where = verdict.summary.path ?? event.toolName;
79
133
  if (ctx.hasUI)
80
- ctx.ui.notify(`warden: ${verdict.error}${budgetExhausted ? " Pattern checks continue without TypeSafe for the rest of this session." : ""}`, "warning");
134
+ ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
135
+ steer(`pi-warden: the content just written to ${where} reads as ${verdict.slopReasons.join(" and ")}. Replace stubs and placeholders with working code, remove comments that restate the code, and keep only what the request needs. If something is intentionally left unimplemented, say so in your reply instead of leaving it in the code.`);
81
136
  }
82
- if (ctx.hasUI && verdict.source !== "read-only")
83
- ctx.ui.setWidget(WIDGET, [formatVerdict(verdict)]);
84
137
  if (verdict.level === "warn") {
85
138
  stats.warned++;
86
139
  if (ctx.hasUI)
@@ -89,29 +142,86 @@ export default function wardenExtension(pi) {
89
142
  }
90
143
  if (verdict.level !== "confirm")
91
144
  return undefined;
92
- stats.confirmed++;
145
+ const mode = activeMode(config, ctx.hasUI);
93
146
  const reasons = verdict.reasons.join("; ");
94
- if (ctx.hasUI) {
147
+ if (mode === "advise") {
148
+ stats.warned++;
149
+ if (ctx.hasUI)
150
+ ctx.ui.notify(`warden · ${event.toolName} (advise mode, not held): ${reasons}`, "warning");
151
+ return undefined;
152
+ }
153
+ if (mode === "confirm") {
95
154
  const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
96
155
  if (allowed)
97
156
  return undefined;
98
- stats.blocked++;
157
+ stats.held++;
99
158
  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.` };
100
159
  }
101
- if (headlessPolicy(config) === "allow")
102
- return undefined;
103
- stats.blocked++;
104
- return { block: true, reason: `pi-warden blocked this ${event.toolName} call because it needs user confirmation (${reasons}) and no user is present. Choose a safer approach or stop and report. Operators can set PI_WARDEN_HEADLESS=allow to permit such calls.` };
160
+ stats.held++;
161
+ held.set(key, task);
162
+ if (ctx.hasUI)
163
+ ctx.ui.notify(`warden · held ${event.toolName}: ${reasons}. The agent was told why and asked to re-plan or ask you.`, "warning");
164
+ return { block: true, reason: steerReason(verdict, { canApprove: judge !== undefined }) };
165
+ });
166
+ pi.on("tool_result", async (event, ctx) => {
167
+ const config = configFor(ctx);
168
+ if (!config.enabled)
169
+ return;
170
+ const failed = resultFailed(event.isError, event.details);
171
+ if (config.done.enabled)
172
+ recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed), event.input);
173
+ if (!config.stuck.enabled)
174
+ return;
175
+ attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
176
+ if (!attempts.shouldJudge(config.stuck))
177
+ return;
178
+ stats.stuckChecks++;
179
+ const verdict = await evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
180
+ if (verdict.error)
181
+ noteError(ctx, verdict.error, undefined);
182
+ if (verdict.source === "repeat" && !verdict.stuck)
183
+ return;
184
+ show(ctx, "stuck", formatStuck(verdict));
185
+ if (!verdict.stuck)
186
+ return;
187
+ stats.stuck++;
188
+ if (ctx.hasUI)
189
+ ctx.ui.notify(`warden · stuck: ${verdict.reasons.join("; ")}${config.stuck.nudge ? " (agent nudged)" : ""}`, "warning");
190
+ if (config.stuck.nudge)
191
+ steer(stuckNudge(verdict));
105
192
  });
106
- const actions = ["status", "enable", "disable", "config", "test"];
193
+ pi.on("agent_end", async (event, ctx) => {
194
+ const config = configFor(ctx);
195
+ if (!config.enabled || !config.done.enabled || doneNudged || !needsDoneCheck(evidence))
196
+ return;
197
+ const finalMessage = finalAssistantText(event.messages);
198
+ const judge = judgeFor(config);
199
+ if (!finalMessage || !judge)
200
+ return;
201
+ stats.doneChecks++;
202
+ const verdict = await evaluateDone(latestUserPrompt(ctx), finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal });
203
+ if (verdict.error)
204
+ noteError(ctx, verdict.error, undefined);
205
+ show(ctx, "done", formatDone(verdict));
206
+ if (!verdict.unverified)
207
+ return;
208
+ stats.unverified++;
209
+ if (ctx.hasUI)
210
+ ctx.ui.notify(`warden · done-check: ${verdict.reasons.join("; ")}${config.done.nudge ? " (agent asked to verify)" : ""}`, "warning");
211
+ if (config.done.nudge) {
212
+ doneNudged = true;
213
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content: doneNudge(verdict), display: true }, { deliverAs: "followUp", triggerTurn: true });
214
+ }
215
+ });
216
+ const actions = ["status", "enable", "disable", "mode", "config", "test"];
107
217
  pi.registerCommand("warden", {
108
- description: "pi-warden status, TypeSafe consent, config editor, and a synthetic guard test",
218
+ description: "pi-warden status, TypeSafe consent, steer/confirm/advise mode, config editor, and a synthetic guard test",
109
219
  getArgumentCompletions(prefix) {
110
220
  const matches = actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
111
221
  return matches.length ? matches : null;
112
222
  },
113
223
  async handler(args, ctx) {
114
- const action = args.trim() || "status";
224
+ const [action = "status", argument] = args.trim().split(/\s+/);
115
225
  const report = (text, level = "info") => {
116
226
  if (ctx.hasUI)
117
227
  ctx.ui.notify(text, level);
@@ -124,12 +234,13 @@ export default function wardenExtension(pi) {
124
234
  const key = resolveApiKey();
125
235
  const source = consentSource(config);
126
236
  const usage = client?.getUsage();
237
+ const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop"].filter(Boolean).join(", ");
127
238
  report([
128
- `pi-warden: ${config.enabled && config.action.enabled ? "guarding" : "off"} ${config.action.tools.join(", ")}; TypeSafe judgments ${source ? `enabled via ${source}` : "disabled (run /warden enable)"}; key ${key ? key.source === "stored" ? "stored (shared with pi-typesafe)" : "from TYPESAFE_API_KEY" : "missing (run /warden enable)"}.`,
129
- `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.confirmed} asked, ${stats.blocked} blocked, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.action.maxRequests} requests.`,
130
- `Thresholds: irreversible warn ${config.action.irreversible.warn} / confirm ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / confirm ${config.action.offTask.confirm}; failOpen ${config.action.failOpen}; headless ${headlessPolicy(config)}.`,
239
+ `pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `enabled via ${source}` : "disabled (run /warden enable)"}; key ${key ? key.source === "stored" ? "stored (shared with pi-typesafe)" : "from TYPESAFE_API_KEY" : "missing (run /warden enable)"}.`,
240
+ `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.slop} slop notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests.`,
241
+ `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / hold ${config.action.offTask.confirm}; stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop quality ${config.slop.quality}, stub ${config.slop.placeholder}; failOpen ${config.action.failOpen}.`,
131
242
  `Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
132
- lastVerdict ? `Last: ${formatVerdict(lastVerdict)}` : "No guarded tool calls yet this session.",
243
+ widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
133
244
  ].join(" "));
134
245
  return;
135
246
  }
@@ -157,6 +268,15 @@ export default function wardenExtension(pi) {
157
268
  report(`TypeSafe judgments disabled in ${path}. Offline pattern checks stay active; set enabled to false there to turn pi-warden off entirely.`);
158
269
  return;
159
270
  }
271
+ if (action === "mode") {
272
+ if (!isMode(argument)) {
273
+ report(`Mode is ${activeMode(config, ctx.hasUI)}${process.env.PI_WARDEN_MODE ? " (from PI_WARDEN_MODE)" : ""}. Use /warden mode steer | confirm | advise. steer holds risky calls and tells the agent why; confirm asks you with a dialog; advise only reports.`);
274
+ return;
275
+ }
276
+ const path = setUserSetting("mode", argument);
277
+ report(`Mode set to ${argument} in ${path}.`);
278
+ return;
279
+ }
160
280
  if (action === "config") {
161
281
  if (!ctx.hasUI) {
162
282
  report(`Edit ${userConfigPath()} directly. Defaults: ${JSON.stringify(defaultConfig())}`);
@@ -182,7 +302,7 @@ export default function wardenExtension(pi) {
182
302
  const path = writeUserConfig(parsed);
183
303
  const effective = applyUserOverrides(defaultConfig(), parsed);
184
304
  client = undefined;
185
- report(`Saved ${path}. Effective: guard ${effective.enabled && effective.action.enabled ? "on" : "off"}, TypeSafe ${effective.typesafe ? "on" : "off"}, tools ${effective.action.tools.join(", ")}, irreversible confirm ≥ ${effective.action.irreversible.confirm}, off-task confirm ≥ ${effective.action.offTask.confirm}.`);
305
+ report(`Saved ${path}. Effective: guard ${effective.enabled && effective.action.enabled ? "on" : "off"}, mode ${effective.mode}, TypeSafe ${effective.typesafe ? "on" : "off"}, tools ${effective.action.tools.join(", ")}, irreversible hold ≥ ${effective.action.irreversible.confirm}, off-task hold ≥ ${effective.action.offTask.confirm}, stuck ${effective.stuck.enabled ? "on" : "off"}, done-check ${effective.done.enabled ? "on" : "off"}, slop ${effective.slop.enabled ? "on" : "off"}.`);
186
306
  return;
187
307
  }
188
308
  if (action === "test") {
@@ -190,13 +310,17 @@ export default function wardenExtension(pi) {
190
310
  if (judge && ctx.hasUI && !await ctx.ui.confirm("Send one synthetic pi-warden test request?", `A synthetic action ("rm -rf /tmp/pi-warden-demo" for the task "Clean up the demo directory") goes to api.typesafe.ai and may incur charges. ${disclosure}`))
191
311
  return;
192
312
  const verdict = await evaluateAction({ tool: "bash", input: { command: "rm -rf /tmp/pi-warden-demo" }, cwd: ctx.cwd, task: "Clean up the demo directory" }, { config: { ...config.action, enabled: true, tools: ["bash"] }, judge });
193
- if (ctx.hasUI)
194
- ctx.ui.setWidget(WIDGET, [formatVerdict(verdict)]);
313
+ show(ctx, "action", formatVerdict(verdict));
195
314
  report(`${formatVerdict(verdict)}${verdict.reasons.length ? ` — ${verdict.reasons.join("; ")}` : ""}${judge ? "" : " (pattern checks only: TypeSafe judgments are not enabled or no key is configured)"}${verdict.error ? ` — ${verdict.error}` : ""}`);
196
- if (ctx.hasUI && verdict.level === "confirm") {
197
- // Show the same dialog a real call would get, so users can see what blocking looks like without touching anything.
198
- const allowed = await ctx.ui.confirm("warden: allow this bash call? (demo)", `${confirmMessage(verdict)}\n\nThis is /warden test: nothing runs either way.`);
199
- report(allowed ? "Demo: you chose Yes, so a real call would have run." : "Demo: you chose No, so a real call would have been blocked and the agent told why.");
315
+ if (verdict.level === "confirm") {
316
+ const mode = activeMode(config, ctx.hasUI);
317
+ if (mode === "confirm" && ctx.hasUI) {
318
+ const allowed = await ctx.ui.confirm("warden: allow this bash call? (demo)", `${confirmMessage(verdict)}\n\nThis is /warden test: nothing runs either way.`);
319
+ report(allowed ? "Demo: you chose Yes, so a real call would have run." : "Demo: you chose No, so a real call would have been blocked and the agent told why.");
320
+ }
321
+ else {
322
+ report(`In ${mode} mode a real call would ${mode === "advise" ? "run with this warning shown to you" : "be held and the agent would read"}: "${steerReason(verdict, { canApprove: judge !== undefined })}"`);
323
+ }
200
324
  }
201
325
  return;
202
326
  }
package/dist/guard.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { IntegrationErrorCode, TypeSafe } from "pi-typesafe";
2
- import type { ActionGuardConfig } from "./config.js";
2
+ import type { ActionGuardConfig, SlopGuardConfig } from "./config.js";
3
3
  export type Level = "allow" | "warn" | "confirm";
4
4
  export type Severity = "destructive" | "risky" | "sensitive";
5
5
  export interface PatternHit {
@@ -37,9 +37,17 @@ export interface Judgment {
37
37
  offTask: number;
38
38
  scope: ScopeLabel;
39
39
  scopeConfidence: number;
40
+ /** P(the latest user message approves this exact action); only asked when a previously held call is retried. */
41
+ approved?: number;
40
42
  model: string;
41
43
  elapsedMs: number;
42
44
  }
45
+ export interface SlopJudgment {
46
+ /** 0 focused … 2 sloppy. */
47
+ quality: number;
48
+ /** P(placeholder or stub where working code is needed). */
49
+ placeholder: number;
50
+ }
43
51
  export interface Verdict {
44
52
  level: Level;
45
53
  source: "skipped" | "read-only" | "pattern" | "typesafe" | "error";
@@ -48,6 +56,11 @@ export interface Verdict {
48
56
  /** Human-readable reasons without secrets or full commands. */
49
57
  reasons: string[];
50
58
  judgment?: Judgment;
59
+ slop?: SlopJudgment;
60
+ /** Set when the slop thresholds were crossed; the level itself is never raised by slop. */
61
+ slopReasons?: string[];
62
+ /** True when a previously held call was allowed because the user's latest message approves it. */
63
+ approvedByUser?: boolean;
51
64
  /** Safe TypeSafe error message when the judge could not answer. */
52
65
  error?: string;
53
66
  errorCode?: IntegrationErrorCode;
@@ -59,6 +72,10 @@ export interface EvaluateOptions {
59
72
  /** Omit to run offline pattern checks only (no consent, no network). */
60
73
  judge?: Judge | undefined;
61
74
  signal?: AbortSignal | undefined;
75
+ /** Adds quality questions for write/edit content to the same request. */
76
+ slop?: SlopGuardConfig | undefined;
77
+ /** This exact call was held earlier and the user has replied since: ask whether the reply approves it. */
78
+ retryAfterHold?: boolean | undefined;
62
79
  }
63
80
  export declare function matchPatterns(tool: string, input: Record<string, unknown>, cwd?: string): PatternHit[];
64
81
  export declare function isReadOnlyCommand(command: string): boolean;
@@ -73,12 +90,25 @@ export declare const questions: {
73
90
  readonly unclear: "`task` or `action` gives too little information to tell";
74
91
  }>;
75
92
  };
76
- export declare function buildRequest(summary: ActionSummary, task: string | undefined): {
93
+ export declare const slopQuestions: {
94
+ slop_quality: import("pi-typesafe").ScoreQuestion<readonly ["Focused: does what `task` needs, clear names, comments only where they add information", "Some filler: comments that restate the code, minor dead code, hedging or repeated text", "Sloppy: placeholder or stub code, TODO where a working implementation is needed, duplicated or commented-out logic, vague or contradictory text"]>;
95
+ slop_placeholder: import("pi-typesafe").NoulQuestion;
96
+ };
97
+ export declare const approvalQuestion: {
98
+ approved: import("pi-typesafe").NoulQuestion;
99
+ };
100
+ export declare function buildRequest(summary: ActionSummary, task: string | undefined, extras?: {
101
+ slop?: boolean;
102
+ approval?: boolean;
103
+ }): {
77
104
  state: {
78
105
  task: string;
79
106
  action: Record<string, string | number | boolean>;
80
107
  };
81
108
  questions: {
109
+ approved?: import("pi-typesafe").NoulQuestion;
110
+ slop_quality?: import("pi-typesafe").ScoreQuestion<readonly ["Focused: does what `task` needs, clear names, comments only where they add information", "Some filler: comments that restate the code, minor dead code, hedging or repeated text", "Sloppy: placeholder or stub code, TODO where a working implementation is needed, duplicated or commented-out logic, vague or contradictory text"]>;
111
+ slop_placeholder?: import("pi-typesafe").NoulQuestion;
82
112
  irreversible: import("pi-typesafe").NoulQuestion;
83
113
  off_task: import("pi-typesafe").NoulQuestion;
84
114
  scope: import("pi-typesafe").ChoiceQuestion<{
@@ -90,5 +120,14 @@ export declare function buildRequest(summary: ActionSummary, task: string | unde
90
120
  };
91
121
  };
92
122
  export declare function evaluateAction(action: ActionInput, options: EvaluateOptions): Promise<Verdict>;
123
+ /** Offline stand-in for the approval question when TypeSafe is not available. */
124
+ export declare function textApproves(task: string | undefined): boolean;
125
+ /**
126
+ * The text the agent receives when a call is held. It explains the judgment and the two acceptable next moves,
127
+ * so the model re-plans instead of retrying. Contains no command text (the model already has it) and no secrets.
128
+ */
129
+ export declare function steerReason(verdict: Verdict, options: {
130
+ canApprove: boolean;
131
+ }): string;
93
132
  /** One-line rendering for widgets and logs. Includes no command text. */
94
133
  export declare function formatVerdict(verdict: Verdict): string;
package/dist/guard.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, relative, resolve, sep } from "node:path";
4
- import { choice, noul, TypeSafeIntegrationError } from "pi-typesafe";
4
+ import { choice, noul, score, TypeSafeIntegrationError } from "pi-typesafe";
5
5
  import { redact } from "./redact.js";
6
6
  const LEVEL_RANK = { allow: 0, warn: 1, confirm: 2 };
7
7
  const higher = (a, b) => (LEVEL_RANK[a] >= LEVEL_RANK[b] ? a : b);
8
8
  const TASK_LIMIT = 1500;
9
9
  const COMMAND_LIMIT = 2000;
10
- const EXCERPT_LIMIT = 600;
11
- const EDIT_LIMIT = 200;
10
+ const EXCERPT_LIMIT = 1500;
11
+ const EDIT_LIMIT = 400;
12
12
  function truncate(text, limit) {
13
13
  return text.length <= limit ? text : `${text.slice(0, limit)}… [${text.length - limit} more chars]`;
14
14
  }
@@ -186,7 +186,7 @@ export function describeAction(tool, input, cwd) {
186
186
  return summary;
187
187
  }
188
188
  // ---------------------------------------------------------------------------
189
- // TypeSafe request: named state fields, three independent questions.
189
+ // TypeSafe request: named state fields, independent questions. Slop and approval questions join the same request.
190
190
  export const questions = {
191
191
  irreversible: noul("Would executing `action` destroy, overwrite, or discard data or state that could not be recovered afterwards?", {
192
192
  true: "Yes: it force-pushes or rewrites shared history, deletes untracked or uncommitted work, drops or truncates database objects, overwrites files outside the project, publishes or deploys, or removes something no build or checkout can regenerate.",
@@ -203,13 +203,31 @@ export const questions = {
203
203
  unclear: "`task` or `action` gives too little information to tell",
204
204
  }),
205
205
  };
206
- export function buildRequest(summary, task) {
206
+ export const slopQuestions = {
207
+ slop_quality: score("How does the new code or text that `action` writes read as a change for `task`?", [
208
+ "Focused: does what `task` needs, clear names, comments only where they add information",
209
+ "Some filler: comments that restate the code, minor dead code, hedging or repeated text",
210
+ "Sloppy: placeholder or stub code, TODO where a working implementation is needed, duplicated or commented-out logic, vague or contradictory text",
211
+ ]),
212
+ slop_placeholder: noul("Does the content `action` writes leave placeholder, stub, mock, or 'implement later' code where `task` needs a working implementation?"),
213
+ };
214
+ export const approvalQuestion = {
215
+ approved: noul("Does `task` (the user's latest message) explicitly approve running `action`, which was held earlier for the user's decision?", {
216
+ true: "Yes: the message says to go ahead with this action or with the deletion, push, reset, or change it performs.",
217
+ false: "No: the message declines, asks for something else, changes the approach, or does not address this action.",
218
+ }),
219
+ };
220
+ function hasContent(summary) {
221
+ return (summary.excerpt?.trim().length ?? 0) > 0 || (summary.edits?.some(edit => edit.newText.trim().length > 0) ?? false);
222
+ }
223
+ export function buildRequest(summary, task, extras = {}) {
224
+ const wantSlop = extras.slop && (summary.tool === "write" || summary.tool === "edit") && hasContent(summary);
207
225
  return {
208
226
  state: {
209
227
  task: task?.trim() ? truncate(task.trim(), TASK_LIMIT) : "(no user request recorded in this session)",
210
228
  action: summary,
211
229
  },
212
- questions,
230
+ questions: { ...questions, ...(wantSlop ? slopQuestions : {}), ...(extras.approval ? approvalQuestion : {}) },
213
231
  };
214
232
  }
215
233
  function combineSignals(timeoutMs, signal) {
@@ -217,6 +235,7 @@ function combineSignals(timeoutMs, signal) {
217
235
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
218
236
  }
219
237
  const percent = (value) => value.toFixed(2);
238
+ const APPROVAL_THRESHOLD = 0.7;
220
239
  // ---------------------------------------------------------------------------
221
240
  export async function evaluateAction(action, options) {
222
241
  const { config, judge } = options;
@@ -247,15 +266,19 @@ export async function evaluateAction(action, options) {
247
266
  if (!judge)
248
267
  return { level, source: "pattern", summary, patterns, reasons };
249
268
  try {
250
- const result = await judge.evaluate(buildRequest(summary, action.task), { signal: combineSignals(config.timeoutMs, options.signal) });
269
+ const request = buildRequest(summary, action.task, { slop: options.slop?.enabled ?? false, approval: options.retryAfterHold ?? false });
270
+ const result = await judge.evaluate(request, { signal: combineSignals(config.timeoutMs, options.signal) });
271
+ const answers = result.answers;
251
272
  const judgment = {
252
- irreversible: result.answers.irreversible.noul,
253
- offTask: result.answers.off_task.noul,
254
- scope: result.answers.scope.choice,
255
- scopeConfidence: result.answers.scope.confidence,
273
+ irreversible: answers.irreversible.noul,
274
+ offTask: answers.off_task.noul,
275
+ scope: answers.scope.choice,
276
+ scopeConfidence: answers.scope.confidence,
256
277
  model: result.model,
257
278
  elapsedMs: result.elapsedMs,
258
279
  };
280
+ if (typeof answers.approved?.noul === "number")
281
+ judgment.approved = answers.approved.noul;
259
282
  if (judgment.irreversible >= config.irreversible.confirm) {
260
283
  level = higher(level, "confirm");
261
284
  reasons.push(`irreversible ${percent(judgment.irreversible)}`);
@@ -272,7 +295,23 @@ export async function evaluateAction(action, options) {
272
295
  level = higher(level, "warn");
273
296
  reasons.push(`off-task ${percent(judgment.offTask)} (${judgment.scope.replace(/_/g, " ")})`);
274
297
  }
275
- return { level, source: "typesafe", summary, patterns, reasons, judgment };
298
+ const verdict = { level, source: "typesafe", summary, patterns, reasons, judgment };
299
+ if (options.slop?.enabled && typeof answers.slop_quality?.score === "number" && typeof answers.slop_placeholder?.noul === "number") {
300
+ verdict.slop = { quality: answers.slop_quality.score, placeholder: answers.slop_placeholder.noul };
301
+ const slopReasons = [];
302
+ if (verdict.slop.quality >= options.slop.quality)
303
+ slopReasons.push(`quality ${verdict.slop.quality.toFixed(2)}/2 (filler or sloppy)`);
304
+ if (verdict.slop.placeholder >= options.slop.placeholder)
305
+ slopReasons.push(`placeholder or stub code ${percent(verdict.slop.placeholder)}`);
306
+ if (slopReasons.length)
307
+ verdict.slopReasons = slopReasons;
308
+ }
309
+ if (level === "confirm" && judgment.approved !== undefined && judgment.approved >= APPROVAL_THRESHOLD) {
310
+ verdict.level = "allow";
311
+ verdict.approvedByUser = true;
312
+ verdict.reasons = [`user approved in the latest message (${percent(judgment.approved)})`, ...reasons];
313
+ }
314
+ return verdict;
276
315
  }
277
316
  catch (error) {
278
317
  const known = error instanceof TypeSafeIntegrationError ? error : undefined;
@@ -287,13 +326,37 @@ export async function evaluateAction(action, options) {
287
326
  return { level, source: "error", summary, patterns, reasons, error: message, ...(known ? { errorCode: known.code } : {}) };
288
327
  }
289
328
  }
329
+ /** Offline stand-in for the approval question when TypeSafe is not available. */
330
+ export function textApproves(task) {
331
+ return /\b(?:yes|yep|yeah|go ahead|do it|proceed|approved?|confirm(?:ed)?|ok(?:ay)?|sure|please do|run it)\b/i.test(task ?? "") && !/\b(?:no|don't|do not|stop|wait|instead|not)\b/i.test(task ?? "");
332
+ }
333
+ /**
334
+ * The text the agent receives when a call is held. It explains the judgment and the two acceptable next moves,
335
+ * so the model re-plans instead of retrying. Contains no command text (the model already has it) and no secrets.
336
+ */
337
+ export function steerReason(verdict, options) {
338
+ const what = verdict.reasons.join("; ");
339
+ const lines = [
340
+ `pi-warden held this ${verdict.summary.tool} call before it ran: ${what}.`,
341
+ "Do not retry it unchanged. Either (1) reach the goal with a recoverable alternative that stays inside the project (a targeted path, a dry run, a move instead of a delete, a normal push), or (2) if this exact action is genuinely required, stop and tell the user in one or two sentences what it does, what cannot be undone, and why it is needed, then wait for their reply.",
342
+ ];
343
+ if (options.canApprove)
344
+ lines.push("If the user's reply approves it, retry the same call and pi-warden will let it through.");
345
+ else
346
+ lines.push("pi-warden allows the same call again once the user has replied with approval.");
347
+ return lines.join(" ");
348
+ }
290
349
  /** One-line rendering for widgets and logs. Includes no command text. */
291
350
  export function formatVerdict(verdict) {
292
351
  const parts = [`warden · ${verdict.summary.tool}`];
293
352
  if (verdict.judgment)
294
353
  parts.push(`irreversible ${percent(verdict.judgment.irreversible)}`, `off-task ${percent(verdict.judgment.offTask)}`, verdict.judgment.scope.replace(/_/g, " "));
354
+ if (verdict.slop)
355
+ parts.push(`slop ${verdict.slop.quality.toFixed(1)}/2`, `stub ${percent(verdict.slop.placeholder)}`);
295
356
  if (verdict.patterns.length)
296
357
  parts.push(`patterns: ${verdict.patterns.map(hit => hit.id).join(", ")}`);
358
+ if (verdict.approvedByUser)
359
+ parts.push("user approved");
297
360
  if (verdict.source === "error")
298
361
  parts.push("typesafe error");
299
362
  if (verdict.source === "read-only")
package/dist/index.d.ts CHANGED
@@ -1,5 +1,9 @@
1
- export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, questions } from "./guard.js";
2
- export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, Level, PatternHit, ScopeLabel, Severity, Verdict } from "./guard.js";
1
+ export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, textApproves, questions, slopQuestions, approvalQuestion } from "./guard.js";
2
+ export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, SlopJudgment, Level, PatternHit, ScopeLabel, Severity, Verdict } from "./guard.js";
3
+ export { AttemptWindow, makeAttempt, resultFailed, resultText, evaluateStuck, buildStuckRequest, stuckQuestions, stuckNudge, formatStuck } from "./stuck.js";
4
+ export type { Attempt, StuckJudgment, StuckVerdict, StuckOptions } from "./stuck.js";
5
+ export { classifyToolResult, recordOutcome, emptyEvidence, needsDoneCheck, finalAssistantText, evaluateDone, buildDoneRequest, doneQuestions, doneNudge, formatDone } from "./done.js";
6
+ export type { ToolOutcome, RunEvidence, DoneJudgment, DoneVerdict, DoneOptions } from "./done.js";
3
7
  export { redact } from "./redact.js";
4
- export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
5
- export type { ActionGuardConfig, Threshold, WardenConfig, LoadOptions } from "./config.js";
8
+ export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
9
+ export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
- export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, questions } from "./guard.js";
1
+ export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, textApproves, questions, slopQuestions, approvalQuestion } from "./guard.js";
2
+ export { AttemptWindow, makeAttempt, resultFailed, resultText, evaluateStuck, buildStuckRequest, stuckQuestions, stuckNudge, formatStuck } from "./stuck.js";
3
+ export { classifyToolResult, recordOutcome, emptyEvidence, needsDoneCheck, finalAssistantText, evaluateDone, buildDoneRequest, doneQuestions, doneNudge, formatDone } from "./done.js";
2
4
  export { redact } from "./redact.js";
3
- export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
5
+ export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";