pi-warden 0.71.0 → 0.74.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 (51) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/adaptive.d.ts +3 -2
  3. package/dist/adaptive.js +4 -3
  4. package/dist/backend.d.ts +33 -14
  5. package/dist/backend.js +27 -14
  6. package/dist/config.d.ts +13 -8
  7. package/dist/config.js +20 -20
  8. package/dist/conscience.d.ts +1 -1
  9. package/dist/extension.d.ts +14 -3
  10. package/dist/extension.js +410 -64
  11. package/dist/guard.d.ts +2 -1
  12. package/dist/guard.js +4 -4
  13. package/dist/holds.d.ts +1 -1
  14. package/dist/holds.js +3 -2
  15. package/dist/host-compat.d.ts +2 -0
  16. package/dist/host-compat.js +132 -0
  17. package/dist/host-dirs.d.ts +10 -0
  18. package/dist/host-dirs.js +21 -0
  19. package/dist/index-cmd.d.ts +6 -5
  20. package/dist/index-cmd.js +16 -24
  21. package/dist/index.d.ts +12 -4
  22. package/dist/index.js +6 -2
  23. package/dist/init.js +31 -3
  24. package/dist/learning.d.ts +12 -11
  25. package/dist/learning.js +60 -38
  26. package/dist/loops.d.ts +2 -1
  27. package/dist/loops.js +3 -2
  28. package/dist/panel.d.ts +4 -1
  29. package/dist/panel.js +11 -6
  30. package/dist/prefs.d.ts +1 -1
  31. package/dist/prefs.js +3 -2
  32. package/dist/rules-audit.d.ts +208 -0
  33. package/dist/rules-audit.js +417 -0
  34. package/dist/rules-calibrate.d.ts +196 -0
  35. package/dist/rules-calibrate.js +474 -0
  36. package/dist/rules-lint.d.ts +2 -0
  37. package/dist/rules-lint.js +7 -2
  38. package/dist/rules-log.d.ts +6 -1
  39. package/dist/rules-log.js +9 -3
  40. package/dist/rules-report.d.ts +3 -0
  41. package/dist/rules-report.js +5 -0
  42. package/dist/rules.d.ts +72 -5
  43. package/dist/rules.js +116 -54
  44. package/dist/shape.js +8 -2
  45. package/dist/turn-rules.d.ts +86 -0
  46. package/dist/turn-rules.js +232 -0
  47. package/dist/widget-render.d.ts +23 -0
  48. package/dist/widget-render.js +82 -0
  49. package/dist/widget.d.ts +5 -22
  50. package/dist/widget.js +3 -80
  51. package/package.json +5 -3
package/dist/extension.js CHANGED
@@ -1,17 +1,20 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
2
2
  import { rm } from "node:fs/promises";
3
3
  import { join, resolve as pathResolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { defaultHostDirs } from "./host-dirs.js";
5
7
  import * as tuiModule from "@earendil-works/pi-tui";
6
8
  const MouseRegion = tuiModule.MouseRegion;
7
- import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
9
+ import { authState, backendHost, createTypeSafe, describeAuth, resolveBackend } from "pi-typesafe";
8
10
  import { ensureApiKey } from "pi-typesafe/ui";
9
- import { backendHost, disclosureFor, judgeOptions, keyEnvFor, loginStoresKey, resolveBackend } from "./backend.js";
11
+ import { backendName, describeBackend, disclosureFor, judgeOptions, loginStoresKey } from "./backend.js";
10
12
  import { ActionGuard } from "./action-guard.js";
11
- import { assistantView, formatMuted, NEVER_MUTED, STEER_KINDS, SteerStats, SteerWatch } from "./adaptive.js";
13
+ import { adaptHost } from "./host-compat.js";
14
+ import { assistantView, formatMuted, NEVER_MUTED, STEER_KINDS, SteerStats, SteerWatch, steerStatsPath } from "./adaptive.js";
12
15
  import { ArmingTracker, unparseableArmingRules } from "./arming.js";
13
16
  import * as configModule from "./config.js";
14
- import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
17
+ import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, PROJECT_CONFIG_FILE, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
15
18
  import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, isVisualCheck, needsDoneCheck, recordOutcome as recordDoneOutcome, recordUi } from "./done.js";
16
19
  import { createdScratch, evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, largeOutputNotice, offTaskSteer, pruneScratch, scratchCandidates, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, wardenHostPaths, writeSinkTargets, isVisibleCommand } from "./guard.js";
17
20
  import { commandOf } from "./tools.js";
@@ -22,8 +25,11 @@ import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_S
22
25
  import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice, CompressionLearner } from "./output.js";
23
26
  import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
24
27
  import { maskSecrets, redact } from "./redact.js";
25
- import { formatRules, pathNoteSteer, projectPath, RulesGuard, rulesSteer, RULES_FILE, FALLBACK_FILES } from "./rules.js";
28
+ import { formatRules, pathNoteSteer, projectPath, RulesGuard, rulesSteer, RULES_FILE, FALLBACK_FILES, turnRulesFor } from "./rules.js";
29
+ import { evaluateTurnRun, SHELL_RULES_CHECKS, snapshotTree, turnSteer } from "./turn-rules.js";
26
30
  import { checkRules, formatRulesCheck } from "./rules-lint.js";
31
+ import { parseRulesAuditArgs, parseBenchArgs, runRulesAudit, runBench, formatRulesAudit, formatBench } from "./rules-audit.js";
32
+ import { calibrate, calibrateGate, calibrationNotice, collectHistory, formatCalibration, planCalibration, tuneRequest, CALIBRATE_DEFAULT_COMMITS, CALIBRATE_DEFAULT_MAX } from "./rules-calibrate.js";
27
33
  import { readRulesLog, RulesLog, rulesLogPath } from "./rules-log.js";
28
34
  import { buildRulesReport, formatRulesReport, REPORT_DEFAULT_DAYS } from "./rules-report.js";
29
35
  import { checkPiWardenMissing } from "./rules-file.js";
@@ -48,12 +54,11 @@ import { changePrefsStore, emptyWordCounts, evaluatePrefs, forgetPref, formatPre
48
54
  import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
49
55
  import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
50
56
  import { TraceFile, judgmentsState, traceDir, traceFilePath } from "./trace-file.js";
51
- import { actionTokens, DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, rulesTokens, SENTENCE_TEMPLATES, statusWidget, TOKEN_NAMES } from "./widget.js";
57
+ import { actionTokens, DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, rulesTokens, SENTENCE_TEMPLATES, TOKEN_NAMES } from "./widget.js";
58
+ import { statusWidget } from "./widget-render.js";
52
59
  export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request, the task spine it is judged against (the first request of the thread and up to four redacted earlier requests), and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, with the agent's own words from the message that makes the call (its stated plan); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; for a write or edit (or a bash command that writes a file with its content in the command) 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. For the conscience coach (recommend mode): your current request (2000 redacted characters), the same task spine (the first request of the thread and up to four redacted earlier requests), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. 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.";
53
60
  const WIDGET = PACKAGE_NAME;
54
61
  const CONFIRM_TEXT_LIMIT = 500;
55
- /** Rules requests one bash command may start; the other files it writes are recorded as skipped. */
56
- const SHELL_RULES_CHECKS = 5;
57
62
  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 });
58
63
  /**
59
64
  * One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
@@ -196,10 +201,11 @@ function planForCall(entries) {
196
201
  * variable that holds a rejected key, or undefined when the rejected key is the one `/typesafe login` saved. Never
197
202
  * names a key value or a path.
198
203
  */
199
- export function judgmentsOffText(reason, backend, headless, rejectedEnv) {
204
+ export function judgmentsOffText(reason, setting, headless, rejectedEnv) {
200
205
  switch (reason) {
201
206
  case "no_consent": return `warden: Jev judgments are off (no consent). ${headless ? "Set PI_WARDEN_ENABLED=1." : "Run /warden enable."}`;
202
- case "no_key": return `warden: Jev judgments are off (no key for ${backend}). Set ${keyEnvFor(backend)}${loginStoresKey(backend) ? " or run /typesafe login" : ""}.`;
207
+ case "bad_backend": return `warden: Jev judgments are off (typesafeBackend refused: ${setting.backendRefusal ?? "the configured value is not usable"})`;
208
+ case "no_key": return `warden: Jev judgments are off (no key for ${backendName(setting.typesafeBackend)}). Set ${resolveBackend(setting.typesafeBackend).keyEnv}${loginStoresKey(setting.typesafeBackend) ? " or run /typesafe login" : ""}.`;
203
209
  case "key_rejected": return rejectedEnv
204
210
  ? `warden: Jev judgments are off (the key in ${rejectedEnv} was rejected). Check the key, then run /warden status.`
205
211
  : "warden: Jev judgments are off (the key saved by /typesafe login was rejected). Run /typesafe login.";
@@ -304,7 +310,50 @@ export function _testSetIndexRunning(running, paths = []) {
304
310
  indexRunning = running;
305
311
  indexWritePaths = paths;
306
312
  }
307
- export default function wardenExtension(pi) {
313
+ /* ─── One-time migration notice ─────────────────────────────────────── */
314
+ const MIGRATION_MARKER = ".pi-warden-migration-notice-shown";
315
+ /** Single-quote a path for /bin/sh; an embedded quote ends the string, escapes itself, and reopens. */
316
+ function shellQuote(value) {
317
+ return `'` + value.replaceAll("'", `'\\''`) + `'`;
318
+ }
319
+ /** The absolute, symlink-free, on-disk-case form of a directory; a missing directory falls back to its absolute path. */
320
+ function canonicalDirectory(path) {
321
+ try {
322
+ return realpathSync.native(path);
323
+ }
324
+ catch {
325
+ return pathResolve(path);
326
+ }
327
+ }
328
+ /**
329
+ * The one-time migration message for a host whose agent directory is not Pi's default, when data
330
+ * still lives only under the default and this host has not shown the notice yet: undefined
331
+ * otherwise. The caller checks the filesystem for data and the marker. The directories are compared
332
+ * in absolute, symlink-free form: the same directory under another spelling (trailing slash, `.`
333
+ * segment, symlink) gets no notice, because the command would move the only copy aside. The command
334
+ * moves the fresh data this host may have created aside (non-destructive, and works when the target
335
+ * folder does not exist) and copies the legacy data whole in its place.
336
+ */
337
+ export function legacyDataNotice(dirs, piDefault, exists) {
338
+ const agentDir = canonicalDirectory(dirs.agentDir);
339
+ const piAgentDir = canonicalDirectory(piDefault.agentDir);
340
+ if (agentDir === piAgentDir)
341
+ return undefined;
342
+ const legacy = join(piAgentDir, PACKAGE_NAME);
343
+ if (!exists(legacy))
344
+ return undefined;
345
+ const target = join(agentDir, PACKAGE_NAME);
346
+ if (exists(join(agentDir, MIGRATION_MARKER)))
347
+ return undefined;
348
+ const command = `{ [ ! -e ${shellQuote(target)} ] || mv ${shellQuote(target)} ${shellQuote(`${target}.before-migration`)}; } && cp -R ${shellQuote(legacy)} ${shellQuote(target)}`;
349
+ return `pi-warden now keeps its data in ${target}. Your earlier config and learning data are in ${legacy}. To keep them, close all Pi and oh-my-pi sessions, then run \`${command}\`. Also copy each project's ${join(piDefault.configDirName, PROJECT_CONFIG_FILE)} to ${join(dirs.configDirName, PROJECT_CONFIG_FILE)} and keep the original.`;
350
+ }
351
+ export default function wardenExtension(host) {
352
+ const pi = adaptHost(host);
353
+ // The host boundary: this module already imports the Pi peer at runtime (via load.js), so it reads
354
+ // the host's agent directory here once and passes it to every library path call below. The library
355
+ // modules stay free of runtime Pi imports.
356
+ const dirs = { agentDir: getAgentDir(), configDirName: CONFIG_DIR_NAME };
308
357
  // The beta policy is held in this closure and passed to the activation gate on every delivery
309
358
  // check; there is no module state. A question-wording change breaks the hash test and the gate
310
359
  // fail-closes (no delivery) until the policy is re-measured.
@@ -362,12 +411,20 @@ export default function wardenExtension(pi) {
362
411
  let fullOutputs = new Map();
363
412
  let evidence = emptyEvidence();
364
413
  let doneNudged = false;
414
+ // Turn rules: the read-only working-tree snapshot started at run start and awaited at run end, and the files a
415
+ // per-edit judgment saw.
416
+ let turnSnapshot;
417
+ let turnJudged = new Set();
365
418
  /** True from a delivered warden follow-up that starts a turn until the next run starts: that run keeps the evidence. */
366
419
  let wardenContinuation = false;
367
420
  let warnedFallback = false;
368
421
  let warnedMissingRules = false;
369
422
  /** True while /warden init is sending a prompt and waiting for the agent to generate pi-warden.md. */
370
423
  let initRunning = false;
424
+ /** The most recent /warden rules calibrate result; /warden rules tune reads its flagged rules. */
425
+ let lastCalibration;
426
+ /** The most recent /warden rules check result of this session; /warden rules tune reads its flagged rules. */
427
+ let lastCheck;
371
428
  /** True while /warden audit is sending a prompt and waiting for the agent to write the report. */
372
429
  let auditRunning = false;
373
430
  const prose = new ProseTrend();
@@ -412,7 +469,7 @@ export default function wardenExtension(pi) {
412
469
  /** The scan, the project's lesson file, and every listed item with its status; read fresh so `forget` numbers match. */
413
470
  const evaluatedPrefs = async (ctx) => {
414
471
  const scan = await standingPrefs(ctx);
415
- const path = prefsStorePath(scan.project);
472
+ const path = prefsStorePath(scan.project, dirs);
416
473
  return { scan, path, items: evaluatePrefs(scan, await readPrefsStore(path)) };
417
474
  };
418
475
  // Assistant turns this session, and the turn of the last user correction or stuck, repeat, or done-check steer in this
@@ -426,7 +483,7 @@ export default function wardenExtension(pi) {
426
483
  const loopsFile = (ctx) => {
427
484
  const manager = ctx.sessionManager;
428
485
  const id = typeof manager?.getSessionId === "function" ? manager.getSessionId() : undefined;
429
- return id ? loopsPath(ctx.cwd, id) : undefined;
486
+ return id ? loopsPath(ctx.cwd, id, dirs) : undefined;
430
487
  };
431
488
  /**
432
489
  * On resume, one message lists the loops still open, so a session picked up later knows what it promised. Not a steer.
@@ -457,7 +514,7 @@ export default function wardenExtension(pi) {
457
514
  let inertReported = false;
458
515
  let unparseableReported = false;
459
516
  const configFor = (ctx) => {
460
- const { config, missing } = guardCurrentSections(completeConfig(loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() })));
517
+ const { config, missing } = guardCurrentSections(completeConfig(loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted(), dirs })));
461
518
  if (missing.length && !shapeReported) {
462
519
  shapeReported = true;
463
520
  // A namespace read stays undefined (not a link error) when an older config module lacks the export.
@@ -501,11 +558,15 @@ export default function wardenExtension(pi) {
501
558
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
502
559
  /** Why no judge is available, or undefined when one is. The notice, the trace file, and the conscience all use it. */
503
560
  const judgmentsOffReason = (config) => {
561
+ // A refused backend is a config error the user must see even before consent or keys matter.
562
+ const backend = config.typesafeBackend;
563
+ if (config.backendRefusal !== undefined || backend === undefined)
564
+ return "bad_backend";
504
565
  if (!consentGiven(config))
505
566
  return "no_consent";
506
567
  if (budgetExhausted)
507
568
  return "budget";
508
- const auth = authState({ backend: config.typesafeBackend });
569
+ const auth = authState({ backend });
509
570
  if (auth.usable)
510
571
  return undefined;
511
572
  // No key in effect, or one whose last request came back 401 or 403.
@@ -522,8 +583,8 @@ export default function wardenExtension(pi) {
522
583
  if (reason === undefined || reason === "budget" || judgmentsReported.has(reason))
523
584
  return;
524
585
  judgmentsReported.add(reason);
525
- const auth = reason === "key_rejected" ? authState({ backend: config.typesafeBackend }) : undefined;
526
- judgmentsNotify?.(judgmentsOffText(reason, config.typesafeBackend, judgmentsHeadless, auth?.kind === "environment" ? auth.keyName : undefined));
586
+ const auth = reason === "key_rejected" && config.typesafeBackend !== undefined ? authState({ backend: config.typesafeBackend }) : undefined;
587
+ judgmentsNotify?.(judgmentsOffText(reason, config, judgmentsHeadless, auth?.kind === "environment" ? auth.keyName : undefined));
527
588
  };
528
589
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
529
590
  /** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
@@ -538,7 +599,26 @@ export default function wardenExtension(pi) {
538
599
  stats.cooldownSkips++;
539
600
  return undefined;
540
601
  }
541
- return client ??= watched(createTypeSafe(judgeOptions(config)));
602
+ if (client)
603
+ return client;
604
+ const backend = config.typesafeBackend;
605
+ // Reached only if a config ever sets no backend and no refusal at once; treating it as off keeps the invariant.
606
+ if (backend === undefined)
607
+ return undefined;
608
+ try {
609
+ client = watched(createTypeSafe(judgeOptions({ maxRequests: config.maxRequests, timeoutMs: config.timeoutMs, typesafeBackend: backend })));
610
+ }
611
+ catch (error) {
612
+ // An endpoint the config accepts can still be unusable (one that names no defaultModel); a hook must never crash.
613
+ const message = error instanceof Error ? error.message : String(error);
614
+ if (!judgmentsReported.has("bad_backend")) {
615
+ judgmentsReported.add("bad_backend");
616
+ traceFile?.judgments(judgmentsState("bad_backend"));
617
+ judgmentsNotify?.(`warden: Jev judgments are off (typesafeBackend refused: ${message})`);
618
+ }
619
+ return undefined;
620
+ }
621
+ return client;
542
622
  };
543
623
  /** Every guard reaches the backend through `evaluate`, so this one seam sees each failure and each success. */
544
624
  const watched = (typesafe) => new Proxy(typesafe, {
@@ -569,7 +649,7 @@ export default function wardenExtension(pi) {
569
649
  }
570
650
  const seconds = Math.round(event.ms / 1000);
571
651
  const remedy = event.kind === "auth"
572
- ? ` Set ${keyEnvFor(judgeConfig?.typesafeBackend ?? "typesafe")} or run /warden enable.`
652
+ ? ` Set ${resolveBackend(judgeConfig?.typesafeBackend).keyEnv} or run /warden enable.`
573
653
  : event.kind === "configuration" ? " /warden status shows the backend setup." : "";
574
654
  noticeUi.notify(`warden: judgments paused for ${seconds}s after a ${event.kind} failure; pattern checks run alone until then.${remedy}`, "warning");
575
655
  };
@@ -608,7 +688,7 @@ export default function wardenExtension(pi) {
608
688
  configPanel.close();
609
689
  return;
610
690
  }
611
- const opened = openConfigPanel(ui, config, { width: config.widget.panelWidth });
691
+ const opened = openConfigPanel(ui, config, { width: config.widget.panelWidth, dirs });
612
692
  configPanel = opened;
613
693
  opened.closed.catch(() => undefined).finally(() => { if (configPanel === opened)
614
694
  configPanel = undefined; });
@@ -740,7 +820,7 @@ export default function wardenExtension(pi) {
740
820
  trace.amend(entry, outcomeNote(item));
741
821
  const idPromise = learningIds.get(item.id);
742
822
  if (idPromise && item.outcome !== "pending") {
743
- idPromise.then(id => recordOutcome(id, item.outcome)).catch(err => console.warn("pi-warden: recordOutcome failed:", err));
823
+ idPromise.then(id => recordOutcome(id, item.outcome, dirs)).catch(err => console.warn("pi-warden: recordOutcome failed:", err));
744
824
  }
745
825
  }
746
826
  if (config.action.feedbackLog && holds.records().length)
@@ -757,7 +837,7 @@ export default function wardenExtension(pi) {
757
837
  const CRITICAL_STEER_GUARDS = new Set(["stuck", "done", "runaway", "subagent"]);
758
838
  let steersThisRun = 0;
759
839
  /** 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();
840
+ const steerStats = new SteerStats(steerStatsPath(dirs));
761
841
  const steerWatch = new SteerWatch();
762
842
  /** Session generation counter for conscience invalidation. Incremented on steer, abort, reload, switch, or config change. */
763
843
  let conscienceGeneration = 0;
@@ -914,6 +994,19 @@ export default function wardenExtension(pi) {
914
994
  if (ctx.hasUI)
915
995
  lastUi = ctx.ui;
916
996
  noticeUi = ctx.hasUI ? ctx.ui : undefined;
997
+ // The one-time migration notice runs before any call creates <agentDir>/pi-warden (the
998
+ // initSchema below does). Interactive sessions only: a headless session neither consumes the
999
+ // notice nor injects a cp instruction into the agent, and the marker records what was shown.
1000
+ const migrationMessage = ctx.hasUI ? legacyDataNotice(dirs, defaultHostDirs(), existsSync) : undefined;
1001
+ if (migrationMessage) {
1002
+ try {
1003
+ writeFileSync(join(dirs.agentDir, MIGRATION_MARKER), "", { mode: 0o600 });
1004
+ }
1005
+ catch (err) {
1006
+ console.warn("pi-warden: could not record the migration notice marker:", err);
1007
+ }
1008
+ ctx.ui.notify(`warden: ${migrationMessage}`, "info");
1009
+ }
917
1010
  cooldown.reset();
918
1011
  if (_event.reason === "reload" || _event.reason === "new")
919
1012
  conscienceGeneration++;
@@ -922,7 +1015,9 @@ export default function wardenExtension(pi) {
922
1015
  warnedFallback = false;
923
1016
  warnedMissingRules = false;
924
1017
  initRunning = false;
925
- await initSchema(loadConfig().learning.retentionDays);
1018
+ lastCalibration = undefined;
1019
+ lastCheck = undefined;
1020
+ await initSchema(loadConfig({ dirs }).learning.retentionDays, dirs);
926
1021
  stats = freshStats();
927
1022
  steerWatch.clear();
928
1023
  widget.clear();
@@ -934,8 +1029,8 @@ export default function wardenExtension(pi) {
934
1029
  holds.reset();
935
1030
  regretCandidates = [];
936
1031
  const sessionId = typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid);
937
- holdLog = new HoldLog(holdLogPath(sessionId));
938
- rulesLog = new RulesLog(ctx.cwd, sessionId, { onFailure: message => record(ctx, loadConfig(), "rules", "rules log write failed", [message]) });
1032
+ holdLog = new HoldLog(holdLogPath(sessionId, new Date(), dirs));
1033
+ rulesLog = new RulesLog(ctx.cwd, sessionId, { path: rulesLogPath(ctx.cwd, dirs), onFailure: message => record(ctx, loadConfig({ dirs }), "rules", "rules log write failed", [message]) });
939
1034
  unsubscribeTraceFile?.();
940
1035
  unsubscribeTraceFile = undefined;
941
1036
  traceFile = undefined;
@@ -945,14 +1040,14 @@ export default function wardenExtension(pi) {
945
1040
  ctx.ui.notify(text, "warning");
946
1041
  else
947
1042
  pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
948
- sessionHostPaths = wardenHostPaths();
1043
+ sessionHostPaths = wardenHostPaths(undefined, dirs);
949
1044
  const dir = traceDir();
950
1045
  if (dir) {
951
1046
  const warn = (text) => { if (ctx.hasUI)
952
1047
  ctx.ui.notify(text, "warning");
953
1048
  else
954
1049
  pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
955
- const opening = loadConfig();
1050
+ const opening = loadConfig({ dirs });
956
1051
  traceFile = new TraceFile(traceFilePath(dir, sessionId), dir, { sessionId, cwd: ctx.cwd, mode: opening.mode, judgments: judgmentsState(judgmentsOffReason(opening)) }, warn);
957
1052
  unsubscribeTraceFile = trace.subscribe(traceFile.listener);
958
1053
  }
@@ -998,8 +1093,8 @@ export default function wardenExtension(pi) {
998
1093
  lessonSignalTurn = undefined;
999
1094
  loopsReminded = undefined;
1000
1095
  // Load capability indexes (once per session, overwritten on every /warden index run).
1001
- globalIndexFile = readIndex(indexPath("global")) ?? undefined;
1002
- projectIndexFile = readIndex(indexPath("project", ctx.cwd)) ?? undefined;
1096
+ globalIndexFile = readIndex(indexPath("global", undefined, undefined, dirs)) ?? undefined;
1097
+ projectIndexFile = readIndex(indexPath("project", ctx.cwd, undefined, dirs)) ?? undefined;
1003
1098
  indexNudged = false;
1004
1099
  indexRunning = false;
1005
1100
  for (const symptom of Object.keys(slopCounts))
@@ -1007,7 +1102,7 @@ export default function wardenExtension(pi) {
1007
1102
  if (ctx.hasUI)
1008
1103
  ctx.ui.setWidget(WIDGET, undefined);
1009
1104
  // One-time notice when confirm mode falls back to steer (headless).
1010
- if (!warnedFallback && loadConfig().mode === "confirm" && !ctx.hasUI) {
1105
+ if (!warnedFallback && loadConfig({ dirs }).mode === "confirm" && !ctx.hasUI) {
1011
1106
  warnedFallback = true;
1012
1107
  const msg = "warden: confirm mode requires a UI; falling back to steer mode for this session.";
1013
1108
  if (ctx.hasUI)
@@ -1355,10 +1450,19 @@ export default function wardenExtension(pi) {
1355
1450
  });
1356
1451
  // Each low-level run collects its own evidence of changes and checks. A run that a warden follow-up started continues
1357
1452
  // the previous run's work, so it keeps that evidence: the changes behind a done-check nudge still need a passing check.
1358
- pi.on("agent_start", async () => {
1453
+ pi.on("agent_start", async (_event, ctx) => {
1359
1454
  if (!wardenContinuation)
1360
1455
  evidence = emptyEvidence();
1361
1456
  wardenContinuation = false;
1457
+ // Turn rules need a baseline of the working tree. The snapshot starts here as a promise and is awaited at
1458
+ // agent_end, so this hook never waits for git to hash the changed and untracked files. One trace line when a run
1459
+ // with turn rules gets no baseline. With no turn rules a failed snapshot is silent: a project without them sees
1460
+ // no change.
1461
+ turnJudged = new Set();
1462
+ turnSnapshot = undefined;
1463
+ const turnConfig = configFor(ctx);
1464
+ if (turnConfig.enabled && turnConfig.rules.enabled)
1465
+ turnSnapshot = snapshotTree(ctx.cwd);
1362
1466
  });
1363
1467
  // Every turn that runs after a compression is a turn that did not carry the removed text.
1364
1468
  pi.on("turn_end", async (_event, ctx) => {
@@ -1494,6 +1598,16 @@ export default function wardenExtension(pi) {
1494
1598
  : undefined;
1495
1599
  // Each file is one rules request, so a script that writes many files would start as many requests at once.
1496
1600
  const shellFiles = mergeWrites(shell?.writes ?? []);
1601
+ // Turn rules: a file these checks judge is not judged again at the end of the run.
1602
+ if (config.rules.enabled) {
1603
+ const inputPath = typeof event.input.path === "string" ? event.input.path : "";
1604
+ const seen = event.toolName === "write" || event.toolName === "edit" ? [inputPath] : shellFiles.slice(0, SHELL_RULES_CHECKS).map(write => write.path);
1605
+ for (const path of seen) {
1606
+ const rel = projectPath(path, ctx.cwd);
1607
+ if (rel)
1608
+ turnJudged.add(rel);
1609
+ }
1610
+ }
1497
1611
  const shellSkips = [...(shell?.skips ?? []), ...shellFiles.slice(SHELL_RULES_CHECKS).map(write => ({ path: write.path, reason: `only the first ${SHELL_RULES_CHECKS} files a command writes are judged` }))];
1498
1612
  const rulesChecks = !config.rules.enabled ? []
1499
1613
  : event.toolName === "write" || event.toolName === "edit" ? [{ check: rulesGuard.inspect(call, siblings, rulesOptions) }]
@@ -1605,7 +1719,7 @@ export default function wardenExtension(pi) {
1605
1719
  // The promise and map entry must exist before noteOutcomes so that an outcome
1606
1720
  // known at record time (e.g. dialog-approved) is not lost to the race.
1607
1721
  const preview = redact(verdict.summary.command ?? verdict.summary.path ?? "");
1608
- 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; });
1722
+ 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 }), dirs).catch(err => { console.warn("pi-warden: recordHold failed:", err); return -1; });
1609
1723
  learningIds.set(item.id, holdPromise);
1610
1724
  pruneLearningIds();
1611
1725
  noteOutcomes(config, outcome ? [item] : []);
@@ -2387,6 +2501,62 @@ export default function wardenExtension(pi) {
2387
2501
  }
2388
2502
  }
2389
2503
  }
2504
+ // ── Turn rules: one end-of-run judgment against the whole diff, and the files no per-edit check saw ──
2505
+ // One steer for the findings, through the done-check's delivery; the verdicts land in the rules log like the rest.
2506
+ if (config.rules.enabled && turnSnapshot) {
2507
+ // The run end waits for the snapshot the run start began; a run that ended first has been waiting since.
2508
+ const snapshot = await turnSnapshot;
2509
+ turnSnapshot = undefined;
2510
+ const set = rulesGuard.store.load(ctx.cwd, config.rules);
2511
+ if (!snapshot.tree) {
2512
+ if (set && turnRulesFor(set).length)
2513
+ record(ctx, config, "rules", `warden · rules · turn rules skipped this run: ${snapshot.reason}`, ["trigger: agent_end", "no working-tree snapshot: the end-of-run pass will not run this turn rules check"]);
2514
+ }
2515
+ else {
2516
+ const turnRun = await evaluateTurnRun({ cwd: ctx.cwd, config: config.rules, set, judge, timeoutMs: config.timeoutMs, signal: ctx.signal, task: latestUserPrompt(ctx), startTree: snapshot.tree, alreadyJudged: turnJudged });
2517
+ if (turnRun.skipped) {
2518
+ record(ctx, config, "rules", `warden · rules · end-of-run pass skipped: ${turnRun.skipped}`, ["trigger: agent_end"]);
2519
+ }
2520
+ // One trace line names the files the per-run cap left unjudged, like the skips of one command's shell writes.
2521
+ if (turnRun.skips.length) {
2522
+ const paths = turnRun.skips.flatMap(skip => (skip.path ? [skip.path] : []));
2523
+ record(ctx, config, "rules", renderTemplate(config.widget.rules, { guard: "rules", tool: "unseen change", path: paths.length ? paths.join(", ") : "file change", status: "skipped" }), turnRun.skips.map(skip => `unseen change not judged${skip.path ? ` (${skip.path})` : ""}: ${skip.reason}`));
2524
+ }
2525
+ let counts = new Map();
2526
+ for (const verdict of turnRun.verdicts)
2527
+ counts = rulesGuard.count(verdict);
2528
+ const hasFindings = turnRun.verdicts.some(verdict => verdict.findings.length > 0);
2529
+ const told = hasFindings && adaptiveSend(ctx, config, "rules") ? turnSteer(turnRun.verdicts, counts) : undefined;
2530
+ const delivered = told ? steer(config, "rules", told, { deliverAs: "followUp", triggerTurn: true }) : false;
2531
+ if (delivered)
2532
+ watchSteer(ctx, config, ["rules"]);
2533
+ for (const verdict of turnRun.verdicts) {
2534
+ const shown = renderTemplate(config.widget.rules, { ...rulesTokens(verdict), tool: verdict.tool === "turn" ? "turn" : "unseen change", path: verdict.tool === "turn" ? "the run's diff" : verdict.path });
2535
+ const cuts = verdict.tool === "turn" && turnRun.cuts.length ? [`diff caps cut: ${turnRun.cuts.join("; ")}`] : [];
2536
+ if (verdict.source !== "skipped") {
2537
+ stats.ruleChecks++;
2538
+ if (verdict.error)
2539
+ noteError(ctx, verdict.error, verdict.errorCode);
2540
+ if (verdict.source === "typesafe") {
2541
+ for (const observation of rulesLog?.record(verdict, config.rules.threshold, Date.now(), config.rules.softThreshold) ?? []) {
2542
+ if (observation.cleared)
2543
+ record(ctx, config, "rules", `rules: ${observation.name} now clear on ${verdict.path}`, [`clear: a later judgment on this path scored ${observation.violation.toFixed(2)}, below the ${config.rules.threshold} threshold`]);
2544
+ }
2545
+ }
2546
+ if (verdict.findings.length) {
2547
+ stats.ruleViolations++;
2548
+ if (ctx.hasUI && config.notices)
2549
+ ctx.ui.notify(`warden · rules · ${verdict.tool === "turn" ? "this run's changes" : verdict.path}: ${verdict.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
2550
+ }
2551
+ const agentTold = told && delivered ? `agent told: ${told}` : told ? `steer recorded, not delivered (a repeat or the per-run budget): ${told}` : undefined;
2552
+ record(ctx, config, "rules", shown, [...cuts, ...rulesDetails(verdict, agentTold)]);
2553
+ }
2554
+ else {
2555
+ record(ctx, config, "rules", shown, [`${verdict.tool} ${verdict.path}: ${verdict.skippedReason}`]);
2556
+ }
2557
+ }
2558
+ }
2559
+ }
2390
2560
  if (!finalMessage || !judge)
2391
2561
  return;
2392
2562
  // Pi shows the agent as working until this hook returns, so the two independent checks share one round trip.
@@ -2440,7 +2610,7 @@ export default function wardenExtension(pi) {
2440
2610
  }
2441
2611
  });
2442
2612
  // Shortcuts are registered once at load; a shape check keeps a typo in the config file from being registered.
2443
- const shortcut = loadConfig().widget.shortcut;
2613
+ const shortcut = loadConfig({ dirs }).widget.shortcut;
2444
2614
  if (/^(?:(?:ctrl|shift|alt|super)\+)+[a-z0-9]+$|^f\d{1,2}$/i.test(shortcut)) {
2445
2615
  pi.registerShortcut(shortcut, {
2446
2616
  description: "Toggle the pi-warden trace sidebar",
@@ -2468,7 +2638,7 @@ export default function wardenExtension(pi) {
2468
2638
  const manager = ctx.sessionManager;
2469
2639
  const session = typeof manager.getSessionId === "function" ? manager.getSessionId() : "unknown";
2470
2640
  const scan = await standingPrefs(ctx);
2471
- const path = prefsStorePath(scan.project);
2641
+ const path = prefsStorePath(scan.project, dirs);
2472
2642
  const reply = await changePrefsStore(path, store => {
2473
2643
  const result = recordLesson({ lesson, session, now: Date.now(), signal, scan, store });
2474
2644
  return { value: result.reply, ...(result.store ? { store: result.store } : {}) };
@@ -2508,14 +2678,20 @@ export default function wardenExtension(pi) {
2508
2678
  return toolReply(recallText(evidenceSnapshot(ctx)));
2509
2679
  },
2510
2680
  });
2511
- const actions = ["status", "rules", "report", "enable", "disable", "mode", "config", "test", "trace", "init", "audit", "index", "prefs", "loops", "recommend", "unmute"];
2681
+ const actions = ["status", "rules", "report", "enable", "disable", "mode", "config", "test", "trace", "init", "audit", "index", "prefs", "loops", "recommend", "unmute", "bench"];
2512
2682
  pi.registerCommand("warden", {
2513
2683
  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",
2514
2684
  getArgumentCompletions(prefix) {
2515
2685
  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.
2686
+ // `rules check`, `rules audit`, `rules calibrate`, and `rules tune` are two-word actions: `rules` on its own stays the local list.
2517
2687
  if ("rules check".startsWith(prefix))
2518
2688
  matches.push({ value: "rules check", label: "rules check", description: "which of the active rules the guard cannot judge well" });
2689
+ if ("rules audit".startsWith(prefix))
2690
+ matches.push({ value: "rules audit", label: "rules audit", description: "judge existing files against the project rules as if they had just been written" });
2691
+ if ("rules calibrate".startsWith(prefix))
2692
+ matches.push({ value: "rules calibrate", label: "rules calibrate", description: "replay recent commits through the active rules and flag the ones that fire on everything or cannot decide" });
2693
+ if ("rules tune".startsWith(prefix))
2694
+ matches.push({ value: "rules tune", label: "rules tune", description: "ask the agent to rewrite the rules the latest calibrate or rules check flagged" });
2519
2695
  return matches.length ? matches : null;
2520
2696
  },
2521
2697
  async handler(args, ctx) {
@@ -2534,45 +2710,199 @@ export default function wardenExtension(pi) {
2534
2710
  try {
2535
2711
  const config = configFor(ctx);
2536
2712
  if (action === "status") {
2537
- const auth = describeAuth(authState({ backend: config.typesafeBackend }));
2713
+ // A refused backend has no key to describe; the refusal itself is the status line.
2714
+ const backend = config.typesafeBackend;
2715
+ const auth = config.backendRefusal !== undefined || backend === undefined ? undefined : describeAuth(authState({ backend }));
2538
2716
  const source = consentSource(config);
2539
2717
  const usage = client?.getUsage();
2540
2718
  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(", ");
2541
- const ls = await holdStats(ctx.cwd);
2719
+ const ls = await holdStats(ctx.cwd, dirs);
2542
2720
  const lifetimeLine = ls.labeled === 0
2543
2721
  ? `Lifetime here: ${ls.held} hold${ls.held === 1 ? "" : "s"}, not yet measurable (${ls.allowed} allowed).`
2544
2722
  : `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).`;
2545
2723
  report([
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}`,
2724
+ `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.backendRefusal !== undefined || backend === undefined || backend === "typesafe" ? "" : ` (${describeBackend(backend)})`}; ${config.backendRefusal !== undefined ? `judgments are off: typesafeBackend refused: ${config.backendRefusal}` : auth?.text ?? ""}`,
2547
2725
  `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`}.`,
2548
2726
  formatSteers(stats),
2549
2727
  `${formatMuted(steerStats.muted(), config.steers)}${stats.steersMuted ? ` This session: ${stats.steersMuted} steer${stats.steersMuted === 1 ? "" : "s"} kept in the trace only.` : ""}`,
2550
2728
  `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}.`,
2551
2729
  formatLedger(ledger.snapshot()),
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)`] : []),
2730
+ ...(config.learning.patternAnalysis ? [`Learning: ${(await generateRecommendations(ctx.cwd, dirs)).length} recommendations, steer effectiveness ${Math.round((await analyzeSteerEffectivenessReport(ctx.cwd, dirs)).overall * 100)}% (use /warden recommend for details)`] : []),
2553
2731
  `${formatHolds(holds.snapshot(), config.action.feedbackLog ? holdLog?.path : undefined)}${holdLog?.lastFailure ? ` Log write failed: ${holdLog.lastFailure}.` : ""}`,
2554
2732
  lifetimeLine,
2555
2733
  `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"}.`,
2556
2734
  ...(config.action.armingRules.length > 0 ? [`Arming: ${arming.statusLine() || "no rules armed"}.`] : []),
2557
2735
  `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)"}.`,
2558
- `Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
2736
+ `Config: ${userConfigPath(dirs)}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd, dirs)}` : ""}.`,
2559
2737
  widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
2560
2738
  `Trace: ${trace.entries().length} events (/warden trace${shortcut ? `, ${shortcut}` : ""}, or click the status line in fullscreen mode; each toggles the sidebar). Widget templates in config.widget: action tokens ${TOKEN_NAMES.action.map(name => `{${name}}`).join(" ")}.`,
2561
2739
  ].join(" "));
2562
2740
  return;
2563
2741
  }
2742
+ if (action === "rules" && argument === "audit") {
2743
+ const parsed = parseRulesAuditArgs(tokens.slice(2));
2744
+ if (parsed.error) {
2745
+ report(parsed.error, "warning");
2746
+ return;
2747
+ }
2748
+ const off = judgmentsOffReason(config);
2749
+ const judge = judgeFor(config);
2750
+ const result = await runRulesAudit({
2751
+ cwd: ctx.cwd, paths: parsed.paths, max: parsed.max, yes: parsed.yes, config: config.rules,
2752
+ set: rulesGuard.store.load(ctx.cwd, config.rules), judge, timeoutMs: config.timeoutMs, signal: ctx.signal,
2753
+ destination: backendHost(config.typesafeBackend),
2754
+ confirm: ctx.hasUI ? (title, body) => ctx.ui.confirm(title, body, ctx.signal ? { signal: ctx.signal } : {}) : undefined,
2755
+ });
2756
+ switch (result.status) {
2757
+ // No key, no consent, or a spent budget: say why and send nothing.
2758
+ case "no-judge":
2759
+ report(`Rules audit sent nothing: Jev judgments are off${off === "budget" ? " (the request budget is used up)" : ""}.${off && off !== "budget" ? ` ${judgmentsOffText(off, config, !ctx.hasUI)}` : ""}`);
2760
+ return;
2761
+ case "no-files":
2762
+ report(`Rules audit sent nothing: ${result.reason}.`);
2763
+ return;
2764
+ case "needs-yes":
2765
+ report(`Rules audit sent nothing: a headless run needs --yes before ${result.files} file samples go to ${backendHost(config.typesafeBackend)} (${result.leftOut} more files left out at the --max ${parsed.max} cap).`, "warning");
2766
+ return;
2767
+ case "cancelled":
2768
+ report("Rules audit cancelled. Nothing was sent.");
2769
+ return;
2770
+ case "done":
2771
+ report(formatRulesAudit(result.outcome));
2772
+ return;
2773
+ }
2774
+ return;
2775
+ }
2776
+ if (action === "bench") {
2777
+ const parsed = parseBenchArgs(tokens.slice(1));
2778
+ if (parsed.error) {
2779
+ report(parsed.error, "warning");
2780
+ return;
2781
+ }
2782
+ const off = judgmentsOffReason(config);
2783
+ const judge = judgeFor(config);
2784
+ const result = await runBench({ runs: parsed.runs, cwd: ctx.cwd, config: config.rules, set: rulesGuard.store.load(ctx.cwd, config.rules), judge, getUsage: judge ? () => judge.getUsage() : undefined, timeoutMs: config.timeoutMs, signal: ctx.signal });
2785
+ switch (result.status) {
2786
+ // No key, no consent, or a spent budget: say why and send nothing.
2787
+ case "no-judge":
2788
+ report(`Bench sent nothing: Jev judgments are off${off === "budget" ? " (the request budget is used up)" : ""}.${off && off !== "budget" ? ` ${judgmentsOffText(off, config, !ctx.hasUI)}` : ""}`);
2789
+ return;
2790
+ case "skipped":
2791
+ report(`Bench sent nothing: ${result.reason}.`);
2792
+ return;
2793
+ case "done":
2794
+ report(formatBench(result));
2795
+ return;
2796
+ }
2797
+ return;
2798
+ }
2564
2799
  if (action === "rules") {
2565
2800
  if (argument === "check") {
2566
2801
  const off = judgmentsOffReason(config);
2567
2802
  const result = await checkRules({ set: rulesGuard.store.load(ctx.cwd, config.rules), judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
2568
2803
  if (result.source === "skipped") {
2569
2804
  // 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.`);
2805
+ report(`Rules check sent nothing: Jev judgments are off${off === "budget" ? " (the request budget is used up)" : ""}.${off && off !== "budget" ? ` ${judgmentsOffText(off, config, !ctx.hasUI)}` : ""} /warden rules shows the rule set locally.`);
2571
2806
  return;
2572
2807
  }
2808
+ if (result.source === "typesafe")
2809
+ lastCheck = result;
2573
2810
  report(formatRulesCheck(result));
2574
2811
  return;
2575
2812
  }
2813
+ if (argument === "calibrate") {
2814
+ const usage = "Usage: /warden rules calibrate [--commits N] [--max N] [--yes].";
2815
+ const commitsMatch = /(?:^|\s)--commits\s+(\S+)/.exec(tail);
2816
+ const maxMatch = /(?:^|\s)--max\s+(\S+)/.exec(tail);
2817
+ const yes = /(?:^|\s)--yes(?:\s|$)/.test(tail);
2818
+ const commits = commitsMatch ? Number(commitsMatch[1]) : CALIBRATE_DEFAULT_COMMITS;
2819
+ const max = maxMatch ? Number(maxMatch[1]) : CALIBRATE_DEFAULT_MAX;
2820
+ if ((commitsMatch && (!Number.isInteger(commits) || commits < 1)) || (maxMatch && (!Number.isInteger(max) || max < 1))) {
2821
+ report(usage, "warning");
2822
+ return;
2823
+ }
2824
+ const set = rulesGuard.store.load(ctx.cwd, config.rules);
2825
+ if (!set) {
2826
+ report("No rules file detected. Run /warden init to create project-specific rules.");
2827
+ return;
2828
+ }
2829
+ if (set.proseOnly) {
2830
+ report(`No rules to calibrate: ${set.sources.join(", ")} has no rule-shaped sections, so the guard judges nothing there.`);
2831
+ return;
2832
+ }
2833
+ if (set.aggregate !== undefined && !set.rules.length) {
2834
+ report(`No separate rules to calibrate: ${set.sources.join(", ")} has no rule headings, so the guard judges it as one document.`);
2835
+ return;
2836
+ }
2837
+ const off = judgmentsOffReason(config);
2838
+ const judge = judgeFor(config);
2839
+ if (!judge) {
2840
+ report(`Rules calibrate sent nothing: Jev judgments are off${off === "budget" ? " (the request budget is used up)" : ""}.${off && off !== "budget" ? ` ${judgmentsOffText(off, config, !ctx.hasUI)}` : ""} /warden rules shows the rule set locally.`);
2841
+ return;
2842
+ }
2843
+ let history;
2844
+ try {
2845
+ history = collectHistory(ctx.cwd, commits);
2846
+ }
2847
+ catch (error) {
2848
+ report(`Rules calibrate could not read the git history: ${error instanceof Error ? error.message : String(error)}`, "warning");
2849
+ return;
2850
+ }
2851
+ const plan = planCalibration(history, { set, config: config.rules, cwd: ctx.cwd, maxRequests: max });
2852
+ if (!plan.requests) {
2853
+ report(`Rules calibrate sent nothing: no changed file in the last ${commits} non-merge commit${commits === 1 ? "" : "s"} is judgeable${plan.skipped.length ? ` (${plan.skipped.length} change${plan.skipped.length === 1 ? "" : "s"} skipped)` : ""}.`);
2854
+ return;
2855
+ }
2856
+ // Nothing leaves the machine before this gate: the dialog shows the count and the redacted diffs, and a
2857
+ // headless run needs the explicit --yes.
2858
+ const gate = calibrateGate({ hasUI: ctx.hasUI, yes });
2859
+ if (gate.kind === "refuse") {
2860
+ report(gate.reason, "warning");
2861
+ return;
2862
+ }
2863
+ if (gate.kind === "confirm" && !await ctx.ui.confirm(`warden: send ${plan.requests} calibration request${plan.requests === 1 ? "" : "s"}?`, calibrationNotice(plan))) {
2864
+ report("Cancelled. Nothing was sent.");
2865
+ return;
2866
+ }
2867
+ const result = await calibrate(history, { set, config: config.rules, cwd: ctx.cwd, judge, timeoutMs: config.timeoutMs, maxRequests: max, signal: ctx.signal });
2868
+ (rulesLog ??= new RulesLog(ctx.cwd, String(process.pid))).appendRecords(result.records);
2869
+ lastCalibration = result;
2870
+ report(formatCalibration(result));
2871
+ return;
2872
+ }
2873
+ if (argument === "tune") {
2874
+ const request = tuneRequest({ calibration: lastCalibration, check: lastCheck, rules: rulesGuard.store.load(ctx.cwd, config.rules)?.rules ?? [] });
2875
+ if ("reason" in request) {
2876
+ report(request.reason);
2877
+ return;
2878
+ }
2879
+ initRunning = true;
2880
+ let sent = true;
2881
+ if (ctx.hasUI)
2882
+ ctx.ui.notify("pi-warden: Sending the rules rewrite prompt...", "info");
2883
+ try {
2884
+ // sendUserMessage throws when the agent is not idle. Brief wait so a
2885
+ // just-closing confirm dialog does not cause a race.
2886
+ for (let attempt = 0; attempt < 40; attempt++) {
2887
+ if (ctx.isIdle())
2888
+ break;
2889
+ await new Promise(resolve => setTimeout(resolve, 250));
2890
+ }
2891
+ pi.sendUserMessage(request.prompt);
2892
+ await ctx.waitForIdle();
2893
+ }
2894
+ catch (err) {
2895
+ sent = false;
2896
+ const detail = err instanceof Error ? err.message : String(err);
2897
+ report(`pi-warden rules tune failed: ${detail}.`, "error");
2898
+ }
2899
+ finally {
2900
+ initRunning = false;
2901
+ }
2902
+ if (sent)
2903
+ report("Rewrite prompt sent. The agent edits pi-warden.md with its own tools; review the changes.");
2904
+ return;
2905
+ }
2576
2906
  report(rulesGuard.details(ctx.cwd, config.rules));
2577
2907
  return;
2578
2908
  }
@@ -2583,9 +2913,13 @@ export default function wardenExtension(pi) {
2583
2913
  report("Usage: /warden report [--days N], where N is at least 1. Default: 30 days.", "warning");
2584
2914
  return;
2585
2915
  }
2586
- const records = await readRulesLog(rulesLogPath(ctx.cwd));
2916
+ const all = await readRulesLog(rulesLogPath(ctx.cwd, dirs));
2917
+ // Calibration replays carry `source: "calibrate"`; the live verdict report counts only the live records and
2918
+ // says how many replays it set apart.
2919
+ const replays = all.filter(record => record.source === "calibrate").length;
2587
2920
  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 })) ?? [] })));
2921
+ const text = formatRulesReport(buildRulesReport(all, { days, currentRules: set?.rules.map(rule => ({ id: rule.id, name: rule.name })) ?? [], source: "live" }));
2922
+ report(replays ? `${text}\n${replays} calibration replay record${replays === 1 ? "" : "s"} in this log, counted apart (source: calibrate).` : text);
2589
2923
  return;
2590
2924
  }
2591
2925
  if (action === "trace") {
@@ -2641,8 +2975,8 @@ export default function wardenExtension(pi) {
2641
2975
  }
2642
2976
  if (action === "recommend") {
2643
2977
  // Learning-driven recommendations based on hold history
2644
- const recommendations = await generateRecommendations(ctx.cwd);
2645
- const steerReport = await analyzeSteerEffectivenessReport(ctx.cwd);
2978
+ const recommendations = await generateRecommendations(ctx.cwd, dirs);
2979
+ const steerReport = await analyzeSteerEffectivenessReport(ctx.cwd, dirs);
2646
2980
  const lines = [];
2647
2981
  lines.push("pi-warden learning recommendations:");
2648
2982
  if (recommendations.length === 0 && steerReport.suggestions.length === 0) {
@@ -2683,27 +3017,36 @@ export default function wardenExtension(pi) {
2683
3017
  return;
2684
3018
  }
2685
3019
  if (action === "enable") {
3020
+ if (config.backendRefusal !== undefined) {
3021
+ report(`Cannot enable judgments: typesafeBackend refused: ${config.backendRefusal} Fix the value in ${userConfigPath(dirs)}, then run /warden enable again.`, "warning");
3022
+ return;
3023
+ }
3024
+ const backend = config.typesafeBackend;
2686
3025
  if (!ctx.hasUI) {
2687
- report(`Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and ${keyEnvFor(config.typesafeBackend)} explicitly.`, "warning");
3026
+ report(`Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and ${resolveBackend(backend).keyEnv} explicitly.`, "warning");
2688
3027
  return;
2689
3028
  }
2690
- if (!await ctx.ui.confirm("Enable TypeSafe judgments for pi-warden?", disclosure))
3029
+ // For a non-TypeSafe backend the dialog names who answers: the label, host, and model sent.
3030
+ const consentText = backend === undefined || backend === "typesafe"
3031
+ ? disclosure
3032
+ : `Judgments go to ${describeBackend(backend)}. ${disclosureFor(backend, disclosure)}`;
3033
+ if (!await ctx.ui.confirm("Enable TypeSafe judgments for pi-warden?", consentText))
2691
3034
  return;
2692
3035
  // One flow: consent, then a key if none is configured yet. TypeSafe prompts, verifies, and stores the key for every
2693
3036
  // pi-typesafe consumer; any other backend has no login, so a missing key is reported with the variable to set.
2694
- const key = await ensureApiKey(ctx, { backend: config.typesafeBackend });
3037
+ const key = await ensureApiKey(ctx, backend === undefined ? {} : { backend });
2695
3038
  if (!key) {
2696
3039
  report("No key entered; pi-warden stays on pattern checks only. Run /warden enable again when you have a key from console.typesafe.ai.", "warning");
2697
3040
  return;
2698
3041
  }
2699
- const path = setUserSetting("typesafe", true);
3042
+ const path = setUserSetting("typesafe", true, dirs);
2700
3043
  client = undefined;
2701
3044
  budgetExhausted = false;
2702
- report(`TypeSafe judgments enabled and saved to ${path}${key.login ? `; key verified (${key.login.models} model${key.login.models === 1 ? "" : "s"}) and stored at ${key.login.path}` : ` using the ${key.source === "stored" ? "stored key" : `key from ${keyEnvFor(config.typesafeBackend)}`}`}. This stays on in new sessions until /warden disable.`);
3045
+ report(`TypeSafe judgments enabled and saved to ${path}${key.login ? `; key verified (${key.login.models} model${key.login.models === 1 ? "" : "s"}) and stored at ${key.login.path}` : ` using the ${key.source === "stored" ? "stored key" : `key from ${resolveBackend(config.typesafeBackend).keyEnv}`}`}. This stays on in new sessions until /warden disable.`);
2703
3046
  return;
2704
3047
  }
2705
3048
  if (action === "disable") {
2706
- const path = setUserSetting("typesafe", false);
3049
+ const path = setUserSetting("typesafe", false, dirs);
2707
3050
  report(`TypeSafe judgments disabled in ${path}. Offline pattern checks stay active; set enabled to false there to turn pi-warden off entirely.`);
2708
3051
  return;
2709
3052
  }
@@ -2712,7 +3055,7 @@ export default function wardenExtension(pi) {
2712
3055
  report(`Mode is ${activeMode(config, ctx.hasUI)}${process.env.PI_WARDEN_MODE ? " (from PI_WARDEN_MODE)" : ""}. Use /warden mode steer | confirm | advise. steer holds risky calls and tells the agent why; confirm asks you with a dialog; advise only reports.`);
2713
3056
  return;
2714
3057
  }
2715
- const path = setUserSetting("mode", argument);
3058
+ const path = setUserSetting("mode", argument, dirs);
2716
3059
  report(`Mode set to ${argument} in ${path}.`);
2717
3060
  return;
2718
3061
  }
@@ -2732,23 +3075,23 @@ export default function wardenExtension(pi) {
2732
3075
  const keyPath = rest.slice(0, spaceIndex).trim();
2733
3076
  const rawValue = rest.slice(spaceIndex + 1).trim();
2734
3077
  const value = parseConfigValue(rawValue);
2735
- const current = readUserConfig();
3078
+ const current = readUserConfig(dirs);
2736
3079
  const updated = setNestedValue(current, keyPath, value);
2737
- const savedPath = writeUserConfig(updated);
3080
+ const savedPath = writeUserConfig(updated, dirs);
2738
3081
  client = undefined;
2739
3082
  report(`Saved ${keyPath} = ${JSON.stringify(value)}.`);
2740
3083
  return;
2741
3084
  }
2742
3085
  if (tail.startsWith("get ")) {
2743
3086
  const keyPath = tail.slice(4).trim();
2744
- const current = readUserConfig();
3087
+ const current = readUserConfig(dirs);
2745
3088
  const value = getNestedValue(current, keyPath);
2746
3089
  const defaultValue = getNestedValue(defaultConfig(), keyPath);
2747
3090
  report(value === undefined ? `${keyPath} not set (default: ${JSON.stringify(defaultValue)})` : `${keyPath} = ${JSON.stringify(value)}`);
2748
3091
  return;
2749
3092
  }
2750
3093
  if (!ctx.hasUI || !lastUi) {
2751
- report(`Edit ${userConfigPath()} directly. Use /warden config set <key> <value> for quick changes.`);
3094
+ report(`Edit ${userConfigPath(dirs)} directly. Use /warden config set <key> <value> for quick changes.`);
2752
3095
  return;
2753
3096
  }
2754
3097
  toggleConfigPanel(lastUi, config);
@@ -2841,7 +3184,10 @@ export default function wardenExtension(pi) {
2841
3184
  }
2842
3185
  if (action === "test") {
2843
3186
  const judge = judgeFor(config);
2844
- 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)}`))
3187
+ // For a non-TypeSafe backend the confirmation names who answers: the label, host, and model sent.
3188
+ const backend = config.typesafeBackend;
3189
+ const destination = backend === undefined || backend === "typesafe" ? backendHost(backend) : describeBackend(backend);
3190
+ 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 ${destination} and may incur charges. ${disclosureFor(config.typesafeBackend, disclosure)}`))
2845
3191
  return;
2846
3192
  const verdict = await evaluateAction({ tool: "bash", input: { command: "rm -rf /tmp/pi-warden-demo" }, cwd: ctx.cwd, task: "Prepare the demo environment" }, { config: { ...config.action, enabled: true, tools: ["bash"] }, judge, rules: config.rules });
2847
3193
  const deliveryVerdict = ctx.hasUI ? verdict : agentDeliveryVerdict(verdict);
@@ -2907,11 +3253,11 @@ export default function wardenExtension(pi) {
2907
3253
  catch {
2908
3254
  toolInfosForIndex = [];
2909
3255
  }
2910
- const globalPath = indexPath("global");
2911
- const projectPath = indexPath("project", ctx.cwd);
2912
- ensureIndexDir("global");
2913
- ensureIndexDir("project");
2914
- const prompt = buildIndexPrompt(ctx.cwd, skillsForIndex, toolInfosForIndex, { global: globalPath, project: projectPath });
3256
+ const globalPath = indexPath("global", undefined, undefined, dirs);
3257
+ const projectPath = indexPath("project", ctx.cwd, undefined, dirs);
3258
+ ensureIndexDir("global", undefined, dirs);
3259
+ ensureIndexDir("project", undefined, dirs);
3260
+ const prompt = buildIndexPrompt(ctx.cwd, skillsForIndex, toolInfosForIndex, { global: globalPath, project: projectPath }, dirs);
2915
3261
  // Snapshot files before the model runs so we can detect whether it wrote anything.
2916
3262
  const snap = (p) => { try {
2917
3263
  const s = statSync(p);