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.
@@ -1,6 +1,7 @@
1
1
  import { choice, noul, score } from "pi-typesafe";
2
2
  import { createHash } from "node:crypto";
3
3
  import { redact } from "./redact.js";
4
+ import { fileContentHash, toolSourceHash } from "./hashing.js";
4
5
  /** Score levels for the usefulness question. Levels are ordered from least to most useful. */
5
6
  export const SCORE_LEVELS = [
6
7
  "No useful contribution to the current request, or conflicts with the supplied constraints.",
@@ -54,7 +55,7 @@ export function buildBatchQuestions(batch, task, recentContext, activeSkills, su
54
55
  const idMap = new Map();
55
56
  // Shared disposition question
56
57
  const dispositionKey = "conscience_disposition";
57
- questions[dispositionKey] = choice("Disposition of the current request.", {
58
+ questions[dispositionKey] = choice(`Disposition of the current request. A message that reports status, shares context, or narrates what the user is doing elsewhere, without asking the agent to do anything, is no_gap; select nothing on it.`, {
58
59
  advance: "A useful next step can be taken now.",
59
60
  awaiting_user: "The assistant has already asked for information and must wait.",
60
61
  no_gap: "The request is satisfied with no useful missing step.",
@@ -63,7 +64,7 @@ export function buildBatchQuestions(batch, task, recentContext, activeSkills, su
63
64
  // Per-candidate Score questions
64
65
  for (const { opaqueId, candidate } of batch) {
65
66
  idMap.set(opaqueId, candidate);
66
- questions[opaqueId] = score(`Judge candidates.${opaqueId} against task and context.`, [...SCORE_LEVELS]);
67
+ questions[opaqueId] = score(`Judge candidates.${opaqueId} against what the user is asking the agent to do now in task, not against the subjects the prompt mentions in passing; a candidate that matches words in the prompt but does not serve the actual request belongs at the lowest level. When the request needs information the repository cannot supply, a research-role candidate serves the request; when the request is about the repository's own code or behaviour, an evidence-role candidate does.`, [...SCORE_LEVELS]);
67
68
  }
68
69
  return { questions, dispositionKey, idMap };
69
70
  }
@@ -73,11 +74,10 @@ export function buildBatchQuestions(batch, task, recentContext, activeSkills, su
73
74
  export function buildState(task, recentContext, activeSkills, suppliedSkills, batch) {
74
75
  const candidates = {};
75
76
  for (const { opaqueId, candidate } of batch) {
76
- candidates[opaqueId] = {
77
- kind: candidate.kind,
78
- name: candidate.id,
79
- description: candidate.description,
80
- };
77
+ const entry = candidate.indexEntry;
78
+ candidates[opaqueId] = entry
79
+ ? { kind: candidate.kind, name: candidate.id, lead: entry.lead, useWhen: entry.useWhen, examples: entry.examples }
80
+ : { kind: candidate.kind, name: candidate.id, description: candidate.description, unindexed: true };
81
81
  }
82
82
  return {
83
83
  task: sanitizeDescription(redact(task)),
@@ -90,8 +90,27 @@ export function buildState(task, recentContext, activeSkills, suppliedSkills, ba
90
90
  /**
91
91
  * Compute a deterministic hash of the assessment questions for deduplication.
92
92
  * Recursively sorts all object keys for canonical form.
93
+ *
94
+ * Opaque candidate ids (c1, c2, …) are positional, not semantic: which batch a prompt happens to
95
+ * produce must not change the hash the activation gate compares. A conscience question set
96
+ * (recognised by the `conscience_disposition` key) is therefore hashed as the disposition question
97
+ * plus one canonical candidate question, keyed `c1` — every candidate question carries the same
98
+ * wording by design (per-candidate state lives in the request state, not the question text), so any
99
+ * one of them is the canonical representative.
100
+ *
101
+ * Why the hash was constant before this change: each assessment issues several requests (one per
102
+ * batch), each with its own question keys, and `assess` kept only the hash of whichever batch wrote
103
+ * `state_.hash` last. Every recorded replay row showed `fb2d35042f667b3c` only because those final
104
+ * batches happened to serialize identically — an accident of batch order, not a guarantee.
93
105
  */
94
106
  export function questionHash(questions) {
107
+ if (questions.conscience_disposition) {
108
+ const firstCandidate = Object.entries(questions).find(([key]) => /^c\d+$/.test(key));
109
+ questions = {
110
+ conscience_disposition: questions.conscience_disposition,
111
+ ...(firstCandidate ? { [firstCandidate[0]]: firstCandidate[1] } : {}),
112
+ };
113
+ }
95
114
  const sorted = JSON.parse(JSON.stringify(questions, (_key, value) => {
96
115
  if (value && typeof value === "object" && !Array.isArray(value)) {
97
116
  return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
@@ -113,11 +132,19 @@ function isExcluded(id, exclude) {
113
132
  }
114
133
  return false;
115
134
  }
116
- /** Filter candidates by eligibility rules, capped at MAX_ELIGIBLE per category. */
117
- export function eligibleCandidates(skills, tools, config, activeSkills, suppliedSkills) {
135
+ /** Filter candidates by eligibility rules, capped at MAX_ELIGIBLE per category. Attaches index entries when available. */
136
+ export function eligibleCandidates(skills, tools, config, activeSkills, suppliedSkills, globalIndex, projectIndex) {
118
137
  const candidates = [];
119
138
  let skillOverflow = false;
120
139
  let toolOverflow = false;
140
+ const findEntry = (name, sourceHash) => {
141
+ const search = (index) => {
142
+ if (!index)
143
+ return undefined;
144
+ return index.entries.find(e => e.name === name && e.sourceHash === sourceHash);
145
+ };
146
+ return search(projectIndex) ?? search(globalIndex);
147
+ };
121
148
  if (config.skills.mode !== "off") {
122
149
  let count = 0;
123
150
  for (const skill of skills) {
@@ -133,11 +160,14 @@ export function eligibleCandidates(skills, tools, config, activeSkills, supplied
133
160
  skillOverflow = true;
134
161
  break;
135
162
  }
163
+ const sourceHash = skill.filePath ? fileContentHash(skill.filePath) : "missing";
164
+ const entry = findEntry(skill.name, sourceHash);
136
165
  candidates.push({
137
166
  kind: "skill",
138
167
  id: skill.name,
139
168
  description: sanitizeDescription(skill.description),
140
169
  skill,
170
+ ...(entry ? { role: entry.role, indexEntry: entry } : {}),
141
171
  });
142
172
  count++;
143
173
  }
@@ -151,10 +181,13 @@ export function eligibleCandidates(skills, tools, config, activeSkills, supplied
151
181
  toolOverflow = true;
152
182
  break;
153
183
  }
184
+ const sourceHash = toolSourceHash(tool.name, tool.description);
185
+ const entry = findEntry(tool.name, sourceHash);
154
186
  candidates.push({
155
187
  kind: "tool",
156
188
  id: tool.name,
157
189
  description: sanitizeDescription(tool.description),
190
+ ...(entry ? { role: entry.role, indexEntry: entry } : {}),
158
191
  });
159
192
  count++;
160
193
  }
@@ -241,7 +274,7 @@ function parseScoreAnswer(answer) {
241
274
  /**
242
275
  * Run assessment for the given prompt revision.
243
276
  * Pre-measurement: returns trace-only results. No steers, no auto-loading.
244
- * Issues sequential requests: skills first, then tools in the remaining envelope.
277
+ * Issues concurrent requests (up to four in flight), skills first, shared deadline.
245
278
  */
246
279
  export async function assess(prompt, recentContext, skills, tools, activeSkills, suppliedSkills, deps) {
247
280
  const start = deps.now?.() ?? Date.now();
@@ -252,120 +285,145 @@ export async function assess(prompt, recentContext, skills, tools, activeSkills,
252
285
  if (!judge) {
253
286
  return { disposition: "no_gap", selected: null, usefulness: 0, pAdvance: 0, questionHash: "", elapsedMs: 0, requestCount: 0, skipReason: "no_consent" };
254
287
  }
255
- const { candidates, skillOverflow, toolOverflow } = eligibleCandidates(skills, tools, config, activeSkills, suppliedSkills);
288
+ const { candidates, skillOverflow, toolOverflow } = eligibleCandidates(skills, tools, config, activeSkills, suppliedSkills, deps.globalIndex, deps.projectIndex);
256
289
  if (candidates.length === 0) {
257
290
  return { disposition: "no_gap", selected: null, usefulness: 0, pAdvance: 0, questionHash: "", elapsedMs: 0, requestCount: 0, skipReason: "no_match" };
258
291
  }
259
- // Split into skills and tools for sequential assessment
260
- const skillCandidates = candidates.filter(c => c.kind === "skill");
261
- const toolCandidates = candidates.filter(c => c.kind === "tool");
262
- // Cap descriptions
263
- const cappedSkills = skillCandidates.map(c => capDescription(c));
264
- const cappedTools = toolCandidates.map(c => capDescription(c));
265
292
  const effectiveTimeout = Math.min(config.timeoutMs, sharedTimeoutMs);
266
293
  const deadline = start + effectiveTimeout;
267
- let requestCount = 0;
268
- // Disposition result (shared across batches)
269
- let disposition = "unclear";
270
- let pAdvance = 0;
271
- let hash = "";
272
- let bestScored = null;
273
- // Process skills first, then tools
274
- const batches = [
275
- { items: cappedSkills, overflow: skillOverflow, categoryOverflowReason: "catalog_limit" },
276
- { items: cappedTools, overflow: toolOverflow, categoryOverflowReason: "catalog_limit" },
294
+ const MAX_IN_FLIGHT = 4;
295
+ const categoryMeta = [
296
+ { category: "skill", overflow: skillOverflow },
297
+ { category: "tool", overflow: toolOverflow },
277
298
  ];
278
- // Track if we have a complete skill assessment (for tool catalog to proceed)
279
- let skillAssessmentComplete = false;
280
- for (const batch of batches) {
281
- if (batch.items.length === 0 && !batch.overflow)
299
+ const cappedByCategory = {
300
+ skill: candidates.filter(c => c.kind === "skill").map(c => ({ candidate: c, capped: capDescription(c) })),
301
+ tool: candidates.filter(c => c.kind === "tool").map(c => ({ candidate: c, capped: capDescription(c) })),
302
+ };
303
+ const maxCandidatesPerRequest = MAX_QUESTIONS_PER_REQUEST - 1; // -1 for disposition
304
+ const workQueue = [];
305
+ for (const meta of categoryMeta) {
306
+ const items = cappedByCategory[meta.category];
307
+ if (items.length === 0 && !meta.overflow)
282
308
  continue;
283
- // Pack into requests of at most MAX_QUESTIONS_PER_REQUEST (including disposition)
284
- const maxCandidatesPerRequest = MAX_QUESTIONS_PER_REQUEST - 1; // -1 for disposition
285
- const chunks = [];
286
- for (let i = 0; i < batch.items.length; i += maxCandidatesPerRequest) {
287
- chunks.push(batch.items.slice(i, i + maxCandidatesPerRequest));
309
+ for (let i = 0; i < items.length; i += maxCandidatesPerRequest) {
310
+ workQueue.push({ category: meta.category, items: items.slice(i, i + maxCandidatesPerRequest), overflow: false });
288
311
  }
289
- for (const chunk of chunks) {
290
- const opaqueBatch = assignOpaqueIds(chunk.map(e => e.candidate));
291
- const { questions, dispositionKey, idMap } = buildBatchQuestions(opaqueBatch, prompt, recentContext, activeSkills, suppliedSkills);
292
- hash = questionHash(questions);
293
- const state = buildState(prompt, recentContext, activeSkills, suppliedSkills, opaqueBatch);
294
- const remaining = deadline - (deps.now?.() ?? Date.now());
295
- if (remaining <= 0) {
296
- return { disposition: "unclear", selected: null, usefulness: 0, pAdvance: 0, questionHash: hash, elapsedMs: (deps.now?.() ?? Date.now()) - start, requestCount, skipReason: "timeout" };
297
- }
298
- let answers;
299
- try {
300
- const result = await Promise.race([
301
- judge.evaluate({ state, questions }),
302
- new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), remaining)),
303
- ]);
304
- answers = result.answers;
305
- requestCount++;
306
- }
307
- catch (err) {
308
- return { disposition: "unclear", selected: null, usefulness: 0, pAdvance: 0, questionHash: hash, elapsedMs: (deps.now?.() ?? Date.now()) - start, requestCount, skipReason: "error", errorCategory: classifyError(err) };
309
- }
310
- // Parse disposition (only from first batch that returns it)
311
- if (disposition === "unclear") {
312
- const dispRaw = parseChoiceAnswer(answers[dispositionKey]);
313
- if (dispRaw) {
314
- disposition = dispRaw.label;
315
- pAdvance = dispRaw.pAdvance;
316
- }
312
+ if (items.length === 0 && meta.overflow) {
313
+ workQueue.push({ category: meta.category, items: [], overflow: true });
314
+ }
315
+ }
316
+ // Per-category tracking: a category whose every batch fails or times out is discarded.
317
+ const categoryCompleted = { skill: false, tool: false };
318
+ const categoryFailed = { skill: false, tool: false };
319
+ const judgeError = { value: null };
320
+ const state_ = { disposition: "unclear", pAdvance: 0, hash: "", requestCount: 0, bestScored: null };
321
+ // Execute chunks with at most MAX_IN_FLIGHT concurrent judge calls.
322
+ // Each chunk races its own evaluate against the shared deadline.
323
+ // A settled chunk (success or error) frees its slot for the next waiting chunk.
324
+ let nextIdx = 0;
325
+ const inFlight = [];
326
+ function launchNext() {
327
+ while (inFlight.length < MAX_IN_FLIGHT && nextIdx < workQueue.length) {
328
+ const work = workQueue[nextIdx];
329
+ nextIdx++;
330
+ const p = runChunk(work).finally(() => {
331
+ const pos = inFlight.indexOf(p);
332
+ if (pos !== -1)
333
+ inFlight.splice(pos, 1);
334
+ launchNext();
335
+ });
336
+ inFlight.push(p);
337
+ }
338
+ }
339
+ async function runChunk(work) {
340
+ if (work.items.length === 0) {
341
+ if (work.overflow)
342
+ categoryCompleted[work.category] = true;
343
+ return;
344
+ }
345
+ const opaqueBatch = assignOpaqueIds(work.items.map(e => e.capped.candidate));
346
+ const { questions, dispositionKey } = buildBatchQuestions(opaqueBatch, prompt, recentContext, activeSkills, suppliedSkills);
347
+ state_.hash = questionHash(questions);
348
+ const state = buildState(prompt, recentContext, activeSkills, suppliedSkills, opaqueBatch);
349
+ const remaining = deadline - (deps.now?.() ?? Date.now());
350
+ if (remaining <= 0) {
351
+ categoryFailed[work.category] = true;
352
+ return;
353
+ }
354
+ let answers;
355
+ const j = judge;
356
+ try {
357
+ const result = await Promise.race([
358
+ j.evaluate({ state, questions }),
359
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), remaining)),
360
+ ]);
361
+ answers = result.answers;
362
+ state_.requestCount++;
363
+ }
364
+ catch (err) {
365
+ categoryFailed[work.category] = true;
366
+ judgeError.value = { message: err instanceof Error ? err.message : String(err), category: classifyError(err) };
367
+ return;
368
+ }
369
+ categoryCompleted[work.category] = true;
370
+ if (state_.disposition === "unclear") {
371
+ const dispRaw = parseChoiceAnswer(answers[dispositionKey]);
372
+ if (dispRaw) {
373
+ state_.disposition = dispRaw.label;
374
+ state_.pAdvance = dispRaw.pAdvance;
317
375
  }
318
- // If disposition is not advance, stop assessing
319
- if (disposition !== "advance")
376
+ }
377
+ if (state_.disposition !== "advance")
378
+ return;
379
+ for (const { opaqueId, candidate } of opaqueBatch) {
380
+ const answer = parseScoreAnswer(answers[opaqueId]);
381
+ if (!answer)
320
382
  continue;
321
- // Score each candidate in this chunk
322
- for (const { opaqueId, candidate } of opaqueBatch) {
323
- const answer = parseScoreAnswer(answers[opaqueId]);
324
- if (!answer)
325
- continue;
326
- // P(useful now) = P(level 2: directly_useful) + P(level 3: prerequisite)
327
- const pUseful = (answer.probabilities[2] ?? 0) + (answer.probabilities[3] ?? 0);
328
- if (!bestScored || pUseful > bestScored.usefulness ||
329
- (pUseful === bestScored.usefulness && answer.level > bestScored.level) ||
330
- (pUseful === bestScored.usefulness && answer.level === bestScored.level &&
331
- (candidate.kind === "skill" && bestScored.candidate.kind !== "skill" ||
332
- (candidate.kind === bestScored.candidate.kind && candidate.id < bestScored.candidate.id)))) {
333
- bestScored = { candidate, usefulness: pUseful, level: answer.level };
334
- }
383
+ const pUseful = (answer.probabilities[2] ?? 0) + (answer.probabilities[3] ?? 0);
384
+ if (!state_.bestScored || pUseful > state_.bestScored.usefulness ||
385
+ (pUseful === state_.bestScored.usefulness && answer.level > state_.bestScored.level) ||
386
+ (pUseful === state_.bestScored.usefulness && answer.level === state_.bestScored.level &&
387
+ (candidate.kind === "skill" && state_.bestScored.candidate.kind !== "skill" ||
388
+ (candidate.kind === state_.bestScored.candidate.kind && candidate.id < state_.bestScored.candidate.id)))) {
389
+ state_.bestScored = { candidate, usefulness: pUseful, level: answer.level };
335
390
  }
336
391
  }
337
- if (batch.items.length > 0 || batch.overflow) {
338
- skillAssessmentComplete = true;
339
- }
340
- // If skills had catalog_limit, do not proceed to tools with partial results
341
- if (batch.overflow && batch.categoryOverflowReason === "catalog_limit") {
342
- // Per spec: "if a required batch for a category fails or times out, discard that category's selection"
343
- // But catalog_limit means the category is incomplete, not failed. Selection may use the other category.
344
- // Record catalog_limit but continue to tools.
345
- }
346
392
  }
347
- // Return disposition result
393
+ launchNext();
394
+ await Promise.all(inFlight);
395
+ const elapsedMs = (deps.now?.() ?? Date.now()) - start;
396
+ const { disposition, pAdvance, hash, requestCount, bestScored } = state_;
397
+ const skillUsable = categoryCompleted.skill && !categoryFailed.skill;
398
+ const toolUsable = categoryCompleted.tool && !categoryFailed.tool;
399
+ // When every category failed, the judge is broken — surface the error, not no_match.
400
+ if (!skillUsable && !toolUsable && judgeError.value) {
401
+ return { disposition: "unclear", selected: null, usefulness: 0, pAdvance: 0, questionHash: state_.hash, elapsedMs, requestCount: state_.requestCount, skipReason: "error", errorCategory: judgeError.value.category };
402
+ }
348
403
  if (disposition !== "advance") {
349
404
  const skipReason = disposition === "awaiting_user" ? "awaiting_user" : undefined;
350
- const result = { disposition, selected: null, usefulness: 0, pAdvance, questionHash: hash, elapsedMs: (deps.now?.() ?? Date.now()) - start, requestCount };
405
+ const result = { disposition, selected: null, usefulness: 0, pAdvance, questionHash: hash, elapsedMs, requestCount };
351
406
  if (skipReason)
352
407
  result.skipReason = skipReason;
353
408
  return result;
354
409
  }
355
- // No candidate scored
356
410
  if (!bestScored) {
357
411
  const skipReason = skillOverflow ? "catalog_limit" : "no_match";
358
- return { disposition, selected: null, usefulness: 0, pAdvance, questionHash: hash, elapsedMs: (deps.now?.() ?? Date.now()) - start, requestCount, skipReason };
412
+ return { disposition, selected: null, usefulness: 0, pAdvance, questionHash: hash, elapsedMs, requestCount, skipReason };
413
+ }
414
+ if (bestScored.candidate.kind === "skill" && !skillUsable) {
415
+ return { disposition, selected: null, usefulness: 0, pAdvance, questionHash: hash, elapsedMs, requestCount, skipReason: "no_match" };
416
+ }
417
+ if (bestScored.candidate.kind === "tool" && !toolUsable) {
418
+ return { disposition, selected: null, usefulness: 0, pAdvance, questionHash: hash, elapsedMs, requestCount, skipReason: "no_match" };
359
419
  }
360
- // Threshold gate: both P(useful now) and P(advance) must pass
361
420
  const passesUsefulness = bestScored.usefulness >= config.recommendThreshold;
362
- const passesAdvance = pAdvance >= config.recommendThreshold;
421
+ const passesAdvance = pAdvance >= config.advanceThreshold;
363
422
  if (!passesUsefulness || !passesAdvance) {
364
- return { disposition, selected: null, usefulness: bestScored.usefulness, pAdvance, questionHash: hash, elapsedMs: (deps.now?.() ?? Date.now()) - start, requestCount, skipReason: "below_threshold" };
423
+ return { disposition, selected: null, usefulness: bestScored.usefulness, pAdvance, questionHash: hash, elapsedMs, requestCount, skipReason: "below_threshold" };
365
424
  }
366
- // Catalog completeness gate: do not select from an incomplete category
367
425
  if (bestScored.candidate.kind === "skill" && skillOverflow) {
368
- return { disposition, selected: null, usefulness: bestScored.usefulness, pAdvance, questionHash: hash, elapsedMs: (deps.now?.() ?? Date.now()) - start, requestCount, skipReason: "catalog_limit" };
426
+ return { disposition, selected: null, usefulness: bestScored.usefulness, pAdvance, questionHash: hash, elapsedMs, requestCount, skipReason: "catalog_limit" };
369
427
  }
370
428
  return {
371
429
  disposition,
@@ -373,7 +431,7 @@ export async function assess(prompt, recentContext, skills, tools, activeSkills,
373
431
  usefulness: bestScored.usefulness,
374
432
  pAdvance,
375
433
  questionHash: hash,
376
- elapsedMs: (deps.now?.() ?? Date.now()) - start,
434
+ elapsedMs,
377
435
  requestCount,
378
436
  };
379
437
  }
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import type { ToolCallRef } from "./action-guard.js";
3
3
  import type { SlopSymptom, Verdict } from "./guard.js";
4
4
  import type { ShapeResult } from "./shape.js";
5
- export declare 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.";
5
+ export declare 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.";
6
6
  /** Which guard spent the user's attention. The status line reports one count per guard. */
7
7
  export type SteerGuard = "action" | "rules" | "security" | "stuck" | "done" | "prose" | "runaway" | "subagent" | "conscience";
8
8
  /**
@@ -33,5 +33,6 @@ export declare function slopSteer(where: string, symptoms: readonly SlopSymptom[
33
33
  * failed until Pi was restarted. The sections this build reads are therefore checked here as well, in the module that reads them.
34
34
  */
35
35
  export declare function guardCurrentSections(result: ShapeResult): ShapeResult;
36
- /** Native Pi registration; importing the root library does not load this module. */
36
+ /** Test-only: set the index-running state and write paths for the action guard bypass. */
37
+ export declare function _testSetIndexRunning(running: boolean, paths?: string[]): void;
37
38
  export default function wardenExtension(pi: ExtensionAPI): void;