pi-warden 0.10.0 → 0.11.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/README.md CHANGED
@@ -66,7 +66,9 @@ Runs on `tool_call`, before the tool executes.
66
66
  2. **Patterns**, offline: force pushes, `git reset --hard`, `git clean`, recursive `rm` on absolute, home, variable, or parent paths, SQL `DROP`/`TRUNCATE`/`DELETE FROM`, block-device writes, `chmod -R 777`, fork bombs, `curl | sh`, `kill -1`, shutdown, package publishing, infrastructure destroys hold the call. `rm -rf` on a project path, `git checkout -- .`, `git branch -D`, `git stash drop`, `find -delete`, `sudo` warn. Reads or writes of `.env`, SSH, AWS, npm, kube, and other credential files warn. A `write` that overwrites a file outside the project holds; creating or editing outside the project warns.
67
67
 
68
68
  Text that is data is not a command. A heredoc body written to a file, a quoted `echo`/`printf` argument, a `grep` pattern, or a `git commit -m` message can mention `git push --force` without a hold. The same text fed to `sh`, `bash -c`, `eval`, `xargs`, or a `python3 - <<EOF` script that calls `os.system` keeps every hit.
69
- 3. **Jev**, with consent: one request with `{ task, context, action }` and four questions. `irreversible` (yes/no), `off_task` (yes/no), `mutates` (does it change anything), `scope` (expected step, plausible side step, unrelated, unclear). Defaults: irreversible at 0.5 warns and at 0.7 holds; off-task at 0.6 warns and at 0.85 with `unrelated` holds, but only when the action can change something. An unrelated `grep` is warned about, never held. Patterns set the floor; Jev can only raise it.
69
+ 3. **Jev**, with consent: one request with `{ task, context, plan, action }` and four questions. `irreversible` (yes/no), `off_task` (yes/no), `mutates` (does it change anything), `scope` (expected step, plausible side step, unrelated, unclear). Defaults: irreversible at 0.5 warns and at 0.7 holds; off-task at 0.6 warns and at 0.85 with `unrelated` holds, but only when the action can change something. An unrelated `grep` is warned about, never held. Patterns set the floor; Jev can only raise it.
70
+
71
+ `plan` is the agent's own words in the message that makes the call (or its latest text since your prompt, 500 redacted characters). It tells Jev which step this is, so a verification fixture the agent just announced is not judged unrelated; it never authorizes anything. When there is a plan, a fifth question `intent_mismatch` asks whether the call does something materially different from it: a delete where the plan said list, a force push where it said push. At `action.intentMismatch` (0.8) on a call that can change something, the call is warned about and the agent is told to keep its words and its calls in step. Never held on that alone; the hold feedback below will say whether it should be. The trace shows the plan under each verdict.
70
72
  4. **Act**, by mode:
71
73
  - `steer` (default): a hold blocks the call and returns the judgment to the agent as its tool result, with the two acceptable next moves: find a recoverable alternative, or explain the action to you and wait. If your reply approves it, the retry goes through (Jev reads your reply; offline, a yes/go-ahead heuristic does).
72
74
  - `confirm`: a `ctx.ui.confirm` dialog. No blocks with a short reason. Falls back to `steer` without a UI.
@@ -184,7 +186,7 @@ Templates in `config.widget` control the text. Segments are separated by ` · `;
184
186
  }
185
187
  ```
186
188
 
187
- Tokens per guard: action `tool level source irreversible offTask scope approved slop slopStub slopComments slopDead slopHedging patterns reasons path model ms flags time`; rules `tool path asked violations status source reasons model ms flags time`; prose `wordy cliches jargon status reasons model ms flags time`; stuck `failures sameStrategy approachChange progress status source reasons model ms flags time`; done `changes checks checksPassed claimsDone claimsVerified checksApply outcome status reasons model ms flags time`; security `tool injection exfiltration status`; context `tool retention bytesSaved`; runaway `kind count chars signal block status time`. `"enabled": false` hides the line; `"shortcut": ""` disables the keybinding.
189
+ Tokens per guard: action `tool level source irreversible offTask scope approved intent plan slop slopStub slopComments slopDead slopHedging patterns reasons path model ms flags time`; rules `tool path asked violations status source reasons model ms flags time`; prose `wordy cliches jargon status reasons model ms flags time`; stuck `failures sameStrategy approachChange progress status source reasons model ms flags time`; done `changes checks checksPassed claimsDone claimsVerified checksApply outcome status reasons model ms flags time`; security `tool injection exfiltration status`; context `tool retention bytesSaved`; runaway `kind count chars signal block status time`. `"enabled": false` hides the line; `"shortcut": ""` disables the keybinding.
188
190
 
189
191
  ## Configuration
190
192
 
@@ -203,6 +205,7 @@ User file `~/.pi/agent/pi-warden/config.json` (owner-only). Missing keys use the
203
205
  "failOpen": true,
204
206
  "irreversible": { "warn": 0.5, "confirm": 0.7 },
205
207
  "offTask": { "warn": 0.6, "confirm": 0.85 },
208
+ "intentMismatch": 0.8,
206
209
  "feedbackLog": true
207
210
  },
208
211
  "rules": {
@@ -252,14 +255,14 @@ A wince-style setup for a backend repo (the full version is [`examples/pi-warden
252
255
 
253
256
  With consent, requests go to `https://api.typesafe.ai`. What is sent:
254
257
 
255
- - **Action guard**: your latest prompt (1500 characters), up to eight earlier user and assistant messages (750 redacted characters each), the tool name, the command (2000 characters) or the file path (relative inside the project, `~`-shortened outside), whether the file exists, a 1500-character head/middle/tail sample of a `write`, the first three edit pairs (400 characters each) of an `edit`. On the first guarded call after your reply, the tool names and commands (300 characters) or paths of up to six calls allowed in the previous turn, for the regret question.
258
+ - **Action guard**: your latest prompt (1500 characters), up to eight earlier user and assistant messages (750 redacted characters each), the agent's text from the message that makes the call (500 redacted characters), the tool name, the command (2000 characters) or the file path (relative inside the project, `~`-shortened outside), whether the file exists, a 1500-character head/middle/tail sample of a `write`, the first three edit pairs (400 characters each) of an `edit`. On the first guarded call after your reply, the tool names and commands (300 characters) or paths of up to six calls allowed in the previous turn, for the regret question.
256
259
  - **Rules**: the project-relative path, a 6000-character sample of a `write` or each edit's new text (1500 characters) with about 40 lines of the current file around the replaced text, and the rule text from your rules file or the condensed fallback document (`rules.maxChars`). No task text. Files under `rules.exclude` are never sent.
257
260
  - **Stuck**: the last 12 tool calls (300 characters each) with 400-character output tails.
258
261
  - **Done-check and prose**: the agent's final message (2000 and 2500 characters), the run's check commands, the audience description.
259
262
  - **Output checks**: a redacted head/tail sample up to 6000 characters plus size, line counts, and tool name.
260
263
  - **Nothing** for duplicate detection, the runaway guard, sensitive-path notes, or pattern checks.
261
264
 
262
- The hold feedback log under `~/.pi/agent/pi-warden/holds/` stays on this machine, owner-only: one JSON line per judged call with tool, pattern ids, scores, level, mode, and outcome; never the command, path, or prompt.
265
+ The hold feedback log under `~/.pi/agent/pi-warden/holds/` stays on this machine, owner-only: one JSON line per judged call with tool, pattern ids, scores, level, mode, outcome, and the length of the agent's stated plan; never the command, path, prompt, or plan text.
263
266
 
264
267
  Obvious credentials (`Authorization` headers, `TOKEN=` and `SECRET=` assignments, `sk-`, `ghp_`, `AKIA`, JWTs, URL passwords, PEM blocks) are replaced with `[redacted]` before sending. Best-effort; do not rely on it for prompts that contain secrets. Text steered to the agent names the tool, the reasons, and the scores, not the command. UI errors never include upstream response bodies or keys. Judgments are model output; thresholds are yours to tune.
265
268
 
@@ -295,7 +298,7 @@ What spans calls in a session lives in `ActionGuard` (hold, reply, retry approva
295
298
  ```bash
296
299
  npm install
297
300
  npm run check # typecheck, offline tests (mocked transport), build
298
- npm run test:live # billable synthetic judgments across the guards; pass action|slop|approval|regret|stuck|done|security|context for one group
301
+ npm run test:live # billable synthetic judgments across the guards; pass action|intent|slop|approval|regret|stuck|done|security|context for one group
299
302
  node scripts/rules-cases.mjs # 13 billable cases against an 8-rule fixture file
300
303
  node scripts/slop-cases.mjs # 22 billable cases for the slop and prose questions
301
304
  node scripts/security-cases.mjs # 9 output-security and task-continuity cases
@@ -14,6 +14,8 @@ export interface Conversation {
14
14
  context?: readonly TaskMessage[] | undefined;
15
15
  /** Tool calls in the same assistant message, this one included; they are judged together. */
16
16
  siblings?: readonly ToolCallRef[] | undefined;
17
+ /** The agent's own words in that message (or its latest text under this prompt); shared by the siblings. Explains, never authorizes. */
18
+ plan?: string | undefined;
17
19
  }
18
20
  export interface InspectOptions {
19
21
  config: ActionGuardConfig;
@@ -20,7 +20,7 @@ export class ActionGuard {
20
20
  const { task } = conversation;
21
21
  // A hold happened under an earlier prompt and the user has since replied: ask whether the reply approves this action.
22
22
  const retryAfterHold = this.holdPending && this.lastHoldPrompt !== task;
23
- const judgeCall = (tool, input, previousActions) => evaluateAction({ tool, input, cwd: options.cwd, task, context: conversation.context }, { config: options.config, judge: options.judge, signal: options.signal, slop: options.slop, security: options.security, retryAfterHold, previousActions });
23
+ const judgeCall = (tool, input, previousActions) => evaluateAction({ tool, input, cwd: options.cwd, task, context: conversation.context, plan: conversation.plan }, { config: options.config, judge: options.judge, signal: options.signal, slop: options.slop, security: options.security, retryAfterHold, previousActions });
24
24
  // A retry after a hold stays sequential because an approval consumed by one sibling changes the question for the next.
25
25
  if (options.judge && !retryAfterHold) {
26
26
  for (const sibling of conversation.siblings ?? []) {
package/dist/config.d.ts CHANGED
@@ -15,6 +15,8 @@ export interface ActionGuardConfig {
15
15
  timeoutMs: number;
16
16
  irreversible: Threshold;
17
17
  offTask: Threshold;
18
+ /** P(the call differs from the agent's own stated plan) at or above this warns and tells the agent; never holds on its own. */
19
+ intentMismatch: number;
18
20
  /** Write each judged call and what the user did next (approved, declined, re-planned, regretted) to an owner-only per-session file under the agent directory; redacted, never the command. */
19
21
  feedbackLog: boolean;
20
22
  }
package/dist/config.js CHANGED
@@ -25,6 +25,7 @@ export function defaultConfig() {
25
25
  timeoutMs: 5000,
26
26
  irreversible: { warn: 0.5, confirm: 0.7 },
27
27
  offTask: { warn: 0.6, confirm: 0.85 },
28
+ intentMismatch: 0.8,
28
29
  feedbackLog: true,
29
30
  },
30
31
  stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, nudge: true },
@@ -93,6 +94,7 @@ function applyAction(base, raw, timeoutMs) {
93
94
  timeoutMs,
94
95
  irreversible: threshold(raw.irreversible, base.irreversible),
95
96
  offTask: threshold(raw.offTask, base.offTask),
97
+ intentMismatch: probability(raw.intentMismatch, base.intentMismatch),
96
98
  feedbackLog: boolean(raw.feedbackLog, base.feedbackLog),
97
99
  };
98
100
  }
@@ -2,12 +2,17 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import type { ToolCallRef } from "./action-guard.js";
3
3
  import type { SlopSymptom, Verdict } from "./guard.js";
4
4
  import type { ShapeResult } from "./shape.js";
5
- export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; redacted tool-output samples for security and context saving (retention and output format); and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
5
+ export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, with the agent's own words from the message that makes the call (its stated plan); for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; redacted tool-output samples for security and context saving (retention and output format); and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
6
6
  /**
7
7
  * Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
8
8
  * so judging them one request at a time costs one round trip per call; judging them together costs one round trip.
9
9
  */
10
10
  export declare function siblingToolCalls(ctx: ExtensionContext): ToolCallRef[];
11
+ /**
12
+ * The agent's own words before the call: the text of the assistant message that carries it, or, when that message is
13
+ * tool calls only, the latest assistant text since the user's prompt. Sent as `plan`; it explains the step and cannot approve it.
14
+ */
15
+ export declare function assistantPlan(ctx: ExtensionContext): string | undefined;
11
16
  export declare function confirmMessage(verdict: Verdict): string;
12
17
  /** Names each symptom and its fix; repeats in the session turn the note into a standing rule. */
13
18
  export declare function slopSteer(where: string, symptoms: readonly SlopSymptom[], counts: Record<SlopSymptom, number>): string;
package/dist/extension.js CHANGED
@@ -5,7 +5,7 @@ import { ActionGuard } from "./action-guard.js";
5
5
  import * as configModule from "./config.js";
6
6
  import { applyUserOverrides, defaultConfig, isMode, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
7
7
  import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome } from "./done.js";
8
- import { evaluateAction, formatVerdict, SLOP_LABELS, steerReason } from "./guard.js";
8
+ import { evaluateAction, formatVerdict, intentSteer, SLOP_LABELS, steerReason } from "./guard.js";
9
9
  import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
10
10
  import { evaluateProse, proseNudge, ProseTrend } from "./prose.js";
11
11
  import { compressOutput, duplicateNote, evaluateOutput, outputKey, saveOutput, securityNotice } from "./output.js";
@@ -20,10 +20,10 @@ import { completeConfig, shapeWarning } from "./shape.js";
20
20
  import { ContextLedger, formatLedger } from "./saver.js";
21
21
  import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
22
22
  import { DEFAULT_TEMPLATES, proseTokens, renderTemplate, TOKEN_NAMES } from "./widget.js";
23
- 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; redacted tool-output samples for security and context saving (retention and output format); and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
23
+ export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs, with the agent's own words from the message that makes the call (its stated plan); for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; redacted tool-output samples for security and context saving (retention and output format); and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
24
24
  const WIDGET = PACKAGE_NAME;
25
25
  const CONFIRM_TEXT_LIMIT = 500;
26
- 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 });
26
+ const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 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 });
27
27
  function latestUserPrompt(ctx) {
28
28
  const entries = ctx.sessionManager.getBranch();
29
29
  for (let index = entries.length - 1; index >= 0; index--) {
@@ -80,6 +80,27 @@ export function siblingToolCalls(ctx) {
80
80
  }
81
81
  return [];
82
82
  }
83
+ /**
84
+ * The agent's own words before the call: the text of the assistant message that carries it, or, when that message is
85
+ * tool calls only, the latest assistant text since the user's prompt. Sent as `plan`; it explains the step and cannot approve it.
86
+ */
87
+ export function assistantPlan(ctx) {
88
+ const entries = ctx.sessionManager.getBranch();
89
+ for (let index = entries.length - 1; index >= 0; index--) {
90
+ const entry = entries[index];
91
+ if (entry?.type !== "message")
92
+ continue;
93
+ if (entry.message.role === "user")
94
+ return undefined;
95
+ if (entry.message.role !== "assistant")
96
+ continue;
97
+ const content = entry.message.content;
98
+ const text = typeof content === "string" ? content : content.filter((part) => part.type === "text").map(part => part.text).join("\n");
99
+ if (text.trim())
100
+ return text.trim();
101
+ }
102
+ return undefined;
103
+ }
83
104
  function activeMode(config, hasUI) {
84
105
  const env = process.env.PI_WARDEN_MODE?.trim();
85
106
  const mode = isMode(env) ? env : config.mode;
@@ -378,7 +399,7 @@ export default function wardenExtension(pi) {
378
399
  ? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
379
400
  : undefined;
380
401
  rulesCheck?.catch(() => undefined);
381
- 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, previousActions: regretCandidates.length ? regretCandidates : undefined });
402
+ const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, previousActions: regretCandidates.length ? regretCandidates : undefined });
382
403
  if (verdict.source === "skipped")
383
404
  return;
384
405
  if (regretCandidates.length && verdict.judgment?.regretted !== undefined) {
@@ -410,6 +431,11 @@ export default function wardenExtension(pi) {
410
431
  traceOf.set(item, entry);
411
432
  noteOutcomes(config, outcome ? [item] : []);
412
433
  };
434
+ // The warn notice below names the mismatch to the user; the agent gets the steer with the other notes.
435
+ if (verdict.intentMismatch) {
436
+ stats.offPlan++;
437
+ notes.push(intentSteer(verdict));
438
+ }
413
439
  if (verdict.slopSymptoms?.length && verdict.slopReasons) {
414
440
  stats.slop++;
415
441
  for (const symptom of verdict.slopSymptoms)
@@ -702,8 +728,8 @@ export default function wardenExtension(pi) {
702
728
  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(", ");
703
729
  report([
704
730
  `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)"}.`,
705
- `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)"}.`,
706
- `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}.`,
731
+ `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan, ${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)"}.`,
732
+ `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / hold ${config.action.offTask.confirm}; intent mismatch ${config.action.intentMismatch}; 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}.`,
707
733
  formatLedger(ledger.snapshot()),
708
734
  `${formatHolds(holds.snapshot(), config.action.feedbackLog ? holdLog?.path : undefined)}${holdLog?.lastFailure ? ` Log write failed: ${holdLog.lastFailure}.` : ""}`,
709
735
  `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"}.`,
package/dist/guard.d.ts CHANGED
@@ -17,6 +17,8 @@ export interface ActionInput {
17
17
  task?: string | undefined;
18
18
  /** Prior conversation clarifies scope, but never grants approval for a held action. */
19
19
  context?: readonly TaskMessage[] | undefined;
20
+ /** The agent's own words in the message that makes this call (or its latest text under this prompt). Explains the step; never authorizes it. */
21
+ plan?: string | undefined;
20
22
  }
21
23
  export interface TaskMessage {
22
24
  role: "user" | "assistant";
@@ -55,6 +57,8 @@ export interface Judgment {
55
57
  securityRisk?: number;
56
58
  /** P(the action changes files, state, or external systems). Off-task alone holds only actions that can change something. */
57
59
  mutates?: number;
60
+ /** P(the action does something materially different from `plan`); only asked when the agent said something before the call. */
61
+ intentMismatch?: number;
58
62
  model: string;
59
63
  elapsedMs: number;
60
64
  }
@@ -88,6 +92,10 @@ export interface Verdict {
88
92
  slopReasons?: string[];
89
93
  /** True when a previously held call was allowed because the user's latest message approves it. */
90
94
  approvedByUser?: boolean;
95
+ /** Redacted, truncated `plan` as sent to Jev and shown in the trace. */
96
+ plan?: string;
97
+ /** True when Jev finds the call at odds with the agent's stated plan and the call can change something; the agent is told. */
98
+ intentMismatch?: boolean;
91
99
  /** Safe TypeSafe error message when the judge could not answer. */
92
100
  error?: string;
93
101
  errorCode?: IntegrationErrorCode;
@@ -131,6 +139,10 @@ export declare const questions: {
131
139
  readonly unclear: "The supplied conversation or action gives too little information to establish scope; this is not itself a violation";
132
140
  }>;
133
141
  };
142
+ /** Asked only when the agent said something before the call; an empty plan cannot be contradicted. */
143
+ export declare const intentQuestion: {
144
+ intent_mismatch: import("pi-typesafe").NoulQuestion;
145
+ };
134
146
  export declare const slopQuestions: {
135
147
  slop_stub: import("pi-typesafe").NoulQuestion;
136
148
  slop_comments: import("pi-typesafe").NoulQuestion;
@@ -157,12 +169,15 @@ export declare function regretQuestions(actions: readonly PreviousAction[]): {
157
169
  [k: string]: string;
158
170
  }>;
159
171
  };
172
+ /** The agent's words as they leave the machine: redacted and bounded. Undefined when the agent said nothing. */
173
+ export declare function describePlan(plan: string | undefined): string | undefined;
160
174
  export declare function buildRequest(summary: ActionSummary, task: string | undefined, extras?: {
161
175
  slop?: boolean;
162
176
  approval?: boolean;
163
177
  security?: boolean;
164
178
  context?: readonly TaskMessage[] | undefined;
165
179
  previousActions?: readonly PreviousAction[] | undefined;
180
+ plan?: string | undefined;
166
181
  }): {
167
182
  state: {
168
183
  previous_actions?: {
@@ -171,6 +186,7 @@ export declare function buildRequest(summary: ActionSummary, task: string | unde
171
186
  tool: string;
172
187
  path?: string;
173
188
  }[];
189
+ plan?: string;
174
190
  task: string;
175
191
  action: Record<string, string | number | boolean>;
176
192
  context: {
@@ -187,6 +203,7 @@ export declare function buildRequest(summary: ActionSummary, task: string | unde
187
203
  slop_comments?: import("pi-typesafe").NoulQuestion;
188
204
  slop_dead?: import("pi-typesafe").NoulQuestion;
189
205
  slop_hedging?: import("pi-typesafe").NoulQuestion;
206
+ intent_mismatch?: import("pi-typesafe").NoulQuestion;
190
207
  irreversible: import("pi-typesafe").NoulQuestion;
191
208
  off_task: import("pi-typesafe").NoulQuestion;
192
209
  mutates: import("pi-typesafe").NoulQuestion;
@@ -207,6 +224,7 @@ export declare function buildRequest(summary: ActionSummary, task: string | unde
207
224
  slop_comments?: import("pi-typesafe").NoulQuestion;
208
225
  slop_dead?: import("pi-typesafe").NoulQuestion;
209
226
  slop_hedging?: import("pi-typesafe").NoulQuestion;
227
+ intent_mismatch?: import("pi-typesafe").NoulQuestion;
210
228
  irreversible: import("pi-typesafe").NoulQuestion;
211
229
  off_task: import("pi-typesafe").NoulQuestion;
212
230
  mutates: import("pi-typesafe").NoulQuestion;
@@ -223,6 +241,7 @@ export declare function buildRequest(summary: ActionSummary, task: string | unde
223
241
  slop_comments?: import("pi-typesafe").NoulQuestion;
224
242
  slop_dead?: import("pi-typesafe").NoulQuestion;
225
243
  slop_hedging?: import("pi-typesafe").NoulQuestion;
244
+ intent_mismatch?: import("pi-typesafe").NoulQuestion;
226
245
  irreversible: import("pi-typesafe").NoulQuestion;
227
246
  off_task: import("pi-typesafe").NoulQuestion;
228
247
  mutates: import("pi-typesafe").NoulQuestion;
@@ -235,6 +254,8 @@ export declare function buildRequest(summary: ActionSummary, task: string | unde
235
254
  };
236
255
  };
237
256
  export declare function evaluateAction(action: ActionInput, options: EvaluateOptions): Promise<Verdict>;
257
+ /** What the agent reads after a call that differs from its own plan ran: name the gap, ask it to keep words and calls in step. */
258
+ export declare function intentSteer(verdict: Verdict): string;
238
259
  /** Offline stand-in for the approval question when TypeSafe is not available. */
239
260
  export declare function textApproves(task: string | undefined): boolean;
240
261
  /**
package/dist/guard.js CHANGED
@@ -10,6 +10,7 @@ export const SLOP_SYMPTOMS = ["stub", "comments", "dead", "hedging"];
10
10
  const LEVEL_RANK = { allow: 0, warn: 1, confirm: 2 };
11
11
  const higher = (a, b) => (LEVEL_RANK[a] >= LEVEL_RANK[b] ? a : b);
12
12
  const TASK_LIMIT = 1500;
13
+ const PLAN_LIMIT = 500;
13
14
  const COMMAND_LIMIT = 2000;
14
15
  const EXCERPT_LIMIT = 1500;
15
16
  const EDIT_LIMIT = 400;
@@ -331,7 +332,7 @@ export const questions = {
331
332
  true: "Yes: it force-pushes or rewrites shared history, deletes untracked or uncommitted work, drops or truncates database objects, overwrites files outside the project, publishes or deploys, or removes something no build or checkout can regenerate.",
332
333
  false: "No: it only reads, builds, tests, or creates new files, or it deletes generated output, caches, or temporary files that a build or checkout can recreate, or it is a change `task` explicitly asked for.",
333
334
  }),
334
- off_task: noul("Is there evidence that `action` is outside the user's active task? `task` is the latest user message; `context` contains earlier conversation to resolve follow-ups, handoffs, and side comments. New user instructions override older ones. Assistant messages describe work but do not authorize it. Missing context alone is not evidence of off-task work.", {
335
+ off_task: noul("Is there evidence that `action` is outside the user's active task? `task` is the latest user message; `context` contains earlier conversation to resolve follow-ups, handoffs, and side comments; `plan`, when present, is the agent's own words in the message that makes this call and explains which step this is. New user instructions override older ones. Assistant messages, including `plan`, describe work but do not authorize it. Missing context alone is not evidence of off-task work.", {
335
336
  true: "Yes: it contradicts the user's current direction, starts unrelated work, or expands the agreed scope without a useful connection to the active task.",
336
337
  false: "No: implementation edits, regression tests, investigation, and verification support the active task, even if not individually named. A side comment does not cancel the task. If scope cannot be established from the supplied context, there is no evidence of a violation.",
337
338
  }),
@@ -339,13 +340,20 @@ export const questions = {
339
340
  true: "Yes: it writes or deletes files, changes version control or a database, installs or publishes, or calls a service that records the request.",
340
341
  false: "No: it only inspects, reads, computes, or prints; running it again leaves everything as it was.",
341
342
  }),
342
- scope: choice("How does `action` relate to the active task described by `task` and the earlier `context`? Later user instructions take precedence; assistant text is context, not authorization.", {
343
+ scope: choice("How does `action` relate to the active task described by `task` and the earlier `context`? `plan`, when present, says which step the agent believes this is. Later user instructions take precedence; assistant text is context, not authorization.", {
343
344
  expected_step: "Required implementation, bug fix, regression test, or verification for the active task",
344
345
  plausible_side_step: "Reasonable supporting work whose necessity is not yet established",
345
346
  unrelated: "No useful connection to the active task, or contrary to the user's current direction",
346
347
  unclear: "The supplied conversation or action gives too little information to establish scope; this is not itself a violation",
347
348
  }),
348
349
  };
350
+ /** Asked only when the agent said something before the call; an empty plan cannot be contradicted. */
351
+ export const intentQuestion = {
352
+ intent_mismatch: noul("Does `action` do something materially different from what `plan` (the agent's own words right before this call) says it is about to do?", {
353
+ true: "Yes: a different target file, branch, or system than described; a broader, destructive, or irreversible operation where the plan describes a read, a check, a dry run, or a narrow change; a more forceful variant of the described step (a force push where a push was described, a delete where a move was, a hard reset where a soft one was); or a step the plan does not mention at all.",
354
+ false: "No: the call carries out the described step or a routine part of it (reading before editing, running the named check, creating the file it said it would) with no added force or scope, or `plan` is too general to contradict it.",
355
+ }),
356
+ };
349
357
  export const slopQuestions = {
350
358
  slop_stub: noul("Does the content `action` writes leave placeholder, stub, mock, or \"implement later\" code where `task` needs a working implementation?", {
351
359
  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.",
@@ -401,17 +409,24 @@ export function regretQuestions(actions) {
401
409
  function hasContent(summary) {
402
410
  return (summary.excerpt?.trim().length ?? 0) > 0 || (summary.edits?.some(edit => edit.newText.trim().length > 0) ?? false);
403
411
  }
412
+ /** The agent's words as they leave the machine: redacted and bounded. Undefined when the agent said nothing. */
413
+ export function describePlan(plan) {
414
+ const text = plan?.trim();
415
+ return text ? truncate(redact(text), PLAN_LIMIT) : undefined;
416
+ }
404
417
  export function buildRequest(summary, task, extras = {}) {
405
418
  const wantSlop = extras.slop && (summary.tool === "write" || summary.tool === "edit") && hasContent(summary);
406
419
  const previous = (extras.previousActions ?? []).slice(-PREVIOUS_ACTIONS_LIMIT).map(action => ({ ...action, ...(action.command !== undefined ? { command: truncate(action.command, PREVIOUS_COMMAND_LIMIT) } : {}) }));
420
+ const plan = describePlan(extras.plan);
407
421
  return {
408
422
  state: {
409
423
  task: task?.trim() ? truncate(redact(task.trim()), TASK_LIMIT) : "(no user request recorded in this session)",
410
424
  action: summary,
411
425
  context: (extras.context ?? []).slice(-8).map(message => ({ role: message.role, text: truncate(redact(message.text), 750) })),
426
+ ...(plan ? { plan } : {}),
412
427
  ...(previous.length ? { previous_actions: previous } : {}),
413
428
  },
414
- questions: { ...questions, ...(wantSlop ? slopQuestions : {}), ...(extras.approval ? approvalQuestion : {}), ...(extras.security && (summary.tool === "write" || summary.tool === "edit") && hasContent(summary) ? securityQuestion : {}), ...(previous.length ? regretQuestions(previous) : {}) },
429
+ questions: { ...questions, ...(plan ? intentQuestion : {}), ...(wantSlop ? slopQuestions : {}), ...(extras.approval ? approvalQuestion : {}), ...(extras.security && (summary.tool === "write" || summary.tool === "edit") && hasContent(summary) ? securityQuestion : {}), ...(previous.length ? regretQuestions(previous) : {}) },
415
430
  };
416
431
  }
417
432
  const percent = (value) => value.toFixed(2);
@@ -425,6 +440,8 @@ export async function evaluateAction(action, options) {
425
440
  if (!config.enabled || !config.tools.includes(action.tool)) {
426
441
  return { level: "allow", source: "skipped", summary, patterns: [], reasons: [] };
427
442
  }
443
+ const plan = describePlan(action.plan);
444
+ const withPlan = (verdict) => (plan ? { ...verdict, plan } : verdict);
428
445
  const patterns = matchPatterns(action.tool, action.input, action.cwd);
429
446
  const reasons = [];
430
447
  let level = "allow";
@@ -452,8 +469,8 @@ export async function evaluateAction(action, options) {
452
469
  return { level, source: "read-only", summary, patterns, reasons };
453
470
  }
454
471
  if (!judge)
455
- return { level, source: "pattern", summary, patterns, reasons };
456
- const request = buildRequest(summary, action.task, { slop: options.slop?.enabled ?? false, approval: options.retryAfterHold ?? false, security: options.security?.enabled ?? false, context: action.context, previousActions: options.previousActions });
472
+ return withPlan({ level, source: "pattern", summary, patterns, reasons });
473
+ const request = buildRequest(summary, action.task, { slop: options.slop?.enabled ?? false, approval: options.retryAfterHold ?? false, security: options.security?.enabled ?? false, context: action.context, previousActions: options.previousActions, plan });
457
474
  const result = await askJev(judge, request, { timeoutMs: config.timeoutMs, signal: options.signal });
458
475
  if (!result.ok) {
459
476
  if (!config.failOpen) {
@@ -463,7 +480,7 @@ export async function evaluateAction(action, options) {
463
480
  else {
464
481
  reasons.push("TypeSafe unavailable; allowed by failOpen");
465
482
  }
466
- return { level, source: "error", summary, patterns, reasons, error: result.error, ...(result.errorCode ? { errorCode: result.errorCode } : {}) };
483
+ return withPlan({ level, source: "error", summary, patterns, reasons, error: result.error, ...(result.errorCode ? { errorCode: result.errorCode } : {}) });
467
484
  }
468
485
  const answers = result.answers;
469
486
  const judgment = {
@@ -478,6 +495,8 @@ export async function evaluateAction(action, options) {
478
495
  judgment.approved = answers.approved.noul;
479
496
  if (typeof answers.mutates?.noul === "number")
480
497
  judgment.mutates = answers.mutates.noul;
498
+ if (plan && typeof answers.intent_mismatch?.noul === "number")
499
+ judgment.intentMismatch = answers.intent_mismatch.noul;
481
500
  if (typeof answers.regretted?.noul === "number") {
482
501
  judgment.regretted = answers.regretted.noul;
483
502
  if (typeof answers.regret_target?.choice === "string")
@@ -524,7 +543,15 @@ export async function evaluateAction(action, options) {
524
543
  reasons.push(`possible security weakness ${percent(judgment.securityRisk)} in written content`);
525
544
  }
526
545
  }
527
- const verdict = { level, source: "typesafe", summary, patterns, reasons, judgment };
546
+ // A call at odds with the agent's own plan is warned about and the agent is told; it is not held on that alone until the hold labels say how precise the signal is.
547
+ const mismatch = judgment.intentMismatch !== undefined && judgment.intentMismatch >= config.intentMismatch && canChange;
548
+ if (mismatch) {
549
+ level = higher(level, "warn");
550
+ reasons.push(`intent mismatch ${percent(judgment.intentMismatch)} (the call differs from the agent's stated plan)`);
551
+ }
552
+ const verdict = withPlan({ level, source: "typesafe", summary, patterns, reasons, judgment });
553
+ if (mismatch)
554
+ verdict.intentMismatch = true;
528
555
  if (options.slop?.enabled && SLOP_SYMPTOMS.every(symptom => typeof answers[`slop_${symptom}`]?.noul === "number")) {
529
556
  verdict.slop = { stub: answers.slop_stub.noul, comments: answers.slop_comments.noul, dead: answers.slop_dead.noul, hedging: answers.slop_hedging.noul };
530
557
  const flagged = SLOP_SYMPTOMS.filter(symptom => verdict.slop[symptom] >= options.slop.threshold).sort((a, b) => verdict.slop[b] - verdict.slop[a]);
@@ -540,6 +567,11 @@ export async function evaluateAction(action, options) {
540
567
  }
541
568
  return verdict;
542
569
  }
570
+ /** What the agent reads after a call that differs from its own plan ran: name the gap, ask it to keep words and calls in step. */
571
+ export function intentSteer(verdict) {
572
+ const score = verdict.judgment?.intentMismatch;
573
+ return `pi-warden: this ${verdict.summary.tool} call does something different from what you said you were about to do${score === undefined ? "" : ` (intent mismatch ${percent(score)})`}. It ran. Before the next call, say what changed and why, and keep your stated plan and your calls in step; if the described step is still needed, do it.`;
574
+ }
543
575
  /** Offline stand-in for the approval question when TypeSafe is not available. */
544
576
  export function textApproves(task) {
545
577
  return /\b(?:yes|yep|yeah|go ahead|do it|proceed|approved?|confirm(?:ed)?|ok(?:ay)?|sure|please do|run it)\b/i.test(task ?? "") && !/\b(?:no|don't|do not|stop|wait|instead|not)\b/i.test(task ?? "");
package/dist/holds.d.ts CHANGED
@@ -24,6 +24,7 @@ export interface CallScores {
24
24
  scope: ScopeLabel;
25
25
  mutates?: number;
26
26
  approved?: number;
27
+ intentMismatch?: number;
27
28
  securityRisk?: number;
28
29
  }
29
30
  export interface CallRecord {
@@ -38,6 +39,8 @@ export interface CallRecord {
38
39
  patterns: string[];
39
40
  /** Reason labels as shown to the user; they name patterns and scores, never the command. */
40
41
  reasons: string[];
42
+ /** Length of the agent's stated plan sent with the call; 0 means the agent said nothing before calling. Decides whether declared intent (layer 2) is worth building. */
43
+ planChars: number;
41
44
  scores?: CallScores;
42
45
  outcome: CallOutcome;
43
46
  outcomeAt?: number;
package/dist/holds.js CHANGED
@@ -11,6 +11,8 @@ function scoresOf(verdict) {
11
11
  scores.mutates = judgment.mutates;
12
12
  if (judgment.approved !== undefined)
13
13
  scores.approved = judgment.approved;
14
+ if (judgment.intentMismatch !== undefined)
15
+ scores.intentMismatch = judgment.intentMismatch;
14
16
  if (judgment.securityRisk !== undefined)
15
17
  scores.securityRisk = judgment.securityRisk;
16
18
  return scores;
@@ -31,6 +33,7 @@ export class HoldLedger {
31
33
  held: options.held,
32
34
  patterns: verdict.patterns.map(hit => hit.id),
33
35
  reasons: [...verdict.reasons],
36
+ planChars: verdict.plan?.length ?? 0,
34
37
  outcome: options.outcome ?? "pending",
35
38
  };
36
39
  const scores = scoresOf(verdict);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, stripDataText, buildRequest, formatVerdict, steerReason, questions, slopQuestions, approvalQuestion, securityQuestion, regretQuestions } from "./guard.js";
1
+ export { evaluateAction, describeAction, describePlan, matchPatterns, isReadOnlyCommand, stripDataText, buildRequest, formatVerdict, steerReason, intentSteer, questions, intentQuestion, slopQuestions, approvalQuestion, securityQuestion, regretQuestions } from "./guard.js";
2
2
  export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, SlopJudgment, SlopSymptom, Level, PatternHit, PreviousAction, 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";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, stripDataText, buildRequest, formatVerdict, steerReason, questions, slopQuestions, approvalQuestion, securityQuestion, regretQuestions } from "./guard.js";
1
+ export { evaluateAction, describeAction, describePlan, matchPatterns, isReadOnlyCommand, stripDataText, buildRequest, formatVerdict, steerReason, intentSteer, questions, intentQuestion, slopQuestions, approvalQuestion, securityQuestion, regretQuestions } 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";
package/dist/shape.js CHANGED
@@ -26,7 +26,7 @@ export function completeConfig(loaded) {
26
26
  timeoutMs: source.timeoutMs ?? 5000,
27
27
  maxRequests: source.maxRequests ?? 500,
28
28
  steerVisible: source.steerVisible ?? false,
29
- action: section("action", { ...off, tools: [], failOpen: true, timeoutMs: 5000, irreversible: { warn: 1, confirm: 1 }, offTask: { warn: 1, confirm: 1 }, feedbackLog: false }),
29
+ action: section("action", { ...off, tools: [], failOpen: true, timeoutMs: 5000, irreversible: { warn: 1, confirm: 1 }, offTask: { warn: 1, confirm: 1 }, intentMismatch: 1, feedbackLog: false }),
30
30
  stuck: section("stuck", { ...off, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 1, nudge: false }),
31
31
  done: section("done", { ...off, claimsDone: 1, nudge: false }),
32
32
  slop: section("slop", { ...off, threshold: 1, prose: proseOff() }),
@@ -51,6 +51,8 @@ export function completeConfig(loaded) {
51
51
  // The feedback log flag was added inside the action section later than the section itself; an older config module leaves it undefined and the log stays on.
52
52
  if (typeof config.action.feedbackLog !== "boolean")
53
53
  config.action = { ...config.action, feedbackLog: true };
54
+ if (typeof config.action.intentMismatch !== "number")
55
+ config.action = { ...config.action, intentMismatch: 0.8 };
54
56
  // The runaway guard added its template later than the other sections; an older widget section renders the default line.
55
57
  if (typeof config.widget.runaway !== "string")
56
58
  config.widget = { ...config.widget, runaway: DEFAULT_TEMPLATES.runaway };
package/dist/trace.js CHANGED
@@ -46,10 +46,12 @@ export function actionDetails(verdict, extra = {}) {
46
46
  lines.push(`${summary.tool} ${summary.path}${summary.location === "outside_project" ? " (outside the project)" : ""}${summary.exists === false ? " (new file)" : ""}${summary.bytes !== undefined ? ` · ${summary.bytes} bytes` : ""}${summary.editCount !== undefined ? ` · ${summary.editCount} edit${summary.editCount === 1 ? "" : "s"}` : ""}`);
47
47
  if (summary.input !== undefined)
48
48
  lines.push(`input: ${clip(summary.input, 300)}`);
49
+ if (verdict.plan !== undefined)
50
+ lines.push(`plan: ${clip(verdict.plan.replace(/\s+/g, " "), 300)}`);
49
51
  if (verdict.patterns.length)
50
52
  lines.push(`patterns: ${verdict.patterns.map(hit => `${hit.id} (${hit.severity})`).join(", ")}`);
51
53
  if (judgment) {
52
- lines.push(`jev: irreversible ${percent(judgment.irreversible)} · off-task ${percent(judgment.offTask)} · ${judgment.scope.replace(/_/g, " ")} (${percent(judgment.scopeConfidence)})${judgment.approved !== undefined ? ` · approved ${percent(judgment.approved)}` : ""}${judgment.mutates !== undefined ? ` · mutates ${percent(judgment.mutates)}` : ""}${judgment.regretted !== undefined ? ` · regret of last turn ${percent(judgment.regretted)}` : ""} · ${judgment.model} · ${judgment.elapsedMs} ms`);
54
+ lines.push(`jev: irreversible ${percent(judgment.irreversible)} · off-task ${percent(judgment.offTask)} · ${judgment.scope.replace(/_/g, " ")} (${percent(judgment.scopeConfidence)})${judgment.approved !== undefined ? ` · approved ${percent(judgment.approved)}` : ""}${judgment.mutates !== undefined ? ` · mutates ${percent(judgment.mutates)}` : ""}${judgment.intentMismatch !== undefined ? ` · intent mismatch ${percent(judgment.intentMismatch)}` : ""}${judgment.regretted !== undefined ? ` · regret of last turn ${percent(judgment.regretted)}` : ""} · ${judgment.model} · ${judgment.elapsedMs} ms`);
53
55
  }
54
56
  if (judgment?.securityRisk !== undefined)
55
57
  lines.push(`security risk: ${percent(judgment.securityRisk)}`);
package/dist/widget.d.ts CHANGED
@@ -51,7 +51,7 @@ export declare const TOKEN_NAMES: {
51
51
  readonly context: readonly ["tool", "retention", "bytesSaved"];
52
52
  readonly runaway: readonly ["kind", "count", "chars", "signal", "block", "status", "time", "guard"];
53
53
  readonly rules: readonly ["tool", "path", "asked", "violations", "status", "source", "reasons", "model", "ms", "flags", "time", "guard"];
54
- readonly action: readonly ["tool", "level", "source", "irreversible", "offTask", "scope", "approved", "slop", "slopStub", "slopComments", "slopDead", "slopHedging", "patterns", "reasons", "path", "model", "ms", "flags", "time", "guard"];
54
+ readonly action: readonly ["tool", "level", "source", "irreversible", "offTask", "scope", "approved", "intent", "plan", "slop", "slopStub", "slopComments", "slopDead", "slopHedging", "patterns", "reasons", "path", "model", "ms", "flags", "time", "guard"];
55
55
  readonly prose: readonly ["wordy", "cliches", "jargon", "status", "reasons", "model", "ms", "flags", "time", "guard"];
56
56
  readonly stuck: readonly ["failures", "sameStrategy", "approachChange", "progress", "status", "source", "reasons", "model", "ms", "flags", "time", "guard"];
57
57
  readonly done: readonly ["changes", "checks", "checksPassed", "claimsDone", "claimsVerified", "checksApply", "outcome", "status", "reasons", "model", "ms", "flags", "time", "guard"];
package/dist/widget.js CHANGED
@@ -37,6 +37,7 @@ const time = (at) => new Date(at).toTimeString().slice(0, 8);
37
37
  export function actionTokens(verdict, at = Date.now()) {
38
38
  const flags = [
39
39
  verdict.approvedByUser ? "user approved" : undefined,
40
+ verdict.intentMismatch ? "off plan" : undefined,
40
41
  verdict.source === "error" ? "typesafe error" : undefined,
41
42
  verdict.source === "read-only" ? "read-only" : undefined,
42
43
  ].filter(Boolean).join(", ");
@@ -50,6 +51,8 @@ export function actionTokens(verdict, at = Date.now()) {
50
51
  offTask: fixed(verdict.judgment?.offTask),
51
52
  scope: verdict.judgment?.scope.replace(/_/g, " "),
52
53
  approved: fixed(verdict.judgment?.approved),
54
+ intent: fixed(verdict.judgment?.intentMismatch),
55
+ plan: verdict.plan === undefined ? undefined : (verdict.plan.length <= 80 ? verdict.plan : `${verdict.plan.slice(0, 80)}…`).replace(/\s+/g, " "),
53
56
  slop: verdict.slopSymptoms?.length ? verdict.slopSymptoms.map(symptom => `${symptom} ${verdict.slop[symptom].toFixed(2)}`).join(", ") : verdict.slop ? "none" : undefined,
54
57
  slopStub: fixed(verdict.slop?.stub),
55
58
  slopComments: fixed(verdict.slop?.comments),
@@ -149,7 +152,7 @@ export const TOKEN_NAMES = {
149
152
  context: ["tool", "retention", "bytesSaved"],
150
153
  runaway: ["kind", "count", "chars", "signal", "block", "status", "time", "guard"],
151
154
  rules: ["tool", "path", "asked", "violations", "status", "source", "reasons", "model", "ms", "flags", "time", "guard"],
152
- action: ["tool", "level", "source", "irreversible", "offTask", "scope", "approved", "slop", "slopStub", "slopComments", "slopDead", "slopHedging", "patterns", "reasons", "path", "model", "ms", "flags", "time", "guard"],
155
+ action: ["tool", "level", "source", "irreversible", "offTask", "scope", "approved", "intent", "plan", "slop", "slopStub", "slopComments", "slopDead", "slopHedging", "patterns", "reasons", "path", "model", "ms", "flags", "time", "guard"],
153
156
  prose: ["wordy", "cliches", "jargon", "status", "reasons", "model", "ms", "flags", "time", "guard"],
154
157
  stuck: ["failures", "sameStrategy", "approachChange", "progress", "status", "source", "reasons", "model", "ms", "flags", "time", "guard"],
155
158
  done: ["changes", "checks", "checksPassed", "claimsDone", "claimsVerified", "checksApply", "outcome", "status", "reasons", "model", "ms", "flags", "time", "guard"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Guardrails for Pi built on pi-typesafe that steer the agent instead of interrupting you: Jev judges irreversible and off-task tool calls before they run, checks written code against your project rules, detects stuck loops, checks unverified done claims, and flags slop.",
5
5
  "type": "module",
6
6
  "license": "MIT",