@lazco/pi-rules 0.0.0-stage → 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
@@ -1,3 +1,217 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # pi-rules
4
+
5
+ Pi extension: load Markdown rules into the agent context, for the whole project or only for matching files.
6
+
7
+ [![npm](https://img.shields.io/npm/v/@lazco/pi-rules)](https://www.npmjs.com/package/@lazco/pi-rules) [![License](https://img.shields.io/npm/l/@lazco/pi-rules)](LICENSE)
8
+
9
+ [Installation](#installation) · [Usage](#usage) · [Rule files](#rule-files) · [Frontmatter](#frontmatter) · [Configuration](#configuration) · [Limitations](#limitations)
10
+
11
+ </div>
12
+
13
+ `@lazco/pi-rules` is a fork of [`tigorlazuardi/pi-rules`](https://github.com/tigorlazuardi/pi-rules), maintained by Lazco Corporation.
14
+ It reads the same rule files, so you can switch without a change to your rules.
15
+
16
+ ## Highlights
17
+
18
+ - **Claude Code compatible.** Reads `.claude/rules/` and `.agents/rules/` next to `.pi/rules/`, so one set of files serves both tools.
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`.
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.
22
+ - **Linked skills.** A rule can load full pi [skills](#skills) when it loads.
23
+ - **Six sources in one list.** Project and user directories merge by file name, and the first source wins. See [Rule files](#rule-files).
24
+
25
+ ## Installation
26
+
27
+ You need [pi](https://github.com/earendil-works/pi) and Node.js 22.19.0 or later.
28
+
29
+ ```sh
30
+ pi install npm:@lazco/pi-rules
31
+ ```
32
+
33
+ Then run `/reload` in pi.
34
+ To check the install, run `pi list`.
35
+ The list of user packages shows `npm:@lazco/pi-rules`.
36
+
37
+ If you use `npm:@tigorhutasuhut/pi-rules`, remove it first:
38
+
39
+ ```sh
40
+ pi remove npm:@tigorhutasuhut/pi-rules
41
+ ```
42
+
43
+ If pi loads both packages, every rule loads twice.
44
+
45
+ ## Usage
46
+
47
+ Create `.pi/rules/project.md` in your project:
48
+
49
+ ```md
50
+ Run the focused tests after you change production code.
51
+ Never commit secrets or build output.
52
+ ```
53
+
54
+ Start pi in the project and send a message.
55
+ Before the first model call, the rule loads, and the chat shows:
56
+
57
+ ```text
58
+ Loaded rules:
59
+ ↳ .pi/rules/project.md
60
+ ```
61
+
62
+ Press the expand key on the message to see the full rule text and the files that matched.
63
+
64
+ ### Rules for some files
65
+
66
+ Add `paths` frontmatter when a rule applies only to part of the project:
67
+
68
+ ```md
69
+ ---
70
+ paths:
71
+ - "src/**/*.ts"
72
+ - "test/**/*.ts"
73
+ ---
74
+ Use strict TypeScript.
75
+ Keep tests next to the behavior they check.
76
+ ```
77
+
78
+ | The agent calls | What happens |
79
+ | --- | --- |
80
+ | `read` on a matching file | The read runs. The rule loads before the next model call. |
81
+ | `edit` or `write` on a matching file | The call stops before it runs. The rule loads, and the agent tries the call again. |
82
+
83
+ Each rule loads once on the current branch of the conversation.
84
+ pi-rules reads the injected rules from the messages that the model sees, so it stays correct when you:
85
+
86
+ - go back to an earlier message with `/tree`, or send a message again after you stop an answer,
87
+ - resume a session, run `/reload`, or fork a session,
88
+ - compact the session. The rules that the summary replaced load again when they are needed.
89
+
90
+ ## Rule files
91
+
92
+ A rule is a Markdown file.
93
+ pi-rules reads every `.md` file in these directories, also in subdirectories:
94
+
95
+ | Order | Directory | Scope |
96
+ | --- | --- | --- |
97
+ | 1 | `<project>/.pi/rules/` | project |
98
+ | 2 | `<project>/.agents/rules/` | project |
99
+ | 3 | `<project>/.claude/rules/` | project |
100
+ | 4 | `~/.pi/agent/rules/` | user |
101
+ | 5 | `~/.agents/rules/` | user |
102
+ | 6 | `~/.claude/rules/` | user |
103
+
104
+ `<project>` is the directory where you start pi.
105
+ The file name without `.md` is the rule name.
106
+ When two files have the same rule name, only the file from the first directory in the table loads.
107
+ pi-rules follows symbolic links to files and directories.
108
+
109
+ `PI_CODING_AGENT_DIR` or `PI_CONFIG_DIR` changes directory 4.
110
+ `CLAUDE_CONFIG_DIR` changes directory 6.
111
+
112
+ ## Frontmatter
113
+
114
+ All frontmatter fields are optional.
115
+ A file without frontmatter is a rule for every session.
116
+
117
+ | Field | Value | Effect |
118
+ | --- | --- | --- |
119
+ | `paths` | a glob or a list of globs | The rule loads only for matching files. |
120
+ | `events` | `tool_call: <tool>` or a list of tools | The rule loads only for these tools: `read`, `edit`, `write`. |
121
+ | `skills` | a skill name or a list of names | These skills load with the rule. |
122
+
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.
136
+
137
+ ### Tools
138
+
139
+ Use `events` to load a rule for some tools only:
140
+
141
+ ```md
142
+ ---
143
+ paths: "docs/**/*.md"
144
+ events:
145
+ tool_call: read
146
+ ---
147
+ Check the facts in the docs against the code.
148
+ ```
149
+
150
+ This rule loads after a read of a matching file, but not for an edit or a write.
151
+
152
+ ### Skills
153
+
154
+ Use `skills` to load full pi skills with a rule:
155
+
156
+ ```md
157
+ ---
158
+ paths: "src/frontend/**"
159
+ skills:
160
+ - frontend-design
161
+ ---
162
+ Follow the linked frontend workflow.
163
+ ```
164
+
165
+ pi-rules finds the skill by name in the skills that pi found.
166
+ The skill loads even when its frontmatter sets `disable-model-invocation: true`.
167
+ A missing skill shows a warning, and the rule still loads.
168
+
169
+ ## Configuration
170
+
171
+ A `rules.json` file turns pi-rules off or changes the directory list.
172
+ pi-rules reads the first file that exists:
173
+
174
+ 1. `<project>/.pi/rules.json`
175
+ 2. `~/.pi/agent/rules.json`
176
+
177
+ The two files do not merge.
178
+
179
+ ```json
180
+ {
181
+ "enabled": true,
182
+ "sources": [
183
+ { "scope": "repo", "kind": "pi" },
184
+ { "scope": "repo", "kind": "agents" },
185
+ { "scope": "repo", "kind": "claude" },
186
+ { "scope": "user", "kind": "pi" },
187
+ { "scope": "user", "kind": "agents" },
188
+ { "scope": "user", "kind": "claude" }
189
+ ]
190
+ }
191
+ ```
192
+
193
+ | Field | Default | Effect |
194
+ | --- | --- | --- |
195
+ | `enabled` | `true` | `false` turns off all rules. |
196
+ | `sources` | the six directories above | The directories to read, in order. `scope` is `repo` or `user`. `kind` is `pi`, `agents`, or `claude`. |
197
+
198
+ To turn off the rules in one project, put `{ "enabled": false, "sources": [] }` in `<project>/.pi/rules.json`.
199
+ An invalid file shows a warning, and no rule loads.
200
+ pi-rules reads `rules.json` when a session starts and after a compaction.
201
+
202
+ ## Limitations
203
+
204
+ - **Project rules of other projects.** A file in another project loads only user rules, not the rules in the directories of that project.
205
+ - **Shell commands.** A file that a `bash` command changes does not load a rule.
206
+ - **Changed rules.** A rule that already loaded does not load again after you change its file, until the next compaction.
207
+
208
+ ## Contributing
209
+
210
+ Report bugs and ask questions in [GitHub issues](https://github.com/Lazco-Corporation/pi-rules/issues).
211
+ Pull requests are welcome.
212
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the setup and the tests.
213
+
214
+ ## License
215
+
216
+ [AGPL-3.0-or-later](LICENSE).
217
+ The code from the original project is under the MIT License. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
@@ -0,0 +1,34 @@
1
+ # Third-party notices
2
+
3
+ `@lazco/pi-rules` is a fork of [`tigorlazuardi/pi-rules`](https://github.com/tigorlazuardi/pi-rules).
4
+ The fork is released under `AGPL-3.0-or-later`. See [LICENSE](LICENSE).
5
+ The code that comes from the original project was released under the MIT License below.
6
+ Releases of the original project stay under the MIT License.
7
+
8
+ ## pi-rules
9
+
10
+ Source: <https://github.com/tigorlazuardi/pi-rules>
11
+
12
+ ```text
13
+ MIT License
14
+
15
+ Copyright (c) 2026 Tigor Hutasuhut
16
+
17
+ Permission is hereby granted, free of charge, to any person obtaining a copy
18
+ of this software and associated documentation files (the "Software"), to deal
19
+ in the Software without restriction, including without limitation the rights
20
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
21
+ copies of the Software, and to permit persons to whom the Software is
22
+ furnished to do so, subject to the following conditions:
23
+
24
+ The above copyright notice and this permission notice shall be included in all
25
+ copies or substantial portions of the Software.
26
+
27
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
28
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
29
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
30
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
31
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
32
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
33
+ SOFTWARE.
34
+ ```
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@lazco/pi-rules",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.8.0",
4
+ "description": "Pi extension: load Markdown rules into the agent context for the whole project or for matching files",
5
+ "type": "module",
6
+ "license": "AGPL-3.0-or-later",
7
+ "author": "Lazco Corporation (拉資科科技有限公司)",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Lazco-Corporation/pi-rules.git"
11
+ },
12
+ "homepage": "https://github.com/Lazco-Corporation/pi-rules#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/Lazco-Corporation/pi-rules/issues"
15
+ },
16
+ "keywords": [
17
+ "pi",
18
+ "pi-extension",
19
+ "pi-package",
20
+ "coding-agent",
21
+ "rules",
22
+ "claude-code"
23
+ ],
24
+ "engines": {
25
+ "node": ">=22.19.0"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "README.md",
30
+ "LICENSE",
31
+ "THIRD_PARTY_NOTICES.md"
32
+ ],
33
+ "pi": {
34
+ "extensions": [
35
+ "./src/index.ts"
36
+ ]
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "@earendil-works/pi-tui": "*",
41
+ "typebox": "*"
42
+ },
43
+ "devDependencies": {
44
+ "@earendil-works/pi-coding-agent": "1.5.2",
45
+ "@earendil-works/pi-tui": "1.5.2",
46
+ "@types/node": "^22.20.1",
47
+ "@types/picomatch": "4.0.2",
48
+ "typebox": "1.4.6",
49
+ "typescript": "^5.9.2"
50
+ },
51
+ "scripts": {
52
+ "check": "tsc --noEmit",
53
+ "test": "node --test --experimental-strip-types test/*.test.ts",
54
+ "release": "bash scripts/release.sh"
55
+ },
56
+ "dependencies": {
57
+ "picomatch": "4.0.7",
58
+ "yaml": "2.9.1"
59
+ }
60
+ }
@@ -0,0 +1,341 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, readdir, realpath, stat } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import Type from "typebox";
6
+ import { Compile } from "typebox/compile";
7
+ import { parseDocument } from "yaml";
8
+ import type { DiscoveryResult, Rule, RuleDiagnostic, RuleEvents, RuleScope, RuleToolName } from "./types.ts";
9
+
10
+ const FRONTMATTER_RE = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
11
+ const RULE_TOOL_NAMES = new Set<RuleToolName>(["read", "edit", "write"]);
12
+
13
+ const RULE_SOURCE_SCHEMA = Type.Object({
14
+ scope: Type.Union([Type.Literal("repo"), Type.Literal("user")]),
15
+ kind: Type.Union([Type.Literal("pi"), Type.Literal("agents"), Type.Literal("claude")]),
16
+ });
17
+
18
+ const RULE_CONFIG_SCHEMA = Type.Object({
19
+ enabled: Type.Optional(Type.Boolean()),
20
+ sources: Type.Array(RULE_SOURCE_SCHEMA),
21
+ });
22
+
23
+ const RULE_CONFIG_VALIDATOR = Compile(RULE_CONFIG_SCHEMA);
24
+
25
+ export type RuleSource = Type.Static<typeof RULE_SOURCE_SCHEMA>;
26
+
27
+ export const DEFAULT_RULE_SOURCES: RuleSource[] = [
28
+ { scope: "repo", kind: "pi" },
29
+ { scope: "repo", kind: "agents" },
30
+ { scope: "repo", kind: "claude" },
31
+ { scope: "user", kind: "pi" },
32
+ { scope: "user", kind: "agents" },
33
+ { scope: "user", kind: "claude" },
34
+ ];
35
+
36
+ interface RuleRoot {
37
+ directory: string;
38
+ label: string;
39
+ scope: RuleScope;
40
+ }
41
+
42
+ interface DiscoveryOptions {
43
+ env?: NodeJS.ProcessEnv;
44
+ home?: string;
45
+ sources?: RuleSource[];
46
+ }
47
+
48
+ export interface RuleConfigResult {
49
+ enabled: boolean;
50
+ sources: RuleSource[];
51
+ configPath?: string;
52
+ diagnostic?: RuleDiagnostic;
53
+ }
54
+
55
+ type ParseResult = { rule: Rule } | { diagnostic: RuleDiagnostic };
56
+
57
+ export async function loadRuleConfig(cwd: string, options: DiscoveryOptions = {}): Promise<RuleConfigResult> {
58
+ const home = options.home ?? os.homedir();
59
+ const env = options.env ?? process.env;
60
+ const piCodingAgentDirectory = resolvePiCodingAgentDirectory(env, home);
61
+ const candidates = [path.join(cwd, ".pi", "rules.json"), path.join(piCodingAgentDirectory, "rules.json")];
62
+
63
+ for (const configPath of candidates) {
64
+ let content: string;
65
+ try {
66
+ content = await readFile(configPath, "utf8");
67
+ } catch (error) {
68
+ const code = getErrorCode(error);
69
+ if (code === "ENOENT") continue;
70
+ return invalidConfig(configPath, labelPath(configPath, cwd, home), `unreadable: ${code}`);
71
+ }
72
+
73
+ let value: unknown;
74
+ try {
75
+ value = JSON.parse(content);
76
+ } catch (error) {
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ return invalidConfig(configPath, labelPath(configPath, cwd, home), `invalid JSON: ${message}`);
79
+ }
80
+
81
+ if (!RULE_CONFIG_VALIDATOR.Check(value)) {
82
+ const [first] = RULE_CONFIG_VALIDATOR.Errors(value);
83
+ const reason = first ? `${first.instancePath || "/"}: ${first.message}` : "schema validation failed";
84
+ return invalidConfig(configPath, labelPath(configPath, cwd, home), reason);
85
+ }
86
+
87
+ return {
88
+ enabled: value.enabled ?? true,
89
+ sources: value.sources,
90
+ configPath,
91
+ };
92
+ }
93
+
94
+ return { enabled: true, sources: DEFAULT_RULE_SOURCES };
95
+ }
96
+
97
+ export async function discoverRules(cwd: string, options: DiscoveryOptions = {}): Promise<DiscoveryResult> {
98
+ const home = options.home ?? os.homedir();
99
+ const env = options.env ?? process.env;
100
+ const roots = await withoutUserAliases(
101
+ (options.sources ?? DEFAULT_RULE_SOURCES).map((source) => resolveRuleRoot(source, cwd, env, home)),
102
+ );
103
+
104
+ const rules: Rule[] = [];
105
+ const diagnostics: RuleDiagnostic[] = [];
106
+ const names = new Set<string>();
107
+
108
+ for (const root of roots) {
109
+ const files = await findMarkdownFiles(root.directory);
110
+ for (const relativePath of files) {
111
+ const name = path.basename(relativePath, ".md");
112
+ if (names.has(name)) continue;
113
+ names.add(name);
114
+
115
+ const sourcePath = path.join(root.directory, ...relativePath.split("/"));
116
+ const sourceLabel = `${root.label}/${relativePath}`;
117
+ const parsed = await parseRuleFile(sourcePath, sourceLabel, root.scope);
118
+ if ("rule" in parsed) {
119
+ rules.push(parsed.rule);
120
+ } else {
121
+ diagnostics.push(parsed.diagnostic);
122
+ }
123
+ }
124
+ }
125
+
126
+ return { rules, diagnostics };
127
+ }
128
+
129
+ export async function parseRuleFile(sourcePath: string, sourceLabel: string, scope: RuleScope): Promise<ParseResult> {
130
+ let content: string;
131
+ try {
132
+ content = await readFile(sourcePath, "utf8");
133
+ } catch (error) {
134
+ const code = getErrorCode(error);
135
+ return { diagnostic: { sourceLabel, reason: `unreadable: ${code}` } };
136
+ }
137
+
138
+ const frontmatter = content.match(FRONTMATTER_RE);
139
+ if (!frontmatter) {
140
+ if (/^\uFEFF?---(?:\r?\n|$)/.test(content)) {
141
+ return { diagnostic: { sourceLabel, reason: "unclosed frontmatter" } };
142
+ }
143
+ const body = content.replace(/^\uFEFF/, "");
144
+ return { rule: { id: sourcePath, sourcePath, sourceLabel, scope, body, hash: hashBody(body) } };
145
+ }
146
+
147
+ let raw: unknown;
148
+ try {
149
+ const document = parseDocument(frontmatter[1] ?? "", { logLevel: "silent" });
150
+ if (document.errors.length > 0) {
151
+ return {
152
+ diagnostic: {
153
+ sourceLabel,
154
+ reason: `invalid YAML: ${document.errors[0]?.message ?? "unknown error"}`,
155
+ },
156
+ };
157
+ }
158
+ raw = document.toJS();
159
+ } catch (error) {
160
+ return {
161
+ diagnostic: {
162
+ sourceLabel,
163
+ reason: `invalid YAML: ${error instanceof Error ? error.message : String(error)}`,
164
+ },
165
+ };
166
+ }
167
+ if (raw !== null && (typeof raw !== "object" || Array.isArray(raw))) {
168
+ return { diagnostic: { sourceLabel, reason: "frontmatter must be a mapping" } };
169
+ }
170
+
171
+ const record = (raw ?? {}) as Record<string, unknown>;
172
+ const paths = normalizeStringList(record.paths, "paths", "glob");
173
+ if (paths && "reason" in paths) {
174
+ return { diagnostic: { sourceLabel, reason: paths.reason } };
175
+ }
176
+ const events = normalizeRuleEvents(record.events);
177
+ if (events && "reason" in events) {
178
+ return { diagnostic: { sourceLabel, reason: events.reason } };
179
+ }
180
+ const skills = normalizeStringList(record.skills, "skills", "skill name");
181
+ if (skills && "reason" in skills) {
182
+ return { diagnostic: { sourceLabel, reason: skills.reason } };
183
+ }
184
+
185
+ const body = content.slice(frontmatter[0].length);
186
+ const rule: Rule = { id: sourcePath, sourcePath, sourceLabel, scope, body, hash: hashBody(body) };
187
+ if (paths) rule.paths = paths;
188
+ if (events) rule.events = events;
189
+ if (skills) rule.skills = skills;
190
+ return { rule };
191
+ }
192
+
193
+ async function findMarkdownFiles(directory: string, ancestors = new Set<string>()): Promise<string[]> {
194
+ let entries;
195
+ let resolvedDirectory: string;
196
+ try {
197
+ resolvedDirectory = await realpath(directory);
198
+ entries = await readdir(directory, { withFileTypes: true });
199
+ } catch (error) {
200
+ if (getErrorCode(error) === "ENOENT") return [];
201
+ throw error;
202
+ }
203
+
204
+ // ponytail: track current ancestry only; permits aliases while stopping symlink cycles.
205
+ if (ancestors.has(resolvedDirectory)) return [];
206
+ const nextAncestors = new Set(ancestors).add(resolvedDirectory);
207
+ const files: string[] = [];
208
+ entries.sort((left, right) => left.name.localeCompare(right.name));
209
+ for (const entry of entries) {
210
+ const entryPath = path.join(directory, entry.name);
211
+ let isDirectory = entry.isDirectory();
212
+ let isFile = entry.isFile();
213
+ if (entry.isSymbolicLink()) {
214
+ try {
215
+ const target = await stat(entryPath);
216
+ isDirectory = target.isDirectory();
217
+ isFile = target.isFile();
218
+ } catch (error) {
219
+ if (getErrorCode(error) === "ENOENT" && entry.name.endsWith(".md")) files.push(entry.name);
220
+ else if (getErrorCode(error) !== "ENOENT") throw error;
221
+ continue;
222
+ }
223
+ }
224
+ if (isDirectory) {
225
+ const nested = await findMarkdownFiles(entryPath, nextAncestors);
226
+ files.push(...nested.map((relativePath) => `${entry.name}/${relativePath}`));
227
+ continue;
228
+ }
229
+ if (isFile && entry.name.endsWith(".md")) files.push(entry.name);
230
+ }
231
+ return files;
232
+ }
233
+
234
+ function resolvePiCodingAgentDirectory(env: NodeJS.ProcessEnv, home: string): string {
235
+ return env.PI_CODING_AGENT_DIR || path.join(env.PI_CONFIG_DIR || path.join(home, ".pi"), "agent");
236
+ }
237
+
238
+ function resolveRuleRoot(source: RuleSource, cwd: string, env: NodeJS.ProcessEnv, home: string): RuleRoot {
239
+ if (source.scope === "repo") {
240
+ return { directory: path.join(cwd, `.${source.kind}`, "rules"), label: `.${source.kind}/rules`, scope: "repo" };
241
+ }
242
+
243
+ if (source.kind === "pi") {
244
+ const directory = path.join(resolvePiCodingAgentDirectory(env, home), "rules");
245
+ return { directory, label: labelDirectory(directory, home), scope: "user" };
246
+ }
247
+ if (source.kind === "agents") {
248
+ return { directory: path.join(home, ".agents", "rules"), label: "~/.agents/rules", scope: "user" };
249
+ }
250
+
251
+ const directory = path.join(env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"), "rules");
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
+ }
273
+ }
274
+
275
+ function invalidConfig(configPath: string, sourceLabel: string, reason: string): RuleConfigResult {
276
+ return { enabled: false, sources: [], configPath, diagnostic: { sourceLabel, reason } };
277
+ }
278
+
279
+ function labelPath(target: string, cwd: string, home: string): string {
280
+ const relative = path.relative(cwd, target);
281
+ if (relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
282
+ return relative.split(path.sep).join("/");
283
+ }
284
+ return labelDirectory(target, home);
285
+ }
286
+
287
+ function normalizeRuleEvents(raw: unknown): RuleEvents | { reason: string } | undefined {
288
+ if (raw === undefined) return undefined;
289
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
290
+ return { reason: "events must be a mapping of Pi event names to filters" };
291
+ }
292
+
293
+ const record = raw as Record<string, unknown>;
294
+ const unsupported = Object.keys(record).filter((name) => name !== "tool_call");
295
+ if (unsupported.length > 0) {
296
+ return { reason: `events contains unsupported Pi event: ${unsupported.join(", ")}` };
297
+ }
298
+ const toolNames = normalizeStringList(record.tool_call, "events.tool_call", "tool name");
299
+ if (!toolNames) return { reason: "events must include tool_call" };
300
+ if ("reason" in toolNames) return toolNames;
301
+ if (!toolNames.every((name): name is RuleToolName => RULE_TOOL_NAMES.has(name as RuleToolName))) {
302
+ return { reason: "events.tool_call supports only read, edit, or write" };
303
+ }
304
+ return { tool_call: toolNames };
305
+ }
306
+
307
+ function normalizeStringList(
308
+ raw: unknown,
309
+ field: string,
310
+ item: string,
311
+ ): string[] | { reason: string } | undefined {
312
+ if (raw === undefined) return undefined;
313
+ const values = typeof raw === "string" ? [raw] : raw;
314
+ if (!Array.isArray(values) || !values.every((value) => typeof value === "string")) {
315
+ return { reason: `${field} must be a string or string list` };
316
+ }
317
+ const normalized = values.map((value) => value.trim());
318
+ if (normalized.length === 0 || normalized.some((value) => value.length === 0)) {
319
+ return { reason: `${field} must contain at least one non-empty ${item}` };
320
+ }
321
+ return [...new Set(normalized)];
322
+ }
323
+
324
+ function labelDirectory(directory: string, home: string): string {
325
+ const relative = path.relative(home, directory);
326
+ if (relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
327
+ return `~/${relative.split(path.sep).join("/")}`;
328
+ }
329
+ return directory.split(path.sep).join("/");
330
+ }
331
+
332
+ function hashBody(body: string): string {
333
+ return createHash("sha256").update(body).digest("hex").slice(0, 16);
334
+ }
335
+
336
+ function getErrorCode(error: unknown): string {
337
+ if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") {
338
+ return error.code;
339
+ }
340
+ return "EUNKNOWN";
341
+ }