pi-warden 0.3.0 → 0.5.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
@@ -8,15 +8,15 @@ The verdicts above are real output from `npm run test:live`. Independent project
8
8
 
9
9
  ## Philosophy
10
10
 
11
- pi-warden is a harness for the agent, not a gate for the user. It watches every tool call, tool result, and reply, asks Jev small typed questions about them, and acts through the agent's own context — a held call, a steer message, a compressed output — rather than through dialogs. Jev decides; code applies the decision; the LLM is never asked to judge itself. Everything warden does is visible in the trace panel, and nothing is rewritten in your session file.
11
+ pi-warden is a harness for the agent, not a gate for the user. It watches every tool call, tool result, and reply, asks Jev small typed questions about them, and acts through the agent's own context — a held call, a steer message, a compressed output — rather than through dialogs. Jev decides; code applies the decision; the LLM is never asked to judge itself. Guard decisions are visible in the trace panel. Existing session entries are never rewritten; output warnings and excerpts are applied before a new tool result enters the session.
12
12
 
13
13
  Three jobs, in order of maturity:
14
14
 
15
15
  | Pillar | What it means | Status |
16
16
  | --- | --- | --- |
17
- | **Security** | Irreversible or off-task actions are held with a reason the agent can act on; credential files, force pushes, destructive SQL, and remote-script execution are caught offline; secrets are redacted before anything leaves the machine. Next: injected instructions inside tool output (web pages, issues, files) flagged and quarantined; insecure patterns in written code steered. | built · extending |
18
- | **Deslopify** | Stubs, placeholders, filler, and restating comments in written code earn a scored nudge; the agent fixes them on the next turn. Next: per-symptom scores instead of one quality number, and wordy or AI-flavoured replies scored against an audience setting. | built for code · prose planned |
19
- | **Context saving** | Jev judges which tool outputs are worth keeping; code compresses them. Only at moments that cannot cost a prompt-cache miss: when an output arrives (before it enters the cache), when the cache is measurably cold, or when Pi compacts anyway. Works alongside prompt-cache optimizers because it never touches the system prompt or a warm prefix. | planned |
17
+ | **Security** | Action holds and offline patterns protect risky operations. Tool-output checks flag possible injected instructions and credential shapes; insecure written code gets a targeted steer. Output notices are advisory, not a sandbox or a guarantee that an attack is contained. | built |
18
+ | **Deslopify** | Written code is scored per symptom — stubs, restating comments, dead code, hedging — and the agent gets a nudge naming the symptom and its fix; repeats become a standing rule. Replies are scored for wordiness, assistant clichés, and jargon against an `audience` setting, and a trend across replies shapes the next one. | built |
19
+ | **Context saving** | Jev selects a retention policy for large new tool results; code keeps deterministic excerpts and an exact local copy. Only tail compression is implemented. Cold-cache pruning and compaction integration remain planned. The system prompt and existing history are untouched. | tail MVP |
20
20
 
21
21
  ## Why a coding agent needs this
22
22
 
@@ -26,7 +26,7 @@ Agents are good at picking the next command and bad at noticing when that comman
26
26
  - **Scope drift gets flagged.** Poems in a bugfix, refactors nobody asked for, dependency installs unrelated to the task: warned to you at 0.6, held at 0.85 when Jev also calls the action `unrelated`.
27
27
  - **Loops get broken.** Three failures with the same strategy (same command with cosmetic changes, same error after each edit) earn the agent a steer: re-read the error, form a new hypothesis, or report the blocker. Exact repeats are caught offline; Jev tells "investigating between failures" from "flailing" — 0.32 vs 0.93 in the smoke run.
28
28
  - **"Done" gets checked.** When the final message reports completion after file changes and no test, build, or lint passed in that run, the agent is asked to verify before you read a false "all green". A claim that tests passed when none ran is called out as such. Once per prompt, so it cannot loop.
29
- - **Slop gets a note.** Stub functions, `TODO: implement later`, mocks returning fake data, filler comments: scored on the same request as the action guard (zero extra latency) and steered back to the agent after the write, never held.
29
+ - **Slop gets a name.** Written code is scored per symptom on the same request as the action guard (zero extra latency): stubs and fake-data mocks, comments that restate the code, dead or duplicated code, hedging notes. The agent is told which symptom and how to fix it, never held; the third repeat in a session becomes a standing rule. Replies are scored too — wordy, clichéd, or too technical for the configured audience — and a trend across replies earns a nudge that shapes the next one without spending a turn.
30
30
  - **Nothing slows down the boring calls.** Read-only shell lines are skipped without a request; anything else costs one request of ~600 input tokens.
31
31
  - **It degrades gracefully.** Offline pattern checks and exact-repeat detection run with no account at all. On an API timeout or outage the call is allowed with a warning (configurable), and reasons never include your command text or upstream error bodies.
32
32
 
@@ -68,18 +68,40 @@ Keeps the last 12 tool results for the current prompt. When the latest result fa
68
68
 
69
69
  Tracks each run's evidence: code changes (`write`, `edit`) and check commands (`npm test`, `pytest`, `cargo test`, `tsc`, `eslint`, `go test`, `make test`, and similar, whether run through `bash` or `ctx_execute`) with pass/fail; context-mode's inline `Command exited with code N` counts as a failure. When a run ends with a normal assistant message after code changes and no passing check, one Jev request judges the message: `claims_done`, `claims_verified`, `verification_applies` (Noul), `outcome` (Choice: complete / partial / blocked / other). A completion claim ≥ 0.7 that is not a blocker or question, for a task where checks would mean something (≥ 0.5; prose and file housekeeping score ~0.05), is reported as unverified; a verification claim with no check run is reported as a false claim. With `nudge: true` (default) the agent receives one follow-up turn asking it to run the checks or say plainly that nothing was verified — once per user prompt.
70
70
 
71
- ### Slop (`write`/`edit`, same request as the action guard)
71
+ ### Slop in code (`write`/`edit`, same request as the action guard)
72
72
 
73
73
  pi-warden cannot rewrite code — Jev only judges — so slop is handled as a feedback loop through the agent:
74
74
 
75
- 1. When the agent calls `write` or `edit`, two questions ride on the action guard's request (no extra latency): `slop_quality`, a Score over three described levels — *focused* (does what the task needs, comments only where they add information) / *some filler* (comments restating the code, minor dead code, hedging) / *sloppy* (stub or placeholder code, TODO where a working implementation is needed, duplicated or commented-out logic, vague text) — and `slop_placeholder`, a Noul: does the content leave placeholder, stub, mock, or "implement later" code where `task` needs a working implementation? Jev sees the first 1500 characters of a `write` or the first three replacement texts of an `edit`, plus your request, so "needed" is judged against what you asked for.
76
- 2. Quality ≥ 1.5 or placeholder ≥ 0.7 (config `slop`) trips the note. Below that, nothing happens.
77
- 3. The write **goes through** — holding it would leave a half-written file. Instead the agent receives a steer message before its next LLM call: *"pi-warden: the content just written to `src/x.ts` reads as quality 1.90/2 (filler or sloppy) and placeholder or stub code 0.92. Replace stubs and placeholders with working code, remove comments that restate the code, and keep only what the request needs. If something is intentionally left unimplemented, say so in your reply instead of leaving it in the code."* The agent fixes the file on its next turn; you see a notification and `slop 1.9/2 · stub 0.92` in the widget.
75
+ 1. When the agent calls `write` or `edit`, four Noul questions ride on the action guard's request (no extra latency), one per symptom, each with concrete yes/no criteria: `slop_stub` (placeholder, mock, or "implement later" code where `task` needs a working implementation), `slop_comments` (explanatory comments that restate what the adjacent code shows), `slop_dead` (commented-out code, unused imports or variables, duplicated logic, unreachable branches), `slop_hedging` ("should work", "for now", TODOs without a plan). Jev sees a head/middle/tail sample of a `write` (1500 characters) or the first three replacement texts of an `edit`, plus your request.
76
+ 2. Any symptom ≥ `slop.threshold` (0.7) trips the note. The write **goes through** — holding it would leave a half-written file.
77
+ 3. The agent receives a steer message naming the symptoms and their fixes, e.g. *"pi-warden: the content just written to `src/x.ts` has stub or placeholder code where a working implementation is needed; hedging or vague notes. Fix it in your next edit: replace stubs, placeholders, and hard-coded fake data with the working implementation, or state in your reply exactly what is left unimplemented and why; replace \"should work\", \"for now\", and TODOs without a plan with a definite statement or a concrete follow-up."* The third time a symptom appears in a session, the note says so and asks the agent to treat it as a standing rule.
78
78
 
79
- From the live smoke: a `// TODO: implement later` stub scored quality 2.00 / placeholder 0.99, a mock returning fake user data 2.00 / 0.98, a focused implementation 0.00 / 0.02, and a function with only restating comments 0.87 / 0.02 — deliberately below the threshold, because the guard is for stubs and filler that change behaviour, not for style. Not covered: code written through bash heredocs, content past the excerpt limits, and the agent's prose replies.
79
+ Per-symptom scoring is what makes this precise. From the tuning set (`scripts/slop-cases.mjs`, 16 code cases): a `// TODO: implement later` stub scores stub 0.99; restating comments score comments 0.97 while an explanatory why-comment scores 0.08; commented-out code scores dead 0.96 and comments 0.43; a mock inside a *test file* scores stub 0.51 (below threshold, correctly); a stub explicitly outside the task scores 0.24. Not covered: code written through shell heredocs and content past the sample limits.
80
+
81
+ ### Slop in replies (`agent_end`)
82
+
83
+ The run's final reply (≥ 200 characters) is judged against `slop.prose.audience`: `wordy` (preamble, restating the request, closing summary, filler), `cliches` ("Great question", "I hope this helps", "it's worth noting", unrequested caveat lists, emoji headings), `jargon` (unexplained terms for the audience). `audience` is `technical` (default), `plain`, or any free-text description such as *"a founder without programming background"*. One long answer to a long question is not punished: a symptom must appear in `trend` (2) of the last 3 replies before the agent is nudged, the nudge is queued for the next user prompt so it shapes the next reply without spending a turn, and two replies pass before the same nudge can fire again. From the tuning set: a padded reply scores wordy 0.97 / clichés 0.99; a dense three-point summary 0.25 / 0.05; the same status update scores jargon 0.94 for a plain audience and 0.24 for a developer.
84
+
85
+ ### Steer messages
86
+
87
+ Nudges from the stuck, done, slop, and prose guards are custom messages in the agent's context. By default they are **hidden from the transcript** (`steerVisible: false`) so the conversation stays yours; the notification tells you a nudge happened and the trace panel shows the exact text. Set `steerVisible: true` to see them inline.
80
88
 
81
89
  If TypeSafe cannot answer (timeout after 5 s, outage, budget), an action-guard call is allowed with a warning (`failOpen: true`; set it to `false` to hold instead), and the other guards simply skip. When the per-session request budget is spent, pi-warden says so once and continues with offline checks. Consent in headless runs comes from `PI_WARDEN_ENABLED=1`; `PI_WARDEN_MODE` overrides the mode.
82
90
 
91
+ ### Tool-output security and tail compression
92
+
93
+ With consent, content-bearing tools (`read`, fetch/search tools and named MCP equivalents) are checked for instructions that redirect the assistant or request private data. Other tools, including shell/context-mode tools, are checked from 2048 characters. Jev receives a redacted 6000-character head/tail sample, so attacks in an unsampled middle can be missed. A score at or above `security.threshold` adds an untrusted-data notice around the text and a hidden steer. Local credential-shape checks work without network access and warn not to echo or commit possible secrets. Images, result details, error flags, and usage are preserved.
94
+
95
+ Written code gets a `security_risk` question in the existing action request: hardcoded credentials, disabled TLS checks, unsafe shell/SQL interpolation, broad permissions, or bypassed verification. A threshold crossing warns and steers; it does not authorize an action or automatically block otherwise safe edits.
96
+
97
+ For a single text block of at least `context.tailMinChars`, the same output request asks Jev to choose `all`, `errors_and_summary`, or `summary_only`. The full result stays when P(keep all) exceeds `1 - context.confidence` (default: keep when Jev gives `all` more than 0.2), when the probability is missing, or on any request error. Code—not Jev—keeps bounded diagnostic/head/tail excerpts. An exact copy is written to an owner-only temporary directory before replacement; the excerpt links to it. If storage fails, the original stays. Multiple text blocks keep their layout and are not compressed. Trace and widget show the actual bytes saved. No extra steer is persisted for compression alone.
98
+
99
+ This MVP is always tail-only: set `context.enabled: false` to stop compression. It never prunes a warm prefix, changes the system prompt, or edits historical session entries. Full-output files can contain secrets; they remain in the OS temporary directory until you or the OS remove them. Excerpts can omit relevant evidence; read the linked file before making a decision that needs it.
100
+
101
+ ### Task continuity
102
+
103
+ The action guard receives the latest user message plus up to eight previous user/assistant text messages, limited to 750 redacted characters each. This keeps follow-ups and side comments from replacing the task. New user instructions override earlier ones; assistant text supplies context, not permission. Approval of a held action still uses only the latest user message. `unclear` scope alone no longer triggers an off-task warning; irreversible-action checks are unchanged. Long handoffs outside that bounded context can still require the user to restate the task.
104
+
83
105
  ## Commands
84
106
 
85
107
  | Command | Effect |
@@ -103,13 +125,16 @@ Templates in `config.widget` control the text. Segments are separated by ` · `;
103
125
  "enabled": true,
104
126
  "placement": "aboveEditor",
105
127
  "shortcut": "ctrl+shift+w",
106
- "action": "warden · {tool} · irreversible {irreversible} · off-task {offTask} · {scope} · slop {slopQuality} · stub {slopStub} · patterns: {patterns} · {flags} · {level}",
128
+ "action": "warden · {tool} · irreversible {irreversible} · off-task {offTask} · {scope} · slop: {slop} · patterns: {patterns} · {flags} · {level}",
107
129
  "stuck": "warden · stuck · {failures} failures · same strategy {sameStrategy} · change {approachChange} · progress {progress} · {flags} · {status}",
108
- "done": "warden · done-check · {changes} changes · {checksPassed}/{checks} checks passed · claims done {claimsDone} · claims verified {claimsVerified} · checks apply {checksApply} · {outcome} · {status}"
130
+ "done": "warden · done-check · {changes} changes · {checksPassed}/{checks} checks passed · claims done {claimsDone} · claims verified {claimsVerified} · checks apply {checksApply} · {outcome} · {status}",
131
+ "prose": "warden · prose · wordy {wordy} · clichés {cliches} · jargon {jargon} · {flags} · {status}",
132
+ "security": "warden · security · {tool} · injection {injection} · exfiltration {exfiltration} · {status}",
133
+ "context": "warden · context · {tool} · {retention} · saved {bytesSaved} bytes"
109
134
  }
110
135
  ```
111
136
 
112
- Tokens — action: `tool level source irreversible offTask scope approved slopQuality slopStub patterns reasons path 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`. A minimal line: `"action": "⚔ {tool} {level} · {irreversible}/{offTask}"`. Set `"enabled": false` to hide the line (the trace and panel keep working); `"shortcut": ""` disables the keybinding.
137
+ Tokens — action: `tool level source irreversible offTask scope approved slop slopStub slopComments slopDead slopHedging patterns reasons path 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 tokens: `tool injection exfiltration status`; context tokens: `tool retention bytesSaved`. A minimal line: `"action": "⚔ {tool} {level} · {irreversible}/{offTask}"`. Set `"enabled": false` to hide the line (the trace and panel keep working); `"shortcut": ""` disables the keybinding.
113
138
 
114
139
  ## Configuration
115
140
 
@@ -131,8 +156,15 @@ User file `~/.pi/agent/pi-warden/config.json` (owner-only). Missing keys use the
131
156
  },
132
157
  "stuck": { "enabled": true, "window": 12, "minFailures": 3, "cooldown": 3, "sameStrategy": 0.7, "nudge": true },
133
158
  "done": { "enabled": true, "claimsDone": 0.7, "nudge": true },
134
- "slop": { "enabled": true, "quality": 1.5, "placeholder": 0.7 },
135
- "widget": { "enabled": true, "placement": "aboveEditor", "shortcut": "ctrl+shift+w" }
159
+ "security": { "enabled": true, "threshold": 0.7 },
160
+ "context": { "enabled": true, "tailMinChars": 12000, "confidence": 0.8 },
161
+ "slop": {
162
+ "enabled": true,
163
+ "threshold": 0.7,
164
+ "prose": { "enabled": true, "audience": "technical", "threshold": 0.7, "trend": 2, "minChars": 200 }
165
+ },
166
+ "widget": { "enabled": true, "placement": "aboveEditor", "shortcut": "ctrl+shift+w" },
167
+ "steerVisible": false
136
168
  }
137
169
  ```
138
170
 
@@ -140,11 +172,15 @@ A project may add `.pi/pi-warden.json` with `enabled` and per-guard overrides (f
140
172
 
141
173
  ## Data handling
142
174
 
143
- - With consent, each guarded call sends to `https://api.typesafe.ai`: your latest prompt (truncated to 1500 characters), the tool name, the command (truncated to 2000 characters) or the file path (relative inside the project, `~`-shortened outside), whether the file exists, a 1500-character content excerpt for `write`, and the first three edit pairs (400 characters each) for `edit`. The stuck detector sends the last 12 tool calls (300 characters each) with 400-character output tails; the done-check sends the agent's final message (2000 characters) and the run's check commands. No other files, history, or telemetry.
175
+ - With consent, each guarded call sends to `https://api.typesafe.ai`: your latest prompt (truncated to 1500 characters), the tool name, the command (truncated to 2000 characters) or the file path (relative inside the project, `~`-shortened outside), whether the file exists, a 1500-character head/middle/tail sample for `write`, and the first three edit pairs (400 characters each) for `edit`. The stuck detector sends the last 12 tool calls (300 characters each) with 400-character output tails; the done-check and the prose check send the agent's final message (2000 and 2500 characters) with the run's check commands and the audience description. Scope and retention requests also carry up to eight prior user/assistant text messages (750 redacted characters each). Output checks send a redacted head/tail sample up to 6000 characters plus size and tool name. No unrelated files or telemetry.
144
176
  - Obvious credentials in the action (`Authorization` headers, `TOKEN=`/`SECRET=` assignments, `sk-`, `ghp_`, `AKIA`, JWTs, URL passwords, PEM blocks) are replaced with `[redacted]` before sending. This is best-effort; do not rely on it for prompts that contain secrets.
145
177
  - Text returned or steered to the agent names the tool, the reasons, and the scores, not the command text. UI errors never include upstream response bodies or keys.
146
178
  - Judgments are model output. Thresholds are yours to tune; a hold is information for the agent and for you, not a verdict on either.
147
179
 
180
+ ### After updating the package
181
+
182
+ Run `/reload` in each existing Pi session after an update. If an existing session still reports `Cannot read properties of undefined (reading 'enabled')` while a fresh session works, restart that session to replace loaded modules. Version labels alone do not prove that all in-memory modules match the files on disk. Current config loading merges missing legacy fields with defaults; regression tests cover malformed and pre-prose config files. A persistent crash after restart needs its new stack trace and a redacted config example.
183
+
148
184
  ## For extension authors
149
185
 
150
186
  The guard is a plain function you can call from your own extension or tests, with any object that has pi-typesafe's `evaluate` method as the judge:
@@ -170,8 +206,10 @@ Also exported: `matchPatterns`, `isReadOnlyCommand`, `describeAction`, `redact`,
170
206
  ```bash
171
207
  npm install
172
208
  npm run check # typecheck, offline tests (mocked transport), build
173
- npm run test:live # 28 billable synthetic judgments across all guards (key from .env or the stored login); pass action|slop|approval|stuck|done for one group
209
+ npm run test:live # 43 billable synthetic judgments across all guards (key from .env or the stored login); pass action|slop|approval|stuck|done|security for one group
210
+ node scripts/security-cases.mjs [context] # the 12-case output-security, task-continuity, and retention set on its own
174
211
  npm run dev:pi # start Pi with this working tree plus an installed pi-typesafe
212
+ node scripts/slop-cases.mjs # 22 billable cases that tune the slop and prose questions
175
213
  npm run preview # re-render docs/preview.png from the recorded live verdicts (needs a Chrome binary)
176
214
  ```
177
215
 
package/dist/config.d.ts CHANGED
@@ -36,12 +36,34 @@ export interface DoneGuardConfig {
36
36
  /** Also send the agent a follow-up asking it to verify. Triggers one more LLM turn. */
37
37
  nudge: boolean;
38
38
  }
39
+ export interface ProseConfig {
40
+ enabled: boolean;
41
+ /** Who reads the agent's replies: "technical", "plain", or a free-text description. Drives the jargon question. */
42
+ audience: string;
43
+ /** P(symptom) at or above this counts as a hit. */
44
+ threshold: number;
45
+ /** A symptom must hit in this many of the last three replies before the agent is nudged. */
46
+ trend: number;
47
+ /** Replies with fewer characters are not judged. */
48
+ minChars: number;
49
+ }
39
50
  export interface SlopGuardConfig {
40
51
  enabled: boolean;
41
- /** Score (0 focused … 2 sloppy) at or above this warns. */
42
- quality: number;
43
- /** P(placeholder or stub code) at or above this warns. */
44
- placeholder: number;
52
+ /** P(symptom) at or above this is reported for written code: stub, comments, dead, hedging. */
53
+ threshold: number;
54
+ prose: ProseConfig;
55
+ }
56
+ export interface SecurityConfig {
57
+ enabled: boolean;
58
+ /** P(injection or exfiltration) at or above this adds an untrusted-output notice. */
59
+ threshold: number;
60
+ }
61
+ export interface ContextConfig {
62
+ enabled: boolean;
63
+ /** Only new tool output is compressed; warm history and system prompts are never changed. */
64
+ tailMinChars: number;
65
+ /** Minimum P(the full output is not needed), 1 - P(all), before code removes output. */
66
+ confidence: number;
45
67
  }
46
68
  export type WardenMode = "steer" | "confirm" | "advise";
47
69
  export interface WardenConfig {
@@ -63,8 +85,12 @@ export interface WardenConfig {
63
85
  stuck: StuckGuardConfig;
64
86
  done: DoneGuardConfig;
65
87
  slop: SlopGuardConfig;
88
+ security: SecurityConfig;
89
+ context: ContextConfig;
66
90
  /** The status line above the editor and the trace panel. */
67
91
  widget: WidgetConfig;
92
+ /** Show steer messages in the transcript. They are always visible in the trace panel. */
93
+ steerVisible: boolean;
68
94
  }
69
95
  export declare const PACKAGE_NAME = "pi-warden";
70
96
  export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
package/dist/config.js CHANGED
@@ -22,8 +22,11 @@ export function defaultConfig() {
22
22
  },
23
23
  stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, nudge: true },
24
24
  done: { enabled: true, claimsDone: 0.7, nudge: true },
25
- slop: { enabled: true, quality: 1.5, placeholder: 0.7 },
25
+ slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
26
+ security: { enabled: true, threshold: 0.7 },
27
+ context: { enabled: true, tailMinChars: 12000, confidence: 0.8 },
26
28
  widget: defaultWidgetConfig(),
29
+ steerVisible: false,
27
30
  };
28
31
  }
29
32
  /** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
@@ -68,9 +71,6 @@ function positiveInteger(value, fallback) {
68
71
  export function isMode(value) {
69
72
  return value === "steer" || value === "confirm" || value === "advise";
70
73
  }
71
- function score(value, fallback) {
72
- return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 2 ? value : fallback;
73
- }
74
74
  function applyAction(base, raw, timeoutMs) {
75
75
  const withTimeout = { ...base, timeoutMs };
76
76
  if (!isObject(raw))
@@ -103,10 +103,22 @@ function applyDone(base, raw) {
103
103
  return base;
104
104
  return { enabled: boolean(raw.enabled, base.enabled), claimsDone: probability(raw.claimsDone, base.claimsDone), nudge: boolean(raw.nudge, base.nudge) };
105
105
  }
106
+ function applyProse(base, raw) {
107
+ if (!isObject(raw))
108
+ return base;
109
+ return {
110
+ enabled: boolean(raw.enabled, base.enabled),
111
+ audience: typeof raw.audience === "string" && raw.audience.trim() ? raw.audience.trim() : base.audience,
112
+ threshold: probability(raw.threshold, base.threshold),
113
+ trend: Math.min(3, positiveInteger(raw.trend, base.trend)),
114
+ minChars: positiveInteger(raw.minChars, base.minChars),
115
+ };
116
+ }
106
117
  function applySlop(base, raw) {
107
118
  if (!isObject(raw))
108
119
  return base;
109
- return { enabled: boolean(raw.enabled, base.enabled), quality: score(raw.quality, base.quality), placeholder: probability(raw.placeholder, base.placeholder) };
120
+ // 0.2.x used `placeholder` for the stub threshold; it still sets the shared threshold.
121
+ return { enabled: boolean(raw.enabled, base.enabled), threshold: probability(raw.threshold ?? raw.placeholder, base.threshold), prose: applyProse(base.prose, raw.prose) };
110
122
  }
111
123
  function applyWidget(base, raw) {
112
124
  if (!isObject(raw))
@@ -119,6 +131,9 @@ function applyWidget(base, raw) {
119
131
  action: template(raw.action, base.action),
120
132
  stuck: template(raw.stuck, base.stuck),
121
133
  done: template(raw.done, base.done),
134
+ prose: template(raw.prose, base.prose),
135
+ security: template(raw.security, base.security),
136
+ context: template(raw.context, base.context),
122
137
  };
123
138
  }
124
139
  /** Shared request settings; `action.timeoutMs`/`action.maxRequests` from 0.1.x files are still honoured. */
@@ -135,6 +150,15 @@ function applyGuards(base, raw, timeoutMs) {
135
150
  stuck: applyStuck(base.stuck, raw.stuck),
136
151
  done: applyDone(base.done, raw.done),
137
152
  slop: applySlop(base.slop, raw.slop),
153
+ security: isObject(raw.security) ? {
154
+ enabled: boolean(raw.security.enabled, base.security.enabled),
155
+ threshold: probability(raw.security.threshold, base.security.threshold),
156
+ } : base.security,
157
+ context: isObject(raw.context) ? {
158
+ enabled: boolean(raw.context.enabled, base.context.enabled),
159
+ tailMinChars: positiveInteger(raw.context.tailMinChars, base.context.tailMinChars),
160
+ confidence: probability(raw.context.confidence, base.context.confidence),
161
+ } : base.context,
138
162
  };
139
163
  }
140
164
  /** Unknown keys and invalid values fall back to the base; nothing throws on a malformed file. */
@@ -149,6 +173,7 @@ export function applyUserOverrides(base, raw) {
149
173
  ...shared,
150
174
  ...applyGuards(base, raw, shared.timeoutMs),
151
175
  widget: applyWidget(base.widget, raw.widget),
176
+ steerVisible: boolean(raw.steerVisible, base.steerVisible),
152
177
  };
153
178
  }
154
179
  /** Project files may tune the guards but cannot grant TypeSafe consent, change the mode, or raise budgets. */
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { Verdict } from "./guard.js";
3
- export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; the last few tool calls and output tails when the agent keeps failing; and the agent's final message when it reports completion without running checks. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
2
+ import type { SlopSymptom, Verdict } from "./guard.js";
3
+ 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 tail compression. Compression stores 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
4
  export declare function confirmMessage(verdict: Verdict): string;
5
+ /** Names each symptom and its fix; repeats in the session turn the note into a standing rule. */
6
+ export declare function slopSteer(where: string, symptoms: readonly SlopSymptom[], counts: Record<SlopSymptom, number>): string;
5
7
  /** Native Pi registration; importing the root library does not load this module. */
6
8
  export default function wardenExtension(pi: ExtensionAPI): void;
package/dist/extension.js CHANGED
@@ -3,15 +3,18 @@ import { createTypeSafe, resolveApiKey } from "pi-typesafe";
3
3
  import { ensureApiKey } from "pi-typesafe/ui";
4
4
  import { applyUserOverrides, defaultConfig, isMode, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
5
5
  import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome } from "./done.js";
6
- import { evaluateAction, formatVerdict, steerReason, textApproves } from "./guard.js";
6
+ import { evaluateAction, formatVerdict, SLOP_LABELS, steerReason, textApproves } from "./guard.js";
7
+ import { evaluateProse, proseNudge, ProseTrend } from "./prose.js";
8
+ import { compressOutput, evaluateOutput, saveOutput, securityNotice } from "./output.js";
9
+ import { redact } from "./redact.js";
7
10
  import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
8
11
  import { openTracePanel } from "./panel.js";
9
- import { actionDetails, doneDetails, stuckDetails, Trace } from "./trace.js";
10
- import { TOKEN_NAMES } from "./widget.js";
11
- export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; the last few tool calls and output tails when the agent keeps failing; and the agent's final message when it reports completion without running checks. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
12
+ import { actionDetails, doneDetails, proseDetails, stuckDetails, Trace } from "./trace.js";
13
+ import { proseTokens, renderTemplate, TOKEN_NAMES } from "./widget.js";
14
+ 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 tail compression. Compression stores 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.";
12
15
  const WIDGET = PACKAGE_NAME;
13
16
  const CONFIRM_TEXT_LIMIT = 500;
14
- const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, slop: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, errors: 0 });
17
+ 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 });
15
18
  function latestUserPrompt(ctx) {
16
19
  const entries = ctx.sessionManager.getBranch();
17
20
  for (let index = entries.length - 1; index >= 0; index--) {
@@ -25,6 +28,28 @@ function latestUserPrompt(ctx) {
25
28
  }
26
29
  return undefined;
27
30
  }
31
+ /** Scope context only: approval still comes from latestUserPrompt, never from this history. */
32
+ function recentTaskContext(ctx) {
33
+ const entries = ctx.sessionManager.getBranch();
34
+ const messages = [];
35
+ let skippedLatestUser = false;
36
+ for (let index = entries.length - 1; index >= 0 && messages.length < 8; index--) {
37
+ const entry = entries[index];
38
+ if (entry?.type !== "message")
39
+ continue;
40
+ if (entry.message.role !== "user" && entry.message.role !== "assistant")
41
+ continue;
42
+ const { role, content } = entry.message;
43
+ if (role === "user" && !skippedLatestUser) {
44
+ skippedLatestUser = true;
45
+ continue;
46
+ }
47
+ const text = typeof content === "string" ? content : content.filter(part => part.type === "text").map(part => part.text).join("\n");
48
+ if (text.trim())
49
+ messages.push({ role, text: redact(text).slice(0, 750) });
50
+ }
51
+ return messages.reverse();
52
+ }
28
53
  function activeMode(config, hasUI) {
29
54
  const env = process.env.PI_WARDEN_MODE?.trim();
30
55
  const mode = isMode(env) ? env : config.mode;
@@ -49,6 +74,19 @@ export function confirmMessage(verdict) {
49
74
  return lines.join("\n");
50
75
  }
51
76
  const callKey = (tool, input) => `${tool}\0${JSON.stringify(input)}`;
77
+ const SLOP_FIXES = {
78
+ stub: "replace stubs, placeholders, and hard-coded fake data with the working implementation, or state in your reply exactly what is left unimplemented and why",
79
+ comments: "delete comments that restate the code; keep only those that explain intent, constraints, or non-obvious behaviour",
80
+ dead: "remove commented-out code, unused imports and variables, duplicated logic, and unreachable branches",
81
+ hedging: "replace \"should work\", \"for now\", and TODOs without a plan with a definite statement or a concrete follow-up",
82
+ };
83
+ /** Names each symptom and its fix; repeats in the session turn the note into a standing rule. */
84
+ export function slopSteer(where, symptoms, counts) {
85
+ const named = symptoms.map(symptom => `${SLOP_LABELS[symptom]}${counts[symptom] >= 3 ? ` (${counts[symptom]}th time this session)` : ""}`).join("; ");
86
+ const fixes = symptoms.map(symptom => SLOP_FIXES[symptom]).join("; ");
87
+ const standing = symptoms.some(symptom => counts[symptom] >= 3) ? " Treat this as a standing rule for the rest of the session." : "";
88
+ return `pi-warden: the content just written to ${where} has ${named}. Fix it in your next edit: ${fixes}.${standing}`;
89
+ }
52
90
  /** Native Pi registration; importing the root library does not load this module. */
53
91
  export default function wardenExtension(pi) {
54
92
  let client;
@@ -63,6 +101,8 @@ export default function wardenExtension(pi) {
63
101
  let attempts = new AttemptWindow(defaultConfig().stuck.window);
64
102
  let evidence = emptyEvidence();
65
103
  let doneNudged = false;
104
+ const prose = new ProseTrend();
105
+ const slopCounts = { stub: 0, comments: 0, dead: 0, hedging: 0 };
66
106
  const configFor = (ctx) => loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
67
107
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
68
108
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
@@ -106,7 +146,7 @@ export default function wardenExtension(pi) {
106
146
  trace.push({ at: Date.now(), guard, line, details });
107
147
  paint(ctx, config);
108
148
  };
109
- const steer = (content) => pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: true }, { deliverAs: "steer" });
149
+ const steer = (config, content, options = { deliverAs: "steer" }) => pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: config.steerVisible }, options);
110
150
  pi.on("session_start", async (_event, ctx) => {
111
151
  client = undefined;
112
152
  budgetExhausted = false;
@@ -117,6 +157,9 @@ export default function wardenExtension(pi) {
117
157
  attempts.reset();
118
158
  evidence = emptyEvidence();
119
159
  doneNudged = false;
160
+ prose.reset();
161
+ for (const symptom of Object.keys(slopCounts))
162
+ slopCounts[symptom] = 0;
120
163
  if (ctx.hasUI)
121
164
  ctx.ui.setWidget(WIDGET, undefined);
122
165
  });
@@ -139,9 +182,12 @@ export default function wardenExtension(pi) {
139
182
  const heldAt = held.get(key);
140
183
  const retryAfterHold = held.has(key) && heldAt !== task;
141
184
  const judge = judgeFor(config);
142
- const verdict = await evaluateAction({ tool: event.toolName, input: event.input, cwd: ctx.cwd, task }, { config: config.action, judge, signal: ctx.signal, slop: config.slop, retryAfterHold });
185
+ const verdict = await evaluateAction({ tool: event.toolName, input: event.input, cwd: ctx.cwd, task, context: recentTaskContext(ctx) }, { config: config.action, judge, signal: ctx.signal, slop: config.slop, security: config.security, retryAfterHold });
143
186
  if (verdict.source === "skipped")
144
187
  return;
188
+ if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
189
+ 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.");
190
+ }
145
191
  if (verdict.judgment)
146
192
  stats.judged++;
147
193
  if (verdict.source === "error")
@@ -160,12 +206,14 @@ export default function wardenExtension(pi) {
160
206
  const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
161
207
  if (verdict.source !== "read-only")
162
208
  record(ctx, config, "action", formatVerdict(verdict, config.widget.action), actionDetails(verdict, { mode, ...(told ? { told } : {}) }));
163
- if (verdict.slopReasons?.length) {
209
+ if (verdict.slopSymptoms?.length && verdict.slopReasons) {
164
210
  stats.slop++;
211
+ for (const symptom of verdict.slopSymptoms)
212
+ slopCounts[symptom]++;
165
213
  const where = verdict.summary.path ?? event.toolName;
166
214
  if (ctx.hasUI)
167
215
  ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
168
- steer(`pi-warden: the content just written to ${where} reads as ${verdict.slopReasons.join(" and ")}. Replace stubs and placeholders with working code, remove comments that restate the code, and keep only what the request needs. If something is intentionally left unimplemented, say so in your reply instead of leaving it in the code.`);
216
+ steer(config, slopSteer(where, verdict.slopSymptoms, slopCounts));
169
217
  }
170
218
  if (verdict.level === "warn") {
171
219
  stats.warned++;
@@ -199,38 +247,111 @@ export default function wardenExtension(pi) {
199
247
  const config = configFor(ctx);
200
248
  if (!config.enabled)
201
249
  return;
250
+ const textBlocks = event.content.filter(part => part.type === "text");
251
+ const text = textBlocks.map(part => part.text).join("\n");
252
+ const output = await evaluateOutput(event.toolName, text, latestUserPrompt(ctx), {
253
+ security: config.security, context: config.context, judge: judgeFor(config), timeoutMs: config.timeoutMs,
254
+ signal: ctx.signal, compressible: textBlocks.length === 1, taskContext: recentTaskContext(ctx),
255
+ });
256
+ if (ctx.signal?.aborted)
257
+ return;
258
+ if (output.error)
259
+ noteError(ctx, output.error, output.errorCode);
260
+ let content = event.content;
261
+ const notice = securityNotice(output);
262
+ const excerpt = compressOutput(text, output.retention);
263
+ if (excerpt && !ctx.signal?.aborted) {
264
+ try {
265
+ const path = await saveOutput(text);
266
+ const replacement = `${excerpt}\n\nFull output: ${path}`;
267
+ const bytesSaved = Buffer.byteLength(text) - Buffer.byteLength(replacement) - (notice ? Buffer.byteLength(notice) * 2 + 4 : 0);
268
+ if (bytesSaved > 0) {
269
+ content = content.map(part => part.type === "text" ? { ...part, text: replacement } : part);
270
+ record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: output.retention, bytesSaved: String(bytesSaved) }), [
271
+ `retention: ${output.retention}; confidence ${output.confidence?.toFixed(2)}; ${output.model}; ${output.elapsedMs} ms`,
272
+ `saved ${bytesSaved} bytes; full output: ${path}`,
273
+ ]);
274
+ }
275
+ }
276
+ catch {
277
+ noteError(ctx, "Could not store full output; keeping it unchanged.", undefined);
278
+ }
279
+ }
280
+ if (notice && textBlocks.length) {
281
+ let index = 0;
282
+ content = content.map(part => {
283
+ if (part.type !== "text")
284
+ return part;
285
+ index++;
286
+ return { ...part, text: `${index === 1 ? `${notice}\n\n` : ""}${part.text}${index === textBlocks.length ? `\n\n${notice}` : ""}` };
287
+ });
288
+ steer(config, notice);
289
+ record(ctx, config, "security", renderTemplate(config.widget.security, {
290
+ tool: event.toolName, injection: output.injection?.toFixed(2), exfiltration: output.exfiltration?.toFixed(2),
291
+ status: [output.suspicious && "untrusted instructions", output.secret && "possible credentials"].filter(Boolean).join(", "),
292
+ }), [
293
+ `jev: injection ${output.injection?.toFixed(2) ?? "not judged"}; exfiltration ${output.exfiltration?.toFixed(2) ?? "not judged"}`,
294
+ `output sample: ${redact(text).slice(0, 300)}`, `agent told: ${notice}`,
295
+ ]);
296
+ }
297
+ const patch = content === event.content ? undefined : { content };
298
+ // Checks and repeat detection use the original result, not the excerpts or security banner.
202
299
  const failed = resultFailed(event.isError, event.details, event.content);
203
300
  if (config.done.enabled)
204
301
  recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed), event.input, event.toolName);
205
302
  if (!config.stuck.enabled)
206
- return;
303
+ return patch;
207
304
  attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
208
305
  if (!attempts.shouldJudge(config.stuck))
209
- return;
306
+ return patch;
210
307
  stats.stuckChecks++;
211
308
  const verdict = await evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
212
309
  if (verdict.error)
213
310
  noteError(ctx, verdict.error, undefined);
214
311
  if (verdict.source === "repeat" && !verdict.stuck)
215
- return;
312
+ return patch;
216
313
  const nudge = verdict.stuck && config.stuck.nudge ? stuckNudge(verdict) : undefined;
217
314
  record(ctx, config, "stuck", formatStuck(verdict, config.widget.stuck), stuckDetails(verdict, attempts.attempts, nudge));
218
315
  if (!verdict.stuck)
219
- return;
316
+ return patch;
220
317
  stats.stuck++;
221
318
  if (ctx.hasUI)
222
319
  ctx.ui.notify(`warden · stuck: ${verdict.reasons.join("; ")}${nudge ? " (agent nudged)" : ""}`, "warning");
223
320
  if (nudge)
224
- steer(nudge);
321
+ steer(config, nudge);
322
+ return patch;
225
323
  });
226
324
  pi.on("agent_end", async (event, ctx) => {
227
325
  const config = configFor(ctx);
228
- if (!config.enabled || !config.done.enabled || doneNudged || !needsDoneCheck(evidence))
326
+ if (!config.enabled)
229
327
  return;
230
328
  const finalMessage = finalAssistantText(event.messages);
231
329
  const judge = judgeFor(config);
232
330
  if (!finalMessage || !judge)
233
331
  return;
332
+ if (config.slop.enabled && config.slop.prose.enabled && finalMessage.length >= config.slop.prose.minChars) {
333
+ stats.proseChecks++;
334
+ const verdict = await evaluateProse(latestUserPrompt(ctx), finalMessage, { config: config.slop.prose, judge, timeoutMs: config.timeoutMs, signal: ctx.signal });
335
+ if (verdict.error)
336
+ noteError(ctx, verdict.error, undefined);
337
+ else
338
+ prose.record(verdict.flagged);
339
+ const due = verdict.error ? [] : prose.due(config.slop.prose.trend);
340
+ const nudge = due.length ? proseNudge(due, config.slop.prose.audience, prose.counts) : undefined;
341
+ if (nudge) {
342
+ verdict.nudged = true;
343
+ prose.markNudged();
344
+ stats.proseNudges++;
345
+ }
346
+ record(ctx, config, "prose", renderTemplate(config.widget.prose, proseTokens(verdict)), proseDetails(verdict, finalMessage, config.slop.prose.audience, nudge));
347
+ if (nudge) {
348
+ if (ctx.hasUI)
349
+ ctx.ui.notify(`warden · prose: ${due.join(", ")} in ${config.slop.prose.trend} of the last 3 replies (agent nudged for the next reply)`, "warning");
350
+ steer(config, nudge, { deliverAs: "nextTurn" });
351
+ }
352
+ }
353
+ if (!config.done.enabled || doneNudged || !needsDoneCheck(evidence))
354
+ return;
234
355
  stats.doneChecks++;
235
356
  const verdict = await evaluateDone(latestUserPrompt(ctx), finalMessage, evidence, { config: config.done, judge, timeoutMs: config.timeoutMs, signal: ctx.signal });
236
357
  if (verdict.error)
@@ -244,7 +365,7 @@ export default function wardenExtension(pi) {
244
365
  ctx.ui.notify(`warden · done-check: ${verdict.reasons.join("; ")}${nudge ? " (agent asked to verify)" : ""}`, "warning");
245
366
  if (nudge) {
246
367
  doneNudged = true;
247
- pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content: nudge, display: true }, { deliverAs: "followUp", triggerTurn: true });
368
+ steer(config, nudge, { deliverAs: "followUp", triggerTurn: true });
248
369
  }
249
370
  });
250
371
  // Shortcuts are registered once at load; a shape check keeps a typo in the config file from being registered.
@@ -277,11 +398,11 @@ export default function wardenExtension(pi) {
277
398
  const key = resolveApiKey();
278
399
  const source = consentSource(config);
279
400
  const usage = client?.getUsage();
280
- const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop"].filter(Boolean).join(", ");
401
+ 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 (tail-only)"].filter(Boolean).join(", ");
281
402
  report([
282
403
  `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)"}.`,
283
- `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.slop} slop notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests.`,
284
- `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / hold ${config.action.offTask.confirm}; stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop quality ${config.slop.quality}, stub ${config.slop.placeholder}; failOpen ${config.action.failOpen}.`,
404
+ `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)"}.`,
405
+ `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}.`,
285
406
  `Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
286
407
  widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
287
408
  `Trace: ${trace.entries().length} events (/warden trace${shortcut ? `, ${shortcut}` : ""}, or click the status line in fullscreen mode). Widget templates in config.widget: action tokens ${TOKEN_NAMES.action.map(name => `{${name}}`).join(" ")}.`,