pi-warden 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,22 @@
1
1
  # pi-warden
2
2
 
3
- Harness guardrails for [Pi](https://pi.dev), built on [pi-typesafe](https://github.com/DevMortimer/pi-typesafe). Before the agent runs a `bash`, `write`, or `edit` call, pi-warden checks it twice: a fast offline pattern pass for the obvious hazards, then one TypeSafe (Jev) request that scores how **irreversible** and how **off-task** the action is relative to your latest request. High scores open a confirm dialog; you decide, and a declined call is blocked with a short reason the agent can act on.
3
+ A second pair of eyes for [Pi](https://pi.dev) that makes the agent smarter instead of interrupting you. Before the agent runs a `bash`, `write`, or `edit` call, pi-warden asks [Jev](https://typesafe.ai) two questions about it in ~250 ms: *would this destroy something that cannot be recovered?* and *is this what the user actually asked for?* The answers are probabilities, so `rm -rf dist` after "rebuild from scratch" sails through while `npm run db:reset` after "add a column" is held, and the agent is told why so it re-plans or asks you in chat. Three more guards watch the run: a **stuck-loop** detector, a **done-check** for completion claims that no test backed up, and a **slop** note for stubs and filler. Built on [pi-typesafe](https://github.com/DevMortimer/pi-typesafe).
4
4
 
5
- Independent project. Not affiliated with TypeSafe AI or the Pi authors.
5
+ ![Real verdicts from pi-warden: the same command gets a different verdict depending on what the user asked for](https://raw.githubusercontent.com/DevMortimer/pi-warden/main/docs/preview.png)
6
6
 
7
- ![Pi asking for confirmation before a destructive command, with pi-warden's irreversible and off-task scores in the dialog](https://raw.githubusercontent.com/DevMortimer/pi-warden/main/docs/preview.png)
7
+ The verdicts above are real output from `npm run test:live`. Independent project; not affiliated with TypeSafe AI or the Pi authors.
8
+
9
+ ## Why a coding agent needs this
10
+
11
+ Agents are good at picking the next command and bad at noticing when that command is out of proportion to the request. Pattern lists catch `rm -rf /` and force pushes; they cannot tell `db:reset` when you asked for a reset from `db:reset` when you asked for a column. A generative model can, but a second LLM call per tool call is slow and expensive. Jev is a System One model: it returns calibrated probabilities instead of text, in a quarter of a second, for a fraction of a cent. That makes it cheap enough to sit in front of **every** guarded call:
12
+
13
+ - **Irreversible actions are held, not run**: history rewrites, deleted untracked work, dropped tables, overwritten files outside the project, publishes and deploys. The agent receives the judgment as its tool result — what was flagged, the scores, and two acceptable next moves: find a recoverable alternative, or explain the action to you and wait. If you approve in chat, the retry goes through; Jev reads your reply as the approval. No modal dialog unless you ask for one (`/warden mode confirm`).
14
+ - **Scope drift gets flagged.** Poems in a bugfix, refactors nobody asked for, dependency installs unrelated to the task: warned to you at 0.6, held at 0.85 when Jev also calls the action `unrelated`.
15
+ - **Loops get broken.** Three failures with the same strategy (same command with cosmetic changes, same error after each edit) earn the agent a steer: re-read the error, form a new hypothesis, or report the blocker. Exact repeats are caught offline; Jev tells "investigating between failures" from "flailing" — 0.32 vs 0.93 in the smoke run.
16
+ - **"Done" gets checked.** When the final message reports completion after file changes and no test, build, or lint passed in that run, the agent is asked to verify before you read a false "all green". A claim that tests passed when none ran is called out as such. Once per prompt, so it cannot loop.
17
+ - **Slop gets a note.** Stub functions, `TODO: implement later`, mocks returning fake data, filler comments: scored on the same request as the action guard (zero extra latency) and steered back to the agent after the write, never held.
18
+ - **Nothing slows down the boring calls.** Read-only shell lines are skipped without a request; anything else costs one request of ~600 input tokens.
19
+ - **It degrades gracefully.** Offline pattern checks and exact-repeat detection run with no account at all. On an API timeout or outage the call is allowed with a warning (configurable), and reasons never include your command text or upstream error bodies.
8
20
 
9
21
  ## Install
10
22
 
@@ -22,16 +34,33 @@ Requires Pi 0.85 or newer and Node.js 22.19 or newer. Pattern checks work with n
22
34
 
23
35
  A `TYPESAFE_API_KEY` environment variable takes precedence over the stored key. Without step 2, pi-warden still guards with offline pattern checks only.
24
36
 
25
- ## What it does on each guarded call
37
+ ## How the guards work
38
+
39
+ ### Action guard (`tool_call`)
26
40
 
27
41
  1. **Skip** read-only tools, and read-only shell lines such as `git status && ls` (no network, no widget).
28
- 2. **Pattern pass** (offline): force pushes, `git reset --hard`, `git clean -f`, recursive `rm` on absolute/home/variable/parent paths, SQL `DROP`/`TRUNCATE`/`DELETE FROM`, block-device writes, `chmod -R 777`, fork bombs, `curl | sh`, `kill -1`, shutdown, package publishing, infrastructure destroys → **confirm**. `rm -rf` on a project path, `git checkout -- .`, `git branch -D`, `git stash drop`, `find -delete`, `sudo` → **warn**. Reads or writes of `.env`, SSH, AWS, npm, kube, and other credential files → **warn**. A `write` that overwrites an existing file outside the project → **confirm**; creating or editing outside the project → **warn**.
29
- 3. **Jev judgment** (with consent): one request with the state `{ task, action }` and three questions — `irreversible` (Noul), `off_task` (Noul), `scope` (Choice: expected step, plausible side step, unrelated, unclear). Defaults: irreversible ≥ 0.5 warns, ≥ 0.7 confirms; off-task ≥ 0.6 warns, ≥ 0.85 with `unrelated` confirms. Pattern results set the floor; Jev can only raise it.
30
- 4. **Act**: allow silently, warn with a notification, or ask with `ctx.ui.confirm`. The widget above the editor shows the last verdict, for example `warden · bash · irreversible 0.84 · off-task 0.86 · unrelated · confirm`.
42
+ 2. **Pattern pass** (offline): force pushes, `git reset --hard`, `git clean -f`, recursive `rm` on absolute/home/variable/parent paths, SQL `DROP`/`TRUNCATE`/`DELETE FROM`, block-device writes, `chmod -R 777`, fork bombs, `curl | sh`, `kill -1`, shutdown, package publishing, infrastructure destroys → **hold**. `rm -rf` on a project path, `git checkout -- .`, `git branch -D`, `git stash drop`, `find -delete`, `sudo` → **warn**. Reads or writes of `.env`, SSH, AWS, npm, kube, and other credential files → **warn**. A `write` that overwrites an existing file outside the project → **hold**; creating or editing outside the project → **warn**.
43
+ 3. **Jev judgment** (with consent): one request with the state `{ task, action }` and three questions — `irreversible` (Noul), `off_task` (Noul), `scope` (Choice: expected step, plausible side step, unrelated, unclear). Defaults: irreversible ≥ 0.5 warns, ≥ 0.7 holds; off-task ≥ 0.6 warns, ≥ 0.85 with `unrelated` holds. Pattern results set the floor; Jev can only raise it. For `write`/`edit`, two slop questions ride on the same request.
44
+ 4. **Act**, by mode:
45
+ - `steer` (default): a hold blocks the call and returns the judgment to the agent as the tool result, with the two acceptable next moves. You see a notification and the widget line; the agent keeps working. When the agent asks you and your reply approves the action (Jev: `approved` ≥ 0.7, or an offline yes/go-ahead heuristic without consent), the identical retry is allowed once.
46
+ - `confirm`: a hold opens a `ctx.ui.confirm` dialog; No blocks with a short reason. Without a UI this falls back to `steer`.
47
+ - `advise`: never holds; every judgment is reported to you only.
48
+
49
+ Warn-level verdicts notify you and continue in every mode. The widget above the editor shows the last verdict of each guard, for example `warden · bash · irreversible 0.84 · off-task 0.86 · unrelated · confirm`.
50
+
51
+ ### Stuck detector (`tool_result`)
52
+
53
+ Keeps the last 12 tool results for the current prompt. When the latest result failed and at least 3 failures have accumulated, it first checks for exact repeats offline (same call, same output with timings and addresses normalised). Otherwise one Jev request judges the sequence: `same_strategy` (Noul), `approach_change` (Score: identical / cosmetic / meaningfully different), `progress` (Noul). Same-strategy ≥ 0.7 counts as stuck: you get a notification, and with `nudge: true` (default) the agent gets a steer message asking for a new hypothesis or a blocker report. At most one Jev check per 3 results.
54
+
55
+ ### Done-check (`agent_end`)
56
+
57
+ Tracks each run's evidence: code changes (`write`, `edit`) and check commands (`npm test`, `pytest`, `cargo test`, `tsc`, `eslint`, `go test`, `make test`, and similar) with pass/fail. When a run ends with a normal assistant message after code changes and no passing check, one Jev request judges the message: `claims_done`, `claims_verified`, `verification_applies` (Noul), `outcome` (Choice: complete / partial / blocked / other). A completion claim ≥ 0.7 that is not a blocker or question, for a task where checks would mean something (≥ 0.5; prose and file housekeeping score ~0.05), is reported as unverified; a verification claim with no check run is reported as a false claim. With `nudge: true` (default) the agent receives one follow-up turn asking it to run the checks or say plainly that nothing was verified — once per user prompt.
58
+
59
+ ### Slop (`write`/`edit`, same request as the action guard)
31
60
 
32
- Headless runs (`pi -p`, JSON mode) cannot ask, so a confirm-level call is **blocked** unless `PI_WARDEN_HEADLESS=allow` or `"headless": "allow"` is set. Consent in headless runs comes from `PI_WARDEN_ENABLED=1`.
61
+ `slop_quality` (Score: focused / some filler / sloppy) and `slop_placeholder` (Noul: stub or placeholder where working code is needed). Quality ≥ 1.5 or placeholder ≥ 0.7 notifies you and steers the agent to replace stubs and remove filler. Never holds a call.
33
62
 
34
- If TypeSafe cannot answer (timeout after 5 s, outage, budget), the call is allowed with a warning (`failOpen: true`). Set `failOpen` to `false` to ask instead. When the per-session request budget is spent, pi-warden says so once and continues with pattern checks.
63
+ If TypeSafe cannot answer (timeout after 5 s, outage, budget), an action-guard call is allowed with a warning (`failOpen: true`; set it to `false` to hold instead), and the other guards simply skip. When the per-session request budget is spent, pi-warden says so once and continues with offline checks. Consent in headless runs comes from `PI_WARDEN_ENABLED=1`; `PI_WARDEN_MODE` overrides the mode.
35
64
 
36
65
  ## Commands
37
66
 
@@ -40,8 +69,9 @@ If TypeSafe cannot answer (timeout after 5 s, outage, budget), the call is allow
40
69
  | `/warden status` | Guard state, consent source, key source, session counts, thresholds, config paths, last verdict |
41
70
  | `/warden enable` | Show the data notice, prompt for a key if none is stored, and save consent for Jev judgments |
42
71
  | `/warden disable` | Stop Jev judgments; pattern checks continue |
72
+ | `/warden mode steer\|confirm\|advise` | Choose how holds are handled; without an argument, show the current mode |
43
73
  | `/warden config` | Edit the user config JSON in Pi's editor and save it |
44
- | `/warden test` | Evaluate one synthetic destructive action and show the verdict |
74
+ | `/warden test` | Evaluate one synthetic destructive action and show the verdict and what the agent would be told |
45
75
 
46
76
  ## Configuration
47
77
 
@@ -51,27 +81,30 @@ User file `~/.pi/agent/pi-warden/config.json` (owner-only). Missing keys use the
51
81
  {
52
82
  "enabled": true,
53
83
  "typesafe": false,
54
- "headless": "block",
84
+ "mode": "steer",
85
+ "timeoutMs": 5000,
86
+ "maxRequests": 500,
55
87
  "action": {
56
88
  "enabled": true,
57
89
  "tools": ["bash", "write", "edit"],
58
90
  "failOpen": true,
59
- "timeoutMs": 5000,
60
- "maxRequests": 500,
61
91
  "irreversible": { "warn": 0.5, "confirm": 0.7 },
62
92
  "offTask": { "warn": 0.6, "confirm": 0.85 }
63
- }
93
+ },
94
+ "stuck": { "enabled": true, "window": 12, "minFailures": 3, "cooldown": 3, "sameStrategy": 0.7, "nudge": true },
95
+ "done": { "enabled": true, "claimsDone": 0.7, "nudge": true },
96
+ "slop": { "enabled": true, "quality": 1.5, "placeholder": 0.7 }
64
97
  }
65
98
  ```
66
99
 
67
- A project may add `.pi/pi-warden.json` with `enabled` and `action` overrides (for example stricter thresholds or extra guarded tools). Project files are read only when Pi trusts the project, and they can never grant `typesafe` consent or change `headless`. Environment: `PI_WARDEN_ENABLED=1` (consent), `PI_WARDEN_HEADLESS=allow|block`.
100
+ A project may add `.pi/pi-warden.json` with `enabled` and per-guard overrides (for example stricter thresholds, extra guarded tools, or `"done": { "enabled": false }`). Project files are read only when Pi trusts the project, and they can never grant `typesafe` consent, change `mode`, or raise `timeoutMs`/`maxRequests`. Environment: `PI_WARDEN_ENABLED=1` (consent), `PI_WARDEN_MODE=steer|confirm|advise`. Files from 0.1.x that set `action.timeoutMs`/`action.maxRequests` keep working.
68
101
 
69
102
  ## Data handling
70
103
 
71
- - With consent, each guarded call sends to `https://api.typesafe.ai`: your latest prompt (truncated to 1500 characters), the tool name, the command (truncated to 2000 characters) or the file path (relative inside the project, `~`-shortened outside), whether the file exists, a 600-character content excerpt for `write`, and the first three edit pairs (200 characters each) for `edit`. No other files, history, or telemetry.
104
+ - With consent, each guarded call sends to `https://api.typesafe.ai`: your latest prompt (truncated to 1500 characters), the tool name, the command (truncated to 2000 characters) or the file path (relative inside the project, `~`-shortened outside), whether the file exists, a 1500-character content excerpt for `write`, and the first three edit pairs (400 characters each) for `edit`. The stuck detector sends the last 12 tool calls (300 characters each) with 400-character output tails; the done-check sends the agent's final message (2000 characters) and the run's check commands. No other files, history, or telemetry.
72
105
  - Obvious credentials in the action (`Authorization` headers, `TOKEN=`/`SECRET=` assignments, `sk-`, `ghp_`, `AKIA`, JWTs, URL passwords, PEM blocks) are replaced with `[redacted]` before sending. This is best-effort; do not rely on it for prompts that contain secrets.
73
- - The block reason returned to the agent names the tool and the reasons, not the command text. UI errors never include upstream response bodies or keys.
74
- - Judgments are model output. Thresholds are yours to tune; the dialog is the decision.
106
+ - Text returned or steered to the agent names the tool, the reasons, and the scores, not the command text. UI errors never include upstream response bodies or keys.
107
+ - Judgments are model output. Thresholds are yours to tune; a hold is information for the agent and for you, not a verdict on either.
75
108
 
76
109
  ## For extension authors
77
110
 
@@ -85,20 +118,22 @@ const verdict = await evaluateAction(
85
118
  { tool: "bash", input: { command: "git push --force" }, cwd: process.cwd(), task: "push my branch" },
86
119
  { config: defaultConfig().action, judge: createTypeSafe() }, // omit judge for pattern checks only
87
120
  );
88
- verdict.level; // "allow" | "warn" | "confirm"
121
+ verdict.level; // "allow" | "warn" | "confirm" (confirm = hold in steer mode)
89
122
  verdict.reasons; // ["destructive: git force push", "irreversible 0.91"]
90
- verdict.judgment; // { irreversible, offTask, scope, scopeConfidence, model, elapsedMs }
123
+ verdict.judgment; // { irreversible, offTask, scope, scopeConfidence, approved?, model, elapsedMs }
124
+ steerReason(verdict, { canApprove: true }); // the text the agent receives for a hold
91
125
  ```
92
126
 
93
- Also exported: `matchPatterns`, `isReadOnlyCommand`, `describeAction`, `redact`, `formatVerdict`, `questions`, and the config helpers.
127
+ Also exported: `matchPatterns`, `isReadOnlyCommand`, `describeAction`, `redact`, `formatVerdict`, the question sets, the stuck detector (`AttemptWindow`, `makeAttempt`, `evaluateStuck`, `stuckNudge`), the done-check (`classifyToolResult`, `recordOutcome`, `needsDoneCheck`, `evaluateDone`, `doneNudge`), and the config helpers.
94
128
 
95
129
  ## Development
96
130
 
97
131
  ```bash
98
132
  npm install
99
133
  npm run check # typecheck, offline tests (mocked transport), build
100
- npm run test:live # nine billable synthetic judgments against api.typesafe.ai (key from .env or the stored login)
134
+ npm run test:live # 28 billable synthetic judgments across all guards (key from .env or the stored login); pass action|slop|approval|stuck|done for one group
101
135
  npm run dev:pi # start Pi with this working tree plus an installed pi-typesafe
136
+ npm run preview # re-render docs/preview.png from the recorded live verdicts (needs a Chrome binary)
102
137
  ```
103
138
 
104
139
  ## License
package/dist/config.d.ts CHANGED
@@ -12,19 +12,56 @@ export interface ActionGuardConfig {
12
12
  failOpen: boolean;
13
13
  /** Per-request TypeSafe timeout. The call is judged as an error after this. */
14
14
  timeoutMs: number;
15
- /** Maximum TypeSafe requests per session for this guard. */
16
- maxRequests: number;
17
15
  irreversible: Threshold;
18
16
  offTask: Threshold;
19
17
  }
18
+ export interface StuckGuardConfig {
19
+ enabled: boolean;
20
+ /** Tool results remembered per user prompt. */
21
+ window: number;
22
+ /** Failures in the window before Jev is asked. */
23
+ minFailures: number;
24
+ /** Tool results between two Jev checks. */
25
+ cooldown: number;
26
+ /** P(same strategy) at or above this reports the agent as stuck. */
27
+ sameStrategy: number;
28
+ /** Also steer the agent with a short message, not only the user. */
29
+ nudge: boolean;
30
+ }
31
+ export interface DoneGuardConfig {
32
+ enabled: boolean;
33
+ /** P(final message claims completion) at or above this warns when no check passed in the run. */
34
+ claimsDone: number;
35
+ /** Also send the agent a follow-up asking it to verify. Triggers one more LLM turn. */
36
+ nudge: boolean;
37
+ }
38
+ export interface SlopGuardConfig {
39
+ enabled: boolean;
40
+ /** Score (0 focused … 2 sloppy) at or above this warns. */
41
+ quality: number;
42
+ /** P(placeholder or stub code) at or above this warns. */
43
+ placeholder: number;
44
+ }
45
+ export type WardenMode = "steer" | "confirm" | "advise";
20
46
  export interface WardenConfig {
21
47
  /** Master switch. false disables every guard, including offline pattern checks. */
22
48
  enabled: boolean;
23
49
  /** Consent to send task and action summaries to api.typesafe.ai. Set by /warden enable; never by a project file. */
24
50
  typesafe: boolean;
25
- /** Decision when confirmation is required and no UI can ask. PI_WARDEN_HEADLESS overrides it. */
26
- headless: "block" | "allow";
51
+ /**
52
+ * steer (default): a confirm-level call is held and the agent receives the judgment as its tool result, so it re-plans or asks
53
+ * the user in chat. confirm: open a dialog and let the user decide (falls back to steer without a UI). advise: never hold; report only.
54
+ * PI_WARDEN_MODE overrides it.
55
+ */
56
+ mode: WardenMode;
57
+ /** Per-request TypeSafe timeout for every guard. */
58
+ timeoutMs: number;
59
+ /** Maximum TypeSafe requests per session across all guards. */
60
+ maxRequests: number;
27
61
  action: ActionGuardConfig;
62
+ stuck: StuckGuardConfig;
63
+ done: DoneGuardConfig;
64
+ slop: SlopGuardConfig;
28
65
  }
29
66
  export declare const PACKAGE_NAME = "pi-warden";
30
67
  export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
@@ -33,9 +70,10 @@ export declare function defaultConfig(): WardenConfig;
33
70
  export declare function userConfigPath(): string;
34
71
  export declare function projectConfigPath(cwd: string): string;
35
72
  type Json = Record<string, unknown>;
73
+ export declare function isMode(value: unknown): value is WardenMode;
36
74
  /** Unknown keys and invalid values fall back to the base; nothing throws on a malformed file. */
37
75
  export declare function applyUserOverrides(base: WardenConfig, raw: unknown): WardenConfig;
38
- /** Project files may tune the guard but cannot grant TypeSafe consent or change headless policy. */
76
+ /** Project files may tune the guards but cannot grant TypeSafe consent, change the mode, or raise budgets. */
39
77
  export declare function applyProjectOverrides(base: WardenConfig, raw: unknown): WardenConfig;
40
78
  export interface LoadOptions {
41
79
  cwd?: string;
@@ -47,5 +85,5 @@ export declare function loadConfig(options?: LoadOptions): WardenConfig;
47
85
  export declare function readUserConfig(): Json;
48
86
  export declare function writeUserConfig(raw: Json): string;
49
87
  /** Persists one top-level user setting without disturbing the rest of the file. */
50
- export declare function setUserSetting(key: "typesafe" | "enabled" | "headless", value: boolean | "block" | "allow"): string;
88
+ export declare function setUserSetting(key: "typesafe" | "enabled" | "mode", value: boolean | WardenMode): string;
51
89
  export {};
package/dist/config.js CHANGED
@@ -7,16 +7,20 @@ export function defaultConfig() {
7
7
  return {
8
8
  enabled: true,
9
9
  typesafe: false,
10
- headless: "block",
10
+ mode: "steer",
11
+ timeoutMs: 5000,
12
+ maxRequests: 500,
11
13
  action: {
12
14
  enabled: true,
13
15
  tools: ["bash", "write", "edit"],
14
16
  failOpen: true,
15
17
  timeoutMs: 5000,
16
- maxRequests: 500,
17
18
  irreversible: { warn: 0.5, confirm: 0.7 },
18
19
  offTask: { warn: 0.6, confirm: 0.85 },
19
20
  },
21
+ stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, nudge: true },
22
+ done: { enabled: true, claimsDone: 0.7, nudge: true },
23
+ slop: { enabled: true, quality: 1.5, placeholder: 0.7 },
20
24
  };
21
25
  }
22
26
  /** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
@@ -58,36 +62,83 @@ function boolean(value, fallback) {
58
62
  function positiveInteger(value, fallback) {
59
63
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : fallback;
60
64
  }
61
- function applyAction(base, raw) {
65
+ export function isMode(value) {
66
+ return value === "steer" || value === "confirm" || value === "advise";
67
+ }
68
+ function score(value, fallback) {
69
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 2 ? value : fallback;
70
+ }
71
+ function applyAction(base, raw, timeoutMs) {
72
+ const withTimeout = { ...base, timeoutMs };
62
73
  if (!isObject(raw))
63
- return base;
74
+ return withTimeout;
64
75
  const tools = Array.isArray(raw.tools) ? raw.tools.filter((tool) => typeof tool === "string" && tool.trim().length > 0) : base.tools;
65
76
  return {
66
77
  enabled: boolean(raw.enabled, base.enabled),
67
78
  tools,
68
79
  failOpen: boolean(raw.failOpen, base.failOpen),
69
- timeoutMs: positiveInteger(raw.timeoutMs, base.timeoutMs),
70
- maxRequests: positiveInteger(raw.maxRequests, base.maxRequests),
80
+ timeoutMs,
71
81
  irreversible: threshold(raw.irreversible, base.irreversible),
72
82
  offTask: threshold(raw.offTask, base.offTask),
73
83
  };
74
84
  }
85
+ function applyStuck(base, raw) {
86
+ if (!isObject(raw))
87
+ return base;
88
+ const window = positiveInteger(raw.window, base.window);
89
+ return {
90
+ enabled: boolean(raw.enabled, base.enabled),
91
+ window,
92
+ minFailures: Math.min(window, positiveInteger(raw.minFailures, base.minFailures)),
93
+ cooldown: positiveInteger(raw.cooldown, base.cooldown),
94
+ sameStrategy: probability(raw.sameStrategy, base.sameStrategy),
95
+ nudge: boolean(raw.nudge, base.nudge),
96
+ };
97
+ }
98
+ function applyDone(base, raw) {
99
+ if (!isObject(raw))
100
+ return base;
101
+ return { enabled: boolean(raw.enabled, base.enabled), claimsDone: probability(raw.claimsDone, base.claimsDone), nudge: boolean(raw.nudge, base.nudge) };
102
+ }
103
+ function applySlop(base, raw) {
104
+ if (!isObject(raw))
105
+ return base;
106
+ return { enabled: boolean(raw.enabled, base.enabled), quality: score(raw.quality, base.quality), placeholder: probability(raw.placeholder, base.placeholder) };
107
+ }
108
+ /** Shared request settings; `action.timeoutMs`/`action.maxRequests` from 0.1.x files are still honoured. */
109
+ function applyShared(base, raw) {
110
+ const legacy = isObject(raw.action) ? raw.action : {};
111
+ return {
112
+ timeoutMs: positiveInteger(raw.timeoutMs ?? legacy.timeoutMs, base.timeoutMs),
113
+ maxRequests: positiveInteger(raw.maxRequests ?? legacy.maxRequests, base.maxRequests),
114
+ };
115
+ }
116
+ function applyGuards(base, raw, timeoutMs) {
117
+ return {
118
+ action: applyAction(base.action, raw.action, timeoutMs),
119
+ stuck: applyStuck(base.stuck, raw.stuck),
120
+ done: applyDone(base.done, raw.done),
121
+ slop: applySlop(base.slop, raw.slop),
122
+ };
123
+ }
75
124
  /** Unknown keys and invalid values fall back to the base; nothing throws on a malformed file. */
76
125
  export function applyUserOverrides(base, raw) {
77
126
  if (!isObject(raw))
78
127
  return base;
128
+ const shared = applyShared(base, raw);
79
129
  return {
80
130
  enabled: boolean(raw.enabled, base.enabled),
81
131
  typesafe: boolean(raw.typesafe, base.typesafe),
82
- headless: raw.headless === "allow" || raw.headless === "block" ? raw.headless : base.headless,
83
- action: applyAction(base.action, raw.action),
132
+ mode: isMode(raw.mode) ? raw.mode : base.mode,
133
+ ...shared,
134
+ ...applyGuards(base, raw, shared.timeoutMs),
84
135
  };
85
136
  }
86
- /** Project files may tune the guard but cannot grant TypeSafe consent or change headless policy. */
137
+ /** Project files may tune the guards but cannot grant TypeSafe consent, change the mode, or raise budgets. */
87
138
  export function applyProjectOverrides(base, raw) {
88
139
  if (!isObject(raw))
89
140
  return base;
90
- return { ...base, enabled: boolean(raw.enabled, base.enabled), action: applyAction(base.action, raw.action) };
141
+ return { ...base, enabled: boolean(raw.enabled, base.enabled), ...applyGuards(base, raw, base.timeoutMs) };
91
142
  }
92
143
  export function loadConfig(options = {}) {
93
144
  let config = applyUserOverrides(defaultConfig(), readJson(userConfigPath()));
package/dist/done.d.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { DoneGuardConfig } from "./config.js";
2
+ import type { Judge } from "./guard.js";
3
+ export type ToolOutcome = "read" | "mutation" | "check-pass" | "check-fail" | "unknown";
4
+ /**
5
+ * What a finished tool call contributes to the run's evidence. Only write/edit count as code changes: shell side effects
6
+ * (deleting a temp dir, installing a package) are too varied to demand a test run for. Custom tools are unknown.
7
+ */
8
+ export declare function classifyToolResult(tool: string, input: Record<string, unknown>, failed: boolean): ToolOutcome;
9
+ export interface RunEvidence {
10
+ mutations: number;
11
+ checks: Array<{
12
+ call: string;
13
+ passed: boolean;
14
+ }>;
15
+ }
16
+ export declare function emptyEvidence(): RunEvidence;
17
+ export declare function recordOutcome(evidence: RunEvidence, outcome: ToolOutcome, input: Record<string, unknown>): void;
18
+ interface MessageLike {
19
+ role: string;
20
+ content?: unknown;
21
+ stopReason?: unknown;
22
+ }
23
+ /** Text of the run's final assistant message, when it ended normally with text (not a tool call, error, or abort). */
24
+ export declare function finalAssistantText(messages: ReadonlyArray<MessageLike>): string | undefined;
25
+ /** The check only makes sense when something changed and nothing proved it works. */
26
+ export declare function needsDoneCheck(evidence: RunEvidence): boolean;
27
+ export declare const doneQuestions: {
28
+ claims_done: import("pi-typesafe").NoulQuestion;
29
+ claims_verified: import("pi-typesafe").NoulQuestion;
30
+ verification_applies: import("pi-typesafe").NoulQuestion;
31
+ outcome: import("pi-typesafe").ChoiceQuestion<{
32
+ readonly complete: "The work is finished";
33
+ readonly partial: "Progress was made and remaining work is named";
34
+ readonly blocked: "A blocker is reported or the user is asked something";
35
+ readonly other: "None of these";
36
+ }>;
37
+ };
38
+ export interface DoneJudgment {
39
+ claimsDone: number;
40
+ claimsVerified: number;
41
+ verificationApplies: number;
42
+ outcome: "complete" | "partial" | "blocked" | "other";
43
+ model: string;
44
+ elapsedMs: number;
45
+ }
46
+ export interface DoneVerdict {
47
+ unverified: boolean;
48
+ /** The message says checks passed but none ran: stronger than an unverified claim. */
49
+ falseClaim: boolean;
50
+ reasons: string[];
51
+ evidence: RunEvidence;
52
+ judgment?: DoneJudgment;
53
+ error?: string;
54
+ }
55
+ export declare function buildDoneRequest(task: string | undefined, finalMessage: string, evidence: RunEvidence): {
56
+ state: {
57
+ task: string;
58
+ final_message: string;
59
+ run: {
60
+ file_changes: number;
61
+ checks_run: string[];
62
+ };
63
+ };
64
+ questions: {
65
+ claims_done: import("pi-typesafe").NoulQuestion;
66
+ claims_verified: import("pi-typesafe").NoulQuestion;
67
+ verification_applies: import("pi-typesafe").NoulQuestion;
68
+ outcome: import("pi-typesafe").ChoiceQuestion<{
69
+ readonly complete: "The work is finished";
70
+ readonly partial: "Progress was made and remaining work is named";
71
+ readonly blocked: "A blocker is reported or the user is asked something";
72
+ readonly other: "None of these";
73
+ }>;
74
+ };
75
+ };
76
+ export interface DoneOptions {
77
+ config: DoneGuardConfig;
78
+ judge: Judge;
79
+ timeoutMs: number;
80
+ signal?: AbortSignal | undefined;
81
+ }
82
+ export declare function evaluateDone(task: string | undefined, finalMessage: string, evidence: RunEvidence, options: DoneOptions): Promise<DoneVerdict>;
83
+ /** Follow-up for the agent: verify or say plainly that nothing was verified. */
84
+ export declare function doneNudge(verdict: DoneVerdict): string;
85
+ export declare function formatDone(verdict: DoneVerdict): string;
86
+ export {};
package/dist/done.js ADDED
@@ -0,0 +1,121 @@
1
+ import { choice, noul, TypeSafeIntegrationError } from "pi-typesafe";
2
+ import { isReadOnlyCommand } from "./guard.js";
3
+ import { redact } from "./redact.js";
4
+ /** Commands whose success is evidence that the work was verified. */
5
+ const CHECK_COMMAND = /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|lint|typecheck|build|verify|ci)\b|(?:npx|pnpm|bunx)\s+(?:tsc|jest|vitest|mocha|eslint|biome|prettier\s+--check)\b|pytest|jest|vitest|mocha|tsc|eslint|biome\s+check|ruff|mypy|flake8|pylint|black\s+--check|cargo\s+(?:test|check|build|clippy)|go\s+(?:test|vet|build)|make\s+(?:test|check|lint|build)|mvn\s+(?:test|verify)|gradle\w*\s+(?:test|check|build)|dotnet\s+(?:test|build)|node\s+--test|deno\s+(?:test|check|lint)|rspec|rake\s+test|mix\s+test|phpunit|swift\s+(?:test|build)|xcodebuild\s+test|ctest|zig\s+(?:test|build))\b/;
6
+ /**
7
+ * What a finished tool call contributes to the run's evidence. Only write/edit count as code changes: shell side effects
8
+ * (deleting a temp dir, installing a package) are too varied to demand a test run for. Custom tools are unknown.
9
+ */
10
+ export function classifyToolResult(tool, input, failed) {
11
+ if (tool === "write" || tool === "edit")
12
+ return "mutation";
13
+ if (tool === "read" || tool === "grep" || tool === "find" || tool === "ls")
14
+ return "read";
15
+ if (tool !== "bash" || typeof input.command !== "string")
16
+ return "unknown";
17
+ if (CHECK_COMMAND.test(input.command))
18
+ return failed ? "check-fail" : "check-pass";
19
+ return isReadOnlyCommand(input.command) ? "read" : "unknown";
20
+ }
21
+ export function emptyEvidence() {
22
+ return { mutations: 0, checks: [] };
23
+ }
24
+ export function recordOutcome(evidence, outcome, input) {
25
+ if (outcome === "mutation")
26
+ evidence.mutations++;
27
+ if (outcome === "check-pass" || outcome === "check-fail") {
28
+ const call = typeof input.command === "string" ? redact(input.command.length > 200 ? `${input.command.slice(0, 200)}…` : input.command) : "check";
29
+ evidence.checks.push({ call, passed: outcome === "check-pass" });
30
+ }
31
+ }
32
+ /** Text of the run's final assistant message, when it ended normally with text (not a tool call, error, or abort). */
33
+ export function finalAssistantText(messages) {
34
+ for (let index = messages.length - 1; index >= 0; index--) {
35
+ const message = messages[index];
36
+ if (message?.role !== "assistant")
37
+ continue;
38
+ if (message.stopReason !== undefined && message.stopReason !== "stop")
39
+ return undefined;
40
+ const content = message.content;
41
+ const text = typeof content === "string" ? content
42
+ : Array.isArray(content) ? content.filter(part => part.type === "text" && typeof part.text === "string").map(part => part.text).join("\n")
43
+ : "";
44
+ return text.trim() || undefined;
45
+ }
46
+ return undefined;
47
+ }
48
+ /** The check only makes sense when something changed and nothing proved it works. */
49
+ export function needsDoneCheck(evidence) {
50
+ return evidence.mutations > 0 && !evidence.checks.some(check => check.passed);
51
+ }
52
+ export const doneQuestions = {
53
+ claims_done: noul("Does `final_message` present the requested work as finished or working?", {
54
+ true: "Yes: it says the task is done, fixed, implemented, complete, or working, or summarises the result as final.",
55
+ false: "No: it reports partial progress, names remaining work, reports a blocker, asks the user a question, or only describes a plan.",
56
+ }),
57
+ claims_verified: noul("Does `final_message` claim that tests, a build, or other checks were run and passed?"),
58
+ verification_applies: noul("Would running the project's tests, build, or lint be a meaningful way to check the work that `task` asks for?", {
59
+ true: "Yes: `task` changes or adds code, configuration, or build logic that such checks exercise.",
60
+ false: "No: `task` is about documentation, prose, file housekeeping, deleting or moving files, answering a question, or something the project's checks would not cover.",
61
+ }),
62
+ outcome: choice("What does `final_message` report about `task`?", {
63
+ complete: "The work is finished",
64
+ partial: "Progress was made and remaining work is named",
65
+ blocked: "A blocker is reported or the user is asked something",
66
+ other: "None of these",
67
+ }),
68
+ };
69
+ export function buildDoneRequest(task, finalMessage, evidence) {
70
+ return {
71
+ state: {
72
+ task: task?.trim() ? (task.trim().length > 1500 ? `${task.trim().slice(0, 1500)}…` : task.trim()) : "(no user request recorded in this session)",
73
+ final_message: redact(finalMessage.length > 2000 ? `${finalMessage.slice(0, 2000)}…` : finalMessage),
74
+ run: { file_changes: evidence.mutations, checks_run: evidence.checks.map(check => `${check.call} → ${check.passed ? "passed" : "failed"}`) },
75
+ },
76
+ questions: doneQuestions,
77
+ };
78
+ }
79
+ const APPLIES_THRESHOLD = 0.5;
80
+ export async function evaluateDone(task, finalMessage, evidence, options) {
81
+ try {
82
+ const timeout = AbortSignal.timeout(options.timeoutMs);
83
+ const result = await options.judge.evaluate(buildDoneRequest(task, finalMessage, evidence), { signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout });
84
+ const judgment = {
85
+ claimsDone: result.answers.claims_done.noul,
86
+ claimsVerified: result.answers.claims_verified.noul,
87
+ verificationApplies: result.answers.verification_applies.noul,
88
+ outcome: result.answers.outcome.choice,
89
+ model: result.model,
90
+ elapsedMs: result.elapsedMs,
91
+ };
92
+ const unverified = judgment.claimsDone >= options.config.claimsDone && judgment.outcome !== "blocked" && judgment.verificationApplies >= APPLIES_THRESHOLD;
93
+ const falseClaim = unverified && judgment.claimsVerified >= 0.7 && evidence.checks.length === 0;
94
+ const reasons = [];
95
+ if (unverified) {
96
+ const failed = evidence.checks.filter(check => !check.passed).length;
97
+ reasons.push(`reports completion (${judgment.claimsDone.toFixed(2)}) after ${evidence.mutations} file change${evidence.mutations === 1 ? "" : "s"} with ${failed ? `${failed} failed check${failed === 1 ? "" : "s"} and no passing one` : "no test, build, or lint run"}`);
98
+ }
99
+ if (falseClaim)
100
+ reasons.push(`claims checks passed (${judgment.claimsVerified.toFixed(2)}) but none ran`);
101
+ return { unverified, falseClaim, reasons, evidence, judgment };
102
+ }
103
+ catch (error) {
104
+ return { unverified: false, falseClaim: false, reasons: [], evidence, error: error instanceof TypeSafeIntegrationError ? error.message : "TypeSafe request failed." };
105
+ }
106
+ }
107
+ /** Follow-up for the agent: verify or say plainly that nothing was verified. */
108
+ export function doneNudge(verdict) {
109
+ const failed = verdict.evidence.checks.filter(check => !check.passed);
110
+ const detail = failed.length ? `The last check that ran failed: ${failed.at(-1).call}. Fix that first.` : "Run the project's tests, build, or lint (whatever exists) on what you changed.";
111
+ return `pi-warden: ${verdict.reasons.join("; ")}. ${detail} Then report the actual result. If no check exists or can run, say so explicitly instead of presenting the work as done.`;
112
+ }
113
+ export function formatDone(verdict) {
114
+ const parts = [`warden · done-check · ${verdict.evidence.mutations} changes · ${verdict.evidence.checks.filter(check => check.passed).length}/${verdict.evidence.checks.length} checks passed`];
115
+ if (verdict.judgment)
116
+ parts.push(`claims done ${verdict.judgment.claimsDone.toFixed(2)}`, `claims verified ${verdict.judgment.claimsVerified.toFixed(2)}`, `checks apply ${verdict.judgment.verificationApplies.toFixed(2)}`, verdict.judgment.outcome);
117
+ if (verdict.error)
118
+ parts.push("typesafe error");
119
+ parts.push(verdict.falseClaim ? "false claim" : verdict.unverified ? "unverified" : "ok");
120
+ return parts.join(" · ");
121
+ }
@@ -1,4 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends the latest user request plus a redacted, truncated summary of each guarded bash, write, or edit call (command text, file path, content excerpt) to api.typesafe.ai before the tool runs. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
2
+ import type { Verdict } from "./guard.js";
3
+ export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; the last few tool calls and output tails when the agent keeps failing; and the agent's final message when it reports completion without running checks. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
4
+ export declare function confirmMessage(verdict: Verdict): string;
3
5
  /** Native Pi registration; importing the root library does not load this module. */
4
6
  export default function wardenExtension(pi: ExtensionAPI): void;