@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.0",
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.11"
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.18.0",
53
+ "@zhushanwen/extension-protocol": "0.20.0",
54
54
  "@zhushanwen/pi-exec-skills": "0.2.1",
55
- "@zhushanwen/pi-ext-guards": "0.4.3",
55
+ "@zhushanwen/pi-ext-guards": "0.4.4",
56
56
  "@zhushanwen/pi-extension-logger": "0.6.2",
57
- "@zhushanwen/pi-llm-shared": "0.11.1",
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
- expect(fs.mkdirSync).toHaveBeenCalledWith(
186
- "/tmp/test-project/.tmp/plans/implement-user-auth",
187
- { recursive: true },
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
- // plan 目录创建(.tmp/plans/<slug>,slug 由 requirement 派生)
106
- expect(fs.mkdirSync).toHaveBeenCalledWith(
107
- expect.stringMatching(/\.tmp[/\\]plans[/\\]/),
108
- expect.objectContaining({ recursive: true }),
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", { recursive: true });
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
- : "untitled";
69
+ : "";
70
+ const baseSlug = slug || "untitled";
67
71
 
68
- const planDir = path.join(projectDir, ".tmp", "plans", slug);
69
- fs.mkdirSync(planDir, { recursive: true });
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;
@@ -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 构造)——展示名取目录名(requirement
55
- * slug 段,2026-09-27 用户裁决;已归档缺陷档案(plan-cjk-slug-data-loss) 缺陷 3 同源修复)。
56
- * kebab 链对 enter slug 幂等,只防 planFilePath 非生产形态的通用输入;提取为空回
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))