pi-warden 0.47.0 → 0.60.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 (48) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/README.md +24 -4
  3. package/dist/action-guard.d.ts +2 -0
  4. package/dist/action-guard.js +1 -1
  5. package/dist/compact.d.ts +52 -11
  6. package/dist/compact.js +108 -29
  7. package/dist/config.d.ts +33 -1
  8. package/dist/config.js +44 -6
  9. package/dist/conscience.d.ts +16 -1
  10. package/dist/conscience.js +50 -4
  11. package/dist/done.d.ts +14 -1
  12. package/dist/done.js +88 -6
  13. package/dist/extension.d.ts +11 -5
  14. package/dist/extension.js +243 -61
  15. package/dist/extension.js.bak +263 -76
  16. package/dist/guard.d.ts +47 -3
  17. package/dist/guard.js +886 -62
  18. package/dist/holds.d.ts +1 -0
  19. package/dist/holds.js +2 -0
  20. package/dist/index-cmd.d.ts +2 -0
  21. package/dist/index-cmd.js +9 -7
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.js +1 -1
  24. package/dist/judge-cooldown.d.ts +1 -0
  25. package/dist/judge-cooldown.js +2 -1
  26. package/dist/learning.d.ts +2 -0
  27. package/dist/learning.js +2 -2
  28. package/dist/output.d.ts +7 -2
  29. package/dist/output.js +11 -4
  30. package/dist/prefs.d.ts +105 -0
  31. package/dist/prefs.js +394 -0
  32. package/dist/redact.d.ts +14 -2
  33. package/dist/redact.js +68 -5
  34. package/dist/rules.d.ts +15 -3
  35. package/dist/rules.js +22 -7
  36. package/dist/saver.d.ts +15 -2
  37. package/dist/saver.js +19 -4
  38. package/dist/shape.d.ts +1 -1
  39. package/dist/shape.js +12 -6
  40. package/dist/shell-writes.d.ts +36 -0
  41. package/dist/shell-writes.js +606 -0
  42. package/dist/stuck.d.ts +22 -0
  43. package/dist/stuck.js +56 -1
  44. package/dist/trace.js +2 -2
  45. package/dist/widget.d.ts +1 -1
  46. package/dist/widget.js +2 -1
  47. package/examples/README.md +2 -2
  48. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,208 @@ How to keep this current: add the entry in the same pull request as the change,
8
8
 
9
9
  <!-- Empty. Next release starts here. -->
10
10
 
11
+ ## 0.60.0
12
+
13
+ ### Changed
14
+
15
+ - `git reset --hard` warns instead of holding when the working tree is clean: `git status --porcelain` in the call's directory prints nothing. The check runs once with a 2-second timeout; a failed or timed-out check holds.
16
+ - `git push --force-with-lease` now holds by default. It warns only when every target branch is named in the command or is the current branch, no target is `main`, `master`, or the remote's HEAD branch, and the command has no plain `--force`/`-f`. A `+` or delete refspec, `--all`, `--mirror`, `--tags`, an unknown flag, a detached HEAD, or `push.default=matching` on a bare push keeps the hold.
17
+ - Both relaxations read only a plain single `git reset` or `git push` command. A `cd`, `-C`, quote, variable, or second command keeps the hold.
18
+ - The conscience no longer sends its `agent_end` reminder when the run ended with a final text reply. The agent has answered; a reminder then started a new turn to revisit a finished answer.
19
+
20
+ ### Fixed
21
+
22
+ - `git reset --hard` with global options before `reset` (`git -C dir`, `--git-dir`, `--work-tree`, `-c key=value`) is held. Before, the pattern needed `reset` right after `git`, so `git -C dir reset --hard` was neither held nor warned.
23
+ - `git push --force`, `-f`, and `--force-with-lease` with the same global options before `push` (`git -C dir push --force`) are held. The push patterns had the same gap.
24
+
25
+ ### Tests
26
+
27
+ - A clean and a changed tree for `reset --hard`, and a failed status check. Lease pushes to a feature branch, to `main`, `master`, and the remote HEAD branch, with plain `--force`, with a bare push that tracks the default branch, and outside a repository. A conscience run that ended with a final reply sends no reminder. `reset --hard` with `-C`, `--git-dir`, `--work-tree`, and `-c` on a clean tree is held. `push --force`, `-f`, and `--force-with-lease` with those options are held.
28
+
29
+ ## 0.59.5
30
+
31
+ ### Fixed
32
+
33
+ - Shell writes that gave neither a judged write nor a skip are now judged. `exec > f; echo x` judges the text the later commands print into `f`; a redirected `{ echo x; } > f` or `(echo x) > f` group judges the text its commands print, and a group that also runs a program is skipped with a reason; `env echo x > f` treats `env` as a wrapper; a target such as `/dev/../tmp/p/f` is normalized before the `/dev/` test; a partly quoted heredoc delimiter (`<<E"OF"`) ends at `EOF` and keeps the body literal, as in bash.
34
+ - One bash command no longer starts one rules request per write. Writes to the same file are joined into one request (40 `>>` appends to one file are one request), and past five files a command writes, the rest are recorded in the trace as skipped. The action request lists each file once.
35
+ - The done-check no longer counts `grep -rn screenshot src`, `idb list-targets`, or `flutter test test/unit/x_test.dart` as visual proof. A `done.visualTools.commandWords` word counts only after a `commands` head, `flutter test` counts only for an `integration_test/` or golden path (or `--update-goldens`), and `idb` only for its `screenshot` or `ui` subcommand. `chrome`, `chromium`, and `google-chrome` are now `commands` heads that count with a screenshot flag (`chrome --headless --screenshot=…`), so a headless-browser screenshot is proof and `chromium --version` is not.
36
+ - The conscience now drops camelCase destructive tools (`deleteIssue`, `dropTable`, `mcp__db__truncateTable`) and tools whose name or leading description verb is `kill`, `force`, `uninstall`, `revoke`, `erase`, or `clear` (`kill_process`, `force_push`, `uninstall_package`).
37
+
38
+ ## 0.59.4
39
+
40
+ ### Fixed
41
+
42
+ - A prompt no longer authorizes a recursive `rm` of `/`, `.`, `./`, `..`, `~`, `*`, or `$HOME`. Before, "Clean up build/ please." released the hold on `rm -rf /`: the target `/` was found in `build/`. The `rm-recursive-dangerous-target` hit is now never authorized by a prompt. A path the prompt authorizes must appear as a whole word (a space, quote, bracket, or punctuation before and after it), so `old/build` and `build.gradle` no longer authorize `rm -rf build`, and a root-like, home, variable, or one-character target is never matched.
43
+ - `git grep` takes the read-only fast path only with listed flags. `--open=sh` (git accepts any abbreviation of `--open-files-in-pager`) and `-O` bundled after other flags (`-lOnode`) run a program on the matched files, and both skipped the judge.
44
+ - The `/warden index` directory is a host path only when neither it nor a directory between it and Pi's agent directory is a symlink, and its real path is inside the agent directory. A symlinked index directory could make the home directory a host path, so an overwrite of a shell profile was not held.
45
+ - The read-only fast path no longer takes commands that write a file named in their arguments: `sort -o`, `uniq` with an output file, `tree -o` and `tree -R`, and `xxd -r` or `xxd` with an output file.
46
+
47
+ ### Tests
48
+
49
+ - Tests for each case above: the root-like `rm` prompts, the `git grep` pager forms and common safe flags, a symlinked index directory, and the writing forms of `sort`, `uniq`, `tree`, and `xxd`.
50
+
51
+ ## 0.59.3
52
+
53
+ ### Fixed
54
+
55
+ - The conscience no longer recommends a tool that cannot run on this platform. `powershell`, `pwsh`, and `cmd` are dropped from the tool candidates when the platform is not Windows, and a tool whose description says Windows-only or macOS-only is dropped on the other platforms. Skills are not filtered. In a run on macOS the conscience recommended `tool:powershell` at P(useful) 0.91; its end-of-run reminder then made the agent spend a turn checking for `pwsh` and answer about PowerShell instead of the task. The reminder re-runs the same assessment, so it can no longer name such a tool either.
56
+ - The conscience's end-of-run reminder now passes the same activation gate as the first recommendation. Before, a capability held with `no_policy` (question hash or answering model not in the policy) was still sent later as `Reminder: consider using …`, which bypassed the gate that should fail closed.
57
+
58
+ ## 0.59.2
59
+
60
+ ### Changed
61
+
62
+ - The `large_output` score of a judged `bash` call is now recorded. The action trace entry shows it (`large-output 0.12` in the details, a `largeOutput` template token), and the holds database keeps it in `scores`. The hold signature still hashes only the irreversible score and the reasons, so hold matching is unchanged. Before, the question never steered in field use and nothing showed whether it scored low or was never asked.
63
+ - The context saver traces the outputs it judged and kept whole. One `context` trace entry per output (per text block for a multi-block result) records the retention, confidence, format, and format confidence, so the `context.confidence` gate can be calibrated. Trace only: no notice, no steer, and the status line does not change.
64
+
65
+ ### Fixed
66
+
67
+ - `/warden recommend` is offered in the command completions (`/warden rec` completes to it) and has a row in `docs/commands.md`.
68
+
69
+ ## 0.59.1
70
+
71
+ ### Fixed
72
+
73
+ - A write or edit of a `/warden index` file is no longer held as "overwrites a file outside the project". `/warden index` asks the agent to write `global.json` and `projects/<hash>.json` in `pi-warden/index/` under Pi's agent directory, and in a hold-precision review 3 of the 4 approved outside-project overwrite holds were these files. That directory is now always a host path for the outside-project rule. The rest of the agent directory is still held: `auth.json`, `settings.json`, pi-warden's own `config.json`, and other extensions' data. Every other check still applies there.
74
+
75
+ ## 0.59.0
76
+
77
+ ### Added
78
+
79
+ - Standing preferences. `/warden prefs` lists the corrections and preferences you repeated in two or more earlier sessions of this project ("never bump the version in a feature commit", "keep replies short"), each with its session count and last date, and suggests adding the ones worth keeping to `pi-warden.md` as rules. The scan reads only the messages you typed (not tool output, extension messages, pasted orders and reports, messages another agent relays for you, questions, or temporary holds such as "don't commit yet") in the session files of the project and of its other git worktrees, at most the 200 newest sessions of the last 30 days within a 250 ms budget. It groups clauses that share most of their words, or a subject word that is rare in your messages ("don't spawn subagents", "no subagents, review it yourself"), and keeps the 10 most repeated. It runs in code, once per session start, and never writes a file. New keys `prefs.enabled` (default `true`) and `prefs.inject` (default `false`): with `inject` on, the list goes to the agent as one context message at session start (at most 600 characters, not a steer, no steer budget spent).
80
+
81
+ ## 0.58.0
82
+
83
+ ### Changed
84
+
85
+ - The conscience no longer recommends core tools the agent already uses. In a week of field use, 75 of 144 recommendations named `read`, `bash`, or `edit`, which tells the agent nothing new; one read "use bash instead of bash". New key `conscience.skipTools`, default `["read", "bash", "edit", "write", "grep", "find", "ls"]`, removes those tools from the candidate list before the judge is asked. Skills are never removed by this key; `skipTools: []` restores the earlier behaviour. The question wording is unchanged, so the beta policy hash still matches.
86
+ - The conscience never recommends a destructive tool. A tool whose name has `delete`, `drop`, `destroy`, `remove`, `purge`, `wipe`, `reset`, or `truncate` as a whole word or `_`-separated part, or whose description starts with one of these words, is not a candidate. The field data had 4 recommendations of `delete_project`.
87
+ - Fewer candidates make the conscience request smaller. On a catalog of 19 skills and 32 tools, one assessment now sends 45 questions in 2 requests instead of 54 questions in 3.
88
+
89
+ ## 0.57.0
90
+
91
+ ### Added
92
+
93
+ - The done-check asks for a visual check after UI changes (`done.uiProof`, default on). When a run changes a file that matches `done.uiFiles` (stylesheets, markup, `.jsx`/`.tsx`/`.vue`/`.svelte`/`.astro` components, Flutter `.dart` files, `web/` and `public/` scripts; tests excluded), whether by `write`, `edit`, or a `bash` file write, passing tests and builds no longer prove a "done" reply. Only a successful `done.visualTools` call after the last UI change does: `agent-browser`, `playwright`, `flutter test`, `idb`, `xcrun simctl io`, a `screenshot` command, a browser MCP tool such as `take_screenshot` or `navigate_page`, or a `read` of an image. Without one, the final reply is judged with the existing questions, and the nudge names the file: "You changed `web/app.css` but did not look at the result. Open it in a browser or take a screenshot before calling it done, or say it is unverified." In a week of real sessions, 23 of 75 UI runs that ended with a "done" reply had no visual step after the last UI change; replayed, the rule nudges 20 of them and 7 of the other 52, 6 of which had no real visual step either. `uiProof: false` restores the previous rule. See `docs/guards.md` → Done-check.
94
+
95
+ ## 0.56.1
96
+
97
+ ### Tests
98
+
99
+ - The two judge cooldown tests that wait for the window to end no longer depend on runner speed. They used a 40 ms window and a 60 ms sleep, so on a slow runner the window could end again before the next action, and the test failed with `the window is open again: 3 !== 2`. They now use a 60 s window and move a mocked `Date.now` forward instead of sleeping. The failed-probe test also checks that the probe after the window reaches the judge, so it can no longer pass when the window never ends. `JudgeCooldown` now reads `Date.now` on each call rather than keeping the function it saw at construction; the timing is unchanged.
100
+
101
+ ## 0.56.0
102
+
103
+ ### Added
104
+
105
+ - The stuck guard catches a literal repeat on the 2nd call, in code, with no request. When the agent makes the same call (same tool and input) a second time and nothing that can change state ran between, it gets a short steer if the call failed with the same output ("you already ran `npm test`; it failed the same way: 1 failing. Change something before running it again.") or if a read (`read`, or a read-only shell command) returned the same output again ("you already have this output from `read src/config.ts` (3 calls ago); nothing changed since."). Every call that is not provably read-only resets the check: writes and edits, MCP tools, scripts, unknown tools, and shell commands that are not read-only; only `read`, `grep`, `find`, `ls`, and read-only shell commands do not. Polling and waiting (`sleep`, `watch`, `git status`, `gh run watch`, `gh pr checks`, `tail -f`, `ps`) never fire. It fires once per call per prompt, so a 3rd identical failure still reaches the regular stuck check; it counts against the per-run steer budget as `repeat`, and a stuck verdict on the same result takes its place. New key `stuck.repeatSteer` (default `true`) turns it off; it also follows `stuck.nudge`.
106
+
107
+ ## 0.55.0
108
+
109
+ ### Changed
110
+
111
+ - With masking on, the credential banner in a tool result is sent only when a value was masked, and its text is unchanged. A credential-shaped value that was detected but not masked is recorded in the trace as `possible credentials, none masked (traced)` and is not announced to the agent. With `security.maskOutput: false` the value is in the agent's context, so the generic notice stays. In one week of sessions, 510 of 527 credential banners were the generic "Possible credentials in this output" text that pointed at no value, and agents disputed 49 of them. The prompt-injection notice is unchanged.
112
+ - Masking now covers every value the offline credential check detects: URL passwords (`postgres://user:pass@host`), `Authorization` header and `Bearer` values, `ghu_`/`ghs_`/`ghr_` GitHub tokens, `xoxr-`/`xoxs-` Slack tokens, and `AIza` keys. Before, these were announced but left readable in the tool result.
113
+
114
+ ## 0.54.0
115
+
116
+ ### Changed
117
+
118
+ - The action guard's read-only fast path, which skips the Jev judgment, now also accepts `sed -n` with one print command (`sed -n '10,20p' f`, `sed -n '/a/,/b/p' f`), `git worktree list`, `git stash list`, and `git merge-base`. For `sed`, the only accepted options are ones that cannot write or run anything: no `-i`, no `-f`, and no option after the first operand. The script must be literal, with no variable expansion and no `w`, `W`, `e`, or `r` command. In one week of real sessions, `sed -n` line ranges were almost all of the read-only commands that still went to Jev; about a third of them got a `warn`. The fast path now also rejects `<(...)` process substitution and git's `--output` and `-O`/`--open-files-in-pager`, which run a command, write a file, or open a pager program.
119
+ - The read-only fast path accepts a leading variable assignment (`LC_ALL=C sort f`) only for `LANG`, `LC_*`, `TZ`, `NO_COLOR`, `TERM`, `COLUMNS`, and `FORCE_COLOR`. Any other assignment, such as `PATH`, `LD_*`, `DYLD_*`, `BASH_ENV`, `IFS`, `PAGER`, or `GIT_*`, can change what the command runs or loads, so the call goes to Jev.
120
+
121
+ ## 0.53.0
122
+
123
+ ### Added
124
+ - Rules, slop, and security judge code that a `bash` command writes to a file, as if it were a `write`: heredocs into `cat` or `tee` (quoted or unquoted delimiter, `<<-`), here-strings, and `echo`/`printf` with `>` or `>>`. Each target in a chain is judged on its own, before the command runs, with the same `.gitignore` and outside-project skip as a `write`; an append judges only the appended text. An authoring form whose text cannot be read (`$VAR` or `$(...)` in the body, a pipe into `tee`) and `sed -i`, `patch`, and `git apply` are not judged, and the trace says why; a program's output sent to a file (`cmd > log`) is not judged and leaves no trace note. See `docs/guards.md` → Rules.
125
+
126
+ ## 0.52.2
127
+
128
+ ### Fixed
129
+
130
+ - The context saver's trace now shows how many token-turns a saving spared. The ledger line of a compression or a dropped duplicate is written before the turn that carries it ends, so it nearly always read `~0 token-turns spared over 0 turns`. When a turn with a saving ends, its latest context entry in the trace gets a second line, `at turn end: Context saver: …`, with the counts that include that turn. Token-turns are the removed tokens (bytes / 4) times the turns the removal has been in effect, summed over the session; a new prompt does not reset them. A whole-file recall puts a stored output back into context, so from the next turn on its bytes no longer count toward token-turns; a scoped recall does not change the count. `/warden status` is unchanged.
131
+
132
+ ## 0.52.1
133
+
134
+ ### Fixed
135
+
136
+ - Authorizing an rm target from the task text no longer drops an unrelated pattern hit. A hit leaves the level computation only when every per-target violation it produced is authorized; before this, violations and hits were matched by list position, so `rm -rf a b c` with a task naming `c` could drop another rule's hit. An rm-family hit with no readable target (`find . -name '*.log' | xargs rm -rf`, `find -delete`) now gets one violation without a path; only a task that contains that command segment verbatim (whitespace collapsed) authorizes it, so "clean up the log files" does not authorize `xargs rm -rf` of any list, and the hit stays in the level computation. rm targets are read with shell quoting (`rm -rf 'a b'` is one target), redirections such as `2>/dev/null` are no longer targets, and each rm-family hit is scoped only to the targets of the segments that produced it (`rm a; rm -rf b` scopes `rm-rf` to `b`).
137
+
138
+ ## 0.52.0
139
+
140
+ ### Changed
141
+
142
+ - The compaction evidence appendix lists what already failed, so the agent does not retry it after compaction. `### Tried and failed` holds up to five distinct failed calls from the stuck guard's attempt window, oldest first, each with the last line of its output that names the error (runner tallies such as "Found 1 error." are skipped), at most 160 characters per entry. `### Verification` names the last passing check and says whether code was written or edited after it (`code changed since last passing check: yes/no`), or says that no check has passed yet when code was changed. The stuck section shows failures out of the attempt window instead of an always-empty same-strategy score and a bare tool name.
143
+ - When the appendix is over its 2 000-character cap, lower-value sections shrink first: saved outputs to the three newest, then held actions, then the active task, stuck state, and older checks. The failed attempts and the verification line are kept whole.
144
+
145
+ ### Fixed
146
+
147
+ - Saved full outputs in the compaction appendix show the tool that produced them and their real size. Before this, every entry read `unknown → path (0 bytes)`. `ContextLedger.record` takes an optional third argument, `{ tool, bytes }`, and the new `ContextLedger.storedOutputs()` returns it with each path. An output recorded without it is listed by path only.
148
+
149
+ ## 0.51.0
150
+
151
+ ### Changed
152
+
153
+ - The rules guard applies its 31-question cap per write after path scoping, not when the rules file loads. Before this, rules past 31 in file order were never judged, even when most rules were scoped by `paths:` to other files. Each write now takes the rules that apply to its path, in file order, and asks the first 31. The trace entry records how many applicable rules were dropped and the first dropped rule id. The first write in a session with dropped rules shows one notice naming the rules file and that rule id.
154
+ - `/warden status` shows the total rule count, and names only the unscoped rules that are past the cap for every file. `RuleSet.dropped` is replaced by `RuleSet.alwaysDropped` (unscoped rules past the cap); `RuleSet.rules` now holds every parsed rule.
155
+
156
+ ## 0.50.1
157
+
158
+ ### Changed
159
+
160
+ - `/warden trace` sends the trace as text whenever `PI_WARDEN_TRACE_DIR` is set to an absolute path (the host asked for the trace file), whether or not the host built the sidebar. Before this, the text went out only when the sidebar component was never built, so a host without a terminal UI that builds components would get no trace.
161
+
162
+ ## 0.50.0
163
+
164
+ ### Added
165
+
166
+ - Credentials in a tool result are masked before the model sees them, not only announced. High-confidence values in the text blocks (private key blocks, `sk-` keys including `sk-proj-`, `ghp_`, `gho_` and `github_pat_` tokens, `AKIA` keys, `xoxa-`/`xoxb-`/`xoxp-` Slack tokens, JWTs, and credential-key assignments whose value looks like a secret) become `[redacted]` before any other rewrite, so compressed excerpts, duplicate notes, and stored full output hold the masked text too. Fixture and documentation stand-ins stay readable. The banner says "N value(s) masked in this output as [redacted]" and tells the agent to check presence without printing the value. A value already announced this session is still masked, without a second banner. `security.maskOutput` (default `true`) turns it off; masking is local and still runs when `security.enabled` is `false`, which only switches off the banner and the security judgments.
167
+ - Built-in pattern `printenv-secret` (destructive): `printenv NAME`, or `echo $NAME` / `echo "${NAME}"`, where the name contains `KEY`, `SECRET`, `TOKEN`, `PASSWORD`, or `PASSWD` in any case, including inside `ssh … "…"` and `fly ssh console -C "…"`. The label tells the agent to check that the variable is set without printing it (`test -n "$NAME" && echo set`, `printenv NAME | wc -c`); those checks, `${NAME:+set}`, `${#NAME}`, bare `printenv`, and `env` are not held. A double-quoted string that expands such a variable is no longer blanked as data text for the pattern rules. Like every built-in pattern, it holds on its own when no judge answers or with `action.floor: "level"`; in the default evidence mode it is evidence for Jev. Exemptable by id.
168
+
169
+ ### Fixed
170
+
171
+ - Signed URLs no longer raise "Possible credentials". A value found only in the query signature of an `https://` URL that also carries an expiry (`X-Amz-Expires`, `X-Goog-Expires`, `Expires`, or `se=`) counts as a stand-in: the `X-Amz-Signature`, `X-Amz-Credential`, `X-Goog-Signature`, `X-Goog-Credential`, `Signature`, and `token` parameters of S3 and GCS presigned URLs and storage links. It is traced once, with no banner and no masking in the result; redaction of what leaves the machine is unchanged. A `token=` outside such a URL, or a value that also appears on its own, still counts. Before this, nearly every read of an issue tracker's API raised the notice, because its responses carry signed upload URLs. `partitionSecrets` takes the text as an optional second argument for this check.
172
+ - A credential value containing `&` is redacted whole: `DB_PASSWORD=Tr0ub4dor&3xK9` no longer leaves `&3xK9` in what goes to Jev, the trace, or the masked result. `&` still ends a value before another `name=` (a URL query), before a second `&`, and before whitespace or the end.
173
+ - `security.maskOutput` is read from the user file only: a repository's `.pi/pi-warden.json` cannot turn off masking of credentials in its own agent's output.
174
+ - `X-Amz-Security-Token` counts as a signed-URL parameter, so an S3 presigned URL made with temporary credentials no longer raises "Possible credentials".
175
+
176
+ ## 0.49.0
177
+
178
+ ### Added
179
+
180
+ - The action guard reads which database a `psql`, `mysql`, `mariadb`, or `supabase` command targets (`sqlTarget`): loopback, hosted, or unknown. A loopback `DELETE FROM … WHERE …` warns (`sql-delete-local`) instead of holding; `DROP`, `TRUNCATE`, and a `DELETE` with no `WHERE` or an always-true one (`WHERE true`, `WHERE 1=1`, `WHERE 'a'='a'`, `WHERE NOT false`) still hold on loopback. SQL in a heredoc fed to a database client is now checked for `DROP`, `TRUNCATE`, and `DELETE FROM` on every target, as `-c` SQL is; before, such a heredoc was treated as data and got no hit. A hosted target (Supabase, Neon, RDS, PlanetScale, or a supabase `--linked` or non-local `--target`) warns as elevated (`sql-hosted`), and a write statement against it holds (`sql-hosted-write`). A hosted Postgres command wrapped as `BEGIN READ ONLY; … ROLLBACK;` is quiet; a `COMMIT` anywhere voids that. A variable host such as `$DATABASE_URL`, and SQL from `-f` or a pipe, keep the plain SQL hits. See docs/guards.md.
181
+
182
+ ## 0.48.1
183
+
184
+ ### Fixed
185
+
186
+ - The intent-mismatch question is asked only when the agent stated a plan for the call: the text of the assistant message that makes the call, or of the text-only message right before it with no tool call in between. Before this, a call with no text of its own was judged against the latest assistant text since the user's prompt, often written before several earlier calls. A shell command with a visible effect (a `git` commit, push, merge, tag, or reset, `gh pr`, `gh release`, or `npm publish` in any segment, decided in code before the request) is still judged against the latest assistant text since the prompt, because an unannounced commit or push after an older plan is the mismatch users object to. On the recorded sessions from 2026-09-21 to 2026-09-24, 151 of 163 intent-mismatch notices were judged against stale text; the question would now be asked for 63 of the 163 (8 against the call's own text, 55 visible actions against stale text). A skipped question is one fewer question in the action request.
187
+
188
+ ### Tooling
189
+
190
+ - `tests/steer-delivery.test.ts` pins that a steer sent from `tool_call` or `tool_result` for the last call of a turn joins the request that carries the tool result and costs no extra model request, so the intent-mismatch notice stays a steer.
191
+ - `scripts/intent-cases.mjs` reads Pi session logs and, for every intent-mismatch notice, classifies where the plan came from: the message that made the call, the text-only message right before it, or text from before earlier tool calls (stale). It also runs the built `assistantPlan` on each call's branch and counts the notices whose question would still be asked. Aggregate counts only; offline.
192
+
193
+ ### Docs
194
+
195
+ - README leads with a new image of the self-correction loop and its numbers from the first nine days of use; the headline numbers are updated to match.
196
+
197
+ ## 0.48.0
198
+
199
+ ### Added
200
+
201
+ - `PI_WARDEN_HOST_PATHS`: a `:`-separated list of absolute directories outside the project where the host lets its agent write. A `write` or `edit` in one of them, after `..` and symlinks are resolved, is not held or warned by the outside-project rule; command rules, path rules, deny rules, sensitive paths, secrets, and Jev still apply. Relative entries, empty entries, and `/` are ignored. Environment only; no config file can set it.
202
+
203
+ ## 0.47.1
204
+
205
+ ### Fixed
206
+
207
+ - A done-check nudge no longer clears the evidence that caused it. A run that a warden follow-up starts (done-check nudge, runaway recovery, subagent wake) keeps the changes and checks of the run before it, so the agent must still show a passing check. A later "done" with no check in that run is judged and recorded as unverified; the one-nudge-per-prompt bound stays. A user prompt still starts with empty evidence.
208
+
209
+ ### Docs
210
+
211
+ - Field usage report for 2026-09-21 to 2026-09-24 (`eval/reports/2026-09-24-field-usage/`) and `scripts/field-usage.mjs`, which reads Pi session logs and the holds database and prints aggregate counts only. README leads with the field numbers, names the task spine under Privacy, and notes the scratch-delete exemption and the judgments-off notice.
212
+
11
213
  ## 0.47.0
12
214
 
13
215
  ### Changed
package/README.md CHANGED
@@ -6,7 +6,11 @@ pi-warden supervises Pi while it works, catching risky actions, ignored rules, s
6
6
 
7
7
  Instead of interrupting you for every problem, Warden usually feeds the issue back to the agent so it can correct itself and keep going.
8
8
 
9
- ![Real verdicts from pi-warden: the same command gets a different verdict depending on what the user asked for](https://raw.githubusercontent.com/DevMortimer/pi-warden/main/docs/preview.png)
9
+ **In nine days of real use: 743 sessions, 193 risky actions held before they ran, 1,292 notes sent to the agent. After a "done" with no test behind it, the agent ran one 78% of the time.**
10
+
11
+ ![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)
12
+
13
+ <sub>Numbers from the first nine days of real use (2026-09-16 to 2026-09-24, 743 sessions). The quoted lines are verbatim. Counted with `scripts/field-usage.mjs`; the [field report](eval/reports/2026-09-24-field-usage/) has the method and what was noise.</sub>
10
14
 
11
15
  ## Install
12
16
 
@@ -31,7 +35,7 @@ Works without any key (offline guards: pattern list, runaway stop, sensitive-pat
31
35
  | **Rules** | every `write` and `edit` | Judges against project Markdown rules, quotes the broken rule |
32
36
  | **Slop** | code and replies | Names stubs, restating comments, dead code, hedging |
33
37
  | **Stuck** | tool results | Repeated failures using the same strategy |
34
- | **Done-check** | final message | "Done" claims with no test/build/lint behind them |
38
+ | **Done-check** | final message | "Done" claims with no test/build/lint behind them, or UI changes nobody looked at |
35
39
  | **Security** | code and output | Hardcoded secrets, injection risks |
36
40
  | **Runaway** | reply stream | Stops replies that begin repeating themselves |
37
41
  | **Subagent triage** | async child reports | Keeps noisy background work from waking the parent |
@@ -39,8 +43,10 @@ Works without any key (offline guards: pattern list, runaway stop, sensitive-pat
39
43
 
40
44
  ## How intervention works
41
45
 
46
+ ![Real verdicts from pi-warden: the same command gets a different verdict depending on what the user asked for](https://raw.githubusercontent.com/DevMortimer/pi-warden/main/docs/preview.png)
47
+
42
48
  1. **Read-only? Skip.** `git status`, `ls`, `read` means no request and no trace entry.
43
- 2. **Known-dangerous pattern? Catch it locally.** Force push, `git reset --hard`, recursive `rm`, SQL `DROP` held instantly.
49
+ 2. **Known-dangerous pattern? Catch it locally.** Force push, `git reset --hard`, recursive `rm`, SQL `DROP` held instantly. An agent deleting temp-directory scratch it created in the same session is not held (macOS and Windows).
44
50
  3. **Needs judgment?** Warden evaluates the action in context.
45
51
  4. **Steer or hold.** Most issues go back to the agent so it can correct itself. Irreversible actions can be stopped before they run.
46
52
 
@@ -52,6 +58,7 @@ In the default `steer` mode, Warden talks to the agent rather than interrupting
52
58
  - **Custom policies**: command, path, and arming rules for your own workflow.
53
59
  - **Context saver** trims oversized tool output while keeping the full result retrievable.
54
60
  - **Multiple judgment backends**: TypeSafe by default, with OpenRouter support.
61
+ - **Honest about being off**: when judgments cannot run (no consent, no key, a rejected key, budget spent) Warden says so once, with the fix. A failing backend is paused after repeated errors instead of costing a timeout per action.
55
62
  - **Full traceability**: inspect what Warden saw, decided, and told the agent.
56
63
  - **Desktop alerts** for events that need you.
57
64
 
@@ -72,6 +79,19 @@ Jev judges every write and quotes violations back. Rules can be things no linter
72
79
 
73
80
  ## Does it actually help?
74
81
 
82
+ ### In daily use
83
+
84
+ Four days of the maintainer's own work, 397 sessions ([report](eval/reports/2026-09-24-field-usage/), [raw numbers](eval/reports/2026-09-24-field-usage/usage.json), `scripts/field-usage.mjs` to run it on your own logs):
85
+
86
+ - **Done-check:** 47 nudges; in 35 the agent then ran a test or build.
87
+ - **Holds:** 143 of 19,695 actions. Of the 44 with a recorded outcome, the agent took a safer route 33 times.
88
+ - **Rules:** 195 corrections naming a project rule, including a process wait with no timeout and a home path in a public test fixture.
89
+ - **Context:** about 123,000 tokens of oversized output trimmed in the traced sessions, and no agent needed the full output back.
90
+
91
+ The report also lists what was noise that week (scratch deletes, credential notices on source code, two over-broad rules) and what changed because of it.
92
+
93
+ ### In benchmarks
94
+
75
95
  In 150 paired agent runs, the control setup violated the tested project rule **6 times**. With Warden: **0**.
76
96
 
77
97
  An overnight stability run covered **13,952 guard cases across 109 cycles** with no score drift.
@@ -86,7 +106,7 @@ These are project-maintained benchmarks, not universal claims. Raw reports and r
86
106
 
87
107
  ## Privacy
88
108
 
89
- Depending on the configured judgment backend, Warden sends to your provider: a redacted summary of each guarded call, the agent's plan, up to eight redacted prior messages, and redacted tool-output samples. Secrets are stripped before anything leaves the machine. [Full details.](docs/data-handling.md) Security work — pentesting, incident response, CTF — has its own configuration recipe for keeping samples local and for the friction that credentials and lab setups cause: [Recipe: security work](docs/configuration.md#recipe-security-work).
109
+ Depending on the configured judgment backend, Warden sends to your provider: a redacted summary of each guarded call, the agent's plan, up to eight redacted prior messages, the task spine (your first request in the thread and up to four earlier ones, capped at 1,200 characters), and redacted tool-output samples. Secrets are stripped before anything leaves the machine. [Full details.](docs/data-handling.md) Security work — pentesting, incident response, CTF — has its own configuration recipe for keeping samples local and for the friction that credentials and lab setups cause: [Recipe: security work](docs/configuration.md#recipe-security-work).
90
110
 
91
111
  ## Documentation
92
112
 
@@ -36,6 +36,8 @@ export interface InspectOptions {
36
36
  previousActions?: readonly PreviousAction[] | undefined;
37
37
  /** Real paths the agent created under the temp directory in this session, forwarded to `evaluateAction`. */
38
38
  scratch?: ScratchRecords | undefined;
39
+ /** Real paths of host directories, forwarded to `evaluateAction`. */
40
+ hostPaths?: readonly string[] | undefined;
39
41
  }
40
42
  /**
41
43
  * The Action guard for one session. `evaluateAction` judges a single call; this module owns what spans calls:
@@ -20,7 +20,7 @@ export class ActionGuard {
20
20
  const { task } = conversation;
21
21
  // A hold happened under an earlier prompt and the user has since replied: ask whether the reply approves this action.
22
22
  const retryAfterHold = this.holdPending && this.lastHoldPrompt !== task;
23
- const judgeCall = (tool, input, previousActions) => evaluateAction({ tool, input, cwd: options.cwd, task, context: conversation.context, 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 });
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
24
  // A retry after a hold stays sequential because an approval consumed by one sibling changes the question for the next.
25
25
  if (options.judge && !retryAfterHold) {
26
26
  for (const sibling of conversation.siblings ?? []) {
package/dist/compact.d.ts CHANGED
@@ -1,8 +1,18 @@
1
+ /**
2
+ * Compaction evidence appendix. Deterministic, pure, no Jev request.
3
+ *
4
+ * After Pi fires `session_compact`, the extension builds a snapshot from session memory and passes it to
5
+ * `compactAppendix`. The returned text is sent as one custom message so the agent does not retry approaches that
6
+ * already failed, knows whether its last passing check still covers the code, and can prefer saved paths over
7
+ * re-running commands.
8
+ */
9
+ import type { RunEvidence } from "./done.js";
1
10
  export interface SavedOutput {
2
- tool: string;
11
+ /** Undefined when the recorder did not say; the appendix then prints only what it knows. */
12
+ tool: string | undefined;
3
13
  path: string;
4
14
  /** Uncompressed size in bytes. */
5
- bytes: number;
15
+ bytes: number | undefined;
6
16
  }
7
17
  export interface CheckEntry {
8
18
  command: string;
@@ -16,10 +26,24 @@ export interface HeldEntry {
16
26
  preview: string;
17
27
  outcome: string;
18
28
  }
29
+ export interface FailedAttempt {
30
+ /** Redacted call view. */
31
+ call: string;
32
+ /** Last meaningful line of the failed output; empty when the output had none. */
33
+ error: string;
34
+ }
35
+ /** Whether the code as it stands was covered by a passing check. */
36
+ export type Verification = {
37
+ kind: "passed";
38
+ command: string;
39
+ changedSince: boolean;
40
+ } | {
41
+ kind: "none-passed";
42
+ };
19
43
  export interface StuckState {
20
44
  failures: number;
21
- sameStrategyScore: number | undefined;
22
- currentCallFamily: string | undefined;
45
+ /** Tool results in the attempt window the failures were counted in. */
46
+ window: number;
23
47
  }
24
48
  export interface CompactSnapshot {
25
49
  savedOutputs: readonly SavedOutput[];
@@ -27,20 +51,34 @@ export interface CompactSnapshot {
27
51
  checks: readonly CheckEntry[];
28
52
  /** Capped at 10 in the hook before this is called. */
29
53
  holds: readonly HeldEntry[];
54
+ /** Distinct failed calls, oldest first, at most 5. */
55
+ failedAttempts: readonly FailedAttempt[];
56
+ verification: Verification | undefined;
30
57
  stuck: StuckState | undefined;
31
58
  activeTask: string | undefined;
32
59
  }
33
60
  /** Returns empty string for an empty snapshot. Capped at 2 000 chars. */
34
61
  export declare function compactAppendix(snapshot: CompactSnapshot): string;
62
+ /** The line of a failed output most likely to name the error: the last specific error line, else the last line. */
63
+ export declare function errorLine(output: string): string;
64
+ /** Distinct failed calls, the latest occurrence of each, oldest first; at most `MAX_FAILED`. */
65
+ export declare function distinctFailures(attempts: ReadonlyArray<{
66
+ key: string;
67
+ call: string;
68
+ failed: boolean;
69
+ output: string;
70
+ }>): FailedAttempt[];
71
+ /** Undefined when no check passed and no code changed: there is nothing to say. */
72
+ export declare function verificationOf(evidence: Pick<RunEvidence, "mutations" | "checks" | "checksBeforeMutation">): Verification | undefined;
35
73
  /**
36
74
  * Build a `CompactSnapshot` from the extension's session-scoped state. All string values are redacted; saved-output
37
75
  * paths are local and allowed.
38
76
  */
39
77
  export declare function buildCompactSnapshot(options: {
40
78
  savedOutputs: Array<{
41
- tool: string;
79
+ tool: string | undefined;
42
80
  path: string;
43
- bytes: number;
81
+ bytes: number | undefined;
44
82
  }>;
45
83
  checks: Array<{
46
84
  command: string;
@@ -53,11 +91,14 @@ export declare function buildCompactSnapshot(options: {
53
91
  preview: string;
54
92
  outcome: string;
55
93
  }>;
56
- stuck: {
57
- failures: number;
58
- sameStrategyScore: number | undefined;
59
- currentCallFamily: string | undefined;
60
- } | undefined;
94
+ /** The stuck guard's attempt window, oldest first. */
95
+ attempts?: ReadonlyArray<{
96
+ key: string;
97
+ call: string;
98
+ failed: boolean;
99
+ output: string;
100
+ }>;
101
+ evidence?: Pick<RunEvidence, "mutations" | "checks" | "checksBeforeMutation">;
61
102
  activeTask: string | undefined;
62
103
  runs: number;
63
104
  }): CompactSnapshot;
package/dist/compact.js CHANGED
@@ -1,20 +1,30 @@
1
- /**
2
- * Compaction evidence appendix. Deterministic, pure, no Jev request.
3
- *
4
- * After Pi fires `session_compact`, the extension builds a snapshot from session memory and passes it to
5
- * `compactAppendix`. The returned text is sent as one custom message so the agent can prefer saved paths
6
- * over re-running commands.
7
- */
8
1
  import { redact } from "./redact.js";
9
2
  const MAX_CHARS = 2000;
10
- /** Returns empty string for an empty snapshot. Capped at 2 000 chars. */
11
- export function compactAppendix(snapshot) {
3
+ const MAX_FAILED = 5;
4
+ const FAILED_ENTRY_CHARS = 160;
5
+ const FAILED_CALL_CHARS = 70;
6
+ const SAVED_KEEP_WHEN_OVER = 3;
7
+ const TASK_CHARS_WHEN_OVER = 300;
8
+ function clip(text, limit) {
9
+ return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
10
+ }
11
+ function render(snapshot) {
12
12
  const sections = [];
13
- if (snapshot.savedOutputs.length) {
14
- const items = snapshot.savedOutputs
15
- .map(item => `- ${redact(item.tool)} → ${redact(item.path)} (${item.bytes} bytes)`)
16
- .join("\n");
17
- sections.push(`### Saved full outputs\n${items}`);
13
+ if (snapshot.failedAttempts.length) {
14
+ const items = snapshot.failedAttempts.map(attempt => {
15
+ const call = clip(redact(attempt.call), FAILED_CALL_CHARS);
16
+ const error = redact(attempt.error);
17
+ const entry = error ? `${call} → ${clip(error, FAILED_ENTRY_CHARS - call.length - 3)}` : call;
18
+ return `- ${entry}`;
19
+ }).join("\n");
20
+ sections.push(`### Tried and failed\nDo not retry these unchanged.\n${items}`);
21
+ }
22
+ if (snapshot.verification) {
23
+ const v = snapshot.verification;
24
+ const line = v.kind === "passed"
25
+ ? `- last passing check: ${redact(v.command)}; code changed since last passing check: ${v.changedSince ? "yes" : "no"}`
26
+ : "- no check has passed yet; code was changed";
27
+ sections.push(`### Verification\n${line}`);
18
28
  }
19
29
  if (snapshot.checks.length) {
20
30
  const items = snapshot.checks
@@ -22,36 +32,99 @@ export function compactAppendix(snapshot) {
22
32
  .join("\n");
23
33
  sections.push(`### Last checks\n${items}`);
24
34
  }
35
+ if (snapshot.stuck) {
36
+ sections.push(`### Stuck state\n- failures: ${snapshot.stuck.failures} of the last ${snapshot.stuck.window} tool results`);
37
+ }
25
38
  if (snapshot.holds.length) {
26
39
  const items = snapshot.holds
27
40
  .map(h => `- ${redact(h.tool)}: ${redact(h.outcome)}${h.preview ? ` (${redact(h.preview)})` : ""}`)
28
41
  .join("\n");
29
42
  sections.push(`### Held actions\n${items}`);
30
43
  }
31
- if (snapshot.stuck) {
32
- const s = snapshot.stuck;
33
- const family = s.currentCallFamily ? `; family: ${redact(s.currentCallFamily)}` : "";
34
- const score = s.sameStrategyScore !== undefined ? `; same-strategy: ${s.sameStrategyScore.toFixed(2)}` : "";
35
- sections.push(`### Stuck state\n- failures: ${s.failures}${score}${family}`);
44
+ if (snapshot.savedOutputs.length) {
45
+ const items = snapshot.savedOutputs
46
+ .map(item => `- ${item.tool ? `${redact(item.tool)} → ` : ""}${redact(item.path)}${item.bytes === undefined ? "" : ` (${item.bytes} bytes)`}`)
47
+ .join("\n");
48
+ sections.push(`### Saved full outputs\n${items}`);
36
49
  }
37
50
  if (snapshot.activeTask) {
38
51
  sections.push(`### Active task\n${redact(snapshot.activeTask)}`);
39
52
  }
40
53
  if (sections.length === 0)
41
54
  return "";
42
- const body = sections.join("\n\n");
43
- const appendix = [
55
+ return [
44
56
  "=== PI-WARDEN COMPACT EVIDENCE ===",
45
57
  "Evidence warden kept across compaction. Prefer the saved-output paths below over re-running commands that produced them.",
46
58
  "",
47
- body,
59
+ sections.join("\n\n"),
48
60
  "=== END PI-WARDEN COMPACT EVIDENCE ===",
49
61
  ].join("\n");
50
- const marker = "\n… [truncated]";
62
+ }
63
+ /**
64
+ * Lower-value content goes first when the appendix is over the cap. Failed attempts and the verification line are
65
+ * never reduced here; they come first in the text, so the final cut reaches them last.
66
+ */
67
+ const REDUCERS = [
68
+ s => ({ ...s, savedOutputs: s.savedOutputs.slice(-SAVED_KEEP_WHEN_OVER) }),
69
+ s => ({ ...s, holds: [] }),
70
+ s => ({ ...s, activeTask: s.activeTask && clip(s.activeTask, TASK_CHARS_WHEN_OVER) }),
71
+ s => ({ ...s, stuck: undefined }),
72
+ s => ({ ...s, checks: s.checks.slice(-2) }),
73
+ s => ({ ...s, savedOutputs: [] }),
74
+ s => ({ ...s, activeTask: undefined }),
75
+ s => ({ ...s, checks: [] }),
76
+ ];
77
+ /** Returns empty string for an empty snapshot. Capped at 2 000 chars. */
78
+ export function compactAppendix(snapshot) {
79
+ let appendix = render(snapshot);
80
+ for (const reduce of REDUCERS) {
81
+ if (appendix.length <= MAX_CHARS)
82
+ return appendix;
83
+ snapshot = reduce(snapshot);
84
+ appendix = render(snapshot);
85
+ }
51
86
  if (appendix.length <= MAX_CHARS)
52
87
  return appendix;
88
+ const marker = "\n… [truncated]";
53
89
  return appendix.slice(0, MAX_CHARS - marker.length) + marker;
54
90
  }
91
+ const ERROR_LINE = /\b(?:error|errors|fail(?:ed|ure|s)?|exception|cannot|can't|could not|not found|no such|denied|refused|invalid|unexpected|missing|undefined|timed? ?out|abort(?:ed)?|panic|fatal|traceback)\b|✗|✖|✘/i;
92
+ /** Runner tallies ("Found 1 error.", "3 failing") say that something failed, not what; an earlier line says what. */
93
+ const SUMMARY_LINE = /^(?:found \d+ errors?\b|\d+ (?:failing|failed|errors?)\b|tests?:\s|ℹ fail \d+|npm (?:err!|error) (?:code|errno|a complete log|command failed|lifecycle)|error: command failed|command failed with exit code)/i;
94
+ /** The line of a failed output most likely to name the error: the last specific error line, else the last line. */
95
+ export function errorLine(output) {
96
+ const lines = output
97
+ .replace(/^\[\d+ earlier chars\] …/, "")
98
+ .split("\n")
99
+ .map(line => line.trim())
100
+ .filter(line => line && !/^[-=─—*_~.`]+$/.test(line));
101
+ for (let index = lines.length - 1; index >= 0; index--) {
102
+ if (ERROR_LINE.test(lines[index]) && !SUMMARY_LINE.test(lines[index]))
103
+ return lines[index];
104
+ }
105
+ return lines.at(-1) ?? "";
106
+ }
107
+ /** Distinct failed calls, the latest occurrence of each, oldest first; at most `MAX_FAILED`. */
108
+ export function distinctFailures(attempts) {
109
+ const latest = new Map();
110
+ for (const attempt of attempts) {
111
+ if (!attempt.failed)
112
+ continue;
113
+ latest.delete(attempt.key);
114
+ latest.set(attempt.key, { call: attempt.call, error: errorLine(attempt.output) });
115
+ }
116
+ return [...latest.values()].slice(-MAX_FAILED);
117
+ }
118
+ /** Undefined when no check passed and no code changed: there is nothing to say. */
119
+ export function verificationOf(evidence) {
120
+ let lastPass = evidence.checks.length - 1;
121
+ while (lastPass >= 0 && !evidence.checks[lastPass].passed)
122
+ lastPass--;
123
+ if (lastPass >= 0) {
124
+ return { kind: "passed", command: evidence.checks[lastPass].call, changedSince: (evidence.checksBeforeMutation ?? -1) > lastPass };
125
+ }
126
+ return evidence.mutations > 0 ? { kind: "none-passed" } : undefined;
127
+ }
55
128
  /**
56
129
  * Build a `CompactSnapshot` from the extension's session-scoped state. All string values are redacted; saved-output
57
130
  * paths are local and allowed.
@@ -74,11 +147,17 @@ export function buildCompactSnapshot(options) {
74
147
  preview: h.preview,
75
148
  outcome: h.outcome,
76
149
  }));
77
- const stuck = options.stuck ? {
78
- failures: options.stuck.failures,
79
- sameStrategyScore: options.stuck.sameStrategyScore,
80
- currentCallFamily: options.stuck.currentCallFamily,
81
- } : undefined;
150
+ const attempts = options.attempts ?? [];
151
+ const failures = attempts.filter(attempt => attempt.failed).length;
152
+ const stuck = failures > 0 ? { failures, window: attempts.length } : undefined;
82
153
  const activeTask = options.activeTask ? redact(options.activeTask) : undefined;
83
- return { savedOutputs, checks, holds, stuck, activeTask };
154
+ return {
155
+ savedOutputs,
156
+ checks,
157
+ holds,
158
+ failedAttempts: distinctFailures(attempts),
159
+ verification: options.evidence ? verificationOf(options.evidence) : undefined,
160
+ stuck,
161
+ activeTask,
162
+ };
84
163
  }