@lazco/pi-rules 0.7.0 → 0.9.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).
@@ -86,6 +87,9 @@ pi-rules reads the injected rules from the messages that the model sees, so it s
86
87
  - resume a session, run `/reload`, or fork a session,
87
88
  - compact the session. The rules that the summary replaced load again when they are needed.
88
89
 
90
+ When you change a rule file that already loaded, also only its frontmatter, the new version loads before the next model call, marked `(updated)`.
91
+ When you delete it, the model gets a short notice that the rule no longer applies.
92
+
89
93
  ## Rule files
90
94
 
91
95
  A rule is a Markdown file.
@@ -119,7 +123,19 @@ A file without frontmatter is a rule for every session.
119
123
  | `events` | `tool_call: <tool>` or a list of tools | The rule loads only for these tools: `read`, `edit`, `write`. |
120
124
  | `skills` | a skill name or a list of names | These skills load with the rule. |
121
125
 
122
- Globs are relative to `<project>`, use `/`, and match dotfiles.
126
+ Globs use `/` and match dotfiles.
127
+ How a glob matches depends on the directory of the rule:
128
+
129
+ | Rule in | Applies to | Glob |
130
+ | --- | --- | --- |
131
+ | a project directory (1 to 3) | files inside `<project>` | relative to `<project>` |
132
+ | 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. |
133
+
134
+ For example, a user rule with `paths: "**/*.sh"` loads for `/tmp/build.sh` and for `scripts/build.sh` in any project.
135
+ A user rule with `paths: "~/Projects/shop/**"` loads only for files of that project.
136
+
137
+ When you start pi in your home directory, `.agents/rules/` and `~/.agents/rules/` are the same directory.
138
+ Its rules then count as user rules.
123
139
 
124
140
  ### Tools
125
141
 
@@ -188,9 +204,8 @@ pi-rules reads `rules.json` when a session starts and after a compaction.
188
204
 
189
205
  ## Limitations
190
206
 
191
- - **Files outside the project.** A `read`, `edit`, or `write` of a file outside `<project>` does not load a rule, also a user rule.
207
+ - **Project rules of other projects.** A file in another project loads only user rules, not the rules in the directories of that project.
192
208
  - **Shell commands.** A file that a `bash` command changes does not load a rule.
193
- - **Changed rules.** A rule that already loaded does not load again after you change its file, until the next compaction.
194
209
 
195
210
  ## Contributing
196
211
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazco/pi-rules",
3
- "version": "0.7.0",
3
+ "version": "0.9.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: hashContent(content) } };
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: hashContent(content) };
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 {
@@ -306,8 +329,8 @@ function labelDirectory(directory: string, home: string): string {
306
329
  return directory.split(path.sep).join("/");
307
330
  }
308
331
 
309
- function hashBody(body: string): string {
310
- return createHash("sha256").update(body).digest("hex").slice(0, 16);
332
+ function hashContent(content: string): string {
333
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
311
334
  }
312
335
 
313
336
  function getErrorCode(error: unknown): string {
package/src/index.ts CHANGED
@@ -8,9 +8,9 @@ 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
- import type { PendingRule, Rule, RuleInjectionDetails } from "./types.ts";
13
+ import type { DueRule, PendingRule, RemovedRule, Rule, RuleInjectionDetails } from "./types.ts";
14
14
 
15
15
  export { CUSTOM_MESSAGE_TYPE };
16
16
 
@@ -38,9 +38,19 @@ export function makeExtension() {
38
38
 
39
39
  pi.registerMessageRenderer<RuleInjectionDetails>(CUSTOM_MESSAGE_TYPE, (message, { expanded }, theme) => {
40
40
  const details = message.details;
41
- const sourceLines = (details?.sources ?? ["(unknown)"]).map((source) => `↳ ${source}`).join("\n");
41
+ const updated = new Set(details?.updated ?? []);
42
+ const sourceLines = (details?.sources ?? ["(unknown)"])
43
+ .map((source) => `↳ ${source}${updated.has(source) ? " (updated)" : ""}`)
44
+ .join("\n");
42
45
  const skillLines = (details?.skills ?? []).map((skill) => `↳ ${skill}`).join("\n");
43
- const summary = `Loaded rules:\n${sourceLines}${skillLines ? `\nLoaded skills:\n${skillLines}` : ""}`;
46
+ const removedLines = (details?.removed ?? []).map((rule) => `↳ ${rule.label}`).join("\n");
47
+ const summary = [
48
+ sourceLines ? `Loaded rules:\n${sourceLines}` : "",
49
+ skillLines ? `Loaded skills:\n${skillLines}` : "",
50
+ removedLines ? `Removed rules:\n${removedLines}` : "",
51
+ ]
52
+ .filter(Boolean)
53
+ .join("\n");
44
54
  if (!expanded) {
45
55
  return new Text(theme.fg("muted", summary), 0, 0);
46
56
  }
@@ -48,15 +58,12 @@ export function makeExtension() {
48
58
  const targetLines = details?.sources
49
59
  .map((source) => {
50
60
  const targets = details.targets[source] ?? [];
51
- return targets.length > 0 ? `${source} → ${targets.join(", ")}` : `${source} → unconditional`;
61
+ if (targets.length > 0) return `${source} → ${targets.join(", ")}`;
62
+ return `${source} → ${updated.has(source) ? "updated" : "unconditional"}`;
52
63
  })
53
64
  .join("\n");
54
65
  const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content, null, 2);
55
- return new Text(
56
- `${theme.fg("accent", "Loaded rules:")}\n${theme.fg("muted", sourceLines)}\n\n${theme.fg("dim", targetLines ?? "")}\n\n${content}`,
57
- 0,
58
- 0,
59
- );
66
+ return new Text(`${theme.fg("accent", summary)}\n\n${theme.fg("dim", targetLines ?? "")}\n\n${content}`, 0, 0);
60
67
  });
61
68
 
62
69
  const reloadConfig = async (cwd: string): Promise<void> => {
@@ -95,20 +102,42 @@ export function makeExtension() {
95
102
  return withInFlight(cachedBranch.state);
96
103
  };
97
104
 
98
- // Takes the matches of this turn and adds every unconditional rule that the model does not have.
99
- const takeDueRules = (state: InjectedState): PendingRule[] => {
100
- const due = [...pendingRules.values()].filter((entry) => !state.rules.has(entry.rule.id));
105
+ /**
106
+ * Takes the matches of this turn, and adds every unconditional rule that the model does not have,
107
+ * every rule whose file changed after the model got it, and every rule whose file is gone.
108
+ */
109
+ const takeDueRules = (state: InjectedState): { due: DueRule[]; removed: RemovedRule[] } => {
110
+ // A rule file can change between the tool call and the end of the turn, so take the newest version.
111
+ const current = new Map(rules.map((rule) => [rule.id, rule]));
112
+ const due: DueRule[] = [];
113
+ for (const entry of pendingRules.values()) {
114
+ const rule = current.get(entry.rule.id);
115
+ if (rule && !state.rules.has(rule.id)) due.push({ rule, targets: entry.targets, updated: false });
116
+ }
101
117
  pendingRules.clear();
102
118
  for (const rule of rules) {
103
- if (isScoped(rule) || state.rules.has(rule.id)) continue;
104
- if (!due.some((entry) => entry.rule.id === rule.id)) due.push({ rule, targets: new Set<string>() });
119
+ if (due.some((entry) => entry.rule.id === rule.id)) continue;
120
+ const known = state.rules.get(rule.id);
121
+ if (known) {
122
+ if (known.hash !== rule.hash) due.push({ rule, targets: new Set<string>(), updated: true });
123
+ } else if (!isScoped(rule)) {
124
+ due.push({ rule, targets: new Set<string>(), updated: false });
125
+ }
105
126
  }
106
- return due;
127
+ const discovered = new Set(rules.map((rule) => rule.id));
128
+ const removed = [...state.rules]
129
+ .filter(([id]) => !discovered.has(id))
130
+ .map(([id, known]) => ({ id, label: known.label }));
131
+ return { due, removed };
107
132
  };
108
133
 
109
- const buildInjection = async (entries: PendingRule[], state: InjectedState): Promise<RuleInjection> => {
110
- const skillBlocks = await loadSkillBlocks(entries, availableSkills, state.skills, reportWarning);
111
- return createInjection(entries, skillBlocks);
134
+ const buildInjection = async (
135
+ { due, removed }: { due: DueRule[]; removed: RemovedRule[] },
136
+ state: InjectedState,
137
+ ): Promise<RuleInjection | undefined> => {
138
+ if (due.length === 0 && removed.length === 0) return undefined;
139
+ const skillBlocks = await loadSkillBlocks(due, availableSkills, state.skills, reportWarning);
140
+ return createInjection(due, skillBlocks, removed);
112
141
  };
113
142
 
114
143
  pi.on("session_start", async (_event, ctx) => {
@@ -133,9 +162,9 @@ export function makeExtension() {
133
162
  if (!config.enabled) return;
134
163
  rules = await reloadRules(ctx.cwd);
135
164
  const state = branchState(ctx);
136
- const due = takeDueRules(state);
137
- if (due.length === 0) return;
138
- return { message: await buildInjection(due, state) };
165
+ const injection = await buildInjection(takeDueRules(state), state);
166
+ if (!injection) return;
167
+ return { message: injection };
139
168
  });
140
169
 
141
170
  pi.on("tool_call", async (event, ctx) => {
@@ -145,17 +174,18 @@ export function makeExtension() {
145
174
  rules = await reloadRules(ctx.cwd);
146
175
  const state = branchState(ctx);
147
176
 
177
+ const label = targetLabel(target);
148
178
  let shouldBlock = false;
149
179
  for (const rule of rules) {
150
180
  if (state.rules.has(rule.id)) continue;
151
- if (rule.paths && !ruleMatchesTarget(rule, target)) continue;
181
+ if (!ruleAppliesToTarget(rule, target)) continue;
152
182
  if (!ruleMatchesToolCallEvent(rule, event.toolName)) continue;
153
183
  if (event.toolName !== "read") shouldBlock = true;
154
184
  const pending = pendingRules.get(rule.id);
155
185
  if (pending) {
156
- if (isScoped(rule)) pending.targets.add(target);
186
+ if (isScoped(rule)) pending.targets.add(label);
157
187
  } else {
158
- pendingRules.set(rule.id, { rule, targets: new Set(isScoped(rule) ? [target] : []) });
188
+ pendingRules.set(rule.id, { rule, targets: new Set(isScoped(rule) ? [label] : []) });
159
189
  }
160
190
  }
161
191
  if (!shouldBlock) return;
@@ -172,9 +202,8 @@ export function makeExtension() {
172
202
  rules = await reloadRules(ctx.cwd);
173
203
  // The preview already holds the entries that earlier handlers proposed for this boundary.
174
204
  const state = withInFlight(collectInjectedState(event.context.contextEntries, event.context.pendingMessages));
175
- const due = takeDueRules(state);
176
- if (due.length === 0) return;
177
- const injection = await buildInjection(due, state);
205
+ const injection = await buildInjection(takeDueRules(state), state);
206
+ if (!injection) return;
178
207
  return { entries: [...event.entries, { type: "custom_message", ...injection }] };
179
208
  });
180
209
 
@@ -188,9 +217,8 @@ export function makeExtension() {
188
217
  rules = await reloadRules(ctx.cwd);
189
218
  // A compaction that retries the turn makes a model call before any turn boundary, so restore the rules now.
190
219
  const state = branchState(ctx);
191
- const due = takeDueRules(state);
192
- if (due.length === 0) return;
193
- const injection = await buildInjection(due, state);
220
+ const injection = await buildInjection(takeDueRules(state), state);
221
+ if (!injection) return;
194
222
  inFlight.set(injection.details.injectionId, injection.details);
195
223
  pi.sendMessage(injection);
196
224
  });
@@ -207,23 +235,34 @@ interface SkillBlock {
207
235
  content: string;
208
236
  }
209
237
 
210
- export function createInjection(entries: PendingRule[], skillBlocks: SkillBlock[] = []): RuleInjection {
238
+ export function createInjection(
239
+ entries: Array<PendingRule & { updated?: boolean }>,
240
+ skillBlocks: SkillBlock[] = [],
241
+ removed: RemovedRule[] = [],
242
+ ): RuleInjection {
211
243
  const sorted = [...entries].sort(comparePendingRules);
212
244
  const sources = sorted.map((entry) => entry.rule.sourceLabel);
213
245
  const targets = Object.fromEntries(sorted.map((entry) => [entry.rule.sourceLabel, [...entry.targets].sort()]));
214
- const ruleBody = sorted
215
- .map((entry) => `## Rule: ${entry.rule.sourceLabel}\n\n${entry.rule.body}`)
216
- .join("\n\n---\n\n");
217
- const skillBody = skillBlocks.map((skill) => skill.content).join("\n\n");
246
+ const blocks = [
247
+ ...sorted.map((entry) =>
248
+ entry.updated
249
+ ? `## Rule: ${entry.rule.sourceLabel} (updated)\n\nThis version replaces the earlier version of this rule.\n\n${entry.rule.body}`
250
+ : `## Rule: ${entry.rule.sourceLabel}\n\n${entry.rule.body}`,
251
+ ),
252
+ ...removed.map((rule) => `## Rule removed: ${rule.label}\n\nThis rule no longer applies. Stop following its earlier version.`),
253
+ ...skillBlocks.map((skill) => skill.content),
254
+ ];
218
255
 
219
256
  return {
220
257
  customType: CUSTOM_MESSAGE_TYPE,
221
- content: skillBody ? `${ruleBody}\n\n---\n\n${skillBody}` : ruleBody,
258
+ content: blocks.join("\n\n---\n\n"),
222
259
  display: true,
223
260
  details: {
224
261
  injectionId: randomUUID(),
225
- rules: sorted.map((entry) => ({ id: entry.rule.id, hash: entry.rule.hash })),
262
+ rules: sorted.map((entry) => ({ id: entry.rule.id, hash: entry.rule.hash, label: entry.rule.sourceLabel })),
263
+ removed,
226
264
  sources,
265
+ updated: sorted.filter((entry) => entry.updated).map((entry) => entry.rule.sourceLabel),
227
266
  targets,
228
267
  skills: skillBlocks.map((skill) => skill.name),
229
268
  },
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/state.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  import type { ProjectedSessionEntry } from "@earendil-works/pi-coding-agent";
2
- import type { RuleInjectionDetails } from "./types.ts";
3
2
 
4
3
  export const CUSTOM_MESSAGE_TYPE = "pi-rules-injection";
5
4
 
6
5
  /** The rules and skills that the model can see now, read from the injection messages in its context. */
7
6
  export interface InjectedState {
8
- /** Rule id to the hash of the body that the model has. */
9
- rules: Map<string, string>;
7
+ /** Rule id to the version of the rule that the model has. */
8
+ rules: Map<string, { hash: string; label: string }>;
10
9
  skills: Set<string>;
11
10
  injectionIds: Set<string>;
12
11
  }
@@ -42,7 +41,11 @@ export function collectInjectedState(
42
41
  export function addInjection(state: InjectedState, details: unknown): void {
43
42
  if (!isInjectionDetails(details)) return;
44
43
  state.injectionIds.add(details.injectionId);
45
- for (const rule of details.rules) state.rules.set(rule.id, rule.hash);
44
+ details.rules.forEach((rule, index) => {
45
+ // 0.7 and 0.8 stored the label only in `sources`, in the same order as `rules`.
46
+ state.rules.set(rule.id, { hash: rule.hash, label: rule.label ?? details.sources[index] ?? rule.id });
47
+ });
48
+ for (const rule of details.removed ?? []) state.rules.delete(rule.id);
46
49
  for (const skill of details.skills) state.skills.add(skill);
47
50
  }
48
51
 
@@ -57,13 +60,24 @@ function isInjectionMessage(message: unknown): message is { details: unknown } {
57
60
  }
58
61
 
59
62
  // Injections written by older versions have no rule list. They are ignored, so those rules load once more.
60
- function isInjectionDetails(details: unknown): details is RuleInjectionDetails {
63
+ interface StoredInjectionDetails {
64
+ injectionId: string;
65
+ rules: Array<{ id: string; hash: string; label?: string }>;
66
+ removed?: Array<{ id: string }>;
67
+ sources: string[];
68
+ skills: string[];
69
+ }
70
+
71
+ function isInjectionDetails(details: unknown): details is StoredInjectionDetails {
61
72
  if (typeof details !== "object" || details === null) return false;
62
- const candidate = details as Partial<RuleInjectionDetails>;
73
+ const candidate = details as Partial<StoredInjectionDetails>;
63
74
  return (
64
75
  typeof candidate.injectionId === "string" &&
65
76
  Array.isArray(candidate.rules) &&
66
77
  candidate.rules.every((rule) => typeof rule?.id === "string" && typeof rule.hash === "string") &&
78
+ (candidate.removed === undefined ||
79
+ (Array.isArray(candidate.removed) && candidate.removed.every((rule) => typeof rule?.id === "string"))) &&
80
+ Array.isArray(candidate.sources) &&
67
81
  Array.isArray(candidate.skills)
68
82
  );
69
83
  }
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,8 +11,9 @@ export interface Rule {
8
11
  id: string;
9
12
  sourcePath: string;
10
13
  sourceLabel: string;
14
+ scope: RuleScope;
11
15
  body: string;
12
- /** Hash of the body, so a changed rule file can be told apart from the copy the model already has. */
16
+ /** Hash of the whole file, so a changed rule can be told apart from the version that the model has. */
13
17
  hash: string;
14
18
  paths?: string[];
15
19
  events?: RuleEvents;
@@ -29,16 +33,31 @@ export interface DiscoveryResult {
29
33
  export interface InjectedRule {
30
34
  id: string;
31
35
  hash: string;
36
+ label: string;
37
+ }
38
+
39
+ export interface RemovedRule {
40
+ id: string;
41
+ label: string;
32
42
  }
33
43
 
34
44
  export interface RuleInjectionDetails {
35
45
  injectionId: string;
46
+ /** Rules that this message loads, also new versions of rules that the model has. */
36
47
  rules: InjectedRule[];
48
+ /** Rules whose file is gone. The message tells the model to stop following them. */
49
+ removed: RemovedRule[];
37
50
  sources: string[];
51
+ /** The labels in `sources` that replace an earlier version. */
52
+ updated: string[];
38
53
  targets: Record<string, string[]>;
39
54
  skills: string[];
40
55
  }
41
56
 
57
+ export interface DueRule extends PendingRule {
58
+ updated: boolean;
59
+ }
60
+
42
61
  export interface PendingRule {
43
62
  rule: Rule;
44
63
  targets: Set<string>;