@zhushanwen/pi-plan 1.1.0 → 1.1.2
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-plan",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Lightweight plan mode for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@earendil-works/pi-tui": "^1.0.0",
|
|
26
26
|
"typebox": "1.3.27",
|
|
27
27
|
"@earendil-works/pi-ai": "^1.0.0",
|
|
28
|
-
"@zhushanwen/pi-goal": "0.14.
|
|
28
|
+
"@zhushanwen/pi-goal": "0.14.13"
|
|
29
29
|
},
|
|
30
30
|
"peerDependenciesMeta": {
|
|
31
31
|
"@earendil-works/pi-tui": {
|
|
@@ -50,11 +50,11 @@
|
|
|
50
50
|
"templates/"
|
|
51
51
|
],
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@zhushanwen/extension-protocol": "0.
|
|
53
|
+
"@zhushanwen/extension-protocol": "0.20.0",
|
|
54
54
|
"@zhushanwen/pi-exec-skills": "0.2.1",
|
|
55
|
-
"@zhushanwen/pi-ext-guards": "0.4.
|
|
55
|
+
"@zhushanwen/pi-ext-guards": "0.4.4",
|
|
56
56
|
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
57
|
-
"@zhushanwen/pi-llm-shared": "0.11.
|
|
57
|
+
"@zhushanwen/pi-llm-shared": "0.11.2",
|
|
58
58
|
"@zhushanwen/pi-session-path": "0.1.0"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
@@ -182,10 +182,9 @@ describe("registerPlanCommand", () => {
|
|
|
182
182
|
it("enters plan mode with slugified path", async () => {
|
|
183
183
|
await handler("Implement User Auth", ctx);
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
);
|
|
185
|
+
// 目录落盘(原子唯一性):plansRoot 父目录 recursive 建立 + 候选目录非 recursive mkdir
|
|
186
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith("/tmp/test-project/.tmp/plans", { recursive: true });
|
|
187
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith("/tmp/test-project/.tmp/plans/implement-user-auth");
|
|
189
188
|
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan", "ask_user"]);
|
|
190
189
|
expect(pi.sendMessage).toHaveBeenCalledWith(...planContextMessage(expect.stringContaining("[PLAN MODE]")));
|
|
191
190
|
expect(pi.sendMessage).toHaveBeenCalledWith(...planContextMessage(expect.stringContaining("Implement User Auth")));
|
|
@@ -207,20 +206,14 @@ describe("registerPlanCommand", () => {
|
|
|
207
206
|
it("handles special characters in requirement for slug", async () => {
|
|
208
207
|
await handler("Fix bug #123: 中文标题!", ctx);
|
|
209
208
|
|
|
210
|
-
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
|
211
|
-
expect.stringContaining("/.tmp/plans/fix-bug-123"),
|
|
212
|
-
{ recursive: true },
|
|
213
|
-
);
|
|
209
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining("/.tmp/plans/fix-bug-123"));
|
|
214
210
|
});
|
|
215
211
|
|
|
216
212
|
it("uses 'untitled' slug when no args", async () => {
|
|
217
213
|
// No existing plans (readdirSync returns [])
|
|
218
214
|
await handler("", ctx);
|
|
219
215
|
|
|
220
|
-
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
|
221
|
-
"/tmp/test-project/.tmp/plans/untitled",
|
|
222
|
-
{ recursive: true },
|
|
223
|
-
);
|
|
216
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith("/tmp/test-project/.tmp/plans/untitled");
|
|
224
217
|
});
|
|
225
218
|
|
|
226
219
|
it("超长 requirement 进入:state/entry 按 64KB 封顶,提示词仍携带全文(MF-1-3)", async () => {
|
|
@@ -92,6 +92,9 @@ function setup(skillCommands: Array<{ name: string; path: string }> = []) {
|
|
|
92
92
|
describe("plan(action='enter') — agent 自助进入(plan-mode-agent-enter U1)", () => {
|
|
93
93
|
beforeEach(() => {
|
|
94
94
|
vi.clearAllMocks();
|
|
95
|
+
// clearAllMocks 不清 mockImplementation——mkdirSync 的 EEXIST/EACCES 抛错实现
|
|
96
|
+
// 会跨用例泄漏,显式 reset 回无实现桩
|
|
97
|
+
vi.mocked(fs.mkdirSync).mockReset();
|
|
95
98
|
});
|
|
96
99
|
|
|
97
100
|
it("idle 进入:收工具到白名单 + 持久化 isActive=true + tool result 携带只读纪律提示词", async () => {
|
|
@@ -102,11 +105,10 @@ describe("plan(action='enter') — agent 自助进入(plan-mode-agent-enter U1
|
|
|
102
105
|
expect(pi.setActiveTools).toHaveBeenCalledWith(PLAN_MODE_TOOLS);
|
|
103
106
|
// 状态持久化 isActive=true(投影链驱动 GUI 显形的锚点)
|
|
104
107
|
expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", expect.objectContaining({ isActive: true }));
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
);
|
|
108
|
+
// 目录落盘(原子唯一性):plansRoot 父目录 recursive 建立 + 候选目录非 recursive
|
|
109
|
+
// mkdir 独占创建(已被占用抛 EEXIST 重探)
|
|
110
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith("/tmp/test-project/.tmp/plans", { recursive: true });
|
|
111
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith("/tmp/test-project/.tmp/plans/auth");
|
|
110
112
|
// 提示词经 tool result 直返(不经对话流消息注入——对本次调用的直接响应)
|
|
111
113
|
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
112
114
|
expect(res.details.action).toBe("enter");
|
|
@@ -207,4 +209,72 @@ describe("plan(action='enter') — agent 自助进入(plan-mode-agent-enter U1
|
|
|
207
209
|
expect(lastEntry.state).toBe("reviewing");
|
|
208
210
|
expect(lastEntry.isActive).toBe(true);
|
|
209
211
|
});
|
|
212
|
+
|
|
213
|
+
it("纯中文需求两轮进入不互覆:slug 折叠空串回落 untitled,第二轮候选位被占(mkdir 抛 EEXIST)重探落 untitled-2", async () => {
|
|
214
|
+
const { exec, sessions } = setup();
|
|
215
|
+
// 原子 mkdir 语义模拟:untitled 目录已被占用 → mkdirSync 抛 EEXIST(existsSync
|
|
216
|
+
// 预探已退役,冲突信号由 mkdir 自身承载——TOCTOU 竞态修复的机制锚)
|
|
217
|
+
let untitledTaken = false;
|
|
218
|
+
vi.mocked(fs.mkdirSync).mockImplementation(((p: fs.PathLike) => {
|
|
219
|
+
if (untitledTaken && String(p).endsWith("/.tmp/plans/untitled")) {
|
|
220
|
+
throw Object.assign(new Error("EEXIST: file already exists, mkdir"), { code: "EEXIST" });
|
|
221
|
+
}
|
|
222
|
+
return undefined;
|
|
223
|
+
}) as typeof fs.mkdirSync);
|
|
224
|
+
|
|
225
|
+
// 第一轮:目录不存在 → 落 untitled
|
|
226
|
+
await exec({ action: "enter", requirement: "重构认证模块" });
|
|
227
|
+
expect((sessions.get("test-session") as PlanState).planFilePath).toBe(
|
|
228
|
+
"/tmp/test-project/.tmp/plans/untitled/plan.md",
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// 终局本轮(exited --enter--> 新一轮),untitled 目录已在盘上 = 第一轮已落盘
|
|
232
|
+
const state = sessions.get("test-session") as PlanState;
|
|
233
|
+
state.isActive = false;
|
|
234
|
+
state.state = "exited";
|
|
235
|
+
untitledTaken = true;
|
|
236
|
+
|
|
237
|
+
await exec({ action: "enter", requirement: "重构登录模块" });
|
|
238
|
+
|
|
239
|
+
// 第二轮经 EEXIST 重探落 untitled-2:两轮 plan.md 路径不同,不互覆
|
|
240
|
+
expect((sessions.get("test-session") as PlanState).planFilePath).toBe(
|
|
241
|
+
"/tmp/test-project/.tmp/plans/untitled-2/plan.md",
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("英文 slug 冲突递增(EEXIST 驱动):base 与 -2 均被占用 → 落 -3", async () => {
|
|
246
|
+
const { exec } = setup();
|
|
247
|
+
vi.mocked(fs.mkdirSync).mockImplementation(((p: fs.PathLike) => {
|
|
248
|
+
const s = String(p);
|
|
249
|
+
if (s.includes("implement-user-auth") && !s.endsWith("implement-user-auth-3")) {
|
|
250
|
+
throw Object.assign(new Error("EEXIST: file already exists, mkdir"), { code: "EEXIST" });
|
|
251
|
+
}
|
|
252
|
+
return undefined;
|
|
253
|
+
}) as typeof fs.mkdirSync);
|
|
254
|
+
|
|
255
|
+
await exec({ action: "enter", requirement: "Implement User Auth" });
|
|
256
|
+
|
|
257
|
+
const mkdirCalls = (fs.mkdirSync as ReturnType<typeof vi.fn>).mock.calls as Array<[string]>;
|
|
258
|
+
expect(mkdirCalls.at(-1)?.[0]).toBe("/tmp/test-project/.tmp/plans/implement-user-auth-3");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("非 EEXIST 的 mkdir 失败照常上抛(fail-fast,不吞真实故障)", async () => {
|
|
262
|
+
const { exec } = setup();
|
|
263
|
+
vi.mocked(fs.mkdirSync).mockImplementation((() => {
|
|
264
|
+
throw Object.assign(new Error("EACCES: permission denied, mkdir"), { code: "EACCES" });
|
|
265
|
+
}) as typeof fs.mkdirSync);
|
|
266
|
+
|
|
267
|
+
await expect(exec({ action: "enter", requirement: "r" })).rejects.toThrow(/EACCES/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("空需求(requirement 省略)走 untitled 目录", async () => {
|
|
271
|
+
const { exec, sessions } = setup();
|
|
272
|
+
|
|
273
|
+
const res = await exec({ action: "enter" });
|
|
274
|
+
|
|
275
|
+
expect(res.details.action).toBe("enter");
|
|
276
|
+
expect((sessions.get("test-session") as PlanState).planFilePath).toBe(
|
|
277
|
+
"/tmp/test-project/.tmp/plans/untitled/plan.md",
|
|
278
|
+
);
|
|
279
|
+
});
|
|
210
280
|
});
|
|
@@ -231,8 +231,8 @@ describe("E1 fail-fast via /plan handler", () => {
|
|
|
231
231
|
await handler("重构 auth --skills tech-design,code review", ctx);
|
|
232
232
|
|
|
233
233
|
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan", "ask_user"]);
|
|
234
|
-
// slug 只保留 [a-z0-9]:「重构 auth」→ "auth"
|
|
235
|
-
expect(fs.mkdirSync).toHaveBeenCalledWith("/tmp/test-project/.tmp/plans/auth"
|
|
234
|
+
// slug 只保留 [a-z0-9]:「重构 auth」→ "auth";候选目录走非 recursive mkdir(原子唯一性)
|
|
235
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith("/tmp/test-project/.tmp/plans/auth");
|
|
236
236
|
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
237
237
|
"plan-state",
|
|
238
238
|
expect.objectContaining({
|
package/src/enter.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
|
|
4
4
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { isErrnoWithCode } from "@zhushanwen/pi-ext-guards";
|
|
5
6
|
|
|
6
7
|
// 技能执行门禁(resolveSkills + SkillResolution)单源在 ADR-0074 共享包
|
|
7
8
|
// @zhushanwen/pi-exec-skills(plan-mode-audit-remediation 批次 4②b 迁入,含空请求
|
|
@@ -61,12 +62,33 @@ export function activatePlanMode(
|
|
|
61
62
|
const state = getPlanState(sessions, sessionId, ctx);
|
|
62
63
|
const { requirement, skills, projectDir } = input;
|
|
63
64
|
|
|
65
|
+
// slug 折叠后可能为空串(纯中文需求)——回落 "untitled",保证目录名恒为
|
|
66
|
+
// <slug> 形态而非退化为 plans 骨架
|
|
64
67
|
const slug = requirement
|
|
65
68
|
? requirement.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, MAX_SLUG_LENGTH)
|
|
66
|
-
: "
|
|
69
|
+
: "";
|
|
70
|
+
const baseSlug = slug || "untitled";
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
// 目录唯一性:探测与落盘收敛为一次原子操作——候选目录走非 recursive mkdirSync,
|
|
73
|
+
// 已被占用即抛 EEXIST,catch 后 <base>-2/-3 递增重探。存在性探测(existsSync)与
|
|
74
|
+
// recursive mkdir 落盘分离会留检查时刻与使用时刻竞态窗口(TOCTOU):recursive 对
|
|
75
|
+
// 已存在目录静默成功,两进程并发 enter 可同探同一空位双双落盘、互覆 plan.md。
|
|
76
|
+
const plansRoot = path.join(projectDir, ".tmp", "plans");
|
|
77
|
+
fs.mkdirSync(plansRoot, { recursive: true });
|
|
78
|
+
let planDir = path.join(plansRoot, baseSlug);
|
|
79
|
+
for (let n = 2; ; n += 1) {
|
|
80
|
+
try {
|
|
81
|
+
fs.mkdirSync(planDir);
|
|
82
|
+
break;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// 仅 EEXIST(候选位被并发方/上一轮抢先命中)递增重探;其余 mkdir 失败
|
|
85
|
+
//(权限等真实故障)照常上抛,不吞
|
|
86
|
+
if (!isErrnoWithCode(error, "EEXIST")) {
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
planDir = path.join(plansRoot, `${baseSlug}-${n}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
70
92
|
const planFilePath = path.join(planDir, "plan.md");
|
|
71
93
|
|
|
72
94
|
state.isActive = true;
|
package/src/execution-notice.ts
CHANGED
|
@@ -51,11 +51,9 @@ function getGoalInit(): GoalInitFn | undefined {
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* goal 桥侧 slug(仅 widget 标题 + history 展示用,不注入 prompt)。生产输入恒为
|
|
54
|
-
* <project>/.tmp/plans/<slug>/plan.md(enter.ts
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* "plan-execution"。中文需求且缺陷 2(enter 期目录唯一性)未修的窗口内,plan.md 直落
|
|
58
|
-
* .tmp/plans 使本函数取到骨架名 "plans"——该形态随缺陷 2 修复消除。
|
|
54
|
+
* <project>/.tmp/plans/<slug>/plan.md(enter.ts 构造,目录名 = requirement slug 段,
|
|
55
|
+
* 已存在时 <base>-2/-3 递增保证唯一)——展示名取实际目录名。kebab 链对 enter slug
|
|
56
|
+
* 幂等,只防 planFilePath 非生产形态的通用输入;提取为空回 "plan-execution"。
|
|
59
57
|
*/
|
|
60
58
|
function buildPlanSlug(planFilePath: string): string {
|
|
61
59
|
const stem = basename(dirname(planFilePath))
|