@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
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { beforeEach,describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock typebox before importing tool
|
|
4
|
+
vi.mock("typebox", () => ({
|
|
5
|
+
Type: {
|
|
6
|
+
Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
|
|
7
|
+
String: (opts?: Record<string, unknown>) => ({ type: "string", ...opts }),
|
|
8
|
+
Optional: (schema: unknown) => schema,
|
|
9
|
+
},
|
|
10
|
+
Static: class {},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("@mariozechner/pi-ai", () => ({
|
|
14
|
+
StringEnum: (values: readonly string[]) => ({ type: "string", enum: [...values] }),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mock compact.js (dynamically imported by complete action)
|
|
18
|
+
vi.mock("../compact.js", () => ({
|
|
19
|
+
handlePlanComplete: vi.fn(),
|
|
20
|
+
detectGoalCapability: vi.fn(() => false),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Mock widget (imported by abort)
|
|
24
|
+
vi.mock("../widget.js", () => ({
|
|
25
|
+
updatePlanWidget: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Mock node:fs — ESM namespace isn't configurable, so we use vi.mock
|
|
29
|
+
vi.mock("node:fs", async () => {
|
|
30
|
+
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
31
|
+
return { ...actual, mkdirSync: vi.fn(), writeFileSync: vi.fn() };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
import * as fs from "node:fs";
|
|
35
|
+
|
|
36
|
+
import { handlePlanComplete } from "../compact.js";
|
|
37
|
+
import { PLAN_ACTIONS, registerPlanTool, validateAction } from "../tool.js";
|
|
38
|
+
import { updatePlanWidget } from "../widget.js";
|
|
39
|
+
|
|
40
|
+
/** Build a fake pi + ctx and capture the execute callback from registerTool. */
|
|
41
|
+
const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
|
|
42
|
+
|
|
43
|
+
function setup() {
|
|
44
|
+
const sessions = new Map();
|
|
45
|
+
let executeFn: (id: string, p: Record<string, unknown>, sig?: AbortSignal, upd?: unknown, ctx?: unknown) => Promise<unknown>;
|
|
46
|
+
const pi = {
|
|
47
|
+
registerTool: vi.fn((tool) => { executeFn = tool.execute; }),
|
|
48
|
+
appendEntry: vi.fn(),
|
|
49
|
+
setActiveTools: vi.fn(),
|
|
50
|
+
getAllTools: vi.fn(() => ALL_TOOL_NAMES.map((n) => ({ name: n }))),
|
|
51
|
+
} as unknown as Parameters<typeof registerPlanTool>[0];
|
|
52
|
+
registerPlanTool(pi, sessions);
|
|
53
|
+
|
|
54
|
+
const ctx = {
|
|
55
|
+
sessionId: "test-session",
|
|
56
|
+
cwd: "/tmp/test-project",
|
|
57
|
+
sessionManager: { getSessionId: () => "test-session", getEntries: () => [] },
|
|
58
|
+
ui: { select: vi.fn(), notify: vi.fn() },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const exec = (params: Record<string, unknown>) => executeFn!("tc0", params, undefined, undefined, ctx);
|
|
62
|
+
return { pi, sessions, ctx, exec };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("registerPlanTool", () => {
|
|
66
|
+
it("registers a tool named 'plan'", () => {
|
|
67
|
+
const { pi } = setup();
|
|
68
|
+
expect(pi.registerTool).toHaveBeenCalledOnce();
|
|
69
|
+
expect((pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0].name).toBe("plan");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// --- list-template ---
|
|
73
|
+
describe("list-template", () => {
|
|
74
|
+
it("returns template list", async () => {
|
|
75
|
+
const { exec } = setup();
|
|
76
|
+
const res = await exec({ action: "list-template" });
|
|
77
|
+
expect(res.content[0].type).toBe("text");
|
|
78
|
+
expect(res.details.action).toBe("list-template");
|
|
79
|
+
expect(Array.isArray(res.details.templates)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// --- select-template ---
|
|
84
|
+
describe("select-template", () => {
|
|
85
|
+
it("throws when templateName is missing", async () => {
|
|
86
|
+
const { exec } = setup();
|
|
87
|
+
await expect(exec({ action: "select-template" })).rejects.toThrow("templateName is required");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("throws when template does not exist", async () => {
|
|
91
|
+
const { exec } = setup();
|
|
92
|
+
await expect(exec({ action: "select-template", templateName: "nonexistent" })).rejects.toThrow("Template not found");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("sets phase to writing and persists", async () => {
|
|
96
|
+
const { exec, pi, sessions } = setup();
|
|
97
|
+
// Use a builtin template name — find one first
|
|
98
|
+
const listRes = await exec({ action: "list-template" });
|
|
99
|
+
const templates = listRes.details.templates as { name: string }[];
|
|
100
|
+
if (templates.length === 0) return; // no builtin templates available
|
|
101
|
+
|
|
102
|
+
const name = templates[0].name;
|
|
103
|
+
const res = await exec({ action: "select-template", templateName: name });
|
|
104
|
+
expect(res.details.templateName).toBe(name);
|
|
105
|
+
expect(res.details.action).toBe("select-template");
|
|
106
|
+
expect(pi.appendEntry).toHaveBeenCalled();
|
|
107
|
+
const state = sessions.get("test-session");
|
|
108
|
+
expect(state?.phase).toBe("writing");
|
|
109
|
+
expect(state?.templateName).toBe(name);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// --- create-template ---
|
|
114
|
+
describe("create-template", () => {
|
|
115
|
+
beforeEach(() => { (fs.mkdirSync as ReturnType<typeof vi.fn>).mockClear(); (fs.writeFileSync as ReturnType<typeof vi.fn>).mockClear(); });
|
|
116
|
+
|
|
117
|
+
it("throws when parameters are missing", async () => {
|
|
118
|
+
const { exec } = setup();
|
|
119
|
+
await expect(exec({ action: "create-template" })).rejects.toThrow("templateName and templateContent are required");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("throws when name sanitizes to empty", async () => {
|
|
123
|
+
const { exec } = setup();
|
|
124
|
+
await expect(exec({ action: "create-template", templateName: "!!!", templateContent: "x" }))
|
|
125
|
+
.rejects.toThrow("Invalid template name");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("writes file with sanitized name", async () => {
|
|
129
|
+
const { exec } = setup();
|
|
130
|
+
const res = await exec({ action: "create-template", templateName: "My Plan v2!", templateContent: "# hello" });
|
|
131
|
+
expect(res.details.templateName).toBe("MyPlanv2");
|
|
132
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith("/tmp/test-project/.pi/plan-templates", { recursive: true });
|
|
133
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith("/tmp/test-project/.pi/plan-templates/MyPlanv2.md", "# hello");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// --- complete ---
|
|
138
|
+
describe("complete", () => {
|
|
139
|
+
it("does not advance when user cancels", async () => {
|
|
140
|
+
const { exec, ctx, pi } = setup();
|
|
141
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Modify the plan first");
|
|
142
|
+
const res = await exec({ action: "complete" });
|
|
143
|
+
expect(res.details.action).toBe("complete-cancelled");
|
|
144
|
+
expect(pi.setActiveTools).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("resets state and restores tools on execute", async () => {
|
|
148
|
+
const { exec, ctx, pi } = setup();
|
|
149
|
+
(ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Subagent-driven execution");
|
|
150
|
+
const res = await exec({ action: "complete" });
|
|
151
|
+
expect(res.details.action).toBe("complete");
|
|
152
|
+
expect(res.details.execMode).toBe("subagent");
|
|
153
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
154
|
+
expect(handlePlanComplete).toHaveBeenCalled();
|
|
155
|
+
expect(res.details.planFilePath).toBeDefined();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- abort ---
|
|
160
|
+
describe("abort", () => {
|
|
161
|
+
it("resets state and cleans up session", async () => {
|
|
162
|
+
const { exec, pi, sessions } = setup();
|
|
163
|
+
// Pre-populate a session
|
|
164
|
+
sessions.set("test-session", { isActive: true, phase: "writing", planFilePath: "/tmp/plan.md", requirement: "test", templateName: "t" });
|
|
165
|
+
const res = await exec({ action: "abort" });
|
|
166
|
+
expect(res.details.action).toBe("abort");
|
|
167
|
+
expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
|
|
168
|
+
expect(sessions.has("test-session")).toBe(false);
|
|
169
|
+
expect(updatePlanWidget).toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("validateAction", () => {
|
|
175
|
+
it("accepts valid actions", () => {
|
|
176
|
+
for (const a of PLAN_ACTIONS) expect(validateAction(a)).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
it("rejects invalid", () => {
|
|
179
|
+
expect(validateAction("bogus")).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import type { PlanSessionMap } from "./state.js";
|
|
7
|
+
import { getPlanState, persistPlanState, resetPlanState } from "./state.js";
|
|
8
|
+
import { updatePlanWidget } from "./widget.js";
|
|
9
|
+
|
|
10
|
+
const MAX_SLUG_LENGTH = 30;
|
|
11
|
+
|
|
12
|
+
export function registerPlanCommand(
|
|
13
|
+
pi: ExtensionAPI,
|
|
14
|
+
sessions: PlanSessionMap,
|
|
15
|
+
): void {
|
|
16
|
+
pi.registerCommand("plan", {
|
|
17
|
+
description:
|
|
18
|
+
"Enter plan mode: /plan [description]. " +
|
|
19
|
+
"Subcommands: /plan abort, /plan status. " +
|
|
20
|
+
"With no args, show status or detect existing plan.",
|
|
21
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
22
|
+
const trimmed = args.trim();
|
|
23
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
24
|
+
const state = getPlanState(sessions, sessionId, ctx);
|
|
25
|
+
|
|
26
|
+
// Subcommand: abort
|
|
27
|
+
if (trimmed === "abort") {
|
|
28
|
+
await handleAbort(pi, sessions, sessionId, ctx, state);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Subcommand: status
|
|
33
|
+
if (trimmed === "status") {
|
|
34
|
+
handleStatus(ctx, state);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// If already in plan mode with no args, show status
|
|
39
|
+
if (state.isActive && !trimmed) {
|
|
40
|
+
handleStatus(ctx, state);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// If already in plan mode with args, warn
|
|
45
|
+
if (state.isActive && trimmed) {
|
|
46
|
+
ctx.ui.notify("Plan mode is already active. Use /plan abort to cancel first.", "warning");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Reentry: check for existing plan files in .xyz-harness/
|
|
51
|
+
if (!state.isActive && !trimmed) {
|
|
52
|
+
const projectDir = ctx.cwd;
|
|
53
|
+
const harnessDir = path.join(projectDir, ".xyz-harness");
|
|
54
|
+
const existingPlans = findExistingPlans(harnessDir);
|
|
55
|
+
if (existingPlans.length > 0) {
|
|
56
|
+
pi.sendUserMessage(
|
|
57
|
+
`[PLAN MODE] Found existing plan files:\n${existingPlans.map((p, i) => ` ${i + 1}. ${p}`).join("\n")}\n\n` +
|
|
58
|
+
`Choose an option:\n` +
|
|
59
|
+
` a) Continue existing plan\n` +
|
|
60
|
+
` b) Implement existing plan\n` +
|
|
61
|
+
` c) Create new plan\n` +
|
|
62
|
+
` d) Cancel`,
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Enter plan mode
|
|
69
|
+
handleEnterPlanMode(pi, sessions, sessionId, ctx, state, trimmed);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Handle /plan abort subcommand */
|
|
75
|
+
async function handleAbort(
|
|
76
|
+
pi: ExtensionAPI,
|
|
77
|
+
sessions: PlanSessionMap,
|
|
78
|
+
sessionId: string,
|
|
79
|
+
ctx: ExtensionContext,
|
|
80
|
+
state: PlanSessionMap extends Map<string, infer V> ? V : never,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
if (!state.isActive) {
|
|
83
|
+
ctx.ui.notify("No active plan mode.", "info");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
|
|
87
|
+
updatePlanWidget(ctx, updatedState);
|
|
88
|
+
// Restore full tool set (SDK does NOT support undefined)
|
|
89
|
+
pi.setActiveTools(pi.getAllTools().map((t: { name: string }) => t.name));
|
|
90
|
+
ctx.ui.notify("Plan mode aborted.", "info");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Handle /plan status subcommand */
|
|
94
|
+
function handleStatus(
|
|
95
|
+
ctx: ExtensionContext,
|
|
96
|
+
state: PlanSessionMap extends Map<string, infer V> ? V : never,
|
|
97
|
+
): void {
|
|
98
|
+
if (!state.isActive) {
|
|
99
|
+
ctx.ui.notify("No active plan mode.", "info");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
ctx.ui.notify(
|
|
103
|
+
`Plan Mode: ${state.phase}\nPlan: ${state.planFilePath}\nTemplate: ${state.templateName || "(not selected)"}`,
|
|
104
|
+
"info",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Find existing plan.md files in .xyz-harness/ subdirectories */
|
|
109
|
+
function findExistingPlans(harnessDir: string): string[] {
|
|
110
|
+
try {
|
|
111
|
+
return fs.readdirSync(harnessDir)
|
|
112
|
+
.filter((f) => {
|
|
113
|
+
const subDir = path.join(harnessDir, f);
|
|
114
|
+
return fs.statSync(subDir).isDirectory() && fs.existsSync(path.join(subDir, "plan.md"));
|
|
115
|
+
})
|
|
116
|
+
.map((f) => path.join(harnessDir, f, "plan.md"));
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Handle entering plan mode */
|
|
123
|
+
function handleEnterPlanMode(
|
|
124
|
+
pi: ExtensionAPI,
|
|
125
|
+
sessions: PlanSessionMap,
|
|
126
|
+
sessionId: string,
|
|
127
|
+
ctx: ExtensionContext,
|
|
128
|
+
state: PlanSessionMap extends Map<string, infer V> ? V : never,
|
|
129
|
+
requirement: string,
|
|
130
|
+
): void {
|
|
131
|
+
const slug = requirement
|
|
132
|
+
? requirement.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, MAX_SLUG_LENGTH)
|
|
133
|
+
: "untitled";
|
|
134
|
+
|
|
135
|
+
const projectDir = ctx.cwd;
|
|
136
|
+
const planDir = path.join(projectDir, ".xyz-harness", slug);
|
|
137
|
+
fs.mkdirSync(planDir, { recursive: true });
|
|
138
|
+
const planFilePath = path.join(planDir, "plan.md");
|
|
139
|
+
|
|
140
|
+
state.isActive = true;
|
|
141
|
+
state.phase = "brainstorming";
|
|
142
|
+
state.planFilePath = planFilePath;
|
|
143
|
+
state.requirement = requirement;
|
|
144
|
+
state.templateName = "";
|
|
145
|
+
|
|
146
|
+
persistPlanState(pi, state);
|
|
147
|
+
updatePlanWidget(ctx, state);
|
|
148
|
+
|
|
149
|
+
// Restrict tools to read-only set during plan mode
|
|
150
|
+
pi.setActiveTools(["read", "bash", "grep", "find", "ls", "plan"]);
|
|
151
|
+
|
|
152
|
+
// Inject plan mode system prompt inline
|
|
153
|
+
pi.sendUserMessage(
|
|
154
|
+
`[PLAN MODE] Entered plan mode.\n\n` +
|
|
155
|
+
`Requirement: ${requirement || "(from conversation context)"}\n` +
|
|
156
|
+
`Plan file: ${planFilePath}\n\n` +
|
|
157
|
+
`## Constraints\n` +
|
|
158
|
+
`- READ-ONLY: Do NOT edit any files except the plan file (${planFilePath}).\n` +
|
|
159
|
+
`- Do NOT run write commands (mkdir, echo, sed, etc.) on non-plan files.\n` +
|
|
160
|
+
`- All plan content goes to the plan file only.\n\n` +
|
|
161
|
+
`## Phase B: Brainstorming\n` +
|
|
162
|
+
`1. **Quick Overview**: ls project root, read README, package.json — build context (< 30s).\n` +
|
|
163
|
+
`2. **Explore before asking**: grep/read code first. Only ask user for preferences, not code-fact questions.\n` +
|
|
164
|
+
`3. **Progressive questioning**: Ask 2-3 questions at a time. Use ask_user tool if available.\n` +
|
|
165
|
+
`4. **Propose 2-3 approaches** with trade-offs + recommendation.\n` +
|
|
166
|
+
`5. **Assumption audit**: Grep-verify interfaces/types exist. Mark [UNVERIFIED] what can't be verified.\n\n` +
|
|
167
|
+
`## Phase C: Writing\n` +
|
|
168
|
+
`1. Call plan tool (list-template) to show available templates.\n` +
|
|
169
|
+
`2. After user selects template, call plan tool (select-template).\n` +
|
|
170
|
+
`3. Write chapters in template order — do NOT skip unwritten chapters.\n` +
|
|
171
|
+
`4. Write all chapters in one turn, then ask user to review.\n\n` +
|
|
172
|
+
`## Phase D: Completion\n` +
|
|
173
|
+
`1. Ask user to review the complete plan.\n` +
|
|
174
|
+
`2. Call plan tool (complete) with isolation method (compact/tree/direct).\n` +
|
|
175
|
+
`3. After plan complete: check subagent capability → suggest goal + wave or single-agent execution.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
package/src/compact.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext, SessionBeforeCompactEvent, SessionBeforeTreeEvent } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import type { PlanSessionMap, PlanState } from "./state.js";
|
|
6
|
+
import { getPlanState } from "./state.js";
|
|
7
|
+
|
|
8
|
+
export function registerPlanEventHandlers(
|
|
9
|
+
pi: ExtensionAPI,
|
|
10
|
+
sessions: PlanSessionMap,
|
|
11
|
+
): void {
|
|
12
|
+
pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
|
|
13
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
14
|
+
const state = getPlanState(sessions, sessionId, ctx);
|
|
15
|
+
if (!state.isActive) return {};
|
|
16
|
+
|
|
17
|
+
const prep = event.preparation;
|
|
18
|
+
|
|
19
|
+
// Read plan file content for recovery after compact
|
|
20
|
+
const planContent = readPlanFileSafe(state.planFilePath);
|
|
21
|
+
|
|
22
|
+
// Include phase info for non-complete phases
|
|
23
|
+
const phaseNote = state.phase !== "complete"
|
|
24
|
+
? `\nPhase: ${state.phase}. Plan was in progress — review and continue.`
|
|
25
|
+
: "\nAwaiting user decision on execution. Do NOT auto-proceed.";
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
compaction: {
|
|
29
|
+
summary:
|
|
30
|
+
`Plan mode active (${state.phase}). Plan file: ${state.planFilePath}\n\n` +
|
|
31
|
+
`## Plan Content\n${planContent}\n\n` +
|
|
32
|
+
`Requirement: ${state.requirement}` +
|
|
33
|
+
phaseNote,
|
|
34
|
+
firstKeptEntryId: prep?.firstKeptEntryId,
|
|
35
|
+
tokensBefore: prep?.tokensBefore,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
pi.on("session_before_tree", async (_event: SessionBeforeTreeEvent, ctx: ExtensionContext) => {
|
|
41
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
42
|
+
const state = getPlanState(sessions, sessionId, ctx);
|
|
43
|
+
if (!state.isActive) return {};
|
|
44
|
+
|
|
45
|
+
const planContent = readPlanFileSafe(state.planFilePath);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
summary: {
|
|
49
|
+
summary:
|
|
50
|
+
`Plan mode active (${state.phase}). Plan file: ${state.planFilePath}\n\n` +
|
|
51
|
+
`## Plan Content\n${planContent}\n\n` +
|
|
52
|
+
`Read the plan file and execute the implementation.`,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read plan file, return content or error message */
|
|
59
|
+
function readPlanFileSafe(planFilePath: string): string {
|
|
60
|
+
try {
|
|
61
|
+
return fs.readFileSync(planFilePath, "utf-8");
|
|
62
|
+
} catch {
|
|
63
|
+
return "(plan file could not be read)";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Detect whether goal extension is available via its programming interface */
|
|
68
|
+
export function detectGoalCapability(pi: ExtensionAPI): boolean {
|
|
69
|
+
try {
|
|
70
|
+
const api = pi as unknown as Record<string, unknown>;
|
|
71
|
+
return typeof api.__goalInit === "function";
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Try to initialize goal via programming interface */
|
|
78
|
+
function tryGoalInit(pi: ExtensionAPI, planFilePath: string, ctx: ExtensionContext): boolean {
|
|
79
|
+
type GoalInitFn = (
|
|
80
|
+
objective: string,
|
|
81
|
+
tasks: string[],
|
|
82
|
+
budget?: { tokenBudget?: number; timeBudgetMinutes?: number; maxTurns?: number },
|
|
83
|
+
ctx?: ExtensionContext,
|
|
84
|
+
) => boolean;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const api = pi as unknown as Record<string, unknown>;
|
|
88
|
+
const goalInit = api.__goalInit as GoalInitFn | undefined;
|
|
89
|
+
if (typeof goalInit !== "function") return false;
|
|
90
|
+
|
|
91
|
+
const planContent = readPlanFileSafe(planFilePath);
|
|
92
|
+
if (planContent.startsWith("(")) return false; // read failed
|
|
93
|
+
|
|
94
|
+
const objective = `Execute plan: ${planFilePath}`;
|
|
95
|
+
const tasks = extractPlanSteps(planContent);
|
|
96
|
+
if (tasks.length === 0) return false;
|
|
97
|
+
|
|
98
|
+
return goalInit(objective, tasks, undefined, ctx);
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Extract numbered steps from plan markdown */
|
|
105
|
+
export function extractPlanSteps(planContent: string): string[] {
|
|
106
|
+
const steps: string[] = [];
|
|
107
|
+
let inStepsSection = false;
|
|
108
|
+
|
|
109
|
+
for (const line of planContent.split("\n")) {
|
|
110
|
+
// Detect steps section headers
|
|
111
|
+
if (/^##\s*(实现步骤|实施步骤|Implementation|Steps)/i.test(line)) {
|
|
112
|
+
inStepsSection = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// Exit on next ## header
|
|
116
|
+
if (inStepsSection && /^##\s/.test(line)) {
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
// Collect numbered list items or checkbox items
|
|
120
|
+
if (inStepsSection) {
|
|
121
|
+
const match = line.match(/^\s*(?:\d+\.|- \[[ x]\])\s+(.+)/);
|
|
122
|
+
if (match && match[1].trim()) {
|
|
123
|
+
steps.push(match[1].trim());
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Fallback: if no steps section found, look for any numbered items (limit MAX_FALLBACK_STEPS)
|
|
129
|
+
const MAX_FALLBACK_STEPS = 10;
|
|
130
|
+
if (steps.length === 0) {
|
|
131
|
+
for (const line of planContent.split("\n")) {
|
|
132
|
+
const match = line.match(/^\s*\d+\.\s+(.+)/);
|
|
133
|
+
if (match && match[1].trim()) {
|
|
134
|
+
steps.push(match[1].trim());
|
|
135
|
+
if (steps.length >= MAX_FALLBACK_STEPS) break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return steps;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
export function handlePlanComplete(
|
|
145
|
+
pi: ExtensionAPI,
|
|
146
|
+
ctx: ExtensionContext,
|
|
147
|
+
state: PlanState,
|
|
148
|
+
isolation: string,
|
|
149
|
+
execMode: string,
|
|
150
|
+
): void {
|
|
151
|
+
const planFilePath = state.planFilePath;
|
|
152
|
+
|
|
153
|
+
// Build mode-specific steer
|
|
154
|
+
const modeMessages: Record<string, string> = {
|
|
155
|
+
subagent: "Execute via subagent-driven development: delegate each task to an independent subagent for parallel execution.",
|
|
156
|
+
goal: "Execute via /goal: set up tracked task decomposition with budget control using the goal extension.",
|
|
157
|
+
"single-agent": "Execute step by step in the current session.",
|
|
158
|
+
};
|
|
159
|
+
const modeHint = modeMessages[execMode] ?? modeMessages["single-agent"];
|
|
160
|
+
|
|
161
|
+
const executeMessage =
|
|
162
|
+
`Plan approved by user. Plan file: ${planFilePath}\n\n` +
|
|
163
|
+
`Execution mode: ${execMode}\n` +
|
|
164
|
+
`${modeHint}\n\n` +
|
|
165
|
+
`Read the plan file and start implementing.`;
|
|
166
|
+
|
|
167
|
+
switch (isolation) {
|
|
168
|
+
case "compact": {
|
|
169
|
+
ctx.compact({
|
|
170
|
+
customInstructions: `Plan file: ${planFilePath}. Read plan and execute implementation.`,
|
|
171
|
+
onComplete: () => {
|
|
172
|
+
pi.sendUserMessage(executeMessage, { deliverAs: "steer" });
|
|
173
|
+
tryGoalInit(pi, planFilePath, ctx);
|
|
174
|
+
},
|
|
175
|
+
onError: (_error: Error) => {
|
|
176
|
+
ctx.ui.notify("Compact failed, continuing without isolation.", "warning");
|
|
177
|
+
pi.sendUserMessage(executeMessage, { deliverAs: "steer" });
|
|
178
|
+
tryGoalInit(pi, planFilePath, ctx);
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
case "tree": {
|
|
185
|
+
ctx.ui.notify("Use /tree to manually navigate back. Plan file: " + planFilePath, "info");
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
case "direct":
|
|
190
|
+
default: {
|
|
191
|
+
pi.sendUserMessage(executeMessage, { deliverAs: "steer" });
|
|
192
|
+
tryGoalInit(pi, planFilePath, ctx);
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { registerPlanCommand } from "./command.js";
|
|
4
|
+
import { type PlanSessionMap, reconstructPlanState } from "./state.js";
|
|
5
|
+
import { registerPlanTool } from "./tool.js";
|
|
6
|
+
import { updatePlanWidget } from "./widget.js";
|
|
7
|
+
|
|
8
|
+
export default function planExtension(pi: ExtensionAPI) {
|
|
9
|
+
// Per-session state cache — keyed by sessionId
|
|
10
|
+
const sessions: PlanSessionMap = new Map();
|
|
11
|
+
|
|
12
|
+
// Register tool and command
|
|
13
|
+
registerPlanTool(pi, sessions);
|
|
14
|
+
registerPlanCommand(pi, sessions);
|
|
15
|
+
|
|
16
|
+
// Dynamic import compact handlers — avoids cross-group static import
|
|
17
|
+
import("./compact.js").then(({ registerPlanEventHandlers }) => {
|
|
18
|
+
registerPlanEventHandlers(pi, sessions);
|
|
19
|
+
}).catch((_e: unknown) => {
|
|
20
|
+
console.warn("[pi-plan] compact handlers load failed:", _e);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Reconstruct state on session start
|
|
24
|
+
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
25
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
26
|
+
const state = reconstructPlanState(ctx);
|
|
27
|
+
sessions.set(sessionId, state);
|
|
28
|
+
updatePlanWidget(ctx, state);
|
|
29
|
+
// If plan mode was active, re-restrict tools to read-only set
|
|
30
|
+
if (state.isActive) {
|
|
31
|
+
pi.setActiveTools(["read", "bash", "grep", "find", "ls", "plan"]);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Clean up on session end
|
|
36
|
+
pi.on("session_shutdown", async (_event: unknown, ctx: ExtensionContext) => {
|
|
37
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
38
|
+
sessions.delete(sessionId);
|
|
39
|
+
});
|
|
40
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { CustomEntry, ExtensionAPI, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type PlanPhase = "idle" | "brainstorming" | "writing" | "complete";
|
|
4
|
+
|
|
5
|
+
export interface PlanState {
|
|
6
|
+
isActive: boolean;
|
|
7
|
+
phase: PlanPhase;
|
|
8
|
+
planFilePath: string;
|
|
9
|
+
requirement: string;
|
|
10
|
+
templateName: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_PLAN_STATE: PlanState = {
|
|
14
|
+
isActive: false,
|
|
15
|
+
phase: "idle",
|
|
16
|
+
planFilePath: "",
|
|
17
|
+
requirement: "",
|
|
18
|
+
templateName: "",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Per-session state cache. Keyed by sessionId. */
|
|
22
|
+
export type PlanSessionMap = Map<string, PlanState>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get plan state for a session. Returns cached state if available,
|
|
26
|
+
* otherwise reconstructs from sessionManager and caches it.
|
|
27
|
+
*/
|
|
28
|
+
export function getPlanState(
|
|
29
|
+
sessions: PlanSessionMap,
|
|
30
|
+
sessionId: string,
|
|
31
|
+
ctx: ExtensionContext,
|
|
32
|
+
): PlanState {
|
|
33
|
+
const cached = sessions.get(sessionId);
|
|
34
|
+
if (cached) return cached;
|
|
35
|
+
|
|
36
|
+
const reconstructed = reconstructPlanState(ctx);
|
|
37
|
+
sessions.set(sessionId, reconstructed);
|
|
38
|
+
return reconstructed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function persistPlanState(pi: ExtensionAPI, state: PlanState): void {
|
|
42
|
+
pi.appendEntry("plan-state", {
|
|
43
|
+
isActive: state.isActive,
|
|
44
|
+
phase: state.phase,
|
|
45
|
+
planFilePath: state.planFilePath,
|
|
46
|
+
requirement: state.requirement,
|
|
47
|
+
templateName: state.templateName,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reset plan state to idle, persist, and clean up session cache. */
|
|
52
|
+
export function resetPlanState(
|
|
53
|
+
pi: ExtensionAPI,
|
|
54
|
+
sessions: PlanSessionMap,
|
|
55
|
+
sessionId: string,
|
|
56
|
+
ctx: ExtensionContext,
|
|
57
|
+
): PlanState {
|
|
58
|
+
const state = getPlanState(sessions, sessionId, ctx);
|
|
59
|
+
state.isActive = false;
|
|
60
|
+
state.phase = "idle";
|
|
61
|
+
state.planFilePath = "";
|
|
62
|
+
state.requirement = "";
|
|
63
|
+
state.templateName = "";
|
|
64
|
+
persistPlanState(pi, state);
|
|
65
|
+
sessions.delete(sessionId);
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isPlanStateEntry(entry: SessionEntry): entry is CustomEntry<Partial<PlanState>> & { customType: "plan-state" } {
|
|
70
|
+
const e = entry as unknown as Record<string, unknown>;
|
|
71
|
+
return e.type === "custom" && e.customType === "plan-state" && typeof e.data === "object" && e.data !== null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function reconstructPlanState(ctx: ExtensionContext): PlanState {
|
|
75
|
+
const state = { ...DEFAULT_PLAN_STATE };
|
|
76
|
+
const entries = ctx.sessionManager.getEntries();
|
|
77
|
+
|
|
78
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
79
|
+
if (isPlanStateEntry(entries[i])) {
|
|
80
|
+
const data = (entries[i] as unknown as { data: Partial<PlanState> }).data;
|
|
81
|
+
state.isActive = data.isActive ?? false;
|
|
82
|
+
state.phase = data.phase ?? "idle";
|
|
83
|
+
state.planFilePath = data.planFilePath ?? "";
|
|
84
|
+
state.requirement = data.requirement ?? "";
|
|
85
|
+
state.templateName = data.templateName ?? "";
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return state;
|
|
91
|
+
}
|