@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 +15 -2
- package/package.json +1 -1
- package/src/discovery.ts +33 -10
- package/src/index.ts +5 -4
- package/src/matching.ts +56 -20
- package/src/types.ts +4 -0
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
|
|
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
|
-
- **
|
|
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
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 =
|
|
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,
|
|
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 (
|
|
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(
|
|
157
|
+
if (isScoped(rule)) pending.targets.add(label);
|
|
157
158
|
} else {
|
|
158
|
-
pendingRules.set(rule.id, { rule, targets: new Set(isScoped(rule) ? [
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
relativePath.length
|
|
18
|
-
relativePath
|
|
19
|
-
relativePath.startsWith(`..${path.sep}`)
|
|
20
|
-
path.isAbsolute(relativePath)
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
38
|
-
|
|
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;
|