@zhushanwen/pi-plan 0.4.4 → 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/README.md +37 -0
- package/package.json +7 -3
- package/src/__tests__/command.test.ts +11 -4
- 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 +31 -13
- package/src/__tests__/templates.test.ts +33 -33
- package/src/__tests__/tool.test.ts +126 -38
- package/src/command.ts +2 -3
- 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
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @zhushanwen/pi-plan
|
|
2
|
+
|
|
3
|
+
轻量级规划模式 pi extension:`/plan [描述]` 触发,产出结构化 plan 文件。与 coding-workflow 的区别:无 gate / review / retrospect,只负责「想清楚再动手」的计划产出(ADR pi-ext-021 prompt-only readonly / pi-ext-022 session-manager state)。
|
|
4
|
+
|
|
5
|
+
> 本 README 的核心概念词条(Plan Mode / Plan File / Brainstorming)迁自 `docs/extensions/glossary.md`「Extension 专属概念」(2026-09-13)。
|
|
6
|
+
|
|
7
|
+
## 用法
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
/plan 修复项目中所有失败的测试 # 进入 plan mode(brainstorming 阶段开始)
|
|
11
|
+
/plan status # 查看当前阶段与 plan 文件路径
|
|
12
|
+
/plan abort # 取消活跃的 plan mode
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 阶段状态机
|
|
16
|
+
|
|
17
|
+
`idle → brainstorming → writing → complete`(`state.ts` PlanPhase):
|
|
18
|
+
|
|
19
|
+
- **brainstorming**:需求探索。固定四步——1. Quick Overview(ls 项目根 / 读 README / package.json,30 秒内建立上下文)→ 2. 渐进式提问澄清需求 → 3. 方案探索 → 4. 假设审计。
|
|
20
|
+
- **writing**:选定模板后撰写 plan 文件。
|
|
21
|
+
- **complete**:完成时提示选择执行方式,恢复工具,状态重置。
|
|
22
|
+
|
|
23
|
+
## plan 工具
|
|
24
|
+
|
|
25
|
+
模型经 `plan` tool 驱动阶段流转,actions:`list-template` / `select-template` / `create-template` / `complete` / `complete-cancelled` / `abort`。
|
|
26
|
+
|
|
27
|
+
## Plan File
|
|
28
|
+
|
|
29
|
+
Plan Mode 的产出物,存储在 `.xyz-harness/{slug}/plan.md`(slug 截断 30 字符)。含 YAML frontmatter 与模板章节。进入 plan mode 时自动扫描既有 plan 文件供续写。
|
|
30
|
+
|
|
31
|
+
## 模板
|
|
32
|
+
|
|
33
|
+
内置 5 个(`templates/`):`feature-plan` / `bugfix-plan` / `refactor-plan` / `implementation-plan` / `research-plan`。支持项目自定义模板(`listTemplates` / `loadTemplate` 按 projectDir 发现)。
|
|
34
|
+
|
|
35
|
+
## 依赖
|
|
36
|
+
|
|
37
|
+
peer 依赖 `@zhushanwen/pi-goal`(plan 完成后衔接 goal 驱动执行)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-plan",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
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
|
-
"@
|
|
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",
|
|
131
|
+
expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", {
|
|
132
132
|
isActive: true,
|
|
133
|
-
|
|
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("
|
|
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 = {
|
|
@@ -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
|
-
|
|
10
|
-
|
|
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.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
});
|