pi-warden 0.40.1 → 0.40.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,13 @@ 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.40.2
12
+
13
+ ### Fixed
14
+
15
+ - The first-run notice no longer names a fallback document when `rules.files` is what resolved. It was decided by whether `pi-warden.md` existed, so a project whose rules come from `rules.files` was told `AGENTS.md` was judging it while its own configured files were the ones in force — and the notice's remedy, creating a `pi-warden.md`, is the one thing that shadows those files. The notice now asks the rule store which tier answered and fires only when a fallback document really is in force, listing `rules.files` among the tiers it looked at.
16
+ - The escalation request carries the rules the config resolves, not a document of the resolver's own choosing. It walked `pi-warden.md` and then `AGENTS.md` / `CLAUDE.md` / `README.md` while the rules guard was judging with `rules.files`, so a project with configured rules sent `AGENTS.md` prose as its rules, or no rules at all when it had no such file. It now takes every file in `rules.files`, in order, before the fallback names, and honors `rules.fallback: false`.
17
+
11
18
  ## 0.40.1
12
19
 
13
20
  ### Fixed
package/README.md CHANGED
@@ -68,7 +68,7 @@ paths: src/**/*.ts
68
68
  Every exported function declares its return type.
69
69
  ```
70
70
 
71
- Jev judges every write and quotes violations back. Rules can be things no linter checks: "a TODO must name a ticket", "comments must not restate the code". Without `pi-warden.md`, the first of `README.md`/`CLAUDE.md`/`AGENTS.md` is judged instead. [Examples and config details.](docs/configuration.md)
71
+ Jev judges every write and quotes violations back. Rules can be things no linter checks: "a TODO must name a ticket", "comments must not restate the code". Without `pi-warden.md`, the first of `AGENTS.md`/`CLAUDE.md`/`README.md` is judged instead. [Examples and config details.](docs/configuration.md)
72
72
 
73
73
  ## Does it actually help?
74
74
 
@@ -1,5 +1,5 @@
1
- import type { ActionGuardConfig, RulesConfig, SecurityConfig, SlopGuardConfig } from "./config.js";
2
- import type { PreviousAction, TaskMessage, Verdict } from "./guard.js";
1
+ import type { ActionGuardConfig, SecurityConfig, SlopGuardConfig } from "./config.js";
2
+ import type { EvaluateOptions, PreviousAction, TaskMessage, Verdict } from "./guard.js";
3
3
  import type { Judge } from "pi-typesafe";
4
4
  /** One tool call as the agent proposed it. `id` is Pi's tool call id, stable across hooks and retries. */
5
5
  export interface ToolCallRef {
@@ -25,8 +25,8 @@ export interface InspectOptions {
25
25
  signal?: AbortSignal | undefined;
26
26
  slop?: SlopGuardConfig | undefined;
27
27
  security?: SecurityConfig | undefined;
28
- /** The rules guard's switch; off keeps the rules file content out of the action request. Omitted, it is sent as before. */
29
- rules?: Pick<RulesConfig, "enabled"> | undefined;
28
+ /** The rules guard's switch, forwarded to `evaluateAction`: off keeps the rules content out of the request. */
29
+ rules?: EvaluateOptions["rules"];
30
30
  /** Calls allowed in the previous turn; the regret question about them rides this call's request, never a sibling's. */
31
31
  previousActions?: readonly PreviousAction[] | undefined;
32
32
  }
package/dist/extension.js CHANGED
@@ -20,7 +20,7 @@ import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_S
20
20
  import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice, CompressionLearner } from "./output.js";
21
21
  import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
22
22
  import { redact } from "./redact.js";
23
- import { formatRules, pathNoteSteer, RulesGuard, rulesSteer } from "./rules.js";
23
+ import { formatRules, pathNoteSteer, RulesGuard, rulesSteer, RULES_FILE, FALLBACK_FILES } from "./rules.js";
24
24
  import { checkPiWardenMissing } from "./rules-file.js";
25
25
  import { writeStarterRules, buildInitPrompt } from "./init.js";
26
26
  import { buildAuditPrompt, findProjects, snapshotReport, reportOutcome } from "./audit.js";
@@ -1060,14 +1060,17 @@ export default function wardenExtension(pi) {
1060
1060
  if (!config.action.enabled || !config.action.tools.includes(event.toolName))
1061
1061
  return;
1062
1062
  stats.inspected++;
1063
- // First-run warning: pi-warden.md missing, one time per session.
1063
+ // First-run warning: no project rules file resolved, one time per session.
1064
1064
  if (!warnedMissingRules && config.rules.enabled) {
1065
- const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd);
1065
+ const { missing, fallbackSource } = checkPiWardenMissing(ctx.cwd, config.rules);
1066
1066
  if (missing && ctx.hasUI) {
1067
1067
  warnedMissingRules = true;
1068
+ // The tiers still in play, in resolution order: naming README/CLAUDE/AGENTS to someone who
1069
+ // turned the fallback off would send them to a document pi-warden will not read.
1070
+ const tiers = [RULES_FILE, "rules.files", ...(config.rules.fallback ? FALLBACK_FILES : [])].join(", ");
1068
1071
  const msg = fallbackSource
1069
- ? `No pi-warden.md detected. Using ${fallbackSource} as active fallback rules. Run /warden init to create project-specific rules.`
1070
- : `No rules file detected (pi-warden.md, README.md, CLAUDE.md, or AGENTS.md). Run /warden init to create project-specific rules.`;
1072
+ ? `No pi-warden.md or rules.files entry here. Using ${fallbackSource} as active fallback rules. Run /warden init to create project-specific rules.`
1073
+ : `No rules file detected (${tiers}). Run /warden init to create project-specific rules.`;
1071
1074
  ctx.ui.notify(msg, "warning");
1072
1075
  }
1073
1076
  }
package/dist/guard.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { IntegrationErrorCode, Judge, Questions } from "pi-typesafe";
2
2
  import type { ActionGuardConfig, ArmingRule, CommandRule, PathRule, RulesConfig, SecurityConfig, SlopGuardConfig } from "./config.js";
3
+ import type { RulesSourceConfig } from "./rules.js";
3
4
  export type Level = "allow" | "warn" | "confirm" | "deny";
4
5
  export type Severity = "destructive" | "risky" | "sensitive" | "deny";
5
6
  export type ViolationSource = "pattern" | "rules-guard" | "security-guard" | "slop-guard";
@@ -171,11 +172,12 @@ export interface EvaluateOptions {
171
172
  slop?: SlopGuardConfig | undefined;
172
173
  security?: SecurityConfig | undefined;
173
174
  /**
174
- * The rules guard's switch. The active rules file rides every judged action request, so `enabled: false` is what keeps
175
- * that content on this machine. Omitted, the file is sent as before, so a library caller that passes no rules config
176
- * keeps today's behaviour.
175
+ * The rules guard's switch. The rules the active config resolves ride every judged action request, so
176
+ * `enabled: false` is what keeps that content on this machine. `files` and `fallback` say which documents
177
+ * those are; omitting them sends what the config would resolve unfiltered, so a library caller that passes
178
+ * no rules config keeps today's behaviour.
177
179
  */
178
- rules?: Pick<RulesConfig, "enabled"> | undefined;
180
+ rules?: Pick<RulesConfig, "enabled"> & Partial<RulesSourceConfig> | undefined;
179
181
  /** This exact call was held earlier and the user has replied since: ask whether the reply approves it. */
180
182
  retryAfterHold?: boolean | undefined;
181
183
  /** Calls allowed in the previous turn: ask whether the user's latest message regrets one of them (rides this request). */
package/dist/guard.js CHANGED
@@ -877,8 +877,9 @@ export async function evaluateAction(action, options) {
877
877
  if (!judge)
878
878
  return withPlan({ level, source: "pattern", summary, patterns, reasons });
879
879
  // Resolve the rules file once per call for the Jev request state. With the rules guard off, no rules content leaves
880
- // the machine at all: no question names the field, so an absent one costs nothing.
881
- const resolved = options.rules?.enabled === false ? null : resolveRulesFile(action.cwd);
880
+ // the machine at all: no question names the field, so an absent one costs nothing. The config decides which files
881
+ // count, so the content sent here is the content the guard judges with.
882
+ const resolved = options.rules?.enabled === false ? null : resolveRulesFile(action.cwd, options.rules);
882
883
  const floorHits = builtInHits.length ? builtInHits.join("; ") : "none";
883
884
  const request = buildRequest(summary, action.task, { slop: options.slop?.enabled ?? false, approval: options.retryAfterHold ?? false, security: options.security?.enabled ?? false, context: action.context, previousActions: options.previousActions, plan, questions: options.questions, rules: resolved?.content, rulesSource: resolved?.source, violations: remainingViolations, floorHits });
884
885
  const result = await ask(judge, request, { timeoutMs: config.timeoutMs, ...(options.signal ? { signal: options.signal } : {}) });
package/dist/index.d.ts CHANGED
@@ -37,7 +37,7 @@ export type { WidgetConfig, WidgetPlacement, WidgetBarMode, Tokens } from "./wid
37
37
  export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails, runawayDetails, rulesDetails } from "./trace.js";
38
38
  export type { TraceEntry, GuardName } from "./trace.js";
39
39
  export { parseRules, condense, isRuleShaped, globToRegExp, matchGlob, projectPath, RuleStore, rulesFor, describeRuleSet, describeTarget, buildRulesRequest, ruleQuestion, evaluateRules, skipReason, rulesSteer, formatRules, pathNotes, pathNoteSteer, RulesGuard, RULES_FILE, FALLBACK_FILES, MAX_RULES, AGGREGATE_QUESTION, LOCATOR_QUESTION } from "./rules.js";
40
- export type { Rule, RuleSet, RuleOutcome, RuleScore, RuleFinding, RulesVerdict, RulesOptions, RulesTarget, EditView, PathNote, RulesCallRef } from "./rules.js";
40
+ export type { Rule, RuleSet, RuleOutcome, RuleScore, RuleFinding, RulesVerdict, RulesOptions, RulesTarget, EditView, PathNote, RulesCallRef, RulesTier, RulesSourceConfig } from "./rules.js";
41
41
  export { resolveRulesFile, extractRules, checkPiWardenMissing } from "./rules-file.js";
42
42
  export type { ResolvedRulesFile } from "./rules-file.js";
43
43
  export { writeStarterRules, generateStarterRules, detectProjectType, buildProjectContext, buildInitPrompt } from "./init.js";
@@ -1,18 +1,33 @@
1
+ import type { RulesConfig } from "./config.js";
2
+ import type { RulesSourceConfig } from "./rules.js";
1
3
  /**
2
- * Resolved active rules file for escalation and context.
3
- * Resolution order: pi-warden.md → AGENTS.md → CLAUDE.md → README.md.
4
+ * The rules content for escalation and context. The order is the rules guard's: pi-warden.md alone, else the files in
5
+ * `rules.files`, in order, else the fallback names while `rules.fallback` is on. An empty document counts where the
6
+ * guard counts it: a configured file is a source whatever it holds, a fallback document has to say something.
4
7
  */
5
8
  export interface ResolvedRulesFile {
6
- path: string;
7
9
  content: string;
10
+ /** The file name, or the configured names joined by commas, as the config spells them. */
8
11
  source: string;
9
12
  }
10
13
  /** Token-aware truncation: extract heading blocks, cap at maxTokens (1 token ≈ 4 chars). */
11
14
  export declare function extractRules(content: string, maxTokens?: number): string;
12
- /** Resolve the active rules file. Returns the first existing file, or null. Content is redacted before it leaves this machine. */
13
- export declare function resolveRulesFile(cwd: string): ResolvedRulesFile | null;
14
- /** Returns missing status and the fallback source if pi-warden.md is absent. */
15
- export declare function checkPiWardenMissing(cwd: string): {
15
+ /**
16
+ * Resolve the rules content the escalation request carries. Returns null when the config reaches no document.
17
+ *
18
+ * `RuleStore` judges with the documents this returns, in this order, so the request names the rules in force. What
19
+ * differs is the cap: the request carries one budget for the whole field (`MAX_CHARS`) where the guard spends
20
+ * `rules.maxChars` per resolution.
21
+ */
22
+ export declare function resolveRulesFile(cwd: string, config?: Partial<RulesSourceConfig>): ResolvedRulesFile | null;
23
+ /**
24
+ * Whether the first-run "no project rules" notice should fire, and which document is being judged.
25
+ *
26
+ * RuleStore.loadTiered owns the resolution order, so ask it which tier answered instead of walking
27
+ * the filesystem here. Inferring one warns while the configured files are the ones in force, names a
28
+ * fallback document the config turned off, or names a `rules.files` entry after the file it points at.
29
+ */
30
+ export declare function checkPiWardenMissing(cwd: string, config: Pick<RulesConfig, "files" | "fallback" | "maxChars">): {
16
31
  missing: boolean;
17
- fallbackSource?: string;
32
+ fallbackSource?: string | undefined;
18
33
  };
@@ -1,12 +1,20 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { resolve } from "node:path";
3
3
  import { redact } from "./redact.js";
4
- import { RULES_FILE, FALLBACK_FILES } from "./rules.js";
5
- const RULES_CANDIDATES = [
6
- { path: RULES_FILE, source: RULES_FILE },
7
- ...FALLBACK_FILES.map(f => ({ path: f, source: f })),
8
- ];
4
+ import { RuleStore, RULES_FILE, FALLBACK_FILES } from "./rules.js";
9
5
  const MAX_CHARS = 16_000; // ~4000 tokens
6
+ /** A rules file's text, or undefined when it is missing, unreadable, or a directory. */
7
+ function readRules(path) {
8
+ if (!existsSync(path))
9
+ return undefined;
10
+ try {
11
+ return readFileSync(path, "utf8");
12
+ }
13
+ catch {
14
+ // Unreadable or non-file entries (e.g., directories matching a candidate name) are skipped.
15
+ return undefined;
16
+ }
17
+ }
10
18
  /** Token-aware truncation: extract heading blocks, cap at maxTokens (1 token ≈ 4 chars). */
11
19
  export function extractRules(content, maxTokens = 4000) {
12
20
  const lines = content.split("\n");
@@ -41,32 +49,54 @@ export function extractRules(content, maxTokens = 4000) {
41
49
  }
42
50
  return result.join("\n\n");
43
51
  }
44
- /** Resolve the active rules file. Returns the first existing file, or null. Content is redacted before it leaves this machine. */
45
- export function resolveRulesFile(cwd) {
46
- for (const candidate of RULES_CANDIDATES) {
47
- const fullPath = join(cwd, candidate.path);
48
- if (existsSync(fullPath)) {
49
- let raw;
50
- try {
51
- raw = readFileSync(fullPath, "utf8");
52
- }
53
- catch {
54
- // Unreadable or non-file entries (e.g., directories matching a candidate name) are skipped.
55
- continue;
56
- }
57
- const content = raw.length > MAX_CHARS ? redact(extractRules(raw)) : redact(raw);
58
- return { path: fullPath, content, source: candidate.source };
59
- }
52
+ /** Content is redacted before it leaves this machine, and capped once it is past the budget. */
53
+ function redactAndCap(source, raw) {
54
+ return { content: raw.length > MAX_CHARS ? redact(extractRules(raw)) : redact(raw), source };
55
+ }
56
+ /**
57
+ * Resolve the rules content the escalation request carries. Returns null when the config reaches no document.
58
+ *
59
+ * `RuleStore` judges with the documents this returns, in this order, so the request names the rules in force. What
60
+ * differs is the cap: the request carries one budget for the whole field (`MAX_CHARS`) where the guard spends
61
+ * `rules.maxChars` per resolution.
62
+ */
63
+ export function resolveRulesFile(cwd, config) {
64
+ const root = resolve(cwd, RULES_FILE);
65
+ const rootContent = readRules(root);
66
+ if (rootContent !== undefined)
67
+ return redactAndCap(RULES_FILE, rootContent);
68
+ const configured = [];
69
+ for (const entry of config?.files ?? []) {
70
+ const content = readRules(resolve(cwd, entry));
71
+ if (content !== undefined)
72
+ configured.push({ source: entry, content });
73
+ }
74
+ if (configured.length) {
75
+ return redactAndCap(configured.map(file => file.source).join(", "), configured.map(file => file.content).join("\n\n"));
76
+ }
77
+ if (config?.fallback === false)
78
+ return null;
79
+ for (const file of FALLBACK_FILES) {
80
+ const content = readRules(resolve(cwd, file));
81
+ // A blank fallback document is not a source: the store walks on to the next name, so this does too.
82
+ if (content?.trim())
83
+ return redactAndCap(file, content);
60
84
  }
61
85
  return null;
62
86
  }
63
- /** Returns missing status and the fallback source if pi-warden.md is absent. */
64
- export function checkPiWardenMissing(cwd) {
65
- if (existsSync(join(cwd, "pi-warden.md")))
66
- return { missing: false };
67
- for (const candidate of RULES_CANDIDATES.slice(1)) {
68
- if (existsSync(join(cwd, candidate.path)))
69
- return { missing: true, fallbackSource: candidate.source };
70
- }
71
- return { missing: true };
87
+ const missingStore = new RuleStore();
88
+ /**
89
+ * Whether the first-run "no project rules" notice should fire, and which document is being judged.
90
+ *
91
+ * RuleStore.loadTiered owns the resolution order, so ask it which tier answered instead of walking
92
+ * the filesystem here. Inferring one warns while the configured files are the ones in force, names a
93
+ * fallback document the config turned off, or names a `rules.files` entry after the file it points at.
94
+ */
95
+ export function checkPiWardenMissing(cwd, config) {
96
+ const { set, tier } = missingStore.loadTiered(cwd, config);
97
+ if (tier === "none")
98
+ return { missing: true };
99
+ // A project that has a rules source of its own is not missing one, empty file or not: /warden init
100
+ // would name the file the project already has, and a pi-warden.md would shadow a bound `rules.files`.
101
+ return tier === "fallback" ? { missing: true, fallbackSource: set?.sources[0] } : { missing: false };
72
102
  }
package/dist/rules.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { RulesConfig } from "./config.js";
5
5
  * agent is steered, never held, with the violated rule. Path scoping and sensitive-path notes are code only.
6
6
  *
7
7
  * Sources, in order: `pi-warden.md` at the project root; else the files in `rules.files`; else, with `rules.fallback`, the
8
- * first of README.md, CLAUDE.md, AGENTS.md as one aggregate rule set. Files are re-read when their mtime or size changes.
8
+ * first of AGENTS.md, CLAUDE.md, README.md as one aggregate rule set. Files are re-read when their mtime or size changes.
9
9
  */
10
10
  export interface Rule {
11
11
  id: string;
@@ -27,6 +27,13 @@ export interface RuleSet {
27
27
  proseOnly?: boolean;
28
28
  }
29
29
  export declare const RULES_FILE = "pi-warden.md";
30
+ /** Which tier of the resolution order answered; `none` means no source resolved. */
31
+ export type RulesTier = "root" | "configured" | "fallback" | "none";
32
+ /**
33
+ * The parts of `rules` that decide which document is in force. A caller that cannot supply them gets
34
+ * the default fallback behaviour, the same as a project config that omits the keys.
35
+ */
36
+ export type RulesSourceConfig = Pick<RulesConfig, "files" | "fallback">;
30
37
  export declare const FALLBACK_FILES: string[];
31
38
  /** TypeSafe answers at most 32 questions per request; one is kept for the edit locator. */
32
39
  export declare const MAX_RULES = 31;
@@ -54,6 +61,11 @@ export declare function projectPath(target: string, cwd: string): string | undef
54
61
  export declare class RuleStore {
55
62
  private readonly cache;
56
63
  private read;
64
+ /** Where a rule set came from; the order is the resolution order. */
65
+ loadTiered(cwd: string, config: Pick<RulesConfig, "files" | "fallback" | "maxChars">): {
66
+ set: RuleSet | undefined;
67
+ tier: RulesTier;
68
+ };
57
69
  /** The active rule set for a project, or undefined when no source exists. Never throws. */
58
70
  load(cwd: string, config: Pick<RulesConfig, "files" | "fallback" | "maxChars">): RuleSet | undefined;
59
71
  }
package/dist/rules.js CHANGED
@@ -218,26 +218,30 @@ export class RuleStore {
218
218
  return undefined;
219
219
  }
220
220
  }
221
- /** The active rule set for a project, or undefined when no source exists. Never throws. */
222
- load(cwd, config) {
221
+ /** Where a rule set came from; the order is the resolution order. */
222
+ loadTiered(cwd, config) {
223
223
  const root = this.read(resolve(cwd, RULES_FILE));
224
224
  if (root !== undefined)
225
- return ruleSet([RULES_FILE], [root], config.maxChars);
225
+ return { set: ruleSet([RULES_FILE], [root], config.maxChars), tier: "root" };
226
226
  const configured = config.files.map(file => ({ file, text: this.read(resolve(cwd, file)) })).filter((entry) => entry.text !== undefined);
227
227
  if (configured.length)
228
- return ruleSet(configured.map(entry => entry.file), configured.map(entry => entry.text), config.maxChars);
228
+ return { set: ruleSet(configured.map(entry => entry.file), configured.map(entry => entry.text), config.maxChars), tier: "configured" };
229
229
  if (!config.fallback)
230
- return undefined;
230
+ return { set: undefined, tier: "none" };
231
231
  for (const file of FALLBACK_FILES) {
232
232
  const text = this.read(resolve(cwd, file));
233
233
  if (text !== undefined && text.trim()) {
234
234
  const redacted = redact(text);
235
235
  if (isRuleShaped(text))
236
- return { sources: [file], rules: [], aggregate: condense(redacted, config.maxChars), dropped: 0 };
237
- return { sources: [file], rules: [], dropped: 0, proseOnly: true };
236
+ return { set: { sources: [file], rules: [], aggregate: condense(redacted, config.maxChars), dropped: 0 }, tier: "fallback" };
237
+ return { set: { sources: [file], rules: [], dropped: 0, proseOnly: true }, tier: "fallback" };
238
238
  }
239
239
  }
240
- return undefined;
240
+ return { set: undefined, tier: "none" };
241
+ }
242
+ /** The active rule set for a project, or undefined when no source exists. Never throws. */
243
+ load(cwd, config) {
244
+ return this.loadTiered(cwd, config).set;
241
245
  }
242
246
  }
243
247
  function ruleSet(sources, texts, maxChars) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "0.40.1",
3
+ "version": "0.40.2",
4
4
  "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.",
5
5
  "type": "module",
6
6
  "license": "MIT",