pi-warden 0.71.0 → 0.74.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 (51) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/adaptive.d.ts +3 -2
  3. package/dist/adaptive.js +4 -3
  4. package/dist/backend.d.ts +33 -14
  5. package/dist/backend.js +27 -14
  6. package/dist/config.d.ts +13 -8
  7. package/dist/config.js +20 -20
  8. package/dist/conscience.d.ts +1 -1
  9. package/dist/extension.d.ts +14 -3
  10. package/dist/extension.js +410 -64
  11. package/dist/guard.d.ts +2 -1
  12. package/dist/guard.js +4 -4
  13. package/dist/holds.d.ts +1 -1
  14. package/dist/holds.js +3 -2
  15. package/dist/host-compat.d.ts +2 -0
  16. package/dist/host-compat.js +132 -0
  17. package/dist/host-dirs.d.ts +10 -0
  18. package/dist/host-dirs.js +21 -0
  19. package/dist/index-cmd.d.ts +6 -5
  20. package/dist/index-cmd.js +16 -24
  21. package/dist/index.d.ts +12 -4
  22. package/dist/index.js +6 -2
  23. package/dist/init.js +31 -3
  24. package/dist/learning.d.ts +12 -11
  25. package/dist/learning.js +60 -38
  26. package/dist/loops.d.ts +2 -1
  27. package/dist/loops.js +3 -2
  28. package/dist/panel.d.ts +4 -1
  29. package/dist/panel.js +11 -6
  30. package/dist/prefs.d.ts +1 -1
  31. package/dist/prefs.js +3 -2
  32. package/dist/rules-audit.d.ts +208 -0
  33. package/dist/rules-audit.js +417 -0
  34. package/dist/rules-calibrate.d.ts +196 -0
  35. package/dist/rules-calibrate.js +474 -0
  36. package/dist/rules-lint.d.ts +2 -0
  37. package/dist/rules-lint.js +7 -2
  38. package/dist/rules-log.d.ts +6 -1
  39. package/dist/rules-log.js +9 -3
  40. package/dist/rules-report.d.ts +3 -0
  41. package/dist/rules-report.js +5 -0
  42. package/dist/rules.d.ts +72 -5
  43. package/dist/rules.js +116 -54
  44. package/dist/shape.js +8 -2
  45. package/dist/turn-rules.d.ts +86 -0
  46. package/dist/turn-rules.js +232 -0
  47. package/dist/widget-render.d.ts +23 -0
  48. package/dist/widget-render.js +82 -0
  49. package/dist/widget.d.ts +5 -22
  50. package/dist/widget.js +3 -80
  51. package/package.json +5 -3
package/CHANGELOG.md CHANGED
@@ -8,6 +8,60 @@ How to keep this current: add the entry in the same pull request as the change,
8
8
 
9
9
  <!-- Empty. Next release starts here. -->
10
10
 
11
+ ## 0.74.0
12
+
13
+ ### Added
14
+
15
+ - `typesafeBackend` accepts `"commandcode"`: judgments go to api.commandcode.ai under `/provider/v1/systemone`, with the key from `COMMANDCODE_API_KEY` and the model `typesafe/jev`.
16
+ - `typesafeBackend` accepts a caller-supplied endpoint object (`{ "label", "host", "path", "keyEnv", "defaultModel" }`, plus optional model-list fields) for a gateway that serves the same decisions protocol (pi-typesafe 0.8.0). The object is passed to the judge as written and validated on every call. It reads only its own `keyEnv` variable: the TypeSafe key and the `/typesafe login` store are never sent to it. `/warden status`, the `/warden enable` dialog, and the `/warden test` confirmation name the label, host, and model sent for any non-TypeSafe backend, and the consent disclosure names the real destination host.
17
+
18
+ ### Changed
19
+
20
+ - An unknown `typesafeBackend` name, or an endpoint object the judge refuses, no longer silently falls back to `"typesafe"`: judgments turn off, the refusal message is shown once and in `/warden status`, and nothing is sent to api.typesafe.ai because a value was mistyped.
21
+
22
+ ## 0.73.1
23
+
24
+ ### Tests
25
+
26
+ - `tests/host-compat.test.ts`: the fake host's per-handler timeout stays referenced while a dispatch runs and is cleared before `emit()` returns, so an awaited `emit()` always has a loop wakeup. The timer was unref'd, so on Node 22 the event loop could drain mid-dispatch and node:test cancelled the rest of the file with `Promise resolution is still pending but the event loop has already resolved`.
27
+
28
+ ## 0.73.0
29
+
30
+ ### Fixed
31
+
32
+ - pi-warden runs on oh-my-pi and other hosts that load Pi extensions through a compatibility layer. `src/host-compat.ts` maps the host API onto the Pi contract: `before_agent_start` gets `systemPromptOptions` with the active skills, and an `appendSystemPrompt` becomes the host's `systemPrompt` array. An input the extension handles is also marked `handled`. `agent_settled` runs after pi-warden's `agent_end` handlers have been dispatched and the host becomes idle. If background work is cancelled without another `agent_end`, the adapter waits for that work to drain. Command contexts get `getSystemPromptOptions()`. On upstream Pi the API is not changed.
33
+ - Data paths follow the host: the user config, the `/warden index` directory, and `holds.db` use the host's agent directory (`~/.pi/agent` on Pi, `~/.omp/agent` on oh-my-pi), and the project file uses the host's config directory (`.pi/pi-warden.json` or `.omp/pi-warden.json`).
34
+ - On a host whose overlay handle cannot release focus, Escape closes the trace and config panels.
35
+ - `import { defaultConfig } from "pi-warden"` works again without the optional `@earendil-works/pi-coding-agent` peer and now also without the optional `@earendil-works/pi-tui` peer: the library entry has no runtime import of either. Host directories come from the new injectable `HostDirs` parameter on every path function (`defaultHostDirs()` reads `PI_CODING_AGENT_DIR` and falls back to `~/.pi/agent`; existing callers keep working unchanged), and `src/extension.ts` builds them once from the host and passes them to every call. The entry's only pi-tui code — the rendered status stack — moved to `src/widget-render.ts`, which the entry does not re-export.
36
+ - On a host whose agent directory is not the Pi default (oh-my-pi), an interactive session shows a one-time notice when `~/.pi/agent/pi-warden` exists and the notice has not been shown on this host: it names the target and legacy folders and a command that moves the fresh data aside and copies the legacy data in its place, and records a marker beside the data folder so it shows once. Headless sessions show nothing.
37
+
38
+ ### Docs
39
+
40
+ - `docs/configuration.md`, `docs/data-handling.md`, `docs/commands.md`, and `docs/faq.md` say that paths follow the host. `docs/configuration.md` and `docs/data-handling.md` add a one-time migration note for existing oh-my-pi users: close all Pi and oh-my-pi sessions, then run `{ [ ! -e "$HOME/.omp/agent/pi-warden" ] || mv "$HOME/.omp/agent/pi-warden" "$HOME/.omp/agent/pi-warden.before-migration"; } && cp -R "$HOME/.pi/agent/pi-warden" "$HOME/.omp/agent/pi-warden"` and copy each project's `.pi/pi-warden.json` to `.omp/pi-warden.json` (keep the original). oh-my-pi shows this notice once per machine.
41
+
42
+ ## 0.72.0
43
+
44
+ ### Added
45
+
46
+ - A project rule may cite where its wording came from with a `source: <file>:<line>` header beside `paths:`, for example `source: AGENTS.md:65`. `/warden rules` shows it, and when the rule fires the steer names it: `"<rule>" (from AGENTS.md line 65) ...`. A bad value is ignored and `/warden rules` reports the line.
47
+ - `/warden init` compiles an existing `AGENTS.md` or `CLAUDE.md` into small rules. The prompt hands the agent the instruction file's lines with line numbers and asks for one rule per instruction that can be judged from one changed file, each with a `source:` header and a `paths:` header when the instruction concerns certain files, wording close to the source, and a concrete violation pattern. Instructions that need other files, repository history, or the task, and anything a linter or type checker enforces, are left out of the file and listed in the reply with the reason. The reply suggests `/warden rules check` when the user has a key; the check is never run automatically. When `pi-warden.md` already exists the prompt is unchanged.
48
+ - `/warden rules audit [paths...] [--max N] [--yes]` judges existing files against the project rules as if each had just been written: one Jev request per file, at most four at a time, over the source files under the given paths (default: the project) that git does not ignore and at least one rule applies to, capped at `--max` (default 50) with the number left out reported. A confirm dialog names the file count and what leaves the machine before anything is sent; a headless run needs `--yes`. The output is a table by rule (files judged, files flagged, mean score) and the flagged files with their rule scores, worst first, plus a Markdown copy at `.pi-warden/rules-audit.md`. Nothing is recorded in the rules log. With no key or no consent it says so and sends nothing.
49
+ - `/warden bench [--runs N]` (default 10) measures what a rules check costs on this machine: the fixed built-in sample file judged N times against the active rules, reporting p50 and p95 latency, requests, mean input tokens per check, and estimated cost per check and per 100 edits. No confirmation is needed — the sample is built in and no project content is sent — and with no key or no consent it says so and sends nothing.
50
+ - `/warden rules calibrate [--commits N] [--max N] [--yes]` replays the changed files of the last N non-merge commits (default 20) through the project rules and reports, per rule, how often it applied and fired, the mean score, and the flags `fires on everything` and `undecided`, worst first, then the rules no file in the sample reached; a rule with no fire in the sample reports `no violation in sample` and is not flagged, because replayed commits are mostly compliant code. Each changed file is one `edit` request (the removed lines as `oldText`, the added lines as `newText`, the file after the commit as context), capped at `--max` (default 40); binary, generated, gitignored, `rules.exclude`, and `rules.skip` files are skipped, and a rule fires at its own `threshold:` cutoff. The scores go to the local rules log with `source: "calibrate"`, which `/warden report` counts apart from live verdicts. Nothing is sent before a confirm dialog showing the request count and the redacted diffs; a headless run needs the explicit `--yes`.
51
+ - `/warden rules tune` sends the session's agent one prompt with the rules the latest calibrate flagged `fires on everything` or `undecided`, or that `/warden rules check` (this session) flagged: each rule's current text and why it was flagged, and the ask for a rewrite that is concrete and judgeable from one changed file. The agent edits `pi-warden.md` with its own tools. With nothing flagged it says so and sends nothing.
52
+ - A project rule may carry `when: turn` beside `paths:`, `threshold:`, and `severity:` (default `edit`). A `when: turn` rule is judged once at the end of each agent run against the whole diff the run made and the user's task for that run, in one request, instead of on every edit — the home for rules one edit cannot answer, such as a change that adds more than the task asked for, a new abstraction with a single use, or logic duplicated across files. Turn rules are never asked about a single edit. The header lines parse in any order and at most once; a bad value is ignored and `/warden rules` reports the line.
53
+ - The end-of-run pass also judges files that shell commands changed in ways the per-edit guard cannot see (`sed -i`, `patch`, `git apply`, a generator's output redirected to a file, a subagent's writes): each changed file that no `write`, `edit`, or literal shell write already judged during the run is judged once, with its diff as the edit and the same per-edit question. The baseline is a read-only snapshot of the working tree taken in the background at run start (a temporary index file; the working tree, the real index, and the stash list are never touched), diffed at run end and including new untracked files git does not ignore. Every git call is asynchronous, so no hook blocks while git hashes the tree; the run end waits for a snapshot still running. The diff is capped at `rules.maxChars` per file and in total with the cut named. The unseen-change requests are capped at five files per run — the same cap as the files one shell command writes — with the first files in diff order judged and the rest named in the trace. Findings are one steer at the end of the run through the done-check's delivery, at most once per run and within the per-run steer budget; nothing holds. With no `when: turn` rules and no change the per-edit guard missed, no request is made and nothing is said.
54
+
55
+ ### Tests
56
+
57
+ - Offline coverage for the history replay: hunk extraction from `git log -p`, the skip rules (binary, generated, ignored, `rules.exclude`, `rules.skip`), the request cap, the confirm and `--yes` paths, the report flags and their order at the `/warden report` thresholds, the `source: "calibrate"` log records, and the tune prompt (with no prompt when nothing is flagged), on temporary git repositories with a fake judge.
58
+
59
+ - Rules replay (`scripts/rules-replay.mjs`, `npm run eval:replay`): replays the `write` and `edit` calls of past Pi sessions in a project against that project's current rules, one request per judged call, and writes a review sheet (every flagged call plus a fixed-seed sample of unflagged calls) outside the repository beside aggregate counts. `--score` turns labelled sheets into precision on the flagged items and the estimated miss rate from the unflagged sample. The first run on this repository's own sessions is in `eval/reports/2026-09-27-rules-replay/`, unlabelled pending an independent reviewer. Offline tests cover the session replay and the output boundaries.
60
+
61
+ ### Docs
62
+
63
+ - `docs/commands.md`, `docs/guards.md`, and `docs/data-handling.md` cover the two commands and what the replay sends.
64
+
11
65
  ## 0.71.0
12
66
 
13
67
  ### Added
@@ -1,3 +1,4 @@
1
+ import type { HostDirs } from "./host-dirs.js";
1
2
  import type { SteersConfig } from "./config.js";
2
3
  /**
3
4
  * Per-model steer adaptation. A steer kind that a model follows rarely or disputes often becomes trace-only for that
@@ -32,8 +33,8 @@ export interface MutedPair {
32
33
  followed: number;
33
34
  disputed: number;
34
35
  }
35
- /** Where the counts live: next to the user config, in pi-warden's data folder. */
36
- export declare function steerStatsPath(): string;
36
+ /** Where the counts live: next to the user config, in pi-warden's data folder. `PI_WARDEN_STEER_STATS` overrides it. */
37
+ export declare function steerStatsPath(dirs?: HostDirs): string;
37
38
  export declare const DEFAULT_STEERS: SteersConfig;
38
39
  /** True when the counts put the pair under the follow floor or over the dispute ceiling. */
39
40
  export declare function failing(kind: SteerKind, counts: Counts, config: SteersConfig): boolean;
package/dist/adaptive.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { writeFileAtomicSync } from "./atomic.js";
4
4
  import { userConfigPath } from "./config.js";
5
+ import { defaultHostDirs } from "./host-dirs.js";
5
6
  export const STEER_KINDS = [
6
7
  "intent-mismatch", "off-task", "should-proceed", "warn-headless", "large-output", "slop", "rules", "sensitive-path",
7
8
  "security-write", "stuck", "repeat", "done", "prose", "conscience", "loops", "runaway", "subagent",
@@ -15,9 +16,9 @@ export const STEER_KINDS = [
15
16
  export const NEVER_MUTED = new Set([
16
17
  "hold", "confirm", "deny", "masking", "credential-notice", "done", "runaway", "subagent",
17
18
  ]);
18
- /** Where the counts live: next to the user config, in pi-warden's data folder. */
19
- export function steerStatsPath() {
20
- return process.env.PI_WARDEN_STEER_STATS ?? join(dirname(userConfigPath()), "steer-stats.json");
19
+ /** Where the counts live: next to the user config, in pi-warden's data folder. `PI_WARDEN_STEER_STATS` overrides it. */
20
+ export function steerStatsPath(dirs = defaultHostDirs()) {
21
+ return process.env.PI_WARDEN_STEER_STATS ?? join(dirname(userConfigPath(dirs)), "steer-stats.json");
21
22
  }
22
23
  export const DEFAULT_STEERS = { adaptive: true, minSteers: 30, minFollowed: 0.2, maxDisputed: 0.4, recheckEvery: 30, probeEvery: 5 };
23
24
  const zero = () => ({ sent: 0, followed: 0, disputed: 0 });
package/dist/backend.d.ts CHANGED
@@ -1,16 +1,37 @@
1
- import type { TypeSafeOptions } from "pi-typesafe";
2
- /** The judgment backend that receives pi-warden's Jev requests. */
3
- export type JudgmentBackend = "typesafe" | "openrouter";
4
- /** Why no judge is available: consent not given, no key for the backend, a saved 401 or 403, or the request budget spent. */
5
- export type JudgmentsOffReason = "no_consent" | "no_key" | "key_rejected" | "budget";
6
- /** The host the consent disclosure names as the destination, without scheme: `api.typesafe.ai`, `openrouter.ai`. */
7
- export declare function backendHost(backend: JudgmentBackend): string;
8
- /** The environment variable that carries the backend's key, for messages that tell the user what to set. */
9
- export declare function keyEnvFor(backend: JudgmentBackend): string;
10
- /** Whether the backend takes the TypeSafe key, the only one `/typesafe login` stores; pi-typesafe's `usesTypesafeKey`, which its package does not export. */
11
- export declare function loginStoresKey(backend: JudgmentBackend): boolean;
1
+ import type { BackendSpec, TypeSafeOptions } from "pi-typesafe";
2
+ /** The judgment backend: a registry name ("typesafe", "openrouter", "commandcode") or a caller-supplied endpoint object. */
3
+ export type JudgmentBackend = BackendSpec;
4
+ /** The judgment destination as the config carries it, plus the refusal that turns judgments off when the configured value was refused. */
5
+ export interface BackendSetting {
6
+ /** The spec judgments go to; undefined only when the configured value was refused. */
7
+ readonly typesafeBackend: JudgmentBackend | undefined;
8
+ /** Why the configured value was refused: the once-per-session notice and `/warden status` quote it. */
9
+ readonly backendRefusal: string | undefined;
10
+ }
11
+ /** What a raw `typesafeBackend` value resolves to: the spec judgments go to, or the refusal that turns them off. Exactly one is set. */
12
+ export type BackendResolution = {
13
+ readonly typesafeBackend: JudgmentBackend;
14
+ readonly backendRefusal?: undefined;
15
+ } | {
16
+ readonly typesafeBackend?: undefined;
17
+ readonly backendRefusal: string;
18
+ };
19
+ /** Why no judge is available: consent not given, the backend refused, no key for the backend, a saved 401 or 403, or the request budget spent. */
20
+ export type JudgmentsOffReason = "no_consent" | "bad_backend" | "no_key" | "key_rejected" | "budget";
21
+ /**
22
+ * Resolve a raw config value to the spec judgments go to, kept as written, or to the refusal that turns judgments off.
23
+ * pi-typesafe validates the spec again on every call. An unknown name or an object it refuses is never silently mapped
24
+ * to the default backend: a mistyped value must not send judgments to api.typesafe.ai.
25
+ */
26
+ export declare function resolveJudgmentBackend(raw: unknown): BackendResolution;
27
+ /** The name messages show for a backend: the registry name, or an endpoint's validated label. */
28
+ export declare function backendName(backend: JudgmentBackend | undefined): string;
29
+ /** Whether the backend takes the TypeSafe key, the only one `/typesafe login` stores; true only for "typesafe". */
30
+ export declare function loginStoresKey(backend: JudgmentBackend | undefined): boolean;
31
+ /** One phrase naming where judgments go — the label, the host, and the model sent — for consent text and status lines. */
32
+ export declare function describeBackend(backend: JudgmentBackend | undefined): string;
12
33
  /** Adapt the consent text to the active backend by substituting the destination host. */
13
- export declare function disclosureFor(backend: JudgmentBackend, disclosure: string): string;
34
+ export declare function disclosureFor(backend: JudgmentBackend | undefined, disclosure: string): string;
14
35
  /**
15
36
  * Build the options forwarded to `createTypeSafe`.
16
37
  * The caller must gate on `authState({ backend }).usable` before calling `createTypeSafe`.
@@ -20,5 +41,3 @@ export declare function judgeOptions(config: {
20
41
  timeoutMs: number;
21
42
  typesafeBackend: JudgmentBackend;
22
43
  }): TypeSafeOptions;
23
- /** Resolve the effective backend from a raw config value; invalid values fall back to "typesafe". */
24
- export declare function resolveBackend(raw: unknown): JudgmentBackend;
package/dist/backend.js CHANGED
@@ -1,19 +1,36 @@
1
- import { DECISIONS_BACKENDS, DEFAULT_BACKEND } from "pi-typesafe";
2
- /** The host the consent disclosure names as the destination, without scheme: `api.typesafe.ai`, `openrouter.ai`. */
3
- export function backendHost(backend) {
4
- return new URL(DECISIONS_BACKENDS[backend].host).host;
1
+ import { DEFAULT_BACKEND, backendHost, resolveBackend } from "pi-typesafe";
2
+ /**
3
+ * Resolve a raw config value to the spec judgments go to, kept as written, or to the refusal that turns judgments off.
4
+ * pi-typesafe validates the spec again on every call. An unknown name or an object it refuses is never silently mapped
5
+ * to the default backend: a mistyped value must not send judgments to api.typesafe.ai.
6
+ */
7
+ export function resolveJudgmentBackend(raw) {
8
+ if (raw === undefined || raw === null)
9
+ return { typesafeBackend: DEFAULT_BACKEND };
10
+ try {
11
+ resolveBackend(raw);
12
+ }
13
+ catch (error) {
14
+ return { backendRefusal: error instanceof Error ? error.message : String(error) };
15
+ }
16
+ return { typesafeBackend: raw };
5
17
  }
6
- /** The environment variable that carries the backend's key, for messages that tell the user what to set. */
7
- export function keyEnvFor(backend) {
8
- return DECISIONS_BACKENDS[backend].keyEnv ?? DECISIONS_BACKENDS[DEFAULT_BACKEND].keyEnv ?? "TYPESAFE_API_KEY";
18
+ /** The name messages show for a backend: the registry name, or an endpoint's validated label. */
19
+ export function backendName(backend) {
20
+ return backend === undefined ? DEFAULT_BACKEND : typeof backend === "string" ? backend : resolveBackend(backend).label;
9
21
  }
10
- /** Whether the backend takes the TypeSafe key, the only one `/typesafe login` stores; pi-typesafe's `usesTypesafeKey`, which its package does not export. */
22
+ /** Whether the backend takes the TypeSafe key, the only one `/typesafe login` stores; true only for "typesafe". */
11
23
  export function loginStoresKey(backend) {
12
- return keyEnvFor(backend) === keyEnvFor(DEFAULT_BACKEND);
24
+ return backend === DEFAULT_BACKEND;
25
+ }
26
+ /** One phrase naming where judgments go — the label, the host, and the model sent — for consent text and status lines. */
27
+ export function describeBackend(backend) {
28
+ const resolved = resolveBackend(backend);
29
+ return `${resolved.label} at ${backendHost(backend)}, model ${resolved.defaultModel ?? "none configured"}`;
13
30
  }
14
31
  /** Adapt the consent text to the active backend by substituting the destination host. */
15
32
  export function disclosureFor(backend, disclosure) {
16
- return backend === DEFAULT_BACKEND ? disclosure : disclosure.replace(backendHost(DEFAULT_BACKEND), backendHost(backend));
33
+ return backend === undefined || backend === DEFAULT_BACKEND ? disclosure : disclosure.replace(backendHost(DEFAULT_BACKEND), backendHost(backend));
17
34
  }
18
35
  /**
19
36
  * Build the options forwarded to `createTypeSafe`.
@@ -26,7 +43,3 @@ export function judgeOptions(config) {
26
43
  ...(config.typesafeBackend === DEFAULT_BACKEND ? {} : { backend: config.typesafeBackend }),
27
44
  };
28
45
  }
29
- /** Resolve the effective backend from a raw config value; invalid values fall back to "typesafe". */
30
- export function resolveBackend(raw) {
31
- return raw === "typesafe" || raw === "openrouter" ? raw : DEFAULT_BACKEND;
32
- }
package/dist/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { JudgmentBackend } from "./backend.js";
2
+ import type { HostDirs } from "./host-dirs.js";
2
3
  import type { WidgetConfig } from "./widget.js";
3
4
  export interface Threshold {
4
5
  /** P(yes) at or above this shows a warning and continues. */
@@ -367,8 +368,10 @@ export interface WardenConfig {
367
368
  enabled: boolean;
368
369
  /** Consent to send task and action summaries to api.typesafe.ai. Set by /warden enable; never by a project file. */
369
370
  typesafe: boolean;
370
- /** The decisions service the judgments go to. User file only: a project must not redirect judgments to another vendor. */
371
- typesafeBackend: JudgmentBackend;
371
+ /** The decisions service the judgments go to: a name ("typesafe", "openrouter", "commandcode") or a caller-supplied endpoint object. User file only: a project must not redirect judgments to another vendor. Undefined when the configured value was refused. */
372
+ typesafeBackend: JudgmentBackend | undefined;
373
+ /** Why the configured typesafeBackend was refused: judgments stay off, and the once-per-session notice and /warden status quote this. */
374
+ backendRefusal: string | undefined;
372
375
  /**
373
376
  * steer (default): a confirm-level call is held and the agent receives the judgment as its tool result, so it re-plans or asks
374
377
  * 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.
@@ -417,9 +420,9 @@ export declare const PACKAGE_NAME = "pi-warden";
417
420
  export declare const CONFIG_SCHEMA = 10;
418
421
  export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
419
422
  export declare function defaultConfig(): WardenConfig;
420
- /** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
421
- export declare function userConfigPath(): string;
422
- export declare function projectConfigPath(cwd: string): string;
423
+ /** The user file is in the host's agent directory: `~/.pi/agent` on Pi, `~/.omp/agent` on oh-my-pi. `PI_CODING_AGENT_DIR` overrides it. */
424
+ export declare function userConfigPath(dirs?: HostDirs): string;
425
+ export declare function projectConfigPath(cwd: string, dirs?: HostDirs): string;
423
426
  type Json = Record<string, unknown>;
424
427
  export declare function isMode(value: unknown): value is WardenMode;
425
428
  /** Unknown keys and invalid values fall back to the base; nothing throws on a malformed file. */
@@ -430,13 +433,15 @@ export interface LoadOptions {
430
433
  cwd?: string;
431
434
  /** Project overrides are applied only when the caller vouches for the project (Pi's trust decision). */
432
435
  projectTrusted?: boolean;
436
+ /** Host directories; defaults to `defaultHostDirs()`. */
437
+ dirs?: HostDirs;
433
438
  }
434
439
  export declare function loadConfig(options?: LoadOptions): WardenConfig;
435
440
  /** Reads only the user file, for editing and persisting consent. */
436
- export declare function readUserConfig(): Json;
437
- export declare function writeUserConfig(raw: Json): string;
441
+ export declare function readUserConfig(dirs?: HostDirs): Json;
442
+ export declare function writeUserConfig(raw: Json, dirs?: HostDirs): string;
438
443
  /** Persists one top-level user setting without disturbing the rest of the file. */
439
- export declare function setUserSetting(key: "typesafe" | "enabled" | "mode", value: boolean | WardenMode): string;
444
+ export declare function setUserSetting(key: "typesafe" | "enabled" | "mode", value: boolean | WardenMode, dirs?: HostDirs): string;
440
445
  export declare function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown>;
441
446
  export declare function getNestedValue(obj: Record<string, unknown>, path: string): unknown;
442
447
  /** Coerce a CLI string into a JSON primitive so `/warden config set` stays ergonomic. */
package/dist/config.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
2
  import { dirname, join } from "node:path";
4
3
  import { writeFileAtomicSync } from "./atomic.js";
5
- import { resolveBackend } from "./backend.js";
4
+ import { resolveJudgmentBackend } from "./backend.js";
5
+ import { defaultHostDirs } from "./host-dirs.js";
6
6
  import { COMMAND_TOOLS } from "./tools.js";
7
7
  import { defaultWidgetConfig } from "./widget.js";
8
8
  // Tests of UI code are proven by running them, not by looking at them.
@@ -31,6 +31,7 @@ export function defaultConfig() {
31
31
  enabled: true,
32
32
  typesafe: false,
33
33
  typesafeBackend: "typesafe",
34
+ backendRefusal: undefined,
34
35
  mode: "steer",
35
36
  timeoutMs: 5000,
36
37
  maxRequests: 500,
@@ -89,16 +90,12 @@ export function defaultConfig() {
89
90
  waste: { enabled: true, tip: false, every: 20, sleep: true, paging: true, search: true, recheck: true },
90
91
  };
91
92
  }
92
- /** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
93
- export function userConfigPath() {
94
- const configured = process.env.PI_CODING_AGENT_DIR?.trim();
95
- const agentDir = configured
96
- ? (configured === "~" || configured.startsWith("~/") ? join(homedir(), configured.slice(1)) : configured)
97
- : join(homedir(), ".pi", "agent");
98
- return join(agentDir, PACKAGE_NAME, "config.json");
93
+ /** The user file is in the host's agent directory: `~/.pi/agent` on Pi, `~/.omp/agent` on oh-my-pi. `PI_CODING_AGENT_DIR` overrides it. */
94
+ export function userConfigPath(dirs = defaultHostDirs()) {
95
+ return join(dirs.agentDir, PACKAGE_NAME, "config.json");
99
96
  }
100
- export function projectConfigPath(cwd) {
101
- return join(cwd, ".pi", PROJECT_CONFIG_FILE);
97
+ export function projectConfigPath(cwd, dirs = defaultHostDirs()) {
98
+ return join(cwd, dirs.configDirName, PROJECT_CONFIG_FILE);
102
99
  }
103
100
  function isObject(value) {
104
101
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -486,10 +483,12 @@ export function applyUserOverrides(base, raw) {
486
483
  if (!isObject(raw))
487
484
  return base;
488
485
  const shared = applyShared(base, raw);
486
+ const backend = resolveJudgmentBackend(raw.typesafeBackend);
489
487
  return {
490
488
  enabled: boolean(raw.enabled, base.enabled),
491
489
  typesafe: boolean(raw.typesafe, base.typesafe),
492
- typesafeBackend: resolveBackend(raw.typesafeBackend),
490
+ typesafeBackend: backend.typesafeBackend,
491
+ backendRefusal: backend.backendRefusal,
493
492
  mode: isMode(raw.mode) ? raw.mode : base.mode,
494
493
  ...shared,
495
494
  ...applyGuards(base, raw, shared.timeoutMs, "user"),
@@ -578,23 +577,24 @@ export function applyProjectOverrides(base, raw) {
578
577
  return { ...base, enabled: boolean(raw.enabled, base.enabled), ...applyGuards(base, raw, base.timeoutMs, "project"), prefs: applyPrefs(base.prefs, raw.prefs) };
579
578
  }
580
579
  export function loadConfig(options = {}) {
581
- let config = applyUserOverrides(defaultConfig(), readJson(userConfigPath()));
580
+ const dirs = options.dirs ?? defaultHostDirs();
581
+ let config = applyUserOverrides(defaultConfig(), readJson(userConfigPath(dirs)));
582
582
  if (options.cwd && options.projectTrusted)
583
- config = applyProjectOverrides(config, readJson(projectConfigPath(options.cwd)));
583
+ config = applyProjectOverrides(config, readJson(projectConfigPath(options.cwd, dirs)));
584
584
  return config;
585
585
  }
586
586
  /** Reads only the user file, for editing and persisting consent. */
587
- export function readUserConfig() {
588
- return readJson(userConfigPath()) ?? {};
587
+ export function readUserConfig(dirs = defaultHostDirs()) {
588
+ return readJson(userConfigPath(dirs)) ?? {};
589
589
  }
590
- export function writeUserConfig(raw) {
591
- const path = userConfigPath();
590
+ export function writeUserConfig(raw, dirs = defaultHostDirs()) {
591
+ const path = userConfigPath(dirs);
592
592
  writeFileAtomicSync(path, `${JSON.stringify(raw, null, 2)}\n`);
593
593
  return path;
594
594
  }
595
595
  /** Persists one top-level user setting without disturbing the rest of the file. */
596
- export function setUserSetting(key, value) {
597
- return writeUserConfig({ ...readUserConfig(), [key]: value });
596
+ export function setUserSetting(key, value, dirs = defaultHostDirs()) {
597
+ return writeUserConfig({ ...readUserConfig(dirs), [key]: value }, dirs);
598
598
  }
599
599
  export function setNestedValue(obj, path, value) {
600
600
  const keys = path.split(".");
@@ -66,7 +66,7 @@ export interface AssessmentResult {
66
66
  /** Number of judge requests issued for this assessment. */
67
67
  requestCount: number;
68
68
  }
69
- export type SkipReason = "disabled" | "no_consent" | "no_key" | "key_rejected" | "no_match" | "already_supplied" | "awaiting_user" | "timeout" | "error" | "stale" | "catalog_unavailable" | "catalog_limit" | "metadata_unsafe" | "budget" | "explicit_skill" | "origin_unknown" | "below_threshold";
69
+ export type SkipReason = "disabled" | "no_consent" | "bad_backend" | "no_key" | "key_rejected" | "no_match" | "already_supplied" | "awaiting_user" | "timeout" | "error" | "stale" | "catalog_unavailable" | "catalog_limit" | "metadata_unsafe" | "budget" | "explicit_skill" | "origin_unknown" | "below_threshold";
70
70
  /** Score levels for the usefulness question. Levels are ordered from least to most useful. */
71
71
  export declare const SCORE_LEVELS: readonly ["No useful contribution to the current request, or conflicts with the supplied constraints.", "Related to the subject, but already covered, premature, or unable to resolve the current need.", "Addresses a concrete unmet need at the current step without displacing the active workflow.", "Supplies a missing prerequisite or directly applicable documented method needed for the current step."];
72
72
  /** Strip absolute paths, URLs, and credentials from a description. */
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { JudgmentBackend, JudgmentsOffReason } from "./backend.js";
2
+ import type { HostDirs } from "./host-dirs.js";
3
+ import type { BackendSetting, JudgmentsOffReason } from "./backend.js";
3
4
  import type { ToolCallRef } from "./action-guard.js";
4
5
  import type { SlopSymptom, Verdict } from "./guard.js";
5
6
  import type { ShapeResult } from "./shape.js";
@@ -36,7 +37,7 @@ export declare function assistantPlan(ctx: ExtensionContext, visibleAction?: boo
36
37
  * variable that holds a rejected key, or undefined when the rejected key is the one `/typesafe login` saved. Never
37
38
  * names a key value or a path.
38
39
  */
39
- export declare function judgmentsOffText(reason: Exclude<JudgmentsOffReason, "budget">, backend: JudgmentBackend, headless: boolean, rejectedEnv?: string): string;
40
+ export declare function judgmentsOffText(reason: Exclude<JudgmentsOffReason, "budget">, setting: BackendSetting, headless: boolean, rejectedEnv?: string): string;
40
41
  export declare function confirmMessage(verdict: Verdict): string;
41
42
  /** Names each symptom and its fix; repeats in the session turn the note into a standing rule. */
42
43
  export declare function slopSteer(where: string, symptoms: readonly SlopSymptom[], counts: Record<SlopSymptom, number>): string;
@@ -48,4 +49,14 @@ export declare function slopSteer(where: string, symptoms: readonly SlopSymptom[
48
49
  export declare function guardCurrentSections(result: ShapeResult): ShapeResult;
49
50
  /** Test-only: set the index-running state and write paths for the action guard bypass. */
50
51
  export declare function _testSetIndexRunning(running: boolean, paths?: string[]): void;
51
- export default function wardenExtension(pi: ExtensionAPI): void;
52
+ /**
53
+ * The one-time migration message for a host whose agent directory is not Pi's default, when data
54
+ * still lives only under the default and this host has not shown the notice yet: undefined
55
+ * otherwise. The caller checks the filesystem for data and the marker. The directories are compared
56
+ * in absolute, symlink-free form: the same directory under another spelling (trailing slash, `.`
57
+ * segment, symlink) gets no notice, because the command would move the only copy aside. The command
58
+ * moves the fresh data this host may have created aside (non-destructive, and works when the target
59
+ * folder does not exist) and copies the legacy data whole in its place.
60
+ */
61
+ export declare function legacyDataNotice(dirs: HostDirs, piDefault: HostDirs, exists: (path: string) => boolean): string | undefined;
62
+ export default function wardenExtension(host: ExtensionAPI): void;