pi-warden 0.64.1 → 0.71.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
@@ -8,6 +8,7 @@ import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
8
8
  import { ensureApiKey } from "pi-typesafe/ui";
9
9
  import { backendHost, disclosureFor, judgeOptions, keyEnvFor, loginStoresKey, resolveBackend } from "./backend.js";
10
10
  import { ActionGuard } from "./action-guard.js";
11
+ import { assistantView, formatMuted, NEVER_MUTED, STEER_KINDS, SteerStats, SteerWatch } from "./adaptive.js";
11
12
  import { ArmingTracker, unparseableArmingRules } from "./arming.js";
12
13
  import * as configModule from "./config.js";
13
14
  import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
@@ -22,12 +23,16 @@ import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey,
22
23
  import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
23
24
  import { maskSecrets, redact } from "./redact.js";
24
25
  import { formatRules, pathNoteSteer, projectPath, RulesGuard, rulesSteer, RULES_FILE, FALLBACK_FILES } from "./rules.js";
26
+ import { checkRules, formatRulesCheck } from "./rules-lint.js";
27
+ import { readRulesLog, RulesLog, rulesLogPath } from "./rules-log.js";
28
+ import { buildRulesReport, formatRulesReport, REPORT_DEFAULT_DAYS } from "./rules-report.js";
25
29
  import { checkPiWardenMissing } from "./rules-file.js";
26
30
  import { writeStarterRules, buildInitPrompt } from "./init.js";
27
31
  import { buildAuditPrompt, findProjects, snapshotReport, reportOutcome } from "./audit.js";
28
32
  import { detectNotifier, sendNotification } from "./notify.js";
29
33
  import { formatRunaway, RunawayMonitor, runawayNudge } from "./runaway.js";
30
34
  import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, quickRepeatNudge, resultFailed, stuckDiff, stuckNudge } from "./stuck.js";
35
+ import { WasteTracker, WASTE_TIP, withWasteTip } from "./waste.js";
31
36
  import { assess } from "./conscience.js";
32
37
  import { loadSkillBody, buildLoadMessage, policyMatches, CONSCIENCE_BETA_POLICY, recordFileIdentity, clearFileIdentityCache } from "./load.js";
33
38
  import { buildIndexPrompt, readIndex, writeIndex, validateIndex, indexStats, indexPath, ensureIndexDir } from "./index-cmd.js";
@@ -38,8 +43,8 @@ import { completeConfig, shapeWarning, taskSpine } from "./shape.js";
38
43
  import { ContextLedger, formatLedger } from "./saver.js";
39
44
  import { SeenText, collapseRuns, seenItem } from "./dedupe.js";
40
45
  import { buildCompactSnapshot, compactAppendix, recallText } from "./compact.js";
41
- import { applyLoopAction, formatLoopsForUser, formatOpenLoops, LOOP_ACTIONS, LOOP_CHARS, loopsFingerprint, loopsPath, openLoops, readLoops, writeLoops } from "./loops.js";
42
- import { emptyWordCounts, evaluatePrefs, forgetPref, formatPrefs, isCorrection, LESSON_CHARS, LESSON_TURNS, NO_LESSON_SIGNAL, prefsMessage, prefsStorePath, readPrefsStore, recordLesson, scanPreferences, writePrefsStore } from "./prefs.js";
46
+ import { applyLoopAction, formatLoopsForUser, formatOpenLoops, LOOP_ACTIONS, LOOP_CHARS, loopsFingerprint, loopsPath, openLoops, readLoops, updateLoops } from "./loops.js";
47
+ import { changePrefsStore, emptyWordCounts, evaluatePrefs, forgetPref, formatPrefs, isCorrection, LESSON_CHARS, LESSON_TURNS, NO_LESSON_SIGNAL, prefsMessage, prefsStorePath, readPrefsStore, recordLesson, scanPreferences } from "./prefs.js";
43
48
  import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
44
49
  import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
45
50
  import { TraceFile, judgmentsState, traceDir, traceFilePath } from "./trace-file.js";
@@ -49,7 +54,7 @@ const WIDGET = PACKAGE_NAME;
49
54
  const CONFIRM_TEXT_LIMIT = 500;
50
55
  /** Rules requests one bash command may start; the other files it writes are recorded as skipped. */
51
56
  const SHELL_RULES_CHECKS = 5;
52
- const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offPlanTraceOnly: 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, cooldownSkips: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
57
+ const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offPlanTraceOnly: 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, cooldownSkips: 0, steers: 0, steersSkipped: 0, steersMuted: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
53
58
  /**
54
59
  * One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
55
60
  * message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
@@ -272,7 +277,7 @@ export function guardCurrentSections(result) {
272
277
  if (typeof config.rules !== "object" || config.rules === null || !Array.isArray(config.rules.exclude)) {
273
278
  if (!missing.includes("rules"))
274
279
  missing.push("rules");
275
- config.rules = { enabled: false, threshold: 1, files: [], fallback: false, maxChars: 500, exclude: [], skip: [], sensitivePaths: {} };
280
+ config.rules = { enabled: false, threshold: 1, softThreshold: 0, files: [], fallback: false, maxChars: 500, exclude: [], skip: [], sensitivePaths: {} };
276
281
  }
277
282
  if (typeof config.widget !== "object" || config.widget === null) {
278
283
  if (!missing.includes("widget"))
@@ -345,9 +350,14 @@ export default function wardenExtension(pi) {
345
350
  }
346
351
  const traceOf = new WeakMap();
347
352
  let holdLog;
353
+ // Local rules verdict log for this session; a write failure is silent to the agent and shown once in the trace.
354
+ let rulesLog;
348
355
  // Allowed calls of the turn the user just replied to; the regret question about them rides the next action request.
349
356
  let regretCandidates = [];
350
357
  let attempts = new AttemptWindow(defaultConfig().stuck.window);
358
+ // Call-waste notes for this session; the tip is offered once, the notes are rate limited per detector.
359
+ const waste = new WasteTracker();
360
+ let wasteTipOffered = false;
351
361
  /** Full output text and saved path keyed by attempt call key, for stuck-loop diffs. */
352
362
  let fullOutputs = new Map();
353
363
  let evidence = emptyEvidence();
@@ -746,6 +756,9 @@ export default function wardenExtension(pi) {
746
756
  /** Guards whose notices gate the run itself; they deliver even when the per-run steer budget is spent. */
747
757
  const CRITICAL_STEER_GUARDS = new Set(["stuck", "done", "runaway", "subagent"]);
748
758
  let steersThisRun = 0;
759
+ /** Follow and dispute counts per (model, steer kind), kept across sessions; and the sent steers still waiting for a reply. */
760
+ const steerStats = new SteerStats();
761
+ const steerWatch = new SteerWatch();
749
762
  /** Session generation counter for conscience invalidation. Incremented on steer, abort, reload, switch, or config change. */
750
763
  let conscienceGeneration = 0;
751
764
  /** Per-prompt error categories already warned about (one console.warn per category per prompt). */
@@ -807,6 +820,39 @@ export default function wardenExtension(pi) {
807
820
  wardenContinuation = true;
808
821
  return true;
809
822
  };
823
+ const modelKey = (ctx) => ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
824
+ const KIND_GUARD = { rules: "rules", "sensitive-path": "rules", "security-write": "security", stuck: "stuck", repeat: "stuck", prose: "prose", conscience: "conscience" };
825
+ /**
826
+ * False when steers of this kind are trace-only for the current model; the skipped note gets a trace line. Holds and
827
+ * judgments are not affected: only whether the note reaches the agent.
828
+ */
829
+ const adaptiveSend = (ctx, config, kind) => {
830
+ const model = modelKey(ctx);
831
+ if (!model)
832
+ return true;
833
+ const decision = steerStats.request(model, kind, config.steers);
834
+ if (decision !== "trace-only")
835
+ return true;
836
+ const pair = steerStats.pair(model, kind);
837
+ const rates = pair ? `${pair.sent} steers observed, ${Math.round((pair.followed / Math.max(1, pair.sent)) * 100)}% followed, ${Math.round((pair.disputed / Math.max(1, pair.sent)) * 100)}% disputed` : "no counts";
838
+ stats.steersMuted++;
839
+ record(ctx, config, KIND_GUARD[kind] ?? "action", `warden · steer trace-only · ${kind} · ${model}`, [
840
+ `${kind} steers are trace-only for ${model} (${rates}); 1 in ${config.steers.probeEvery} is still sent for the re-check`,
841
+ `/warden unmute ${kind} sends them again`,
842
+ ]);
843
+ return false;
844
+ };
845
+ /** A delivered steer waits for the agent's next two messages; its outcome is counted for the (model, kind) pair. */
846
+ const watchSteer = (ctx, config, kinds, subject = {}, nextTurn = false) => {
847
+ const model = modelKey(ctx);
848
+ if (!model || !config.steers.adaptive)
849
+ return;
850
+ steerWatch.add(model, kinds.filter(kind => !NEVER_MUTED.has(kind) || kind === "done"), subject, { nextTurn });
851
+ };
852
+ const settleSteers = (config, outcomes) => {
853
+ for (const outcome of outcomes)
854
+ steerStats.observe(outcome.model, outcome.kind, outcome, config.steers);
855
+ };
810
856
  /**
811
857
  * Desktop notification for a moment that needs the user back at the terminal. Interactive sessions only: a headless run
812
858
  * or a subagent has nobody to call, and several of them would flood the desktop. Fire-and-forget; failures are silent.
@@ -878,6 +924,7 @@ export default function wardenExtension(pi) {
878
924
  initRunning = false;
879
925
  await initSchema(loadConfig().learning.retentionDays);
880
926
  stats = freshStats();
927
+ steerWatch.clear();
881
928
  widget.clear();
882
929
  trace.clear();
883
930
  panel?.close();
@@ -888,6 +935,7 @@ export default function wardenExtension(pi) {
888
935
  regretCandidates = [];
889
936
  const sessionId = typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid);
890
937
  holdLog = new HoldLog(holdLogPath(sessionId));
938
+ rulesLog = new RulesLog(ctx.cwd, sessionId, { onFailure: message => record(ctx, loadConfig(), "rules", "rules log write failed", [message]) });
891
939
  unsubscribeTraceFile?.();
892
940
  unsubscribeTraceFile = undefined;
893
941
  traceFile = undefined;
@@ -910,6 +958,8 @@ export default function wardenExtension(pi) {
910
958
  }
911
959
  arming.reset();
912
960
  attempts.reset();
961
+ waste.reset();
962
+ wasteTipOffered = false;
913
963
  evidence = emptyEvidence();
914
964
  doneNudged = false;
915
965
  wardenContinuation = false;
@@ -991,6 +1041,22 @@ export default function wardenExtension(pi) {
991
1041
  noticeUi = ctx.hasUI ? ctx.ui : undefined;
992
1042
  const config = configFor(ctx);
993
1043
  attempts = new AttemptWindow(config.stuck.window);
1044
+ // ── Call waste: the session tip ──
1045
+ // The tip is appended to the prompt, so every other part of the prompt stays where it was and the host records the
1046
+ // change as a prompt-section delta. It is offered once per session; the trace line says so, and the next runs
1047
+ // re-append the same text, which is what keeps it in the prompt instead of being diffed away.
1048
+ const wasteOptions = event.systemPromptOptions;
1049
+ if (config.enabled && config.waste.enabled && config.waste.tip && wasteOptions) {
1050
+ wasteOptions.appendSystemPrompt = withWasteTip(wasteOptions.appendSystemPrompt);
1051
+ if (!wasteTipOffered) {
1052
+ wasteTipOffered = true;
1053
+ record(ctx, config, "waste", "warden · waste · session tip · instructions_supplied", [
1054
+ "trigger: before_agent_start",
1055
+ "delivery: appended to the system prompt (appendSystemPrompt)",
1056
+ `tip: ${WASTE_TIP}`,
1057
+ ]);
1058
+ }
1059
+ }
994
1060
  fullOutputs = new Map();
995
1061
  doneNudged = false;
996
1062
  wardenContinuation = false;
@@ -1490,6 +1556,7 @@ export default function wardenExtension(pi) {
1490
1556
  // Notes for the agent about the content it just wrote: slop, rule violations, and sensitive paths arrive as one message.
1491
1557
  const notes = [];
1492
1558
  const noteGuards = new Set();
1559
+ const noteKinds = [];
1493
1560
  /** Slop and rule findings say "just written", so they wait until the call proceeds. A held, denied, or declined write
1494
1561
  * never lands and drops them; an approved retry is judged again and brings its own. They go between the action notes
1495
1562
  * and the sensitive-path notes, the order of the combined message. */
@@ -1497,8 +1564,9 @@ export default function wardenExtension(pi) {
1497
1564
  const pathNotes = [];
1498
1565
  /** Sensitive-path notes ride with the combined steer for this call; their trace waits for the delivery result. */
1499
1566
  const pathNoteTraces = [];
1500
- if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
1501
- steer(config, "security", "pi-warden: the proposed write may introduce a security weakness. Check for embedded credentials, disabled TLS, unsafe command/SQL interpolation, broad permissions, or bypassed verification; use a safe implementation instead.");
1567
+ if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold && adaptiveSend(ctx, config, "security-write")) {
1568
+ if (steer(config, "security", "pi-warden: the proposed write may introduce a security weakness. Check for embedded credentials, disabled TLS, unsafe command/SQL interpolation, broad permissions, or bypassed verification; use a safe implementation instead."))
1569
+ watchSteer(ctx, config, ["security-write"], verdict.summary.path ? { path: verdict.summary.path } : {});
1502
1570
  }
1503
1571
  if (verdict.judgment)
1504
1572
  stats.judged++;
@@ -1547,25 +1615,29 @@ export default function wardenExtension(pi) {
1547
1615
  stats.offPlan++;
1548
1616
  if (verdict.intentTraceOnly)
1549
1617
  stats.offPlanTraceOnly++;
1550
- else {
1618
+ else if (adaptiveSend(ctx, config, "intent-mismatch")) {
1551
1619
  noteGuards.add("action");
1620
+ noteKinds.push("intent-mismatch");
1552
1621
  notes.push(intentSteer(verdict));
1553
1622
  }
1554
1623
  }
1555
1624
  if (verdict.offTaskSteer) {
1556
1625
  stats.offTask++;
1557
- if (!verdict.offTaskTraceOnly) {
1626
+ if (!verdict.offTaskTraceOnly && adaptiveSend(ctx, config, "off-task")) {
1558
1627
  noteGuards.add("action");
1628
+ noteKinds.push("off-task");
1559
1629
  notes.push(offTaskSteer(verdict));
1560
1630
  }
1561
1631
  }
1562
- if (verdict.shouldProceedSteer && !verdict.shouldProceedTraceOnly) {
1632
+ if (verdict.shouldProceedSteer && !verdict.shouldProceedTraceOnly && adaptiveSend(ctx, config, "should-proceed")) {
1563
1633
  noteGuards.add("action");
1634
+ noteKinds.push("should-proceed");
1564
1635
  notes.push(shouldProceedMessage(verdict));
1565
1636
  }
1566
1637
  const largeOutput = largeOutputNotice(verdict, largeOutputSteered);
1567
- if (largeOutput) {
1638
+ if (largeOutput && adaptiveSend(ctx, config, "large-output")) {
1568
1639
  noteGuards.add("action");
1640
+ noteKinds.push("large-output");
1569
1641
  notes.push(largeOutput);
1570
1642
  }
1571
1643
  if (verdict.slopSymptoms?.length && verdict.slopReasons) {
@@ -1575,11 +1647,11 @@ export default function wardenExtension(pi) {
1575
1647
  if (ctx.hasUI && config.notices)
1576
1648
  ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
1577
1649
  contentNotes.push(proceeds => {
1578
- if (!proceeds)
1650
+ if (!proceeds || !adaptiveSend(ctx, config, "slop"))
1579
1651
  return undefined;
1580
1652
  for (const symptom of symptoms)
1581
1653
  slopCounts[symptom]++;
1582
- return { guard: "action", text: slopSteer(where, symptoms, slopCounts) };
1654
+ return { guard: "action", kind: "slop", text: slopSteer(where, symptoms, slopCounts) };
1583
1655
  });
1584
1656
  }
1585
1657
  for (const { check, shellWrite } of rulesChecks) {
@@ -1596,18 +1668,26 @@ export default function wardenExtension(pi) {
1596
1668
  if (capNotice)
1597
1669
  ctx.ui.notify(capNotice, "warning");
1598
1670
  holds.recordRules({ source: rules.source, path: rules.path, findings: rules.findings.map(f => ({ name: f.name, violation: f.violation })), ...(rules.error ? { error: rules.error } : {}) });
1671
+ // A later judgment below the threshold clears an earlier finding of the same rule on this path: one trace line, no steer.
1672
+ if (rules.source === "typesafe") {
1673
+ for (const observation of rulesLog?.record(rules, config.rules.threshold, Date.now(), config.rules.softThreshold) ?? []) {
1674
+ if (observation.cleared)
1675
+ record(ctx, config, "rules", `rules: ${observation.name} now clear on ${rules.path}`, [`clear: a later ${rules.tool} on this path scored ${observation.violation.toFixed(2)}, below the ${config.rules.threshold} threshold`]);
1676
+ }
1677
+ }
1599
1678
  if (rules.findings.length) {
1600
1679
  stats.ruleViolations++;
1601
1680
  if (ctx.hasUI && config.notices)
1602
1681
  ctx.ui.notify(`warden · rules · ${rules.path}: ${rules.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
1603
1682
  }
1604
1683
  contentNotes.push(proceeds => {
1605
- const told = proceeds && rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
1684
+ const hasRules = rules.findings.length > 0 || (rules.softFindings?.length ?? 0) > 0;
1685
+ const told = proceeds && hasRules && adaptiveSend(ctx, config, "rules") ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
1606
1686
  const details = [...shellNote, ...rulesDetails(rules, told)];
1607
1687
  if (!proceeds && rules.findings.length)
1608
1688
  details.push("agent not told: the write was held");
1609
1689
  record(ctx, config, "rules", rulesLine, details);
1610
- return told ? { guard: "rules", text: told } : undefined;
1690
+ return told ? { guard: "rules", kind: "rules", text: told } : undefined;
1611
1691
  });
1612
1692
  }
1613
1693
  else {
@@ -1620,7 +1700,7 @@ export default function wardenExtension(pi) {
1620
1700
  }
1621
1701
  if (config.rules.enabled && (event.toolName === "write" || event.toolName === "edit")) {
1622
1702
  const hits = rulesGuard.notesFor(verdict.summary.location === "inside_project" ? verdict.summary.path : undefined, config.rules.sensitivePaths);
1623
- if (hits.length && verdict.summary.path) {
1703
+ if (hits.length && verdict.summary.path && adaptiveSend(ctx, config, "sensitive-path")) {
1624
1704
  stats.pathNotes++;
1625
1705
  const told = pathNoteSteer(verdict.summary.path, hits);
1626
1706
  // A repeat or a spent steer budget records the note without the agent reading it again, so the trace waits
@@ -1631,6 +1711,7 @@ export default function wardenExtension(pi) {
1631
1711
  ctx.ui.notify(`warden · sensitive path · ${verdict.summary.path} (${hits.map(hit => hit.glob).join(", ")}); the agent was given the note`, "warning");
1632
1712
  });
1633
1713
  noteGuards.add("rules");
1714
+ noteKinds.push("sensitive-path");
1634
1715
  pathNotes.push(told);
1635
1716
  }
1636
1717
  }
@@ -1641,17 +1722,28 @@ export default function wardenExtension(pi) {
1641
1722
  if (!note)
1642
1723
  continue;
1643
1724
  noteGuards.add(note.guard);
1725
+ noteKinds.push(note.kind);
1644
1726
  notes.push(note.text);
1645
1727
  }
1646
1728
  notes.push(...pathNotes);
1647
1729
  if (notes.length) {
1648
1730
  const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
1731
+ const notePath = verdict.summary.path ?? shell?.writes[0]?.path;
1732
+ if (delivered)
1733
+ watchSteer(ctx, config, noteKinds, notePath ? { path: notePath } : {});
1649
1734
  for (const traceNote of pathNoteTraces)
1650
1735
  traceNote(delivered);
1651
1736
  pathNoteTraces.length = 0;
1652
1737
  }
1653
1738
  };
1654
- const warnSteer = (reasons) => reasons.length > 0 && steer(config, "action", `pi-warden: this ${event.toolName} call ran with a warning (${reasons.join("; ")}). Nobody sees this in a headless run, so it is on you: if the flagged risk is expected, continue; otherwise fix it or ask the user before building on it.`);
1739
+ const warnSteer = (reasons) => {
1740
+ if (!reasons.length || !adaptiveSend(ctx, config, "warn-headless"))
1741
+ return false;
1742
+ const sent = steer(config, "action", `pi-warden: this ${event.toolName} call ran with a warning (${reasons.join("; ")}). Nobody sees this in a headless run, so it is on you: if the flagged risk is expected, continue; otherwise fix it or ask the user before building on it.`);
1743
+ if (sent)
1744
+ watchSteer(ctx, config, ["warn-headless"]);
1745
+ return sent;
1746
+ };
1655
1747
  if (verdict.level === "warn") {
1656
1748
  deliverNotes(true);
1657
1749
  stats.warned++;
@@ -1751,6 +1843,10 @@ export default function wardenExtension(pi) {
1751
1843
  pruneScratch(sessionScratch);
1752
1844
  // Repeat detection uses the original result, so its request goes out together with the output check.
1753
1845
  const failed = resultFailed(event.isError, event.details, event.content);
1846
+ // ── Call waste ──
1847
+ // Every finished call enters the window; a note, when one fires, is attached to this result below. Nothing here
1848
+ // reads the call's fate, and the note never reaches a hold, the judge, or the steer budget.
1849
+ const wasteNudge = waste.record({ tool: event.toolName, input: event.input, cwd: ctx.cwd, failed, result: text }, config.waste);
1754
1850
  // ── Conscience: track tool_result for the pending capability ──
1755
1851
  if (config.conscience.enabled && pendingCapability && !triggerConsumed) {
1756
1852
  if (pendingCapability.kind === "tool" && event.toolName === pendingCapability.id) {
@@ -2035,6 +2131,32 @@ export default function wardenExtension(pi) {
2035
2131
  fullOutputs.set(currentKey, { text });
2036
2132
  }
2037
2133
  const patch = content === event.content ? undefined : { content };
2134
+ /** The note rides the result the model is about to read; the call already ran, so this only adds text to it. */
2135
+ const withWaste = (current) => {
2136
+ if (!wasteNudge)
2137
+ return current;
2138
+ const base = current?.content ?? event.content;
2139
+ const lastText = base.reduce((last, part, index) => part.type === "text" ? index : last, -1);
2140
+ if (lastText < 0)
2141
+ return current;
2142
+ const next = base.map((part, index) => {
2143
+ if (part.type !== "text" || index !== lastText)
2144
+ return part;
2145
+ return { ...part, text: `${part.text}\n\n${wasteNudge.text}` };
2146
+ });
2147
+ // Redaction runs before the cut: a 200-character slice can leave half a credential the redactor no longer knows.
2148
+ const triggerCommand = event.toolName === "bash" && typeof event.input.command === "string"
2149
+ ? ` ${redact(event.input.command).replace(/\s+/g, " ").slice(0, 200)}`
2150
+ : "";
2151
+ record(ctx, config, "waste", `warden · waste · ${wasteNudge.detector} · ${wasteNudge.subject} · agent told`, [
2152
+ `trigger: ${event.toolName}${triggerCommand}`,
2153
+ `detector: ${wasteNudge.detector}`,
2154
+ `subject: ${redact(wasteNudge.subject)}`,
2155
+ `nudge: ${wasteNudge.text}`,
2156
+ `rate limit: at most one ${wasteNudge.detector} note per ${config.waste.every} calls; no steer, no request`,
2157
+ ]);
2158
+ return { content: next };
2159
+ };
2038
2160
  // Checks use the original result, not the excerpts or security banner.
2039
2161
  if (config.done.enabled)
2040
2162
  recordDoneOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
@@ -2047,34 +2169,38 @@ export default function wardenExtension(pi) {
2047
2169
  // A stuck verdict on the same call carries its own steer; the quick one would say the same thing twice.
2048
2170
  if (quickRepeat && !verdict?.stuck) {
2049
2171
  const nudge = quickRepeatNudge(quickRepeat);
2050
- const sent = steer(config, "repeat", nudge);
2172
+ const sent = adaptiveSend(ctx, config, "repeat") && steer(config, "repeat", nudge);
2173
+ if (sent)
2174
+ watchSteer(ctx, config, ["repeat"], { call: { name: event.toolName, input: event.input } });
2051
2175
  record(ctx, config, "stuck", `warden · stuck · repeat · ${quickRepeat.attempt.tool} · ${quickRepeat.attempt.failed ? "same failure" : "same output"} · ${sent ? "agent nudged" : "recorded only"}`, [
2052
- `call: ${quickRepeat.attempt.call}`, `previous identical call: ${quickRepeat.callsAgo} calls ago, nothing changed between`, `agent told: ${sent ? nudge : "nothing (repeat of a recent steer or over the steer budget)"}`,
2176
+ `call: ${quickRepeat.attempt.call}`, `previous identical call: ${quickRepeat.callsAgo} calls ago, nothing changed between`, `agent told: ${sent ? nudge : "nothing (repeat of a recent steer, over the steer budget, or trace-only for this model)"}`,
2053
2177
  ]);
2054
2178
  if (sent && ctx.hasUI && config.notices)
2055
2179
  ctx.ui.notify(`warden \u00b7 repeat: ${quickRepeat.attempt.failed ? "same call failed the same way" : "same output re-read"} (agent nudged)`, "warning");
2056
2180
  }
2057
2181
  if (!verdict)
2058
- return patch;
2182
+ return withWaste(patch);
2059
2183
  if (verdict.error)
2060
2184
  noteError(ctx, verdict.error, verdict.errorCode);
2061
2185
  if (verdict.source === "repeat" && !verdict.stuck)
2062
- return patch;
2186
+ return withWaste(patch);
2063
2187
  const nudge = verdict.stuck && config.stuck.nudge ? stuckNudge(verdict) : undefined;
2064
2188
  record(ctx, config, "stuck", formatStuck(verdict, config.widget.stuck), stuckDetails(verdict, attempts.attempts, nudge));
2065
2189
  if (!verdict.stuck)
2066
- return patch;
2190
+ return withWaste(patch);
2067
2191
  stats.stuck++;
2068
2192
  if (ctx.hasUI && config.notices)
2069
2193
  ctx.ui.notify(`warden \u00b7 stuck: ${verdict.reasons.join("; ")}${nudge ? " (agent nudged)" : ""}`, "warning");
2070
- if (nudge)
2071
- steer(config, "stuck", nudge);
2072
- return patch;
2194
+ if (nudge && adaptiveSend(ctx, config, "stuck") && steer(config, "stuck", nudge))
2195
+ watchSteer(ctx, config, ["stuck"], { call: { name: event.toolName, input: event.input } });
2196
+ return withWaste(patch);
2073
2197
  });
2074
2198
  // Opt-in: a relayed report that pastes earlier turns again keeps only its new part. Pi persists the replaced message.
2075
2199
  pi.on("message_end", async (event, ctx) => {
2076
2200
  const config = configFor(ctx);
2077
2201
  const message = event.message;
2202
+ if (message.role === "assistant")
2203
+ settleSteers(config, steerWatch.assistant(assistantView(message.content)));
2078
2204
  if (!config.enabled || !config.context.enabled || !config.context.dedupeRuns || !config.context.dedupeMessages)
2079
2205
  return;
2080
2206
  if (message.role !== "user" && message.role !== "custom")
@@ -2151,6 +2277,7 @@ export default function wardenExtension(pi) {
2151
2277
  }
2152
2278
  // Operator input invalidates in-flight conscience assessments
2153
2279
  conscienceGeneration++;
2280
+ settleSteers(configFor(ctx), steerWatch.user());
2154
2281
  });
2155
2282
  /**
2156
2283
  * One short reminder of the open loops when a run ends, for the next turn: it starts no turn of its own, counts against
@@ -2166,8 +2293,10 @@ export default function wardenExtension(pi) {
2166
2293
  if (!open.length || fingerprint === loopsReminded)
2167
2294
  return;
2168
2295
  const notice = `pi-warden: ${open.length} open loop${open.length === 1 ? "" : "s"} you promised in this session:\n${formatOpenLoops(open)}\nClose each with warden_loops done or drop once it is finished or no longer needed.`;
2169
- if (steer(config, "loops", notice, { deliverAs: "nextTurn" }))
2296
+ if (adaptiveSend(ctx, config, "loops") && steer(config, "loops", notice, { deliverAs: "nextTurn" })) {
2170
2297
  loopsReminded = fingerprint;
2298
+ watchSteer(ctx, config, ["loops"], {}, true);
2299
+ }
2171
2300
  }
2172
2301
  catch (error) {
2173
2302
  record(ctx, config, "action", "loops reminder error", [`reading the loops file failed: ${error instanceof Error ? error.message : String(error)}`]);
@@ -2239,14 +2368,15 @@ export default function wardenExtension(pi) {
2239
2368
  // Same activation gate as the first recommendation: selectedCapability is set before that gate, so without this a
2240
2369
  // capability held for no_policy would be delivered here.
2241
2370
  if (result.selected && result.selected.kind === selectedCapability.kind && result.selected.id === selectedCapability.id &&
2242
- result.disposition !== "awaiting_user" && policyMatches(consciencePolicy, result.questionHash, judgeModel)) {
2371
+ result.disposition !== "awaiting_user" && policyMatches(consciencePolicy, result.questionHash, judgeModel) && adaptiveSend(ctx, config, "conscience")) {
2243
2372
  reminderSent = true;
2244
2373
  nudgesThisPrompt++;
2245
2374
  spendBudgetUnit();
2246
2375
  const msg = result.selected.kind === "skill"
2247
2376
  ? `Reminder: consider using the \"${result.selected.id}\" skill. ${result.selected.description}`
2248
2377
  : `Reminder: consider using the \"${result.selected.id}\" tool. ${result.selected.description}`;
2249
- steer(config, "conscience", msg, { deliverAs: "followUp" });
2378
+ if (steer(config, "conscience", msg, { deliverAs: "followUp" }))
2379
+ watchSteer(ctx, config, ["conscience"], { capability: { kind: result.selected.kind === "skill" ? "skill" : "tool", id: result.selected.id } });
2250
2380
  }
2251
2381
  }
2252
2382
  catch (err) {
@@ -2285,7 +2415,8 @@ export default function wardenExtension(pi) {
2285
2415
  if (nudge) {
2286
2416
  if (ctx.hasUI && config.notices)
2287
2417
  ctx.ui.notify(`warden · prose: ${due.join(", ")} in ${config.slop.prose.trend} of the last 3 replies (agent nudged for the next reply)`, "warning");
2288
- steer(config, "prose", nudge, { deliverAs: "nextTurn" });
2418
+ if (adaptiveSend(ctx, config, "prose") && steer(config, "prose", nudge, { deliverAs: "nextTurn" }))
2419
+ watchSteer(ctx, config, ["prose"], {}, true);
2289
2420
  }
2290
2421
  }
2291
2422
  if (!doneCheck)
@@ -2304,7 +2435,8 @@ export default function wardenExtension(pi) {
2304
2435
  ctx.ui.notify(`warden · done-check: ${verdict.reasons.join("; ")}${nudge ? " (agent asked to verify)" : ""}`, "warning");
2305
2436
  if (nudge) {
2306
2437
  doneNudged = true;
2307
- steer(config, "done", nudge, { deliverAs: "followUp", triggerTurn: true });
2438
+ if (steer(config, "done", nudge, { deliverAs: "followUp", triggerTurn: true }))
2439
+ watchSteer(ctx, config, ["done"]);
2308
2440
  }
2309
2441
  });
2310
2442
  // Shortcuts are registered once at load; a shape check keeps a typo in the config file from being registered.
@@ -2337,10 +2469,11 @@ export default function wardenExtension(pi) {
2337
2469
  const session = typeof manager.getSessionId === "function" ? manager.getSessionId() : "unknown";
2338
2470
  const scan = await standingPrefs(ctx);
2339
2471
  const path = prefsStorePath(scan.project);
2340
- const result = recordLesson({ lesson, session, now: Date.now(), signal, scan, store: await readPrefsStore(path) });
2341
- if (result.store)
2342
- await writePrefsStore(path, result.store);
2343
- return toolReply(result.reply);
2472
+ const reply = await changePrefsStore(path, store => {
2473
+ const result = recordLesson({ lesson, session, now: Date.now(), signal, scan, store });
2474
+ return { value: result.reply, ...(result.store ? { store: result.store } : {}) };
2475
+ });
2476
+ return toolReply(reply);
2344
2477
  },
2345
2478
  });
2346
2479
  pi.registerTool({
@@ -2360,10 +2493,8 @@ export default function wardenExtension(pi) {
2360
2493
  const file = loopsFile(ctx);
2361
2494
  if (!file)
2362
2495
  return toolReply("not available: this session has no id to keep loops under");
2363
- const result = applyLoopAction(await readLoops(file), params, Date.now());
2364
- if (result.store)
2365
- await writeLoops(file, result.store);
2366
- return toolReply(result.reply);
2496
+ const reply = await updateLoops(file, store => applyLoopAction(store, params, Date.now()));
2497
+ return toolReply(reply);
2367
2498
  },
2368
2499
  });
2369
2500
  pi.registerTool({
@@ -2377,11 +2508,14 @@ export default function wardenExtension(pi) {
2377
2508
  return toolReply(recallText(evidenceSnapshot(ctx)));
2378
2509
  },
2379
2510
  });
2380
- const actions = ["status", "enable", "disable", "mode", "config", "test", "trace", "init", "audit", "index", "prefs", "loops", "recommend"];
2511
+ const actions = ["status", "rules", "report", "enable", "disable", "mode", "config", "test", "trace", "init", "audit", "index", "prefs", "loops", "recommend", "unmute"];
2381
2512
  pi.registerCommand("warden", {
2382
- description: "pi-warden status, config (set/get/editor), TypeSafe consent, mode, trace panel, recommend, standing preferences, open loops, and a synthetic guard test",
2513
+ description: "pi-warden status, active rules, config (set/get/editor), TypeSafe consent, mode, trace panel, recommend, standing preferences, open loops, and a synthetic guard test",
2383
2514
  getArgumentCompletions(prefix) {
2384
2515
  const matches = actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
2516
+ // `rules check` is the one two-word action: `rules` on its own stays the local list.
2517
+ if ("rules check".startsWith(prefix))
2518
+ matches.push({ value: "rules check", label: "rules check", description: "which of the active rules the guard cannot judge well" });
2385
2519
  return matches.length ? matches : null;
2386
2520
  },
2387
2521
  async handler(args, ctx) {
@@ -2412,6 +2546,7 @@ export default function wardenExtension(pi) {
2412
2546
  `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}`,
2413
2547
  `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan (${stats.offPlanTraceOnly} trace-only), ${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, ${stats.cooldownSkips} checks without Jev during a judge cooldown; ${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`}.`,
2414
2548
  formatSteers(stats),
2549
+ `${formatMuted(steerStats.muted(), config.steers)}${stats.steersMuted ? ` This session: ${stats.steersMuted} steer${stats.steersMuted === 1 ? "" : "s"} kept in the trace only.` : ""}`,
2415
2550
  `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, trace-only: ${config.action.intentTraceOnly}); 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}.`,
2416
2551
  formatLedger(ledger.snapshot()),
2417
2552
  ...(config.learning.patternAnalysis ? [`Learning: ${(await generateRecommendations(ctx.cwd)).length} recommendations, steer effectiveness ${Math.round((await analyzeSteerEffectivenessReport(ctx.cwd)).overall * 100)}% (use /warden recommend for details)`] : []),
@@ -2426,6 +2561,33 @@ export default function wardenExtension(pi) {
2426
2561
  ].join(" "));
2427
2562
  return;
2428
2563
  }
2564
+ if (action === "rules") {
2565
+ if (argument === "check") {
2566
+ const off = judgmentsOffReason(config);
2567
+ const result = await checkRules({ set: rulesGuard.store.load(ctx.cwd, config.rules), judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
2568
+ if (result.source === "skipped") {
2569
+ // No key, no consent, or a spent budget: say why and send nothing.
2570
+ report(`Rules check sent nothing: Jev judgments are off${off === "budget" ? " (the request budget is used up)" : ""}.${off && off !== "budget" ? ` ${judgmentsOffText(off, config.typesafeBackend, !ctx.hasUI)}` : ""} /warden rules shows the rule set locally.`);
2571
+ return;
2572
+ }
2573
+ report(formatRulesCheck(result));
2574
+ return;
2575
+ }
2576
+ report(rulesGuard.details(ctx.cwd, config.rules));
2577
+ return;
2578
+ }
2579
+ if (action === "report") {
2580
+ const match = /--days\s+(\d+)/.exec(tail);
2581
+ const days = match ? Number(match[1]) : REPORT_DEFAULT_DAYS;
2582
+ if (match && days < 1) {
2583
+ report("Usage: /warden report [--days N], where N is at least 1. Default: 30 days.", "warning");
2584
+ return;
2585
+ }
2586
+ const records = await readRulesLog(rulesLogPath(ctx.cwd));
2587
+ const set = rulesGuard.store.load(ctx.cwd, config.rules);
2588
+ report(formatRulesReport(buildRulesReport(records, { days, currentRules: set?.rules.map(rule => ({ id: rule.id, name: rule.name })) ?? [] })));
2589
+ return;
2590
+ }
2429
2591
  if (action === "trace") {
2430
2592
  if (!ctx.hasUI) {
2431
2593
  report(traceText());
@@ -2441,6 +2603,17 @@ export default function wardenExtension(pi) {
2441
2603
  void opened?.closed.then(fallback, fallback);
2442
2604
  return;
2443
2605
  }
2606
+ if (action === "unmute") {
2607
+ const kind = STEER_KINDS.find(name => name === argument);
2608
+ if (!kind) {
2609
+ report(`Usage: /warden unmute <kind> [model]. Kinds: ${STEER_KINDS.filter(name => !NEVER_MUTED.has(name)).join(", ")}.`, "warning");
2610
+ return;
2611
+ }
2612
+ const models = tokens[2] ? [tokens[2]] : modelKey(ctx) ? [modelKey(ctx)] : steerStats.modelsWith(kind);
2613
+ const reset = models.filter(model => steerStats.unmute(model, kind));
2614
+ report(reset.length ? `Reset ${kind} for ${reset.join(", ")}: its steers are sent again and counted from zero.` : `No counts for ${kind} on ${models.join(", ") || "any model"}; nothing to reset.`);
2615
+ return;
2616
+ }
2444
2617
  if (action === "loops") {
2445
2618
  const file = loopsFile(ctx);
2446
2619
  report(file ? formatLoopsForUser(await readLoops(file)) : "No loops: this session has no id to keep them under.");
@@ -2459,7 +2632,7 @@ export default function wardenExtension(pi) {
2459
2632
  report(`Usage: /warden prefs forget <n>, where n is an item number from /warden prefs (1 to ${items.length}).`, "warning");
2460
2633
  return;
2461
2634
  }
2462
- await writePrefsStore(path, forgetPref(await readPrefsStore(path), item));
2635
+ await changePrefsStore(path, store => ({ value: undefined, store: forgetPref(store, item) }));
2463
2636
  report(`Forgotten for this project: "${item.text}". It is not listed or injected again.`);
2464
2637
  return;
2465
2638
  }