pi-warden 1.11.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,100 @@ 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
+ ## 1.14.0
12
+
13
+ ### Added
14
+
15
+ - **The agent's writes to pi-warden's own config are held.** A `write`, `edit`, or `bash` call that writes `.pi/pi-warden.json`, or a file in `<agent dir>/pi-warden/`, is held by a new built-in floor rule, `warden-config-write`, so a cloned repository cannot switch a check off by having the agent edit its own config. The command reader finds the write in a redirect, `tee`, a heredoc, a `-c` string, a wrapper, or a script the command runs; in the destination of `cp`, `mv`, `ln`, `install`, `rsync`, `truncate`, `touch`, `dd of=`, `sed -i`, `perl -i`, `curl -o`, or `wget -O`; and in the source of `rm`, `unlink`, `mv`, or `ln`. It follows a literal `cd` or `pushd` and resolves `~`, `$HOME`, `..`, a relative path, and symlinks. The capability index stays writable, reads stay free, and `action.exemptRules: ["warden-config-write"]` (user file only) silences the rule like any built-in.
16
+ - **One line at session start names every check the project file loosens**, and from which file. The record of the last loosened set is kept per project beside the user config; a further loosening of an already-named check shows the line again, and the same set stays quiet.
17
+ - **One line when either config file changes during a session** without pi-warden's own write. It names `.pi/pi-warden.json` or `your pi-warden config`, never a full path, shows once per new content, and says the change applies at the next session.
18
+
19
+ ### Changed
20
+
21
+ - Both config files are read once at session start, and those snapshots serve every call of the session; a mid-session edit applies from the next session, so a write shape the floor misses cannot change a check mid-run. `/warden enable`, `/warden disable`, `/warden mode`, `/warden config set`, and the config panel update the snapshot in the same step, so they still apply at once.
22
+ - The loosening direction is named correctly for `cause.windowDays` (lowered), `action.intentTraceOnly` (moved to a quieter mode), and `judge.failuresBeforeCooldown`.
23
+
24
+ ### Fixed
25
+
26
+ - A scratch project's `pi-warden.json` stays free at any depth under a temp root; the session's own project file is never exempt.
27
+ - An empty loosened set is recorded as a set of its own, so a set that goes away and comes back shows its line again.
28
+ - Every config path and file name is compared case-folded and in one Unicode form, because APFS treats `.pi/PI-WARDEN.JSON` and `.pi/pi-warden.json` as one file.
29
+
30
+ ### Docs
31
+
32
+ - `configuration.md`: when the config files are read, and the loosening notice with its per-project record. `guards.md`: the `warden-config-write` hold. `deterministic-floor-spec.md`: the config hold and its write shapes.
33
+
34
+ ## 1.13.1
35
+
36
+ ### Changed
37
+
38
+ - The done-check UI nudge says what counts as looking at the result: seeing the changed page after the change — a page snapshot, a passing browser or device test, or a screenshot of the page that is then read; a screenshot saved to a file counts only once it is read. The detection is unchanged — a scripted review that sends screenshots to a vision model over HTTP still leaves no session evidence (`#187`). `docs/configuration.md` now says a custom `done.visualTools` list replaces its defaults and that a `commands` entry matches the command head the way the agent runs it.
39
+
40
+ ## 1.13.0
41
+
42
+ ### Added
43
+
44
+ - `/warden improve` hands the judgement to the session's own model: the code prints the measured counts, the session files behind them and the rules text, and the model reads the sessions and edits this project's `pi-warden.md`. Each change carries a `Measured` line naming the count and the session file it came from, at most three changes are made, and a second run replaces an existing `Measured` line rather than adding one. The pass takes a timestamped backup before the turn, then prints each rule added, changed or removed with the count it cited, and the diff. The quiet window is armed before the turn, so the rules judge does not grade the rewrite; the destructive floor stays on.
45
+
46
+ ### Fixed
47
+
48
+ - `/warden improve` called zero rule judgments "the rules held". It now says the guard judged nothing for this project in the window, so nothing is known about the rules, and gives the counts that do exist.
49
+ - `/warden improve` printed "repeated calls" beside "repeated requests" as if they were the same number. They are now "calls flagged as repeats" and "requests typed twice".
50
+ - `/warden improve` printed a 250 ms scan budget it missed. It now reports what it measured: files read, bytes, and time.
51
+
52
+ ## 1.12.1
53
+
54
+ ### Docs
55
+
56
+ - README: the improve pass and its quiet window, the rules report, standing lessons and the capability index; how the offline floor reads a script a command runs, and which delete text it skips; the test and development scripts.
57
+
58
+ ## 1.12.0
59
+
60
+ ### Added
61
+
62
+ - `/warden improve [days]` reads the last N days (default 7, 1 to 90) of this project's own sessions, rules log and hold logs, prints the counts — reading cost, calls a waste detector flags as repeats, rule judgments, fires and clears, holds by kind — and proposes at most three edits to this project's `pi-warden.md`, each naming the count it came from and the files it read. A cited number that is not on the printed list refuses the whole set; with no support it writes nothing and says the rules held. A confirmed run writes a timestamped backup and then the file atomically, and refuses a file that changed under it. Nothing is sent and no judge request is made.
63
+ - While the pass runs it arms its own session's quiet window (`improve.quietMs`, default `600000`, capped at `900000`): the rules judge, the turn-start rule reminder and the text guards stay quiet in that session only, other Pi sessions keep judging, the window expires by the clock, and the destructive-action floor, path rules, deny list and arming rules stay on. `/warden status` shows it while it is live.
64
+
65
+ ## 1.11.3
66
+
67
+ ### Fixed
68
+
69
+ - The floor finds a script run where bash runs it. It read a command by cutting it at every separator and taking the first word of each part, so it missed a script run inside `( )`, `{ }`, `$( )`, backticks, `<( )`, `if`/`elif`/`else`, a loop, a `case` arm, a function body, or an array assignment (`arr=($(sh a.sh))`); after `!` or a wrapper with options (`timeout 5`, `nice -n 5`, `env -i`, `xargs`); with a quoted path (`sh "a.sh"`); and inside a `-c` string, an `eval`, an `env -S` string, or the command of `find -exec`, `-execdir`, `-ok`, or `-okdir`. One reader of the simple commands that bash runs now feeds the floor, the `script_lines` of the judge request, and the ask gate, so the ask gate also asks about a push in a script run in these shapes. A quoted `~` stays literal, as in bash. A command the reader cannot read whole (an unclosed quote, a stray `fi`), or one nested more than 64 levels deep or with more than 50,000 constructs, is cut at every separator as before, so it reads more, never less. The floor does not evaluate conditions: a script in a branch that bash skips, or in a function that nobody calls, is read too.
70
+ - A script fed to a shell on its input is read: `sh < a.sh`, `cat a.sh | sh`, `cat < a.sh | sh`, `sh /dev/stdin < a.sh`, `bash -s x < a.sh`, and `bash <(cat a.sh)`. So is a file's text run as code: `sh -c "$(cat a.sh)"`, `eval "$(< a.sh)"`, and `sh <<< "$(cat a.sh)"`. `/dev/stdin` itself is never opened. A heredoc fed to a shell inside `$( )` ends at its delimiter followed by `)`, as in bash, so the delete in `x=$(sh <<EOF` … `EOF)` is held.
71
+ - A literal `cd` moves the script runs after it, inside its own subshell only. `(cd sub && sh a.sh)` reads `sub/a.sh`, so a harmless script is no longer held for a deleting namesake in the start folder, and a `cd sub &` moves nothing after it. After a `cd` that may be skipped (after `&&` or `||`, or in a branch, a loop, or a function), both folders are read; after a `cd` to a folder that does not exist, the old one is.
72
+ - A delete is read by the command word bash runs: `\rm`, `"rm"`, `r''m`, `/bin/rm`, and `${x}rm` (an empty `x`) are `rm`, also after a `case` pattern's `)` and inside a `-c` or `eval` string. User `commandRules` still match the command as typed.
73
+ - A script path with `..` is resolved before the roots check. `sh /tmp/../net/x/a.sh` passed the check on its literal text and reached the file system outside the roots, where a lookup can start an automount.
74
+
75
+ ### Tests
76
+
77
+ - The differential test adds rows for the new shapes, typed and in script bodies, and runs each through `bash -c` with a `touch "$MARK"` twin: the floor holds an input exactly when bash creates the marker. Unit tests hold a delete nested 10,000 deep in each construct with no error, and check that a stray `fi` or `done` throws nowhere, that a quoted `~` reads no file in the home folder, that a `..` path asks the file system about nothing outside the roots, that stdin feeds are read, and that a `cd` before `&` does not move the commands after it.
78
+
79
+ ### Docs
80
+
81
+ - The Script bodies paragraph in `docs/guards.md` lists the new ways the floor finds a script run, how it follows `cd`, and the nesting limit.
82
+
83
+ ## 1.11.2
84
+
85
+ ### Fixed
86
+
87
+ - The action guard's floor no longer holds a bash call for text the shell never runs. Every pattern check (the built-in rules, the SQL and git state checks, user `commandRules`, and path rules on the command surface) reads a command and a script body without shell comments, the same comment scan the judge request uses. A `#!/bin/sh` first line no longer keeps every quoted `echo`/`printf` message in scope (the data scan read it as a call to `sh`), and a message that holds `;`, `|`, or `&&` inside its quotes is no longer cut into commands. The hit lines of a script body leave out its comment lines.
88
+ - A `grep`, `sed`, or `rtk grep` of a file, with `\|` in its quoted pattern, no longer reads that file as a script that runs. Script detection cut the command at every `|`, also inside quotes, so `down=" scripts/x.py` read as an assignment and the file as the command; it now cuts only where the shell does. A command with a substitution, an unclosed quote, `eval`, or `sh -c` keeps the old cut, so every script read before is still read. ([#190](https://github.com/DevMortimer/pi-warden/issues/190))
89
+ - The floor reads line continuations as bash does. A delete split by a backslash-newline (`r\<newline>m -rf ~`, `rm \<newline>-rf ~`) was not held, and neither was a script run as `sh \<newline>a.sh`. The comment scan now joins each backslash-newline that bash removes: outside quotes, in double quotes and `${...}`, and in a heredoc body with an unquoted delimiter, but not in single quotes, `$'...'`, a quoted heredoc body, or a comment. A backslash-newline before a script run (`cd . && \<newline> sh a.sh`, also `npm run` and `make`) no longer hides the script from the floor or the ask gate.
90
+ - A delete after a here-string or a partly quoted heredoc delimiter is held again. The data scan found heredocs with a line regex that read `<<<` and a `<<` inside quotes as the start of a heredoc, and took `E` as the delimiter of `<<E"O"F`, so every later line was blanked as heredoc data. It now takes heredocs from the comment scan, which skips here-strings, quotes, and comments, removes quotes from the delimiter, and joins a backslash-newline in an unquoted body before it matches the delimiter; a command that scan cannot read keeps every line in scope.
91
+ - The ask gate reads the command the way the floor does, without shell comments and with quoted messages and heredoc data blanked. A push or a delete that appears only in a comment or in a quoted `echo` message no longer sends a judge request.
92
+
93
+ ### Tests
94
+
95
+ - A differential test runs each input through `bash -c` with a `touch "$MARK"` twin in place of the delete: the floor holds an input exactly when bash creates the marker (13 inputs it stops holding, 16 it keeps holding, among them a delete inside `$(...)` on a heredoc line that starts with `#`, and a delete on the line after a comment that holds an apostrophe). Unit tests pin the issue 190 command with its user rule, and every way of running a script (`sh`, `bash`, `./`, `.`, `source`, `npm run`), also next to a read of the same file.
96
+ - The differential test adds 28 inputs with backslash-newlines, here-strings, and quoted or split heredoc delimiters, typed and in script bodies with `#!/bin/sh`, `#!/bin/bash`, `#!/usr/bin/env bash`, or no first line; the comment-scan output test adds 8 inputs whose output bash must print unchanged. Unit tests hold each of them through `matchPatterns`, check that the ask gate still asks for a script named after a backslash-newline, and check that the comment, echo message, and `sed` shapes still run.
97
+ - A judge-call count through `evaluateAction` pins that a push only in a comment sends no request, and that the same push as a second command sends one.
98
+
99
+ ## 1.11.1
100
+
101
+ ### Fixed
102
+
103
+ - `/warden index` now finalizes the entries-only files its own prompt asks the model to write. The entries-only fallback ran only when the strict read had already found a complete file, so an `{ "entries": [...] }` file never got its metadata, its rejections were never reported, and every later session loaded the index as missing and repeated the "run /warden index" nudge. The fallback now runs whenever the strict read finds no file, and rejected entries are reported. ([#185](https://github.com/DevMortimer/pi-warden/issues/185))
104
+
11
105
  ## 1.11.0
12
106
 
13
107
  ### Added
package/README.md CHANGED
@@ -44,11 +44,22 @@ Most guardrails stop and ask you. pi-warden tells **the agent** what it got wron
44
44
  | is about to `git push --force`, `reset --hard`, `rm -rf`, `DROP` | holds it before it runs. A built-in pattern holds the destructive ones with no key at all; with Jev on, a score of 0.9 or more for "cannot be undone" holds too |
45
45
  | retries the same failing fix for the third time | asks for a new hypothesis |
46
46
  | writes stubs, restating comments, hardcoded secrets | names them on the spot |
47
+ | repeats the same mistake across sessions | counts it, and offers the rule that would have stopped it (`/warden improve`) |
47
48
  | floods its context with a huge log | keeps the lines that matter, stores the rest |
48
49
  | starts repeating itself forever | stops the reply |
49
50
 
50
51
  [Every guard, with its thresholds and calibration →](docs/guards.md)
51
52
 
53
+ ## It reads your sessions back
54
+
55
+ `/warden improve` looks at the last seven days of this project's own sessions, rules log and hold log, and prints what actually cost you: whole-file reads and calls per turn, tool calls the waste detectors flag as repeats, requests typed twice, and the same refusal, rule by rule.
56
+
57
+ Then it hands the judging to the model already running in this session. The model reads the sessions behind those numbers and edits `pi-warden.md`: at most three changes, each with a `Measured` line naming the count it came from and the session file. With nothing supported it changes nothing and says the rules held, with the counts that show it; when no rule was judged in the window it says that instead, because nothing is known about the rules then.
58
+
59
+ A run writes a timestamped backup before the model's turn, then prints every rule added, changed or removed with the count it cited, and the diff. The counts, the backup and the checks are local: no extra key, no TypeSafe request, and the only cost is that one turn of the session model. `/warden improve 4` reads four days; the range is 1 to 90.
60
+
61
+ While the pass runs, the rules judge goes quiet in that one session, so the rewrite is not graded against the rules it is rewriting. The window expires by the clock, another Pi session keeps judging, and the destructive floor stays on.
62
+
52
63
  ## Features
53
64
 
54
65
  Every guard and feature, its default, and its status. Defaults are `defaultConfig()` in `src/config.ts`; Beta and Experimental are the labels in [the guard docs](docs/guards.md). Guards that ask Jev need a key and `/warden enable`; without one, their offline parts still run.
@@ -57,7 +68,7 @@ Every guard and feature, its default, and its status. Defaults are `defaultConfi
57
68
  | --- | --- | --- | --- |
58
69
  | Jev judgments | Sends redacted samples to Jev for the judged checks below | Off until `/warden enable` | Stable |
59
70
  | [Action guard](docs/guards.md#action-guard) | Checks each command, write, and edit before it runs: offline patterns plus one Jev request | On | Stable |
60
- | ↳ Offline floor | Holds destructive patterns (force push, `reset --hard`, `rm -rf` on a path outside scratch, `DROP`) with no key and no request; with Jev on, a pattern hit goes to Jev as evidence | On | Stable |
71
+ | ↳ Offline floor | Holds destructive patterns (force push, `reset --hard`, `rm -rf` on a path outside scratch, `DROP`) with no key and no request. It reads a script that a command runs, one level deep, the way bash runs it (`( )`, `if`, a loop, `bash -c`, a pipe into a shell, a quoted path), and holds a delete spelled `\rm`, `"rm"`, `r''m` or `${x}rm`; delete text the shell never runs — a comment, an `echo` message, a file `grep` only reads — is skipped. With Jev on, a pattern hit goes to Jev as evidence | On | Stable |
61
72
  | ↳ Irreversible hold | Jev adds holds: a call scored 0.9 or more as irreversible is held; 0.5 to 0.9 warns | On | Stable |
62
73
  | ↳ Scratch deletes | A recursive `rm` of temp scratch the agent made warns instead of holding; data moved in keeps the hold | On | Stable |
63
74
  | ↳ Ask gate | Code decides offline when no Jev answer could change what the agent sees, so the call sends no request: 48.9% fewer requests, 327 of the 338 changed outcomes still asked | On. `action.ask` | Stable |
@@ -84,6 +95,11 @@ Every guard and feature, its default, and its status. Defaults are `defaultConfi
84
95
  | [Call-waste notes](docs/guards.md#call-waste) | One advisory line on polling, paging, repeated searches, and re-filtered checks | On | Stable |
85
96
  | ↳ Session tip | Adds one paragraph about call cost to the system prompt | Off. `waste.tip` | Stable |
86
97
  | [Open loops and recall](docs/guards.md#open-loops-and-recall) | `warden_loops` keeps the agent's promises; `warden_recall` lists what it already tried | On, no switch | Stable |
98
+ | [Standing lessons](docs/guards.md#open-loops-and-recall) | `warden_remember` keeps one standing instruction for this project, so a later session starts with the correction you already made | On, no switch | Stable |
99
+ | [Improve pass](docs/commands.md) | Reads the last 7 days of this project's sessions and proposes at most three count-backed edits to `pi-warden.md`; nothing supported means nothing written | On, no switch (`/warden improve`) | Stable |
100
+ | ↳ Quiet window | The rules judge and the text guards go quiet in the arming session only, so the rules file can be rewritten; the floor stays on and the window expires by the clock | On. `improve.quietMs` | Stable |
101
+ | [Rules report](docs/commands.md) | Per rule: judged, fired, cleared after a fire, and the flags `never fires`, `fires on everything`, `undecided` | On, local only (`/warden report`) | Stable |
102
+ | [Capability index](docs/configuration.md) | `/warden index` builds a local index of your skills and tools so the conscience ranks candidates on your machine before it sends anything | On, local only | Stable |
87
103
  | [Subagent triage](docs/guards.md#subagent-triage) | Wakes the agent only for a subagent report that needs it | On | Stable |
88
104
  | [Token Guardian observer](docs/guards.md#token-guardian-observer) | Records Jev's answers to redacted supervision triggers in the trace; never answers Guardian or acts on them | On with Jev consent | Record-only |
89
105
  | [Judge cooldown](docs/guards.md#judge-cooldown) | Pauses Jev requests after repeated failures, so a dead backend costs no timeouts | On, no switch | Stable |
@@ -111,7 +127,7 @@ Jev requests cost money, so each 1.0 change was measured by what it saves. Each
111
127
  | Approval on demand ([#160](https://github.com/DevMortimer/pi-warden/pull/160), [#161](https://github.com/DevMortimer/pi-warden/pull/161)) | Approval questions per 1,000 judged calls 90.0 → 0.7; wrong releases 45 → 1 in the measured runs | [Approval on demand](docs/guards.md#approval-on-demand-2026-10-01-three-designs-three-sets) |
112
128
  | Hold database ([#149](https://github.com/DevMortimer/pi-warden/pull/149)) | A copy of 52,362 rows shrinks from 285.9 MiB to 41.6 MiB | [Data handling](docs/data-handling.md#what-stays-on-this-machine) |
113
129
 
114
- Features that did not earn their cost did not ship: the working-memory pruning, the stale-result stubs, and a hybrid compaction failed their gates, and the off-task and should-proceed steers stay trace-only. The numbers are in the [Calibration](docs/guards.md#calibration) section.
130
+ Features that did not earn their cost did not ship: the working-memory pruning, the stale-result stubs, and a hybrid compaction failed their gates, and the off-task and should-proceed steers stay trace-only. The numbers are in the [Calibration](docs/guards.md#calibration) section. `/warden report` sends no request at all; `/warden improve` spends one turn of the session model and no TypeSafe request.
115
131
 
116
132
  ## Rules no linter can check
117
133
 
@@ -169,6 +185,8 @@ From 1.0, pi-warden follows semver for: the documented config keys and their def
169
185
  ```bash
170
186
  npm install
171
187
  npm run check # typecheck + offline tests + build
188
+ npm test # offline tests only
189
+ npm run dev:pi # start Pi with this working tree
172
190
  ```
173
191
 
174
192
  MIT licensed.
package/dist/ask-gate.js CHANGED
@@ -264,7 +264,7 @@ export function gateCommand(scanned, raw = scanned, cwd, scripts) {
264
264
  const direct = gateText(scanned, raw);
265
265
  if (direct.ask)
266
266
  return direct;
267
- const sources = scripts ?? (cwd === undefined ? [] : scriptSources(scanned, cwd));
267
+ const sources = scripts ?? (cwd === undefined ? [] : scriptSources(raw, cwd));
268
268
  for (const source of sources) {
269
269
  const decision = gateText(source.body, source.body);
270
270
  if (decision.ask)
package/dist/config.d.ts CHANGED
@@ -352,6 +352,16 @@ export interface PrefsConfig {
352
352
  /** At session start, send the standing preferences that pass every injection rule to the agent as one context message. */
353
353
  inject: boolean;
354
354
  }
355
+ /**
356
+ * `/warden improve`: the window the pass reads and the quiet window it arms. Both are the user's own settings: a
357
+ * project file cannot arm a quiet window, because a quieter rules judge is a weaker guard.
358
+ */
359
+ export interface ImproveConfig {
360
+ /** Days a pass looks back when no number is typed: `/warden improve 4` overrides it. 1 to 90. */
361
+ days: number;
362
+ /** How long the rules judge and the text guards stay quiet in the arming session: ten minutes by default, never more than fifteen. */
363
+ quietMs: number;
364
+ }
355
365
  /** The silent-half field trial (trial.ts): a random half of sessions records what it would have told the agent. */
356
366
  export interface TrialConfig {
357
367
  /**
@@ -478,6 +488,8 @@ export interface WardenConfig {
478
488
  conscience: ConscienceConfig;
479
489
  /** Standing preferences: corrections the user repeated in earlier sessions of this project. */
480
490
  prefs: PrefsConfig;
491
+ /** The `/warden improve` pass: its default window and its quiet window. */
492
+ improve: ImproveConfig;
481
493
  /** Silent-half field trial: off by default, user file only. */
482
494
  trial: TrialConfig;
483
495
  /** Call-waste notes: advisory sentences attached to the tool result that triggers them. */
@@ -489,7 +501,7 @@ export interface WardenConfig {
489
501
  }
490
502
  export declare const PACKAGE_NAME = "pi-warden";
491
503
  /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
492
- export declare const CONFIG_SCHEMA = 14;
504
+ export declare const CONFIG_SCHEMA = 15;
493
505
  export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
494
506
  export declare function defaultConfig(): WardenConfig;
495
507
  /** The user file is in the host's agent directory: `~/.pi/agent` on Pi, `~/.omp/agent` on oh-my-pi. `PI_CODING_AGENT_DIR` overrides it. */
@@ -516,7 +528,67 @@ export interface LoadOptions {
516
528
  projectTrusted?: boolean;
517
529
  /** Host directories; defaults to `defaultHostDirs()`. */
518
530
  dirs?: HostDirs;
531
+ /**
532
+ * The project file as the session read it once at its start. A session that passes one never re-reads the file, so a
533
+ * mid-run edit lands at the next session. Omitted, the file is read on every call, as a library caller expects.
534
+ */
535
+ projectRaw?: {
536
+ value: unknown;
537
+ } | undefined;
538
+ /** The user file, read once at session start the same way: an outside edit lands at the next session. */
539
+ userRaw?: {
540
+ value: unknown;
541
+ } | undefined;
542
+ }
543
+ /** The user file's content, read without applying anything: the session snapshot is taken from here. */
544
+ export declare function readUserConfigFile(dirs?: HostDirs): unknown;
545
+ /** The project file's content, read without applying anything: the session snapshot is taken from here. */
546
+ export declare function readProjectConfig(cwd: string, dirs?: HostDirs): unknown;
547
+ /**
548
+ * The checks a project file loosens compared with the user's own config. The project file cannot weaken the action guard
549
+ * or security (those values are refused with a warning at load), so what is compared here is every other check it can
550
+ * switch off, move a threshold on, or take a file out of.
551
+ */
552
+ export interface LoosenedCheck {
553
+ /** The check, named as `/warden status` names it. */
554
+ name: string;
555
+ /** How it loosens; absent when the project file simply switches the check off. */
556
+ detail?: string;
557
+ }
558
+ /** How a project value loosens the check it tunes. */
559
+ export type ProjectKeyKind = "off" | "high" | "low" | "fewer" | "more" | "scale" | "none";
560
+ /** One key a project file can set: the check it loosens, and how; or why it is not a check. */
561
+ export interface ProjectKeyClass {
562
+ kind: ProjectKeyKind;
563
+ /** The check, named as `/warden status` names it; absent only when the key is not a check. */
564
+ check?: string;
565
+ /** What moves, as the line's detail names it (`threshold`, `UI files`). */
566
+ label?: string;
567
+ /** Loosest first, for a named option list. */
568
+ order?: readonly string[];
569
+ /** Why this key is not a check; required when kind is "none". */
570
+ why?: string;
519
571
  }
572
+ /**
573
+ * Every key a project file can set, classified: a direction that loosens a check, or "none" with the reason it is not
574
+ * one. A test walks every leaf of `defaultConfig()` in a project section and fails when a key has no row here, so a
575
+ * config key added later cannot quietly loosen a check without being named in the session-start line.
576
+ * The action guard and security rows that a project can only tighten (`projectProbability`, `projectSwitch`) are "none":
577
+ * the loader refuses the looser value, so the notice has nothing to say about it.
578
+ */
579
+ export declare const PROJECT_KEYS: Readonly<Record<string, ProjectKeyClass>>;
580
+ /** Every check `layered` loosens compared with `user`, in a stable order. Empty when the project file loosens nothing. */
581
+ export declare function loosenedChecks(user: WardenConfig, layered: WardenConfig): LoosenedCheck[];
582
+ /** The one line a session start shows for a loosened set: what the project file takes away, and from which file. */
583
+ export declare function loosenNotice(checks: readonly LoosenedCheck[], dirs?: HostDirs): string;
584
+ /** Where the last loosened set for a project is kept: beside the user config, keyed by the project path. */
585
+ export declare function loosenRecordPath(cwd: string, dirs?: HostDirs): string;
586
+ /** The checks last recorded for this project; undefined when no record exists, it cannot be read, or it predates
587
+ * details (a names-only record is a different set, so the line shows once more after the upgrade). */
588
+ export declare function readLoosenRecord(cwd: string, dirs?: HostDirs): LoosenedCheck[] | undefined;
589
+ /** Records the loosened set now shown for this project, so the same set stays quiet until it changes. A read-only
590
+ * agent directory must not stop a session: the record is an optimisation, and an unwritable one shows the line again. */
591
+ export declare function writeLoosenRecord(cwd: string, checks: readonly LoosenedCheck[], dirs?: HostDirs): void;
520
592
  export declare function loadConfig(options?: LoadOptions): WardenConfig;
521
593
  /** Reads only the user file, for editing and persisting consent. */
522
594
  export declare function readUserConfig(dirs?: HostDirs): Json;
package/dist/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { dirname, join } from "node:path";
3
4
  import { writeFileAtomicSync } from "./atomic.js";
4
5
  import { resolveJudgmentBackend } from "./backend.js";
@@ -32,7 +33,7 @@ export const WARDEN_MODES = ["steer", "confirm", "advise"];
32
33
  export const CONSCIENCE_SKILL_MODES = ["off", "recommend", "load"];
33
34
  export const PACKAGE_NAME = "pi-warden";
34
35
  /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
35
- export const CONFIG_SCHEMA = 14;
36
+ export const CONFIG_SCHEMA = 15;
36
37
  export const PROJECT_CONFIG_FILE = `${PACKAGE_NAME}.json`;
37
38
  export function defaultConfig() {
38
39
  return {
@@ -102,6 +103,7 @@ export function defaultConfig() {
102
103
  localFloor: 0.5,
103
104
  },
104
105
  prefs: { enabled: true, inject: true },
106
+ improve: { days: 7, quietMs: 600000 },
105
107
  trial: { silentHalf: false },
106
108
  waste: { enabled: true, tip: false, every: 20, sleep: true, paging: true, search: true, recheck: true },
107
109
  // pi-claude-bridge compacts its own models and cancels on failure; its summary must not be replaced.
@@ -647,11 +649,15 @@ export function applyUserOverrides(base, raw) {
647
649
  learning: applyLearning(base.learning, raw.learning),
648
650
  conscience: applyConscience(base.conscience, raw.conscience),
649
651
  prefs: applyPrefs(base.prefs, raw.prefs),
652
+ improve: applyImprove(base.improve, raw.improve, warnings),
650
653
  trial: applyTrial(base.trial, raw.trial),
651
654
  warnings: [...(base.warnings ?? []), ...warnings],
652
655
  };
653
656
  }
654
- /** A project cannot turn the trial on: applyProjectOverrides never copies this section, so the user file is the only source. */
657
+ /**
658
+ * A project cannot turn the trial on, and cannot arm an improve quiet window: applyProjectOverrides never copies either
659
+ * section, so the user file is the only source.
660
+ */
655
661
  function applyTrial(base, raw) {
656
662
  if (!isObject(raw))
657
663
  return base;
@@ -701,6 +707,24 @@ function applyPrefs(base, raw) {
701
707
  return base;
702
708
  return { enabled: boolean(raw.enabled, base.enabled), inject: boolean(raw.inject, base.inject) };
703
709
  }
710
+ /** The 1..90 and 0..900000 ranges are the caps of improve.ts; a value past either is refused here, not silently kept. */
711
+ function applyImprove(base, raw, warnings) {
712
+ if (!isObject(raw))
713
+ return base;
714
+ const days = raw.days;
715
+ const quietMs = raw.quietMs;
716
+ const keptDays = typeof days === "number" && Number.isSafeInteger(days) && days >= 1 && days <= 90
717
+ ? days
718
+ : base.days;
719
+ if (days !== undefined && keptDays !== days)
720
+ warnings.push(`improve.days ${String(days)} is not a whole number of days from 1 to 90; ${base.days} is used`);
721
+ const keptQuiet = typeof quietMs === "number" && Number.isFinite(quietMs) && quietMs >= 0 && quietMs <= 900000
722
+ ? Math.trunc(quietMs)
723
+ : base.quietMs;
724
+ if (quietMs !== undefined && keptQuiet !== quietMs)
725
+ warnings.push(`improve.quietMs ${String(quietMs)} is not a number of milliseconds from 0 to 900000; ${base.quietMs} is used`);
726
+ return { days: keptDays, quietMs: keptQuiet };
727
+ }
704
728
  function applySteers(base, raw) {
705
729
  if (!isObject(raw))
706
730
  return base;
@@ -763,12 +787,235 @@ export function applyProjectOverrides(base, raw) {
763
787
  const guards = applyGuards(base, raw, base.timeoutMs, "project", warnings);
764
788
  return { ...base, enabled, ...guards, prefs: applyPrefs(base.prefs, raw.prefs), warnings: [...(base.warnings ?? []), ...warnings] };
765
789
  }
790
+ /** The user file's content, read without applying anything: the session snapshot is taken from here. */
791
+ export function readUserConfigFile(dirs = defaultHostDirs()) {
792
+ return readJson(userConfigPath(dirs));
793
+ }
794
+ /** The project file's content, read without applying anything: the session snapshot is taken from here. */
795
+ export function readProjectConfig(cwd, dirs = defaultHostDirs()) {
796
+ return readJson(projectConfigPath(cwd, dirs));
797
+ }
798
+ /**
799
+ * Every key a project file can set, classified: a direction that loosens a check, or "none" with the reason it is not
800
+ * one. A test walks every leaf of `defaultConfig()` in a project section and fails when a key has no row here, so a
801
+ * config key added later cannot quietly loosen a check without being named in the session-start line.
802
+ * The action guard and security rows that a project can only tighten (`projectProbability`, `projectSwitch`) are "none":
803
+ * the loader refuses the looser value, so the notice has nothing to say about it.
804
+ */
805
+ export const PROJECT_KEYS = {
806
+ "stuck.enabled": { kind: "off", check: "stuck-check" },
807
+ "stuck.minFailures": { kind: "high", check: "stuck-check", label: "failures needed" },
808
+ "stuck.sameStrategy": { kind: "high", check: "stuck-check", label: "same-strategy threshold" },
809
+ "stuck.churnThreshold": { kind: "high", check: "stuck-check", label: "churn threshold" },
810
+ "stuck.cooldown": { kind: "high", check: "stuck-check", label: "cooldown" },
811
+ "stuck.nudge": { kind: "off", check: "stuck-check", label: "nudge" },
812
+ "stuck.repeatSteer": { kind: "off", check: "stuck-check", label: "repeat steer" },
813
+ "stuck.evidence": { kind: "off", check: "stuck-check", label: "evidence" },
814
+ "stuck.diffLimit": { kind: "low", check: "stuck-check", label: "diff limit" },
815
+ "stuck.tailLimit": { kind: "low", check: "stuck-check", label: "tail limit" },
816
+ "stuck.window": { kind: "none", why: "a cap on minFailures, which decides on its own" },
817
+ "done.enabled": { kind: "off", check: "done-check" },
818
+ "done.claimsDone": { kind: "high", check: "done-check", label: "claims-done threshold" },
819
+ "done.nudge": { kind: "off", check: "done-check", label: "nudge" },
820
+ "done.uiProof": { kind: "off", check: "done-check", label: "UI proof" },
821
+ "done.uiFiles": { kind: "fewer", check: "done-check", label: "UI files" },
822
+ "done.visualTools.commands": { kind: "more", check: "done-check", label: "visual commands" },
823
+ "done.visualTools.commandWords": { kind: "more", check: "done-check", label: "visual command words" },
824
+ "done.visualTools.tools": { kind: "more", check: "done-check", label: "visual tools" },
825
+ "done.visualTools.images": { kind: "more", check: "done-check", label: "visual image types" },
826
+ "cause.enabled": { kind: "off", check: "cause-check" },
827
+ "cause.claimsCause": { kind: "high", check: "cause-check", label: "claims-cause threshold" },
828
+ "cause.nudge": { kind: "off", check: "cause-check", label: "nudge" },
829
+ "cause.windowDays": { kind: "low", check: "cause-check", label: "window days" },
830
+ "slop.enabled": { kind: "off", check: "slop-check" },
831
+ "slop.threshold": { kind: "high", check: "slop-check", label: "threshold" },
832
+ "slop.prose.enabled": { kind: "off", check: "slop-check", label: "prose" },
833
+ "slop.prose.threshold": { kind: "high", check: "slop-check", label: "prose threshold" },
834
+ "slop.prose.trend": { kind: "high", check: "slop-check", label: "prose trend" },
835
+ "slop.prose.minChars": { kind: "high", check: "slop-check", label: "prose minimum chars" },
836
+ "slop.prose.audience": { kind: "none", why: "who the prose is written for, not a threshold" },
837
+ "rules.enabled": { kind: "off", check: "rules-check" },
838
+ "rules.threshold": { kind: "high", check: "rules-check", label: "threshold" },
839
+ "rules.softThreshold": { kind: "high", check: "rules-check", label: "soft threshold" },
840
+ "rules.fallback": { kind: "off", check: "rules-check", label: "fallback" },
841
+ "rules.maxChars": { kind: "low", check: "rules-check", label: "rules text limit" },
842
+ "rules.files": { kind: "fewer", check: "rules-check", label: "files" },
843
+ "rules.exclude": { kind: "more", check: "rules-check", label: "excluded paths" },
844
+ "rules.skip": { kind: "more", check: "rules-check", label: "skipped paths" },
845
+ "rules.sensitivePaths": { kind: "fewer", check: "rules-check", label: "sensitive-path notes" },
846
+ "rulesAtTurnStart.enabled": { kind: "none", why: "a project can only switch it on; switching it off is refused at load" },
847
+ "rulesAtTurnStart.threshold": { kind: "none", why: "a project can only lower it, which names more rules" },
848
+ "security.enabled": { kind: "none", why: "a project cannot switch security off; the value is refused at load" },
849
+ "security.threshold": { kind: "none", why: "a project cannot raise the threshold; the value is refused at load" },
850
+ "security.maskOutput": { kind: "none", why: "user file only: a repository must not unmask credentials" },
851
+ "security.shareLinkParams": { kind: "none", why: "user file only: it excuses a share-link token" },
852
+ "action.enabled": { kind: "none", why: "a project can only switch the guard on; switching it off is refused" },
853
+ "action.tools": { kind: "none", why: "a project can only add guarded tools, which guards more" },
854
+ "action.failOpen": { kind: "none", why: "a project cannot turn fail-open on; the value is refused" },
855
+ "action.ask.enabled": { kind: "none", why: "a project can only switch the ask gate on" },
856
+ "action.traceSample": { kind: "none", why: "how much trace-only signal is sampled, not a call decision" },
857
+ "action.timeoutMs": { kind: "none", why: "user file only" },
858
+ "action.irreversible.warn": { kind: "none", why: "a project cannot raise the warn threshold; the value is refused" },
859
+ "action.irreversible.confirm": { kind: "none", why: "a project cannot raise the hold threshold; the value is refused" },
860
+ "action.offTask.warn": { kind: "high", check: "off-task steer", label: "warn threshold" },
861
+ "action.offTask.steer": { kind: "high", check: "off-task steer", label: "steer threshold" },
862
+ "action.intentMismatch": { kind: "high", check: "intent-mismatch steer", label: "threshold" },
863
+ "action.visibleMismatch": { kind: "high", check: "intent-mismatch steer", label: "visible-action threshold" },
864
+ "action.intentTraceOnly": { kind: "scale", check: "intent-mismatch steer", label: "trace-only mode", order: ["all", "invisible", "none"] },
865
+ "action.shouldProceed.threshold": { kind: "high", check: "should-proceed steer", label: "threshold" },
866
+ "action.shouldProceed.steer": { kind: "off", check: "should-proceed steer" },
867
+ "action.feedbackLog": { kind: "none", why: "the hold record, not a check" },
868
+ "action.commandRules": { kind: "none", why: "user file only" },
869
+ "action.commandDenyRules": { kind: "none", why: "user file only" },
870
+ "action.exemptRules": { kind: "none", why: "user file only" },
871
+ "action.pathRules": { kind: "none", why: "user file only" },
872
+ "action.armingRules": { kind: "none", why: "user file only" },
873
+ "action.escalationThreshold": { kind: "high", check: "escalation", label: "threshold" },
874
+ "action.floor": { kind: "none", why: "user file only" },
875
+ "context.enabled": { kind: "off", check: "context-check" },
876
+ "context.tailMinChars": { kind: "none", why: "how output is trimmed, not a check" },
877
+ "context.confidence": { kind: "none", why: "how a retention answer is trusted" },
878
+ "context.duplicateMinChars": { kind: "none", why: "when an output counts as a repeat" },
879
+ "context.recallTool": { kind: "none", why: "which tool recalls a stored output" },
880
+ "context.formatConfidence": { kind: "none", why: "how a named output format is trusted" },
881
+ "context.compactAppendix": { kind: "none", why: "an appendix after compaction" },
882
+ "context.dedupeRuns": { kind: "none", why: "repeated outputs in one run" },
883
+ "context.dedupeMessages": { kind: "none", why: "repeated outputs across messages" },
884
+ "context.largeOutput.enabled": { kind: "off", check: "large-output notice" },
885
+ "context.largeOutput.threshold": { kind: "high", check: "large-output notice", label: "threshold" },
886
+ "context.filter.enabled": { kind: "none", why: "user file only: it sends whole outputs to the judge" },
887
+ "context.filter.chunkChars": { kind: "none", why: "how a filtered output is chunked" },
888
+ "context.filter.minScore": { kind: "none", why: "how a filtered chunk is scored" },
889
+ "context.filter.maxKeptChars": { kind: "none", why: "how much of a filtered output is kept" },
890
+ "context.filter.timeoutMs": { kind: "none", why: "the filter's own deadline" },
891
+ "runaway.enabled": { kind: "off", check: "runaway stop" },
892
+ "runaway.repeats": { kind: "high", check: "runaway stop", label: "text repeats" },
893
+ "runaway.thinkingRepeats": { kind: "high", check: "runaway stop", label: "thinking repeats" },
894
+ "runaway.minChars": { kind: "high", check: "runaway stop", label: "minimum chars" },
895
+ "runaway.recover": { kind: "none", why: "what happens after a runaway stop, not the stop" },
896
+ "notify.enabled": { kind: "off", check: "notify" },
897
+ "notify.cooldownMs": { kind: "high", check: "notify", label: "cooldown" },
898
+ "notify.command": { kind: "none", why: "user file only: a project never names a command" },
899
+ "judge.cooldownMs": { kind: "high", check: "judge", label: "cooldown" },
900
+ "judge.failuresBeforeCooldown": { kind: "low", check: "judge", label: "failures before cooldown" },
901
+ "subagent.enabled": { kind: "off", check: "subagent triage" },
902
+ "subagent.wake": { kind: "off", check: "subagent wake" },
903
+ "subagent.observer": { kind: "off", check: "subagent observer" },
904
+ "subagent.threshold": { kind: "high", check: "subagent wake", label: "threshold" },
905
+ "subagent.cooldownMs": { kind: "high", check: "subagent wake", label: "cooldown" },
906
+ "waste.enabled": { kind: "off", check: "waste note" },
907
+ "waste.tip": { kind: "off", check: "waste tip" },
908
+ "waste.every": { kind: "high", check: "waste note", label: "calls between notes" },
909
+ "waste.sleep": { kind: "off", check: "waste sleep note" },
910
+ "waste.paging": { kind: "off", check: "waste paging note" },
911
+ "waste.search": { kind: "off", check: "waste search note" },
912
+ "waste.recheck": { kind: "off", check: "waste recheck note" },
913
+ "compaction.enabled": { kind: "none", why: "user file only: turning it on spends requests" },
914
+ "compaction.keepThreshold": { kind: "none", why: "how much context is kept" },
915
+ "compaction.maxSummaryTokens": { kind: "none", why: "the summary's size" },
916
+ "compaction.timeoutMs": { kind: "none", why: "the compaction deadline" },
917
+ "compaction.maxRequests": { kind: "none", why: "the compaction budget" },
918
+ "compaction.skipProviders": { kind: "none", why: "which providers keep their own summary" },
919
+ "prefs.enabled": { kind: "none", why: "standing preferences are context, not a check" },
920
+ "prefs.inject": { kind: "none", why: "standing preferences are context, not a check" },
921
+ enabled: { kind: "none", why: "a project can only switch pi-warden on; switching it off is refused" },
922
+ };
923
+ /** A config value by its dotted path. */
924
+ function valueAt(config, path) {
925
+ let current = config;
926
+ for (const part of path.split(".")) {
927
+ if (current === null || typeof current !== "object")
928
+ return undefined;
929
+ current = current[part];
930
+ }
931
+ return current;
932
+ }
933
+ /** A list value as the items a project file adds or leaves out; a map counts by its keys. */
934
+ function itemsOf(value) {
935
+ if (Array.isArray(value))
936
+ return value.map(String);
937
+ if (value !== null && typeof value === "object")
938
+ return Object.keys(value);
939
+ return [];
940
+ }
941
+ /** Every check `layered` loosens compared with `user`, in a stable order. Empty when the project file loosens nothing. */
942
+ export function loosenedChecks(user, layered) {
943
+ const checks = [];
944
+ for (const [path, entry] of Object.entries(PROJECT_KEYS)) {
945
+ if (entry.kind === "none")
946
+ continue;
947
+ const before = valueAt(user, path);
948
+ const after = valueAt(layered, path);
949
+ const name = entry.check;
950
+ if (entry.kind === "off") {
951
+ if (before === true && after === false)
952
+ checks.push({ name, ...(entry.label === undefined ? {} : { detail: entry.label }) });
953
+ continue;
954
+ }
955
+ if (entry.kind === "high" || entry.kind === "low") {
956
+ if (typeof before !== "number" || typeof after !== "number")
957
+ continue;
958
+ if (entry.kind === "high" ? after > before : after < before)
959
+ checks.push({ name, detail: `${entry.label ?? "value"} ${before} → ${after}` });
960
+ continue;
961
+ }
962
+ if (entry.kind === "scale") {
963
+ const order = entry.order ?? [];
964
+ const moved = order.indexOf(String(before)) > order.indexOf(String(after)) && order.includes(String(before)) && order.includes(String(after));
965
+ if (moved)
966
+ checks.push({ name, detail: `${entry.label ?? "mode"} ${String(before)} → ${String(after)}` });
967
+ continue;
968
+ }
969
+ const lost = entry.kind === "fewer" ? itemsOf(before).filter(item => !itemsOf(after).includes(item)) : itemsOf(after).filter(item => !itemsOf(before).includes(item));
970
+ if (lost.length)
971
+ checks.push({ name, detail: `${entry.label ?? "entries"} ${entry.kind === "fewer" ? "left out" : "added"}: ${lost.join(", ")}` });
972
+ }
973
+ return checks;
974
+ }
975
+ /** The one line a session start shows for a loosened set: what the project file takes away, and from which file. */
976
+ export function loosenNotice(checks, dirs = defaultHostDirs()) {
977
+ const items = checks.map(check => (check.detail === undefined ? check.name : `${check.name} (${check.detail})`));
978
+ const verb = checks.every(check => check.detail === undefined) ? "switches off" : "loosens";
979
+ return `This project's ${join(dirs.configDirName, PROJECT_CONFIG_FILE)} ${verb}: ${items.join(", ")}.`;
980
+ }
981
+ /** Where the last loosened set for a project is kept: beside the user config, keyed by the project path. */
982
+ export function loosenRecordPath(cwd, dirs = defaultHostDirs()) {
983
+ const project = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
984
+ return join(dirname(userConfigPath(dirs)), "project-config", `${project}.json`);
985
+ }
986
+ /** The checks last recorded for this project; undefined when no record exists, it cannot be read, or it predates
987
+ * details (a names-only record is a different set, so the line shows once more after the upgrade). */
988
+ export function readLoosenRecord(cwd, dirs = defaultHostDirs()) {
989
+ const raw = readJson(loosenRecordPath(cwd, dirs));
990
+ if (raw?.version !== 2 || !Array.isArray(raw.loosened))
991
+ return undefined;
992
+ const checks = [];
993
+ for (const item of raw.loosened) {
994
+ if (!isObject(item) || typeof item.name !== "string")
995
+ return undefined;
996
+ if (item.detail !== undefined && typeof item.detail !== "string")
997
+ return undefined;
998
+ checks.push({ name: item.name, ...(typeof item.detail === "string" ? { detail: item.detail } : {}) });
999
+ }
1000
+ return checks;
1001
+ }
1002
+ /** Records the loosened set now shown for this project, so the same set stays quiet until it changes. A read-only
1003
+ * agent directory must not stop a session: the record is an optimisation, and an unwritable one shows the line again. */
1004
+ export function writeLoosenRecord(cwd, checks, dirs = defaultHostDirs()) {
1005
+ try {
1006
+ writeFileAtomicSync(loosenRecordPath(cwd, dirs), `${JSON.stringify({ version: 2, loosened: checks.map(check => (check.detail === undefined ? { name: check.name } : { name: check.name, detail: check.detail })) }, null, 2)}\n`);
1007
+ }
1008
+ catch (error) {
1009
+ console.warn("pi-warden: could not record the loosened project checks:", error instanceof Error ? error.stack ?? error.message : String(error));
1010
+ }
1011
+ }
766
1012
  export function loadConfig(options = {}) {
767
1013
  const dirs = options.dirs ?? defaultHostDirs();
768
- let config = applyUserOverrides(defaultConfig(), readJson(userConfigPath(dirs)));
769
- if (options.cwd && options.projectTrusted)
770
- config = applyProjectOverrides(config, readJson(projectConfigPath(options.cwd, dirs)));
771
- return config;
1014
+ let config = applyUserOverrides(defaultConfig(), options.userRaw ? options.userRaw.value : readJson(userConfigPath(dirs)));
1015
+ if (!options.cwd || !options.projectTrusted)
1016
+ return config;
1017
+ const project = options.projectRaw ? options.projectRaw.value : readProjectConfig(options.cwd, dirs);
1018
+ return applyProjectOverrides(config, project);
772
1019
  }
773
1020
  /** Reads only the user file, for editing and persisting consent. */
774
1021
  export function readUserConfig(dirs = defaultHostDirs()) {
package/dist/done.js CHANGED
@@ -600,7 +600,7 @@ export function doneNudge(verdict, hint = {}) {
600
600
  : "Run the project's tests, build, or lint (whatever exists) on what you changed.";
601
601
  const code = codeUnverified ? ` ${detail} Then report the actual result. If no check exists or can run, say so explicitly instead of presenting the work as done.` : "";
602
602
  const open = hint.localUrl !== undefined ? `Open ${redact(hint.localUrl)} in a browser` : "Open it in a browser";
603
- const ui = verdict.unseenUi !== undefined ? ` You changed \`${redact(verdict.unseenUi)}\` but did not look at the result. ${open} or take a screenshot before calling it done, or say it is unverified.` : "";
603
+ const ui = verdict.unseenUi !== undefined ? ` You changed \`${redact(verdict.unseenUi)}\` but did not look at the result. ${open} or take a screenshot before calling it done, or say it is unverified. It counts when you see the changed page after the change: a page snapshot, a passing browser or device test, or a screenshot of the page that you then read (a screenshot saved to a file counts only once you read it).` : "";
604
604
  return `pi-warden: ${verdict.reasons.join("; ")}.${code}${ui}`;
605
605
  }
606
606
  export function formatDone(verdict, template = DEFAULT_TEMPLATES.done) {