@narumitw/pi-plan-mode 0.1.34 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/package.json +1 -1
- package/src/plan-mode.ts +380 -49
package/README.md
CHANGED
|
@@ -13,8 +13,9 @@ Pi core intentionally does not ship a built-in plan mode; this package provides
|
|
|
13
13
|
- Enables built-in read-only tools by default while Plan mode is active.
|
|
14
14
|
- Disables extension and custom tools by default, with a `/plan tools` selector for explicit user-risk opt-in.
|
|
15
15
|
- Blocks mutating built-in tools and bash commands such as `rm`, `git commit`, dependency installs, redirects, and editor launches.
|
|
16
|
-
- Injects Codex-like Plan mode instructions: explore first, ask
|
|
17
|
-
-
|
|
16
|
+
- Injects Codex-like Plan mode instructions: explore first, ask decision questions for high-impact ambiguity, do not mutate files, and finish with `<proposed_plan>` only when decision-complete.
|
|
17
|
+
- Adds a required `plan_mode_question` tool so the agent can ask structured Plan-mode questions before finalizing a plan.
|
|
18
|
+
- Detects proposed plan blocks and prompts you to implement, stay in Plan mode, or exit and discard the plan.
|
|
18
19
|
- Shows Plan mode state in Pi's statusline as `📝 plan active` or `📝 plan ready`.
|
|
19
20
|
- Persists Plan mode state in the Pi session so resume restores the mode.
|
|
20
21
|
|
|
@@ -46,13 +47,15 @@ pi -e ./extensions/pi-plan-mode
|
|
|
46
47
|
|
|
47
48
|
Use `/plan` to enter Plan mode before writing your planning prompt. Use `/plan <prompt>` to enter Plan mode and immediately submit `<prompt>` as the first Plan-mode user message. Use `/plan tools` to choose which tools are active while Plan mode is enabled; the selector is paginated at 10 tools per page.
|
|
48
49
|
|
|
49
|
-
When Plan mode is active, ask the agent to design the change. The agent may inspect files and run read-only commands, but it should not edit files or execute the implementation.
|
|
50
|
+
When Plan mode is active, ask the agent to design the change. The agent may inspect files and run read-only commands, but it should not edit files or execute the implementation. It should explore first, then use structured questions when your preference or a tradeoff materially changes the plan.
|
|
50
51
|
|
|
51
|
-
By default, Plan mode manages only Pi's built-in tools: `read`, limited `bash`,
|
|
52
|
+
By default, Plan mode manages only Pi's built-in tools: `read`, limited `bash`, available read-only built-ins such as `grep`, `find`, and `ls`, plus the required `plan_mode_question` tool. Built-in `edit` and `write` are blocked. Extension and custom tools are disabled by default because Pi tools do not expose standardized mutability metadata; enable them from `/plan tools` only when you accept the risk for that session. For example, you can opt into `firecrawl_scrape`, `firecrawl_search`, or `biome_lsp_diagnostics` if those extensions are loaded and you want to use them during planning.
|
|
53
|
+
|
|
54
|
+
`plan_mode_question` follows Codex's `request_user_input` pattern: the agent can ask 1-3 concise questions, each with meaningful options and a free-form Other path. If you cancel or no interactive UI is available, the agent should ask a concise plain-text question or proceed only with a clearly stated low-risk assumption instead of prematurely producing a final plan.
|
|
52
55
|
|
|
53
56
|
Pi activates tools by tool name. The `/plan tools` selector stores selections by name and shows each currently effective tool's source from Pi metadata, such as `built-in`, a user extension path, or a project extension path. If an extension overrides a built-in tool with the same name, Pi exposes the effective tool for that name and the selector shows that source.
|
|
54
57
|
|
|
55
|
-
A complete Plan mode answer should include exactly one block like this:
|
|
58
|
+
A complete Plan mode answer should appear only after the agent has resolved discoverable facts and any high-impact user decisions. It should include exactly one block like this:
|
|
56
59
|
|
|
57
60
|
```xml
|
|
58
61
|
<proposed_plan>
|
|
@@ -72,12 +75,12 @@ A complete Plan mode answer should include exactly one block like this:
|
|
|
72
75
|
</proposed_plan>
|
|
73
76
|
```
|
|
74
77
|
|
|
75
|
-
After a proposed plan is detected, `/plan` lets you choose whether to implement the plan,
|
|
78
|
+
After a proposed plan is detected, `/plan` lets you choose whether to implement the plan, stay in Plan mode, or exit Plan mode. Choosing implementation disables Plan mode, restores full tool access, and immediately starts an implementation turn with the proposed plan. Choosing Stay keeps the plan ready while you decide what to do next; to revise the plan, choose Stay and type your revision feedback in the normal prompt. When that next Plan-mode turn starts, the previous plan is no longer treated as the latest implementable plan unless the agent produces an updated `<proposed_plan>`. Choosing exit/off disables Plan mode and discards the proposed plan so it is not carried into later non-plan turns.
|
|
76
79
|
|
|
77
80
|
While Plan mode is enabled, the extension also publishes a compact status for Pi statuslines. With `@narumitw/pi-statusline`, this appears in the extension status area:
|
|
78
81
|
|
|
79
82
|
- `📝 plan active`: Plan mode is enabled and still gathering context or drafting a plan.
|
|
80
|
-
- `📝 plan ready`: A `<proposed_plan>` was detected and
|
|
83
|
+
- `📝 plan ready`: A `<proposed_plan>` was detected and remains ready until you implement it, continue planning, or exit Plan mode.
|
|
81
84
|
|
|
82
85
|
You can also exit directly. Direct exit discards the latest proposed plan instead of treating it as an implementation request:
|
|
83
86
|
|
|
@@ -91,6 +94,7 @@ This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
|
|
|
91
94
|
|
|
92
95
|
- Plan mode is a conversational collaboration mode, not TODO/progress tracking.
|
|
93
96
|
- `/plan <prompt>` follows Codex behavior by switching to Plan mode before submitting the inline prompt.
|
|
97
|
+
- The agent should use `plan_mode_question` for important non-discoverable preferences or tradeoffs before finalizing.
|
|
94
98
|
- `update_plan`-style checklist use is discouraged while Plan mode is active.
|
|
95
99
|
- The implementation boundary is explicit: Plan mode restores tools before starting implementation, choosing implementation immediately triggers a normal agent turn with full tool access, and plain exit/off discards the proposed plan.
|
|
96
100
|
- Pi extension safety is approximated with built-in tool restriction plus bash filtering; non-built-in tools are user-selected at user risk because Plan mode does not classify extension/custom tool behavior.
|
package/package.json
CHANGED
package/src/plan-mode.ts
CHANGED
|
@@ -5,6 +5,7 @@ const STATUS_KEY = "plan-mode";
|
|
|
5
5
|
const PLAN_WIDGET_KEY = "plan-mode-plan";
|
|
6
6
|
const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
|
|
7
7
|
const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
|
|
8
|
+
const PLAN_MODE_QUESTION_TOOL_NAME = "plan_mode_question";
|
|
8
9
|
const PLAN_CONTEXT_MARKER = "[CODEX-LIKE PLAN MODE ACTIVE]";
|
|
9
10
|
const SAFE_BUILTIN_PLAN_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
|
|
10
11
|
const BLOCKED_BUILTIN_TOOLS = new Set(["edit", "write"]);
|
|
@@ -38,6 +39,95 @@ type TextBlock = {
|
|
|
38
39
|
text?: string;
|
|
39
40
|
};
|
|
40
41
|
|
|
42
|
+
type PlanModeQuestionOption = {
|
|
43
|
+
label: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type PlanModeQuestion = {
|
|
48
|
+
id: string;
|
|
49
|
+
header: string;
|
|
50
|
+
question: string;
|
|
51
|
+
options: PlanModeQuestionOption[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type PlanModeQuestionParams = {
|
|
55
|
+
questions: PlanModeQuestion[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type PlanModeQuestionAnswer = {
|
|
59
|
+
id: string;
|
|
60
|
+
header: string;
|
|
61
|
+
question: string;
|
|
62
|
+
answer: string;
|
|
63
|
+
wasCustom: boolean;
|
|
64
|
+
optionIndex?: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type PlanModeQuestionReason = "cancelled" | "ui_unavailable" | "plan_mode_inactive" | "invalid_input";
|
|
68
|
+
|
|
69
|
+
type PlanModeQuestionDetails = {
|
|
70
|
+
cancelled: boolean;
|
|
71
|
+
reason?: PlanModeQuestionReason;
|
|
72
|
+
questions: PlanModeQuestion[];
|
|
73
|
+
answers?: PlanModeQuestionAnswer[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const PLAN_MODE_QUESTION_PARAMS = {
|
|
77
|
+
type: "object",
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
required: ["questions"],
|
|
80
|
+
properties: {
|
|
81
|
+
questions: {
|
|
82
|
+
type: "array",
|
|
83
|
+
minItems: 1,
|
|
84
|
+
maxItems: 3,
|
|
85
|
+
description: "Questions to show the user. Prefer 1 and do not exceed 3.",
|
|
86
|
+
items: {
|
|
87
|
+
type: "object",
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
required: ["id", "header", "question", "options"],
|
|
90
|
+
properties: {
|
|
91
|
+
id: {
|
|
92
|
+
type: "string",
|
|
93
|
+
description: "Stable identifier for mapping answers (snake_case).",
|
|
94
|
+
},
|
|
95
|
+
header: {
|
|
96
|
+
type: "string",
|
|
97
|
+
description: "Short header label shown in the UI (12 or fewer chars).",
|
|
98
|
+
},
|
|
99
|
+
question: {
|
|
100
|
+
type: "string",
|
|
101
|
+
description: "Single-sentence prompt shown to the user.",
|
|
102
|
+
},
|
|
103
|
+
options: {
|
|
104
|
+
type: "array",
|
|
105
|
+
minItems: 2,
|
|
106
|
+
maxItems: 4,
|
|
107
|
+
description:
|
|
108
|
+
"Provide 2-4 mutually exclusive choices. Put the recommended option first when there is a clear default.",
|
|
109
|
+
items: {
|
|
110
|
+
type: "object",
|
|
111
|
+
additionalProperties: false,
|
|
112
|
+
required: ["label", "description"],
|
|
113
|
+
properties: {
|
|
114
|
+
label: {
|
|
115
|
+
type: "string",
|
|
116
|
+
description: "User-facing label (1-5 words).",
|
|
117
|
+
},
|
|
118
|
+
description: {
|
|
119
|
+
type: "string",
|
|
120
|
+
description: "One short sentence explaining impact/tradeoff if selected.",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
} as const;
|
|
130
|
+
|
|
41
131
|
const MUTATING_BASH_PATTERNS = [
|
|
42
132
|
/\brm\b/i,
|
|
43
133
|
/\brmdir\b/i,
|
|
@@ -91,6 +181,51 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
91
181
|
default: false,
|
|
92
182
|
});
|
|
93
183
|
|
|
184
|
+
pi.registerTool({
|
|
185
|
+
name: PLAN_MODE_QUESTION_TOOL_NAME,
|
|
186
|
+
label: "Plan question",
|
|
187
|
+
description:
|
|
188
|
+
"Ask the user one to three Plan-mode clarification questions with meaningful options, then wait for the answer. Only available while Plan mode is active.",
|
|
189
|
+
promptSnippet: "Ask user decision questions while Plan mode is active",
|
|
190
|
+
promptGuidelines: [
|
|
191
|
+
"In Plan mode, use plan_mode_question for important preferences, tradeoffs, or assumptions that cannot be discovered from read-only exploration.",
|
|
192
|
+
],
|
|
193
|
+
parameters: PLAN_MODE_QUESTION_PARAMS,
|
|
194
|
+
async execute(_toolCallId, params: unknown, _signal, _onUpdate, ctx) {
|
|
195
|
+
if (!state.enabled) {
|
|
196
|
+
return planModeQuestionCancelled(
|
|
197
|
+
[],
|
|
198
|
+
"plan_mode_inactive",
|
|
199
|
+
"Error: plan_mode_question is only available while Plan mode is active.",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const parsed = normalizePlanModeQuestionParams(params);
|
|
204
|
+
if (!parsed.ok) {
|
|
205
|
+
return planModeQuestionCancelled([], "invalid_input", `Error: ${parsed.error}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!ctx.hasUI) {
|
|
209
|
+
return planModeQuestionCancelled(
|
|
210
|
+
parsed.questions,
|
|
211
|
+
"ui_unavailable",
|
|
212
|
+
"Unable to ask Plan-mode questions because interactive UI is not available.",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const answers = await askPlanModeQuestions(parsed.questions, ctx);
|
|
217
|
+
if (!answers) {
|
|
218
|
+
return planModeQuestionCancelled(
|
|
219
|
+
parsed.questions,
|
|
220
|
+
"cancelled",
|
|
221
|
+
"User cancelled the Plan-mode question prompt.",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return planModeQuestionAnswered(parsed.questions, answers);
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
94
229
|
pi.registerCommand("plan", {
|
|
95
230
|
description: "Enter or manage Codex-like Plan mode",
|
|
96
231
|
handler: async (args, ctx) => {
|
|
@@ -123,6 +258,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
123
258
|
restoreState(ctx);
|
|
124
259
|
if (pi.getFlag("plan") === true) state.enabled = true;
|
|
125
260
|
if (state.enabled) activatePlanModeTools();
|
|
261
|
+
else deactivatePlanModeQuestionTool();
|
|
126
262
|
updateUi(ctx);
|
|
127
263
|
});
|
|
128
264
|
|
|
@@ -151,23 +287,27 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
151
287
|
});
|
|
152
288
|
|
|
153
289
|
pi.on("context", async (event) => {
|
|
154
|
-
|
|
290
|
+
const messagesWithoutLegacyPlanContext = event.messages.filter(
|
|
291
|
+
(message: unknown) => !messageContainsLegacyPlanModeContextArtifact(message),
|
|
292
|
+
);
|
|
293
|
+
if (state.enabled) return { messages: messagesWithoutLegacyPlanContext };
|
|
155
294
|
return {
|
|
156
|
-
messages:
|
|
295
|
+
messages: messagesWithoutLegacyPlanContext
|
|
157
296
|
.filter((message: unknown) => !messageContainsInactivePlanModeArtifact(message))
|
|
158
297
|
.map(stripProposedPlanBlocksFromMessage),
|
|
159
298
|
};
|
|
160
299
|
});
|
|
161
300
|
|
|
162
|
-
pi.on("before_agent_start", () => {
|
|
301
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
163
302
|
if (!state.enabled) return;
|
|
303
|
+
if (state.latestPlan || state.awaitingAction) {
|
|
304
|
+
state = { ...state, latestPlan: undefined, awaitingAction: false };
|
|
305
|
+
persistState();
|
|
306
|
+
updateUi(ctx);
|
|
307
|
+
}
|
|
164
308
|
applyPlanModeTools();
|
|
165
309
|
return {
|
|
166
|
-
|
|
167
|
-
customType: PLAN_CONTEXT_MESSAGE_TYPE,
|
|
168
|
-
content: buildPlanModePrompt(),
|
|
169
|
-
display: false,
|
|
170
|
-
},
|
|
310
|
+
systemPrompt: `${event.systemPrompt}\n\n${buildPlanModePrompt()}`,
|
|
171
311
|
};
|
|
172
312
|
});
|
|
173
313
|
|
|
@@ -186,21 +326,24 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
186
326
|
persistState();
|
|
187
327
|
updateUi(ctx);
|
|
188
328
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
329
|
+
scheduleAfterCurrentAgentRun(async () => {
|
|
330
|
+
if (!state.enabled || state.latestPlan !== proposedPlan) return;
|
|
331
|
+
if (ctx.hasUI) await showPlanReadyMenu(ctx);
|
|
332
|
+
if (!state.enabled || state.latestPlan !== proposedPlan) return;
|
|
333
|
+
|
|
334
|
+
pi.sendMessage(
|
|
335
|
+
{
|
|
336
|
+
customType: PROPOSED_PLAN_MESSAGE_TYPE,
|
|
337
|
+
content: `**Proposed Plan**\n\n${proposedPlan}`,
|
|
338
|
+
display: true,
|
|
339
|
+
},
|
|
340
|
+
{ triggerTurn: false },
|
|
341
|
+
);
|
|
342
|
+
});
|
|
200
343
|
});
|
|
201
344
|
|
|
202
345
|
function enterPlanMode(ctx: ExtensionContext) {
|
|
203
|
-
if (!state.enabled) previousTools = safeGetActiveTools();
|
|
346
|
+
if (!state.enabled) previousTools = withoutPlanModeQuestionTool(safeGetActiveTools());
|
|
204
347
|
state = { ...state, enabled: true, awaitingAction: false };
|
|
205
348
|
activatePlanModeTools();
|
|
206
349
|
persistState();
|
|
@@ -229,6 +372,15 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
229
372
|
else pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
230
373
|
}
|
|
231
374
|
|
|
375
|
+
function scheduleAfterCurrentAgentRun(task: () => Promise<void> | void) {
|
|
376
|
+
setTimeout(() => {
|
|
377
|
+
void Promise.resolve(task()).catch((error: unknown) => {
|
|
378
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
379
|
+
console.error(`Plan mode follow-up failed: ${message}`);
|
|
380
|
+
});
|
|
381
|
+
}, 0);
|
|
382
|
+
}
|
|
383
|
+
|
|
232
384
|
function startImplementation(ctx: ExtensionContext) {
|
|
233
385
|
const plan = state.latestPlan?.trim();
|
|
234
386
|
exitPlanMode(ctx);
|
|
@@ -283,8 +435,6 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
283
435
|
async function showPlanReadyMenu(ctx: ExtensionContext) {
|
|
284
436
|
const choice = await ctx.ui.select("Proposed plan ready. What next?", [
|
|
285
437
|
"Implement this plan",
|
|
286
|
-
"Revise plan",
|
|
287
|
-
"Configure Plan-mode tools",
|
|
288
438
|
"Stay in Plan mode",
|
|
289
439
|
"Exit Plan mode",
|
|
290
440
|
]);
|
|
@@ -292,15 +442,6 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
292
442
|
startImplementation(ctx);
|
|
293
443
|
return;
|
|
294
444
|
}
|
|
295
|
-
if (choice === "Configure Plan-mode tools") {
|
|
296
|
-
await showToolSelector(ctx);
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
if (choice === "Revise plan") {
|
|
300
|
-
const refinement = await ctx.ui.editor("Revise the plan", "");
|
|
301
|
-
if (refinement?.trim()) sendPlanModeUserMessage(refinement.trim(), ctx);
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
445
|
if (choice === "Exit Plan mode") {
|
|
305
446
|
exitPlanMode(ctx);
|
|
306
447
|
ctx.ui.notify("Plan mode disabled. Proposed plan discarded.", "info");
|
|
@@ -373,7 +514,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
373
514
|
}
|
|
374
515
|
|
|
375
516
|
function activatePlanModeTools() {
|
|
376
|
-
previousTools ??= safeGetActiveTools();
|
|
517
|
+
previousTools ??= withoutPlanModeQuestionTool(safeGetActiveTools());
|
|
377
518
|
applyPlanModeTools();
|
|
378
519
|
}
|
|
379
520
|
|
|
@@ -383,12 +524,14 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
383
524
|
|
|
384
525
|
function planModeToolNames() {
|
|
385
526
|
const tools = selectableTools();
|
|
386
|
-
if (tools.length === 0) return ["read", "bash"];
|
|
527
|
+
if (tools.length === 0) return ["read", "bash", PLAN_MODE_QUESTION_TOOL_NAME];
|
|
387
528
|
|
|
388
529
|
const selectedNames = planModeSelectedNames(tools);
|
|
389
|
-
return
|
|
390
|
-
|
|
391
|
-
|
|
530
|
+
return withRequiredPlanModeTools(
|
|
531
|
+
tools
|
|
532
|
+
.filter((tool) => selectedNames.has(tool.name) && canSelectToolInPlanMode(tool))
|
|
533
|
+
.map((tool) => tool.name),
|
|
534
|
+
);
|
|
392
535
|
}
|
|
393
536
|
|
|
394
537
|
function planModeSelectedNames(tools: ToolInfo[]) {
|
|
@@ -422,7 +565,9 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
422
565
|
}
|
|
423
566
|
|
|
424
567
|
function selectableTools() {
|
|
425
|
-
return safeGetAllTools()
|
|
568
|
+
return safeGetAllTools()
|
|
569
|
+
.filter((tool) => tool.name !== PLAN_MODE_QUESTION_TOOL_NAME)
|
|
570
|
+
.sort(compareTools);
|
|
426
571
|
}
|
|
427
572
|
|
|
428
573
|
function toolSelectorPageCount(tools: ToolInfo[]) {
|
|
@@ -438,10 +583,19 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
438
583
|
}
|
|
439
584
|
|
|
440
585
|
function restoreTools() {
|
|
441
|
-
|
|
586
|
+
const restoredTools = previousTools && previousTools.length > 0 ? previousTools : DEFAULT_TOOLS;
|
|
587
|
+
pi.setActiveTools(withoutPlanModeQuestionTool(restoredTools));
|
|
442
588
|
previousTools = undefined;
|
|
443
589
|
}
|
|
444
590
|
|
|
591
|
+
function deactivatePlanModeQuestionTool() {
|
|
592
|
+
const activeTools = safeGetActiveTools();
|
|
593
|
+
const filteredTools = withoutPlanModeQuestionTool(activeTools);
|
|
594
|
+
if (filteredTools.length !== activeTools.length) {
|
|
595
|
+
pi.setActiveTools(filteredTools);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
445
599
|
function safeGetActiveTools() {
|
|
446
600
|
try {
|
|
447
601
|
return pi.getActiveTools();
|
|
@@ -572,19 +726,194 @@ function unique(values: string[]) {
|
|
|
572
726
|
return Array.from(new Set(values));
|
|
573
727
|
}
|
|
574
728
|
|
|
729
|
+
function withRequiredPlanModeTools(toolNames: string[]) {
|
|
730
|
+
return unique([...withoutPlanModeQuestionTool(toolNames), PLAN_MODE_QUESTION_TOOL_NAME]);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function withoutPlanModeQuestionTool(toolNames: string[]) {
|
|
734
|
+
return toolNames.filter((toolName) => toolName !== PLAN_MODE_QUESTION_TOOL_NAME);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
type NormalizePlanModeQuestionParamsResult =
|
|
738
|
+
| { ok: true; questions: PlanModeQuestion[] }
|
|
739
|
+
| { ok: false; error: string };
|
|
740
|
+
|
|
741
|
+
function normalizePlanModeQuestionParams(input: unknown): NormalizePlanModeQuestionParamsResult {
|
|
742
|
+
if (!isRecord(input) || !Array.isArray(input.questions)) {
|
|
743
|
+
return { ok: false, error: "questions must be an array" };
|
|
744
|
+
}
|
|
745
|
+
if (input.questions.length < 1 || input.questions.length > 3) {
|
|
746
|
+
return { ok: false, error: "questions must contain 1-3 items" };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const questions: PlanModeQuestion[] = [];
|
|
750
|
+
for (const [questionIndex, rawQuestion] of input.questions.entries()) {
|
|
751
|
+
if (!isRecord(rawQuestion)) {
|
|
752
|
+
return { ok: false, error: `question ${questionIndex + 1} must be an object` };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const id = stringField(rawQuestion.id);
|
|
756
|
+
const header = stringField(rawQuestion.header);
|
|
757
|
+
const question = stringField(rawQuestion.question);
|
|
758
|
+
if (!id || !header || !question) {
|
|
759
|
+
return {
|
|
760
|
+
ok: false,
|
|
761
|
+
error: `question ${questionIndex + 1} requires non-empty id, header, and question`,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (!Array.isArray(rawQuestion.options)) {
|
|
766
|
+
return { ok: false, error: `question ${questionIndex + 1} options must be an array` };
|
|
767
|
+
}
|
|
768
|
+
if (rawQuestion.options.length < 2 || rawQuestion.options.length > 4) {
|
|
769
|
+
return { ok: false, error: `question ${questionIndex + 1} options must contain 2-4 items` };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const options: PlanModeQuestionOption[] = [];
|
|
773
|
+
for (const [optionIndex, rawOption] of rawQuestion.options.entries()) {
|
|
774
|
+
if (!isRecord(rawOption)) {
|
|
775
|
+
return {
|
|
776
|
+
ok: false,
|
|
777
|
+
error: `question ${questionIndex + 1} option ${optionIndex + 1} must be an object`,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const label = stringField(rawOption.label);
|
|
782
|
+
if (!label) {
|
|
783
|
+
return {
|
|
784
|
+
ok: false,
|
|
785
|
+
error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a label`,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
const description = stringField(rawOption.description);
|
|
789
|
+
if (!description) {
|
|
790
|
+
return {
|
|
791
|
+
ok: false,
|
|
792
|
+
error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a description`,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
options.push({ label, description });
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
questions.push({ id, header, question, options });
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return { ok: true, questions };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async function askPlanModeQuestions(
|
|
805
|
+
questions: PlanModeQuestion[],
|
|
806
|
+
ctx: ExtensionContext,
|
|
807
|
+
): Promise<PlanModeQuestionAnswer[] | undefined> {
|
|
808
|
+
const answers: PlanModeQuestionAnswer[] = [];
|
|
809
|
+
for (const question of questions) {
|
|
810
|
+
const choices = question.options.map(formatPlanModeQuestionChoice);
|
|
811
|
+
const otherChoice = `${question.options.length + 1}. Other (free-form)`;
|
|
812
|
+
const choice = await ctx.ui.select(`${question.header}: ${question.question}`, [...choices, otherChoice]);
|
|
813
|
+
if (!choice) return undefined;
|
|
814
|
+
|
|
815
|
+
if (choice === otherChoice) {
|
|
816
|
+
const customAnswer = (await ctx.ui.editor(question.question, ""))?.trim();
|
|
817
|
+
if (!customAnswer) return undefined;
|
|
818
|
+
answers.push({
|
|
819
|
+
id: question.id,
|
|
820
|
+
header: question.header,
|
|
821
|
+
question: question.question,
|
|
822
|
+
answer: customAnswer,
|
|
823
|
+
wasCustom: true,
|
|
824
|
+
});
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const optionIndex = choices.indexOf(choice);
|
|
829
|
+
const option = question.options[optionIndex];
|
|
830
|
+
if (!option) return undefined;
|
|
831
|
+
answers.push({
|
|
832
|
+
id: question.id,
|
|
833
|
+
header: question.header,
|
|
834
|
+
question: question.question,
|
|
835
|
+
answer: option.label,
|
|
836
|
+
wasCustom: false,
|
|
837
|
+
optionIndex: optionIndex + 1,
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
return answers;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: number) {
|
|
844
|
+
return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function planModeQuestionAnswered(questions: PlanModeQuestion[], answers: PlanModeQuestionAnswer[]) {
|
|
848
|
+
return {
|
|
849
|
+
content: [{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: false, answers }) }],
|
|
850
|
+
details: { cancelled: false, questions, answers } satisfies PlanModeQuestionDetails,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function planModeQuestionCancelled(
|
|
855
|
+
questions: PlanModeQuestion[],
|
|
856
|
+
reason: PlanModeQuestionReason,
|
|
857
|
+
message: string,
|
|
858
|
+
) {
|
|
859
|
+
return {
|
|
860
|
+
content: [{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: true, reason, message }) }],
|
|
861
|
+
details: { cancelled: true, reason, questions } satisfies PlanModeQuestionDetails,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function formatPlanModeQuestionPayload(payload: {
|
|
866
|
+
cancelled: boolean;
|
|
867
|
+
reason?: PlanModeQuestionReason;
|
|
868
|
+
message?: string;
|
|
869
|
+
answers?: PlanModeQuestionAnswer[];
|
|
870
|
+
}) {
|
|
871
|
+
return JSON.stringify(payload, null, 2);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
875
|
+
return typeof value === "object" && value !== null;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function stringField(value: unknown) {
|
|
879
|
+
return typeof value === "string" ? value.trim() : undefined;
|
|
880
|
+
}
|
|
881
|
+
|
|
575
882
|
function buildPlanModePrompt() {
|
|
576
883
|
return `${PLAN_CONTEXT_MARKER}
|
|
577
|
-
|
|
884
|
+
# Plan Mode (Conversational)
|
|
885
|
+
|
|
886
|
+
You are in Plan Mode, a Codex-like collaboration mode for producing a decision-complete implementation plan. Chat your way to the plan before finalizing it. A final plan must leave no implementation decisions unresolved.
|
|
887
|
+
|
|
888
|
+
## Mode rules
|
|
578
889
|
|
|
579
|
-
Mode rules:
|
|
580
890
|
- Stay in Plan Mode until a developer or extension explicitly exits it.
|
|
581
891
|
- Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
|
|
582
|
-
- Use non-mutating exploration first: read files, search, inspect configuration, run read-only checks, and resolve discoverable facts before asking the user.
|
|
583
|
-
- Ask the user only for preferences or tradeoffs that cannot be discovered from the repository.
|
|
584
892
|
- Do not use update_plan/TODO tooling in Plan Mode; Plan Mode is conversational planning, not execution progress tracking.
|
|
585
893
|
- Plan Mode manages built-in tool safety only. Non-built-in tools are disabled by default and may be enabled by the user at their own risk.
|
|
586
894
|
- Do not perform mutating actions: no edit/write tools, no patching, no formatting that rewrites files, no dependency installation, no commits, no migrations.
|
|
587
|
-
|
|
895
|
+
|
|
896
|
+
## Phase 1 — Ground in the environment
|
|
897
|
+
|
|
898
|
+
- Explore first and ask second. Use non-mutating exploration to read files, search, inspect configuration, run read-only checks, and resolve discoverable facts.
|
|
899
|
+
- Before asking the user any question, perform at least one targeted non-mutating exploration pass unless no local environment or repository is available.
|
|
900
|
+
- Do not ask questions that can be answered from repository or system truth. Ask only when multiple plausible choices remain, a needed identifier/context is missing, or the ambiguity is product intent.
|
|
901
|
+
|
|
902
|
+
## Phase 2 — Intent chat
|
|
903
|
+
|
|
904
|
+
- Keep asking until you can clearly state the goal, success criteria, in/out of scope, constraints, current state, and key preferences/tradeoffs.
|
|
905
|
+
- Bias toward questions over guessing: if a high-impact ambiguity remains, do not produce a proposed plan yet.
|
|
906
|
+
|
|
907
|
+
## Phase 3 — Implementation chat
|
|
908
|
+
|
|
909
|
+
- Once intent is stable, keep asking until the spec is decision-complete: approach, interfaces, data flow, edge cases/failure modes, testing and acceptance criteria, and any migration or compatibility constraints.
|
|
910
|
+
- Use plan_mode_question for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. Ask 1-3 concise questions with 2-4 meaningful options. Do not include filler options.
|
|
911
|
+
- If plan_mode_question returns cancelled or ui_unavailable, do not jump straight to a final plan when the missing answer is high impact. Ask one concise plain-text question or proceed only with a clearly stated low-risk assumption.
|
|
912
|
+
|
|
913
|
+
## Finalization rule
|
|
914
|
+
|
|
915
|
+
Only output the final plan when it is decision-complete and leaves no decisions to the implementer. When presenting the official plan, output exactly one proposed plan block and keep the tags exactly as shown:
|
|
916
|
+
|
|
588
917
|
<proposed_plan>
|
|
589
918
|
# Title
|
|
590
919
|
|
|
@@ -600,7 +929,8 @@ Mode rules:
|
|
|
600
929
|
## Assumptions
|
|
601
930
|
...
|
|
602
931
|
</proposed_plan>
|
|
603
|
-
|
|
932
|
+
|
|
933
|
+
Keep the proposed plan concise, human and agent digestible, and free of open decisions. Do not ask "should I proceed?" in the final output; the Plan-mode ready menu handles implementation, staying in Plan mode, or exit.`;
|
|
604
934
|
}
|
|
605
935
|
|
|
606
936
|
function readCommand(input: unknown) {
|
|
@@ -631,13 +961,14 @@ function latestAssistantText(messages: unknown) {
|
|
|
631
961
|
return "";
|
|
632
962
|
}
|
|
633
963
|
|
|
964
|
+
function messageContainsLegacyPlanModeContextArtifact(message: unknown) {
|
|
965
|
+
const candidate = unwrapSessionMessage(message);
|
|
966
|
+
return candidate.customType === PLAN_CONTEXT_MESSAGE_TYPE;
|
|
967
|
+
}
|
|
968
|
+
|
|
634
969
|
function messageContainsInactivePlanModeArtifact(message: unknown) {
|
|
635
970
|
const candidate = unwrapSessionMessage(message);
|
|
636
|
-
return
|
|
637
|
-
candidate.customType === PLAN_CONTEXT_MESSAGE_TYPE ||
|
|
638
|
-
candidate.customType === PROPOSED_PLAN_MESSAGE_TYPE ||
|
|
639
|
-
contentText(candidate.content).includes(PLAN_CONTEXT_MARKER)
|
|
640
|
-
);
|
|
971
|
+
return candidate.customType === PROPOSED_PLAN_MESSAGE_TYPE;
|
|
641
972
|
}
|
|
642
973
|
|
|
643
974
|
function stripProposedPlanBlocksFromMessage<T>(message: T): T {
|