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.bak
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 } from "./guard.js";
|
|
16
16
|
import { commandOf } from "./tools.js";
|
|
17
17
|
import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
|
|
18
18
|
import { initSchema, recordHold, recordOutcome, toHoldRecord, holdStats, generateRecommendations, analyzeSteerEffectivenessReport } from "./learning.js";
|
|
@@ -20,7 +20,7 @@ import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_S
|
|
|
20
20
|
import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice, CompressionLearner } from "./output.js";
|
|
21
21
|
import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
|
|
22
22
|
import { redact } from "./redact.js";
|
|
23
|
-
import { formatRules, pathNoteSteer, RulesGuard, rulesSteer } from "./rules.js";
|
|
23
|
+
import { formatRules, pathNoteSteer, RulesGuard, rulesSteer, RULES_FILE, FALLBACK_FILES } from "./rules.js";
|
|
24
24
|
import { checkPiWardenMissing } from "./rules-file.js";
|
|
25
25
|
import { writeStarterRules, buildInitPrompt } from "./init.js";
|
|
26
26
|
import { buildAuditPrompt, findProjects, snapshotReport, reportOutcome } from "./audit.js";
|
|
@@ -31,39 +31,19 @@ import { assess } from "./conscience.js";
|
|
|
31
31
|
import { loadSkillBody, buildLoadMessage, policyMatches, CONSCIENCE_BETA_POLICY, recordFileIdentity, clearFileIdentityCache } from "./load.js";
|
|
32
32
|
import { buildIndexPrompt, readIndex, writeIndex, validateIndex, indexStats, indexPath, ensureIndexDir } from "./index-cmd.js";
|
|
33
33
|
import { fileContentHash } from "./hashing.js";
|
|
34
|
-
|
|
35
|
-
function classifyConscienceError(err) {
|
|
36
|
-
if (err && typeof err === "object" && "code" in err) {
|
|
37
|
-
const code = err.code;
|
|
38
|
-
if (code === "timeout")
|
|
39
|
-
return "timeout";
|
|
40
|
-
if (code === "http" || code === "connection" || code === "response")
|
|
41
|
-
return "network";
|
|
42
|
-
if (code === "configuration" || code === "validation")
|
|
43
|
-
return "configuration";
|
|
44
|
-
if (code === "budget" || code === "aborted")
|
|
45
|
-
return "other";
|
|
46
|
-
}
|
|
47
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
48
|
-
if (/timeout|timed out/i.test(msg))
|
|
49
|
-
return "timeout";
|
|
50
|
-
if (/auth|key|credential|401|403/i.test(msg))
|
|
51
|
-
return "auth";
|
|
52
|
-
if (/network|fetch|connect|ECONNREFUSED|ENOTFOUND/i.test(msg))
|
|
53
|
-
return "network";
|
|
54
|
-
return "other";
|
|
55
|
-
}
|
|
34
|
+
import { classifyJudgeError, cooldownFailureKind, JudgeCooldown } from "./judge-cooldown.js";
|
|
56
35
|
import { openConfigPanel, openTracePanel } from "./panel.js";
|
|
57
|
-
import { completeConfig, shapeWarning } from "./shape.js";
|
|
36
|
+
import { completeConfig, shapeWarning, taskSpine } from "./shape.js";
|
|
58
37
|
import { ContextLedger, formatLedger } from "./saver.js";
|
|
59
38
|
import { buildCompactSnapshot, compactAppendix } from "./compact.js";
|
|
60
39
|
import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
|
|
61
40
|
import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
|
|
41
|
+
import { TraceFile, judgmentsState, traceDir, traceFilePath } from "./trace-file.js";
|
|
62
42
|
import { actionTokens, DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, SENTENCE_TEMPLATES, statusWidget, TOKEN_NAMES } from "./widget.js";
|
|
63
|
-
export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, with the agent's own words from the message that makes the call (its stated plan); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. For the conscience coach (recommend mode): your current request (2000 redacted characters), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory; an owner-only SQLite database under Pi's agent directory stores redacted hold context (plan, summary, redacted command preview, outcomes) for held and judged-allowed calls, for learning and retention (configurable, default 365 days). Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
43
|
+
export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request, the task spine it is judged against (the first request of the thread and up to four redacted earlier requests), and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, with the agent's own words from the message that makes the call (its stated plan); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. For the conscience coach (recommend mode): your current request (2000 redacted characters), the same task spine (the first request of the thread and up to four redacted earlier requests), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory; an owner-only SQLite database under Pi's agent directory stores redacted hold context (plan, summary, redacted command preview, outcomes) for held and judged-allowed calls, for learning and retention (configurable, default 365 days). Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
|
|
64
44
|
const WIDGET = PACKAGE_NAME;
|
|
65
45
|
const CONFIRM_TEXT_LIMIT = 500;
|
|
66
|
-
const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offTask: 0, slop: 0, ruleChecks: 0, ruleViolations: 0, pathNotes: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, runaway: 0, errors: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
|
|
46
|
+
const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offTask: 0, slop: 0, ruleChecks: 0, ruleViolations: 0, pathNotes: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, runaway: 0, errors: 0, cooldownSkips: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
|
|
67
47
|
/**
|
|
68
48
|
* One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
|
|
69
49
|
* message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
|
|
@@ -154,6 +134,20 @@ export function assistantPlan(ctx) {
|
|
|
154
134
|
}
|
|
155
135
|
return undefined;
|
|
156
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* The once-per-session notice: why Jev judgments are off and what turns them on. `rejectedEnv` is the environment
|
|
139
|
+
* variable that holds a rejected key, or undefined when the rejected key is the one `/typesafe login` saved. Never
|
|
140
|
+
* names a key value or a path.
|
|
141
|
+
*/
|
|
142
|
+
export function judgmentsOffText(reason, backend, headless, rejectedEnv) {
|
|
143
|
+
switch (reason) {
|
|
144
|
+
case "no_consent": return `warden: Jev judgments are off (no consent). ${headless ? "Set PI_WARDEN_ENABLED=1." : "Run /warden enable."}`;
|
|
145
|
+
case "no_key": return `warden: Jev judgments are off (no key for ${backend}). Set ${keyEnvFor(backend)}${loginStoresKey(backend) ? " or run /typesafe login" : ""}.`;
|
|
146
|
+
case "key_rejected": return rejectedEnv
|
|
147
|
+
? `warden: Jev judgments are off (the key in ${rejectedEnv} was rejected). Check the key, then run /warden status.`
|
|
148
|
+
: "warden: Jev judgments are off (the key saved by /typesafe login was rejected). Run /typesafe login.";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
157
151
|
function activeMode(config, hasUI) {
|
|
158
152
|
const env = process.env.PI_WARDEN_MODE?.trim();
|
|
159
153
|
const mode = isMode(env) ? env : config.mode;
|
|
@@ -256,10 +250,19 @@ export default function wardenExtension(pi) {
|
|
|
256
250
|
// fail-closes (no delivery) until the policy is re-measured.
|
|
257
251
|
const consciencePolicy = CONSCIENCE_BETA_POLICY;
|
|
258
252
|
let client;
|
|
253
|
+
const cooldown = new JudgeCooldown();
|
|
254
|
+
let judgeConfig;
|
|
255
|
+
/** Where cooldown notices go; unset headless, where a failure is already silent (see noteError). */
|
|
256
|
+
let noticeUi;
|
|
259
257
|
let budgetExhausted = false;
|
|
260
258
|
let stats = freshStats();
|
|
261
259
|
const widget = new Map();
|
|
262
260
|
const trace = new Trace();
|
|
261
|
+
// The trace file for a host without the terminal UI; set per session when PI_WARDEN_TRACE_DIR is an absolute path.
|
|
262
|
+
let traceFile;
|
|
263
|
+
let unsubscribeTraceFile;
|
|
264
|
+
// Directories outside the project the host lets its agent write; set per session from PI_WARDEN_HOST_PATHS.
|
|
265
|
+
let sessionHostPaths = [];
|
|
263
266
|
let panel;
|
|
264
267
|
let configPanel;
|
|
265
268
|
let lastUi;
|
|
@@ -294,6 +297,8 @@ export default function wardenExtension(pi) {
|
|
|
294
297
|
let fullOutputs = new Map();
|
|
295
298
|
let evidence = emptyEvidence();
|
|
296
299
|
let doneNudged = false;
|
|
300
|
+
/** True from a delivered warden follow-up that starts a turn until the next run starts: that run keeps the evidence. */
|
|
301
|
+
let wardenContinuation = false;
|
|
297
302
|
let warnedFallback = false;
|
|
298
303
|
let warnedMissingRules = false;
|
|
299
304
|
/** True while /warden init is sending a prompt and waiting for the agent to generate pi-warden.md. */
|
|
@@ -309,6 +314,13 @@ export default function wardenExtension(pi) {
|
|
|
309
314
|
const secretsSeen = new Set();
|
|
310
315
|
// Subagent report entries already triaged, by session entry id; the wake window outlives one scan.
|
|
311
316
|
const subagentSeen = new Set();
|
|
317
|
+
// Command families already steered toward filtered output this session: one steer per family.
|
|
318
|
+
const largeOutputSteered = new Set();
|
|
319
|
+
// Real paths the agent created under the temp directory this session, with the identity each had when recorded.
|
|
320
|
+
// A recursive rm of only these is not destructive; a record whose path is gone or replaced is dropped after each call.
|
|
321
|
+
const sessionScratch = new Map();
|
|
322
|
+
// Per tool call id: when it started and the temp paths it may create that did not exist yet.
|
|
323
|
+
const scratchPending = new Map();
|
|
312
324
|
const wakePolicy = new WakePolicy(0);
|
|
313
325
|
const runaway = new RunawayMonitor();
|
|
314
326
|
// Runs stopped by the runaway guard for the current user prompt; the first one gets a recovery turn, later ones wait for the user.
|
|
@@ -368,14 +380,79 @@ export default function wardenExtension(pi) {
|
|
|
368
380
|
return config;
|
|
369
381
|
};
|
|
370
382
|
const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
|
|
383
|
+
/** Why no judge is available, or undefined when one is. The notice, the trace file, and the conscience all use it. */
|
|
384
|
+
const judgmentsOffReason = (config) => {
|
|
385
|
+
if (!consentGiven(config))
|
|
386
|
+
return "no_consent";
|
|
387
|
+
if (budgetExhausted)
|
|
388
|
+
return "budget";
|
|
389
|
+
const auth = authState({ backend: config.typesafeBackend });
|
|
390
|
+
if (auth.usable)
|
|
391
|
+
return undefined;
|
|
392
|
+
// No key in effect, or one whose last request came back 401 or 403.
|
|
393
|
+
return auth.source === undefined ? "no_key" : "key_rejected";
|
|
394
|
+
};
|
|
395
|
+
// Where the judgments-off notice goes: the session's UI, or a status message for a headless session.
|
|
396
|
+
let judgmentsNotify;
|
|
397
|
+
let judgmentsHeadless = false;
|
|
398
|
+
const judgmentsReported = new Set();
|
|
399
|
+
/** Say once per session and reason that judgments are off, and record the state in the trace file. */
|
|
400
|
+
const noteJudgments = (config, reason) => {
|
|
401
|
+
traceFile?.judgments(judgmentsState(reason));
|
|
402
|
+
// The budget has its own notice in noteError.
|
|
403
|
+
if (reason === undefined || reason === "budget" || judgmentsReported.has(reason))
|
|
404
|
+
return;
|
|
405
|
+
judgmentsReported.add(reason);
|
|
406
|
+
const auth = reason === "key_rejected" ? authState({ backend: config.typesafeBackend }) : undefined;
|
|
407
|
+
judgmentsNotify?.(judgmentsOffText(reason, config.typesafeBackend, judgmentsHeadless, auth?.kind === "environment" ? auth.keyName : undefined));
|
|
408
|
+
};
|
|
371
409
|
const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
|
|
372
410
|
/** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
|
|
373
411
|
const judgeFor = (config) => {
|
|
374
|
-
|
|
412
|
+
const off = judgmentsOffReason(config);
|
|
413
|
+
noteJudgments(config, off);
|
|
414
|
+
if (off)
|
|
375
415
|
return undefined;
|
|
376
|
-
|
|
416
|
+
judgeConfig = config;
|
|
417
|
+
// Absent, exactly as with no judge configured, so no guard needs to know a cooldown exists.
|
|
418
|
+
if (cooldown.active()) {
|
|
419
|
+
stats.cooldownSkips++;
|
|
377
420
|
return undefined;
|
|
378
|
-
|
|
421
|
+
}
|
|
422
|
+
return client ??= watched(createTypeSafe(judgeOptions(config)));
|
|
423
|
+
};
|
|
424
|
+
/** Every guard reaches the backend through `evaluate`, so this one seam sees each failure and each success. */
|
|
425
|
+
const watched = (typesafe) => new Proxy(typesafe, {
|
|
426
|
+
get(target, prop, receiver) {
|
|
427
|
+
if (prop !== "evaluate")
|
|
428
|
+
return Reflect.get(target, prop, receiver);
|
|
429
|
+
return async (...args) => {
|
|
430
|
+
try {
|
|
431
|
+
const result = await target.evaluate(...args);
|
|
432
|
+
tellCooldown(cooldown.success());
|
|
433
|
+
return result;
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
const kind = cooldownFailureKind(err, args[1]?.signal);
|
|
437
|
+
if (kind)
|
|
438
|
+
tellCooldown(cooldown.failure(kind, judgeConfig?.judge ?? defaultConfig().judge));
|
|
439
|
+
throw err;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
const tellCooldown = (event) => {
|
|
445
|
+
if (!event || !noticeUi)
|
|
446
|
+
return;
|
|
447
|
+
if (event.type === "recovered") {
|
|
448
|
+
noticeUi.notify("warden: judgments resumed.", "info");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const seconds = Math.round(event.ms / 1000);
|
|
452
|
+
const remedy = event.kind === "auth"
|
|
453
|
+
? ` Set ${keyEnvFor(judgeConfig?.typesafeBackend ?? "typesafe")} or run /warden enable.`
|
|
454
|
+
: event.kind === "configuration" ? " /warden status shows the backend setup." : "";
|
|
455
|
+
noticeUi.notify(`warden: judgments paused for ${seconds}s after a ${event.kind} failure; pattern checks run alone until then.${remedy}`, "warning");
|
|
379
456
|
};
|
|
380
457
|
const noteError = (ctx, message, code) => {
|
|
381
458
|
stats.errors++;
|
|
@@ -387,15 +464,22 @@ export default function wardenExtension(pi) {
|
|
|
387
464
|
/** Click, shortcut, and /warden trace all toggle the same sidebar. */
|
|
388
465
|
const togglePanel = (ui, config) => {
|
|
389
466
|
if (!ui)
|
|
390
|
-
return;
|
|
467
|
+
return undefined;
|
|
391
468
|
if (panel) {
|
|
392
469
|
panel.close();
|
|
393
|
-
return;
|
|
470
|
+
return undefined;
|
|
394
471
|
}
|
|
395
472
|
const opened = openTracePanel(ui, trace, { width: config.widget.panelWidth });
|
|
396
473
|
panel = opened;
|
|
397
474
|
opened.closed.catch(() => undefined).finally(() => { if (panel === opened)
|
|
398
475
|
panel = undefined; });
|
|
476
|
+
return opened;
|
|
477
|
+
};
|
|
478
|
+
/** The last 20 trace entries as text, newest last, for a host that cannot show the sidebar. */
|
|
479
|
+
const traceText = () => {
|
|
480
|
+
const entries = trace.entries();
|
|
481
|
+
const text = entries.length ? entries.slice(-20).map(entry => `${new Date(entry.at).toTimeString().slice(0, 8)} ${entry.guard}: ${entry.line}${entry.details.length ? `\n ${entry.details.join("\n ")}` : ""}`).join("\n") : "No guarded activity yet this session.";
|
|
482
|
+
return traceFile ? `${text}\nTrace file: ${traceFile.path}` : text;
|
|
399
483
|
};
|
|
400
484
|
/** The config overlay toggles the same way the sidebar does, so the hint in its header is true. */
|
|
401
485
|
const toggleConfigPanel = (ui, config) => {
|
|
@@ -555,6 +639,8 @@ export default function wardenExtension(pi) {
|
|
|
555
639
|
steersThisRun++;
|
|
556
640
|
const { display, ...delivery } = options ?? { deliverAs: "steer" };
|
|
557
641
|
pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: display ?? config.steerVisible }, delivery);
|
|
642
|
+
if (delivery.triggerTurn)
|
|
643
|
+
wardenContinuation = true;
|
|
558
644
|
return true;
|
|
559
645
|
};
|
|
560
646
|
/**
|
|
@@ -617,6 +703,8 @@ export default function wardenExtension(pi) {
|
|
|
617
703
|
pi.on("session_start", async (_event, ctx) => {
|
|
618
704
|
if (ctx.hasUI)
|
|
619
705
|
lastUi = ctx.ui;
|
|
706
|
+
noticeUi = ctx.hasUI ? ctx.ui : undefined;
|
|
707
|
+
cooldown.reset();
|
|
620
708
|
if (_event.reason === "reload" || _event.reason === "new")
|
|
621
709
|
conscienceGeneration++;
|
|
622
710
|
client = undefined;
|
|
@@ -634,11 +722,33 @@ export default function wardenExtension(pi) {
|
|
|
634
722
|
rulesGuard.reset();
|
|
635
723
|
holds.reset();
|
|
636
724
|
regretCandidates = [];
|
|
637
|
-
|
|
725
|
+
const sessionId = typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid);
|
|
726
|
+
holdLog = new HoldLog(holdLogPath(sessionId));
|
|
727
|
+
unsubscribeTraceFile?.();
|
|
728
|
+
unsubscribeTraceFile = undefined;
|
|
729
|
+
traceFile = undefined;
|
|
730
|
+
judgmentsReported.clear();
|
|
731
|
+
judgmentsHeadless = !ctx.hasUI;
|
|
732
|
+
judgmentsNotify = text => { if (ctx.hasUI)
|
|
733
|
+
ctx.ui.notify(text, "warning");
|
|
734
|
+
else
|
|
735
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
|
|
736
|
+
sessionHostPaths = hostPaths();
|
|
737
|
+
const dir = traceDir();
|
|
738
|
+
if (dir) {
|
|
739
|
+
const warn = (text) => { if (ctx.hasUI)
|
|
740
|
+
ctx.ui.notify(text, "warning");
|
|
741
|
+
else
|
|
742
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
|
|
743
|
+
const opening = loadConfig();
|
|
744
|
+
traceFile = new TraceFile(traceFilePath(dir, sessionId), dir, { sessionId, cwd: ctx.cwd, mode: opening.mode, judgments: judgmentsState(judgmentsOffReason(opening)) }, warn);
|
|
745
|
+
unsubscribeTraceFile = trace.subscribe(traceFile.listener);
|
|
746
|
+
}
|
|
638
747
|
arming.reset();
|
|
639
748
|
attempts.reset();
|
|
640
749
|
evidence = emptyEvidence();
|
|
641
750
|
doneNudged = false;
|
|
751
|
+
wardenContinuation = false;
|
|
642
752
|
prose.reset();
|
|
643
753
|
// Clean up temp output dirs from the previous session.
|
|
644
754
|
for (const dir of ledger.storedPaths()) {
|
|
@@ -653,6 +763,9 @@ export default function wardenExtension(pi) {
|
|
|
653
763
|
compressionLearner.reset();
|
|
654
764
|
secretsSeen.clear();
|
|
655
765
|
subagentSeen.clear();
|
|
766
|
+
largeOutputSteered.clear();
|
|
767
|
+
sessionScratch.clear();
|
|
768
|
+
scratchPending.clear();
|
|
656
769
|
wakePolicy.reset();
|
|
657
770
|
steerRepeats.reset();
|
|
658
771
|
steersThisRun = 0;
|
|
@@ -688,10 +801,12 @@ export default function wardenExtension(pi) {
|
|
|
688
801
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
689
802
|
if (ctx.hasUI)
|
|
690
803
|
lastUi = ctx.ui;
|
|
804
|
+
noticeUi = ctx.hasUI ? ctx.ui : undefined;
|
|
691
805
|
const config = configFor(ctx);
|
|
692
806
|
attempts = new AttemptWindow(config.stuck.window);
|
|
693
807
|
fullOutputs = new Map();
|
|
694
808
|
doneNudged = false;
|
|
809
|
+
wardenContinuation = false;
|
|
695
810
|
steersThisRun = 0;
|
|
696
811
|
finals.reset();
|
|
697
812
|
runaway.reset();
|
|
@@ -744,6 +859,7 @@ export default function wardenExtension(pi) {
|
|
|
744
859
|
const truncationRecorded = event.prompt.length > 2000 ? [`prompt truncated from ${event.prompt.length} to 2000 chars`] : [];
|
|
745
860
|
// Build recent context from session branch (up to 4 messages, 500 chars each)
|
|
746
861
|
const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
|
|
862
|
+
const spine = taskSpine(branch, event.prompt);
|
|
747
863
|
const recentMessages = [];
|
|
748
864
|
for (const entry of branch.slice(-6)) {
|
|
749
865
|
if (recentMessages.length >= 4)
|
|
@@ -804,7 +920,7 @@ export default function wardenExtension(pi) {
|
|
|
804
920
|
// it. Empty until the first evaluate call, and an empty model never matches a policy, so the gate fails closed.
|
|
805
921
|
let judgeModel = "";
|
|
806
922
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); judgeModel = r.model; return { answers: r.answers }; } } : undefined;
|
|
807
|
-
const result = await assess(redactedPrompt, recentContext, skills, toolInfos, activeSkills, suppliedSkills, { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
923
|
+
const result = await assess(redactedPrompt, recentContext, skills, toolInfos, activeSkills, suppliedSkills, { judge: judgeAdapter, judgmentsOff: judgmentsOffReason(config), config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
|
|
808
924
|
// Check generation after await
|
|
809
925
|
if (conscienceGeneration !== myGeneration) {
|
|
810
926
|
record(ctx, config, "conscience", "stale: generation changed during assessment", ["trigger: before_agent_start", `generation: ${myGeneration} → ${conscienceGeneration}`, `skipReason: stale`]);
|
|
@@ -889,7 +1005,7 @@ export default function wardenExtension(pi) {
|
|
|
889
1005
|
}
|
|
890
1006
|
}
|
|
891
1007
|
catch (err) {
|
|
892
|
-
const category =
|
|
1008
|
+
const category = classifyJudgeError(err);
|
|
893
1009
|
if (!warnedErrorCategories.has(category)) {
|
|
894
1010
|
warnedErrorCategories.add(category);
|
|
895
1011
|
console.warn(`pi-warden: conscience ${category}`);
|
|
@@ -925,7 +1041,10 @@ export default function wardenExtension(pi) {
|
|
|
925
1041
|
const judge = judgeFor(config);
|
|
926
1042
|
if (judge && cachedSkills.length > 0) {
|
|
927
1043
|
const myGeneration = conscienceGeneration;
|
|
928
|
-
const
|
|
1044
|
+
const prompt = typeof event.message.content === "string" ? event.message.content : "";
|
|
1045
|
+
const redactedText = redact(prompt).slice(0, 2000);
|
|
1046
|
+
const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
|
|
1047
|
+
const spine = taskSpine(branch, prompt);
|
|
929
1048
|
let toolInfos = [];
|
|
930
1049
|
try {
|
|
931
1050
|
toolInfos = pi.getAllTools().map((t) => ({ name: t.name, description: t.description }));
|
|
@@ -936,13 +1055,13 @@ export default function wardenExtension(pi) {
|
|
|
936
1055
|
const activeSkills = cachedSkills.map(s => s.name);
|
|
937
1056
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
|
|
938
1057
|
try {
|
|
939
|
-
const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
1058
|
+
const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
|
|
940
1059
|
if (conscienceGeneration !== myGeneration)
|
|
941
1060
|
return;
|
|
942
1061
|
record(ctx, config, "conscience", `queued prompt assessed → ${result.selected ? `${result.selected.kind}:${result.selected.id}` : "none"} (${result.skipReason ?? "none"})`, ["trigger: message_start", `origin: queued`, `skipReason: ${result.skipReason ?? "none"}`]);
|
|
943
1062
|
}
|
|
944
1063
|
catch (err) {
|
|
945
|
-
const cat =
|
|
1064
|
+
const cat = classifyJudgeError(err);
|
|
946
1065
|
if (!warnedErrorCategories.has(cat)) {
|
|
947
1066
|
warnedErrorCategories.add(cat);
|
|
948
1067
|
console.warn(`pi-warden: conscience ${cat}`);
|
|
@@ -980,9 +1099,12 @@ export default function wardenExtension(pi) {
|
|
|
980
1099
|
conscienceGeneration++;
|
|
981
1100
|
ctx.abort();
|
|
982
1101
|
});
|
|
983
|
-
// Each low-level run collects its own evidence of changes and checks.
|
|
1102
|
+
// Each low-level run collects its own evidence of changes and checks. A run that a warden follow-up started continues
|
|
1103
|
+
// the previous run's work, so it keeps that evidence: the changes behind a done-check nudge still need a passing check.
|
|
984
1104
|
pi.on("agent_start", async () => {
|
|
985
|
-
|
|
1105
|
+
if (!wardenContinuation)
|
|
1106
|
+
evidence = emptyEvidence();
|
|
1107
|
+
wardenContinuation = false;
|
|
986
1108
|
});
|
|
987
1109
|
// Every turn that runs after a compression is a turn that did not carry the removed text.
|
|
988
1110
|
pi.on("turn_end", async (_event, ctx) => {
|
|
@@ -1002,11 +1124,13 @@ export default function wardenExtension(pi) {
|
|
|
1002
1124
|
catch {
|
|
1003
1125
|
toolInfos = [];
|
|
1004
1126
|
}
|
|
1005
|
-
const
|
|
1127
|
+
const prompt = latestUserPrompt(ctx);
|
|
1128
|
+
const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
|
|
1129
|
+
const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
|
|
1006
1130
|
const activeSkills = cachedSkills.map(s => s.name);
|
|
1007
1131
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
|
|
1008
1132
|
try {
|
|
1009
|
-
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
1133
|
+
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
|
|
1010
1134
|
if (conscienceGeneration !== myGeneration)
|
|
1011
1135
|
return;
|
|
1012
1136
|
assessmentsThisPrompt++;
|
|
@@ -1031,6 +1155,8 @@ export default function wardenExtension(pi) {
|
|
|
1031
1155
|
const config = configFor(ctx);
|
|
1032
1156
|
if (!config.enabled)
|
|
1033
1157
|
return;
|
|
1158
|
+
if (event.toolName === "bash" || event.toolName === "write")
|
|
1159
|
+
scratchPending.set(event.toolCallId, { started: Date.now(), candidates: scratchCandidates(event.toolName, event.input, ctx.cwd) });
|
|
1034
1160
|
// ── Conscience: track tool attempts on the selected capability ──
|
|
1035
1161
|
if (config.conscience.enabled && selectedCapability && !triggerConsumed) {
|
|
1036
1162
|
if (selectedCapability.kind === "tool" && event.toolName === selectedCapability.id) {
|
|
@@ -1060,14 +1186,17 @@ export default function wardenExtension(pi) {
|
|
|
1060
1186
|
if (!config.action.enabled || !config.action.tools.includes(event.toolName))
|
|
1061
1187
|
return;
|
|
1062
1188
|
stats.inspected++;
|
|
1063
|
-
// First-run warning:
|
|
1189
|
+
// First-run warning: no project rules file resolved, one time per session.
|
|
1064
1190
|
if (!warnedMissingRules && config.rules.enabled) {
|
|
1065
|
-
const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd);
|
|
1191
|
+
const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd, config.rules);
|
|
1066
1192
|
if (missing && ctx.hasUI) {
|
|
1067
1193
|
warnedMissingRules = true;
|
|
1194
|
+
// The tiers still in play, in resolution order: naming README/CLAUDE/AGENTS to someone who
|
|
1195
|
+
// turned the fallback off would send them to a document pi-warden will not read.
|
|
1196
|
+
const tiers = [RULES_FILE, "rules.files", ...(config.rules.fallback ? FALLBACK_FILES : [])].join(", ");
|
|
1068
1197
|
const msg = fallbackSource
|
|
1069
|
-
? `No pi-warden.md
|
|
1070
|
-
: `No rules file detected (
|
|
1198
|
+
? `No pi-warden.md or rules.files entry here. Using ${fallbackSource} as active fallback rules. Run /warden init to create project-specific rules.`
|
|
1199
|
+
: `No rules file detected (${tiers}). Run /warden init to create project-specific rules.`;
|
|
1071
1200
|
ctx.ui.notify(msg, "warning");
|
|
1072
1201
|
}
|
|
1073
1202
|
}
|
|
@@ -1103,7 +1232,7 @@ export default function wardenExtension(pi) {
|
|
|
1103
1232
|
? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
1104
1233
|
: undefined;
|
|
1105
1234
|
rulesCheck?.catch(() => undefined);
|
|
1106
|
-
const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined });
|
|
1235
|
+
const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx), spine: taskSpine(ctx.sessionManager.getBranch()) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, largeOutput: config.context.largeOutput, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined, scratch: sessionScratch, hostPaths: sessionHostPaths });
|
|
1107
1236
|
if (verdict.source === "skipped")
|
|
1108
1237
|
return;
|
|
1109
1238
|
// Arming check: if any armed rule's command regex matches this call, inject a hit into the verdict.
|
|
@@ -1159,6 +1288,11 @@ export default function wardenExtension(pi) {
|
|
|
1159
1288
|
// Notes for the agent about the content it just wrote: slop, rule violations, and sensitive paths arrive as one message.
|
|
1160
1289
|
const notes = [];
|
|
1161
1290
|
const noteGuards = new Set();
|
|
1291
|
+
/** Slop and rule findings say "just written", so they wait until the call proceeds. A held, denied, or declined write
|
|
1292
|
+
* never lands and drops them; an approved retry is judged again and brings its own. They go between the action notes
|
|
1293
|
+
* and the sensitive-path notes, the order of the combined message. */
|
|
1294
|
+
const contentNotes = [];
|
|
1295
|
+
const pathNotes = [];
|
|
1162
1296
|
/** Sensitive-path notes ride with the combined steer for this call; their trace waits for the delivery result. */
|
|
1163
1297
|
const pathNoteTraces = [];
|
|
1164
1298
|
if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
|
|
@@ -1223,15 +1357,24 @@ export default function wardenExtension(pi) {
|
|
|
1223
1357
|
noteGuards.add("action");
|
|
1224
1358
|
notes.push(shouldProceedMessage(verdict));
|
|
1225
1359
|
}
|
|
1360
|
+
const largeOutput = largeOutputNotice(verdict, largeOutputSteered);
|
|
1361
|
+
if (largeOutput) {
|
|
1362
|
+
noteGuards.add("action");
|
|
1363
|
+
notes.push(largeOutput);
|
|
1364
|
+
}
|
|
1226
1365
|
if (verdict.slopSymptoms?.length && verdict.slopReasons) {
|
|
1227
1366
|
stats.slop++;
|
|
1228
|
-
|
|
1229
|
-
for (const symptom of verdict.slopSymptoms)
|
|
1230
|
-
slopCounts[symptom]++;
|
|
1367
|
+
const symptoms = verdict.slopSymptoms;
|
|
1231
1368
|
const where = verdict.summary.path ?? event.toolName;
|
|
1232
1369
|
if (ctx.hasUI && config.notices)
|
|
1233
1370
|
ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
|
|
1234
|
-
|
|
1371
|
+
contentNotes.push(proceeds => {
|
|
1372
|
+
if (!proceeds)
|
|
1373
|
+
return undefined;
|
|
1374
|
+
for (const symptom of symptoms)
|
|
1375
|
+
slopCounts[symptom]++;
|
|
1376
|
+
return { guard: "action", text: slopSteer(where, symptoms, slopCounts) };
|
|
1377
|
+
});
|
|
1235
1378
|
}
|
|
1236
1379
|
if (rulesCheck) {
|
|
1237
1380
|
const rules = await rulesCheck;
|
|
@@ -1239,16 +1382,23 @@ export default function wardenExtension(pi) {
|
|
|
1239
1382
|
stats.ruleChecks++;
|
|
1240
1383
|
if (rules.error)
|
|
1241
1384
|
noteError(ctx, rules.error, rules.errorCode);
|
|
1242
|
-
const told = rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
|
|
1243
|
-
record(ctx, config, "rules", formatRules(rules, config.widget.rules), rulesDetails(rules, told));
|
|
1244
1385
|
holds.recordRules({ source: rules.source, path: rules.path, findings: rules.findings.map(f => ({ name: f.name, violation: f.violation })), ...(rules.error ? { error: rules.error } : {}) });
|
|
1245
|
-
if (
|
|
1386
|
+
if (rules.findings.length) {
|
|
1246
1387
|
stats.ruleViolations++;
|
|
1247
|
-
noteGuards.add("rules");
|
|
1248
1388
|
if (ctx.hasUI && config.notices)
|
|
1249
1389
|
ctx.ui.notify(`warden · rules · ${rules.path}: ${rules.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
|
|
1250
|
-
notes.push(told);
|
|
1251
1390
|
}
|
|
1391
|
+
contentNotes.push(proceeds => {
|
|
1392
|
+
const told = proceeds && rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
|
|
1393
|
+
const details = rulesDetails(rules, told);
|
|
1394
|
+
if (!proceeds && rules.findings.length)
|
|
1395
|
+
details.push("agent not told: the write was held");
|
|
1396
|
+
record(ctx, config, "rules", formatRules(rules, config.widget.rules), details);
|
|
1397
|
+
return told ? { guard: "rules", text: told } : undefined;
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
else {
|
|
1401
|
+
record(ctx, config, "rules", formatRules(rules, config.widget.rules), [`${rules.tool} ${rules.path}: ${rules.skippedReason}`]);
|
|
1252
1402
|
}
|
|
1253
1403
|
const hits = rulesGuard.notesFor(verdict.summary.location === "inside_project" ? verdict.summary.path : undefined, config.rules.sensitivePaths);
|
|
1254
1404
|
if (hits.length && verdict.summary.path) {
|
|
@@ -1262,17 +1412,29 @@ export default function wardenExtension(pi) {
|
|
|
1262
1412
|
ctx.ui.notify(`warden · sensitive path · ${verdict.summary.path} (${hits.map(hit => hit.glob).join(", ")}); the agent was given the note`, "warning");
|
|
1263
1413
|
});
|
|
1264
1414
|
noteGuards.add("rules");
|
|
1265
|
-
|
|
1415
|
+
pathNotes.push(told);
|
|
1266
1416
|
}
|
|
1267
1417
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
for (const
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1418
|
+
/** Sends the combined steer once the call's fate is known; `proceeds` is false when the call does not run. */
|
|
1419
|
+
const deliverNotes = (proceeds) => {
|
|
1420
|
+
for (const contentNote of contentNotes) {
|
|
1421
|
+
const note = contentNote(proceeds);
|
|
1422
|
+
if (!note)
|
|
1423
|
+
continue;
|
|
1424
|
+
noteGuards.add(note.guard);
|
|
1425
|
+
notes.push(note.text);
|
|
1426
|
+
}
|
|
1427
|
+
notes.push(...pathNotes);
|
|
1428
|
+
if (notes.length) {
|
|
1429
|
+
const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
|
|
1430
|
+
for (const traceNote of pathNoteTraces)
|
|
1431
|
+
traceNote(delivered);
|
|
1432
|
+
pathNoteTraces.length = 0;
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1274
1435
|
const warnSteer = (reasons) => reasons.length > 0 && steer(config, "action", `pi-warden: this ${event.toolName} call ran with a warning (${reasons.join("; ")}). Nobody sees this in a headless run, so it is on you: if the flagged risk is expected, continue; otherwise fix it or ask the user before building on it.`);
|
|
1275
1436
|
if (verdict.level === "warn") {
|
|
1437
|
+
deliverNotes(true);
|
|
1276
1438
|
stats.warned++;
|
|
1277
1439
|
if (ctx.hasUI && config.notices)
|
|
1278
1440
|
ctx.ui.notify(`warden · ${event.toolName}: ${verdict.reasons.join("; ")}`, "warning");
|
|
@@ -1284,6 +1446,7 @@ export default function wardenExtension(pi) {
|
|
|
1284
1446
|
return undefined;
|
|
1285
1447
|
}
|
|
1286
1448
|
if (verdict.level === "deny") {
|
|
1449
|
+
deliverNotes(false);
|
|
1287
1450
|
stats.held++;
|
|
1288
1451
|
track(true, "declined", "deny");
|
|
1289
1452
|
if (ctx.hasUI && config.notices)
|
|
@@ -1292,6 +1455,7 @@ export default function wardenExtension(pi) {
|
|
|
1292
1455
|
return { block: true, reason: `pi-warden blocked this ${event.toolName} call (${deliveryReasons.join("; ")}). A deny rule matched; this command is not allowed to run. Ask the user if this is genuinely required.` };
|
|
1293
1456
|
}
|
|
1294
1457
|
if (verdict.level !== "confirm") {
|
|
1458
|
+
deliverNotes(true);
|
|
1295
1459
|
track(false);
|
|
1296
1460
|
return undefined;
|
|
1297
1461
|
}
|
|
@@ -1304,6 +1468,7 @@ export default function wardenExtension(pi) {
|
|
|
1304
1468
|
if (dialogRule && ctx.hasUI) {
|
|
1305
1469
|
notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
|
|
1306
1470
|
const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
|
|
1471
|
+
deliverNotes(allowed);
|
|
1307
1472
|
if (allowed) {
|
|
1308
1473
|
track(true, "approved", "dialog");
|
|
1309
1474
|
return undefined;
|
|
@@ -1313,6 +1478,7 @@ export default function wardenExtension(pi) {
|
|
|
1313
1478
|
return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${deliveredReasons}). Do not retry it unchanged; ask the user how to proceed.` };
|
|
1314
1479
|
}
|
|
1315
1480
|
if (mode === "advise") {
|
|
1481
|
+
deliverNotes(true);
|
|
1316
1482
|
stats.warned++;
|
|
1317
1483
|
if (ctx.hasUI && config.notices)
|
|
1318
1484
|
ctx.ui.notify(`warden · ${event.toolName} (advise mode, not held): ${reasons}`, "warning");
|
|
@@ -1324,6 +1490,7 @@ export default function wardenExtension(pi) {
|
|
|
1324
1490
|
if (mode === "confirm") {
|
|
1325
1491
|
notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
|
|
1326
1492
|
const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
|
|
1493
|
+
deliverNotes(allowed);
|
|
1327
1494
|
if (allowed) {
|
|
1328
1495
|
track(true, "approved", "dialog");
|
|
1329
1496
|
return undefined;
|
|
@@ -1332,6 +1499,7 @@ export default function wardenExtension(pi) {
|
|
|
1332
1499
|
track(true, "declined", "dialog");
|
|
1333
1500
|
return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${deliveredReasons}). Do not retry it unchanged; ask the user how to proceed.` };
|
|
1334
1501
|
}
|
|
1502
|
+
deliverNotes(false);
|
|
1335
1503
|
stats.held++;
|
|
1336
1504
|
actionGuard.hold(task);
|
|
1337
1505
|
track(true);
|
|
@@ -1346,6 +1514,13 @@ export default function wardenExtension(pi) {
|
|
|
1346
1514
|
return;
|
|
1347
1515
|
const textBlocks = event.content.filter(part => part.type === "text");
|
|
1348
1516
|
const text = textBlocks.map(part => part.text).join("\n");
|
|
1517
|
+
const scratch = scratchPending.get(event.toolCallId);
|
|
1518
|
+
if (scratch) {
|
|
1519
|
+
scratchPending.delete(event.toolCallId);
|
|
1520
|
+
for (const [path, identity] of createdScratch(event.toolName, event.input, text, scratch.started, scratch.candidates))
|
|
1521
|
+
sessionScratch.set(path, identity);
|
|
1522
|
+
}
|
|
1523
|
+
pruneScratch(sessionScratch);
|
|
1349
1524
|
// Repeat detection uses the original result, so its request goes out together with the output check.
|
|
1350
1525
|
const failed = resultFailed(event.isError, event.details, event.content);
|
|
1351
1526
|
// ── Conscience: track tool_result for the pending capability ──
|
|
@@ -1468,7 +1643,9 @@ export default function wardenExtension(pi) {
|
|
|
1468
1643
|
}
|
|
1469
1644
|
if (config.context.enabled && !earlier && !recallRead && textBlocks.length === 1 && text.length >= config.context.tailMinChars)
|
|
1470
1645
|
ledger.candidate();
|
|
1471
|
-
|
|
1646
|
+
// Captured before the loop so the notice block below knows whether each block already carries its banner.
|
|
1647
|
+
const bannersPlacedWithBlocks = multiBlock && !ctx.signal?.aborted;
|
|
1648
|
+
if (bannersPlacedWithBlocks) {
|
|
1472
1649
|
// Retention and banners per block; block order and non-text parts are never touched.
|
|
1473
1650
|
content = [...content];
|
|
1474
1651
|
let textIndex = 0;
|
|
@@ -1536,8 +1713,11 @@ export default function wardenExtension(pi) {
|
|
|
1536
1713
|
}
|
|
1537
1714
|
if (key && !earlier)
|
|
1538
1715
|
ledger.remember(key, event.toolName, storedPath);
|
|
1716
|
+
// The banner in the result already tells the agent. A steer custom message sent here arrives after the turn ends
|
|
1717
|
+
// when this result closes the turn and starts a new turn to answer a message that asks for nothing, so the notice
|
|
1718
|
+
// rides the result content alone and is never steered.
|
|
1539
1719
|
if (notice && textBlocks.length) {
|
|
1540
|
-
if (!
|
|
1720
|
+
if (!bannersPlacedWithBlocks) {
|
|
1541
1721
|
let index = 0;
|
|
1542
1722
|
content = content.map(part => {
|
|
1543
1723
|
if (part.type !== "text")
|
|
@@ -1546,13 +1726,12 @@ export default function wardenExtension(pi) {
|
|
|
1546
1726
|
return { ...part, text: `${index === 1 ? `${notice}\n\n` : ""}${part.text}${index === textBlocks.length ? `\n\n${notice}` : ""}` };
|
|
1547
1727
|
});
|
|
1548
1728
|
}
|
|
1549
|
-
const delivered = steer(config, "security", notice);
|
|
1550
1729
|
record(ctx, config, "security", renderTemplate(config.widget.security, {
|
|
1551
1730
|
tool: event.toolName, injection: output.injection?.toFixed(2), exfiltration: output.exfiltration?.toFixed(2),
|
|
1552
1731
|
status: [output.suspicious && "untrusted instructions", output.secret && "possible credentials"].filter(Boolean).join(", "),
|
|
1553
1732
|
}), [
|
|
1554
1733
|
`jev: injection ${output.injection?.toFixed(2) ?? "not judged"}; exfiltration ${output.exfiltration?.toFixed(2) ?? "not judged"}`,
|
|
1555
|
-
`output sample: ${redact(text).slice(0, 300)}`,
|
|
1734
|
+
`output sample: ${redact(text).slice(0, 300)}`, `agent told by the banner in the tool result: ${notice}`,
|
|
1556
1735
|
]);
|
|
1557
1736
|
}
|
|
1558
1737
|
// Stuck-loop diff: when the verdict is stuck on a failed result and a previous failed output
|
|
@@ -1742,11 +1921,13 @@ export default function wardenExtension(pi) {
|
|
|
1742
1921
|
catch {
|
|
1743
1922
|
toolInfos = [];
|
|
1744
1923
|
}
|
|
1745
|
-
const
|
|
1924
|
+
const prompt = latestUserPrompt(ctx);
|
|
1925
|
+
const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
|
|
1926
|
+
const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
|
|
1746
1927
|
const activeSkills = cachedSkills.map(s => s.name);
|
|
1747
1928
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
|
|
1748
1929
|
try {
|
|
1749
|
-
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
1930
|
+
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
|
|
1750
1931
|
if (conscienceGeneration !== myGeneration)
|
|
1751
1932
|
return;
|
|
1752
1933
|
assessmentsThisPrompt++;
|
|
@@ -1776,7 +1957,7 @@ export default function wardenExtension(pi) {
|
|
|
1776
1957
|
const proseCheck = config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars
|
|
1777
1958
|
? evaluateProse(task, finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
1778
1959
|
: undefined;
|
|
1779
|
-
const doneCheck = config.done.enabled &&
|
|
1960
|
+
const doneCheck = config.done.enabled && needsDoneCheck(evidence)
|
|
1780
1961
|
? evaluateDone(task, finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
1781
1962
|
: undefined;
|
|
1782
1963
|
if (proseCheck) {
|
|
@@ -1806,7 +1987,8 @@ export default function wardenExtension(pi) {
|
|
|
1806
1987
|
const verdict = await doneCheck;
|
|
1807
1988
|
if (verdict.error)
|
|
1808
1989
|
noteError(ctx, verdict.error, verdict.errorCode);
|
|
1809
|
-
|
|
1990
|
+
// At most one nudge per user prompt; a later unverified claim is still judged and recorded.
|
|
1991
|
+
const nudge = verdict.unverified && config.done.nudge && !doneNudged ? doneNudge(verdict) : undefined;
|
|
1810
1992
|
record(ctx, config, "done", formatDone(verdict, config.widget.done), doneDetails(verdict, finalMessage, nudge));
|
|
1811
1993
|
if (!verdict.unverified)
|
|
1812
1994
|
return;
|
|
@@ -1860,7 +2042,7 @@ export default function wardenExtension(pi) {
|
|
|
1860
2042
|
: `Lifetime here: ${ls.held} hold${ls.held === 1 ? "" : "s"}, ${ls.labeled} labeled, ${ls.declined + ls.replanned} stood (${ls.declined + ls.replanned}/${ls.labeled}), ${ls.allowed} allowed (${ls.accepted} accepted, ${ls.regretted} regretted).`;
|
|
1861
2043
|
report([
|
|
1862
2044
|
`pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `consented via ${source}` : "not consented (run /warden enable)"}${config.typesafeBackend !== "typesafe" ? ` (${config.typesafeBackend})` : ""}; ${auth.text}`,
|
|
1863
|
-
`Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan, ${stats.offTask} off task, ${stats.slop} slop notes, ${stats.ruleViolations}/${stats.ruleChecks} rule violations, ${stats.pathNotes} sensitive-path notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.runaway} runaway stops, ${stats.subagentWoken}/${stats.subagentReports} subagent reports woken, ${stats.restatements} restatements, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests. Steers are ${config.steerVisible ? "shown in the transcript" : "hidden from the transcript (trace panel shows them)"}. Steer budget: ${config.steerBudget === 0 ? "off" : `${config.steerBudget} per run`}.`,
|
|
2045
|
+
`Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan, ${stats.offTask} off task, ${stats.slop} slop notes, ${stats.ruleViolations}/${stats.ruleChecks} rule violations, ${stats.pathNotes} sensitive-path notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.runaway} runaway stops, ${stats.subagentWoken}/${stats.subagentReports} subagent reports woken, ${stats.restatements} restatements, ${stats.errors} TypeSafe errors, ${stats.cooldownSkips} checks without Jev during a judge cooldown; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests. Steers are ${config.steerVisible ? "shown in the transcript" : "hidden from the transcript (trace panel shows them)"}. Steer budget: ${config.steerBudget === 0 ? "off" : `${config.steerBudget} per run`}.`,
|
|
1864
2046
|
formatSteers(stats),
|
|
1865
2047
|
`Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / steer ${config.action.offTask.steer} (never holds); intent mismatch ${config.action.intentMismatch} (${config.action.visibleMismatch} on a visible action); stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, rules ${config.rules.threshold}, prose ${config.slop.prose.threshold} in ${config.slop.prose.trend}/3 replies; runaway ${config.runaway.repeats} repeats (thinking ${config.runaway.thinkingRepeats}), recover ${config.runaway.recover}; failOpen ${config.action.failOpen}.`,
|
|
1866
2048
|
formatLedger(ledger.snapshot()),
|
|
@@ -1878,11 +2060,16 @@ export default function wardenExtension(pi) {
|
|
|
1878
2060
|
}
|
|
1879
2061
|
if (action === "trace") {
|
|
1880
2062
|
if (!ctx.hasUI) {
|
|
1881
|
-
|
|
1882
|
-
report(entries.length ? entries.slice(-20).map(entry => `${new Date(entry.at).toTimeString().slice(0, 8)} ${entry.guard}: ${entry.line}${entry.details.length ? `\n ${entry.details.join("\n ")}` : ""}`).join("\n") : "No guarded activity yet this session.");
|
|
2063
|
+
report(traceText());
|
|
1883
2064
|
return;
|
|
1884
2065
|
}
|
|
1885
|
-
togglePanel(ctx.ui, config);
|
|
2066
|
+
const opened = togglePanel(ctx.ui, config);
|
|
2067
|
+
// RPC mode has a UI but settles custom() without building the component, so the sidebar never shows. hasUI
|
|
2068
|
+
// is true there; only the host not asking for the component tells the two apart. Not awaited: in the
|
|
2069
|
+
// terminal `closed` settles when the user closes the sidebar.
|
|
2070
|
+
const fallback = () => { if (opened && !opened.built())
|
|
2071
|
+
ctx.ui.notify(traceText(), "info"); };
|
|
2072
|
+
void opened?.closed.then(fallback, fallback);
|
|
1886
2073
|
return;
|
|
1887
2074
|
}
|
|
1888
2075
|
if (action === "recommend") {
|