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