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/README.en.md +205 -84
- package/README.md +207 -88
- package/index.ts +262 -229
- package/package.json +60 -55
- package/rules.json +196 -33
- package/shepherd/compaction.ts +76 -0
- package/shepherd/conditions.ts +98 -0
- package/shepherd/ephemeral.ts +55 -52
- package/shepherd/git.ts +64 -0
- package/shepherd/index.ts +13 -0
- package/shepherd/line-count.ts +86 -86
- package/shepherd/message-end.ts +120 -0
- package/shepherd/rules-editor.ts +250 -215
- package/shepherd/rules-tool-helpers.ts +119 -120
- package/shepherd/rules-tool-list.ts +124 -126
- package/shepherd/rules-tool.ts +182 -142
- package/shepherd/rules-validate.ts +89 -44
- package/shepherd/rules.ts +366 -295
- package/shepherd/tool-event-types.ts +27 -14
- package/shepherd/tool-hooks.ts +165 -177
- package/shepherd/worktree-check.ts +130 -130
- package/tsconfig.json +21 -14
package/shepherd/git.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
function parseGitStatus(): { dirty: string[]; untracked: string[] } {
|
|
4
|
+
const result = { dirty: [] as string[], untracked: [] as string[] };
|
|
5
|
+
try {
|
|
6
|
+
const status = execSync("git status --porcelain", {
|
|
7
|
+
timeout: 5000,
|
|
8
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
9
|
+
cwd: process.cwd(),
|
|
10
|
+
})
|
|
11
|
+
.toString()
|
|
12
|
+
.trim();
|
|
13
|
+
if (!status) return result;
|
|
14
|
+
for (const line of status.split("\n")) {
|
|
15
|
+
if (!line) continue;
|
|
16
|
+
if (line.startsWith("??")) {
|
|
17
|
+
result.untracked.push(line);
|
|
18
|
+
} else {
|
|
19
|
+
result.dirty.push(line);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
/* not a git repo or git not available */
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 检测 git 工作区是否有已跟踪文件的未提交改动(M/A/D/R) */
|
|
29
|
+
export function isGitDirty(): boolean {
|
|
30
|
+
return parseGitStatus().dirty.length > 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 检测 git 工作区是否有未跟踪文件(??) */
|
|
34
|
+
export function hasGitUntracked(): boolean {
|
|
35
|
+
return parseGitStatus().untracked.length > 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 检测 git 工作区是否有已跟踪改动或未跟踪文件 */
|
|
39
|
+
export function isGitDirtyOrUntracked(): boolean {
|
|
40
|
+
const { dirty, untracked } = parseGitStatus();
|
|
41
|
+
return dirty.length > 0 || untracked.length > 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 向后兼容:原 hasGitUncommittedChanges 只看已跟踪文件 */
|
|
45
|
+
export function hasGitUncommittedChanges(): boolean {
|
|
46
|
+
return isGitDirty();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 当前是否在 worktree 中 */
|
|
50
|
+
export function isInWorktree(): boolean {
|
|
51
|
+
try {
|
|
52
|
+
const cwd = process.cwd();
|
|
53
|
+
if (/\/\.worktrees\/[^/]+/.test(cwd)) return true;
|
|
54
|
+
const gitDir = execSync("git rev-parse --git-dir", {
|
|
55
|
+
timeout: 5000,
|
|
56
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
57
|
+
})
|
|
58
|
+
.toString()
|
|
59
|
+
.trim();
|
|
60
|
+
return gitDir !== ".git";
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/shepherd/index.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export { hasWarnings, notifySummary, pushWarning } from "./ephemeral";
|
|
6
|
+
export { registerSessionCompact } from "./compaction";
|
|
6
7
|
export {
|
|
7
8
|
drainHints,
|
|
8
9
|
hasHints,
|
|
@@ -12,15 +13,26 @@ export {
|
|
|
12
13
|
} from "./ephemeral-shared";
|
|
13
14
|
export { checkLineCount } from "./line-count";
|
|
14
15
|
export {
|
|
16
|
+
extractAssistantText,
|
|
17
|
+
registerMessageEnd,
|
|
18
|
+
resetMessageEndState,
|
|
19
|
+
} from "./message-end";
|
|
20
|
+
export {
|
|
21
|
+
type BuiltinContext,
|
|
15
22
|
CODE_EXT_RE,
|
|
16
23
|
type Condition,
|
|
24
|
+
type ConditionBuiltin,
|
|
17
25
|
compileRules,
|
|
18
26
|
getMatchTargets,
|
|
19
27
|
hasGitUncommittedChanges,
|
|
28
|
+
hasGitUntracked,
|
|
29
|
+
isGitDirty,
|
|
30
|
+
isGitDirtyOrUntracked,
|
|
20
31
|
isInWorktree,
|
|
21
32
|
isSubagent,
|
|
22
33
|
loadRules,
|
|
23
34
|
loadRulesFromFile,
|
|
35
|
+
matchBuiltinCondition,
|
|
24
36
|
type Rule,
|
|
25
37
|
ruleMatches,
|
|
26
38
|
toolMatches,
|
|
@@ -38,3 +50,4 @@ export {
|
|
|
38
50
|
toolsAvailable,
|
|
39
51
|
} from "./tool-hooks";
|
|
40
52
|
export { checkWorktrees } from "./worktree-check";
|
|
53
|
+
export type { ToolEvent } from "./tool-event-types";
|
package/shepherd/line-count.ts
CHANGED
|
@@ -1,86 +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
|
-
}
|
|
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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* message_end hook 处理逻辑
|
|
3
|
+
*
|
|
4
|
+
* 在 AI 助手回复完成后,对回复文本进行正则匹配,
|
|
5
|
+
* 触发 notify(弹通知)或 steer(注入下一轮)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { pushWarning } from "./ephemeral.js";
|
|
10
|
+
import {
|
|
11
|
+
isSubagent,
|
|
12
|
+
type LoadRulesOptions,
|
|
13
|
+
loadRules,
|
|
14
|
+
ruleMatches,
|
|
15
|
+
} from "./rules.js";
|
|
16
|
+
import type { ToolState } from "./tool-hooks.js";
|
|
17
|
+
import { toolsAvailable } from "./tool-hooks.js";
|
|
18
|
+
|
|
19
|
+
// ── 文本提取 ──────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** assistant 消息中的 content block 类型 */
|
|
22
|
+
interface TextBlock {
|
|
23
|
+
type: "text";
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 从 assistant 消息中提取所有文本 content blocks 并拼接。
|
|
29
|
+
* 安全处理 null/undefined/非数组 content。
|
|
30
|
+
*/
|
|
31
|
+
export function extractAssistantText(
|
|
32
|
+
message: { role: string; content?: unknown } | null | undefined,
|
|
33
|
+
): string {
|
|
34
|
+
if (!message?.content || !Array.isArray(message.content)) return "";
|
|
35
|
+
return (message.content as Array<unknown>)
|
|
36
|
+
.filter(
|
|
37
|
+
(block): block is TextBlock =>
|
|
38
|
+
!!block &&
|
|
39
|
+
typeof block === "object" &&
|
|
40
|
+
(block as any).type === "text" &&
|
|
41
|
+
typeof (block as any).text === "string",
|
|
42
|
+
)
|
|
43
|
+
.map((block) => block.text)
|
|
44
|
+
.join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── message_end hook 注册 ─────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** 防重复触发的 rule comment 集合 */
|
|
50
|
+
const _messageEndFired = new Set<string>();
|
|
51
|
+
|
|
52
|
+
/** 重置防重复标记(agent_start 时调用) */
|
|
53
|
+
export function resetMessageEndState(): void {
|
|
54
|
+
_messageEndFired.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 注册 message_end hook */
|
|
58
|
+
export function registerMessageEnd(
|
|
59
|
+
pi: ExtensionAPI,
|
|
60
|
+
state: ToolState,
|
|
61
|
+
rulesDir?: string,
|
|
62
|
+
rulesOptions?: LoadRulesOptions,
|
|
63
|
+
): void {
|
|
64
|
+
pi.on("message_end", async (event, _ctx) => {
|
|
65
|
+
// 只处理 assistant 消息
|
|
66
|
+
const message = event.message;
|
|
67
|
+
if (!message || (message as any).role !== "assistant") return;
|
|
68
|
+
|
|
69
|
+
const rules = loadRules(rulesDir, rulesOptions).filter(
|
|
70
|
+
(r) => r.hook === "message_end",
|
|
71
|
+
);
|
|
72
|
+
if (rules.length === 0) return;
|
|
73
|
+
|
|
74
|
+
// 提取 AI 回复的纯文本
|
|
75
|
+
const text = extractAssistantText(message as any);
|
|
76
|
+
if (!text) return;
|
|
77
|
+
|
|
78
|
+
// 构造匹配目标:只有 text 字段
|
|
79
|
+
const targets = { text, path: "", command: "", glob: "" };
|
|
80
|
+
|
|
81
|
+
for (const rule of rules) {
|
|
82
|
+
// 跳过禁用规则
|
|
83
|
+
if (rule.enabled === false) continue;
|
|
84
|
+
// 子代理控制
|
|
85
|
+
if (isSubagent() && rule.subagent === false) continue;
|
|
86
|
+
// 工具依赖检查
|
|
87
|
+
if (!toolsAvailable(rule, pi, state)) continue;
|
|
88
|
+
// 防重复触发
|
|
89
|
+
if (_messageEndFired.has(rule.comment)) continue;
|
|
90
|
+
|
|
91
|
+
// 条件匹配
|
|
92
|
+
if (rule.conditions && rule.conditions.length > 0) {
|
|
93
|
+
if (!ruleMatches(rule, targets, undefined, "")) continue;
|
|
94
|
+
} else if (rule.pattern) {
|
|
95
|
+
// 单条件模式:pattern 匹配 text
|
|
96
|
+
const re = rule._compiled ?? new RegExp(rule.pattern, rule.flags || "");
|
|
97
|
+
if (!re.test(text)) continue;
|
|
98
|
+
} else {
|
|
99
|
+
// 无条件:无条件匹配(总是触发)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 标记已触发
|
|
103
|
+
_messageEndFired.add(rule.comment);
|
|
104
|
+
|
|
105
|
+
// 执行 action
|
|
106
|
+
if (rule.action === "steer") {
|
|
107
|
+
pushWarning(rule.reason, rule.comment);
|
|
108
|
+
} else if (rule.action === "notify" || !rule.action) {
|
|
109
|
+
pushWarning(rule.reason, rule.comment);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 不再在此处发送 sendMessage(triggerTurn)。
|
|
114
|
+
// agent_end 会统一检查 hasWarnings() 并发送唯一的 triggerTurn,
|
|
115
|
+
// 避免 message_end 和 agent_end 各发一个 sendMessage 导致第二个 drainHints 空 → 空回复。
|
|
116
|
+
//
|
|
117
|
+
// 时序:message_end → turn_end → agent_end
|
|
118
|
+
// message_end 推入的 warning 会在 agent_end 的 sendMessage 触发后被 drainHints 消费。
|
|
119
|
+
});
|
|
120
|
+
}
|