@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 +1 -3
- package/package.json +8 -4
- package/src/__tests__/command.test.ts +14 -7
- package/src/__tests__/compact-criteria-array.test.ts +22 -13
- package/src/__tests__/compact-handler.test.ts +160 -50
- package/src/__tests__/state.test.ts +36 -18
- package/src/__tests__/templates.test.ts +33 -33
- package/src/__tests__/tool.test.ts +126 -38
- package/src/command.ts +6 -7
- package/src/compact.ts +124 -51
- package/src/index.ts +3 -9
- package/src/state.ts +1 -7
- package/src/templates.ts +6 -28
- package/src/tool.ts +69 -99
- package/templates/bugfix-plan.md +1 -1
- package/templates/implementation-plan.md +3 -5
- package/templates/refactor-plan.md +1 -1
- package/templates/research-plan.md +1 -1
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 的产出物,存储在 `.
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
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
|
-
"@
|
|
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/.
|
|
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",
|
|
131
|
+
expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", {
|
|
132
132
|
isActive: true,
|
|
133
|
-
|
|
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("/.
|
|
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/.
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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 后
|
|
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 提前退出,
|
|
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
|
-
|
|
70
|
-
(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"
|
|
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
|
-
|
|
91
|
-
|
|
146
|
+
describe("direct isolation", () => {
|
|
147
|
+
it("goal mode success: returns outcome synchronously, goal steer, no notify", () => {
|
|
148
|
+
attachGoalInit(() => true);
|
|
92
149
|
|
|
93
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
expect.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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: ".
|
|
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
|
-
|
|
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
|
-
|
|
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: ".
|
|
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.
|
|
115
|
-
|
|
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
|
});
|