pi-warden 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/extension.js CHANGED
@@ -1,13 +1,18 @@
1
+ import { MouseRegion, Text } from "@earendil-works/pi-tui";
1
2
  import { createTypeSafe, resolveApiKey } from "pi-typesafe";
2
3
  import { ensureApiKey } from "pi-typesafe/ui";
3
4
  import { applyUserOverrides, defaultConfig, isMode, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
4
5
  import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome } from "./done.js";
5
- import { evaluateAction, formatVerdict, steerReason, textApproves } from "./guard.js";
6
+ import { evaluateAction, formatVerdict, SLOP_LABELS, steerReason, textApproves } from "./guard.js";
7
+ import { evaluateProse, proseNudge, ProseTrend } from "./prose.js";
6
8
  import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
9
+ import { openTracePanel } from "./panel.js";
10
+ import { actionDetails, doneDetails, proseDetails, stuckDetails, Trace } from "./trace.js";
11
+ import { proseTokens, renderTemplate, TOKEN_NAMES } from "./widget.js";
7
12
  export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; the last few tool calls and output tails when the agent keeps failing; and the agent's final message when it reports completion without running checks. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
8
13
  const WIDGET = PACKAGE_NAME;
9
14
  const CONFIRM_TEXT_LIMIT = 500;
10
- const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, slop: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, errors: 0 });
15
+ const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, slop: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, errors: 0 });
11
16
  function latestUserPrompt(ctx) {
12
17
  const entries = ctx.sessionManager.getBranch();
13
18
  for (let index = entries.length - 1; index >= 0; index--) {
@@ -45,17 +50,35 @@ export function confirmMessage(verdict) {
45
50
  return lines.join("\n");
46
51
  }
47
52
  const callKey = (tool, input) => `${tool}\0${JSON.stringify(input)}`;
53
+ const SLOP_FIXES = {
54
+ stub: "replace stubs, placeholders, and hard-coded fake data with the working implementation, or state in your reply exactly what is left unimplemented and why",
55
+ comments: "delete comments that restate the code; keep only those that explain intent, constraints, or non-obvious behaviour",
56
+ dead: "remove commented-out code, unused imports and variables, duplicated logic, and unreachable branches",
57
+ hedging: "replace \"should work\", \"for now\", and TODOs without a plan with a definite statement or a concrete follow-up",
58
+ };
59
+ /** Names each symptom and its fix; repeats in the session turn the note into a standing rule. */
60
+ export function slopSteer(where, symptoms, counts) {
61
+ const named = symptoms.map(symptom => `${SLOP_LABELS[symptom]}${counts[symptom] >= 3 ? ` (${counts[symptom]}th time this session)` : ""}`).join("; ");
62
+ const fixes = symptoms.map(symptom => SLOP_FIXES[symptom]).join("; ");
63
+ const standing = symptoms.some(symptom => counts[symptom] >= 3) ? " Treat this as a standing rule for the rest of the session." : "";
64
+ return `pi-warden: the content just written to ${where} has ${named}. Fix it in your next edit: ${fixes}.${standing}`;
65
+ }
48
66
  /** Native Pi registration; importing the root library does not load this module. */
49
67
  export default function wardenExtension(pi) {
50
68
  let client;
51
69
  let budgetExhausted = false;
52
70
  let stats = freshStats();
53
71
  const widget = new Map();
72
+ const trace = new Trace();
73
+ let panelOpen = false;
74
+ let lastUi;
54
75
  /** Calls held in steer mode, with the user prompt current at that time; a retry after the user replies asks Jev about approval. */
55
76
  const held = new Map();
56
77
  let attempts = new AttemptWindow(defaultConfig().stuck.window);
57
78
  let evidence = emptyEvidence();
58
79
  let doneNudged = false;
80
+ const prose = new ProseTrend();
81
+ const slopCounts = { stub: 0, comments: 0, dead: 0, hedging: 0 };
59
82
  const configFor = (ctx) => loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
60
83
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
61
84
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
@@ -71,21 +94,48 @@ export default function wardenExtension(pi) {
71
94
  if (ctx.hasUI)
72
95
  ctx.ui.notify(`warden: ${message}${budgetExhausted ? " Pattern checks continue without TypeSafe for the rest of this session." : ""}`, "warning");
73
96
  };
74
- const show = (ctx, guard, line) => {
97
+ const openPanel = (ui) => {
98
+ if (!ui || panelOpen)
99
+ return;
100
+ panelOpen = true;
101
+ openTracePanel(ui, trace).catch(() => undefined).finally(() => { panelOpen = false; });
102
+ };
103
+ const paint = (ctx, config) => {
104
+ if (!ctx.hasUI)
105
+ return;
106
+ lastUi = ctx.ui;
107
+ if (!config.widget.enabled || widget.size === 0) {
108
+ ctx.ui.setWidget(WIDGET, undefined);
109
+ return;
110
+ }
111
+ const lines = [...widget.values()];
112
+ // A custom component so a click (fullscreen mode) opens the trace panel; plain lines otherwise look the same.
113
+ ctx.ui.setWidget(WIDGET, (_tui, theme) => new MouseRegion(new Text(lines.map(line => theme.fg("muted", line)).join("\n"), 0, 0), event => {
114
+ if (event.type !== "click" || event.button !== "left")
115
+ return undefined;
116
+ openPanel(lastUi);
117
+ return { handled: true };
118
+ }), { placement: config.widget.placement });
119
+ };
120
+ const record = (ctx, config, guard, line, details) => {
75
121
  widget.set(guard, line);
76
- if (ctx.hasUI)
77
- ctx.ui.setWidget(WIDGET, [...widget.values()]);
122
+ trace.push({ at: Date.now(), guard, line, details });
123
+ paint(ctx, config);
78
124
  };
79
- const steer = (content) => pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: true }, { deliverAs: "steer" });
125
+ const steer = (config, content, options = { deliverAs: "steer" }) => pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: config.steerVisible }, options);
80
126
  pi.on("session_start", async (_event, ctx) => {
81
127
  client = undefined;
82
128
  budgetExhausted = false;
83
129
  stats = freshStats();
84
130
  widget.clear();
131
+ trace.clear();
85
132
  held.clear();
86
133
  attempts.reset();
87
134
  evidence = emptyEvidence();
88
135
  doneNudged = false;
136
+ prose.reset();
137
+ for (const symptom of Object.keys(slopCounts))
138
+ slopCounts[symptom] = 0;
89
139
  if (ctx.hasUI)
90
140
  ctx.ui.setWidget(WIDGET, undefined);
91
141
  });
@@ -125,14 +175,18 @@ export default function wardenExtension(pi) {
125
175
  stats.approved++;
126
176
  held.delete(key);
127
177
  }
178
+ const mode = activeMode(config, ctx.hasUI);
179
+ const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
128
180
  if (verdict.source !== "read-only")
129
- show(ctx, "action", formatVerdict(verdict));
130
- if (verdict.slopReasons?.length) {
181
+ record(ctx, config, "action", formatVerdict(verdict, config.widget.action), actionDetails(verdict, { mode, ...(told ? { told } : {}) }));
182
+ if (verdict.slopSymptoms?.length && verdict.slopReasons) {
131
183
  stats.slop++;
184
+ for (const symptom of verdict.slopSymptoms)
185
+ slopCounts[symptom]++;
132
186
  const where = verdict.summary.path ?? event.toolName;
133
187
  if (ctx.hasUI)
134
188
  ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
135
- steer(`pi-warden: the content just written to ${where} reads as ${verdict.slopReasons.join(" and ")}. Replace stubs and placeholders with working code, remove comments that restate the code, and keep only what the request needs. If something is intentionally left unimplemented, say so in your reply instead of leaving it in the code.`);
189
+ steer(config, slopSteer(where, verdict.slopSymptoms, slopCounts));
136
190
  }
137
191
  if (verdict.level === "warn") {
138
192
  stats.warned++;
@@ -142,7 +196,6 @@ export default function wardenExtension(pi) {
142
196
  }
143
197
  if (verdict.level !== "confirm")
144
198
  return undefined;
145
- const mode = activeMode(config, ctx.hasUI);
146
199
  const reasons = verdict.reasons.join("; ");
147
200
  if (mode === "advise") {
148
201
  stats.warned++;
@@ -161,15 +214,15 @@ export default function wardenExtension(pi) {
161
214
  held.set(key, task);
162
215
  if (ctx.hasUI)
163
216
  ctx.ui.notify(`warden · held ${event.toolName}: ${reasons}. The agent was told why and asked to re-plan or ask you.`, "warning");
164
- return { block: true, reason: steerReason(verdict, { canApprove: judge !== undefined }) };
217
+ return { block: true, reason: told ?? steerReason(verdict, { canApprove: judge !== undefined }) };
165
218
  });
166
219
  pi.on("tool_result", async (event, ctx) => {
167
220
  const config = configFor(ctx);
168
221
  if (!config.enabled)
169
222
  return;
170
- const failed = resultFailed(event.isError, event.details);
223
+ const failed = resultFailed(event.isError, event.details, event.content);
171
224
  if (config.done.enabled)
172
- recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed), event.input);
225
+ recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed), event.input, event.toolName);
173
226
  if (!config.stuck.enabled)
174
227
  return;
175
228
  attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
@@ -181,41 +234,75 @@ export default function wardenExtension(pi) {
181
234
  noteError(ctx, verdict.error, undefined);
182
235
  if (verdict.source === "repeat" && !verdict.stuck)
183
236
  return;
184
- show(ctx, "stuck", formatStuck(verdict));
237
+ const nudge = verdict.stuck && config.stuck.nudge ? stuckNudge(verdict) : undefined;
238
+ record(ctx, config, "stuck", formatStuck(verdict, config.widget.stuck), stuckDetails(verdict, attempts.attempts, nudge));
185
239
  if (!verdict.stuck)
186
240
  return;
187
241
  stats.stuck++;
188
242
  if (ctx.hasUI)
189
- ctx.ui.notify(`warden · stuck: ${verdict.reasons.join("; ")}${config.stuck.nudge ? " (agent nudged)" : ""}`, "warning");
190
- if (config.stuck.nudge)
191
- steer(stuckNudge(verdict));
243
+ ctx.ui.notify(`warden · stuck: ${verdict.reasons.join("; ")}${nudge ? " (agent nudged)" : ""}`, "warning");
244
+ if (nudge)
245
+ steer(config, nudge);
192
246
  });
193
247
  pi.on("agent_end", async (event, ctx) => {
194
248
  const config = configFor(ctx);
195
- if (!config.enabled || !config.done.enabled || doneNudged || !needsDoneCheck(evidence))
249
+ if (!config.enabled)
196
250
  return;
197
251
  const finalMessage = finalAssistantText(event.messages);
198
252
  const judge = judgeFor(config);
199
253
  if (!finalMessage || !judge)
200
254
  return;
255
+ if (config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars) {
256
+ stats.proseChecks++;
257
+ const verdict = await evaluateProse(latestUserPrompt(ctx), finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal });
258
+ if (verdict.error)
259
+ noteError(ctx, verdict.error, undefined);
260
+ else
261
+ prose.record(verdict.flagged);
262
+ const due = verdict.error ? [] : prose.due(config.slop.prose.trend);
263
+ const nudge = due.length ? proseNudge(due, config.slop.prose.audience, prose.counts) : undefined;
264
+ if (nudge) {
265
+ verdict.nudged = true;
266
+ prose.markNudged();
267
+ stats.proseNudges++;
268
+ }
269
+ record(ctx, config, "prose", renderTemplate(config.widget.prose, proseTokens(verdict)), proseDetails(verdict, finalMessage, config.slop.prose.audience, nudge));
270
+ if (nudge) {
271
+ if (ctx.hasUI)
272
+ ctx.ui.notify(`warden · prose: ${due.join(", ")} in ${config.slop.prose.trend} of the last 3 replies (agent nudged for the next reply)`, "warning");
273
+ steer(config, nudge, { deliverAs: "nextTurn" });
274
+ }
275
+ }
276
+ if (!config.done.enabled || doneNudged || !needsDoneCheck(evidence))
277
+ return;
201
278
  stats.doneChecks++;
202
279
  const verdict = await evaluateDone(latestUserPrompt(ctx), finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal });
203
280
  if (verdict.error)
204
281
  noteError(ctx, verdict.error, undefined);
205
- show(ctx, "done", formatDone(verdict));
282
+ const nudge = verdict.unverified && config.done.nudge ? doneNudge(verdict) : undefined;
283
+ record(ctx, config, "done", formatDone(verdict, config.widget.done), doneDetails(verdict, finalMessage, nudge));
206
284
  if (!verdict.unverified)
207
285
  return;
208
286
  stats.unverified++;
209
287
  if (ctx.hasUI)
210
- ctx.ui.notify(`warden · done-check: ${verdict.reasons.join("; ")}${config.done.nudge ? " (agent asked to verify)" : ""}`, "warning");
211
- if (config.done.nudge) {
288
+ ctx.ui.notify(`warden · done-check: ${verdict.reasons.join("; ")}${nudge ? " (agent asked to verify)" : ""}`, "warning");
289
+ if (nudge) {
212
290
  doneNudged = true;
213
- pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content: doneNudge(verdict), display: true }, { deliverAs: "followUp", triggerTurn: true });
291
+ steer(config, nudge, { deliverAs: "followUp", triggerTurn: true });
214
292
  }
215
293
  });
216
- const actions = ["status", "enable", "disable", "mode", "config", "test"];
294
+ // Shortcuts are registered once at load; a shape check keeps a typo in the config file from being registered.
295
+ const shortcut = loadConfig().widget.shortcut;
296
+ if (/^(?:(?:ctrl|shift|alt|super)\+)+[a-z0-9]+$|^f\d{1,2}$/i.test(shortcut)) {
297
+ pi.registerShortcut(shortcut, {
298
+ description: "Open the pi-warden trace panel",
299
+ handler: async (ctx) => { if (ctx.hasUI)
300
+ openPanel(ctx.ui); },
301
+ });
302
+ }
303
+ const actions = ["status", "enable", "disable", "mode", "config", "test", "trace"];
217
304
  pi.registerCommand("warden", {
218
- description: "pi-warden status, TypeSafe consent, steer/confirm/advise mode, config editor, and a synthetic guard test",
305
+ description: "pi-warden status, TypeSafe consent, steer/confirm/advise mode, config editor, trace panel, and a synthetic guard test",
219
306
  getArgumentCompletions(prefix) {
220
307
  const matches = actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
221
308
  return matches.length ? matches : null;
@@ -234,16 +321,26 @@ export default function wardenExtension(pi) {
234
321
  const key = resolveApiKey();
235
322
  const source = consentSource(config);
236
323
  const usage = client?.getUsage();
237
- const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop"].filter(Boolean).join(", ");
324
+ 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})`].filter(Boolean).join(", ");
238
325
  report([
239
326
  `pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `enabled via ${source}` : "disabled (run /warden enable)"}; key ${key ? key.source === "stored" ? "stored (shared with pi-typesafe)" : "from TYPESAFE_API_KEY" : "missing (run /warden enable)"}.`,
240
- `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.slop} slop notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests.`,
241
- `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / hold ${config.action.offTask.confirm}; stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop quality ${config.slop.quality}, stub ${config.slop.placeholder}; failOpen ${config.action.failOpen}.`,
327
+ `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.slop} slop notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests. Steers are ${config.steerVisible ? "shown in the transcript" : "hidden from the transcript (trace panel shows them)"}.`,
328
+ `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / hold ${config.action.offTask.confirm}; stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, prose ${config.slop.prose.threshold} in ${config.slop.prose.trend}/3 replies; failOpen ${config.action.failOpen}.`,
242
329
  `Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
243
330
  widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
331
+ `Trace: ${trace.entries().length} events (/warden trace${shortcut ? `, ${shortcut}` : ""}, or click the status line in fullscreen mode). Widget templates in config.widget: action tokens ${TOKEN_NAMES.action.map(name => `{${name}}`).join(" ")}.`,
244
332
  ].join(" "));
245
333
  return;
246
334
  }
335
+ if (action === "trace") {
336
+ if (!ctx.hasUI) {
337
+ const entries = trace.entries();
338
+ report(entries.length ? entries.slice(-20).map(entry => `${new Date(entry.at).toTimeString().slice(0, 8)} ${entry.guard}: ${entry.line}${entry.details.length ? `\n ${entry.details.join("\n ")}` : ""}`).join("\n") : "No guarded activity yet this session.");
339
+ return;
340
+ }
341
+ openPanel(ctx.ui);
342
+ return;
343
+ }
247
344
  if (action === "enable") {
248
345
  if (!ctx.hasUI) {
249
346
  report("Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and TYPESAFE_API_KEY explicitly.", "warning");
@@ -310,7 +407,7 @@ export default function wardenExtension(pi) {
310
407
  if (judge && ctx.hasUI && !await ctx.ui.confirm("Send one synthetic pi-warden test request?", `A synthetic action ("rm -rf /tmp/pi-warden-demo" for the task "Clean up the demo directory") goes to api.typesafe.ai and may incur charges. ${disclosure}`))
311
408
  return;
312
409
  const verdict = await evaluateAction({ tool: "bash", input: { command: "rm -rf /tmp/pi-warden-demo" }, cwd: ctx.cwd, task: "Clean up the demo directory" }, { config: { ...config.action, enabled: true, tools: ["bash"] }, judge });
313
- show(ctx, "action", formatVerdict(verdict));
410
+ record(ctx, config, "action", formatVerdict(verdict, config.widget.action), actionDetails(verdict, { mode: activeMode(config, ctx.hasUI) }));
314
411
  report(`${formatVerdict(verdict)}${verdict.reasons.length ? ` — ${verdict.reasons.join("; ")}` : ""}${judge ? "" : " (pattern checks only: TypeSafe judgments are not enabled or no key is configured)"}${verdict.error ? ` — ${verdict.error}` : ""}`);
315
412
  if (verdict.level === "confirm") {
316
413
  const mode = activeMode(config, ctx.hasUI);
package/dist/guard.d.ts CHANGED
@@ -42,12 +42,15 @@ export interface Judgment {
42
42
  model: string;
43
43
  elapsedMs: number;
44
44
  }
45
+ /** One probability per slop symptom; the steer names the symptoms above the threshold. */
45
46
  export interface SlopJudgment {
46
- /** 0 focused … 2 sloppy. */
47
- quality: number;
48
- /** P(placeholder or stub where working code is needed). */
49
- placeholder: number;
47
+ stub: number;
48
+ comments: number;
49
+ dead: number;
50
+ hedging: number;
50
51
  }
52
+ export type SlopSymptom = keyof SlopJudgment;
53
+ export declare const SLOP_SYMPTOMS: readonly SlopSymptom[];
51
54
  export interface Verdict {
52
55
  level: Level;
53
56
  source: "skipped" | "read-only" | "pattern" | "typesafe" | "error";
@@ -57,7 +60,8 @@ export interface Verdict {
57
60
  reasons: string[];
58
61
  judgment?: Judgment;
59
62
  slop?: SlopJudgment;
60
- /** Set when the slop thresholds were crossed; the level itself is never raised by slop. */
63
+ /** Symptoms at or above the slop threshold, strongest first. The level itself is never raised by slop. */
64
+ slopSymptoms?: SlopSymptom[];
61
65
  slopReasons?: string[];
62
66
  /** True when a previously held call was allowed because the user's latest message approves it. */
63
67
  approvedByUser?: boolean;
@@ -91,9 +95,12 @@ export declare const questions: {
91
95
  }>;
92
96
  };
93
97
  export declare const slopQuestions: {
94
- slop_quality: import("pi-typesafe").ScoreQuestion<readonly ["Focused: does what `task` needs, clear names, comments only where they add information", "Some filler: comments that restate the code, minor dead code, hedging or repeated text", "Sloppy: placeholder or stub code, TODO where a working implementation is needed, duplicated or commented-out logic, vague or contradictory text"]>;
95
- slop_placeholder: import("pi-typesafe").NoulQuestion;
98
+ slop_stub: import("pi-typesafe").NoulQuestion;
99
+ slop_comments: import("pi-typesafe").NoulQuestion;
100
+ slop_dead: import("pi-typesafe").NoulQuestion;
101
+ slop_hedging: import("pi-typesafe").NoulQuestion;
96
102
  };
103
+ export declare const SLOP_LABELS: Record<SlopSymptom, string>;
97
104
  export declare const approvalQuestion: {
98
105
  approved: import("pi-typesafe").NoulQuestion;
99
106
  };
@@ -107,8 +114,10 @@ export declare function buildRequest(summary: ActionSummary, task: string | unde
107
114
  };
108
115
  questions: {
109
116
  approved?: import("pi-typesafe").NoulQuestion;
110
- slop_quality?: import("pi-typesafe").ScoreQuestion<readonly ["Focused: does what `task` needs, clear names, comments only where they add information", "Some filler: comments that restate the code, minor dead code, hedging or repeated text", "Sloppy: placeholder or stub code, TODO where a working implementation is needed, duplicated or commented-out logic, vague or contradictory text"]>;
111
- slop_placeholder?: import("pi-typesafe").NoulQuestion;
117
+ slop_stub?: import("pi-typesafe").NoulQuestion;
118
+ slop_comments?: import("pi-typesafe").NoulQuestion;
119
+ slop_dead?: import("pi-typesafe").NoulQuestion;
120
+ slop_hedging?: import("pi-typesafe").NoulQuestion;
112
121
  irreversible: import("pi-typesafe").NoulQuestion;
113
122
  off_task: import("pi-typesafe").NoulQuestion;
114
123
  scope: import("pi-typesafe").ChoiceQuestion<{
@@ -129,5 +138,5 @@ export declare function textApproves(task: string | undefined): boolean;
129
138
  export declare function steerReason(verdict: Verdict, options: {
130
139
  canApprove: boolean;
131
140
  }): string;
132
- /** One-line rendering for widgets and logs. Includes no command text. */
133
- export declare function formatVerdict(verdict: Verdict): string;
141
+ /** One-line rendering for widgets and logs. Includes no command text. Templates: see widget.ts. */
142
+ export declare function formatVerdict(verdict: Verdict, template?: string): string;
package/dist/guard.js CHANGED
@@ -3,6 +3,9 @@ import { homedir } from "node:os";
3
3
  import { isAbsolute, relative, resolve, sep } from "node:path";
4
4
  import { choice, noul, score, TypeSafeIntegrationError } from "pi-typesafe";
5
5
  import { redact } from "./redact.js";
6
+ import { commandOf } from "./tools.js";
7
+ import { actionTokens, DEFAULT_TEMPLATES, renderTemplate } from "./widget.js";
8
+ export const SLOP_SYMPTOMS = ["stub", "comments", "dead", "hedging"];
6
9
  const LEVEL_RANK = { allow: 0, warn: 1, confirm: 2 };
7
10
  const higher = (a, b) => (LEVEL_RANK[a] >= LEVEL_RANK[b] ? a : b);
8
11
  const TASK_LIMIT = 1500;
@@ -12,6 +15,16 @@ const EDIT_LIMIT = 400;
12
15
  function truncate(text, limit) {
13
16
  return text.length <= limit ? text : `${text.slice(0, limit)}… [${text.length - limit} more chars]`;
14
17
  }
18
+ /** Head, a slice from the middle, and the tail, so stubs at the end of a long file are still seen. */
19
+ function sample(text, limit) {
20
+ if (text.length <= limit)
21
+ return text;
22
+ const head = Math.floor(limit * 0.6);
23
+ const mid = Math.floor(limit * 0.2);
24
+ const tail = limit - head - mid;
25
+ const middleStart = Math.floor(text.length / 2 - mid / 2);
26
+ return `${text.slice(0, head)}\n… [${middleStart - head} chars] …\n${text.slice(middleStart, middleStart + mid)}\n… [${text.length - tail - (middleStart + mid)} chars] …\n${text.slice(-tail)}`;
27
+ }
15
28
  const SHELL_RULES = [
16
29
  { id: "git-force-push", severity: "destructive", label: "git force push", test: /\bgit\s+push\b[^\n;&|]*\s(?:-f|--force)(?![-\w])/ },
17
30
  { id: "git-force-with-lease", severity: "risky", label: "git push --force-with-lease", test: /\bgit\s+push\b[^\n;&|]*--force-with-lease/ },
@@ -72,7 +85,7 @@ export function matchPatterns(tool, input, cwd) {
72
85
  const hits = new Map();
73
86
  const add = (hit) => { if (hit && !hits.has(hit.id))
74
87
  hits.set(hit.id, hit); };
75
- const command = typeof input.command === "string" ? input.command : undefined;
88
+ const command = commandOf(tool, input)?.command;
76
89
  if (command) {
77
90
  for (const rule of SHELL_RULES)
78
91
  if (rule.test.test(command))
@@ -159,9 +172,10 @@ function displayPath(target, cwd) {
159
172
  }
160
173
  export function describeAction(tool, input, cwd) {
161
174
  const summary = { tool };
162
- if (typeof input.command === "string")
163
- summary.command = redact(truncate(input.command, COMMAND_LIMIT));
164
- if (typeof input.path === "string" && input.path.trim()) {
175
+ const view = commandOf(tool, input);
176
+ if (view)
177
+ summary.command = redact(truncate(view.command, COMMAND_LIMIT));
178
+ if (typeof input.path === "string" && input.path.trim() && tool !== "ctx_execute_file") {
165
179
  const shown = displayPath(input.path, cwd);
166
180
  summary.path = shown.path;
167
181
  summary.location = shown.location;
@@ -169,7 +183,7 @@ export function describeAction(tool, input, cwd) {
169
183
  }
170
184
  if (tool === "write" && typeof input.content === "string") {
171
185
  summary.bytes = Buffer.byteLength(input.content, "utf8");
172
- summary.excerpt = redact(truncate(input.content, EXCERPT_LIMIT));
186
+ summary.excerpt = redact(sample(input.content, EXCERPT_LIMIT));
173
187
  }
174
188
  if (tool === "edit" && Array.isArray(input.edits)) {
175
189
  summary.editCount = input.edits.length;
@@ -204,12 +218,28 @@ export const questions = {
204
218
  }),
205
219
  };
206
220
  export const slopQuestions = {
207
- slop_quality: score("How does the new code or text that `action` writes read as a change for `task`?", [
208
- "Focused: does what `task` needs, clear names, comments only where they add information",
209
- "Some filler: comments that restate the code, minor dead code, hedging or repeated text",
210
- "Sloppy: placeholder or stub code, TODO where a working implementation is needed, duplicated or commented-out logic, vague or contradictory text",
211
- ]),
212
- slop_placeholder: noul("Does the content `action` writes leave placeholder, stub, mock, or 'implement later' code where `task` needs a working implementation?"),
221
+ slop_stub: noul("Does the content `action` writes leave placeholder, stub, mock, or \"implement later\" code where `task` needs a working implementation?", {
222
+ true: "Yes: a function returns a constant, null, or fake data instead of doing its job; a TODO or \"implement later\" stands where the logic should be; a mock is hard-coded where a real call is needed.",
223
+ false: "No: the code does what `task` asks, or the incomplete part is clearly outside what `task` asked for.",
224
+ }),
225
+ slop_comments: noul("Do the explanatory comments in the content `action` writes mostly restate what the adjacent code already shows, instead of explaining intent, constraints, or non-obvious behaviour? Commented-out code is not an explanatory comment and is judged elsewhere.", {
226
+ true: "Yes: comments such as \"// increment the counter\" above counter++, \"// return the result\", \"// loop over items\", banners repeating the function name, or doc comments that only repeat parameter names.",
227
+ false: "No: there are few or no comments, or the comments explain why, an invariant, a workaround, a limitation, a reference, or a decision a reader could not infer from the code.",
228
+ }),
229
+ slop_dead: noul("Does the content `action` writes include dead or redundant code: commented-out code, unused imports or variables, duplicated logic, or branches that cannot be reached?", {
230
+ true: "Yes: blocks of commented-out code, variables or imports that are never used, the same logic written twice, or checks that are always true or false.",
231
+ false: "No: every line participates in the behaviour.",
232
+ }),
233
+ slop_hedging: noul("Does the content `action` writes contain vague or hedging text: \"this should work\", \"for now\", \"might need changes\", TODO notes without a plan, or explanations that say nothing specific?", {
234
+ true: "Yes: uncertain or apologetic notes in code or docs, placeholders like \"TBD\", or prose that does not commit to what the code does.",
235
+ false: "No: the text states what the code does and why, or there is no such text.",
236
+ }),
237
+ };
238
+ export const SLOP_LABELS = {
239
+ stub: "stub or placeholder code where a working implementation is needed",
240
+ comments: "comments that restate the code",
241
+ dead: "dead or duplicated code",
242
+ hedging: "hedging or vague notes",
213
243
  };
214
244
  export const approvalQuestion = {
215
245
  approved: noul("Does `task` (the user's latest message) explicitly approve running `action`, which was held earlier for the user's decision?", {
@@ -260,7 +290,8 @@ export async function evaluateAction(action, options) {
260
290
  reasons.push(`${action.tool === "write" ? "creates" : "changes"} a file outside the project`);
261
291
  }
262
292
  }
263
- if (action.tool === "bash" && patterns.length === 0 && typeof action.input.command === "string" && isReadOnlyCommand(action.input.command)) {
293
+ const view = commandOf(action.tool, action.input);
294
+ if (view?.shell && patterns.length === 0 && isReadOnlyCommand(view.command)) {
264
295
  return { level, source: "read-only", summary, patterns, reasons };
265
296
  }
266
297
  if (!judge)
@@ -296,15 +327,13 @@ export async function evaluateAction(action, options) {
296
327
  reasons.push(`off-task ${percent(judgment.offTask)} (${judgment.scope.replace(/_/g, " ")})`);
297
328
  }
298
329
  const verdict = { level, source: "typesafe", summary, patterns, reasons, judgment };
299
- if (options.slop?.enabled && typeof answers.slop_quality?.score === "number" && typeof answers.slop_placeholder?.noul === "number") {
300
- verdict.slop = { quality: answers.slop_quality.score, placeholder: answers.slop_placeholder.noul };
301
- const slopReasons = [];
302
- if (verdict.slop.quality >= options.slop.quality)
303
- slopReasons.push(`quality ${verdict.slop.quality.toFixed(2)}/2 (filler or sloppy)`);
304
- if (verdict.slop.placeholder >= options.slop.placeholder)
305
- slopReasons.push(`placeholder or stub code ${percent(verdict.slop.placeholder)}`);
306
- if (slopReasons.length)
307
- verdict.slopReasons = slopReasons;
330
+ if (options.slop?.enabled && SLOP_SYMPTOMS.every(symptom => typeof answers[`slop_${symptom}`]?.noul === "number")) {
331
+ verdict.slop = { stub: answers.slop_stub.noul, comments: answers.slop_comments.noul, dead: answers.slop_dead.noul, hedging: answers.slop_hedging.noul };
332
+ const flagged = SLOP_SYMPTOMS.filter(symptom => verdict.slop[symptom] >= options.slop.threshold).sort((a, b) => verdict.slop[b] - verdict.slop[a]);
333
+ if (flagged.length) {
334
+ verdict.slopSymptoms = flagged;
335
+ verdict.slopReasons = flagged.map(symptom => `${SLOP_LABELS[symptom]} (${percent(verdict.slop[symptom])})`);
336
+ }
308
337
  }
309
338
  if (level === "confirm" && judgment.approved !== undefined && judgment.approved >= APPROVAL_THRESHOLD) {
310
339
  verdict.level = "allow";
@@ -346,21 +375,7 @@ export function steerReason(verdict, options) {
346
375
  lines.push("pi-warden allows the same call again once the user has replied with approval.");
347
376
  return lines.join(" ");
348
377
  }
349
- /** One-line rendering for widgets and logs. Includes no command text. */
350
- export function formatVerdict(verdict) {
351
- const parts = [`warden · ${verdict.summary.tool}`];
352
- if (verdict.judgment)
353
- parts.push(`irreversible ${percent(verdict.judgment.irreversible)}`, `off-task ${percent(verdict.judgment.offTask)}`, verdict.judgment.scope.replace(/_/g, " "));
354
- if (verdict.slop)
355
- parts.push(`slop ${verdict.slop.quality.toFixed(1)}/2`, `stub ${percent(verdict.slop.placeholder)}`);
356
- if (verdict.patterns.length)
357
- parts.push(`patterns: ${verdict.patterns.map(hit => hit.id).join(", ")}`);
358
- if (verdict.approvedByUser)
359
- parts.push("user approved");
360
- if (verdict.source === "error")
361
- parts.push("typesafe error");
362
- if (verdict.source === "read-only")
363
- parts.push("read-only");
364
- parts.push(verdict.level);
365
- return parts.join(" · ");
378
+ /** One-line rendering for widgets and logs. Includes no command text. Templates: see widget.ts. */
379
+ export function formatVerdict(verdict, template = DEFAULT_TEMPLATES.action) {
380
+ return renderTemplate(template, actionTokens(verdict));
366
381
  }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, textApproves, questions, slopQuestions, approvalQuestion } from "./guard.js";
2
- export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, SlopJudgment, Level, PatternHit, ScopeLabel, Severity, Verdict } from "./guard.js";
2
+ export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, SlopJudgment, SlopSymptom, Level, PatternHit, ScopeLabel, Severity, Verdict } from "./guard.js";
3
+ export { SLOP_SYMPTOMS, SLOP_LABELS } from "./guard.js";
4
+ export { evaluateProse, buildProseRequest, proseQuestions, proseNudge, describeAudience, ProseTrend, PROSE_SYMPTOMS, PROSE_LABELS } from "./prose.js";
5
+ export type { ProseSymptom, ProseVerdict, ProseOptions } from "./prose.js";
3
6
  export { AttemptWindow, makeAttempt, resultFailed, resultText, evaluateStuck, buildStuckRequest, stuckQuestions, stuckNudge, formatStuck } from "./stuck.js";
4
7
  export type { Attempt, StuckJudgment, StuckVerdict, StuckOptions } from "./stuck.js";
5
8
  export { classifyToolResult, recordOutcome, emptyEvidence, needsDoneCheck, finalAssistantText, evaluateDone, buildDoneRequest, doneQuestions, doneNudge, formatDone } from "./done.js";
6
9
  export type { ToolOutcome, RunEvidence, DoneJudgment, DoneVerdict, DoneOptions } from "./done.js";
7
10
  export { redact } from "./redact.js";
8
11
  export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
9
- export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
12
+ export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
13
+ export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
14
+ export type { CommandView } from "./tools.js";
15
+ export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
16
+ export type { WidgetConfig, WidgetPlacement, Tokens } from "./widget.js";
17
+ export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails } from "./trace.js";
18
+ export type { TraceEntry, GuardName } from "./trace.js";
package/dist/index.js CHANGED
@@ -1,5 +1,10 @@
1
1
  export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, textApproves, questions, slopQuestions, approvalQuestion } from "./guard.js";
2
+ export { SLOP_SYMPTOMS, SLOP_LABELS } from "./guard.js";
3
+ export { evaluateProse, buildProseRequest, proseQuestions, proseNudge, describeAudience, ProseTrend, PROSE_SYMPTOMS, PROSE_LABELS } from "./prose.js";
2
4
  export { AttemptWindow, makeAttempt, resultFailed, resultText, evaluateStuck, buildStuckRequest, stuckQuestions, stuckNudge, formatStuck } from "./stuck.js";
3
5
  export { classifyToolResult, recordOutcome, emptyEvidence, needsDoneCheck, finalAssistantText, evaluateDone, buildDoneRequest, doneQuestions, doneNudge, formatDone } from "./done.js";
4
6
  export { redact } from "./redact.js";
5
7
  export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
8
+ export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
9
+ export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
10
+ export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails } from "./trace.js";
@@ -0,0 +1,44 @@
1
+ import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
2
+ import type { Trace } from "./trace.js";
3
+ interface ThemeLike {
4
+ fg(color: string, text: string): string;
5
+ bold(text: string): string;
6
+ }
7
+ /**
8
+ * Side panel listing the trace newest-first, live-updating while open. Keys: ↑/↓/j/k scroll a line, PgUp/PgDn a page,
9
+ * Home/End jump, c clears the trace, Esc or q closes. Wheel scrolling works in fullscreen mode.
10
+ */
11
+ export declare class TracePanel implements Component {
12
+ private readonly trace;
13
+ private readonly theme;
14
+ private readonly close;
15
+ private readonly requestRender;
16
+ private readonly title;
17
+ focused: boolean;
18
+ private scroll;
19
+ private viewport;
20
+ private readonly unsubscribe;
21
+ constructor(trace: Trace, theme: ThemeLike, close: () => void, requestRender: () => void, title?: string);
22
+ dispose(): void;
23
+ /** Rendering is cheap and derived from the trace each time; nothing is cached. */
24
+ invalidate(): void;
25
+ private lines;
26
+ render(width: number): string[];
27
+ /** The overlay tells us how tall we may be through the layout; fall back to a fixed viewport otherwise. */
28
+ setViewport(rows: number): void;
29
+ handleInput(data: string): void;
30
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
31
+ }
32
+ export interface PanelUi {
33
+ custom<T>(factory: (tui: {
34
+ requestRender(): void;
35
+ terminal?: {
36
+ rows: number;
37
+ };
38
+ }, theme: ThemeLike, keybindings: unknown, done: (result: T) => void) => Component & {
39
+ dispose?(): void;
40
+ }, options?: Record<string, unknown>): Promise<T>;
41
+ }
42
+ /** Open the trace as a right-hand overlay. Resolves when the user closes it. */
43
+ export declare function openTracePanel(ui: PanelUi, trace: Trace): Promise<void>;
44
+ export {};