@zhushanwen/pi-plan 0.4.5 → 0.4.7

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.md CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  轻量级规划模式 pi extension:`/plan [描述]` 触发,产出结构化 plan 文件。与 coding-workflow 的区别:无 gate / review / retrospect,只负责「想清楚再动手」的计划产出(ADR pi-ext-021 prompt-only readonly / pi-ext-022 session-manager state)。
4
4
 
5
- > 本 README 的核心概念词条(Plan Mode / Plan File / Brainstorming)迁自 `docs/extensions/glossary.md`「Extension 专属概念」(2026-09-13)。
6
-
7
5
  ## 用法
8
6
 
9
7
  ```
@@ -26,7 +24,7 @@
26
24
 
27
25
  ## Plan File
28
26
 
29
- Plan Mode 的产出物,存储在 `.xyz-harness/{slug}/plan.md`(slug 截断 30 字符)。含 YAML frontmatter 与模板章节。进入 plan mode 时自动扫描既有 plan 文件供续写。
27
+ Plan Mode 的产出物,存储在 `.taiji-harness/{slug}/plan.md`(slug 截断 30 字符)。含 YAML frontmatter 与模板章节。进入 plan mode 时自动扫描既有 plan 文件供续写。
30
28
 
31
29
  ## 模板
32
30
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-plan",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Lightweight plan mode for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
- "xyz-agent": {
7
+ "taiji": {
8
8
  "role": "universal"
9
9
  },
10
10
  "pi": {
@@ -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.3"
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
+ "@taiji/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": {
@@ -122,23 +122,25 @@ describe("registerPlanCommand", () => {
122
122
  await handler("Implement User Auth", ctx);
123
123
 
124
124
  expect(fs.mkdirSync).toHaveBeenCalledWith(
125
- "/tmp/test-project/.xyz-harness/implement-user-auth",
125
+ "/tmp/test-project/.taiji-harness/implement-user-auth",
126
126
  { recursive: true },
127
127
  );
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/.taiji-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 () => {
138
140
  await handler("Fix bug #123: 中文标题!", ctx);
139
141
 
140
142
  expect(fs.mkdirSync).toHaveBeenCalledWith(
141
- expect.stringContaining("/.xyz-harness/fix-bug-123"),
143
+ expect.stringContaining("/.taiji-harness/fix-bug-123"),
142
144
  { recursive: true },
143
145
  );
144
146
  });
@@ -148,7 +150,7 @@ describe("registerPlanCommand", () => {
148
150
  await handler("", ctx);
149
151
 
150
152
  expect(fs.mkdirSync).toHaveBeenCalledWith(
151
- "/tmp/test-project/.xyz-harness/untitled",
153
+ "/tmp/test-project/.taiji-harness/untitled",
152
154
  { recursive: true },
153
155
  );
154
156
  });
@@ -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 = {
@@ -46,7 +39,7 @@ describe("PlanState", () => {
46
39
  {
47
40
  type: "custom",
48
41
  customType: "plan-state",
49
- data: { isActive: true, phase: "writing", planFilePath: ".xyz-harness/test/plan.md", requirement: "test", templateName: "feature-plan" },
42
+ data: { isActive: true, phase: "writing", planFilePath: ".taiji-harness/test/plan.md", requirement: "test", templateName: "feature-plan" },
50
43
  },
51
44
  ],
52
45
  },
@@ -54,28 +47,26 @@ 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
- planFilePath: ".xyz-harness/test/plan.md",
59
+ planFilePath: ".taiji-harness/test/plan.md",
69
60
  requirement: "test requirement",
70
61
  templateName: "feature-plan",
71
62
  };
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
- planFilePath: ".xyz-harness/test/plan.md",
69
+ planFilePath: ".taiji-harness/test/plan.md",
79
70
  requirement: "test requirement",
80
71
  templateName: "feature-plan",
81
72
  });
@@ -100,7 +91,7 @@ describe("State persistence", () => {
100
91
  data: {
101
92
  isActive: true,
102
93
  phase: "writing",
103
- planFilePath: ".xyz-harness/test/plan.md",
94
+ planFilePath: ".taiji-harness/test/plan.md",
104
95
  requirement: "test",
105
96
  templateName: "feature-plan",
106
97
  },
@@ -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
- expect(state.planFilePath).toBe(".xyz-harness/test/plan.md");
105
+ expect(state.planFilePath).toBe(".taiji-harness/test/plan.md");
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: ".taiji-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(".taiji-harness/legacy/plan.md");
132
+ expect(state.requirement).toBe("legacy");
133
+ expect(state.templateName).toBe("feature-plan");
116
134
  });
117
135
  });