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
|
@@ -5,27 +5,40 @@
|
|
|
5
5
|
|
|
6
6
|
import type {
|
|
7
7
|
BashToolCallEvent,
|
|
8
|
-
BashToolResultEvent,
|
|
9
8
|
CustomToolCallEvent,
|
|
10
|
-
CustomToolResultEvent,
|
|
11
9
|
EditToolCallEvent,
|
|
12
|
-
EditToolResultEvent,
|
|
13
10
|
GrepToolCallEvent,
|
|
14
|
-
GrepToolResultEvent,
|
|
15
11
|
LsToolCallEvent,
|
|
16
|
-
LsToolResultEvent,
|
|
17
12
|
ReadToolCallEvent,
|
|
18
|
-
|
|
13
|
+
ToolResultEvent,
|
|
19
14
|
WriteToolCallEvent,
|
|
20
|
-
WriteToolResultEvent,
|
|
21
15
|
} from "@earendil-works/pi-coding-agent";
|
|
22
16
|
|
|
17
|
+
|
|
18
|
+
/** content block 中的文本块 */
|
|
19
|
+
interface ContentBlock {
|
|
20
|
+
type: string;
|
|
21
|
+
text?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 从 tool_result 事件的 content 中提取纯文本 */
|
|
25
|
+
export function extractResultText(event: ToolEvent): string {
|
|
26
|
+
if (!("content" in event)) return "";
|
|
27
|
+
const content = (event as { content?: ContentBlock[] }).content;
|
|
28
|
+
if (!Array.isArray(content)) return "";
|
|
29
|
+
return content
|
|
30
|
+
.filter((c) => c.type === "text")
|
|
31
|
+
.map((c) => c.text ?? "")
|
|
32
|
+
.join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
/** getMatchTargets 接收的事件类型(tool_call 或 tool_result) */
|
|
24
36
|
export type ToolEvent =
|
|
25
|
-
| BashToolCallEvent
|
|
26
|
-
| CustomToolCallEvent
|
|
27
|
-
| EditToolCallEvent
|
|
28
|
-
| GrepToolCallEvent
|
|
29
|
-
| LsToolCallEvent
|
|
30
|
-
| ReadToolCallEvent
|
|
31
|
-
| WriteToolCallEvent
|
|
37
|
+
| BashToolCallEvent
|
|
38
|
+
| CustomToolCallEvent
|
|
39
|
+
| EditToolCallEvent
|
|
40
|
+
| GrepToolCallEvent
|
|
41
|
+
| LsToolCallEvent
|
|
42
|
+
| ReadToolCallEvent
|
|
43
|
+
| WriteToolCallEvent
|
|
44
|
+
| ToolResultEvent;
|
package/shepherd/tool-hooks.ts
CHANGED
|
@@ -1,177 +1,165 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* tool_call 和 tool_result hook 处理逻辑
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
state
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
pi
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (!
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
if (rule.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (rule.state) state.tracker.markTriggered(rule.comment);
|
|
167
|
-
const stats = state.tracker.getStats(rule.state?.tools ?? []);
|
|
168
|
-
const reason = rule.reason
|
|
169
|
-
.replace("{count}", String(stats.count))
|
|
170
|
-
.replace("{chars}", String(Math.round(stats.chars / 1000)));
|
|
171
|
-
pushWarning(reason, rule.comment);
|
|
172
|
-
} else if (rule.action === "notify") {
|
|
173
|
-
pushWarning(rule.reason, rule.comment);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* tool_call 和 tool_result hook 处理逻辑
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import { pushWarning } from "./ephemeral.js";
|
|
8
|
+
import { extractResultText } from "./tool-event-types.js";
|
|
9
|
+
import { checkLineCount } from "./line-count.js";
|
|
10
|
+
import {
|
|
11
|
+
getMatchTargets,
|
|
12
|
+
isRtkAvailable,
|
|
13
|
+
isSubagent,
|
|
14
|
+
type LoadRulesOptions,
|
|
15
|
+
loadRules,
|
|
16
|
+
type Rule,
|
|
17
|
+
ruleMatches,
|
|
18
|
+
toolMatches,
|
|
19
|
+
} from "./rules.js";
|
|
20
|
+
import type { ResettableRule, StateTracker } from "./state-tracker.js";
|
|
21
|
+
|
|
22
|
+
/** 工具 hook 共享的可变状态 */
|
|
23
|
+
export interface ToolState {
|
|
24
|
+
hasEdits: boolean;
|
|
25
|
+
tracker: StateTracker;
|
|
26
|
+
cachedTools: Set<string> | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getAvailableTools(
|
|
30
|
+
pi: ExtensionAPI,
|
|
31
|
+
state: ToolState,
|
|
32
|
+
): Set<string> {
|
|
33
|
+
if (!state.cachedTools) {
|
|
34
|
+
state.cachedTools = new Set(pi.getActiveTools());
|
|
35
|
+
}
|
|
36
|
+
return state.cachedTools;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function toolsAvailable(
|
|
40
|
+
rule: Rule,
|
|
41
|
+
pi: ExtensionAPI,
|
|
42
|
+
state: ToolState,
|
|
43
|
+
): boolean {
|
|
44
|
+
if (!rule.requiresTools || rule.requiresTools.length === 0) return true;
|
|
45
|
+
const tools = getAvailableTools(pi, state);
|
|
46
|
+
return rule.requiresTools.every((t) => tools.has(t));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 注册 tool_call hook */
|
|
50
|
+
export function registerToolCall(
|
|
51
|
+
pi: ExtensionAPI,
|
|
52
|
+
state: ToolState,
|
|
53
|
+
rulesDir?: string,
|
|
54
|
+
rulesOptions?: LoadRulesOptions,
|
|
55
|
+
): void {
|
|
56
|
+
pi.on("tool_call", async (event) => {
|
|
57
|
+
if (event.toolName === "edit" || event.toolName === "write") {
|
|
58
|
+
state.hasEdits = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const rules = loadRules(rulesDir, rulesOptions).filter(
|
|
62
|
+
(r) => r.hook === "tool_call" && toolMatches(r.tool, event.toolName!),
|
|
63
|
+
);
|
|
64
|
+
if (rules.length === 0) return;
|
|
65
|
+
|
|
66
|
+
const targets = getMatchTargets(event.toolName!, event, "tool_call");
|
|
67
|
+
if (!targets || Object.keys(targets).length === 0) return;
|
|
68
|
+
|
|
69
|
+
for (const rule of rules) {
|
|
70
|
+
if (isSubagent() && rule.subagent === false) continue;
|
|
71
|
+
if (!toolsAvailable(rule, pi, state)) continue;
|
|
72
|
+
if (!ruleMatches(rule, targets, undefined, event.toolName!)) continue;
|
|
73
|
+
|
|
74
|
+
if (rule.action === "block") {
|
|
75
|
+
return { block: true, reason: `⛔ shepherd: ${rule.reason}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (rule.action === "rewrite" && event.toolName === "bash") {
|
|
79
|
+
if (!isRtkAvailable) continue;
|
|
80
|
+
const cmd = targets.command;
|
|
81
|
+
if (cmd && !cmd.startsWith("rtk ")) {
|
|
82
|
+
(event.input as any).command = `rtk ${cmd}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (rule.action === "notify") {
|
|
87
|
+
// tool_call notify:在 turn 内部触发,before_provider_request 会在同 turn 内消费
|
|
88
|
+
pushWarning(rule.reason, rule.comment);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 注册 tool_result hook */
|
|
95
|
+
export function registerToolResult(
|
|
96
|
+
pi: ExtensionAPI,
|
|
97
|
+
state: ToolState,
|
|
98
|
+
rulesDir?: string,
|
|
99
|
+
rulesOptions?: LoadRulesOptions,
|
|
100
|
+
): void {
|
|
101
|
+
pi.on("tool_result", async (event, _ctx) => {
|
|
102
|
+
// 行数检查(edit/write/memory_update 后)
|
|
103
|
+
if (event.toolName === "edit" || event.toolName === "write") {
|
|
104
|
+
const filePath = (event.input as any)?.path as string;
|
|
105
|
+
if (filePath) {
|
|
106
|
+
checkLineCount(filePath);
|
|
107
|
+
}
|
|
108
|
+
} else if (event.toolName === "memory_update") {
|
|
109
|
+
// memory_update 工具内部写文件,从 tool_result 文本中提取路径
|
|
110
|
+
const memResultText = extractResultText(event);
|
|
111
|
+
const pathMatch = memResultText.match(/(?:写入|更新|创建).*?`([^`]+\.md)`/);
|
|
112
|
+
if (pathMatch?.[1]) checkLineCount(pathMatch[1]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 状态更新
|
|
116
|
+
const resultText = extractResultText(event);
|
|
117
|
+
state.tracker.update(event.toolName, resultText.length, !!event.isError);
|
|
118
|
+
|
|
119
|
+
const allRules = loadRules(rulesDir, rulesOptions);
|
|
120
|
+
const rules = allRules.filter((r) => r.hook === "tool_result");
|
|
121
|
+
|
|
122
|
+
// resetOn 检查
|
|
123
|
+
state.tracker.resetIf(event.toolName, rules as ResettableRule[]);
|
|
124
|
+
|
|
125
|
+
// 规则匹配
|
|
126
|
+
for (const rule of rules) {
|
|
127
|
+
if ((rule as any).enabled === false) continue;
|
|
128
|
+
if (isSubagent() && rule.subagent === false) continue;
|
|
129
|
+
if (!toolsAvailable(rule, pi, state)) continue;
|
|
130
|
+
if (rule.requireSuccess && event.isError) continue;
|
|
131
|
+
if (rule.tool && !toolMatches(rule.tool, event.toolName!)) continue;
|
|
132
|
+
|
|
133
|
+
// 正则条件匹配
|
|
134
|
+
if (rule.conditions || rule.pattern) {
|
|
135
|
+
const targets = getMatchTargets(event.toolName!, event, "tool_result");
|
|
136
|
+
if (!targets || Object.keys(targets).length === 0) continue;
|
|
137
|
+
if (!ruleMatches(rule, targets, undefined, event.toolName!)) continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 状态条件匹配
|
|
141
|
+
if (rule.state) {
|
|
142
|
+
const ruleKey = rule.comment;
|
|
143
|
+
if (state.tracker.isTriggered(ruleKey)) {
|
|
144
|
+
const baseGte = rule.state.gte ?? 1;
|
|
145
|
+
const nextThresh = state.tracker.nextThreshold(baseGte, ruleKey);
|
|
146
|
+
const stats = state.tracker.getStats(rule.state.tools ?? []);
|
|
147
|
+
if (stats.count < nextThresh) continue;
|
|
148
|
+
}
|
|
149
|
+
if (!state.tracker.matches(rule.state)) continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 执行 action:注入到临时缓冲区,由 before_provider_request 消费
|
|
153
|
+
if (rule.action === "steer") {
|
|
154
|
+
if (rule.state) state.tracker.markTriggered(rule.comment);
|
|
155
|
+
const stats = state.tracker.getStats(rule.state?.tools ?? []);
|
|
156
|
+
const reason = rule.reason
|
|
157
|
+
.replace("{count}", String(stats.count))
|
|
158
|
+
.replace("{chars}", String(Math.round(stats.chars / 1000)));
|
|
159
|
+
pushWarning(reason, rule.comment);
|
|
160
|
+
} else if (rule.action === "notify") {
|
|
161
|
+
pushWarning(rule.reason, rule.comment);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -1,130 +1,130 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Worktree 检测与清理
|
|
3
|
-
* session_start 时自动检测未合并 worktree 并提醒
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { execFileSync } from "node:child_process";
|
|
7
|
-
import { existsSync, rmSync } from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import { isInWorktree, isSubagent } from "./rules.js";
|
|
10
|
-
|
|
11
|
-
interface UIContext {
|
|
12
|
-
notify?: (msg: string, level?: "error" | "info" | "warning") => void;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function git(args: string[], opts: { cwd: string; timeout?: number }): string {
|
|
16
|
-
try {
|
|
17
|
-
return execFileSync("git", args, {
|
|
18
|
-
timeout: opts.timeout ?? 5000,
|
|
19
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
20
|
-
cwd: opts.cwd,
|
|
21
|
-
encoding: "utf-8",
|
|
22
|
-
}).trim();
|
|
23
|
-
} catch {
|
|
24
|
-
return "";
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function checkWorktrees(ctx: UIContext): void {
|
|
29
|
-
if (isSubagent()) return;
|
|
30
|
-
if (isInWorktree()) return;
|
|
31
|
-
|
|
32
|
-
try {
|
|
33
|
-
const cwd = process.cwd();
|
|
34
|
-
const branches = git(["branch", "--list", "worktree/*", "--no-color"], {
|
|
35
|
-
cwd,
|
|
36
|
-
});
|
|
37
|
-
if (!branches) return;
|
|
38
|
-
|
|
39
|
-
const branchList = branches
|
|
40
|
-
.split("\n")
|
|
41
|
-
.map((b) => b.replace(/^\s*[+*]\s*/, "").trim())
|
|
42
|
-
.filter(Boolean);
|
|
43
|
-
if (branchList.length === 0) return;
|
|
44
|
-
|
|
45
|
-
const merged: string[] = [];
|
|
46
|
-
const unmerged: string[] = [];
|
|
47
|
-
|
|
48
|
-
for (const branch of branchList) {
|
|
49
|
-
const logResult = git(["log", `main..${branch}`, "--oneline"], {
|
|
50
|
-
cwd,
|
|
51
|
-
timeout: 3000,
|
|
52
|
-
});
|
|
53
|
-
if (logResult) {
|
|
54
|
-
unmerged.push(branch);
|
|
55
|
-
} else {
|
|
56
|
-
const verify = git(["rev-parse", "--verify", `${branch}^{commit}`], {
|
|
57
|
-
cwd,
|
|
58
|
-
timeout: 2000,
|
|
59
|
-
});
|
|
60
|
-
if (verify) {
|
|
61
|
-
merged.push(branch);
|
|
62
|
-
}
|
|
63
|
-
// 无 commit 的分支,忽略
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (merged.length > 0) {
|
|
68
|
-
for (const branch of merged) {
|
|
69
|
-
const name = branch.replace(/^worktree\//, "");
|
|
70
|
-
const wtPath = join(cwd, ".worktrees", name);
|
|
71
|
-
try {
|
|
72
|
-
// 先尝试 git worktree remove
|
|
73
|
-
git(["worktree", "remove", join(".worktrees", name), "--force"], {
|
|
74
|
-
cwd,
|
|
75
|
-
timeout: 10000,
|
|
76
|
-
});
|
|
77
|
-
} catch {
|
|
78
|
-
// fallback: 用 fs.rmSync 而非 shell rm -rf
|
|
79
|
-
if (existsSync(wtPath)) {
|
|
80
|
-
try {
|
|
81
|
-
rmSync(wtPath, { recursive: true, force: true });
|
|
82
|
-
} catch {
|
|
83
|
-
/* ignore */
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
git(["branch", "-d", branch], { cwd, timeout: 3000 });
|
|
88
|
-
}
|
|
89
|
-
ctx.notify?.(
|
|
90
|
-
`🧹 shepherd: 自动清理了 ${merged.length} 个已合并 worktree: ${merged.map((b) => b.replace("worktree/", "")).join(", ")}`,
|
|
91
|
-
"info",
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (unmerged.length > 0) {
|
|
96
|
-
const details = unmerged.map((b) => {
|
|
97
|
-
const name = b.replace(/^worktree\//, "");
|
|
98
|
-
const wtPath = join(cwd, ".worktrees", name);
|
|
99
|
-
let uncommitted = 0;
|
|
100
|
-
let aheadCount = 0;
|
|
101
|
-
if (existsSync(wtPath)) {
|
|
102
|
-
const statusLines = git(["-C", wtPath, "status", "--short"], {
|
|
103
|
-
cwd,
|
|
104
|
-
timeout: 3000,
|
|
105
|
-
});
|
|
106
|
-
if (statusLines) uncommitted = statusLines.split("\n").length;
|
|
107
|
-
}
|
|
108
|
-
const logLines = git(["log", `main..${b}`, "--oneline"], {
|
|
109
|
-
cwd,
|
|
110
|
-
timeout: 3000,
|
|
111
|
-
});
|
|
112
|
-
if (logLines) aheadCount = logLines.split("\n").length;
|
|
113
|
-
const parts: string[] = [];
|
|
114
|
-
if (aheadCount > 0) parts.push(`${aheadCount} 个未合并提交`);
|
|
115
|
-
if (uncommitted > 0) parts.push(`${uncommitted} 个未提交改动`);
|
|
116
|
-
const summary = parts.length > 0 ? ` (${parts.join(", ")})` : "";
|
|
117
|
-
return ` - ${name}${summary}`;
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
ctx.notify?.(
|
|
121
|
-
`⚠️ shepherd: ${unmerged.length} 个未合并 worktree:\n` +
|
|
122
|
-
details.join("\n") +
|
|
123
|
-
"\n合并: /worktree-merge | 删除: /worktree destroy <名称>",
|
|
124
|
-
"warning",
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
} catch {
|
|
128
|
-
// 非 git 仓库,忽略
|
|
129
|
-
}
|
|
130
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Worktree 检测与清理
|
|
3
|
+
* session_start 时自动检测未合并 worktree 并提醒
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { existsSync, rmSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { isInWorktree, isSubagent } from "./rules.js";
|
|
10
|
+
|
|
11
|
+
interface UIContext {
|
|
12
|
+
notify?: (msg: string, level?: "error" | "info" | "warning") => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function git(args: string[], opts: { cwd: string; timeout?: number }): string {
|
|
16
|
+
try {
|
|
17
|
+
return execFileSync("git", args, {
|
|
18
|
+
timeout: opts.timeout ?? 5000,
|
|
19
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
20
|
+
cwd: opts.cwd,
|
|
21
|
+
encoding: "utf-8",
|
|
22
|
+
}).trim();
|
|
23
|
+
} catch {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function checkWorktrees(ctx: UIContext): void {
|
|
29
|
+
if (isSubagent()) return;
|
|
30
|
+
if (isInWorktree()) return;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const cwd = process.cwd();
|
|
34
|
+
const branches = git(["branch", "--list", "worktree/*", "--no-color"], {
|
|
35
|
+
cwd,
|
|
36
|
+
});
|
|
37
|
+
if (!branches) return;
|
|
38
|
+
|
|
39
|
+
const branchList = branches
|
|
40
|
+
.split("\n")
|
|
41
|
+
.map((b) => b.replace(/^\s*[+*]\s*/, "").trim())
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
if (branchList.length === 0) return;
|
|
44
|
+
|
|
45
|
+
const merged: string[] = [];
|
|
46
|
+
const unmerged: string[] = [];
|
|
47
|
+
|
|
48
|
+
for (const branch of branchList) {
|
|
49
|
+
const logResult = git(["log", `main..${branch}`, "--oneline"], {
|
|
50
|
+
cwd,
|
|
51
|
+
timeout: 3000,
|
|
52
|
+
});
|
|
53
|
+
if (logResult) {
|
|
54
|
+
unmerged.push(branch);
|
|
55
|
+
} else {
|
|
56
|
+
const verify = git(["rev-parse", "--verify", `${branch}^{commit}`], {
|
|
57
|
+
cwd,
|
|
58
|
+
timeout: 2000,
|
|
59
|
+
});
|
|
60
|
+
if (verify) {
|
|
61
|
+
merged.push(branch);
|
|
62
|
+
}
|
|
63
|
+
// 无 commit 的分支,忽略
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (merged.length > 0) {
|
|
68
|
+
for (const branch of merged) {
|
|
69
|
+
const name = branch.replace(/^worktree\//, "");
|
|
70
|
+
const wtPath = join(cwd, ".worktrees", name);
|
|
71
|
+
try {
|
|
72
|
+
// 先尝试 git worktree remove
|
|
73
|
+
git(["worktree", "remove", join(".worktrees", name), "--force"], {
|
|
74
|
+
cwd,
|
|
75
|
+
timeout: 10000,
|
|
76
|
+
});
|
|
77
|
+
} catch {
|
|
78
|
+
// fallback: 用 fs.rmSync 而非 shell rm -rf
|
|
79
|
+
if (existsSync(wtPath)) {
|
|
80
|
+
try {
|
|
81
|
+
rmSync(wtPath, { recursive: true, force: true });
|
|
82
|
+
} catch {
|
|
83
|
+
/* ignore */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
git(["branch", "-d", branch], { cwd, timeout: 3000 });
|
|
88
|
+
}
|
|
89
|
+
ctx.notify?.(
|
|
90
|
+
`🧹 shepherd: 自动清理了 ${merged.length} 个已合并 worktree: ${merged.map((b) => b.replace("worktree/", "")).join(", ")}`,
|
|
91
|
+
"info",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (unmerged.length > 0) {
|
|
96
|
+
const details = unmerged.map((b) => {
|
|
97
|
+
const name = b.replace(/^worktree\//, "");
|
|
98
|
+
const wtPath = join(cwd, ".worktrees", name);
|
|
99
|
+
let uncommitted = 0;
|
|
100
|
+
let aheadCount = 0;
|
|
101
|
+
if (existsSync(wtPath)) {
|
|
102
|
+
const statusLines = git(["-C", wtPath, "status", "--short"], {
|
|
103
|
+
cwd,
|
|
104
|
+
timeout: 3000,
|
|
105
|
+
});
|
|
106
|
+
if (statusLines) uncommitted = statusLines.split("\n").length;
|
|
107
|
+
}
|
|
108
|
+
const logLines = git(["log", `main..${b}`, "--oneline"], {
|
|
109
|
+
cwd,
|
|
110
|
+
timeout: 3000,
|
|
111
|
+
});
|
|
112
|
+
if (logLines) aheadCount = logLines.split("\n").length;
|
|
113
|
+
const parts: string[] = [];
|
|
114
|
+
if (aheadCount > 0) parts.push(`${aheadCount} 个未合并提交`);
|
|
115
|
+
if (uncommitted > 0) parts.push(`${uncommitted} 个未提交改动`);
|
|
116
|
+
const summary = parts.length > 0 ? ` (${parts.join(", ")})` : "";
|
|
117
|
+
return ` - ${name}${summary}`;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
ctx.notify?.(
|
|
121
|
+
`⚠️ shepherd: ${unmerged.length} 个未合并 worktree:\n` +
|
|
122
|
+
details.join("\n") +
|
|
123
|
+
"\n合并: /worktree-merge | 删除: /worktree destroy <名称>",
|
|
124
|
+
"warning",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// 非 git 仓库,忽略
|
|
129
|
+
}
|
|
130
|
+
}
|