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/CHANGELOG.md +96 -0
- package/README.md +23 -3
- package/dist/action-guard.d.ts +11 -2
- package/dist/action-guard.js +1 -1
- package/dist/backend.d.ts +4 -0
- package/dist/backend.js +4 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +15 -1
- package/dist/conscience.d.ts +9 -3
- package/dist/conscience.js +13 -4
- package/dist/extension.d.ts +17 -4
- package/dist/extension.js +311 -80
- package/dist/extension.js.bak +263 -76
- package/dist/guard.d.ts +115 -1
- package/dist/guard.js +404 -11
- package/dist/holds.d.ts +2 -0
- package/dist/holds.js +5 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/judge-cooldown.d.ts +44 -0
- package/dist/judge-cooldown.js +92 -0
- package/dist/panel.d.ts +5 -2
- package/dist/panel.js +5 -2
- package/dist/redact.d.ts +5 -3
- package/dist/redact.js +17 -4
- package/dist/rules.d.ts +2 -0
- package/dist/rules.js +23 -0
- package/dist/shape.d.ts +46 -0
- package/dist/shape.js +76 -1
- package/dist/trace-file.d.ts +50 -0
- package/dist/trace-file.js +141 -0
- package/dist/trace.d.ts +12 -1
- package/dist/trace.js +3 -3
- package/package.json +1 -1
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
|
-
|
|
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
|
|
138
|
-
* tool calls only, the
|
|
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 =
|
|
152
|
-
if (
|
|
153
|
-
|
|
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
|
-
|
|
458
|
+
const off = judgmentsOffReason(config);
|
|
459
|
+
noteJudgments(config, off);
|
|
460
|
+
if (off)
|
|
375
461
|
return undefined;
|
|
376
|
-
|
|
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
|
-
|
|
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:
|
|
545
|
-
//
|
|
546
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1462
|
+
pathNotes.push(told);
|
|
1269
1463
|
}
|
|
1270
1464
|
}
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
for (const
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
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 (!
|
|
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)}`,
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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") {
|