pi-warden 0.71.0 → 0.74.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 +54 -0
- package/dist/adaptive.d.ts +3 -2
- package/dist/adaptive.js +4 -3
- package/dist/backend.d.ts +33 -14
- package/dist/backend.js +27 -14
- package/dist/config.d.ts +13 -8
- package/dist/config.js +20 -20
- package/dist/conscience.d.ts +1 -1
- package/dist/extension.d.ts +14 -3
- package/dist/extension.js +410 -64
- package/dist/guard.d.ts +2 -1
- package/dist/guard.js +4 -4
- package/dist/holds.d.ts +1 -1
- package/dist/holds.js +3 -2
- package/dist/host-compat.d.ts +2 -0
- package/dist/host-compat.js +132 -0
- package/dist/host-dirs.d.ts +10 -0
- package/dist/host-dirs.js +21 -0
- package/dist/index-cmd.d.ts +6 -5
- package/dist/index-cmd.js +16 -24
- package/dist/index.d.ts +12 -4
- package/dist/index.js +6 -2
- package/dist/init.js +31 -3
- package/dist/learning.d.ts +12 -11
- package/dist/learning.js +60 -38
- package/dist/loops.d.ts +2 -1
- package/dist/loops.js +3 -2
- package/dist/panel.d.ts +4 -1
- package/dist/panel.js +11 -6
- package/dist/prefs.d.ts +1 -1
- package/dist/prefs.js +3 -2
- package/dist/rules-audit.d.ts +208 -0
- package/dist/rules-audit.js +417 -0
- package/dist/rules-calibrate.d.ts +196 -0
- package/dist/rules-calibrate.js +474 -0
- package/dist/rules-lint.d.ts +2 -0
- package/dist/rules-lint.js +7 -2
- package/dist/rules-log.d.ts +6 -1
- package/dist/rules-log.js +9 -3
- package/dist/rules-report.d.ts +3 -0
- package/dist/rules-report.js +5 -0
- package/dist/rules.d.ts +72 -5
- package/dist/rules.js +116 -54
- package/dist/shape.js +8 -2
- package/dist/turn-rules.d.ts +86 -0
- package/dist/turn-rules.js +232 -0
- package/dist/widget-render.d.ts +23 -0
- package/dist/widget-render.js +82 -0
- package/dist/widget.d.ts +5 -22
- package/dist/widget.js +3 -80
- package/package.json +5 -3
package/dist/extension.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { rm } from "node:fs/promises";
|
|
3
3
|
import { join, resolve as pathResolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { defaultHostDirs } from "./host-dirs.js";
|
|
5
7
|
import * as tuiModule from "@earendil-works/pi-tui";
|
|
6
8
|
const MouseRegion = tuiModule.MouseRegion;
|
|
7
|
-
import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
|
|
9
|
+
import { authState, backendHost, createTypeSafe, describeAuth, resolveBackend } from "pi-typesafe";
|
|
8
10
|
import { ensureApiKey } from "pi-typesafe/ui";
|
|
9
|
-
import {
|
|
11
|
+
import { backendName, describeBackend, disclosureFor, judgeOptions, loginStoresKey } from "./backend.js";
|
|
10
12
|
import { ActionGuard } from "./action-guard.js";
|
|
11
|
-
import {
|
|
13
|
+
import { adaptHost } from "./host-compat.js";
|
|
14
|
+
import { assistantView, formatMuted, NEVER_MUTED, STEER_KINDS, SteerStats, SteerWatch, steerStatsPath } from "./adaptive.js";
|
|
12
15
|
import { ArmingTracker, unparseableArmingRules } from "./arming.js";
|
|
13
16
|
import * as configModule from "./config.js";
|
|
14
|
-
import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
|
|
17
|
+
import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, PROJECT_CONFIG_FILE, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
|
|
15
18
|
import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, isVisualCheck, needsDoneCheck, recordOutcome as recordDoneOutcome, recordUi } from "./done.js";
|
|
16
19
|
import { createdScratch, evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, largeOutputNotice, offTaskSteer, pruneScratch, scratchCandidates, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, wardenHostPaths, writeSinkTargets, isVisibleCommand } from "./guard.js";
|
|
17
20
|
import { commandOf } from "./tools.js";
|
|
@@ -22,8 +25,11 @@ import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_S
|
|
|
22
25
|
import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice, CompressionLearner } from "./output.js";
|
|
23
26
|
import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
|
|
24
27
|
import { maskSecrets, redact } from "./redact.js";
|
|
25
|
-
import { formatRules, pathNoteSteer, projectPath, RulesGuard, rulesSteer, RULES_FILE, FALLBACK_FILES } from "./rules.js";
|
|
28
|
+
import { formatRules, pathNoteSteer, projectPath, RulesGuard, rulesSteer, RULES_FILE, FALLBACK_FILES, turnRulesFor } from "./rules.js";
|
|
29
|
+
import { evaluateTurnRun, SHELL_RULES_CHECKS, snapshotTree, turnSteer } from "./turn-rules.js";
|
|
26
30
|
import { checkRules, formatRulesCheck } from "./rules-lint.js";
|
|
31
|
+
import { parseRulesAuditArgs, parseBenchArgs, runRulesAudit, runBench, formatRulesAudit, formatBench } from "./rules-audit.js";
|
|
32
|
+
import { calibrate, calibrateGate, calibrationNotice, collectHistory, formatCalibration, planCalibration, tuneRequest, CALIBRATE_DEFAULT_COMMITS, CALIBRATE_DEFAULT_MAX } from "./rules-calibrate.js";
|
|
27
33
|
import { readRulesLog, RulesLog, rulesLogPath } from "./rules-log.js";
|
|
28
34
|
import { buildRulesReport, formatRulesReport, REPORT_DEFAULT_DAYS } from "./rules-report.js";
|
|
29
35
|
import { checkPiWardenMissing } from "./rules-file.js";
|
|
@@ -48,12 +54,11 @@ import { changePrefsStore, emptyWordCounts, evaluatePrefs, forgetPref, formatPre
|
|
|
48
54
|
import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
|
|
49
55
|
import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
|
|
50
56
|
import { TraceFile, judgmentsState, traceDir, traceFilePath } from "./trace-file.js";
|
|
51
|
-
import { actionTokens, DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, rulesTokens, SENTENCE_TEMPLATES,
|
|
57
|
+
import { actionTokens, DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, rulesTokens, SENTENCE_TEMPLATES, TOKEN_NAMES } from "./widget.js";
|
|
58
|
+
import { statusWidget } from "./widget-render.js";
|
|
52
59
|
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 (or a bash command that writes a file with its content in the command) 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.";
|
|
53
60
|
const WIDGET = PACKAGE_NAME;
|
|
54
61
|
const CONFIRM_TEXT_LIMIT = 500;
|
|
55
|
-
/** Rules requests one bash command may start; the other files it writes are recorded as skipped. */
|
|
56
|
-
const SHELL_RULES_CHECKS = 5;
|
|
57
62
|
const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offPlanTraceOnly: 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, steersMuted: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
|
|
58
63
|
/**
|
|
59
64
|
* One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
|
|
@@ -196,10 +201,11 @@ function planForCall(entries) {
|
|
|
196
201
|
* variable that holds a rejected key, or undefined when the rejected key is the one `/typesafe login` saved. Never
|
|
197
202
|
* names a key value or a path.
|
|
198
203
|
*/
|
|
199
|
-
export function judgmentsOffText(reason,
|
|
204
|
+
export function judgmentsOffText(reason, setting, headless, rejectedEnv) {
|
|
200
205
|
switch (reason) {
|
|
201
206
|
case "no_consent": return `warden: Jev judgments are off (no consent). ${headless ? "Set PI_WARDEN_ENABLED=1." : "Run /warden enable."}`;
|
|
202
|
-
case "
|
|
207
|
+
case "bad_backend": return `warden: Jev judgments are off (typesafeBackend refused: ${setting.backendRefusal ?? "the configured value is not usable"})`;
|
|
208
|
+
case "no_key": return `warden: Jev judgments are off (no key for ${backendName(setting.typesafeBackend)}). Set ${resolveBackend(setting.typesafeBackend).keyEnv}${loginStoresKey(setting.typesafeBackend) ? " or run /typesafe login" : ""}.`;
|
|
203
209
|
case "key_rejected": return rejectedEnv
|
|
204
210
|
? `warden: Jev judgments are off (the key in ${rejectedEnv} was rejected). Check the key, then run /warden status.`
|
|
205
211
|
: "warden: Jev judgments are off (the key saved by /typesafe login was rejected). Run /typesafe login.";
|
|
@@ -304,7 +310,50 @@ export function _testSetIndexRunning(running, paths = []) {
|
|
|
304
310
|
indexRunning = running;
|
|
305
311
|
indexWritePaths = paths;
|
|
306
312
|
}
|
|
307
|
-
|
|
313
|
+
/* ─── One-time migration notice ─────────────────────────────────────── */
|
|
314
|
+
const MIGRATION_MARKER = ".pi-warden-migration-notice-shown";
|
|
315
|
+
/** Single-quote a path for /bin/sh; an embedded quote ends the string, escapes itself, and reopens. */
|
|
316
|
+
function shellQuote(value) {
|
|
317
|
+
return `'` + value.replaceAll("'", `'\\''`) + `'`;
|
|
318
|
+
}
|
|
319
|
+
/** The absolute, symlink-free, on-disk-case form of a directory; a missing directory falls back to its absolute path. */
|
|
320
|
+
function canonicalDirectory(path) {
|
|
321
|
+
try {
|
|
322
|
+
return realpathSync.native(path);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return pathResolve(path);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The one-time migration message for a host whose agent directory is not Pi's default, when data
|
|
330
|
+
* still lives only under the default and this host has not shown the notice yet: undefined
|
|
331
|
+
* otherwise. The caller checks the filesystem for data and the marker. The directories are compared
|
|
332
|
+
* in absolute, symlink-free form: the same directory under another spelling (trailing slash, `.`
|
|
333
|
+
* segment, symlink) gets no notice, because the command would move the only copy aside. The command
|
|
334
|
+
* moves the fresh data this host may have created aside (non-destructive, and works when the target
|
|
335
|
+
* folder does not exist) and copies the legacy data whole in its place.
|
|
336
|
+
*/
|
|
337
|
+
export function legacyDataNotice(dirs, piDefault, exists) {
|
|
338
|
+
const agentDir = canonicalDirectory(dirs.agentDir);
|
|
339
|
+
const piAgentDir = canonicalDirectory(piDefault.agentDir);
|
|
340
|
+
if (agentDir === piAgentDir)
|
|
341
|
+
return undefined;
|
|
342
|
+
const legacy = join(piAgentDir, PACKAGE_NAME);
|
|
343
|
+
if (!exists(legacy))
|
|
344
|
+
return undefined;
|
|
345
|
+
const target = join(agentDir, PACKAGE_NAME);
|
|
346
|
+
if (exists(join(agentDir, MIGRATION_MARKER)))
|
|
347
|
+
return undefined;
|
|
348
|
+
const command = `{ [ ! -e ${shellQuote(target)} ] || mv ${shellQuote(target)} ${shellQuote(`${target}.before-migration`)}; } && cp -R ${shellQuote(legacy)} ${shellQuote(target)}`;
|
|
349
|
+
return `pi-warden now keeps its data in ${target}. Your earlier config and learning data are in ${legacy}. To keep them, close all Pi and oh-my-pi sessions, then run \`${command}\`. Also copy each project's ${join(piDefault.configDirName, PROJECT_CONFIG_FILE)} to ${join(dirs.configDirName, PROJECT_CONFIG_FILE)} and keep the original.`;
|
|
350
|
+
}
|
|
351
|
+
export default function wardenExtension(host) {
|
|
352
|
+
const pi = adaptHost(host);
|
|
353
|
+
// The host boundary: this module already imports the Pi peer at runtime (via load.js), so it reads
|
|
354
|
+
// the host's agent directory here once and passes it to every library path call below. The library
|
|
355
|
+
// modules stay free of runtime Pi imports.
|
|
356
|
+
const dirs = { agentDir: getAgentDir(), configDirName: CONFIG_DIR_NAME };
|
|
308
357
|
// The beta policy is held in this closure and passed to the activation gate on every delivery
|
|
309
358
|
// check; there is no module state. A question-wording change breaks the hash test and the gate
|
|
310
359
|
// fail-closes (no delivery) until the policy is re-measured.
|
|
@@ -362,12 +411,20 @@ export default function wardenExtension(pi) {
|
|
|
362
411
|
let fullOutputs = new Map();
|
|
363
412
|
let evidence = emptyEvidence();
|
|
364
413
|
let doneNudged = false;
|
|
414
|
+
// Turn rules: the read-only working-tree snapshot started at run start and awaited at run end, and the files a
|
|
415
|
+
// per-edit judgment saw.
|
|
416
|
+
let turnSnapshot;
|
|
417
|
+
let turnJudged = new Set();
|
|
365
418
|
/** True from a delivered warden follow-up that starts a turn until the next run starts: that run keeps the evidence. */
|
|
366
419
|
let wardenContinuation = false;
|
|
367
420
|
let warnedFallback = false;
|
|
368
421
|
let warnedMissingRules = false;
|
|
369
422
|
/** True while /warden init is sending a prompt and waiting for the agent to generate pi-warden.md. */
|
|
370
423
|
let initRunning = false;
|
|
424
|
+
/** The most recent /warden rules calibrate result; /warden rules tune reads its flagged rules. */
|
|
425
|
+
let lastCalibration;
|
|
426
|
+
/** The most recent /warden rules check result of this session; /warden rules tune reads its flagged rules. */
|
|
427
|
+
let lastCheck;
|
|
371
428
|
/** True while /warden audit is sending a prompt and waiting for the agent to write the report. */
|
|
372
429
|
let auditRunning = false;
|
|
373
430
|
const prose = new ProseTrend();
|
|
@@ -412,7 +469,7 @@ export default function wardenExtension(pi) {
|
|
|
412
469
|
/** The scan, the project's lesson file, and every listed item with its status; read fresh so `forget` numbers match. */
|
|
413
470
|
const evaluatedPrefs = async (ctx) => {
|
|
414
471
|
const scan = await standingPrefs(ctx);
|
|
415
|
-
const path = prefsStorePath(scan.project);
|
|
472
|
+
const path = prefsStorePath(scan.project, dirs);
|
|
416
473
|
return { scan, path, items: evaluatePrefs(scan, await readPrefsStore(path)) };
|
|
417
474
|
};
|
|
418
475
|
// Assistant turns this session, and the turn of the last user correction or stuck, repeat, or done-check steer in this
|
|
@@ -426,7 +483,7 @@ export default function wardenExtension(pi) {
|
|
|
426
483
|
const loopsFile = (ctx) => {
|
|
427
484
|
const manager = ctx.sessionManager;
|
|
428
485
|
const id = typeof manager?.getSessionId === "function" ? manager.getSessionId() : undefined;
|
|
429
|
-
return id ? loopsPath(ctx.cwd, id) : undefined;
|
|
486
|
+
return id ? loopsPath(ctx.cwd, id, dirs) : undefined;
|
|
430
487
|
};
|
|
431
488
|
/**
|
|
432
489
|
* On resume, one message lists the loops still open, so a session picked up later knows what it promised. Not a steer.
|
|
@@ -457,7 +514,7 @@ export default function wardenExtension(pi) {
|
|
|
457
514
|
let inertReported = false;
|
|
458
515
|
let unparseableReported = false;
|
|
459
516
|
const configFor = (ctx) => {
|
|
460
|
-
const { config, missing } = guardCurrentSections(completeConfig(loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() })));
|
|
517
|
+
const { config, missing } = guardCurrentSections(completeConfig(loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted(), dirs })));
|
|
461
518
|
if (missing.length && !shapeReported) {
|
|
462
519
|
shapeReported = true;
|
|
463
520
|
// A namespace read stays undefined (not a link error) when an older config module lacks the export.
|
|
@@ -501,11 +558,15 @@ export default function wardenExtension(pi) {
|
|
|
501
558
|
const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
|
|
502
559
|
/** Why no judge is available, or undefined when one is. The notice, the trace file, and the conscience all use it. */
|
|
503
560
|
const judgmentsOffReason = (config) => {
|
|
561
|
+
// A refused backend is a config error the user must see even before consent or keys matter.
|
|
562
|
+
const backend = config.typesafeBackend;
|
|
563
|
+
if (config.backendRefusal !== undefined || backend === undefined)
|
|
564
|
+
return "bad_backend";
|
|
504
565
|
if (!consentGiven(config))
|
|
505
566
|
return "no_consent";
|
|
506
567
|
if (budgetExhausted)
|
|
507
568
|
return "budget";
|
|
508
|
-
const auth = authState({ backend
|
|
569
|
+
const auth = authState({ backend });
|
|
509
570
|
if (auth.usable)
|
|
510
571
|
return undefined;
|
|
511
572
|
// No key in effect, or one whose last request came back 401 or 403.
|
|
@@ -522,8 +583,8 @@ export default function wardenExtension(pi) {
|
|
|
522
583
|
if (reason === undefined || reason === "budget" || judgmentsReported.has(reason))
|
|
523
584
|
return;
|
|
524
585
|
judgmentsReported.add(reason);
|
|
525
|
-
const auth = reason === "key_rejected" ? authState({ backend: config.typesafeBackend }) : undefined;
|
|
526
|
-
judgmentsNotify?.(judgmentsOffText(reason, config
|
|
586
|
+
const auth = reason === "key_rejected" && config.typesafeBackend !== undefined ? authState({ backend: config.typesafeBackend }) : undefined;
|
|
587
|
+
judgmentsNotify?.(judgmentsOffText(reason, config, judgmentsHeadless, auth?.kind === "environment" ? auth.keyName : undefined));
|
|
527
588
|
};
|
|
528
589
|
const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
|
|
529
590
|
/** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
|
|
@@ -538,7 +599,26 @@ export default function wardenExtension(pi) {
|
|
|
538
599
|
stats.cooldownSkips++;
|
|
539
600
|
return undefined;
|
|
540
601
|
}
|
|
541
|
-
|
|
602
|
+
if (client)
|
|
603
|
+
return client;
|
|
604
|
+
const backend = config.typesafeBackend;
|
|
605
|
+
// Reached only if a config ever sets no backend and no refusal at once; treating it as off keeps the invariant.
|
|
606
|
+
if (backend === undefined)
|
|
607
|
+
return undefined;
|
|
608
|
+
try {
|
|
609
|
+
client = watched(createTypeSafe(judgeOptions({ maxRequests: config.maxRequests, timeoutMs: config.timeoutMs, typesafeBackend: backend })));
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
// An endpoint the config accepts can still be unusable (one that names no defaultModel); a hook must never crash.
|
|
613
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
614
|
+
if (!judgmentsReported.has("bad_backend")) {
|
|
615
|
+
judgmentsReported.add("bad_backend");
|
|
616
|
+
traceFile?.judgments(judgmentsState("bad_backend"));
|
|
617
|
+
judgmentsNotify?.(`warden: Jev judgments are off (typesafeBackend refused: ${message})`);
|
|
618
|
+
}
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
return client;
|
|
542
622
|
};
|
|
543
623
|
/** Every guard reaches the backend through `evaluate`, so this one seam sees each failure and each success. */
|
|
544
624
|
const watched = (typesafe) => new Proxy(typesafe, {
|
|
@@ -569,7 +649,7 @@ export default function wardenExtension(pi) {
|
|
|
569
649
|
}
|
|
570
650
|
const seconds = Math.round(event.ms / 1000);
|
|
571
651
|
const remedy = event.kind === "auth"
|
|
572
|
-
? ` Set ${
|
|
652
|
+
? ` Set ${resolveBackend(judgeConfig?.typesafeBackend).keyEnv} or run /warden enable.`
|
|
573
653
|
: event.kind === "configuration" ? " /warden status shows the backend setup." : "";
|
|
574
654
|
noticeUi.notify(`warden: judgments paused for ${seconds}s after a ${event.kind} failure; pattern checks run alone until then.${remedy}`, "warning");
|
|
575
655
|
};
|
|
@@ -608,7 +688,7 @@ export default function wardenExtension(pi) {
|
|
|
608
688
|
configPanel.close();
|
|
609
689
|
return;
|
|
610
690
|
}
|
|
611
|
-
const opened = openConfigPanel(ui, config, { width: config.widget.panelWidth });
|
|
691
|
+
const opened = openConfigPanel(ui, config, { width: config.widget.panelWidth, dirs });
|
|
612
692
|
configPanel = opened;
|
|
613
693
|
opened.closed.catch(() => undefined).finally(() => { if (configPanel === opened)
|
|
614
694
|
configPanel = undefined; });
|
|
@@ -740,7 +820,7 @@ export default function wardenExtension(pi) {
|
|
|
740
820
|
trace.amend(entry, outcomeNote(item));
|
|
741
821
|
const idPromise = learningIds.get(item.id);
|
|
742
822
|
if (idPromise && item.outcome !== "pending") {
|
|
743
|
-
idPromise.then(id => recordOutcome(id, item.outcome)).catch(err => console.warn("pi-warden: recordOutcome failed:", err));
|
|
823
|
+
idPromise.then(id => recordOutcome(id, item.outcome, dirs)).catch(err => console.warn("pi-warden: recordOutcome failed:", err));
|
|
744
824
|
}
|
|
745
825
|
}
|
|
746
826
|
if (config.action.feedbackLog && holds.records().length)
|
|
@@ -757,7 +837,7 @@ export default function wardenExtension(pi) {
|
|
|
757
837
|
const CRITICAL_STEER_GUARDS = new Set(["stuck", "done", "runaway", "subagent"]);
|
|
758
838
|
let steersThisRun = 0;
|
|
759
839
|
/** Follow and dispute counts per (model, steer kind), kept across sessions; and the sent steers still waiting for a reply. */
|
|
760
|
-
const steerStats = new SteerStats();
|
|
840
|
+
const steerStats = new SteerStats(steerStatsPath(dirs));
|
|
761
841
|
const steerWatch = new SteerWatch();
|
|
762
842
|
/** Session generation counter for conscience invalidation. Incremented on steer, abort, reload, switch, or config change. */
|
|
763
843
|
let conscienceGeneration = 0;
|
|
@@ -914,6 +994,19 @@ export default function wardenExtension(pi) {
|
|
|
914
994
|
if (ctx.hasUI)
|
|
915
995
|
lastUi = ctx.ui;
|
|
916
996
|
noticeUi = ctx.hasUI ? ctx.ui : undefined;
|
|
997
|
+
// The one-time migration notice runs before any call creates <agentDir>/pi-warden (the
|
|
998
|
+
// initSchema below does). Interactive sessions only: a headless session neither consumes the
|
|
999
|
+
// notice nor injects a cp instruction into the agent, and the marker records what was shown.
|
|
1000
|
+
const migrationMessage = ctx.hasUI ? legacyDataNotice(dirs, defaultHostDirs(), existsSync) : undefined;
|
|
1001
|
+
if (migrationMessage) {
|
|
1002
|
+
try {
|
|
1003
|
+
writeFileSync(join(dirs.agentDir, MIGRATION_MARKER), "", { mode: 0o600 });
|
|
1004
|
+
}
|
|
1005
|
+
catch (err) {
|
|
1006
|
+
console.warn("pi-warden: could not record the migration notice marker:", err);
|
|
1007
|
+
}
|
|
1008
|
+
ctx.ui.notify(`warden: ${migrationMessage}`, "info");
|
|
1009
|
+
}
|
|
917
1010
|
cooldown.reset();
|
|
918
1011
|
if (_event.reason === "reload" || _event.reason === "new")
|
|
919
1012
|
conscienceGeneration++;
|
|
@@ -922,7 +1015,9 @@ export default function wardenExtension(pi) {
|
|
|
922
1015
|
warnedFallback = false;
|
|
923
1016
|
warnedMissingRules = false;
|
|
924
1017
|
initRunning = false;
|
|
925
|
-
|
|
1018
|
+
lastCalibration = undefined;
|
|
1019
|
+
lastCheck = undefined;
|
|
1020
|
+
await initSchema(loadConfig({ dirs }).learning.retentionDays, dirs);
|
|
926
1021
|
stats = freshStats();
|
|
927
1022
|
steerWatch.clear();
|
|
928
1023
|
widget.clear();
|
|
@@ -934,8 +1029,8 @@ export default function wardenExtension(pi) {
|
|
|
934
1029
|
holds.reset();
|
|
935
1030
|
regretCandidates = [];
|
|
936
1031
|
const sessionId = typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid);
|
|
937
|
-
holdLog = new HoldLog(holdLogPath(sessionId));
|
|
938
|
-
rulesLog = new RulesLog(ctx.cwd, sessionId, { onFailure: message => record(ctx, loadConfig(), "rules", "rules log write failed", [message]) });
|
|
1032
|
+
holdLog = new HoldLog(holdLogPath(sessionId, new Date(), dirs));
|
|
1033
|
+
rulesLog = new RulesLog(ctx.cwd, sessionId, { path: rulesLogPath(ctx.cwd, dirs), onFailure: message => record(ctx, loadConfig({ dirs }), "rules", "rules log write failed", [message]) });
|
|
939
1034
|
unsubscribeTraceFile?.();
|
|
940
1035
|
unsubscribeTraceFile = undefined;
|
|
941
1036
|
traceFile = undefined;
|
|
@@ -945,14 +1040,14 @@ export default function wardenExtension(pi) {
|
|
|
945
1040
|
ctx.ui.notify(text, "warning");
|
|
946
1041
|
else
|
|
947
1042
|
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
|
|
948
|
-
sessionHostPaths = wardenHostPaths();
|
|
1043
|
+
sessionHostPaths = wardenHostPaths(undefined, dirs);
|
|
949
1044
|
const dir = traceDir();
|
|
950
1045
|
if (dir) {
|
|
951
1046
|
const warn = (text) => { if (ctx.hasUI)
|
|
952
1047
|
ctx.ui.notify(text, "warning");
|
|
953
1048
|
else
|
|
954
1049
|
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
|
|
955
|
-
const opening = loadConfig();
|
|
1050
|
+
const opening = loadConfig({ dirs });
|
|
956
1051
|
traceFile = new TraceFile(traceFilePath(dir, sessionId), dir, { sessionId, cwd: ctx.cwd, mode: opening.mode, judgments: judgmentsState(judgmentsOffReason(opening)) }, warn);
|
|
957
1052
|
unsubscribeTraceFile = trace.subscribe(traceFile.listener);
|
|
958
1053
|
}
|
|
@@ -998,8 +1093,8 @@ export default function wardenExtension(pi) {
|
|
|
998
1093
|
lessonSignalTurn = undefined;
|
|
999
1094
|
loopsReminded = undefined;
|
|
1000
1095
|
// Load capability indexes (once per session, overwritten on every /warden index run).
|
|
1001
|
-
globalIndexFile = readIndex(indexPath("global")) ?? undefined;
|
|
1002
|
-
projectIndexFile = readIndex(indexPath("project", ctx.cwd)) ?? undefined;
|
|
1096
|
+
globalIndexFile = readIndex(indexPath("global", undefined, undefined, dirs)) ?? undefined;
|
|
1097
|
+
projectIndexFile = readIndex(indexPath("project", ctx.cwd, undefined, dirs)) ?? undefined;
|
|
1003
1098
|
indexNudged = false;
|
|
1004
1099
|
indexRunning = false;
|
|
1005
1100
|
for (const symptom of Object.keys(slopCounts))
|
|
@@ -1007,7 +1102,7 @@ export default function wardenExtension(pi) {
|
|
|
1007
1102
|
if (ctx.hasUI)
|
|
1008
1103
|
ctx.ui.setWidget(WIDGET, undefined);
|
|
1009
1104
|
// One-time notice when confirm mode falls back to steer (headless).
|
|
1010
|
-
if (!warnedFallback && loadConfig().mode === "confirm" && !ctx.hasUI) {
|
|
1105
|
+
if (!warnedFallback && loadConfig({ dirs }).mode === "confirm" && !ctx.hasUI) {
|
|
1011
1106
|
warnedFallback = true;
|
|
1012
1107
|
const msg = "warden: confirm mode requires a UI; falling back to steer mode for this session.";
|
|
1013
1108
|
if (ctx.hasUI)
|
|
@@ -1355,10 +1450,19 @@ export default function wardenExtension(pi) {
|
|
|
1355
1450
|
});
|
|
1356
1451
|
// Each low-level run collects its own evidence of changes and checks. A run that a warden follow-up started continues
|
|
1357
1452
|
// the previous run's work, so it keeps that evidence: the changes behind a done-check nudge still need a passing check.
|
|
1358
|
-
pi.on("agent_start", async () => {
|
|
1453
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
1359
1454
|
if (!wardenContinuation)
|
|
1360
1455
|
evidence = emptyEvidence();
|
|
1361
1456
|
wardenContinuation = false;
|
|
1457
|
+
// Turn rules need a baseline of the working tree. The snapshot starts here as a promise and is awaited at
|
|
1458
|
+
// agent_end, so this hook never waits for git to hash the changed and untracked files. One trace line when a run
|
|
1459
|
+
// with turn rules gets no baseline. With no turn rules a failed snapshot is silent: a project without them sees
|
|
1460
|
+
// no change.
|
|
1461
|
+
turnJudged = new Set();
|
|
1462
|
+
turnSnapshot = undefined;
|
|
1463
|
+
const turnConfig = configFor(ctx);
|
|
1464
|
+
if (turnConfig.enabled && turnConfig.rules.enabled)
|
|
1465
|
+
turnSnapshot = snapshotTree(ctx.cwd);
|
|
1362
1466
|
});
|
|
1363
1467
|
// Every turn that runs after a compression is a turn that did not carry the removed text.
|
|
1364
1468
|
pi.on("turn_end", async (_event, ctx) => {
|
|
@@ -1494,6 +1598,16 @@ export default function wardenExtension(pi) {
|
|
|
1494
1598
|
: undefined;
|
|
1495
1599
|
// Each file is one rules request, so a script that writes many files would start as many requests at once.
|
|
1496
1600
|
const shellFiles = mergeWrites(shell?.writes ?? []);
|
|
1601
|
+
// Turn rules: a file these checks judge is not judged again at the end of the run.
|
|
1602
|
+
if (config.rules.enabled) {
|
|
1603
|
+
const inputPath = typeof event.input.path === "string" ? event.input.path : "";
|
|
1604
|
+
const seen = event.toolName === "write" || event.toolName === "edit" ? [inputPath] : shellFiles.slice(0, SHELL_RULES_CHECKS).map(write => write.path);
|
|
1605
|
+
for (const path of seen) {
|
|
1606
|
+
const rel = projectPath(path, ctx.cwd);
|
|
1607
|
+
if (rel)
|
|
1608
|
+
turnJudged.add(rel);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1497
1611
|
const shellSkips = [...(shell?.skips ?? []), ...shellFiles.slice(SHELL_RULES_CHECKS).map(write => ({ path: write.path, reason: `only the first ${SHELL_RULES_CHECKS} files a command writes are judged` }))];
|
|
1498
1612
|
const rulesChecks = !config.rules.enabled ? []
|
|
1499
1613
|
: event.toolName === "write" || event.toolName === "edit" ? [{ check: rulesGuard.inspect(call, siblings, rulesOptions) }]
|
|
@@ -1605,7 +1719,7 @@ export default function wardenExtension(pi) {
|
|
|
1605
1719
|
// The promise and map entry must exist before noteOutcomes so that an outcome
|
|
1606
1720
|
// known at record time (e.g. dialog-approved) is not lost to the race.
|
|
1607
1721
|
const preview = redact(verdict.summary.command ?? verdict.summary.path ?? "");
|
|
1608
|
-
const holdPromise = recordHold(toHoldRecord({ at: item.at, tool: item.tool, level: item.level, reasons: item.reasons, scores: item.scores, held }, ctx.cwd, { task: task ? redact(task) : task, plan: verdict.plan, contextSummary: ctxSummary, agentReason: held ? steerReason(deliveryVerdict, { canApprove: judge !== undefined }) : undefined, preview })).catch(err => { console.warn("pi-warden: recordHold failed:", err); return -1; });
|
|
1722
|
+
const holdPromise = recordHold(toHoldRecord({ at: item.at, tool: item.tool, level: item.level, reasons: item.reasons, scores: item.scores, held }, ctx.cwd, { task: task ? redact(task) : task, plan: verdict.plan, contextSummary: ctxSummary, agentReason: held ? steerReason(deliveryVerdict, { canApprove: judge !== undefined }) : undefined, preview }), dirs).catch(err => { console.warn("pi-warden: recordHold failed:", err); return -1; });
|
|
1609
1723
|
learningIds.set(item.id, holdPromise);
|
|
1610
1724
|
pruneLearningIds();
|
|
1611
1725
|
noteOutcomes(config, outcome ? [item] : []);
|
|
@@ -2387,6 +2501,62 @@ export default function wardenExtension(pi) {
|
|
|
2387
2501
|
}
|
|
2388
2502
|
}
|
|
2389
2503
|
}
|
|
2504
|
+
// ── Turn rules: one end-of-run judgment against the whole diff, and the files no per-edit check saw ──
|
|
2505
|
+
// One steer for the findings, through the done-check's delivery; the verdicts land in the rules log like the rest.
|
|
2506
|
+
if (config.rules.enabled && turnSnapshot) {
|
|
2507
|
+
// The run end waits for the snapshot the run start began; a run that ended first has been waiting since.
|
|
2508
|
+
const snapshot = await turnSnapshot;
|
|
2509
|
+
turnSnapshot = undefined;
|
|
2510
|
+
const set = rulesGuard.store.load(ctx.cwd, config.rules);
|
|
2511
|
+
if (!snapshot.tree) {
|
|
2512
|
+
if (set && turnRulesFor(set).length)
|
|
2513
|
+
record(ctx, config, "rules", `warden · rules · turn rules skipped this run: ${snapshot.reason}`, ["trigger: agent_end", "no working-tree snapshot: the end-of-run pass will not run this turn rules check"]);
|
|
2514
|
+
}
|
|
2515
|
+
else {
|
|
2516
|
+
const turnRun = await evaluateTurnRun({ cwd: ctx.cwd, config: config.rules, set, judge, timeoutMs: config.timeoutMs, signal: ctx.signal, task: latestUserPrompt(ctx), startTree: snapshot.tree, alreadyJudged: turnJudged });
|
|
2517
|
+
if (turnRun.skipped) {
|
|
2518
|
+
record(ctx, config, "rules", `warden · rules · end-of-run pass skipped: ${turnRun.skipped}`, ["trigger: agent_end"]);
|
|
2519
|
+
}
|
|
2520
|
+
// One trace line names the files the per-run cap left unjudged, like the skips of one command's shell writes.
|
|
2521
|
+
if (turnRun.skips.length) {
|
|
2522
|
+
const paths = turnRun.skips.flatMap(skip => (skip.path ? [skip.path] : []));
|
|
2523
|
+
record(ctx, config, "rules", renderTemplate(config.widget.rules, { guard: "rules", tool: "unseen change", path: paths.length ? paths.join(", ") : "file change", status: "skipped" }), turnRun.skips.map(skip => `unseen change not judged${skip.path ? ` (${skip.path})` : ""}: ${skip.reason}`));
|
|
2524
|
+
}
|
|
2525
|
+
let counts = new Map();
|
|
2526
|
+
for (const verdict of turnRun.verdicts)
|
|
2527
|
+
counts = rulesGuard.count(verdict);
|
|
2528
|
+
const hasFindings = turnRun.verdicts.some(verdict => verdict.findings.length > 0);
|
|
2529
|
+
const told = hasFindings && adaptiveSend(ctx, config, "rules") ? turnSteer(turnRun.verdicts, counts) : undefined;
|
|
2530
|
+
const delivered = told ? steer(config, "rules", told, { deliverAs: "followUp", triggerTurn: true }) : false;
|
|
2531
|
+
if (delivered)
|
|
2532
|
+
watchSteer(ctx, config, ["rules"]);
|
|
2533
|
+
for (const verdict of turnRun.verdicts) {
|
|
2534
|
+
const shown = renderTemplate(config.widget.rules, { ...rulesTokens(verdict), tool: verdict.tool === "turn" ? "turn" : "unseen change", path: verdict.tool === "turn" ? "the run's diff" : verdict.path });
|
|
2535
|
+
const cuts = verdict.tool === "turn" && turnRun.cuts.length ? [`diff caps cut: ${turnRun.cuts.join("; ")}`] : [];
|
|
2536
|
+
if (verdict.source !== "skipped") {
|
|
2537
|
+
stats.ruleChecks++;
|
|
2538
|
+
if (verdict.error)
|
|
2539
|
+
noteError(ctx, verdict.error, verdict.errorCode);
|
|
2540
|
+
if (verdict.source === "typesafe") {
|
|
2541
|
+
for (const observation of rulesLog?.record(verdict, config.rules.threshold, Date.now(), config.rules.softThreshold) ?? []) {
|
|
2542
|
+
if (observation.cleared)
|
|
2543
|
+
record(ctx, config, "rules", `rules: ${observation.name} now clear on ${verdict.path}`, [`clear: a later judgment on this path scored ${observation.violation.toFixed(2)}, below the ${config.rules.threshold} threshold`]);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
if (verdict.findings.length) {
|
|
2547
|
+
stats.ruleViolations++;
|
|
2548
|
+
if (ctx.hasUI && config.notices)
|
|
2549
|
+
ctx.ui.notify(`warden · rules · ${verdict.tool === "turn" ? "this run's changes" : verdict.path}: ${verdict.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
|
|
2550
|
+
}
|
|
2551
|
+
const agentTold = told && delivered ? `agent told: ${told}` : told ? `steer recorded, not delivered (a repeat or the per-run budget): ${told}` : undefined;
|
|
2552
|
+
record(ctx, config, "rules", shown, [...cuts, ...rulesDetails(verdict, agentTold)]);
|
|
2553
|
+
}
|
|
2554
|
+
else {
|
|
2555
|
+
record(ctx, config, "rules", shown, [`${verdict.tool} ${verdict.path}: ${verdict.skippedReason}`]);
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2390
2560
|
if (!finalMessage || !judge)
|
|
2391
2561
|
return;
|
|
2392
2562
|
// Pi shows the agent as working until this hook returns, so the two independent checks share one round trip.
|
|
@@ -2440,7 +2610,7 @@ export default function wardenExtension(pi) {
|
|
|
2440
2610
|
}
|
|
2441
2611
|
});
|
|
2442
2612
|
// Shortcuts are registered once at load; a shape check keeps a typo in the config file from being registered.
|
|
2443
|
-
const shortcut = loadConfig().widget.shortcut;
|
|
2613
|
+
const shortcut = loadConfig({ dirs }).widget.shortcut;
|
|
2444
2614
|
if (/^(?:(?:ctrl|shift|alt|super)\+)+[a-z0-9]+$|^f\d{1,2}$/i.test(shortcut)) {
|
|
2445
2615
|
pi.registerShortcut(shortcut, {
|
|
2446
2616
|
description: "Toggle the pi-warden trace sidebar",
|
|
@@ -2468,7 +2638,7 @@ export default function wardenExtension(pi) {
|
|
|
2468
2638
|
const manager = ctx.sessionManager;
|
|
2469
2639
|
const session = typeof manager.getSessionId === "function" ? manager.getSessionId() : "unknown";
|
|
2470
2640
|
const scan = await standingPrefs(ctx);
|
|
2471
|
-
const path = prefsStorePath(scan.project);
|
|
2641
|
+
const path = prefsStorePath(scan.project, dirs);
|
|
2472
2642
|
const reply = await changePrefsStore(path, store => {
|
|
2473
2643
|
const result = recordLesson({ lesson, session, now: Date.now(), signal, scan, store });
|
|
2474
2644
|
return { value: result.reply, ...(result.store ? { store: result.store } : {}) };
|
|
@@ -2508,14 +2678,20 @@ export default function wardenExtension(pi) {
|
|
|
2508
2678
|
return toolReply(recallText(evidenceSnapshot(ctx)));
|
|
2509
2679
|
},
|
|
2510
2680
|
});
|
|
2511
|
-
const actions = ["status", "rules", "report", "enable", "disable", "mode", "config", "test", "trace", "init", "audit", "index", "prefs", "loops", "recommend", "unmute"];
|
|
2681
|
+
const actions = ["status", "rules", "report", "enable", "disable", "mode", "config", "test", "trace", "init", "audit", "index", "prefs", "loops", "recommend", "unmute", "bench"];
|
|
2512
2682
|
pi.registerCommand("warden", {
|
|
2513
2683
|
description: "pi-warden status, active rules, config (set/get/editor), TypeSafe consent, mode, trace panel, recommend, standing preferences, open loops, and a synthetic guard test",
|
|
2514
2684
|
getArgumentCompletions(prefix) {
|
|
2515
2685
|
const matches = actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
|
|
2516
|
-
// `rules check`
|
|
2686
|
+
// `rules check`, `rules audit`, `rules calibrate`, and `rules tune` are two-word actions: `rules` on its own stays the local list.
|
|
2517
2687
|
if ("rules check".startsWith(prefix))
|
|
2518
2688
|
matches.push({ value: "rules check", label: "rules check", description: "which of the active rules the guard cannot judge well" });
|
|
2689
|
+
if ("rules audit".startsWith(prefix))
|
|
2690
|
+
matches.push({ value: "rules audit", label: "rules audit", description: "judge existing files against the project rules as if they had just been written" });
|
|
2691
|
+
if ("rules calibrate".startsWith(prefix))
|
|
2692
|
+
matches.push({ value: "rules calibrate", label: "rules calibrate", description: "replay recent commits through the active rules and flag the ones that fire on everything or cannot decide" });
|
|
2693
|
+
if ("rules tune".startsWith(prefix))
|
|
2694
|
+
matches.push({ value: "rules tune", label: "rules tune", description: "ask the agent to rewrite the rules the latest calibrate or rules check flagged" });
|
|
2519
2695
|
return matches.length ? matches : null;
|
|
2520
2696
|
},
|
|
2521
2697
|
async handler(args, ctx) {
|
|
@@ -2534,45 +2710,199 @@ export default function wardenExtension(pi) {
|
|
|
2534
2710
|
try {
|
|
2535
2711
|
const config = configFor(ctx);
|
|
2536
2712
|
if (action === "status") {
|
|
2537
|
-
|
|
2713
|
+
// A refused backend has no key to describe; the refusal itself is the status line.
|
|
2714
|
+
const backend = config.typesafeBackend;
|
|
2715
|
+
const auth = config.backendRefusal !== undefined || backend === undefined ? undefined : describeAuth(authState({ backend }));
|
|
2538
2716
|
const source = consentSource(config);
|
|
2539
2717
|
const usage = client?.getUsage();
|
|
2540
2718
|
const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop", config.slop.enabled && config.slop.prose.enabled && `prose (${config.slop.prose.audience})`, config.security.enabled && "security", config.rules.enabled && "rules", config.context.enabled && "context", config.runaway.enabled && "runaway", config.subagent.enabled && "subagent triage", config.notify.enabled && "desktop notifications"].filter(Boolean).join(", ");
|
|
2541
|
-
const ls = await holdStats(ctx.cwd);
|
|
2719
|
+
const ls = await holdStats(ctx.cwd, dirs);
|
|
2542
2720
|
const lifetimeLine = ls.labeled === 0
|
|
2543
2721
|
? `Lifetime here: ${ls.held} hold${ls.held === 1 ? "" : "s"}, not yet measurable (${ls.allowed} allowed).`
|
|
2544
2722
|
: `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).`;
|
|
2545
2723
|
report([
|
|
2546
|
-
`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.
|
|
2724
|
+
`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.backendRefusal !== undefined || backend === undefined || backend === "typesafe" ? "" : ` (${describeBackend(backend)})`}; ${config.backendRefusal !== undefined ? `judgments are off: typesafeBackend refused: ${config.backendRefusal}` : auth?.text ?? ""}`,
|
|
2547
2725
|
`Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan (${stats.offPlanTraceOnly} trace-only), ${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`}.`,
|
|
2548
2726
|
formatSteers(stats),
|
|
2549
2727
|
`${formatMuted(steerStats.muted(), config.steers)}${stats.steersMuted ? ` This session: ${stats.steersMuted} steer${stats.steersMuted === 1 ? "" : "s"} kept in the trace only.` : ""}`,
|
|
2550
2728
|
`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, trace-only: ${config.action.intentTraceOnly}); 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}.`,
|
|
2551
2729
|
formatLedger(ledger.snapshot()),
|
|
2552
|
-
...(config.learning.patternAnalysis ? [`Learning: ${(await generateRecommendations(ctx.cwd)).length} recommendations, steer effectiveness ${Math.round((await analyzeSteerEffectivenessReport(ctx.cwd)).overall * 100)}% (use /warden recommend for details)`] : []),
|
|
2730
|
+
...(config.learning.patternAnalysis ? [`Learning: ${(await generateRecommendations(ctx.cwd, dirs)).length} recommendations, steer effectiveness ${Math.round((await analyzeSteerEffectivenessReport(ctx.cwd, dirs)).overall * 100)}% (use /warden recommend for details)`] : []),
|
|
2553
2731
|
`${formatHolds(holds.snapshot(), config.action.feedbackLog ? holdLog?.path : undefined)}${holdLog?.lastFailure ? ` Log write failed: ${holdLog.lastFailure}.` : ""}`,
|
|
2554
2732
|
lifetimeLine,
|
|
2555
2733
|
`Rules: ${config.rules.enabled ? `${rulesGuard.describe(ctx.cwd, config.rules)}${Object.keys(config.rules.sensitivePaths).length ? `; ${Object.keys(config.rules.sensitivePaths).length} sensitive path${Object.keys(config.rules.sensitivePaths).length === 1 ? "" : "s"}` : ""}` : "off"}.`,
|
|
2556
2734
|
...(config.action.armingRules.length > 0 ? [`Arming: ${arming.statusLine() || "no rules armed"}.`] : []),
|
|
2557
2735
|
`Desktop notifications: ${config.notify.enabled ? `on (${config.notify.command.length ? `command ${config.notify.command[0]}` : (await (notifier ??= detectNotifier())) ?? "no notifier found on this machine"}; cooldown ${config.notify.cooldownMs} ms)` : "off (\"notify\": { \"enabled\": true } in the config turns them on)"}.`,
|
|
2558
|
-
`Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
|
|
2736
|
+
`Config: ${userConfigPath(dirs)}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd, dirs)}` : ""}.`,
|
|
2559
2737
|
widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
|
|
2560
2738
|
`Trace: ${trace.entries().length} events (/warden trace${shortcut ? `, ${shortcut}` : ""}, or click the status line in fullscreen mode; each toggles the sidebar). Widget templates in config.widget: action tokens ${TOKEN_NAMES.action.map(name => `{${name}}`).join(" ")}.`,
|
|
2561
2739
|
].join(" "));
|
|
2562
2740
|
return;
|
|
2563
2741
|
}
|
|
2742
|
+
if (action === "rules" && argument === "audit") {
|
|
2743
|
+
const parsed = parseRulesAuditArgs(tokens.slice(2));
|
|
2744
|
+
if (parsed.error) {
|
|
2745
|
+
report(parsed.error, "warning");
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
const off = judgmentsOffReason(config);
|
|
2749
|
+
const judge = judgeFor(config);
|
|
2750
|
+
const result = await runRulesAudit({
|
|
2751
|
+
cwd: ctx.cwd, paths: parsed.paths, max: parsed.max, yes: parsed.yes, config: config.rules,
|
|
2752
|
+
set: rulesGuard.store.load(ctx.cwd, config.rules), judge, timeoutMs: config.timeoutMs, signal: ctx.signal,
|
|
2753
|
+
destination: backendHost(config.typesafeBackend),
|
|
2754
|
+
confirm: ctx.hasUI ? (title, body) => ctx.ui.confirm(title, body, ctx.signal ? { signal: ctx.signal } : {}) : undefined,
|
|
2755
|
+
});
|
|
2756
|
+
switch (result.status) {
|
|
2757
|
+
// No key, no consent, or a spent budget: say why and send nothing.
|
|
2758
|
+
case "no-judge":
|
|
2759
|
+
report(`Rules audit sent nothing: Jev judgments are off${off === "budget" ? " (the request budget is used up)" : ""}.${off && off !== "budget" ? ` ${judgmentsOffText(off, config, !ctx.hasUI)}` : ""}`);
|
|
2760
|
+
return;
|
|
2761
|
+
case "no-files":
|
|
2762
|
+
report(`Rules audit sent nothing: ${result.reason}.`);
|
|
2763
|
+
return;
|
|
2764
|
+
case "needs-yes":
|
|
2765
|
+
report(`Rules audit sent nothing: a headless run needs --yes before ${result.files} file samples go to ${backendHost(config.typesafeBackend)} (${result.leftOut} more files left out at the --max ${parsed.max} cap).`, "warning");
|
|
2766
|
+
return;
|
|
2767
|
+
case "cancelled":
|
|
2768
|
+
report("Rules audit cancelled. Nothing was sent.");
|
|
2769
|
+
return;
|
|
2770
|
+
case "done":
|
|
2771
|
+
report(formatRulesAudit(result.outcome));
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
return;
|
|
2775
|
+
}
|
|
2776
|
+
if (action === "bench") {
|
|
2777
|
+
const parsed = parseBenchArgs(tokens.slice(1));
|
|
2778
|
+
if (parsed.error) {
|
|
2779
|
+
report(parsed.error, "warning");
|
|
2780
|
+
return;
|
|
2781
|
+
}
|
|
2782
|
+
const off = judgmentsOffReason(config);
|
|
2783
|
+
const judge = judgeFor(config);
|
|
2784
|
+
const result = await runBench({ runs: parsed.runs, cwd: ctx.cwd, config: config.rules, set: rulesGuard.store.load(ctx.cwd, config.rules), judge, getUsage: judge ? () => judge.getUsage() : undefined, timeoutMs: config.timeoutMs, signal: ctx.signal });
|
|
2785
|
+
switch (result.status) {
|
|
2786
|
+
// No key, no consent, or a spent budget: say why and send nothing.
|
|
2787
|
+
case "no-judge":
|
|
2788
|
+
report(`Bench sent nothing: Jev judgments are off${off === "budget" ? " (the request budget is used up)" : ""}.${off && off !== "budget" ? ` ${judgmentsOffText(off, config, !ctx.hasUI)}` : ""}`);
|
|
2789
|
+
return;
|
|
2790
|
+
case "skipped":
|
|
2791
|
+
report(`Bench sent nothing: ${result.reason}.`);
|
|
2792
|
+
return;
|
|
2793
|
+
case "done":
|
|
2794
|
+
report(formatBench(result));
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2564
2799
|
if (action === "rules") {
|
|
2565
2800
|
if (argument === "check") {
|
|
2566
2801
|
const off = judgmentsOffReason(config);
|
|
2567
2802
|
const result = await checkRules({ set: rulesGuard.store.load(ctx.cwd, config.rules), judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
|
|
2568
2803
|
if (result.source === "skipped") {
|
|
2569
2804
|
// No key, no consent, or a spent budget: say why and send nothing.
|
|
2570
|
-
report(`Rules check sent nothing: Jev judgments are off${off === "budget" ? " (the request budget is used up)" : ""}.${off && off !== "budget" ? ` ${judgmentsOffText(off, config
|
|
2805
|
+
report(`Rules check sent nothing: Jev judgments are off${off === "budget" ? " (the request budget is used up)" : ""}.${off && off !== "budget" ? ` ${judgmentsOffText(off, config, !ctx.hasUI)}` : ""} /warden rules shows the rule set locally.`);
|
|
2571
2806
|
return;
|
|
2572
2807
|
}
|
|
2808
|
+
if (result.source === "typesafe")
|
|
2809
|
+
lastCheck = result;
|
|
2573
2810
|
report(formatRulesCheck(result));
|
|
2574
2811
|
return;
|
|
2575
2812
|
}
|
|
2813
|
+
if (argument === "calibrate") {
|
|
2814
|
+
const usage = "Usage: /warden rules calibrate [--commits N] [--max N] [--yes].";
|
|
2815
|
+
const commitsMatch = /(?:^|\s)--commits\s+(\S+)/.exec(tail);
|
|
2816
|
+
const maxMatch = /(?:^|\s)--max\s+(\S+)/.exec(tail);
|
|
2817
|
+
const yes = /(?:^|\s)--yes(?:\s|$)/.test(tail);
|
|
2818
|
+
const commits = commitsMatch ? Number(commitsMatch[1]) : CALIBRATE_DEFAULT_COMMITS;
|
|
2819
|
+
const max = maxMatch ? Number(maxMatch[1]) : CALIBRATE_DEFAULT_MAX;
|
|
2820
|
+
if ((commitsMatch && (!Number.isInteger(commits) || commits < 1)) || (maxMatch && (!Number.isInteger(max) || max < 1))) {
|
|
2821
|
+
report(usage, "warning");
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2824
|
+
const set = rulesGuard.store.load(ctx.cwd, config.rules);
|
|
2825
|
+
if (!set) {
|
|
2826
|
+
report("No rules file detected. Run /warden init to create project-specific rules.");
|
|
2827
|
+
return;
|
|
2828
|
+
}
|
|
2829
|
+
if (set.proseOnly) {
|
|
2830
|
+
report(`No rules to calibrate: ${set.sources.join(", ")} has no rule-shaped sections, so the guard judges nothing there.`);
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
if (set.aggregate !== undefined && !set.rules.length) {
|
|
2834
|
+
report(`No separate rules to calibrate: ${set.sources.join(", ")} has no rule headings, so the guard judges it as one document.`);
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
const off = judgmentsOffReason(config);
|
|
2838
|
+
const judge = judgeFor(config);
|
|
2839
|
+
if (!judge) {
|
|
2840
|
+
report(`Rules calibrate sent nothing: Jev judgments are off${off === "budget" ? " (the request budget is used up)" : ""}.${off && off !== "budget" ? ` ${judgmentsOffText(off, config, !ctx.hasUI)}` : ""} /warden rules shows the rule set locally.`);
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
let history;
|
|
2844
|
+
try {
|
|
2845
|
+
history = collectHistory(ctx.cwd, commits);
|
|
2846
|
+
}
|
|
2847
|
+
catch (error) {
|
|
2848
|
+
report(`Rules calibrate could not read the git history: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
const plan = planCalibration(history, { set, config: config.rules, cwd: ctx.cwd, maxRequests: max });
|
|
2852
|
+
if (!plan.requests) {
|
|
2853
|
+
report(`Rules calibrate sent nothing: no changed file in the last ${commits} non-merge commit${commits === 1 ? "" : "s"} is judgeable${plan.skipped.length ? ` (${plan.skipped.length} change${plan.skipped.length === 1 ? "" : "s"} skipped)` : ""}.`);
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
// Nothing leaves the machine before this gate: the dialog shows the count and the redacted diffs, and a
|
|
2857
|
+
// headless run needs the explicit --yes.
|
|
2858
|
+
const gate = calibrateGate({ hasUI: ctx.hasUI, yes });
|
|
2859
|
+
if (gate.kind === "refuse") {
|
|
2860
|
+
report(gate.reason, "warning");
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
if (gate.kind === "confirm" && !await ctx.ui.confirm(`warden: send ${plan.requests} calibration request${plan.requests === 1 ? "" : "s"}?`, calibrationNotice(plan))) {
|
|
2864
|
+
report("Cancelled. Nothing was sent.");
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
const result = await calibrate(history, { set, config: config.rules, cwd: ctx.cwd, judge, timeoutMs: config.timeoutMs, maxRequests: max, signal: ctx.signal });
|
|
2868
|
+
(rulesLog ??= new RulesLog(ctx.cwd, String(process.pid))).appendRecords(result.records);
|
|
2869
|
+
lastCalibration = result;
|
|
2870
|
+
report(formatCalibration(result));
|
|
2871
|
+
return;
|
|
2872
|
+
}
|
|
2873
|
+
if (argument === "tune") {
|
|
2874
|
+
const request = tuneRequest({ calibration: lastCalibration, check: lastCheck, rules: rulesGuard.store.load(ctx.cwd, config.rules)?.rules ?? [] });
|
|
2875
|
+
if ("reason" in request) {
|
|
2876
|
+
report(request.reason);
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
initRunning = true;
|
|
2880
|
+
let sent = true;
|
|
2881
|
+
if (ctx.hasUI)
|
|
2882
|
+
ctx.ui.notify("pi-warden: Sending the rules rewrite prompt...", "info");
|
|
2883
|
+
try {
|
|
2884
|
+
// sendUserMessage throws when the agent is not idle. Brief wait so a
|
|
2885
|
+
// just-closing confirm dialog does not cause a race.
|
|
2886
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
2887
|
+
if (ctx.isIdle())
|
|
2888
|
+
break;
|
|
2889
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
2890
|
+
}
|
|
2891
|
+
pi.sendUserMessage(request.prompt);
|
|
2892
|
+
await ctx.waitForIdle();
|
|
2893
|
+
}
|
|
2894
|
+
catch (err) {
|
|
2895
|
+
sent = false;
|
|
2896
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2897
|
+
report(`pi-warden rules tune failed: ${detail}.`, "error");
|
|
2898
|
+
}
|
|
2899
|
+
finally {
|
|
2900
|
+
initRunning = false;
|
|
2901
|
+
}
|
|
2902
|
+
if (sent)
|
|
2903
|
+
report("Rewrite prompt sent. The agent edits pi-warden.md with its own tools; review the changes.");
|
|
2904
|
+
return;
|
|
2905
|
+
}
|
|
2576
2906
|
report(rulesGuard.details(ctx.cwd, config.rules));
|
|
2577
2907
|
return;
|
|
2578
2908
|
}
|
|
@@ -2583,9 +2913,13 @@ export default function wardenExtension(pi) {
|
|
|
2583
2913
|
report("Usage: /warden report [--days N], where N is at least 1. Default: 30 days.", "warning");
|
|
2584
2914
|
return;
|
|
2585
2915
|
}
|
|
2586
|
-
const
|
|
2916
|
+
const all = await readRulesLog(rulesLogPath(ctx.cwd, dirs));
|
|
2917
|
+
// Calibration replays carry `source: "calibrate"`; the live verdict report counts only the live records and
|
|
2918
|
+
// says how many replays it set apart.
|
|
2919
|
+
const replays = all.filter(record => record.source === "calibrate").length;
|
|
2587
2920
|
const set = rulesGuard.store.load(ctx.cwd, config.rules);
|
|
2588
|
-
|
|
2921
|
+
const text = formatRulesReport(buildRulesReport(all, { days, currentRules: set?.rules.map(rule => ({ id: rule.id, name: rule.name })) ?? [], source: "live" }));
|
|
2922
|
+
report(replays ? `${text}\n${replays} calibration replay record${replays === 1 ? "" : "s"} in this log, counted apart (source: calibrate).` : text);
|
|
2589
2923
|
return;
|
|
2590
2924
|
}
|
|
2591
2925
|
if (action === "trace") {
|
|
@@ -2641,8 +2975,8 @@ export default function wardenExtension(pi) {
|
|
|
2641
2975
|
}
|
|
2642
2976
|
if (action === "recommend") {
|
|
2643
2977
|
// Learning-driven recommendations based on hold history
|
|
2644
|
-
const recommendations = await generateRecommendations(ctx.cwd);
|
|
2645
|
-
const steerReport = await analyzeSteerEffectivenessReport(ctx.cwd);
|
|
2978
|
+
const recommendations = await generateRecommendations(ctx.cwd, dirs);
|
|
2979
|
+
const steerReport = await analyzeSteerEffectivenessReport(ctx.cwd, dirs);
|
|
2646
2980
|
const lines = [];
|
|
2647
2981
|
lines.push("pi-warden learning recommendations:");
|
|
2648
2982
|
if (recommendations.length === 0 && steerReport.suggestions.length === 0) {
|
|
@@ -2683,27 +3017,36 @@ export default function wardenExtension(pi) {
|
|
|
2683
3017
|
return;
|
|
2684
3018
|
}
|
|
2685
3019
|
if (action === "enable") {
|
|
3020
|
+
if (config.backendRefusal !== undefined) {
|
|
3021
|
+
report(`Cannot enable judgments: typesafeBackend refused: ${config.backendRefusal} Fix the value in ${userConfigPath(dirs)}, then run /warden enable again.`, "warning");
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
const backend = config.typesafeBackend;
|
|
2686
3025
|
if (!ctx.hasUI) {
|
|
2687
|
-
report(`Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and ${
|
|
3026
|
+
report(`Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and ${resolveBackend(backend).keyEnv} explicitly.`, "warning");
|
|
2688
3027
|
return;
|
|
2689
3028
|
}
|
|
2690
|
-
|
|
3029
|
+
// For a non-TypeSafe backend the dialog names who answers: the label, host, and model sent.
|
|
3030
|
+
const consentText = backend === undefined || backend === "typesafe"
|
|
3031
|
+
? disclosure
|
|
3032
|
+
: `Judgments go to ${describeBackend(backend)}. ${disclosureFor(backend, disclosure)}`;
|
|
3033
|
+
if (!await ctx.ui.confirm("Enable TypeSafe judgments for pi-warden?", consentText))
|
|
2691
3034
|
return;
|
|
2692
3035
|
// One flow: consent, then a key if none is configured yet. TypeSafe prompts, verifies, and stores the key for every
|
|
2693
3036
|
// pi-typesafe consumer; any other backend has no login, so a missing key is reported with the variable to set.
|
|
2694
|
-
const key = await ensureApiKey(ctx, {
|
|
3037
|
+
const key = await ensureApiKey(ctx, backend === undefined ? {} : { backend });
|
|
2695
3038
|
if (!key) {
|
|
2696
3039
|
report("No key entered; pi-warden stays on pattern checks only. Run /warden enable again when you have a key from console.typesafe.ai.", "warning");
|
|
2697
3040
|
return;
|
|
2698
3041
|
}
|
|
2699
|
-
const path = setUserSetting("typesafe", true);
|
|
3042
|
+
const path = setUserSetting("typesafe", true, dirs);
|
|
2700
3043
|
client = undefined;
|
|
2701
3044
|
budgetExhausted = false;
|
|
2702
|
-
report(`TypeSafe judgments enabled and saved to ${path}${key.login ? `; key verified (${key.login.models} model${key.login.models === 1 ? "" : "s"}) and stored at ${key.login.path}` : ` using the ${key.source === "stored" ? "stored key" : `key from ${
|
|
3045
|
+
report(`TypeSafe judgments enabled and saved to ${path}${key.login ? `; key verified (${key.login.models} model${key.login.models === 1 ? "" : "s"}) and stored at ${key.login.path}` : ` using the ${key.source === "stored" ? "stored key" : `key from ${resolveBackend(config.typesafeBackend).keyEnv}`}`}. This stays on in new sessions until /warden disable.`);
|
|
2703
3046
|
return;
|
|
2704
3047
|
}
|
|
2705
3048
|
if (action === "disable") {
|
|
2706
|
-
const path = setUserSetting("typesafe", false);
|
|
3049
|
+
const path = setUserSetting("typesafe", false, dirs);
|
|
2707
3050
|
report(`TypeSafe judgments disabled in ${path}. Offline pattern checks stay active; set enabled to false there to turn pi-warden off entirely.`);
|
|
2708
3051
|
return;
|
|
2709
3052
|
}
|
|
@@ -2712,7 +3055,7 @@ export default function wardenExtension(pi) {
|
|
|
2712
3055
|
report(`Mode is ${activeMode(config, ctx.hasUI)}${process.env.PI_WARDEN_MODE ? " (from PI_WARDEN_MODE)" : ""}. Use /warden mode steer | confirm | advise. steer holds risky calls and tells the agent why; confirm asks you with a dialog; advise only reports.`);
|
|
2713
3056
|
return;
|
|
2714
3057
|
}
|
|
2715
|
-
const path = setUserSetting("mode", argument);
|
|
3058
|
+
const path = setUserSetting("mode", argument, dirs);
|
|
2716
3059
|
report(`Mode set to ${argument} in ${path}.`);
|
|
2717
3060
|
return;
|
|
2718
3061
|
}
|
|
@@ -2732,23 +3075,23 @@ export default function wardenExtension(pi) {
|
|
|
2732
3075
|
const keyPath = rest.slice(0, spaceIndex).trim();
|
|
2733
3076
|
const rawValue = rest.slice(spaceIndex + 1).trim();
|
|
2734
3077
|
const value = parseConfigValue(rawValue);
|
|
2735
|
-
const current = readUserConfig();
|
|
3078
|
+
const current = readUserConfig(dirs);
|
|
2736
3079
|
const updated = setNestedValue(current, keyPath, value);
|
|
2737
|
-
const savedPath = writeUserConfig(updated);
|
|
3080
|
+
const savedPath = writeUserConfig(updated, dirs);
|
|
2738
3081
|
client = undefined;
|
|
2739
3082
|
report(`Saved ${keyPath} = ${JSON.stringify(value)}.`);
|
|
2740
3083
|
return;
|
|
2741
3084
|
}
|
|
2742
3085
|
if (tail.startsWith("get ")) {
|
|
2743
3086
|
const keyPath = tail.slice(4).trim();
|
|
2744
|
-
const current = readUserConfig();
|
|
3087
|
+
const current = readUserConfig(dirs);
|
|
2745
3088
|
const value = getNestedValue(current, keyPath);
|
|
2746
3089
|
const defaultValue = getNestedValue(defaultConfig(), keyPath);
|
|
2747
3090
|
report(value === undefined ? `${keyPath} not set (default: ${JSON.stringify(defaultValue)})` : `${keyPath} = ${JSON.stringify(value)}`);
|
|
2748
3091
|
return;
|
|
2749
3092
|
}
|
|
2750
3093
|
if (!ctx.hasUI || !lastUi) {
|
|
2751
|
-
report(`Edit ${userConfigPath()} directly. Use /warden config set <key> <value> for quick changes.`);
|
|
3094
|
+
report(`Edit ${userConfigPath(dirs)} directly. Use /warden config set <key> <value> for quick changes.`);
|
|
2752
3095
|
return;
|
|
2753
3096
|
}
|
|
2754
3097
|
toggleConfigPanel(lastUi, config);
|
|
@@ -2841,7 +3184,10 @@ export default function wardenExtension(pi) {
|
|
|
2841
3184
|
}
|
|
2842
3185
|
if (action === "test") {
|
|
2843
3186
|
const judge = judgeFor(config);
|
|
2844
|
-
|
|
3187
|
+
// For a non-TypeSafe backend the confirmation names who answers: the label, host, and model sent.
|
|
3188
|
+
const backend = config.typesafeBackend;
|
|
3189
|
+
const destination = backend === undefined || backend === "typesafe" ? backendHost(backend) : describeBackend(backend);
|
|
3190
|
+
if (judge && ctx.hasUI && !await ctx.ui.confirm("Send one synthetic pi-warden test request?", `A synthetic action ("rm -rf /tmp/pi-warden-demo" for the task "Prepare the demo environment") goes to ${destination} and may incur charges. ${disclosureFor(config.typesafeBackend, disclosure)}`))
|
|
2845
3191
|
return;
|
|
2846
3192
|
const verdict = await evaluateAction({ tool: "bash", input: { command: "rm -rf /tmp/pi-warden-demo" }, cwd: ctx.cwd, task: "Prepare the demo environment" }, { config: { ...config.action, enabled: true, tools: ["bash"] }, judge, rules: config.rules });
|
|
2847
3193
|
const deliveryVerdict = ctx.hasUI ? verdict : agentDeliveryVerdict(verdict);
|
|
@@ -2907,11 +3253,11 @@ export default function wardenExtension(pi) {
|
|
|
2907
3253
|
catch {
|
|
2908
3254
|
toolInfosForIndex = [];
|
|
2909
3255
|
}
|
|
2910
|
-
const globalPath = indexPath("global");
|
|
2911
|
-
const projectPath = indexPath("project", ctx.cwd);
|
|
2912
|
-
ensureIndexDir("global");
|
|
2913
|
-
ensureIndexDir("project");
|
|
2914
|
-
const prompt = buildIndexPrompt(ctx.cwd, skillsForIndex, toolInfosForIndex, { global: globalPath, project: projectPath });
|
|
3256
|
+
const globalPath = indexPath("global", undefined, undefined, dirs);
|
|
3257
|
+
const projectPath = indexPath("project", ctx.cwd, undefined, dirs);
|
|
3258
|
+
ensureIndexDir("global", undefined, dirs);
|
|
3259
|
+
ensureIndexDir("project", undefined, dirs);
|
|
3260
|
+
const prompt = buildIndexPrompt(ctx.cwd, skillsForIndex, toolInfosForIndex, { global: globalPath, project: projectPath }, dirs);
|
|
2915
3261
|
// Snapshot files before the model runs so we can detect whether it wrote anything.
|
|
2916
3262
|
const snap = (p) => { try {
|
|
2917
3263
|
const s = statSync(p);
|