@dreki-gg/pi-plan-mode 0.7.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,63 +1,38 @@
1
1
  /**
2
- * Plan Mode Extension
2
+ * Plan Mode Extension — Thin orchestrator
3
3
  *
4
4
  * Two-phase workflow:
5
5
  * 1. PLAN phase — read-only tools + submit_plan tool + medium thinking
6
- * Planner analyzes codebase, calls submit_plan with structured data
7
6
  * 2. EXECUTE phase — full tools + update_step tool + low thinking
8
- * Executor works through plan steps, calling update_step for each
9
- *
10
- * Plans live in `.plans/<kebab-name>/plan.json` with structured steps and context.
11
7
  *
12
8
  * Commands:
13
- * /plan [prompt] — enter plan mode (optionally with a starting prompt)
9
+ * /plan [prompt] — enter plan mode
10
+ * /plan resume — resume an in-progress plan from disk
11
+ * /plan-exec — execute the current plan in a clean session
14
12
  * /todos — show current plan progress
15
- * Ctrl+Alt+P — toggle plan mode (shortcut)
13
+ * Ctrl+Alt+P — toggle plan mode
16
14
  *
17
15
  * Flag:
18
16
  * --plan — start session in plan mode
19
17
  */
20
18
 
21
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
19
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
22
20
  import { Key } from '@earendil-works/pi-tui';
23
- import { mkdir, writeFile } from 'node:fs/promises';
24
- import { isSafeCommand } from './utils.js';
25
- import { readPlansJson, serializePlansJson } from './plans-json.js';
21
+ import { PLAN_TOOLS, EXEC_TOOLS, PLAN_MODEL, PLAN_THINKING, EXEC_MODEL, EXEC_THINKING } from './constants.js';
22
+ import type { ThinkingLevel } from './types.js';
23
+ import { PlanModeState } from './state.js';
24
+ import { savePlanToDisk, loadPlanFromDisk, readAndClearExecPending, updatePlansManifest } from './plan-storage.js';
25
+ import { updateUI } from './ui.js';
26
+ import { buildPlanModePrompt, buildExecutionPrompt } from './prompts.js';
27
+ import { filterExecutionMessages, filterStalePlanMessages } from './context-filter.js';
28
+ import { enterPlanMode, exitPlanMode, switchModel } from './phase-transitions.js';
29
+ import { resumePlan, executeInNewSession } from './resume.js';
26
30
  import { registerSubmitPlanTool } from './tools/submit-plan.js';
27
31
  import { registerUpdateStepTool } from './tools/update-step.js';
28
- import type { PlanData, PersistedState } from './types.js';
29
-
30
- // ── Tool sets ────────────────────────────────────────────────────────────────
31
- const PLAN_TOOLS = [
32
- 'read',
33
- 'bash',
34
- 'grep',
35
- 'find',
36
- 'ls',
37
- 'submit_plan',
38
- 'questionnaire',
39
- 'search_skills',
40
- ];
41
- const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_step', 'search_skills'];
42
-
43
- // ── Model + thinking presets ─────────────────────────────────────────────────
44
- const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
45
- const PLAN_THINKING = 'medium' as const;
46
-
47
- const EXEC_MODEL = { provider: 'openai', id: 'gpt-5.5' } as const;
48
- const EXEC_THINKING = 'low' as const;
49
-
50
- type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
32
+ import { isSafeCommand } from './utils.js';
51
33
 
52
- // ── Extension ────────────────────────────────────────────────────────────────
53
34
  export default function planMode(pi: ExtensionAPI): void {
54
- let planEnabled = false;
55
- let executing = false;
56
- let planDir: string | undefined;
57
- let plan: PlanData | undefined;
58
- let executionStartIdx: number | undefined;
59
- let previousThinking: ThinkingLevel | undefined;
60
- let previousModel: { provider: string; id: string } | undefined;
35
+ const state = new PlanModeState();
61
36
 
62
37
  // ── Flag ──────────────────────────────────────────────────────────────────
63
38
  pi.registerFlag('plan', {
@@ -66,203 +41,65 @@ export default function planMode(pi: ExtensionAPI): void {
66
41
  default: false,
67
42
  });
68
43
 
69
- // ── State persistence ─────────────────────────────────────────────────────
70
- function persist(): void {
71
- pi.appendEntry<PersistedState>('plan-mode', {
72
- planEnabled,
73
- executing,
74
- planDir,
75
- plan,
76
- executionStartIdx,
77
- });
78
- }
79
-
80
- // ── plans.json tracking ───────────────────────────────────────────────────
81
- async function updatePlansManifest(
82
- planName: string,
83
- status: 'in-progress' | 'done',
84
- title?: string,
85
- ): Promise<void> {
86
- const manifest = await readPlansJson();
87
- const existing = manifest[planName];
88
- const now = new Date().toISOString();
89
-
90
- manifest[planName] = {
91
- status,
92
- title: title ?? existing?.title ?? 'Untitled plan',
93
- created: existing?.created ?? now,
94
- completed: status === 'done' ? now : null,
95
- };
96
-
97
- await mkdir('.plans', { recursive: true });
98
- await writeFile('.plans/plans.json', serializePlansJson(manifest), 'utf-8');
99
- }
100
-
101
- // ── Save plan.json to disk ────────────────────────────────────────────────
102
- async function savePlanToDisk(): Promise<void> {
103
- if (!planDir || !plan) return;
104
- await mkdir(planDir, { recursive: true });
105
- await writeFile(`${planDir}/plan.json`, JSON.stringify(plan, null, 2) + '\n', 'utf-8');
106
- }
107
-
108
- // ── UI updates ────────────────────────────────────────────────────────────
109
- function updateUI(ctx: ExtensionContext): void {
110
- const { theme } = ctx.ui;
111
-
112
- if (executing && plan) {
113
- const done = plan.steps.filter((s) => s.status === 'done').length;
114
- const total = plan.steps.length;
115
- ctx.ui.setStatus('plan-mode', theme.fg('accent', `📋 exec ${done}/${total}`));
116
- } else if (planEnabled) {
117
- ctx.ui.setStatus('plan-mode', theme.fg('warning', '📝 plan'));
118
- } else {
119
- ctx.ui.setStatus('plan-mode', undefined);
120
- }
121
-
122
- if (executing && plan) {
123
- const lines = plan.steps.map((step, i) => {
124
- const num = `${i + 1}. `;
125
- switch (step.status) {
126
- case 'done':
127
- return theme.fg('success', '✓ ') + theme.fg('muted', theme.strikethrough(num + step.description));
128
- case 'skipped':
129
- return theme.fg('warning', '⊘ ') + theme.fg('muted', theme.strikethrough(num + step.description));
130
- case 'blocked':
131
- return theme.fg('error', '✗ ') + theme.fg('error', num + step.description);
132
- default:
133
- return theme.fg('muted', '☐ ') + (num + step.description);
134
- }
135
- });
136
- ctx.ui.setWidget('plan-todos', lines);
137
- } else {
138
- ctx.ui.setWidget('plan-todos', undefined);
139
- }
140
- }
141
-
142
- // ── Model switching ───────────────────────────────────────────────────────
143
- async function switchModel(
144
- ctx: ExtensionContext,
145
- preset: { provider: string; id: string },
146
- ): Promise<boolean> {
147
- const model = ctx.modelRegistry.find(preset.provider, preset.id);
148
- if (!model) {
149
- ctx.ui.notify(`Model ${preset.provider}/${preset.id} not found`, 'error');
150
- return false;
151
- }
152
- const ok = await pi.setModel(model);
153
- if (!ok) {
154
- ctx.ui.notify(`No API key for ${preset.provider}/${preset.id}`, 'error');
155
- return false;
156
- }
157
- return true;
158
- }
159
-
160
- // ── Phase transitions ─────────────────────────────────────────────────────
161
- async function enterPlanMode(ctx: ExtensionContext): Promise<void> {
162
- planEnabled = true;
163
- executing = false;
164
- planDir = undefined;
165
- plan = undefined;
166
- previousThinking = pi.getThinkingLevel() as ThinkingLevel;
167
- previousModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
168
- pi.setActiveTools(PLAN_TOOLS);
169
- await switchModel(ctx, PLAN_MODEL);
170
- pi.setThinkingLevel(PLAN_THINKING);
171
- ctx.ui.notify(
172
- `Plan mode ON — ${PLAN_MODEL.provider}/${PLAN_MODEL.id}:${PLAN_THINKING}`,
173
- 'info',
174
- );
175
- updateUI(ctx);
176
- persist();
177
- }
178
-
179
- async function exitPlanMode(ctx: ExtensionContext): Promise<void> {
180
- planEnabled = false;
181
- executing = false;
182
- planDir = undefined;
183
- plan = undefined;
184
- executionStartIdx = undefined;
185
- pi.setActiveTools(EXEC_TOOLS);
186
- if (previousModel) {
187
- await switchModel(ctx, previousModel);
188
- }
189
- if (previousThinking) {
190
- pi.setThinkingLevel(previousThinking);
191
- }
192
- ctx.ui.notify('Plan mode OFF — original model restored', 'info');
193
- updateUI(ctx);
194
- persist();
195
- }
196
-
197
- async function startExecution(ctx: ExtensionContext): Promise<void> {
198
- planEnabled = false;
199
- executing = true;
200
- // Record message count so the context filter can drop the planning conversation
201
- executionStartIdx = ctx.sessionManager.getEntries().length;
202
- pi.setActiveTools(EXEC_TOOLS);
203
- await switchModel(ctx, EXEC_MODEL);
204
- pi.setThinkingLevel(EXEC_THINKING);
205
- ctx.ui.notify(
206
- `Executing plan — ${EXEC_MODEL.provider}/${EXEC_MODEL.id}:${EXEC_THINKING}`,
207
- 'info',
208
- );
209
- updateUI(ctx);
210
- persist();
211
- }
212
-
213
- async function togglePlanMode(ctx: ExtensionContext): Promise<void> {
214
- if (planEnabled || executing) {
215
- await exitPlanMode(ctx);
216
- } else {
217
- await enterPlanMode(ctx);
218
- }
219
- }
220
-
221
- // ── Register tools ────────────────────────────────────────────────────────
44
+ // ── Tools ─────────────────────────────────────────────────────────────────
222
45
  registerSubmitPlanTool(pi, {
223
46
  onPlanSubmitted: (dir, submittedPlan) => {
224
- planDir = dir;
225
- plan = submittedPlan;
226
- persist();
47
+ state.planDir = dir;
48
+ state.plan = submittedPlan;
49
+ state.persist(pi);
227
50
  },
228
51
  });
229
52
 
230
53
  registerUpdateStepTool(pi, {
231
- getPlan: () => plan,
54
+ getPlan: () => state.plan,
232
55
  onStepUpdated: (step, status, notes) => {
233
- if (!plan) return;
234
- const stepIdx = step - 1;
235
- plan.steps[stepIdx].status = status;
236
- if (notes) plan.steps[stepIdx].notes = notes;
237
- persist();
56
+ if (!state.plan) return;
57
+ state.plan.steps[step - 1].status = status;
58
+ if (notes) state.plan.steps[step - 1].notes = notes;
59
+ state.persist(pi);
238
60
  },
239
61
  });
240
62
 
241
63
  // ── Commands ──────────────────────────────────────────────────────────────
242
64
  pi.registerCommand('plan', {
243
- description: 'Enter plan mode, optionally with a starting prompt',
65
+ description: 'Enter plan mode, optionally with a starting prompt. Use "/plan resume" to pick up an existing plan.',
244
66
  handler: async (args, ctx) => {
245
- if (planEnabled || executing) {
246
- await togglePlanMode(ctx);
67
+ const trimmed = args?.trim();
68
+ if (trimmed === 'resume') {
69
+ await resumePlan(state, pi, ctx);
70
+ return;
71
+ }
72
+ if (state.planEnabled || state.executing) {
73
+ await exitPlanMode(state, pi, ctx);
247
74
  return;
248
75
  }
249
- await enterPlanMode(ctx);
250
- const prompt = args?.trim();
251
- if (prompt) {
252
- pi.sendUserMessage(prompt);
76
+ await enterPlanMode(state, pi, ctx);
77
+ if (trimmed) pi.sendUserMessage(trimmed);
78
+ },
79
+ });
80
+
81
+ pi.registerCommand('plan-exec', {
82
+ description: 'Execute the current plan in a clean session',
83
+ handler: async (_args, ctx) => {
84
+ if (!state.planDir || !state.plan) {
85
+ ctx.ui.notify('No plan to execute.', 'error');
86
+ return;
253
87
  }
88
+ const stepList = state.plan.steps.map((s, i) => `${i + 1}. ${s.description}`).join('\n');
89
+ const kickoff = `Execute the following plan: "${state.plan.title}"\n\nSteps:\n${stepList}\n\nStart with step 1. Call update_step after completing each step.`;
90
+ await executeInNewSession(ctx, state.planDir, state.plan, kickoff);
254
91
  },
255
92
  });
256
93
 
257
94
  pi.registerCommand('todos', {
258
95
  description: 'Show current plan progress',
259
96
  handler: async (_args, ctx) => {
260
- if (!plan || plan.steps.length === 0) {
97
+ if (!state.plan || state.plan.steps.length === 0) {
261
98
  ctx.ui.notify('No plan yet. Use /plan to start planning.', 'info');
262
99
  return;
263
100
  }
264
- const statusIcon = { pending: '○', done: '✓', skipped: '⊘', blocked: '✗' };
265
- const list = plan.steps
101
+ const statusIcon = { pending: '○', done: '✓', skipped: '⊘', blocked: '✗' } as const;
102
+ const list = state.plan.steps
266
103
  .map((s, i) => `${i + 1}. ${statusIcon[s.status]} ${s.description}`)
267
104
  .join('\n');
268
105
  ctx.ui.notify(`Plan Progress:\n${list}`, 'info');
@@ -271,13 +108,18 @@ export default function planMode(pi: ExtensionAPI): void {
271
108
 
272
109
  pi.registerShortcut(Key.ctrlAlt('p'), {
273
110
  description: 'Toggle plan mode',
274
- handler: async (ctx) => togglePlanMode(ctx),
111
+ handler: async (ctx) => {
112
+ if (state.planEnabled || state.executing) {
113
+ await exitPlanMode(state, pi, ctx);
114
+ } else {
115
+ await enterPlanMode(state, pi, ctx);
116
+ }
117
+ },
275
118
  });
276
119
 
277
- // ── Block destructive bash in plan mode ───────────────────────────────────
120
+ // ── Event: block destructive bash in plan mode ────────────────────────────
278
121
  pi.on('tool_call', async (event) => {
279
- if (!planEnabled) return;
280
-
122
+ if (!state.planEnabled) return;
281
123
  if (event.toolName === 'bash') {
282
124
  const command = event.input.command as string;
283
125
  if (!isSafeCommand(command)) {
@@ -289,231 +131,145 @@ export default function planMode(pi: ExtensionAPI): void {
289
131
  }
290
132
  });
291
133
 
292
- // ── Filter planning conversation when executing ──────────────────────────
134
+ // ── Event: filter context ─────────────────────────────────────────────────
293
135
  pi.on('context', async (event) => {
294
- if (planEnabled) return;
295
-
296
- if (executing && executionStartIdx !== undefined) {
297
- // During execution, drop everything from the planning phase.
298
- // The executor gets its context from the before_agent_start injection.
299
- const startIdx = executionStartIdx;
300
- return {
301
- messages: event.messages.filter((_m, i) => i >= startIdx),
302
- };
136
+ if (state.planEnabled) return;
137
+ if (state.executing && state.executionStartIdx !== undefined) {
138
+ return { messages: filterExecutionMessages(event.messages, state.executionStartIdx) };
303
139
  }
304
-
305
- // Not executing — just strip stale plan-mode injected messages
306
- return {
307
- messages: event.messages.filter((m) => {
308
- const msg = m as { customType?: string; role?: string; content?: unknown };
309
- if (msg.customType === 'plan-mode-context') return false;
310
- if (msg.role !== 'user') return true;
311
- const content = msg.content;
312
- if (typeof content === 'string') {
313
- return !content.includes('[PLAN MODE ACTIVE]');
314
- }
315
- if (Array.isArray(content)) {
316
- return !content.some(
317
- (c: { type?: string; text?: string }) =>
318
- c.type === 'text' && c.text?.includes('[PLAN MODE ACTIVE]'),
319
- );
320
- }
321
- return true;
322
- }),
323
- };
140
+ return { messages: filterStalePlanMessages(event.messages) };
324
141
  });
325
142
 
326
- // ── Inject context for each phase ─────────────────────────────────────────
143
+ // ── Event: inject phase prompts ───────────────────────────────────────────
327
144
  pi.on('before_agent_start', async () => {
328
- if (planEnabled) {
145
+ if (state.planEnabled) {
329
146
  return {
330
- message: {
331
- customType: 'plan-mode-context',
332
- content: `[PLAN MODE ACTIVE]
333
- You are in plan mode — a planning phase with strict bash restrictions.
334
-
335
- Restrictions:
336
- - Available tools: ${PLAN_TOOLS.join(', ')}
337
- - Bash is restricted to read-only commands (ls, grep, git status, etc.)
338
-
339
- Your task:
340
- 1. Analyze the codebase thoroughly using the available read-only tools
341
- 2. Ask clarifying questions if needed (use the questionnaire tool)
342
- 3. Produce a detailed, concrete plan
343
-
344
- When you are ready to finalize the plan, call the submit_plan tool with:
345
- - name: a short kebab-case name (e.g. "add-auth-middleware")
346
- - title: a human-readable plan title
347
- - context: complete codebase context including relevant file paths, APIs, patterns, constraints, and gotchas — this must be thorough enough that an implementor with zero prior context can execute the plan
348
- - steps: an array of steps, each with a short description (≤60 chars for display) and detailed implementation instructions
349
- - risks: any open questions, assumptions, or concerns
350
-
351
- Do NOT attempt to make product code changes — only analyze and plan.
352
- Do NOT write files manually — use submit_plan to finalize the plan.`,
353
- display: false,
354
- },
147
+ message: { customType: 'plan-mode-context', content: buildPlanModePrompt(), display: false },
355
148
  };
356
149
  }
357
-
358
- if (executing && plan) {
359
- const remaining = plan.steps
360
- .map((s, i) => ({ ...s, num: i + 1 }))
361
- .filter((s) => s.status === 'pending');
362
-
363
- if (remaining.length === 0) return;
364
-
365
- const stepList = remaining
366
- .map((s) => `${s.num}. ${s.description}\n Details: ${s.details}`)
367
- .join('\n\n');
368
-
369
- return {
370
- message: {
371
- customType: 'plan-execution-context',
372
- content: `[EXECUTING PLAN — Full tool access enabled]
373
-
374
- Context:
375
- ${plan.context}
376
-
377
- Remaining steps:
378
- ${stepList}
379
-
380
- Execute each step in order. After completing each step, call the update_step tool to mark it done before moving to the next.
381
- If a step is unnecessary, call update_step with status "skipped".
382
- If a step cannot be completed, call update_step with status "blocked" and explain why in the notes.`,
383
- display: false,
384
- },
385
- };
150
+ if (state.executing && state.plan) {
151
+ const content = buildExecutionPrompt(state.plan);
152
+ if (content) {
153
+ return {
154
+ message: { customType: 'plan-execution-context', content, display: false },
155
+ };
156
+ }
386
157
  }
387
158
  });
388
159
 
389
- // ── Handle blocked steps and plan completion ──────────────────────────────
160
+ // ── Event: agent_end — blocked steps, completion, post-plan menu ──────────
390
161
  pi.on('agent_end', async (_event, ctx) => {
391
- // Check for blocked steps during execution
392
- if (executing && plan) {
393
- const blocked = plan.steps
162
+ // ── During execution: handle blocked steps and completion ──
163
+ if (state.executing && state.plan) {
164
+ const blocked = state.plan.steps
394
165
  .map((s, i) => ({ ...s, num: i + 1 }))
395
166
  .filter((s) => s.status === 'blocked');
396
167
 
397
168
  if (blocked.length > 0) {
398
- const blockedStep = blocked[0];
399
- const blockedInfo = blockedStep.notes
400
- ? `Step ${blockedStep.num}: ${blockedStep.description}\nReason: ${blockedStep.notes}`
401
- : `Step ${blockedStep.num}: ${blockedStep.description}`;
169
+ const bs = blocked[0];
170
+ const info = bs.notes
171
+ ? `Step ${bs.num}: ${bs.description}\nReason: ${bs.notes}`
172
+ : `Step ${bs.num}: ${bs.description}`;
402
173
 
403
- const choice = await ctx.ui.select(`Step blocked — ${blockedInfo}\n\nWhat next?`, [
404
- 'Skip this step',
405
- 'Provide instructions',
406
- 'Re-plan',
407
- 'Abort execution',
174
+ const choice = await ctx.ui.select(`Step blocked — ${info}\n\nWhat next?`, [
175
+ 'Skip this step', 'Provide instructions', 'Re-plan', 'Abort execution',
408
176
  ]);
409
177
 
410
178
  if (choice === 'Skip this step') {
411
- plan.steps[blockedStep.num - 1].status = 'skipped';
412
- await savePlanToDisk();
413
- updateUI(ctx);
414
- persist();
415
-
416
- // Check if there are more pending steps
417
- const hasPending = plan.steps.some((s) => s.status === 'pending');
418
- if (hasPending) {
179
+ state.plan.steps[bs.num - 1].status = 'skipped';
180
+ await savePlanToDisk(state.planDir!, state.plan);
181
+ updateUI(state, ctx);
182
+ state.persist(pi);
183
+ if (state.plan.steps.some((s) => s.status === 'pending')) {
419
184
  pi.sendUserMessage('The blocked step has been skipped. Continue with the next step.', { deliverAs: 'followUp' });
420
185
  }
421
- // If no pending, fall through to completion check below
422
186
  } else if (choice === 'Provide instructions') {
423
187
  const instructions = await ctx.ui.editor('Instructions for the blocked step:', '');
424
188
  if (instructions?.trim()) {
425
- plan.steps[blockedStep.num - 1].status = 'pending';
426
- plan.steps[blockedStep.num - 1].notes = undefined;
427
- await savePlanToDisk();
428
- updateUI(ctx);
429
- persist();
189
+ state.plan.steps[bs.num - 1].status = 'pending';
190
+ state.plan.steps[bs.num - 1].notes = undefined;
191
+ await savePlanToDisk(state.planDir!, state.plan);
192
+ updateUI(state, ctx);
193
+ state.persist(pi);
430
194
  pi.sendUserMessage(
431
- `Retry step ${blockedStep.num} with these additional instructions: ${instructions.trim()}`,
195
+ `Retry step ${bs.num} with these additional instructions: ${instructions.trim()}`,
432
196
  { deliverAs: 'followUp' },
433
197
  );
434
198
  }
435
199
  return;
436
200
  } else if (choice === 'Re-plan') {
437
- await enterPlanMode(ctx);
201
+ await enterPlanMode(state, pi, ctx);
438
202
  pi.sendUserMessage(
439
- `Step ${blockedStep.num} was blocked: ${blockedStep.notes ?? 'no details'}. Re-analyze and create a revised plan.`,
203
+ `Step ${bs.num} was blocked: ${bs.notes ?? 'no details'}. Re-analyze and create a revised plan.`,
440
204
  { deliverAs: 'followUp' },
441
205
  );
442
206
  return;
443
207
  } else if (choice === 'Abort execution') {
444
- await exitPlanMode(ctx);
208
+ await exitPlanMode(state, pi, ctx);
445
209
  return;
446
210
  }
447
211
  }
448
212
 
449
- // Check if all steps are resolved (done or skipped)
450
- const allResolved = plan.steps.every((s) => s.status === 'done' || s.status === 'skipped');
213
+ // Check completion
214
+ const allResolved = state.plan.steps.every((s) => s.status === 'done' || s.status === 'skipped');
451
215
  if (allResolved) {
452
- if (planDir) {
453
- const planName = planDir.replace(/^\.plans\//, '');
454
- await updatePlansManifest(planName, 'done', plan.title);
455
- await savePlanToDisk();
216
+ if (state.planDir) {
217
+ await updatePlansManifest(state.planDir.replace(/^\.plans\//, ''), 'done', state.plan.title);
218
+ await savePlanToDisk(state.planDir, state.plan);
456
219
  }
457
-
458
- const list = plan.steps
459
- .map((s) => {
460
- if (s.status === 'done') return `~~${s.description}~~`;
461
- if (s.status === 'skipped') return `⊘ ~~${s.description}~~`;
462
- return s.description;
220
+ const done = state.plan.steps.filter((s) => s.status === 'done').length;
221
+ const skipped = state.plan.steps.filter((s) => s.status === 'skipped').length;
222
+ const total = state.plan.steps.length;
223
+ const stats = skipped > 0
224
+ ? `${done}/${total} done, ${skipped} skipped`
225
+ : `${done}/${total} done`;
226
+
227
+ // Build a summary of what was actually done from step notes
228
+ const changeSummary = state.plan.steps
229
+ .map((s, i) => {
230
+ const icon = s.status === 'done' ? '✓' : '⊘';
231
+ const label = `${i + 1}. ${icon} ${s.description}`;
232
+ return s.notes ? `${label}\n ${s.notes}` : label;
463
233
  })
464
234
  .join('\n');
465
235
 
236
+ const summary = [
237
+ `**Plan Complete!** ✓ — ${state.plan.title}`,
238
+ '',
239
+ `> ${stats}`,
240
+ '',
241
+ '## Summary',
242
+ '',
243
+ changeSummary,
244
+ ].join('\n');
245
+
466
246
  pi.sendMessage(
467
- {
468
- customType: 'plan-complete',
469
- content: `**Plan Complete!** ✓\n\n${list}`,
470
- display: true,
471
- },
247
+ { customType: 'plan-complete', content: summary, display: true },
472
248
  { triggerTurn: false },
473
249
  );
474
250
 
475
- executing = false;
476
- plan = undefined;
477
- planDir = undefined;
478
- executionStartIdx = undefined;
251
+ const { previousModel: pm, previousThinking: pt } = state;
252
+ state.reset();
479
253
  pi.setActiveTools(EXEC_TOOLS);
480
- if (previousModel) {
481
- await switchModel(ctx, previousModel);
482
- }
483
- if (previousThinking) {
484
- pi.setThinkingLevel(previousThinking);
485
- }
486
- updateUI(ctx);
487
- persist();
254
+ if (pm) await switchModel(pi, ctx, pm);
255
+ if (pt) pi.setThinkingLevel(pt);
256
+ updateUI(state, ctx);
257
+ state.persist(pi);
488
258
  return;
489
259
  }
490
260
  return;
491
261
  }
492
262
 
493
- if (!planEnabled || !ctx.hasUI) return;
494
- if (!planDir || !plan) return;
263
+ // ── After plan submission: show post-plan menu ──
264
+ if (!state.planEnabled || !ctx.hasUI) return;
265
+ if (!state.planDir || !state.plan) return;
495
266
 
496
- // Show menu after plan submission
497
267
  const choice = await ctx.ui.select('Plan ready — what next?', [
498
- 'Execute Plan',
499
- 'Refine Plan',
500
- 'Follow up',
501
- 'Exit plan mode',
268
+ 'Execute Plan', 'Refine Plan', 'Follow up', 'Exit plan mode',
502
269
  ]);
503
270
 
504
271
  if (choice === 'Execute Plan') {
505
- await startExecution(ctx);
506
- updateUI(ctx);
507
-
508
- // Build execution prompt from structured plan data
509
- const stepList = plan.steps
510
- .map((s, i) => `${i + 1}. ${s.description}`)
511
- .join('\n');
512
-
513
- pi.sendUserMessage(
514
- `Execute the following plan: "${plan.title}"\n\nSteps:\n${stepList}\n\nStart with step 1. Call update_step after completing each step.`,
515
- { deliverAs: 'followUp' },
516
- );
272
+ pi.sendUserMessage('/plan-exec', { deliverAs: 'followUp' });
517
273
  } else if (choice === 'Refine Plan') {
518
274
  pi.sendUserMessage(
519
275
  `Review the plan you just created with an adversarial lens. Challenge assumptions, find gaps, identify risks, and look for:
@@ -528,49 +284,48 @@ After your review, call submit_plan again with the improved plan.`,
528
284
  { deliverAs: 'followUp' },
529
285
  );
530
286
  } else if (choice === 'Follow up') {
531
- const followUp = await ctx.ui.editor('Follow-up instructions for the planner:', '');
532
- if (followUp?.trim()) {
533
- pi.sendUserMessage(followUp.trim(), { deliverAs: 'followUp' });
534
- }
287
+ // No-op: dismiss menu, let user type naturally
535
288
  } else if (choice === 'Exit plan mode') {
536
- await exitPlanMode(ctx);
289
+ await exitPlanMode(state, pi, ctx);
537
290
  }
538
291
  });
539
292
 
540
- // ── Restore state on session start/resume ─────────────────────────────────
293
+ // ── Event: session restore ────────────────────────────────────────────────
541
294
  pi.on('session_start', async (_event, ctx) => {
542
- if (pi.getFlag('plan') === true) {
543
- planEnabled = true;
544
- }
295
+ if (pi.getFlag('plan') === true) state.planEnabled = true;
545
296
 
546
- // Restore persisted state
547
- const entries = ctx.sessionManager.getEntries();
548
- const saved = entries
549
- .filter(
550
- (e: { type: string; customType?: string }) =>
551
- e.type === 'custom' && e.customType === 'plan-mode',
552
- )
553
- .pop() as { data?: PersistedState } | undefined;
297
+ state.restore(
298
+ ctx.sessionManager.getEntries() as Array<{ type: string; customType?: string; data?: any }>,
299
+ );
554
300
 
555
- if (saved?.data) {
556
- planEnabled = saved.data.planEnabled ?? planEnabled;
557
- executing = saved.data.executing ?? executing;
558
- planDir = saved.data.planDir ?? planDir;
559
- plan = saved.data.plan ?? plan;
560
- executionStartIdx = saved.data.executionStartIdx ?? executionStartIdx;
301
+ // Check for exec-pending handoff from planning session
302
+ const pending = await readAndClearExecPending();
303
+ if (pending) {
304
+ state.planDir = pending.planDir;
305
+ state.plan = await loadPlanFromDisk(pending.planDir);
306
+ if (state.plan) {
307
+ state.executing = true;
308
+ state.planEnabled = false;
309
+ pi.setActiveTools(EXEC_TOOLS);
310
+ await switchModel(pi, ctx, pending.config.model);
311
+ pi.setThinkingLevel(pending.config.thinking as ThinkingLevel);
312
+ updateUI(state, ctx);
313
+ state.persist(pi);
314
+ return;
315
+ }
561
316
  }
562
317
 
563
318
  // Apply tool restrictions, model, and thinking level
564
- if (planEnabled) {
319
+ if (state.planEnabled) {
565
320
  pi.setActiveTools(PLAN_TOOLS);
566
- await switchModel(ctx, PLAN_MODEL);
321
+ await switchModel(pi, ctx, PLAN_MODEL);
567
322
  pi.setThinkingLevel(PLAN_THINKING);
568
- } else if (executing) {
323
+ } else if (state.executing) {
569
324
  pi.setActiveTools(EXEC_TOOLS);
570
- await switchModel(ctx, EXEC_MODEL);
325
+ await switchModel(pi, ctx, EXEC_MODEL);
571
326
  pi.setThinkingLevel(EXEC_THINKING);
572
327
  }
573
328
 
574
- updateUI(ctx);
329
+ updateUI(state, ctx);
575
330
  });
576
331
  }