@dreki-gg/pi-plan-mode 0.2.0 → 0.3.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.
@@ -1,664 +1,579 @@
1
- import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
2
- import { Key, type AutocompleteItem } from '@mariozechner/pi-tui';
1
+ /**
2
+ * Plan Mode Extension
3
+ *
4
+ * Two-phase workflow:
5
+ * 1. PLAN phase — read-only tools (+ edit/write for .plans/ only) + medium thinking
6
+ * Planner analyzes codebase, asks questions, writes PLAN.md + START-PROMPT.md
7
+ * 2. EXECUTE phase — full tools + low thinking, clean context from START-PROMPT.md
8
+ * Executor works through the plan step by step with [DONE:n] tracking
9
+ *
10
+ * Plans live in `.plans/<kebab-name>/PLAN.md` with a `START-PROMPT.md` sibling for clean handoff.
11
+ *
12
+ * Commands:
13
+ * /plan [prompt] — enter plan mode (optionally with a starting prompt)
14
+ * /todos — show current plan progress
15
+ * Ctrl+Alt+P — toggle plan mode (shortcut)
16
+ *
17
+ * Flag:
18
+ * --plan — start session in plan mode
19
+ */
20
+
21
+ import type { AgentMessage } from '@earendil-works/pi-agent-core';
22
+ import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai';
23
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
24
+ import { Key } from '@earendil-works/pi-tui';
3
25
  import {
4
26
  extractTodoItems,
5
- formatTodoList,
6
27
  isSafeCommand,
7
28
  markCompletedSteps,
8
29
  type TodoItem,
9
30
  } from './utils.js';
10
31
 
11
- type WorkflowPhase = 'off' | 'planning' | 'plan-files' | 'executing';
12
-
13
- interface PersistedState {
14
- phase: WorkflowPhase;
15
- todos: TodoItem[];
16
- }
17
-
18
- interface BeforeAgentStartCompatEvent {
19
- systemPromptOptions?: {
20
- selectedTools?: string[];
21
- };
22
- }
23
-
24
- const STATE_ENTRY = 'plan-mode-state';
25
- const CLEAR_VALUES = new Set(['', 'off', 'none', 'disable', 'exit']);
26
- const PLANNING_TOOL_PATTERNS = [
32
+ // ── Tool sets ────────────────────────────────────────────────────────────────
33
+ // Plan phase: read-only + edit/write (for .plans/ files only, enforced by prompt)
34
+ const PLAN_TOOLS = [
27
35
  'read',
28
36
  'bash',
29
37
  'grep',
30
38
  'find',
31
39
  'ls',
40
+ 'edit',
41
+ 'write',
32
42
  'questionnaire',
33
- 'lsp',
34
- ] as const;
35
- const PLAN_FILE_TOOL_PATTERNS = [...PLANNING_TOOL_PATTERNS, 'edit', 'write'] as const;
36
-
37
- function isAssistantMessage(message: unknown): message is {
38
- role: 'assistant';
39
- content: Array<{ type: string; text?: string }>;
40
- } {
41
- return (
42
- typeof message === 'object' &&
43
- message !== null &&
44
- 'role' in message &&
45
- (message as { role?: unknown }).role === 'assistant' &&
46
- 'content' in message &&
47
- Array.isArray((message as { content?: unknown }).content)
48
- );
49
- }
43
+ 'search_skills',
44
+ ];
45
+ const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'search_skills'];
50
46
 
51
- function getMessageText(message: unknown): string {
52
- if (!isAssistantMessage(message)) return '';
53
- return message.content
54
- .filter(
55
- (block): block is { type: 'text'; text: string } =>
56
- block.type === 'text' && typeof block.text === 'string',
57
- )
58
- .map((block) => block.text)
59
- .join('\n');
60
- }
47
+ // ── Model + thinking presets ─────────────────────────────────────────────────
48
+ const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
49
+ const PLAN_THINKING = 'medium' as const;
61
50
 
62
- function matchesToolPattern(name: string, pattern: string): boolean {
63
- if (pattern.endsWith('*')) return name.startsWith(pattern.slice(0, -1));
64
- return name === pattern;
65
- }
51
+ const EXEC_MODEL = { provider: 'openai', id: 'gpt-5.5' } as const;
52
+ const EXEC_THINKING = 'low' as const;
66
53
 
67
- function resolveToolNames(allToolNames: string[], patterns: readonly string[]): string[] {
68
- return allToolNames.filter((name) =>
69
- patterns.some(
70
- (pattern) =>
71
- matchesToolPattern(name, pattern) ||
72
- (pattern === 'context7_*' && name.startsWith('context7_')),
73
- ),
74
- );
75
- }
54
+ type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
76
55
 
77
- function findSavedState(ctx: ExtensionContext): PersistedState | undefined {
78
- const branchEntries = ctx.sessionManager.getBranch?.() ?? ctx.sessionManager.getEntries?.() ?? [];
79
- let lastState: PersistedState | undefined;
80
-
81
- for (const entry of branchEntries) {
82
- if (entry.type !== 'custom' || entry.customType !== STATE_ENTRY) continue;
83
- const data = entry.data as PersistedState | undefined;
84
- if (data && typeof data.phase === 'string' && Array.isArray(data.todos)) lastState = data;
85
- }
86
-
87
- return lastState;
56
+ // ── Persisted state ──────────────────────────────────────────────────────────
57
+ interface PersistedState {
58
+ planEnabled: boolean;
59
+ executing: boolean;
60
+ planDir: string | undefined;
61
+ todos: TodoItem[];
88
62
  }
89
63
 
90
- function buildPlanningInstructions(
91
- ctx: ExtensionContext,
92
- activeTools: string[],
93
- todos: TodoItem[],
94
- ): string {
95
- const sections = [
96
- 'PLAN MODE ACTIVE.',
97
- 'Run a Cursor-style planning pass before implementation.',
98
- `Enabled tools: ${activeTools.length > 0 ? activeTools.join(', ') : '(none)'}`,
99
- 'Rules:',
100
- '- Stay read-only. Do not edit files or claim to have changed code.',
101
- '- Inspect the real codebase before proposing work. Do not guess from filenames.',
102
- '- If requirements are underspecified and the questionnaire tool is available, use it to ask 1-5 structured clarifying questions before finalizing the plan.',
103
- '- Produce a concrete numbered plan under a `Plan:` header.',
104
- '- Include open questions, assumptions, risks, and sequencing when relevant.',
105
- '- If the user wants terminology hardened or the design pressure-tested, point them to the domain-model workflow.',
106
- '- If the user wants self-contained handoff files, point them to the implementation-plan workflow.',
107
- ];
108
-
109
- if (ctx.hasUI) {
110
- sections.push(
111
- 'After the planning response, the extension may offer next-step choices like domain-model review, implementation-plan generation, or execution.',
112
- );
113
- }
114
-
115
- if (todos.length > 0) {
116
- sections.push(`Current plan draft:\n${formatTodoList(todos)}`);
117
- }
118
-
119
- return sections.join('\n');
64
+ // ── Helpers ──────────────────────────────────────────────────────────────────
65
+ function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
66
+ return m.role === 'assistant' && Array.isArray(m.content);
120
67
  }
121
68
 
122
- function buildPlanFileInstructions(activeTools: string[], todos: TodoItem[]): string {
123
- const sections = [
124
- 'PLAN FILE AUTHORING MODE ACTIVE.',
125
- `Enabled tools: ${activeTools.length > 0 ? activeTools.join(', ') : '(none)'}`,
126
- 'You may use edit/write in this phase, but only to author planning artifacts such as `*.plan.md`, `CONTEXT.md`, or ADR docs requested by the workflow.',
127
- 'Do not implement product code in this phase.',
128
- 'Ground every plan file in the current codebase state.',
129
- ];
130
-
131
- if (todos.length > 0) {
132
- sections.push(`Current approved plan:\n${formatTodoList(todos)}`);
133
- }
134
-
135
- return sections.join('\n');
136
- }
137
-
138
- function buildExecutionInstructions(todos: TodoItem[]): string {
139
- const sections = [
140
- 'PLAN EXECUTION MODE ACTIVE.',
141
- 'Execute the approved plan in small verified steps.',
142
- 'After completing a plan step, include a `[DONE:n]` tag in the response so progress can be tracked.',
143
- ];
144
-
145
- if (todos.length > 0) {
146
- sections.push(`Current checklist:\n${formatTodoList(todos)}`);
147
- }
148
-
149
- return sections.join('\n');
69
+ function getTextContent(message: AssistantMessage): string {
70
+ return message.content
71
+ .filter((b): b is TextContent => b.type === 'text')
72
+ .map((b) => b.text)
73
+ .join('\n');
150
74
  }
151
75
 
152
- export default function planModeExtension(pi: ExtensionAPI) {
153
- let phase: WorkflowPhase = 'off';
154
- let restoreToolNames: string[] | null = null;
155
- let todoItems: TodoItem[] = [];
156
- let returnToPlanningAfterNextAgentEnd = false;
157
-
158
- function getPlanCommandCompletions(argumentText: string): AutocompleteItem[] | null {
159
- if (argumentText.trim().includes(' ')) return null;
160
-
161
- const query = argumentText.trim().toLowerCase();
162
- const items: AutocompleteItem[] = [
163
- {
164
- value: 'status',
165
- label: 'status',
166
- description: `Show the current plan workflow state (currently ${phase})`,
167
- },
168
- {
169
- value: 'domain',
170
- label: 'domain',
171
- description: 'Stress-test the current plan with a domain-model review',
172
- },
173
- {
174
- value: 'plans',
175
- label: 'plans',
176
- description: 'Generate self-contained implementation plan files',
177
- },
178
- {
179
- value: 'execute',
180
- label: 'execute',
181
- description: 'Leave read-only planning and execute the approved plan',
182
- },
183
- {
184
- value: 'off',
185
- label: 'off',
186
- description: 'Disable plan mode',
187
- },
188
- ];
189
-
190
- const filtered = items.filter((item) => {
191
- if (!query) return true;
192
- return (
193
- item.value.toLowerCase().startsWith(query) ||
194
- item.label?.toLowerCase().includes(query) ||
195
- item.description?.toLowerCase().includes(query)
196
- );
197
- });
198
-
199
- return filtered.length > 0 ? filtered : null;
200
- }
76
+ // ── Extension ────────────────────────────────────────────────────────────────
77
+ export default function planMode(pi: ExtensionAPI): void {
78
+ let planEnabled = false;
79
+ let executing = false;
80
+ let planDir: string | undefined;
81
+ let todos: TodoItem[] = [];
82
+ let previousThinking: ThinkingLevel | undefined;
83
+ let previousModel: { provider: string; id: string } | undefined;
201
84
 
85
+ // ── Flag ──────────────────────────────────────────────────────────────────
202
86
  pi.registerFlag('plan', {
203
- description: 'Start in plan mode (Cursor-style planning workflow)',
87
+ description: 'Start in plan mode (read-only + medium thinking)',
204
88
  type: 'boolean',
205
89
  default: false,
206
90
  });
207
91
 
208
- function persistState() {
209
- pi.appendEntry<PersistedState>(STATE_ENTRY, {
210
- phase,
211
- todos: todoItems.map((item) => ({ ...item })),
92
+ // ── State persistence ─────────────────────────────────────────────────────
93
+ function persist(): void {
94
+ pi.appendEntry<PersistedState>('plan-mode', {
95
+ planEnabled,
96
+ executing,
97
+ planDir,
98
+ todos,
212
99
  });
213
100
  }
214
101
 
215
- function updateUi(ctx: ExtensionContext) {
216
- if (phase === 'off') {
217
- ctx.ui.setStatus('plan-mode', undefined);
218
- ctx.ui.setWidget('plan-mode-todos', undefined);
219
- return;
220
- }
102
+ // ── UI updates ────────────────────────────────────────────────────────────
103
+ function updateUI(ctx: ExtensionContext): void {
104
+ const { theme } = ctx.ui;
221
105
 
222
- if (phase === 'planning') {
223
- ctx.ui.setStatus('plan-mode', ctx.ui.theme.fg('warning', 'plan'));
224
- } else if (phase === 'plan-files') {
225
- ctx.ui.setStatus('plan-mode', ctx.ui.theme.fg('accent', 'plan:files'));
106
+ if (executing && todos.length > 0) {
107
+ const done = todos.filter((t) => t.completed).length;
108
+ ctx.ui.setStatus('plan-mode', theme.fg('accent', `📋 exec ${done}/${todos.length}`));
109
+ } else if (planEnabled) {
110
+ ctx.ui.setStatus('plan-mode', theme.fg('warning', '📝 plan'));
226
111
  } else {
227
- const completed = todoItems.filter((item) => item.completed).length;
228
- const total = todoItems.length;
229
- ctx.ui.setStatus(
230
- 'plan-mode',
231
- ctx.ui.theme.fg('success', total > 0 ? `plan:exec ${completed}/${total}` : 'plan:exec'),
232
- );
233
- }
234
-
235
- if (todoItems.length === 0) {
236
- ctx.ui.setWidget('plan-mode-todos', undefined);
237
- return;
238
- }
239
-
240
- const lines = todoItems.map((item) => {
241
- if (item.completed) {
242
- return ctx.ui.theme.fg('success', `☑ ${ctx.ui.theme.strikethrough(item.text)}`);
243
- }
244
- const marker = phase === 'executing' ? '☐' : '•';
245
- return `${ctx.ui.theme.fg('muted', `${marker} `)}${item.text}`;
246
- });
247
- ctx.ui.setWidget('plan-mode-todos', lines);
248
- }
249
-
250
- function ensureRestoreTools() {
251
- if (restoreToolNames) return;
252
- restoreToolNames = [...pi.getActiveTools()];
253
- }
254
-
255
- function applyPhaseTools(targetPhase: WorkflowPhase) {
256
- const allToolNames = pi.getAllTools().map((tool) => tool.name);
257
-
258
- if (targetPhase === 'planning') {
259
- const resolved = resolveToolNames(allToolNames, [...PLANNING_TOOL_PATTERNS, 'context7_*']);
260
- if (resolved.length > 0) pi.setActiveTools(resolved);
261
- return;
262
- }
263
-
264
- if (targetPhase === 'plan-files') {
265
- const resolved = resolveToolNames(allToolNames, [...PLAN_FILE_TOOL_PATTERNS, 'context7_*']);
266
- if (resolved.length > 0) pi.setActiveTools(resolved);
267
- return;
112
+ ctx.ui.setStatus('plan-mode', undefined);
268
113
  }
269
114
 
270
- if (targetPhase === 'executing' && restoreToolNames) {
271
- pi.setActiveTools([...restoreToolNames]);
115
+ if (executing && todos.length > 0) {
116
+ const lines = todos.map((item) => {
117
+ if (item.completed) {
118
+ return theme.fg('success', '☑ ') + theme.fg('muted', theme.strikethrough(item.text));
119
+ }
120
+ return `${theme.fg('muted', '☐ ')}${item.text}`;
121
+ });
122
+ ctx.ui.setWidget('plan-todos', lines);
123
+ } else {
124
+ ctx.ui.setWidget('plan-todos', undefined);
272
125
  }
273
126
  }
274
127
 
275
- function setPhase(
276
- nextPhase: WorkflowPhase,
128
+ // ── Model switching ───────────────────────────────────────────────────────
129
+ async function switchModel(
277
130
  ctx: ExtensionContext,
278
- options?: { notify?: string },
279
- ) {
280
- if (phase === 'off' && nextPhase !== 'off') ensureRestoreTools();
281
- if (nextPhase === 'off' && restoreToolNames) {
282
- pi.setActiveTools([...restoreToolNames]);
283
- restoreToolNames = null;
284
- } else {
285
- applyPhaseTools(nextPhase);
131
+ preset: { provider: string; id: string },
132
+ ): Promise<boolean> {
133
+ const model = ctx.modelRegistry.find(preset.provider, preset.id);
134
+ if (!model) {
135
+ ctx.ui.notify(`Model ${preset.provider}/${preset.id} not found`, 'error');
136
+ return false;
286
137
  }
287
-
288
- phase = nextPhase;
289
- updateUi(ctx);
290
- persistState();
291
-
292
- if (options?.notify) ctx.ui.notify(options.notify, 'info');
293
- }
294
-
295
- async function sendPlanningPrompt(prompt: string, ctx: ExtensionContext) {
296
- const commandCtx = ctx as ExtensionContext & { waitForIdle?: () => Promise<void> };
297
- if (typeof commandCtx.waitForIdle === 'function') {
298
- await commandCtx.waitForIdle();
138
+ const ok = await pi.setModel(model);
139
+ if (!ok) {
140
+ ctx.ui.notify(`No API key for ${preset.provider}/${preset.id}`, 'error');
141
+ return false;
299
142
  }
300
- pi.sendUserMessage(prompt.trim());
301
- }
302
-
303
- function availableCommand(name: string): string | undefined {
304
- const commands = pi.getCommands?.() ?? [];
305
- return commands.find((command) => command.name === name)?.name;
306
- }
307
-
308
- async function startPlanning(prompt: string | undefined, ctx: ExtensionContext) {
309
- setPhase('planning', ctx, {
310
- notify:
311
- phase === 'off'
312
- ? 'Plan mode enabled. Ask pi to inspect, question, and plan before implementation.'
313
- : undefined,
314
- });
315
-
316
- const trimmed = prompt?.trim();
317
- if (!trimmed) return;
318
- await sendPlanningPrompt(trimmed, ctx);
143
+ return true;
319
144
  }
320
145
 
321
- async function runDomainWorkflow(args: string | undefined, ctx: ExtensionContext) {
322
- setPhase('planning', ctx, { notify: 'Running domain-model review in read-only plan mode.' });
323
-
324
- const planText =
325
- todoItems.length > 0 ? formatTodoList(todoItems) : 'No extracted plan steps yet.';
326
- const trimmedArgs = args?.trim();
327
- const promptText =
328
- trimmedArgs && trimmedArgs.length > 0
329
- ? trimmedArgs
330
- : `Stress-test the current plan against the existing domain model and terminology. Challenge ambiguous terms, invent concrete scenarios, compare claims against the codebase, and suggest any CONTEXT.md or ADR updates only when justified.\n\nCurrent plan:\n${planText}`;
331
- const skillCommand = availableCommand('skill:domain-model');
332
-
333
- if (skillCommand) {
334
- const suffix =
335
- trimmedArgs && trimmedArgs.length > 0
336
- ? trimmedArgs
337
- : `Stress-test the current plan against the domain model.\n\nCurrent plan:\n${planText}`;
338
- await sendPlanningPrompt(`/${skillCommand} ${suffix}`.trim(), ctx);
339
- return;
340
- }
341
-
342
- await sendPlanningPrompt(promptText, ctx);
146
+ // ── Phase transitions ─────────────────────────────────────────────────────
147
+ async function enterPlanMode(ctx: ExtensionContext): Promise<void> {
148
+ planEnabled = true;
149
+ executing = false;
150
+ planDir = undefined;
151
+ todos = [];
152
+ previousThinking = pi.getThinkingLevel() as ThinkingLevel;
153
+ previousModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
154
+ pi.setActiveTools(PLAN_TOOLS);
155
+ await switchModel(ctx, PLAN_MODEL);
156
+ pi.setThinkingLevel(PLAN_THINKING);
157
+ ctx.ui.notify(
158
+ `Plan mode ON — ${PLAN_MODEL.provider}/${PLAN_MODEL.id}:${PLAN_THINKING}`,
159
+ 'info',
160
+ );
161
+ updateUI(ctx);
162
+ persist();
343
163
  }
344
164
 
345
- async function runPlanFileWorkflow(args: string | undefined, ctx: ExtensionContext) {
346
- setPhase('plan-files', ctx, {
347
- notify:
348
- 'Plan-file authoring enabled. pi may write planning docs, but should not implement code.',
349
- });
350
- returnToPlanningAfterNextAgentEnd = true;
351
-
352
- const planText =
353
- todoItems.length > 0 ? formatTodoList(todoItems) : 'No extracted plan steps yet.';
354
- const trimmedArgs = args?.trim();
355
- const promptText =
356
- trimmedArgs && trimmedArgs.length > 0
357
- ? trimmedArgs
358
- : `Create self-contained implementation plan files for the current approved plan. Ground every file in the real codebase, document exact APIs and file paths, and write the plans to docs/plans unless the repo or user prefers another location.\n\nCurrent plan:\n${planText}`;
359
- const skillCommand = availableCommand('skill:create-implementation-plans');
360
-
361
- if (skillCommand) {
362
- const suffix =
363
- trimmedArgs && trimmedArgs.length > 0
364
- ? trimmedArgs
365
- : `Create self-contained implementation plan files for the current approved plan.\n\nCurrent plan:\n${planText}`;
366
- await sendPlanningPrompt(`/${skillCommand} ${suffix}`.trim(), ctx);
367
- return;
165
+ async function exitPlanMode(ctx: ExtensionContext): Promise<void> {
166
+ planEnabled = false;
167
+ executing = false;
168
+ planDir = undefined;
169
+ todos = [];
170
+ pi.setActiveTools(EXEC_TOOLS);
171
+ if (previousModel) {
172
+ await switchModel(ctx, previousModel);
368
173
  }
369
-
370
- await sendPlanningPrompt(promptText, ctx);
371
- }
372
-
373
- async function startExecution(args: string | undefined, ctx: ExtensionContext) {
374
- setPhase('executing', ctx, {
375
- notify: 'Plan execution mode enabled. Full tool access restored.',
376
- });
377
-
378
- const trimmedArgs = args?.trim();
379
- if (trimmedArgs && trimmedArgs.length > 0) {
380
- await sendPlanningPrompt(trimmedArgs, ctx);
381
- return;
174
+ if (previousThinking) {
175
+ pi.setThinkingLevel(previousThinking);
382
176
  }
383
-
384
- const defaultPrompt =
385
- todoItems.length > 0
386
- ? `Execute the approved plan in order.\n\nPlan:\n${formatTodoList(todoItems)}\n\nStart with step 1 and include [DONE:n] tags as each step is completed.`
387
- : 'Execute the approved plan in small verified steps. Include [DONE:n] tags as each major plan step is completed.';
388
- await sendPlanningPrompt(defaultPrompt, ctx);
177
+ ctx.ui.notify('Plan mode OFF — original model restored', 'info');
178
+ updateUI(ctx);
179
+ persist();
389
180
  }
390
181
 
391
- function showStatus(ctx: ExtensionContext) {
392
- const lines = [`Phase: ${phase}`];
393
- if (todoItems.length > 0) {
394
- const completed = todoItems.filter((item) => item.completed).length;
395
- lines.push(`Plan steps: ${completed}/${todoItems.length} complete`);
396
- lines.push(
397
- '',
398
- ...todoItems.map((item) => `${item.step}. ${item.completed ? '✓' : '○'} ${item.text}`),
399
- );
400
- }
401
- ctx.ui.notify(lines.join('\n'), 'info');
182
+ async function startExecution(ctx: ExtensionContext): Promise<void> {
183
+ planEnabled = false;
184
+ executing = true;
185
+ pi.setActiveTools(EXEC_TOOLS);
186
+ await switchModel(ctx, EXEC_MODEL);
187
+ pi.setThinkingLevel(EXEC_THINKING);
188
+ ctx.ui.notify(
189
+ `Executing plan ${EXEC_MODEL.provider}/${EXEC_MODEL.id}:${EXEC_THINKING}`,
190
+ 'info',
191
+ );
192
+ updateUI(ctx);
193
+ persist();
402
194
  }
403
195
 
404
- async function showPlanMenu(ctx: ExtensionContext) {
405
- if (!ctx.hasUI) {
406
- showStatus(ctx);
407
- return;
408
- }
409
-
410
- const choice = await ctx.ui.select('Plan mode', [
411
- 'Stay in planning mode',
412
- 'Stress-test with domain-model',
413
- 'Generate implementation plan files',
414
- 'Execute current plan',
415
- 'Show status',
416
- 'Disable plan mode',
417
- ]);
418
-
419
- if (!choice || choice === 'Stay in planning mode') return;
420
- if (choice === 'Stress-test with domain-model') {
421
- await runDomainWorkflow(undefined, ctx);
422
- return;
423
- }
424
- if (choice === 'Generate implementation plan files') {
425
- await runPlanFileWorkflow(undefined, ctx);
426
- return;
427
- }
428
- if (choice === 'Execute current plan') {
429
- await startExecution(undefined, ctx);
430
- return;
431
- }
432
- if (choice === 'Show status') {
433
- showStatus(ctx);
434
- return;
196
+ async function togglePlanMode(ctx: ExtensionContext): Promise<void> {
197
+ if (planEnabled || executing) {
198
+ await exitPlanMode(ctx);
199
+ } else {
200
+ await enterPlanMode(ctx);
435
201
  }
436
-
437
- setPhase('off', ctx, { notify: 'Plan mode disabled.' });
438
202
  }
439
203
 
204
+ // ── Commands ──────────────────────────────────────────────────────────────
440
205
  pi.registerCommand('plan', {
441
- description: 'Enable plan mode, send a planning prompt, or manage the plan workflow',
442
- getArgumentCompletions: getPlanCommandCompletions,
206
+ description: 'Enter plan mode, optionally with a starting prompt',
443
207
  handler: async (args, ctx) => {
444
- const raw = args?.trim() ?? '';
445
- if (!raw) {
446
- if (phase === 'off') {
447
- await startPlanning(undefined, ctx);
448
- } else {
449
- await showPlanMenu(ctx);
450
- }
208
+ if (planEnabled || executing) {
209
+ await togglePlanMode(ctx);
451
210
  return;
452
211
  }
453
-
454
- const lower = raw.toLowerCase();
455
- if (CLEAR_VALUES.has(lower)) {
456
- setPhase('off', ctx, { notify: 'Plan mode disabled.' });
457
- return;
212
+ await enterPlanMode(ctx);
213
+ const prompt = args?.trim();
214
+ if (prompt) {
215
+ pi.sendUserMessage(prompt);
458
216
  }
459
- if (lower === 'status') {
460
- showStatus(ctx);
461
- return;
462
- }
463
- if (lower === 'domain') {
464
- await runDomainWorkflow(undefined, ctx);
465
- return;
466
- }
467
- if (lower === 'plans' || lower === 'files') {
468
- await runPlanFileWorkflow(undefined, ctx);
469
- return;
470
- }
471
- if (lower === 'execute' || lower === 'run') {
472
- await startExecution(undefined, ctx);
473
- return;
474
- }
475
-
476
- await startPlanning(raw, ctx);
477
217
  },
478
218
  });
479
219
 
480
- pi.registerCommand('plan-status', {
481
- description: 'Show current plan workflow phase and extracted plan steps',
220
+ pi.registerCommand('todos', {
221
+ description: 'Show current plan progress',
482
222
  handler: async (_args, ctx) => {
483
- showStatus(ctx);
484
- },
485
- });
486
-
487
- pi.registerCommand('plan-domain', {
488
- description: 'Run a domain-model stress test for the current plan',
489
- handler: async (args, ctx) => {
490
- await runDomainWorkflow(args, ctx);
491
- },
492
- });
493
-
494
- pi.registerCommand('plan-plans', {
495
- description: 'Generate self-contained implementation plan files for the current plan',
496
- handler: async (args, ctx) => {
497
- await runPlanFileWorkflow(args, ctx);
498
- },
499
- });
500
-
501
- pi.registerCommand('plan-execute', {
502
- description: 'Leave read-only planning and execute the current approved plan',
503
- handler: async (args, ctx) => {
504
- await startExecution(args, ctx);
223
+ if (todos.length === 0) {
224
+ ctx.ui.notify('No plan yet. Use /plan to start planning.', 'info');
225
+ return;
226
+ }
227
+ const list = todos
228
+ .map((t, i) => `${i + 1}. ${t.completed ? '✓' : '○'} ${t.text}`)
229
+ .join('\n');
230
+ ctx.ui.notify(`Plan Progress:\n${list}`, 'info');
505
231
  },
506
232
  });
507
233
 
508
234
  pi.registerShortcut(Key.ctrlAlt('p'), {
509
235
  description: 'Toggle plan mode',
510
- handler: async (ctx) => {
511
- if (phase === 'off') setPhase('planning', ctx, { notify: 'Plan mode enabled.' });
512
- else setPhase('off', ctx, { notify: 'Plan mode disabled.' });
513
- },
236
+ handler: async (ctx) => togglePlanMode(ctx),
514
237
  });
515
238
 
239
+ // ── Block destructive bash in plan mode ───────────────────────────────────
516
240
  pi.on('tool_call', async (event) => {
517
- if (phase !== 'planning' || event.toolName !== 'bash') return;
241
+ if (!planEnabled) return;
242
+
243
+ // Block bash commands that aren't on the safe allowlist
244
+ if (event.toolName === 'bash') {
245
+ const command = event.input.command as string;
246
+ if (!isSafeCommand(command)) {
247
+ return {
248
+ block: true,
249
+ reason: `Plan mode: command blocked. Use /plan to exit plan mode first.\nCommand: ${command}`,
250
+ };
251
+ }
252
+ }
518
253
 
519
- const command = event.input.command as string;
520
- if (isSafeCommand(command)) return;
254
+ // Block edit/write to paths outside .plans/
255
+ if (event.toolName === 'edit' || event.toolName === 'write') {
256
+ const path = (event.input as { path?: string }).path ?? '';
257
+ if (!path.startsWith('.plans/') && !path.startsWith('.plans\\')) {
258
+ return {
259
+ block: true,
260
+ reason: `Plan mode: file modifications are restricted to .plans/ directory.\nPath: ${path}`,
261
+ };
262
+ }
263
+ }
264
+ });
521
265
 
266
+ // ── Filter stale plan context when not planning ───────────────────────────
267
+ pi.on('context', async (event) => {
268
+ if (planEnabled) return;
522
269
  return {
523
- block: true,
524
- reason: `Plan mode blocks non-read-only bash commands. Disable plan mode or use /plan-plans for controlled plan-file authoring.\nCommand: ${command}`,
270
+ messages: event.messages.filter((m) => {
271
+ const msg = m as AgentMessage & { customType?: string };
272
+ if (msg.customType === 'plan-mode-context') return false;
273
+ if (msg.role !== 'user') return true;
274
+ const content = msg.content;
275
+ if (typeof content === 'string') {
276
+ return !content.includes('[PLAN MODE ACTIVE]');
277
+ }
278
+ if (Array.isArray(content)) {
279
+ return !content.some(
280
+ (c) => c.type === 'text' && (c as TextContent).text?.includes('[PLAN MODE ACTIVE]'),
281
+ );
282
+ }
283
+ return true;
284
+ }),
525
285
  };
526
286
  });
527
287
 
528
- pi.on('before_agent_start', async (event, ctx) => {
529
- if (phase === 'off') return;
288
+ // ── Inject context for each phase ─────────────────────────────────────────
289
+ pi.on('before_agent_start', async () => {
290
+ if (planEnabled) {
291
+ return {
292
+ message: {
293
+ customType: 'plan-mode-context',
294
+ content: `[PLAN MODE ACTIVE]
295
+ You are in plan mode — a planning phase with strict bash restrictions.
530
296
 
531
- const compatEvent = event as typeof event & BeforeAgentStartCompatEvent;
532
- const activeTools = compatEvent.systemPromptOptions?.selectedTools ?? pi.getActiveTools();
297
+ Restrictions:
298
+ - Available tools: ${PLAN_TOOLS.join(', ')}
299
+ - Bash is restricted to read-only commands (ls, grep, git status, etc.)
300
+ - edit and write are ONLY allowed for files inside the \`.plans/\` directory
533
301
 
534
- if (phase === 'planning') {
535
- return {
536
- systemPrompt: `${event.systemPrompt}\n\n${buildPlanningInstructions(ctx, activeTools, todoItems)}`,
302
+ Your task:
303
+ 1. Analyze the codebase thoroughly using the available read-only tools
304
+ 2. Ask clarifying questions if needed (use the questionnaire tool)
305
+ 3. Produce a detailed, concrete plan
306
+
307
+ When you are ready to finalize the plan:
308
+ 1. Choose a short descriptive kebab-case name for the plan (e.g. "add-auth-middleware")
309
+ 2. Create \`.plans/<plan-name>/PLAN.md\` with the full numbered plan under a \`Plan:\` header:
310
+
311
+ \`\`\`markdown
312
+ # <Plan Title>
313
+
314
+ <Brief description of what this plan accomplishes>
315
+
316
+ ## Context
317
+ <Key findings from codebase analysis>
318
+
319
+ ## Plan:
320
+ 1. First step — what to change and where
321
+ 2. Second step — what to change and where
322
+ ...
323
+
324
+ ## Risks / Open Questions
325
+ <Any concerns or assumptions>
326
+ \`\`\`
327
+
328
+ 3. Create \`.plans/<plan-name>/START-PROMPT.md\` — a self-contained handoff prompt that a different model can use to execute the plan WITHOUT access to this conversation. It must include:
329
+ - Complete context about the codebase (relevant file paths, APIs, patterns)
330
+ - The full plan steps to execute
331
+ - Any critical constraints or gotchas
332
+ - Clear instructions to mark each step done with \`[DONE:n]\` tags
333
+
334
+ The START-PROMPT.md is critical — it must be thorough enough that an implementor with zero prior context can execute the plan correctly.
335
+
336
+ If you need supporting reference files for extra context (code snippets, diagrams, specs), place them alongside in the same \`.plans/<plan-name>/\` directory.
337
+
338
+ Do NOT attempt to make product code changes — only create planning artifacts in \`.plans/\`.`,
339
+ display: false,
340
+ },
537
341
  };
538
342
  }
539
343
 
540
- if (phase === 'plan-files') {
344
+ if (executing && todos.length > 0) {
345
+ const remaining = todos.filter((t) => !t.completed);
346
+ const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join('\n');
541
347
  return {
542
- systemPrompt: `${event.systemPrompt}\n\n${buildPlanFileInstructions(activeTools, todoItems)}`,
348
+ message: {
349
+ customType: 'plan-execution-context',
350
+ content: `[EXECUTING PLAN — Full tool access enabled]
351
+
352
+ Remaining steps:
353
+ ${todoList}
354
+
355
+ Execute each step in order. You MUST include [DONE:n] in your response after completing each step before moving to the next one.`,
356
+ display: false,
357
+ },
543
358
  };
544
359
  }
545
-
546
- return {
547
- systemPrompt: `${event.systemPrompt}\n\n${buildExecutionInstructions(todoItems)}`,
548
- };
549
360
  });
550
361
 
362
+ // ── Track [DONE:n] markers during execution ───────────────────────────────
551
363
  pi.on('turn_end', async (event, ctx) => {
552
- if (phase !== 'executing' || todoItems.length === 0) return;
553
- const text = getMessageText(event.message);
554
- if (!text) return;
364
+ if (!executing || todos.length === 0) return;
365
+ if (!isAssistantMessage(event.message)) return;
555
366
 
556
- if (markCompletedSteps(text, todoItems) > 0) {
557
- updateUi(ctx);
558
- persistState();
367
+ const text = getTextContent(event.message);
368
+ if (markCompletedSteps(text, todos) > 0) {
369
+ updateUI(ctx);
559
370
  }
371
+ persist();
560
372
  });
561
373
 
562
- pi.on('agent_end', async (event, ctx) => {
563
- if (returnToPlanningAfterNextAgentEnd && phase === 'plan-files') {
564
- returnToPlanningAfterNextAgentEnd = false;
565
- setPhase('planning', ctx, { notify: 'Returned to read-only plan mode.' });
374
+ // ── Detect plan directory from written files ──────────────────────────────
375
+ pi.on('tool_result', async (event) => {
376
+ if (!planEnabled) return;
377
+ if (event.toolName !== 'write' && event.toolName !== 'edit') return;
378
+ if (event.isError) return;
379
+
380
+ const path = (event.input as { path?: string }).path;
381
+ if (!path) return;
382
+
383
+ // Detect .plans/<name>/ directory from written files
384
+ const match = path.match(/\.plans\/([^/]+)\//);
385
+ if (match && !planDir) {
386
+ planDir = `.plans/${match[1]}`;
387
+ persist();
566
388
  }
389
+ });
567
390
 
568
- if (
569
- phase === 'executing' &&
570
- todoItems.length > 0 &&
571
- todoItems.every((item) => item.completed)
572
- ) {
573
- setPhase('off', ctx, { notify: 'Plan execution complete.' });
574
- todoItems = [];
575
- updateUi(ctx);
576
- persistState();
391
+ // ── After agent finishes: prompt for next action ──────────────────────────
392
+ pi.on('agent_end', async (event, ctx) => {
393
+ // Check execution completion
394
+ if (executing && todos.length > 0) {
395
+ if (todos.every((t) => t.completed)) {
396
+ const list = todos.map((t) => `~~${t.text}~~`).join('\n');
397
+ pi.sendMessage(
398
+ {
399
+ customType: 'plan-complete',
400
+ content: `**Plan Complete!** ✓\n\n${list}`,
401
+ display: true,
402
+ },
403
+ { triggerTurn: false },
404
+ );
405
+ executing = false;
406
+ todos = [];
407
+ planDir = undefined;
408
+ pi.setActiveTools(EXEC_TOOLS);
409
+ if (previousModel) {
410
+ await switchModel(ctx, previousModel);
411
+ }
412
+ if (previousThinking) {
413
+ pi.setThinkingLevel(previousThinking);
414
+ }
415
+ updateUI(ctx);
416
+ persist();
417
+ }
577
418
  return;
578
419
  }
579
420
 
580
- if (phase !== 'planning') return;
581
-
582
- const lastAssistantMessage = [...event.messages]
583
- .reverse()
584
- .find((message) => isAssistantMessage(message));
585
- if (!lastAssistantMessage) return;
586
-
587
- const extracted = extractTodoItems(getMessageText(lastAssistantMessage));
588
- if (extracted.length > 0) {
589
- todoItems = extracted;
590
- updateUi(ctx);
591
- persistState();
592
- }
421
+ if (!planEnabled || !ctx.hasUI) return;
593
422
 
594
- if (todoItems.length === 0 || !ctx.hasUI) return;
423
+ // Check if plan files were created by looking for planDir
424
+ if (!planDir) return;
595
425
 
426
+ // Show menu
596
427
  const choice = await ctx.ui.select('Plan ready — what next?', [
597
- 'Stay in planning mode',
598
- 'Stress-test with domain-model',
599
- 'Generate implementation plan files',
600
- 'Execute current plan',
601
- 'Refine the plan',
602
- 'Disable plan mode',
428
+ 'Execute Plan',
429
+ 'Refine Plan',
430
+ 'Follow up',
431
+ 'Exit plan mode',
603
432
  ]);
604
433
 
605
- if (!choice || choice === 'Stay in planning mode') return;
606
- if (choice === 'Stress-test with domain-model') {
607
- await runDomainWorkflow(undefined, ctx);
608
- return;
609
- }
610
- if (choice === 'Generate implementation plan files') {
611
- await runPlanFileWorkflow(undefined, ctx);
612
- return;
613
- }
614
- if (choice === 'Execute current plan') {
615
- await startExecution(undefined, ctx);
616
- return;
617
- }
618
- if (choice === 'Refine the plan') {
619
- const refinement = await ctx.ui.editor('Refine the plan:', '');
620
- if (refinement?.trim()) await sendPlanningPrompt(refinement, ctx);
621
- return;
622
- }
434
+ if (choice === 'Execute Plan') {
435
+ // Read START-PROMPT.md for clean context handoff
436
+ const startPromptPath = `${planDir}/START-PROMPT.md`;
437
+ const planMdPath = `${planDir}/PLAN.md`;
438
+
439
+ // Read the plan to extract todos
440
+ let planContent = '';
441
+ try {
442
+ const result = await pi.exec('cat', [planMdPath]);
443
+ if (result.code === 0) {
444
+ planContent = result.stdout;
445
+ }
446
+ } catch {
447
+ // Fall through will use empty plan content
448
+ }
623
449
 
624
- setPhase('off', ctx, { notify: 'Plan mode disabled.' });
625
- });
450
+ const extracted = extractTodoItems(planContent);
451
+ if (extracted.length > 0) {
452
+ todos = extracted;
453
+ }
626
454
 
627
- async function restoreState(ctx: ExtensionContext) {
628
- const savedState = findSavedState(ctx);
455
+ // Read the start prompt for clean handoff
456
+ let startPrompt = '';
457
+ try {
458
+ const result = await pi.exec('cat', [startPromptPath]);
459
+ if (result.code === 0) {
460
+ startPrompt = result.stdout.trim();
461
+ }
462
+ } catch {
463
+ // Fall through
464
+ }
629
465
 
630
- if (pi.getFlag('plan') === true && !savedState) {
631
- ensureRestoreTools();
632
- phase = 'planning';
633
- applyPhaseTools(phase);
634
- updateUi(ctx);
635
- persistState();
636
- return;
466
+ await startExecution(ctx);
467
+ updateUI(ctx);
468
+
469
+ if (startPrompt) {
470
+ pi.sendMessage(
471
+ {
472
+ customType: 'plan-mode-execute',
473
+ content: startPrompt,
474
+ display: true,
475
+ },
476
+ { triggerTurn: true },
477
+ );
478
+ } else {
479
+ // Fallback: ask executor to read the plan
480
+ pi.sendMessage(
481
+ {
482
+ customType: 'plan-mode-execute',
483
+ content: `Execute the plan in ${planMdPath}. Read it first, then execute step by step. Mark each step with [DONE:n] before moving to the next.`,
484
+ display: true,
485
+ },
486
+ { triggerTurn: true },
487
+ );
488
+ }
489
+ } else if (choice === 'Refine Plan') {
490
+ // Adversarial review — planner critiques its own plan
491
+ pi.sendMessage(
492
+ {
493
+ customType: 'plan-mode-refine',
494
+ content: `Review the plan you just created in ${planDir}/PLAN.md with an adversarial lens. Challenge assumptions, find gaps, identify risks, and look for:
495
+
496
+ - Missing edge cases or error handling
497
+ - Incorrect assumptions about the codebase
498
+ - Steps that are too vague or could be misinterpreted
499
+ - Missing dependencies between steps
500
+ - Simpler alternatives that were overlooked
501
+
502
+ After your review, update PLAN.md and START-PROMPT.md with any improvements.`,
503
+ display: true,
504
+ },
505
+ { triggerTurn: true },
506
+ );
507
+ } else if (choice === 'Follow up') {
508
+ const followUp = await ctx.ui.editor('Follow-up instructions for the planner:', '');
509
+ if (followUp?.trim()) {
510
+ pi.sendUserMessage(followUp.trim());
511
+ }
512
+ } else if (choice === 'Exit plan mode') {
513
+ await exitPlanMode(ctx);
637
514
  }
515
+ });
638
516
 
639
- if (!savedState) {
640
- phase = 'off';
641
- todoItems = [];
642
- updateUi(ctx);
643
- return;
517
+ // ── Restore state on session start/resume ─────────────────────────────────
518
+ pi.on('session_start', async (_event, ctx) => {
519
+ // Check CLI flag
520
+ if (pi.getFlag('plan') === true) {
521
+ planEnabled = true;
644
522
  }
645
523
 
646
- todoItems = savedState.todos.map((item) => ({ ...item }));
647
- phase = savedState.phase;
648
-
649
- if (phase === 'planning' || phase === 'plan-files') {
650
- ensureRestoreTools();
651
- applyPhaseTools(phase);
524
+ // Restore persisted state
525
+ const entries = ctx.sessionManager.getEntries();
526
+ const saved = entries
527
+ .filter(
528
+ (e: { type: string; customType?: string }) =>
529
+ e.type === 'custom' && e.customType === 'plan-mode',
530
+ )
531
+ .pop() as { data?: PersistedState } | undefined;
532
+
533
+ if (saved?.data) {
534
+ planEnabled = saved.data.planEnabled ?? planEnabled;
535
+ executing = saved.data.executing ?? executing;
536
+ planDir = saved.data.planDir ?? planDir;
537
+ todos = saved.data.todos ?? todos;
652
538
  }
653
539
 
654
- updateUi(ctx);
655
- }
540
+ // Re-scan [DONE:n] markers on resume
541
+ if (executing && todos.length > 0) {
542
+ let execIdx = -1;
543
+ for (let i = entries.length - 1; i >= 0; i--) {
544
+ const entry = entries[i] as { type: string; customType?: string };
545
+ if (entry.customType === 'plan-mode-execute') {
546
+ execIdx = i;
547
+ break;
548
+ }
549
+ }
656
550
 
657
- pi.on('session_start', async (_event, ctx) => {
658
- await restoreState(ctx);
659
- });
551
+ const messages: AssistantMessage[] = [];
552
+ for (let i = execIdx + 1; i < entries.length; i++) {
553
+ const entry = entries[i];
554
+ if (
555
+ entry.type === 'message' &&
556
+ 'message' in entry &&
557
+ isAssistantMessage(entry.message as AgentMessage)
558
+ ) {
559
+ messages.push(entry.message as AssistantMessage);
560
+ }
561
+ }
562
+ const allText = messages.map(getTextContent).join('\n');
563
+ markCompletedSteps(allText, todos);
564
+ }
565
+
566
+ // Apply tool restrictions, model, and thinking level
567
+ if (planEnabled) {
568
+ pi.setActiveTools(PLAN_TOOLS);
569
+ await switchModel(ctx, PLAN_MODEL);
570
+ pi.setThinkingLevel(PLAN_THINKING);
571
+ } else if (executing) {
572
+ pi.setActiveTools(EXEC_TOOLS);
573
+ await switchModel(ctx, EXEC_MODEL);
574
+ pi.setThinkingLevel(EXEC_THINKING);
575
+ }
660
576
 
661
- pi.on('session_tree', async (_event, ctx) => {
662
- await restoreState(ctx);
577
+ updateUI(ctx);
663
578
  });
664
579
  }