pi-warden 0.23.0 → 0.26.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/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { resolveBackend } from "./backend.js";
4
5
  import { COMMAND_TOOLS } from "./tools.js";
5
6
  import { defaultWidgetConfig } from "./widget.js";
6
7
  const RECALL_TOOLS = ["auto", "rg", "ag", "ugrep", "git-grep", "grep", "select-string", "findstr", "none"];
@@ -15,6 +16,7 @@ export function defaultConfig() {
15
16
  return {
16
17
  enabled: true,
17
18
  typesafe: false,
19
+ typesafeBackend: "typesafe",
18
20
  mode: "steer",
19
21
  timeoutMs: 5000,
20
22
  maxRequests: 500,
@@ -28,6 +30,11 @@ export function defaultConfig() {
28
30
  intentMismatch: 0.9,
29
31
  visibleMismatch: 0.8,
30
32
  feedbackLog: true,
33
+ commandRules: [],
34
+ commandDenyRules: [],
35
+ exemptRules: [],
36
+ pathRules: [],
37
+ armingRules: [],
31
38
  },
32
39
  stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true },
33
40
  done: { enabled: true, claimsDone: 0.7, nudge: true },
@@ -94,7 +101,142 @@ function positiveInteger(value, fallback) {
94
101
  export function isMode(value) {
95
102
  return value === "steer" || value === "confirm" || value === "advise";
96
103
  }
97
- function applyAction(base, raw, timeoutMs) {
104
+ function parseCommandRule(raw, defaultSeverity) {
105
+ if (!isObject(raw))
106
+ return undefined;
107
+ const id = typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : undefined;
108
+ const pattern = typeof raw.pattern === "string" && raw.pattern.trim() ? raw.pattern : undefined;
109
+ if (!id || !pattern)
110
+ return undefined;
111
+ const severity = raw.severity === "warn" || raw.severity === "confirm" || raw.severity === "deny" ? raw.severity : defaultSeverity;
112
+ // Dialog is the default for a confirm rule (the reason one writes such a rule); hold restores steer semantics.
113
+ // A deny or warn rule cannot carry an action: there is nothing to prompt and nothing to steer differently, and a
114
+ // stray action on one would misreport in traces as a dialog rule.
115
+ const action = raw.action === "dialog" || raw.action === "hold" ? (severity === "confirm" ? raw.action : undefined) : severity === "confirm" ? "dialog" : undefined;
116
+ const message = typeof raw.message === "string" && raw.message.trim() ? raw.message.trim() : undefined;
117
+ const caseSensitive = typeof raw.caseSensitive === "boolean" ? raw.caseSensitive : false;
118
+ return { id, pattern, severity, ...(action ? { action } : {}), ...(message ? { message } : {}), ...(caseSensitive ? { caseSensitive } : {}) };
119
+ }
120
+ function parseCommandRules(raw, defaultSeverity) {
121
+ if (!Array.isArray(raw))
122
+ return [];
123
+ const ids = new Set();
124
+ const rules = [];
125
+ for (const item of raw) {
126
+ const rule = parseCommandRule(item, defaultSeverity);
127
+ if (!rule || ids.has(rule.id))
128
+ continue;
129
+ ids.add(rule.id);
130
+ rules.push(rule);
131
+ }
132
+ return rules;
133
+ }
134
+ function parseExemptRules(raw) {
135
+ if (!Array.isArray(raw))
136
+ return [];
137
+ const ids = new Set();
138
+ for (const item of raw) {
139
+ if (typeof item === "string" && item.trim())
140
+ ids.add(item.trim());
141
+ }
142
+ return [...ids];
143
+ }
144
+ const PATH_TOOL_NAMES = new Set(["read", "write", "edit", "bash", "powershell", "ctx_execute", "ctx_batch_execute", "ctx_execute_file"]);
145
+ function parsePathRule(raw) {
146
+ if (!isObject(raw))
147
+ return undefined;
148
+ const id = typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : undefined;
149
+ const paths = globList(raw.paths, []);
150
+ if (!id || paths.length === 0)
151
+ return undefined;
152
+ // "*" widens the rule to the bash surface; other entries name the structured file-tool surfaces.
153
+ const requested = Array.isArray(raw.tools) ? raw.tools.filter((tool) => typeof tool === "string" && tool.trim().length > 0).map(tool => tool.trim()) : ["*"];
154
+ const tools = requested.length > 0 ? requested : ["*"];
155
+ const access = raw.access === "none" || raw.access === "read" || raw.access === "write" ? raw.access : "none";
156
+ const action = raw.action === "warn" || raw.action === "confirm" || raw.action === "block" ? raw.action : "note";
157
+ const message = typeof raw.message === "string" && raw.message.trim() ? raw.message.trim() : undefined;
158
+ const onlyIfExists = raw.onlyIfExists === false ? false : true;
159
+ return { id, paths, ...(raw.regex === true ? { regex: true } : {}), access, tools, action, ...(message ? { message } : {}), ...(onlyIfExists === false ? { onlyIfExists: false } : {}) };
160
+ }
161
+ function parsePathRules(raw) {
162
+ if (!Array.isArray(raw))
163
+ return [];
164
+ const ids = new Set();
165
+ const rules = [];
166
+ for (const item of raw) {
167
+ const rule = parsePathRule(item);
168
+ // A tool name that is neither a structured surface nor "*" is inert on every surface; keep it only when it means something.
169
+ if (!rule || ids.has(rule.id) || !rule.tools.some(tool => tool === "*" || PATH_TOOL_NAMES.has(tool)))
170
+ continue;
171
+ ids.add(rule.id);
172
+ rules.push(rule);
173
+ }
174
+ return rules;
175
+ }
176
+ /** Parse a duration string ("10m", "30s", "2h") or number (ms) into milliseconds. */
177
+ function parseDuration(raw, fallback) {
178
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0)
179
+ return raw;
180
+ if (typeof raw !== "string")
181
+ return fallback;
182
+ const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/.exec(raw.trim());
183
+ if (!match)
184
+ return fallback;
185
+ const value = parseFloat(match[1]);
186
+ const unit = match[2] ?? "m";
187
+ return value * (unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : 3_600_000);
188
+ }
189
+ const DEFAULT_ARMING_DURATION = 600_000; // 10 minutes
190
+ const ARMING_TOOLS = new Set(["write", "edit"]);
191
+ function parseArmingRule(raw) {
192
+ if (!isObject(raw))
193
+ return undefined;
194
+ const id = typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : undefined;
195
+ const whenRaw = isObject(raw.when) ? raw.when : undefined;
196
+ const edited = globList(whenRaw?.edited, []);
197
+ if (!id || edited.length === 0)
198
+ return undefined;
199
+ const armsRaw = isObject(raw.arms) ? raw.arms : {};
200
+ const command = typeof armsRaw.command === "string" && armsRaw.command.trim() ? armsRaw.command : undefined;
201
+ if (!command)
202
+ return undefined;
203
+ const action = raw.action === "confirm" || raw.action === "hold" || raw.action === "block" ? raw.action : undefined;
204
+ if (!action)
205
+ return undefined;
206
+ const toolsProvided = Array.isArray(whenRaw?.tools) && whenRaw.tools.length > 0;
207
+ const tools = toolsProvided
208
+ ? whenRaw.tools.filter((t) => typeof t === "string" && ARMING_TOOLS.has(t))
209
+ : ["write", "edit"];
210
+ // A when.tools that filters to nothing (e.g. ["bash"]) is inert: no tool would ever arm it.
211
+ if (toolsProvided && tools.length === 0)
212
+ return undefined;
213
+ const forMs = parseDuration(armsRaw.for, DEFAULT_ARMING_DURATION);
214
+ const caseSensitive = typeof armsRaw.caseSensitive === "boolean" ? armsRaw.caseSensitive : false;
215
+ const message = typeof raw.message === "string" && raw.message.trim() ? raw.message.trim() : undefined;
216
+ const regex = whenRaw?.regex === true;
217
+ return {
218
+ id,
219
+ when: { edited, ...(regex ? { regex: true } : {}), ...(toolsProvided && tools.length > 0 ? { tools } : {}) },
220
+ arms: { command, for: forMs, ...(caseSensitive ? { caseSensitive } : {}) },
221
+ action,
222
+ ...(message ? { message } : {}),
223
+ };
224
+ }
225
+ function parseArmingRules(raw) {
226
+ if (!Array.isArray(raw))
227
+ return [];
228
+ const ids = new Set();
229
+ const rules = [];
230
+ for (const item of raw) {
231
+ const rule = parseArmingRule(item);
232
+ if (!rule || ids.has(rule.id))
233
+ continue;
234
+ ids.add(rule.id);
235
+ rules.push(rule);
236
+ }
237
+ return rules;
238
+ }
239
+ function applyAction(base, raw, timeoutMs, source) {
98
240
  const withTimeout = { ...base, timeoutMs };
99
241
  if (!isObject(raw))
100
242
  return withTimeout;
@@ -109,6 +251,15 @@ function applyAction(base, raw, timeoutMs) {
109
251
  intentMismatch: probability(raw.intentMismatch, base.intentMismatch),
110
252
  visibleMismatch: Math.min(probability(raw.visibleMismatch, base.visibleMismatch), probability(raw.intentMismatch, base.intentMismatch)),
111
253
  feedbackLog: boolean(raw.feedbackLog, base.feedbackLog),
254
+ // Only the user file declares these; a project file cannot add, edit, or remove them. The base (already the
255
+ // user's rules when a project file layers on top) is carried through, so a project "action" block cannot wipe them.
256
+ commandRules: source === "user" ? parseCommandRules(raw.commandRules, "warn") : base.commandRules,
257
+ commandDenyRules: source === "user" ? parseCommandRules(raw.commandDenyRules, "deny") : base.commandDenyRules,
258
+ exemptRules: source === "user" ? parseExemptRules(raw.exemptRules) : base.exemptRules,
259
+ // Path rules are user-declared security policy: a project file cannot add, edit, or remove them either.
260
+ pathRules: source === "user" ? parsePathRules(raw.pathRules) : base.pathRules,
261
+ // Arming rules are user-declared security policy: same gate.
262
+ armingRules: source === "user" ? parseArmingRules(raw.armingRules) : base.armingRules,
112
263
  };
113
264
  }
114
265
  function applyStuck(base, raw) {
@@ -229,7 +380,7 @@ function applyGuards(base, raw, timeoutMs, source) {
229
380
  subagent: applySubagent(base.subagent, raw.subagent),
230
381
  // A project file may switch notifications off or on, but never names a command to run.
231
382
  notify: applyNotify(base.notify, raw.notify, source === "user"),
232
- action: applyAction(base.action, raw.action, timeoutMs),
383
+ action: applyAction(base.action, raw.action, timeoutMs, source),
233
384
  stuck: applyStuck(base.stuck, raw.stuck),
234
385
  done: applyDone(base.done, raw.done),
235
386
  slop: applySlop(base.slop, raw.slop),
@@ -255,6 +406,7 @@ export function applyUserOverrides(base, raw) {
255
406
  return {
256
407
  enabled: boolean(raw.enabled, base.enabled),
257
408
  typesafe: boolean(raw.typesafe, base.typesafe),
409
+ typesafeBackend: resolveBackend(raw.typesafeBackend),
258
410
  mode: isMode(raw.mode) ? raw.mode : base.mode,
259
411
  ...shared,
260
412
  ...applyGuards(base, raw, shared.timeoutMs, "user"),
package/dist/extension.js CHANGED
@@ -1,12 +1,17 @@
1
- import { MouseRegion } from "@earendil-works/pi-tui";
1
+ import * as tuiModule from "@earendil-works/pi-tui";
2
+ const MouseRegion = tuiModule.MouseRegion;
2
3
  import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
3
4
  import { ensureApiKey } from "pi-typesafe/ui";
5
+ import { backendHost, disclosureFor, judgeOptions, keyAvailable, resolveBackend } from "./backend.js";
4
6
  import { ActionGuard } from "./action-guard.js";
7
+ import { ArmingTracker, unparseableArmingRules } from "./arming.js";
5
8
  import * as configModule from "./config.js";
6
9
  import { applyUserOverrides, defaultConfig, isMode, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
7
- import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome } from "./done.js";
8
- import { evaluateAction, formatVerdict, intentSteer, offTaskSteer, SLOP_LABELS, SteerRepeatWindow, steerReason } from "./guard.js";
10
+ import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome as recordDoneOutcome } from "./done.js";
11
+ import { evaluateAction, formatVerdict, higher, inertPathRules, intentSteer, offTaskSteer, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
12
+ import { commandOf } from "./tools.js";
9
13
  import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
14
+ import { initSchema, recordHold, recordOutcome, toHoldRecord } from "./learning.js";
10
15
  import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_SHARE, RestatementWindow, substantiveSentences } from "./prose.js";
11
16
  import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice } from "./output.js";
12
17
  import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
@@ -191,9 +196,28 @@ export default function wardenExtension(pi) {
191
196
  let panel;
192
197
  let lastUi;
193
198
  const actionGuard = new ActionGuard();
199
+ // Arming rules: session-scoped state that correlates preparation edits with later commands.
200
+ const arming = new ArmingTracker([]);
194
201
  const rulesGuard = new RulesGuard();
195
202
  // Hold feedback: what the user did after each judged call, the trace entry each label lands on, and the per-session log.
196
203
  const holds = new HoldLedger();
204
+ initSchema();
205
+ const learningIds = new Map(); // holds.id -> learning.id
206
+ // Prune learningIds when it grows large to prevent memory leaks
207
+ function pruneLearningIds() {
208
+ if (learningIds.size > 1000) {
209
+ const entries = [...learningIds.entries()];
210
+ learningIds.clear();
211
+ // Keep only the most recent 500
212
+ for (const [k, v] of entries.slice(-500))
213
+ learningIds.set(k, v);
214
+ }
215
+ }
216
+ function summarizeContext(context) {
217
+ if (!context?.length)
218
+ return undefined;
219
+ return context.slice(-8).map(m => m.role + ": " + redact(m.text.slice(0, 200))).join("\n");
220
+ }
197
221
  const traceOf = new WeakMap();
198
222
  let holdLog;
199
223
  // Allowed calls of the turn the user just replied to; the regret question about them rides the next action request.
@@ -220,6 +244,10 @@ export default function wardenExtension(pi) {
220
244
  let lastNotifiedAt = 0;
221
245
  // A partially updated module graph can hand this build a config without the sections it expects; see shape.ts.
222
246
  let shapeReported = false;
247
+ // An exemptRules id that names neither a built-in nor one of the user's own rules is inert; said once, not per call.
248
+ let exemptReported = false;
249
+ let inertReported = false;
250
+ let unparseableReported = false;
223
251
  const configFor = (ctx) => {
224
252
  const { config, missing } = guardCurrentSections(completeConfig(loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() })));
225
253
  if (missing.length && !shapeReported) {
@@ -231,15 +259,46 @@ export default function wardenExtension(pi) {
231
259
  else
232
260
  pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
233
261
  }
262
+ const unknownExempt = unknownExemptIds(config.action.exemptRules, config.action.commandRules, config.action.commandDenyRules, config.action.pathRules, config.action.armingRules);
263
+ if (unknownExempt.length && !exemptReported) {
264
+ exemptReported = true;
265
+ const text = `warden: exemptRules names ${unknownExempt.join(", ")}, which match no built-in or user rule; those entries are inert`;
266
+ if (ctx.hasUI)
267
+ ctx.ui.notify(text, "warning");
268
+ else
269
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
270
+ }
271
+ const inert = inertPathRules(config.action.pathRules, config.action.tools);
272
+ if (inert.length && !inertReported) {
273
+ inertReported = true;
274
+ const text = `warden: path rules ${inert.join(", ")} can never fire with the current access/tools combination; check access polarity or add the tool to action.tools`;
275
+ if (ctx.hasUI)
276
+ ctx.ui.notify(text, "warning");
277
+ else
278
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
279
+ }
280
+ const unparseable = unparseableArmingRules(config.action.armingRules);
281
+ if (unparseable.length && !unparseableReported) {
282
+ // Separate flag so an operator with both an inert path rule and a bad arming regex sees both warnings.
283
+ unparseableReported = true;
284
+ const text = `warden: arming rules ${unparseable.join(", ")} have an invalid command regex; the rules will never fire`;
285
+ if (ctx.hasUI)
286
+ ctx.ui.notify(text, "warning");
287
+ else
288
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
289
+ }
290
+ arming.updateRules(config.action.armingRules);
234
291
  return config;
235
292
  };
236
293
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
237
294
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
238
- /** A consent flag is not proof that judgments happen; ask pi-typesafe for the real key state. */
295
+ /** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
239
296
  const judgeFor = (config) => {
240
- if (!consentGiven(config) || budgetExhausted || !authState().usable)
297
+ if (!consentGiven(config) || budgetExhausted)
241
298
  return undefined;
242
- return client ??= createTypeSafe({ maxRequests: config.maxRequests, timeoutMs: config.timeoutMs });
299
+ if (!keyAvailable(config.typesafeBackend, process.env, () => authState().usable))
300
+ return undefined;
301
+ return client ??= createTypeSafe(judgeOptions(config));
243
302
  };
244
303
  const noteError = (ctx, message, code) => {
245
304
  stats.errors++;
@@ -271,12 +330,18 @@ export default function wardenExtension(pi) {
271
330
  }
272
331
  const entries = [...widget].map(([guard, line]) => ({ guard, line }));
273
332
  // A custom component so the lines wrap to the pane and a click (fullscreen mode) opens the trace panel.
274
- ctx.ui.setWidget(WIDGET, (_tui, theme) => new MouseRegion(statusWidget(entries, theme), event => {
275
- if (event.type !== "click" || event.button !== "left")
276
- return undefined;
277
- togglePanel(lastUi, config);
278
- return { handled: true };
279
- }), { placement: config.widget.placement });
333
+ // When the host TUI lacks MouseRegion (e.g. omp 18.2.5), the extension still loads — the widget
334
+ // renders the same text, but a click does nothing. A missing named import is a link-time error
335
+ // that kills the entire extension silently; a missing namespace property is undefined.
336
+ ctx.ui.setWidget(WIDGET, (_tui, theme) => {
337
+ const body = statusWidget(entries, theme);
338
+ return MouseRegion ? new MouseRegion(body, event => {
339
+ if (event.type !== "click" || event.button !== "left")
340
+ return undefined;
341
+ togglePanel(lastUi, config);
342
+ return { handled: true };
343
+ }) : body;
344
+ }, { placement: config.widget.placement });
280
345
  };
281
346
  const record = (ctx, config, guard, line, details) => {
282
347
  widget.set(guard, line);
@@ -399,6 +464,7 @@ export default function wardenExtension(pi) {
399
464
  holds.reset();
400
465
  regretCandidates = [];
401
466
  holdLog = new HoldLog(holdLogPath(typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid)));
467
+ arming.reset();
402
468
  attempts.reset();
403
469
  evidence = emptyEvidence();
404
470
  doneNudged = false;
@@ -495,6 +561,18 @@ export default function wardenExtension(pi) {
495
561
  if (!config.action.enabled || !config.action.tools.includes(event.toolName))
496
562
  return;
497
563
  stats.inspected++;
564
+ // Arming: a write/edit to a protected path arms matching command patterns for a window. Bash redirect/tee
565
+ // targets that match a when.edited glob also arm. This is session state, not a per-call verdict — it runs
566
+ // before the action guard so the armed check on a later command sees the preparation.
567
+ if (config.action.armingRules.length > 0) {
568
+ const inputPath = typeof event.input.path === "string" ? event.input.path : undefined;
569
+ const armCmd = commandOf(event.toolName, event.input)?.command;
570
+ // Parity with checkArmed: strip data text before extracting write-sink targets so a redirect
571
+ // mentioned inside a data heredoc (no shell sink) does not arm, but a real redirect in an executed
572
+ // heredoc or plain command does.
573
+ const sinks = armCmd ? writeSinkTargets(stripDataText(armCmd).text) : undefined;
574
+ arming.arm(event.toolName, inputPath, ctx.cwd, sinks);
575
+ }
498
576
  const task = latestUserPrompt(ctx);
499
577
  const judge = judgeFor(config);
500
578
  const siblings = siblingToolCalls(ctx);
@@ -507,6 +585,51 @@ export default function wardenExtension(pi) {
507
585
  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 });
508
586
  if (verdict.source === "skipped")
509
587
  return;
588
+ // Arming check: if any armed rule's command regex matches this call, inject a hit into the verdict.
589
+ // The hit is deterministic — it fires regardless of Jev. But to avoid spending a judge request on a call whose
590
+ // outcome is already decided by the armed hit, the armed check runs before the action guard when armed rules
591
+ // exist, and if it produces a deny/confirm/hold verdict, the judge is skipped entirely (fix 11).
592
+ const cmd = commandOf(event.toolName, event.input)?.command;
593
+ const armedHits = cmd && config.action.armingRules.length > 0 ? arming.checkArmed(cmd) : [];
594
+ // Fix 3: exemptRules can silence an armed rule by raw id (the injected id is `armed:<id>`, but the user wrote <id>).
595
+ const visibleArmedHits = armedHits.filter(hit => !config.action.exemptRules.includes(hit.id));
596
+ if (visibleArmedHits.length > 0) {
597
+ // Build the verdict from patterns alone — no judge request (fix 11). The deny/confirm/hold plumbing handles it.
598
+ const armedPatterns = [];
599
+ const armedReasons = [];
600
+ let armedLevel = "allow";
601
+ for (const hit of visibleArmedHits) {
602
+ const action = hit.action;
603
+ // Fix 1: hold → destructive severity + action:"hold" (steer-mode hold semantics, same as commandRules action:"hold");
604
+ // confirm → destructive + action:"dialog"; block → deny. The old code mapped hold to risky→warn, a no-op.
605
+ const severity = action === "block" ? "deny" : "destructive";
606
+ const patternHit = {
607
+ id: `armed:${hit.id}`,
608
+ severity,
609
+ label: hit.message ?? `armed rule ${hit.id}`,
610
+ ...(action === "confirm" ? { action: "dialog" } : action === "hold" ? { action: "hold" } : {}),
611
+ ...(hit.message ? { message: hit.message } : {}),
612
+ };
613
+ armedPatterns.push(patternHit);
614
+ // Fix 8: armed confirm dialog names the files that armed it.
615
+ const armedBy = hit.armedByPaths.length > 0 ? ` (armed by: ${hit.armedByPaths.join(", ")})` : "";
616
+ if (severity === "deny") {
617
+ armedLevel = "deny";
618
+ armedReasons.push(`${hit.message ?? `armed rule ${hit.id}`}${armedBy}`);
619
+ }
620
+ else if (severity === "destructive") {
621
+ armedLevel = higher(armedLevel, "confirm");
622
+ armedReasons.push(`armed: ${hit.message ?? hit.id}${armedBy}`);
623
+ }
624
+ }
625
+ // Merge the armed patterns into the existing verdict (the action guard may have produced one too).
626
+ verdict.patterns.push(...armedPatterns);
627
+ verdict.level = higher(verdict.level, armedLevel);
628
+ verdict.reasons.push(...armedReasons);
629
+ // Fix 6: armed hits on read-only commands still record a trace line (bypass the read-only source gate).
630
+ if (verdict.source === "read-only")
631
+ verdict.source = "pattern";
632
+ }
510
633
  if (regretCandidates.length && verdict.judgment?.regretted !== undefined) {
511
634
  settleRegret(config, { regretted: regretsAt(verdict.judgment.regretted), target: verdict.judgment.regretTarget, probability: verdict.judgment.regretted, via: "jev" });
512
635
  }
@@ -526,18 +649,42 @@ export default function wardenExtension(pi) {
526
649
  if (verdict.approvedByUser) {
527
650
  stats.approved++;
528
651
  const released = holds.approved(event.toolName);
529
- if (released)
652
+ if (released) {
530
653
  noteOutcomes(config, [released]);
654
+ const learningId = learningIds.get(released.id);
655
+ if (learningId)
656
+ recordOutcome(learningId, "approved");
657
+ }
531
658
  }
532
659
  const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
660
+ // SAFETY: verdict.source is a string union; the read-only → pattern rewrite above may have narrowed it in TS's view,
661
+ // but the field is still one of the source values at runtime when no armed hits fired.
533
662
  const entry = verdict.source !== "read-only" ? record(ctx, config, "action", formatVerdict(verdict, config.widget.action), actionDetails(verdict, { mode, ...(told ? { told } : {}) })) : undefined;
534
663
  // What happens to this call is the label for its scores; a decision made in the dialog lands at once, a steer-mode hold waits for the user.
535
664
  const track = (held, outcome, via) => {
536
665
  if (!entry)
537
666
  return;
538
- const item = holds.record(verdict, { held, mode, outcome, via });
667
+ const recordOpts = { held, mode, outcome, via };
668
+ if (task)
669
+ recordOpts.task = task;
670
+ if (verdict.plan)
671
+ recordOpts.plan = verdict.plan;
672
+ const ctxSummary = summarizeContext(recentTaskContext(ctx));
673
+ if (ctxSummary)
674
+ recordOpts.contextSummary = ctxSummary;
675
+ if (held)
676
+ recordOpts.agentReason = steerReason(verdict, { canApprove: judge !== undefined });
677
+ const item = holds.record(verdict, recordOpts);
539
678
  traceOf.set(item, entry);
540
679
  noteOutcomes(config, outcome ? [item] : []);
680
+ // Record to SQLite for learning
681
+ if (held) {
682
+ const id = recordHold(toHoldRecord({ at: item.at, tool: item.tool, level: item.level, reasons: item.reasons, scores: item.scores }, ctx.cwd, { task, plan: verdict.plan, contextSummary: ctxSummary, agentReason: steerReason(verdict, { canApprove: judge !== undefined }) }));
683
+ learningIds.set(item.id, id);
684
+ if (outcome)
685
+ recordOutcome(id, outcome);
686
+ pruneLearningIds();
687
+ }
541
688
  };
542
689
  // The warn notice below names the mismatch to the user; the agent gets the steer with the other notes.
543
690
  if (verdict.intentMismatch) {
@@ -608,11 +755,34 @@ export default function wardenExtension(pi) {
608
755
  track(false);
609
756
  return undefined;
610
757
  }
758
+ if (verdict.level === "deny") {
759
+ stats.held++;
760
+ track(true, "declined", "deny");
761
+ if (ctx.hasUI && config.notices)
762
+ ctx.ui.notify(`warden · blocked ${event.toolName}: ${verdict.reasons.join("; ")}`, "error");
763
+ notifyDesktop(ctx, config, `Blocked ${event.toolName}: ${verdict.reasons.join("; ")}. A deny rule matched; the call never ran.`);
764
+ return { block: true, reason: `pi-warden blocked this ${event.toolName} call (${verdict.reasons.join("; ")}). A deny rule matched; this command is not allowed to run. Ask the user if this is genuinely required.` };
765
+ }
611
766
  if (verdict.level !== "confirm") {
612
767
  track(false);
613
768
  return undefined;
614
769
  }
615
770
  const reasons = verdict.reasons.join("; ");
771
+ // A user-defined confirm rule prompts the user in every mode, advise included: the operator wrote the rule to be
772
+ // asked. Advise mode keeps Jev holds advisory; it does not soften a prompt the operator asked for by name.
773
+ // The action defaults to dialog at parse time (the reason one writes such a rule); hold restores steer semantics.
774
+ const dialogRule = verdict.patterns.some(hit => hit.action === "dialog");
775
+ if (dialogRule && ctx.hasUI) {
776
+ notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
777
+ const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
778
+ if (allowed) {
779
+ track(true, "approved", "dialog");
780
+ return undefined;
781
+ }
782
+ stats.held++;
783
+ track(true, "declined", "dialog");
784
+ 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.` };
785
+ }
616
786
  if (mode === "advise") {
617
787
  stats.warned++;
618
788
  if (ctx.hasUI && config.notices)
@@ -836,7 +1006,7 @@ export default function wardenExtension(pi) {
836
1006
  const patch = content === event.content ? undefined : { content };
837
1007
  // Checks use the original result, not the excerpts or security banner.
838
1008
  if (config.done.enabled)
839
- recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
1009
+ recordDoneOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
840
1010
  const verdict = await stuckCheck;
841
1011
  if (!verdict)
842
1012
  return patch;
@@ -863,6 +1033,8 @@ export default function wardenExtension(pi) {
863
1033
  const config = configFor(ctx);
864
1034
  if (!config.enabled)
865
1035
  return;
1036
+ // Arming state survives across runs within a session (the Scenario B case: edit in one turn,
1037
+ // reconcile in the next). It is cleared on session_start and bounded by the rule's `for` window.
866
1038
  // No guarded call carried the regret question this run (the agent only replied): the offline heuristic reads the prompt.
867
1039
  if (regretCandidates.length)
868
1040
  settleRegret(config, { regretted: textRegrets(latestUserPrompt(ctx)), via: "text" });
@@ -976,13 +1148,14 @@ export default function wardenExtension(pi) {
976
1148
  const usage = client?.getUsage();
977
1149
  const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop", config.slop.enabled && config.slop.prose.enabled && `prose (${config.slop.prose.audience})`, config.security.enabled && "security", config.rules.enabled && "rules", config.context.enabled && "context", config.runaway.enabled && "runaway", config.subagent.enabled && "subagent triage", config.notify.enabled && "desktop notifications"].filter(Boolean).join(", ");
978
1150
  report([
979
- `pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `consented via ${source}` : "not consented (run /warden enable)"}; ${auth.text}`,
1151
+ `pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `consented via ${source}` : "not consented (run /warden enable)"}${config.typesafeBackend !== "typesafe" ? ` (${config.typesafeBackend})` : ""}; ${auth.text}`,
980
1152
  `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan, ${stats.offTask} off task, ${stats.slop} slop notes, ${stats.ruleViolations}/${stats.ruleChecks} rule violations, ${stats.pathNotes} sensitive-path notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.runaway} runaway stops, ${stats.subagentWoken}/${stats.subagentReports} subagent reports woken, ${stats.restatements} restatements, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests. Steers are ${config.steerVisible ? "shown in the transcript" : "hidden from the transcript (trace panel shows them)"}. Steer budget: ${config.steerBudget === 0 ? "off" : `${config.steerBudget} per run`}.`,
981
1153
  formatSteers(stats),
982
1154
  `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / steer ${config.action.offTask.steer} (never holds); intent mismatch ${config.action.intentMismatch} (${config.action.visibleMismatch} on a visible action); stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, rules ${config.rules.threshold}, prose ${config.slop.prose.threshold} in ${config.slop.prose.trend}/3 replies; runaway ${config.runaway.repeats} repeats (thinking ${config.runaway.thinkingRepeats}), recover ${config.runaway.recover}; failOpen ${config.action.failOpen}.`,
983
1155
  formatLedger(ledger.snapshot()),
984
1156
  `${formatHolds(holds.snapshot(), config.action.feedbackLog ? holdLog?.path : undefined)}${holdLog?.lastFailure ? ` Log write failed: ${holdLog.lastFailure}.` : ""}`,
985
1157
  `Rules: ${config.rules.enabled ? `${rulesGuard.describe(ctx.cwd, config.rules)}${Object.keys(config.rules.sensitivePaths).length ? `; ${Object.keys(config.rules.sensitivePaths).length} sensitive path${Object.keys(config.rules.sensitivePaths).length === 1 ? "" : "s"}` : ""}` : "off"}.`,
1158
+ ...(config.action.armingRules.length > 0 ? [`Arming: ${arming.statusLine() || "no rules armed"}.`] : []),
986
1159
  `Desktop notifications: ${config.notify.enabled ? `on (${config.notify.command.length ? `command ${config.notify.command[0]}` : (await (notifier ??= detectNotifier())) ?? "no notifier found on this machine"}; cooldown ${config.notify.cooldownMs} ms)` : "off (\"notify\": { \"enabled\": true } in the config turns them on)"}.`,
987
1160
  `Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
988
1161
  widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
@@ -1062,7 +1235,7 @@ export default function wardenExtension(pi) {
1062
1235
  }
1063
1236
  if (action === "test") {
1064
1237
  const judge = judgeFor(config);
1065
- 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}`))
1238
+ 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 ${backendHost(config.typesafeBackend)} and may incur charges. ${disclosureFor(config.typesafeBackend, disclosure)}`))
1066
1239
  return;
1067
1240
  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 });
1068
1241
  record(ctx, config, "action", formatVerdict(verdict, config.widget.action), actionDetails(verdict, { mode: activeMode(config, ctx.hasUI) }));