@zhushanwen/pi-plan 0.5.2 → 1.0.0
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 +9 -9
- package/package.json +6 -4
- package/skills/plan-ext-config/SKILL.md +33 -18
- package/src/__tests__/active-path.test.ts +204 -0
- package/src/__tests__/command.test.ts +55 -32
- package/src/__tests__/enter.test.ts +58 -20
- package/src/__tests__/exec-options-alignment.test.ts +56 -0
- package/src/__tests__/execution-notice.test.ts +197 -0
- package/src/__tests__/{compact.test.ts → extract-plan-steps.test.ts} +24 -15
- package/src/__tests__/fsm-consumption.test.ts +388 -0
- package/src/__tests__/index.test.ts +175 -75
- package/src/__tests__/prompts.test.ts +66 -0
- package/src/__tests__/review-contract-boundary.test.ts +123 -0
- package/src/__tests__/review.test.ts +264 -97
- package/src/__tests__/skills.test.ts +14 -5
- package/src/__tests__/state.test.ts +254 -52
- package/src/__tests__/{compact-criteria-array.test.ts → success-criteria-array.test.ts} +29 -70
- package/src/__tests__/templates.test.ts +8 -25
- package/src/__tests__/tool.test.ts +269 -121
- package/src/command.ts +26 -29
- package/src/enter.ts +31 -50
- package/src/{compact.ts → execution-notice.ts} +29 -117
- package/src/index.ts +100 -88
- package/src/prompts.ts +42 -12
- package/src/state.ts +290 -87
- package/src/templates.ts +0 -15
- package/src/tool.ts +663 -255
- package/src/__tests__/compact-handler.test.ts +0 -348
- package/src/__tests__/exec-skills.test.ts +0 -397
- package/src/exec-skills.ts +0 -407
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import type { PlanState } from "../state.js";
|
|
4
|
-
import { PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
|
|
5
|
-
|
|
6
|
-
// Mock fs before importing compact.ts (ESM namespace is not configurable)
|
|
7
|
-
vi.mock("node:fs", () => ({
|
|
8
|
-
readFileSync: vi.fn(),
|
|
9
|
-
}));
|
|
10
|
-
|
|
11
|
-
// Import after mock setup
|
|
12
|
-
import { handlePlanComplete, registerPlanEventHandlers } from "../compact.js";
|
|
13
|
-
|
|
14
|
-
const fsMock = vi.mocked(await import("node:fs"));
|
|
15
|
-
|
|
16
|
-
// --- Shared mock factories ---
|
|
17
|
-
|
|
18
|
-
function makePi() {
|
|
19
|
-
return {
|
|
20
|
-
on: vi.fn(),
|
|
21
|
-
appendEntry: vi.fn(),
|
|
22
|
-
sendMessage: vi.fn(),
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
type CtxMock = ReturnType<typeof makeCtx>;
|
|
27
|
-
function makeCtx() {
|
|
28
|
-
const onCompleteFns: Array<() => void> = [];
|
|
29
|
-
const onErrorFns: Array<(e: Error) => void> = [];
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
sessionManager: { getSessionId: () => "test-session", getEntries: () => [] as unknown[] },
|
|
33
|
-
ui: { notify: vi.fn() },
|
|
34
|
-
compact: vi.fn((opts: { onComplete?: () => void; onError?: (e: Error) => void }) => {
|
|
35
|
-
if (opts.onComplete) onCompleteFns.push(opts.onComplete);
|
|
36
|
-
if (opts.onError) onErrorFns.push(opts.onError);
|
|
37
|
-
}),
|
|
38
|
-
_onCompleteFns: onCompleteFns,
|
|
39
|
-
_onErrorFns: onErrorFns,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function makeActiveState(): PlanState {
|
|
44
|
-
return {
|
|
45
|
-
isActive: true,
|
|
46
|
-
planFilePath: "/tmp/plan.md",
|
|
47
|
-
requirement: "Add login page",
|
|
48
|
-
templateName: "default",
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function setupFsMock(content: string) {
|
|
53
|
-
fsMock.readFileSync.mockReturnValue(content);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* goal 桥 slot key——与 compact.ts / goal 侧 index.ts 的字符串一致(本地声明,
|
|
58
|
-
* 不 import 对方包:pi-goal 是 optional peer,两侧靠同一字符串共享 slot)。
|
|
59
|
-
*/
|
|
60
|
-
const GOAL_INIT_SLOT_KEY = Symbol.for("@zhushanwen/pi-goal.goalInit");
|
|
61
|
-
|
|
62
|
-
/** 在 globalThis slot 上挂 goal 桥——mock 形态与真实通道同构(goal-bridge-cross-extension.md §3.4:goal 侧 Reflect.set 挂 slot,本测试同款挂载)。 */
|
|
63
|
-
function attachGoalInit(impl: () => boolean) {
|
|
64
|
-
const fn = vi.fn(impl);
|
|
65
|
-
Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, fn);
|
|
66
|
-
return fn;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** 清 slot 防跨用例 globalThis 泄漏(设计 §3.4:teardown 两侧通用)。 */
|
|
70
|
-
afterEach(() => {
|
|
71
|
-
Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, undefined);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
/** 最近一次 sendMessage 的正文(P8 custom message 的 content 字段)。 */
|
|
75
|
-
function lastSteer(pi: ReturnType<typeof makePi>): string {
|
|
76
|
-
const calls = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
|
77
|
-
const last = calls[calls.length - 1]?.[0] as { content?: string } | undefined;
|
|
78
|
-
return String(last?.content ?? "");
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** P8 执行通知断言形态:custom message 三要素 + streaming steer options(A6)。 */
|
|
82
|
-
function executionNotice(content: unknown) {
|
|
83
|
-
return [
|
|
84
|
-
{ customType: PLAN_CONTEXT_CUSTOM_TYPE, content, display: false },
|
|
85
|
-
{ deliverAs: "steer", triggerTurn: true },
|
|
86
|
-
];
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// --- handlePlanComplete tests ---
|
|
90
|
-
|
|
91
|
-
describe("handlePlanComplete", () => {
|
|
92
|
-
let pi: ReturnType<typeof makePi>;
|
|
93
|
-
let ctx: CtxMock;
|
|
94
|
-
|
|
95
|
-
beforeEach(() => {
|
|
96
|
-
vi.clearAllMocks();
|
|
97
|
-
pi = makePi();
|
|
98
|
-
ctx = makeCtx();
|
|
99
|
-
setupFsMock("## 实现步骤\n1. Step one\n2. Step two");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
describe("compact isolation", () => {
|
|
103
|
-
it("execute mode: goalInit deferred to onComplete, then goal steer on success (D2 时序——goal entry 在压缩后世界创建)", () => {
|
|
104
|
-
const goalInit = attachGoalInit(() => true);
|
|
105
|
-
|
|
106
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "execute");
|
|
107
|
-
|
|
108
|
-
expect(outcome).toBeUndefined(); // compact 档 outcome 在回调内产生,不进同步返回值
|
|
109
|
-
expect(ctx.compact).toHaveBeenCalledOnce();
|
|
110
|
-
expect(goalInit).not.toHaveBeenCalled(); // 压缩完成前不创建 goal entry
|
|
111
|
-
|
|
112
|
-
ctx._onCompleteFns[0]();
|
|
113
|
-
|
|
114
|
-
expect(goalInit).toHaveBeenCalledWith(
|
|
115
|
-
"Execute plan: /tmp/plan.md",
|
|
116
|
-
undefined,
|
|
117
|
-
ctx,
|
|
118
|
-
"plan",
|
|
119
|
-
[
|
|
120
|
-
"All 2 steps of plan executed and verified",
|
|
121
|
-
"1. Step one",
|
|
122
|
-
"2. Step two",
|
|
123
|
-
],
|
|
124
|
-
);
|
|
125
|
-
expect(pi.sendMessage).toHaveBeenCalledWith(...executionNotice(expect.stringContaining("Goal tracking is active via /goal")));
|
|
126
|
-
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("execute mode (compact, bridge unmounted): onComplete sends degraded direct-execution steer, no goalInit", () => {
|
|
130
|
-
// execute 档整合 goal 桥:桥未挂载(goal-unavailable)→ 降级为直接执行指引
|
|
131
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "execute");
|
|
132
|
-
ctx._onCompleteFns[0]();
|
|
133
|
-
|
|
134
|
-
const steer = lastSteer(pi);
|
|
135
|
-
expect(steer).toContain("Goal tracking was not started (goal-unavailable)");
|
|
136
|
-
expect(steer).toContain("subagents"); // 降级后仍指到 auto-parallel 直接执行
|
|
137
|
-
expect(steer).toContain("current session");
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("skill mode (compact): onComplete steer carries the skill entry path (D10 skillEntryPath 通路)", () => {
|
|
141
|
-
const goalInit = attachGoalInit(() => true);
|
|
142
|
-
const skillEntryPath = "/tmp/fixtures/skills/dev-flow/SKILL.md";
|
|
143
|
-
|
|
144
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "skill:dev-flow", skillEntryPath);
|
|
145
|
-
ctx._onCompleteFns[0]();
|
|
146
|
-
|
|
147
|
-
expect(goalInit).not.toHaveBeenCalled();
|
|
148
|
-
const steer = lastSteer(pi);
|
|
149
|
-
expect(steer).toContain("skill:dev-flow"); // Execution mode 行透传 execMode
|
|
150
|
-
expect(steer).toContain("dev-flow"); // 指引按名加载
|
|
151
|
-
expect(steer).toContain("/tmp/fixtures/skills/dev-flow/SKILL.md"); // 路径读取指引(skillEntryPath = 入口文件路径,直接透传)
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("onError (execute mode, init-refused): compact-failure notify + degraded steer + warning notify, no /goal promise", () => {
|
|
155
|
-
attachGoalInit(() => false); // goalInit 返回 false:已有 active goal
|
|
156
|
-
|
|
157
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact", "execute");
|
|
158
|
-
ctx._onErrorFns[0](new Error("compact failed"));
|
|
159
|
-
|
|
160
|
-
const notifyTexts = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.map((c) => String(c[0]));
|
|
161
|
-
expect(notifyTexts.some((t) => t.includes("Compact failed"))).toBe(true);
|
|
162
|
-
expect(notifyTexts.some((t) => t.includes("init-refused"))).toBe(true);
|
|
163
|
-
|
|
164
|
-
const steer = lastSteer(pi);
|
|
165
|
-
expect(steer).toContain("Goal tracking was not started (init-refused)");
|
|
166
|
-
expect(steer).toContain("/goal clear"); // 恢复动作
|
|
167
|
-
expect(steer).not.toContain("Goal tracking is active via /goal"); // 承诺不再无凭据发出
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
describe("direct isolation", () => {
|
|
172
|
-
it("execute mode success: returns outcome synchronously, goal steer, no notify", () => {
|
|
173
|
-
attachGoalInit(() => true);
|
|
174
|
-
|
|
175
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
176
|
-
|
|
177
|
-
expect(outcome).toEqual({ started: true });
|
|
178
|
-
expect(pi.sendMessage).toHaveBeenCalledWith(...executionNotice(expect.stringContaining("Goal tracking is active via /goal")));
|
|
179
|
-
expect(ctx.compact).not.toHaveBeenCalled();
|
|
180
|
-
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("skill mode (direct): returns undefined, steer with skill entry path (steer 文案含 skillEntryPath,D10)", () => {
|
|
184
|
-
attachGoalInit(() => true);
|
|
185
|
-
const skillEntryPath = "/tmp/fixtures/skills/dev-flow/SKILL.md";
|
|
186
|
-
|
|
187
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "skill:dev-flow", skillEntryPath);
|
|
188
|
-
|
|
189
|
-
expect(outcome).toBeUndefined();
|
|
190
|
-
const steer = lastSteer(pi);
|
|
191
|
-
expect(steer).toContain("Execution mode: skill:dev-flow");
|
|
192
|
-
expect(steer).toContain("/tmp/fixtures/skills/dev-flow/SKILL.md");
|
|
193
|
-
expect(steer).toContain("follow its workflow");
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("skill mode (散 .md 形态): steer carries the file path itself, not a dangling joined SKILL.md", () => {
|
|
197
|
-
attachGoalInit(() => true);
|
|
198
|
-
const looseEntryPath = "/tmp/fixtures/skills/loose-tool.md";
|
|
199
|
-
|
|
200
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "skill:loose-tool", looseEntryPath);
|
|
201
|
-
|
|
202
|
-
const steer = lastSteer(pi);
|
|
203
|
-
expect(steer).toContain("/tmp/fixtures/skills/loose-tool.md"); // 文件路径本身
|
|
204
|
-
expect(steer).not.toContain("loose-tool.md/SKILL.md"); // 不再拼接 SKILL.md(悬空指引根修)
|
|
205
|
-
expect(steer).not.toContain("/tmp/fixtures/skills/SKILL.md"); // 旧缺陷形态:散 .md 的 dirname + SKILL.md
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("skill mode without skillEntryPath falls back to name-only guidance (防御形态)", () => {
|
|
209
|
-
attachGoalInit(() => true);
|
|
210
|
-
|
|
211
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "skill:dev-flow");
|
|
212
|
-
|
|
213
|
-
const steer = lastSteer(pi);
|
|
214
|
-
expect(steer).toContain("load the dev-flow skill");
|
|
215
|
-
expect(steer).not.toContain("SKILL.md");
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it("unknown isolation value falls through to direct delivery instead of silently dropping the choice (D1 防御形态)", () => {
|
|
219
|
-
attachGoalInit(() => true);
|
|
220
|
-
|
|
221
|
-
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "tree", "execute");
|
|
222
|
-
|
|
223
|
-
expect(pi.sendMessage).toHaveBeenCalledWith(...executionNotice(expect.stringContaining("Goal tracking is active via /goal")));
|
|
224
|
-
expect(ctx.compact).not.toHaveBeenCalled();
|
|
225
|
-
expect(ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("/tree"), "info");
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// goal 桥五个失败出口 → GoalBridgeOutcome 五值(D2)
|
|
230
|
-
describe("tryGoalInit failure exits (execute mode, direct isolation)", () => {
|
|
231
|
-
it("goal-unavailable: bridge not mounted → degraded steer + warning notify", () => {
|
|
232
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
233
|
-
|
|
234
|
-
expect(outcome).toEqual({ started: false, reason: "goal-unavailable" });
|
|
235
|
-
const steer = lastSteer(pi);
|
|
236
|
-
expect(steer).toContain("Goal tracking was not started (goal-unavailable)");
|
|
237
|
-
expect(steer).not.toContain("Goal tracking is active via /goal");
|
|
238
|
-
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("goal-unavailable"), "warning");
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it("plan-unreadable: plan file read fails → recovery points at the plan file", () => {
|
|
242
|
-
attachGoalInit(() => true);
|
|
243
|
-
fsMock.readFileSync.mockImplementation(() => { throw new Error("ENOENT: plan.md"); });
|
|
244
|
-
|
|
245
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
246
|
-
|
|
247
|
-
expect(outcome).toEqual({ started: false, reason: "plan-unreadable" });
|
|
248
|
-
expect(lastSteer(pi)).toContain("Check that the plan file exists");
|
|
249
|
-
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("plan-unreadable"), "warning");
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it("no-steps: plan content has no extractable steps", () => {
|
|
253
|
-
attachGoalInit(() => true);
|
|
254
|
-
setupFsMock("# Plan\n\nProse without any numbered list.");
|
|
255
|
-
|
|
256
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
257
|
-
|
|
258
|
-
expect(outcome).toEqual({ started: false, reason: "no-steps" });
|
|
259
|
-
expect(lastSteer(pi)).toContain("Implementation Steps");
|
|
260
|
-
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("no-steps"), "warning");
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("init-refused: goalInit returns false (active goal exists)", () => {
|
|
264
|
-
attachGoalInit(() => false);
|
|
265
|
-
|
|
266
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
267
|
-
|
|
268
|
-
expect(outcome).toEqual({ started: false, reason: "init-refused" });
|
|
269
|
-
expect(lastSteer(pi)).toContain("/goal clear");
|
|
270
|
-
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("init-refused"), "warning");
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it("internal-error: goalInit throws → outcome carries detail, notify includes it, does not propagate", () => {
|
|
274
|
-
attachGoalInit(() => { throw new Error("goalInit exploded"); });
|
|
275
|
-
|
|
276
|
-
const outcome = handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct", "execute");
|
|
277
|
-
|
|
278
|
-
expect(outcome).toEqual({ started: false, reason: "internal-error", detail: "goalInit exploded" });
|
|
279
|
-
expect(lastSteer(pi)).toContain("Goal tracking was not started (internal-error)");
|
|
280
|
-
expect(lastSteer(pi)).not.toContain("Goal tracking is active via /goal");
|
|
281
|
-
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("goalInit exploded"), "warning");
|
|
282
|
-
});
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
// --- registerPlanEventHandlers tests ---
|
|
287
|
-
|
|
288
|
-
describe("registerPlanEventHandlers", () => {
|
|
289
|
-
let pi: ReturnType<typeof makePi>;
|
|
290
|
-
|
|
291
|
-
beforeEach(() => {
|
|
292
|
-
vi.clearAllMocks();
|
|
293
|
-
pi = makePi();
|
|
294
|
-
setupFsMock("Plan content here");
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
function captureHandlers(): Record<string, (...args: unknown[]) => Promise<unknown>> {
|
|
298
|
-
const handlers: Record<string, (...args: unknown[]) => Promise<unknown>> = {};
|
|
299
|
-
for (const call of pi.on.mock.calls) {
|
|
300
|
-
handlers[call[0] as string] = call[1];
|
|
301
|
-
}
|
|
302
|
-
return handlers;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
it("session_before_compact (active): returns compaction summary with plan content", async () => {
|
|
306
|
-
const sessions = new Map();
|
|
307
|
-
sessions.set("test-session", makeActiveState());
|
|
308
|
-
|
|
309
|
-
registerPlanEventHandlers(pi as never, sessions);
|
|
310
|
-
const handlers = captureHandlers();
|
|
311
|
-
|
|
312
|
-
const result = await handlers["session_before_compact"]({}, makeCtx() as never);
|
|
313
|
-
const r = result as { compaction: { summary: string } };
|
|
314
|
-
|
|
315
|
-
expect(r.compaction.summary).toContain("Plan content here");
|
|
316
|
-
expect(r.compaction.summary).toContain("Add login page");
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it("session_before_compact (inactive): returns empty object {}", async () => {
|
|
320
|
-
registerPlanEventHandlers(pi as never, new Map());
|
|
321
|
-
const handlers = captureHandlers();
|
|
322
|
-
|
|
323
|
-
const result = await handlers["session_before_compact"]({}, makeCtx() as never);
|
|
324
|
-
expect(result).toEqual({});
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
it("session_before_tree (active): returns summary with plan content", async () => {
|
|
328
|
-
const sessions = new Map();
|
|
329
|
-
sessions.set("test-session", makeActiveState());
|
|
330
|
-
|
|
331
|
-
registerPlanEventHandlers(pi as never, sessions);
|
|
332
|
-
const handlers = captureHandlers();
|
|
333
|
-
|
|
334
|
-
const result = await handlers["session_before_tree"]({}, makeCtx() as never);
|
|
335
|
-
const r = result as { summary: { summary: string } };
|
|
336
|
-
|
|
337
|
-
expect(r.summary.summary).toContain("Plan content here");
|
|
338
|
-
expect(r.summary.summary).toContain("/tmp/plan.md");
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
it("session_before_tree (inactive): returns empty object {}", async () => {
|
|
342
|
-
registerPlanEventHandlers(pi as never, new Map());
|
|
343
|
-
const handlers = captureHandlers();
|
|
344
|
-
|
|
345
|
-
const result = await handlers["session_before_tree"]({}, makeCtx() as never);
|
|
346
|
-
expect(result).toEqual({});
|
|
347
|
-
});
|
|
348
|
-
});
|