@lazco/pi-rules 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,7 @@ It reads the same rule files, so you can switch without a change to your rules.
17
17
 
18
18
  - **Claude Code compatible.** Reads `.claude/rules/` and `.agents/rules/` next to `.pi/rules/`, so one set of files serves both tools.
19
19
  - **Rules for matching files.** A rule with `paths` loads only when the agent reads, edits, or writes a matching file.
20
+ - **User rules for any file.** A rule in your home directory also loads for files outside the project, such as `/tmp/build.sh` or `~/.zshrc`.
20
21
  - **Rule before change.** An edit or a write to a matching file waits until the agent has the rule, then the agent tries again.
21
22
  - **Linked skills.** A rule can load full pi [skills](#skills) when it loads.
22
23
  - **Six sources in one list.** Project and user directories merge by file name, and the first source wins. See [Rule files](#rule-files).
@@ -119,7 +120,19 @@ A file without frontmatter is a rule for every session.
119
120
  | `events` | `tool_call: <tool>` or a list of tools | The rule loads only for these tools: `read`, `edit`, `write`. |
120
121
  | `skills` | a skill name or a list of names | These skills load with the rule. |
121
122
 
122
- Globs are relative to `<project>`, use `/`, and match dotfiles.
123
+ Globs use `/` and match dotfiles.
124
+ How a glob matches depends on the directory of the rule:
125
+
126
+ | Rule in | Applies to | Glob |
127
+ | --- | --- | --- |
128
+ | a project directory (1 to 3) | files inside `<project>` | relative to `<project>` |
129
+ | a user directory (4 to 6) | any file | `/…` is an absolute path. `~/…` starts at your home directory. Other globs match the path inside `<project>` or the absolute path. |
130
+
131
+ For example, a user rule with `paths: "**/*.sh"` loads for `/tmp/build.sh` and for `scripts/build.sh` in any project.
132
+ A user rule with `paths: "~/Projects/shop/**"` loads only for files of that project.
133
+
134
+ When you start pi in your home directory, `.agents/rules/` and `~/.agents/rules/` are the same directory.
135
+ Its rules then count as user rules.
123
136
 
124
137
  ### Tools
125
138
 
@@ -188,7 +201,7 @@ pi-rules reads `rules.json` when a session starts and after a compaction.
188
201
 
189
202
  ## Limitations
190
203
 
191
- - **Files outside the project.** A `read`, `edit`, or `write` of a file outside `<project>` does not load a rule, also a user rule.
204
+ - **Project rules of other projects.** A file in another project loads only user rules, not the rules in the directories of that project.
192
205
  - **Shell commands.** A file that a `bash` command changes does not load a rule.
193
206
  - **Changed rules.** A rule that already loaded does not load again after you change its file, until the next compaction.
194
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazco/pi-rules",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pi extension: load Markdown rules into the agent context for the whole project or for matching files",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
package/src/discovery.ts CHANGED
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import Type from "typebox";
6
6
  import { Compile } from "typebox/compile";
7
7
  import { parseDocument } from "yaml";
8
- import type { DiscoveryResult, Rule, RuleDiagnostic, RuleEvents, RuleToolName } from "./types.ts";
8
+ import type { DiscoveryResult, Rule, RuleDiagnostic, RuleEvents, RuleScope, RuleToolName } from "./types.ts";
9
9
 
10
10
  const FRONTMATTER_RE = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
11
11
  const RULE_TOOL_NAMES = new Set<RuleToolName>(["read", "edit", "write"]);
@@ -36,6 +36,7 @@ export const DEFAULT_RULE_SOURCES: RuleSource[] = [
36
36
  interface RuleRoot {
37
37
  directory: string;
38
38
  label: string;
39
+ scope: RuleScope;
39
40
  }
40
41
 
41
42
  interface DiscoveryOptions {
@@ -96,7 +97,9 @@ export async function loadRuleConfig(cwd: string, options: DiscoveryOptions = {}
96
97
  export async function discoverRules(cwd: string, options: DiscoveryOptions = {}): Promise<DiscoveryResult> {
97
98
  const home = options.home ?? os.homedir();
98
99
  const env = options.env ?? process.env;
99
- const roots = (options.sources ?? DEFAULT_RULE_SOURCES).map((source) => resolveRuleRoot(source, cwd, env, home));
100
+ const roots = await withoutUserAliases(
101
+ (options.sources ?? DEFAULT_RULE_SOURCES).map((source) => resolveRuleRoot(source, cwd, env, home)),
102
+ );
100
103
 
101
104
  const rules: Rule[] = [];
102
105
  const diagnostics: RuleDiagnostic[] = [];
@@ -111,7 +114,7 @@ export async function discoverRules(cwd: string, options: DiscoveryOptions = {})
111
114
 
112
115
  const sourcePath = path.join(root.directory, ...relativePath.split("/"));
113
116
  const sourceLabel = `${root.label}/${relativePath}`;
114
- const parsed = await parseRuleFile(sourcePath, sourceLabel);
117
+ const parsed = await parseRuleFile(sourcePath, sourceLabel, root.scope);
115
118
  if ("rule" in parsed) {
116
119
  rules.push(parsed.rule);
117
120
  } else {
@@ -123,7 +126,7 @@ export async function discoverRules(cwd: string, options: DiscoveryOptions = {})
123
126
  return { rules, diagnostics };
124
127
  }
125
128
 
126
- export async function parseRuleFile(sourcePath: string, sourceLabel: string): Promise<ParseResult> {
129
+ export async function parseRuleFile(sourcePath: string, sourceLabel: string, scope: RuleScope): Promise<ParseResult> {
127
130
  let content: string;
128
131
  try {
129
132
  content = await readFile(sourcePath, "utf8");
@@ -138,7 +141,7 @@ export async function parseRuleFile(sourcePath: string, sourceLabel: string): Pr
138
141
  return { diagnostic: { sourceLabel, reason: "unclosed frontmatter" } };
139
142
  }
140
143
  const body = content.replace(/^\uFEFF/, "");
141
- return { rule: { id: sourcePath, sourcePath, sourceLabel, body, hash: hashBody(body) } };
144
+ return { rule: { id: sourcePath, sourcePath, sourceLabel, scope, body, hash: hashBody(body) } };
142
145
  }
143
146
 
144
147
  let raw: unknown;
@@ -180,7 +183,7 @@ export async function parseRuleFile(sourcePath: string, sourceLabel: string): Pr
180
183
  }
181
184
 
182
185
  const body = content.slice(frontmatter[0].length);
183
- const rule: Rule = { id: sourcePath, sourcePath, sourceLabel, body, hash: hashBody(body) };
186
+ const rule: Rule = { id: sourcePath, sourcePath, sourceLabel, scope, body, hash: hashBody(body) };
184
187
  if (paths) rule.paths = paths;
185
188
  if (events) rule.events = events;
186
189
  if (skills) rule.skills = skills;
@@ -234,19 +237,39 @@ function resolvePiCodingAgentDirectory(env: NodeJS.ProcessEnv, home: string): st
234
237
 
235
238
  function resolveRuleRoot(source: RuleSource, cwd: string, env: NodeJS.ProcessEnv, home: string): RuleRoot {
236
239
  if (source.scope === "repo") {
237
- return { directory: path.join(cwd, `.${source.kind}`, "rules"), label: `.${source.kind}/rules` };
240
+ return { directory: path.join(cwd, `.${source.kind}`, "rules"), label: `.${source.kind}/rules`, scope: "repo" };
238
241
  }
239
242
 
240
243
  if (source.kind === "pi") {
241
244
  const directory = path.join(resolvePiCodingAgentDirectory(env, home), "rules");
242
- return { directory, label: labelDirectory(directory, home) };
245
+ return { directory, label: labelDirectory(directory, home), scope: "user" };
243
246
  }
244
247
  if (source.kind === "agents") {
245
- return { directory: path.join(home, ".agents", "rules"), label: "~/.agents/rules" };
248
+ return { directory: path.join(home, ".agents", "rules"), label: "~/.agents/rules", scope: "user" };
246
249
  }
247
250
 
248
251
  const directory = path.join(env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"), "rules");
249
- return { directory, label: labelDirectory(directory, home) };
252
+ return { directory, label: labelDirectory(directory, home), scope: "user" };
253
+ }
254
+
255
+ /**
256
+ * Drop a project root that is a user root, such as `.agents/rules` when pi starts in the home directory.
257
+ * Its rules then load once, from the user root, with user scope.
258
+ */
259
+ async function withoutUserAliases(roots: RuleRoot[]): Promise<RuleRoot[]> {
260
+ const realDirectories = await Promise.all(roots.map((root) => realDirectory(root.directory)));
261
+ const userDirectories = new Set(
262
+ roots.flatMap((root, index) => (root.scope === "user" ? [realDirectories[index]] : [])),
263
+ );
264
+ return roots.filter((root, index) => root.scope === "user" || !userDirectories.has(realDirectories[index]));
265
+ }
266
+
267
+ async function realDirectory(directory: string): Promise<string> {
268
+ try {
269
+ return await realpath(directory);
270
+ } catch {
271
+ return path.resolve(directory);
272
+ }
250
273
  }
251
274
 
252
275
  function invalidConfig(configPath: string, sourceLabel: string, reason: string): RuleConfigResult {
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import { Text } from "@earendil-works/pi-tui";
10
10
  import { DEFAULT_RULE_SOURCES, discoverRules, loadRuleConfig, type RuleSource } from "./discovery.ts";
11
- import { extractTarget, ruleMatchesTarget, ruleMatchesToolCallEvent } from "./matching.ts";
11
+ import { extractTarget, ruleAppliesToTarget, ruleMatchesToolCallEvent, targetLabel } from "./matching.ts";
12
12
  import { addInjection, collectInjectedState, CUSTOM_MESSAGE_TYPE, type InjectedState } from "./state.ts";
13
13
  import type { PendingRule, Rule, RuleInjectionDetails } from "./types.ts";
14
14
 
@@ -145,17 +145,18 @@ export function makeExtension() {
145
145
  rules = await reloadRules(ctx.cwd);
146
146
  const state = branchState(ctx);
147
147
 
148
+ const label = targetLabel(target);
148
149
  let shouldBlock = false;
149
150
  for (const rule of rules) {
150
151
  if (state.rules.has(rule.id)) continue;
151
- if (rule.paths && !ruleMatchesTarget(rule, target)) continue;
152
+ if (!ruleAppliesToTarget(rule, target)) continue;
152
153
  if (!ruleMatchesToolCallEvent(rule, event.toolName)) continue;
153
154
  if (event.toolName !== "read") shouldBlock = true;
154
155
  const pending = pendingRules.get(rule.id);
155
156
  if (pending) {
156
- if (isScoped(rule)) pending.targets.add(target);
157
+ if (isScoped(rule)) pending.targets.add(label);
157
158
  } else {
158
- pendingRules.set(rule.id, { rule, targets: new Set(isScoped(rule) ? [target] : []) });
159
+ pendingRules.set(rule.id, { rule, targets: new Set(isScoped(rule) ? [label] : []) });
159
160
  }
160
161
  }
161
162
  if (!shouldBlock) return;
package/src/matching.ts CHANGED
@@ -1,41 +1,77 @@
1
+ import os from "node:os";
1
2
  import path from "node:path";
2
3
  import picomatch from "picomatch";
3
- import type { ToolCallEvent, ToolResultEvent } from "@earendil-works/pi-coding-agent";
4
+ import type { ToolCallEvent } from "@earendil-works/pi-coding-agent";
4
5
  import type { Rule } from "./types.ts";
5
6
 
6
7
  const FILE_TOOLS = new Set(["read", "edit", "write"]);
7
8
 
8
- export function extractTarget(event: ToolCallEvent | ToolResultEvent, cwd: string): string | undefined {
9
- if (("isError" in event && event.isError) || !FILE_TOOLS.has(event.toolName)) return undefined;
9
+ /** A file that a tool call reads or changes. */
10
+ export interface ToolTarget {
11
+ /** Absolute path with `/` separators. */
12
+ absolutePath: string;
13
+ /** Path relative to the working directory, only when the file is inside it. */
14
+ relativePath?: string;
15
+ }
16
+
17
+ export function extractTarget(event: ToolCallEvent, cwd: string, home = os.homedir()): ToolTarget | undefined {
18
+ if (!FILE_TOOLS.has(event.toolName)) return undefined;
10
19
  const rawPath = (event.input as { path?: unknown }).path;
11
20
  if (typeof rawPath !== "string" || rawPath.trim().length === 0) return undefined;
12
-
13
21
  const withoutAt = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
14
- const absolutePath = path.resolve(cwd, withoutAt);
22
+ return resolveTarget(withoutAt, cwd, home);
23
+ }
24
+
25
+ export function resolveTarget(rawPath: string, cwd: string, home = os.homedir()): ToolTarget {
26
+ const expanded = rawPath === "~" || rawPath.startsWith("~/") ? path.join(home, rawPath.slice(1)) : rawPath;
27
+ const absolutePath = path.resolve(cwd, expanded);
15
28
  const relativePath = path.relative(cwd, absolutePath);
16
- if (
17
- relativePath.length === 0 ||
18
- relativePath === ".." ||
19
- relativePath.startsWith(`..${path.sep}`) ||
20
- path.isAbsolute(relativePath)
21
- ) {
22
- return undefined;
23
- }
24
- return toPosixPath(relativePath);
29
+ const inside =
30
+ relativePath.length > 0 &&
31
+ relativePath !== ".." &&
32
+ !relativePath.startsWith(`..${path.sep}`) &&
33
+ !path.isAbsolute(relativePath);
34
+ return inside
35
+ ? { absolutePath: toPosixPath(absolutePath), relativePath: toPosixPath(relativePath) }
36
+ : { absolutePath: toPosixPath(absolutePath) };
25
37
  }
26
38
 
27
- export function ruleMatchesTarget(rule: Rule, target: string): boolean {
28
- if (!rule.paths) return false;
29
- const patterns = rule.paths.map(normalizePattern);
30
- return picomatch.isMatch(target, patterns, { dot: true });
39
+ /** The text that the injection message shows for a target. */
40
+ export function targetLabel(target: ToolTarget): string {
41
+ return target.relativePath ?? target.absolutePath;
42
+ }
43
+
44
+ /**
45
+ * A project rule applies only inside the working directory, and its globs are relative to it.
46
+ * A user rule applies to any file. Its globs can be absolute (`/…`), start at the home directory (`~/…`),
47
+ * or be relative: a relative glob matches the path inside the working directory or the absolute path.
48
+ */
49
+ export function ruleAppliesToTarget(rule: Rule, target: ToolTarget, home = os.homedir()): boolean {
50
+ if (rule.scope === "repo" && target.relativePath === undefined) return false;
51
+ if (!rule.paths) return true;
52
+ return rule.paths.some((pattern) => patternMatches(pattern, rule, target, home));
31
53
  }
32
54
 
33
55
  export function ruleMatchesToolCallEvent(rule: Rule, toolName: string): boolean {
34
56
  return !rule.events || rule.events.tool_call.some((name) => name === toolName);
35
57
  }
36
58
 
37
- function normalizePattern(pattern: string): string {
38
- return toPosixPath(pattern).replace(/^\.\//, "");
59
+ function patternMatches(pattern: string, rule: Rule, target: ToolTarget, home: string): boolean {
60
+ const normalized = toPosixPath(pattern).replace(/^\.\//, "");
61
+ if (rule.scope === "repo") return isMatch(target.relativePath ?? "", normalized);
62
+ if (normalized.startsWith("/")) return isMatch(target.absolutePath, normalized);
63
+ if (normalized.startsWith("~/")) {
64
+ // Compare in `~/` form, so glob characters in the home path cannot change the pattern.
65
+ const homePrefix = `${toPosixPath(home)}/`;
66
+ if (!target.absolutePath.startsWith(homePrefix)) return false;
67
+ return isMatch(`~/${target.absolutePath.slice(homePrefix.length)}`, normalized);
68
+ }
69
+ if (target.relativePath !== undefined && isMatch(target.relativePath, normalized)) return true;
70
+ return isMatch(target.absolutePath.replace(/^\//, ""), normalized);
71
+ }
72
+
73
+ function isMatch(candidate: string, pattern: string): boolean {
74
+ return picomatch.isMatch(candidate, pattern, { dot: true });
39
75
  }
40
76
 
41
77
  function toPosixPath(value: string): string {
package/src/types.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export type RuleToolName = "read" | "edit" | "write";
2
2
 
3
+ /** `repo` rules come from the project and apply inside it. `user` rules come from the home directory and apply to any file. */
4
+ export type RuleScope = "repo" | "user";
5
+
3
6
  export interface RuleEvents {
4
7
  tool_call: RuleToolName[];
5
8
  }
@@ -8,6 +11,7 @@ export interface Rule {
8
11
  id: string;
9
12
  sourcePath: string;
10
13
  sourceLabel: string;
14
+ scope: RuleScope;
11
15
  body: string;
12
16
  /** Hash of the body, so a changed rule file can be told apart from the copy the model already has. */
13
17
  hash: string;