pi-warden 0.48.1 → 0.64.1

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 +216 -0
  2. package/README.md +1 -1
  3. package/dist/compact.d.ts +61 -11
  4. package/dist/compact.js +135 -32
  5. package/dist/config.d.ts +39 -1
  6. package/dist/config.js +49 -7
  7. package/dist/conscience.d.ts +16 -1
  8. package/dist/conscience.js +50 -4
  9. package/dist/dedupe.d.ts +41 -0
  10. package/dist/dedupe.js +162 -0
  11. package/dist/done.d.ts +14 -1
  12. package/dist/done.js +88 -6
  13. package/dist/extension.d.ts +2 -2
  14. package/dist/extension.js +416 -69
  15. package/dist/guard.d.ts +34 -3
  16. package/dist/guard.js +959 -70
  17. package/dist/holds.d.ts +1 -0
  18. package/dist/holds.js +2 -0
  19. package/dist/index-cmd.d.ts +2 -0
  20. package/dist/index-cmd.js +9 -7
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +1 -1
  23. package/dist/judge-cooldown.d.ts +1 -0
  24. package/dist/judge-cooldown.js +2 -1
  25. package/dist/learning.d.ts +2 -0
  26. package/dist/learning.js +2 -2
  27. package/dist/loops.d.ts +54 -0
  28. package/dist/loops.js +137 -0
  29. package/dist/output.d.ts +7 -2
  30. package/dist/output.js +11 -4
  31. package/dist/prefs.d.ts +248 -0
  32. package/dist/prefs.js +707 -0
  33. package/dist/redact.d.ts +14 -2
  34. package/dist/redact.js +68 -5
  35. package/dist/rules.d.ts +15 -3
  36. package/dist/rules.js +22 -7
  37. package/dist/saver.d.ts +24 -3
  38. package/dist/saver.js +33 -8
  39. package/dist/shape.d.ts +1 -1
  40. package/dist/shape.js +21 -8
  41. package/dist/shell-writes.d.ts +36 -0
  42. package/dist/shell-writes.js +606 -0
  43. package/dist/stuck.d.ts +22 -0
  44. package/dist/stuck.js +56 -1
  45. package/dist/trace.js +2 -2
  46. package/dist/widget.d.ts +1 -1
  47. package/dist/widget.js +2 -1
  48. package/examples/README.md +2 -2
  49. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,222 @@ 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.64.1
12
+
13
+ ### Fixed
14
+
15
+ - A recursive `rm` of recorded session scratch now keeps its hold when the same command also moves, links, copies, extracts, or mounts data: `mv`, `ln`, `cp`, `tar` (also `g`- or `bsd`-prefixed), `rsync`, `mount`, `hdiutil`, `bindfs`, or `git … clone`, anywhere in the command and also quoted or escaped (`\mv`, `"ln"`, `l''n`). The scratch tree walk runs before the command, so it could not see data the command itself put into the scratch path before its `rm`. A plain `rm -rf` of recorded scratch is still released.
16
+
17
+ ## 0.64.0
18
+
19
+ ### Added
20
+
21
+ - Open loops. New agent tool `warden_loops` keeps what the agent promised to do later in this session: `add` (at most 160 characters, with an optional `when` condition such as "after CI passes"), `done`, `drop` with a reason, and `list`; its description tells the agent to add a loop whenever it promises something for later. The open loops (at most 8 and 600 characters) appear in the compaction appendix, in one end-of-run notice for the next turn (counted against the steer budget as `loops`, never twice for the same unchanged list), and in one message on resume. Loops are stored per session and project in pi-warden's data folder, so they survive compaction and resume and never reach another session or project. `/warden loops` lists them for the user.
22
+ - New agent tool `warden_recall`: the failed attempts of this session with their error lines, the last passing check with whether the code changed since, and the saved-output paths. It prints the same sections the compaction appendix builds, from the same session state. Read-only.
23
+ - Both run in code, with no TypeSafe request.
24
+
25
+ ## 0.63.0
26
+
27
+ ### Changed
28
+
29
+ - An intent mismatch on a call with no visible effect is trace-only: the trace, the warn notice, and the status line (`N off plan (M trace-only)`) record it, and the agent gets no steer. A commit, push, merge, tag, reset, pull request, release, or publish still steers (decided in code by `isVisibleCommand`), and so does any call Jev judges `visible` at 0.8 or more, such as an install, a launched program, or a message sent from a script. The steer reaches the agent only after the call ran: on 275 recorded intent-mismatch steers over 7 days, 275 arrived after the call, and a strict course change (the agent asked the user, reverted, or changed approach) followed 8% of them. New `action.intentTraceOnly` sets which mismatches are trace-only: `"invisible"` (default), `"all"`, or `"none"` (every mismatch steers, as before).
30
+
31
+ ## 0.62.0
32
+
33
+ ### Changed
34
+
35
+ - `prefs.inject` is on by default, and strict: a wrong memory is worse than none. A standing preference reaches the agent only when you typed it, in a standing form (no question and no temporary word; `now`, `until`, `this PR`, `this branch`, and `for this` join `yet`, `for now`, `today`, and `this time`), not bound to one task (a pronoun-only object such as "don't commit it", a ticket, branch, PR number, or hash), in 3 or more sessions on 2 or more days, last within 30 days, not lifted by a later message of the opposite polarity ("do use subagents here"), and not weakening a check (skipping tests, checks, reviews, or confirmations, turning warden off, or pushing, deploying, or deleting without asking; the markers are in `WEAKENS_CHECK`). At most 5 items and 400 characters, each quoted as said with its session count, ending with "If the current request says otherwise, follow the current request." Still one message per session, not a steer, and not repeated on resume.
36
+ - `/warden prefs` shows each item as injected or the rule that kept it out ("not injected: seen in 2 sessions", "not injected: weakens a check"). `/warden prefs forget <n>` drops an item for the project for good, rewordings included; the key is kept in pi-warden's data folder.
37
+ - New agent tool `warden_remember`: after a user correction, or a stuck, repeat, or done-check steer, within the last 5 assistant turns of the run, the agent can record one standing lesson (at most 160 characters) for the project. The same rules apply; a lesson that repeats a stored lesson or a listed preference confirms it instead of adding one. A lesson is injected only once confirmed (recorded again in a later session, or said by the user), after your preferences, marked `(agent lesson)`, and expires after 30 days without a confirmation. Lessons are stored with the date and session id under pi-warden's data folder, never in a rules file or a session file. Preference and lesson text goes to the session model only, never to TypeSafe.
38
+
39
+ ## 0.61.0
40
+
41
+ ### Added
42
+
43
+ - The context saver cuts repeated runs. In a new tool result, a run of at least 20 lines and 1500 characters that exactly matches text already in context on the current branch becomes one pointer line naming where the earlier copy is and the file that holds the full text. The last 2000 characters and images are never changed. The ledger counts the removed bytes and their token-turns; `/warden status` shows the repeats cut. `context.dedupeRuns` (default `true`) turns it off. `context.dedupeMessages` (default `false`) applies the same cut to new user and custom messages; it is off by default because a repeat the user sends can itself carry meaning.
44
+
45
+ ### Tests
46
+
47
+ - A repeated report in a new tool result, and with `context.dedupeMessages` in a custom or user message, is cut to one pointer line; messages stay whole by default; one changed line breaks the match; the tail stays; the stored copy holds the full text and reading it back is a recall; `context.dedupeRuns: false` leaves everything whole.
48
+
49
+ ## 0.60.1
50
+
51
+ ### Fixed
52
+
53
+ - Message text in a command is no longer read as a command. The quoted values of `--body`, `-b`, `--title`, `-t`, and `--notes` on `gh pr|issue|release create|edit|comment`, and of `-m` and `--message` on `git commit` and `git tag`, are blanked before the patterns run, also in `=` form and as `$'…'`. A body that held `;`, `&&`, or new lines was cut into segments before, so a PR body that quoted `rm -rf /` was held as destructive. A value with `$(`, backticks, or an unclosed quote is still read, because the shell runs it.
54
+ - A recursive `rm` right after a backtick (`` `rm -rf ~` ``) is now classified like one after `$(`.
55
+
56
+ ## 0.60.0
57
+
58
+ ### Changed
59
+
60
+ - `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.
61
+ - `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.
62
+ - Both relaxations read only a plain single `git reset` or `git push` command. A `cd`, `-C`, quote, variable, or second command keeps the hold.
63
+ - 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.
64
+
65
+ ### Fixed
66
+
67
+ - `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.
68
+ - `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.
69
+
70
+ ### Tests
71
+
72
+ - 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.
73
+
74
+ ## 0.59.5
75
+
76
+ ### Fixed
77
+
78
+ - 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.
79
+ - 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.
80
+ - 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.
81
+ - 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`).
82
+
83
+ ## 0.59.4
84
+
85
+ ### Fixed
86
+
87
+ - 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.
88
+ - `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.
89
+ - 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.
90
+ - 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.
91
+
92
+ ### Tests
93
+
94
+ - 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`.
95
+
96
+ ## 0.59.3
97
+
98
+ ### Fixed
99
+
100
+ - 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.
101
+ - 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.
102
+
103
+ ## 0.59.2
104
+
105
+ ### Changed
106
+
107
+ - 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.
108
+ - 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.
109
+
110
+ ### Fixed
111
+
112
+ - `/warden recommend` is offered in the command completions (`/warden rec` completes to it) and has a row in `docs/commands.md`.
113
+
114
+ ## 0.59.1
115
+
116
+ ### Fixed
117
+
118
+ - 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.
119
+
120
+ ## 0.59.0
121
+
122
+ ### Added
123
+
124
+ - 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).
125
+
126
+ ## 0.58.0
127
+
128
+ ### Changed
129
+
130
+ - 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.
131
+ - 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`.
132
+ - 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.
133
+
134
+ ## 0.57.0
135
+
136
+ ### Added
137
+
138
+ - 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.
139
+
140
+ ## 0.56.1
141
+
142
+ ### Tests
143
+
144
+ - 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.
145
+
146
+ ## 0.56.0
147
+
148
+ ### Added
149
+
150
+ - 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`.
151
+
152
+ ## 0.55.0
153
+
154
+ ### Changed
155
+
156
+ - 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.
157
+ - 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.
158
+
159
+ ## 0.54.0
160
+
161
+ ### Changed
162
+
163
+ - 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.
164
+ - 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.
165
+
166
+ ## 0.53.0
167
+
168
+ ### Added
169
+ - 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.
170
+
171
+ ## 0.52.2
172
+
173
+ ### Fixed
174
+
175
+ - 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.
176
+
177
+ ## 0.52.1
178
+
179
+ ### Fixed
180
+
181
+ - 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`).
182
+
183
+ ## 0.52.0
184
+
185
+ ### Changed
186
+
187
+ - 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.
188
+ - 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.
189
+
190
+ ### Fixed
191
+
192
+ - 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.
193
+
194
+ ## 0.51.0
195
+
196
+ ### Changed
197
+
198
+ - 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.
199
+ - `/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.
200
+
201
+ ## 0.50.1
202
+
203
+ ### Changed
204
+
205
+ - `/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.
206
+
207
+ ## 0.50.0
208
+
209
+ ### Added
210
+
211
+ - 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.
212
+ - 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.
213
+
214
+ ### Fixed
215
+
216
+ - 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.
217
+ - 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.
218
+ - `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.
219
+ - `X-Amz-Security-Token` counts as a signed-URL parameter, so an S3 presigned URL made with temporary credentials no longer raises "Possible credentials".
220
+
221
+ ## 0.49.0
222
+
223
+ ### Added
224
+
225
+ - 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.
226
+
11
227
  ## 0.48.1
12
228
 
13
229
  ### Fixed
package/README.md CHANGED
@@ -35,7 +35,7 @@ Works without any key (offline guards: pattern list, runaway stop, sensitive-pat
35
35
  | **Rules** | every `write` and `edit` | Judges against project Markdown rules, quotes the broken rule |
36
36
  | **Slop** | code and replies | Names stubs, restating comments, dead code, hedging |
37
37
  | **Stuck** | tool results | Repeated failures using the same strategy |
38
- | **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 |
39
39
  | **Security** | code and output | Hardcoded secrets, injection risks |
40
40
  | **Runaway** | reply stream | Stops replies that begin repeating themselves |
41
41
  | **Subagent triage** | async child reports | Keeps noisy background work from waking the parent |
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,42 @@ 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;
59
+ /** The session's open loops, already formatted and capped by `formatOpenLoops`; absent or empty when none is open. */
60
+ openLoops?: string;
32
61
  }
33
62
  /** Returns empty string for an empty snapshot. Capped at 2 000 chars. */
34
63
  export declare function compactAppendix(snapshot: CompactSnapshot): string;
64
+ /**
65
+ * `warden_recall`: what was already tried in this session, from the same snapshot and the same section text as the
66
+ * compaction appendix: the failed attempts with their error lines, the last passing check with whether the code
67
+ * changed since, and the saved-output paths. Capped like the appendix.
68
+ */
69
+ export declare function recallText(snapshot: CompactSnapshot): string;
70
+ /** The line of a failed output most likely to name the error: the last specific error line, else the last line. */
71
+ export declare function errorLine(output: string): string;
72
+ /** Distinct failed calls, the latest occurrence of each, oldest first; at most `MAX_FAILED`. */
73
+ export declare function distinctFailures(attempts: ReadonlyArray<{
74
+ key: string;
75
+ call: string;
76
+ failed: boolean;
77
+ output: string;
78
+ }>): FailedAttempt[];
79
+ /** Undefined when no check passed and no code changed: there is nothing to say. */
80
+ export declare function verificationOf(evidence: Pick<RunEvidence, "mutations" | "checks" | "checksBeforeMutation">): Verification | undefined;
35
81
  /**
36
82
  * Build a `CompactSnapshot` from the extension's session-scoped state. All string values are redacted; saved-output
37
83
  * paths are local and allowed.
38
84
  */
39
85
  export declare function buildCompactSnapshot(options: {
40
86
  savedOutputs: Array<{
41
- tool: string;
87
+ tool: string | undefined;
42
88
  path: string;
43
- bytes: number;
89
+ bytes: number | undefined;
44
90
  }>;
45
91
  checks: Array<{
46
92
  command: string;
@@ -53,11 +99,15 @@ export declare function buildCompactSnapshot(options: {
53
99
  preview: string;
54
100
  outcome: string;
55
101
  }>;
56
- stuck: {
57
- failures: number;
58
- sameStrategyScore: number | undefined;
59
- currentCallFamily: string | undefined;
60
- } | undefined;
102
+ /** The stuck guard's attempt window, oldest first. */
103
+ attempts?: ReadonlyArray<{
104
+ key: string;
105
+ call: string;
106
+ failed: boolean;
107
+ output: string;
108
+ }>;
109
+ evidence?: Pick<RunEvidence, "mutations" | "checks" | "checksBeforeMutation">;
61
110
  activeTask: string | undefined;
62
111
  runs: number;
112
+ openLoops?: string;
63
113
  }): CompactSnapshot;
package/dist/compact.js CHANGED
@@ -1,57 +1,153 @@
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
+ /** Every section in appendix order; `warden_recall` prints some of the same strings, so the two never disagree. */
12
+ function renderSections(snapshot) {
12
13
  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}`);
14
+ const push = (name, text) => sections.push([name, text]);
15
+ if (snapshot.failedAttempts.length) {
16
+ const items = snapshot.failedAttempts.map(attempt => {
17
+ const call = clip(redact(attempt.call), FAILED_CALL_CHARS);
18
+ const error = redact(attempt.error);
19
+ const entry = error ? `${call} → ${clip(error, FAILED_ENTRY_CHARS - call.length - 3)}` : call;
20
+ return `- ${entry}`;
21
+ }).join("\n");
22
+ push("failed", `### Tried and failed\nDo not retry these unchanged.\n${items}`);
23
+ }
24
+ if (snapshot.verification) {
25
+ const v = snapshot.verification;
26
+ const line = v.kind === "passed"
27
+ ? `- last passing check: ${redact(v.command)}; code changed since last passing check: ${v.changedSince ? "yes" : "no"}`
28
+ : "- no check has passed yet; code was changed";
29
+ push("verification", `### Verification\n${line}`);
30
+ }
31
+ if (snapshot.openLoops) {
32
+ push("loops", `### Open loops\nPromised earlier in this session and not done yet; close each with warden_loops done or drop.\n${snapshot.openLoops}`);
18
33
  }
19
34
  if (snapshot.checks.length) {
20
35
  const items = snapshot.checks
21
36
  .map(c => `- ${c.passed ? "✓" : "✗"} ${c.when}: ${redact(c.command)}`)
22
37
  .join("\n");
23
- sections.push(`### Last checks\n${items}`);
38
+ push("checks", `### Last checks\n${items}`);
39
+ }
40
+ if (snapshot.stuck) {
41
+ push("stuck", `### Stuck state\n- failures: ${snapshot.stuck.failures} of the last ${snapshot.stuck.window} tool results`);
24
42
  }
25
43
  if (snapshot.holds.length) {
26
44
  const items = snapshot.holds
27
45
  .map(h => `- ${redact(h.tool)}: ${redact(h.outcome)}${h.preview ? ` (${redact(h.preview)})` : ""}`)
28
46
  .join("\n");
29
- sections.push(`### Held actions\n${items}`);
47
+ push("holds", `### Held actions\n${items}`);
30
48
  }
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}`);
49
+ if (snapshot.savedOutputs.length) {
50
+ const items = snapshot.savedOutputs
51
+ .map(item => `- ${item.tool ? `${redact(item.tool)} → ` : ""}${redact(item.path)}${item.bytes === undefined ? "" : ` (${item.bytes} bytes)`}`)
52
+ .join("\n");
53
+ push("saved", `### Saved full outputs\n${items}`);
36
54
  }
37
55
  if (snapshot.activeTask) {
38
- sections.push(`### Active task\n${redact(snapshot.activeTask)}`);
56
+ push("task", `### Active task\n${redact(snapshot.activeTask)}`);
39
57
  }
58
+ return sections;
59
+ }
60
+ function render(snapshot) {
61
+ const sections = renderSections(snapshot).map(([, text]) => text);
40
62
  if (sections.length === 0)
41
63
  return "";
42
- const body = sections.join("\n\n");
43
- const appendix = [
64
+ return [
44
65
  "=== PI-WARDEN COMPACT EVIDENCE ===",
45
66
  "Evidence warden kept across compaction. Prefer the saved-output paths below over re-running commands that produced them.",
46
67
  "",
47
- body,
68
+ sections.join("\n\n"),
48
69
  "=== END PI-WARDEN COMPACT EVIDENCE ===",
49
70
  ].join("\n");
50
- const marker = "\n… [truncated]";
71
+ }
72
+ /**
73
+ * Lower-value content goes first when the appendix is over the cap. Failed attempts, the verification line, and the
74
+ * open loops (at most 600 characters) are never reduced here; they come first in the text, so the final cut reaches
75
+ * them last.
76
+ */
77
+ const REDUCERS = [
78
+ s => ({ ...s, savedOutputs: s.savedOutputs.slice(-SAVED_KEEP_WHEN_OVER) }),
79
+ s => ({ ...s, holds: [] }),
80
+ s => ({ ...s, activeTask: s.activeTask && clip(s.activeTask, TASK_CHARS_WHEN_OVER) }),
81
+ s => ({ ...s, stuck: undefined }),
82
+ s => ({ ...s, checks: s.checks.slice(-2) }),
83
+ s => ({ ...s, savedOutputs: [] }),
84
+ s => ({ ...s, activeTask: undefined }),
85
+ s => ({ ...s, checks: [] }),
86
+ ];
87
+ /** Returns empty string for an empty snapshot. Capped at 2 000 chars. */
88
+ export function compactAppendix(snapshot) {
89
+ let appendix = render(snapshot);
90
+ for (const reduce of REDUCERS) {
91
+ if (appendix.length <= MAX_CHARS)
92
+ return appendix;
93
+ snapshot = reduce(snapshot);
94
+ appendix = render(snapshot);
95
+ }
51
96
  if (appendix.length <= MAX_CHARS)
52
97
  return appendix;
98
+ const marker = "\n… [truncated]";
53
99
  return appendix.slice(0, MAX_CHARS - marker.length) + marker;
54
100
  }
101
+ /**
102
+ * `warden_recall`: what was already tried in this session, from the same snapshot and the same section text as the
103
+ * compaction appendix: the failed attempts with their error lines, the last passing check with whether the code
104
+ * changed since, and the saved-output paths. Capped like the appendix.
105
+ */
106
+ export function recallText(snapshot) {
107
+ const kept = renderSections(snapshot).filter(([name]) => name === "failed" || name === "verification" || name === "saved").map(([, text]) => text);
108
+ const text = kept.length ? kept.join("\n\n") : "Nothing recorded yet in this session: no failed attempt, no check, and no saved output.";
109
+ if (text.length <= MAX_CHARS)
110
+ return text;
111
+ const marker = "\n… [truncated]";
112
+ return text.slice(0, MAX_CHARS - marker.length) + marker;
113
+ }
114
+ 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;
115
+ /** Runner tallies ("Found 1 error.", "3 failing") say that something failed, not what; an earlier line says what. */
116
+ 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;
117
+ /** The line of a failed output most likely to name the error: the last specific error line, else the last line. */
118
+ export function errorLine(output) {
119
+ const lines = output
120
+ .replace(/^\[\d+ earlier chars\] …/, "")
121
+ .split("\n")
122
+ .map(line => line.trim())
123
+ .filter(line => line && !/^[-=─—*_~.`]+$/.test(line));
124
+ for (let index = lines.length - 1; index >= 0; index--) {
125
+ if (ERROR_LINE.test(lines[index]) && !SUMMARY_LINE.test(lines[index]))
126
+ return lines[index];
127
+ }
128
+ return lines.at(-1) ?? "";
129
+ }
130
+ /** Distinct failed calls, the latest occurrence of each, oldest first; at most `MAX_FAILED`. */
131
+ export function distinctFailures(attempts) {
132
+ const latest = new Map();
133
+ for (const attempt of attempts) {
134
+ if (!attempt.failed)
135
+ continue;
136
+ latest.delete(attempt.key);
137
+ latest.set(attempt.key, { call: attempt.call, error: errorLine(attempt.output) });
138
+ }
139
+ return [...latest.values()].slice(-MAX_FAILED);
140
+ }
141
+ /** Undefined when no check passed and no code changed: there is nothing to say. */
142
+ export function verificationOf(evidence) {
143
+ let lastPass = evidence.checks.length - 1;
144
+ while (lastPass >= 0 && !evidence.checks[lastPass].passed)
145
+ lastPass--;
146
+ if (lastPass >= 0) {
147
+ return { kind: "passed", command: evidence.checks[lastPass].call, changedSince: (evidence.checksBeforeMutation ?? -1) > lastPass };
148
+ }
149
+ return evidence.mutations > 0 ? { kind: "none-passed" } : undefined;
150
+ }
55
151
  /**
56
152
  * Build a `CompactSnapshot` from the extension's session-scoped state. All string values are redacted; saved-output
57
153
  * paths are local and allowed.
@@ -74,11 +170,18 @@ export function buildCompactSnapshot(options) {
74
170
  preview: h.preview,
75
171
  outcome: h.outcome,
76
172
  }));
77
- const stuck = options.stuck ? {
78
- failures: options.stuck.failures,
79
- sameStrategyScore: options.stuck.sameStrategyScore,
80
- currentCallFamily: options.stuck.currentCallFamily,
81
- } : undefined;
173
+ const attempts = options.attempts ?? [];
174
+ const failures = attempts.filter(attempt => attempt.failed).length;
175
+ const stuck = failures > 0 ? { failures, window: attempts.length } : undefined;
82
176
  const activeTask = options.activeTask ? redact(options.activeTask) : undefined;
83
- return { savedOutputs, checks, holds, stuck, activeTask };
177
+ return {
178
+ savedOutputs,
179
+ checks,
180
+ holds,
181
+ failedAttempts: distinctFailures(attempts),
182
+ verification: options.evidence ? verificationOf(options.evidence) : undefined,
183
+ stuck,
184
+ activeTask,
185
+ ...(options.openLoops ? { openLoops: options.openLoops } : {}),
186
+ };
84
187
  }