pi-warden 0.38.3 → 0.40.2
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 +52 -0
- package/README.md +8 -4
- package/dist/action-guard.d.ts +4 -4
- package/dist/backend.d.ts +4 -7
- package/dist/backend.js +11 -19
- package/dist/config.d.ts +2 -0
- package/dist/config.js +5 -3
- package/dist/conscience.d.ts +49 -3
- package/dist/conscience.js +157 -99
- package/dist/extension.d.ts +3 -2
- package/dist/extension.js +284 -29
- package/dist/extension.js.bak +307 -30
- package/dist/guard.d.ts +6 -4
- package/dist/guard.js +12 -7
- package/dist/hashing.d.ts +4 -0
- package/dist/hashing.js +22 -0
- package/dist/index-cmd.d.ts +87 -0
- package/dist/index-cmd.js +330 -0
- package/dist/index.d.ts +1 -1
- package/dist/load.d.ts +12 -3
- package/dist/load.js +20 -11
- package/dist/rules-file.d.ts +23 -8
- package/dist/rules-file.js +61 -31
- package/dist/rules.d.ts +13 -1
- package/dist/rules.js +12 -8
- package/dist/shape.js +2 -2
- package/package.json +2 -2
package/dist/extension.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { rm } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, resolve as pathResolve } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import * as tuiModule from "@earendil-works/pi-tui";
|
|
5
6
|
const MouseRegion = tuiModule.MouseRegion;
|
|
6
7
|
import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
|
|
7
8
|
import { ensureApiKey } from "pi-typesafe/ui";
|
|
8
|
-
import { backendHost, disclosureFor, judgeOptions,
|
|
9
|
+
import { backendHost, disclosureFor, judgeOptions, keyEnvFor, resolveBackend } from "./backend.js";
|
|
9
10
|
import { ActionGuard } from "./action-guard.js";
|
|
10
11
|
import { ArmingTracker, unparseableArmingRules } from "./arming.js";
|
|
11
12
|
import * as configModule from "./config.js";
|
|
@@ -19,7 +20,7 @@ import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_S
|
|
|
19
20
|
import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice, CompressionLearner } from "./output.js";
|
|
20
21
|
import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
|
|
21
22
|
import { redact } from "./redact.js";
|
|
22
|
-
import { formatRules, pathNoteSteer, RulesGuard, rulesSteer } from "./rules.js";
|
|
23
|
+
import { formatRules, pathNoteSteer, RulesGuard, rulesSteer, RULES_FILE, FALLBACK_FILES } from "./rules.js";
|
|
23
24
|
import { checkPiWardenMissing } from "./rules-file.js";
|
|
24
25
|
import { writeStarterRules, buildInitPrompt } from "./init.js";
|
|
25
26
|
import { buildAuditPrompt, findProjects, snapshotReport, reportOutcome } from "./audit.js";
|
|
@@ -27,7 +28,9 @@ import { detectNotifier, sendNotification } from "./notify.js";
|
|
|
27
28
|
import { formatRunaway, RunawayMonitor, runawayNudge } from "./runaway.js";
|
|
28
29
|
import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckDiff, stuckNudge } from "./stuck.js";
|
|
29
30
|
import { assess } from "./conscience.js";
|
|
30
|
-
import { loadSkillBody, buildLoadMessage, policyMatches,
|
|
31
|
+
import { loadSkillBody, buildLoadMessage, policyMatches, CONSCIENCE_BETA_POLICY, recordFileIdentity, clearFileIdentityCache } from "./load.js";
|
|
32
|
+
import { buildIndexPrompt, readIndex, writeIndex, validateIndex, indexStats, indexPath, ensureIndexDir } from "./index-cmd.js";
|
|
33
|
+
import { fileContentHash } from "./hashing.js";
|
|
31
34
|
/** Classify a conscience assessment error into a safe category (spec §6: never exception bodies). */
|
|
32
35
|
function classifyConscienceError(err) {
|
|
33
36
|
if (err && typeof err === "object" && "code" in err) {
|
|
@@ -57,7 +60,7 @@ import { buildCompactSnapshot, compactAppendix } from "./compact.js";
|
|
|
57
60
|
import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
|
|
58
61
|
import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
|
|
59
62
|
import { actionTokens, DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, SENTENCE_TEMPLATES, statusWidget, TOKEN_NAMES } from "./widget.js";
|
|
60
|
-
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
|
|
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.";
|
|
61
64
|
const WIDGET = PACKAGE_NAME;
|
|
62
65
|
const CONFIRM_TEXT_LIMIT = 500;
|
|
63
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 });
|
|
@@ -240,7 +243,18 @@ export function guardCurrentSections(result) {
|
|
|
240
243
|
return { config, missing };
|
|
241
244
|
}
|
|
242
245
|
/** Native Pi registration; importing the root library does not load this module. */
|
|
246
|
+
let indexRunning = false;
|
|
247
|
+
let indexWritePaths = [];
|
|
248
|
+
/** Test-only: set the index-running state and write paths for the action guard bypass. */
|
|
249
|
+
export function _testSetIndexRunning(running, paths = []) {
|
|
250
|
+
indexRunning = running;
|
|
251
|
+
indexWritePaths = paths;
|
|
252
|
+
}
|
|
243
253
|
export default function wardenExtension(pi) {
|
|
254
|
+
// The beta policy is held in this closure and passed to the activation gate on every delivery
|
|
255
|
+
// check; there is no module state. A question-wording change breaks the hash test and the gate
|
|
256
|
+
// fail-closes (no delivery) until the policy is re-measured.
|
|
257
|
+
const consciencePolicy = CONSCIENCE_BETA_POLICY;
|
|
244
258
|
let client;
|
|
245
259
|
let budgetExhausted = false;
|
|
246
260
|
let stats = freshStats();
|
|
@@ -359,7 +373,7 @@ export default function wardenExtension(pi) {
|
|
|
359
373
|
const judgeFor = (config) => {
|
|
360
374
|
if (!consentGiven(config) || budgetExhausted)
|
|
361
375
|
return undefined;
|
|
362
|
-
if (!
|
|
376
|
+
if (!authState({ backend: config.typesafeBackend }).usable)
|
|
363
377
|
return undefined;
|
|
364
378
|
return client ??= createTypeSafe(judgeOptions(config));
|
|
365
379
|
};
|
|
@@ -506,6 +520,10 @@ export default function wardenExtension(pi) {
|
|
|
506
520
|
let queuedRevision = 0;
|
|
507
521
|
let beforeAgentStartFired = false;
|
|
508
522
|
let needsReassessment = false;
|
|
523
|
+
// Index state: loaded once per session, nudged once when stale.
|
|
524
|
+
let globalIndexFile;
|
|
525
|
+
let projectIndexFile;
|
|
526
|
+
let indexNudged = false;
|
|
509
527
|
/** The final messages of the current run, for restatement measurement. */
|
|
510
528
|
const finals = new RestatementWindow();
|
|
511
529
|
/** Returns true when the message was delivered; false means it was recorded in the trace only. */
|
|
@@ -646,6 +664,11 @@ export default function wardenExtension(pi) {
|
|
|
646
664
|
searchTool = undefined;
|
|
647
665
|
notifier = undefined;
|
|
648
666
|
lastNotifiedAt = 0;
|
|
667
|
+
// Load capability indexes (once per session, overwritten on every /warden index run).
|
|
668
|
+
globalIndexFile = readIndex(indexPath("global")) ?? undefined;
|
|
669
|
+
projectIndexFile = readIndex(indexPath("project", ctx.cwd)) ?? undefined;
|
|
670
|
+
indexNudged = false;
|
|
671
|
+
indexRunning = false;
|
|
649
672
|
for (const symptom of Object.keys(slopCounts))
|
|
650
673
|
slopCounts[symptom] = 0;
|
|
651
674
|
if (ctx.hasUI)
|
|
@@ -740,6 +763,31 @@ export default function wardenExtension(pi) {
|
|
|
740
763
|
// Active and supplied skills
|
|
741
764
|
const activeSkills = skills.map(s => s.name);
|
|
742
765
|
const suppliedSkills = [];
|
|
766
|
+
// ── Index nudge: once per session when index is missing or stale ──
|
|
767
|
+
if (!indexNudged && (globalIndexFile === undefined || projectIndexFile === undefined)) {
|
|
768
|
+
indexNudged = true;
|
|
769
|
+
if (ctx.hasUI)
|
|
770
|
+
ctx.ui.notify("pi-warden: run /warden index so the conscience recommends skills and tools from real descriptions.", "info");
|
|
771
|
+
else
|
|
772
|
+
record(ctx, config, "conscience", "nudge: index missing", ["trigger: before_agent_start", "nudgeReason: missing_index"]);
|
|
773
|
+
}
|
|
774
|
+
else if (!indexNudged && skills.length > 0) {
|
|
775
|
+
// Check if any cached skill has no matching index entry
|
|
776
|
+
const missing = skills.filter(s => {
|
|
777
|
+
if (s.disableModelInvocation)
|
|
778
|
+
return false;
|
|
779
|
+
const hash = s.filePath ? fileContentHash(s.filePath) : "missing";
|
|
780
|
+
const search = (index) => index?.entries.some(e => e.name === s.name && e.sourceHash === hash) ?? false;
|
|
781
|
+
return !search(projectIndexFile) && !search(globalIndexFile);
|
|
782
|
+
});
|
|
783
|
+
if (missing.length > 0) {
|
|
784
|
+
indexNudged = true;
|
|
785
|
+
if (ctx.hasUI)
|
|
786
|
+
ctx.ui.notify("pi-warden: run /warden index so the conscience recommends skills and tools from real descriptions.", "info");
|
|
787
|
+
else
|
|
788
|
+
record(ctx, config, "conscience", `nudge: ${missing.length} skill(s) unindexed`, ["trigger: before_agent_start", "nudgeReason: stale_index", ...missing.map(s => `unindexed: ${s.name}`)]);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
743
791
|
// Deadline: smaller of conscience.timeoutMs and shared timeoutMs
|
|
744
792
|
const effectiveTimeout = Math.min(config.conscience.timeoutMs, config.timeoutMs);
|
|
745
793
|
const start = Date.now();
|
|
@@ -752,8 +800,11 @@ export default function wardenExtension(pi) {
|
|
|
752
800
|
hostSignal.addEventListener("abort", () => ac.abort(), { once: true });
|
|
753
801
|
}
|
|
754
802
|
try {
|
|
755
|
-
|
|
756
|
-
|
|
803
|
+
// The evaluate result carries the model that answered; the activation gate compares the policy's model against
|
|
804
|
+
// it. Empty until the first evaluate call, and an empty model never matches a policy, so the gate fails closed.
|
|
805
|
+
let judgeModel = "";
|
|
806
|
+
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 });
|
|
757
808
|
// Check generation after await
|
|
758
809
|
if (conscienceGeneration !== myGeneration) {
|
|
759
810
|
record(ctx, config, "conscience", "stale: generation changed during assessment", ["trigger: before_agent_start", `generation: ${myGeneration} → ${conscienceGeneration}`, `skipReason: stale`]);
|
|
@@ -769,7 +820,8 @@ export default function wardenExtension(pi) {
|
|
|
769
820
|
console.warn(`pi-warden: conscience ${result.errorCategory}`);
|
|
770
821
|
}
|
|
771
822
|
}
|
|
772
|
-
|
|
823
|
+
const skipTag = result.skipReason ? `, ${result.skipReason}` : "";
|
|
824
|
+
record(ctx, config, "conscience", `assessed ${skills.length} skills + ${toolInfos.length} tools → ${selectedName} (P(useful)=${result.usefulness.toFixed(2)}, P(advance)=${result.pAdvance.toFixed(2)}, ${result.elapsedMs}ms${skipTag})`, ["trigger: before_agent_start", `eligible: ${skills.length} skills, ${toolInfos.length} tools`, `requests: ${result.requestCount}`, `questionHash: ${result.questionHash}`, ...skipInfo, ...truncInfo]);
|
|
773
825
|
// Track state for turn-end triggers and reminders
|
|
774
826
|
assessmentsThisPrompt++;
|
|
775
827
|
lastAssessmentHash = result.questionHash;
|
|
@@ -786,10 +838,10 @@ export default function wardenExtension(pi) {
|
|
|
786
838
|
}
|
|
787
839
|
// Delivery: only when selected, thresholds pass, and activation gate clears
|
|
788
840
|
if (result.selected && budgetAvailable(config)) {
|
|
789
|
-
// Activation gate (spec §7): policy
|
|
790
|
-
|
|
791
|
-
if (
|
|
792
|
-
record(ctx, config, "conscience", "no policy for current hash/model", ["trigger: before_agent_start", "skipReason: no_policy"]);
|
|
841
|
+
// Activation gate (spec §7): no policy means no delivery; a policy that does not match the current hash or
|
|
842
|
+
// the model that actually answered also means no delivery. Fail closed either way.
|
|
843
|
+
if (!policyMatches(consciencePolicy, result.questionHash, judgeModel)) {
|
|
844
|
+
record(ctx, config, "conscience", "no policy for current hash/model", ["trigger: before_agent_start", "skipReason: no_policy", `hash: ${result.questionHash}`, `model: ${judgeModel || "none"}`]);
|
|
793
845
|
}
|
|
794
846
|
else {
|
|
795
847
|
spendBudgetUnit();
|
|
@@ -884,10 +936,10 @@ export default function wardenExtension(pi) {
|
|
|
884
936
|
const activeSkills = cachedSkills.map(s => s.name);
|
|
885
937
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
|
|
886
938
|
try {
|
|
887
|
-
const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now() });
|
|
939
|
+
const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
888
940
|
if (conscienceGeneration !== myGeneration)
|
|
889
941
|
return;
|
|
890
|
-
record(ctx, config, "conscience", `queued prompt assessed → ${result.selected ? `${result.selected.kind}:${result.selected.id}` : "none"}`, ["trigger: message_start", `origin: queued`, `skipReason: ${result.skipReason ?? "none"}`]);
|
|
942
|
+
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"}`]);
|
|
891
943
|
}
|
|
892
944
|
catch (err) {
|
|
893
945
|
const cat = classifyConscienceError(err);
|
|
@@ -954,7 +1006,7 @@ export default function wardenExtension(pi) {
|
|
|
954
1006
|
const activeSkills = cachedSkills.map(s => s.name);
|
|
955
1007
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
|
|
956
1008
|
try {
|
|
957
|
-
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now() });
|
|
1009
|
+
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
958
1010
|
if (conscienceGeneration !== myGeneration)
|
|
959
1011
|
return;
|
|
960
1012
|
assessmentsThisPrompt++;
|
|
@@ -963,7 +1015,7 @@ export default function wardenExtension(pi) {
|
|
|
963
1015
|
selectedCapability = { kind: result.selected.kind, id: result.selected.id };
|
|
964
1016
|
lastAssessmentHash = result.questionHash;
|
|
965
1017
|
}
|
|
966
|
-
record(ctx, config, "conscience", `turn_end reassessment → ${result.selected ? `${result.selected.kind}:${result.selected.id}` : "none"} (${result.elapsedMs}ms)`, ["trigger: turn_end", `assessments: ${assessmentsThisPrompt}/${config.conscience.maxAssessments}`, `skipReason: ${result.skipReason ?? "none"}`]);
|
|
1018
|
+
record(ctx, config, "conscience", `turn_end reassessment → ${result.selected ? `${result.selected.kind}:${result.selected.id}` : "none"} (${result.elapsedMs}ms, ${result.skipReason ?? "none"})`, ["trigger: turn_end", `assessments: ${assessmentsThisPrompt}/${config.conscience.maxAssessments}`, `skipReason: ${result.skipReason ?? "none"}`]);
|
|
967
1019
|
}
|
|
968
1020
|
catch (err) {
|
|
969
1021
|
const cat = err instanceof Error ? (/(timeout|timed out)/i.test(err.message) ? "timeout" : /(auth|key|credential|401|403)/i.test(err.message) ? "auth" : /(network|fetch|connect)/i.test(err.message) ? "network" : "other") : "other";
|
|
@@ -1008,14 +1060,17 @@ export default function wardenExtension(pi) {
|
|
|
1008
1060
|
if (!config.action.enabled || !config.action.tools.includes(event.toolName))
|
|
1009
1061
|
return;
|
|
1010
1062
|
stats.inspected++;
|
|
1011
|
-
// First-run warning:
|
|
1063
|
+
// First-run warning: no project rules file resolved, one time per session.
|
|
1012
1064
|
if (!warnedMissingRules && config.rules.enabled) {
|
|
1013
|
-
const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd);
|
|
1065
|
+
const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd, config.rules);
|
|
1014
1066
|
if (missing && ctx.hasUI) {
|
|
1015
1067
|
warnedMissingRules = true;
|
|
1068
|
+
// The tiers still in play, in resolution order: naming README/CLAUDE/AGENTS to someone who
|
|
1069
|
+
// turned the fallback off would send them to a document pi-warden will not read.
|
|
1070
|
+
const tiers = [RULES_FILE, "rules.files", ...(config.rules.fallback ? FALLBACK_FILES : [])].join(", ");
|
|
1016
1071
|
const msg = fallbackSource
|
|
1017
|
-
? `No pi-warden.md
|
|
1018
|
-
: `No rules file detected (
|
|
1072
|
+
? `No pi-warden.md or rules.files entry here. Using ${fallbackSource} as active fallback rules. Run /warden init to create project-specific rules.`
|
|
1073
|
+
: `No rules file detected (${tiers}). Run /warden init to create project-specific rules.`;
|
|
1019
1074
|
ctx.ui.notify(msg, "warning");
|
|
1020
1075
|
}
|
|
1021
1076
|
}
|
|
@@ -1035,6 +1090,17 @@ export default function wardenExtension(pi) {
|
|
|
1035
1090
|
const judge = judgeFor(config);
|
|
1036
1091
|
const siblings = siblingToolCalls(ctx);
|
|
1037
1092
|
const call = { id: event.toolCallId, tool: event.toolName, input: event.input };
|
|
1093
|
+
// Index command writes its own output files — exempt them from the action guard.
|
|
1094
|
+
if (indexRunning && event.toolName === "write") {
|
|
1095
|
+
const rawPath = typeof event.input.path === "string" ? event.input.path : "";
|
|
1096
|
+
if (rawPath) {
|
|
1097
|
+
const resolved = rawPath.startsWith("~") ? pathResolve(rawPath.replace(/^~/, homedir())) : pathResolve(rawPath);
|
|
1098
|
+
if (indexWritePaths.includes(resolved)) {
|
|
1099
|
+
record(ctx, config, "action", "index write, expected", ["trigger: before_tool_use", `path: ${redact(rawPath)}`]);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1038
1104
|
// The rules request carries the written content and the rule text, so it goes out beside the action request, not inside it.
|
|
1039
1105
|
const rulesCheck = config.rules.enabled && (event.toolName === "write" || event.toolName === "edit")
|
|
1040
1106
|
? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
@@ -1622,6 +1688,10 @@ export default function wardenExtension(pi) {
|
|
|
1622
1688
|
ctx.ui.notify("pi-warden is running an audit. Please wait...", "warning");
|
|
1623
1689
|
return { action: "handled" };
|
|
1624
1690
|
}
|
|
1691
|
+
if (indexRunning) {
|
|
1692
|
+
ctx.ui.notify("pi-warden is building the capability index. Please wait...", "warning");
|
|
1693
|
+
return { action: "handled" };
|
|
1694
|
+
}
|
|
1625
1695
|
// Operator input invalidates in-flight conscience assessments
|
|
1626
1696
|
conscienceGeneration++;
|
|
1627
1697
|
});
|
|
@@ -1679,7 +1749,7 @@ export default function wardenExtension(pi) {
|
|
|
1679
1749
|
const activeSkills = cachedSkills.map(s => s.name);
|
|
1680
1750
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
|
|
1681
1751
|
try {
|
|
1682
|
-
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now() });
|
|
1752
|
+
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
1683
1753
|
if (conscienceGeneration !== myGeneration)
|
|
1684
1754
|
return;
|
|
1685
1755
|
assessmentsThisPrompt++;
|
|
@@ -1760,7 +1830,7 @@ export default function wardenExtension(pi) {
|
|
|
1760
1830
|
togglePanel(ctx.ui, configFor(ctx)); },
|
|
1761
1831
|
});
|
|
1762
1832
|
}
|
|
1763
|
-
const actions = ["status", "enable", "disable", "mode", "config", "test", "trace", "init", "audit"];
|
|
1833
|
+
const actions = ["status", "enable", "disable", "mode", "config", "test", "trace", "init", "audit", "index"];
|
|
1764
1834
|
pi.registerCommand("warden", {
|
|
1765
1835
|
description: "pi-warden status, config (set/get/editor), TypeSafe consent, mode, trace panel, recommend, and a synthetic guard test",
|
|
1766
1836
|
getArgumentCompletions(prefix) {
|
|
@@ -1783,7 +1853,7 @@ export default function wardenExtension(pi) {
|
|
|
1783
1853
|
try {
|
|
1784
1854
|
const config = configFor(ctx);
|
|
1785
1855
|
if (action === "status") {
|
|
1786
|
-
const auth = describeAuth();
|
|
1856
|
+
const auth = describeAuth(authState({ backend: config.typesafeBackend }));
|
|
1787
1857
|
const source = consentSource(config);
|
|
1788
1858
|
const usage = client?.getUsage();
|
|
1789
1859
|
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(", ");
|
|
@@ -1863,13 +1933,14 @@ export default function wardenExtension(pi) {
|
|
|
1863
1933
|
}
|
|
1864
1934
|
if (action === "enable") {
|
|
1865
1935
|
if (!ctx.hasUI) {
|
|
1866
|
-
report(
|
|
1936
|
+
report(`Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and ${keyEnvFor(config.typesafeBackend)} explicitly.`, "warning");
|
|
1867
1937
|
return;
|
|
1868
1938
|
}
|
|
1869
1939
|
if (!await ctx.ui.confirm("Enable TypeSafe judgments for pi-warden?", disclosure))
|
|
1870
1940
|
return;
|
|
1871
|
-
// One flow: consent, then a key if none is configured yet
|
|
1872
|
-
|
|
1941
|
+
// One flow: consent, then a key if none is configured yet. TypeSafe prompts, verifies, and stores the key for every
|
|
1942
|
+
// pi-typesafe consumer; any other backend has no login, so a missing key is reported with the variable to set.
|
|
1943
|
+
const key = await ensureApiKey(ctx, { backend: config.typesafeBackend });
|
|
1873
1944
|
if (!key) {
|
|
1874
1945
|
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");
|
|
1875
1946
|
return;
|
|
@@ -1877,7 +1948,7 @@ export default function wardenExtension(pi) {
|
|
|
1877
1948
|
const path = setUserSetting("typesafe", true);
|
|
1878
1949
|
client = undefined;
|
|
1879
1950
|
budgetExhausted = false;
|
|
1880
|
-
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" :
|
|
1951
|
+
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 ${keyEnvFor(config.typesafeBackend)}`}`}. This stays on in new sessions until /warden disable.`);
|
|
1881
1952
|
return;
|
|
1882
1953
|
}
|
|
1883
1954
|
if (action === "disable") {
|
|
@@ -2045,6 +2116,190 @@ export default function wardenExtension(pi) {
|
|
|
2045
2116
|
}
|
|
2046
2117
|
return;
|
|
2047
2118
|
}
|
|
2119
|
+
if (action === "index") {
|
|
2120
|
+
if (!ctx.hasUI) {
|
|
2121
|
+
report("Index needs an interactive session to run.", "warning");
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
// The snapshot from Pi is authoritative; cachedSkills is only filled by before_agent_start.
|
|
2125
|
+
let skillsForIndex = [];
|
|
2126
|
+
try {
|
|
2127
|
+
const snapshot = ctx.getSystemPromptOptions().skills;
|
|
2128
|
+
if (snapshot && snapshot.length > 0) {
|
|
2129
|
+
skillsForIndex = snapshot.filter(s => !s.disableModelInvocation).map(s => ({ name: s.name, description: s.description, filePath: s.filePath ?? "" }));
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
catch (err) {
|
|
2133
|
+
record(ctx, config, "conscience", `snapshot unavailable: ${err instanceof Error ? err.message : String(err)}`, ["trigger: warden_index"]);
|
|
2134
|
+
}
|
|
2135
|
+
if (skillsForIndex.length === 0 && cachedSkills.length > 0) {
|
|
2136
|
+
skillsForIndex = cachedSkills.filter(s => !s.disableModelInvocation).map(s => ({ name: s.name, description: s.description, filePath: s.filePath ?? "" }));
|
|
2137
|
+
}
|
|
2138
|
+
if (skillsForIndex.length === 0) {
|
|
2139
|
+
report("No skills available. Run a prompt first so Pi discovers installed skills, then retry /warden index.", "warning");
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
let toolCount = 0;
|
|
2143
|
+
try {
|
|
2144
|
+
toolCount = pi.getAllTools().length;
|
|
2145
|
+
}
|
|
2146
|
+
catch {
|
|
2147
|
+
toolCount = 0;
|
|
2148
|
+
}
|
|
2149
|
+
const roughBytes = skillsForIndex.length * 200 + toolCount * 100;
|
|
2150
|
+
if (!await ctx.ui.confirm("Build capability index?", `This reads every installed skill file (${skillsForIndex.length} skills) and tool description (${toolCount} tools, ~${roughBytes} bytes total) and runs the session model to produce index entries. It may take a minute and use real tokens.`))
|
|
2151
|
+
return;
|
|
2152
|
+
let toolInfosForIndex = [];
|
|
2153
|
+
try {
|
|
2154
|
+
toolInfosForIndex = pi.getAllTools().map((t) => ({ name: t.name, description: t.description }));
|
|
2155
|
+
}
|
|
2156
|
+
catch {
|
|
2157
|
+
toolInfosForIndex = [];
|
|
2158
|
+
}
|
|
2159
|
+
const globalPath = indexPath("global");
|
|
2160
|
+
const projectPath = indexPath("project", ctx.cwd);
|
|
2161
|
+
ensureIndexDir("global");
|
|
2162
|
+
ensureIndexDir("project");
|
|
2163
|
+
const prompt = buildIndexPrompt(ctx.cwd, skillsForIndex, toolInfosForIndex, { global: globalPath, project: projectPath });
|
|
2164
|
+
// Snapshot files before the model runs so we can detect whether it wrote anything.
|
|
2165
|
+
const snap = (p) => { try {
|
|
2166
|
+
const s = statSync(p);
|
|
2167
|
+
return { exists: true, mtimeMs: s.mtimeMs };
|
|
2168
|
+
}
|
|
2169
|
+
catch {
|
|
2170
|
+
return { exists: false, mtimeMs: 0 };
|
|
2171
|
+
} };
|
|
2172
|
+
const preGlobal = snap(globalPath);
|
|
2173
|
+
const preProject = snap(projectPath);
|
|
2174
|
+
indexRunning = true;
|
|
2175
|
+
indexWritePaths = [pathResolve(globalPath), pathResolve(projectPath)];
|
|
2176
|
+
if (ctx.hasUI)
|
|
2177
|
+
ctx.ui.notify("pi-warden: Building capability index...", "info");
|
|
2178
|
+
try {
|
|
2179
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
2180
|
+
if (ctx.isIdle())
|
|
2181
|
+
break;
|
|
2182
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
2183
|
+
}
|
|
2184
|
+
pi.sendUserMessage(prompt);
|
|
2185
|
+
await ctx.waitForIdle();
|
|
2186
|
+
}
|
|
2187
|
+
catch (err) {
|
|
2188
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2189
|
+
report(`pi-warden index failed: ${detail}.`, "error");
|
|
2190
|
+
indexRunning = false;
|
|
2191
|
+
indexWritePaths = [];
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
finally {
|
|
2195
|
+
indexRunning = false;
|
|
2196
|
+
indexWritePaths = [];
|
|
2197
|
+
}
|
|
2198
|
+
const postGlobal = snap(globalPath);
|
|
2199
|
+
const postProject = snap(projectPath);
|
|
2200
|
+
const globalChanged = !preGlobal.exists || postGlobal.mtimeMs > preGlobal.mtimeMs;
|
|
2201
|
+
const projectChanged = !preProject.exists || postProject.mtimeMs > preProject.mtimeMs;
|
|
2202
|
+
if (!globalChanged && !projectChanged) {
|
|
2203
|
+
report("The model did not write the index; nothing was changed.", "warning");
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
const rawGlobal = readIndex(globalPath);
|
|
2207
|
+
const rawProject = readIndex(projectPath);
|
|
2208
|
+
// Accept both the full format and entries-only; warden always writes the final metadata.
|
|
2209
|
+
let globalResult = rawGlobal ? validateIndex(rawGlobal) : { file: undefined, rejections: [] };
|
|
2210
|
+
let projectResult = rawProject ? validateIndex(rawProject) : { file: undefined, rejections: [] };
|
|
2211
|
+
// Entries-only fallback: read raw JSON and extract entries array
|
|
2212
|
+
if (!globalResult.file && rawGlobal) {
|
|
2213
|
+
try {
|
|
2214
|
+
const parsed = JSON.parse(readFileSync(globalPath, "utf8"));
|
|
2215
|
+
if (parsed && typeof parsed === "object" && Array.isArray(parsed.entries)) {
|
|
2216
|
+
globalResult = validateIndex({ formatVersion: 1, builtAt: "", model: "", entries: parsed.entries });
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
catch (err) {
|
|
2220
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2221
|
+
if (!msg.includes("ENOENT"))
|
|
2222
|
+
record(ctx, config, "conscience", `global entries-only parse: ${msg}`, ["trigger: warden_index"]);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
if (!projectResult.file && rawProject) {
|
|
2226
|
+
try {
|
|
2227
|
+
const parsed = JSON.parse(readFileSync(projectPath, "utf8"));
|
|
2228
|
+
if (parsed && typeof parsed === "object" && Array.isArray(parsed.entries)) {
|
|
2229
|
+
projectResult = validateIndex({ formatVersion: 1, builtAt: "", model: "", entries: parsed.entries });
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
catch (err) {
|
|
2233
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2234
|
+
// ENOENT is normal (no project index yet); only trace actual parse errors
|
|
2235
|
+
if (!msg.includes("ENOENT"))
|
|
2236
|
+
record(ctx, config, "conscience", `project entries-only parse: ${msg}`, ["trigger: warden_index"]);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
// Collect rejections from both files
|
|
2240
|
+
const allRejections = [];
|
|
2241
|
+
if (rawGlobal && !globalResult.file)
|
|
2242
|
+
allRejections.push(...globalResult.rejections.map(r => `global index: entry ${r}`));
|
|
2243
|
+
if (rawProject && !projectResult.file)
|
|
2244
|
+
allRejections.push(...projectResult.rejections.map(r => `project index: entry ${r}`));
|
|
2245
|
+
if (allRejections.length > 0) {
|
|
2246
|
+
report(`Index rejected:\n${allRejections.map(r => ` ${r}`).join("\n")}\nOld files kept.`, "warning");
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
const validatedGlobal = globalResult.file;
|
|
2250
|
+
const validatedProject = projectResult.file;
|
|
2251
|
+
// Recompute sourceHash from actual skill files; the model's hash is untrusted.
|
|
2252
|
+
const recomputeHashes = (file) => {
|
|
2253
|
+
for (const entry of file.entries) {
|
|
2254
|
+
if (entry.kind === "skill") {
|
|
2255
|
+
const skill = skillsForIndex.find(s => s.name === entry.name);
|
|
2256
|
+
if (skill?.filePath)
|
|
2257
|
+
entry.sourceHash = fileContentHash(skill.filePath);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
};
|
|
2261
|
+
if (validatedGlobal)
|
|
2262
|
+
recomputeHashes(validatedGlobal);
|
|
2263
|
+
if (validatedProject)
|
|
2264
|
+
recomputeHashes(validatedProject);
|
|
2265
|
+
const realModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unknown";
|
|
2266
|
+
const builtAt = new Date().toISOString();
|
|
2267
|
+
const buildFile = (file) => ({ formatVersion: 1, builtAt, model: realModel, entries: file.entries });
|
|
2268
|
+
if (validatedGlobal)
|
|
2269
|
+
writeIndex(globalPath, buildFile(validatedGlobal));
|
|
2270
|
+
if (validatedProject)
|
|
2271
|
+
writeIndex(projectPath, buildFile(validatedProject));
|
|
2272
|
+
globalIndexFile = validatedGlobal;
|
|
2273
|
+
projectIndexFile = validatedProject;
|
|
2274
|
+
// Report
|
|
2275
|
+
const globalStats = validatedGlobal ? indexStats(validatedGlobal) : { globalEntries: 0, projectEntries: 0, thinSources: [], truncatedCount: 0, sourceQualityReport: [] };
|
|
2276
|
+
const projectStats = validatedProject ? indexStats(validatedProject) : { globalEntries: 0, projectEntries: 0, thinSources: [], truncatedCount: 0, sourceQualityReport: [] };
|
|
2277
|
+
const totalEntries = globalStats.globalEntries + projectStats.projectEntries;
|
|
2278
|
+
const allThin = [...globalStats.thinSources, ...projectStats.thinSources];
|
|
2279
|
+
const totalTruncated = globalStats.truncatedCount + projectStats.truncatedCount;
|
|
2280
|
+
const lines = [
|
|
2281
|
+
`Index built: ${totalEntries} entries (${globalStats.globalEntries} global, ${projectStats.projectEntries} project).`,
|
|
2282
|
+
allThin.length ? `Thin sources: ${allThin.join(", ")}.` : undefined,
|
|
2283
|
+
totalTruncated ? `${totalTruncated} truncated at bullet boundary.` : undefined,
|
|
2284
|
+
].filter(Boolean);
|
|
2285
|
+
const qualityReport = [...globalStats.sourceQualityReport, ...projectStats.sourceQualityReport]
|
|
2286
|
+
.sort((a, b) => (a.sourceQuality === "thin" ? 0 : 1) - (b.sourceQuality === "thin" ? 0 : 1));
|
|
2287
|
+
if (qualityReport.length === 0) {
|
|
2288
|
+
lines.push("", "Every description states when to use it.");
|
|
2289
|
+
}
|
|
2290
|
+
else {
|
|
2291
|
+
lines.push("", "These descriptions would recommend better with a rewrite:");
|
|
2292
|
+
const shown = qualityReport.slice(0, 25);
|
|
2293
|
+
for (const q of shown) {
|
|
2294
|
+
lines.push(` ${q.name} (${q.kind}): ${q.improve}`);
|
|
2295
|
+
}
|
|
2296
|
+
if (qualityReport.length > 25) {
|
|
2297
|
+
lines.push(` ... and ${qualityReport.length - 25} more.`);
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
report(lines.join("\n"));
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2048
2303
|
report(`Unknown action "${action}". Use: ${actions.join(", ")}.`, "warning");
|
|
2049
2304
|
}
|
|
2050
2305
|
catch (error) {
|