@zhushanwen/pi-plan 0.3.15 → 0.4.1

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.15",
3
+ "version": "0.4.1",
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.4",
24
- "@zhushanwen/pi-goal": "0.11.1"
24
+ "@zhushanwen/pi-goal": "0.12.1"
25
25
  },
26
26
  "peerDependenciesMeta": {
27
27
  "@earendil-works/pi-ai": {
@@ -38,7 +38,7 @@
38
38
  "templates/"
39
39
  ],
40
40
  "dependencies": {
41
- "@zhushanwen/pi-extension-logger": "0.3.1"
41
+ "@zhushanwen/pi-extension-logger": "0.4.1"
42
42
  },
43
43
  "scripts": {
44
44
  "typecheck": "npx tsc --noEmit",
package/src/command.ts CHANGED
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
 
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
 
6
- import type { PlanSessionMap } from "./state.js";
6
+ import type { PlanSessionMap, PlanState } from "./state.js";
7
7
  import { getPlanState, persistPlanState, resetPlanState } from "./state.js";
8
8
  import { updatePlanWidget } from "./widget.js";
9
9
 
@@ -88,7 +88,7 @@ async function handleAbort(
88
88
  sessions: PlanSessionMap,
89
89
  sessionId: string,
90
90
  ctx: ExtensionContext,
91
- state: PlanSessionMap extends Map<string, infer V> ? V : never,
91
+ state: PlanState,
92
92
  ): Promise<void> {
93
93
  if (!state.isActive) {
94
94
  ctx.ui.notify("No active plan mode.", "info");
@@ -104,7 +104,7 @@ async function handleAbort(
104
104
  /** Handle /plan status subcommand */
105
105
  function handleStatus(
106
106
  ctx: ExtensionContext,
107
- state: PlanSessionMap extends Map<string, infer V> ? V : never,
107
+ state: PlanState,
108
108
  ): void {
109
109
  if (!state.isActive) {
110
110
  ctx.ui.notify("No active plan mode.", "info");
@@ -136,7 +136,7 @@ function handleEnterPlanMode(
136
136
  sessions: PlanSessionMap,
137
137
  sessionId: string,
138
138
  ctx: ExtensionContext,
139
- state: PlanSessionMap extends Map<string, infer V> ? V : never,
139
+ state: PlanState,
140
140
  requirement: string,
141
141
  ): void {
142
142
  const slug = requirement
@@ -157,7 +157,7 @@ function handleEnterPlanMode(
157
157
  persistPlanState(pi, state);
158
158
  updatePlanWidget(ctx, state);
159
159
 
160
- // Restrict tools to read-only set during plan mode
160
+ // Restrict tools to the plan-mode set (includes bash — file-write constraints come from the injected plan mode prompt below)
161
161
  pi.setActiveTools(["read", "bash", "grep", "find", "ls", "plan"]);
162
162
 
163
163
  // Inject plan mode system prompt inline
@@ -183,27 +183,6 @@ function handleEnterPlanMode(
183
183
  `## Phase D: Completion\n` +
184
184
  `1. Ask user to review the complete plan.\n` +
185
185
  `2. Call plan tool (complete) with isolation method (compact/tree/direct).\n` +
186
- `3. After plan complete: check subagent capability suggest goal + wave or single-agent execution.`,
186
+ `3. After plan complete: the user picks an execution path (subagent-driven / goal-driven / single-agent) via the completion dialog.`,
187
187
  );
188
188
  }
189
-
190
- /**
191
- * Programmatic entry to start plan mode (for `pi.__planStart`).
192
- *
193
- * Mirrors the `/plan` command's enter logic. Returns false if plan mode is
194
- * already active (caller can then surface a message or wait).
195
- *
196
- * @returns true if plan mode started; false if already active
197
- */
198
- export function startPlanMode(
199
- pi: ExtensionAPI,
200
- sessions: PlanSessionMap,
201
- ctx: ExtensionContext,
202
- requirement: string,
203
- ): boolean {
204
- const sessionId = ctx.sessionManager.getSessionId();
205
- const state = getPlanState(sessions, sessionId, ctx);
206
- if (state.isActive) return false;
207
- handleEnterPlanMode(pi, sessions, sessionId, ctx, state, requirement);
208
- return true;
209
- }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { getLogger } from "@zhushanwen/pi-extension-logger";
3
3
 
4
- import { registerPlanCommand, startPlanMode } from "./command.js";
4
+ import { registerPlanCommand } from "./command.js";
5
5
  import { type PlanSessionMap, reconstructPlanState } from "./state.js";
6
6
  import { registerPlanTool } from "./tool.js";
7
7
  import { updatePlanWidget } from "./widget.js";
@@ -16,13 +16,6 @@ export default function planExtension(pi: ExtensionAPI) {
16
16
  registerPlanTool(pi, sessions);
17
17
  registerPlanCommand(pi, sessions);
18
18
 
19
- // External API: __planStart(allow other extensions to start plan mode programmatically, #9)
20
- // 交叉类型单步断言(ExtensionAPI 可赋给 ExtensionAPI & { __planStart? },无需 unknown 中转)
21
- const api = pi as ExtensionAPI & { __planStart?: (requirement: string, ctx: ExtensionContext) => boolean };
22
- api.__planStart = (requirement: string, ctx: ExtensionContext): boolean => {
23
- return startPlanMode(pi, sessions, ctx, requirement);
24
- };
25
-
26
19
  // Dynamic import compact handlers — avoids cross-group static import
27
20
  import("./compact.js").then(({ registerPlanEventHandlers }) => {
28
21
  registerPlanEventHandlers(pi, sessions);
@@ -36,7 +29,7 @@ export default function planExtension(pi: ExtensionAPI) {
36
29
  const state = reconstructPlanState(ctx);
37
30
  sessions.set(sessionId, state);
38
31
  updatePlanWidget(ctx, state);
39
- // If plan mode was active, re-restrict tools to read-only set
32
+ // If plan mode was active, re-restrict tools to the plan-mode set (includes bash — file-write constraints come from the injected plan mode prompt)
40
33
  if (state.isActive) {
41
34
  pi.setActiveTools(["read", "bash", "grep", "find", "ls", "plan"]);
42
35
  }
package/src/tool.ts CHANGED
@@ -6,7 +6,7 @@ import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendi
6
6
  import { Text } from "@earendil-works/pi-tui";
7
7
  import { Type } from "typebox";
8
8
 
9
- import type { PlanSessionMap } from "./state.js";
9
+ import type { PlanSessionMap, PlanState } from "./state.js";
10
10
  import { getPlanState, persistPlanState, resetPlanState } from "./state.js";
11
11
  import { listTemplates, loadTemplate } from "./templates.js";
12
12
  import { updatePlanWidget } from "./widget.js";
@@ -171,6 +171,172 @@ function renderPlanResult(
171
171
  }
172
172
  }
173
173
 
174
+ // ── Action executors (one per switch case) ─────────────────────────
175
+
176
+ /** Execute result envelope (shared shape returned by every action). */
177
+ interface ActionResult {
178
+ content: Array<{ type: "text"; text: string }>;
179
+ details: PlanDetails;
180
+ }
181
+
182
+ function executeListTemplate(projectDir: string): ActionResult {
183
+ const templates = listTemplates(projectDir);
184
+ return {
185
+ content: [{ type: "text" as const, text: `${templates.length} templates available` }],
186
+ details: { action: "list-template", templates },
187
+ };
188
+ }
189
+
190
+ function executeSelectTemplate(
191
+ pi: ExtensionAPI,
192
+ params: Record<string, unknown>,
193
+ state: PlanState,
194
+ projectDir: string,
195
+ ): ActionResult {
196
+ const templateName = params.templateName as string;
197
+ if (!templateName) {
198
+ throw new Error("templateName is required for select-template");
199
+ }
200
+ const content = loadTemplate(templateName, projectDir);
201
+ if (!content) {
202
+ throw new Error(`Template not found: ${templateName}`);
203
+ }
204
+ state.templateName = templateName;
205
+ state.phase = "writing";
206
+ persistPlanState(pi, state);
207
+ return {
208
+ content: [{ type: "text" as const, text: `Template selected: ${templateName}` }],
209
+ details: { action: "select-template", templateName, content, phase: state.phase },
210
+ };
211
+ }
212
+
213
+ function executeCreateTemplate(params: Record<string, unknown>, projectDir: string): ActionResult {
214
+ const templateName = params.templateName as string;
215
+ const templateContent = params.templateContent as string;
216
+ if (!templateName || !templateContent) {
217
+ throw new Error("templateName and templateContent are required for create-template");
218
+ }
219
+ const sanitizedName = templateName.replace(/[^a-zA-Z0-9_-]/g, "");
220
+ if (!sanitizedName) {
221
+ throw new Error("Invalid template name: must contain alphanumeric characters");
222
+ }
223
+ const templateDir = path.join(projectDir, ".pi", "plan-templates");
224
+ fs.mkdirSync(templateDir, { recursive: true });
225
+ const filePath = path.join(templateDir, `${sanitizedName}.md`);
226
+ fs.writeFileSync(filePath, templateContent);
227
+ return {
228
+ content: [{ type: "text" as const, text: `Template created: ${sanitizedName}` }],
229
+ details: {
230
+ action: "create-template",
231
+ templateName: sanitizedName,
232
+ templateDir: relativePath(filePath, projectDir),
233
+ },
234
+ };
235
+ }
236
+
237
+ function executeAbort(
238
+ pi: ExtensionAPI,
239
+ sessions: PlanSessionMap,
240
+ sessionId: string,
241
+ ctx: ExtensionContext,
242
+ ): ActionResult {
243
+ const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
244
+ updatePlanWidget(ctx, updatedState);
245
+ restoreFullToolSet(pi);
246
+ return {
247
+ content: [{ type: "text" as const, text: "Plan mode aborted. Full tool access restored." }],
248
+ details: { action: "abort" },
249
+ };
250
+ }
251
+
252
+ /** Build execution options filtered by available capabilities. */
253
+ async function buildExecOptions(pi: ExtensionAPI): Promise<string[]> {
254
+ const execOptions = ["Subagent-driven execution"];
255
+ const hasGoal = (await import("./compact.js")).detectGoalCapability(pi);
256
+ if (hasGoal) execOptions.push("Goal-driven execution (/goal)");
257
+ execOptions.push("Single-agent (current session)");
258
+ execOptions.push("Modify the plan first", "Save for later");
259
+ return execOptions;
260
+ }
261
+
262
+ /** Map the user's execution-method choice to the chosenMode string. */
263
+ function chosenModeFromChoice(choice: string): string {
264
+ if (choice === "Subagent-driven execution") return "subagent";
265
+ if (choice === "Goal-driven execution (/goal)") return "goal";
266
+ return "single-agent";
267
+ }
268
+
269
+ /** Outcome of the complete-action execution-method prompt. */
270
+ type CompleteChoiceOutcome =
271
+ | { kind: "cancelled"; result: ActionResult }
272
+ | { kind: "mode"; chosenMode: string };
273
+
274
+ /**
275
+ * Prompt the user for an execution method (no-op selection when headless).
276
+ * Cancel / "Modify the plan first" / "Save for later" → cancelled with a
277
+ * complete-cancelled result; otherwise the mapped chosenMode.
278
+ */
279
+ async function resolveCompleteChoice(ctx: ExtensionContext, pi: ExtensionAPI): Promise<CompleteChoiceOutcome> {
280
+ const execOptions = await buildExecOptions(pi);
281
+
282
+ if (typeof ctx.ui.select !== "function") {
283
+ return { kind: "mode", chosenMode: "single-agent" };
284
+ }
285
+ const choice = await ctx.ui.select("Plan is ready. Choose execution method:", execOptions);
286
+ if (!choice || choice === "Modify the plan first" || choice === "Save for later") {
287
+ return {
288
+ kind: "cancelled",
289
+ result: {
290
+ content: [
291
+ { type: "text" as const, text: `User chose: ${choice ?? "cancelled"}. Staying in plan mode.` },
292
+ ],
293
+ details: { action: "complete-cancelled", reason: choice ?? "cancelled" },
294
+ },
295
+ };
296
+ }
297
+ return { kind: "mode", chosenMode: chosenModeFromChoice(choice) };
298
+ }
299
+
300
+ /** complete action: prompt for execution mode, persist final phase, restore tools, reset state. */
301
+ async function executeComplete(
302
+ pi: ExtensionAPI,
303
+ ctx: ExtensionContext,
304
+ params: Record<string, unknown>,
305
+ state: PlanState,
306
+ sessions: PlanSessionMap,
307
+ sessionId: string,
308
+ projectDir: string,
309
+ ): Promise<ActionResult> {
310
+ const choice = await resolveCompleteChoice(ctx, pi);
311
+ if (choice.kind === "cancelled") {
312
+ return choice.result;
313
+ }
314
+ const chosenMode = choice.chosenMode;
315
+
316
+ // Persist final phase before cleanup
317
+ const planFilePath = state.planFilePath;
318
+ const isolation = (params.isolation as string) ?? "direct";
319
+ state.phase = "complete";
320
+ persistPlanState(pi, state);
321
+
322
+ // Restore full tool set
323
+ restoreFullToolSet(pi);
324
+
325
+ // Execute completion handler (compact/tree setup)
326
+ const { handlePlanComplete } = await import("./compact.js");
327
+ handlePlanComplete(pi, ctx, state, isolation, chosenMode);
328
+
329
+ // Reset state and clear widget — same as abort
330
+ const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
331
+ updatePlanWidget(ctx, updatedState);
332
+
333
+ const displayPath = relativePath(planFilePath, projectDir);
334
+ return {
335
+ content: [{ type: "text" as const, text: `Plan approved. File: ${displayPath}` }],
336
+ details: { action: "complete", planFilePath: displayPath, isolation, execMode: chosenMode },
337
+ };
338
+ }
339
+
174
340
  // ── Register tool ──────────────────────────────────────────────────
175
341
 
176
342
  export function registerPlanTool(
@@ -182,7 +348,7 @@ export function registerPlanTool(
182
348
  label: "Plan Mode",
183
349
  description:
184
350
  "Manages plan mode lifecycle (template selection, state transitions, completion). " +
185
- "NOT for writing plan content — use the 'write' tool to write plan.md. " +
351
+ "NOT for writing plan content — write plan.md via the bash tool (e.g. cat heredoc). " +
186
352
  "Actions: list-template, select-template, create-template, complete, abort.",
187
353
  parameters: Type.Object({
188
354
  action: StringEnum(PLAN_ACTIONS, { description: "Action to perform" }),
@@ -195,25 +361,25 @@ export function registerPlanTool(
195
361
  ),
196
362
  }),
197
363
  promptSnippet:
198
- "## When to use this tool vs 'write'\n" +
364
+ "## When to use this tool vs the bash tool\n" +
199
365
  "Use 'plan' tool ONLY for plan mode state management:\n" +
200
366
  "- list-template / select-template / create-template — template operations\n" +
201
367
  "- complete — user approved plan, exit plan mode\n" +
202
368
  "- abort — cancel plan mode\n" +
203
369
  "\n" +
204
- "Use 'write' tool for ALL plan content: writing plan.md, updating plan chapters.\n" +
370
+ "Use the bash tool for ALL plan content: writing plan.md, updating plan chapters (e.g. cat heredoc).\n" +
205
371
  "\n" +
206
372
  "## End-to-end workflow example\n" +
207
373
  "1. /plan 'add dark mode' — user enters plan mode\n" +
208
374
  "2. AI explores codebase (read, grep, bash) — brainstorming\n" +
209
375
  "3. plan(action='list-template') — show available templates\n" +
210
376
  "4. User picks template → plan(action='select-template', templateName='feature-plan')\n" +
211
- "5. write({path: planFilePath, content: '...filled template...'}) — write plan content\n" +
377
+ "5. bash: cat > \"$PLAN_FILE\" <<'EOF' ... EOF — write plan content\n" +
212
378
  "6. User reviews → plan(action='complete', isolation='compact') — exit plan mode\n" +
213
379
  "\n" +
214
380
  "## Common mistakes\n" +
215
- "❌ plan(action='complete') to 'write the plan' — WRONG, use write tool\n" +
216
- "❌ Calling plan tool when user says 'write plan to file' — use write tool\n" +
381
+ "❌ plan(action='complete') to 'write the plan' — WRONG, write plan.md via the bash tool\n" +
382
+ "❌ Calling plan tool when user says 'write plan to file' — use the bash tool\n" +
217
383
  "✅ plan(action='list-template') to discover templates\n" +
218
384
  "✅ plan(action='complete') AFTER plan.md is written AND user approves",
219
385
  renderResult(
@@ -240,113 +406,20 @@ export function registerPlanTool(
240
406
  const projectDir = ctx.cwd;
241
407
 
242
408
  switch (action) {
243
- case "list-template": {
244
- const templates = listTemplates(projectDir);
245
- return {
246
- content: [{ type: "text" as const, text: `${templates.length} templates available` }],
247
- details: { action: "list-template", templates },
248
- };
249
- }
250
-
251
- case "select-template": {
252
- const templateName = params.templateName as string;
253
- if (!templateName) {
254
- throw new Error("templateName is required for select-template");
255
- }
256
- const content = loadTemplate(templateName, projectDir);
257
- if (!content) {
258
- throw new Error(`Template not found: ${templateName}`);
259
- }
260
- state.templateName = templateName;
261
- state.phase = "writing";
262
- persistPlanState(pi, state);
263
- return {
264
- content: [{ type: "text" as const, text: `Template selected: ${templateName}` }],
265
- details: { action: "select-template", templateName, content, phase: state.phase },
266
- };
267
- }
268
-
269
- case "create-template": {
270
- const templateName = params.templateName as string;
271
- const templateContent = params.templateContent as string;
272
- if (!templateName || !templateContent) {
273
- throw new Error("templateName and templateContent are required for create-template");
274
- }
275
- const sanitizedName = templateName.replace(/[^a-zA-Z0-9_-]/g, "");
276
- if (!sanitizedName) {
277
- throw new Error("Invalid template name: must contain alphanumeric characters");
278
- }
279
- const templateDir = path.join(projectDir, ".pi", "plan-templates");
280
- fs.mkdirSync(templateDir, { recursive: true });
281
- const filePath = path.join(templateDir, `${sanitizedName}.md`);
282
- fs.writeFileSync(filePath, templateContent);
283
- return {
284
- content: [{ type: "text" as const, text: `Template created: ${sanitizedName}` }],
285
- details: {
286
- action: "create-template",
287
- templateName: sanitizedName,
288
- templateDir: relativePath(filePath, projectDir),
289
- },
290
- };
291
- }
292
-
293
- case "complete": {
294
- // Build execution options filtered by available capabilities
295
- const execOptions = ["Subagent-driven execution"];
296
- const hasGoal = (await import("./compact.js")).detectGoalCapability(pi);
297
- if (hasGoal) execOptions.push("Goal-driven execution (/goal)");
298
- execOptions.push("Single-agent (current session)");
299
- execOptions.push("Modify the plan first", "Save for later");
300
-
301
- let chosenMode = "single-agent";
302
- if (typeof ctx.ui.select === "function") {
303
- const choice = await ctx.ui.select("Plan is ready. Choose execution method:", execOptions);
304
- if (!choice || choice === "Modify the plan first" || choice === "Save for later") {
305
- return {
306
- content: [
307
- { type: "text" as const, text: `User chose: ${choice ?? "cancelled"}. Staying in plan mode.` },
308
- ],
309
- details: { action: "complete-cancelled", reason: choice ?? "cancelled" },
310
- };
311
- }
312
- if (choice === "Subagent-driven execution") chosenMode = "subagent";
313
- else if (choice === "Goal-driven execution (/goal)") chosenMode = "goal";
314
- else chosenMode = "single-agent";
315
- }
316
-
317
- // Persist final phase before cleanup
318
- const planFilePath = state.planFilePath;
319
- const isolation = (params.isolation as string) ?? "direct";
320
- state.phase = "complete";
321
- persistPlanState(pi, state);
322
-
323
- // Restore full tool set
324
- restoreFullToolSet(pi);
325
-
326
- // Execute completion handler (compact/tree setup)
327
- const { handlePlanComplete } = await import("./compact.js");
328
- handlePlanComplete(pi, ctx, state, isolation, chosenMode);
329
-
330
- // Reset state and clear widget — same as abort
331
- const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
332
- updatePlanWidget(ctx, updatedState);
333
-
334
- const displayPath = relativePath(planFilePath, projectDir);
335
- return {
336
- content: [{ type: "text" as const, text: `Plan approved. File: ${displayPath}` }],
337
- details: { action: "complete", planFilePath: displayPath, isolation, execMode: chosenMode },
338
- };
339
- }
340
-
341
- case "abort": {
342
- const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
343
- updatePlanWidget(ctx, updatedState);
344
- restoreFullToolSet(pi);
345
- return {
346
- content: [{ type: "text" as const, text: "Plan mode aborted. Full tool access restored." }],
347
- details: { action: "abort" },
348
- };
349
- }
409
+ case "list-template":
410
+ return executeListTemplate(projectDir);
411
+
412
+ case "select-template":
413
+ return executeSelectTemplate(pi, params, state, projectDir);
414
+
415
+ case "create-template":
416
+ return executeCreateTemplate(params, projectDir);
417
+
418
+ case "complete":
419
+ return await executeComplete(pi, ctx, params, state, sessions, sessionId, projectDir);
420
+
421
+ case "abort":
422
+ return executeAbort(pi, sessions, sessionId, ctx);
350
423
  }
351
424
  },
352
425
  });