@zhushanwen/pi-plan 0.3.11 → 0.3.13
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.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"description": "Lightweight plan mode for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"@earendil-works/pi-coding-agent": ">=0.73.0",
|
|
22
22
|
"typebox": "*",
|
|
23
23
|
"@earendil-works/pi-ai": "^0.84.1",
|
|
24
|
-
"@zhushanwen/pi-goal": "0.
|
|
24
|
+
"@zhushanwen/pi-goal": "0.10.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependenciesMeta": {
|
|
27
27
|
"@earendil-works/pi-ai": {
|
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
"src/",
|
|
38
38
|
"templates/"
|
|
39
39
|
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@zhushanwen/pi-extension-logger": "0.3.0"
|
|
42
|
+
},
|
|
40
43
|
"scripts": {
|
|
41
44
|
"typecheck": "npx tsc --noEmit",
|
|
42
45
|
"test": "vitest run"
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan/compact.ts — buildPlanSuccessCriteria 数组形态测试
|
|
3
|
+
*
|
|
4
|
+
* 形态契约(U25):1 条总述 `All N steps of <basename> executed and verified`
|
|
5
|
+
* + 前 3 条 step preview(编号前缀、单条截断 ≤80 chars),合计 ≤4 条
|
|
6
|
+
* (goal schema maxItems:8),每条单行不含 \r\n(goal handler 拒含换行条目)。
|
|
7
|
+
*/
|
|
8
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
vi.mock("node:fs", () => ({
|
|
11
|
+
readFileSync: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { buildPlanSuccessCriteria, handlePlanComplete } from "../compact.js";
|
|
15
|
+
|
|
16
|
+
const fsMock = vi.mocked(await import("node:fs"));
|
|
17
|
+
|
|
18
|
+
function makePi() {
|
|
19
|
+
return {
|
|
20
|
+
on: vi.fn(),
|
|
21
|
+
appendEntry: vi.fn(),
|
|
22
|
+
sendUserMessage: vi.fn(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeCtx() {
|
|
27
|
+
const onCompleteFns: Array<() => void> = [];
|
|
28
|
+
const onErrorFns: Array<(e: Error) => void> = [];
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
sessionManager: { getSessionId: () => "test-session", getEntries: () => [] as unknown[] },
|
|
32
|
+
ui: { notify: vi.fn() },
|
|
33
|
+
compact: vi.fn((opts: { onComplete?: () => void; onError?: (e: Error) => void }) => {
|
|
34
|
+
if (opts.onComplete) onCompleteFns.push(opts.onComplete);
|
|
35
|
+
if (opts.onError) onErrorFns.push(opts.onError);
|
|
36
|
+
}),
|
|
37
|
+
_onCompleteFns: onCompleteFns,
|
|
38
|
+
_onErrorFns: onErrorFns,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeActiveState() {
|
|
43
|
+
return {
|
|
44
|
+
isActive: true,
|
|
45
|
+
phase: "complete" as const,
|
|
46
|
+
planFilePath: "/tmp/plan.md",
|
|
47
|
+
requirement: "Add login page",
|
|
48
|
+
templateName: "default",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makePlanContent(steps: string[]): string {
|
|
53
|
+
return `## 实现步骤\n${steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 验收共性断言:string[] 且每条单行不含 \r\n */
|
|
57
|
+
function expectSingleLineArray(value: unknown): string[] {
|
|
58
|
+
expect(Array.isArray(value)).toBe(true);
|
|
59
|
+
const items = value as string[];
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
expect(item).not.toMatch(/[\r\n]/);
|
|
62
|
+
}
|
|
63
|
+
return items;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- buildPlanSuccessCriteria 单元 ---
|
|
67
|
+
|
|
68
|
+
describe("buildPlanSuccessCriteria — 1 总述 + 前 3 条 preview", () => {
|
|
69
|
+
it("5 步 plan → 4 条:总述 + 前 3 条 preview", () => {
|
|
70
|
+
const steps = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"];
|
|
71
|
+
const items = expectSingleLineArray(buildPlanSuccessCriteria("/tmp/plan.md", steps));
|
|
72
|
+
|
|
73
|
+
expect(items).toHaveLength(4);
|
|
74
|
+
expect(items[0]).toBe("All 5 steps of plan executed and verified");
|
|
75
|
+
expect(items.slice(1)).toEqual(["1. Alpha", "2. Bravo", "3. Charlie"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("1 步 plan → 2 条", () => {
|
|
79
|
+
const items = expectSingleLineArray(buildPlanSuccessCriteria("/tmp/plan.md", ["Only step"]));
|
|
80
|
+
|
|
81
|
+
expect(items).toHaveLength(2);
|
|
82
|
+
expect(items[0]).toBe("All 1 steps of plan executed and verified");
|
|
83
|
+
expect(items[1]).toBe("1. Only step");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("0 步 → 仅总述 1 条", () => {
|
|
87
|
+
const items = expectSingleLineArray(buildPlanSuccessCriteria("/tmp/plan.md", []));
|
|
88
|
+
|
|
89
|
+
expect(items).toEqual(["All 0 steps of plan executed and verified"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("12 步 → 仍 4 条,总述含实际总数", () => {
|
|
93
|
+
const steps = Array.from({ length: 12 }, (_, i) => `S${i + 1}`);
|
|
94
|
+
const items = expectSingleLineArray(buildPlanSuccessCriteria("/tmp/plan.md", steps));
|
|
95
|
+
|
|
96
|
+
expect(items).toHaveLength(4);
|
|
97
|
+
expect(items[0]).toBe("All 12 steps of plan executed and verified");
|
|
98
|
+
expect(items.slice(1)).toEqual(["1. S1", "2. S2", "3. S3"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("basename 为 plan 文件名去扩展名(含大写 .MD)", () => {
|
|
102
|
+
const items = buildPlanSuccessCriteria("/work/feat-login-plan.MD", ["S1"]);
|
|
103
|
+
expect(items[0]).toBe("All 1 steps of feat-login-plan executed and verified");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("超长 step → 截断至 ≤80 chars 且以 ... 结尾,保留编号前缀", () => {
|
|
107
|
+
const long = "x".repeat(120);
|
|
108
|
+
const items = expectSingleLineArray(buildPlanSuccessCriteria("/tmp/plan.md", [long]));
|
|
109
|
+
|
|
110
|
+
expect(items[1]).toHaveLength(80);
|
|
111
|
+
expect(items[1].endsWith("...")).toBe(true);
|
|
112
|
+
expect(items[1].startsWith("1. xxx")).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("恰好 80 chars 的条目 → 不截断、不加省略号", () => {
|
|
116
|
+
const exact = "y".repeat(77); // "1. " 前缀 + 77 = 80
|
|
117
|
+
const items = buildPlanSuccessCriteria("/tmp/plan.md", [exact]);
|
|
118
|
+
|
|
119
|
+
expect(items[1]).toBe(`1. ${exact}`);
|
|
120
|
+
expect(items[1]).toHaveLength(80);
|
|
121
|
+
expect(items[1].endsWith("...")).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("step 文本含换行符 → 折叠为单行空格分隔(goal handler 拒 \r\n)", () => {
|
|
125
|
+
const items = expectSingleLineArray(buildPlanSuccessCriteria("/tmp/plan.md", ["line1\nline2\r\nline3"]));
|
|
126
|
+
|
|
127
|
+
expect(items[1]).toBe("1. line1 line2 line3");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// --- handlePlanComplete → tryGoalInit 端到端 ---
|
|
132
|
+
|
|
133
|
+
describe("handlePlanComplete — __goalInit 第 5 参数为新形态 string[]", () => {
|
|
134
|
+
let pi: ReturnType<typeof makePi>;
|
|
135
|
+
let ctx: ReturnType<typeof makeCtx>;
|
|
136
|
+
|
|
137
|
+
beforeEach(() => {
|
|
138
|
+
vi.clearAllMocks();
|
|
139
|
+
pi = makePi();
|
|
140
|
+
ctx = makeCtx();
|
|
141
|
+
(pi as unknown as Record<string, unknown>).__goalInit = vi.fn().mockReturnValue(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
function getCriteriaArg(): string[] {
|
|
145
|
+
const goalInitMock = (pi as unknown as Record<string, unknown>).__goalInit as ReturnType<typeof vi.fn>;
|
|
146
|
+
expect(goalInitMock).toHaveBeenCalled();
|
|
147
|
+
return expectSingleLineArray(goalInitMock.mock.calls[0][4]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
it("direct isolation: 3 步 plan → 总述 + 3 条 preview", () => {
|
|
151
|
+
fsMock.readFileSync.mockReturnValue(makePlanContent(["Step A", "Step B", "Step C"]));
|
|
152
|
+
|
|
153
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct");
|
|
154
|
+
|
|
155
|
+
expect(getCriteriaArg()).toEqual([
|
|
156
|
+
"All 3 steps of plan executed and verified",
|
|
157
|
+
"1. Step A",
|
|
158
|
+
"2. Step B",
|
|
159
|
+
"3. Step C",
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("compact isolation: onComplete 后 __goalInit 收到同形态数组", () => {
|
|
164
|
+
fsMock.readFileSync.mockReturnValue(makePlanContent(["Step one", "Step two"]));
|
|
165
|
+
|
|
166
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact");
|
|
167
|
+
ctx._onCompleteFns[0]();
|
|
168
|
+
|
|
169
|
+
expect(getCriteriaArg()).toEqual([
|
|
170
|
+
"All 2 steps of plan executed and verified",
|
|
171
|
+
"1. Step one",
|
|
172
|
+
"2. Step two",
|
|
173
|
+
]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("12 步 plan → 数组长度固定 4(1 总述 + 3 preview),不再按 8 条上限截断", () => {
|
|
177
|
+
const steps = Array.from({ length: 12 }, (_, i) => `Step ${i + 1}`);
|
|
178
|
+
fsMock.readFileSync.mockReturnValue(makePlanContent(steps));
|
|
179
|
+
|
|
180
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct");
|
|
181
|
+
|
|
182
|
+
const items = getCriteriaArg();
|
|
183
|
+
expect(items).toHaveLength(4);
|
|
184
|
+
expect(items[0]).toContain("12 steps");
|
|
185
|
+
expect(items[0]).toContain("plan");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("CRLF plan 文件 → 每条 criteria 仍单行不含 \\r \\n", () => {
|
|
189
|
+
fsMock.readFileSync.mockReturnValue("## 实现步骤\r\n1. Step one\r\n2. Step two\r\n3. Step three");
|
|
190
|
+
|
|
191
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct");
|
|
192
|
+
|
|
193
|
+
expect(getCriteriaArg()).toEqual([
|
|
194
|
+
"All 3 steps of plan executed and verified",
|
|
195
|
+
"1. Step one",
|
|
196
|
+
"2. Step two",
|
|
197
|
+
"3. Step three",
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("0 步 plan → tryGoalInit 提前退出,__goalInit 不被调用", () => {
|
|
202
|
+
(fsMock.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue("## Overview\nNo numbered steps here.");
|
|
203
|
+
|
|
204
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct");
|
|
205
|
+
|
|
206
|
+
const goalInitMock = (pi as unknown as Record<string, unknown>).__goalInit as ReturnType<typeof vi.fn>;
|
|
207
|
+
expect(goalInitMock).not.toHaveBeenCalled();
|
|
208
|
+
// steer 仍发出(执行流程不因 goal 缺席中断)
|
|
209
|
+
expect(pi.sendUserMessage).toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -79,7 +79,11 @@ describe("handlePlanComplete", () => {
|
|
|
79
79
|
undefined,
|
|
80
80
|
ctx,
|
|
81
81
|
"plan",
|
|
82
|
-
|
|
82
|
+
[
|
|
83
|
+
"All 2 steps of plan executed and verified",
|
|
84
|
+
"1. Step one",
|
|
85
|
+
"2. Step two",
|
|
86
|
+
],
|
|
83
87
|
);
|
|
84
88
|
});
|
|
85
89
|
|
|
@@ -111,7 +115,11 @@ describe("handlePlanComplete", () => {
|
|
|
111
115
|
undefined,
|
|
112
116
|
ctx,
|
|
113
117
|
"plan",
|
|
114
|
-
|
|
118
|
+
[
|
|
119
|
+
"All 2 steps of plan executed and verified",
|
|
120
|
+
"1. Step one",
|
|
121
|
+
"2. Step two",
|
|
122
|
+
],
|
|
115
123
|
);
|
|
116
124
|
});
|
|
117
125
|
});
|
package/src/compact.ts
CHANGED
|
@@ -90,17 +90,38 @@ function buildPlanSlug(planFilePath: string): string {
|
|
|
90
90
|
return stem || "plan-execution";
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
const
|
|
93
|
+
/** step preview 条数上限(1 条总述 + 3 条 preview,合计 ≤4 条,满足 goal schema maxItems:8) */
|
|
94
|
+
const PREVIEW_COUNT = 3;
|
|
95
|
+
/** 单条 preview 最大长度(超出部分截断,以 "..." 结尾) */
|
|
96
|
+
const PREVIEW_MAX_CHARS = 80;
|
|
97
|
+
const ELLIPSIS = "...";
|
|
98
|
+
|
|
99
|
+
/** 折叠换行为空格:goal 侧 handler 拒绝含 \r\n 的条目 */
|
|
100
|
+
function toSingleLine(text: string): string {
|
|
101
|
+
return text.replace(/[\r\n]+/g, " ").trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function truncatePreview(text: string): string {
|
|
105
|
+
if (text.length <= PREVIEW_MAX_CHARS) return text;
|
|
106
|
+
return text.slice(0, PREVIEW_MAX_CHARS - ELLIPSIS.length) + ELLIPSIS;
|
|
107
|
+
}
|
|
95
108
|
|
|
96
109
|
/**
|
|
97
110
|
* 从 plan 步骤构造可检查的 successCriteria(plan 完成 = 所有步骤执行并验证)。
|
|
98
|
-
* goal 的 complete
|
|
111
|
+
* goal 的 complete 判定会对照本字段逐条做证据审计。
|
|
112
|
+
*
|
|
113
|
+
* 形态固定:1 条总述 `All N steps of <basename> executed and verified`
|
|
114
|
+
* + 前 PREVIEW_COUNT 条 step preview(编号前缀、单条截断 ≤PREVIEW_MAX_CHARS),
|
|
115
|
+
* 合计 ≤4 条(goal schema maxItems:8),每条单行不含 \r\n。
|
|
99
116
|
*/
|
|
100
|
-
function buildPlanSuccessCriteria(planFilePath: string, tasks: string[]): string {
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
117
|
+
export function buildPlanSuccessCriteria(planFilePath: string, tasks: string[]): string[] {
|
|
118
|
+
const planName = toSingleLine(basename(planFilePath).replace(/\.md$/i, ""));
|
|
119
|
+
const items = [`All ${tasks.length} steps of ${planName} executed and verified`];
|
|
120
|
+
const previews = tasks
|
|
121
|
+
.slice(0, PREVIEW_COUNT)
|
|
122
|
+
.map((step, i) => truncatePreview(toSingleLine(`${i + 1}. ${step}`)));
|
|
123
|
+
items.push(...previews);
|
|
124
|
+
return items;
|
|
104
125
|
}
|
|
105
126
|
|
|
106
127
|
/** Try to initialize goal via programming interface */
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
2
3
|
|
|
3
4
|
import { registerPlanCommand, startPlanMode } from "./command.js";
|
|
4
5
|
import { type PlanSessionMap, reconstructPlanState } from "./state.js";
|
|
5
6
|
import { registerPlanTool } from "./tool.js";
|
|
6
7
|
import { updatePlanWidget } from "./widget.js";
|
|
7
8
|
|
|
9
|
+
const logger = getLogger("pi-plan");
|
|
10
|
+
|
|
8
11
|
export default function planExtension(pi: ExtensionAPI) {
|
|
9
12
|
// Per-session state cache — keyed by sessionId
|
|
10
13
|
const sessions: PlanSessionMap = new Map();
|
|
@@ -24,7 +27,7 @@ export default function planExtension(pi: ExtensionAPI) {
|
|
|
24
27
|
import("./compact.js").then(({ registerPlanEventHandlers }) => {
|
|
25
28
|
registerPlanEventHandlers(pi, sessions);
|
|
26
29
|
}).catch((_e: unknown) => {
|
|
27
|
-
|
|
30
|
+
logger.warn('compact handlers load failed', { error: String(_e) });
|
|
28
31
|
});
|
|
29
32
|
|
|
30
33
|
// Reconstruct state on session start
|