@zhushanwen/pi-plan 0.4.5 → 0.4.6

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": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Lightweight plan mode for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -21,11 +21,14 @@
21
21
  "@earendil-works/pi-coding-agent": ">=0.73.0",
22
22
  "typebox": "*",
23
23
  "@earendil-works/pi-ai": "^0.84.4",
24
- "@zhushanwen/pi-goal": "0.14.1"
24
+ "@zhushanwen/pi-goal": "0.14.2"
25
25
  },
26
26
  "peerDependenciesMeta": {
27
27
  "@earendil-works/pi-ai": {
28
28
  "optional": true
29
+ },
30
+ "@zhushanwen/pi-goal": {
31
+ "optional": true
29
32
  }
30
33
  },
31
34
  "devDependencies": {
@@ -38,7 +41,8 @@
38
41
  "templates/"
39
42
  ],
40
43
  "dependencies": {
41
- "@zhushanwen/pi-ext-guards": "0.3.0",
44
+ "@xyz-agent/extension-protocol": "0.11.0",
45
+ "@zhushanwen/pi-ext-guards": "0.4.0",
42
46
  "@zhushanwen/pi-extension-logger": "0.6.0"
43
47
  },
44
48
  "scripts": {
@@ -128,10 +128,12 @@ describe("registerPlanCommand", () => {
128
128
  expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
129
129
  expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("[PLAN MODE]"));
130
130
  expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Implement User Auth"));
131
- expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", expect.objectContaining({
131
+ expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", {
132
132
  isActive: true,
133
- phase: "brainstorming",
134
- }));
133
+ planFilePath: "/tmp/test-project/.xyz-harness/implement-user-auth/plan.md",
134
+ requirement: "Implement User Auth",
135
+ templateName: "",
136
+ });
135
137
  });
136
138
 
137
139
  it("handles special characters in requirement for slug", async () => {
@@ -158,8 +160,13 @@ describe("registerPlanCommand", () => {
158
160
  vi.clearAllMocks();
159
161
 
160
162
  await handler("", ctx);
163
+ // D6:phase 删除后 status 显示 plan 文件与模板,无档位行
161
164
  expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith(
162
- expect.stringContaining("brainstorming"),
165
+ expect.stringContaining("my-feature/plan.md"),
166
+ "info",
167
+ );
168
+ expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith(
169
+ expect.stringContaining("Template: (not selected)"),
163
170
  "info",
164
171
  );
165
172
  });
@@ -5,7 +5,7 @@
5
5
  * + 前 3 条 step preview(编号前缀、单条截断 ≤80 chars),合计 ≤4 条
6
6
  * (goal schema maxItems:8),每条单行不含 \r\n(goal handler 拒含换行条目)。
7
7
  */
8
- import { beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
9
 
10
10
  vi.mock("node:fs", () => ({
11
11
  readFileSync: vi.fn(),
@@ -42,7 +42,6 @@ function makeCtx() {
42
42
  function makeActiveState() {
43
43
  return {
44
44
  isActive: true,
45
- phase: "complete" as const,
46
45
  planFilePath: "/tmp/plan.md",
47
46
  requirement: "Add login page",
48
47
  templateName: "default",
@@ -130,19 +129,30 @@ describe("buildPlanSuccessCriteria — 1 总述 + 前 3 条 preview", () => {
130
129
 
131
130
  // --- handlePlanComplete → tryGoalInit 端到端 ---
132
131
 
133
- describe("handlePlanComplete — __goalInit 第 5 参数为新形态 string[]", () => {
132
+ /**
133
+ * goal 桥 slot key——与 compact.ts / goal 侧 index.ts 的字符串一致(本地声明,
134
+ * 不 import 对方包:pi-goal 是 optional peer)。mock 挂 slot 与真实通道同构。
135
+ */
136
+ const GOAL_INIT_SLOT_KEY = Symbol.for("@zhushanwen/pi-goal.goalInit");
137
+
138
+ describe("handlePlanComplete — goalInit slot 第 5 参数为新形态 string[]", () => {
134
139
  let pi: ReturnType<typeof makePi>;
135
140
  let ctx: ReturnType<typeof makeCtx>;
141
+ let goalInitMock: ReturnType<typeof vi.fn>;
136
142
 
137
143
  beforeEach(() => {
138
144
  vi.clearAllMocks();
139
145
  pi = makePi();
140
146
  ctx = makeCtx();
141
- (pi as unknown as Record<string, unknown>).__goalInit = vi.fn().mockReturnValue(true);
147
+ goalInitMock = vi.fn().mockReturnValue(true);
148
+ Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, goalInitMock);
149
+ });
150
+
151
+ afterEach(() => {
152
+ Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, undefined);
142
153
  });
143
154
 
144
155
  function getCriteriaArg(): string[] {
145
- const goalInitMock = (pi as unknown as Record<string, unknown>).__goalInit as ReturnType<typeof vi.fn>;
146
156
  expect(goalInitMock).toHaveBeenCalled();
147
157
  return expectSingleLineArray(goalInitMock.mock.calls[0][4]);
148
158
  }
@@ -150,7 +160,7 @@ describe("handlePlanComplete — __goalInit 第 5 参数为新形态 string[]",
150
160
  it("direct isolation: 3 步 plan → 总述 + 3 条 preview", () => {
151
161
  fsMock.readFileSync.mockReturnValue(makePlanContent(["Step A", "Step B", "Step C"]));
152
162
 
153
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct");
163
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
154
164
 
155
165
  expect(getCriteriaArg()).toEqual([
156
166
  "All 3 steps of plan executed and verified",
@@ -160,10 +170,10 @@ describe("handlePlanComplete — __goalInit 第 5 参数为新形态 string[]",
160
170
  ]);
161
171
  });
162
172
 
163
- it("compact isolation: onComplete 后 __goalInit 收到同形态数组", () => {
173
+ it("compact isolation: onComplete 后 goalInit 收到同形态数组", () => {
164
174
  fsMock.readFileSync.mockReturnValue(makePlanContent(["Step one", "Step two"]));
165
175
 
166
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact");
176
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "goal");
167
177
  ctx._onCompleteFns[0]();
168
178
 
169
179
  expect(getCriteriaArg()).toEqual([
@@ -177,7 +187,7 @@ describe("handlePlanComplete — __goalInit 第 5 参数为新形态 string[]",
177
187
  const steps = Array.from({ length: 12 }, (_, i) => `Step ${i + 1}`);
178
188
  fsMock.readFileSync.mockReturnValue(makePlanContent(steps));
179
189
 
180
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct");
190
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
181
191
 
182
192
  const items = getCriteriaArg();
183
193
  expect(items).toHaveLength(4);
@@ -188,7 +198,7 @@ describe("handlePlanComplete — __goalInit 第 5 参数为新形态 string[]",
188
198
  it("CRLF plan 文件 → 每条 criteria 仍单行不含 \\r \\n", () => {
189
199
  fsMock.readFileSync.mockReturnValue("## 实现步骤\r\n1. Step one\r\n2. Step two\r\n3. Step three");
190
200
 
191
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct");
201
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
192
202
 
193
203
  expect(getCriteriaArg()).toEqual([
194
204
  "All 3 steps of plan executed and verified",
@@ -198,12 +208,11 @@ describe("handlePlanComplete — __goalInit 第 5 参数为新形态 string[]",
198
208
  ]);
199
209
  });
200
210
 
201
- it("0 步 plan → tryGoalInit 提前退出,__goalInit 不被调用", () => {
211
+ it("0 步 plan → tryGoalInit 提前退出,goalInit 不被调用", () => {
202
212
  (fsMock.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue("## Overview\nNo numbered steps here.");
203
213
 
204
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct");
214
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
205
215
 
206
- const goalInitMock = (pi as unknown as Record<string, unknown>).__goalInit as ReturnType<typeof vi.fn>;
207
216
  expect(goalInitMock).not.toHaveBeenCalled();
208
217
  // steer 仍发出(执行流程不因 goal 缺席中断)
209
218
  expect(pi.sendUserMessage).toHaveBeenCalled();
@@ -1,4 +1,4 @@
1
- import { beforeEach,describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import type { PlanState } from "../state.js";
4
4
 
@@ -42,7 +42,6 @@ function makeCtx() {
42
42
  function makeActiveState(): PlanState {
43
43
  return {
44
44
  isActive: true,
45
- phase: "complete",
46
45
  planFilePath: "/tmp/plan.md",
47
46
  requirement: "Add login page",
48
47
  templateName: "default",
@@ -53,6 +52,30 @@ function setupFsMock(content: string) {
53
52
  fsMock.readFileSync.mockReturnValue(content);
54
53
  }
55
54
 
55
+ /**
56
+ * goal 桥 slot key——与 compact.ts / goal 侧 index.ts 的字符串一致(本地声明,
57
+ * 不 import 对方包:pi-goal 是 optional peer,两侧靠同一字符串共享 slot)。
58
+ */
59
+ const GOAL_INIT_SLOT_KEY = Symbol.for("@zhushanwen/pi-goal.goalInit");
60
+
61
+ /** 在 globalThis slot 上挂 goal 桥——mock 形态与真实通道同构(goal-bridge-cross-extension.md §3.4:goal 侧 Reflect.set 挂 slot,本测试同款挂载)。 */
62
+ function attachGoalInit(impl: () => boolean) {
63
+ const fn = vi.fn(impl);
64
+ Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, fn);
65
+ return fn;
66
+ }
67
+
68
+ /** 清 slot 防跨用例 globalThis 泄漏(设计 §3.4:teardown 两侧通用)。 */
69
+ afterEach(() => {
70
+ Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, undefined);
71
+ });
72
+
73
+ /** 最近一次 steer 消息正文。 */
74
+ function lastSteer(pi: ReturnType<typeof makePi>): string {
75
+ const calls = (pi.sendUserMessage as ReturnType<typeof vi.fn>).mock.calls;
76
+ return String(calls[calls.length - 1]?.[0]);
77
+ }
78
+
56
79
  // --- handlePlanComplete tests ---
57
80
 
58
81
  describe("handlePlanComplete", () => {
@@ -66,61 +89,148 @@ describe("handlePlanComplete", () => {
66
89
  setupFsMock("## 实现步骤\n1. Step one\n2. Step two");
67
90
  });
68
91
 
69
- it("compact isolation: calls compact, onComplete sends steer + tryGoalInit", () => {
70
- (pi as unknown as Record<string, unknown>).__goalInit = vi.fn().mockReturnValue(true);
71
-
72
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact");
73
-
74
- expect(ctx.compact).toHaveBeenCalledOnce();
75
- ctx._onCompleteFns[0]();
76
- expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.any(String), { deliverAs: "steer" });
77
- expect((pi as unknown as Record<string, unknown>).__goalInit).toHaveBeenCalledWith(
78
- expect.any(String),
79
- undefined,
80
- ctx,
81
- "plan",
82
- [
83
- "All 2 steps of plan executed and verified",
84
- "1. Step one",
85
- "2. Step two",
86
- ],
87
- );
92
+ describe("compact isolation", () => {
93
+ it("goal mode: goalInit deferred to onComplete, then goal steer on success (D2 时序——goal entry 在压缩后世界创建)", () => {
94
+ const goalInit = attachGoalInit(() => true);
95
+
96
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "goal");
97
+
98
+ expect(outcome).toBeUndefined(); // compact 档 outcome 在回调内产生,不进同步返回值
99
+ expect(ctx.compact).toHaveBeenCalledOnce();
100
+ expect(goalInit).not.toHaveBeenCalled(); // 压缩完成前不创建 goal entry
101
+
102
+ ctx._onCompleteFns[0]();
103
+
104
+ expect(goalInit).toHaveBeenCalledWith(
105
+ "Execute plan: /tmp/plan.md",
106
+ undefined,
107
+ ctx,
108
+ "plan",
109
+ [
110
+ "All 2 steps of plan executed and verified",
111
+ "1. Step one",
112
+ "2. Step two",
113
+ ],
114
+ );
115
+ expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Execute via /goal"), { deliverAs: "steer" });
116
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it("non-goal mode: onComplete sends mode steer without calling goalInit", () => {
120
+ const goalInit = attachGoalInit(() => true);
121
+
122
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "subagent");
123
+ ctx._onCompleteFns[0]();
124
+
125
+ expect(goalInit).not.toHaveBeenCalled();
126
+ expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("subagent-driven"), { deliverAs: "steer" });
127
+ });
128
+
129
+ it("onError (goal mode, init-refused): compact-failure notify + degraded steer + warning notify, no /goal promise", () => {
130
+ attachGoalInit(() => false); // goalInit 返回 false:已有 active goal
131
+
132
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "goal");
133
+ ctx._onErrorFns[0](new Error("compact failed"));
134
+
135
+ const notifyTexts = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.map((c) => String(c[0]));
136
+ expect(notifyTexts.some((t) => t.includes("Compact failed"))).toBe(true);
137
+ expect(notifyTexts.some((t) => t.includes("init-refused"))).toBe(true);
138
+
139
+ const steer = lastSteer(pi);
140
+ expect(steer).toContain("Goal execution was not started (init-refused)");
141
+ expect(steer).toContain("/goal clear"); // 恢复动作
142
+ expect(steer).not.toContain("Execute via /goal"); // 承诺不再无凭据发出
143
+ });
88
144
  });
89
145
 
90
- it("compact onError: falls back to notify + steer", () => {
91
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact");
146
+ describe("direct isolation", () => {
147
+ it("goal mode success: returns outcome synchronously, goal steer, no notify", () => {
148
+ attachGoalInit(() => true);
92
149
 
93
- ctx._onErrorFns[0](new Error("compact failed"));
94
- expect(ctx.ui.notify).toHaveBeenCalledWith(expect.any(String), "warning");
95
- expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.any(String), { deliverAs: "steer" });
96
- });
150
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
97
151
 
98
- it("tree isolation: only notify, no compact or steer", () => {
99
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "tree");
152
+ expect(outcome).toEqual({ started: true });
153
+ expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Execute via /goal"), { deliverAs: "steer" });
154
+ expect(ctx.compact).not.toHaveBeenCalled();
155
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
156
+ });
100
157
 
101
- expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("/tmp/plan.md"), "info");
102
- expect(ctx.compact).not.toHaveBeenCalled();
103
- expect(pi.sendUserMessage).not.toHaveBeenCalled();
158
+ it("non-goal mode: returns undefined, mode steer, no goalInit call", () => {
159
+ const goalInit = attachGoalInit(() => true);
160
+
161
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "single-agent");
162
+
163
+ expect(outcome).toBeUndefined();
164
+ expect(goalInit).not.toHaveBeenCalled();
165
+ expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("step by step"), { deliverAs: "steer" });
166
+ expect(ctx.compact).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it("unknown isolation value falls through to direct delivery instead of silently dropping the choice (D1 防御形态)", () => {
170
+ attachGoalInit(() => true);
171
+
172
+ handlePlanComplete(pi as never, ctx as never, makeActiveState(), "tree", "goal");
173
+
174
+ expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Execute via /goal"), { deliverAs: "steer" });
175
+ expect(ctx.compact).not.toHaveBeenCalled();
176
+ expect(ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("/tree"), "info");
177
+ });
104
178
  });
105
179
 
106
- it("direct isolation: directly sends steer", () => {
107
- (pi as unknown as Record<string, unknown>).__goalInit = vi.fn().mockReturnValue(true);
108
- handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct");
109
-
110
- expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.any(String), { deliverAs: "steer" });
111
- expect(ctx.compact).not.toHaveBeenCalled();
112
- expect(ctx.ui.notify).not.toHaveBeenCalled();
113
- expect((pi as unknown as Record<string, unknown>).__goalInit).toHaveBeenCalledWith(
114
- expect.any(String),
115
- undefined,
116
- ctx,
117
- "plan",
118
- [
119
- "All 2 steps of plan executed and verified",
120
- "1. Step one",
121
- "2. Step two",
122
- ],
123
- );
180
+ // goal 桥五个失败出口 GoalBridgeOutcome 五值(D2)
181
+ describe("tryGoalInit failure exits (goal mode, direct isolation)", () => {
182
+ it("goal-unavailable: bridge not mounted degraded steer + warning notify", () => {
183
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
184
+
185
+ expect(outcome).toEqual({ started: false, reason: "goal-unavailable" });
186
+ const steer = lastSteer(pi);
187
+ expect(steer).toContain("Goal execution was not started (goal-unavailable)");
188
+ expect(steer).not.toContain("Execute via /goal");
189
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("goal-unavailable"), "warning");
190
+ });
191
+
192
+ it("plan-unreadable: plan file read fails → recovery points at the plan file", () => {
193
+ attachGoalInit(() => true);
194
+ fsMock.readFileSync.mockImplementation(() => { throw new Error("ENOENT: plan.md"); });
195
+
196
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
197
+
198
+ expect(outcome).toEqual({ started: false, reason: "plan-unreadable" });
199
+ expect(lastSteer(pi)).toContain("Check that the plan file exists");
200
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("plan-unreadable"), "warning");
201
+ });
202
+
203
+ it("no-steps: plan content has no extractable steps", () => {
204
+ attachGoalInit(() => true);
205
+ setupFsMock("# Plan\n\nProse without any numbered list.");
206
+
207
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
208
+
209
+ expect(outcome).toEqual({ started: false, reason: "no-steps" });
210
+ expect(lastSteer(pi)).toContain("Implementation Steps");
211
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("no-steps"), "warning");
212
+ });
213
+
214
+ it("init-refused: goalInit returns false (active goal exists)", () => {
215
+ attachGoalInit(() => false);
216
+
217
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
218
+
219
+ expect(outcome).toEqual({ started: false, reason: "init-refused" });
220
+ expect(lastSteer(pi)).toContain("/goal clear");
221
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("init-refused"), "warning");
222
+ });
223
+
224
+ it("internal-error: goalInit throws → outcome carries detail, notify includes it, does not propagate", () => {
225
+ attachGoalInit(() => { throw new Error("goalInit exploded"); });
226
+
227
+ const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "goal");
228
+
229
+ expect(outcome).toEqual({ started: false, reason: "internal-error", detail: "goalInit exploded" });
230
+ expect(lastSteer(pi)).toContain("Goal execution was not started (internal-error)");
231
+ expect(lastSteer(pi)).not.toContain("Execute via /goal");
232
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("goalInit exploded"), "warning");
233
+ });
124
234
  });
125
235
  });
126
236
 
@@ -5,7 +5,6 @@ import {
5
5
  DEFAULT_PLAN_STATE,
6
6
  getPlanState,
7
7
  persistPlanState,
8
- type PlanPhase,
9
8
  type PlanSessionMap,
10
9
  type PlanState,
11
10
  reconstructPlanState,
@@ -14,20 +13,14 @@ import {
14
13
  describe("PlanState", () => {
15
14
  it("DEFAULT_PLAN_STATE has correct defaults", () => {
16
15
  expect(DEFAULT_PLAN_STATE.isActive).toBe(false);
17
- expect(DEFAULT_PLAN_STATE.phase).toBe("idle");
18
16
  expect(DEFAULT_PLAN_STATE.planFilePath).toBe("");
19
17
  expect(DEFAULT_PLAN_STATE.requirement).toBe("");
20
18
  expect(DEFAULT_PLAN_STATE.templateName).toBe("");
21
19
  });
22
20
 
23
- it("PlanPhase type includes all required phases", () => {
24
- const phases: PlanPhase[] = ["idle", "brainstorming", "writing", "complete"];
25
- expect(phases).toHaveLength(4);
26
- });
27
-
28
21
  it("getPlanState returns cached state if exists", () => {
29
22
  const sessions: PlanSessionMap = new Map();
30
- const cached: PlanState = { ...DEFAULT_PLAN_STATE, isActive: true, phase: "brainstorming" };
23
+ const cached: PlanState = { ...DEFAULT_PLAN_STATE, isActive: true };
31
24
  sessions.set("session-1", cached);
32
25
 
33
26
  const mockCtx = {
@@ -54,17 +47,15 @@ describe("PlanState", () => {
54
47
 
55
48
  const result = getPlanState(sessions, "session-2", mockCtx);
56
49
  expect(result.isActive).toBe(true);
57
- expect(result.phase).toBe("writing");
58
50
  expect(sessions.get("session-2")).toBe(result);
59
51
  });
60
52
  });
61
53
 
62
54
  describe("State persistence", () => {
63
- it("persistPlanState calls appendEntry with correct data", () => {
55
+ it("persistPlanState calls appendEntry with correct data (no phase field — D6)", () => {
64
56
  const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
65
57
  const state: PlanState = {
66
58
  isActive: true,
67
- phase: "brainstorming",
68
59
  planFilePath: ".xyz-harness/test/plan.md",
69
60
  requirement: "test requirement",
70
61
  templateName: "feature-plan",
@@ -72,9 +63,9 @@ describe("State persistence", () => {
72
63
 
73
64
  persistPlanState(mockPi, state);
74
65
 
66
+ // 精确匹配:新写的 plan-state entry 无 phase 字段(V5② 守卫)
75
67
  expect(mockPi.appendEntry).toHaveBeenCalledWith("plan-state", {
76
68
  isActive: true,
77
- phase: "brainstorming",
78
69
  planFilePath: ".xyz-harness/test/plan.md",
79
70
  requirement: "test requirement",
80
71
  templateName: "feature-plan",
@@ -111,7 +102,34 @@ describe("State persistence", () => {
111
102
 
112
103
  const state = reconstructPlanState(mockCtx);
113
104
  expect(state.isActive).toBe(true);
114
- expect(state.phase).toBe("writing");
115
105
  expect(state.planFilePath).toBe(".xyz-harness/test/plan.md");
116
106
  });
107
+
108
+ it("reconstructPlanState ignores the legacy phase field in old entries (D6 兼容读 — V5②)", () => {
109
+ // 旧版(含 phase)写的 entry:重开后 plan mode 重建正常,phase 被白名单式读取自然忽略
110
+ const mockCtx = {
111
+ sessionManager: {
112
+ getEntries: () => [
113
+ {
114
+ type: "custom",
115
+ customType: "plan-state",
116
+ data: {
117
+ isActive: true,
118
+ phase: "brainstorming",
119
+ planFilePath: ".xyz-harness/legacy/plan.md",
120
+ requirement: "legacy",
121
+ templateName: "feature-plan",
122
+ },
123
+ },
124
+ ],
125
+ },
126
+ } as unknown as ExtensionContext;
127
+
128
+ const state = reconstructPlanState(mockCtx);
129
+ expect(Object.keys(state).sort()).toEqual(["isActive", "planFilePath", "requirement", "templateName"]);
130
+ expect(state.isActive).toBe(true);
131
+ expect(state.planFilePath).toBe(".xyz-harness/legacy/plan.md");
132
+ expect(state.requirement).toBe("legacy");
133
+ expect(state.templateName).toBe("feature-plan");
134
+ });
117
135
  });
@@ -1,21 +1,26 @@
1
1
  import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
2
 
5
3
  import { describe, expect, it } from "vitest";
6
4
 
5
+ import { extractPlanSteps } from "../compact.js";
7
6
  import { getBuiltinTemplateDir, listTemplates, loadTemplate } from "../templates.js";
8
7
 
9
- describe("Template system", () => {
10
- it("listTemplates returns builtin templates", () => {
8
+ const BUILTIN_TEMPLATE_NAMES = [
9
+ "feature-plan",
10
+ "bugfix-plan",
11
+ "refactor-plan",
12
+ "research-plan",
13
+ "implementation-plan",
14
+ ];
15
+
16
+ describe("Template system (builtin single source, D3)", () => {
17
+ it("listTemplates returns exactly the 5 builtin templates with no source field", () => {
11
18
  const templates = listTemplates();
12
- expect(templates.length).toBeGreaterThanOrEqual(5);
13
- const names = templates.map((t) => t.name);
14
- expect(names).toContain("feature-plan");
15
- expect(names).toContain("bugfix-plan");
16
- expect(names).toContain("refactor-plan");
17
- expect(names).toContain("research-plan");
18
- expect(names).toContain("implementation-plan");
19
+ expect(templates.map((t) => t.name).sort()).toEqual([...BUILTIN_TEMPLATE_NAMES].sort());
20
+ // 单源化后 TemplateInfo 只有 name/path —— source 等多源残留字段即红
21
+ for (const t of templates) {
22
+ expect(t).toEqual({ name: t.name, path: t.path });
23
+ }
19
24
  });
20
25
 
21
26
  it("loadTemplate returns content for existing builtin template", () => {
@@ -33,28 +38,23 @@ describe("Template system", () => {
33
38
  const dir = getBuiltinTemplateDir();
34
39
  expect(fs.existsSync(dir)).toBe(true);
35
40
  });
41
+ });
36
42
 
37
- it("TC8: PI_CODING_AGENT_DIR 隔离目录下扫到全局模板(getAgentDir 派生,不依赖 ~/.pi/agent)", () => {
38
- const origEnv = process.env.PI_CODING_AGENT_DIR;
39
- const isolated = fs.mkdtempSync(path.join(os.tmpdir(), "plan-tpl-"));
40
- try {
41
- process.env.PI_CODING_AGENT_DIR = isolated;
42
- fs.mkdirSync(path.join(isolated, "plan-templates"), { recursive: true });
43
- fs.writeFileSync(path.join(isolated, "plan-templates", "isolated-template.md"), "# t");
44
-
45
- const templates = listTemplates();
46
- const names = templates.map((t) => t.name);
47
- expect(names).toContain("isolated-template");
48
- // 隔离目录模板 source 为 global(getAgentDir 直接返回 PI_CODING_AGENT_DIR 值,无 .pi/agent 嵌套)
49
- const tpl = templates.find((t) => t.name === "isolated-template");
50
- expect(tpl?.source).toBe("global");
51
- expect(tpl?.path.startsWith(isolated)).toBe(true);
52
- // 仍能扫到 builtin 模板(最低优先级)
53
- expect(names).toContain("feature-plan");
54
- } finally {
55
- if (origEnv === undefined) delete process.env.PI_CODING_AGENT_DIR;
56
- else process.env.PI_CODING_AGENT_DIR = origEnv;
57
- fs.rmSync(isolated, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
58
- }
43
+ describe("template extractPlanSteps alignment guard (D4)", () => {
44
+ // 守卫对象:模板生成端的步骤节标题与解析端正则同仓同测,漂移即红——
45
+ // 模板标题改名 恰一节断言失败;
46
+ // ② 解析正则与标题脱节 → 提取退化到 fallback 收进其他节的噪音项 → toEqual 失败。
47
+ it.each(BUILTIN_TEMPLATE_NAMES)("template '%s' has exactly one '## Implementation Steps' section that extractPlanSteps consumes", (name) => {
48
+ const content = loadTemplate(name);
49
+ expect(content).not.toBeNull();
50
+
51
+ expect(content!.match(/^## Implementation Steps$/gm)).toHaveLength(1);
52
+
53
+ const plan = content!
54
+ // 在第一个非步骤节标题后放噪音编号项(模拟 Requirements 等节含编号列表)
55
+ .replace(/^(## (?!Implementation Steps).+)$/m, "$1\n1. noise-from-other-section")
56
+ // 在步骤节标题后填编号步骤(模拟 AI 按模板写 plan.md)
57
+ .replace(/^## Implementation Steps$/m, "## Implementation Steps\n1. Real step A\n2. Real step B");
58
+ expect(extractPlanSteps(plan)).toEqual(["Real step A", "Real step B"]);
59
59
  });
60
60
  });