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.
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, hostPaths, inertPathRules, intentSteer, largeOutputNotice, offTaskSteer, pruneScratch, scratchCandidates, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets, isVisibleCommand } 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";
@@ -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.
@@ -133,12 +113,24 @@ export function siblingToolCalls(ctx) {
133
113
  }
134
114
  return [];
135
115
  }
116
+ /** Whether the call is a shell command with a visible effect (commit, push, merge, tag, reset, pull request, release, publish). */
117
+ export function isVisibleAction(tool, input) {
118
+ const view = commandOf(tool, input);
119
+ return view?.shell === true && isVisibleCommand(view.command);
120
+ }
136
121
  /**
137
- * The agent's own words before the call: the text of the assistant message that carries it, or, when that message is
138
- * tool calls only, the latest assistant text since the user's prompt. Sent as `plan`; it explains the step and cannot approve it.
122
+ * The agent's own words for the call: the text of the assistant message that carries it, or, when that message is
123
+ * tool calls only, the text of the assistant message right before it with no tool call in between. Text from before an
124
+ * earlier tool call described that call, so it is no plan and the intent question is not asked. The exception is
125
+ * `visibleAction` (a commit, push, merge, tag, reset, pull request, release, or publish, by `isVisibleCommand`): such a
126
+ * call is still judged against the latest assistant text since the user's prompt, because an unannounced commit or push
127
+ * after an older plan is the mismatch users object to. Sent as `plan`; it explains the step and cannot approve it.
139
128
  */
140
- export function assistantPlan(ctx) {
129
+ export function assistantPlan(ctx, visibleAction = false) {
141
130
  const entries = ctx.sessionManager.getBranch();
131
+ const plan = planForCall(entries);
132
+ if (plan !== undefined || !visibleAction)
133
+ return plan;
142
134
  for (let index = entries.length - 1; index >= 0; index--) {
143
135
  const entry = entries[index];
144
136
  if (entry?.type !== "message")
@@ -147,13 +139,61 @@ export function assistantPlan(ctx) {
147
139
  return undefined;
148
140
  if (entry.message.role !== "assistant")
149
141
  continue;
142
+ const text = assistantText(entry.message.content);
143
+ if (text)
144
+ return text;
145
+ }
146
+ return undefined;
147
+ }
148
+ function assistantText(content) {
149
+ return (typeof content === "string" ? content : content.filter((part) => part.type === "text").map(part => part.text).join("\n")).trim();
150
+ }
151
+ /** The text of the message that carries the call, or of the assistant message right before it with no tool call in between. */
152
+ function planForCall(entries) {
153
+ let carrierSeen = false;
154
+ for (let index = entries.length - 1; index >= 0; index--) {
155
+ const entry = entries[index];
156
+ if (entry?.type !== "message")
157
+ continue;
158
+ const { role } = entry.message;
159
+ if (role === "user")
160
+ return undefined;
161
+ // A result after the carrier belongs to a sibling call; one before it means an earlier tool call ran.
162
+ if (role === "toolResult") {
163
+ if (carrierSeen)
164
+ return undefined;
165
+ continue;
166
+ }
167
+ if (role !== "assistant")
168
+ continue;
150
169
  const content = entry.message.content;
151
- const text = typeof content === "string" ? content : content.filter((part) => part.type === "text").map(part => part.text).join("\n");
152
- if (text.trim())
153
- return text.trim();
170
+ const text = assistantText(content);
171
+ if (!carrierSeen) {
172
+ if (text)
173
+ return text;
174
+ carrierSeen = true;
175
+ continue;
176
+ }
177
+ if (typeof content !== "string" && content.some(part => part.type === "toolCall"))
178
+ return undefined;
179
+ return text || undefined;
154
180
  }
155
181
  return undefined;
156
182
  }
183
+ /**
184
+ * The once-per-session notice: why Jev judgments are off and what turns them on. `rejectedEnv` is the environment
185
+ * variable that holds a rejected key, or undefined when the rejected key is the one `/typesafe login` saved. Never
186
+ * names a key value or a path.
187
+ */
188
+ export function judgmentsOffText(reason, backend, headless, rejectedEnv) {
189
+ switch (reason) {
190
+ case "no_consent": return `warden: Jev judgments are off (no consent). ${headless ? "Set PI_WARDEN_ENABLED=1." : "Run /warden enable."}`;
191
+ case "no_key": return `warden: Jev judgments are off (no key for ${backend}). Set ${keyEnvFor(backend)}${loginStoresKey(backend) ? " or run /typesafe login" : ""}.`;
192
+ case "key_rejected": return rejectedEnv
193
+ ? `warden: Jev judgments are off (the key in ${rejectedEnv} was rejected). Check the key, then run /warden status.`
194
+ : "warden: Jev judgments are off (the key saved by /typesafe login was rejected). Run /typesafe login.";
195
+ }
196
+ }
157
197
  function activeMode(config, hasUI) {
158
198
  const env = process.env.PI_WARDEN_MODE?.trim();
159
199
  const mode = isMode(env) ? env : config.mode;
@@ -256,10 +296,19 @@ export default function wardenExtension(pi) {
256
296
  // fail-closes (no delivery) until the policy is re-measured.
257
297
  const consciencePolicy = CONSCIENCE_BETA_POLICY;
258
298
  let client;
299
+ const cooldown = new JudgeCooldown();
300
+ let judgeConfig;
301
+ /** Where cooldown notices go; unset headless, where a failure is already silent (see noteError). */
302
+ let noticeUi;
259
303
  let budgetExhausted = false;
260
304
  let stats = freshStats();
261
305
  const widget = new Map();
262
306
  const trace = new Trace();
307
+ // The trace file for a host without the terminal UI; set per session when PI_WARDEN_TRACE_DIR is an absolute path.
308
+ let traceFile;
309
+ let unsubscribeTraceFile;
310
+ // Directories outside the project the host lets its agent write; set per session from PI_WARDEN_HOST_PATHS.
311
+ let sessionHostPaths = [];
263
312
  let panel;
264
313
  let configPanel;
265
314
  let lastUi;
@@ -294,6 +343,8 @@ export default function wardenExtension(pi) {
294
343
  let fullOutputs = new Map();
295
344
  let evidence = emptyEvidence();
296
345
  let doneNudged = false;
346
+ /** True from a delivered warden follow-up that starts a turn until the next run starts: that run keeps the evidence. */
347
+ let wardenContinuation = false;
297
348
  let warnedFallback = false;
298
349
  let warnedMissingRules = false;
299
350
  /** True while /warden init is sending a prompt and waiting for the agent to generate pi-warden.md. */
@@ -309,6 +360,13 @@ export default function wardenExtension(pi) {
309
360
  const secretsSeen = new Set();
310
361
  // Subagent report entries already triaged, by session entry id; the wake window outlives one scan.
311
362
  const subagentSeen = new Set();
363
+ // Command families already steered toward filtered output this session: one steer per family.
364
+ const largeOutputSteered = new Set();
365
+ // Real paths the agent created under the temp directory this session, with the identity each had when recorded.
366
+ // A recursive rm of only these is not destructive; a record whose path is gone or replaced is dropped after each call.
367
+ const sessionScratch = new Map();
368
+ // Per tool call id: when it started and the temp paths it may create that did not exist yet.
369
+ const scratchPending = new Map();
312
370
  const wakePolicy = new WakePolicy(0);
313
371
  const runaway = new RunawayMonitor();
314
372
  // 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 +426,79 @@ export default function wardenExtension(pi) {
368
426
  return config;
369
427
  };
370
428
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
429
+ /** Why no judge is available, or undefined when one is. The notice, the trace file, and the conscience all use it. */
430
+ const judgmentsOffReason = (config) => {
431
+ if (!consentGiven(config))
432
+ return "no_consent";
433
+ if (budgetExhausted)
434
+ return "budget";
435
+ const auth = authState({ backend: config.typesafeBackend });
436
+ if (auth.usable)
437
+ return undefined;
438
+ // No key in effect, or one whose last request came back 401 or 403.
439
+ return auth.source === undefined ? "no_key" : "key_rejected";
440
+ };
441
+ // Where the judgments-off notice goes: the session's UI, or a status message for a headless session.
442
+ let judgmentsNotify;
443
+ let judgmentsHeadless = false;
444
+ const judgmentsReported = new Set();
445
+ /** Say once per session and reason that judgments are off, and record the state in the trace file. */
446
+ const noteJudgments = (config, reason) => {
447
+ traceFile?.judgments(judgmentsState(reason));
448
+ // The budget has its own notice in noteError.
449
+ if (reason === undefined || reason === "budget" || judgmentsReported.has(reason))
450
+ return;
451
+ judgmentsReported.add(reason);
452
+ const auth = reason === "key_rejected" ? authState({ backend: config.typesafeBackend }) : undefined;
453
+ judgmentsNotify?.(judgmentsOffText(reason, config.typesafeBackend, judgmentsHeadless, auth?.kind === "environment" ? auth.keyName : undefined));
454
+ };
371
455
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
372
456
  /** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
373
457
  const judgeFor = (config) => {
374
- if (!consentGiven(config) || budgetExhausted)
458
+ const off = judgmentsOffReason(config);
459
+ noteJudgments(config, off);
460
+ if (off)
375
461
  return undefined;
376
- if (!authState({ backend: config.typesafeBackend }).usable)
462
+ judgeConfig = config;
463
+ // Absent, exactly as with no judge configured, so no guard needs to know a cooldown exists.
464
+ if (cooldown.active()) {
465
+ stats.cooldownSkips++;
377
466
  return undefined;
378
- return client ??= createTypeSafe(judgeOptions(config));
467
+ }
468
+ return client ??= watched(createTypeSafe(judgeOptions(config)));
469
+ };
470
+ /** Every guard reaches the backend through `evaluate`, so this one seam sees each failure and each success. */
471
+ const watched = (typesafe) => new Proxy(typesafe, {
472
+ get(target, prop, receiver) {
473
+ if (prop !== "evaluate")
474
+ return Reflect.get(target, prop, receiver);
475
+ return async (...args) => {
476
+ try {
477
+ const result = await target.evaluate(...args);
478
+ tellCooldown(cooldown.success());
479
+ return result;
480
+ }
481
+ catch (err) {
482
+ const kind = cooldownFailureKind(err, args[1]?.signal);
483
+ if (kind)
484
+ tellCooldown(cooldown.failure(kind, judgeConfig?.judge ?? defaultConfig().judge));
485
+ throw err;
486
+ }
487
+ };
488
+ },
489
+ });
490
+ const tellCooldown = (event) => {
491
+ if (!event || !noticeUi)
492
+ return;
493
+ if (event.type === "recovered") {
494
+ noticeUi.notify("warden: judgments resumed.", "info");
495
+ return;
496
+ }
497
+ const seconds = Math.round(event.ms / 1000);
498
+ const remedy = event.kind === "auth"
499
+ ? ` Set ${keyEnvFor(judgeConfig?.typesafeBackend ?? "typesafe")} or run /warden enable.`
500
+ : event.kind === "configuration" ? " /warden status shows the backend setup." : "";
501
+ noticeUi.notify(`warden: judgments paused for ${seconds}s after a ${event.kind} failure; pattern checks run alone until then.${remedy}`, "warning");
379
502
  };
380
503
  const noteError = (ctx, message, code) => {
381
504
  stats.errors++;
@@ -387,15 +510,22 @@ export default function wardenExtension(pi) {
387
510
  /** Click, shortcut, and /warden trace all toggle the same sidebar. */
388
511
  const togglePanel = (ui, config) => {
389
512
  if (!ui)
390
- return;
513
+ return undefined;
391
514
  if (panel) {
392
515
  panel.close();
393
- return;
516
+ return undefined;
394
517
  }
395
518
  const opened = openTracePanel(ui, trace, { width: config.widget.panelWidth });
396
519
  panel = opened;
397
520
  opened.closed.catch(() => undefined).finally(() => { if (panel === opened)
398
521
  panel = undefined; });
522
+ return opened;
523
+ };
524
+ /** The last 20 trace entries as text, newest last, for a host that cannot show the sidebar. */
525
+ const traceText = () => {
526
+ const entries = trace.entries();
527
+ 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.";
528
+ return traceFile ? `${text}\nTrace file: ${traceFile.path}` : text;
399
529
  };
400
530
  /** The config overlay toggles the same way the sidebar does, so the hint in its header is true. */
401
531
  const toggleConfigPanel = (ui, config) => {
@@ -541,9 +671,10 @@ export default function wardenExtension(pi) {
541
671
  stats.steerGuards[name] = (stats.steerGuards[name] ?? 0) + 1;
542
672
  const critical = names.every(name => CRITICAL_STEER_GUARDS.has(name));
543
673
  // A notice delivered once is already in the agent's context. Sending the repeat again costs the accounting turn it
544
- // forbids, so repeats are recorded only. The same goes for notices past the per-run steer budget: every delivered
545
- // steer costs at least one LLM turn, and a closing run that collects six notices collects six restatements of the
546
- // final status. A notice skipped for the budget keeps its fingerprint, so the same notice can deliver next run.
674
+ // forbids, so repeats are recorded only. The same goes for notices past the per-run steer budget: a steer sent during
675
+ // a tool call rides the request the tool result needs (tests/steer-delivery.test.ts), but one queued after the run
676
+ // ends starts a new model turn, and every steer asks the agent for a reply sentence; a closing run that collects six
677
+ // notices collects six restatements of the final status. A notice skipped for the budget keeps its fingerprint, so the same notice can deliver next run.
547
678
  // Critical guards (stuck, done, runaway recovery, subagent wake) always deliver: their message starts the turn.
548
679
  const overBudget = config.steerBudget > 0 && steersThisRun >= config.steerBudget;
549
680
  const deliver = critical || (!overBudget && !steerRepeats.seen(content));
@@ -555,6 +686,8 @@ export default function wardenExtension(pi) {
555
686
  steersThisRun++;
556
687
  const { display, ...delivery } = options ?? { deliverAs: "steer" };
557
688
  pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: display ?? config.steerVisible }, delivery);
689
+ if (delivery.triggerTurn)
690
+ wardenContinuation = true;
558
691
  return true;
559
692
  };
560
693
  /**
@@ -617,6 +750,8 @@ export default function wardenExtension(pi) {
617
750
  pi.on("session_start", async (_event, ctx) => {
618
751
  if (ctx.hasUI)
619
752
  lastUi = ctx.ui;
753
+ noticeUi = ctx.hasUI ? ctx.ui : undefined;
754
+ cooldown.reset();
620
755
  if (_event.reason === "reload" || _event.reason === "new")
621
756
  conscienceGeneration++;
622
757
  client = undefined;
@@ -634,11 +769,33 @@ export default function wardenExtension(pi) {
634
769
  rulesGuard.reset();
635
770
  holds.reset();
636
771
  regretCandidates = [];
637
- holdLog = new HoldLog(holdLogPath(typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid)));
772
+ const sessionId = typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid);
773
+ holdLog = new HoldLog(holdLogPath(sessionId));
774
+ unsubscribeTraceFile?.();
775
+ unsubscribeTraceFile = undefined;
776
+ traceFile = undefined;
777
+ judgmentsReported.clear();
778
+ judgmentsHeadless = !ctx.hasUI;
779
+ judgmentsNotify = text => { if (ctx.hasUI)
780
+ ctx.ui.notify(text, "warning");
781
+ else
782
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
783
+ sessionHostPaths = hostPaths();
784
+ const dir = traceDir();
785
+ if (dir) {
786
+ const warn = (text) => { if (ctx.hasUI)
787
+ ctx.ui.notify(text, "warning");
788
+ else
789
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
790
+ const opening = loadConfig();
791
+ traceFile = new TraceFile(traceFilePath(dir, sessionId), dir, { sessionId, cwd: ctx.cwd, mode: opening.mode, judgments: judgmentsState(judgmentsOffReason(opening)) }, warn);
792
+ unsubscribeTraceFile = trace.subscribe(traceFile.listener);
793
+ }
638
794
  arming.reset();
639
795
  attempts.reset();
640
796
  evidence = emptyEvidence();
641
797
  doneNudged = false;
798
+ wardenContinuation = false;
642
799
  prose.reset();
643
800
  // Clean up temp output dirs from the previous session.
644
801
  for (const dir of ledger.storedPaths()) {
@@ -653,6 +810,9 @@ export default function wardenExtension(pi) {
653
810
  compressionLearner.reset();
654
811
  secretsSeen.clear();
655
812
  subagentSeen.clear();
813
+ largeOutputSteered.clear();
814
+ sessionScratch.clear();
815
+ scratchPending.clear();
656
816
  wakePolicy.reset();
657
817
  steerRepeats.reset();
658
818
  steersThisRun = 0;
@@ -688,10 +848,12 @@ export default function wardenExtension(pi) {
688
848
  pi.on("before_agent_start", async (event, ctx) => {
689
849
  if (ctx.hasUI)
690
850
  lastUi = ctx.ui;
851
+ noticeUi = ctx.hasUI ? ctx.ui : undefined;
691
852
  const config = configFor(ctx);
692
853
  attempts = new AttemptWindow(config.stuck.window);
693
854
  fullOutputs = new Map();
694
855
  doneNudged = false;
856
+ wardenContinuation = false;
695
857
  steersThisRun = 0;
696
858
  finals.reset();
697
859
  runaway.reset();
@@ -744,6 +906,7 @@ export default function wardenExtension(pi) {
744
906
  const truncationRecorded = event.prompt.length > 2000 ? [`prompt truncated from ${event.prompt.length} to 2000 chars`] : [];
745
907
  // Build recent context from session branch (up to 4 messages, 500 chars each)
746
908
  const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
909
+ const spine = taskSpine(branch, event.prompt);
747
910
  const recentMessages = [];
748
911
  for (const entry of branch.slice(-6)) {
749
912
  if (recentMessages.length >= 4)
@@ -804,7 +967,7 @@ export default function wardenExtension(pi) {
804
967
  // it. Empty until the first evaluate call, and an empty model never matches a policy, so the gate fails closed.
805
968
  let judgeModel = "";
806
969
  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 });
970
+ 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
971
  // Check generation after await
809
972
  if (conscienceGeneration !== myGeneration) {
810
973
  record(ctx, config, "conscience", "stale: generation changed during assessment", ["trigger: before_agent_start", `generation: ${myGeneration} → ${conscienceGeneration}`, `skipReason: stale`]);
@@ -889,7 +1052,7 @@ export default function wardenExtension(pi) {
889
1052
  }
890
1053
  }
891
1054
  catch (err) {
892
- const category = classifyConscienceError(err);
1055
+ const category = classifyJudgeError(err);
893
1056
  if (!warnedErrorCategories.has(category)) {
894
1057
  warnedErrorCategories.add(category);
895
1058
  console.warn(`pi-warden: conscience ${category}`);
@@ -925,7 +1088,10 @@ export default function wardenExtension(pi) {
925
1088
  const judge = judgeFor(config);
926
1089
  if (judge && cachedSkills.length > 0) {
927
1090
  const myGeneration = conscienceGeneration;
928
- const redactedText = redact(typeof event.message.content === "string" ? event.message.content : "").slice(0, 2000);
1091
+ const prompt = typeof event.message.content === "string" ? event.message.content : "";
1092
+ const redactedText = redact(prompt).slice(0, 2000);
1093
+ const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
1094
+ const spine = taskSpine(branch, prompt);
929
1095
  let toolInfos = [];
930
1096
  try {
931
1097
  toolInfos = pi.getAllTools().map((t) => ({ name: t.name, description: t.description }));
@@ -936,13 +1102,13 @@ export default function wardenExtension(pi) {
936
1102
  const activeSkills = cachedSkills.map(s => s.name);
937
1103
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
938
1104
  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 });
1105
+ 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
1106
  if (conscienceGeneration !== myGeneration)
941
1107
  return;
942
1108
  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
1109
  }
944
1110
  catch (err) {
945
- const cat = classifyConscienceError(err);
1111
+ const cat = classifyJudgeError(err);
946
1112
  if (!warnedErrorCategories.has(cat)) {
947
1113
  warnedErrorCategories.add(cat);
948
1114
  console.warn(`pi-warden: conscience ${cat}`);
@@ -980,9 +1146,12 @@ export default function wardenExtension(pi) {
980
1146
  conscienceGeneration++;
981
1147
  ctx.abort();
982
1148
  });
983
- // Each low-level run collects its own evidence of changes and checks.
1149
+ // Each low-level run collects its own evidence of changes and checks. A run that a warden follow-up started continues
1150
+ // the previous run's work, so it keeps that evidence: the changes behind a done-check nudge still need a passing check.
984
1151
  pi.on("agent_start", async () => {
985
- evidence = emptyEvidence();
1152
+ if (!wardenContinuation)
1153
+ evidence = emptyEvidence();
1154
+ wardenContinuation = false;
986
1155
  });
987
1156
  // Every turn that runs after a compression is a turn that did not carry the removed text.
988
1157
  pi.on("turn_end", async (_event, ctx) => {
@@ -1002,11 +1171,13 @@ export default function wardenExtension(pi) {
1002
1171
  catch {
1003
1172
  toolInfos = [];
1004
1173
  }
1005
- const redactedPrompt = redact(latestUserPrompt(ctx) ?? "").slice(0, 2000);
1174
+ const prompt = latestUserPrompt(ctx);
1175
+ const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
1176
+ const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
1006
1177
  const activeSkills = cachedSkills.map(s => s.name);
1007
1178
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
1008
1179
  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 });
1180
+ 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
1181
  if (conscienceGeneration !== myGeneration)
1011
1182
  return;
1012
1183
  assessmentsThisPrompt++;
@@ -1031,6 +1202,8 @@ export default function wardenExtension(pi) {
1031
1202
  const config = configFor(ctx);
1032
1203
  if (!config.enabled)
1033
1204
  return;
1205
+ if (event.toolName === "bash" || event.toolName === "write")
1206
+ scratchPending.set(event.toolCallId, { started: Date.now(), candidates: scratchCandidates(event.toolName, event.input, ctx.cwd) });
1034
1207
  // ── Conscience: track tool attempts on the selected capability ──
1035
1208
  if (config.conscience.enabled && selectedCapability && !triggerConsumed) {
1036
1209
  if (selectedCapability.kind === "tool" && event.toolName === selectedCapability.id) {
@@ -1106,7 +1279,7 @@ export default function wardenExtension(pi) {
1106
1279
  ? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
1107
1280
  : undefined;
1108
1281
  rulesCheck?.catch(() => undefined);
1109
- 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 });
1282
+ const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx, isVisibleAction(event.toolName, event.input)), 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 });
1110
1283
  if (verdict.source === "skipped")
1111
1284
  return;
1112
1285
  // Arming check: if any armed rule's command regex matches this call, inject a hit into the verdict.
@@ -1162,6 +1335,11 @@ export default function wardenExtension(pi) {
1162
1335
  // Notes for the agent about the content it just wrote: slop, rule violations, and sensitive paths arrive as one message.
1163
1336
  const notes = [];
1164
1337
  const noteGuards = new Set();
1338
+ /** Slop and rule findings say "just written", so they wait until the call proceeds. A held, denied, or declined write
1339
+ * never lands and drops them; an approved retry is judged again and brings its own. They go between the action notes
1340
+ * and the sensitive-path notes, the order of the combined message. */
1341
+ const contentNotes = [];
1342
+ const pathNotes = [];
1165
1343
  /** Sensitive-path notes ride with the combined steer for this call; their trace waits for the delivery result. */
1166
1344
  const pathNoteTraces = [];
1167
1345
  if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
@@ -1226,15 +1404,24 @@ export default function wardenExtension(pi) {
1226
1404
  noteGuards.add("action");
1227
1405
  notes.push(shouldProceedMessage(verdict));
1228
1406
  }
1407
+ const largeOutput = largeOutputNotice(verdict, largeOutputSteered);
1408
+ if (largeOutput) {
1409
+ noteGuards.add("action");
1410
+ notes.push(largeOutput);
1411
+ }
1229
1412
  if (verdict.slopSymptoms?.length && verdict.slopReasons) {
1230
1413
  stats.slop++;
1231
- noteGuards.add("action");
1232
- for (const symptom of verdict.slopSymptoms)
1233
- slopCounts[symptom]++;
1414
+ const symptoms = verdict.slopSymptoms;
1234
1415
  const where = verdict.summary.path ?? event.toolName;
1235
1416
  if (ctx.hasUI && config.notices)
1236
1417
  ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
1237
- notes.push(slopSteer(where, verdict.slopSymptoms, slopCounts));
1418
+ contentNotes.push(proceeds => {
1419
+ if (!proceeds)
1420
+ return undefined;
1421
+ for (const symptom of symptoms)
1422
+ slopCounts[symptom]++;
1423
+ return { guard: "action", text: slopSteer(where, symptoms, slopCounts) };
1424
+ });
1238
1425
  }
1239
1426
  if (rulesCheck) {
1240
1427
  const rules = await rulesCheck;
@@ -1242,16 +1429,23 @@ export default function wardenExtension(pi) {
1242
1429
  stats.ruleChecks++;
1243
1430
  if (rules.error)
1244
1431
  noteError(ctx, rules.error, rules.errorCode);
1245
- const told = rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
1246
- record(ctx, config, "rules", formatRules(rules, config.widget.rules), rulesDetails(rules, told));
1247
1432
  holds.recordRules({ source: rules.source, path: rules.path, findings: rules.findings.map(f => ({ name: f.name, violation: f.violation })), ...(rules.error ? { error: rules.error } : {}) });
1248
- if (told) {
1433
+ if (rules.findings.length) {
1249
1434
  stats.ruleViolations++;
1250
- noteGuards.add("rules");
1251
1435
  if (ctx.hasUI && config.notices)
1252
1436
  ctx.ui.notify(`warden · rules · ${rules.path}: ${rules.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
1253
- notes.push(told);
1254
1437
  }
1438
+ contentNotes.push(proceeds => {
1439
+ const told = proceeds && rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
1440
+ const details = rulesDetails(rules, told);
1441
+ if (!proceeds && rules.findings.length)
1442
+ details.push("agent not told: the write was held");
1443
+ record(ctx, config, "rules", formatRules(rules, config.widget.rules), details);
1444
+ return told ? { guard: "rules", text: told } : undefined;
1445
+ });
1446
+ }
1447
+ else {
1448
+ record(ctx, config, "rules", formatRules(rules, config.widget.rules), [`${rules.tool} ${rules.path}: ${rules.skippedReason}`]);
1255
1449
  }
1256
1450
  const hits = rulesGuard.notesFor(verdict.summary.location === "inside_project" ? verdict.summary.path : undefined, config.rules.sensitivePaths);
1257
1451
  if (hits.length && verdict.summary.path) {
@@ -1265,17 +1459,29 @@ export default function wardenExtension(pi) {
1265
1459
  ctx.ui.notify(`warden · sensitive path · ${verdict.summary.path} (${hits.map(hit => hit.glob).join(", ")}); the agent was given the note`, "warning");
1266
1460
  });
1267
1461
  noteGuards.add("rules");
1268
- notes.push(told);
1462
+ pathNotes.push(told);
1269
1463
  }
1270
1464
  }
1271
- if (notes.length) {
1272
- const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
1273
- for (const traceNote of pathNoteTraces)
1274
- traceNote(delivered);
1275
- pathNoteTraces.length = 0;
1276
- }
1465
+ /** Sends the combined steer once the call's fate is known; `proceeds` is false when the call does not run. */
1466
+ const deliverNotes = (proceeds) => {
1467
+ for (const contentNote of contentNotes) {
1468
+ const note = contentNote(proceeds);
1469
+ if (!note)
1470
+ continue;
1471
+ noteGuards.add(note.guard);
1472
+ notes.push(note.text);
1473
+ }
1474
+ notes.push(...pathNotes);
1475
+ if (notes.length) {
1476
+ const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
1477
+ for (const traceNote of pathNoteTraces)
1478
+ traceNote(delivered);
1479
+ pathNoteTraces.length = 0;
1480
+ }
1481
+ };
1277
1482
  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.`);
1278
1483
  if (verdict.level === "warn") {
1484
+ deliverNotes(true);
1279
1485
  stats.warned++;
1280
1486
  if (ctx.hasUI && config.notices)
1281
1487
  ctx.ui.notify(`warden · ${event.toolName}: ${verdict.reasons.join("; ")}`, "warning");
@@ -1287,6 +1493,7 @@ export default function wardenExtension(pi) {
1287
1493
  return undefined;
1288
1494
  }
1289
1495
  if (verdict.level === "deny") {
1496
+ deliverNotes(false);
1290
1497
  stats.held++;
1291
1498
  track(true, "declined", "deny");
1292
1499
  if (ctx.hasUI && config.notices)
@@ -1295,6 +1502,7 @@ export default function wardenExtension(pi) {
1295
1502
  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.` };
1296
1503
  }
1297
1504
  if (verdict.level !== "confirm") {
1505
+ deliverNotes(true);
1298
1506
  track(false);
1299
1507
  return undefined;
1300
1508
  }
@@ -1307,6 +1515,7 @@ export default function wardenExtension(pi) {
1307
1515
  if (dialogRule && ctx.hasUI) {
1308
1516
  notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
1309
1517
  const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
1518
+ deliverNotes(allowed);
1310
1519
  if (allowed) {
1311
1520
  track(true, "approved", "dialog");
1312
1521
  return undefined;
@@ -1316,6 +1525,7 @@ export default function wardenExtension(pi) {
1316
1525
  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.` };
1317
1526
  }
1318
1527
  if (mode === "advise") {
1528
+ deliverNotes(true);
1319
1529
  stats.warned++;
1320
1530
  if (ctx.hasUI && config.notices)
1321
1531
  ctx.ui.notify(`warden · ${event.toolName} (advise mode, not held): ${reasons}`, "warning");
@@ -1327,6 +1537,7 @@ export default function wardenExtension(pi) {
1327
1537
  if (mode === "confirm") {
1328
1538
  notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
1329
1539
  const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
1540
+ deliverNotes(allowed);
1330
1541
  if (allowed) {
1331
1542
  track(true, "approved", "dialog");
1332
1543
  return undefined;
@@ -1335,6 +1546,7 @@ export default function wardenExtension(pi) {
1335
1546
  track(true, "declined", "dialog");
1336
1547
  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.` };
1337
1548
  }
1549
+ deliverNotes(false);
1338
1550
  stats.held++;
1339
1551
  actionGuard.hold(task);
1340
1552
  track(true);
@@ -1349,6 +1561,13 @@ export default function wardenExtension(pi) {
1349
1561
  return;
1350
1562
  const textBlocks = event.content.filter(part => part.type === "text");
1351
1563
  const text = textBlocks.map(part => part.text).join("\n");
1564
+ const scratch = scratchPending.get(event.toolCallId);
1565
+ if (scratch) {
1566
+ scratchPending.delete(event.toolCallId);
1567
+ for (const [path, identity] of createdScratch(event.toolName, event.input, text, scratch.started, scratch.candidates))
1568
+ sessionScratch.set(path, identity);
1569
+ }
1570
+ pruneScratch(sessionScratch);
1352
1571
  // Repeat detection uses the original result, so its request goes out together with the output check.
1353
1572
  const failed = resultFailed(event.isError, event.details, event.content);
1354
1573
  // ── Conscience: track tool_result for the pending capability ──
@@ -1471,7 +1690,9 @@ export default function wardenExtension(pi) {
1471
1690
  }
1472
1691
  if (config.context.enabled && !earlier && !recallRead && textBlocks.length === 1 && text.length >= config.context.tailMinChars)
1473
1692
  ledger.candidate();
1474
- if (multiBlock && !ctx.signal?.aborted) {
1693
+ // Captured before the loop so the notice block below knows whether each block already carries its banner.
1694
+ const bannersPlacedWithBlocks = multiBlock && !ctx.signal?.aborted;
1695
+ if (bannersPlacedWithBlocks) {
1475
1696
  // Retention and banners per block; block order and non-text parts are never touched.
1476
1697
  content = [...content];
1477
1698
  let textIndex = 0;
@@ -1539,8 +1760,11 @@ export default function wardenExtension(pi) {
1539
1760
  }
1540
1761
  if (key && !earlier)
1541
1762
  ledger.remember(key, event.toolName, storedPath);
1763
+ // The banner in the result already tells the agent, so the notice rides the result content alone and is not also
1764
+ // steered. A steer sent here would join the request that carries this result and cost no extra turn
1765
+ // (tests/steer-delivery.test.ts); it would only repeat the banner.
1542
1766
  if (notice && textBlocks.length) {
1543
- if (!multiBlock) {
1767
+ if (!bannersPlacedWithBlocks) {
1544
1768
  let index = 0;
1545
1769
  content = content.map(part => {
1546
1770
  if (part.type !== "text")
@@ -1549,13 +1773,12 @@ export default function wardenExtension(pi) {
1549
1773
  return { ...part, text: `${index === 1 ? `${notice}\n\n` : ""}${part.text}${index === textBlocks.length ? `\n\n${notice}` : ""}` };
1550
1774
  });
1551
1775
  }
1552
- const delivered = steer(config, "security", notice);
1553
1776
  record(ctx, config, "security", renderTemplate(config.widget.security, {
1554
1777
  tool: event.toolName, injection: output.injection?.toFixed(2), exfiltration: output.exfiltration?.toFixed(2),
1555
1778
  status: [output.suspicious && "untrusted instructions", output.secret && "possible credentials"].filter(Boolean).join(", "),
1556
1779
  }), [
1557
1780
  `jev: injection ${output.injection?.toFixed(2) ?? "not judged"}; exfiltration ${output.exfiltration?.toFixed(2) ?? "not judged"}`,
1558
- `output sample: ${redact(text).slice(0, 300)}`, delivered ? `agent told: ${notice}` : `steer recorded, not delivered (a repeat or the per-run budget): ${notice}`,
1781
+ `output sample: ${redact(text).slice(0, 300)}`, `agent told by the banner in the tool result: ${notice}`,
1559
1782
  ]);
1560
1783
  }
1561
1784
  // Stuck-loop diff: when the verdict is stuck on a failed result and a previous failed output
@@ -1745,11 +1968,13 @@ export default function wardenExtension(pi) {
1745
1968
  catch {
1746
1969
  toolInfos = [];
1747
1970
  }
1748
- const redactedPrompt = redact(latestUserPrompt(ctx) ?? "").slice(0, 2000);
1971
+ const prompt = latestUserPrompt(ctx);
1972
+ const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
1973
+ const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
1749
1974
  const activeSkills = cachedSkills.map(s => s.name);
1750
1975
  const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
1751
1976
  try {
1752
- const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
1977
+ const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
1753
1978
  if (conscienceGeneration !== myGeneration)
1754
1979
  return;
1755
1980
  assessmentsThisPrompt++;
@@ -1779,7 +2004,7 @@ export default function wardenExtension(pi) {
1779
2004
  const proseCheck = config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars
1780
2005
  ? evaluateProse(task, finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
1781
2006
  : undefined;
1782
- const doneCheck = config.done.enabled && !doneNudged && needsDoneCheck(evidence)
2007
+ const doneCheck = config.done.enabled && needsDoneCheck(evidence)
1783
2008
  ? evaluateDone(task, finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
1784
2009
  : undefined;
1785
2010
  if (proseCheck) {
@@ -1809,7 +2034,8 @@ export default function wardenExtension(pi) {
1809
2034
  const verdict = await doneCheck;
1810
2035
  if (verdict.error)
1811
2036
  noteError(ctx, verdict.error, verdict.errorCode);
1812
- const nudge = verdict.unverified && config.done.nudge ? doneNudge(verdict) : undefined;
2037
+ // At most one nudge per user prompt; a later unverified claim is still judged and recorded.
2038
+ const nudge = verdict.unverified && config.done.nudge && !doneNudged ? doneNudge(verdict) : undefined;
1813
2039
  record(ctx, config, "done", formatDone(verdict, config.widget.done), doneDetails(verdict, finalMessage, nudge));
1814
2040
  if (!verdict.unverified)
1815
2041
  return;
@@ -1863,7 +2089,7 @@ export default function wardenExtension(pi) {
1863
2089
  : `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).`;
1864
2090
  report([
1865
2091
  `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}`,
1866
- `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`}.`,
2092
+ `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`}.`,
1867
2093
  formatSteers(stats),
1868
2094
  `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}.`,
1869
2095
  formatLedger(ledger.snapshot()),
@@ -1881,11 +2107,16 @@ export default function wardenExtension(pi) {
1881
2107
  }
1882
2108
  if (action === "trace") {
1883
2109
  if (!ctx.hasUI) {
1884
- const entries = trace.entries();
1885
- 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.");
2110
+ report(traceText());
1886
2111
  return;
1887
2112
  }
1888
- togglePanel(ctx.ui, config);
2113
+ const opened = togglePanel(ctx.ui, config);
2114
+ // RPC mode has a UI but settles custom() without building the component, so the sidebar never shows. hasUI
2115
+ // is true there; only the host not asking for the component tells the two apart. Not awaited: in the
2116
+ // terminal `closed` settles when the user closes the sidebar.
2117
+ const fallback = () => { if (opened && !opened.built())
2118
+ ctx.ui.notify(traceText(), "info"); };
2119
+ void opened?.closed.then(fallback, fallback);
1889
2120
  return;
1890
2121
  }
1891
2122
  if (action === "recommend") {