pi-warden 0.74.0 → 1.0.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +280 -1
  2. package/README.md +93 -11
  3. package/dist/action-guard.d.ts +14 -6
  4. package/dist/action-guard.js +15 -17
  5. package/dist/arming.js +3 -21
  6. package/dist/ask-gate.d.ts +13 -0
  7. package/dist/ask-gate.js +308 -0
  8. package/dist/config.d.ts +69 -16
  9. package/dist/config.js +205 -49
  10. package/dist/conscience.d.ts +65 -2
  11. package/dist/conscience.js +185 -34
  12. package/dist/curator.d.ts +89 -0
  13. package/dist/curator.js +154 -0
  14. package/dist/done.d.ts +2 -2
  15. package/dist/done.js +12 -3
  16. package/dist/extension.d.ts +9 -1
  17. package/dist/extension.js +613 -224
  18. package/dist/filter.d.ts +82 -0
  19. package/dist/filter.js +176 -0
  20. package/dist/guard.d.ts +140 -29
  21. package/dist/guard.js +765 -55
  22. package/dist/holds.d.ts +39 -10
  23. package/dist/holds.js +58 -20
  24. package/dist/index-cmd.js +7 -6
  25. package/dist/index.d.ts +12 -5
  26. package/dist/index.js +6 -3
  27. package/dist/learning.d.ts +33 -6
  28. package/dist/learning.js +304 -57
  29. package/dist/output.d.ts +3 -0
  30. package/dist/output.js +6 -1
  31. package/dist/prefs.d.ts +3 -13
  32. package/dist/prefs.js +3 -15
  33. package/dist/redact.d.ts +3 -3
  34. package/dist/redact.js +29 -7
  35. package/dist/relevance.d.ts +165 -0
  36. package/dist/relevance.js +558 -0
  37. package/dist/saver.d.ts +27 -0
  38. package/dist/saver.js +32 -2
  39. package/dist/shape.d.ts +1 -1
  40. package/dist/shape.js +37 -9
  41. package/dist/sqlite-adapter.d.ts +48 -0
  42. package/dist/sqlite-adapter.js +68 -0
  43. package/dist/tools.d.ts +6 -0
  44. package/dist/tools.js +9 -0
  45. package/dist/trace.js +5 -1
  46. package/dist/turn-rules.d.ts +17 -1
  47. package/dist/turn-rules.js +94 -12
  48. package/dist/widget.js +1 -1
  49. package/package.json +5 -2
package/CHANGELOG.md CHANGED
@@ -6,7 +6,286 @@ How to keep this current: add the entry in the same pull request as the change,
6
6
 
7
7
  ## Unreleased
8
8
 
9
- <!-- Empty. Next release starts here. -->
9
+ ## 1.0.0
10
+
11
+ The first stable release. From 1.0, pi-warden follows semver for the surface the README's Versioning section lists. Coming from 0.74.1, the last release on npm: [docs/upgrading.md](docs/upgrading.md) lists the removed settings, the new defaults, and what project files may still change. The changes since 0.74.1 are in the sections 0.75.0 to 0.90.0 below, and the README's "What it costs" table gives the measured effect of each 1.0 change.
12
+
13
+ ### Added
14
+
15
+ - `scripts/steer-calibration.mjs`: the blind-label steer measurement (draw the sample from a copy of the hold log, score the labels, gate the slices). `scripts/steer-ask-probe.mjs` measures a candidate question on the same labels, one request per call.
16
+ - `should_ask` in `scripts/action-candidates.mjs`: a reworded should-proceed question that asks whether any visible instruction covers the call. Recorded as a candidate only (never acted on); its measurement is blocked on the TypeSafe account balance.
17
+ - `eval/reports/2026-10-01-field-usage/`: the field report for 2026-09-16 to 2026-10-01 (1,168 sessions, 53,868 judged actions, 419 holds). It replaces the 2026-09-24 report as the source of the README numbers and the hero image.
18
+
19
+ ### Changed
20
+
21
+ - `scripts/field-usage.mjs` classifies the steers it did not know: stuck-loop, off-task, security-weakness, open-loop, subagent-report, repeat-note, action-warning, and tool-or-skill reminder messages, and the turn-rules message for a change made by a command. "Other" falls from 239 of 1,834 steers (13.0%) to 10 (0.5%) over 2026-09-16 to 2026-10-01, and from 152 of 473 (32.1%) to 5 (1.1%) over 2026-09-25 to 2026-10-01. The JSON report carries rule counts, not rule names.
22
+
23
+ ### Docs
24
+
25
+ - `README.md` describes what 1.0 ships: the ask gate, approval on demand, rules at turn start, the local conscience ranking, scratch deletes, credential masking that leaves code values alone, the stricter-only project config, and the hold database (what it keeps, the 90-day prune of allowed rows). A "What it costs" section gives each change's measured effect with its source. A hold no longer reads as needing Jev: the offline floor holds destructive patterns, and Jev adds holds at `irreversible` 0.9 or more. The hero image and the Receipts numbers come from the new field report.
26
+ - `docs/guards.md`: the Action guard step for Jev describes the acting request and the trace-only questions as shipped; Calibration records the ask gate and lean request, and three negative results: the working-memory gate (moved here), the hybrid relevance compaction, and the stale-result stubs.
27
+ - The off-task and should-proceed steers are documented as trace-only, with what `action.offTask.warn`, `action.offTask.steer`, `action.shouldProceed.threshold` and `action.shouldProceed.steer` really change, and the 2026-09-30 blind-label measurement (no slice passes the ship gate) in `docs/guards.md` and `docs/configuration.md`. `README.md` lists `action.shouldProceed.steer` with the keys outside the semver promise. The guards docs name who labelled those 427 calls: one language model, blind to the scores and strata, on the clipped request, plan, context, and call.
28
+ - The claim that 140 intent-mismatch calls were labelled "by hand" is gone: the record (pull request 139) does not say who labelled them. The same for a sample of 14 in the 2026-09-24 field report.
29
+ - `docs/data-handling.md`: the conscience row says candidates are ranked on the machine first and what sends nothing; the hold database row says a held call keeps its summary and an allowed call keeps the judge data. `docs/commands.md`: `/warden status` shows the lifetime hold counts. `docs/extension-authors.md`: `verdict.level` includes `"deny"`.
30
+ - `docs/upgrading.md`: what changes between 0.74.1 and 1.0 (removed settings, project files that only tighten, new defaults, deprecated names), linked from the README.
31
+ - Configuration reference checked against `src/config.ts`: `compaction.maxRequests` default is 12 (was 20), the project-file table lists `action.ask.enabled` and `rulesAtTurnStart.*` as stricter-only, the user-only keys are named, `stuck.diffLimit`, `stuck.tailLimit`, `context.compactAppendix`, and the `PI_WARDEN_DB`, `PI_WARDEN_STEER_STATS`, `PI_WARDEN_INDEX_DIR` variables are documented, and a table that lost its header renders again. The adaptive-steer kind list in the guard docs matches `STEER_KINDS`. The README paired-run count names its four report batches.
32
+
33
+ ## 0.90.0
34
+
35
+ ### Changed
36
+
37
+ - A config file that still sets `conscience.loadThreshold` or `learning.adaptiveThresholds` (documented in 0.74.1, removed since) now gets one config warning per key, from the user file and from a project file. The warning says the key was removed in 1.0 and is ignored, and what happens now. The key still has no effect. `docs/configuration.md` has a "Removed in 1.0" note.
38
+
39
+ ## 0.89.1
40
+
41
+ ### Added
42
+
43
+ - A/B benchmark tooling, registration v3 (`eval/preregistration.md`): model B is `claude-bridge/claude-sonnet-5-5`; agent dollars come from the providers' price pages instead of Pi's catalog, each DeepSeek call priced by its own timestamp at the peak or off-peak rate (Chinese public holidays off-peak, from the State Council's 2026 notice) and Sonnet 5.5 at Anthropic's list prices; a failed Jev judgment in a `warden` run stops the batch and the run is not counted (`eval/jev-stop.mjs`, exit code 3); `scripts/eval-call-costs.mjs` lists every call of a report with its price window; `npm run eval:power` adds the wall-clock of each batch. Repo tooling; rides along with the next release.
44
+ - A/B batch scheduling (`eval/batch.mjs`, `scripts/eval-ab.mjs`), pre-registration v3 revised before any batch run: the queue runs in blocks (one per task x repeat, repeat by repeat) with the cell order of each block shuffled by a seeded generator (`--seed`) and the block's runs dispatched one after another, so paired runs share a price window and the machine's load; `--resume DIR` continues a stopped batch from its `runs.json`, which is now rewritten after every run; a run that fails on an agent-model API error is re-run after 1 and 5 minutes, then recorded with `infraError` and its whole block left out of the metrics, and 5 such failures in a row stop the batch (exit code 4); a Jev request still in flight when a run's process exits no longer stops the batch (recorded as `abandonedJevRequests`); `--jev-usd-cap N` stops the batch at N Jev dollars (exit code 3); the launch commands run under `caffeinate -i`. Repo tooling; rides along with the next release.
45
+ - A/B batch corrections, registration v3 corrected before any batch run: a run is an infrastructure failure only when it ended on the error (its last assistant message, or the last of any turn, stopped on an error, or pi exited before any assistant message); an error pi retried and got past is a valid run, counted as `providerErrorsRecovered`; after an exit-4 stop all 5 runs of the streak run again on resume (an isolated failure stays excluded); a run killed at the timeout with an unreadable Jev ledger has an unknown Jev cost, so it leaves the dollar metric only and the report counts it; `--resume` refuses `--typesafe-cap`; `buildReport` and `buildWeakReport` drop excluded blocks themselves. Repo tooling; rides along with the next release.
46
+
47
+ ## 0.89.0
48
+
49
+ ### Changed
50
+
51
+ - Approval round 2 replaces the one-question design of 0.88.0. The approval request of a held call carries a second question, `reply_points_at_action` (do the agreeing parts of the reply point at this action, not at another item or question), and the call is released only when both `approved` and `reply_points_at_action` are at least 0.7. `replyApprovalQuestion` holds both questions; `askApproval` also returns `pointsAtAction`, and `Judgment.pointsAtAction` records it. No exported name changed.
52
+ - `asked` is the text of every assistant message after the previous user message and before the reply, in order, redacted, last 3,000 characters (before: the newest assistant message, last 1,500). A reply that arrives mid-run follows messages that hold only tool calls, and an explanation can sit earlier in the turn. The approval request now sends up to 3,000 redacted characters of the agent's turn; the consent text (`disclosure`) and `docs/data-handling.md` say so.
53
+ - Measured on 30 cases, 17 held-out cases, and 24 recorded holds (runs; `docs/guards.md`): wrong releases fall from 12 to 1 and correct releases from 81 to 78; held approvals rise from 9 to 12. The pre-set rule counted in runs chose the one-question design (3 fewer correct releases, 2 allowed); the owner shipped round 2 because the held-out cases show 1 wrong release against 6 at equal correct releases, and a wrong release costs more than a second "yes".
54
+
55
+ ## 0.88.0
56
+
57
+ ### Added
58
+
59
+ - Approval on demand. The acting request no longer asks whether the reply approves; a call that is held again under a new user prompt gets one approval request, with the reply and the agent message it answers (`asked`: the newest assistant message before the reply, redacted, its last 1,500 characters). The call is released when `approved` is at least 0.7. Exports: `settleApproval`, `askApproval`, `buildApprovalRequest`, `replyApprovalQuestion`, `describeAsked`, `APPROVAL_THRESHOLD`; `asked` on `Conversation` and `ActionInput`.
60
+ - `scripts/approval-cases.mjs`, `scripts/approval-designs.mjs`, and `scripts/approval-replay.mjs` measure the old question, the shipped one, and a second design that was not shipped, on 30 synthetic cases, 17 held-out cases, and recorded holds. `docs/guards.md` has the tables and the rule that chose the design.
61
+
62
+ ### Changed
63
+
64
+ - `evaluateAction` with `retryAfterHold` takes the same approval step as the Action guard: the acting request does not carry `approved`; when the final verdict is a hold, one approval request follows with `asked` (optional field of the action), and without a judge or when the request fails a reply that reads as approval stands in. Before, it asked the combined question on the acting request and read no agent message. No exported name changed.
65
+ - Approval requests fall from 90.0 to 0.7 per 1,000 judged calls (measured on recorded sessions since 2026-09-16). Measured on recorded holds and cases, wrong releases fall from 45 to 12 and correct releases rise from 55 to 81 (runs; `docs/guards.md`).
66
+ - The consent text (`disclosure`) and `docs/data-handling.md` say what the approval request sends.
67
+
68
+ ## 0.87.0
69
+
70
+ ### Fixed
71
+
72
+ - The end-of-run rules check no longer judges changes that git brought in. When HEAD moved during the run (a `git pull`, a merge), a changed file with no uncommitted change at the end, whose content equals its content in a commit the run did not make and that was committed before the run began, is skipped. Edits, uncommitted command changes (`git checkout <old> -- file` included), commits made during the run, and conflicts the agent resolved are still judged. When HEAD does not move, the check adds one `git rev-parse`; after a pull it adds a few git calls, and it saves the per-file diffs of the skipped files.
73
+ - The done-check no longer counts a `write` or `edit` whose path lies outside the project root as a code change.
74
+ - `/warden test` sends one request. It no longer passes `action.traceSample`, so the first judged call of a process is not also sent with the trace sample, and the headless branch that filtered a sampled verdict is gone.
75
+ - A background notice (the turn-start rules reminder, a conscience tip) no longer causes a model call of its own. It waits for `turn_end` and is steered only when the turn ran tool calls and not every result set `terminate`, the one case where Pi's loop makes another call anyway. Otherwise it is dropped and the trace says why, so it never stays in Pi's steering queue for the next run.
76
+ - The test "session scratch: a symlink under /tmp pointing outside the temp directory stays held" passes wherever the checkout lives.
77
+ - A background notice is no longer dropped on a host that does not send `tool_execution_end`. The turn's tool-call count now comes from `turn_end` (`toolResults`, or the tool calls in the assistant message when that field is absent); `tool_execution_end` is read only for `terminate`, and a batch counts as terminated only when the host reported a result for every call and every result set `terminate`.
78
+ - The git-brought filter of the end-of-run rules check no longer swallows a git failure. The failure reaches the run-end trace, which says the filter failed, gives the error message, and says that every changed file was judged.
79
+ - The git-brought filter probes at most 20,000 file × commit pairs. With more, only the newest commits that fit are probed and the trace says the filter was cut; the newest commits find almost every match. Measured with 300 files and 1,000 older commits, the run-end diff takes about 0.3 s instead of about 2.3 s.
80
+
81
+ ### Changed
82
+
83
+ - The consent text (`disclosure`) and `docs/data-handling.md` say what is sent now: earlier messages and the rules file content ride only the sampled request (one judged call in twenty, `action.traceSample`); the rules content rides the acting request only while a rule violation is open; the turn-start rules request is not sent for short continuations and relayed child reports; when the request fails, times out, or no rule passes the threshold, it was already sent and nothing is appended.
84
+ - The text of the turn-start rules reminder, the conscience tip, `docs/configuration.md`, and `docs/guards.md` says when a notice is delivered: at the end of a turn whose loop continues, and dropped for a turn with no tool call, a batch where every result ended the run, a failed or aborted turn, and a run that ends first. The trace line no longer says "next tool boundary".
85
+ - `docs/configuration.md` and `docs/guards.md` say what the action request carries: earlier messages and the rules content ride only the trace sample (one judged call in twenty, `action.traceSample`), and the rules content rides the acting request only while a violation is open.
86
+
87
+ ### Tests
88
+
89
+ - A `cd` to the home directory after `then`, `do`, and `eval` still holds the relative `rm -rf` that follows.
90
+ - The host test counts the new `tool_execution_end` hook (15 hooks).
91
+ - New tests cover a host that sends `turn_end` with tool results and no `tool_execution_end`, the tool-call fallback to the assistant message, a `terminate` result for only some calls, a git failure in the filter (unit and run-end trace), and the probe-limit cut.
92
+
93
+ ## 0.86.1
94
+
95
+ ### Added
96
+
97
+ - A/B benchmark tooling: a third cell (`warden-offline`, pi-warden with Jev judgments off, so only the offline parts run) beside the prose-only and warden cells; a cost axis that prices each run in dollars — agent input, output, cache-read, and cache-write tokens from the session log at a price table in `eval/config.mjs` (prices from Pi's model catalog, source named there), plus the run's Jev requests and input tokens — reported per run and per cell; four multi-turn tasks (5-6 user turns, a project rule matters only after the first turn) that exercise the turn-start rules reminder; `npm run eval:power`, the power calculation from the earlier reports (violations per run, success non-inferiority, dollars per run) per registered model, with the proposed batches and their estimated cost (billed for one model, list-price equivalent for the plan-based one, plus its token total for quota); and `eval/preregistration.md`, the pre-registration for the 1.0 thesis run. Repo tooling; rides along with the next release.
98
+
99
+ ## 0.86.0
100
+
101
+ ### Added
102
+
103
+ - The turn-start rules reminder: before the first model call of each new user message, pi-warden starts one request asking which of the project's rules apply to that request (one question per rule, carrying the request, the task spine, and each rule's heading, text, and `paths:` scope), and the ones that pass the threshold reach the agent as one short message at the next tool boundary, at most three, strongest first. The prompt never waits for the answer; nothing earlier in the context moves, so a warm prompt cache is never invalidated. A judgment that is off, fails, or takes longer than two seconds appends nothing and says so in the trace, and a run that ends before the answer arrives drops the reminder with a trace line. A short continuation or a relayed child report sends no request: the two prompts the conscience's local gate also skips, with the reason traced. `rulesAtTurnStart.enabled` (default true) and `rulesAtTurnStart.threshold` (default 0.3) control it; a project file may make it stricter, never turn it off. Measured on 100 real requests from three projects, labelled by one model: 68.9% of the rules named apply, 80 of 100 requests name at least one rule, p50 278 ms and p90 336 ms for the request itself. See [guards.md → Rules at turn start](docs/guards.md#rules-at-turn-start).
104
+ - `/warden status` reports the session's turn-start reminder: rules named, requests, failures, and the latency percentiles.
105
+
106
+ ### Changed
107
+
108
+ - The conscience assessment and the turn-start rules request both run in the background: `before_agent_start` starts them and returns, so a prompt never waits for Jev. The hold falls from the judgment's own latency (p50 254 ms against a 250 ms local mock) to under 1 ms at p90 for both. A passing tip is delivered at the next tool boundary through the steer path, with its existing budget rule and its `agent_end` reminder; a tip or a reminder whose run ended first is dropped and traced. See [guards.md → Turn-start delivery calibration](docs/guards.md#turn-start-delivery-calibration-2026-10-01-background-delivery).
109
+ - The turn-start rules request skips the prompts the conscience's local gate skips: offline over 7,633 recorded prompts, 105 short continuations and 785 relayed child reports send no request, so the rate falls from 100 to 88.3 requests per 100 recorded prompts.
110
+
111
+ ### Removed
112
+
113
+ - `workingMemory.*` from the config: the pruning-at-a-cold-turn-start idea failed its feasibility gate before any of its code was written (200 sampled tool results, one Jev question each: no threshold dropped at least 30% of candidates with at most 10% misses), so no feature sits behind the keys. Every documented key is a promise kept through 1.x, so the inert surface is gone. The measurement stays in [guards.md → Working-memory feasibility](docs/guards.md#working-memory-feasibility-2026-09-30-gate-failed).
114
+
115
+ ### Docs
116
+
117
+ - `docs/guards.md` (Rules at turn start) describes the background delivery, the tool-boundary rule, the drop trace, the skip, and both measurements; `docs/configuration.md` documents `rulesAtTurnStart.*`; `docs/data-handling.md` lists exactly what the turn-start request sends and when it sends nothing; `docs/commands.md` names the new `/warden status` totals.
118
+
119
+ ## 0.85.0
120
+
121
+ ### Added
122
+
123
+ - `action.ask` (default on): code decides before the request whether Jev can change anything the agent sees for this call. It asks for a git history or remote write, a delete or a move, a write through a redirect, `tee`, or `sed -i`, a database client, a network write, a publish, a deploy, an infrastructure command, a `gh` write, `ssh`/`scp`/`rsync`, a build or package target that deploys, publishes, or installs, a call nested in a `for`, `do`, or substitution, an interpreter script that names such a shape, and every `write` and `edit`. Every other call is decided by the offline pattern pass and the floor, and the trace says `not asked` with the reason.
124
+ - `action.traceSample` (default 0.05): one judged call in twenty still asks the off-task, scope, and should-proceed questions in a second request, so the recorded signal keeps coming. Their answers go through the off-task and should-proceed checks, stay trace-only, and never hold.
125
+
126
+ ### Changed
127
+
128
+ - The action guard's Jev spend falls on recorded traffic: on the calls pi-warden judged from 2026-09-25 to 2026-09-30, the acting requests fall 51.1% (28,036 to 13,627 plus 717 sampled) and the input tokens per request fall about 42% (measured against the API on 171 sampled calls, 2,288 to 1,327). Together, about 68% fewer input tokens on the action path.
129
+ - The gate still asks 96.7% of the calls whose answer changed what the agent saw in that window (327 of 338). The 11 it leaves offline were all `warn`-level, none was held, and the highest irreversible score among them was 0.57, under the 0.9 hold.
130
+ - `action.offTask`, `action.shouldProceed.threshold`, and `action.shouldProceed.steer` keep working with the lean request: the sampled call's off-task, scope, and should-proceed answers go through the same checks as before, and `shouldProceed.steer: true` puts `should_proceed` on every acting request the ask gate sends.
131
+ - `off_task`, `scope`, and `scopeConfidence` are optional on a judgment and absent from the acting request's answers; a verdict with no off-task answer reads as "no evidence", never as a warning.
132
+ - The offline pattern pass and the floor decide a call the gate leaves unasked exactly as they decide one whose request failed: built-in hits warn or hold as they would with `floor: "level"`, so no current hold weakens.
133
+
134
+ ## 0.84.0
135
+
136
+ ### Added
137
+
138
+ - `conscience.localTopK` (default 31, the most one request holds) and `conscience.localFloor` (default 0.5): the local rank decides how many candidates reach Jev, and a prompt with no candidate above the floor gets no request at all.
139
+
140
+ ### Changed
141
+
142
+ - The conscience never offers Pi's core tools, a tool whose name or index entry says it deletes, drops, or destroys, a tool the session already called, a skill file the session already read, or a tool the model cannot call by that name in this session.
143
+ - A local gate runs before any request. Short continuations, relayed child reports, and a task spine already assessed in this session are skipped with a traced reason; the rest are ranked locally against the request and the task spine, and only the top candidates go to Jev.
144
+ - A recommendation tip is the name, one `useWhen` line, and for a skill the file to read, instead of the whole tool description.
145
+
146
+ ## 0.83.0
147
+
148
+ ### Changed
149
+
150
+ - A `KEY=value` or `KEY: value` hit whose value is a code expression is no longer a credential, so masking no longer rewrites source code in a tool result. A call (`readKeySync(configPath`), an index (`rows[0]`), a member access with or without optional chaining (`output.secretIds`, `opts?.tokens`), a non-null assertion (`match[1]!.split(`), an arrow body (`x => y`), a template literal (`` tag`/\s+/` ``, `` `${x}` ``), and a type name with type arguments (`Record<string`) stay readable; the field case, reading a line as `const tokens = [redacted]).filter(Boolean)`, cannot happen. Token shapes (`sk-`, `ghp_`, `AKIA`, JWTs, PEM blocks, URL passwords, signed-URL parameters) still mask in code. A bracket, a parenthesis, or a word before one inside otherwise opaque characters is still a key: `DB_PASSWORD=<password>(<more>` and `API_KEY=abc[123]DEFghi789` keep their mask, as does a call whose name is plain lowercase or snake_case (`get_config_value(config_key=...`), where a parenthesized password cannot be told apart. Replayed against 1,152 recorded session logs since 2026-09-16 (95,141 tool-result text blocks, all projects): tool results that mask a value fall from 274 to 171, and 17 distinct values stop being masked, every one of them a code fragment from the list above. No value that was masked becomes readable apart from those 17, nothing that was readable becomes masked, and 27 of the 126 values still masked are token-shaped (JWTs, PEM blocks, `sk-` keys, `gh*_` tokens, an `AKIA` key).
151
+ - A value that is a stand-in is still traced, not announced, unchanged; only the code-expression class moved from "masked and announced" to "not a credential".
152
+
153
+ ### Fixed
154
+
155
+ - Credential detection no longer backtracks exponentially, so a tool result can no longer freeze the guard. The code-identifier alternatives in `looksLikeSecretValue` (`src/redact.ts`) wrote a repeated group whose character class could eat the capital that the next iteration needed, so a credential-key assignment carrying a long mixed-case value (about 85 characters of mixed case and digits, as a signed-URL signature produces) took minutes of CPU time: one recorded session result hung the offline replay until the pattern was rewritten. Every pattern in that check is now linear, and the replay's masking cost is unchanged — per masked tool result the median falls from 0.10 ms to 0.08 ms (the p99 rises from 0.99 ms to 1.67 ms, because the remaining results are a smaller set with a larger share of the expensive ones), and across all text blocks the mean is 0.028 ms before and after.
156
+
157
+ ### Docs
158
+
159
+ - `docs/guards.md` (Security) and `docs/configuration.md` (`security.maskOutput`) name the code expressions that are not credentials. `scripts/credential-replay.mjs` replays session logs against a chosen build (`--files`, `--lib`, `--skip`) and reports the masks a change removes (`--compare`), with the removed values written to a file outside the repository.
160
+
161
+ ## 0.82.0
162
+
163
+ ### Added
164
+
165
+ - A recursive `rm` whose target is a variable the same command assigns once before it, to a literal value with no `$`, backtick, substitution, or glob (a leading `~` is allowed), is classified by what that value names: `D=/tmp/x && rm -rf "$D"` warns as `rm-temp-subtree`, `rm -rf "$HOME/projects"` holds, and `"$D"/*` stays held as a wildcard. `$HOME` and `$TMPDIR` resolve from the session environment unless the command writes them. A variable a `mktemp` holds resolves to the directory it made, so `cd "$d" && rm -rf build` warns.
166
+ - A relative `rm` target after `cd DIR` or `pushd DIR` in the same command resolves against `DIR` — a literal path, `~`, `$HOME`, a `mktemp` variable, or a same-command literal assignment — and is then classified as an absolute target: `cd /tmp/x && rm -rf build` warns, while `cd ~ && rm -rf projects` and `cd /Users && rm -rf someone/projects` hold. A `cd` the guard cannot read (`cd -`, `cd` alone, a substitution, an unknown variable) leaves relative targets as they were read before, and so does a command with no `cd`.
167
+ - `PI_WARDEN_SCRATCH_PATHS`: a `:`-separated list of absolute roots the host declares as scratch. A recursive `rm` whose every target is strictly inside one warns (`rm-session-scratch`) instead of holding. A root that is `/`, the home directory, the project root, or a git working tree (it contains `.git`) is ignored, and the session names every ignored entry and its reason once. Environment only, like `PI_WARDEN_HOST_PATHS`.
168
+
169
+ ### Changed
170
+
171
+ - A recursive `rm` of a variable a `mktemp` earlier in the same command assigned warns as `rm-session-scratch` instead of holding: `d=$(mktemp -d) && … && rm -rf "$d"`, the backtick form, and quoted assignments. A variable that was reassigned before the `rm`, a `mktemp -u`/`--dry-run`, and a template outside a temp root keep the hold.
172
+ - A recursive `rm` whose every target is a literal absolute path strictly inside a volatile temp root (`os.tmpdir()`, `$TMPDIR`, `/tmp`, `/private/tmp`, and macOS `/var/folders/<x>/<y>/T`) warns under the new classifier id `rm-temp-subtree` (risky) instead of holding. It rests on location, not birth time, so it applies on every platform and needs no session record. A temp root itself, a wildcard directly under one (`/tmp/*`), a `..` segment, `/var/tmp`, a variable, and a command that raises privileges (`sudo`, `doas`, `su`, `pkexec`, `run0`) keep the destructive hold.
173
+ - Because a literal path under a volatile temp root now warns by location, a command that copies or extracts data in (`cp`, a plain `tar`, a plain `rsync`, `git clone`, `git archive`) no longer keeps the hold for such a target; it still disables the session-scratch exemption, and the `rm` then reports `rm-temp-subtree`. Birth-time records still decide the hit for any other temp root. A command that moves, links, mounts, or takes data with it keeps the hold itself (see Fixed).
174
+ - `/warden test` demonstrates its hold with `rm -rf /var/tmp/pi-warden-demo`; `/tmp` is a warning now.
175
+
176
+ ### Fixed
177
+
178
+ - Data a command moved in no longer loses the hold. A `mv` or `ln` destination that is equal to, inside, or above an `rm` target keeps every release rule off (`mv ~/projects/app /tmp/old-app && rm -rf /tmp/old-app`, and `ln -s ~/projects/app /tmp/lnk && rm -rf /tmp/lnk/`, where the trailing slash deletes the link's target), and so do `mount`, `hdiutil`, `bindfs`, `rsync --remove-source-files`/`--remove-sent-files`, and `tar --remove-files` anywhere in the command. A `mv` or `ln` word the guard cannot read as a destination — inside a shell sink (`bash -c 'mv …'`, `xargs mv`) or at a statement position of another segment — blocks every release as well. A plain `cp`, `tar`, `rsync`, `git clone`, or `git archive` keeps its source, so such a target still warns as `rm-temp-subtree`.
179
+ - The session records every `mv`/`ln` destination it resolved inside a volatile temp root or a declared scratch root, so a later `rm -rf /tmp/old-app` of a path equal to, inside, or above one of them holds even when the birth-time walk cannot tell.
180
+ - A `mktemp` variable counts only when nothing else in the command writes the name: a second `NAME=` or `NAME+=`, a bare `export`/`local`/`declare`/`readonly`/`typeset NAME`, `read … NAME`, `for NAME in`, or `unset NAME`, at the top level, in a function body, in a subshell, or in braces. `d=$(mktemp -d); f() { d=~; }; f; rm -rf "$d"` holds again.
181
+
182
+ - Resolving a variable is an allowlist. A literal assignment, a `mktemp` variable, `$HOME`, or `$TMPDIR` resolves only when every way the command can set a variable is a form the parser reads; `eval`, `source` or `.` as a command, `printf -v`, `read`, `mapfile`, `readarray`, `getopts`, `let`, an arithmetic `((…))`, a `${NAME=…}`/`${NAME:=…}` or `+=` assignment, a declaration builtin with an option, and a function definition anywhere in the command resolve no variable, and the targets classify as they did before the variable rules: `D=/tmp/x; printf -v D %s ~; rm -rf "$D"` and `D=/tmp/x; mapfile -t D < list; rm -rf "$D"` hold again.
183
+ - A `cd` or `pushd` after `if`, `then`, `elif`, `else`, `do`, `while`, `until`, `!`, `{`, or `(` moves the directory the guard resolves against, so `if cd ~; then rm -rf projects; fi` and `while cd ~; do rm -rf projects; break; done` hold. `popd`, `cd -`, `eval`, `builtin cd`, `command cd`, `source`, and `.` leave the directory unknown for every later segment, and a relative recursive `rm` after one holds as `rm-recursive-dangerous-target` instead of resolving: `cd /tmp/x && eval cd ~ && rm -rf projects` and `cd ~ && pushd /tmp/x && popd && rm -rf projects` hold.
184
+ - Classifying an `rm` target, a `cd` destination, or a `mktemp` template reads the file system only when its literal text starts with a volatile temp root (`/tmp`, `/private/tmp`, `/var/folders`, `/private/var/folders`, `os.tmpdir()`, `$TMPDIR`) or a declared scratch root. Any other path, `/home/…` and `/net/<host>/…` included, is not temp and is classified with no file-system call, so an attacker-chosen path can no longer start an automount or block the guard.
185
+
186
+ ### Docs
187
+
188
+ - `docs/guards.md` (Action guard) covers the moved-in block, the `cd` rule, and the literal-variable rule; `docs/commands.md` describes the `/warden test` demo as it now is.
189
+ - `docs/guards.md` (Action guard) describes the three scratch sources, the new `rm-temp-subtree` id, and the changed moves-in interaction; `docs/configuration.md` documents `PI_WARDEN_SCRATCH_PATHS` and lists `rm-temp-subtree` among the exemptable `rm` ids.
190
+ - `docs/guards.md` (Action guard) describes the variable allowlist, the `cd` keyword and unread-change rules, and the file-system boundary.
191
+
192
+ ## 0.81.0
193
+
194
+ ### Changed
195
+
196
+ - The hold database stores less per call. Every row now carries the Pi session id (it used to be empty); the task text is stored once per task, in a `hold_tasks` table keyed by its hash, instead of once per call; and a row of a call that was not held keeps the judge data and the agent's plan (the scores, the reasons, and the plan) and drops the summary and the agent reason for the call. A database written by an earlier version is migrated once at startup, inside one transaction, and the pages the migration and the prune free are reclaimed by a VACUUM that runs only when no other session holds the database; when one does, the VACUUM is skipped and the next start tries again, so no session waits for it.
197
+ - A record of a call that was not held is pruned after `learning.allowedRetentionDays` (default 90 days); `learning.retentionDays` (default 365) still applies to holds.
198
+ - A steer-mode hold is labelled by what happened next: `approved` (your reply released the call), `replanned` (the agent ran a different call that changes something instead), and the new `abandoned` (the run after your reply neither released nor replaced it). The old rule labelled a hold `replanned` two prompts later without looking at what the agent did. `/warden status` counts abandoned holds apart, and the precision line still reports (declined + replanned) over the labelled holds.
199
+
200
+ ### Fixed
201
+
202
+ - A database an earlier version wrote keeps the columns that version's `INSERT` names (`task`, `input_summary`, `prediction`, `preceding_actions`, `confidence`). They are no longer rebuilt away: they stay on `holds`, always empty, so a Pi session still running the older code — and a downgrade — keeps recording holds against a migrated database instead of failing every guarded call until it restarts. The size win does not change: the task text lands once in `hold_tasks`, and the older layout's text is cleared.
203
+ - A row of a call that was not held keeps the judge data: the scores, the reasons, and the agent's plan stay on the row, and its task text lands once in `hold_tasks` with the row pointing at it by hash. Calibration on field traffic reads all four, so only `context_summary` and the agent reason for the call are not written for it.
204
+ - At each start, a row an older session wrote after the migration is settled the same way: its task text moves into `hold_tasks`, the older layout's text is cleared, and an allowed row loses its summary and agent reason. A new `hold_meta` table keeps the highest row id already settled, so a start looks only at the rows written after it and changes no page when there are none; a database another session holds skips the step, as it skips the prune, and the next start runs it.
205
+
206
+ ### Docs
207
+
208
+ - `docs/configuration.md` documents `learning.allowedRetentionDays`.
209
+ - `docs/data-handling.md` describes the slim hold database, the one-time migration, and every hold outcome.
210
+
211
+ ## 0.80.0
212
+
213
+ ### Changed
214
+
215
+ - Rule values are never silently weakened. Every rule kind reads `deny` and `block` as the same value. An unknown `severity` in a command rule, or an unknown `action` in a path or arming rule, used to fall back to the weakest level (`warn`, `note`, or dropping the arming rule); it now applies at `confirm` (a deny-list rule stays `deny`), and a config warning names the rule, the value, and the valid values.
216
+ - Arming durations: a number in `arms.for` is milliseconds and a string without a unit is minutes, as before; a string also takes `ms`, `s`, `m`, or `h` with decimals (`"1.5h"`) in every code path. A config warning names a rule that arms for under one second and a string with no unit.
217
+ - A project file may make the action guard and security stricter, never weaker. It can no longer set `enabled`, `action.enabled`, or `security.enabled` to `false`, raise `action.irreversible.warn`, `action.irreversible.confirm`, or `security.threshold` above the user's value, remove a tool from `action.tools` (it may add one), or set `action.failOpen` to `true` over the user's `false`. Each ignored value gives one config warning. Other guards stay tunable both ways from a project file.
218
+ - Config warnings: a value pi-warden cannot apply as written is shown once when a session starts and again in `/warden status`.
219
+ - `action.shouldProceed.hold` is renamed `action.shouldProceed.threshold`; it never held a call. The old name is still read through 1.x and is deprecated.
220
+ - An arming rule with no `action` is still ignored, and now gives a config warning naming the rule.
221
+ - Removed the settings nothing read: `learning.adaptiveThresholds`, `learning.minHoldsForAdaptive`, `learning.adaptationRate`, and `conscience.loadThreshold`. A config file that still sets them loads as before.
222
+
223
+ ### Fixed
224
+
225
+ - `learning.retentionDays: 0` keeps every hold record, as documented; it used to become 365.
226
+ - The capability index files that `/warden index` writes are owner-only, as documented: the folders are created `0700` and each file is written `0600` through an atomic replace; before, they had the default permissions.
227
+
228
+ ### Docs
229
+
230
+ - `docs/configuration.md` lists the project-file keys that only get stricter, documents `widget.barMode` and `stuck.churnThreshold`, and the lab profile now turns the security guard off in the user file.
231
+ - `docs/commands.md` has a row for `/warden enable` and shows `/warden init [--force]`.
232
+ - `docs/data-handling.md` lists `steer-stats.json`, the capability index files, and the migration notice marker.
233
+ - `docs/extension-authors.md` names exactly the exports semver covers; every other export is internal.
234
+ - `README.md` has a Versioning section that says what semver covers from 1.0.
235
+ - `CONTRIBUTING.md` gives the hold threshold as 0.9.
236
+
237
+ ## 0.79.1
238
+
239
+ ### Fixed
240
+
241
+ - The hold log is now written to a temporary file and renamed into place, so a reader always sees a whole file. Before, a reader during an in-place rewrite could get a line cut short, which made an extension test fail now and then.
242
+
243
+ ### Tests
244
+
245
+ - `npm test` clears every `PI_*` variable and judge key and gives each test process its own agent directory, so a run in a shell inside Pi no longer writes trace lines or steer stats to the developer's directories; the host test builds into a temporary directory instead of `dist/`, and no test file shares a fixed temporary path.
246
+
247
+ ## 0.79.0
248
+
249
+ ### Changed
250
+
251
+ - `warden_remember` records a lesson with no user correction or failure before it, for example a fact about the project that later sessions need; the rules for a lesson are unchanged, and it still reaches later sessions only once recorded in a second session or said by the user.
252
+
253
+ ## 0.78.1
254
+
255
+ ### Docs
256
+
257
+ - `docs/hero.png` is now rendered by `scripts/render-hero.mjs` (`npm run hero`), and every number in it comes from the 2026-09-24 field report (759 sessions); the install line reads `pi install npm:pi-warden`.
258
+
259
+ ## 0.78.0
260
+
261
+ ### Added
262
+
263
+ - Relevance compaction (`compaction`): experimental, off by default, not recommended. In a replay of 48 recorded compactions its summary was 4.7 times the size of Pi's at the median and kept whole only 1 of the 34 files the agent read again. Try it or improve it; changes that make it smaller or keep what the agent goes back for are welcome. At compaction, pi-warden can write the summary instead of Pi's model. User messages and assistant text stay word for word, thinking is left out, and Jev scores each tool call with its result, each extension message, and each part of the previous summary against the current task; kept units go in verbatim, tool output inside a fence marked untrusted, and the rest become one line each. Every kept section is fenced, and a `<` that starts a `summary` tag in kept text is written `&lt;`, so kept text cannot end Pi's summary wrapper. When the security check is on, results it flagged as a possible prompt injection are never kept verbatim; results the context saver compressed keep their excerpt. `compaction.enabled` is user file only; a project may tune the other keys. One compaction sends at most `compaction.maxRequests` (12) requests sends nothing when its requests would leave fewer than 50 of the session's `maxRequests`, and stops before any request when fewer than 50 remain, so it never turns judgments off for the guards; `compaction.timeoutMs` bounds the whole compaction and the global `timeoutMs` each request. Any failure, timeout, abort, missing consent, a provider in `compaction.skipProviders` (default `claude-bridge`), a request limit, or a summary over `compaction.maxSummaryTokens` lets Pi's own summary run; the hook never cancels a compaction. One trace entry per compaction and a line in `/warden status`.
264
+ - `scripts/relevance-replay.mjs` replays recorded compactions through the relevance compaction and compares size, re-fetch coverage, and cost with Pi's summaries. First measurement in `docs/guards.md` → Calibration.
265
+
266
+ ## 0.77.0
267
+
268
+ ### Added
269
+
270
+ - Context filter (beta, off by default). With `"context": { "filter": { "enabled": true } }`, an output that would get the generic head/diagnostic/tail excerpt is split at line boundaries and Jev scores each chunk for the agent's current task; chunks scoring at least `minScore` (1.5) are kept word for word in original order, up to `maxKeptChars` (6000) with the last 1000 characters always kept, and each gap is marked. Parser excerpts, `all`, duplicates, and repeated runs are unchanged. Any error, a timeout (`timeoutMs`, 4000), judgments off, an exhausted request budget, or no passing chunk keeps today's excerpt. `/warden status`, the trace, and the new offline `scripts/filter-report.mjs` count filtered and excerpt outputs apart (count, recalls, kept size, requests, time, fallbacks), so a trial can be judged.
271
+
272
+ ## 0.76.0
273
+
274
+ ### Changed
275
+
276
+ - The intent-mismatch verdict is trace-only by default: `action.intentTraceOnly` is `"all"` instead of `"invisible"`, so a mismatch on a call with a visible effect (a commit, push, merge, tag, reset, pull request, release, publish, install, launched program, or a message sent from a script) no longer reaches the agent. The score, the trace entry, the `/warden status` counters, and the `visibleMismatch` and `intentMismatch` thresholds are unchanged; labels on 140 sampled calls, made blind to the score, put the score's separation of a differing call at AUROC 0.815, but of the 37 steers that would reach the agent, 36 were calls the plan or the user's latest request had asked for. Restore the old delivery with `"action": { "intentTraceOnly": "invisible" }`.
277
+
278
+ ## 0.75.0
279
+
280
+ ### Changed
281
+
282
+ - The default `action.irreversible.confirm` (the hold threshold) is 0.9 instead of 0.7, so a judge-only hold waits for the confidence the recorded action-guard corpus shows is safe: the judge's error rate falls from 15% below confidence 0.8 to under 1% above it, and a 0.9 cutoff chosen on one half of the corpus removed about 52 false alarms on the other half without losing a true catch. A call the judge scores 0.5 to 0.9 now warns instead of holding; pattern holds and user or project overrides are unchanged. Restore the old behaviour with `"action": { "irreversible": { "confirm": 0.7 } }`.
283
+
284
+ ## 0.74.1
285
+
286
+ ### Fixed
287
+
288
+ - Learning features work when Pi runs on Bun. Pi's release binaries (`pi-linux-x64.tar.gz` and the rest) are Bun `--compile` executables in which `import("node:sqlite")` fails with `No such built-in module: node:sqlite`, so `holds.db` never opened and learning was off for every release-binary user. `node:sqlite` is still tried first and is unchanged; only when that import fails and the process runs on Bun does `src/sqlite-adapter.ts` open the database through `bun:sqlite` behind the `DatabaseSync` subset learning uses. With neither module loading, learning stays off behind one warning that now names both modules.
10
289
 
11
290
  ## 0.74.0
12
291
 
package/README.md CHANGED
@@ -14,13 +14,13 @@
14
14
 
15
15
  </div>
16
16
 
17
- ![pi-warden tells the agent what it got wrong and the agent fixes it: 65 untested "done" claims, 51 then ran the tests, 5 found a failure it had missed; after a hold the agent found a safer way 33 times](https://raw.githubusercontent.com/DevMortimer/pi-warden/main/docs/hero.png)
17
+ ![pi-warden tells the agent what it got wrong and the agent fixes it: told 124 times that it said done with nothing checked, the agent ran a check next 94 times (76%); of 111 held actions with a known outcome, the agent found a safer way 79 times, you approved 29, you said no 3 times; 422 steers named a project rule; 1,168 real sessions, 2026-09-16 to 2026-10-01](https://raw.githubusercontent.com/DevMortimer/pi-warden/main/docs/hero.png)
18
18
 
19
19
  <div align="center">
20
20
 
21
- **759 sessions · 220 risky actions stopped before they ran · 76% of fake "done"s turned into real test runs**
21
+ **1,168 sessions · 419 risky actions stopped before they ran · 76% of fake "done"s turned into real test runs**
22
22
 
23
- <sub>Nine days of the maintainer's real use across all their projects, not just this one, 2026-09-16 to 2026-09-24. Method and noise: [field report](eval/reports/2026-09-24-field-usage/).</sub>
23
+ <sub>Sixteen days of the maintainer's real use across all their projects, not just this one, 2026-09-16 to 2026-10-01. Method and noise: [field report](eval/reports/2026-10-01-field-usage/).</sub>
24
24
 
25
25
  </div>
26
26
 
@@ -34,20 +34,81 @@ Then `/warden enable` (paste a [TypeSafe](https://console.typesafe.ai) key) and
34
34
 
35
35
  ## It steers. It doesn't nag.
36
36
 
37
- Most guardrails stop and ask you. pi-warden tells **the agent** what it got wrong, and the agent corrects itself. You are pulled in only when something can't be undone: **3 holds per 1,000 calls. The other 997 just run.**
37
+ Most guardrails stop and ask you. pi-warden tells **the agent** what it got wrong, and the agent corrects itself. You are pulled in only when something can't be undone. In a replay of 18,075 recorded calls (2026-09-21), **0.27% were held at the old 0.7 threshold and 0.1% at 0.9**, the default since 0.75.0 ([report](eval/reports/2026-09-21-calibration-0.33.3/report.md)).
38
38
 
39
39
  | Your agent… | pi-warden… |
40
40
  | --- | --- |
41
41
  | says "done" with no test, build, or lint behind it | sends it back to prove it |
42
42
  | breaks a rule in your `pi-warden.md` or `AGENTS.md` | quotes the exact rule it broke |
43
- | is about to `git push --force`, `reset --hard`, `rm -rf`, `DROP` | holds it before it runs |
43
+ | is about to `git push --force`, `reset --hard`, `rm -rf`, `DROP` | holds it before it runs. A built-in pattern holds the destructive ones with no key at all; with Jev on, a score of 0.9 or more for "cannot be undone" holds too |
44
44
  | retries the same failing fix for the third time | asks for a new hypothesis |
45
45
  | writes stubs, restating comments, hardcoded secrets | names them on the spot |
46
- | floods its context with a 40k-line log | keeps the lines that matter, stores the rest |
46
+ | floods its context with a huge log | keeps the lines that matter, stores the rest |
47
47
  | starts repeating itself forever | stops the reply |
48
48
 
49
49
  [Every guard, with its thresholds and calibration →](docs/guards.md)
50
50
 
51
+ ## Features
52
+
53
+ Every guard and feature, its default, and its status. Defaults are `defaultConfig()` in `src/config.ts`; Beta and Experimental are the labels in [the guard docs](docs/guards.md). Guards that ask Jev need a key and `/warden enable`; without one, their offline parts still run.
54
+
55
+ | Feature | What it does | Default | Status |
56
+ | --- | --- | --- | --- |
57
+ | Jev judgments | Sends redacted samples to Jev for the judged checks below | Off until `/warden enable` | Stable |
58
+ | [Action guard](docs/guards.md#action-guard) | Checks each command, write, and edit before it runs: offline patterns plus one Jev request | On | Stable |
59
+ | ↳ Offline floor | Holds destructive patterns (force push, `reset --hard`, `rm -rf` on a path outside scratch, `DROP`) with no key and no request; with Jev on, a pattern hit goes to Jev as evidence | On | Stable |
60
+ | ↳ Irreversible hold | Jev adds holds: a call scored 0.9 or more as irreversible is held; 0.5 to 0.9 warns | On | Stable |
61
+ | ↳ Scratch deletes | A recursive `rm` of temp scratch the agent made warns instead of holding; data moved in keeps the hold | On | Stable |
62
+ | ↳ Ask gate | Code decides offline when no Jev answer could change what the agent sees, so the call sends no request: 48.9% fewer requests, 327 of the 338 changed outcomes still asked | On. `action.ask` | Stable |
63
+ | ↳ Approval on demand | After a hold, a call held again under your next reply gets one request that reads your reply and the agent's turn; released only when `approved` and `reply_points_at_action` are both 0.7 or more | On | Stable |
64
+ | ↳ Off-task notice | Notes in the trace when a call looks unrelated to your request; never steers or holds | On, trace only | Stable |
65
+ | ↳ Intent-mismatch steer | Tells the agent when a call differs from its stated plan; the score stays in the trace | Off: trace only. `action.intentTraceOnly: "invisible"` | Stable |
66
+ | ↳ Should-proceed notice | Notes in the trace when the judge would ask you first. `action.shouldProceed.steer: true` also tells the agent; the 2026-09-30 labels do not support it (2 of 427 calls needed asking) | Off: trace only | Beta |
67
+ | ↳ Your command, path, and arming rules | Warn, hold, or deny commands and paths you name | On, none set | Stable |
68
+ | ↳ Large-output warning | Tells the agent to filter a command that will print far more than it needs | On | Stable |
69
+ | [Rules](docs/guards.md#rules) | Judges every write and edit against the rules in your `pi-warden.md` | On | Stable |
70
+ | [Rules at turn start](docs/guards.md#rules-at-turn-start) | Names the rules a new request will touch, in the background, and delivers them at the next tool boundary; the prompt never waits | On. `rulesAtTurnStart.enabled` | Stable |
71
+ | ↳ Soft double-check tier | Asks the agent to double-check a score just below a rule's cutoff | Off. `rules.softThreshold` | Stable |
72
+ | [Slop](docs/guards.md#slop) | Names stubs, restating comments, dead code, and padded replies | On | Stable |
73
+ | [Security](docs/guards.md#security) | Flags risky written code and prompt injection in tool output; masks credentials, but leaves code values such as a call or an index alone | On | Stable |
74
+ | [Stuck](docs/guards.md#stuck) | Asks for a new hypothesis when the agent repeats a failing approach | On | Stable |
75
+ | [Done-check](docs/guards.md#done-check) | Sends an unverified "done" back for a check; after a UI change, a visual check | On | Stable |
76
+ | [Runaway](docs/guards.md#runaway) | Stops a reply that repeats itself, offline | On | Stable |
77
+ | [Context saver](docs/guards.md#context-saver) | Replaces large or duplicate tool output with an excerpt and a saved full copy | On | Stable |
78
+ | ↳ Repeated user messages | Cuts repeated runs in your own messages too | Off. `context.dedupeMessages` | Stable |
79
+ | ↳ Compaction evidence appendix | After a compaction, lists failed calls, the last passing check, holds, and saved outputs | On | Stable |
80
+ | [Context filter](docs/guards.md#context-filter-beta-off-by-default) | Jev scores chunks of a large output and keeps the ones for the current task | Off. `context.filter.enabled` | Beta |
81
+ | [Relevance compaction](docs/guards.md#relevance-compaction) | Writes the compaction summary instead of Pi's model; not recommended | Off. `compaction.enabled` (user file only) | Experimental |
82
+ | [Call-waste notes](docs/guards.md#call-waste) | One advisory line on polling, paging, repeated searches, and re-filtered checks | On | Stable |
83
+ | ↳ Session tip | Adds one paragraph about call cost to the system prompt | Off. `waste.tip` | Stable |
84
+ | [Open loops and recall](docs/guards.md#open-loops-and-recall) | `warden_loops` keeps the agent's promises; `warden_recall` lists what it already tried | On, no switch | Stable |
85
+ | [Subagent triage](docs/guards.md#subagent-triage) | Wakes the agent only for a subagent report that needs it | On | Stable |
86
+ | [Judge cooldown](docs/guards.md#judge-cooldown) | Pauses Jev requests after repeated failures, so a dead backend costs no timeouts | On, no switch | Stable |
87
+ | [Steer budget](docs/guards.md#steer-messages) | At most 3 non-critical steers per run; the rest go to the trace | On | Stable |
88
+ | [Adaptive steers](docs/guards.md#adaptive-steers-per-model) | Makes a steer kind trace-only for a model that rarely follows or often disputes it | On | Stable |
89
+ | Steers and notices in the transcript | Shows steers and per-call notices in the chat, not only in the trace | Off. `steerVisible`, `notices` | Stable |
90
+ | [Desktop notifications](docs/guards.md#desktop-notifications) | Holds, confirm dialogs, and runaway stops reach the desktop or your relay | Off. `notify.enabled` | Stable |
91
+ | [Conscience](docs/guards.md#conscience) | Recommends a skill or tool the agent is missing; ranks candidates on your machine first and sends one request, or none | Off. `conscience.enabled` | Beta |
92
+ | Learning from holds | Keeps each judged call in a local database with its Pi session id and, for a hold, what happened next (`approved`, `declined`, `replanned`, `abandoned`); a call that was not held keeps only the judge data, and is pruned after 90 days (`learning.allowedRetentionDays`; holds after 365). `/warden recommend` suggests threshold changes from it. Nothing is sent | On | Stable |
93
+ | Config surface | A project file can only make the action guard and security stricter, a value that cannot be applied as written is shown once as a warning, and `deny` and `block` mean the same in every rule kind | On, no switch | Stable |
94
+ | Standing preferences | `/warden prefs` finds preferences you repeated across sessions and sends them at session start | On | Stable |
95
+
96
+ All keys and their values: [configuration](docs/configuration.md). Commands: [commands](docs/commands.md).
97
+
98
+ ## What it costs
99
+
100
+ Jev requests cost money, so each 1.0 change was measured by what it saves. Each figure is the measured change on recorded sessions of one machine, not a promise for yours.
101
+
102
+ | Change | Measured effect | Source |
103
+ | --- | --- | --- |
104
+ | Ask gate and lean request ([#153](https://github.com/DevMortimer/pi-warden/pull/153)) | Action-guard requests 28,036 → 14,326 (−48.9%); input tokens 64.2M → 20.5M (−68.1%); 327 of the 338 calls whose answer changed what the agent saw are still asked (96.7%) | [Ask gate and lean request](docs/guards.md#ask-gate-and-lean-request-2026-09-30) |
105
+ | Conscience ranks locally first ([#155](https://github.com/DevMortimer/pi-warden/pull/155)) | Requests per prompt 2 → 1; input tokens per prompt 14,620 → 10,241 (−30%); 30.9% of 7,614 recorded prompts send none | [Local gate, top k, and tip text](docs/guards.md#local-gate-top-k-and-tip-text-2026-10-01) |
106
+ | Turn-start reminder and conscience in the background ([#152](https://github.com/DevMortimer/pi-warden/pull/152)) | The prompt hold falls from about 255 ms to under 1 ms at p90; the reminder names a rule that applies 68.9% of the time and catches 21.5% of the rules that apply | [Turn-start delivery calibration](docs/guards.md#turn-start-delivery-calibration-2026-10-01-background-delivery) and [Rules at turn start calibration](docs/guards.md#rules-at-turn-start-calibration-2026-09-30-first-measurement) |
107
+ | Approval on demand ([#160](https://github.com/DevMortimer/pi-warden/pull/160), [#161](https://github.com/DevMortimer/pi-warden/pull/161)) | Approval questions per 1,000 judged calls 90.0 → 0.7; wrong releases 45 → 1 in the measured runs | [Approval on demand](docs/guards.md#approval-on-demand-2026-10-01-three-designs-three-sets) |
108
+ | Hold database ([#149](https://github.com/DevMortimer/pi-warden/pull/149)) | A copy of 52,362 rows shrinks from 285.9 MiB to 41.6 MiB | [Data handling](docs/data-handling.md#what-stays-on-this-machine) |
109
+
110
+ Features that did not earn their cost did not ship: the working-memory pruning, the stale-result stubs, and a hybrid compaction failed their gates, and the off-task and should-proceed steers stay trace-only. The numbers are in the [Calibration](docs/guards.md#calibration) section.
111
+
51
112
  ## Rules no linter can check
52
113
 
53
114
  ```markdown
@@ -61,18 +122,39 @@ Catch errors at the handler and return a message a user can act on.
61
122
 
62
123
  Each `#` heading is one rule. Every write and edit is judged against it in about a quarter of a second by [Jev](https://typesafe.ai), a model that returns a probability, not prose. No `pi-warden.md`? Your `AGENTS.md`, `CLAUDE.md`, or `README.md` is used instead.
63
124
 
125
+ Coming from 0.74.1? See [Upgrading](docs/upgrading.md).
126
+
64
127
  ## Receipts
65
128
 
66
- - **Rules:** in 150 paired agent runs, the agent without pi-warden broke the tested rule **6 times**. With it: **0**.
67
- - **Done-check:** after a nudge, the agent ran a check **57 of 75** times, and sometimes found a failure it had missed.
68
- - **Holds:** when the agent was stopped, it found a safer way **40 of 65** times; you approved 24.
129
+ - **Rules:** in 150 paired agent runs (four batches, `eval/reports/2026-09-18T00-*`), the agent without pi-warden broke a project rule in **6** of 150 runs. With it: **0** of 150.
130
+ - **Done-check:** after a nudge, the agent ran a check **94 of 124** times, and sometimes found a failure it had missed.
131
+ - **Holds:** when the agent was stopped, it found a safer way **79 of 111** times; you approved 29. Field data from 2026-09-16 to 2026-10-01, across versions, so it includes holds from before the 0.9 threshold and the `rm` scratch change.
69
132
  - **Stability:** 13,952 guard cases over 109 overnight cycles, no score drift.
70
133
 
71
- Every number has a script and a raw report in [`eval/reports/`](eval/reports/). They are the maintainer's measurements, not a universal promise, and the reports list what was noise.
134
+ Every number has a script and a raw report in [`eval/reports/`](eval/reports/) or the Calibration section of [the guard docs](docs/guards.md#calibration). They are the maintainer's measurements, not a universal promise, and the reports list what was noise.
135
+
136
+ ## How the defaults are chosen
137
+
138
+ Thresholds are set on recorded sessions. Where the data allows, a threshold is chosen on one half of the corpus and checked on the other. A feature that measured worse ships off or trace-only. [Calibration →](docs/guards.md#calibration)
139
+
140
+ - **Irreversible hold at 0.9** (0.75.0). On 15,346 judged calls the judge was wrong on 15% of calls below confidence 0.8 and under 1% above it. A 0.9 cutoff chosen on one half removed about 52 false alarms on the other half and lost no true catch. Scores from 0.5 to 0.9 now warn.
141
+ - **Intent steer in the trace only** (0.76.0). The score separates a differing call (AUROC 0.815 on 140 calls labelled blind to the score), but 36 of the 37 steers it would send were for calls the plan or your request had asked for.
142
+ - **Failed gates stay off.** Pruning stale tool results had a safe point in 10 of 1,042 recorded sessions (median saving 0.00%); a hybrid compaction kept 5 of the 34 re-read files where the gate asked for 10; the working-memory gate's best cut dropped 23.5% of candidates and missed 12.8% where the gate allowed 10%; and no slice of the off-task or should-proceed steers reached 0.80 precision. Each is recorded in [Calibration](docs/guards.md#calibration).
143
+ - **Relevance compaction off** (0.78.0). In a replay of 48 recorded compactions its summary was 4.7 times the size of Pi's at the median and kept whole only 1 of the 34 files the agent read again.
72
144
 
73
145
  ## Privacy
74
146
 
75
- Secrets and unshown paths are stripped before anything leaves your machine. [Exactly what is sent →](docs/data-handling.md)
147
+ Secrets and unshown paths are stripped before anything leaves your machine. The offline guards send nothing. The opt-in features send more when you turn them on: the context filter sends a large output whole, redacted, in chunks; relevance compaction sends an outline of the conversation and a sample of each tool result. [Exactly what is sent →](docs/data-handling.md)
148
+
149
+ ## Credits
150
+
151
+ - **Confidence bands for the irreversible hold:** Li, Miao, Krishnan, Padman, "JEV-as-a-Judge: Accept When Confident, Escalate When Unsure", [arXiv:2609.26550](https://arxiv.org/abs/2609.26550).
152
+ - **Chunk scoring in the context filter:** GPT Researcher's [context filter](https://docs.gptr.dev/docs/gpt-researcher/gptr/context-filter).
153
+ - **The judge:** [Jev](https://typesafe.ai) by TypeSafe.
154
+
155
+ ## Versioning
156
+
157
+ From 1.0, pi-warden follows semver for: the documented config keys and their defaults (except `compaction.*`, `context.filter.*`, `conscience.*`, and `action.shouldProceed.steer`, which are experimental or beta); the `/warden` commands and their arguments; the `warden_remember`, `warden_loops`, and `warden_recall` tools; the documented `PI_WARDEN_*` environment variables; the widget template tokens; and the exports named in [docs/extension-authors.md](docs/extension-authors.md). The trace file format, the files in pi-warden's data folder, session message types, and every other export are internal and may change in any release.
76
158
 
77
159
  ## Docs
78
160
 
@@ -19,6 +19,8 @@ export interface Conversation {
19
19
  siblings?: readonly ToolCallRef[] | undefined;
20
20
  /** The agent's own words in that message (or its latest text under this prompt); shared by the siblings. Explains, never authorizes. */
21
21
  plan?: string | undefined;
22
+ /** The agent's words the latest user message answers: the text of every assistant message of the turn before it, in order. Lets a short or numbered reply be read against what it points at. Explains, never authorizes. */
23
+ asked?: string | undefined;
22
24
  }
23
25
  export interface InspectOptions {
24
26
  config: ActionGuardConfig;
@@ -36,19 +38,25 @@ export interface InspectOptions {
36
38
  previousActions?: readonly PreviousAction[] | undefined;
37
39
  /** Real paths the agent created under the temp directory in this session, forwarded to `evaluateAction`. */
38
40
  scratch?: ScratchRecords | undefined;
41
+ /** Scratch roots the host declared for this session, forwarded to `evaluateAction`. */
42
+ scratchPaths?: readonly string[] | undefined;
43
+ /** Real paths earlier calls moved or linked data into, forwarded to `evaluateAction`. */
44
+ movedIn?: readonly string[] | undefined;
39
45
  /** Real paths of host directories, forwarded to `evaluateAction`. */
40
46
  hostPaths?: readonly string[] | undefined;
41
47
  }
42
48
  /**
43
49
  * The Action guard for one session. `evaluateAction` judges a single call; this module owns what spans calls:
44
50
  *
45
- * - **Hold and approval.** After a hold in steer mode, the next guarded call that runs under a *new* user prompt asks Jev
46
- * whether that prompt approves it. The retry rarely repeats the held string byte for byte (a `command -v` dropped, a
47
- * different timeout), so approval is judged against the action itself, never matched against the earlier command text.
48
- * A re-hold under the reply keeps the original reference prompt; otherwise the reply could never approve anything.
49
- * Without a judge, a reply that reads as approval stands in for the question.
51
+ * - **Hold and approval.** After a hold in steer mode, a guarded call that runs under a *new* user prompt and is held
52
+ * again gets one approval request: does that prompt, read against the agent message it answers (`asked`), approve this
53
+ * action? A call the verdict lets through sends none. The retry rarely repeats the held string byte for byte (a
54
+ * `command -v` dropped, a different timeout), so approval is judged against the action itself, never matched against the
55
+ * earlier command text. One approval releases one held call. A re-hold under the reply keeps the original reference
56
+ * prompt; otherwise the reply could never approve anything. Without a judge, or when the request fails, a reply that
57
+ * reads as approval stands in for the question.
50
58
  * - **Sibling prejudging.** Calls of one assistant message are judged as soon as the first of them is inspected, so their
51
- * requests go out together. A judgment is used once and only for the input it was made for; an earlier hook may have
59
+ * requests go out together; approval requests are made per call, in order. A judgment is used once and only for the input it was made for; an earlier hook may have
52
60
  * changed the call's input, and a stale judgment is discarded, not reused. Prejudgments do not outlive their turn.
53
61
  */
54
62
  export declare class ActionGuard {
@@ -1,14 +1,16 @@
1
- import { evaluateAction, textApproves } from "./guard.js";
1
+ import { evaluateAction, settleApproval } from "./guard.js";
2
2
  /**
3
3
  * The Action guard for one session. `evaluateAction` judges a single call; this module owns what spans calls:
4
4
  *
5
- * - **Hold and approval.** After a hold in steer mode, the next guarded call that runs under a *new* user prompt asks Jev
6
- * whether that prompt approves it. The retry rarely repeats the held string byte for byte (a `command -v` dropped, a
7
- * different timeout), so approval is judged against the action itself, never matched against the earlier command text.
8
- * A re-hold under the reply keeps the original reference prompt; otherwise the reply could never approve anything.
9
- * Without a judge, a reply that reads as approval stands in for the question.
5
+ * - **Hold and approval.** After a hold in steer mode, a guarded call that runs under a *new* user prompt and is held
6
+ * again gets one approval request: does that prompt, read against the agent message it answers (`asked`), approve this
7
+ * action? A call the verdict lets through sends none. The retry rarely repeats the held string byte for byte (a
8
+ * `command -v` dropped, a different timeout), so approval is judged against the action itself, never matched against the
9
+ * earlier command text. One approval releases one held call. A re-hold under the reply keeps the original reference
10
+ * prompt; otherwise the reply could never approve anything. Without a judge, or when the request fails, a reply that
11
+ * reads as approval stands in for the question.
10
12
  * - **Sibling prejudging.** Calls of one assistant message are judged as soon as the first of them is inspected, so their
11
- * requests go out together. A judgment is used once and only for the input it was made for; an earlier hook may have
13
+ * requests go out together; approval requests are made per call, in order. A judgment is used once and only for the input it was made for; an earlier hook may have
12
14
  * changed the call's input, and a stale judgment is discarded, not reused. Prejudgments do not outlive their turn.
13
15
  */
14
16
  export class ActionGuard {
@@ -18,11 +20,10 @@ export class ActionGuard {
18
20
  /** Judges one call. The verdict's `approvedByUser` means a pending hold was released by the user's reply. */
19
21
  async inspect(call, conversation, options) {
20
22
  const { task } = conversation;
21
- // A hold happened under an earlier prompt and the user has since replied: ask whether the reply approves this action.
23
+ // A hold happened under an earlier prompt and the user has since replied: a held call may now be released by that reply.
22
24
  const retryAfterHold = this.holdPending && this.lastHoldPrompt !== task;
23
- const judgeCall = (tool, input, previousActions) => evaluateAction({ tool, input, cwd: options.cwd, task, context: conversation.context, plan: conversation.plan, spine: conversation.spine }, { config: options.config, judge: options.judge, signal: options.signal, slop: options.slop, security: options.security, largeOutput: options.largeOutput, rules: options.rules, retryAfterHold, previousActions, scratch: options.scratch, hostPaths: options.hostPaths });
24
- // A retry after a hold stays sequential because an approval consumed by one sibling changes the question for the next.
25
- if (options.judge && !retryAfterHold) {
25
+ const judgeCall = (tool, input, previousActions) => evaluateAction({ tool, input, cwd: options.cwd, task, context: conversation.context, plan: conversation.plan, spine: conversation.spine }, { config: options.config, judge: options.judge, signal: options.signal, slop: options.slop, security: options.security, largeOutput: options.largeOutput, rules: options.rules, previousActions, scratch: options.scratch, scratchPaths: options.scratchPaths, movedIn: options.movedIn, hostPaths: options.hostPaths, traceSample: options.config.traceSample });
26
+ if (options.judge) {
26
27
  for (const sibling of conversation.siblings ?? []) {
27
28
  if (sibling.id === call.id || this.prejudged.has(sibling.id) || !options.config.tools.includes(sibling.tool))
28
29
  continue;
@@ -33,14 +34,11 @@ export class ActionGuard {
33
34
  }
34
35
  const key = JSON.stringify(call.input);
35
36
  const ready = this.prejudged.get(call.id);
36
- const pending = ready && !ready.used && ready.key === key && !retryAfterHold ? ready.verdict : judgeCall(call.tool, call.input, options.previousActions);
37
+ const pending = ready && !ready.used && ready.key === key ? ready.verdict : judgeCall(call.tool, call.input, options.previousActions);
37
38
  this.prejudged.set(call.id, { key, verdict: pending, used: true });
38
39
  const verdict = await pending;
39
- if (retryAfterHold && !options.judge && verdict.level === "confirm" && textApproves(task)) {
40
- verdict.level = "allow";
41
- verdict.approvedByUser = true;
42
- verdict.reasons = ["user approved in the latest message", ...verdict.reasons];
43
- }
40
+ if (retryAfterHold && verdict.level === "confirm")
41
+ await settleApproval(verdict, task, conversation.asked, { judge: options.judge, timeoutMs: options.config.timeoutMs, signal: options.signal });
44
42
  if (verdict.approvedByUser)
45
43
  this.holdPending = false;
46
44
  return verdict;