pi-warden 0.7.0 → 0.9.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/config.js CHANGED
@@ -9,7 +9,7 @@ export function isRecallTool(value) {
9
9
  }
10
10
  export const PACKAGE_NAME = "pi-warden";
11
11
  /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
12
- export const CONFIG_SCHEMA = 3;
12
+ export const CONFIG_SCHEMA = 5;
13
13
  export const PROJECT_CONFIG_FILE = `${PACKAGE_NAME}.json`;
14
14
  export function defaultConfig() {
15
15
  return {
@@ -30,7 +30,10 @@ export function defaultConfig() {
30
30
  done: { enabled: true, claimsDone: 0.7, nudge: true },
31
31
  slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
32
32
  security: { enabled: true, threshold: 0.7 },
33
+ rules: { enabled: true, threshold: 0.7, files: [], fallback: true, maxChars: 8000, exclude: [], skip: [], sensitivePaths: {} },
33
34
  context: { enabled: true, tailMinChars: 12000, confidence: 0.8, duplicateMinChars: 2000, recallTool: "auto", formatConfidence: 0.7 },
35
+ runaway: { enabled: true, repeats: 4, thinkingRepeats: 10, minChars: 400, recover: true },
36
+ notify: { enabled: true, cooldownMs: 10000, command: [] },
34
37
  widget: defaultWidgetConfig(),
35
38
  steerVisible: false,
36
39
  };
@@ -104,6 +107,26 @@ function applyStuck(base, raw) {
104
107
  nudge: boolean(raw.nudge, base.nudge),
105
108
  };
106
109
  }
110
+ function applyRunaway(base, raw) {
111
+ if (!isObject(raw))
112
+ return base;
113
+ return {
114
+ enabled: boolean(raw.enabled, base.enabled),
115
+ // One occurrence is not a repeat, so the floor is 2.
116
+ repeats: Math.max(2, positiveInteger(raw.repeats, base.repeats)),
117
+ thinkingRepeats: Math.max(2, positiveInteger(raw.thinkingRepeats, base.thinkingRepeats)),
118
+ minChars: positiveInteger(raw.minChars, base.minChars),
119
+ recover: boolean(raw.recover, base.recover),
120
+ };
121
+ }
122
+ function applyNotify(base, raw, allowCommand) {
123
+ if (!isObject(raw))
124
+ return base;
125
+ const cooldown = typeof raw.cooldownMs === "number" && Number.isSafeInteger(raw.cooldownMs) && raw.cooldownMs >= 0 ? raw.cooldownMs : base.cooldownMs;
126
+ const command = allowCommand && Array.isArray(raw.command) && raw.command.every((arg) => typeof arg === "string") && (raw.command.length === 0 || raw.command[0].trim())
127
+ ? [...raw.command] : base.command;
128
+ return { enabled: boolean(raw.enabled, base.enabled), cooldownMs: cooldown, command };
129
+ }
107
130
  function applyDone(base, raw) {
108
131
  if (!isObject(raw))
109
132
  return base;
@@ -126,6 +149,26 @@ function applySlop(base, raw) {
126
149
  // 0.2.x used `placeholder` for the stub threshold; it still sets the shared threshold.
127
150
  return { enabled: boolean(raw.enabled, base.enabled), threshold: probability(raw.threshold ?? raw.placeholder, base.threshold), prose: applyProse(base.prose, raw.prose) };
128
151
  }
152
+ function globList(value, fallback) {
153
+ return Array.isArray(value) ? value.filter((glob) => typeof glob === "string" && glob.trim().length > 0).map(glob => glob.trim()) : fallback;
154
+ }
155
+ function applyRules(base, raw) {
156
+ if (!isObject(raw))
157
+ return base;
158
+ const notes = isObject(raw.sensitivePaths)
159
+ ? Object.fromEntries(Object.entries(raw.sensitivePaths).filter((entry) => entry[0].trim().length > 0 && typeof entry[1] === "string" && entry[1].trim().length > 0))
160
+ : base.sensitivePaths;
161
+ return {
162
+ enabled: boolean(raw.enabled, base.enabled),
163
+ threshold: probability(raw.threshold, base.threshold),
164
+ files: globList(raw.files, base.files),
165
+ fallback: boolean(raw.fallback, base.fallback),
166
+ maxChars: Math.max(500, positiveInteger(raw.maxChars, base.maxChars)),
167
+ exclude: globList(raw.exclude, base.exclude),
168
+ skip: globList(raw.skip, base.skip),
169
+ sensitivePaths: notes,
170
+ };
171
+ }
129
172
  function applyWidget(base, raw) {
130
173
  if (!isObject(raw))
131
174
  return base;
@@ -142,6 +185,8 @@ function applyWidget(base, raw) {
142
185
  prose: template(raw.prose, base.prose),
143
186
  security: template(raw.security, base.security),
144
187
  context: template(raw.context, base.context),
188
+ runaway: template(raw.runaway, base.runaway),
189
+ rules: template(raw.rules, base.rules),
145
190
  };
146
191
  }
147
192
  /** Shared request settings; `action.timeoutMs`/`action.maxRequests` from 0.1.x files are still honoured. */
@@ -152,8 +197,12 @@ function applyShared(base, raw) {
152
197
  maxRequests: positiveInteger(raw.maxRequests ?? legacy.maxRequests, base.maxRequests),
153
198
  };
154
199
  }
155
- function applyGuards(base, raw, timeoutMs) {
200
+ function applyGuards(base, raw, timeoutMs, source) {
156
201
  return {
202
+ rules: applyRules(base.rules, raw.rules),
203
+ runaway: applyRunaway(base.runaway, raw.runaway),
204
+ // A project file may switch notifications off or on, but never names a command to run.
205
+ notify: applyNotify(base.notify, raw.notify, source === "user"),
157
206
  action: applyAction(base.action, raw.action, timeoutMs),
158
207
  stuck: applyStuck(base.stuck, raw.stuck),
159
208
  done: applyDone(base.done, raw.done),
@@ -182,7 +231,7 @@ export function applyUserOverrides(base, raw) {
182
231
  typesafe: boolean(raw.typesafe, base.typesafe),
183
232
  mode: isMode(raw.mode) ? raw.mode : base.mode,
184
233
  ...shared,
185
- ...applyGuards(base, raw, shared.timeoutMs),
234
+ ...applyGuards(base, raw, shared.timeoutMs, "user"),
186
235
  widget: applyWidget(base.widget, raw.widget),
187
236
  steerVisible: boolean(raw.steerVisible, base.steerVisible),
188
237
  };
@@ -191,7 +240,7 @@ export function applyUserOverrides(base, raw) {
191
240
  export function applyProjectOverrides(base, raw) {
192
241
  if (!isObject(raw))
193
242
  return base;
194
- return { ...base, enabled: boolean(raw.enabled, base.enabled), ...applyGuards(base, raw, base.timeoutMs) };
243
+ return { ...base, enabled: boolean(raw.enabled, base.enabled), ...applyGuards(base, raw, base.timeoutMs, "project") };
195
244
  }
196
245
  export function loadConfig(options = {}) {
197
246
  let config = applyUserOverrides(defaultConfig(), readJson(userConfigPath()));
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { ToolCallRef } from "./action-guard.js";
3
3
  import type { SlopSymptom, Verdict } from "./guard.js";
4
- 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; 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; and redacted tool-output samples for security and context saving (retention and output format). Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
4
+ 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; 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; and redacted tool-output samples for security and context saving (retention and output format). Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine. 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
5
  /**
6
6
  * Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
7
7
  * so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
package/dist/extension.js CHANGED
@@ -10,16 +10,19 @@ import { evaluateProse, proseNudge, ProseTrend } from "./prose.js";
10
10
  import { compressOutput, duplicateNote, evaluateOutput, outputKey, saveOutput, securityNotice } from "./output.js";
11
11
  import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
12
12
  import { redact } from "./redact.js";
13
+ import { formatRules, pathNoteSteer, RulesGuard, rulesSteer } from "./rules.js";
14
+ import { detectNotifier, sendNotification } from "./notify.js";
15
+ import { formatRunaway, RunawayMonitor, runawayNudge } from "./runaway.js";
13
16
  import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
14
17
  import { openTracePanel } from "./panel.js";
15
18
  import { completeConfig, shapeWarning } from "./shape.js";
16
19
  import { ContextLedger, formatLedger } from "./saver.js";
17
- import { actionDetails, doneDetails, proseDetails, stuckDetails, Trace } from "./trace.js";
20
+ import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
18
21
  import { proseTokens, renderTemplate, TOKEN_NAMES } from "./widget.js";
19
- export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; 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; and redacted tool-output samples for security and context saving (retention and output format). Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
22
+ export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; 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; and redacted tool-output samples for security and context saving (retention and output format). Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
20
23
  const WIDGET = PACKAGE_NAME;
21
24
  const CONFIRM_TEXT_LIMIT = 500;
22
- 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 });
25
+ const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 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 });
23
26
  function latestUserPrompt(ctx) {
24
27
  const entries = ctx.sessionManager.getBranch();
25
28
  for (let index = entries.length - 1; index >= 0; index--) {
@@ -122,14 +125,22 @@ export default function wardenExtension(pi) {
122
125
  let panel;
123
126
  let lastUi;
124
127
  const actionGuard = new ActionGuard();
128
+ const rulesGuard = new RulesGuard();
125
129
  let attempts = new AttemptWindow(defaultConfig().stuck.window);
126
130
  let evidence = emptyEvidence();
127
131
  let doneNudged = false;
128
132
  const prose = new ProseTrend();
129
133
  const slopCounts = { stub: 0, comments: 0, dead: 0, hedging: 0 };
130
134
  const ledger = new ContextLedger();
135
+ const runaway = new RunawayMonitor();
136
+ // Runs stopped by the runaway guard for the current user prompt; the first one gets a recovery turn, later ones wait for the user.
137
+ let runawayStops = 0;
138
+ let pendingRunaway;
131
139
  // Probed once per session, outside any tool_result handler; the footer under excerpts names this command.
132
140
  let searchTool;
141
+ // Desktop notifier, probed on first use; undefined after the probe means this machine has no route to the desktop.
142
+ let notifier;
143
+ let lastNotifiedAt = 0;
133
144
  // A partially updated module graph can hand this build a config without the sections it expects; see shape.ts.
134
145
  let shapeReported = false;
135
146
  const configFor = (ctx) => {
@@ -195,6 +206,24 @@ export default function wardenExtension(pi) {
195
206
  paint(ctx, config);
196
207
  };
197
208
  const steer = (config, content, options = { deliverAs: "steer" }) => pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: config.steerVisible }, options);
209
+ /**
210
+ * Desktop notification for a moment that needs the user back at the terminal. Interactive sessions only: a headless run
211
+ * or a subagent has nobody to call, and several of them would flood the desktop. Fire-and-forget; failures are silent.
212
+ */
213
+ const notifyDesktop = (ctx, config, body) => {
214
+ if (!ctx.hasUI || !config.notify.enabled)
215
+ return;
216
+ const now = Date.now();
217
+ if (now - lastNotifiedAt < config.notify.cooldownMs)
218
+ return;
219
+ lastNotifiedAt = now;
220
+ if (config.notify.command.length) {
221
+ void sendNotification(config.notify.command, { title: "pi-warden", body }).catch(() => false);
222
+ return;
223
+ }
224
+ notifier ??= detectNotifier();
225
+ void notifier.then(name => (name ? sendNotification(name, { title: "pi-warden", body }) : false)).catch(() => false);
226
+ };
198
227
  pi.on("session_start", async (_event, ctx) => {
199
228
  client = undefined;
200
229
  budgetExhausted = false;
@@ -203,12 +232,18 @@ export default function wardenExtension(pi) {
203
232
  trace.clear();
204
233
  panel?.close();
205
234
  actionGuard.reset();
235
+ rulesGuard.reset();
206
236
  attempts.reset();
207
237
  evidence = emptyEvidence();
208
238
  doneNudged = false;
209
239
  prose.reset();
210
240
  ledger.reset();
241
+ runaway.reset();
242
+ runawayStops = 0;
243
+ pendingRunaway = undefined;
211
244
  searchTool = undefined;
245
+ notifier = undefined;
246
+ lastNotifiedAt = 0;
212
247
  for (const symptom of Object.keys(slopCounts))
213
248
  slopCounts[symptom] = 0;
214
249
  if (ctx.hasUI)
@@ -218,7 +253,39 @@ export default function wardenExtension(pi) {
218
253
  pi.on("before_agent_start", async (_event, ctx) => {
219
254
  attempts = new AttemptWindow(configFor(ctx).stuck.window);
220
255
  doneNudged = false;
256
+ runaway.reset();
257
+ runawayStops = 0;
258
+ pendingRunaway = undefined;
221
259
  actionGuard.turnEnd();
260
+ rulesGuard.turnEnd();
261
+ });
262
+ // Each assistant message is judged on its own; Pi does not forward the stream's own "start" event, so this is the reset.
263
+ pi.on("message_start", async (event) => {
264
+ if (event.message.role === "assistant")
265
+ runaway.reset();
266
+ });
267
+ // Per token this only appends to a buffer; every 256 characters the buffer is checked for identical blocks, with code only.
268
+ pi.on("message_update", async (event, ctx) => {
269
+ const kind = runaway.feed(event.assistantMessageEvent);
270
+ if (!kind || runaway.stopped)
271
+ return;
272
+ const config = configFor(ctx);
273
+ if (!config.enabled || !config.runaway.enabled)
274
+ return;
275
+ const verdict = runaway.check(kind, config.runaway);
276
+ if (!verdict)
277
+ return;
278
+ stats.runaway++;
279
+ runawayStops++;
280
+ const recover = config.runaway.recover && runawayStops === 1;
281
+ const nudge = runawayNudge(verdict, recover);
282
+ pendingRunaway = { nudge, recover };
283
+ record(ctx, config, "runaway", formatRunaway(verdict, recover, config.widget.runaway), runawayDetails(verdict, nudge, recover));
284
+ if (ctx.hasUI)
285
+ ctx.ui.notify(`warden · runaway: the same ${verdict.kind} block repeated ${verdict.count} times in ${verdict.chars} chars; run stopped${recover ? " (agent gets one follow-up turn)" : " (not restarted: second time for this prompt)"}`, "error");
286
+ notifyDesktop(ctx, config, `Runaway stopped: the same ${verdict.kind} block repeated ${verdict.count} times. ${recover ? "The agent gets one recovery turn." : "Second time for this prompt; the agent is waiting for you."}`);
287
+ // Interactive Pi restores the user's queued messages to the editor before aborting; the follow-up is queued in agent_end, after that.
288
+ ctx.abort();
222
289
  });
223
290
  // Each low-level run collects its own evidence of changes and checks.
224
291
  pi.on("agent_start", async () => {
@@ -228,6 +295,7 @@ export default function wardenExtension(pi) {
228
295
  pi.on("turn_end", async () => {
229
296
  ledger.turnEnd();
230
297
  actionGuard.turnEnd();
298
+ rulesGuard.turnEnd();
231
299
  });
232
300
  pi.on("tool_call", async (event, ctx) => {
233
301
  const config = configFor(ctx);
@@ -248,9 +316,18 @@ export default function wardenExtension(pi) {
248
316
  stats.inspected++;
249
317
  const task = latestUserPrompt(ctx);
250
318
  const judge = judgeFor(config);
251
- const verdict = await actionGuard.inspect({ id: event.toolCallId, tool: event.toolName, input: event.input }, { task, context: recentTaskContext(ctx), siblings: siblingToolCalls(ctx) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security });
319
+ const siblings = siblingToolCalls(ctx);
320
+ const call = { id: event.toolCallId, tool: event.toolName, input: event.input };
321
+ // The rules request carries the written content and the rule text, so it goes out beside the action request, not inside it.
322
+ const rulesCheck = config.rules.enabled && (event.toolName === "write" || event.toolName === "edit")
323
+ ? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
324
+ : undefined;
325
+ rulesCheck?.catch(() => undefined);
326
+ const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security });
252
327
  if (verdict.source === "skipped")
253
328
  return;
329
+ // Notes for the agent about the content it just wrote: slop, rule violations, and sensitive paths arrive as one message.
330
+ const notes = [];
254
331
  if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
255
332
  steer(config, "pi-warden: the proposed write may introduce a security weakness. Check for embedded credentials, disabled TLS, unsafe command/SQL interpolation, broad permissions, or bypassed verification; use a safe implementation instead.");
256
333
  }
@@ -271,8 +348,35 @@ export default function wardenExtension(pi) {
271
348
  const where = verdict.summary.path ?? event.toolName;
272
349
  if (ctx.hasUI)
273
350
  ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
274
- steer(config, slopSteer(where, verdict.slopSymptoms, slopCounts));
351
+ notes.push(slopSteer(where, verdict.slopSymptoms, slopCounts));
275
352
  }
353
+ if (rulesCheck) {
354
+ const rules = await rulesCheck;
355
+ if (rules.source !== "skipped") {
356
+ stats.ruleChecks++;
357
+ if (rules.error)
358
+ noteError(ctx, rules.error, rules.errorCode);
359
+ const told = rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
360
+ record(ctx, config, "rules", formatRules(rules, config.widget.rules), rulesDetails(rules, told));
361
+ if (told) {
362
+ stats.ruleViolations++;
363
+ if (ctx.hasUI)
364
+ ctx.ui.notify(`warden · rules · ${rules.path}: ${rules.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
365
+ notes.push(told);
366
+ }
367
+ }
368
+ const hits = rulesGuard.notesFor(verdict.summary.location === "inside_project" ? verdict.summary.path : undefined, config.rules.sensitivePaths);
369
+ if (hits.length && verdict.summary.path) {
370
+ stats.pathNotes++;
371
+ const told = pathNoteSteer(verdict.summary.path, hits);
372
+ record(ctx, config, "rules", renderTemplate(config.widget.rules, { tool: event.toolName, path: verdict.summary.path, violations: `sensitive path ${hits.map(hit => hit.glob).join(", ")}`, status: "note" }), [`${event.toolName} ${verdict.summary.path} matches ${hits.map(hit => hit.glob).join(", ")} in rules.sensitivePaths`, `agent told: ${told}`]);
373
+ if (ctx.hasUI)
374
+ ctx.ui.notify(`warden · sensitive path · ${verdict.summary.path} (${hits.map(hit => hit.glob).join(", ")}); the agent was given the note`, "warning");
375
+ notes.push(told);
376
+ }
377
+ }
378
+ if (notes.length)
379
+ steer(config, notes.join("\n\n"));
276
380
  if (verdict.level === "warn") {
277
381
  stats.warned++;
278
382
  if (ctx.hasUI)
@@ -289,6 +393,7 @@ export default function wardenExtension(pi) {
289
393
  return undefined;
290
394
  }
291
395
  if (mode === "confirm") {
396
+ notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
292
397
  const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
293
398
  if (allowed)
294
399
  return undefined;
@@ -299,6 +404,7 @@ export default function wardenExtension(pi) {
299
404
  actionGuard.hold(task);
300
405
  if (ctx.hasUI)
301
406
  ctx.ui.notify(`warden · held ${event.toolName}: ${reasons}. The agent was told why and asked to re-plan or ask you.`, "warning");
407
+ notifyDesktop(ctx, config, `Held ${event.toolName}: ${reasons}. The agent will re-plan or ask you in chat.`);
302
408
  return { block: true, reason: told ?? steerReason(verdict, { canApprove: judge !== undefined }) };
303
409
  });
304
410
  pi.on("tool_result", async (event, ctx) => {
@@ -422,6 +528,16 @@ export default function wardenExtension(pi) {
422
528
  const config = configFor(ctx);
423
529
  if (!config.enabled)
424
530
  return;
531
+ if (pendingRunaway) {
532
+ const { nudge, recover } = pendingRunaway;
533
+ pendingRunaway = undefined;
534
+ // A follow-up queued here continues the run once the aborted message is in place; without recovery the note is appended only.
535
+ if (recover)
536
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content: nudge, display: true }, { deliverAs: "followUp", triggerTurn: true });
537
+ else
538
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content: nudge, display: true }, { triggerTurn: false });
539
+ return;
540
+ }
425
541
  const finalMessage = finalAssistantText(event.messages);
426
542
  const judge = judgeFor(config);
427
543
  if (!finalMessage || !judge)
@@ -503,12 +619,14 @@ export default function wardenExtension(pi) {
503
619
  const key = resolveApiKey();
504
620
  const source = consentSource(config);
505
621
  const usage = client?.getUsage();
506
- const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop", config.slop.enabled && config.slop.prose.enabled && `prose (${config.slop.prose.audience})`, config.security.enabled && "security", config.context.enabled && "context"].filter(Boolean).join(", ");
622
+ const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop", config.slop.enabled && config.slop.prose.enabled && `prose (${config.slop.prose.audience})`, config.security.enabled && "security", config.rules.enabled && "rules", config.context.enabled && "context", config.runaway.enabled && "runaway", config.notify.enabled && "desktop notifications"].filter(Boolean).join(", ");
507
623
  report([
508
624
  `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)"}.`,
509
- `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)"}.`,
510
- `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}.`,
625
+ `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${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.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)"}.`,
626
+ `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}, 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}.`,
511
627
  formatLedger(ledger.snapshot()),
628
+ `Rules: ${config.rules.enabled ? `${rulesGuard.describe(ctx.cwd, config.rules)}${Object.keys(config.rules.sensitivePaths).length ? `; ${Object.keys(config.rules.sensitivePaths).length} sensitive path${Object.keys(config.rules.sensitivePaths).length === 1 ? "" : "s"}` : ""}` : "off"}.`,
629
+ `Desktop notifications: ${config.notify.enabled ? `on (${config.notify.command.length ? `command ${config.notify.command[0]}` : (await (notifier ??= detectNotifier())) ?? "no notifier found on this machine"}; cooldown ${config.notify.cooldownMs} ms)` : "off"}.`,
512
630
  `Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
513
631
  widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
514
632
  `Trace: ${trace.entries().length} events (/warden trace${shortcut ? `, ${shortcut}` : ""}, or click the status line in fullscreen mode; each toggles the sidebar). Widget templates in config.widget: action tokens ${TOKEN_NAMES.action.map(name => `{${name}}`).join(" ")}.`,
package/dist/guard.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface ActionSummary {
37
37
  newText: string;
38
38
  }>;
39
39
  input?: string;
40
+ /** Present when part of the command is data (a heredoc body, a quoted message), so a destructive string inside it is payload. */
41
+ dataText?: string;
40
42
  }
41
43
  export type ScopeLabel = "expected_step" | "plausible_side_step" | "unrelated" | "unclear";
42
44
  export interface Judgment {
@@ -91,6 +93,17 @@ export interface EvaluateOptions {
91
93
  /** This exact call was held earlier and the user has replied since: ask whether the reply approves it. */
92
94
  retryAfterHold?: boolean | undefined;
93
95
  }
96
+ export interface ScannedCommand {
97
+ /** The command with data text blanked; what the pattern rules read. */
98
+ text: string;
99
+ /** True when a heredoc body or quoted data was removed. */
100
+ stripped: boolean;
101
+ }
102
+ /**
103
+ * Removes heredoc bodies that are not fed to a shell and quoted arguments of data commands. Interpreter heredocs
104
+ * (`python3 - <<EOF`) are kept when the script calls out to a shell or process API.
105
+ */
106
+ export declare function stripDataText(command: string): ScannedCommand;
94
107
  export declare function matchPatterns(tool: string, input: Record<string, unknown>, cwd?: string): PatternHit[];
95
108
  export declare function isReadOnlyCommand(command: string): boolean;
96
109
  export declare function describeAction(tool: string, input: Record<string, unknown>, cwd: string): ActionSummary;
package/dist/guard.js CHANGED
@@ -52,12 +52,123 @@ const SENSITIVE_PATH = /(?:^|[\s/"'=:(])\.env(?:\.(?!example\b|sample\b|template
52
52
  function splitShell(command) {
53
53
  return command.split(/\n|;|&&|\|\||\||&/).map(part => part.trim()).filter(Boolean);
54
54
  }
55
- /** rm with both recursive and force flags. Absolute, home, variable, or wildcard targets are destructive; relative ones are risky. */
55
+ // ---------------------------------------------------------------------------
56
+ // Data text: a heredoc body written to a file, a quoted message, or a search pattern is not a command. Pattern rules skip
57
+ // it so a test fixture or a commit message that mentions `git push --force` is not held. A shell sink anywhere in the
58
+ // command (sh, eval, bash -c, command substitution) keeps every byte in scope, because the payload is executed.
59
+ const WRAPPERS = new Set(["sudo", "nohup", "time", "env", "command", "builtin", "exec", "nice", "timeout", "doas"]);
60
+ const SHELL_SINKS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish", "eval", "source", ".", "xargs", "su"]);
61
+ /** Commands whose quoted arguments are text they print, search, or record. */
62
+ const DATA_HEADS = new Set(["echo", "printf", "grep", "egrep", "fgrep", "rg", "ag", "ugrep", "jq", "cat", "tee", "head", "tail", "wc", "sort", "uniq", "cut", "tr", "less", "more", "test", "["]);
63
+ const GIT_MESSAGE_SUBCOMMANDS = new Set(["commit", "tag", "notes", "merge", "stash"]);
64
+ /** Interpreters whose stdin script can still run shell commands; their heredoc bodies stay in scope when they do. */
65
+ const INTERPRETERS = /^(?:python[\d.]*|node|ruby|perl|php|deno|bun|tsx|Rscript|lua[\d.]*)$/;
66
+ const EXEC_CALLS = /\b(?:os\.system|os\.popen|os\.exec\w*|subprocess|child_process|execSync|spawnSync|execFileSync|spawn\(|exec\(|system\(|popen\(|shell_exec|passthru|proc_open|Open3|IO\.popen|Deno\.run|Deno\.Command|Bun\.spawn|Bun\.\$|%x[\[{(]|`[^`\n]*\b(?:rm|git|dd|mkfs|kubectl|terraform)\b)/;
67
+ const HEREDOC = /<<-?\s*(?:"(\w+)"|'(\w+)'|\\?(\w+))/;
68
+ function headOf(segment) {
69
+ const tokens = segment.trim().split(/\s+/);
70
+ let index = 0;
71
+ while (index < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index]) || WRAPPERS.has(tokens[index])))
72
+ index++;
73
+ const head = tokens[index];
74
+ return head ? head.replace(/^.*\//, "") : undefined;
75
+ }
76
+ /** Quoted strings replaced by a placeholder; escapes inside double quotes are honoured, single quotes take everything. */
77
+ function blankQuotes(segment) {
78
+ let out = "";
79
+ for (let index = 0; index < segment.length; index++) {
80
+ const char = segment[index];
81
+ if (char !== "'" && char !== "\"") {
82
+ out += char;
83
+ continue;
84
+ }
85
+ let end = index + 1;
86
+ while (end < segment.length && segment[end] !== char)
87
+ end += char === "\"" && segment[end] === "\\" ? 2 : 1;
88
+ if (end >= segment.length) {
89
+ out += segment.slice(index);
90
+ break;
91
+ }
92
+ out += `${char}[text]${char}`;
93
+ index = end;
94
+ }
95
+ return out;
96
+ }
97
+ function isDataSegment(segment) {
98
+ const head = headOf(segment);
99
+ if (!head)
100
+ return false;
101
+ if (head === "git") {
102
+ const sub = segment.trim().split(/\s+/).find(token => !token.startsWith("-") && token !== "git" && !WRAPPERS.has(token));
103
+ return sub !== undefined && GIT_MESSAGE_SUBCOMMANDS.has(sub) && !/\s-c\s|--config/.test(segment);
104
+ }
105
+ if (head === "gh")
106
+ return /\s--(?:body|title|notes)\b/.test(segment) || /\s-[bt]\s/.test(segment);
107
+ return DATA_HEADS.has(head);
108
+ }
109
+ /**
110
+ * Removes heredoc bodies that are not fed to a shell and quoted arguments of data commands. Interpreter heredocs
111
+ * (`python3 - <<EOF`) are kept when the script calls out to a shell or process API.
112
+ */
113
+ export function stripDataText(command) {
114
+ if (/\$\(|`/.test(command))
115
+ return { text: command, stripped: false };
116
+ const lines = command.split("\n");
117
+ const out = [];
118
+ let stripped = false;
119
+ for (let index = 0; index < lines.length; index++) {
120
+ const line = lines[index];
121
+ const heredoc = HEREDOC.exec(line);
122
+ if (!heredoc) {
123
+ out.push(line);
124
+ continue;
125
+ }
126
+ const delimiter = heredoc[1] ?? heredoc[2] ?? heredoc[3];
127
+ const consumer = headOf(line.slice(0, heredoc.index)) ?? "";
128
+ const body = [];
129
+ let close = index + 1;
130
+ while (close < lines.length && lines[close].replace(/^\t+/, "") !== delimiter)
131
+ body.push(lines[close]), close++;
132
+ const bodyText = body.join("\n");
133
+ const executed = SHELL_SINKS.has(consumer) || (INTERPRETERS.test(consumer) && EXEC_CALLS.test(bodyText));
134
+ out.push(line);
135
+ if (executed)
136
+ out.push(...body);
137
+ else if (body.length) {
138
+ out.push(`[heredoc body: ${body.length} lines of data]`);
139
+ stripped = true;
140
+ }
141
+ if (close < lines.length)
142
+ out.push(lines[close]);
143
+ index = close;
144
+ }
145
+ const joined = out.join("\n");
146
+ const segments = splitShell(joined);
147
+ if (segments.some(segment => { const head = headOf(segment); return head !== undefined && SHELL_SINKS.has(head); }))
148
+ return { text: joined, stripped };
149
+ if (/\b(?:ba|z|da|k)?sh\s+-[a-zA-Z]*c\b/.test(joined))
150
+ return { text: joined, stripped };
151
+ let text = joined;
152
+ for (const segment of segments) {
153
+ if (!isDataSegment(segment) || !/["']/.test(segment))
154
+ continue;
155
+ const blanked = blankQuotes(segment);
156
+ if (blanked === segment)
157
+ continue;
158
+ text = text.replace(segment, blanked);
159
+ stripped = true;
160
+ }
161
+ return { text, stripped };
162
+ }
163
+ /**
164
+ * rm with both recursive and force flags. Absolute, home, variable, or wildcard targets are destructive; relative ones are
165
+ * risky. A quote before `rm` is allowed so `bash -c "rm -rf /"` is read; data quotes were blanked before this runs.
166
+ */
56
167
  function classifyRm(segment, cwd) {
57
- const match = /(?:^|\s)rm\s+(.*)$/.exec(segment);
168
+ const match = /(?:^|[\s"'])rm\s+(.*)$/.exec(segment);
58
169
  if (!match)
59
170
  return undefined;
60
- const tokens = match[1].split(/\s+/).filter(Boolean);
171
+ const tokens = match[1].split(/\s+/).filter(Boolean).map(token => token.replace(/["']$/, ""));
61
172
  const flags = tokens.filter(token => token.startsWith("-"));
62
173
  const targets = tokens.filter(token => !token.startsWith("-"));
63
174
  const recursive = flags.some(flag => flag === "--recursive" || (/^-[a-zA-Z]+$/.test(flag) && /[rR]/.test(flag)));
@@ -86,8 +197,9 @@ export function matchPatterns(tool, input, cwd) {
86
197
  const hits = new Map();
87
198
  const add = (hit) => { if (hit && !hits.has(hit.id))
88
199
  hits.set(hit.id, hit); };
89
- const command = commandOf(tool, input)?.command;
90
- if (command) {
200
+ const raw = commandOf(tool, input)?.command;
201
+ if (raw) {
202
+ const command = stripDataText(raw).text;
91
203
  for (const rule of SHELL_RULES)
92
204
  if (rule.test.test(command))
93
205
  add({ id: rule.id, severity: rule.severity, label: rule.label });
@@ -174,8 +286,12 @@ function displayPath(target, cwd) {
174
286
  export function describeAction(tool, input, cwd) {
175
287
  const summary = { tool };
176
288
  const view = commandOf(tool, input);
177
- if (view)
289
+ if (view) {
178
290
  summary.command = redact(truncate(view.command, COMMAND_LIMIT));
291
+ // Jev sees the full text; this names the part of it that is written or printed rather than executed.
292
+ if (stripDataText(view.command).stripped)
293
+ summary.dataText = "heredoc bodies and quoted arguments of echo/printf/grep/git commit in this command are text that is written, printed, searched, or recorded, not executed";
294
+ }
179
295
  if (typeof input.path === "string" && input.path.trim() && tool !== "ctx_execute_file") {
180
296
  const shown = displayPath(input.path, cwd);
181
297
  summary.path = shown.path;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, questions, slopQuestions, approvalQuestion, securityQuestion } from "./guard.js";
2
- export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, SlopJudgment, SlopSymptom, Level, PatternHit, ScopeLabel, Severity, Verdict } from "./guard.js";
1
+ export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, stripDataText, buildRequest, formatVerdict, steerReason, questions, slopQuestions, approvalQuestion, securityQuestion } from "./guard.js";
2
+ export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, SlopJudgment, SlopSymptom, Level, PatternHit, ScannedCommand, ScopeLabel, Severity, Verdict } from "./guard.js";
3
3
  export { SLOP_SYMPTOMS, SLOP_LABELS } from "./guard.js";
4
4
  export { ActionGuard } from "./action-guard.js";
5
5
  export type { Conversation, InspectOptions, ToolCallRef } from "./action-guard.js";
@@ -16,15 +16,21 @@ export type { OutputFormat } from "./excerpt.js";
16
16
  export { detectSearchTool, recallInstruction, classifyRecall } from "./recall.js";
17
17
  export type { SearchTool } from "./recall.js";
18
18
  export { redact } from "./redact.js";
19
+ export { detectNotifier, notifierCommand, sendNotification, candidates as notifierCandidates, clipBody } from "./notify.js";
20
+ export type { NotifierName, NotifierTarget, Notification, NotifierCommand, Runner } from "./notify.js";
21
+ export { RunawayMonitor, repeatedBlock, repeatedTail, findRepeats, runawayNudge, formatRunaway, BLOCK_MIN_CHARS, PHRASE_CHARS, CHECK_EVERY } from "./runaway.js";
22
+ export type { RunawayVerdict, RepeatSignal, StreamKind } from "./runaway.js";
19
23
  export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
20
24
  export { ContextLedger, formatLedger } from "./saver.js";
21
25
  export type { ContextLedgerSnapshot, RecallKind } from "./saver.js";
22
26
  export type { ShapeResult } from "./shape.js";
23
27
  export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, isRecallTool, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
24
- export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, ContextConfig, RecallTool, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
28
+ export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, RulesConfig, ContextConfig, RunawayConfig, NotifyConfig, RecallTool, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
25
29
  export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
26
30
  export type { CommandView } from "./tools.js";
27
- export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
31
+ export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, runawayTokens, rulesTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
28
32
  export type { WidgetConfig, WidgetPlacement, Tokens } from "./widget.js";
29
- export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails } from "./trace.js";
33
+ export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails, runawayDetails, rulesDetails } from "./trace.js";
30
34
  export type { TraceEntry, GuardName } from "./trace.js";
35
+ export { parseRules, condense, globToRegExp, matchGlob, projectPath, RuleStore, rulesFor, describeRuleSet, describeTarget, buildRulesRequest, ruleQuestion, evaluateRules, skipReason, rulesSteer, formatRules, pathNotes, pathNoteSteer, RulesGuard, RULES_FILE, FALLBACK_FILES, MAX_RULES, AGGREGATE_QUESTION, LOCATOR_QUESTION } from "./rules.js";
36
+ export type { Rule, RuleSet, RuleOutcome, RuleScore, RuleFinding, RulesVerdict, RulesOptions, RulesTarget, EditView, PathNote, RulesCallRef } from "./rules.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, questions, slopQuestions, approvalQuestion, securityQuestion } from "./guard.js";
1
+ export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, stripDataText, buildRequest, formatVerdict, steerReason, questions, slopQuestions, approvalQuestion, securityQuestion } from "./guard.js";
2
2
  export { SLOP_SYMPTOMS, SLOP_LABELS } from "./guard.js";
3
3
  export { ActionGuard } from "./action-guard.js";
4
4
  export { evaluateProse, buildProseRequest, proseQuestions, proseNudge, describeAudience, ProseTrend, PROSE_SYMPTOMS, PROSE_LABELS } from "./prose.js";
@@ -8,9 +8,12 @@ export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, sa
8
8
  export { formatExcerpt, formatQuestion } from "./excerpt.js";
9
9
  export { detectSearchTool, recallInstruction, classifyRecall } from "./recall.js";
10
10
  export { redact } from "./redact.js";
11
+ export { detectNotifier, notifierCommand, sendNotification, candidates as notifierCandidates, clipBody } from "./notify.js";
12
+ export { RunawayMonitor, repeatedBlock, repeatedTail, findRepeats, runawayNudge, formatRunaway, BLOCK_MIN_CHARS, PHRASE_CHARS, CHECK_EVERY } from "./runaway.js";
11
13
  export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
12
14
  export { ContextLedger, formatLedger } from "./saver.js";
13
15
  export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, isRecallTool, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
14
16
  export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
15
- export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
16
- export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails } from "./trace.js";
17
+ export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, runawayTokens, rulesTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
18
+ export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails, runawayDetails, rulesDetails } from "./trace.js";
19
+ export { parseRules, condense, globToRegExp, matchGlob, projectPath, RuleStore, rulesFor, describeRuleSet, describeTarget, buildRulesRequest, ruleQuestion, evaluateRules, skipReason, rulesSteer, formatRules, pathNotes, pathNoteSteer, RulesGuard, RULES_FILE, FALLBACK_FILES, MAX_RULES, AGGREGATE_QUESTION, LOCATOR_QUESTION } from "./rules.js";