pi-warden 0.40.2 → 0.48.1

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.
@@ -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, hostPaths, 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,19 @@ 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;
264
+ // Directories outside the project the host lets its agent write; set per session from PI_WARDEN_HOST_PATHS.
265
+ let sessionHostPaths = [];
263
266
  let panel;
264
267
  let configPanel;
265
268
  let lastUi;
@@ -294,6 +297,8 @@ export default function wardenExtension(pi) {
294
297
  let fullOutputs = new Map();
295
298
  let evidence = emptyEvidence();
296
299
  let doneNudged = false;
300
+ /** True from a delivered warden follow-up that starts a turn until the next run starts: that run keeps the evidence. */
301
+ let wardenContinuation = false;
297
302
  let warnedFallback = false;
298
303
  let warnedMissingRules = false;
299
304
  /** True while /warden init is sending a prompt and waiting for the agent to generate pi-warden.md. */
@@ -309,6 +314,13 @@ export default function wardenExtension(pi) {
309
314
  const secretsSeen = new Set();
310
315
  // Subagent report entries already triaged, by session entry id; the wake window outlives one scan.
311
316
  const subagentSeen = new Set();
317
+ // Command families already steered toward filtered output this session: one steer per family.
318
+ const largeOutputSteered = new Set();
319
+ // Real paths the agent created under the temp directory this session, with the identity each had when recorded.
320
+ // A recursive rm of only these is not destructive; a record whose path is gone or replaced is dropped after each call.
321
+ const sessionScratch = new Map();
322
+ // Per tool call id: when it started and the temp paths it may create that did not exist yet.
323
+ const scratchPending = new Map();
312
324
  const wakePolicy = new WakePolicy(0);
313
325
  const runaway = new RunawayMonitor();
314
326
  // 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 +380,79 @@ export default function wardenExtension(pi) {
368
380
  return config;
369
381
  };
370
382
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
383
+ /** Why no judge is available, or undefined when one is. The notice, the trace file, and the conscience all use it. */
384
+ const judgmentsOffReason = (config) => {
385
+ if (!consentGiven(config))
386
+ return "no_consent";
387
+ if (budgetExhausted)
388
+ return "budget";
389
+ const auth = authState({ backend: config.typesafeBackend });
390
+ if (auth.usable)
391
+ return undefined;
392
+ // No key in effect, or one whose last request came back 401 or 403.
393
+ return auth.source === undefined ? "no_key" : "key_rejected";
394
+ };
395
+ // Where the judgments-off notice goes: the session's UI, or a status message for a headless session.
396
+ let judgmentsNotify;
397
+ let judgmentsHeadless = false;
398
+ const judgmentsReported = new Set();
399
+ /** Say once per session and reason that judgments are off, and record the state in the trace file. */
400
+ const noteJudgments = (config, reason) => {
401
+ traceFile?.judgments(judgmentsState(reason));
402
+ // The budget has its own notice in noteError.
403
+ if (reason === undefined || reason === "budget" || judgmentsReported.has(reason))
404
+ return;
405
+ judgmentsReported.add(reason);
406
+ const auth = reason === "key_rejected" ? authState({ backend: config.typesafeBackend }) : undefined;
407
+ judgmentsNotify?.(judgmentsOffText(reason, config.typesafeBackend, judgmentsHeadless, auth?.kind === "environment" ? auth.keyName : undefined));
408
+ };
371
409
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
372
410
  /** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
373
411
  const judgeFor = (config) => {
374
- if (!consentGiven(config) || budgetExhausted)
412
+ const off = judgmentsOffReason(config);
413
+ noteJudgments(config, off);
414
+ if (off)
375
415
  return undefined;
376
- if (!authState({ backend: config.typesafeBackend }).usable)
416
+ judgeConfig = config;
417
+ // Absent, exactly as with no judge configured, so no guard needs to know a cooldown exists.
418
+ if (cooldown.active()) {
419
+ stats.cooldownSkips++;
377
420
  return undefined;
378
- return client ??= createTypeSafe(judgeOptions(config));
421
+ }
422
+ return client ??= watched(createTypeSafe(judgeOptions(config)));
423
+ };
424
+ /** Every guard reaches the backend through `evaluate`, so this one seam sees each failure and each success. */
425
+ const watched = (typesafe) => new Proxy(typesafe, {
426
+ get(target, prop, receiver) {
427
+ if (prop !== "evaluate")
428
+ return Reflect.get(target, prop, receiver);
429
+ return async (...args) => {
430
+ try {
431
+ const result = await target.evaluate(...args);
432
+ tellCooldown(cooldown.success());
433
+ return result;
434
+ }
435
+ catch (err) {
436
+ const kind = cooldownFailureKind(err, args[1]?.signal);
437
+ if (kind)
438
+ tellCooldown(cooldown.failure(kind, judgeConfig?.judge ?? defaultConfig().judge));
439
+ throw err;
440
+ }
441
+ };
442
+ },
443
+ });
444
+ const tellCooldown = (event) => {
445
+ if (!event || !noticeUi)
446
+ return;
447
+ if (event.type === "recovered") {
448
+ noticeUi.notify("warden: judgments resumed.", "info");
449
+ return;
450
+ }
451
+ const seconds = Math.round(event.ms / 1000);
452
+ const remedy = event.kind === "auth"
453
+ ? ` Set ${keyEnvFor(judgeConfig?.typesafeBackend ?? "typesafe")} or run /warden enable.`
454
+ : event.kind === "configuration" ? " /warden status shows the backend setup." : "";
455
+ noticeUi.notify(`warden: judgments paused for ${seconds}s after a ${event.kind} failure; pattern checks run alone until then.${remedy}`, "warning");
379
456
  };
380
457
  const noteError = (ctx, message, code) => {
381
458
  stats.errors++;
@@ -387,15 +464,22 @@ export default function wardenExtension(pi) {
387
464
  /** Click, shortcut, and /warden trace all toggle the same sidebar. */
388
465
  const togglePanel = (ui, config) => {
389
466
  if (!ui)
390
- return;
467
+ return undefined;
391
468
  if (panel) {
392
469
  panel.close();
393
- return;
470
+ return undefined;
394
471
  }
395
472
  const opened = openTracePanel(ui, trace, { width: config.widget.panelWidth });
396
473
  panel = opened;
397
474
  opened.closed.catch(() => undefined).finally(() => { if (panel === opened)
398
475
  panel = undefined; });
476
+ return opened;
477
+ };
478
+ /** The last 20 trace entries as text, newest last, for a host that cannot show the sidebar. */
479
+ const traceText = () => {
480
+ const entries = trace.entries();
481
+ 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.";
482
+ return traceFile ? `${text}\nTrace file: ${traceFile.path}` : text;
399
483
  };
400
484
  /** The config overlay toggles the same way the sidebar does, so the hint in its header is true. */
401
485
  const toggleConfigPanel = (ui, config) => {
@@ -555,6 +639,8 @@ export default function wardenExtension(pi) {
555
639
  steersThisRun++;
556
640
  const { display, ...delivery } = options ?? { deliverAs: "steer" };
557
641
  pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: display ?? config.steerVisible }, delivery);
642
+ if (delivery.triggerTurn)
643
+ wardenContinuation = true;
558
644
  return true;
559
645
  };
560
646
  /**
@@ -617,6 +703,8 @@ export default function wardenExtension(pi) {
617
703
  pi.on("session_start", async (_event, ctx) => {
618
704
  if (ctx.hasUI)
619
705
  lastUi = ctx.ui;
706
+ noticeUi = ctx.hasUI ? ctx.ui : undefined;
707
+ cooldown.reset();
620
708
  if (_event.reason === "reload" || _event.reason === "new")
621
709
  conscienceGeneration++;
622
710
  client = undefined;
@@ -634,11 +722,33 @@ export default function wardenExtension(pi) {
634
722
  rulesGuard.reset();
635
723
  holds.reset();
636
724
  regretCandidates = [];
637
- holdLog = new HoldLog(holdLogPath(typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid)));
725
+ const sessionId = typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid);
726
+ holdLog = new HoldLog(holdLogPath(sessionId));
727
+ unsubscribeTraceFile?.();
728
+ unsubscribeTraceFile = undefined;
729
+ traceFile = undefined;
730
+ judgmentsReported.clear();
731
+ judgmentsHeadless = !ctx.hasUI;
732
+ judgmentsNotify = text => { if (ctx.hasUI)
733
+ ctx.ui.notify(text, "warning");
734
+ else
735
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
736
+ sessionHostPaths = hostPaths();
737
+ const dir = traceDir();
738
+ if (dir) {
739
+ const warn = (text) => { if (ctx.hasUI)
740
+ ctx.ui.notify(text, "warning");
741
+ else
742
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
743
+ const opening = loadConfig();
744
+ traceFile = new TraceFile(traceFilePath(dir, sessionId), dir, { sessionId, cwd: ctx.cwd, mode: opening.mode, judgments: judgmentsState(judgmentsOffReason(opening)) }, warn);
745
+ unsubscribeTraceFile = trace.subscribe(traceFile.listener);
746
+ }
638
747
  arming.reset();
639
748
  attempts.reset();
640
749
  evidence = emptyEvidence();
641
750
  doneNudged = false;
751
+ wardenContinuation = false;
642
752
  prose.reset();
643
753
  // Clean up temp output dirs from the previous session.
644
754
  for (const dir of ledger.storedPaths()) {
@@ -653,6 +763,9 @@ export default function wardenExtension(pi) {
653
763
  compressionLearner.reset();
654
764
  secretsSeen.clear();
655
765
  subagentSeen.clear();
766
+ largeOutputSteered.clear();
767
+ sessionScratch.clear();
768
+ scratchPending.clear();
656
769
  wakePolicy.reset();
657
770
  steerRepeats.reset();
658
771
  steersThisRun = 0;
@@ -688,10 +801,12 @@ export default function wardenExtension(pi) {
688
801
  pi.on("before_agent_start", async (event, ctx) => {
689
802
  if (ctx.hasUI)
690
803
  lastUi = ctx.ui;
804
+ noticeUi = ctx.hasUI ? ctx.ui : undefined;
691
805
  const config = configFor(ctx);
692
806
  attempts = new AttemptWindow(config.stuck.window);
693
807
  fullOutputs = new Map();
694
808
  doneNudged = false;
809
+ wardenContinuation = false;
695
810
  steersThisRun = 0;
696
811
  finals.reset();
697
812
  runaway.reset();
@@ -744,6 +859,7 @@ export default function wardenExtension(pi) {
744
859
  const truncationRecorded = event.prompt.length > 2000 ? [`prompt truncated from ${event.prompt.length} to 2000 chars`] : [];
745
860
  // Build recent context from session branch (up to 4 messages, 500 chars each)
746
861
  const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
862
+ const spine = taskSpine(branch, event.prompt);
747
863
  const recentMessages = [];
748
864
  for (const entry of branch.slice(-6)) {
749
865
  if (recentMessages.length >= 4)
@@ -804,7 +920,7 @@ export default function wardenExtension(pi) {
804
920
  // it. Empty until the first evaluate call, and an empty model never matches a policy, so the gate fails closed.
805
921
  let judgeModel = "";
806
922
  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 });
923
+ 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
924
  // Check generation after await
809
925
  if (conscienceGeneration !== myGeneration) {
810
926
  record(ctx, config, "conscience", "stale: generation changed during assessment", ["trigger: before_agent_start", `generation: ${myGeneration} → ${conscienceGeneration}`, `skipReason: stale`]);
@@ -889,7 +1005,7 @@ export default function wardenExtension(pi) {
889
1005
  }
890
1006
  }
891
1007
  catch (err) {
892
- const category = classifyConscienceError(err);
1008
+ const category = classifyJudgeError(err);
893
1009
  if (!warnedErrorCategories.has(category)) {
894
1010
  warnedErrorCategories.add(category);
895
1011
  console.warn(`pi-warden: conscience ${category}`);
@@ -925,7 +1041,10 @@ export default function wardenExtension(pi) {
925
1041
  const judge = judgeFor(config);
926
1042
  if (judge && cachedSkills.length > 0) {
927
1043
  const myGeneration = conscienceGeneration;
928
- const redactedText = redact(typeof event.message.content === "string" ? event.message.content : "").slice(0, 2000);
1044
+ const prompt = typeof event.message.content === "string" ? event.message.content : "";
1045
+ const redactedText = redact(prompt).slice(0, 2000);
1046
+ const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
1047
+ const spine = taskSpine(branch, prompt);
929
1048
  let toolInfos = [];
930
1049
  try {
931
1050
  toolInfos = pi.getAllTools().map((t) => ({ name: t.name, description: t.description }));
@@ -936,13 +1055,13 @@ export default function wardenExtension(pi) {
936
1055
  const activeSkills = cachedSkills.map(s => s.name);
937
1056
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
938
1057
  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 });
1058
+ 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
1059
  if (conscienceGeneration !== myGeneration)
941
1060
  return;
942
1061
  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
1062
  }
944
1063
  catch (err) {
945
- const cat = classifyConscienceError(err);
1064
+ const cat = classifyJudgeError(err);
946
1065
  if (!warnedErrorCategories.has(cat)) {
947
1066
  warnedErrorCategories.add(cat);
948
1067
  console.warn(`pi-warden: conscience ${cat}`);
@@ -980,9 +1099,12 @@ export default function wardenExtension(pi) {
980
1099
  conscienceGeneration++;
981
1100
  ctx.abort();
982
1101
  });
983
- // Each low-level run collects its own evidence of changes and checks.
1102
+ // Each low-level run collects its own evidence of changes and checks. A run that a warden follow-up started continues
1103
+ // the previous run's work, so it keeps that evidence: the changes behind a done-check nudge still need a passing check.
984
1104
  pi.on("agent_start", async () => {
985
- evidence = emptyEvidence();
1105
+ if (!wardenContinuation)
1106
+ evidence = emptyEvidence();
1107
+ wardenContinuation = false;
986
1108
  });
987
1109
  // Every turn that runs after a compression is a turn that did not carry the removed text.
988
1110
  pi.on("turn_end", async (_event, ctx) => {
@@ -1002,11 +1124,13 @@ export default function wardenExtension(pi) {
1002
1124
  catch {
1003
1125
  toolInfos = [];
1004
1126
  }
1005
- const redactedPrompt = redact(latestUserPrompt(ctx) ?? "").slice(0, 2000);
1127
+ const prompt = latestUserPrompt(ctx);
1128
+ const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
1129
+ const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
1006
1130
  const activeSkills = cachedSkills.map(s => s.name);
1007
1131
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
1008
1132
  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 });
1133
+ 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
1134
  if (conscienceGeneration !== myGeneration)
1011
1135
  return;
1012
1136
  assessmentsThisPrompt++;
@@ -1031,6 +1155,8 @@ export default function wardenExtension(pi) {
1031
1155
  const config = configFor(ctx);
1032
1156
  if (!config.enabled)
1033
1157
  return;
1158
+ if (event.toolName === "bash" || event.toolName === "write")
1159
+ scratchPending.set(event.toolCallId, { started: Date.now(), candidates: scratchCandidates(event.toolName, event.input, ctx.cwd) });
1034
1160
  // ── Conscience: track tool attempts on the selected capability ──
1035
1161
  if (config.conscience.enabled && selectedCapability && !triggerConsumed) {
1036
1162
  if (selectedCapability.kind === "tool" && event.toolName === selectedCapability.id) {
@@ -1060,14 +1186,17 @@ export default function wardenExtension(pi) {
1060
1186
  if (!config.action.enabled || !config.action.tools.includes(event.toolName))
1061
1187
  return;
1062
1188
  stats.inspected++;
1063
- // First-run warning: pi-warden.md missing, one time per session.
1189
+ // First-run warning: no project rules file resolved, one time per session.
1064
1190
  if (!warnedMissingRules && config.rules.enabled) {
1065
- const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd);
1191
+ const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd, config.rules);
1066
1192
  if (missing && ctx.hasUI) {
1067
1193
  warnedMissingRules = true;
1194
+ // The tiers still in play, in resolution order: naming README/CLAUDE/AGENTS to someone who
1195
+ // turned the fallback off would send them to a document pi-warden will not read.
1196
+ const tiers = [RULES_FILE, "rules.files", ...(config.rules.fallback ? FALLBACK_FILES : [])].join(", ");
1068
1197
  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.`;
1198
+ ? `No pi-warden.md or rules.files entry here. Using ${fallbackSource} as active fallback rules. Run /warden init to create project-specific rules.`
1199
+ : `No rules file detected (${tiers}). Run /warden init to create project-specific rules.`;
1071
1200
  ctx.ui.notify(msg, "warning");
1072
1201
  }
1073
1202
  }
@@ -1103,7 +1232,7 @@ export default function wardenExtension(pi) {
1103
1232
  ? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
1104
1233
  : undefined;
1105
1234
  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 });
1235
+ 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, hostPaths: sessionHostPaths });
1107
1236
  if (verdict.source === "skipped")
1108
1237
  return;
1109
1238
  // Arming check: if any armed rule's command regex matches this call, inject a hit into the verdict.
@@ -1159,6 +1288,11 @@ export default function wardenExtension(pi) {
1159
1288
  // Notes for the agent about the content it just wrote: slop, rule violations, and sensitive paths arrive as one message.
1160
1289
  const notes = [];
1161
1290
  const noteGuards = new Set();
1291
+ /** Slop and rule findings say "just written", so they wait until the call proceeds. A held, denied, or declined write
1292
+ * never lands and drops them; an approved retry is judged again and brings its own. They go between the action notes
1293
+ * and the sensitive-path notes, the order of the combined message. */
1294
+ const contentNotes = [];
1295
+ const pathNotes = [];
1162
1296
  /** Sensitive-path notes ride with the combined steer for this call; their trace waits for the delivery result. */
1163
1297
  const pathNoteTraces = [];
1164
1298
  if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
@@ -1223,15 +1357,24 @@ export default function wardenExtension(pi) {
1223
1357
  noteGuards.add("action");
1224
1358
  notes.push(shouldProceedMessage(verdict));
1225
1359
  }
1360
+ const largeOutput = largeOutputNotice(verdict, largeOutputSteered);
1361
+ if (largeOutput) {
1362
+ noteGuards.add("action");
1363
+ notes.push(largeOutput);
1364
+ }
1226
1365
  if (verdict.slopSymptoms?.length && verdict.slopReasons) {
1227
1366
  stats.slop++;
1228
- noteGuards.add("action");
1229
- for (const symptom of verdict.slopSymptoms)
1230
- slopCounts[symptom]++;
1367
+ const symptoms = verdict.slopSymptoms;
1231
1368
  const where = verdict.summary.path ?? event.toolName;
1232
1369
  if (ctx.hasUI && config.notices)
1233
1370
  ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
1234
- notes.push(slopSteer(where, verdict.slopSymptoms, slopCounts));
1371
+ contentNotes.push(proceeds => {
1372
+ if (!proceeds)
1373
+ return undefined;
1374
+ for (const symptom of symptoms)
1375
+ slopCounts[symptom]++;
1376
+ return { guard: "action", text: slopSteer(where, symptoms, slopCounts) };
1377
+ });
1235
1378
  }
1236
1379
  if (rulesCheck) {
1237
1380
  const rules = await rulesCheck;
@@ -1239,16 +1382,23 @@ export default function wardenExtension(pi) {
1239
1382
  stats.ruleChecks++;
1240
1383
  if (rules.error)
1241
1384
  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
1385
  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) {
1386
+ if (rules.findings.length) {
1246
1387
  stats.ruleViolations++;
1247
- noteGuards.add("rules");
1248
1388
  if (ctx.hasUI && config.notices)
1249
1389
  ctx.ui.notify(`warden · rules · ${rules.path}: ${rules.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
1250
- notes.push(told);
1251
1390
  }
1391
+ contentNotes.push(proceeds => {
1392
+ const told = proceeds && rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
1393
+ const details = rulesDetails(rules, told);
1394
+ if (!proceeds && rules.findings.length)
1395
+ details.push("agent not told: the write was held");
1396
+ record(ctx, config, "rules", formatRules(rules, config.widget.rules), details);
1397
+ return told ? { guard: "rules", text: told } : undefined;
1398
+ });
1399
+ }
1400
+ else {
1401
+ record(ctx, config, "rules", formatRules(rules, config.widget.rules), [`${rules.tool} ${rules.path}: ${rules.skippedReason}`]);
1252
1402
  }
1253
1403
  const hits = rulesGuard.notesFor(verdict.summary.location === "inside_project" ? verdict.summary.path : undefined, config.rules.sensitivePaths);
1254
1404
  if (hits.length && verdict.summary.path) {
@@ -1262,17 +1412,29 @@ export default function wardenExtension(pi) {
1262
1412
  ctx.ui.notify(`warden · sensitive path · ${verdict.summary.path} (${hits.map(hit => hit.glob).join(", ")}); the agent was given the note`, "warning");
1263
1413
  });
1264
1414
  noteGuards.add("rules");
1265
- notes.push(told);
1415
+ pathNotes.push(told);
1266
1416
  }
1267
1417
  }
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
- }
1418
+ /** Sends the combined steer once the call's fate is known; `proceeds` is false when the call does not run. */
1419
+ const deliverNotes = (proceeds) => {
1420
+ for (const contentNote of contentNotes) {
1421
+ const note = contentNote(proceeds);
1422
+ if (!note)
1423
+ continue;
1424
+ noteGuards.add(note.guard);
1425
+ notes.push(note.text);
1426
+ }
1427
+ notes.push(...pathNotes);
1428
+ if (notes.length) {
1429
+ const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
1430
+ for (const traceNote of pathNoteTraces)
1431
+ traceNote(delivered);
1432
+ pathNoteTraces.length = 0;
1433
+ }
1434
+ };
1274
1435
  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
1436
  if (verdict.level === "warn") {
1437
+ deliverNotes(true);
1276
1438
  stats.warned++;
1277
1439
  if (ctx.hasUI && config.notices)
1278
1440
  ctx.ui.notify(`warden · ${event.toolName}: ${verdict.reasons.join("; ")}`, "warning");
@@ -1284,6 +1446,7 @@ export default function wardenExtension(pi) {
1284
1446
  return undefined;
1285
1447
  }
1286
1448
  if (verdict.level === "deny") {
1449
+ deliverNotes(false);
1287
1450
  stats.held++;
1288
1451
  track(true, "declined", "deny");
1289
1452
  if (ctx.hasUI && config.notices)
@@ -1292,6 +1455,7 @@ export default function wardenExtension(pi) {
1292
1455
  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
1456
  }
1294
1457
  if (verdict.level !== "confirm") {
1458
+ deliverNotes(true);
1295
1459
  track(false);
1296
1460
  return undefined;
1297
1461
  }
@@ -1304,6 +1468,7 @@ export default function wardenExtension(pi) {
1304
1468
  if (dialogRule && ctx.hasUI) {
1305
1469
  notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
1306
1470
  const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
1471
+ deliverNotes(allowed);
1307
1472
  if (allowed) {
1308
1473
  track(true, "approved", "dialog");
1309
1474
  return undefined;
@@ -1313,6 +1478,7 @@ export default function wardenExtension(pi) {
1313
1478
  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
1479
  }
1315
1480
  if (mode === "advise") {
1481
+ deliverNotes(true);
1316
1482
  stats.warned++;
1317
1483
  if (ctx.hasUI && config.notices)
1318
1484
  ctx.ui.notify(`warden · ${event.toolName} (advise mode, not held): ${reasons}`, "warning");
@@ -1324,6 +1490,7 @@ export default function wardenExtension(pi) {
1324
1490
  if (mode === "confirm") {
1325
1491
  notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
1326
1492
  const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
1493
+ deliverNotes(allowed);
1327
1494
  if (allowed) {
1328
1495
  track(true, "approved", "dialog");
1329
1496
  return undefined;
@@ -1332,6 +1499,7 @@ export default function wardenExtension(pi) {
1332
1499
  track(true, "declined", "dialog");
1333
1500
  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
1501
  }
1502
+ deliverNotes(false);
1335
1503
  stats.held++;
1336
1504
  actionGuard.hold(task);
1337
1505
  track(true);
@@ -1346,6 +1514,13 @@ export default function wardenExtension(pi) {
1346
1514
  return;
1347
1515
  const textBlocks = event.content.filter(part => part.type === "text");
1348
1516
  const text = textBlocks.map(part => part.text).join("\n");
1517
+ const scratch = scratchPending.get(event.toolCallId);
1518
+ if (scratch) {
1519
+ scratchPending.delete(event.toolCallId);
1520
+ for (const [path, identity] of createdScratch(event.toolName, event.input, text, scratch.started, scratch.candidates))
1521
+ sessionScratch.set(path, identity);
1522
+ }
1523
+ pruneScratch(sessionScratch);
1349
1524
  // Repeat detection uses the original result, so its request goes out together with the output check.
1350
1525
  const failed = resultFailed(event.isError, event.details, event.content);
1351
1526
  // ── Conscience: track tool_result for the pending capability ──
@@ -1468,7 +1643,9 @@ export default function wardenExtension(pi) {
1468
1643
  }
1469
1644
  if (config.context.enabled && !earlier && !recallRead && textBlocks.length === 1 && text.length >= config.context.tailMinChars)
1470
1645
  ledger.candidate();
1471
- if (multiBlock && !ctx.signal?.aborted) {
1646
+ // Captured before the loop so the notice block below knows whether each block already carries its banner.
1647
+ const bannersPlacedWithBlocks = multiBlock && !ctx.signal?.aborted;
1648
+ if (bannersPlacedWithBlocks) {
1472
1649
  // Retention and banners per block; block order and non-text parts are never touched.
1473
1650
  content = [...content];
1474
1651
  let textIndex = 0;
@@ -1536,8 +1713,11 @@ export default function wardenExtension(pi) {
1536
1713
  }
1537
1714
  if (key && !earlier)
1538
1715
  ledger.remember(key, event.toolName, storedPath);
1716
+ // The banner in the result already tells the agent. A steer custom message sent here arrives after the turn ends
1717
+ // when this result closes the turn and starts a new turn to answer a message that asks for nothing, so the notice
1718
+ // rides the result content alone and is never steered.
1539
1719
  if (notice && textBlocks.length) {
1540
- if (!multiBlock) {
1720
+ if (!bannersPlacedWithBlocks) {
1541
1721
  let index = 0;
1542
1722
  content = content.map(part => {
1543
1723
  if (part.type !== "text")
@@ -1546,13 +1726,12 @@ export default function wardenExtension(pi) {
1546
1726
  return { ...part, text: `${index === 1 ? `${notice}\n\n` : ""}${part.text}${index === textBlocks.length ? `\n\n${notice}` : ""}` };
1547
1727
  });
1548
1728
  }
1549
- const delivered = steer(config, "security", notice);
1550
1729
  record(ctx, config, "security", renderTemplate(config.widget.security, {
1551
1730
  tool: event.toolName, injection: output.injection?.toFixed(2), exfiltration: output.exfiltration?.toFixed(2),
1552
1731
  status: [output.suspicious && "untrusted instructions", output.secret && "possible credentials"].filter(Boolean).join(", "),
1553
1732
  }), [
1554
1733
  `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}`,
1734
+ `output sample: ${redact(text).slice(0, 300)}`, `agent told by the banner in the tool result: ${notice}`,
1556
1735
  ]);
1557
1736
  }
1558
1737
  // Stuck-loop diff: when the verdict is stuck on a failed result and a previous failed output
@@ -1742,11 +1921,13 @@ export default function wardenExtension(pi) {
1742
1921
  catch {
1743
1922
  toolInfos = [];
1744
1923
  }
1745
- const redactedPrompt = redact(latestUserPrompt(ctx) ?? "").slice(0, 2000);
1924
+ const prompt = latestUserPrompt(ctx);
1925
+ const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
1926
+ const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
1746
1927
  const activeSkills = cachedSkills.map(s => s.name);
1747
1928
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
1748
1929
  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 });
1930
+ 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
1931
  if (conscienceGeneration !== myGeneration)
1751
1932
  return;
1752
1933
  assessmentsThisPrompt++;
@@ -1776,7 +1957,7 @@ export default function wardenExtension(pi) {
1776
1957
  const proseCheck = config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars
1777
1958
  ? evaluateProse(task, finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
1778
1959
  : undefined;
1779
- const doneCheck = config.done.enabled && !doneNudged && needsDoneCheck(evidence)
1960
+ const doneCheck = config.done.enabled && needsDoneCheck(evidence)
1780
1961
  ? evaluateDone(task, finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
1781
1962
  : undefined;
1782
1963
  if (proseCheck) {
@@ -1806,7 +1987,8 @@ export default function wardenExtension(pi) {
1806
1987
  const verdict = await doneCheck;
1807
1988
  if (verdict.error)
1808
1989
  noteError(ctx, verdict.error, verdict.errorCode);
1809
- const nudge = verdict.unverified && config.done.nudge ? doneNudge(verdict) : undefined;
1990
+ // At most one nudge per user prompt; a later unverified claim is still judged and recorded.
1991
+ const nudge = verdict.unverified && config.done.nudge && !doneNudged ? doneNudge(verdict) : undefined;
1810
1992
  record(ctx, config, "done", formatDone(verdict, config.widget.done), doneDetails(verdict, finalMessage, nudge));
1811
1993
  if (!verdict.unverified)
1812
1994
  return;
@@ -1860,7 +2042,7 @@ export default function wardenExtension(pi) {
1860
2042
  : `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
2043
  report([
1862
2044
  `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`}.`,
2045
+ `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
2046
  formatSteers(stats),
1865
2047
  `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
2048
  formatLedger(ledger.snapshot()),
@@ -1878,11 +2060,16 @@ export default function wardenExtension(pi) {
1878
2060
  }
1879
2061
  if (action === "trace") {
1880
2062
  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.");
2063
+ report(traceText());
1883
2064
  return;
1884
2065
  }
1885
- togglePanel(ctx.ui, config);
2066
+ const opened = togglePanel(ctx.ui, config);
2067
+ // RPC mode has a UI but settles custom() without building the component, so the sidebar never shows. hasUI
2068
+ // is true there; only the host not asking for the component tells the two apart. Not awaited: in the
2069
+ // terminal `closed` settles when the user closes the sidebar.
2070
+ const fallback = () => { if (opened && !opened.built())
2071
+ ctx.ui.notify(traceText(), "info"); };
2072
+ void opened?.closed.then(fallback, fallback);
1886
2073
  return;
1887
2074
  }
1888
2075
  if (action === "recommend") {