pi-warden 0.34.1 → 0.36.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/CHANGELOG.md CHANGED
@@ -8,6 +8,19 @@ How to keep this current: add the entry in the same pull request as the change,
8
8
 
9
9
  <!-- Empty. Next release starts here. -->
10
10
 
11
+ ## 0.36.0
12
+
13
+ ### Changed
14
+ - The resolved rules file content no longer rides the action request when the rules guard is off. `rules.enabled: false` now means no rules file content leaves the machine at all; with the guard on, the request is unchanged. `EvaluateOptions.rules` and `InspectOptions.rules` carry the switch, and a library caller that omits it keeps the earlier behaviour. The disclosure and `docs/data-handling.md` say so.
15
+
16
+ ### Docs
17
+ - `docs/configuration.md`: `Recipe: security work` — what each guard sends off the machine and what `/warden disable` leaves behind, a local-only user profile, a lab/CTF project profile with the exemptions security work needs, and an out-of-scope deny rule. README privacy paragraph points to it.
18
+
19
+ ## 0.35.0
20
+
21
+ ### Added
22
+ - Stuck-loop diff: when the stuck detector marks a repeated failed attempt, the agent sees a short unified line diff against the previous output instead of the full repeated output again. Byte-identical outputs get a one-line note. The diff note never grows the result. Config keys `stuck.diffLimit` (3000) and `stuck.tailLimit` (1000) control the diff and tail caps.
23
+
11
24
  ## 0.34.1
12
25
 
13
26
  ### Fixed
package/README.md CHANGED
@@ -82,7 +82,7 @@ These are project-maintained benchmarks, not universal claims. Raw reports and r
82
82
 
83
83
  ## Privacy
84
84
 
85
- Depending on the configured judgment backend, Warden sends to your provider: a redacted summary of each guarded call, the agent's plan, up to eight redacted prior messages, and redacted tool-output samples. Secrets are stripped before anything leaves the machine. [Full details.](docs/data-handling.md)
85
+ Depending on the configured judgment backend, Warden sends to your provider: a redacted summary of each guarded call, the agent's plan, up to eight redacted prior messages, and redacted tool-output samples. Secrets are stripped before anything leaves the machine. [Full details.](docs/data-handling.md) Security work — pentesting, incident response, CTF — has its own configuration recipe for keeping samples local and for the friction that credentials and lab setups cause: [Recipe: security work](docs/configuration.md#recipe-security-work).
86
86
 
87
87
  ## Documentation
88
88
 
@@ -1,4 +1,4 @@
1
- import type { ActionGuardConfig, SecurityConfig, SlopGuardConfig } from "./config.js";
1
+ import type { ActionGuardConfig, RulesConfig, SecurityConfig, SlopGuardConfig } from "./config.js";
2
2
  import type { PreviousAction, TaskMessage, Verdict } from "./guard.js";
3
3
  import type { Judge } from "pi-typesafe";
4
4
  /** One tool call as the agent proposed it. `id` is Pi's tool call id, stable across hooks and retries. */
@@ -25,6 +25,8 @@ export interface InspectOptions {
25
25
  signal?: AbortSignal | undefined;
26
26
  slop?: SlopGuardConfig | undefined;
27
27
  security?: SecurityConfig | undefined;
28
+ /** The rules guard's switch; off keeps the rules file content out of the action request. Omitted, it is sent as before. */
29
+ rules?: Pick<RulesConfig, "enabled"> | undefined;
28
30
  /** Calls allowed in the previous turn; the regret question about them rides this call's request, never a sibling's. */
29
31
  previousActions?: readonly PreviousAction[] | undefined;
30
32
  }
@@ -20,7 +20,7 @@ export class ActionGuard {
20
20
  const { task } = conversation;
21
21
  // A hold happened under an earlier prompt and the user has since replied: ask whether the reply approves this action.
22
22
  const retryAfterHold = this.holdPending && this.lastHoldPrompt !== task;
23
- const judgeCall = (tool, input, previousActions) => evaluateAction({ tool, input, cwd: options.cwd, task, context: conversation.context, plan: conversation.plan }, { config: options.config, judge: options.judge, signal: options.signal, slop: options.slop, security: options.security, retryAfterHold, previousActions });
23
+ const judgeCall = (tool, input, previousActions) => evaluateAction({ tool, input, cwd: options.cwd, task, context: conversation.context, plan: conversation.plan }, { config: options.config, judge: options.judge, signal: options.signal, slop: options.slop, security: options.security, rules: options.rules, retryAfterHold, previousActions });
24
24
  // A retry after a hold stays sequential because an approval consumed by one sibling changes the question for the next.
25
25
  if (options.judge && !retryAfterHold) {
26
26
  for (const sibling of conversation.siblings ?? []) {
package/dist/config.d.ts CHANGED
@@ -127,6 +127,10 @@ 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
+ /** Maximum characters of the unified line diff in a stuck-loop diff note. */
131
+ diffLimit: number;
132
+ /** Characters of the current output tail shown after the diff in a stuck-loop diff note. */
133
+ tailLimit: number;
130
134
  }
131
135
  export interface DoneGuardConfig {
132
136
  enabled: boolean;
package/dist/config.js CHANGED
@@ -39,7 +39,7 @@ export function defaultConfig() {
39
39
  escalationThreshold: 0.85,
40
40
  floor: "evidence",
41
41
  },
42
- stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true },
42
+ stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true, diffLimit: 3000, tailLimit: 1000 },
43
43
  done: { enabled: true, claimsDone: 0.7, nudge: true },
44
44
  slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
45
45
  security: { enabled: true, threshold: 0.7 },
@@ -284,6 +284,8 @@ function applyStuck(base, raw) {
284
284
  sameStrategy: probability(raw.sameStrategy, base.sameStrategy),
285
285
  churnThreshold: Math.min(window, positiveInteger(raw.churnThreshold, base.churnThreshold)),
286
286
  nudge: boolean(raw.nudge, base.nudge),
287
+ diffLimit: positiveInteger(raw.diffLimit, base.diffLimit),
288
+ tailLimit: positiveInteger(raw.tailLimit, base.tailLimit),
287
289
  };
288
290
  }
289
291
  function applyRunaway(base, raw) {
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import type { ToolCallRef } from "./action-guard.js";
3
3
  import type { SlopSymptom, Verdict } from "./guard.js";
4
4
  import type { ShapeResult } from "./shape.js";
5
- export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, 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, including calls where the rules guard is disabled; 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. 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.";
5
+ export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, 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. 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
6
  /** Which guard spent the user's attention. The status line reports one count per guard. */
7
7
  export type SteerGuard = "action" | "rules" | "security" | "stuck" | "done" | "prose" | "runaway" | "subagent";
8
8
  /**
package/dist/extension.js CHANGED
@@ -25,14 +25,14 @@ import { writeStarterRules, buildInitPrompt } from "./init.js";
25
25
  import { buildAuditPrompt, findProjects, snapshotReport, reportOutcome } from "./audit.js";
26
26
  import { detectNotifier, sendNotification } from "./notify.js";
27
27
  import { formatRunaway, RunawayMonitor, runawayNudge } from "./runaway.js";
28
- import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
28
+ import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckDiff, stuckNudge } from "./stuck.js";
29
29
  import { openConfigPanel, openTracePanel } from "./panel.js";
30
30
  import { completeConfig, shapeWarning } from "./shape.js";
31
31
  import { ContextLedger, formatLedger } from "./saver.js";
32
32
  import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
33
33
  import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
34
34
  import { actionTokens, DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, SENTENCE_TEMPLATES, statusWidget, TOKEN_NAMES } from "./widget.js";
35
- export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, 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, including calls where the rules guard is disabled; 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. 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.";
35
+ export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, 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. 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.";
36
36
  const WIDGET = PACKAGE_NAME;
37
37
  const CONFIRM_TEXT_LIMIT = 500;
38
38
  const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offTask: 0, slop: 0, ruleChecks: 0, ruleViolations: 0, pathNotes: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, runaway: 0, errors: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
@@ -250,6 +250,8 @@ export default function wardenExtension(pi) {
250
250
  // Allowed calls of the turn the user just replied to; the regret question about them rides the next action request.
251
251
  let regretCandidates = [];
252
252
  let attempts = new AttemptWindow(defaultConfig().stuck.window);
253
+ /** Full output text and saved path keyed by attempt call key, for stuck-loop diffs. */
254
+ let fullOutputs = new Map();
253
255
  let evidence = emptyEvidence();
254
256
  let doneNudged = false;
255
257
  let warnedFallback = false;
@@ -595,6 +597,7 @@ export default function wardenExtension(pi) {
595
597
  lastUi = ctx.ui;
596
598
  const config = configFor(ctx);
597
599
  attempts = new AttemptWindow(config.stuck.window);
600
+ fullOutputs = new Map();
598
601
  doneNudged = false;
599
602
  steersThisRun = 0;
600
603
  finals.reset();
@@ -698,7 +701,7 @@ export default function wardenExtension(pi) {
698
701
  ? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
699
702
  : undefined;
700
703
  rulesCheck?.catch(() => undefined);
701
- const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, previousActions: regretCandidates.length ? regretCandidates : undefined });
704
+ const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined });
702
705
  if (verdict.source === "skipped")
703
706
  return;
704
707
  // Arming check: if any armed rule's command regex matches this call, inject a hit into the verdict.
@@ -1129,11 +1132,53 @@ export default function wardenExtension(pi) {
1129
1132
  `output sample: ${redact(text).slice(0, 300)}`, delivered ? `agent told: ${notice}` : `steer recorded, not delivered (a repeat or the per-run budget): ${notice}`,
1130
1133
  ]);
1131
1134
  }
1135
+ // Stuck-loop diff: when the verdict is stuck on a failed result and a previous failed output
1136
+ // for the same call is available, replace the tool result with a short diff note. Compute
1137
+ // bytesSaved against the current content (after duplicate detection and compression), so the
1138
+ // diff never grows the result.
1139
+ const verdict = await stuckCheck;
1140
+ if (verdict && verdict.stuck && failed && !verdict.successRepeat && !verdict.churn && text.length > 0) {
1141
+ const currentKey = attempts.attempts.at(-1)?.key;
1142
+ const prevEntry = currentKey ? fullOutputs.get(currentKey) : undefined;
1143
+ if (currentKey && prevEntry && prevEntry.text !== text) {
1144
+ // Differing outputs: save the current output and diff against the previous one.
1145
+ try {
1146
+ const saved = await saveOutput(text);
1147
+ const diffNote = stuckDiff(prevEntry.text, text, { diffLimit: config.stuck.diffLimit, tailLimit: config.stuck.tailLimit, fullPath: saved });
1148
+ const currentLen = Buffer.byteLength(content.map(part => part.type === "text" ? part.text : "").join("\n"));
1149
+ if (Buffer.byteLength(diffNote) < currentLen) {
1150
+ content = [{ type: "text", text: diffNote }, ...content.filter(part => part.type !== "text")];
1151
+ }
1152
+ fullOutputs.set(currentKey, { text, path: saved });
1153
+ }
1154
+ catch {
1155
+ noteError(ctx, "Could not store full output for stuck diff; keeping it unchanged.", undefined);
1156
+ fullOutputs.set(currentKey, { text });
1157
+ }
1158
+ }
1159
+ else if (currentKey && prevEntry && prevEntry.text === text && prevEntry.path) {
1160
+ // Byte-identical outputs: one-line note reusing the existing saved path.
1161
+ const diffNote = stuckDiff(text, text, { diffLimit: config.stuck.diffLimit, tailLimit: config.stuck.tailLimit, fullPath: prevEntry.path });
1162
+ const currentLen = Buffer.byteLength(content.map(part => part.type === "text" ? part.text : "").join("\n"));
1163
+ if (Buffer.byteLength(diffNote) < currentLen) {
1164
+ content = [{ type: "text", text: diffNote }, ...content.filter(part => part.type !== "text")];
1165
+ }
1166
+ }
1167
+ else if (currentKey) {
1168
+ // First failed output for this call, or no previous entry yet: store for future diffs.
1169
+ fullOutputs.set(currentKey, { text });
1170
+ }
1171
+ }
1172
+ else if (failed && text.length > 0) {
1173
+ // Not stuck (or no verdict yet): store the output so a future repeat can diff against it.
1174
+ const currentKey = attempts.attempts.at(-1)?.key;
1175
+ if (currentKey)
1176
+ fullOutputs.set(currentKey, { text });
1177
+ }
1132
1178
  const patch = content === event.content ? undefined : { content };
1133
1179
  // Checks use the original result, not the excerpts or security banner.
1134
1180
  if (config.done.enabled)
1135
1181
  recordDoneOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
1136
- const verdict = await stuckCheck;
1137
1182
  if (!verdict)
1138
1183
  return patch;
1139
1184
  if (verdict.error)
@@ -1146,7 +1191,7 @@ export default function wardenExtension(pi) {
1146
1191
  return patch;
1147
1192
  stats.stuck++;
1148
1193
  if (ctx.hasUI && config.notices)
1149
- ctx.ui.notify(`warden · stuck: ${verdict.reasons.join("; ")}${nudge ? " (agent nudged)" : ""}`, "warning");
1194
+ ctx.ui.notify(`warden \u00b7 stuck: ${verdict.reasons.join("; ")}${nudge ? " (agent nudged)" : ""}`, "warning");
1150
1195
  if (nudge)
1151
1196
  steer(config, "stuck", nudge);
1152
1197
  return patch;
@@ -1513,7 +1558,7 @@ export default function wardenExtension(pi) {
1513
1558
  const judge = judgeFor(config);
1514
1559
  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 "Prepare the demo environment") goes to ${backendHost(config.typesafeBackend)} and may incur charges. ${disclosureFor(config.typesafeBackend, disclosure)}`))
1515
1560
  return;
1516
- const verdict = await evaluateAction({ tool: "bash", input: { command: "rm -rf /tmp/pi-warden-demo" }, cwd: ctx.cwd, task: "Prepare the demo environment" }, { config: { ...config.action, enabled: true, tools: ["bash"] }, judge });
1561
+ const verdict = await evaluateAction({ tool: "bash", input: { command: "rm -rf /tmp/pi-warden-demo" }, cwd: ctx.cwd, task: "Prepare the demo environment" }, { config: { ...config.action, enabled: true, tools: ["bash"] }, judge, rules: config.rules });
1517
1562
  const deliveryVerdict = ctx.hasUI ? verdict : agentDeliveryVerdict(verdict);
1518
1563
  const deliveryReasons = deliveryVerdict.reasons;
1519
1564
  const fmt = formatVerdictTokens(verdict, config.widget.action);
package/dist/guard.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { IntegrationErrorCode, Judge, Questions } from "pi-typesafe";
2
- import type { ActionGuardConfig, ArmingRule, CommandRule, PathRule, SecurityConfig, SlopGuardConfig } from "./config.js";
2
+ import type { ActionGuardConfig, ArmingRule, CommandRule, PathRule, RulesConfig, SecurityConfig, SlopGuardConfig } from "./config.js";
3
3
  export type Level = "allow" | "warn" | "confirm" | "deny";
4
4
  export type Severity = "destructive" | "risky" | "sensitive" | "deny";
5
5
  export type ViolationSource = "pattern" | "rules-guard" | "security-guard" | "slop-guard";
@@ -170,6 +170,12 @@ export interface EvaluateOptions {
170
170
  /** Adds quality questions for write/edit content to the same request. */
171
171
  slop?: SlopGuardConfig | undefined;
172
172
  security?: SecurityConfig | undefined;
173
+ /**
174
+ * The rules guard's switch. The active rules file rides every judged action request, so `enabled: false` is what keeps
175
+ * that content on this machine. Omitted, the file is sent as before, so a library caller that passes no rules config
176
+ * keeps today's behaviour.
177
+ */
178
+ rules?: Pick<RulesConfig, "enabled"> | undefined;
173
179
  /** This exact call was held earlier and the user has replied since: ask whether the reply approves it. */
174
180
  retryAfterHold?: boolean | undefined;
175
181
  /** Calls allowed in the previous turn: ask whether the user's latest message regrets one of them (rides this request). */
package/dist/guard.js CHANGED
@@ -876,8 +876,9 @@ export async function evaluateAction(action, options) {
876
876
  }
877
877
  if (!judge)
878
878
  return withPlan({ level, source: "pattern", summary, patterns, reasons });
879
- // Resolve the rules file once per call for the Jev request state.
880
- const resolved = resolveRulesFile(action.cwd);
879
+ // Resolve the rules file once per call for the Jev request state. With the rules guard off, no rules content leaves
880
+ // the machine at all: no question names the field, so an absent one costs nothing.
881
+ const resolved = options.rules?.enabled === false ? null : resolveRulesFile(action.cwd);
881
882
  const floorHits = builtInHits.length ? builtInHits.join("; ") : "none";
882
883
  const request = buildRequest(summary, action.task, { slop: options.slop?.enabled ?? false, approval: options.retryAfterHold ?? false, security: options.security?.enabled ?? false, context: action.context, previousActions: options.previousActions, plan, questions: options.questions, rules: resolved?.content, rulesSource: resolved?.source, violations: remainingViolations, floorHits });
883
884
  const result = await ask(judge, request, { timeoutMs: config.timeoutMs, ...(options.signal ? { signal: options.signal } : {}) });
package/dist/shape.js CHANGED
@@ -31,7 +31,7 @@ export function completeConfig(loaded) {
31
31
  notices: source.notices ?? false,
32
32
  steerBudget: typeof source.steerBudget === "number" && source.steerBudget >= 0 ? source.steerBudget : 3,
33
33
  action: section("action", { ...off, tools: [], failOpen: true, timeoutMs: 5000, irreversible: { warn: 1, confirm: 1 }, offTask: { warn: 1, steer: 1 }, intentMismatch: 1, visibleMismatch: 1, shouldProceed: { hold: 0.6, steer: false }, feedbackLog: false, commandRules: [], commandDenyRules: [], exemptRules: [], pathRules: [], armingRules: [], escalationThreshold: 0.85, floor: "evidence" }),
34
- stuck: section("stuck", { ...off, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 1, churnThreshold: 5, nudge: false }),
34
+ stuck: section("stuck", { ...off, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 1, churnThreshold: 5, nudge: false, diffLimit: 3000, tailLimit: 1000 }),
35
35
  done: section("done", { ...off, claimsDone: 1, nudge: false }),
36
36
  slop: section("slop", { ...off, threshold: 1, prose: proseOff() }),
37
37
  security: section("security", { ...off, threshold: 1 }),
package/dist/stuck.d.ts CHANGED
@@ -102,4 +102,13 @@ export declare function evaluateStuck(window: AttemptWindow, task: string | unde
102
102
  /** Steering text for the agent. Names the pattern and asks for a change of method, not another retry. A successful
103
103
  * repeat is a different disease than a failure loop: the model already has the answer, so it should use it. */
104
104
  export declare function stuckNudge(verdict: StuckVerdict): string;
105
+ /**
106
+ * Short note for a stuck-loop repeated failure: header, capped unified diff against the previous output,
107
+ * tail of the current output, and the path of the full copy. All inputs must be pre-redacted.
108
+ */
109
+ export declare function stuckDiff(previous: string, current: string, options: {
110
+ diffLimit: number;
111
+ tailLimit: number;
112
+ fullPath: string;
113
+ }): string;
105
114
  export declare function formatStuck(verdict: StuckVerdict, template?: string): string;
package/dist/stuck.js CHANGED
@@ -177,6 +177,107 @@ export function stuckNudge(verdict) {
177
177
  }
178
178
  return `pi-warden: ${verdict.reasons.join("; ")}. Stop retrying. Re-read the last error output carefully, state a new hypothesis about the cause, and either gather the missing information (read the relevant file, check versions or paths) or try a different method. If two different methods have failed, report the blocker to the user with the exact error instead of trying again.`;
179
179
  }
180
+ /** LCS-based line diff between two outputs, capped at diffLimit characters. Both inputs must be pre-redacted. */
181
+ function lineDiff(previous, current, diffLimit) {
182
+ const prevLines = previous.split("\n");
183
+ const currLines = current.split("\n");
184
+ const m = prevLines.length;
185
+ const n = currLines.length;
186
+ // Longest common subsequence.
187
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
188
+ for (let i = 1; i <= m; i++) {
189
+ for (let j = 1; j <= n; j++) {
190
+ dp[i][j] = prevLines[i - 1] === currLines[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
191
+ }
192
+ }
193
+ // Backtrack to build unified diff.
194
+ let i = m;
195
+ let j = n;
196
+ const ops = [];
197
+ while (i > 0 || j > 0) {
198
+ if (i > 0 && j > 0 && prevLines[i - 1] === currLines[j - 1]) {
199
+ ops.push([" ", prevLines[i - 1]]);
200
+ i--;
201
+ j--;
202
+ }
203
+ else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
204
+ ops.push(["+", currLines[j - 1]]);
205
+ j--;
206
+ }
207
+ else {
208
+ ops.push(["-", prevLines[i - 1]]);
209
+ i--;
210
+ }
211
+ }
212
+ ops.reverse();
213
+ // Identify indices of changed ops (+ or -).
214
+ const changedIndices = [];
215
+ for (let k = 0; k < ops.length; k++) {
216
+ if (ops[k][0] !== " ")
217
+ changedIndices.push(k);
218
+ }
219
+ if (changedIndices.length === 0)
220
+ return "";
221
+ // Merge nearby changes: expand each changed index by 3 lines of context on each side,
222
+ // then merge overlapping ranges.
223
+ const CONTEXT = 3;
224
+ const ranges = [];
225
+ for (const idx of changedIndices) {
226
+ const start = Math.max(0, idx - CONTEXT);
227
+ const end = Math.min(ops.length - 1, idx + CONTEXT);
228
+ if (ranges.length > 0 && start <= ranges[ranges.length - 1][1] + 1) {
229
+ ranges[ranges.length - 1][1] = end;
230
+ }
231
+ else {
232
+ ranges.push([start, end]);
233
+ }
234
+ }
235
+ // Emit hunks.
236
+ const hunks = [];
237
+ for (const [rangeStart, rangeEnd] of ranges) {
238
+ let prevPos = 0;
239
+ for (let k = 0; k < rangeStart; k++) {
240
+ const op = ops[k][0];
241
+ if (op === " " || op === "-")
242
+ prevPos++;
243
+ }
244
+ // Count lines in the range for each file.
245
+ let prevCount = 0;
246
+ let currCount = 0;
247
+ const lines = [];
248
+ for (let k = rangeStart; k <= rangeEnd; k++) {
249
+ const [op, line] = ops[k];
250
+ lines.push(`${op} ${line}`);
251
+ if (op === " ") {
252
+ prevCount++;
253
+ currCount++;
254
+ }
255
+ else if (op === "-") {
256
+ prevCount++;
257
+ }
258
+ else {
259
+ currCount++;
260
+ }
261
+ }
262
+ hunks.push(`@@ -${prevPos + 1},${prevCount} +${prevPos + 1},${currCount} @@`);
263
+ hunks.push(...lines);
264
+ }
265
+ const text = hunks.join("\n");
266
+ return text.length <= diffLimit ? text : `${text.slice(0, diffLimit)}\n… [diff truncated]`;
267
+ }
268
+ /**
269
+ * Short note for a stuck-loop repeated failure: header, capped unified diff against the previous output,
270
+ * tail of the current output, and the path of the full copy. All inputs must be pre-redacted.
271
+ */
272
+ export function stuckDiff(previous, current, options) {
273
+ const header = `pi-warden: stuck-loop diff; this output repeated a failure (previous: ${previous.length} chars, current: ${current.length} chars).`;
274
+ if (previous === current) {
275
+ return `${header}\nOutputs are byte-identical; see the full output at ${options.fullPath}.`;
276
+ }
277
+ const diff = lineDiff(previous, current, options.diffLimit);
278
+ const tail = current.length <= options.tailLimit ? current : `… [${current.length - options.tailLimit} earlier chars omitted]\n${current.slice(-options.tailLimit)}`;
279
+ return `${header}\n${diff}\n${tail}\n\nFull output: ${options.fullPath}`;
280
+ }
180
281
  export function formatStuck(verdict, template = DEFAULT_TEMPLATES.stuck) {
181
282
  return renderTemplate(template, stuckTokens(verdict));
182
283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "0.34.1",
3
+ "version": "0.36.0",
4
4
  "description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.",
5
5
  "type": "module",
6
6
  "license": "MIT",