pi-warden 0.60.0 → 0.64.1

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
@@ -36,8 +36,10 @@ import { classifyJudgeError, cooldownFailureKind, JudgeCooldown } from "./judge-
36
36
  import { openConfigPanel, openTracePanel } from "./panel.js";
37
37
  import { completeConfig, shapeWarning, taskSpine } from "./shape.js";
38
38
  import { ContextLedger, formatLedger } from "./saver.js";
39
- import { buildCompactSnapshot, compactAppendix } from "./compact.js";
40
- import { formatPrefs, prefsMessage, scanPreferences } from "./prefs.js";
39
+ import { SeenText, collapseRuns, seenItem } from "./dedupe.js";
40
+ import { buildCompactSnapshot, compactAppendix, recallText } from "./compact.js";
41
+ import { applyLoopAction, formatLoopsForUser, formatOpenLoops, LOOP_ACTIONS, LOOP_CHARS, loopsFingerprint, loopsPath, openLoops, readLoops, writeLoops } from "./loops.js";
42
+ import { emptyWordCounts, evaluatePrefs, forgetPref, formatPrefs, isCorrection, LESSON_CHARS, LESSON_TURNS, NO_LESSON_SIGNAL, prefsMessage, prefsStorePath, readPrefsStore, recordLesson, scanPreferences, writePrefsStore } from "./prefs.js";
41
43
  import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
42
44
  import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
43
45
  import { TraceFile, judgmentsState, traceDir, traceFilePath } from "./trace-file.js";
@@ -47,7 +49,7 @@ const WIDGET = PACKAGE_NAME;
47
49
  const CONFIRM_TEXT_LIMIT = 500;
48
50
  /** Rules requests one bash command may start; the other files it writes are recorded as skipped. */
49
51
  const SHELL_RULES_CHECKS = 5;
50
- const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offTask: 0, slop: 0, ruleChecks: 0, ruleViolations: 0, pathNotes: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, runaway: 0, errors: 0, cooldownSkips: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
52
+ const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offPlanTraceOnly: 0, offTask: 0, slop: 0, ruleChecks: 0, ruleViolations: 0, pathNotes: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, runaway: 0, errors: 0, cooldownSkips: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
51
53
  /**
52
54
  * One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
53
55
  * message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
@@ -208,6 +210,7 @@ function agentDeliveryReasons(verdict) {
208
210
  const indexes = [
209
211
  verdict.offTaskTraceOnly ? verdict.offTaskTraceOnlyReasonIndex : undefined,
210
212
  verdict.shouldProceedTraceOnly ? verdict.shouldProceedTraceOnlyReasonIndex : undefined,
213
+ verdict.intentTraceOnly ? verdict.intentTraceOnlyReasonIndex : undefined,
211
214
  ].filter((index) => index !== undefined && index >= 0 && index < verdict.reasons.length);
212
215
  if (!indexes.length)
213
216
  return verdict.reasons;
@@ -223,6 +226,8 @@ function agentDeliveryVerdict(verdict) {
223
226
  delete deliveryVerdict.offTaskSteer;
224
227
  if (verdict.shouldProceedTraceOnly)
225
228
  delete deliveryVerdict.shouldProceedSteer;
229
+ if (verdict.intentTraceOnly)
230
+ delete deliveryVerdict.intentMismatch;
226
231
  return deliveryVerdict;
227
232
  }
228
233
  function clip(text) {
@@ -392,7 +397,48 @@ export default function wardenExtension(pi) {
392
397
  const manager = ctx.sessionManager;
393
398
  const dir = typeof manager.getSessionDir === "function" ? manager.getSessionDir() : undefined;
394
399
  const exclude = typeof manager.getSessionFile === "function" ? manager.getSessionFile() : undefined;
395
- return prefsScan = dir ? scanPreferences({ dir, exclude, cwd: ctx.cwd }) : Promise.resolve({ prefs: [], scanned: 0, directories: 0, ms: 0 });
400
+ return prefsScan = dir ? scanPreferences({ dir, exclude, cwd: ctx.cwd }) : Promise.resolve({ prefs: [], candidates: [], counts: emptyWordCounts(), project: ctx.cwd, scanned: 0, directories: 0, ms: 0 });
401
+ };
402
+ /** The scan, the project's lesson file, and every listed item with its status; read fresh so `forget` numbers match. */
403
+ const evaluatedPrefs = async (ctx) => {
404
+ const scan = await standingPrefs(ctx);
405
+ const path = prefsStorePath(scan.project);
406
+ return { scan, path, items: evaluatePrefs(scan, await readPrefsStore(path)) };
407
+ };
408
+ // Assistant turns this session, and the turn of the last user correction or stuck, repeat, or done-check steer in this
409
+ // run: `warden_remember` records a lesson only within LESSON_TURNS turns of one.
410
+ let assistantTurns = 0;
411
+ let lessonSignalTurn;
412
+ // The open-loop list the last end-of-run reminder named: the same unchanged list is not named again.
413
+ let loopsReminded;
414
+ const LOOPS_TYPE = `${PACKAGE_NAME}-loops`;
415
+ /** The session's loops file, or undefined when the host gives the session no id (nothing to key it by). */
416
+ const loopsFile = (ctx) => {
417
+ const manager = ctx.sessionManager;
418
+ const id = typeof manager?.getSessionId === "function" ? manager.getSessionId() : undefined;
419
+ return id ? loopsPath(ctx.cwd, id) : undefined;
420
+ };
421
+ /**
422
+ * On resume, one message lists the loops still open, so a session picked up later knows what it promised. Not a steer.
423
+ * A branch whose last loops message says the same already has it.
424
+ */
425
+ const announceLoops = async (ctx, config, branch) => {
426
+ const file = loopsFile(ctx);
427
+ if (!file)
428
+ return;
429
+ try {
430
+ const open = openLoops(await readLoops(file));
431
+ if (!open.length)
432
+ return;
433
+ const content = `Open loops from earlier in this session (warden_loops):\n${formatOpenLoops(open)}\nClose each with warden_loops done or drop once it is finished or no longer needed.`;
434
+ const last = [...branch].reverse().find(entry => entry.type === "custom_message" && entry.customType === LOOPS_TYPE);
435
+ if (last?.content === content)
436
+ return;
437
+ pi.sendMessage({ customType: LOOPS_TYPE, content, display: config.steerVisible });
438
+ }
439
+ catch (error) {
440
+ console.warn("pi-warden: reading the loops file failed:", error instanceof Error ? error.message : String(error));
441
+ }
396
442
  };
397
443
  // A partially updated module graph can hand this build a config without the sections it expects; see shape.ts.
398
444
  let shapeReported = false;
@@ -626,6 +672,51 @@ export default function wardenExtension(pi) {
626
672
  };
627
673
  /** A judged output the saver left whole gets a trace line with its verdict, so the confidence gate can be calibrated.
628
674
  * Trace only: nothing happened to the output, so the status line keeps the last real context event. */
675
+ /** Text already in context on the current branch, for repeated-run detection. Synced before each check, not kept by hand. */
676
+ const seenText = new SeenText();
677
+ const syncSeen = (ctx) => {
678
+ const manager = ctx.sessionManager;
679
+ // Compaction-aware when the host has it: text a compaction summarized is no longer in context, so nothing may point to it.
680
+ const entries = typeof manager.buildContextEntries === "function" ? manager.buildContextEntries() : typeof manager.getBranch === "function" ? manager.getBranch() : [];
681
+ seenText.sync(entries.map(seenItem).filter(item => item !== undefined));
682
+ };
683
+ /** `text` with each run that repeats context replaced by a pointer line, or undefined when nothing repeats or the copy cannot be stored. */
684
+ const dedupeText = async (ctx, config, text, source) => {
685
+ const runs = seenText.find(text);
686
+ if (!runs.length)
687
+ return undefined;
688
+ try {
689
+ const path = await saveOutput(text);
690
+ const collapsed = collapseRuns(text, runs, path);
691
+ const bytesSaved = Buffer.byteLength(text) - Buffer.byteLength(collapsed);
692
+ ledger.repeat(path, bytesSaved, { tool: source, bytes: Buffer.byteLength(text) });
693
+ savingEntry = record(ctx, config, "context", renderTemplate(config.widget.context, { tool: source, retention: "repeat", bytesSaved: String(bytesSaved) }), [
694
+ ...runs.map(run => `${run.lines} lines (${run.chars} characters) repeat ${run.where}`),
695
+ `saved ${bytesSaved} bytes; full text: ${path}`,
696
+ formatLedger(ledger.snapshot()),
697
+ ]);
698
+ return collapsed;
699
+ }
700
+ catch {
701
+ noteError(ctx, "Could not store full text; keeping the repeated run.", undefined);
702
+ return undefined;
703
+ }
704
+ };
705
+ /** Text parts only; images and other parts keep their place and bytes. */
706
+ const dedupeContent = async (ctx, config, content, source) => {
707
+ let changed;
708
+ for (let index = 0; index < content.length; index++) {
709
+ const part = content[index];
710
+ if (part.type !== "text" || typeof part.text !== "string")
711
+ continue;
712
+ const text = await dedupeText(ctx, config, part.text, source);
713
+ if (text === undefined)
714
+ continue;
715
+ changed ??= [...content];
716
+ changed[index] = { ...part, text };
717
+ }
718
+ return changed;
719
+ };
629
720
  const recordKeptWhole = (config, tool, verdict, prefix = "") => {
630
721
  trace.push({ at: Date.now(), guard: "context", line: renderTemplate(config.widget.context, { tool, retention: "kept whole" }), details: [
631
722
  `${prefix}kept whole: retention ${verdict.retention}; confidence ${verdict.confidence?.toFixed(2)}; format ${verdict.format ?? "generic"}${verdict.formatConfidence === undefined ? "" : ` (${verdict.formatConfidence.toFixed(2)})`}; ${verdict.model}; ${verdict.elapsedMs} ms`,
@@ -693,6 +784,8 @@ export default function wardenExtension(pi) {
693
784
  const names = typeof guard === "string" ? [guard] : [...guard];
694
785
  for (const name of names)
695
786
  stats.steerGuards[name] = (stats.steerGuards[name] ?? 0) + 1;
787
+ if (names.some(name => name === "stuck" || name === "repeat" || name === "done"))
788
+ lessonSignalTurn = assistantTurns;
696
789
  const critical = names.every(name => CRITICAL_STEER_GUARDS.has(name));
697
790
  // A notice delivered once is already in the agent's context. Sending the repeat again costs the accounting turn it
698
791
  // forbids, so repeats are recorded only. The same goes for notices past the per-run steer budget: a steer sent during
@@ -831,6 +924,7 @@ export default function wardenExtension(pi) {
831
924
  }
832
925
  }
833
926
  ledger.reset();
927
+ seenText.clear();
834
928
  savingEntry = undefined;
835
929
  compressionLearner.reset();
836
930
  secretsSeen.clear();
@@ -850,6 +944,9 @@ export default function wardenExtension(pi) {
850
944
  notifier = undefined;
851
945
  lastNotifiedAt = 0;
852
946
  prefsScan = undefined;
947
+ assistantTurns = 0;
948
+ lessonSignalTurn = undefined;
949
+ loopsReminded = undefined;
853
950
  // Load capability indexes (once per session, overwritten on every /warden index run).
854
951
  globalIndexFile = readIndex(indexPath("global")) ?? undefined;
855
952
  projectIndexFile = readIndex(indexPath("project", ctx.cwd)) ?? undefined;
@@ -869,13 +966,12 @@ export default function wardenExtension(pi) {
869
966
  pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: msg, display: true });
870
967
  }
871
968
  const opening = configFor(ctx);
872
- if (opening.enabled && opening.prefs.enabled && opening.prefs.inject) {
873
- // A resumed or reloaded session already carries the message; sending it again would repeat it in the context.
874
- const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
875
- if (branch.some(entry => entry.type === "custom_message" && entry.customType === PREFS_TYPE))
876
- return;
969
+ // A resumed or reloaded session already carries the message; sending it again would repeat it in the context.
970
+ const openingBranch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
971
+ const prefsSent = openingBranch.some(entry => entry.type === "custom_message" && entry.customType === PREFS_TYPE);
972
+ if (opening.enabled && opening.prefs.enabled && opening.prefs.inject && !prefsSent) {
877
973
  try {
878
- const message = prefsMessage((await standingPrefs(ctx)).prefs);
974
+ const message = prefsMessage((await evaluatedPrefs(ctx)).items);
879
975
  // Not a steer; one message per session; do not spend a steer unit.
880
976
  if (message)
881
977
  pi.sendMessage({ customType: PREFS_TYPE, content: message, display: opening.steerVisible });
@@ -884,6 +980,8 @@ export default function wardenExtension(pi) {
884
980
  console.warn("pi-warden: standing preferences scan failed:", error instanceof Error ? error.message : String(error));
885
981
  }
886
982
  }
983
+ if (opening.enabled && _event.reason === "resume")
984
+ await announceLoops(ctx, opening, openingBranch);
887
985
  });
888
986
  // A new user prompt starts a new attempt history, a new steer budget, and a new restatement window; answering the
889
987
  // user is never a restatement.
@@ -897,6 +995,7 @@ export default function wardenExtension(pi) {
897
995
  doneNudged = false;
898
996
  wardenContinuation = false;
899
997
  steersThisRun = 0;
998
+ lessonSignalTurn = isCorrection(event.prompt ?? "") ? assistantTurns : undefined;
900
999
  finals.reset();
901
1000
  runaway.reset();
902
1001
  runawayStops = 0;
@@ -1197,6 +1296,7 @@ export default function wardenExtension(pi) {
1197
1296
  });
1198
1297
  // Every turn that runs after a compression is a turn that did not carry the removed text.
1199
1298
  pi.on("turn_end", async (_event, ctx) => {
1299
+ assistantTurns++;
1200
1300
  ledger.turnEnd();
1201
1301
  if (savingEntry)
1202
1302
  trace.amend(savingEntry, `at turn end: ${formatLedger(ledger.snapshot())}`);
@@ -1445,8 +1545,12 @@ export default function wardenExtension(pi) {
1445
1545
  // The warn notice below names the mismatch to the user; the agent gets the steer with the other notes.
1446
1546
  if (verdict.intentMismatch) {
1447
1547
  stats.offPlan++;
1448
- noteGuards.add("action");
1449
- notes.push(intentSteer(verdict));
1548
+ if (verdict.intentTraceOnly)
1549
+ stats.offPlanTraceOnly++;
1550
+ else {
1551
+ noteGuards.add("action");
1552
+ notes.push(intentSteer(verdict));
1553
+ }
1450
1554
  }
1451
1555
  if (verdict.offTaskSteer) {
1452
1556
  stats.offTask++;
@@ -1857,6 +1961,13 @@ export default function wardenExtension(pi) {
1857
1961
  if (!compressed && !ctx.signal?.aborted && output.confidence !== undefined)
1858
1962
  recordKeptWhole(config, event.toolName, output);
1859
1963
  }
1964
+ // A recall brings the stored text back on purpose, and a duplicate note has nothing left to cut.
1965
+ if (config.context.enabled && config.context.dedupeRuns && !earlier && !recallRead && !ctx.signal?.aborted) {
1966
+ syncSeen(ctx);
1967
+ const deduped = await dedupeContent(ctx, config, content, event.toolName);
1968
+ if (deduped)
1969
+ content = deduped;
1970
+ }
1860
1971
  if (key && !earlier)
1861
1972
  ledger.remember(key, event.toolName, storedPath);
1862
1973
  // The banner in the result already tells the agent, so the notice rides the result content alone and is not also
@@ -1960,6 +2071,23 @@ export default function wardenExtension(pi) {
1960
2071
  steer(config, "stuck", nudge);
1961
2072
  return patch;
1962
2073
  });
2074
+ // Opt-in: a relayed report that pastes earlier turns again keeps only its new part. Pi persists the replaced message.
2075
+ pi.on("message_end", async (event, ctx) => {
2076
+ const config = configFor(ctx);
2077
+ const message = event.message;
2078
+ if (!config.enabled || !config.context.enabled || !config.context.dedupeRuns || !config.context.dedupeMessages)
2079
+ return;
2080
+ if (message.role !== "user" && message.role !== "custom")
2081
+ return;
2082
+ syncSeen(ctx);
2083
+ const source = message.role === "custom" ? `${message.customType} message` : "user message";
2084
+ if (typeof message.content === "string") {
2085
+ const text = await dedupeText(ctx, config, message.content, source);
2086
+ return text === undefined ? undefined : { message: { ...message, content: text } };
2087
+ }
2088
+ const content = await dedupeContent(ctx, config, message.content, source);
2089
+ return content ? { message: { ...message, content } } : undefined;
2090
+ });
1963
2091
  // The agent has caught up and Pi will not continue on its own: the one moment a wake costs the user nothing.
1964
2092
  pi.on("agent_settled", async (_event, ctx) => {
1965
2093
  await checkSubagentReports(ctx, configFor(ctx));
@@ -1973,32 +2101,29 @@ export default function wardenExtension(pi) {
1973
2101
  // Compaction evidence appendix: after compaction succeeds, send the evidence warden holds as one
1974
2102
  // custom message so the agent can prefer saved paths over re-running commands. This is not a steer
1975
2103
  // and does not spend a steer unit; it is one message per compaction, the same path steers use.
2104
+ /** The session's evidence as the compaction appendix and `warden_recall` both read it. */
2105
+ const evidenceSnapshot = (ctx, openLoopsText) => buildCompactSnapshot({
2106
+ savedOutputs: ledger.storedOutputs(),
2107
+ checks: evidence.checks.map((check, index) => ({ command: check.call, passed: check.passed, runIndex: 1, indexInRun: index })),
2108
+ holds: holds.records().filter(r => r.held).map(r => ({ tool: r.tool, preview: r.callExcerpt ?? "", outcome: r.outcome })),
2109
+ attempts: attempts.attempts,
2110
+ evidence,
2111
+ activeTask: latestUserPrompt(ctx),
2112
+ runs: 1,
2113
+ ...(openLoopsText ? { openLoops: openLoopsText } : {}),
2114
+ });
1976
2115
  pi.on("session_compact", async (_event, ctx) => {
1977
2116
  const config = configFor(ctx);
1978
2117
  if (!config.enabled || !config.context.compactAppendix)
1979
2118
  return;
1980
2119
  try {
1981
- const checkRecords = evidence.checks.map((check, index) => ({
1982
- command: check.call,
1983
- passed: check.passed,
1984
- runIndex: 1,
1985
- indexInRun: index,
1986
- }));
1987
- const holdRecords = holds.records().filter(r => r.held).map(r => ({
1988
- tool: r.tool,
1989
- preview: r.callExcerpt ?? "",
1990
- outcome: r.outcome,
1991
- }));
1992
- const task = latestUserPrompt(ctx);
1993
- const snapshot = buildCompactSnapshot({
1994
- savedOutputs: ledger.storedOutputs(),
1995
- checks: checkRecords,
1996
- holds: holdRecords,
1997
- attempts: attempts.attempts,
1998
- evidence,
1999
- activeTask: task,
2000
- runs: 1,
2001
- });
2120
+ const file = loopsFile(ctx);
2121
+ // An unreadable loops file costs the appendix its loops section, not the evidence.
2122
+ const loopsText = file ? await readLoops(file).then(store => formatOpenLoops(openLoops(store)), (error) => {
2123
+ record(ctx, config, "action", "loops read error", [`reading the loops file failed: ${error instanceof Error ? error.message : String(error)}`]);
2124
+ return undefined;
2125
+ }) : undefined;
2126
+ const snapshot = evidenceSnapshot(ctx, loopsText);
2002
2127
  const appendix = compactAppendix(snapshot);
2003
2128
  if (!appendix)
2004
2129
  return;
@@ -2027,6 +2152,27 @@ export default function wardenExtension(pi) {
2027
2152
  // Operator input invalidates in-flight conscience assessments
2028
2153
  conscienceGeneration++;
2029
2154
  });
2155
+ /**
2156
+ * One short reminder of the open loops when a run ends, for the next turn: it starts no turn of its own, counts against
2157
+ * the steer budget as `loops`, and names the same unchanged list only once.
2158
+ */
2159
+ const remindLoops = async (ctx, config) => {
2160
+ const file = loopsFile(ctx);
2161
+ if (!file)
2162
+ return;
2163
+ try {
2164
+ const open = openLoops(await readLoops(file));
2165
+ const fingerprint = loopsFingerprint(open);
2166
+ if (!open.length || fingerprint === loopsReminded)
2167
+ return;
2168
+ const notice = `pi-warden: ${open.length} open loop${open.length === 1 ? "" : "s"} you promised in this session:\n${formatOpenLoops(open)}\nClose each with warden_loops done or drop once it is finished or no longer needed.`;
2169
+ if (steer(config, "loops", notice, { deliverAs: "nextTurn" }))
2170
+ loopsReminded = fingerprint;
2171
+ }
2172
+ catch (error) {
2173
+ record(ctx, config, "action", "loops reminder error", [`reading the loops file failed: ${error instanceof Error ? error.message : String(error)}`]);
2174
+ }
2175
+ };
2030
2176
  pi.on("agent_end", async (event, ctx) => {
2031
2177
  const config = configFor(ctx);
2032
2178
  if (!config.enabled)
@@ -2046,6 +2192,7 @@ export default function wardenExtension(pi) {
2046
2192
  steer(config, "runaway", nudge, { triggerTurn: false, display: true });
2047
2193
  return;
2048
2194
  }
2195
+ await remindLoops(ctx, config);
2049
2196
  const finalMessage = finalAssistantText(event.messages);
2050
2197
  // Restatement is measured in code, before any judging: the run that collects five accounting replies restating the
2051
2198
  // same completion status is the noise the user sees, and it is invisible to every per-reply and per-call guard.
@@ -2169,9 +2316,70 @@ export default function wardenExtension(pi) {
2169
2316
  togglePanel(ctx.ui, configFor(ctx)); },
2170
2317
  });
2171
2318
  }
2172
- const actions = ["status", "enable", "disable", "mode", "config", "test", "trace", "init", "audit", "index", "prefs", "recommend"];
2319
+ // A plain JSON Schema: Pi compiles tool schemas with TypeBox, which accepts one, and pi-warden keeps no TypeBox dependency.
2320
+ const toolParameters = (properties, required) => ({ type: "object", properties, required, additionalProperties: false });
2321
+ const toolReply = (text) => ({ content: [{ type: "text", text }], details: undefined });
2322
+ const rememberParameters = toolParameters({ lesson: { type: "string", maxLength: LESSON_CHARS, description: `One standing instruction for this project, at most ${LESSON_CHARS} characters, e.g. "Never X" or "Always Y".` } }, ["lesson"]);
2323
+ pi.registerTool({
2324
+ name: "warden_remember",
2325
+ label: "warden remember",
2326
+ description: `Record a standing lesson for this project, so later sessions keep it. Use it only right after the user corrected you, or after a mistake you had to undo; never for task notes, plans, or progress. The lesson is one instruction in a standing form (don't, never, always, stop, from now on, next time), names no ticket, branch, PR, or hash, and never skips a test, check, review, or confirmation. It reaches later sessions only once it is confirmed.`,
2327
+ parameters: rememberParameters,
2328
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2329
+ const config = configFor(ctx);
2330
+ if (!config.enabled || !config.prefs.enabled)
2331
+ return toolReply(`not recorded: standing preferences are off (${config.enabled ? "prefs.enabled" : "enabled"} is false)`);
2332
+ const signal = lessonSignalTurn !== undefined && assistantTurns - lessonSignalTurn <= LESSON_TURNS;
2333
+ const lesson = typeof params.lesson === "string" ? params.lesson : "";
2334
+ if (!signal)
2335
+ return toolReply(NO_LESSON_SIGNAL);
2336
+ const manager = ctx.sessionManager;
2337
+ const session = typeof manager.getSessionId === "function" ? manager.getSessionId() : "unknown";
2338
+ const scan = await standingPrefs(ctx);
2339
+ const path = prefsStorePath(scan.project);
2340
+ const result = recordLesson({ lesson, session, now: Date.now(), signal, scan, store: await readPrefsStore(path) });
2341
+ if (result.store)
2342
+ await writePrefsStore(path, result.store);
2343
+ return toolReply(result.reply);
2344
+ },
2345
+ });
2346
+ pi.registerTool({
2347
+ name: "warden_loops",
2348
+ label: "warden loops",
2349
+ description: `Keep open loops for this session, so a long session never loses a promise. Whenever you promise to do something later ("after CI passes I will bump the version", "I will come back to the flaky test"), add a loop; mark it done when you did it, or drop it with a reason when it is no longer needed. Actions: add (text, at most ${LOOP_CHARS} characters, and an optional when condition such as "after CI passes"), done (id), drop (id and reason), list. Open loops are shown again after compaction, at the end of a run, and on resume. They belong to this session only.`,
2350
+ parameters: toolParameters({
2351
+ action: { type: "string", enum: [...LOOP_ACTIONS], description: "add, done, drop, or list." },
2352
+ text: { type: "string", maxLength: LOOP_CHARS, description: "For add: what you promised to do." },
2353
+ when: { type: "string", maxLength: LOOP_CHARS, description: "For add, optional: the condition that makes it due." },
2354
+ id: { type: "integer", minimum: 1, description: "For done and drop: the loop number." },
2355
+ reason: { type: "string", maxLength: LOOP_CHARS, description: "For drop: why it is no longer needed." },
2356
+ }, ["action"]),
2357
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2358
+ if (!configFor(ctx).enabled)
2359
+ return toolReply("not available: pi-warden is off (enabled is false)");
2360
+ const file = loopsFile(ctx);
2361
+ if (!file)
2362
+ return toolReply("not available: this session has no id to keep loops under");
2363
+ const result = applyLoopAction(await readLoops(file), params, Date.now());
2364
+ if (result.store)
2365
+ await writeLoops(file, result.store);
2366
+ return toolReply(result.reply);
2367
+ },
2368
+ });
2369
+ pi.registerTool({
2370
+ name: "warden_recall",
2371
+ label: "warden recall",
2372
+ description: "Recall what was already tried in this session before you try it again: the failed attempts with their error lines, the last passing check and whether the code changed since, and the paths of saved full outputs (read those instead of re-running the command). Read-only; takes no arguments.",
2373
+ parameters: toolParameters({}, []),
2374
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
2375
+ if (!configFor(ctx).enabled)
2376
+ return toolReply("not available: pi-warden is off (enabled is false)");
2377
+ return toolReply(recallText(evidenceSnapshot(ctx)));
2378
+ },
2379
+ });
2380
+ const actions = ["status", "enable", "disable", "mode", "config", "test", "trace", "init", "audit", "index", "prefs", "loops", "recommend"];
2173
2381
  pi.registerCommand("warden", {
2174
- description: "pi-warden status, config (set/get/editor), TypeSafe consent, mode, trace panel, recommend, standing preferences, and a synthetic guard test",
2382
+ description: "pi-warden status, config (set/get/editor), TypeSafe consent, mode, trace panel, recommend, standing preferences, open loops, and a synthetic guard test",
2175
2383
  getArgumentCompletions(prefix) {
2176
2384
  const matches = actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
2177
2385
  return matches.length ? matches : null;
@@ -2202,9 +2410,9 @@ export default function wardenExtension(pi) {
2202
2410
  : `Lifetime here: ${ls.held} hold${ls.held === 1 ? "" : "s"}, ${ls.labeled} labeled, ${ls.declined + ls.replanned} stood (${ls.declined + ls.replanned}/${ls.labeled}), ${ls.allowed} allowed (${ls.accepted} accepted, ${ls.regretted} regretted).`;
2203
2411
  report([
2204
2412
  `pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `consented via ${source}` : "not consented (run /warden enable)"}${config.typesafeBackend !== "typesafe" ? ` (${config.typesafeBackend})` : ""}; ${auth.text}`,
2205
- `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan, ${stats.offTask} off task, ${stats.slop} slop notes, ${stats.ruleViolations}/${stats.ruleChecks} rule violations, ${stats.pathNotes} sensitive-path notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.runaway} runaway stops, ${stats.subagentWoken}/${stats.subagentReports} subagent reports woken, ${stats.restatements} restatements, ${stats.errors} TypeSafe errors, ${stats.cooldownSkips} checks without Jev during a judge cooldown; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests. Steers are ${config.steerVisible ? "shown in the transcript" : "hidden from the transcript (trace panel shows them)"}. Steer budget: ${config.steerBudget === 0 ? "off" : `${config.steerBudget} per run`}.`,
2413
+ `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan (${stats.offPlanTraceOnly} trace-only), ${stats.offTask} off task, ${stats.slop} slop notes, ${stats.ruleViolations}/${stats.ruleChecks} rule violations, ${stats.pathNotes} sensitive-path notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.runaway} runaway stops, ${stats.subagentWoken}/${stats.subagentReports} subagent reports woken, ${stats.restatements} restatements, ${stats.errors} TypeSafe errors, ${stats.cooldownSkips} checks without Jev during a judge cooldown; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests. Steers are ${config.steerVisible ? "shown in the transcript" : "hidden from the transcript (trace panel shows them)"}. Steer budget: ${config.steerBudget === 0 ? "off" : `${config.steerBudget} per run`}.`,
2206
2414
  formatSteers(stats),
2207
- `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / steer ${config.action.offTask.steer} (never holds); intent mismatch ${config.action.intentMismatch} (${config.action.visibleMismatch} on a visible action); stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, rules ${config.rules.threshold}, prose ${config.slop.prose.threshold} in ${config.slop.prose.trend}/3 replies; runaway ${config.runaway.repeats} repeats (thinking ${config.runaway.thinkingRepeats}), recover ${config.runaway.recover}; failOpen ${config.action.failOpen}.`,
2415
+ `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / steer ${config.action.offTask.steer} (never holds); intent mismatch ${config.action.intentMismatch} (${config.action.visibleMismatch} on a visible action, trace-only: ${config.action.intentTraceOnly}); stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, rules ${config.rules.threshold}, prose ${config.slop.prose.threshold} in ${config.slop.prose.trend}/3 replies; runaway ${config.runaway.repeats} repeats (thinking ${config.runaway.thinkingRepeats}), recover ${config.runaway.recover}; failOpen ${config.action.failOpen}.`,
2208
2416
  formatLedger(ledger.snapshot()),
2209
2417
  ...(config.learning.patternAnalysis ? [`Learning: ${(await generateRecommendations(ctx.cwd)).length} recommendations, steer effectiveness ${Math.round((await analyzeSteerEffectivenessReport(ctx.cwd)).overall * 100)}% (use /warden recommend for details)`] : []),
2210
2418
  `${formatHolds(holds.snapshot(), config.action.feedbackLog ? holdLog?.path : undefined)}${holdLog?.lastFailure ? ` Log write failed: ${holdLog.lastFailure}.` : ""}`,
@@ -2233,12 +2441,29 @@ export default function wardenExtension(pi) {
2233
2441
  void opened?.closed.then(fallback, fallback);
2234
2442
  return;
2235
2443
  }
2444
+ if (action === "loops") {
2445
+ const file = loopsFile(ctx);
2446
+ report(file ? formatLoopsForUser(await readLoops(file)) : "No loops: this session has no id to keep them under.");
2447
+ return;
2448
+ }
2236
2449
  if (action === "prefs") {
2237
2450
  if (!config.enabled || !config.prefs.enabled) {
2238
2451
  report(`Standing preferences are off (${config.enabled ? "prefs.enabled" : "enabled"} is false); no session files were read.`);
2239
2452
  return;
2240
2453
  }
2241
- report(formatPrefs(await standingPrefs(ctx)));
2454
+ const { scan, path, items } = await evaluatedPrefs(ctx);
2455
+ if (argument === "forget") {
2456
+ const index = Number(tokens[2]);
2457
+ const item = Number.isInteger(index) ? items[index - 1] : undefined;
2458
+ if (!item) {
2459
+ report(`Usage: /warden prefs forget <n>, where n is an item number from /warden prefs (1 to ${items.length}).`, "warning");
2460
+ return;
2461
+ }
2462
+ await writePrefsStore(path, forgetPref(await readPrefsStore(path), item));
2463
+ report(`Forgotten for this project: "${item.text}". It is not listed or injected again.`);
2464
+ return;
2465
+ }
2466
+ report(formatPrefs(scan, items));
2242
2467
  return;
2243
2468
  }
2244
2469
  if (action === "recommend") {
package/dist/guard.d.ts CHANGED
@@ -156,6 +156,10 @@ export interface Verdict {
156
156
  plan?: string;
157
157
  /** True when Jev finds the call at odds with the agent's stated plan and the call can change something; the agent is told. */
158
158
  intentMismatch?: boolean;
159
+ /** The intent mismatch is recorded in the trace but the agent is not told (`action.intentTraceOnly`). */
160
+ intentTraceOnly?: boolean;
161
+ /** Index of the trace-only intent-mismatch reason; any prepend must adjust this index. */
162
+ intentTraceOnlyReasonIndex?: number;
159
163
  /** True when Jev finds the call unrelated to the request on a call that can change something. Still steered in the reason log, but the steer message is suppressed until AUC improves above 0.51. */
160
164
  offTaskSteer?: boolean;
161
165
  /** True when should_proceed is below the hold threshold; the agent is told to pause and ask. */