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/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, keyAvailable, resolveBackend } from "./backend.js";
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, getActivePolicy, recordFileIdentity, clearFileIdentityCache } from "./load.js";
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 and description only; full skill instructions never go to Jev). 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.";
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 (!keyAvailable(config.typesafeBackend, process.env, () => authState().usable))
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
- const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
756
- const result = await assess(redactedPrompt, recentContext, skills, toolInfos, activeSkills, suppliedSkills, { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now() });
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
- 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)`, ["trigger: before_agent_start", `eligible: ${skills.length} skills, ${toolInfos.length} tools`, `requests: ${result.requestCount}`, `questionHash: ${result.questionHash}`, ...skipInfo, ...truncInfo]);
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 must match hash and model when a policy exists
790
- const policyActive = getActivePolicy() !== null;
791
- if (policyActive && !policyMatches(result.questionHash, "jev-latest")) {
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: pi-warden.md missing, one time per session.
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 detected. Using ${fallbackSource} as active fallback rules. Run /warden init to create project-specific rules.`
1018
- : `No rules file detected (pi-warden.md, README.md, CLAUDE.md, or AGENTS.md). Run /warden init to create project-specific rules.`;
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("Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and TYPESAFE_API_KEY explicitly.", "warning");
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 (hidden input, verified, stored for every pi-typesafe consumer).
1872
- const key = await ensureApiKey(ctx);
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" : "key from TYPESAFE_API_KEY"}`}. This stays on in new sessions until /warden disable.`);
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) {