pi-warden 0.28.4 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +158 -1
  2. package/README.md +5 -1
  3. package/dist/audit.d.ts +22 -0
  4. package/dist/audit.js +110 -0
  5. package/dist/config.d.ts +14 -0
  6. package/dist/config.js +12 -1
  7. package/dist/conscience-config.d.ts +21 -0
  8. package/dist/conscience-config.js +82 -0
  9. package/dist/conscience-loader.d.ts +26 -0
  10. package/dist/conscience-loader.js +82 -0
  11. package/dist/conscience-policy.d.ts +24 -0
  12. package/dist/conscience-policy.js +28 -0
  13. package/dist/conscience-privacy.d.ts +4 -0
  14. package/dist/conscience-privacy.js +11 -0
  15. package/dist/discover.d.ts +14 -0
  16. package/dist/discover.js +85 -0
  17. package/dist/extension.d.ts +1 -1
  18. package/dist/extension.js +261 -38
  19. package/dist/extension.js.bak +213 -35
  20. package/dist/guard.d.ts +127 -1
  21. package/dist/guard.js +447 -31
  22. package/dist/holds.d.ts +2 -0
  23. package/dist/holds.js +7 -1
  24. package/dist/index.d.ts +9 -3
  25. package/dist/index.js +5 -2
  26. package/dist/init.d.ts +36 -0
  27. package/dist/init.js +199 -0
  28. package/dist/laya-download.d.ts +40 -0
  29. package/dist/laya-download.js +288 -0
  30. package/dist/laya-judge.d.ts +62 -0
  31. package/dist/laya-judge.js +495 -0
  32. package/dist/learning.d.ts +30 -0
  33. package/dist/learning.js +53 -10
  34. package/dist/redact.js +9 -4
  35. package/dist/rules-file.d.ts +18 -0
  36. package/dist/rules-file.js +72 -0
  37. package/dist/rules.d.ts +8 -0
  38. package/dist/rules.js +54 -3
  39. package/dist/shape.js +10 -2
  40. package/dist/stuck.d.ts +9 -0
  41. package/dist/stuck.js +101 -0
  42. package/dist/tool-recommendation.d.ts +15 -0
  43. package/dist/tool-recommendation.js +31 -0
  44. package/dist/widget.d.ts +3 -1
  45. package/dist/widget.js +16 -13
  46. package/package.json +2 -2
package/dist/extension.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { rm } from "node:fs/promises";
3
+ import { join } from "node:path";
2
4
  import * as tuiModule from "@earendil-works/pi-tui";
3
5
  const MouseRegion = tuiModule.MouseRegion;
4
6
  import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
@@ -9,25 +11,28 @@ import { ArmingTracker, unparseableArmingRules } from "./arming.js";
9
11
  import * as configModule from "./config.js";
10
12
  import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
11
13
  import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome as recordDoneOutcome } from "./done.js";
12
- import { evaluateAction, formatVerdict, formatVerdictTokens, higher, inertPathRules, intentSteer, offTaskSteer, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
14
+ import { evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, offTaskSteer, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
13
15
  import { commandOf } from "./tools.js";
14
16
  import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
15
- import { initSchema, recordHold, recordOutcome, toHoldRecord, generateRecommendations, analyzeSteerEffectivenessReport } from "./learning.js";
17
+ import { initSchema, recordHold, recordOutcome, toHoldRecord, holdStats, generateRecommendations, analyzeSteerEffectivenessReport } from "./learning.js";
16
18
  import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_SHARE, RestatementWindow, substantiveSentences } from "./prose.js";
17
19
  import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice, CompressionLearner } from "./output.js";
18
20
  import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
19
21
  import { redact } from "./redact.js";
20
22
  import { formatRules, pathNoteSteer, RulesGuard, rulesSteer } from "./rules.js";
23
+ import { checkPiWardenMissing } from "./rules-file.js";
24
+ import { writeStarterRules, buildInitPrompt } from "./init.js";
25
+ import { buildAuditPrompt, findProjects, snapshotReport, reportOutcome } from "./audit.js";
21
26
  import { detectNotifier, sendNotification } from "./notify.js";
22
27
  import { formatRunaway, RunawayMonitor, runawayNudge } from "./runaway.js";
23
- import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
28
+ import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckDiff, stuckNudge } from "./stuck.js";
24
29
  import { openConfigPanel, openTracePanel } from "./panel.js";
25
30
  import { completeConfig, shapeWarning } from "./shape.js";
26
31
  import { ContextLedger, formatLedger } from "./saver.js";
27
32
  import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
28
33
  import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
29
- import { DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, SENTENCE_TEMPLATES, statusWidget, TOKEN_NAMES } from "./widget.js";
30
- 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); 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, reason) 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.";
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.";
31
36
  const WIDGET = PACKAGE_NAME;
32
37
  const CONFIRM_TEXT_LIMIT = 500;
33
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 });
@@ -126,6 +131,28 @@ function activeMode(config, hasUI) {
126
131
  const mode = isMode(env) ? env : config.mode;
127
132
  return mode === "confirm" && !hasUI ? "steer" : mode;
128
133
  }
134
+ /** Keep diagnostics intact while removing structured trace-only items at the agent boundary. */
135
+ function agentDeliveryReasons(verdict) {
136
+ const indexes = [
137
+ verdict.offTaskTraceOnly ? verdict.offTaskTraceOnlyReasonIndex : undefined,
138
+ verdict.shouldProceedTraceOnly ? verdict.shouldProceedTraceOnlyReasonIndex : undefined,
139
+ ].filter((index) => index !== undefined && index >= 0 && index < verdict.reasons.length);
140
+ if (!indexes.length)
141
+ return verdict.reasons;
142
+ return verdict.reasons.filter((_reason, reasonIndex) => !indexes.includes(reasonIndex));
143
+ }
144
+ /** Keep the full judgment while suppressing trace-only reasons and steer flags. */
145
+ function agentDeliveryVerdict(verdict) {
146
+ const reasons = agentDeliveryReasons(verdict);
147
+ if (reasons === verdict.reasons)
148
+ return verdict;
149
+ const deliveryVerdict = { ...verdict, reasons };
150
+ if (verdict.offTaskTraceOnly)
151
+ delete deliveryVerdict.offTaskSteer;
152
+ if (verdict.shouldProceedTraceOnly)
153
+ delete deliveryVerdict.shouldProceedSteer;
154
+ return deliveryVerdict;
155
+ }
129
156
  function clip(text) {
130
157
  return text.length <= CONFIRM_TEXT_LIMIT ? text : `${text.slice(0, CONFIRM_TEXT_LIMIT)}…`;
131
158
  }
@@ -202,7 +229,7 @@ export default function wardenExtension(pi) {
202
229
  const rulesGuard = new RulesGuard();
203
230
  // Hold feedback: what the user did after each judged call, the trace entry each label lands on, and the per-session log.
204
231
  const holds = new HoldLedger();
205
- const learningIds = new Map(); // holds.id -> learning.id
232
+ const learningIds = new Map(); // holds.id -> promise of learning.id
206
233
  // Prune learningIds when it grows large to prevent memory leaks
207
234
  function pruneLearningIds() {
208
235
  if (learningIds.size > 1000) {
@@ -223,9 +250,16 @@ export default function wardenExtension(pi) {
223
250
  // Allowed calls of the turn the user just replied to; the regret question about them rides the next action request.
224
251
  let regretCandidates = [];
225
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();
226
255
  let evidence = emptyEvidence();
227
256
  let doneNudged = false;
228
257
  let warnedFallback = false;
258
+ let warnedMissingRules = false;
259
+ /** True while /warden init is sending a prompt and waiting for the agent to generate pi-warden.md. */
260
+ let initRunning = false;
261
+ /** True while /warden audit is sending a prompt and waiting for the agent to write the report. */
262
+ let auditRunning = false;
229
263
  const prose = new ProseTrend();
230
264
  const slopCounts = { stub: 0, comments: 0, dead: 0, hedging: 0 };
231
265
  const ledger = new ContextLedger();
@@ -343,7 +377,20 @@ export default function wardenExtension(pi) {
343
377
  const color = LEVEL_COLOR[chip ?? ""] ?? "text";
344
378
  const chipText = chip ? `${theme.bold(theme.fg(color, chip.toUpperCase()))} ` : "";
345
379
  const guardText = theme.fg("muted", lastEntry.guard + " ");
346
- const body = { render: (width) => [chipText + guardText + sentence], invalidate: () => { } };
380
+ // Like widgetLines: the head width is tracked, the body wraps to what remains, and a line wider than
381
+ // the pane trips pi's render-width guard, which aborts the session.
382
+ const chipWidth = chip ? chip.length + 2 : 0;
383
+ const headWidth = chipWidth + lastEntry.guard.length + 1;
384
+ const head = chipText + guardText;
385
+ const body = {
386
+ render: (width) => {
387
+ if (!width)
388
+ return [head + sentence];
389
+ const wrapped = tuiModule.wrapTextWithAnsi(sentence, Math.max(10, width - headWidth));
390
+ return [head + (wrapped[0] ?? ""), ...wrapped.slice(1).map(rest => " ".repeat(headWidth) + rest)];
391
+ },
392
+ invalidate: () => { },
393
+ };
347
394
  return MouseRegion ? new MouseRegion(body, event => {
348
395
  if (event.type !== "click" || event.button !== "left")
349
396
  return undefined;
@@ -353,7 +400,7 @@ export default function wardenExtension(pi) {
353
400
  }, { placement: config.widget.placement });
354
401
  return;
355
402
  }
356
- entries = [entries.at(-1)];
403
+ entries = [lastEntry ?? entries.at(-1)];
357
404
  }
358
405
  // A custom component so the lines wrap to the pane and a click (fullscreen mode) opens the trace panel.
359
406
  // When the host TUI lacks MouseRegion (e.g. omp 18.2.5), the extension still loads — the widget
@@ -370,6 +417,7 @@ export default function wardenExtension(pi) {
370
417
  }, { placement: config.widget.placement });
371
418
  };
372
419
  const record = (ctx, config, guard, line, details, tokens) => {
420
+ widget.delete(guard);
373
421
  widget.set(guard, line);
374
422
  const entry = { at: Date.now(), guard, line, details, tokens };
375
423
  trace.push(entry);
@@ -382,6 +430,10 @@ export default function wardenExtension(pi) {
382
430
  const entry = traceOf.get(item);
383
431
  if (entry)
384
432
  trace.amend(entry, outcomeNote(item));
433
+ const idPromise = learningIds.get(item.id);
434
+ if (idPromise && item.outcome !== "pending") {
435
+ idPromise.then(id => recordOutcome(id, item.outcome)).catch(err => console.warn("pi-warden: recordOutcome failed:", err));
436
+ }
385
437
  }
386
438
  if (config.action.feedbackLog && holds.records().length)
387
439
  void holdLog?.save(holds.records());
@@ -484,6 +536,8 @@ export default function wardenExtension(pi) {
484
536
  client = undefined;
485
537
  budgetExhausted = false;
486
538
  warnedFallback = false;
539
+ warnedMissingRules = false;
540
+ initRunning = false;
487
541
  await initSchema(loadConfig().learning.retentionDays);
488
542
  stats = freshStats();
489
543
  widget.clear();
@@ -543,6 +597,7 @@ export default function wardenExtension(pi) {
543
597
  lastUi = ctx.ui;
544
598
  const config = configFor(ctx);
545
599
  attempts = new AttemptWindow(config.stuck.window);
600
+ fullOutputs = new Map();
546
601
  doneNudged = false;
547
602
  steersThisRun = 0;
548
603
  finals.reset();
@@ -614,6 +669,17 @@ export default function wardenExtension(pi) {
614
669
  if (!config.action.enabled || !config.action.tools.includes(event.toolName))
615
670
  return;
616
671
  stats.inspected++;
672
+ // First-run warning: pi-warden.md missing, one time per session.
673
+ if (!warnedMissingRules && config.rules.enabled) {
674
+ const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd);
675
+ if (missing && ctx.hasUI) {
676
+ warnedMissingRules = true;
677
+ const msg = fallbackSource
678
+ ? `No pi-warden.md detected. Using ${fallbackSource} as active fallback rules. Run /warden init to create project-specific rules.`
679
+ : `No rules file detected (pi-warden.md, README.md, CLAUDE.md, or AGENTS.md). Run /warden init to create project-specific rules.`;
680
+ ctx.ui.notify(msg, "warning");
681
+ }
682
+ }
617
683
  // Arming: a write/edit to a protected path arms matching command patterns for a window. Bash redirect/tee
618
684
  // targets that match a when.edited glob also arm. This is session state, not a per-call verdict — it runs
619
685
  // before the action guard so the armed check on a later command sees the preparation.
@@ -683,6 +749,8 @@ export default function wardenExtension(pi) {
683
749
  if (verdict.source === "read-only")
684
750
  verdict.source = "pattern";
685
751
  }
752
+ const deliveryVerdict = agentDeliveryVerdict(verdict);
753
+ const deliveryReasons = deliveryVerdict.reasons;
686
754
  if (regretCandidates.length && verdict.judgment?.regretted !== undefined) {
687
755
  settleRegret(config, { regretted: regretsAt(verdict.judgment.regretted), target: verdict.judgment.regretTarget, probability: verdict.judgment.regretted, via: "jev" });
688
756
  }
@@ -704,12 +772,9 @@ export default function wardenExtension(pi) {
704
772
  const released = holds.approved(event.toolName);
705
773
  if (released) {
706
774
  noteOutcomes(config, [released]);
707
- const learningId = learningIds.get(released.id);
708
- if (learningId)
709
- recordOutcome(learningId, "approved").catch(err => console.warn("pi-warden: recordOutcome failed:", err));
710
775
  }
711
776
  }
712
- const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
777
+ const told = verdict.level === "confirm" && mode === "steer" ? steerReason(deliveryVerdict, { canApprove: judge !== undefined }) : undefined;
713
778
  // SAFETY: verdict.source is a string union; the read-only → pattern rewrite above may have narrowed it in TS's view,
714
779
  // but the field is still one of the source values at runtime when no armed hits fired.
715
780
  const actionFmt = formatVerdictTokens(verdict, config.widget.action);
@@ -727,19 +792,17 @@ export default function wardenExtension(pi) {
727
792
  if (ctxSummary)
728
793
  recordOpts.contextSummary = ctxSummary;
729
794
  if (held)
730
- recordOpts.agentReason = steerReason(verdict, { canApprove: judge !== undefined });
795
+ recordOpts.agentReason = steerReason(deliveryVerdict, { canApprove: judge !== undefined });
731
796
  const item = holds.record(verdict, recordOpts);
732
797
  traceOf.set(item, entry);
798
+ // Record to SQLite for learning (held and judged-allowed calls).
799
+ // The promise and map entry must exist before noteOutcomes so that an outcome
800
+ // known at record time (e.g. dialog-approved) is not lost to the race.
801
+ const preview = redact(verdict.summary.command ?? verdict.summary.path ?? "");
802
+ const holdPromise = recordHold(toHoldRecord({ at: item.at, tool: item.tool, level: item.level, reasons: item.reasons, scores: item.scores, held }, ctx.cwd, { task: task ? redact(task) : task, plan: verdict.plan, contextSummary: ctxSummary, agentReason: held ? steerReason(deliveryVerdict, { canApprove: judge !== undefined }) : undefined, preview })).catch(err => { console.warn("pi-warden: recordHold failed:", err); return -1; });
803
+ learningIds.set(item.id, holdPromise);
804
+ pruneLearningIds();
733
805
  noteOutcomes(config, outcome ? [item] : []);
734
- // Record to SQLite for learning
735
- if (held) {
736
- recordHold(toHoldRecord({ at: item.at, tool: item.tool, level: item.level, reasons: item.reasons, scores: item.scores }, ctx.cwd, { task: task ? redact(task) : task, plan: verdict.plan, contextSummary: ctxSummary, agentReason: steerReason(verdict, { canApprove: judge !== undefined }) })).then(id => {
737
- learningIds.set(item.id, id);
738
- if (outcome)
739
- recordOutcome(id, outcome).catch(err => console.warn("pi-warden: recordOutcome failed:", err));
740
- }).catch(err => console.warn("pi-warden: recordHold failed:", err));
741
- pruneLearningIds();
742
- }
743
806
  };
744
807
  // The warn notice below names the mismatch to the user; the agent gets the steer with the other notes.
745
808
  if (verdict.intentMismatch) {
@@ -749,8 +812,14 @@ export default function wardenExtension(pi) {
749
812
  }
750
813
  if (verdict.offTaskSteer) {
751
814
  stats.offTask++;
815
+ if (!verdict.offTaskTraceOnly) {
816
+ noteGuards.add("action");
817
+ notes.push(offTaskSteer(verdict));
818
+ }
819
+ }
820
+ if (verdict.shouldProceedSteer && !verdict.shouldProceedTraceOnly) {
752
821
  noteGuards.add("action");
753
- notes.push(offTaskSteer(verdict));
822
+ notes.push(shouldProceedMessage(verdict));
754
823
  }
755
824
  if (verdict.slopSymptoms?.length && verdict.slopReasons) {
756
825
  stats.slop++;
@@ -800,13 +869,15 @@ export default function wardenExtension(pi) {
800
869
  traceNote(delivered);
801
870
  pathNoteTraces.length = 0;
802
871
  }
803
- const warnSteer = (label) => steer(config, "action", `pi-warden: this ${event.toolName} call ran with a warning (${label}). 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.`);
872
+ 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.`);
804
873
  if (verdict.level === "warn") {
805
874
  stats.warned++;
806
875
  if (ctx.hasUI && config.notices)
807
876
  ctx.ui.notify(`warden · ${event.toolName}: ${verdict.reasons.join("; ")}`, "warning");
808
- else if (!ctx.hasUI)
809
- warnSteer(verdict.reasons.join("; "));
877
+ else if (!ctx.hasUI) {
878
+ const otherReasons = deliveryReasons.filter(r => !r.startsWith("should-proceed "));
879
+ warnSteer(otherReasons);
880
+ }
810
881
  track(false);
811
882
  return undefined;
812
883
  }
@@ -816,13 +887,14 @@ export default function wardenExtension(pi) {
816
887
  if (ctx.hasUI && config.notices)
817
888
  ctx.ui.notify(`warden · blocked ${event.toolName}: ${verdict.reasons.join("; ")}`, "error");
818
889
  notifyDesktop(ctx, config, `Blocked ${event.toolName}: ${verdict.reasons.join("; ")}. A deny rule matched; the call never ran.`);
819
- 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.` };
890
+ return { block: true, reason: `pi-warden blocked this ${event.toolName} call (${deliveryReasons.join("; ")}). A deny rule matched; this command is not allowed to run. Ask the user if this is genuinely required.` };
820
891
  }
821
892
  if (verdict.level !== "confirm") {
822
893
  track(false);
823
894
  return undefined;
824
895
  }
825
896
  const reasons = verdict.reasons.join("; ");
897
+ const deliveredReasons = deliveryReasons.join("; ");
826
898
  // A user-defined confirm rule prompts the user in every mode, advise included: the operator wrote the rule to be
827
899
  // asked. Advise mode keeps Jev holds advisory; it does not soften a prompt the operator asked for by name.
828
900
  // The action defaults to dialog at parse time (the reason one writes such a rule); hold restores steer semantics.
@@ -836,14 +908,14 @@ export default function wardenExtension(pi) {
836
908
  }
837
909
  stats.held++;
838
910
  track(true, "declined", "dialog");
839
- 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.` };
911
+ return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${deliveredReasons}). Do not retry it unchanged; ask the user how to proceed.` };
840
912
  }
841
913
  if (mode === "advise") {
842
914
  stats.warned++;
843
915
  if (ctx.hasUI && config.notices)
844
916
  ctx.ui.notify(`warden · ${event.toolName} (advise mode, not held): ${reasons}`, "warning");
845
917
  else if (!ctx.hasUI)
846
- warnSteer(reasons);
918
+ warnSteer(deliveryReasons);
847
919
  track(false);
848
920
  return undefined;
849
921
  }
@@ -856,7 +928,7 @@ export default function wardenExtension(pi) {
856
928
  }
857
929
  stats.held++;
858
930
  track(true, "declined", "dialog");
859
- 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.` };
931
+ return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${deliveredReasons}). Do not retry it unchanged; ask the user how to proceed.` };
860
932
  }
861
933
  stats.held++;
862
934
  actionGuard.hold(task);
@@ -864,7 +936,7 @@ export default function wardenExtension(pi) {
864
936
  if (ctx.hasUI && config.notices)
865
937
  ctx.ui.notify(`warden · held ${event.toolName}: ${reasons}. The agent was told why and asked to re-plan or ask you.`, "warning");
866
938
  notifyDesktop(ctx, config, `Held ${event.toolName}: ${reasons}. The agent will re-plan or ask you in chat.`);
867
- return { block: true, reason: told ?? steerReason(verdict, { canApprove: judge !== undefined }) };
939
+ return { block: true, reason: told ?? steerReason(deliveryVerdict, { canApprove: judge !== undefined }) };
868
940
  });
869
941
  pi.on("tool_result", async (event, ctx) => {
870
942
  const config = configFor(ctx);
@@ -1060,11 +1132,53 @@ export default function wardenExtension(pi) {
1060
1132
  `output sample: ${redact(text).slice(0, 300)}`, delivered ? `agent told: ${notice}` : `steer recorded, not delivered (a repeat or the per-run budget): ${notice}`,
1061
1133
  ]);
1062
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
+ }
1063
1178
  const patch = content === event.content ? undefined : { content };
1064
1179
  // Checks use the original result, not the excerpts or security banner.
1065
1180
  if (config.done.enabled)
1066
1181
  recordDoneOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
1067
- const verdict = await stuckCheck;
1068
1182
  if (!verdict)
1069
1183
  return patch;
1070
1184
  if (verdict.error)
@@ -1077,7 +1191,7 @@ export default function wardenExtension(pi) {
1077
1191
  return patch;
1078
1192
  stats.stuck++;
1079
1193
  if (ctx.hasUI && config.notices)
1080
- 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");
1081
1195
  if (nudge)
1082
1196
  steer(config, "stuck", nudge);
1083
1197
  return patch;
@@ -1086,6 +1200,17 @@ export default function wardenExtension(pi) {
1086
1200
  pi.on("agent_settled", async (_event, ctx) => {
1087
1201
  await checkSubagentReports(ctx, configFor(ctx));
1088
1202
  });
1203
+ // Block new user messages while /warden init or /warden audit is running.
1204
+ pi.on("input", async (event, ctx) => {
1205
+ if (initRunning) {
1206
+ ctx.ui.notify("pi-warden is generating rules. Please wait...", "warning");
1207
+ return { action: "handled" };
1208
+ }
1209
+ if (auditRunning) {
1210
+ ctx.ui.notify("pi-warden is running an audit. Please wait...", "warning");
1211
+ return { action: "handled" };
1212
+ }
1213
+ });
1089
1214
  pi.on("agent_end", async (event, ctx) => {
1090
1215
  const config = configFor(ctx);
1091
1216
  if (!config.enabled)
@@ -1182,7 +1307,7 @@ export default function wardenExtension(pi) {
1182
1307
  togglePanel(ctx.ui, configFor(ctx)); },
1183
1308
  });
1184
1309
  }
1185
- const actions = ["status", "enable", "disable", "mode", "config", "test", "trace"];
1310
+ const actions = ["status", "enable", "disable", "mode", "config", "test", "trace", "init", "audit"];
1186
1311
  pi.registerCommand("warden", {
1187
1312
  description: "pi-warden status, config (set/get/editor), TypeSafe consent, mode, trace panel, recommend, and a synthetic guard test",
1188
1313
  getArgumentCompletions(prefix) {
@@ -1204,6 +1329,10 @@ export default function wardenExtension(pi) {
1204
1329
  const source = consentSource(config);
1205
1330
  const usage = client?.getUsage();
1206
1331
  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(", ");
1332
+ const ls = await holdStats(ctx.cwd);
1333
+ const lifetimeLine = ls.labeled === 0
1334
+ ? `Lifetime here: ${ls.held} hold${ls.held === 1 ? "" : "s"}, not yet measurable (${ls.allowed} allowed).`
1335
+ : `Lifetime here: ${ls.held} hold${ls.held === 1 ? "" : "s"}, ${ls.labeled} labeled, ${ls.declined + ls.replanned} stood (${ls.declined + ls.replanned}/${ls.labeled}), ${ls.allowed} allowed (${ls.accepted} accepted, ${ls.regretted} regretted).`;
1207
1336
  report([
1208
1337
  `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}`,
1209
1338
  `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`}.`,
@@ -1212,6 +1341,7 @@ export default function wardenExtension(pi) {
1212
1341
  formatLedger(ledger.snapshot()),
1213
1342
  ...(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)`] : []),
1214
1343
  `${formatHolds(holds.snapshot(), config.action.feedbackLog ? holdLog?.path : undefined)}${holdLog?.lastFailure ? ` Log write failed: ${holdLog.lastFailure}.` : ""}`,
1344
+ lifetimeLine,
1215
1345
  `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"}.`,
1216
1346
  ...(config.action.armingRules.length > 0 ? [`Arming: ${arming.statusLine() || "no rules armed"}.`] : []),
1217
1347
  `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)"}.`,
@@ -1339,14 +1469,107 @@ export default function wardenExtension(pi) {
1339
1469
  openConfigPanel(lastUi, config, { width: config.widget.panelWidth });
1340
1470
  return;
1341
1471
  }
1472
+ if (action === "init") {
1473
+ const targetPath = join(ctx.cwd, "pi-warden.md");
1474
+ const force = argument === "--force";
1475
+ if (existsSync(targetPath)) {
1476
+ if (ctx.hasUI && !force) {
1477
+ if (!await ctx.ui.confirm("pi-warden.md already exists. Overwrite with a fresh starter template?", `This replaces ${targetPath} with a generic starter. Your current rules will be lost.`)) {
1478
+ report("Cancelled. Existing pi-warden.md unchanged.");
1479
+ return;
1480
+ }
1481
+ }
1482
+ else if (!force) {
1483
+ report(`pi-warden.md already exists at ${targetPath}. Pass --force to overwrite.`, "warning");
1484
+ return;
1485
+ }
1486
+ }
1487
+ const prompt = buildInitPrompt(ctx.cwd);
1488
+ initRunning = true;
1489
+ if (ctx.hasUI)
1490
+ ctx.ui.notify("pi-warden: Generating rules file...", "info");
1491
+ try {
1492
+ // sendUserMessage throws when the agent is not idle. Brief wait so a
1493
+ // just-closing confirm dialog does not cause a race.
1494
+ for (let attempt = 0; attempt < 40; attempt++) {
1495
+ if (ctx.isIdle())
1496
+ break;
1497
+ await new Promise(resolve => setTimeout(resolve, 250));
1498
+ }
1499
+ pi.sendUserMessage(prompt);
1500
+ await ctx.waitForIdle();
1501
+ }
1502
+ catch (err) {
1503
+ const detail = err instanceof Error ? err.message : String(err);
1504
+ report(`pi-warden init failed: ${detail}. Try creating pi-warden.md manually.`, "error");
1505
+ }
1506
+ finally {
1507
+ initRunning = false;
1508
+ }
1509
+ const wardenExists = existsSync(join(ctx.cwd, "pi-warden.md"));
1510
+ report(wardenExists ? "pi-warden.md created. Review the rules and edit as needed." : "Agent did not create pi-warden.md. Create it manually or try /warden init again.");
1511
+ return;
1512
+ }
1513
+ if (action === "audit") {
1514
+ if (!ctx.hasUI) {
1515
+ report("Audit needs an interactive session to run.", "warning");
1516
+ return;
1517
+ }
1518
+ if (!await ctx.ui.confirm("Run workspace audit?", `This runs an agent-driven audit of ${redact(ctx.cwd)}. It uses the session model, reads source code, and writes a report. It may take several minutes and use real tokens. Measured comparisons need the agent's TypeSafe tool, which is enabled per session with \`/typesafe enable\`; without it findings will be marked unmeasured.`))
1519
+ return;
1520
+ const projects = findProjects(ctx.cwd);
1521
+ const prompt = buildAuditPrompt(ctx.cwd, projects);
1522
+ const reportPath = join(ctx.cwd, ".pi-warden", "audit-report.html");
1523
+ const preSnapshot = snapshotReport(reportPath);
1524
+ auditRunning = true;
1525
+ if (ctx.hasUI)
1526
+ ctx.ui.notify("pi-warden: Running workspace audit...", "info");
1527
+ try {
1528
+ for (let attempt = 0; attempt < 40; attempt++) {
1529
+ if (ctx.isIdle())
1530
+ break;
1531
+ await new Promise(resolve => setTimeout(resolve, 250));
1532
+ }
1533
+ pi.sendUserMessage(prompt);
1534
+ await ctx.waitForIdle();
1535
+ }
1536
+ catch (err) {
1537
+ const detail = err instanceof Error ? err.message : String(err);
1538
+ report(`pi-warden audit failed: ${detail}.`, "error");
1539
+ }
1540
+ finally {
1541
+ auditRunning = false;
1542
+ }
1543
+ const outcome = reportOutcome(preSnapshot, snapshotReport(reportPath));
1544
+ switch (outcome) {
1545
+ case "written":
1546
+ report(`Audit report written to ${reportPath}`);
1547
+ break;
1548
+ case "stale":
1549
+ report("Audit finished but the report file was not updated — the agent may have reported findings in chat instead.");
1550
+ break;
1551
+ case "missing":
1552
+ report("Agent did not write an audit report. The model may have reported findings in chat instead.");
1553
+ break;
1554
+ }
1555
+ return;
1556
+ }
1342
1557
  if (action === "test") {
1343
1558
  const judge = judgeFor(config);
1344
- 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)}`))
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)}`))
1345
1560
  return;
1346
- 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 });
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 });
1562
+ const deliveryVerdict = ctx.hasUI ? verdict : agentDeliveryVerdict(verdict);
1563
+ const deliveryReasons = deliveryVerdict.reasons;
1347
1564
  const fmt = formatVerdictTokens(verdict, config.widget.action);
1348
1565
  record(ctx, config, "action", fmt.line, actionDetails(verdict, { mode: activeMode(config, ctx.hasUI) }), fmt.tokens);
1349
- report(`${formatVerdict(verdict)}${verdict.reasons.length ? ` — ${verdict.reasons.join("; ")}` : ""}${judge ? "" : " (pattern checks only: TypeSafe judgments are not enabled or no key is configured)"}${verdict.error ? ` — ${verdict.error}` : ""}`);
1566
+ const deliveryTokens = actionTokens(deliveryVerdict);
1567
+ if (!ctx.hasUI && verdict.offTaskTraceOnly) {
1568
+ // Redact only off-task presentation tokens; retain the judgment for other consumers.
1569
+ delete deliveryTokens.offTask;
1570
+ delete deliveryTokens.scope;
1571
+ }
1572
+ report(`${renderTemplate(DEFAULT_TEMPLATES.action, deliveryTokens)}${deliveryReasons.length ? ` — ${deliveryReasons.join("; ")}` : ""}${judge ? "" : " (pattern checks only: TypeSafe judgments are not enabled or no key is configured)"}${verdict.error ? ` — ${verdict.error}` : ""}`);
1350
1573
  if (verdict.level === "confirm") {
1351
1574
  const mode = activeMode(config, ctx.hasUI);
1352
1575
  if (mode === "confirm" && ctx.hasUI) {
@@ -1354,7 +1577,7 @@ export default function wardenExtension(pi) {
1354
1577
  report(allowed ? "Demo: you chose Yes, so a real call would have run." : "Demo: you chose No, so a real call would have been blocked and the agent told why.");
1355
1578
  }
1356
1579
  else {
1357
- report(`In ${mode} mode a real call would ${mode === "advise" ? "run with this warning shown to you" : "be held and the agent would read"}: "${steerReason(verdict, { canApprove: judge !== undefined })}"`);
1580
+ report(`In ${mode} mode a real call would ${mode === "advise" ? "run with this warning shown to you" : "be held and the agent would read"}: "${steerReason(deliveryVerdict, { canApprove: judge !== undefined })}"`);
1358
1581
  }
1359
1582
  }
1360
1583
  return;