pi-warden 0.40.1 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/extension.js CHANGED
@@ -6,13 +6,13 @@ import * as tuiModule from "@earendil-works/pi-tui";
6
6
  const MouseRegion = tuiModule.MouseRegion;
7
7
  import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
8
8
  import { ensureApiKey } from "pi-typesafe/ui";
9
- import { backendHost, disclosureFor, judgeOptions, keyEnvFor, resolveBackend } from "./backend.js";
9
+ import { backendHost, disclosureFor, judgeOptions, keyEnvFor, loginStoresKey, resolveBackend } from "./backend.js";
10
10
  import { ActionGuard } from "./action-guard.js";
11
11
  import { ArmingTracker, unparseableArmingRules } from "./arming.js";
12
12
  import * as configModule from "./config.js";
13
13
  import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
14
14
  import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome as recordDoneOutcome } from "./done.js";
15
- import { evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, offTaskSteer, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
15
+ import { createdScratch, evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, largeOutputNotice, offTaskSteer, pruneScratch, scratchCandidates, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
16
16
  import { commandOf } from "./tools.js";
17
17
  import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
18
18
  import { initSchema, recordHold, recordOutcome, toHoldRecord, holdStats, generateRecommendations, analyzeSteerEffectivenessReport } from "./learning.js";
@@ -20,7 +20,7 @@ import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_S
20
20
  import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice, CompressionLearner } from "./output.js";
21
21
  import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
22
22
  import { redact } from "./redact.js";
23
- import { formatRules, pathNoteSteer, RulesGuard, rulesSteer } from "./rules.js";
23
+ import { formatRules, pathNoteSteer, RulesGuard, rulesSteer, RULES_FILE, FALLBACK_FILES } from "./rules.js";
24
24
  import { checkPiWardenMissing } from "./rules-file.js";
25
25
  import { writeStarterRules, buildInitPrompt } from "./init.js";
26
26
  import { buildAuditPrompt, findProjects, snapshotReport, reportOutcome } from "./audit.js";
@@ -31,39 +31,19 @@ import { assess } from "./conscience.js";
31
31
  import { loadSkillBody, buildLoadMessage, policyMatches, CONSCIENCE_BETA_POLICY, recordFileIdentity, clearFileIdentityCache } from "./load.js";
32
32
  import { buildIndexPrompt, readIndex, writeIndex, validateIndex, indexStats, indexPath, ensureIndexDir } from "./index-cmd.js";
33
33
  import { fileContentHash } from "./hashing.js";
34
- /** Classify a conscience assessment error into a safe category (spec §6: never exception bodies). */
35
- function classifyConscienceError(err) {
36
- if (err && typeof err === "object" && "code" in err) {
37
- const code = err.code;
38
- if (code === "timeout")
39
- return "timeout";
40
- if (code === "http" || code === "connection" || code === "response")
41
- return "network";
42
- if (code === "configuration" || code === "validation")
43
- return "configuration";
44
- if (code === "budget" || code === "aborted")
45
- return "other";
46
- }
47
- const msg = err instanceof Error ? err.message : String(err);
48
- if (/timeout|timed out/i.test(msg))
49
- return "timeout";
50
- if (/auth|key|credential|401|403/i.test(msg))
51
- return "auth";
52
- if (/network|fetch|connect|ECONNREFUSED|ENOTFOUND/i.test(msg))
53
- return "network";
54
- return "other";
55
- }
34
+ import { classifyJudgeError, cooldownFailureKind, JudgeCooldown } from "./judge-cooldown.js";
56
35
  import { openConfigPanel, openTracePanel } from "./panel.js";
57
- import { completeConfig, shapeWarning } from "./shape.js";
36
+ import { completeConfig, shapeWarning, taskSpine } from "./shape.js";
58
37
  import { ContextLedger, formatLedger } from "./saver.js";
59
38
  import { buildCompactSnapshot, compactAppendix } from "./compact.js";
60
39
  import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
61
40
  import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
41
+ import { TraceFile, judgmentsState, traceDir, traceFilePath } from "./trace-file.js";
62
42
  import { actionTokens, DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, SENTENCE_TEMPLATES, statusWidget, TOKEN_NAMES } from "./widget.js";
63
- export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, with the agent's own words from the message that makes the call (its stated plan); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. For the conscience coach (recommend mode): your current request (2000 redacted characters), 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.";
43
+ 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 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.";
64
44
  const WIDGET = PACKAGE_NAME;
65
45
  const CONFIRM_TEXT_LIMIT = 500;
66
- 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 });
46
+ 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, cooldownSkips: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
67
47
  /**
68
48
  * One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
69
49
  * message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
@@ -154,6 +134,20 @@ export function assistantPlan(ctx) {
154
134
  }
155
135
  return undefined;
156
136
  }
137
+ /**
138
+ * The once-per-session notice: why Jev judgments are off and what turns them on. `rejectedEnv` is the environment
139
+ * variable that holds a rejected key, or undefined when the rejected key is the one `/typesafe login` saved. Never
140
+ * names a key value or a path.
141
+ */
142
+ export function judgmentsOffText(reason, backend, headless, rejectedEnv) {
143
+ switch (reason) {
144
+ case "no_consent": return `warden: Jev judgments are off (no consent). ${headless ? "Set PI_WARDEN_ENABLED=1." : "Run /warden enable."}`;
145
+ case "no_key": return `warden: Jev judgments are off (no key for ${backend}). Set ${keyEnvFor(backend)}${loginStoresKey(backend) ? " or run /typesafe login" : ""}.`;
146
+ case "key_rejected": return rejectedEnv
147
+ ? `warden: Jev judgments are off (the key in ${rejectedEnv} was rejected). Check the key, then run /warden status.`
148
+ : "warden: Jev judgments are off (the key saved by /typesafe login was rejected). Run /typesafe login.";
149
+ }
150
+ }
157
151
  function activeMode(config, hasUI) {
158
152
  const env = process.env.PI_WARDEN_MODE?.trim();
159
153
  const mode = isMode(env) ? env : config.mode;
@@ -256,10 +250,17 @@ export default function wardenExtension(pi) {
256
250
  // fail-closes (no delivery) until the policy is re-measured.
257
251
  const consciencePolicy = CONSCIENCE_BETA_POLICY;
258
252
  let client;
253
+ const cooldown = new JudgeCooldown();
254
+ let judgeConfig;
255
+ /** Where cooldown notices go; unset headless, where a failure is already silent (see noteError). */
256
+ let noticeUi;
259
257
  let budgetExhausted = false;
260
258
  let stats = freshStats();
261
259
  const widget = new Map();
262
260
  const trace = new Trace();
261
+ // The trace file for a host without the terminal UI; set per session when PI_WARDEN_TRACE_DIR is an absolute path.
262
+ let traceFile;
263
+ let unsubscribeTraceFile;
263
264
  let panel;
264
265
  let configPanel;
265
266
  let lastUi;
@@ -309,6 +310,13 @@ export default function wardenExtension(pi) {
309
310
  const secretsSeen = new Set();
310
311
  // Subagent report entries already triaged, by session entry id; the wake window outlives one scan.
311
312
  const subagentSeen = new Set();
313
+ // Command families already steered toward filtered output this session: one steer per family.
314
+ const largeOutputSteered = new Set();
315
+ // Real paths the agent created under the temp directory this session, with the identity each had when recorded.
316
+ // A recursive rm of only these is not destructive; a record whose path is gone or replaced is dropped after each call.
317
+ const sessionScratch = new Map();
318
+ // Per tool call id: when it started and the temp paths it may create that did not exist yet.
319
+ const scratchPending = new Map();
312
320
  const wakePolicy = new WakePolicy(0);
313
321
  const runaway = new RunawayMonitor();
314
322
  // Runs stopped by the runaway guard for the current user prompt; the first one gets a recovery turn, later ones wait for the user.
@@ -368,14 +376,79 @@ export default function wardenExtension(pi) {
368
376
  return config;
369
377
  };
370
378
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
379
+ /** Why no judge is available, or undefined when one is. The notice, the trace file, and the conscience all use it. */
380
+ const judgmentsOffReason = (config) => {
381
+ if (!consentGiven(config))
382
+ return "no_consent";
383
+ if (budgetExhausted)
384
+ return "budget";
385
+ const auth = authState({ backend: config.typesafeBackend });
386
+ if (auth.usable)
387
+ return undefined;
388
+ // No key in effect, or one whose last request came back 401 or 403.
389
+ return auth.source === undefined ? "no_key" : "key_rejected";
390
+ };
391
+ // Where the judgments-off notice goes: the session's UI, or a status message for a headless session.
392
+ let judgmentsNotify;
393
+ let judgmentsHeadless = false;
394
+ const judgmentsReported = new Set();
395
+ /** Say once per session and reason that judgments are off, and record the state in the trace file. */
396
+ const noteJudgments = (config, reason) => {
397
+ traceFile?.judgments(judgmentsState(reason));
398
+ // The budget has its own notice in noteError.
399
+ if (reason === undefined || reason === "budget" || judgmentsReported.has(reason))
400
+ return;
401
+ judgmentsReported.add(reason);
402
+ const auth = reason === "key_rejected" ? authState({ backend: config.typesafeBackend }) : undefined;
403
+ judgmentsNotify?.(judgmentsOffText(reason, config.typesafeBackend, judgmentsHeadless, auth?.kind === "environment" ? auth.keyName : undefined));
404
+ };
371
405
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
372
406
  /** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
373
407
  const judgeFor = (config) => {
374
- if (!consentGiven(config) || budgetExhausted)
408
+ const off = judgmentsOffReason(config);
409
+ noteJudgments(config, off);
410
+ if (off)
375
411
  return undefined;
376
- if (!authState({ backend: config.typesafeBackend }).usable)
412
+ judgeConfig = config;
413
+ // Absent, exactly as with no judge configured, so no guard needs to know a cooldown exists.
414
+ if (cooldown.active()) {
415
+ stats.cooldownSkips++;
377
416
  return undefined;
378
- return client ??= createTypeSafe(judgeOptions(config));
417
+ }
418
+ return client ??= watched(createTypeSafe(judgeOptions(config)));
419
+ };
420
+ /** Every guard reaches the backend through `evaluate`, so this one seam sees each failure and each success. */
421
+ const watched = (typesafe) => new Proxy(typesafe, {
422
+ get(target, prop, receiver) {
423
+ if (prop !== "evaluate")
424
+ return Reflect.get(target, prop, receiver);
425
+ return async (...args) => {
426
+ try {
427
+ const result = await target.evaluate(...args);
428
+ tellCooldown(cooldown.success());
429
+ return result;
430
+ }
431
+ catch (err) {
432
+ const kind = cooldownFailureKind(err, args[1]?.signal);
433
+ if (kind)
434
+ tellCooldown(cooldown.failure(kind, judgeConfig?.judge ?? defaultConfig().judge));
435
+ throw err;
436
+ }
437
+ };
438
+ },
439
+ });
440
+ const tellCooldown = (event) => {
441
+ if (!event || !noticeUi)
442
+ return;
443
+ if (event.type === "recovered") {
444
+ noticeUi.notify("warden: judgments resumed.", "info");
445
+ return;
446
+ }
447
+ const seconds = Math.round(event.ms / 1000);
448
+ const remedy = event.kind === "auth"
449
+ ? ` Set ${keyEnvFor(judgeConfig?.typesafeBackend ?? "typesafe")} or run /warden enable.`
450
+ : event.kind === "configuration" ? " /warden status shows the backend setup." : "";
451
+ noticeUi.notify(`warden: judgments paused for ${seconds}s after a ${event.kind} failure; pattern checks run alone until then.${remedy}`, "warning");
379
452
  };
380
453
  const noteError = (ctx, message, code) => {
381
454
  stats.errors++;
@@ -387,15 +460,22 @@ export default function wardenExtension(pi) {
387
460
  /** Click, shortcut, and /warden trace all toggle the same sidebar. */
388
461
  const togglePanel = (ui, config) => {
389
462
  if (!ui)
390
- return;
463
+ return undefined;
391
464
  if (panel) {
392
465
  panel.close();
393
- return;
466
+ return undefined;
394
467
  }
395
468
  const opened = openTracePanel(ui, trace, { width: config.widget.panelWidth });
396
469
  panel = opened;
397
470
  opened.closed.catch(() => undefined).finally(() => { if (panel === opened)
398
471
  panel = undefined; });
472
+ return opened;
473
+ };
474
+ /** The last 20 trace entries as text, newest last, for a host that cannot show the sidebar. */
475
+ const traceText = () => {
476
+ const entries = trace.entries();
477
+ const text = entries.length ? entries.slice(-20).map(entry => `${new Date(entry.at).toTimeString().slice(0, 8)} ${entry.guard}: ${entry.line}${entry.details.length ? `\n ${entry.details.join("\n ")}` : ""}`).join("\n") : "No guarded activity yet this session.";
478
+ return traceFile ? `${text}\nTrace file: ${traceFile.path}` : text;
399
479
  };
400
480
  /** The config overlay toggles the same way the sidebar does, so the hint in its header is true. */
401
481
  const toggleConfigPanel = (ui, config) => {
@@ -617,6 +697,8 @@ export default function wardenExtension(pi) {
617
697
  pi.on("session_start", async (_event, ctx) => {
618
698
  if (ctx.hasUI)
619
699
  lastUi = ctx.ui;
700
+ noticeUi = ctx.hasUI ? ctx.ui : undefined;
701
+ cooldown.reset();
620
702
  if (_event.reason === "reload" || _event.reason === "new")
621
703
  conscienceGeneration++;
622
704
  client = undefined;
@@ -634,7 +716,27 @@ export default function wardenExtension(pi) {
634
716
  rulesGuard.reset();
635
717
  holds.reset();
636
718
  regretCandidates = [];
637
- holdLog = new HoldLog(holdLogPath(typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid)));
719
+ const sessionId = typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid);
720
+ holdLog = new HoldLog(holdLogPath(sessionId));
721
+ unsubscribeTraceFile?.();
722
+ unsubscribeTraceFile = undefined;
723
+ traceFile = undefined;
724
+ judgmentsReported.clear();
725
+ judgmentsHeadless = !ctx.hasUI;
726
+ judgmentsNotify = text => { if (ctx.hasUI)
727
+ ctx.ui.notify(text, "warning");
728
+ else
729
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
730
+ const dir = traceDir();
731
+ if (dir) {
732
+ const warn = (text) => { if (ctx.hasUI)
733
+ ctx.ui.notify(text, "warning");
734
+ else
735
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
736
+ const opening = loadConfig();
737
+ traceFile = new TraceFile(traceFilePath(dir, sessionId), dir, { sessionId, cwd: ctx.cwd, mode: opening.mode, judgments: judgmentsState(judgmentsOffReason(opening)) }, warn);
738
+ unsubscribeTraceFile = trace.subscribe(traceFile.listener);
739
+ }
638
740
  arming.reset();
639
741
  attempts.reset();
640
742
  evidence = emptyEvidence();
@@ -653,6 +755,9 @@ export default function wardenExtension(pi) {
653
755
  compressionLearner.reset();
654
756
  secretsSeen.clear();
655
757
  subagentSeen.clear();
758
+ largeOutputSteered.clear();
759
+ sessionScratch.clear();
760
+ scratchPending.clear();
656
761
  wakePolicy.reset();
657
762
  steerRepeats.reset();
658
763
  steersThisRun = 0;
@@ -688,6 +793,7 @@ export default function wardenExtension(pi) {
688
793
  pi.on("before_agent_start", async (event, ctx) => {
689
794
  if (ctx.hasUI)
690
795
  lastUi = ctx.ui;
796
+ noticeUi = ctx.hasUI ? ctx.ui : undefined;
691
797
  const config = configFor(ctx);
692
798
  attempts = new AttemptWindow(config.stuck.window);
693
799
  fullOutputs = new Map();
@@ -744,6 +850,7 @@ export default function wardenExtension(pi) {
744
850
  const truncationRecorded = event.prompt.length > 2000 ? [`prompt truncated from ${event.prompt.length} to 2000 chars`] : [];
745
851
  // Build recent context from session branch (up to 4 messages, 500 chars each)
746
852
  const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
853
+ const spine = taskSpine(branch, event.prompt);
747
854
  const recentMessages = [];
748
855
  for (const entry of branch.slice(-6)) {
749
856
  if (recentMessages.length >= 4)
@@ -804,7 +911,7 @@ export default function wardenExtension(pi) {
804
911
  // it. Empty until the first evaluate call, and an empty model never matches a policy, so the gate fails closed.
805
912
  let judgeModel = "";
806
913
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); judgeModel = r.model; return { answers: r.answers }; } } : undefined;
807
- const result = await assess(redactedPrompt, recentContext, skills, toolInfos, activeSkills, suppliedSkills, { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
914
+ const result = await assess(redactedPrompt, recentContext, skills, toolInfos, activeSkills, suppliedSkills, { judge: judgeAdapter, judgmentsOff: judgmentsOffReason(config), config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
808
915
  // Check generation after await
809
916
  if (conscienceGeneration !== myGeneration) {
810
917
  record(ctx, config, "conscience", "stale: generation changed during assessment", ["trigger: before_agent_start", `generation: ${myGeneration} → ${conscienceGeneration}`, `skipReason: stale`]);
@@ -889,7 +996,7 @@ export default function wardenExtension(pi) {
889
996
  }
890
997
  }
891
998
  catch (err) {
892
- const category = classifyConscienceError(err);
999
+ const category = classifyJudgeError(err);
893
1000
  if (!warnedErrorCategories.has(category)) {
894
1001
  warnedErrorCategories.add(category);
895
1002
  console.warn(`pi-warden: conscience ${category}`);
@@ -925,7 +1032,10 @@ export default function wardenExtension(pi) {
925
1032
  const judge = judgeFor(config);
926
1033
  if (judge && cachedSkills.length > 0) {
927
1034
  const myGeneration = conscienceGeneration;
928
- const redactedText = redact(typeof event.message.content === "string" ? event.message.content : "").slice(0, 2000);
1035
+ const prompt = typeof event.message.content === "string" ? event.message.content : "";
1036
+ const redactedText = redact(prompt).slice(0, 2000);
1037
+ const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
1038
+ const spine = taskSpine(branch, prompt);
929
1039
  let toolInfos = [];
930
1040
  try {
931
1041
  toolInfos = pi.getAllTools().map((t) => ({ name: t.name, description: t.description }));
@@ -936,13 +1046,13 @@ export default function wardenExtension(pi) {
936
1046
  const activeSkills = cachedSkills.map(s => s.name);
937
1047
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
938
1048
  try {
939
- const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
1049
+ const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
940
1050
  if (conscienceGeneration !== myGeneration)
941
1051
  return;
942
1052
  record(ctx, config, "conscience", `queued prompt assessed → ${result.selected ? `${result.selected.kind}:${result.selected.id}` : "none"} (${result.skipReason ?? "none"})`, ["trigger: message_start", `origin: queued`, `skipReason: ${result.skipReason ?? "none"}`]);
943
1053
  }
944
1054
  catch (err) {
945
- const cat = classifyConscienceError(err);
1055
+ const cat = classifyJudgeError(err);
946
1056
  if (!warnedErrorCategories.has(cat)) {
947
1057
  warnedErrorCategories.add(cat);
948
1058
  console.warn(`pi-warden: conscience ${cat}`);
@@ -1002,11 +1112,13 @@ export default function wardenExtension(pi) {
1002
1112
  catch {
1003
1113
  toolInfos = [];
1004
1114
  }
1005
- const redactedPrompt = redact(latestUserPrompt(ctx) ?? "").slice(0, 2000);
1115
+ const prompt = latestUserPrompt(ctx);
1116
+ const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
1117
+ const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
1006
1118
  const activeSkills = cachedSkills.map(s => s.name);
1007
1119
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
1008
1120
  try {
1009
- const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
1121
+ const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
1010
1122
  if (conscienceGeneration !== myGeneration)
1011
1123
  return;
1012
1124
  assessmentsThisPrompt++;
@@ -1031,6 +1143,8 @@ export default function wardenExtension(pi) {
1031
1143
  const config = configFor(ctx);
1032
1144
  if (!config.enabled)
1033
1145
  return;
1146
+ if (event.toolName === "bash" || event.toolName === "write")
1147
+ scratchPending.set(event.toolCallId, { started: Date.now(), candidates: scratchCandidates(event.toolName, event.input, ctx.cwd) });
1034
1148
  // ── Conscience: track tool attempts on the selected capability ──
1035
1149
  if (config.conscience.enabled && selectedCapability && !triggerConsumed) {
1036
1150
  if (selectedCapability.kind === "tool" && event.toolName === selectedCapability.id) {
@@ -1060,14 +1174,17 @@ export default function wardenExtension(pi) {
1060
1174
  if (!config.action.enabled || !config.action.tools.includes(event.toolName))
1061
1175
  return;
1062
1176
  stats.inspected++;
1063
- // First-run warning: pi-warden.md missing, one time per session.
1177
+ // First-run warning: no project rules file resolved, one time per session.
1064
1178
  if (!warnedMissingRules && config.rules.enabled) {
1065
- const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd);
1179
+ const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd, config.rules);
1066
1180
  if (missing && ctx.hasUI) {
1067
1181
  warnedMissingRules = true;
1182
+ // The tiers still in play, in resolution order: naming README/CLAUDE/AGENTS to someone who
1183
+ // turned the fallback off would send them to a document pi-warden will not read.
1184
+ const tiers = [RULES_FILE, "rules.files", ...(config.rules.fallback ? FALLBACK_FILES : [])].join(", ");
1068
1185
  const msg = fallbackSource
1069
- ? `No pi-warden.md detected. Using ${fallbackSource} as active fallback rules. Run /warden init to create project-specific rules.`
1070
- : `No rules file detected (pi-warden.md, README.md, CLAUDE.md, or AGENTS.md). Run /warden init to create project-specific rules.`;
1186
+ ? `No pi-warden.md or rules.files entry here. Using ${fallbackSource} as active fallback rules. Run /warden init to create project-specific rules.`
1187
+ : `No rules file detected (${tiers}). Run /warden init to create project-specific rules.`;
1071
1188
  ctx.ui.notify(msg, "warning");
1072
1189
  }
1073
1190
  }
@@ -1103,7 +1220,7 @@ export default function wardenExtension(pi) {
1103
1220
  ? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
1104
1221
  : undefined;
1105
1222
  rulesCheck?.catch(() => undefined);
1106
- const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined });
1223
+ const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx), spine: taskSpine(ctx.sessionManager.getBranch()) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, largeOutput: config.context.largeOutput, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined, scratch: sessionScratch });
1107
1224
  if (verdict.source === "skipped")
1108
1225
  return;
1109
1226
  // Arming check: if any armed rule's command regex matches this call, inject a hit into the verdict.
@@ -1159,6 +1276,11 @@ export default function wardenExtension(pi) {
1159
1276
  // Notes for the agent about the content it just wrote: slop, rule violations, and sensitive paths arrive as one message.
1160
1277
  const notes = [];
1161
1278
  const noteGuards = new Set();
1279
+ /** Slop and rule findings say "just written", so they wait until the call proceeds. A held, denied, or declined write
1280
+ * never lands and drops them; an approved retry is judged again and brings its own. They go between the action notes
1281
+ * and the sensitive-path notes, the order of the combined message. */
1282
+ const contentNotes = [];
1283
+ const pathNotes = [];
1162
1284
  /** Sensitive-path notes ride with the combined steer for this call; their trace waits for the delivery result. */
1163
1285
  const pathNoteTraces = [];
1164
1286
  if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
@@ -1223,15 +1345,24 @@ export default function wardenExtension(pi) {
1223
1345
  noteGuards.add("action");
1224
1346
  notes.push(shouldProceedMessage(verdict));
1225
1347
  }
1348
+ const largeOutput = largeOutputNotice(verdict, largeOutputSteered);
1349
+ if (largeOutput) {
1350
+ noteGuards.add("action");
1351
+ notes.push(largeOutput);
1352
+ }
1226
1353
  if (verdict.slopSymptoms?.length && verdict.slopReasons) {
1227
1354
  stats.slop++;
1228
- noteGuards.add("action");
1229
- for (const symptom of verdict.slopSymptoms)
1230
- slopCounts[symptom]++;
1355
+ const symptoms = verdict.slopSymptoms;
1231
1356
  const where = verdict.summary.path ?? event.toolName;
1232
1357
  if (ctx.hasUI && config.notices)
1233
1358
  ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
1234
- notes.push(slopSteer(where, verdict.slopSymptoms, slopCounts));
1359
+ contentNotes.push(proceeds => {
1360
+ if (!proceeds)
1361
+ return undefined;
1362
+ for (const symptom of symptoms)
1363
+ slopCounts[symptom]++;
1364
+ return { guard: "action", text: slopSteer(where, symptoms, slopCounts) };
1365
+ });
1235
1366
  }
1236
1367
  if (rulesCheck) {
1237
1368
  const rules = await rulesCheck;
@@ -1239,16 +1370,23 @@ export default function wardenExtension(pi) {
1239
1370
  stats.ruleChecks++;
1240
1371
  if (rules.error)
1241
1372
  noteError(ctx, rules.error, rules.errorCode);
1242
- const told = rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
1243
- record(ctx, config, "rules", formatRules(rules, config.widget.rules), rulesDetails(rules, told));
1244
1373
  holds.recordRules({ source: rules.source, path: rules.path, findings: rules.findings.map(f => ({ name: f.name, violation: f.violation })), ...(rules.error ? { error: rules.error } : {}) });
1245
- if (told) {
1374
+ if (rules.findings.length) {
1246
1375
  stats.ruleViolations++;
1247
- noteGuards.add("rules");
1248
1376
  if (ctx.hasUI && config.notices)
1249
1377
  ctx.ui.notify(`warden · rules · ${rules.path}: ${rules.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
1250
- notes.push(told);
1251
1378
  }
1379
+ contentNotes.push(proceeds => {
1380
+ const told = proceeds && rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
1381
+ const details = rulesDetails(rules, told);
1382
+ if (!proceeds && rules.findings.length)
1383
+ details.push("agent not told: the write was held");
1384
+ record(ctx, config, "rules", formatRules(rules, config.widget.rules), details);
1385
+ return told ? { guard: "rules", text: told } : undefined;
1386
+ });
1387
+ }
1388
+ else {
1389
+ record(ctx, config, "rules", formatRules(rules, config.widget.rules), [`${rules.tool} ${rules.path}: ${rules.skippedReason}`]);
1252
1390
  }
1253
1391
  const hits = rulesGuard.notesFor(verdict.summary.location === "inside_project" ? verdict.summary.path : undefined, config.rules.sensitivePaths);
1254
1392
  if (hits.length && verdict.summary.path) {
@@ -1262,17 +1400,29 @@ export default function wardenExtension(pi) {
1262
1400
  ctx.ui.notify(`warden · sensitive path · ${verdict.summary.path} (${hits.map(hit => hit.glob).join(", ")}); the agent was given the note`, "warning");
1263
1401
  });
1264
1402
  noteGuards.add("rules");
1265
- notes.push(told);
1403
+ pathNotes.push(told);
1266
1404
  }
1267
1405
  }
1268
- if (notes.length) {
1269
- const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
1270
- for (const traceNote of pathNoteTraces)
1271
- traceNote(delivered);
1272
- pathNoteTraces.length = 0;
1273
- }
1406
+ /** Sends the combined steer once the call's fate is known; `proceeds` is false when the call does not run. */
1407
+ const deliverNotes = (proceeds) => {
1408
+ for (const contentNote of contentNotes) {
1409
+ const note = contentNote(proceeds);
1410
+ if (!note)
1411
+ continue;
1412
+ noteGuards.add(note.guard);
1413
+ notes.push(note.text);
1414
+ }
1415
+ notes.push(...pathNotes);
1416
+ if (notes.length) {
1417
+ const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
1418
+ for (const traceNote of pathNoteTraces)
1419
+ traceNote(delivered);
1420
+ pathNoteTraces.length = 0;
1421
+ }
1422
+ };
1274
1423
  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.`);
1275
1424
  if (verdict.level === "warn") {
1425
+ deliverNotes(true);
1276
1426
  stats.warned++;
1277
1427
  if (ctx.hasUI && config.notices)
1278
1428
  ctx.ui.notify(`warden · ${event.toolName}: ${verdict.reasons.join("; ")}`, "warning");
@@ -1284,6 +1434,7 @@ export default function wardenExtension(pi) {
1284
1434
  return undefined;
1285
1435
  }
1286
1436
  if (verdict.level === "deny") {
1437
+ deliverNotes(false);
1287
1438
  stats.held++;
1288
1439
  track(true, "declined", "deny");
1289
1440
  if (ctx.hasUI && config.notices)
@@ -1292,6 +1443,7 @@ export default function wardenExtension(pi) {
1292
1443
  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.` };
1293
1444
  }
1294
1445
  if (verdict.level !== "confirm") {
1446
+ deliverNotes(true);
1295
1447
  track(false);
1296
1448
  return undefined;
1297
1449
  }
@@ -1304,6 +1456,7 @@ export default function wardenExtension(pi) {
1304
1456
  if (dialogRule && ctx.hasUI) {
1305
1457
  notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
1306
1458
  const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
1459
+ deliverNotes(allowed);
1307
1460
  if (allowed) {
1308
1461
  track(true, "approved", "dialog");
1309
1462
  return undefined;
@@ -1313,6 +1466,7 @@ export default function wardenExtension(pi) {
1313
1466
  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.` };
1314
1467
  }
1315
1468
  if (mode === "advise") {
1469
+ deliverNotes(true);
1316
1470
  stats.warned++;
1317
1471
  if (ctx.hasUI && config.notices)
1318
1472
  ctx.ui.notify(`warden · ${event.toolName} (advise mode, not held): ${reasons}`, "warning");
@@ -1324,6 +1478,7 @@ export default function wardenExtension(pi) {
1324
1478
  if (mode === "confirm") {
1325
1479
  notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
1326
1480
  const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
1481
+ deliverNotes(allowed);
1327
1482
  if (allowed) {
1328
1483
  track(true, "approved", "dialog");
1329
1484
  return undefined;
@@ -1332,6 +1487,7 @@ export default function wardenExtension(pi) {
1332
1487
  track(true, "declined", "dialog");
1333
1488
  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.` };
1334
1489
  }
1490
+ deliverNotes(false);
1335
1491
  stats.held++;
1336
1492
  actionGuard.hold(task);
1337
1493
  track(true);
@@ -1346,6 +1502,13 @@ export default function wardenExtension(pi) {
1346
1502
  return;
1347
1503
  const textBlocks = event.content.filter(part => part.type === "text");
1348
1504
  const text = textBlocks.map(part => part.text).join("\n");
1505
+ const scratch = scratchPending.get(event.toolCallId);
1506
+ if (scratch) {
1507
+ scratchPending.delete(event.toolCallId);
1508
+ for (const [path, identity] of createdScratch(event.toolName, event.input, text, scratch.started, scratch.candidates))
1509
+ sessionScratch.set(path, identity);
1510
+ }
1511
+ pruneScratch(sessionScratch);
1349
1512
  // Repeat detection uses the original result, so its request goes out together with the output check.
1350
1513
  const failed = resultFailed(event.isError, event.details, event.content);
1351
1514
  // ── Conscience: track tool_result for the pending capability ──
@@ -1468,7 +1631,9 @@ export default function wardenExtension(pi) {
1468
1631
  }
1469
1632
  if (config.context.enabled && !earlier && !recallRead && textBlocks.length === 1 && text.length >= config.context.tailMinChars)
1470
1633
  ledger.candidate();
1471
- if (multiBlock && !ctx.signal?.aborted) {
1634
+ // Captured before the loop so the notice block below knows whether each block already carries its banner.
1635
+ const bannersPlacedWithBlocks = multiBlock && !ctx.signal?.aborted;
1636
+ if (bannersPlacedWithBlocks) {
1472
1637
  // Retention and banners per block; block order and non-text parts are never touched.
1473
1638
  content = [...content];
1474
1639
  let textIndex = 0;
@@ -1536,8 +1701,11 @@ export default function wardenExtension(pi) {
1536
1701
  }
1537
1702
  if (key && !earlier)
1538
1703
  ledger.remember(key, event.toolName, storedPath);
1704
+ // The banner in the result already tells the agent. A steer custom message sent here arrives after the turn ends
1705
+ // when this result closes the turn and starts a new turn to answer a message that asks for nothing, so the notice
1706
+ // rides the result content alone and is never steered.
1539
1707
  if (notice && textBlocks.length) {
1540
- if (!multiBlock) {
1708
+ if (!bannersPlacedWithBlocks) {
1541
1709
  let index = 0;
1542
1710
  content = content.map(part => {
1543
1711
  if (part.type !== "text")
@@ -1546,13 +1714,12 @@ export default function wardenExtension(pi) {
1546
1714
  return { ...part, text: `${index === 1 ? `${notice}\n\n` : ""}${part.text}${index === textBlocks.length ? `\n\n${notice}` : ""}` };
1547
1715
  });
1548
1716
  }
1549
- const delivered = steer(config, "security", notice);
1550
1717
  record(ctx, config, "security", renderTemplate(config.widget.security, {
1551
1718
  tool: event.toolName, injection: output.injection?.toFixed(2), exfiltration: output.exfiltration?.toFixed(2),
1552
1719
  status: [output.suspicious && "untrusted instructions", output.secret && "possible credentials"].filter(Boolean).join(", "),
1553
1720
  }), [
1554
1721
  `jev: injection ${output.injection?.toFixed(2) ?? "not judged"}; exfiltration ${output.exfiltration?.toFixed(2) ?? "not judged"}`,
1555
- `output sample: ${redact(text).slice(0, 300)}`, delivered ? `agent told: ${notice}` : `steer recorded, not delivered (a repeat or the per-run budget): ${notice}`,
1722
+ `output sample: ${redact(text).slice(0, 300)}`, `agent told by the banner in the tool result: ${notice}`,
1556
1723
  ]);
1557
1724
  }
1558
1725
  // Stuck-loop diff: when the verdict is stuck on a failed result and a previous failed output
@@ -1742,11 +1909,13 @@ export default function wardenExtension(pi) {
1742
1909
  catch {
1743
1910
  toolInfos = [];
1744
1911
  }
1745
- const redactedPrompt = redact(latestUserPrompt(ctx) ?? "").slice(0, 2000);
1912
+ const prompt = latestUserPrompt(ctx);
1913
+ const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
1914
+ const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
1746
1915
  const activeSkills = cachedSkills.map(s => s.name);
1747
1916
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
1748
1917
  try {
1749
- const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
1918
+ const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
1750
1919
  if (conscienceGeneration !== myGeneration)
1751
1920
  return;
1752
1921
  assessmentsThisPrompt++;
@@ -1860,7 +2029,7 @@ export default function wardenExtension(pi) {
1860
2029
  : `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).`;
1861
2030
  report([
1862
2031
  `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}`,
1863
- `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`}.`,
2032
+ `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, ${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`}.`,
1864
2033
  formatSteers(stats),
1865
2034
  `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / steer ${config.action.offTask.steer} (never holds); intent mismatch ${config.action.intentMismatch} (${config.action.visibleMismatch} on a visible action); stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, rules ${config.rules.threshold}, prose ${config.slop.prose.threshold} in ${config.slop.prose.trend}/3 replies; runaway ${config.runaway.repeats} repeats (thinking ${config.runaway.thinkingRepeats}), recover ${config.runaway.recover}; failOpen ${config.action.failOpen}.`,
1866
2035
  formatLedger(ledger.snapshot()),
@@ -1878,11 +2047,16 @@ export default function wardenExtension(pi) {
1878
2047
  }
1879
2048
  if (action === "trace") {
1880
2049
  if (!ctx.hasUI) {
1881
- const entries = trace.entries();
1882
- report(entries.length ? entries.slice(-20).map(entry => `${new Date(entry.at).toTimeString().slice(0, 8)} ${entry.guard}: ${entry.line}${entry.details.length ? `\n ${entry.details.join("\n ")}` : ""}`).join("\n") : "No guarded activity yet this session.");
2050
+ report(traceText());
1883
2051
  return;
1884
2052
  }
1885
- togglePanel(ctx.ui, config);
2053
+ const opened = togglePanel(ctx.ui, config);
2054
+ // RPC mode has a UI but settles custom() without building the component, so the sidebar never shows. hasUI
2055
+ // is true there; only the host not asking for the component tells the two apart. Not awaited: in the
2056
+ // terminal `closed` settles when the user closes the sidebar.
2057
+ const fallback = () => { if (opened && !opened.built())
2058
+ ctx.ui.notify(traceText(), "info"); };
2059
+ void opened?.closed.then(fallback, fallback);
1886
2060
  return;
1887
2061
  }
1888
2062
  if (action === "recommend") {