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.
- package/README.en.md +136 -0
- package/README.md +136 -0
- package/index.ts +229 -0
- package/node_modules/@pi-atelier/shared-utils/README.en.md +182 -0
- package/node_modules/@pi-atelier/shared-utils/README.md +182 -0
- package/node_modules/@pi-atelier/shared-utils/package.json +51 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/agents.test.ts +120 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/ephemeral.test.ts +100 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/file-lock.test.ts +152 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/filter-match.test.ts +187 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/memory-parser.test.ts +170 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/paths.test.ts +126 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config-edge.test.ts +138 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config.test.ts +257 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools-mcp.test.ts +189 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools.test.ts +204 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-advanced.test.ts +269 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-array.test.ts +267 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup.test.ts +520 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-read.test.ts +116 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-write.test.ts +119 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/tool-output.test.ts +145 -0
- package/node_modules/@pi-atelier/shared-utils/src/agents.ts +39 -0
- package/node_modules/@pi-atelier/shared-utils/src/ephemeral.ts +42 -0
- package/node_modules/@pi-atelier/shared-utils/src/file-lock.ts +62 -0
- package/node_modules/@pi-atelier/shared-utils/src/filter-match.ts +100 -0
- package/node_modules/@pi-atelier/shared-utils/src/index.ts +71 -0
- package/node_modules/@pi-atelier/shared-utils/src/memory-parser.ts +96 -0
- package/node_modules/@pi-atelier/shared-utils/src/paths.ts +23 -0
- package/node_modules/@pi-atelier/shared-utils/src/project-config.ts +241 -0
- package/node_modules/@pi-atelier/shared-utils/src/project-tools.ts +191 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings-array.ts +73 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup-rollback.ts +104 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup-utils.ts +75 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup.ts +172 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings.ts +104 -0
- package/node_modules/@pi-atelier/shared-utils/src/tool-output.ts +149 -0
- package/node_modules/@pi-atelier/shared-utils/tsconfig.json +9 -0
- package/node_modules/@pi-atelier/shared-utils/vitest.config.ts +24 -0
- package/package.json +49 -0
- package/rules.json +516 -0
- package/shepherd/ephemeral-shared.ts +14 -0
- package/shepherd/ephemeral.ts +52 -0
- package/shepherd/index.ts +39 -0
- package/shepherd/line-count.ts +86 -0
- package/shepherd/rules-editor.ts +135 -0
- package/shepherd/rules-tool.ts +99 -0
- package/shepherd/rules-validate.ts +44 -0
- package/shepherd/rules.ts +283 -0
- package/shepherd/state-tracker.ts +119 -0
- package/shepherd/tool-event-types.ts +31 -0
- package/shepherd/tool-hooks.ts +176 -0
- package/shepherd/worktree-check.ts +130 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard StateTracker — 有状态规则引擎的状态收集器
|
|
3
|
+
*
|
|
4
|
+
* 自动追踪工具调用次数、字符量和连续错误次数,
|
|
5
|
+
* 供 shepherd 的 tool_result hook 查询匹配。
|
|
6
|
+
*
|
|
7
|
+
* 状态只存在内存中,不持久化(重启 pi 重置)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface StateCondition {
|
|
11
|
+
tools?: string[];
|
|
12
|
+
gte?: number;
|
|
13
|
+
lte?: number;
|
|
14
|
+
countKind?: "calls" | "errors" | "chars";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ResettableRule {
|
|
18
|
+
comment: string;
|
|
19
|
+
state?: StateCondition;
|
|
20
|
+
resetOn?: string[];
|
|
21
|
+
/** 运行时:已触发标记 */
|
|
22
|
+
_triggered?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class StateTracker {
|
|
26
|
+
private toolCounts = new Map<string, number>();
|
|
27
|
+
private toolChars = new Map<string, number>();
|
|
28
|
+
private consecutiveErrors = 0;
|
|
29
|
+
/** 规则级触发状态:key = comment(规则唯一标识),value = { triggered, firedCount } */
|
|
30
|
+
private ruleStates = new Map<
|
|
31
|
+
string,
|
|
32
|
+
{ triggered: boolean; firedCount: number }
|
|
33
|
+
>();
|
|
34
|
+
|
|
35
|
+
/** 每次 tool_result 调用,自动更新状态 */
|
|
36
|
+
update(toolName: string, resultChars: number, isError: boolean): void {
|
|
37
|
+
this.toolCounts.set(toolName, (this.toolCounts.get(toolName) ?? 0) + 1);
|
|
38
|
+
this.toolChars.set(
|
|
39
|
+
toolName,
|
|
40
|
+
(this.toolChars.get(toolName) ?? 0) + resultChars,
|
|
41
|
+
);
|
|
42
|
+
this.consecutiveErrors = isError ? this.consecutiveErrors + 1 : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 判断规则的状态条件是否满足 */
|
|
46
|
+
matches(condition: StateCondition): boolean {
|
|
47
|
+
const kind = condition.countKind ?? "calls";
|
|
48
|
+
let value: number;
|
|
49
|
+
|
|
50
|
+
if (kind === "errors") {
|
|
51
|
+
value = this.consecutiveErrors;
|
|
52
|
+
} else if (kind === "chars") {
|
|
53
|
+
value = (condition.tools ?? []).reduce(
|
|
54
|
+
(sum, t) => sum + (this.toolChars.get(t) ?? 0),
|
|
55
|
+
0,
|
|
56
|
+
);
|
|
57
|
+
} else {
|
|
58
|
+
value = (condition.tools ?? []).reduce(
|
|
59
|
+
(sum, t) => sum + (this.toolCounts.get(t) ?? 0),
|
|
60
|
+
0,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (condition.gte != null && value < condition.gte) return false;
|
|
65
|
+
if (condition.lte != null && value > condition.lte) return false;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 检查当前 toolName 是否触发 resetOn → 重置匹配规则的计数 */
|
|
70
|
+
resetIf(toolName: string, rules: ResettableRule[]): void {
|
|
71
|
+
for (const rule of rules) {
|
|
72
|
+
if (rule.resetOn?.includes(toolName)) {
|
|
73
|
+
if (rule.state?.tools) {
|
|
74
|
+
for (const t of rule.state.tools) {
|
|
75
|
+
this.toolCounts.delete(t);
|
|
76
|
+
this.toolChars.delete(t);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 重置规则触发状态(持久化的 ruleStates)
|
|
80
|
+
const key = rule.comment;
|
|
81
|
+
if (this.ruleStates.has(key)) {
|
|
82
|
+
this.ruleStates.get(key)!.triggered = false;
|
|
83
|
+
}
|
|
84
|
+
rule._triggered = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 获取格式化信息(用于 reason 模板替换) */
|
|
90
|
+
getStats(tools: string[]): { count: number; chars: number } {
|
|
91
|
+
return {
|
|
92
|
+
count: tools.reduce((s, t) => s + (this.toolCounts.get(t) ?? 0), 0),
|
|
93
|
+
chars: tools.reduce((s, t) => s + (this.toolChars.get(t) ?? 0), 0),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 规则是否已触发(持久化在 StateTracker 内,不受 loadRules 重新创建实例影响) */
|
|
98
|
+
isTriggered(ruleKey: string): boolean {
|
|
99
|
+
return this.ruleStates.get(ruleKey)?.triggered ?? false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 标记规则已触发,返回当前触发次数 */
|
|
103
|
+
markTriggered(ruleKey: string): number {
|
|
104
|
+
const s = this.ruleStates.get(ruleKey) ?? {
|
|
105
|
+
triggered: true,
|
|
106
|
+
firedCount: 0,
|
|
107
|
+
};
|
|
108
|
+
s.triggered = true;
|
|
109
|
+
s.firedCount++;
|
|
110
|
+
this.ruleStates.set(ruleKey, s);
|
|
111
|
+
return s.firedCount;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 计算递增阈值:baseThreshold + firedCount * step */
|
|
115
|
+
nextThreshold(baseThreshold: number, ruleKey: string, step = 5): number {
|
|
116
|
+
const firedCount = this.ruleStates.get(ruleKey)?.firedCount ?? 0;
|
|
117
|
+
return baseThreshold + firedCount * step;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi SDK 工具事件类型定义
|
|
3
|
+
* 统一 tool_call 和 tool_result 事件的联合类型
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
BashToolCallEvent,
|
|
8
|
+
BashToolResultEvent,
|
|
9
|
+
CustomToolCallEvent,
|
|
10
|
+
CustomToolResultEvent,
|
|
11
|
+
EditToolCallEvent,
|
|
12
|
+
EditToolResultEvent,
|
|
13
|
+
GrepToolCallEvent,
|
|
14
|
+
GrepToolResultEvent,
|
|
15
|
+
LsToolCallEvent,
|
|
16
|
+
LsToolResultEvent,
|
|
17
|
+
ReadToolCallEvent,
|
|
18
|
+
ReadToolResultEvent,
|
|
19
|
+
WriteToolCallEvent,
|
|
20
|
+
WriteToolResultEvent,
|
|
21
|
+
} from "@earendil-works/pi-coding-agent";
|
|
22
|
+
|
|
23
|
+
/** getMatchTargets 接收的事件类型(tool_call 或 tool_result) */
|
|
24
|
+
export type ToolEvent =
|
|
25
|
+
| BashToolCallEvent | BashToolResultEvent
|
|
26
|
+
| CustomToolCallEvent | CustomToolResultEvent
|
|
27
|
+
| EditToolCallEvent | EditToolResultEvent
|
|
28
|
+
| GrepToolCallEvent | GrepToolResultEvent
|
|
29
|
+
| LsToolCallEvent | LsToolResultEvent
|
|
30
|
+
| ReadToolCallEvent | ReadToolResultEvent
|
|
31
|
+
| WriteToolCallEvent | WriteToolResultEvent;
|
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
} from "./rules.js";
|
|
24
|
+
import type { ResettableRule, StateTracker } from "./state-tracker.js";
|
|
25
|
+
|
|
26
|
+
/** 工具 hook 共享的可变状态 */
|
|
27
|
+
export interface ToolState {
|
|
28
|
+
hasEdits: boolean;
|
|
29
|
+
tracker: StateTracker;
|
|
30
|
+
cachedTools: Set<string> | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getAvailableTools(
|
|
34
|
+
pi: ExtensionAPI,
|
|
35
|
+
state: ToolState,
|
|
36
|
+
): Set<string> {
|
|
37
|
+
if (!state.cachedTools) {
|
|
38
|
+
state.cachedTools = new Set(pi.getActiveTools());
|
|
39
|
+
}
|
|
40
|
+
return state.cachedTools;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function toolsAvailable(
|
|
44
|
+
rule: Rule,
|
|
45
|
+
pi: ExtensionAPI,
|
|
46
|
+
state: ToolState,
|
|
47
|
+
): boolean {
|
|
48
|
+
if (!rule.requiresTools || rule.requiresTools.length === 0) return true;
|
|
49
|
+
const tools = getAvailableTools(pi, state);
|
|
50
|
+
return rule.requiresTools.every((t) => tools.has(t));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 注册 tool_call hook */
|
|
54
|
+
export function registerToolCall(
|
|
55
|
+
pi: ExtensionAPI,
|
|
56
|
+
state: ToolState,
|
|
57
|
+
rulesDir?: string,
|
|
58
|
+
rulesOptions?: LoadRulesOptions,
|
|
59
|
+
): void {
|
|
60
|
+
pi.on("tool_call", async (event) => {
|
|
61
|
+
if (event.toolName === "edit" || event.toolName === "write") {
|
|
62
|
+
state.hasEdits = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rules = loadRules(rulesDir, rulesOptions).filter(
|
|
66
|
+
(r) => r.hook === "tool_call" && r.tool === event.toolName,
|
|
67
|
+
);
|
|
68
|
+
if (rules.length === 0) return;
|
|
69
|
+
|
|
70
|
+
const targets = getMatchTargets(event.toolName!, event, "tool_call");
|
|
71
|
+
if (!targets || Object.keys(targets).length === 0) return;
|
|
72
|
+
|
|
73
|
+
for (const rule of rules) {
|
|
74
|
+
if (isSubagent() && rule.subagent === false) continue;
|
|
75
|
+
if (!toolsAvailable(rule, pi, state)) continue;
|
|
76
|
+
if (!ruleMatches(rule, event.toolName!, targets)) continue;
|
|
77
|
+
|
|
78
|
+
if (rule.action === "block") {
|
|
79
|
+
return { block: true, reason: `⛔ shepherd: ${rule.reason}` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (rule.action === "rewrite" && event.toolName === "bash") {
|
|
83
|
+
if (!isRtkAvailable) continue;
|
|
84
|
+
const cmd = targets.command;
|
|
85
|
+
if (cmd && !cmd.startsWith("rtk ")) {
|
|
86
|
+
(event.input as any).command = `rtk ${cmd}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (rule.action === "notify") {
|
|
91
|
+
pushWarning(rule.reason, rule.comment);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 注册 tool_result hook */
|
|
98
|
+
export function registerToolResult(
|
|
99
|
+
pi: ExtensionAPI,
|
|
100
|
+
state: ToolState,
|
|
101
|
+
rulesDir?: string,
|
|
102
|
+
rulesOptions?: LoadRulesOptions,
|
|
103
|
+
): void {
|
|
104
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
105
|
+
// 行数检查(edit/write/memory_update 后)
|
|
106
|
+
if (event.toolName === "edit" || event.toolName === "write") {
|
|
107
|
+
const filePath = (event.input as any)?.path as string;
|
|
108
|
+
if (filePath) {
|
|
109
|
+
checkLineCount(filePath);
|
|
110
|
+
}
|
|
111
|
+
} else if (event.toolName === "memory_update") {
|
|
112
|
+
// memory_update 工具内部写文件,从 tool_result 文本中提取路径
|
|
113
|
+
const resultText =
|
|
114
|
+
event.content
|
|
115
|
+
?.filter((c: ContentBlock) => c.type === "text")
|
|
116
|
+
?.map((c: ContentBlock) => c.text)
|
|
117
|
+
.join("") ?? "";
|
|
118
|
+
const pathMatch = resultText.match(/(?:写入|更新|创建).*?`([^`]+\.md)`/);
|
|
119
|
+
if (pathMatch?.[1]) checkLineCount(pathMatch[1]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 状态更新
|
|
123
|
+
const resultText =
|
|
124
|
+
event.content
|
|
125
|
+
?.filter((c: ContentBlock) => c.type === "text")
|
|
126
|
+
?.map((c: ContentBlock) => c.text)
|
|
127
|
+
.join("") ?? "";
|
|
128
|
+
state.tracker.update(event.toolName, resultText.length, !!event.isError);
|
|
129
|
+
|
|
130
|
+
const allRules = loadRules(rulesDir, rulesOptions);
|
|
131
|
+
const rules = allRules.filter((r) => r.hook === "tool_result");
|
|
132
|
+
|
|
133
|
+
// resetOn 检查
|
|
134
|
+
state.tracker.resetIf(event.toolName, rules as ResettableRule[]);
|
|
135
|
+
|
|
136
|
+
// 规则匹配
|
|
137
|
+
for (const rule of rules) {
|
|
138
|
+
if ((rule as any).enabled === false) continue;
|
|
139
|
+
if (isSubagent() && rule.subagent === false) continue;
|
|
140
|
+
if (!toolsAvailable(rule, pi, state)) continue;
|
|
141
|
+
if (rule.requireSuccess && event.isError) continue;
|
|
142
|
+
if (rule.tool && rule.tool !== event.toolName) continue;
|
|
143
|
+
|
|
144
|
+
// 正则条件匹配
|
|
145
|
+
if (rule.conditions || rule.pattern) {
|
|
146
|
+
const targets = getMatchTargets(event.toolName!, event, "tool_result");
|
|
147
|
+
if (!targets || Object.keys(targets).length === 0) continue;
|
|
148
|
+
if (!ruleMatches(rule, event.toolName!, targets)) continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 状态条件匹配
|
|
152
|
+
if (rule.state) {
|
|
153
|
+
const ruleKey = rule.comment;
|
|
154
|
+
if (state.tracker.isTriggered(ruleKey)) {
|
|
155
|
+
const baseGte = rule.state.gte ?? 1;
|
|
156
|
+
const nextThresh = state.tracker.nextThreshold(baseGte, ruleKey);
|
|
157
|
+
const stats = state.tracker.getStats(rule.state.tools ?? []);
|
|
158
|
+
if (stats.count < nextThresh) continue;
|
|
159
|
+
}
|
|
160
|
+
if (!state.tracker.matches(rule.state)) continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 执行 action:注入到临时缓冲区,由 before_provider_request 消费
|
|
164
|
+
if (rule.action === "steer") {
|
|
165
|
+
if (rule.state) state.tracker.markTriggered(rule.comment);
|
|
166
|
+
const stats = state.tracker.getStats(rule.state?.tools ?? []);
|
|
167
|
+
const reason = rule.reason
|
|
168
|
+
.replace("{count}", String(stats.count))
|
|
169
|
+
.replace("{chars}", String(Math.round(stats.chars / 1000)));
|
|
170
|
+
pushWarning(reason, rule.comment);
|
|
171
|
+
} else if (rule.action === "notify") {
|
|
172
|
+
pushWarning(rule.reason, rule.comment);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"strict": false,
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"rootDir": ".",
|
|
7
|
+
"paths": {
|
|
8
|
+
"@pi-atelier/shared-utils": [
|
|
9
|
+
"./node_modules/@pi-atelier/shared-utils/src/index.ts"
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"include": ["index.ts", "shepherd/**/*.ts", "tests/**/*.ts"]
|
|
14
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createConfig } from "../vitest.config.base";
|
|
3
|
+
|
|
4
|
+
export default createConfig({
|
|
5
|
+
alias: {
|
|
6
|
+
"@earendil-works/pi-coding-agent": true,
|
|
7
|
+
"@pi-atelier/shepherd": path.resolve(__dirname, "./shepherd"),
|
|
8
|
+
},
|
|
9
|
+
include: ["tests/**/*.test.ts"],
|
|
10
|
+
test: {
|
|
11
|
+
testTimeout: 10000,
|
|
12
|
+
},
|
|
13
|
+
});
|