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.
@@ -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
- ReadToolResultEvent,
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 | BashToolResultEvent
26
- | CustomToolCallEvent | CustomToolResultEvent
27
- | EditToolCallEvent | EditToolResultEvent
28
- | GrepToolCallEvent | GrepToolResultEvent
29
- | LsToolCallEvent | LsToolResultEvent
30
- | ReadToolCallEvent | ReadToolResultEvent
31
- | WriteToolCallEvent | WriteToolResultEvent;
37
+ | BashToolCallEvent
38
+ | CustomToolCallEvent
39
+ | EditToolCallEvent
40
+ | GrepToolCallEvent
41
+ | LsToolCallEvent
42
+ | ReadToolCallEvent
43
+ | WriteToolCallEvent
44
+ | ToolResultEvent;
@@ -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
- /** pi 工具结果中的 content block 类型 */
8
- interface ContentBlock {
9
- type: string;
10
- text?: string;
11
- [key: string]: unknown;
12
- }
13
- import { pushWarning } from "./ephemeral.js";
14
- import { checkLineCount } from "./line-count.js";
15
- import {
16
- getMatchTargets,
17
- isRtkAvailable,
18
- isSubagent,
19
- type LoadRulesOptions,
20
- loadRules,
21
- type Rule,
22
- ruleMatches,
23
- toolMatches,
24
- } from "./rules.js";
25
- import type { ResettableRule, StateTracker } from "./state-tracker.js";
26
-
27
- /** 工具 hook 共享的可变状态 */
28
- export interface ToolState {
29
- hasEdits: boolean;
30
- tracker: StateTracker;
31
- cachedTools: Set<string> | null;
32
- }
33
-
34
- export function getAvailableTools(
35
- pi: ExtensionAPI,
36
- state: ToolState,
37
- ): Set<string> {
38
- if (!state.cachedTools) {
39
- state.cachedTools = new Set(pi.getActiveTools());
40
- }
41
- return state.cachedTools;
42
- }
43
-
44
- export function toolsAvailable(
45
- rule: Rule,
46
- pi: ExtensionAPI,
47
- state: ToolState,
48
- ): boolean {
49
- if (!rule.requiresTools || rule.requiresTools.length === 0) return true;
50
- const tools = getAvailableTools(pi, state);
51
- return rule.requiresTools.every((t) => tools.has(t));
52
- }
53
-
54
- /** 注册 tool_call hook */
55
- export function registerToolCall(
56
- pi: ExtensionAPI,
57
- state: ToolState,
58
- rulesDir?: string,
59
- rulesOptions?: LoadRulesOptions,
60
- ): void {
61
- pi.on("tool_call", async (event) => {
62
- if (event.toolName === "edit" || event.toolName === "write") {
63
- state.hasEdits = true;
64
- }
65
-
66
- const rules = loadRules(rulesDir, rulesOptions).filter(
67
- (r) => r.hook === "tool_call" && toolMatches(r.tool, event.toolName!),
68
- );
69
- if (rules.length === 0) return;
70
-
71
- const targets = getMatchTargets(event.toolName!, event, "tool_call");
72
- if (!targets || Object.keys(targets).length === 0) return;
73
-
74
- for (const rule of rules) {
75
- if (isSubagent() && rule.subagent === false) continue;
76
- if (!toolsAvailable(rule, pi, state)) continue;
77
- if (!ruleMatches(rule, event.toolName!, targets)) continue;
78
-
79
- if (rule.action === "block") {
80
- return { block: true, reason: `⛔ shepherd: ${rule.reason}` };
81
- }
82
-
83
- if (rule.action === "rewrite" && event.toolName === "bash") {
84
- if (!isRtkAvailable) continue;
85
- const cmd = targets.command;
86
- if (cmd && !cmd.startsWith("rtk ")) {
87
- (event.input as any).command = `rtk ${cmd}`;
88
- }
89
- }
90
-
91
- if (rule.action === "notify") {
92
- pushWarning(rule.reason, rule.comment);
93
- }
94
- }
95
- });
96
- }
97
-
98
- /** 注册 tool_result hook */
99
- export function registerToolResult(
100
- pi: ExtensionAPI,
101
- state: ToolState,
102
- rulesDir?: string,
103
- rulesOptions?: LoadRulesOptions,
104
- ): void {
105
- pi.on("tool_result", async (event, ctx) => {
106
- // 行数检查(edit/write/memory_update 后)
107
- if (event.toolName === "edit" || event.toolName === "write") {
108
- const filePath = (event.input as any)?.path as string;
109
- if (filePath) {
110
- checkLineCount(filePath);
111
- }
112
- } else if (event.toolName === "memory_update") {
113
- // memory_update 工具内部写文件,从 tool_result 文本中提取路径
114
- const resultText =
115
- event.content
116
- ?.filter((c: ContentBlock) => c.type === "text")
117
- ?.map((c: ContentBlock) => c.text)
118
- .join("") ?? "";
119
- const pathMatch = resultText.match(/(?:写入|更新|创建).*?`([^`]+\.md)`/);
120
- if (pathMatch?.[1]) checkLineCount(pathMatch[1]);
121
- }
122
-
123
- // 状态更新
124
- const resultText =
125
- event.content
126
- ?.filter((c: ContentBlock) => c.type === "text")
127
- ?.map((c: ContentBlock) => c.text)
128
- .join("") ?? "";
129
- state.tracker.update(event.toolName, resultText.length, !!event.isError);
130
-
131
- const allRules = loadRules(rulesDir, rulesOptions);
132
- const rules = allRules.filter((r) => r.hook === "tool_result");
133
-
134
- // resetOn 检查
135
- state.tracker.resetIf(event.toolName, rules as ResettableRule[]);
136
-
137
- // 规则匹配
138
- for (const rule of rules) {
139
- if ((rule as any).enabled === false) continue;
140
- if (isSubagent() && rule.subagent === false) continue;
141
- if (!toolsAvailable(rule, pi, state)) continue;
142
- if (rule.requireSuccess && event.isError) continue;
143
- if (rule.tool && !toolMatches(rule.tool, event.toolName!)) continue;
144
-
145
- // 正则条件匹配
146
- if (rule.conditions || rule.pattern) {
147
- const targets = getMatchTargets(event.toolName!, event, "tool_result");
148
- if (!targets || Object.keys(targets).length === 0) continue;
149
- if (!ruleMatches(rule, event.toolName!, targets)) continue;
150
- }
151
-
152
- // 状态条件匹配
153
- if (rule.state) {
154
- const ruleKey = rule.comment;
155
- if (state.tracker.isTriggered(ruleKey)) {
156
- const baseGte = rule.state.gte ?? 1;
157
- const nextThresh = state.tracker.nextThreshold(baseGte, ruleKey);
158
- const stats = state.tracker.getStats(rule.state.tools ?? []);
159
- if (stats.count < nextThresh) continue;
160
- }
161
- if (!state.tracker.matches(rule.state)) continue;
162
- }
163
-
164
- // 执行 action:注入到临时缓冲区,由 before_provider_request 消费
165
- if (rule.action === "steer") {
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
+ }