@lazco/pi-rules 0.0.0-stage → 0.7.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/LICENSE +663 -0
- package/README.md +203 -2
- package/THIRD_PARTY_NOTICES.md +34 -0
- package/package.json +58 -4
- package/src/discovery.ts +318 -0
- package/src/index.ts +271 -0
- package/src/matching.ts +43 -0
- package/src/state.ts +69 -0
- package/src/types.ts +45 -0
package/README.md
CHANGED
|
@@ -1,3 +1,204 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/@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
|
+
- **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
|
+
- **Linked skills.** A rule can load full pi [skills](#skills) when it loads.
|
|
22
|
+
- **Six sources in one list.** Project and user directories merge by file name, and the first source wins. See [Rule files](#rule-files).
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
You need [pi](https://github.com/earendil-works/pi) and Node.js 22.19.0 or later.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pi install npm:@lazco/pi-rules
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then run `/reload` in pi.
|
|
33
|
+
To check the install, run `pi list`.
|
|
34
|
+
The list of user packages shows `npm:@lazco/pi-rules`.
|
|
35
|
+
|
|
36
|
+
If you use `npm:@tigorhutasuhut/pi-rules`, remove it first:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pi remove npm:@tigorhutasuhut/pi-rules
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If pi loads both packages, every rule loads twice.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
Create `.pi/rules/project.md` in your project:
|
|
47
|
+
|
|
48
|
+
```md
|
|
49
|
+
Run the focused tests after you change production code.
|
|
50
|
+
Never commit secrets or build output.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Start pi in the project and send a message.
|
|
54
|
+
Before the first model call, the rule loads, and the chat shows:
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
Loaded rules:
|
|
58
|
+
↳ .pi/rules/project.md
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Press the expand key on the message to see the full rule text and the files that matched.
|
|
62
|
+
|
|
63
|
+
### Rules for some files
|
|
64
|
+
|
|
65
|
+
Add `paths` frontmatter when a rule applies only to part of the project:
|
|
66
|
+
|
|
67
|
+
```md
|
|
68
|
+
---
|
|
69
|
+
paths:
|
|
70
|
+
- "src/**/*.ts"
|
|
71
|
+
- "test/**/*.ts"
|
|
72
|
+
---
|
|
73
|
+
Use strict TypeScript.
|
|
74
|
+
Keep tests next to the behavior they check.
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| The agent calls | What happens |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `read` on a matching file | The read runs. The rule loads before the next model call. |
|
|
80
|
+
| `edit` or `write` on a matching file | The call stops before it runs. The rule loads, and the agent tries the call again. |
|
|
81
|
+
|
|
82
|
+
Each rule loads once on the current branch of the conversation.
|
|
83
|
+
pi-rules reads the injected rules from the messages that the model sees, so it stays correct when you:
|
|
84
|
+
|
|
85
|
+
- go back to an earlier message with `/tree`, or send a message again after you stop an answer,
|
|
86
|
+
- resume a session, run `/reload`, or fork a session,
|
|
87
|
+
- compact the session. The rules that the summary replaced load again when they are needed.
|
|
88
|
+
|
|
89
|
+
## Rule files
|
|
90
|
+
|
|
91
|
+
A rule is a Markdown file.
|
|
92
|
+
pi-rules reads every `.md` file in these directories, also in subdirectories:
|
|
93
|
+
|
|
94
|
+
| Order | Directory | Scope |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| 1 | `<project>/.pi/rules/` | project |
|
|
97
|
+
| 2 | `<project>/.agents/rules/` | project |
|
|
98
|
+
| 3 | `<project>/.claude/rules/` | project |
|
|
99
|
+
| 4 | `~/.pi/agent/rules/` | user |
|
|
100
|
+
| 5 | `~/.agents/rules/` | user |
|
|
101
|
+
| 6 | `~/.claude/rules/` | user |
|
|
102
|
+
|
|
103
|
+
`<project>` is the directory where you start pi.
|
|
104
|
+
The file name without `.md` is the rule name.
|
|
105
|
+
When two files have the same rule name, only the file from the first directory in the table loads.
|
|
106
|
+
pi-rules follows symbolic links to files and directories.
|
|
107
|
+
|
|
108
|
+
`PI_CODING_AGENT_DIR` or `PI_CONFIG_DIR` changes directory 4.
|
|
109
|
+
`CLAUDE_CONFIG_DIR` changes directory 6.
|
|
110
|
+
|
|
111
|
+
## Frontmatter
|
|
112
|
+
|
|
113
|
+
All frontmatter fields are optional.
|
|
114
|
+
A file without frontmatter is a rule for every session.
|
|
115
|
+
|
|
116
|
+
| Field | Value | Effect |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `paths` | a glob or a list of globs | The rule loads only for matching files. |
|
|
119
|
+
| `events` | `tool_call: <tool>` or a list of tools | The rule loads only for these tools: `read`, `edit`, `write`. |
|
|
120
|
+
| `skills` | a skill name or a list of names | These skills load with the rule. |
|
|
121
|
+
|
|
122
|
+
Globs are relative to `<project>`, use `/`, and match dotfiles.
|
|
123
|
+
|
|
124
|
+
### Tools
|
|
125
|
+
|
|
126
|
+
Use `events` to load a rule for some tools only:
|
|
127
|
+
|
|
128
|
+
```md
|
|
129
|
+
---
|
|
130
|
+
paths: "docs/**/*.md"
|
|
131
|
+
events:
|
|
132
|
+
tool_call: read
|
|
133
|
+
---
|
|
134
|
+
Check the facts in the docs against the code.
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
This rule loads after a read of a matching file, but not for an edit or a write.
|
|
138
|
+
|
|
139
|
+
### Skills
|
|
140
|
+
|
|
141
|
+
Use `skills` to load full pi skills with a rule:
|
|
142
|
+
|
|
143
|
+
```md
|
|
144
|
+
---
|
|
145
|
+
paths: "src/frontend/**"
|
|
146
|
+
skills:
|
|
147
|
+
- frontend-design
|
|
148
|
+
---
|
|
149
|
+
Follow the linked frontend workflow.
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
pi-rules finds the skill by name in the skills that pi found.
|
|
153
|
+
The skill loads even when its frontmatter sets `disable-model-invocation: true`.
|
|
154
|
+
A missing skill shows a warning, and the rule still loads.
|
|
155
|
+
|
|
156
|
+
## Configuration
|
|
157
|
+
|
|
158
|
+
A `rules.json` file turns pi-rules off or changes the directory list.
|
|
159
|
+
pi-rules reads the first file that exists:
|
|
160
|
+
|
|
161
|
+
1. `<project>/.pi/rules.json`
|
|
162
|
+
2. `~/.pi/agent/rules.json`
|
|
163
|
+
|
|
164
|
+
The two files do not merge.
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"enabled": true,
|
|
169
|
+
"sources": [
|
|
170
|
+
{ "scope": "repo", "kind": "pi" },
|
|
171
|
+
{ "scope": "repo", "kind": "agents" },
|
|
172
|
+
{ "scope": "repo", "kind": "claude" },
|
|
173
|
+
{ "scope": "user", "kind": "pi" },
|
|
174
|
+
{ "scope": "user", "kind": "agents" },
|
|
175
|
+
{ "scope": "user", "kind": "claude" }
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
| Field | Default | Effect |
|
|
181
|
+
| --- | --- | --- |
|
|
182
|
+
| `enabled` | `true` | `false` turns off all rules. |
|
|
183
|
+
| `sources` | the six directories above | The directories to read, in order. `scope` is `repo` or `user`. `kind` is `pi`, `agents`, or `claude`. |
|
|
184
|
+
|
|
185
|
+
To turn off the rules in one project, put `{ "enabled": false, "sources": [] }` in `<project>/.pi/rules.json`.
|
|
186
|
+
An invalid file shows a warning, and no rule loads.
|
|
187
|
+
pi-rules reads `rules.json` when a session starts and after a compaction.
|
|
188
|
+
|
|
189
|
+
## Limitations
|
|
190
|
+
|
|
191
|
+
- **Files outside the project.** A `read`, `edit`, or `write` of a file outside `<project>` does not load a rule, also a user rule.
|
|
192
|
+
- **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
|
+
|
|
195
|
+
## Contributing
|
|
196
|
+
|
|
197
|
+
Report bugs and ask questions in [GitHub issues](https://github.com/Lazco-Corporation/pi-rules/issues).
|
|
198
|
+
Pull requests are welcome.
|
|
199
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the setup and the tests.
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
[AGPL-3.0-or-later](LICENSE).
|
|
204
|
+
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.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
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
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, 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
|
+
}
|
|
40
|
+
|
|
41
|
+
interface DiscoveryOptions {
|
|
42
|
+
env?: NodeJS.ProcessEnv;
|
|
43
|
+
home?: string;
|
|
44
|
+
sources?: RuleSource[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RuleConfigResult {
|
|
48
|
+
enabled: boolean;
|
|
49
|
+
sources: RuleSource[];
|
|
50
|
+
configPath?: string;
|
|
51
|
+
diagnostic?: RuleDiagnostic;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type ParseResult = { rule: Rule } | { diagnostic: RuleDiagnostic };
|
|
55
|
+
|
|
56
|
+
export async function loadRuleConfig(cwd: string, options: DiscoveryOptions = {}): Promise<RuleConfigResult> {
|
|
57
|
+
const home = options.home ?? os.homedir();
|
|
58
|
+
const env = options.env ?? process.env;
|
|
59
|
+
const piCodingAgentDirectory = resolvePiCodingAgentDirectory(env, home);
|
|
60
|
+
const candidates = [path.join(cwd, ".pi", "rules.json"), path.join(piCodingAgentDirectory, "rules.json")];
|
|
61
|
+
|
|
62
|
+
for (const configPath of candidates) {
|
|
63
|
+
let content: string;
|
|
64
|
+
try {
|
|
65
|
+
content = await readFile(configPath, "utf8");
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const code = getErrorCode(error);
|
|
68
|
+
if (code === "ENOENT") continue;
|
|
69
|
+
return invalidConfig(configPath, labelPath(configPath, cwd, home), `unreadable: ${code}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let value: unknown;
|
|
73
|
+
try {
|
|
74
|
+
value = JSON.parse(content);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
77
|
+
return invalidConfig(configPath, labelPath(configPath, cwd, home), `invalid JSON: ${message}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!RULE_CONFIG_VALIDATOR.Check(value)) {
|
|
81
|
+
const [first] = RULE_CONFIG_VALIDATOR.Errors(value);
|
|
82
|
+
const reason = first ? `${first.instancePath || "/"}: ${first.message}` : "schema validation failed";
|
|
83
|
+
return invalidConfig(configPath, labelPath(configPath, cwd, home), reason);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
enabled: value.enabled ?? true,
|
|
88
|
+
sources: value.sources,
|
|
89
|
+
configPath,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { enabled: true, sources: DEFAULT_RULE_SOURCES };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function discoverRules(cwd: string, options: DiscoveryOptions = {}): Promise<DiscoveryResult> {
|
|
97
|
+
const home = options.home ?? os.homedir();
|
|
98
|
+
const env = options.env ?? process.env;
|
|
99
|
+
const roots = (options.sources ?? DEFAULT_RULE_SOURCES).map((source) => resolveRuleRoot(source, cwd, env, home));
|
|
100
|
+
|
|
101
|
+
const rules: Rule[] = [];
|
|
102
|
+
const diagnostics: RuleDiagnostic[] = [];
|
|
103
|
+
const names = new Set<string>();
|
|
104
|
+
|
|
105
|
+
for (const root of roots) {
|
|
106
|
+
const files = await findMarkdownFiles(root.directory);
|
|
107
|
+
for (const relativePath of files) {
|
|
108
|
+
const name = path.basename(relativePath, ".md");
|
|
109
|
+
if (names.has(name)) continue;
|
|
110
|
+
names.add(name);
|
|
111
|
+
|
|
112
|
+
const sourcePath = path.join(root.directory, ...relativePath.split("/"));
|
|
113
|
+
const sourceLabel = `${root.label}/${relativePath}`;
|
|
114
|
+
const parsed = await parseRuleFile(sourcePath, sourceLabel);
|
|
115
|
+
if ("rule" in parsed) {
|
|
116
|
+
rules.push(parsed.rule);
|
|
117
|
+
} else {
|
|
118
|
+
diagnostics.push(parsed.diagnostic);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { rules, diagnostics };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function parseRuleFile(sourcePath: string, sourceLabel: string): Promise<ParseResult> {
|
|
127
|
+
let content: string;
|
|
128
|
+
try {
|
|
129
|
+
content = await readFile(sourcePath, "utf8");
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const code = getErrorCode(error);
|
|
132
|
+
return { diagnostic: { sourceLabel, reason: `unreadable: ${code}` } };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const frontmatter = content.match(FRONTMATTER_RE);
|
|
136
|
+
if (!frontmatter) {
|
|
137
|
+
if (/^\uFEFF?---(?:\r?\n|$)/.test(content)) {
|
|
138
|
+
return { diagnostic: { sourceLabel, reason: "unclosed frontmatter" } };
|
|
139
|
+
}
|
|
140
|
+
const body = content.replace(/^\uFEFF/, "");
|
|
141
|
+
return { rule: { id: sourcePath, sourcePath, sourceLabel, body, hash: hashBody(body) } };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let raw: unknown;
|
|
145
|
+
try {
|
|
146
|
+
const document = parseDocument(frontmatter[1] ?? "", { logLevel: "silent" });
|
|
147
|
+
if (document.errors.length > 0) {
|
|
148
|
+
return {
|
|
149
|
+
diagnostic: {
|
|
150
|
+
sourceLabel,
|
|
151
|
+
reason: `invalid YAML: ${document.errors[0]?.message ?? "unknown error"}`,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
raw = document.toJS();
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return {
|
|
158
|
+
diagnostic: {
|
|
159
|
+
sourceLabel,
|
|
160
|
+
reason: `invalid YAML: ${error instanceof Error ? error.message : String(error)}`,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (raw !== null && (typeof raw !== "object" || Array.isArray(raw))) {
|
|
165
|
+
return { diagnostic: { sourceLabel, reason: "frontmatter must be a mapping" } };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const record = (raw ?? {}) as Record<string, unknown>;
|
|
169
|
+
const paths = normalizeStringList(record.paths, "paths", "glob");
|
|
170
|
+
if (paths && "reason" in paths) {
|
|
171
|
+
return { diagnostic: { sourceLabel, reason: paths.reason } };
|
|
172
|
+
}
|
|
173
|
+
const events = normalizeRuleEvents(record.events);
|
|
174
|
+
if (events && "reason" in events) {
|
|
175
|
+
return { diagnostic: { sourceLabel, reason: events.reason } };
|
|
176
|
+
}
|
|
177
|
+
const skills = normalizeStringList(record.skills, "skills", "skill name");
|
|
178
|
+
if (skills && "reason" in skills) {
|
|
179
|
+
return { diagnostic: { sourceLabel, reason: skills.reason } };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const body = content.slice(frontmatter[0].length);
|
|
183
|
+
const rule: Rule = { id: sourcePath, sourcePath, sourceLabel, body, hash: hashBody(body) };
|
|
184
|
+
if (paths) rule.paths = paths;
|
|
185
|
+
if (events) rule.events = events;
|
|
186
|
+
if (skills) rule.skills = skills;
|
|
187
|
+
return { rule };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function findMarkdownFiles(directory: string, ancestors = new Set<string>()): Promise<string[]> {
|
|
191
|
+
let entries;
|
|
192
|
+
let resolvedDirectory: string;
|
|
193
|
+
try {
|
|
194
|
+
resolvedDirectory = await realpath(directory);
|
|
195
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (getErrorCode(error) === "ENOENT") return [];
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ponytail: track current ancestry only; permits aliases while stopping symlink cycles.
|
|
202
|
+
if (ancestors.has(resolvedDirectory)) return [];
|
|
203
|
+
const nextAncestors = new Set(ancestors).add(resolvedDirectory);
|
|
204
|
+
const files: string[] = [];
|
|
205
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
const entryPath = path.join(directory, entry.name);
|
|
208
|
+
let isDirectory = entry.isDirectory();
|
|
209
|
+
let isFile = entry.isFile();
|
|
210
|
+
if (entry.isSymbolicLink()) {
|
|
211
|
+
try {
|
|
212
|
+
const target = await stat(entryPath);
|
|
213
|
+
isDirectory = target.isDirectory();
|
|
214
|
+
isFile = target.isFile();
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (getErrorCode(error) === "ENOENT" && entry.name.endsWith(".md")) files.push(entry.name);
|
|
217
|
+
else if (getErrorCode(error) !== "ENOENT") throw error;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (isDirectory) {
|
|
222
|
+
const nested = await findMarkdownFiles(entryPath, nextAncestors);
|
|
223
|
+
files.push(...nested.map((relativePath) => `${entry.name}/${relativePath}`));
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (isFile && entry.name.endsWith(".md")) files.push(entry.name);
|
|
227
|
+
}
|
|
228
|
+
return files;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function resolvePiCodingAgentDirectory(env: NodeJS.ProcessEnv, home: string): string {
|
|
232
|
+
return env.PI_CODING_AGENT_DIR || path.join(env.PI_CONFIG_DIR || path.join(home, ".pi"), "agent");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function resolveRuleRoot(source: RuleSource, cwd: string, env: NodeJS.ProcessEnv, home: string): RuleRoot {
|
|
236
|
+
if (source.scope === "repo") {
|
|
237
|
+
return { directory: path.join(cwd, `.${source.kind}`, "rules"), label: `.${source.kind}/rules` };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (source.kind === "pi") {
|
|
241
|
+
const directory = path.join(resolvePiCodingAgentDirectory(env, home), "rules");
|
|
242
|
+
return { directory, label: labelDirectory(directory, home) };
|
|
243
|
+
}
|
|
244
|
+
if (source.kind === "agents") {
|
|
245
|
+
return { directory: path.join(home, ".agents", "rules"), label: "~/.agents/rules" };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const directory = path.join(env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"), "rules");
|
|
249
|
+
return { directory, label: labelDirectory(directory, home) };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function invalidConfig(configPath: string, sourceLabel: string, reason: string): RuleConfigResult {
|
|
253
|
+
return { enabled: false, sources: [], configPath, diagnostic: { sourceLabel, reason } };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function labelPath(target: string, cwd: string, home: string): string {
|
|
257
|
+
const relative = path.relative(cwd, target);
|
|
258
|
+
if (relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
|
|
259
|
+
return relative.split(path.sep).join("/");
|
|
260
|
+
}
|
|
261
|
+
return labelDirectory(target, home);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalizeRuleEvents(raw: unknown): RuleEvents | { reason: string } | undefined {
|
|
265
|
+
if (raw === undefined) return undefined;
|
|
266
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
267
|
+
return { reason: "events must be a mapping of Pi event names to filters" };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const record = raw as Record<string, unknown>;
|
|
271
|
+
const unsupported = Object.keys(record).filter((name) => name !== "tool_call");
|
|
272
|
+
if (unsupported.length > 0) {
|
|
273
|
+
return { reason: `events contains unsupported Pi event: ${unsupported.join(", ")}` };
|
|
274
|
+
}
|
|
275
|
+
const toolNames = normalizeStringList(record.tool_call, "events.tool_call", "tool name");
|
|
276
|
+
if (!toolNames) return { reason: "events must include tool_call" };
|
|
277
|
+
if ("reason" in toolNames) return toolNames;
|
|
278
|
+
if (!toolNames.every((name): name is RuleToolName => RULE_TOOL_NAMES.has(name as RuleToolName))) {
|
|
279
|
+
return { reason: "events.tool_call supports only read, edit, or write" };
|
|
280
|
+
}
|
|
281
|
+
return { tool_call: toolNames };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function normalizeStringList(
|
|
285
|
+
raw: unknown,
|
|
286
|
+
field: string,
|
|
287
|
+
item: string,
|
|
288
|
+
): string[] | { reason: string } | undefined {
|
|
289
|
+
if (raw === undefined) return undefined;
|
|
290
|
+
const values = typeof raw === "string" ? [raw] : raw;
|
|
291
|
+
if (!Array.isArray(values) || !values.every((value) => typeof value === "string")) {
|
|
292
|
+
return { reason: `${field} must be a string or string list` };
|
|
293
|
+
}
|
|
294
|
+
const normalized = values.map((value) => value.trim());
|
|
295
|
+
if (normalized.length === 0 || normalized.some((value) => value.length === 0)) {
|
|
296
|
+
return { reason: `${field} must contain at least one non-empty ${item}` };
|
|
297
|
+
}
|
|
298
|
+
return [...new Set(normalized)];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function labelDirectory(directory: string, home: string): string {
|
|
302
|
+
const relative = path.relative(home, directory);
|
|
303
|
+
if (relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
|
|
304
|
+
return `~/${relative.split(path.sep).join("/")}`;
|
|
305
|
+
}
|
|
306
|
+
return directory.split(path.sep).join("/");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function hashBody(body: string): string {
|
|
310
|
+
return createHash("sha256").update(body).digest("hex").slice(0, 16);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function getErrorCode(error: unknown): string {
|
|
314
|
+
if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") {
|
|
315
|
+
return error.code;
|
|
316
|
+
}
|
|
317
|
+
return "EUNKNOWN";
|
|
318
|
+
}
|