Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ How to keep this current: add the entry in the same pull request as the change,

<!-- Empty. Next release starts here. -->

## 0.76.0

### Changed

- The intent-mismatch verdict is trace-only by default: `action.intentTraceOnly` is `"all"` instead of `"invisible"`, so a mismatch on a call with a visible effect (a commit, push, merge, tag, reset, pull request, release, publish, install, launched program, or a message sent from a script) no longer reaches the agent. The score, the trace entry, the `/warden status` counters, and the `visibleMismatch` and `intentMismatch` thresholds are unchanged; hand labels on 140 sampled calls, blind to the score, put the score's separation of a differing call at AUROC 0.815, but of the 37 steers that would reach the agent, 36 were calls the plan or the user's latest request had asked for. Restore the old delivery with `"action": { "intentTraceOnly": "invisible" }`.

## 0.75.0

### Changed
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ User file `~/.pi/agent/pi-warden/config.json` (owner-only). `/warden config` ope
"offTask": { "warn": 0.6, "steer": 0.85 },
"intentMismatch": 0.9,
"visibleMismatch": 0.8,
"intentTraceOnly": "invisible",
"intentTraceOnly": "all",
"shouldProceed": { "hold": 0.6, "steer": false },
"feedbackLog": true,
"floor": "evidence",
Expand Down Expand Up @@ -89,7 +89,7 @@ User file `~/.pi/agent/pi-warden/config.json` (owner-only). `/warden config` ope
| `action.offTask` | `warn` and `steer` thresholds on P(off-task). Off-task never holds. |
| `action.intentMismatch` | P(call differs from the agent's stated plan) that warns and tells the agent, on calls that can change something. |
| `action.visibleMismatch` | Lower mismatch threshold for commands whose effect is visible outside the working tree (commit, push, publish, install, launch). |
| `action.intentTraceOnly` | Which intent mismatches stay in the trace without a steer to the agent. `"invisible"` (default): a call with no visible effect: not a commit, push, merge, tag, reset, pull request, release, or publish (decided in code), and not judged `visible` at 0.8 or more (an install, a launched program, a message sent from a script). `"all"`: every mismatch. `"none"`: none; every mismatch steers, as before. The steer arrives after the call ran. |
| `action.intentTraceOnly` | Which intent mismatches stay in the trace without a steer to the agent. `"all"` (default): every mismatch, visible effect or not. `"invisible"`: only a call with no visible effect: not a commit, push, merge, tag, reset, pull request, release, or publish (decided in code), and not judged `visible` at 0.8 or more (an install, a launched program, a message sent from a script). `"none"`: none; every mismatch steers, as before. The steer arrives after the call ran. Blind labels on 140 sampled calls put the score's separation of a differing call at AUROC 0.815, but of the 37 steers that would reach the agent, 36 were calls the plan or the user's latest request had asked for. Restore the old behaviour with `"action": { "intentTraceOnly": "invisible" }`. |
| `action.shouldProceed` | `{ hold, steer }`. Scores at or below `hold` (default 0.6) are trace-only by default until calibrated; they never hold a call. |
| `action.shouldProceed.steer` | Default `false`. Set `true` to restore the steer that asks the agent to pause and seek user approval. |
| `action.feedbackLog` | Write each judged call and its outcome to `~/.pi/agent/pi-warden/holds/`; never the command. |
Expand Down
6 changes: 5 additions & 1 deletion docs/guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Runs on `tool_call`, before the tool executes.
In **evidence mode** (`action.floor: "evidence"`, the default), built-in pattern hits listed above are fed to the judge as `floor_hits` in the request state and traced as `(evidence)` in reasons, but they do not set the hold level. The judge's `irreversible` score against the configured thresholds decides warn and confirm. This prevents the floor from overriding a present, confident judge. Without a judge (TypeSafe unavailable, consent off, request failed), or in **level mode** (`action.floor: "level"`), the floor applies as before: destructive hits hold, risky/sensitive hits warn, outside-project existing-file writes hold.
3. **Jev**, with consent: one request with `{ task, spine, context, plan, action, floor_hits }` and five questions. `irreversible` (yes/no), `off_task` (yes/no), `mutates` (does it change anything), `scope` (expected step, plausible side step, unrelated, unclear), `should_proceed` (yes/no, inverted: low = steer). Defaults: irreversible at 0.5 warns and at 0.9 holds; the 0.5 to 0.9 band warns instead of holding (see [Calibration](#calibration)). Off-task never holds: at 0.6 it warns, and at 0.85 with `unrelated` on a call that can change something the agent is also steered back to your request (an unrelated `grep` is warned about only). `should_proceed` steers but never holds: when P(yes) drops below 0.6 the agent is told to pause and ask the user. In evidence mode, built-in pattern hits are listed in `floor_hits` so the judge weighs them; in level mode, patterns set the floor and Jev can only raise it.

`plan` is the agent's own words in the message that makes the call, or in the text-only message right before it with no tool call in between (500 redacted characters). Text from before an earlier tool call described that call, so it is not sent and the intent question is not asked, except for a shell command with a visible effect (a `git` commit, push, merge, tag, or reset, `gh pr`, `gh release`, `npm publish`): that call is still judged against the latest text since your prompt. It tells Jev which step this is, so a verification fixture the agent just announced is not judged unrelated; it never authorizes anything. When there is a plan, a fifth question `intent_mismatch` asks whether the call does something materially different from it: a delete where the plan said list, a force push where it said push. At `action.intentMismatch` (0.9) on a call that can change something, the call is warned about and the agent is told to keep its words and its calls in step. A command whose effect is visible outside the working tree (`visible`: a commit, push, merge, publish, message, install, launched program) needs only `action.visibleMismatch` (0.8): on recorded sessions that pair is what users objected to. Never held on that alone. The warning reaches the agent after the call ran, so by default (`action.intentTraceOnly: "invisible"`) only a call with a visible effect steers the agent: a commit, push, merge, tag, reset, pull request, release, or publish (decided in code), or a call Jev judges `visible` at 0.8 or more (an install, a launched program, a message sent from a script); any other mismatch stays in the trace and the status count. On 275 recorded steers, all 275 arrived after the call, and a strict course change followed 8%. `"none"` steers on every mismatch; `"all"` on none. The trace shows the plan under each verdict.
`plan` is the agent's own words in the message that makes the call, or in the text-only message right before it with no tool call in between (500 redacted characters). Text from before an earlier tool call described that call, so it is not sent and the intent question is not asked, except for a shell command with a visible effect (a `git` commit, push, merge, tag, or reset, `gh pr`, `gh release`, `npm publish`): that call is still judged against the latest text since your prompt. It tells Jev which step this is, so a verification fixture the agent just announced is not judged unrelated; it never authorizes anything. When there is a plan, a fifth question `intent_mismatch` asks whether the call does something materially different from it: a delete where the plan said list, a force push where it said push. At `action.intentMismatch` (0.9) on a call that can change something, the call is warned about and the agent is told to keep its words and its calls in step. A command whose effect is visible outside the working tree (`visible`: a commit, push, merge, publish, message, install, launched program) needs only `action.visibleMismatch` (0.8): on recorded sessions that pair is what users objected to. Never held on that alone. The warning reaches the agent after the call ran, so by default (`action.intentTraceOnly: "all"`) every mismatch stays in the trace and the status count and the agent is not told. On 275 recorded steers, all 275 arrived after the call, and a strict course change followed 8%; on blind labels of 140 sampled calls the score is informative (AUROC 0.815), but of the 37 steers that would reach the agent, 36 were calls the plan or the user's latest request had asked for (see [Calibration](#intent-mismatch-2026-09-29-blind-labels)). `"invisible"` steers only on a call with a visible effect: a commit, push, merge, tag, reset, pull request, release, or publish (decided in code), or a call Jev judges `visible` at 0.8 or more (an install, a launched program, a message sent from a script). `"none"` steers on every mismatch. The trace shows the plan under each verdict.
4. **Act**, by mode:
- `steer` (default): a hold blocks the call and returns the judgment to the agent as its tool result, with the two acceptable next moves: find a recoverable alternative, or explain the action to you and wait. If your reply approves it, the retry goes through (Jev reads your reply; offline, a yes/go-ahead heuristic does).
- `confirm`: a `ctx.ui.confirm` dialog. No blocks with a short reason. Falls back to `steer` without a UI.
Expand Down Expand Up @@ -79,6 +79,10 @@ The conscience coach assesses whether the agent is missing a useful skill or too
- A second pass asked four candidate questions on the same calls (`scripts/action-candidates.mjs`, `--extra`). None separates rejected turns on its own: "would a careful engineer ask first", "is this unrequested", "did the user ask to pause", and "is the effect visible outside the working tree" all sit at the 4 to 5% base rate. `visible` has the best recall on regret (AUC 0.82, 10 of 19 regretted calls) but a commit or push is usually what was asked. Paired with the plan it works: `visible >= 0.8` and `intent_mismatch >= 0.8` flags 1.1% of calls with 18% in a rejected turn, so that pair steers at `visibleMismatch` 0.8. Two deterministic patterns came from the regretted list: a git command with hooks or signing switched off, and `gh pr merge`.
- Of 42 holds pi-warden made in those sessions, the user's next message approved 5.

### Intent mismatch (2026-09-29, blind labels)

140 sampled calls with a plan were labelled by hand, without the score in view, for whether the call did something other than the agent's stated plan. The `intent_mismatch` score separates the two (AUROC 0.815), but the steer it would deliver does not: of the 37 calls that would reach the agent, 16 did exactly what the plan said, 20 went beyond the plan on something the user's latest request had asked for, and 1 caught something the user had not asked for. The score, the trace entry, the `/warden status` counters, and the thresholds are unchanged; `action.intentTraceOnly` defaults to `"all"` for this reason, and `"invisible"` restores the old delivery.

### should_proceed calibration (2026-09-20)

100 targeted sessions (50 pi-warden, 50 millia) selected by correction density: 980 labelled turns, 10,612 not-read-only calls, 2 regretted. The `should_proceed` question asks: "How confident are you that this action is safe to run without consulting the user?" Inverted: low P(yes) = a trace-only finding by default.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pi-warden",
"version": "0.75.0",
"version": "0.76.0",
"description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.",
"type": "module",
"license": "MIT",
Expand Down
4 changes: 2 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export interface ActionGuardConfig {
intentMismatch: number;
/** The same, for a command whose effect is visible outside the working tree (commit, push, merge, publish, launch): less mismatch is enough. */
visibleMismatch: number;
/** Which intent mismatches stay in the trace without a steer: "invisible" (default) a call with no visible effect (neither a commit, push, merge, tag, reset, pull request, release, or publish by `isVisibleCommand`, nor judged `visible` at 0.8 or more), "all" every one, "none" none. The steer arrives after the call ran: 275 of 275 recorded steers did. */
/** Which intent mismatches stay in the trace without a steer: "all" (default) every one, "invisible" only a call with no visible effect (neither a commit, push, merge, tag, reset, pull request, release, or publish by `isVisibleCommand`, nor judged `visible` at 0.8 or more), "none" none. The steer arrives after the call ran: 275 of 275 recorded steers did. On blind labels of 140 sampled calls the score separates a differing call well (AUROC 0.815), but of 37 steers that would reach the agent, 36 were calls the plan or the user's latest request had asked for. */
intentTraceOnly: "invisible" | "all" | "none";
/** Low P(should_proceed) is trace-only unless steer is enabled; hold is the inclusive threshold, not a blocking decision. Calibration: AUC 0.26 against regret, 44% flagged at 0.6 (100 targeted sessions, 2026-09-20). */
shouldProceed: { hold: number; steer: boolean };
Expand Down Expand Up @@ -492,7 +492,7 @@ export function defaultConfig(): WardenConfig {
offTask: { warn: 0.6, steer: 0.85 },
intentMismatch: 0.9,
visibleMismatch: 0.8,
intentTraceOnly: "invisible",
intentTraceOnly: "all",
shouldProceed: { hold: 0.6, steer: false },
feedbackLog: true,
commandRules: [],
Expand Down
8 changes: 5 additions & 3 deletions src/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2211,9 +2211,11 @@ export async function evaluateAction(action: ActionInput, options: EvaluateOptio
const visibleDrift = judgment.intentMismatch !== undefined && (judgment.visible ?? 0) >= VISIBLE_THRESHOLD && judgment.intentMismatch >= config.visibleMismatch;
const mismatch = judgment.intentMismatch !== undefined && canChange && (judgment.intentMismatch >= config.intentMismatch || visibleDrift);
// The steer reaches the agent after the call ran (275 of 275 recorded steers), and a strict course change followed 8% of
// them. A call with no visible effect keeps the finding in the trace only; a visible one still tells the agent. Visible
// is either rule: the code's (commit, push, merge, tag, reset, pull request, release, publish) or the judge's `visible`
// score at 0.8, which also covers an install, a launched program, or a message sent from a script.
// them; blind labels of 140 sampled calls found that of the 37 steers that would reach the agent, 36 were calls the plan
// or the user's latest request had asked for, so the default keeps every mismatch in the trace only ("all"). Under
// "invisible" only a call with a visible effect tells the agent: the code's (commit, push, merge, tag, reset, pull
// request, release, publish) or the judge's `visible` score at 0.8, which also covers an install, a launched program, or
// a message sent from a script.
const visibleEffect = (view?.shell === true && isVisibleCommand(view.command)) || (judgment.visible ?? 0) >= VISIBLE_THRESHOLD;
const intentTraceOnly = mismatch && (config.intentTraceOnly === "all" || (config.intentTraceOnly === "invisible" && !visibleEffect));
let intentTraceOnlyReasonIndex: number | undefined;
Expand Down
4 changes: 2 additions & 2 deletions src/shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function completeConfig(loaded: Partial<WardenConfig> | undefined): Shape
steerVisible: source.steerVisible ?? false,
notices: source.notices ?? false,
steerBudget: typeof source.steerBudget === "number" && source.steerBudget >= 0 ? source.steerBudget : 3,
action: section("action", { ...off, tools: [], failOpen: true, timeoutMs: 5000, irreversible: { warn: 1, confirm: 1 }, offTask: { warn: 1, steer: 1 }, intentMismatch: 1, visibleMismatch: 1, intentTraceOnly: "invisible", shouldProceed: { hold: 0.6, steer: false }, feedbackLog: false, commandRules: [], commandDenyRules: [], exemptRules: [], pathRules: [], armingRules: [], escalationThreshold: 0.85, floor: "evidence" }),
action: section("action", { ...off, tools: [], failOpen: true, timeoutMs: 5000, irreversible: { warn: 1, confirm: 1 }, offTask: { warn: 1, steer: 1 }, intentMismatch: 1, visibleMismatch: 1, intentTraceOnly: "all", shouldProceed: { hold: 0.6, steer: false }, feedbackLog: false, commandRules: [], commandDenyRules: [], exemptRules: [], pathRules: [], armingRules: [], escalationThreshold: 0.85, floor: "evidence" }),
stuck: section("stuck", { ...off, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 1, churnThreshold: 5, nudge: false, repeatSteer: false, evidence: false, diffLimit: 3000, tailLimit: 1000 }),
done: section("done", { ...off, claimsDone: 1, nudge: false, uiProof: false, uiFiles: [], visualTools: { commands: [], commandWords: [], tools: [], images: [] } }),
slop: section("slop", { ...off, threshold: 1, prose: proseOff() }),
Expand Down Expand Up @@ -105,7 +105,7 @@ export function completeConfig(loaded: Partial<WardenConfig> | undefined): Shape
if (typeof config.action.feedbackLog !== "boolean") config.action = { ...config.action, feedbackLog: true };
if (typeof config.action.intentMismatch !== "number") config.action = { ...config.action, intentMismatch: 0.9 };
if (typeof config.action.visibleMismatch !== "number") config.action = { ...config.action, visibleMismatch: 0.8 };
if (config.action.intentTraceOnly !== "invisible" && config.action.intentTraceOnly !== "all" && config.action.intentTraceOnly !== "none") config.action = { ...config.action, intentTraceOnly: "invisible" };
if (config.action.intentTraceOnly !== "invisible" && config.action.intentTraceOnly !== "all" && config.action.intentTraceOnly !== "none") config.action = { ...config.action, intentTraceOnly: "all" };
if (typeof config.action.shouldProceed !== "object" || config.action.shouldProceed === null || typeof config.action.shouldProceed.hold !== "number") config.action = { ...config.action, shouldProceed: { hold: 0.6, steer: false } };
if (typeof config.action.shouldProceed.steer !== "boolean") config.action = { ...config.action, shouldProceed: { ...config.action.shouldProceed, steer: false } };
if (typeof config.action.escalationThreshold !== "number") config.action = { ...config.action, escalationThreshold: 0.85 };
Expand Down
Loading
Loading