pi-shepherd 0.1.2 → 0.2.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/shepherd/rules.ts CHANGED
@@ -1,295 +1,366 @@
1
- /**
2
- * Guard 规则类型定义 + 规则加载/编译/匹配 + git 辅助函数
3
- */
4
-
5
- import { execSync } from "node:child_process";
6
- import * as fs from "node:fs";
7
- import * as path from "node:path";
8
- import { pushRuleError } from "./ephemeral.js";
9
- import type { ResettableRule, StateCondition } from "./state-tracker.js";
10
- import type { ToolEvent } from "./tool-event-types.js";
11
-
12
- // ── 类型定义 ──────────────────────────────────────────────────
13
-
14
- export interface Condition {
15
- field: "path" | "text" | "glob";
16
- pattern: string;
17
- flags?: string;
18
- _compiled?: RegExp;
19
- }
20
-
21
- export interface Rule {
22
- comment: string;
23
- hook?: "tool_call" | "tool_result" | "agent_end" | "session_shutdown"; // 默认 "tool_call"
24
- tool?: string; // 默认 "bash",支持 "|" 分隔多值匹配(如 "edit|write")
25
- // 单条件模式(向后兼容):pattern 匹配 command(bash)或 path(edit/write)
26
- pattern?: string;
27
- flags?: string;
28
- // 多条件 AND 模式:设置了 conditions 时忽略 pattern
29
- conditions?: Condition[];
30
- action?: "block" | "notify" | "rewrite" | "steer"; // 默认 "block"
31
- reason: string;
32
- enabled?: boolean;
33
- // session_shutdown / agent_end 专用:内置检查类型
34
- check?: "git_uncommitted" | "has_edits" | "always";
35
- // agent_end 专用:只在指定 stopReason 时触发(默认 ["stop"])
36
- stopReason?: ("stop" | "length" | "toolUse" | "error" | "aborted")[];
37
- // 有状态规则:状态条件(与 conditions 正则是 AND 关系)
38
- state?: StateCondition;
39
- // 有状态规则:当这些工具执行后重置此规则的计数
40
- resetOn?: string[];
41
- // 子代理控制:false 表示在子代理环境中跳过此规则(默认 true)
42
- subagent?: boolean;
43
- // 工具依赖:全部可用才触发(AND 语义),不设或空数组 = 不限制
44
- requiresTools?: string[];
45
- // 仅成功时触发:true 时跳过 isError 的 tool_result(默认 false)
46
- requireSuccess?: boolean;
47
- // 运行时:已触发标记(防重复)
48
- _triggered?: boolean;
49
- // 编译后的正则(运行时填充,单条件模式)
50
- _compiled?: RegExp;
51
- }
52
-
53
- // ── Git 辅助函数 ──────────────────────────────────────────────
54
-
55
- /** 检测 git 工作区是否有未提交的改动 */
56
- export function hasGitUncommittedChanges(): boolean {
57
- try {
58
- const cwd = process.cwd();
59
- const status = execSync("git status --porcelain", {
60
- timeout: 5000,
61
- stdio: ["pipe", "pipe", "pipe"],
62
- cwd,
63
- })
64
- .toString()
65
- .trim();
66
- // 只关注已跟踪文件的变更(M/A/D/R 等),忽略 untracked(?? 前缀)
67
- const tracked = status
68
- .split("\n")
69
- .filter((line) => line && !line.startsWith("??"));
70
- return tracked.length > 0;
71
- } catch {
72
- return false;
73
- }
74
- }
75
-
76
- /** 当前是否在 worktree 中 */
77
- export function isInWorktree(): boolean {
78
- try {
79
- const cwd = process.cwd();
80
- if (/\/\.worktrees\/[^/]+/.test(cwd)) return true;
81
- const gitDir = execSync("git rev-parse --git-dir", {
82
- timeout: 3000,
83
- stdio: ["pipe", "pipe", "pipe"],
84
- cwd,
85
- })
86
- .toString()
87
- .trim();
88
- const commonDir = execSync("git rev-parse --git-common-dir", {
89
- timeout: 3000,
90
- stdio: ["pipe", "pipe", "pipe"],
91
- cwd,
92
- })
93
- .toString()
94
- .trim();
95
- return gitDir !== commonDir && gitDir !== ".git";
96
- } catch {
97
- return false;
98
- }
99
- }
100
-
101
- /** 当前是否在子代理环境中 */
102
- export const isSubagent = () =>
103
- !!(process.env.PI_SUBAGENT_AGENT || process.env.PI_SUBAGENT_SESSION);
104
-
105
- // ── 代码文件扩展名 ─────────────────────────────────────────
106
-
107
- /** 代码文件扩展名正则(glob 或文件名末尾) */
108
- export const CODE_EXT_RE = /\.(py|rs|ts|js|toml|json)(\*|"|')?$/;
109
-
110
- // ── 规则加载/编译/匹配 ────────────────────────────────────────
111
-
112
- // RULES_PATH 已移除——规则路径由 loadRules(rulesDir) 参数传入
113
-
114
- /** 从单个文件加载规则(不编译),处理文件不存在和 JSON 解析错误 */
115
- export function loadRulesFromFile(filePath: string): {
116
- rules: Rule[];
117
- error?: string;
118
- } {
119
- try {
120
- const raw = fs.readFileSync(filePath, "utf-8");
121
- const parsed = JSON.parse(raw);
122
- if (!Array.isArray(parsed)) {
123
- const fileName = path.basename(filePath);
124
- return {
125
- rules: [],
126
- error: `${fileName}: 顶层必须是 JSON 数组,当前是 ${typeof parsed}`,
127
- };
128
- }
129
- return { rules: parsed };
130
- } catch (e: unknown) {
131
- if (e.code === "ENOENT") return { rules: [] };
132
- const fileName = path.basename(filePath);
133
- return { rules: [], error: `${fileName}: JSON 解析失败 — ${e.message}` };
134
- }
135
- }
136
-
137
- /** 编译规则:正则编译 + 默认值填充 */
138
- export function compileRules(rules: Rule[]): Rule[] {
139
- // 过滤禁用规则
140
- const active = rules.filter((r) => r.enabled !== false);
141
- for (const rule of active) {
142
- // 多条件模式:编译每个 condition
143
- if (rule.conditions && rule.conditions.length > 0) {
144
- for (const cond of rule.conditions) {
145
- cond._compiled = new RegExp(cond.pattern, cond.flags || "");
146
- }
147
- } else if (rule.pattern) {
148
- // 单条件模式:编译 pattern(向后兼容)
149
- rule._compiled = new RegExp(rule.pattern, rule.flags || "");
150
- }
151
- // 填充默认值
152
- if (!rule.hook) rule.hook = "tool_call";
153
- if (!rule.tool) rule.tool = "bash";
154
- if (!rule.action) rule.action = "block";
155
- }
156
- return active;
157
- }
158
-
159
- /** 加载所有规则并校验格式,返回编译后的规则列表 */
160
- export interface LoadRulesOptions {
161
- /** 项目级规则文件前缀,默认 "shepherd-rules-" */
162
- projectRulesPattern?: string;
163
- }
164
-
165
- export function loadRules(
166
- rulesDir?: string,
167
- options?: LoadRulesOptions,
168
- ): Rule[] {
169
- const allRules: Rule[] = [];
170
- const errors: string[] = [];
171
- const prefix = options?.projectRulesPattern || "shepherd-rules-";
172
-
173
- // 1. 全局规则:由消费者传入规则文件所在目录
174
- if (rulesDir) {
175
- const result = loadRulesFromFile(path.join(rulesDir, "rules.json"));
176
- allRules.push(...result.rules);
177
- if (result.error) errors.push(result.error);
178
- }
179
-
180
- // 2. 项目级规则(<cwd>/.pi/extensions/{prefix}*.json 或 shepherd-rules.json)
181
- const projectExtDir = path.join(process.cwd(), ".pi", "extensions");
182
- if (fs.existsSync(projectExtDir)) {
183
- for (const file of fs.readdirSync(projectExtDir).sort()) {
184
- if (file.endsWith(".json") && (file.startsWith(prefix) || file === "shepherd-rules.json")) {
185
- const result = loadRulesFromFile(path.join(projectExtDir, file));
186
- allRules.push(...result.rules);
187
- if (result.error) errors.push(result.error);
188
- }
189
- }
190
- }
191
-
192
- // 格式校验失败时推入 shepherd 提示缓冲区
193
- if (errors.length > 0) {
194
- const msg = errors.join(";");
195
- console.error(`[shepherd] 规则文件格式错误: ${msg}`);
196
- pushRuleError(msg);
197
- }
198
-
199
- return compileRules(allRules);
200
- }
201
-
202
- /** 从事件中提取匹配目标(多字段)
203
- * @param phase 调用阶段:"tool_call" 时 git commit 会被短路(避免 commit message 误触发 block 规则),
204
- * "tool_result" 时不短路(允许 git commit 后的 steer/notify 规则触发)
205
- */
206
- export function getMatchTargets(
207
- tool: string,
208
- event: ToolEvent,
209
- phase?: string,
210
- ): Record<string, string> {
211
- if (tool === "bash") {
212
- const command = (event.input as any)?.command || "";
213
- // git commit 的 message 可能包含 sed -i / echo >> 等关键词,跳过匹配
214
- // 注意:命令可能是 "cd xxx && git commit ..." 格式
215
- // 但仅在 tool_call 阶段短路——tool_result 阶段需要匹配 git commit 后的 steer 规则
216
- if (phase === "tool_call" && /(^|&&|;)\s*git\s+commit\b/.test(command)) {
217
- return {} as Record<string, string>;
218
- }
219
- return {
220
- command,
221
- path: "",
222
- text: "",
223
- glob: "",
224
- };
225
- }
226
- // grep 工具:提取 glob(文件过滤)、path(搜索目录)、text(搜索模式)
227
- if (tool === "grep") {
228
- const pathVal = (event.input as any)?.path || "";
229
- const globVal = (event.input as any)?.glob || "";
230
- const patternVal = (event.input as any)?.pattern || "";
231
-
232
- // 有 glob 时必须是代码扩展名,无 glob 时默认全搜(也触发提醒)
233
- if (globVal && !CODE_EXT_RE.test(globVal)) {
234
- return {} as Record<string, string>;
235
- }
236
- return { path: pathVal, text: patternVal, command: "", glob: globVal };
237
- }
238
- // edit / write / 其他工具
239
- const pathVal = (event.input as any)?.path || "";
240
- let text = "";
241
- if (tool === "edit") {
242
- const edits = (event.input as any)?.edits;
243
- if (Array.isArray(edits)) {
244
- text = edits
245
- .flatMap((e: { oldText?: string; newText?: string }) => [e.oldText || "", e.newText || ""])
246
- .join("\n");
247
- }
248
- } else if (tool === "write") {
249
- text = (event.input as any)?.content || "";
250
- } else {
251
- // 其他工具:把所有参数序列化为 text,供 conditions 的 text field 匹配
252
- const input = event.input as any;
253
- if (input && typeof input === "object") {
254
- text = JSON.stringify(input);
255
- }
256
- }
257
- return { path: pathVal, text, command: "", glob: "" };
258
- }
259
-
260
- /** 判断规则是否匹配事件 */
261
- export function ruleMatches(
262
- rule: Rule,
263
- tool: string,
264
- targets: Record<string, string>,
265
- ): boolean {
266
- // 多条件 AND 模式
267
- if (rule.conditions && rule.conditions.length > 0) {
268
- return rule.conditions.every((cond) => {
269
- const target = targets[cond.field] || "";
270
- return cond._compiled?.test(target) ?? false;
271
- });
272
- }
273
- // 单条件模式(向后兼容)
274
- if (rule._compiled) {
275
- const target = targets[tool === "bash" ? "command" : "path"] || "";
276
- return rule._compiled.test(target);
277
- }
278
- return false;
279
- }
280
-
281
- /** tool 字段匹配:支持 "|" 分隔的多值(如 "edit|write") */
282
- export function toolMatches(ruleTool: string | undefined, eventTool: string): boolean {
283
- if (!ruleTool) return true; // 未指定 tool 时默认匹配所有(由 hook 类型决定范围)
284
- return ruleTool.split("|").map((t) => t.trim()).includes(eventTool);
285
- }
286
-
287
- /** rtk 可用性(模块加载时检测) */
288
- export const isRtkAvailable: boolean = (() => {
289
- try {
290
- execSync("which rtk", { timeout: 2000, stdio: "pipe" });
291
- return true;
292
- } catch {
293
- return false;
294
- }
295
- })();
1
+ /**
2
+ * Guard 规则类型定义 + 规则加载/编译/匹配
3
+ */
4
+
5
+ import type { BuiltinContext, ConditionBuiltin } from "./conditions";
6
+
7
+ // 条件匹配从 conditions.ts re-export
8
+ export { isQuestionEnding, matchBuiltinCondition } from "./conditions";
9
+ export type { BuiltinContext, ConditionBuiltin } from "./conditions";
10
+ // git 相关函数从 git.ts re-export,保持向后兼容
11
+ export {
12
+ hasGitUncommittedChanges,
13
+ hasGitUntracked,
14
+ isGitDirty,
15
+ isGitDirtyOrUntracked,
16
+ isInWorktree,
17
+ } from "./git";
18
+
19
+ /** 当前是否在子代理环境中 */
20
+ export const isSubagent = () =>
21
+ !!(process.env.PI_SUBAGENT_AGENT || process.env.PI_SUBAGENT_SESSION);
22
+
23
+ /** 代码文件扩展名正则(glob 或文件名末尾) */
24
+ export const CODE_EXT_RE = /\.(py|rs|ts|js|toml|json)(\*|"|')?$/;
25
+
26
+ import * as fs from "node:fs";
27
+ import * as path from "node:path";
28
+ import { execSync } from "node:child_process";
29
+ import { matchBuiltinCondition } from "./conditions";
30
+ import { pushRuleError } from "./ephemeral.js";
31
+ import type { StateCondition } from "./state-tracker.js";
32
+ import type { ToolEvent } from "./tool-event-types.js";
33
+ import { extractResultText } from "./tool-event-types.js";
34
+
35
+ // ── 类型定义 ──────────────────────────────────────────────────
36
+
37
+ export interface Condition {
38
+ field?: "path" | "text" | "glob" | "result" | "command" | "reason";
39
+ pattern?: string;
40
+ flags?: string;
41
+ /** true 时取反:正则不匹配才算通过 */
42
+ negate?: boolean;
43
+ /** 内置条件:不依赖正则,直接检查环境状态(field/pattern 为占位) */
44
+ builtin?: ConditionBuiltin;
45
+ _compiled?: RegExp;
46
+ }
47
+
48
+ // ConditionBuiltin 类型定义移到 conditions.ts,这里 import + re-export
49
+ // (上方已 import type 到本地作用域,此处仅对外导出)
50
+
51
+ export interface Rule {
52
+ comment: string;
53
+ hook?:
54
+ | "tool_call"
55
+ | "tool_result"
56
+ | "agent_end"
57
+ | "session_shutdown"
58
+ | "message_end"
59
+ | "session_compact"; // 默认 "tool_call"
60
+ tool?: string; // 默认 "bash",支持 "|" 分隔多值匹配(如 "edit|write")
61
+ // 单条件模式(向后兼容):pattern 匹配 command(bash)或 path(edit/write)
62
+ pattern?: string;
63
+ flags?: string;
64
+ // 多条件 AND 模式:设置了 conditions 时忽略 pattern
65
+ conditions?: Condition[];
66
+ action?: "block" | "notify" | "rewrite" | "steer"; // 默认 "block"
67
+ reason: string;
68
+ enabled?: boolean;
69
+ // session_shutdown / agent_end 专用:内置检查类型(旧字段,自动迁移到 conditions)
70
+ check?: "git_uncommitted" | "has_edits" | "always";
71
+ // agent_end 专用:只在指定 stopReason 时触发(默认 ["stop"])
72
+ stopReason?: ("stop" | "length" | "toolUse" | "error" | "aborted")[];
73
+ // 有状态规则:状态条件(与 conditions 正则是 AND 关系)
74
+ state?: StateCondition;
75
+ // 有状态规则:当这些工具执行后重置此规则的计数
76
+ resetOn?: string[];
77
+ // 子代理控制:false 表示在子代理环境中跳过此规则(默认 true)
78
+ subagent?: boolean;
79
+ // 工具依赖:全部可用才触发(AND 语义),不设或空数组 = 不限制
80
+ requiresTools?: string[];
81
+ // 仅成功时触发:true 时跳过 isError 的 tool_result(默认 false)
82
+ requireSuccess?: boolean;
83
+ // 条件组合逻辑:"and"(默认)= 所有条件都满足,"or" = 任一满足
84
+ conditionLogic?: "and" | "or";
85
+ // 运行时:已触发标记(防重复)
86
+ _triggered?: boolean;
87
+ // 编译后的正则(运行时填充,单条件模式)
88
+ _compiled?: RegExp;
89
+ }
90
+
91
+ // ── 内置条件匹配 ────────────────────────────────────────────
92
+
93
+ // ── 规则加载/编译/匹配 ────────────────────────────────────────
94
+
95
+ // RULES_PATH 已移除——规则路径由 loadRules(rulesDir) 参数传入
96
+
97
+ /** 从单个文件加载规则(不编译),处理文件不存在和 JSON 解析错误 */
98
+ export function loadRulesFromFile(filePath: string): {
99
+ rules: Rule[];
100
+ error?: string;
101
+ } {
102
+ try {
103
+ const raw = fs.readFileSync(filePath, "utf-8");
104
+ const parsed = JSON.parse(raw);
105
+ if (!Array.isArray(parsed)) {
106
+ const fileName = path.basename(filePath);
107
+ return {
108
+ rules: [],
109
+ error: `${fileName}: 顶层必须是 JSON 数组,当前是 ${typeof parsed}`,
110
+ };
111
+ }
112
+ return { rules: parsed };
113
+ } catch (e: unknown) {
114
+ if (e instanceof Error && "code" in e && e.code === "ENOENT") return { rules: [] };
115
+ const fileName = path.basename(filePath);
116
+ const msg = e instanceof Error ? e.message : String(e);
117
+ return { rules: [], error: `${fileName}: JSON 解析失败 — ${msg}` };
118
+ }
119
+ }
120
+
121
+ /** 编译规则:正则编译 + 默认值填充 */
122
+ export function compileRules(rules: Rule[]): Rule[] {
123
+ // 过滤禁用规则
124
+ const active = rules.filter((r) => r.enabled !== false);
125
+ for (const rule of active) {
126
+ // 多条件模式:编译每个 condition
127
+ if (rule.conditions && rule.conditions.length > 0) {
128
+ for (const cond of rule.conditions) {
129
+ cond._compiled = new RegExp(cond.pattern ?? "", cond.flags || "");
130
+ }
131
+ } else if (rule.pattern) {
132
+ // 单条件模式:编译 pattern(向后兼容)
133
+ rule._compiled = new RegExp(rule.pattern, rule.flags || "");
134
+ }
135
+ // check 字段自动迁移为 conditions(向后兼容)
136
+ if (rule.check && (!rule.conditions || rule.conditions.length === 0)) {
137
+ const builtinName = rule.check === "has_edits" ? "has_edits" : rule.check;
138
+ rule.conditions = [{ builtin: builtinName as ConditionBuiltin }];
139
+ }
140
+ // 填充默认值
141
+ if (!rule.hook) rule.hook = "tool_call";
142
+ if (!rule.tool) rule.tool = "bash";
143
+ if (!rule.action) rule.action = "block";
144
+ }
145
+ return active;
146
+ }
147
+
148
+ /** 加载所有规则并校验格式,返回编译后的规则列表 */
149
+ export interface LoadRulesOptions {
150
+ /** 已弃用,保留以兼容旧调用方 */
151
+ projectRulesPattern?: string;
152
+ }
153
+
154
+ /** loadRules 缓存:基于文件 mtime 的自动失效 */
155
+ let _cacheKey = "";
156
+ let _cacheValue: Rule[] = [];
157
+
158
+ function buildCacheKey(rulesDir?: string): string {
159
+ const parts: string[] = [];
160
+ if (rulesDir) {
161
+ const globalPath = path.join(rulesDir, "rules.json");
162
+ try {
163
+ parts.push(`${globalPath}:${fs.statSync(globalPath).mtimeMs}`);
164
+ } catch {
165
+ parts.push(`${globalPath}:none`);
166
+ }
167
+ }
168
+ const projectPath = path.join(process.cwd(), ".pi", "extensions", "shepherd-rules.json");
169
+ try {
170
+ parts.push(`${projectPath}:${fs.statSync(projectPath).mtimeMs}`);
171
+ } catch {
172
+ parts.push(`${projectPath}:none`);
173
+ }
174
+ return parts.join("|");
175
+ }
176
+
177
+ /** 清除 loadRules 缓存(规则文件变更后调用) */
178
+ export function invalidateRulesCache(): void {
179
+ _cacheKey = "";
180
+ _cacheValue = [];
181
+ }
182
+
183
+ export function loadRules(
184
+ rulesDir?: string,
185
+ options?: LoadRulesOptions,
186
+ ): Rule[] {
187
+ const key = buildCacheKey(rulesDir);
188
+ if (key === _cacheKey) return _cacheValue;
189
+
190
+ const allRules: Rule[] = [];
191
+ const errors: string[] = [];
192
+
193
+ // 1. 全局规则:rules.json
194
+ if (rulesDir) {
195
+ const result = loadRulesFromFile(path.join(rulesDir, "rules.json"));
196
+ allRules.push(...result.rules);
197
+ if (result.error) errors.push(result.error);
198
+ }
199
+
200
+ // 2. 项目级规则:<cwd>/.pi/extensions/shepherd-rules.json
201
+ const projectRulesPath = path.join(process.cwd(), ".pi", "extensions", "shepherd-rules.json");
202
+ if (fs.existsSync(projectRulesPath)) {
203
+ const result = loadRulesFromFile(projectRulesPath);
204
+ allRules.push(...result.rules);
205
+ if (result.error) errors.push(result.error);
206
+ }
207
+
208
+ // 格式校验失败时推入 shepherd 提示缓冲区
209
+ if (errors.length > 0) {
210
+ const msg = errors.join(";");
211
+ console.error(`[shepherd] 规则文件格式错误: ${msg}`);
212
+ pushRuleError(msg);
213
+ }
214
+
215
+ _cacheKey = key;
216
+ _cacheValue = compileRules(allRules);
217
+ return _cacheValue;
218
+ }
219
+
220
+ /** 从事件中提取匹配目标(多字段)
221
+ * @param phase 调用阶段:"tool_call" 时 git commit 会被短路(避免 commit message 误触发 block 规则),
222
+ * "tool_result" 时不短路(允许 git commit 后的 steer/notify 规则触发)
223
+ * "tool_result" 时额外注入 result 字段(工具输出文本)
224
+ */
225
+ export function getMatchTargets(
226
+ tool: string,
227
+ event: ToolEvent,
228
+ phase?: string,
229
+ ): Record<string, string> {
230
+ // result 字段:仅 tool_result 阶段提取工具输出文本
231
+ const result = phase === "tool_result" ? extractResultText(event) : "";
232
+
233
+ if (tool === "bash") {
234
+ const command = (event.input as any)?.command || "";
235
+ // git commit 的 message 可能包含 sed -i / echo >> 等关键词,跳过匹配
236
+ // 注意:命令可能是 "cd xxx && git commit ..." 格式
237
+ // 但仅在 tool_call 阶段短路——tool_result 阶段需要匹配 git commit 后的 steer 规则
238
+ if (phase === "tool_call" && /(^|&&|;)\s*git\s+commit\b/.test(command)) {
239
+ return { result } as Record<string, string>;
240
+ }
241
+ return {
242
+ command,
243
+ path: "",
244
+ text: "",
245
+ glob: "",
246
+ result,
247
+ };
248
+ }
249
+ // grep 工具:提取 glob(文件过滤)、path(搜索目录)、text(搜索模式)
250
+ if (tool === "grep") {
251
+ const pathVal = (event.input as any)?.path || "";
252
+ const globVal = (event.input as any)?.glob || "";
253
+ const patternVal = (event.input as any)?.pattern || "";
254
+
255
+ // 有 glob 时必须是代码扩展名,无 glob 时默认全搜(也触发提醒)
256
+ if (globVal && !CODE_EXT_RE.test(globVal)) {
257
+ return { result } as Record<string, string>;
258
+ }
259
+ return { path: pathVal, text: patternVal, command: "", glob: globVal, result };
260
+ }
261
+ // edit / write / 其他工具
262
+ const pathVal = (event.input as any)?.path || "";
263
+ let text = "";
264
+ if (tool === "edit") {
265
+ const edits = (event.input as any)?.edits;
266
+ if (Array.isArray(edits)) {
267
+ text = edits.map((e: { newText?: string }) => e.newText || "").join("\n");
268
+ }
269
+ } else if (tool === "write") {
270
+ text = (event.input as any)?.content || "";
271
+ } else {
272
+ // 其他工具:把所有参数序列化为 text,供 conditions 的 text field 匹配
273
+ const input = event.input as any;
274
+ if (input && typeof input === "object") {
275
+ text = JSON.stringify(input);
276
+ }
277
+ }
278
+ return { path: pathVal, text, command: "", glob: "", result };
279
+ }
280
+
281
+ /** 判断规则是否匹配事件
282
+ * @param rule 规则对象
283
+ * @param toolOrTargets 工具名(旧签名)或匹配目标(新签名)
284
+ * @param targetsOrCtx 匹配目标(旧签名)或 BuiltinContext(新签名)
285
+ * @param tool 工具名(仅新签名使用)
286
+ */
287
+ export function ruleMatches(
288
+ rule: Rule,
289
+ toolOrTargets: string | Record<string, string>,
290
+ targetsOrCtx?: Record<string, string> | BuiltinContext,
291
+ tool?: string,
292
+ ): boolean {
293
+ // 参数重载:兼容旧签名 ruleMatches(rule, tool, targets)
294
+ let targets: Record<string, string>;
295
+ let ctx: BuiltinContext | undefined;
296
+ let toolName: string | undefined;
297
+ if (typeof toolOrTargets === "string") {
298
+ // 旧签名: ruleMatches(rule, toolName, targets)
299
+ toolName = toolOrTargets;
300
+ targets = (targetsOrCtx as Record<string, string>) ?? {};
301
+ ctx = undefined;
302
+ } else {
303
+ // 新签名: ruleMatches(rule, targets, ctx?, tool?)
304
+ targets = toolOrTargets;
305
+ ctx = targetsOrCtx as BuiltinContext | undefined;
306
+ toolName = tool;
307
+ }
308
+ // 多条件模式
309
+ if (rule.conditions && rule.conditions.length > 0) {
310
+ const logic = rule.conditionLogic ?? "and";
311
+ if (logic === "or") {
312
+ return rule.conditions.some((cond) => matchCondition(cond, targets, ctx));
313
+ }
314
+ return rule.conditions.every((cond) => matchCondition(cond, targets, ctx));
315
+ }
316
+ // 单条件模式(向后兼容)
317
+ if (rule._compiled) {
318
+ const field = toolName === "bash" ? "command" : "path";
319
+ const target = targets[field] || "";
320
+ return rule._compiled.test(target);
321
+ }
322
+ return false;
323
+ }
324
+
325
+ /** 判断单个条件是否满足(正则或 builtin) */
326
+ function matchCondition(
327
+ cond: Condition,
328
+ targets: Record<string, string>,
329
+ ctx?: BuiltinContext,
330
+ ): boolean {
331
+ // builtin 条件:直接检查环境状态
332
+ if (cond.builtin) {
333
+ const result = matchBuiltinCondition(cond.builtin, ctx ?? {});
334
+ return cond.negate ? !result : result;
335
+ }
336
+ // 正则条件
337
+ const field = cond.field ?? "path";
338
+ const target = targets[field] || "";
339
+ const matched = cond._compiled?.test(target) ?? false;
340
+ return cond.negate ? !matched : matched;
341
+ }
342
+
343
+ /** tool 字段匹配:支持 "|" 分隔的多值(如 "edit|write") */
344
+ export function toolMatches(
345
+ ruleTool: string | undefined,
346
+ eventTool: string,
347
+ ): boolean {
348
+ if (!ruleTool) return true; // 未指定 tool 时默认匹配所有(由 hook 类型决定范围)
349
+ return ruleTool
350
+ .split("|")
351
+ .map((t) => t.trim())
352
+ .includes(eventTool);
353
+ }
354
+
355
+ /** rtk 可用性(懒加载 + 缓存,避免模块加载时 2s timeout) */
356
+ let _rtkAvailable: boolean | undefined;
357
+ export function isRtkAvailable(): boolean {
358
+ if (_rtkAvailable !== undefined) return _rtkAvailable;
359
+ try {
360
+ execSync("which rtk", { timeout: 2000, stdio: "pipe" });
361
+ _rtkAvailable = true;
362
+ } catch {
363
+ _rtkAvailable = false;
364
+ }
365
+ return _rtkAvailable;
366
+ }