@bacnh85/pi-plan 0.1.1 → 0.1.2

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.
Files changed (3) hide show
  1. package/README.md +17 -10
  2. package/index.ts +121 -53
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  Pi extension that adds a lightweight plan mode inspired by Codex and Claude Code:
4
4
 
5
- - Toggle plan mode with `/plan`, `/plan on`, `/plan off`, or `Ctrl+Alt+P`.
6
- - Pick separate thinking/reasoning levels for planning and normal execution with `/plan levels`.
5
+ - Toggle plan mode with `/plan` or `Ctrl+Alt+P`.
6
+ - Remembers separate thinking/reasoning levels for planning and normal execution based on the active mode when you change Pi's reasoning level.
7
7
  - Keeps planning read-only by disabling built-in `edit`/`write` and blocking destructive shell commands.
8
8
  - Provides a `write_plan` tool so the agent writes reviewable Markdown plans into `.agents/plans/` in the current workspace.
9
+ - Provides an `ask_plan_question` tool so the agent can ask selection-style clarifying questions during planning, with an option for free-form user input.
9
10
  - Prompts after a plan is written so you can approve execution, approve only, keep planning, or refine with feedback.
10
11
 
11
12
  ## Install
@@ -39,29 +40,35 @@ pi --plan
39
40
  | Command / shortcut | Description |
40
41
  | --- | --- |
41
42
  | `/plan` | Toggle plan mode. |
42
- | `/plan on` / `/plan off` | Explicitly enter or exit plan mode. |
43
- | `/plan levels` | Select planning and normal/execution thinking levels. |
44
- | `/plan status` | Show current mode, thinking levels, and last plan file. |
45
- | `/plan high` | Set the plan-mode thinking level directly (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`). |
46
43
  | `Ctrl+Alt+P` | Toggle plan mode. |
47
44
 
48
45
  ## Workflow
49
46
 
50
47
  1. Enter plan mode.
51
48
  2. Ask pi to research the task and propose an implementation.
52
- 3. The model explores read-only context and calls `write_plan`.
53
- 4. The plan is saved under `.agents/plans/<timestamp>-<title>.md`.
54
- 5. Choose one of the approval options:
49
+ 3. The model explores read-only context. If a consequential decision remains ambiguous, it can call `ask_plan_question` so you can choose an option or type your own answer.
50
+ 4. The model calls `write_plan`.
51
+ 5. The plan is saved under `.agents/plans/<timestamp>-<title>.md`.
52
+ 6. Choose one of the approval options:
55
53
  - **Approve and execute**: exits plan mode, restores tools, applies normal thinking level, and sends a follow-up execution prompt.
56
54
  - **Approve only**: exits plan mode without starting work.
57
55
  - **Keep planning**: remains in plan mode.
58
56
  - **Refine with feedback**: sends your feedback as a follow-up planning prompt.
59
57
 
58
+ ## Reasoning levels
59
+
60
+ pi-plan remembers two reasoning levels:
61
+
62
+ - Change Pi's active reasoning level while plan mode is active to update the planning level.
63
+ - Change Pi's active reasoning level in normal or execution mode to update the normal/execution level.
64
+
65
+ The remembered levels are restored when switching modes and across resumed sessions.
66
+
60
67
  ## Design notes
61
68
 
62
69
  Research findings used for this extension:
63
70
 
64
- - Pi has no built-in plan mode by design, but extensions can implement it with commands, shortcuts, tool gating, status widgets, and prompt injection.
71
+ - Pi has no built-in plan mode by design, but extensions can implement it with commands, shortcuts, tools, tool gating, and prompt injection.
65
72
  - Claude Code plan mode emphasizes read-only exploration, writing a Markdown plan, and an approval flow before edits.
66
73
  - Codex guidance recommends plan mode for complex or ambiguous tasks and using higher reasoning levels for harder planning/debugging work.
67
74
 
package/index.ts CHANGED
@@ -7,8 +7,9 @@ import path from "node:path";
7
7
  const STATUS_KEY = "pi-plan";
8
8
  const PLAN_DIR = path.join(".agents", "plans");
9
9
  const PLAN_TOOL = "write_plan";
10
+ const PLAN_QUESTION_TOOL = "ask_plan_question";
10
11
  const PLAN_MODE_DISABLED_TOOLS = new Set(["edit", "write"]);
11
- const DEFAULT_PLAN_TOOLS = ["read", "bash", "grep", "find", "ls", PLAN_TOOL];
12
+ const DEFAULT_PLAN_TOOLS = ["read", "bash", "grep", "find", "ls", PLAN_TOOL, PLAN_QUESTION_TOOL];
12
13
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
13
14
 
14
15
  type ThinkingLevel = (typeof THINKING_LEVELS)[number];
@@ -31,6 +32,17 @@ interface WritePlanParams {
31
32
  status?: PlanStatus;
32
33
  }
33
34
 
35
+ interface PlanQuestionOption {
36
+ label: string;
37
+ description?: string;
38
+ }
39
+
40
+ interface PlanQuestionParams {
41
+ question: string;
42
+ options: PlanQuestionOption[];
43
+ allowOther?: boolean;
44
+ }
45
+
34
46
  const DESTRUCTIVE_BASH_PATTERNS = [
35
47
  /\brm\b/i,
36
48
  /\brmdir\b/i,
@@ -103,6 +115,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
103
115
  let lastPlanPath: string | undefined;
104
116
  let lastPlanTitle: string | undefined;
105
117
  let lastPlanStatus: PlanStatus | undefined;
118
+ let applyingStoredThinking = false;
106
119
 
107
120
  function setStatus(ctx: ExtensionContext): void {
108
121
  if (planModeEnabled) {
@@ -116,17 +129,8 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
116
129
  ctx.ui.setStatus(STATUS_KEY, undefined);
117
130
  }
118
131
 
119
- function setPlanWidget(ctx: ExtensionContext): void {
120
- if (!planModeEnabled) {
121
- ctx.ui.setWidget(STATUS_KEY, undefined);
122
- return;
123
- }
124
- ctx.ui.setWidget(STATUS_KEY, [
125
- ctx.ui.theme.fg("warning", "Plan mode: read-only exploration"),
126
- `Plan file target: ${PLAN_DIR}/`,
127
- `Plan thinking: ${planThinking}; normal thinking: ${normalThinking}`,
128
- "Use write_plan when the plan is ready for approval.",
129
- ]);
132
+ function clearPlanWidget(ctx: ExtensionContext): void {
133
+ ctx.ui.setWidget(STATUS_KEY, undefined);
130
134
  }
131
135
 
132
136
  function persistState(): void {
@@ -154,7 +158,23 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
154
158
  }
155
159
 
156
160
  function applyThinking(level: ThinkingLevel): void {
161
+ applyingStoredThinking = true;
157
162
  pi.setThinkingLevel(level);
163
+ setTimeout(() => {
164
+ applyingStoredThinking = false;
165
+ }, 0);
166
+ }
167
+
168
+ function recordActiveThinkingLevel(level: ThinkingLevel, ctx: ExtensionContext): void {
169
+ if (planModeEnabled) {
170
+ if (planThinking === level) return;
171
+ planThinking = level;
172
+ } else {
173
+ if (normalThinking === level) return;
174
+ normalThinking = level;
175
+ }
176
+ setStatus(ctx);
177
+ persistState();
158
178
  }
159
179
 
160
180
  function enterPlanMode(ctx: ExtensionContext): void {
@@ -163,7 +183,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
163
183
  enablePlanTools();
164
184
  applyThinking(planThinking);
165
185
  setStatus(ctx);
166
- setPlanWidget(ctx);
186
+ clearPlanWidget(ctx);
167
187
  persistState();
168
188
  ctx.ui.notify(`Plan mode enabled. Thinking=${planThinking}. Plans will be written to ${PLAN_DIR}/`, "info");
169
189
  }
@@ -174,43 +194,14 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
174
194
  restoreTools();
175
195
  if (restoreThinking) applyThinking(normalThinking);
176
196
  setStatus(ctx);
177
- setPlanWidget(ctx);
197
+ clearPlanWidget(ctx);
178
198
  persistState();
179
199
  ctx.ui.notify(`Plan mode disabled. Thinking=${pi.getThinkingLevel()}.`, "info");
180
200
  }
181
201
 
182
- async function chooseThinkingLevels(ctx: ExtensionContext): Promise<void> {
183
- const plan = await ctx.ui.select("Plan mode reasoning level", [...THINKING_LEVELS]);
184
- if (plan && isThinkingLevel(plan)) planThinking = plan;
185
- const normal = await ctx.ui.select("Normal/execution reasoning level", [...THINKING_LEVELS]);
186
- if (normal && isThinkingLevel(normal)) normalThinking = normal;
187
- if (planModeEnabled) applyThinking(planThinking);
188
- else applyThinking(normalThinking);
189
- setStatus(ctx);
190
- setPlanWidget(ctx);
191
- persistState();
192
- ctx.ui.notify(`pi-plan levels: plan=${planThinking}, normal=${normalThinking}`, "info");
193
- }
194
-
195
202
  async function handlePlanCommand(args: string, ctx: ExtensionContext): Promise<void> {
196
- const subcommand = args.trim();
197
- if (subcommand === "levels") return chooseThinkingLevels(ctx);
198
- if (subcommand === "on" || subcommand === "start") return enterPlanMode(ctx);
199
- if (subcommand === "off" || subcommand === "stop") return leavePlanMode(ctx);
200
- if (subcommand === "status") {
201
- ctx.ui.notify(
202
- [`Mode: ${planModeEnabled ? "planning" : executionMode ? "executing" : "normal"}`, `Plan thinking: ${planThinking}`, `Normal thinking: ${normalThinking}`, `Last plan: ${lastPlanPath ? relativeToCwd(ctx.cwd, lastPlanPath) : "none"}`].join("\n"),
203
- "info",
204
- );
205
- return;
206
- }
207
- if (subcommand.length > 0 && isThinkingLevel(subcommand)) {
208
- planThinking = subcommand;
209
- if (planModeEnabled) applyThinking(planThinking);
210
- setStatus(ctx);
211
- setPlanWidget(ctx);
212
- persistState();
213
- ctx.ui.notify(`Plan thinking set to ${planThinking}`, "info");
203
+ if (args.trim().length > 0) {
204
+ ctx.ui.notify("/plan does not take arguments; use /plan or Ctrl+Alt+P to toggle plan mode.", "warning");
214
205
  return;
215
206
  }
216
207
  if (planModeEnabled) leavePlanMode(ctx);
@@ -253,12 +244,83 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
253
244
  },
254
245
  });
255
246
 
256
- pi.registerCommand("plan", {
257
- description: "Toggle pi-plan mode; subcommands: on, off, levels, status, or a thinking level",
258
- getArgumentCompletions: (prefix) => {
259
- const values = ["on", "off", "levels", "status", ...THINKING_LEVELS].filter((value) => value.startsWith(prefix));
260
- return values.length ? values.map((value) => ({ label: value, value })) : null;
247
+ pi.registerTool({
248
+ name: PLAN_QUESTION_TOOL,
249
+ label: "Ask Plan Question",
250
+ description: "Ask the user a consequential planning question with selectable options and optional free-form input.",
251
+ promptSnippet: "Ask the user a concise planning question with 2-4 concrete options when an open decision materially affects the plan.",
252
+ promptGuidelines: [
253
+ "Use only after repository research leaves a consequential ambiguity that affects the plan.",
254
+ "Prefer 2-4 concrete options with short labels and useful descriptions.",
255
+ "Do not ask about details that can be discovered from the repository.",
256
+ "If the user already gave a preference, incorporate it instead of asking again.",
257
+ ],
258
+ parameters: Type.Object({
259
+ question: Type.String({ description: "The planning question to ask the user" }),
260
+ options: Type.Array(
261
+ Type.Object({
262
+ label: Type.String({ description: "Short selectable option label" }),
263
+ description: Type.Optional(Type.String({ description: "Optional explanation shown with the option" })),
264
+ }),
265
+ { description: "Concrete options for the user to choose from" },
266
+ ),
267
+ allowOther: Type.Optional(Type.Boolean({ description: "Whether to allow a free-form user answer; defaults to true" })),
268
+ }),
269
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
270
+ const typedParams = params as PlanQuestionParams;
271
+ const options = typedParams.options ?? [];
272
+ if (options.length === 0) {
273
+ return {
274
+ content: [{ type: "text", text: "Error: ask_plan_question requires at least one option." }],
275
+ details: { question: typedParams.question, answer: null },
276
+ };
277
+ }
278
+ if (!ctx.hasUI) {
279
+ return {
280
+ content: [{ type: "text", text: "UI is not available. Ask this planning question directly in chat and wait for the user's answer." }],
281
+ details: { question: typedParams.question, options, answer: null },
282
+ };
283
+ }
284
+
285
+ const allowOther = typedParams.allowOther !== false;
286
+ const labels = options.map((option) =>
287
+ option.description ? `${option.label} — ${option.description}` : option.label,
288
+ );
289
+ const otherLabel = "Other / type my answer";
290
+ const choice = await ctx.ui.select(typedParams.question, allowOther ? [...labels, otherLabel] : labels);
291
+ if (!choice) {
292
+ return {
293
+ content: [{ type: "text", text: "User cancelled the planning question." }],
294
+ details: { question: typedParams.question, options, answer: null, cancelled: true },
295
+ };
296
+ }
297
+
298
+ if (choice === otherLabel) {
299
+ const answer = (await ctx.ui.editor("Your answer", ""))?.trim();
300
+ if (!answer) {
301
+ return {
302
+ content: [{ type: "text", text: "User cancelled the planning question." }],
303
+ details: { question: typedParams.question, options, answer: null, cancelled: true },
304
+ };
305
+ }
306
+ return {
307
+ content: [{ type: "text", text: `User wrote: ${answer}` }],
308
+ details: { question: typedParams.question, options, answer, wasCustom: true },
309
+ };
310
+ }
311
+
312
+ const selectedIndex = labels.indexOf(choice);
313
+ const selected = options[selectedIndex];
314
+ const answer = selected?.label ?? choice;
315
+ return {
316
+ content: [{ type: "text", text: `User selected: ${answer}` }],
317
+ details: { question: typedParams.question, options, answer, selectedIndex: selectedIndex + 1, wasCustom: false },
318
+ };
261
319
  },
320
+ });
321
+
322
+ pi.registerCommand("plan", {
323
+ description: "Toggle pi-plan mode",
262
324
  handler: async (args, ctx) => handlePlanCommand(args, ctx),
263
325
  });
264
326
 
@@ -291,7 +353,13 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
291
353
  applyThinking(planThinking);
292
354
  }
293
355
  setStatus(ctx);
294
- setPlanWidget(ctx);
356
+ clearPlanWidget(ctx);
357
+ });
358
+
359
+ pi.on("thinking_level_select", async (event, ctx) => {
360
+ if (applyingStoredThinking) return;
361
+ if (!isThinkingLevel(event.level)) return;
362
+ recordActiveThinkingLevel(event.level, ctx);
295
363
  });
296
364
 
297
365
  pi.on("tool_call", async (event) => {
@@ -311,7 +379,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
311
379
  return {
312
380
  message: {
313
381
  customType: "pi-plan-context",
314
- content: `[PI-PLAN MODE ACTIVE]\nYou are in read-only planning mode. Research the codebase and produce a reviewable implementation plan before making changes.\n\nRules:\n- Do not edit source files, configs, lockfiles, or git state.\n- You may read files, search, inspect git state, and run read-only shell commands.\n- Ask concise clarifying questions if requirements are ambiguous.\n- When the plan is ready, call ${PLAN_TOOL} with a complete Markdown plan.\n- The plan file must live in ${PLAN_DIR}/. Current/next plan path: ${relativePlan}\n\nPlan content should include:\n1. Goal and assumptions.\n2. Key findings with durable file/symbol paths.\n3. Proposed implementation steps.\n4. Verification plan.\n5. Risks, open questions, and rejected alternatives if relevant.`,
382
+ content: `[PI-PLAN MODE ACTIVE]\nYou are in read-only planning mode. Research the codebase and produce a reviewable implementation plan before making changes.\n\nRules:\n- Do not edit source files, configs, lockfiles, or git state.\n- You may read files, search, inspect git state, and run read-only shell commands.\n- Ask concise clarifying questions if requirements are ambiguous. Use ${PLAN_QUESTION_TOOL} for consequential open decisions with 2-4 clear options and an Other/user-opinion path.\n- Do not ask about details you can discover from repository evidence. If the user already gave an opinion, incorporate it instead of asking again.\n- When the plan is ready, call ${PLAN_TOOL} with a complete Markdown plan.\n- The plan file must live in ${PLAN_DIR}/. Current/next plan path: ${relativePlan}\n\nPlan content should include:\n1. Goal and assumptions.\n2. Key findings with durable file/symbol paths.\n3. Proposed implementation steps.\n4. Verification plan.\n5. Risks, open questions, and rejected alternatives if relevant.`,
315
383
  display: false,
316
384
  },
317
385
  };
@@ -350,7 +418,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
350
418
  restoreTools();
351
419
  applyThinking(normalThinking);
352
420
  setStatus(ctx);
353
- setPlanWidget(ctx);
421
+ clearPlanWidget(ctx);
354
422
  persistState();
355
423
  pi.sendUserMessage(`Execute the approved plan in ${relativePlan}. Keep the implementation scoped to the plan, update the plan if reality differs materially, and run the verification described there.`, { deliverAs: "followUp" });
356
424
  } else if (choice === "Approve only (exit plan mode)") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-plan",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Pi extension that adds a plan mode with workspace markdown plans and thinking-level presets.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {