pi-warden 1.0.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +3 -0
  3. package/dist/adaptive.d.ts +1 -1
  4. package/dist/adaptive.js +2 -2
  5. package/dist/ask-gate.d.ts +5 -3
  6. package/dist/ask-gate.js +19 -4
  7. package/dist/build-info.d.ts +27 -0
  8. package/dist/build-info.js +104 -0
  9. package/dist/cause.d.ts +108 -0
  10. package/dist/cause.js +182 -0
  11. package/dist/config.d.ts +11 -1
  12. package/dist/config.js +20 -4
  13. package/dist/done.d.ts +30 -9
  14. package/dist/done.js +408 -41
  15. package/dist/extension.d.ts +2 -2
  16. package/dist/extension.js +240 -43
  17. package/dist/guard.d.ts +9 -0
  18. package/dist/guard.js +142 -57
  19. package/dist/index.d.ts +6 -4
  20. package/dist/index.js +4 -3
  21. package/dist/learning.d.ts +12 -0
  22. package/dist/learning.js +35 -1
  23. package/dist/script-bodies.d.ts +16 -0
  24. package/dist/script-bodies.js +383 -0
  25. package/dist/shape.d.ts +1 -1
  26. package/dist/shape.js +5 -2
  27. package/dist/trace.d.ts +3 -1
  28. package/dist/trace.js +15 -0
  29. package/dist/turn-rules.d.ts +1 -1
  30. package/dist/turn-rules.js +102 -25
  31. package/dist/widget.d.ts +5 -0
  32. package/dist/widget.js +16 -0
  33. package/package.json +4 -4
  34. package/dist/conscience-config.d.ts +0 -21
  35. package/dist/conscience-config.js +0 -82
  36. package/dist/conscience-loader.d.ts +0 -26
  37. package/dist/conscience-loader.js +0 -82
  38. package/dist/conscience-policy.d.ts +0 -24
  39. package/dist/conscience-policy.js +0 -28
  40. package/dist/conscience-privacy.d.ts +0 -4
  41. package/dist/conscience-privacy.js +0 -11
  42. package/dist/extension.js.bak +0 -3060
  43. package/dist/jev.d.ts +0 -23
  44. package/dist/jev.js +0 -19
  45. package/dist/laya-download.d.ts +0 -40
  46. package/dist/laya-download.js +0 -288
  47. package/dist/laya-judge.d.ts +0 -62
  48. package/dist/laya-judge.js +0 -495
  49. package/dist/tool-recommendation.d.ts +0 -15
  50. package/dist/tool-recommendation.js +0 -31
package/CHANGELOG.md CHANGED
@@ -6,6 +6,96 @@ How to keep this current: add the entry in the same pull request as the change,
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 1.4.0
10
+
11
+ ### Added
12
+
13
+ - When the session runs in a [herdr](https://herdr.dev) pane, a warden confirm dialog now reports through herdr's `herdr:blocked` channel while it waits, so the pane's sidebar shows blocked instead of working. Interactive sessions only; no emission on hold-steers or headless runs. ([Guards](docs/guards.md#herdr-pane-state))
14
+
15
+ ## 1.3.1
16
+
17
+ ### Fixed
18
+
19
+ - The commands that send their own prompt to the agent (`/warden init`, `/warden rules tune`, `/warden audit`, `/warden index`) no longer swallow it. Each set its busy flag before `sendUserMessage`, and the busy-flag guard on the `input` event saw the command's own prompt (which arrives with `source: "extension"`) and answered "handled", so no run ever started and the command reported the file (or report, or index) was never created — every time, deterministically. The guard now waits only for operator input (`interactive`/`rpc`); warden's own prompt passes and, like any user prompt, invalidates in-flight conscience assessments.
20
+ - Those commands also no longer report before the run they injected ends. Pi's extension-facing `sendUserMessage` is fire-and-forget, so the `await ctx.waitForIdle()` that followed it could return before the injected run even flipped the session busy — the command reported while the agent was still working, or its status message was steered into the next run instead of recorded. Each command now waits in two phases: first for `before_agent_start` carrying the prompt it sent (30 seconds; on a miss — a preflight failure or another extension handling the input — it clears its flag and reports right away instead of blocking operator input), then for `agent_end` of that run with no cap (pi emits it on an abort too, so Esc still ends the wait), and finally for the host to go idle so the status message is recorded rather than steered.
21
+
22
+ ### Tests
23
+
24
+ - A real-session e2e runs `/warden init` the way Pi does (the shipped entry in a real agent session with a scripted model): the injected prompt reaches the model, the agent's write call lands, and "pi-warden.md created" is recorded only after the run ended. Two unit tests pin the guard (extension input passes while a flag is set, operator input waits) and the wait (the command does not report before the run starts or ends). The session-test `write` stub now really writes, relative to the session directory, so the e2e can prove the file on disk.
25
+ ## 1.3.0
26
+
27
+ ### Added
28
+
29
+ - The cause-check guard (on by default, `cause.enabled`): when the final reply of a run states a likely cause, or asks a person to check something the agent could inspect itself, and no tool result in that run checked it, pi-warden sends the reply back with one follow-up turn: check the cause with its own tools (a query, a log, a file, or a command) and give the evidence, or say plainly that the cause is unverified and why it cannot check it. It is the sibling of the done-check. An offline pre-filter (`causePreFilter`) fires on causal and hand-off wording, so a reply about results or next steps spends no request; the guard asks Jev whether the reply states a cause or hands off a check, whether the run checked it, and, when the project has an earlier unchecked cause inside the window, which earlier cause the reply repeats. The request carries the task, the final message, the last 40 of the run's tool calls, and up to five earlier redacted cause sentences with their keys and dates. Each unchecked cause is stored per project for `cause.windowDays` (default 7) as the redacted sentence that states it; a later reply whose cause Jev matches to an earlier one gets a stronger steer that names the earlier date, and a reply that hands a check to a person gets a steer that says so. When the done-check steers the same reply, the cause-check records its decision in the trace and sends no steer, so one reply gets at most one end-of-run steer. The repeat history lives in a `causes` table in the same owner-only database as the hold log, and every request, stored summary, and trace line passes through `redact()`.
30
+
31
+ ### Docs
32
+
33
+ - `docs/guards.md` describes the new cause-check; `docs/configuration.md` lists its keys, widget template, and tokens; `docs/data-handling.md` and the consent notice name the cause-check request and its stored repeat history; `README.md` lists the guard next to the done-check.
34
+ - `eval/reports/2026-10-07-cause-check/` measures the offline pre-filter on recorded sessions: the totals, the flagged count, and how many of a 20-reply sample were real unchecked causes.
35
+
36
+ ## 1.2.0
37
+
38
+ ### Added
39
+
40
+ - The action guard reads a script a command runs from disk and applies the same pattern floor and ask gate to its body: `bash FILE`, `./FILE`, `source FILE` and `. FILE`, `npm`/`pnpm`/`yarn`/`bun` scripts from `package.json` (with `preNAME` and `postNAME`), `make TARGET` or the default target, and `node`/`python`/`python3`/`ruby`/`perl`/`deno run FILE`. A body is read only when it resolves, after symlinks, inside the project root or a temp root, as a regular file of at most 64 KB, and one level deep. A body hit keeps the id and severity it has when typed directly and its label names the source (`(via cleanup.sh)`, `(via npm run clean)`); `action.exemptRules` and user `commandRules` apply to it; a body hit makes the ask gate ask with `runs a script with <shape>`; and the body lines that hit ride the request to Jev as `script_lines`, redacted and bounded to five lines and 600 characters.
41
+
42
+ - `/warden status` shows the build that runs: the pi-warden version, the checkout commit when the package is a git tree, the `dist/extension.js` build time, and the loaded pi-typesafe version. Session start warns once when a `src/` file is newer than the build (`pi-warden runs an old build: run npm run build`) and once when the loaded pi-typesafe version is outside the range in `package.json`.
43
+
44
+ ### Changed
45
+
46
+ - The done-check nudge names one check command instead of "whatever exists": the last check that passed earlier in this session in this project, else the `check` then `test` script of the project's `package.json` run with the package manager its lock file names (`pnpm-lock.yaml`, `yarn.lock`, `bun.lockb` or `bun.lock`, else npm), else the same `Makefile` targets; with none of those it keeps the generic ask. The UI nudge names the last local URL a tool result printed this session (`http://localhost:PORT`, `127.0.0.1`, `[::1]`) when there is one.
47
+ - Done-check UI proof now counts only what shows the page to the agent: a successful tool result that contains an image, a page text snapshot (`agent-browser snapshot`, `agent-browser get text`, the MCP `take_snapshot` tool), a browser or device test run (`playwright test`, `flutter test` with `integration_test` or golden tests), or a `read` of an image file. A screenshot command that only writes a file (`agent-browser screenshot PATH`, `idb screenshot`, `xcrun simctl io … screenshot`, `chrome --headless --screenshot`), `agent-browser` `open`/`close`/`click`/`fill`/`set`/`wait`/`eval`, and MCP navigation tools no longer count; a saved screenshot counts once something reads it. The `done.visualTools` defaults match, and every config key still works.
48
+ - pi-typesafe `^0.9.1` (was `^0.9.0`).
49
+ - `npm run check` builds before it tests, so the offline suite exercises a fresh `dist/` (the entry Pi ships) instead of a stale one.
50
+
51
+ ### Fixed
52
+
53
+ - The done-check counts a shell command as a check only when a check runner starts one of its shell segments, after `cd DIR`, environment assignments, and `timeout`/`time`/`env`/`npx`/`pnpm exec`/`bunx`/`yarn`/`uv run`/`poetry run`/`python -m` wrappers (a wrapper is stripped only when what follows does not start a runner, so `yarn test` and `yarn run lint` are checks). A runner name in an argument (`grep -n jest package.json`, `which eslint`, `npm view vitest version`) no longer counts as a check.
54
+ - A check run counts as proof only when it really ran and its output agrees: a failure summary is a failed check even with exit code 0, and a visible failure stays a failed check also with a passing summary; a summary that reports zero tests (`Tests: 0 total`, `[no tests to run]`, and similar) is never a check, unless a line of the output shows tests did run (cargo doc-tests after real results, a package a `go test` filter skipped); and when the shell can hide the exit code of any runner segment (a pipe right after its segment without `set -o pipefail`, or a `||`, `;`, or newline with more commands anywhere after it) the exit code is no proof — with no runner summary the run is not a check at all. Each run that names a runner but does not count gets one trace-only detail with the reason and no command; the reason names the word after the hiding separator only when it is a bare command name.
55
+ - The done-check reads more runner summaries as proof: vitest, pytest without the `===` frame (`-q`), mypy, ruff (check, format, and `--fix`), black, prettier, biome, tsc pretty output, eslint, bun, mocha, playwright, flutter, deno, and cargo build, check and clippy; and the generic wrapper failure lines (`make: *** … Error N`, `npm error Lifecycle script … failed`, `npm ERR! code ELIFECYCLE`, pnpm and yarn `Command failed with exit code N`). The generic lines decide only when a runner starts the command, so a failed `npm install` is not a failed check. A failure marker anywhere in the tail wins over a passing one (black and ruff format `N files would be reformatted` beside `N files would be left unchanged.`, any go `FAIL` line, any cargo `test result: FAILED.` after an `ok`, pytest `2 passed, 1 error`, an indented or `pkg:test:`-prefixed vitest summary), a generic failure line overrides a passing summary in the same output, and the `Finished` line proves a `cargo build`, `cargo check` or `cargo clippy` segment only.
56
+ - The done-check recognises more runner forms and reports: a runner started by a path (`.venv/bin/pytest`, `node_modules/.bin/tsc`), `python3.12 -m` and `.venv/bin/python -m` followed by a runner or `unittest`, wrappers with options (`timeout -s KILL 60`, `uv run --with httpx`, `npx -y`), package-manager options (`pnpm -C web test`, `npm --prefix web test`, `yarn workspace api test`), `make -C web test`, and `bash -c "npm test"`; the last exit code the agent printed with `echo`/`printf` (`$?`, or `${PIPESTATUS[0]}` after a pipe); a silent runner (`tsc`, `eslint`, `flake8`, `go vet`, `go build`) named in its own segment whose output is empty behind a pipe to `tail`, `head`, `tee`, or `cat`; and a hidden runner is covered only by its own printed exit code, its own silent pass, or a summary verdict. A command is first stripped of data text and a backslash-newline continuation is joined, so a runner word in a commit message or heredoc body is not a check. A bare `npm ci` or `bun ci` installs and does not count, a runner sent to the background with `&` does not count, and a visible failure stays a failed check when another runner's exit code is hidden.
57
+ - With pi-typesafe 0.9.1 the usage ledger no longer loses counts when several Pi sessions run at once, so the reported spend and the daily caps count every session.
58
+
59
+ ### Tests
60
+
61
+ - A new offline session test loads pi-warden the way Pi does (`package.json` → `pi.extensions` → `extensions/index.js` → `dist/extension.js`) into a real agent session with a scripted model, and asserts on the session's messages: a held destructive bash call, a done-check nudge after an unverified completion, and the repeat note after the same failing read twice. It fails with `run npm run build first` when the shipped entry is missing.
62
+
63
+ ### Docs
64
+
65
+ - `docs/commands.md` describes the `/warden status` build line; `docs/ci-cd.md` states the new `npm run check` order (typecheck, build, offline tests).
66
+
67
+
68
+
69
+
70
+ ## 1.1.2
71
+
72
+ ### Fixed
73
+
74
+ - The build removes `dist/` outputs whose `src/` source is gone. `tsc` never deletes an output whose source was removed, so stale files from other branches shipped with `1.1.1`: 17 files (`conscience-config`, `conscience-loader`, `conscience-policy`, `conscience-privacy`, `jev`, `laya-download`, `laya-judge`, `tool-recommendation`, each as `.js` and `.d.ts`, plus `extension.js.bak`) that nothing imports. Users need to do nothing.
75
+
76
+ ## 1.1.1
77
+
78
+ ### Fixed
79
+
80
+ - `scripts/live-smoke.mjs` and `scripts/security-cases.mjs` no longer crash when a verdict has no off-task answer, which is now the usual case: the acting request does not ask `off_task`, only the trace sample does. The missing value prints as `-`.
81
+ - The three off-task cases in `scripts/security-cases.mjs` (side comment, take over previous work, new instruction overrides history) pass `traceSample: 1`, so every call asks the off-task question and the cases test its answer again.
82
+
83
+ ## 1.1.0
84
+
85
+ ### Added
86
+
87
+ - `"typesafeBackend": "liquid"` sends judgments to Liquid AI (api.liquid.ai, key in `LIQUID_API_KEY`, default model `d1:free`) through pi-typesafe 0.9.0. `d1:free` is free but slow (median about 14 seconds a request against the 5-second default `timeoutMs`), and the paid `d1` counts the state once per question, so a 7-question action request costs several times what it costs on Jev; see [docs/configuration.md](docs/configuration.md#liquid-ai-backend).
88
+
89
+ ### Changed
90
+
91
+ - pi-typesafe `^0.9.0` (was `^0.8.0`).
92
+
93
+ ## 1.0.1
94
+
95
+ ### Fixed
96
+
97
+ - The run-end rules check no longer judges files that a `git pull` brought in when the pulled commit was made during the run, such as a squash commit from a pull request merged on the hosting service: a commit counts as brought by git when this worktree's HEAD reflog shows that the checkout did not create it, whatever its date. The commit-date rule stays as the fallback when the reflog is off.
98
+
9
99
  ## 1.0.0
10
100
 
11
101
  The first stable release. From 1.0, pi-warden follows semver for the surface the README's Versioning section lists. Coming from 0.74.1, the last release on npm: [docs/upgrading.md](docs/upgrading.md) lists the removed settings, the new defaults, and what project files may still change. The changes since 0.74.1 are in the sections 0.75.0 to 0.90.0 below, and the README's "What it costs" table gives the measured effect of each 1.0 change.
package/README.md CHANGED
@@ -39,6 +39,7 @@ Most guardrails stop and ask you. pi-warden tells **the agent** what it got wron
39
39
  | Your agent… | pi-warden… |
40
40
  | --- | --- |
41
41
  | says "done" with no test, build, or lint behind it | sends it back to prove it |
42
+ | names a likely cause ("probably", "ask the team") with nothing checked that run | sends it back to check it with its own tools, or state plainly that it is unverified |
42
43
  | breaks a rule in your `pi-warden.md` or `AGENTS.md` | quotes the exact rule it broke |
43
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 |
44
45
  | retries the same failing fix for the third time | asks for a new hypothesis |
@@ -73,6 +74,7 @@ Every guard and feature, its default, and its status. Defaults are `defaultConfi
73
74
  | [Security](docs/guards.md#security) | Flags risky written code and prompt injection in tool output; masks credentials, but leaves code values such as a call or an index alone | On | Stable |
74
75
  | [Stuck](docs/guards.md#stuck) | Asks for a new hypothesis when the agent repeats a failing approach | On | Stable |
75
76
  | [Done-check](docs/guards.md#done-check) | Sends an unverified "done" back for a check; after a UI change, a visual check | On | Stable |
77
+ | [Cause-check](docs/guards.md#cause-check) | Sends a final reply back to check a stated cause, or to say plainly that it is unverified; the same unchecked cause on a later day gets a stronger steer | On | Stable |
76
78
  | [Runaway](docs/guards.md#runaway) | Stops a reply that repeats itself, offline | On | Stable |
77
79
  | [Context saver](docs/guards.md#context-saver) | Replaces large or duplicate tool output with an excerpt and a saved full copy | On | Stable |
78
80
  | ↳ Repeated user messages | Cuts repeated runs in your own messages too | Off. `context.dedupeMessages` | Stable |
@@ -88,6 +90,7 @@ Every guard and feature, its default, and its status. Defaults are `defaultConfi
88
90
  | [Adaptive steers](docs/guards.md#adaptive-steers-per-model) | Makes a steer kind trace-only for a model that rarely follows or often disputes it | On | Stable |
89
91
  | Steers and notices in the transcript | Shows steers and per-call notices in the chat, not only in the trace | Off. `steerVisible`, `notices` | Stable |
90
92
  | [Desktop notifications](docs/guards.md#desktop-notifications) | Holds, confirm dialogs, and runaway stops reach the desktop or your relay | Off. `notify.enabled` | Stable |
93
+ | [Herdr pane state](docs/guards.md#herdr-pane-state) | In a herdr pane, a waiting confirm dialog shows as blocked in the herdr sidebar instead of working | On, no switch | Stable |
91
94
  | [Conscience](docs/guards.md#conscience) | Recommends a skill or tool the agent is missing; ranks candidates on your machine first and sends one request, or none | Off. `conscience.enabled` | Beta |
92
95
  | Learning from holds | Keeps each judged call in a local database with its Pi session id and, for a hold, what happened next (`approved`, `declined`, `replanned`, `abandoned`); a call that was not held keeps only the judge data, and is pruned after 90 days (`learning.allowedRetentionDays`; holds after 365). `/warden recommend` suggests threshold changes from it. Nothing is sent | On | Stable |
93
96
  | Config surface | A project file can only make the action guard and security stricter, a value that cannot be applied as written is shown once as a warning, and `deny` and `block` mean the same in every rule kind | On, no switch | Stable |
@@ -6,7 +6,7 @@ import type { SteersConfig } from "./config.js";
6
6
  * models dispute noise ("as I said …"). Holds and per-call judgments never change; only whether a note is sent.
7
7
  */
8
8
  /** One kind of note to the agent. Finer than the steer guard: the `action` guard sends several unrelated notes. */
9
- export type SteerKind = "intent-mismatch" | "off-task" | "should-proceed" | "warn-headless" | "large-output" | "slop" | "rules" | "sensitive-path" | "security-write" | "stuck" | "repeat" | "done" | "prose" | "conscience" | "loops" | "runaway" | "subagent" | "hold" | "confirm" | "deny" | "masking" | "credential-notice";
9
+ export type SteerKind = "intent-mismatch" | "off-task" | "should-proceed" | "warn-headless" | "large-output" | "slop" | "rules" | "sensitive-path" | "security-write" | "stuck" | "repeat" | "done" | "cause" | "prose" | "conscience" | "loops" | "runaway" | "subagent" | "hold" | "confirm" | "deny" | "masking" | "credential-notice";
10
10
  export declare const STEER_KINDS: readonly SteerKind[];
11
11
  /**
12
12
  * Never trace-only, whatever a model does with them: holds, confirm and deny verdicts, security masking, the credential
package/dist/adaptive.js CHANGED
@@ -5,7 +5,7 @@ import { userConfigPath } from "./config.js";
5
5
  import { defaultHostDirs } from "./host-dirs.js";
6
6
  export const STEER_KINDS = [
7
7
  "intent-mismatch", "off-task", "should-proceed", "warn-headless", "large-output", "slop", "rules", "sensitive-path",
8
- "security-write", "stuck", "repeat", "done", "prose", "conscience", "loops", "runaway", "subagent",
8
+ "security-write", "stuck", "repeat", "done", "cause", "prose", "conscience", "loops", "runaway", "subagent",
9
9
  "hold", "confirm", "deny", "masking", "credential-notice",
10
10
  ];
11
11
  /**
@@ -26,7 +26,7 @@ const fresh = () => ({ ...zero(), muted: false, asked: 0, probe: zero() });
26
26
  /** Kinds whose "followed" can be read from the next calls or text; the rest are judged by disputes alone. */
27
27
  const FOLLOW_MEASURED = new Set([
28
28
  "intent-mismatch", "off-task", "should-proceed", "warn-headless", "large-output", "slop", "rules", "security-write",
29
- "stuck", "repeat", "done", "conscience", "loops",
29
+ "stuck", "repeat", "done", "cause", "conscience", "loops",
30
30
  ]);
31
31
  /** True when the counts put the pair under the follow floor or over the dispute ceiling. */
32
32
  export function failing(kind, counts, config) {
@@ -1,3 +1,4 @@
1
+ import { type ScriptSource } from "./script-bodies.js";
1
2
  export interface AskDecision {
2
3
  /** True when the call goes to Jev; false when the offline pass decides it alone. */
3
4
  ask: boolean;
@@ -8,6 +9,7 @@ export interface AskDecision {
8
9
  * Whether this call needs a Jev request, and the short reason when it does not.
9
10
  * `text` is the command with data text blanked (`stripDataText`), so a commit message that mentions a push is not a push.
10
11
  */
11
- export declare function actionAskGate(tool: string, input: Record<string, unknown>, text: string | undefined, cwd?: string): AskDecision;
12
- /** The decision for one shell command. Exported for the replay and for tests. */
13
- export declare function gateCommand(scanned: string, raw?: string): AskDecision;
12
+ export declare function actionAskGate(tool: string, input: Record<string, unknown>, text: string | undefined, cwd?: string, scripts?: readonly ScriptSource[]): AskDecision;
13
+ /** The decision for one shell command. Exported for the replay and for tests. A command that runs a body from disk
14
+ * also asks when that body holds a shape the gate would ask a typed command for. */
15
+ export declare function gateCommand(scanned: string, raw?: string, cwd?: string, scripts?: readonly ScriptSource[]): AskDecision;
package/dist/ask-gate.js CHANGED
@@ -15,6 +15,7 @@
15
15
  * whether a judge is worth a request.
16
16
  */
17
17
  import { commandOf } from "./tools.js";
18
+ import { scriptSources } from "./script-bodies.js";
18
19
  /** Tools that always reach Jev: what a write or an edit leaves on disk is what every question is about. */
19
20
  const ALWAYS_ASK = new Set(["write", "edit"]);
20
21
  /** Heads that change version control or remote state, publish, deploy, or drive infrastructure. */
@@ -242,7 +243,7 @@ function ghAsks(sub, segment) {
242
243
  * Whether this call needs a Jev request, and the short reason when it does not.
243
244
  * `text` is the command with data text blanked (`stripDataText`), so a commit message that mentions a push is not a push.
244
245
  */
245
- export function actionAskGate(tool, input, text, cwd) {
246
+ export function actionAskGate(tool, input, text, cwd, scripts) {
246
247
  if (ALWAYS_ASK.has(tool))
247
248
  return { ask: true, why: tool };
248
249
  const view = commandOf(tool, input);
@@ -255,10 +256,24 @@ export function actionAskGate(tool, input, text, cwd) {
255
256
  const httpTool = /^(?:ctx_execute|ctx_batch_execute|ctx_execute_file)$/.test(tool);
256
257
  if (!httpTool && !view?.shell)
257
258
  return { ask: true, why: `${tool} code` };
258
- return gateCommand(scanned, command);
259
+ return gateCommand(scanned, command, cwd, scripts);
259
260
  }
260
- /** The decision for one shell command. Exported for the replay and for tests. */
261
- export function gateCommand(scanned, raw = scanned) {
261
+ /** The decision for one shell command. Exported for the replay and for tests. A command that runs a body from disk
262
+ * also asks when that body holds a shape the gate would ask a typed command for. */
263
+ export function gateCommand(scanned, raw = scanned, cwd, scripts) {
264
+ const direct = gateText(scanned, raw);
265
+ if (direct.ask)
266
+ return direct;
267
+ const sources = scripts ?? (cwd === undefined ? [] : scriptSources(scanned, cwd));
268
+ for (const source of sources) {
269
+ const decision = gateText(source.body, source.body);
270
+ if (decision.ask)
271
+ return { ask: true, why: `runs a script with ${decision.why}` };
272
+ }
273
+ return direct;
274
+ }
275
+ /** The gate for one text, before any body it runs is read. */
276
+ function gateText(scanned, raw = scanned) {
262
277
  // A script given on the command line or on stdin is scanned as a command: `python3 -c "shutil.rmtree(...)"` asks.
263
278
  const sink = SCRIPT_SINK.test(raw);
264
279
  const texts = sink && scanned !== raw ? [scanned, raw] : [scanned];
@@ -0,0 +1,27 @@
1
+ /**
2
+ * What the running pi-warden was built from, read once when a session starts and shown by `/warden status`.
3
+ *
4
+ * Pi loads the extension from `extensions/index.js`, which re-exports `dist/extension.js`, so the module URL is the
5
+ * built file. The package root resolves from there; `dist/extension.js` is the shipped entry and `src/` is the source a
6
+ * checkout edits. A published package has no `src/`, so the stale check only speaks for a git checkout.
7
+ */
8
+ export interface WardenBuild {
9
+ version: string;
10
+ /** Short HEAD commit when the package root is a git checkout; undefined otherwise. */
11
+ commit: string | undefined;
12
+ /** ISO build time of `dist/extension.js`; undefined when the shipped entry is missing. */
13
+ builtAt: string | undefined;
14
+ /** Version of the pi-typesafe that this process loaded. */
15
+ typesafeVersion: string | undefined;
16
+ /** The caret range pi-warden declares for pi-typesafe. */
17
+ typesafeRange: string | undefined;
18
+ /** True when a file under `src/` is newer than `dist/extension.js`. */
19
+ stale: boolean;
20
+ }
21
+ /**
22
+ * Whether `version` sits inside the caret range `range`. Only `^x.y.z` is read, the form pi-warden declares; an
23
+ * unreadable range or version returns true so a version string this code cannot parse never pages the user.
24
+ */
25
+ export declare function satisfiesCaret(version: string, range: string): boolean;
26
+ /** Read the build provenance of this checkout or install. Never throws: a missing or unreadable file reads as absent. */
27
+ export declare function readWardenBuild(): WardenBuild;
@@ -0,0 +1,104 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const packageRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
6
+ function readJson(path) {
7
+ try {
8
+ return JSON.parse(readFileSync(path, "utf8"));
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ /** The short commit of the checkout that holds `root`, or undefined when `root` is not a git checkout. */
15
+ function gitCommit(root) {
16
+ if (!existsSync(join(root, ".git")))
17
+ return undefined;
18
+ try {
19
+ const output = execFileSync("git", ["-C", root, "rev-parse", "--short", "HEAD"], { encoding: "utf8", timeout: 2000 });
20
+ const commit = output.trim();
21
+ return commit || undefined;
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ /** The newest mtime under `src/`, or 0 when the package has no source directory (a published install). */
28
+ function newestSourceMtime(root) {
29
+ const source = join(root, "src");
30
+ if (!existsSync(source))
31
+ return 0;
32
+ let newest = 0;
33
+ const walk = (dir) => {
34
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
35
+ const path = join(dir, entry.name);
36
+ if (entry.isDirectory())
37
+ walk(path);
38
+ else if (entry.isFile()) {
39
+ // A file removed between the readdir and the stat cannot be the one that makes the build stale.
40
+ const info = statSync(path, { throwIfNoEntry: false });
41
+ if (info)
42
+ newest = Math.max(newest, info.mtimeMs);
43
+ }
44
+ }
45
+ };
46
+ walk(source);
47
+ return newest;
48
+ }
49
+ /** The version of the pi-typesafe this process resolved, from its package manifest. */
50
+ function loadedTypesafeVersion() {
51
+ try {
52
+ // pi-typesafe is ESM-only (its exports define `import`, not `require`), so resolve it the ESM way and walk up to
53
+ // the manifest; `require.resolve` fails with ERR_PACKAGE_PATH_NOT_EXPORTED on this package.
54
+ let dir = dirname(fileURLToPath(import.meta.resolve("pi-typesafe")));
55
+ while (!existsSync(join(dir, "package.json")) && dir !== dirname(dir))
56
+ dir = dirname(dir);
57
+ const manifest = readJson(join(dir, "package.json"));
58
+ return typeof manifest?.version === "string" ? manifest.version : undefined;
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ /**
65
+ * Whether `version` sits inside the caret range `range`. Only `^x.y.z` is read, the form pi-warden declares; an
66
+ * unreadable range or version returns true so a version string this code cannot parse never pages the user.
67
+ */
68
+ export function satisfiesCaret(version, range) {
69
+ const wanted = /^\s*\^\s*(\d+)\.(\d+)\.(\d+)\s*$/.exec(range);
70
+ const got = /(\d+)\.(\d+)\.(\d+)/.exec(version);
71
+ if (!wanted || !got)
72
+ return true;
73
+ const [wantedMajor, wantedMinor, wantedPatch] = [Number(wanted[1]), Number(wanted[2]), Number(wanted[3])];
74
+ const [major, minor, patch] = [Number(got[1]), Number(got[2]), Number(got[3])];
75
+ if (major !== wantedMajor)
76
+ return false;
77
+ // A caret on 0.x is an exact minor line in npm (^0.9.1 := >=0.9.1 <0.10.0); above 0 it holds the major.
78
+ if (wantedMajor > 0)
79
+ return minor > wantedMinor || (minor === wantedMinor && patch >= wantedPatch);
80
+ return minor === wantedMinor && patch >= wantedPatch;
81
+ }
82
+ /** Read the build provenance of this checkout or install. Never throws: a missing or unreadable file reads as absent. */
83
+ export function readWardenBuild() {
84
+ const manifest = readJson(join(packageRoot, "package.json")) ?? {};
85
+ const dependencies = (manifest.dependencies ?? {});
86
+ const dist = join(packageRoot, "dist", "extension.js");
87
+ let builtAt;
88
+ let builtMtime = 0;
89
+ try {
90
+ builtMtime = statSync(dist).mtimeMs;
91
+ builtAt = new Date(builtMtime).toISOString();
92
+ }
93
+ catch {
94
+ builtAt = undefined;
95
+ }
96
+ return {
97
+ version: typeof manifest.version === "string" ? manifest.version : "unknown",
98
+ commit: gitCommit(packageRoot),
99
+ builtAt,
100
+ typesafeVersion: loadedTypesafeVersion(),
101
+ typesafeRange: typeof dependencies["pi-typesafe"] === "string" ? dependencies["pi-typesafe"] : undefined,
102
+ stale: builtAt !== undefined && newestSourceMtime(packageRoot) > builtMtime,
103
+ };
104
+ }
@@ -0,0 +1,108 @@
1
+ import type { IntegrationErrorCode, Judge } from "pi-typesafe";
2
+ import type { CauseGuardConfig } from "./config.js";
3
+ /** One tool call of the run, as the judge reads it: a redacted call and a short redacted output sample. */
4
+ export interface CauseActivity {
5
+ calls: Array<{
6
+ call: string;
7
+ output: string;
8
+ }>;
9
+ }
10
+ export declare function emptyCauseActivity(): CauseActivity;
11
+ /** Record one finished tool call: its redacted command or path, and a short redacted output sample. */
12
+ export declare function recordCauseActivity(activity: CauseActivity, tool: string, input: Record<string, unknown>, output?: string): void;
13
+ /**
14
+ * The offline pre-filter: true when the final reply carries causal or hand-off wording, so a reply about results or
15
+ * next steps sends no request. Deliberately broad: a Jev answer still decides, and a missed ask loses a real catch.
16
+ */
17
+ export declare function causePreFilter(text: string): boolean;
18
+ export declare const causeQuestions: {
19
+ states_cause: import("pi-typesafe").NoulQuestion;
20
+ hands_off: import("pi-typesafe").NoulQuestion;
21
+ checked: import("pi-typesafe").NoulQuestion;
22
+ };
23
+ /**
24
+ * One earlier unchecked cause as the judge reads it: a short key for the answer, its date, and the redacted sentence
25
+ * the agent gave. The key and the date alone cannot say whether two causes are the same.
26
+ */
27
+ export interface EarlierCause {
28
+ key: string;
29
+ date: string;
30
+ summary: string;
31
+ at: number;
32
+ }
33
+ export interface CauseJudgment {
34
+ statesCause: number;
35
+ handsOff: number;
36
+ checked: number;
37
+ model: string;
38
+ elapsedMs: number;
39
+ }
40
+ /** One remembered unchecked cause. */
41
+ export interface CauseStoreRecord {
42
+ summary: string;
43
+ at: number;
44
+ }
45
+ /** Project-level memory of unchecked causes. */
46
+ export interface CauseStore {
47
+ /** The project's unchecked causes at or after `since`, newest first, at most `limit`. */
48
+ recall(projectRoot: string, since: number, limit: number): Promise<CauseStoreRecord[]>;
49
+ /** Remember one unchecked cause. */
50
+ record(projectRoot: string, record: {
51
+ summary: string;
52
+ at: number;
53
+ }): Promise<void>;
54
+ }
55
+ export interface CauseVerdict {
56
+ /** The reply states or hands off a cause and no tool result in the run checked it. */
57
+ unchecked: boolean;
58
+ /** The reply asks a person to check something the agent could check itself. */
59
+ handsOff: boolean;
60
+ /** Short redacted summary of the cause sentence, stored for repeats. */
61
+ summary: string;
62
+ /** An earlier cause the judge matched inside the window. */
63
+ previous?: CauseStoreRecord;
64
+ reasons: string[];
65
+ judgment?: CauseJudgment;
66
+ error?: string;
67
+ errorCode?: IntegrationErrorCode;
68
+ }
69
+ export interface CauseOptions {
70
+ config: CauseGuardConfig;
71
+ judge: Judge;
72
+ timeoutMs: number;
73
+ signal?: AbortSignal | undefined;
74
+ store?: CauseStore | undefined;
75
+ projectRoot?: string | undefined;
76
+ now?: number | undefined;
77
+ }
78
+ export declare function buildCauseRequest(task: string | undefined, finalMessage: string, activity: CauseActivity, earlier?: readonly EarlierCause[]): {
79
+ state: {
80
+ earlier_causes?: {
81
+ key: string;
82
+ date: string;
83
+ cause: string;
84
+ }[];
85
+ task: string;
86
+ final_message: string;
87
+ run: {
88
+ tool_calls: {
89
+ call: string;
90
+ output: string;
91
+ }[];
92
+ };
93
+ };
94
+ questions: {
95
+ states_cause: import("pi-typesafe").NoulQuestion;
96
+ hands_off: import("pi-typesafe").NoulQuestion;
97
+ checked: import("pi-typesafe").NoulQuestion;
98
+ } | {
99
+ same_cause: import("pi-typesafe").ChoiceQuestion<Record<string, string>>;
100
+ states_cause: import("pi-typesafe").NoulQuestion;
101
+ hands_off: import("pi-typesafe").NoulQuestion;
102
+ checked: import("pi-typesafe").NoulQuestion;
103
+ };
104
+ };
105
+ export declare function evaluateCause(task: string | undefined, finalMessage: string, activity: CauseActivity, options: CauseOptions): Promise<CauseVerdict>;
106
+ /** The follow-up for the agent: check the cause with its own tools, or say plainly that it is unverified. */
107
+ export declare function causeNudge(verdict: CauseVerdict): string;
108
+ export declare function formatCause(verdict: CauseVerdict, template?: string): string;