pi-shepherd 0.1.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.
Files changed (55) hide show
  1. package/README.en.md +136 -0
  2. package/README.md +136 -0
  3. package/index.ts +229 -0
  4. package/node_modules/@pi-atelier/shared-utils/README.en.md +182 -0
  5. package/node_modules/@pi-atelier/shared-utils/README.md +182 -0
  6. package/node_modules/@pi-atelier/shared-utils/package.json +51 -0
  7. package/node_modules/@pi-atelier/shared-utils/src/__tests__/agents.test.ts +120 -0
  8. package/node_modules/@pi-atelier/shared-utils/src/__tests__/ephemeral.test.ts +100 -0
  9. package/node_modules/@pi-atelier/shared-utils/src/__tests__/file-lock.test.ts +152 -0
  10. package/node_modules/@pi-atelier/shared-utils/src/__tests__/filter-match.test.ts +187 -0
  11. package/node_modules/@pi-atelier/shared-utils/src/__tests__/memory-parser.test.ts +170 -0
  12. package/node_modules/@pi-atelier/shared-utils/src/__tests__/paths.test.ts +126 -0
  13. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config-edge.test.ts +138 -0
  14. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config.test.ts +257 -0
  15. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools-mcp.test.ts +189 -0
  16. package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools.test.ts +204 -0
  17. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-advanced.test.ts +269 -0
  18. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-array.test.ts +267 -0
  19. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup.test.ts +520 -0
  20. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-read.test.ts +116 -0
  21. package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-write.test.ts +119 -0
  22. package/node_modules/@pi-atelier/shared-utils/src/__tests__/tool-output.test.ts +145 -0
  23. package/node_modules/@pi-atelier/shared-utils/src/agents.ts +39 -0
  24. package/node_modules/@pi-atelier/shared-utils/src/ephemeral.ts +42 -0
  25. package/node_modules/@pi-atelier/shared-utils/src/file-lock.ts +62 -0
  26. package/node_modules/@pi-atelier/shared-utils/src/filter-match.ts +100 -0
  27. package/node_modules/@pi-atelier/shared-utils/src/index.ts +71 -0
  28. package/node_modules/@pi-atelier/shared-utils/src/memory-parser.ts +96 -0
  29. package/node_modules/@pi-atelier/shared-utils/src/paths.ts +23 -0
  30. package/node_modules/@pi-atelier/shared-utils/src/project-config.ts +241 -0
  31. package/node_modules/@pi-atelier/shared-utils/src/project-tools.ts +191 -0
  32. package/node_modules/@pi-atelier/shared-utils/src/settings-array.ts +73 -0
  33. package/node_modules/@pi-atelier/shared-utils/src/settings-backup-rollback.ts +104 -0
  34. package/node_modules/@pi-atelier/shared-utils/src/settings-backup-utils.ts +75 -0
  35. package/node_modules/@pi-atelier/shared-utils/src/settings-backup.ts +172 -0
  36. package/node_modules/@pi-atelier/shared-utils/src/settings.ts +104 -0
  37. package/node_modules/@pi-atelier/shared-utils/src/tool-output.ts +149 -0
  38. package/node_modules/@pi-atelier/shared-utils/tsconfig.json +9 -0
  39. package/node_modules/@pi-atelier/shared-utils/vitest.config.ts +24 -0
  40. package/package.json +49 -0
  41. package/rules.json +516 -0
  42. package/shepherd/ephemeral-shared.ts +14 -0
  43. package/shepherd/ephemeral.ts +52 -0
  44. package/shepherd/index.ts +39 -0
  45. package/shepherd/line-count.ts +86 -0
  46. package/shepherd/rules-editor.ts +135 -0
  47. package/shepherd/rules-tool.ts +99 -0
  48. package/shepherd/rules-validate.ts +44 -0
  49. package/shepherd/rules.ts +283 -0
  50. package/shepherd/state-tracker.ts +119 -0
  51. package/shepherd/tool-event-types.ts +31 -0
  52. package/shepherd/tool-hooks.ts +176 -0
  53. package/shepherd/worktree-check.ts +130 -0
  54. package/tsconfig.json +14 -0
  55. package/vitest.config.ts +13 -0
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 文件行数检查
3
+ * edit/write 后自动检测文件行数,超阈值时注入 ephemeral 提醒
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { pushWarning } from "./ephemeral.js";
9
+
10
+ // ── 代码文件阈值 ─────────────────────────────────────────────
11
+ const LINE_WARN = 200;
12
+ const LINE_MUST = 300;
13
+ const LINE_BAN = 500;
14
+
15
+ // ── 记忆文件阈值 ─────────────────────────────────────────────
16
+ const MEMORY_LINE_LIMIT = 200;
17
+
18
+ const CHECKED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".py", ".rs", ".go"]);
19
+
20
+ /** 判断路径是否为记忆文件(memory/ 或 .pi/memory/ 下的 .md,排除索引文件 MEMORY.md) */
21
+ function isMemoryFile(filePath: string): boolean {
22
+ if (!filePath.endsWith(".md")) return false;
23
+ const name = path.basename(filePath);
24
+ if (name === "MEMORY.md") return false;
25
+ const normalized = filePath.replace(/\\/g, "/");
26
+ return /\/memory\//.test(normalized);
27
+ }
28
+
29
+ export function checkLineCount(filePath: string): void {
30
+ const ext = path.extname(filePath);
31
+
32
+ // 记忆文件检查
33
+ if (isMemoryFile(filePath)) {
34
+ checkMemoryFile(filePath);
35
+ return;
36
+ }
37
+
38
+ // 代码文件检查
39
+ if (!CHECKED_EXTENSIONS.has(ext)) return;
40
+
41
+ let lines: number;
42
+ try {
43
+ const content = fs.readFileSync(filePath, "utf-8");
44
+ lines = content.split("\n").length;
45
+ } catch {
46
+ return;
47
+ }
48
+
49
+ const name = path.basename(filePath);
50
+
51
+ if (lines >= LINE_BAN) {
52
+ pushWarning(
53
+ `❌ 严禁: ${name} 已有 ${lines} 行(阈值 ${LINE_BAN} 行)。\n` +
54
+ `拆分建议:提取公共函数/工具类到独立文件,目标每个文件 ≤ 200 行。`,
55
+ );
56
+ } else if (lines >= LINE_MUST) {
57
+ pushWarning(
58
+ `🔴 必须拆分: ${name} 已有 ${lines} 行(阈值 ${LINE_MUST} 行)。\n` +
59
+ `拆分建议:提取公共函数/工具类到独立文件,目标每个文件 ≤ 200 行。`,
60
+ );
61
+ } else if (lines >= LINE_WARN) {
62
+ pushWarning(
63
+ `⚠️ 应主动检查: ${name} 已有 ${lines} 行(阈值 ${LINE_WARN} 行)。\n` +
64
+ `拆分建议:提取公共函数/工具类到独立文件,目标每个文件 ≤ 200 行。`,
65
+ );
66
+ }
67
+ }
68
+
69
+ /** 记忆文件行数检查:超过 200 行必须拆分 */
70
+ function checkMemoryFile(filePath: string): void {
71
+ let lines: number;
72
+ try {
73
+ const content = fs.readFileSync(filePath, "utf-8");
74
+ lines = content.split("\n").length;
75
+ } catch {
76
+ return;
77
+ }
78
+
79
+ if (lines >= MEMORY_LINE_LIMIT) {
80
+ const name = path.basename(filePath);
81
+ pushWarning(
82
+ `📝 记忆文件过长: ${name} 已有 ${lines} 行(上限 ${MEMORY_LINE_LIMIT} 行)。\n` +
83
+ `必须拆分:每个记忆文件一个主题,拆分后用 memory_update 或手动更新 MEMORY.md 索引。`,
84
+ );
85
+ }
86
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Shepherd 规则文件安全编辑器
3
+ *
4
+ * 提供 rules.json 的安全增删改查:
5
+ * - 写入前校验(必填字段、正则合法性、枚举值)
6
+ * - 自动备份(.bak)
7
+ * - 写入后回读验证
8
+ * - 失败自动回滚
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { validateRule } from "./rules-validate";
14
+ export { validateRule } from "./rules-validate";
15
+ export type { ValidationResult } from "./rules-validate";
16
+
17
+ // ── 类型 ──────────────────────────────────────────────────
18
+
19
+ export interface RuleSummary {
20
+ index: number;
21
+ comment: string;
22
+ action?: string;
23
+ tool?: string;
24
+ hook?: string;
25
+ enabled?: boolean;
26
+ pattern?: string;
27
+ }
28
+
29
+ export interface ListResult {
30
+ rules: RuleSummary[];
31
+ count: number;
32
+ error?: string;
33
+ }
34
+
35
+ export interface WriteResult {
36
+ success: boolean;
37
+ error?: string;
38
+ index?: number;
39
+ deleted?: { comment: string; [key: string]: unknown };
40
+ }
41
+
42
+ // ── 文件操作辅助 ──────────────────────────────────────────
43
+
44
+ interface FileData {
45
+ rules: Record<string, unknown>[];
46
+ error?: string;
47
+ }
48
+
49
+ function readFile(filePath: string): FileData {
50
+ if (!fs.existsSync(filePath)) return { rules: [] };
51
+ try {
52
+ const raw = fs.readFileSync(filePath, "utf-8");
53
+ const parsed = JSON.parse(raw);
54
+ if (!Array.isArray(parsed)) {
55
+ return { rules: [], error: `顶层必须是 JSON 数组,当前是 ${typeof parsed}` };
56
+ }
57
+ return { rules: parsed };
58
+ } catch (e: unknown) {
59
+ return { rules: [], error: `JSON 解析失败: ${e.message}` };
60
+ }
61
+ }
62
+
63
+ function safeWrite(filePath: string, rules: Record<string, unknown>[]): WriteResult {
64
+ const backupPath = `${filePath}.bak`;
65
+ if (fs.existsSync(filePath)) fs.copyFileSync(filePath, backupPath);
66
+ const dir = path.dirname(filePath);
67
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
68
+ fs.writeFileSync(filePath, JSON.stringify(rules, null, "\t"), "utf-8");
69
+ try {
70
+ const raw = fs.readFileSync(filePath, "utf-8");
71
+ const parsed = JSON.parse(raw);
72
+ if (!Array.isArray(parsed)) throw new Error("not array");
73
+ return { success: true };
74
+ } catch {
75
+ if (fs.existsSync(backupPath)) fs.copyFileSync(backupPath, filePath);
76
+ return { success: false, error: "写入后验证失败,已从备份恢复" };
77
+ }
78
+ }
79
+
80
+ // ── 公开 API ─────────────────────────────────────────────
81
+
82
+ export function listRules(filePath: string): ListResult {
83
+ const { rules, error } = readFile(filePath);
84
+ if (error) return { rules: [], count: 0, error };
85
+ return {
86
+ rules: rules.map((r, i) => ({
87
+ index: i,
88
+ comment: (r.comment as string) || "",
89
+ action: r.action as string | undefined,
90
+ tool: r.tool as string | undefined,
91
+ hook: r.hook as string | undefined,
92
+ enabled: r.enabled as boolean | undefined,
93
+ pattern: r.pattern as string | undefined,
94
+ })),
95
+ count: rules.length,
96
+ };
97
+ }
98
+
99
+ export function addRule(filePath: string, rule: Record<string, unknown>): WriteResult {
100
+ const { rules, error } = readFile(filePath);
101
+ if (error) return { success: false, error };
102
+ const validation = validateRule(rule);
103
+ if (!validation.valid) return { success: false, error: validation.errors.join("; ") };
104
+ rules.push(rule);
105
+ const writeResult = safeWrite(filePath, rules);
106
+ return writeResult.success ? { success: true, index: rules.length - 1 } : writeResult;
107
+ }
108
+
109
+ export function updateRule(
110
+ filePath: string,
111
+ index: number,
112
+ changes: Record<string, unknown>,
113
+ ): WriteResult {
114
+ if (index < 0) return { success: false, error: `编号越界: ${index}` };
115
+ const { rules, error } = readFile(filePath);
116
+ if (error) return { success: false, error };
117
+ if (index >= rules.length) return { success: false, error: `编号越界: ${index}(共 ${rules.length} 条)` };
118
+ const merged = { ...rules[index], ...changes };
119
+ const validation = validateRule(merged);
120
+ if (!validation.valid) return { success: false, error: validation.errors.join("; ") };
121
+ rules[index] = merged;
122
+ return safeWrite(filePath, rules);
123
+ }
124
+
125
+ export function deleteRule(filePath: string, index: number): WriteResult {
126
+ if (index < 0) return { success: false, error: `编号越界: ${index}` };
127
+ const { rules, error } = readFile(filePath);
128
+ if (error) return { success: false, error };
129
+ if (index >= rules.length) return { success: false, error: `编号越界: ${index}(共 ${rules.length} 条)` };
130
+ const deleted = rules.splice(index, 1)[0];
131
+ const writeResult = safeWrite(filePath, rules);
132
+ return writeResult.success
133
+ ? { success: true, deleted: { comment: (deleted.comment as string) || "", ...deleted } }
134
+ : writeResult;
135
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Shepherd 规则编辑工具注册
3
+ *
4
+ * 注册 shepherd_rules 工具到 pi,提供规则文件的安全增删改查。
5
+ */
6
+
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { addRule, deleteRule, listRules, updateRule } from "./rules-editor";
9
+
10
+ /** 构造 pi 工具 execute 的标准返回格式 */
11
+ function textResult(text: string) {
12
+ return { content: [{ type: "text" as const, text }] };
13
+ }
14
+
15
+ export function registerRulesEditorTool(pi: ExtensionAPI, rulesFilePath: string) {
16
+ pi.registerTool({
17
+ name: "shepherd_rules",
18
+ label: "Shepherd Rules Editor",
19
+ description:
20
+ "安全编辑 shepherd 规则文件。支持 list(列出所有规则)、add(添加规则)、update(部分更新规则)、delete(删除规则)。" +
21
+ "写入前自动校验必填字段和正则合法性,写入后回读验证,失败自动从备份恢复。",
22
+ parameters: {
23
+ type: "object",
24
+ properties: {
25
+ action: {
26
+ type: "string",
27
+ enum: ["list", "add", "update", "delete"],
28
+ description: "操作类型",
29
+ },
30
+ rule: {
31
+ type: "object",
32
+ description: "add 时传入的完整规则对象(必须含 comment 和 reason)",
33
+ },
34
+ index: {
35
+ type: "number",
36
+ description: "update/delete 时指定规则编号(0-based)",
37
+ },
38
+ changes: {
39
+ type: "object",
40
+ description: "update 时要修改的字段(只传需要改的)",
41
+ },
42
+ },
43
+ required: ["action"],
44
+ },
45
+ async execute(
46
+ _toolCallId: string,
47
+ params: {
48
+ action: "list" | "add" | "update" | "delete";
49
+ rule?: Record<string, unknown>;
50
+ index?: number;
51
+ changes?: Record<string, unknown>;
52
+ },
53
+ ) {
54
+ switch (params.action) {
55
+ case "list": {
56
+ const result = listRules(rulesFilePath);
57
+ if (result.error) return textResult(`❌ ${result.error}`);
58
+ if (result.count === 0) return textResult("暂无规则。");
59
+ return textResult(
60
+ result.rules
61
+ .map(
62
+ (r) =>
63
+ `[${r.index}] ${r.comment}` +
64
+ (r.enabled === false ? " (disabled)" : "") +
65
+ (r.action ? ` — ${r.action}` : "") +
66
+ (r.tool ? ` on ${r.tool}` : "") +
67
+ (r.hook ? ` @ ${r.hook}` : ""),
68
+ )
69
+ .join("\n"),
70
+ );
71
+ }
72
+ case "add": {
73
+ if (!params.rule) return textResult("❌ add 需要 rule 参数");
74
+ const result = addRule(rulesFilePath, params.rule);
75
+ return result.success
76
+ ? textResult(`✅ 规则已添加 [${result.index}]`)
77
+ : textResult(`❌ ${result.error}`);
78
+ }
79
+ case "update": {
80
+ if (params.index === undefined) return textResult("❌ update 需要 index 参数");
81
+ if (!params.changes) return textResult("❌ update 需要 changes 参数");
82
+ const result = updateRule(rulesFilePath, params.index, params.changes);
83
+ return result.success
84
+ ? textResult(`✅ 规则 [${params.index}] 已更新`)
85
+ : textResult(`❌ ${result.error}`);
86
+ }
87
+ case "delete": {
88
+ if (params.index === undefined) return textResult("❌ delete 需要 index 参数");
89
+ const result = deleteRule(rulesFilePath, params.index);
90
+ return result.success
91
+ ? textResult(`✅ 规则已删除: ${(result.deleted as any)?.comment || ""}`)
92
+ : textResult(`❌ ${result.error}`);
93
+ }
94
+ default:
95
+ return textResult(`❌ 未知操作: ${(params as any).action}`);
96
+ }
97
+ },
98
+ });
99
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shepherd 规则校验逻辑
3
+ */
4
+
5
+ const VALID_ACTIONS = ["block", "notify", "rewrite", "steer"] as const;
6
+ const VALID_HOOKS = ["tool_call", "tool_result", "agent_end", "session_shutdown"] as const;
7
+
8
+ export interface ValidationResult {
9
+ valid: boolean;
10
+ errors: string[];
11
+ }
12
+
13
+ /** 校验单条规则 */
14
+ export function validateRule(rule: Record<string, unknown>): ValidationResult {
15
+ const errors: string[] = [];
16
+
17
+ if (!rule.comment || typeof rule.comment !== "string" || !rule.comment.trim()) {
18
+ errors.push("缺少必填字段: comment");
19
+ }
20
+ if (!rule.reason || typeof rule.reason !== "string" || !rule.reason.trim()) {
21
+ errors.push("缺少必填字段: reason");
22
+ }
23
+ if (rule.action && !VALID_ACTIONS.includes(rule.action as any)) {
24
+ errors.push(`action 值 "${rule.action}" 不合法,可选: ${VALID_ACTIONS.join(", ")}`);
25
+ }
26
+ if (rule.hook && !VALID_HOOKS.includes(rule.hook as any)) {
27
+ errors.push(`hook 值 "${rule.hook}" 不合法,可选: ${VALID_HOOKS.join(", ")}`);
28
+ }
29
+ if (rule.pattern) {
30
+ try { new RegExp(rule.pattern as string, (rule.flags as string) || ""); }
31
+ catch (e: unknown) { errors.push(`pattern 正则编译失败: ${e.message}`); }
32
+ }
33
+ if (Array.isArray(rule.conditions)) {
34
+ for (let i = 0; i < rule.conditions.length; i++) {
35
+ const cond = rule.conditions[i] as Record<string, unknown>;
36
+ if (cond.pattern) {
37
+ try { new RegExp(cond.pattern as string, (cond.flags as string) || ""); }
38
+ catch (e: unknown) { errors.push(`conditions[${i}].pattern 正则编译失败: ${e.message}`); }
39
+ }
40
+ }
41
+ }
42
+
43
+ return { valid: errors.length === 0, errors };
44
+ }
@@ -0,0 +1,283 @@
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"
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)
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.startsWith(prefix) && file.endsWith(".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
+ }
251
+ return { path: pathVal, text, command: "", glob: "" };
252
+ }
253
+
254
+ /** 判断规则是否匹配事件 */
255
+ export function ruleMatches(
256
+ rule: Rule,
257
+ tool: string,
258
+ targets: Record<string, string>,
259
+ ): boolean {
260
+ // 多条件 AND 模式
261
+ if (rule.conditions && rule.conditions.length > 0) {
262
+ return rule.conditions.every((cond) => {
263
+ const target = targets[cond.field] || "";
264
+ return cond._compiled?.test(target) ?? false;
265
+ });
266
+ }
267
+ // 单条件模式(向后兼容)
268
+ if (rule._compiled) {
269
+ const target = targets[tool === "bash" ? "command" : "path"] || "";
270
+ return rule._compiled.test(target);
271
+ }
272
+ return false;
273
+ }
274
+
275
+ /** rtk 可用性(模块加载时检测) */
276
+ export const isRtkAvailable: boolean = (() => {
277
+ try {
278
+ execSync("which rtk", { timeout: 2000, stdio: "pipe" });
279
+ return true;
280
+ } catch {
281
+ return false;
282
+ }
283
+ })();