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/index.ts CHANGED
@@ -1,229 +1,262 @@
1
- /**
2
- * Shepherd — 通用 Hook 规则引擎
3
- *
4
- * 规则驱动的事件 hook,支持多种动作:
5
- * - tool_call: 工具调用前(可 block 拦截 / notify 提醒 / rewrite 重写)
6
- * - tool_result: 工具执行后(可 notify 提醒 / steer 向 LLM 注入 + 行数检查)
7
- * - agent_end: AI 正常完成时(可 notify 提醒,支持 stopReason 过滤)
8
- * - session_shutdown: 会话结束时(可 notify 提醒)
9
- *
10
- * steer/notify 提示通过 before_provider_request 临时注入到 LLM payload,
11
- * 不写入 session 历史,不占用后续上下文。
12
- *
13
- * 规则配置文件:
14
- * 全局: ~/.pi/agent/extensions/shepherd/rules.json
15
- * 项目级: <cwd>/.pi/extensions/shepherd-rules-*.json(自动扫描,叠加加载)
16
- *
17
- * 修改规则文件后 /reload 即可生效,无需重启 pi。
18
- */
19
-
20
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
- import { dirname, join } from "path";
22
- import { fileURLToPath } from "url";
23
-
24
- /** pi payload 消息结构的最小类型 */
25
- interface PayloadMessage {
26
- role: string;
27
- content?: unknown;
28
- [key: string]: unknown;
29
- }
30
-
31
- /** pi provider payload 的最小类型 */
32
- interface ProviderPayload {
33
- messages?: PayloadMessage[];
34
- [key: string]: unknown;
35
- }
36
-
37
- const __dirname = dirname(fileURLToPath(import.meta.url));
38
- const RULES_DIR = __dirname;
39
-
40
- import { getEffectiveConfig } from "@pi-atelier/shared-utils";
41
- import {
42
- checkWorktrees,
43
- drainHints,
44
- hasGitUncommittedChanges,
45
- hasWarnings,
46
- isSubagent,
47
- loadRules,
48
- notifySummary,
49
- pushWarning,
50
- registerToolCall,
51
- registerToolResult,
52
- StateTracker,
53
- type ToolState,
54
- } from "./shepherd";
55
- import { registerRulesEditorTool } from "./shepherd/rules-tool";
56
-
57
- /** 本地 hints 缓冲区(收集 pi.events.emit("ephemeral:hint") 的数据) */
58
- const _localHints: { text: string; short?: string }[] = [];
59
-
60
- /** 可变状态:跨 hook 共享 */
61
- let _aborted = false;
62
- let _wasDirty = false;
63
- const _agentEndFired = new Set<string>();
64
- const _toolState: ToolState = {
65
- hasEdits: false,
66
- tracker: new StateTracker(),
67
- cachedTools: null,
68
- };
69
-
70
- export default function shepherdExtension(pi: ExtensionAPI) {
71
- // ── 读取配置(三层合并:defaults → 全局 settings → 项目 settings)──
72
- const shepherdConfig = getEffectiveConfig<{
73
- projectRulesPattern: string;
74
- maxWarnings: number;
75
- }>(
76
- "shepherd",
77
- {
78
- projectRulesPattern: "shepherd-rules-",
79
- maxWarnings: 5,
80
- },
81
- process.cwd(),
82
- );
83
-
84
- // ── 监听跨扩展 hints(通过 pi.events 绕过 jiti 多实例) ──
85
- pi.events.on("ephemeral:hint", (data) => {
86
- const { text, short } = data as { text: string; short?: string };
87
- _localHints.push({ text, short });
88
- });
89
-
90
- // ── before_provider_request:注入临时提示 ──────────────────
91
- pi.on("before_provider_request", async (event, ctx) => {
92
- // shepherd 规则 hints
93
- const shepherdText = drainHints();
94
- if (shepherdText) {
95
- _localHints.unshift({ text: shepherdText });
96
- }
97
-
98
- // 通知摘要:short 优先,fallback 到 notifySummary 截断
99
- const shortParts = _localHints
100
- .map((h) => h.short)
101
- .filter(Boolean) as string[];
102
- const longParts = _localHints
103
- .map((h) => (h.short ? null : h.text))
104
- .filter(Boolean) as string[];
105
- const notifyText = [...shortParts, ...longParts].join("\n\n");
106
-
107
- const allHints = _localHints
108
- .splice(0)
109
- .map((h) => h.text)
110
- .join("\n\n");
111
- let payload = event.payload as ProviderPayload;
112
-
113
- if (allHints) {
114
- const text = allHints;
115
- payload = { ...payload };
116
- payload.messages = [...(payload.messages ?? [])];
117
- payload.messages.push({
118
- role: "user",
119
- content: [{ type: "text", text }],
120
- });
121
- ctx.ui.notify?.(notifySummary(notifyText), "warning");
122
- }
123
-
124
- return payload;
125
- });
126
-
127
- // ── session_start ──────────────────────────────────────────
128
- pi.on("session_start", async (_event, ctx) => {
129
- checkWorktrees(ctx.ui);
130
- });
131
-
132
- // ── agent_start ────────────────────────────────────────────
133
- pi.on("agent_start", async (_event, ctx) => {
134
- _aborted = ctx.signal?.aborted ?? false;
135
- _toolState.hasEdits = false;
136
- _toolState.cachedTools = null;
137
- _agentEndFired.clear();
138
- if (ctx.signal && !ctx.signal.aborted) {
139
- ctx.signal.addEventListener("abort", () => {
140
- _aborted = true;
141
- });
142
- }
143
- _wasDirty = hasGitUncommittedChanges();
144
- });
145
-
146
- pi.on("input", async (_event) => {
147
- /* 占位:防止 shepherd steer 循环 */
148
- });
149
-
150
- // ── agent_end ──────────────────────────────────────────────
151
- pi.on("agent_end", async (event, _ctx) => {
152
- if (isSubagent() || _aborted) return;
153
- const rules = loadRules(RULES_DIR, {
154
- projectRulesPattern: shepherdConfig.projectRulesPattern,
155
- }).filter((r) => r.hook === "agent_end");
156
- if (rules.length === 0) return;
157
-
158
- const lastAssistant = [...event.messages]
159
- .reverse()
160
- .find((m: PayloadMessage) => m.role === "assistant");
161
- const stopReason: string | undefined = (lastAssistant as PayloadMessage | undefined)?.stopReason as string | undefined;
162
-
163
- for (const rule of rules) {
164
- const allowedReasons = rule.stopReason ?? ["stop"];
165
- if (!allowedReasons.includes(stopReason ?? "")) continue;
166
- if (_agentEndFired.has(rule.comment)) continue;
167
-
168
- let shouldNotify = false;
169
- if (rule.check === "git_uncommitted") {
170
- const isDirty = hasGitUncommittedChanges();
171
- shouldNotify = isDirty && _toolState.hasEdits;
172
- _wasDirty = isDirty;
173
- } else if (rule.check === "has_edits") {
174
- // hasEdits:本轮是否调用过 edit/write,用于提醒记忆更新和总结
175
- shouldNotify = _toolState.hasEdits;
176
- } else if (rule.check === "always" || !rule.check) {
177
- shouldNotify = true;
178
- }
179
-
180
- if (shouldNotify && rule.action === "notify") {
181
- _agentEndFired.add(rule.comment);
182
- pushWarning(rule.reason, rule.comment);
183
- }
184
- }
185
-
186
- // 如有缓冲提示,用极简消息触发新 turn(before_provider_request 会注入实际内容)
187
- if (hasWarnings()) {
188
- setTimeout(() => {
189
- try {
190
- pi.sendMessage(
191
- { customType: "shepherd-agent-end", display: false, content: "" },
192
- { triggerTurn: true },
193
- );
194
- } catch {
195
- /* session 已替换 */
196
- }
197
- }, 0);
198
- }
199
- });
200
-
201
- // ── session_shutdown ───────────────────────────────────────
202
- pi.on("session_shutdown", async (_event, ctx) => {
203
- const rules = loadRules(RULES_DIR, {
204
- projectRulesPattern: shepherdConfig.projectRulesPattern,
205
- }).filter((r) => r.hook === "session_shutdown");
206
- if (rules.length === 0) return;
207
- for (const rule of rules) {
208
- let shouldNotify = false;
209
- if (rule.check === "git_uncommitted") {
210
- shouldNotify = hasGitUncommittedChanges();
211
- } else if (rule.check === "always" || !rule.check) {
212
- shouldNotify = true;
213
- }
214
- if (shouldNotify && rule.action === "notify") {
215
- ctx.ui.notify?.(`⚠️ shepherd: ${rule.reason}`, "warning");
216
- }
217
- }
218
- });
219
-
220
- // ── tool_call + tool_result(提取到 tool-hooks.ts)────────
221
- const _rulesOpts = {
222
- projectRulesPattern: shepherdConfig.projectRulesPattern,
223
- };
224
- registerToolCall(pi, _toolState, RULES_DIR, _rulesOpts);
225
- registerToolResult(pi, _toolState, RULES_DIR, _rulesOpts);
226
-
227
- // ── shepherd_rules 工具:规则文件安全编辑 ───────────────────
228
- registerRulesEditorTool(pi, RULES_DIR, process.cwd());
229
- }
1
+ /**
2
+ * Shepherd — 通用 Hook 规则引擎
3
+ *
4
+ * 规则驱动的事件 hook,支持多种动作:
5
+ * - tool_call: 工具调用前(可 block 拦截 / notify 提醒 / rewrite 重写)
6
+ * - tool_result: 工具执行后(可 notify 提醒 / steer 向 LLM 注入 + 行数检查)
7
+ * - agent_end: AI 正常完成时(可 notify 提醒,支持 stopReason 过滤)
8
+ * - session_shutdown: 会话结束时(可 notify 提醒)
9
+ *
10
+ * steer/notify 提示通过 before_provider_request 临时注入到 LLM payload,
11
+ * 不写入 session 历史,不占用后续上下文。
12
+ *
13
+ * 规则配置文件:
14
+ * 全局: ~/.pi/agent/extensions/shepherd/rules.json
15
+ * 项目级: <cwd>/.pi/extensions/shepherd-rules-*.json(自动扫描,叠加加载)
16
+ *
17
+ * 修改规则文件后 /reload 即可生效,无需重启 pi。
18
+ */
19
+
20
+ import { dirname } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
+
24
+ /** pi payload 消息结构的最小类型 */
25
+ interface PayloadMessage {
26
+ role: string;
27
+ content?: unknown;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ /** pi provider payload 的最小类型 */
32
+ interface ProviderPayload {
33
+ messages?: PayloadMessage[];
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ const __dirname = dirname(fileURLToPath(import.meta.url));
38
+ const RULES_DIR = __dirname;
39
+
40
+ import { getEffectiveConfig } from "@pi-atelier/shared-utils";
41
+ import {
42
+ checkWorktrees,
43
+ drainHints,
44
+ hasGitUncommittedChanges,
45
+ hasGitUntracked,
46
+ hasWarnings,
47
+ isSubagent,
48
+ loadRules,
49
+ notifySummary,
50
+ pushWarning,
51
+ registerMessageEnd,
52
+ registerSessionCompact,
53
+ registerToolCall,
54
+ registerToolResult,
55
+ resetMessageEndState,
56
+ StateTracker,
57
+ type ToolState,
58
+ } from "./shepherd";
59
+ import { isQuestionEnding, ruleMatches } from "./shepherd/rules";
60
+ import type { BuiltinContext } from "./shepherd/conditions";
61
+ import { registerRulesEditorTool } from "./shepherd/rules-tool";
62
+
63
+ /** 本地 hints 缓冲区(收集 pi.events.emit("ephemeral:hint") 的数据) */
64
+ const _localHints: { text: string; short?: string }[] = [];
65
+
66
+ /** 可变状态:跨 hook 共享 */
67
+ let _aborted = false;
68
+ let _wasDirty = false;
69
+ const _agentEndFired = new Set<string>();
70
+ const _toolState: ToolState = {
71
+ hasEdits: false,
72
+ tracker: new StateTracker(),
73
+ cachedTools: null,
74
+ };
75
+
76
+ export default function shepherdExtension(pi: ExtensionAPI) {
77
+ // ── 读取配置(三层合并:defaults → 全局 settings → 项目 settings)──
78
+ const { config: shepherdConfig } = getEffectiveConfig<{
79
+ projectRulesPattern: string;
80
+ maxWarnings: number;
81
+ }>(
82
+ "shepherd",
83
+ {
84
+ projectRulesPattern: "shepherd-rules-",
85
+ maxWarnings: 5,
86
+ },
87
+ process.cwd(),
88
+ );
89
+
90
+ // ── 监听跨扩展 hints(通过 pi.events 绕过 jiti 多实例) ──
91
+ pi.events.on("ephemeral:hint", (data) => {
92
+ const { text, short } = data as { text: string; short?: string };
93
+ _localHints.push({ text, short });
94
+ });
95
+
96
+ // ── before_provider_request:注入临时提示 ──────────────────
97
+ pi.on("before_provider_request", async (event, ctx) => {
98
+ // shepherd 规则 hints
99
+ const shepherdText = drainHints();
100
+ if (shepherdText) {
101
+ _localHints.unshift({ text: shepherdText });
102
+ }
103
+
104
+ // 通知摘要:short 优先,fallback 到 notifySummary 截断
105
+ const shortParts = _localHints
106
+ .map((h) => h.short)
107
+ .filter(Boolean) as string[];
108
+ const longParts = _localHints
109
+ .map((h) => (h.short ? null : h.text))
110
+ .filter(Boolean) as string[];
111
+ const notifyText = [...shortParts, ...longParts].join("\n\n");
112
+
113
+ const allHints = _localHints
114
+ .splice(0)
115
+ .map((h) => h.text)
116
+ .join("\n\n");
117
+ let payload = event.payload as ProviderPayload;
118
+
119
+ if (allHints) {
120
+ const text = allHints;
121
+ payload = { ...payload };
122
+ payload.messages = [...(payload.messages ?? [])];
123
+ payload.messages.push({
124
+ role: "user",
125
+ content: [{ type: "text", text }],
126
+ });
127
+ ctx.ui.notify?.(notifySummary(notifyText), "warning");
128
+ }
129
+
130
+ return payload;
131
+ });
132
+
133
+ // ── session_start ──────────────────────────────────────────
134
+ pi.on("session_start", async (_event, ctx) => {
135
+ checkWorktrees(ctx.ui);
136
+ });
137
+
138
+ // ── agent_start ────────────────────────────────────────────
139
+ pi.on("agent_start", async (_event, ctx) => {
140
+ _aborted = ctx.signal?.aborted ?? false;
141
+ _toolState.hasEdits = false;
142
+ _toolState.cachedTools = null;
143
+ resetMessageEndState();
144
+ if (ctx.signal && !ctx.signal.aborted) {
145
+ ctx.signal.addEventListener("abort", () => {
146
+ _aborted = true;
147
+ });
148
+ }
149
+ _wasDirty = hasGitUncommittedChanges();
150
+ });
151
+
152
+ pi.on("input", async (_event) => {
153
+ /* 用户输入新消息 — 允许 agent_end 规则再次触发 */
154
+ _agentEndFired.clear();
155
+ });
156
+
157
+ // ── agent_end ──────────────────────────────────────────────
158
+ pi.on("agent_end", async (event, _ctx) => {
159
+ if (isSubagent() || _aborted) return;
160
+ const rules = loadRules(RULES_DIR, {
161
+ projectRulesPattern: shepherdConfig.projectRulesPattern,
162
+ }).filter((r) => r.hook === "agent_end");
163
+ if (rules.length === 0) return;
164
+
165
+ const lastAssistant = [...event.messages]
166
+ .reverse()
167
+ .find((m) => m.role === "assistant");
168
+ const stopReason: string | undefined = (
169
+ lastAssistant as unknown as PayloadMessage | undefined
170
+ )?.stopReason as string | undefined;
171
+
172
+ // 提取最后一条 assistant 消息的文本内容(用于 not_question_ending 条件)
173
+ let lastAssistantText = "";
174
+ if (lastAssistant) {
175
+ const parts = (lastAssistant as unknown as PayloadMessage).content;
176
+ if (Array.isArray(parts)) {
177
+ lastAssistantText = parts
178
+ .filter((p: { type?: string }) => p.type === "text")
179
+ .map((p: { text?: string }) => p.text ?? "")
180
+ .join("");
181
+ } else if (typeof parts === "string") {
182
+ lastAssistantText = parts;
183
+ }
184
+ }
185
+ let pushed = false;
186
+
187
+ // 如果 AI 在问用户问题(等待回复),跳过所有 agent_end 提醒
188
+ const isQuestion = isQuestionEnding(lastAssistantText);
189
+
190
+ for (const rule of rules) {
191
+ const allowedReasons = rule.stopReason ?? ["stop"];
192
+ if (!allowedReasons.includes((stopReason ?? "") as never)) continue;
193
+ if (_agentEndFired.has(rule.comment)) continue;
194
+ if (isQuestion) continue; // AI 在问问题,跳过收尾提醒
195
+
196
+ // 统一条件匹配:通过 ruleMatches 检查 conditions(含 builtin)
197
+ const ctx: BuiltinContext = {
198
+ hasEdits: _toolState.hasEdits,
199
+ gitDirty: hasGitUncommittedChanges(),
200
+ gitUntracked: hasGitUntracked(),
201
+ lastAssistantText,
202
+ };
203
+ const shouldNotify = ruleMatches(rule, {}, ctx);
204
+ if (ctx.gitDirty) _wasDirty = true;
205
+
206
+ if (shouldNotify && rule.action === "notify") {
207
+ _agentEndFired.add(rule.comment);
208
+ pushWarning(rule.reason, rule.comment);
209
+ pushed = true;
210
+ }
211
+ }
212
+
213
+ // 统一发送唯一的 sendMessage(triggerTurn):
214
+ // - pushed:agent_end 自己推入了 warning
215
+ // - hasWarnings():message_end 或 tool_result 推入了 warning(它们不再自己发 sendMessage)
216
+ // 只发一次,避免多个 hook 各发一次导致第二个 drainHints 拿空 → 空回复
217
+ if (pushed || hasWarnings()) {
218
+ setTimeout(() => {
219
+ try {
220
+ pi.sendMessage(
221
+ { customType: "shepherd-agent-end", display: false, content: "" },
222
+ { triggerTurn: true },
223
+ );
224
+ } catch {
225
+ /* session 已替换 */
226
+ }
227
+ }, 0);
228
+ }
229
+ });
230
+
231
+ // ── session_shutdown ───────────────────────────────────────
232
+ pi.on("session_shutdown", async (_event, ctx) => {
233
+ const rules = loadRules(RULES_DIR, {
234
+ projectRulesPattern: shepherdConfig.projectRulesPattern,
235
+ }).filter((r) => r.hook === "session_shutdown");
236
+ if (rules.length === 0) return;
237
+ for (const rule of rules) {
238
+ // 统一条件匹配
239
+ const builtinCtx: BuiltinContext = {
240
+ hasEdits: false,
241
+ gitDirty: hasGitUncommittedChanges(),
242
+ gitUntracked: hasGitUntracked(),
243
+ };
244
+ const shouldNotify = ruleMatches(rule, {}, builtinCtx);
245
+ if (shouldNotify && rule.action === "notify") {
246
+ ctx.ui?.notify?.(`⚠️ shepherd: ${rule.reason}`, "warning");
247
+ }
248
+ }
249
+ });
250
+
251
+ // ── tool_call + tool_result(提取到 tool-hooks.ts)────────
252
+ const _rulesOpts = {
253
+ projectRulesPattern: shepherdConfig.projectRulesPattern,
254
+ };
255
+ registerToolCall(pi, _toolState, RULES_DIR, _rulesOpts);
256
+ registerToolResult(pi, _toolState, RULES_DIR, _rulesOpts);
257
+ registerMessageEnd(pi, _toolState, RULES_DIR, _rulesOpts);
258
+ registerSessionCompact(pi, _toolState, RULES_DIR, _rulesOpts);
259
+
260
+ // ── shepherd_rules 工具:规则文件安全编辑 ───────────────────
261
+ registerRulesEditorTool(pi, RULES_DIR, process.cwd());
262
+ }
package/package.json CHANGED
@@ -1,55 +1,60 @@
1
- {
2
- "name": "pi-shepherd",
3
- "version": "0.1.2",
4
- "description": "Line count guard and behavior rules extension for pi-coding-agent",
5
- "main": "index.ts",
6
- "scripts": {
7
- "test": "vitest run",
8
- "lint": "biome check lib tests index.ts tool-hooks.ts",
9
- "typecheck": "tsc --noEmit"
10
- },
11
- "dependencies": {
12
- "@pi-atelier/shared-utils": "^1.0.0",
13
- "ignore": "^7.0.5"
14
- },
15
- "peerDependencies": {
16
- "@earendil-works/pi-coding-agent": ">=0.9.0"
17
- },
18
- "peerDependenciesMeta": {
19
- "@earendil-works/pi-coding-agent": {
20
- "optional": true
21
- }
22
- },
23
- "pi": {
24
- "extensions": [
25
- "."
26
- ]
27
- },
28
- "devDependencies": {
29
- "@biomejs/biome": "^2.4.15",
30
- "@vitest/coverage-v8": "^3.2.4",
31
- "vitest": "^3.0.0"
32
- },
33
- "files": [
34
- "index.ts",
35
- "shepherd/",
36
- "rules.json",
37
- "README.md",
38
- "README.en.md",
39
- "tsconfig.json",
40
- "vitest.config.ts",
41
- "package.json"
42
- ],
43
- "keywords": [
44
- "pi-package"
45
- ],
46
- "homepage": "https://catlain.github.io/pi-atelier/",
47
- "repository": {
48
- "type": "git",
49
- "url": "git+ssh://git@github.com/catlain/pi-shepherd.git"
50
- },
51
- "bugs": {
52
- "url": "https://github.com/catlain/pi-shepherd/issues"
53
- },
54
- "license": "MIT"
55
- }
1
+ {
2
+ "name": "pi-shepherd",
3
+ "version": "0.2.0",
4
+ "description": "Line count guard and behavior rules extension for pi-coding-agent",
5
+ "main": "index.ts",
6
+ "scripts": {
7
+ "test": "vitest run",
8
+ "lint": "biome check lib tests index.ts tool-hooks.ts",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@pi-atelier/shared-utils": "^1.0.0",
13
+ "ignore": "^7.0.5"
14
+ },
15
+ "peerDependencies": {
16
+ "@earendil-works/pi-coding-agent": ">=0.9.0"
17
+ },
18
+ "peerDependenciesMeta": {
19
+ "@earendil-works/pi-coding-agent": {
20
+ "optional": true
21
+ }
22
+ },
23
+ "optionalDependencies": {
24
+ "@earendil-works/pi-coding-agent": "^0.76.0"
25
+ },
26
+ "pi": {
27
+ "extensions": [
28
+ "."
29
+ ]
30
+ },
31
+ "devDependencies": {
32
+ "@biomejs/biome": "^2.4.15",
33
+ "@types/node": "^25.9.2",
34
+ "@vitest/coverage-v8": "^3.2.4",
35
+ "typescript": "^6.0.3",
36
+ "vitest": "^3.0.0"
37
+ },
38
+ "files": [
39
+ "index.ts",
40
+ "shepherd/",
41
+ "rules.json",
42
+ "README.md",
43
+ "README.en.md",
44
+ "tsconfig.json",
45
+ "vitest.config.ts",
46
+ "package.json"
47
+ ],
48
+ "keywords": [
49
+ "pi-package"
50
+ ],
51
+ "homepage": "https://catlain.github.io/pi-atelier/",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+ssh://git@github.com/catlain/pi-shepherd.git"
55
+ },
56
+ "bugs": {
57
+ "url": "https://github.com/catlain/pi-shepherd/issues"
58
+ },
59
+ "license": "MIT"
60
+ }