@zhushanwen/pi-plan 0.2.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/index.ts +1 -0
- package/package.json +39 -0
- package/src/__tests__/command.test.ts +166 -0
- package/src/__tests__/compact-handler.test.ts +179 -0
- package/src/__tests__/compact.test.ts +102 -0
- package/src/__tests__/state.test.ts +117 -0
- package/src/__tests__/templates.test.ts +34 -0
- package/src/__tests__/tool.test.ts +181 -0
- package/src/command.ts +177 -0
- package/src/compact.ts +196 -0
- package/src/index.ts +40 -0
- package/src/state.ts +91 -0
- package/src/templates.ts +61 -0
- package/src/tool.ts +353 -0
- package/src/widget.ts +15 -0
- package/templates/bugfix-plan.md +22 -0
- package/templates/feature-plan.md +25 -0
- package/templates/implementation-plan.md +19 -0
- package/templates/refactor-plan.md +22 -0
- package/templates/research-plan.md +22 -0
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-plan",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Lightweight plan mode for Pi coding agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"pi": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./index.ts"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi-package",
|
|
14
|
+
"extension"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@mariozechner/pi-coding-agent": ">=0.73.0",
|
|
19
|
+
"@sinclair/typebox": "*",
|
|
20
|
+
"@mariozechner/pi-ai": "*"
|
|
21
|
+
},
|
|
22
|
+
"peerDependenciesMeta": {
|
|
23
|
+
"@mariozechner/pi-ai": {
|
|
24
|
+
"optional": true
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"vitest": "^4.1.8"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"index.ts",
|
|
32
|
+
"src/",
|
|
33
|
+
"templates/"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"typecheck": "npx tsc --noEmit",
|
|
37
|
+
"test": "vitest run"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { beforeEach,describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock dependencies before importing
|
|
4
|
+
vi.mock("node:fs", () => ({
|
|
5
|
+
mkdirSync: vi.fn(),
|
|
6
|
+
readdirSync: vi.fn(() => []),
|
|
7
|
+
statSync: vi.fn(),
|
|
8
|
+
existsSync: vi.fn(() => false),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("../widget.js", () => ({
|
|
12
|
+
updatePlanWidget: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
import { registerPlanCommand } from "../command.js";
|
|
20
|
+
|
|
21
|
+
const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
|
|
22
|
+
|
|
23
|
+
function createMocks() {
|
|
24
|
+
let capturedHandler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
25
|
+
|
|
26
|
+
const pi = {
|
|
27
|
+
registerCommand: vi.fn((_name: string, def: { handler: (args: string, ctx: ExtensionContext) => Promise<void> }) => {
|
|
28
|
+
capturedHandler = def.handler;
|
|
29
|
+
}),
|
|
30
|
+
appendEntry: vi.fn(),
|
|
31
|
+
setActiveTools: vi.fn(),
|
|
32
|
+
sendUserMessage: vi.fn(),
|
|
33
|
+
getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
|
|
34
|
+
} as unknown as ExtensionAPI;
|
|
35
|
+
|
|
36
|
+
const ctx = {
|
|
37
|
+
cwd: "/tmp/test-project",
|
|
38
|
+
sessionManager: {
|
|
39
|
+
getSessionId: () => "test-session",
|
|
40
|
+
getEntries: () => [] as unknown[],
|
|
41
|
+
},
|
|
42
|
+
ui: {
|
|
43
|
+
notify: vi.fn(),
|
|
44
|
+
setWidget: vi.fn(),
|
|
45
|
+
setStatus: vi.fn(),
|
|
46
|
+
theme: { fg: (_t: string, text: string) => text },
|
|
47
|
+
},
|
|
48
|
+
} as unknown as ExtensionContext;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
pi,
|
|
52
|
+
ctx,
|
|
53
|
+
getHandler: () => capturedHandler!,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("registerPlanCommand", () => {
|
|
58
|
+
let pi: ExtensionAPI;
|
|
59
|
+
let ctx: ExtensionContext;
|
|
60
|
+
let handler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.clearAllMocks();
|
|
64
|
+
const mocks = createMocks();
|
|
65
|
+
pi = mocks.pi;
|
|
66
|
+
ctx = mocks.ctx;
|
|
67
|
+
const sessions = new Map();
|
|
68
|
+
registerPlanCommand(pi, sessions);
|
|
69
|
+
handler = mocks.getHandler();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("registers 'plan' command", () => {
|
|
73
|
+
expect(pi.registerCommand).toHaveBeenCalledWith("plan", expect.objectContaining({ handler: expect.any(Function) }));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// --- abort subcommand ---
|
|
77
|
+
|
|
78
|
+
it("abort: notifies 'No active plan mode' when idle", async () => {
|
|
79
|
+
await handler("abort", ctx);
|
|
80
|
+
expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith("No active plan mode.", "info");
|
|
81
|
+
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("abort: resets state and restores tools when active", async () => {
|
|
85
|
+
// Enter plan mode first — handler uses the sessions map from registerPlanCommand closure
|
|
86
|
+
await handler("implement user auth", ctx);
|
|
87
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
|
|
88
|
+
vi.clearAllMocks();
|
|
89
|
+
|
|
90
|
+
// Now abort — state is active in the sessions map
|
|
91
|
+
await handler("abort", ctx);
|
|
92
|
+
|
|
93
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
94
|
+
expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith("Plan mode aborted.", "info");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// --- status subcommand ---
|
|
98
|
+
|
|
99
|
+
it("status: notifies 'No active plan mode' when idle", async () => {
|
|
100
|
+
await handler("status", ctx);
|
|
101
|
+
expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith("No active plan mode.", "info");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// --- already active + new args ---
|
|
105
|
+
|
|
106
|
+
it("warns when already active and args provided", async () => {
|
|
107
|
+
// First enter plan mode
|
|
108
|
+
await handler("my feature", ctx);
|
|
109
|
+
vi.clearAllMocks();
|
|
110
|
+
|
|
111
|
+
// Try to enter again with different args
|
|
112
|
+
await handler("another feature", ctx);
|
|
113
|
+
expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith(
|
|
114
|
+
"Plan mode is already active. Use /plan abort to cancel first.",
|
|
115
|
+
"warning",
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// --- enter plan mode ---
|
|
120
|
+
|
|
121
|
+
it("enters plan mode with slugified path", async () => {
|
|
122
|
+
await handler("Implement User Auth", ctx);
|
|
123
|
+
|
|
124
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
|
125
|
+
"/tmp/test-project/.xyz-harness/implement-user-auth",
|
|
126
|
+
{ recursive: true },
|
|
127
|
+
);
|
|
128
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
|
|
129
|
+
expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("[PLAN MODE]"));
|
|
130
|
+
expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Implement User Auth"));
|
|
131
|
+
expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", expect.objectContaining({
|
|
132
|
+
isActive: true,
|
|
133
|
+
phase: "brainstorming",
|
|
134
|
+
}));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("handles special characters in requirement for slug", async () => {
|
|
138
|
+
await handler("Fix bug #123: 中文标题!", ctx);
|
|
139
|
+
|
|
140
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
|
141
|
+
expect.stringContaining("/.xyz-harness/fix-bug-123"),
|
|
142
|
+
{ recursive: true },
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("uses 'untitled' slug when no args", async () => {
|
|
147
|
+
// No existing plans (readdirSync returns [])
|
|
148
|
+
await handler("", ctx);
|
|
149
|
+
|
|
150
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith(
|
|
151
|
+
"/tmp/test-project/.xyz-harness/untitled",
|
|
152
|
+
{ recursive: true },
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("shows status when active with no args", async () => {
|
|
157
|
+
await handler("my feature", ctx);
|
|
158
|
+
vi.clearAllMocks();
|
|
159
|
+
|
|
160
|
+
await handler("", ctx);
|
|
161
|
+
expect((ctx as ReturnType<typeof createMocks>["ctx"]).ui.notify).toHaveBeenCalledWith(
|
|
162
|
+
expect.stringContaining("brainstorming"),
|
|
163
|
+
"info",
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { beforeEach,describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { PlanState } from "../state.js";
|
|
4
|
+
|
|
5
|
+
// Mock fs before importing compact.ts (ESM namespace is not configurable)
|
|
6
|
+
vi.mock("node:fs", () => ({
|
|
7
|
+
readFileSync: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
// Import after mock setup
|
|
11
|
+
import { handlePlanComplete, registerPlanEventHandlers } from "../compact.js";
|
|
12
|
+
|
|
13
|
+
const fsMock = vi.mocked(await import("node:fs"));
|
|
14
|
+
|
|
15
|
+
// --- Shared mock factories ---
|
|
16
|
+
|
|
17
|
+
function makePi() {
|
|
18
|
+
return {
|
|
19
|
+
on: vi.fn(),
|
|
20
|
+
appendEntry: vi.fn(),
|
|
21
|
+
sendUserMessage: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type CtxMock = ReturnType<typeof makeCtx>;
|
|
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(): PlanState {
|
|
43
|
+
return {
|
|
44
|
+
isActive: true,
|
|
45
|
+
phase: "complete",
|
|
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
|
+
// --- handlePlanComplete tests ---
|
|
57
|
+
|
|
58
|
+
describe("handlePlanComplete", () => {
|
|
59
|
+
let pi: ReturnType<typeof makePi>;
|
|
60
|
+
let ctx: CtxMock;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.clearAllMocks();
|
|
64
|
+
pi = makePi();
|
|
65
|
+
ctx = makeCtx();
|
|
66
|
+
setupFsMock("## 实现步骤\n1. Step one\n2. Step two");
|
|
67
|
+
});
|
|
68
|
+
|
|
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
|
+
expect.arrayContaining([expect.any(String)]),
|
|
80
|
+
undefined,
|
|
81
|
+
ctx,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("compact onError: falls back to notify + steer", () => {
|
|
86
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "compact");
|
|
87
|
+
|
|
88
|
+
ctx._onErrorFns[0](new Error("compact failed"));
|
|
89
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.any(String), "warning");
|
|
90
|
+
expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.any(String), { deliverAs: "steer" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("tree isolation: only notify, no compact or steer", () => {
|
|
94
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "tree");
|
|
95
|
+
|
|
96
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("/tmp/plan.md"), "info");
|
|
97
|
+
expect(ctx.compact).not.toHaveBeenCalled();
|
|
98
|
+
expect(pi.sendUserMessage).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("direct isolation: directly sends steer", () => {
|
|
102
|
+
(pi as unknown as Record<string, unknown>).__goalInit = vi.fn().mockReturnValue(true);
|
|
103
|
+
handlePlanComplete(pi as never, ctx as never, makeActiveState(), "direct");
|
|
104
|
+
|
|
105
|
+
expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.any(String), { deliverAs: "steer" });
|
|
106
|
+
expect(ctx.compact).not.toHaveBeenCalled();
|
|
107
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
108
|
+
expect((pi as unknown as Record<string, unknown>).__goalInit).toHaveBeenCalledWith(
|
|
109
|
+
expect.any(String),
|
|
110
|
+
expect.arrayContaining([expect.any(String)]),
|
|
111
|
+
undefined,
|
|
112
|
+
ctx,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// --- registerPlanEventHandlers tests ---
|
|
118
|
+
|
|
119
|
+
describe("registerPlanEventHandlers", () => {
|
|
120
|
+
let pi: ReturnType<typeof makePi>;
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
vi.clearAllMocks();
|
|
124
|
+
pi = makePi();
|
|
125
|
+
setupFsMock("Plan content here");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
function captureHandlers(): Record<string, (...args: unknown[]) => Promise<unknown>> {
|
|
129
|
+
const handlers: Record<string, (...args: unknown[]) => Promise<unknown>> = {};
|
|
130
|
+
for (const call of pi.on.mock.calls) {
|
|
131
|
+
handlers[call[0] as string] = call[1];
|
|
132
|
+
}
|
|
133
|
+
return handlers;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
it("session_before_compact (active): returns compaction summary with plan content", async () => {
|
|
137
|
+
const sessions = new Map();
|
|
138
|
+
sessions.set("test-session", makeActiveState());
|
|
139
|
+
|
|
140
|
+
registerPlanEventHandlers(pi as never, sessions);
|
|
141
|
+
const handlers = captureHandlers();
|
|
142
|
+
|
|
143
|
+
const result = await handlers["session_before_compact"]({}, makeCtx() as never);
|
|
144
|
+
const r = result as { compaction: { summary: string } };
|
|
145
|
+
|
|
146
|
+
expect(r.compaction.summary).toContain("Plan content here");
|
|
147
|
+
expect(r.compaction.summary).toContain("Add login page");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("session_before_compact (inactive): returns empty object {}", async () => {
|
|
151
|
+
registerPlanEventHandlers(pi as never, new Map());
|
|
152
|
+
const handlers = captureHandlers();
|
|
153
|
+
|
|
154
|
+
const result = await handlers["session_before_compact"]({}, makeCtx() as never);
|
|
155
|
+
expect(result).toEqual({});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("session_before_tree (active): returns summary with plan content", async () => {
|
|
159
|
+
const sessions = new Map();
|
|
160
|
+
sessions.set("test-session", makeActiveState());
|
|
161
|
+
|
|
162
|
+
registerPlanEventHandlers(pi as never, sessions);
|
|
163
|
+
const handlers = captureHandlers();
|
|
164
|
+
|
|
165
|
+
const result = await handlers["session_before_tree"]({}, makeCtx() as never);
|
|
166
|
+
const r = result as { summary: { summary: string } };
|
|
167
|
+
|
|
168
|
+
expect(r.summary.summary).toContain("Plan content here");
|
|
169
|
+
expect(r.summary.summary).toContain("/tmp/plan.md");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("session_before_tree (inactive): returns empty object {}", async () => {
|
|
173
|
+
registerPlanEventHandlers(pi as never, new Map());
|
|
174
|
+
const handlers = captureHandlers();
|
|
175
|
+
|
|
176
|
+
const result = await handlers["session_before_tree"]({}, makeCtx() as never);
|
|
177
|
+
expect(result).toEqual({});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect,it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { extractPlanSteps } from "../compact.js";
|
|
4
|
+
|
|
5
|
+
describe("extractPlanSteps", () => {
|
|
6
|
+
it("extracts numbered steps from 实现步骤 section", () => {
|
|
7
|
+
const plan = `# Plan
|
|
8
|
+
|
|
9
|
+
## 背景
|
|
10
|
+
Some context
|
|
11
|
+
|
|
12
|
+
## 实现步骤
|
|
13
|
+
1. Create the user model
|
|
14
|
+
2. Add validation logic
|
|
15
|
+
3. Write unit tests
|
|
16
|
+
|
|
17
|
+
## 验证
|
|
18
|
+
Run tests`;
|
|
19
|
+
const steps = extractPlanSteps(plan);
|
|
20
|
+
expect(steps).toEqual([
|
|
21
|
+
"Create the user model",
|
|
22
|
+
"Add validation logic",
|
|
23
|
+
"Write unit tests",
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("extracts numbered steps from 实施步骤 section", () => {
|
|
28
|
+
const plan = `## 实施步骤
|
|
29
|
+
1. Step one
|
|
30
|
+
2. Step two`;
|
|
31
|
+
const steps = extractPlanSteps(plan);
|
|
32
|
+
expect(steps).toEqual(["Step one", "Step two"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("extracts steps from English Steps section", () => {
|
|
36
|
+
const plan = `## Steps
|
|
37
|
+
1. First step
|
|
38
|
+
2. Second step`;
|
|
39
|
+
const steps = extractPlanSteps(plan);
|
|
40
|
+
expect(steps).toEqual(["First step", "Second step"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("extracts steps from Implementation section", () => {
|
|
44
|
+
const plan = `## Implementation Steps
|
|
45
|
+
1. Create file
|
|
46
|
+
2. Add exports`;
|
|
47
|
+
const steps = extractPlanSteps(plan);
|
|
48
|
+
expect(steps).toEqual(["Create file", "Add exports"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("stops at next ## header", () => {
|
|
52
|
+
const plan = `## 实现步骤
|
|
53
|
+
1. Step in plan section
|
|
54
|
+
|
|
55
|
+
## 验证
|
|
56
|
+
1. Not a plan step
|
|
57
|
+
2. Also not a plan step`;
|
|
58
|
+
const steps = extractPlanSteps(plan);
|
|
59
|
+
expect(steps).toEqual(["Step in plan section"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("extracts checkbox items from steps section", () => {
|
|
63
|
+
const plan = `## 实现步骤
|
|
64
|
+
- [ ] Add auth middleware
|
|
65
|
+
- [x] Create user model
|
|
66
|
+
- [ ] Write integration test`;
|
|
67
|
+
const steps = extractPlanSteps(plan);
|
|
68
|
+
expect(steps).toEqual([
|
|
69
|
+
"Add auth middleware",
|
|
70
|
+
"Create user model",
|
|
71
|
+
"Write integration test",
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns empty array when no steps section and no numbered items", () => {
|
|
76
|
+
const plan = `## 背景
|
|
77
|
+
Some context
|
|
78
|
+
|
|
79
|
+
## 方案
|
|
80
|
+
Option A is preferred`;
|
|
81
|
+
const steps = extractPlanSteps(plan);
|
|
82
|
+
expect(steps).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("fallback: collects numbered items when no steps section header", () => {
|
|
86
|
+
const plan = `# Plan
|
|
87
|
+
1. First thing
|
|
88
|
+
2. Second thing
|
|
89
|
+
3. Third thing`;
|
|
90
|
+
const steps = extractPlanSteps(plan);
|
|
91
|
+
expect(steps).toEqual(["First thing", "Second thing", "Third thing"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("skips empty lines and whitespace-only items", () => {
|
|
95
|
+
const plan = `## 实现步骤
|
|
96
|
+
1. Valid step
|
|
97
|
+
|
|
98
|
+
2. Another valid step`;
|
|
99
|
+
const steps = extractPlanSteps(plan);
|
|
100
|
+
expect(steps).toEqual(["Valid step", "Another valid step"]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_PLAN_STATE,
|
|
6
|
+
getPlanState,
|
|
7
|
+
persistPlanState,
|
|
8
|
+
type PlanPhase,
|
|
9
|
+
type PlanSessionMap,
|
|
10
|
+
type PlanState,
|
|
11
|
+
reconstructPlanState,
|
|
12
|
+
} from "../state.js";
|
|
13
|
+
|
|
14
|
+
describe("PlanState", () => {
|
|
15
|
+
it("DEFAULT_PLAN_STATE has correct defaults", () => {
|
|
16
|
+
expect(DEFAULT_PLAN_STATE.isActive).toBe(false);
|
|
17
|
+
expect(DEFAULT_PLAN_STATE.phase).toBe("idle");
|
|
18
|
+
expect(DEFAULT_PLAN_STATE.planFilePath).toBe("");
|
|
19
|
+
expect(DEFAULT_PLAN_STATE.requirement).toBe("");
|
|
20
|
+
expect(DEFAULT_PLAN_STATE.templateName).toBe("");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("PlanPhase type includes all required phases", () => {
|
|
24
|
+
const phases: PlanPhase[] = ["idle", "brainstorming", "writing", "complete"];
|
|
25
|
+
expect(phases).toHaveLength(4);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("getPlanState returns cached state if exists", () => {
|
|
29
|
+
const sessions: PlanSessionMap = new Map();
|
|
30
|
+
const cached: PlanState = { ...DEFAULT_PLAN_STATE, isActive: true, phase: "brainstorming" };
|
|
31
|
+
sessions.set("session-1", cached);
|
|
32
|
+
|
|
33
|
+
const mockCtx = {
|
|
34
|
+
sessionManager: { getEntries: () => [] },
|
|
35
|
+
} as unknown as ExtensionContext;
|
|
36
|
+
|
|
37
|
+
const result = getPlanState(sessions, "session-1", mockCtx);
|
|
38
|
+
expect(result).toBe(cached);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("getPlanState reconstructs from sessionManager if not cached", () => {
|
|
42
|
+
const sessions: PlanSessionMap = new Map();
|
|
43
|
+
const mockCtx = {
|
|
44
|
+
sessionManager: {
|
|
45
|
+
getEntries: () => [
|
|
46
|
+
{
|
|
47
|
+
type: "custom",
|
|
48
|
+
customType: "plan-state",
|
|
49
|
+
data: { isActive: true, phase: "writing", planFilePath: ".xyz-harness/test/plan.md", requirement: "test", templateName: "feature-plan" },
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
} as unknown as ExtensionContext;
|
|
54
|
+
|
|
55
|
+
const result = getPlanState(sessions, "session-2", mockCtx);
|
|
56
|
+
expect(result.isActive).toBe(true);
|
|
57
|
+
expect(result.phase).toBe("writing");
|
|
58
|
+
expect(sessions.get("session-2")).toBe(result);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("State persistence", () => {
|
|
63
|
+
it("persistPlanState calls appendEntry with correct data", () => {
|
|
64
|
+
const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
|
|
65
|
+
const state: PlanState = {
|
|
66
|
+
isActive: true,
|
|
67
|
+
phase: "brainstorming",
|
|
68
|
+
planFilePath: ".xyz-harness/test/plan.md",
|
|
69
|
+
requirement: "test requirement",
|
|
70
|
+
templateName: "feature-plan",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
persistPlanState(mockPi, state);
|
|
74
|
+
|
|
75
|
+
expect(mockPi.appendEntry).toHaveBeenCalledWith("plan-state", {
|
|
76
|
+
isActive: true,
|
|
77
|
+
phase: "brainstorming",
|
|
78
|
+
planFilePath: ".xyz-harness/test/plan.md",
|
|
79
|
+
requirement: "test requirement",
|
|
80
|
+
templateName: "feature-plan",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("reconstructPlanState returns DEFAULT_PLAN_STATE when no entries", () => {
|
|
85
|
+
const mockCtx = {
|
|
86
|
+
sessionManager: { getEntries: () => [] },
|
|
87
|
+
} as unknown as ExtensionContext;
|
|
88
|
+
|
|
89
|
+
const state = reconstructPlanState(mockCtx);
|
|
90
|
+
expect(state).toEqual(DEFAULT_PLAN_STATE);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("reconstructPlanState restores state from entries", () => {
|
|
94
|
+
const mockCtx = {
|
|
95
|
+
sessionManager: {
|
|
96
|
+
getEntries: () => [
|
|
97
|
+
{
|
|
98
|
+
type: "custom",
|
|
99
|
+
customType: "plan-state",
|
|
100
|
+
data: {
|
|
101
|
+
isActive: true,
|
|
102
|
+
phase: "writing",
|
|
103
|
+
planFilePath: ".xyz-harness/test/plan.md",
|
|
104
|
+
requirement: "test",
|
|
105
|
+
templateName: "feature-plan",
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
} as unknown as ExtensionContext;
|
|
111
|
+
|
|
112
|
+
const state = reconstructPlanState(mockCtx);
|
|
113
|
+
expect(state.isActive).toBe(true);
|
|
114
|
+
expect(state.phase).toBe("writing");
|
|
115
|
+
expect(state.planFilePath).toBe(".xyz-harness/test/plan.md");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { describe, expect,it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { getBuiltinTemplateDir,listTemplates, loadTemplate } from "../templates.js";
|
|
6
|
+
|
|
7
|
+
describe("Template system", () => {
|
|
8
|
+
it("listTemplates returns builtin templates", () => {
|
|
9
|
+
const templates = listTemplates();
|
|
10
|
+
expect(templates.length).toBeGreaterThanOrEqual(5);
|
|
11
|
+
const names = templates.map((t) => t.name);
|
|
12
|
+
expect(names).toContain("feature-plan");
|
|
13
|
+
expect(names).toContain("bugfix-plan");
|
|
14
|
+
expect(names).toContain("refactor-plan");
|
|
15
|
+
expect(names).toContain("research-plan");
|
|
16
|
+
expect(names).toContain("implementation-plan");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("loadTemplate returns content for existing builtin template", () => {
|
|
20
|
+
const content = loadTemplate("feature-plan");
|
|
21
|
+
expect(content).not.toBeNull();
|
|
22
|
+
expect(content).toContain("## ");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("loadTemplate returns null for non-existent template", () => {
|
|
26
|
+
const content = loadTemplate("non-existent-template");
|
|
27
|
+
expect(content).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("getBuiltinTemplateDir returns valid path", () => {
|
|
31
|
+
const dir = getBuiltinTemplateDir();
|
|
32
|
+
expect(fs.existsSync(dir)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|