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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,12 +2,12 @@
2
2
  * Plan Mode Extension
3
3
  *
4
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
5
+ * 1. PLAN phase — read-only tools + submit_plan tool + medium thinking
6
+ * Planner analyzes codebase, calls submit_plan with structured data
7
+ * 2. EXECUTE phase — full tools + update_step tool + low thinking
8
+ * Executor works through plan steps, calling update_step for each
9
9
  *
10
- * Plans live in `.plans/<kebab-name>/PLAN.md` with a `START-PROMPT.md` sibling for clean handoff.
10
+ * Plans live in `.plans/<kebab-name>/plan.json` with structured steps and context.
11
11
  *
12
12
  * Commands:
13
13
  * /plan [prompt] — enter plan mode (optionally with a starting prompt)
@@ -18,33 +18,27 @@
18
18
  * --plan — start session in plan mode
19
19
  */
20
20
 
21
- import type { AgentMessage } from '@earendil-works/pi-agent-core';
22
- import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai';
23
21
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
24
22
  import { Key } from '@earendil-works/pi-tui';
25
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
26
- import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from './utils.js';
27
- import {
28
- extractPlanTitle,
29
- readPlansJson,
30
- serializePlansJson,
31
- type PlansManifest,
32
- } from './plans-json.js';
23
+ import { mkdir, writeFile } from 'node:fs/promises';
24
+ import { isSafeCommand } from './utils.js';
25
+ import { readPlansJson, serializePlansJson } from './plans-json.js';
26
+ import { registerSubmitPlanTool } from './tools/submit-plan.js';
27
+ import { registerUpdateStepTool } from './tools/update-step.js';
28
+ import type { PlanData, PersistedState } from './types.js';
33
29
 
34
30
  // ── Tool sets ────────────────────────────────────────────────────────────────
35
- // Plan phase: read-only + edit/write (for .plans/ files only, enforced by prompt)
36
31
  const PLAN_TOOLS = [
37
32
  'read',
38
33
  'bash',
39
34
  'grep',
40
35
  'find',
41
36
  'ls',
42
- 'edit',
43
- 'write',
37
+ 'submit_plan',
44
38
  'questionnaire',
45
39
  'search_skills',
46
40
  ];
47
- const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'search_skills'];
41
+ const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_step', 'search_skills'];
48
42
 
49
43
  // ── Model + thinking presets ─────────────────────────────────────────────────
50
44
  const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
@@ -55,32 +49,13 @@ const EXEC_THINKING = 'low' as const;
55
49
 
56
50
  type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
57
51
 
58
- // ── Persisted state ──────────────────────────────────────────────────────────
59
- interface PersistedState {
60
- planEnabled: boolean;
61
- executing: boolean;
62
- planDir: string | undefined;
63
- todos: TodoItem[];
64
- }
65
-
66
- // ── Helpers ──────────────────────────────────────────────────────────────────
67
- function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
68
- return m.role === 'assistant' && Array.isArray(m.content);
69
- }
70
-
71
- function getTextContent(message: AssistantMessage): string {
72
- return message.content
73
- .filter((b): b is TextContent => b.type === 'text')
74
- .map((b) => b.text)
75
- .join('\n');
76
- }
77
-
78
52
  // ── Extension ────────────────────────────────────────────────────────────────
79
53
  export default function planMode(pi: ExtensionAPI): void {
80
54
  let planEnabled = false;
81
55
  let executing = false;
82
56
  let planDir: string | undefined;
83
- let todos: TodoItem[] = [];
57
+ let plan: PlanData | undefined;
58
+ let executionStartIdx: number | undefined;
84
59
  let previousThinking: ThinkingLevel | undefined;
85
60
  let previousModel: { provider: string; id: string } | undefined;
86
61
 
@@ -97,7 +72,8 @@ export default function planMode(pi: ExtensionAPI): void {
97
72
  planEnabled,
98
73
  executing,
99
74
  planDir,
100
- todos,
75
+ plan,
76
+ executionStartIdx,
101
77
  });
102
78
  }
103
79
 
@@ -119,29 +95,43 @@ export default function planMode(pi: ExtensionAPI): void {
119
95
  };
120
96
 
121
97
  await mkdir('.plans', { recursive: true });
122
- const content = serializePlansJson(manifest);
123
- await writeFile('.plans/plans.json', content, 'utf-8');
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');
124
106
  }
125
107
 
126
108
  // ── UI updates ────────────────────────────────────────────────────────────
127
109
  function updateUI(ctx: ExtensionContext): void {
128
110
  const { theme } = ctx.ui;
129
111
 
130
- if (executing && todos.length > 0) {
131
- const done = todos.filter((t) => t.completed).length;
132
- ctx.ui.setStatus('plan-mode', theme.fg('accent', `📋 exec ${done}/${todos.length}`));
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}`));
133
116
  } else if (planEnabled) {
134
117
  ctx.ui.setStatus('plan-mode', theme.fg('warning', '📝 plan'));
135
118
  } else {
136
119
  ctx.ui.setStatus('plan-mode', undefined);
137
120
  }
138
121
 
139
- if (executing && todos.length > 0) {
140
- const lines = todos.map((item) => {
141
- if (item.completed) {
142
- return theme.fg('success', '☑ ') + theme.fg('muted', theme.strikethrough(item.text));
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);
143
134
  }
144
- return `${theme.fg('muted', '☐ ')}${item.text}`;
145
135
  });
146
136
  ctx.ui.setWidget('plan-todos', lines);
147
137
  } else {
@@ -172,7 +162,7 @@ export default function planMode(pi: ExtensionAPI): void {
172
162
  planEnabled = true;
173
163
  executing = false;
174
164
  planDir = undefined;
175
- todos = [];
165
+ plan = undefined;
176
166
  previousThinking = pi.getThinkingLevel() as ThinkingLevel;
177
167
  previousModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
178
168
  pi.setActiveTools(PLAN_TOOLS);
@@ -190,7 +180,8 @@ export default function planMode(pi: ExtensionAPI): void {
190
180
  planEnabled = false;
191
181
  executing = false;
192
182
  planDir = undefined;
193
- todos = [];
183
+ plan = undefined;
184
+ executionStartIdx = undefined;
194
185
  pi.setActiveTools(EXEC_TOOLS);
195
186
  if (previousModel) {
196
187
  await switchModel(ctx, previousModel);
@@ -206,6 +197,8 @@ export default function planMode(pi: ExtensionAPI): void {
206
197
  async function startExecution(ctx: ExtensionContext): Promise<void> {
207
198
  planEnabled = false;
208
199
  executing = true;
200
+ // Record message count so the context filter can drop the planning conversation
201
+ executionStartIdx = ctx.sessionManager.getEntries().length;
209
202
  pi.setActiveTools(EXEC_TOOLS);
210
203
  await switchModel(ctx, EXEC_MODEL);
211
204
  pi.setThinkingLevel(EXEC_THINKING);
@@ -225,6 +218,26 @@ export default function planMode(pi: ExtensionAPI): void {
225
218
  }
226
219
  }
227
220
 
221
+ // ── Register tools ────────────────────────────────────────────────────────
222
+ registerSubmitPlanTool(pi, {
223
+ onPlanSubmitted: (dir, submittedPlan) => {
224
+ planDir = dir;
225
+ plan = submittedPlan;
226
+ persist();
227
+ },
228
+ });
229
+
230
+ registerUpdateStepTool(pi, {
231
+ getPlan: () => plan,
232
+ 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();
238
+ },
239
+ });
240
+
228
241
  // ── Commands ──────────────────────────────────────────────────────────────
229
242
  pi.registerCommand('plan', {
230
243
  description: 'Enter plan mode, optionally with a starting prompt',
@@ -244,11 +257,14 @@ export default function planMode(pi: ExtensionAPI): void {
244
257
  pi.registerCommand('todos', {
245
258
  description: 'Show current plan progress',
246
259
  handler: async (_args, ctx) => {
247
- if (todos.length === 0) {
260
+ if (!plan || plan.steps.length === 0) {
248
261
  ctx.ui.notify('No plan yet. Use /plan to start planning.', 'info');
249
262
  return;
250
263
  }
251
- const list = todos.map((t, i) => `${i + 1}. ${t.completed ? '✓' : ''} ${t.text}`).join('\n');
264
+ const statusIcon = { pending: '○', done: '✓', skipped: '', blocked: '' };
265
+ const list = plan.steps
266
+ .map((s, i) => `${i + 1}. ${statusIcon[s.status]} ${s.description}`)
267
+ .join('\n');
252
268
  ctx.ui.notify(`Plan Progress:\n${list}`, 'info');
253
269
  },
254
270
  });
@@ -262,7 +278,6 @@ export default function planMode(pi: ExtensionAPI): void {
262
278
  pi.on('tool_call', async (event) => {
263
279
  if (!planEnabled) return;
264
280
 
265
- // Block bash commands that aren't on the safe allowlist
266
281
  if (event.toolName === 'bash') {
267
282
  const command = event.input.command as string;
268
283
  if (!isSafeCommand(command)) {
@@ -272,25 +287,25 @@ export default function planMode(pi: ExtensionAPI): void {
272
287
  };
273
288
  }
274
289
  }
275
-
276
- // Block edit/write to paths outside .plans/
277
- if (event.toolName === 'edit' || event.toolName === 'write') {
278
- const path = (event.input as { path?: string }).path ?? '';
279
- if (!path.startsWith('.plans/') && !path.startsWith('.plans\\')) {
280
- return {
281
- block: true,
282
- reason: `Plan mode: file modifications are restricted to .plans/ directory.\nPath: ${path}`,
283
- };
284
- }
285
- }
286
290
  });
287
291
 
288
- // ── Filter stale plan context when not planning ───────────────────────────
292
+ // ── Filter planning conversation when executing ──────────────────────────
289
293
  pi.on('context', async (event) => {
290
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
+ };
303
+ }
304
+
305
+ // Not executing — just strip stale plan-mode injected messages
291
306
  return {
292
307
  messages: event.messages.filter((m) => {
293
- const msg = m as AgentMessage & { customType?: string };
308
+ const msg = m as { customType?: string; role?: string; content?: unknown };
294
309
  if (msg.customType === 'plan-mode-context') return false;
295
310
  if (msg.role !== 'user') return true;
296
311
  const content = msg.content;
@@ -299,7 +314,8 @@ export default function planMode(pi: ExtensionAPI): void {
299
314
  }
300
315
  if (Array.isArray(content)) {
301
316
  return !content.some(
302
- (c) => c.type === 'text' && (c as TextContent).text?.includes('[PLAN MODE ACTIVE]'),
317
+ (c: { type?: string; text?: string }) =>
318
+ c.type === 'text' && c.text?.includes('[PLAN MODE ACTIVE]'),
303
319
  );
304
320
  }
305
321
  return true;
@@ -319,131 +335,134 @@ You are in plan mode — a planning phase with strict bash restrictions.
319
335
  Restrictions:
320
336
  - Available tools: ${PLAN_TOOLS.join(', ')}
321
337
  - Bash is restricted to read-only commands (ls, grep, git status, etc.)
322
- - edit and write are ONLY allowed for files inside the \`.plans/\` directory
323
338
 
324
339
  Your task:
325
340
  1. Analyze the codebase thoroughly using the available read-only tools
326
341
  2. Ask clarifying questions if needed (use the questionnaire tool)
327
342
  3. Produce a detailed, concrete plan
328
343
 
329
- When you are ready to finalize the plan:
330
- 1. Choose a short descriptive kebab-case name for the plan (e.g. "add-auth-middleware")
331
- 2. Create \`.plans/<plan-name>/PLAN.md\` with the full numbered plan under a \`Plan:\` header:
332
-
333
- \`\`\`markdown
334
- # <Plan Title>
335
-
336
- <Brief description of what this plan accomplishes>
337
-
338
- ## Context
339
- <Key findings from codebase analysis>
340
-
341
- ## Plan:
342
- 1. First step — what to change and where
343
- 2. Second step — what to change and where
344
- ...
345
-
346
- ## Risks / Open Questions
347
- <Any concerns or assumptions>
348
- \`\`\`
349
-
350
- 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:
351
- - Complete context about the codebase (relevant file paths, APIs, patterns)
352
- - The full plan steps to execute
353
- - Any critical constraints or gotchas
354
- - Clear instructions to mark each step done with \`[DONE:n]\` tags
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
355
350
 
356
- The START-PROMPT.md is critical it must be thorough enough that an implementor with zero prior context can execute the plan correctly.
357
-
358
- If you need supporting reference files for extra context (code snippets, diagrams, specs), place them alongside in the same \`.plans/<plan-name>/\` directory.
359
-
360
- Do NOT attempt to make product code changes — only create planning artifacts in \`.plans/\`.`,
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.`,
361
353
  display: false,
362
354
  },
363
355
  };
364
356
  }
365
357
 
366
- if (executing && todos.length > 0) {
367
- const remaining = todos.filter((t) => !t.completed);
368
- const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join('\n');
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
369
  return {
370
370
  message: {
371
371
  customType: 'plan-execution-context',
372
372
  content: `[EXECUTING PLAN — Full tool access enabled]
373
373
 
374
+ Context:
375
+ ${plan.context}
376
+
374
377
  Remaining steps:
375
- ${todoList}
378
+ ${stepList}
376
379
 
377
- Execute each step in order. You MUST include [DONE:n] in your response after completing each step before moving to the next one.`,
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.`,
378
383
  display: false,
379
384
  },
380
385
  };
381
386
  }
382
387
  });
383
388
 
384
- // ── Track [DONE:n] markers during execution ───────────────────────────────
385
- pi.on('turn_end', async (event, ctx) => {
386
- if (!executing || todos.length === 0) return;
387
- if (!isAssistantMessage(event.message)) return;
388
-
389
- const text = getTextContent(event.message);
390
- if (markCompletedSteps(text, todos) > 0) {
391
- updateUI(ctx);
392
- }
393
- persist();
394
- });
395
-
396
- // ── Detect plan directory from written files ──────────────────────────────
397
- pi.on('tool_result', async (event) => {
398
- if (!planEnabled) return;
399
- if (event.toolName !== 'write' && event.toolName !== 'edit') return;
400
- if (event.isError) return;
401
-
402
- const path = (event.input as { path?: string }).path;
403
- if (!path) return;
404
-
405
- // Detect .plans/<name>/ directory from written files
406
- const match = path.match(/\.plans\/([^/]+)\//);
407
- if (match && !planDir) {
408
- planDir = `.plans/${match[1]}`;
409
- const planName = match[1];
410
-
411
- // Read PLAN.md to extract the title for plans.json
412
- let title = 'Untitled plan';
413
- if (path.endsWith('PLAN.md')) {
414
- try {
415
- const content = await readFile(path, 'utf-8');
416
- title = extractPlanTitle(content);
417
- } catch {
418
- // Fall through
389
+ // ── Handle blocked steps and plan completion ──────────────────────────────
390
+ pi.on('agent_end', async (_event, ctx) => {
391
+ // Check for blocked steps during execution
392
+ if (executing && plan) {
393
+ const blocked = plan.steps
394
+ .map((s, i) => ({ ...s, num: i + 1 }))
395
+ .filter((s) => s.status === 'blocked');
396
+
397
+ 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}`;
402
+
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',
408
+ ]);
409
+
410
+ 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) {
419
+ pi.sendUserMessage('The blocked step has been skipped. Continue with the next step.', { deliverAs: 'followUp' });
420
+ }
421
+ // If no pending, fall through to completion check below
422
+ } else if (choice === 'Provide instructions') {
423
+ const instructions = await ctx.ui.editor('Instructions for the blocked step:', '');
424
+ 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();
430
+ pi.sendUserMessage(
431
+ `Retry step ${blockedStep.num} with these additional instructions: ${instructions.trim()}`,
432
+ { deliverAs: 'followUp' },
433
+ );
434
+ }
435
+ return;
436
+ } else if (choice === 'Re-plan') {
437
+ await enterPlanMode(ctx);
438
+ pi.sendUserMessage(
439
+ `Step ${blockedStep.num} was blocked: ${blockedStep.notes ?? 'no details'}. Re-analyze and create a revised plan.`,
440
+ { deliverAs: 'followUp' },
441
+ );
442
+ return;
443
+ } else if (choice === 'Abort execution') {
444
+ await exitPlanMode(ctx);
445
+ return;
419
446
  }
420
447
  }
421
- await updatePlansManifest(planName, 'in-progress', title);
422
- persist();
423
- } else if (match && planDir && path.endsWith('PLAN.md')) {
424
- // planDir already set but PLAN.md just written — update title
425
- try {
426
- const content = await readFile(path, 'utf-8');
427
- const title = extractPlanTitle(content);
428
- await updatePlansManifest(match[1], 'in-progress', title);
429
- } catch {
430
- // Fall through
431
- }
432
- }
433
- });
434
448
 
435
- // ── After agent finishes: prompt for next action ──────────────────────────
436
- pi.on('agent_end', async (event, ctx) => {
437
- // Check execution completion
438
- if (executing && todos.length > 0) {
439
- if (todos.every((t) => t.completed)) {
440
- // Mark plan as done in plans.json
449
+ // Check if all steps are resolved (done or skipped)
450
+ const allResolved = plan.steps.every((s) => s.status === 'done' || s.status === 'skipped');
451
+ if (allResolved) {
441
452
  if (planDir) {
442
453
  const planName = planDir.replace(/^\.plans\//, '');
443
- await updatePlansManifest(planName, 'done');
454
+ await updatePlansManifest(planName, 'done', plan.title);
455
+ await savePlanToDisk();
444
456
  }
445
457
 
446
- const list = todos.map((t) => `~~${t.text}~~`).join('\n');
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;
463
+ })
464
+ .join('\n');
465
+
447
466
  pi.sendMessage(
448
467
  {
449
468
  customType: 'plan-complete',
@@ -452,9 +471,11 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
452
471
  },
453
472
  { triggerTurn: false },
454
473
  );
474
+
455
475
  executing = false;
456
- todos = [];
476
+ plan = undefined;
457
477
  planDir = undefined;
478
+ executionStartIdx = undefined;
458
479
  pi.setActiveTools(EXEC_TOOLS);
459
480
  if (previousModel) {
460
481
  await switchModel(ctx, previousModel);
@@ -464,16 +485,15 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
464
485
  }
465
486
  updateUI(ctx);
466
487
  persist();
488
+ return;
467
489
  }
468
490
  return;
469
491
  }
470
492
 
471
493
  if (!planEnabled || !ctx.hasUI) return;
494
+ if (!planDir || !plan) return;
472
495
 
473
- // Check if plan files were created by looking for planDir
474
- if (!planDir) return;
475
-
476
- // Show menu
496
+ // Show menu after plan submission
477
497
  const choice = await ctx.ui.select('Plan ready — what next?', [
478
498
  'Execute Plan',
479
499
  'Refine Plan',
@@ -482,60 +502,21 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
482
502
  ]);
483
503
 
484
504
  if (choice === 'Execute Plan') {
485
- // Read START-PROMPT.md for clean context handoff
486
- const startPromptPath = `${planDir}/START-PROMPT.md`;
487
- const planMdPath = `${planDir}/PLAN.md`;
488
-
489
- // Read the plan to extract todos
490
- let planContent = '';
491
- try {
492
- planContent = await readFile(planMdPath, 'utf-8');
493
- } catch {
494
- // Fall through — will use empty plan content
495
- }
496
-
497
- const extracted = extractTodoItems(planContent);
498
- if (extracted.length > 0) {
499
- todos = extracted;
500
- }
501
-
502
- // Read the start prompt for clean handoff
503
- let startPrompt = '';
504
- try {
505
- startPrompt = (await readFile(startPromptPath, 'utf-8')).trim();
506
- } catch {
507
- // Fall through
508
- }
509
-
510
505
  await startExecution(ctx);
511
506
  updateUI(ctx);
512
507
 
513
- if (startPrompt) {
514
- pi.sendMessage(
515
- {
516
- customType: 'plan-mode-execute',
517
- content: startPrompt,
518
- display: true,
519
- },
520
- { triggerTurn: true, deliverAs: 'followUp' },
521
- );
522
- } else {
523
- // Fallback: ask executor to read the plan
524
- pi.sendMessage(
525
- {
526
- customType: 'plan-mode-execute',
527
- 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.`,
528
- display: true,
529
- },
530
- { triggerTurn: true, deliverAs: 'followUp' },
531
- );
532
- }
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
+ );
533
517
  } else if (choice === 'Refine Plan') {
534
- // Adversarial review — planner critiques its own plan
535
- pi.sendMessage(
536
- {
537
- customType: 'plan-mode-refine',
538
- content: `Review the plan you just created in ${planDir}/PLAN.md with an adversarial lens. Challenge assumptions, find gaps, identify risks, and look for:
518
+ pi.sendUserMessage(
519
+ `Review the plan you just created with an adversarial lens. Challenge assumptions, find gaps, identify risks, and look for:
539
520
 
540
521
  - Missing edge cases or error handling
541
522
  - Incorrect assumptions about the codebase
@@ -543,22 +524,13 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
543
524
  - Missing dependencies between steps
544
525
  - Simpler alternatives that were overlooked
545
526
 
546
- After your review, update PLAN.md and START-PROMPT.md with any improvements.`,
547
- display: true,
548
- },
549
- { triggerTurn: true, deliverAs: 'followUp' },
527
+ After your review, call submit_plan again with the improved plan.`,
528
+ { deliverAs: 'followUp' },
550
529
  );
551
530
  } else if (choice === 'Follow up') {
552
531
  const followUp = await ctx.ui.editor('Follow-up instructions for the planner:', '');
553
532
  if (followUp?.trim()) {
554
- pi.sendMessage(
555
- {
556
- customType: 'plan-mode-followup',
557
- content: followUp.trim(),
558
- display: true,
559
- },
560
- { triggerTurn: true, deliverAs: 'followUp' },
561
- );
533
+ pi.sendUserMessage(followUp.trim(), { deliverAs: 'followUp' });
562
534
  }
563
535
  } else if (choice === 'Exit plan mode') {
564
536
  await exitPlanMode(ctx);
@@ -567,7 +539,6 @@ After your review, update PLAN.md and START-PROMPT.md with any improvements.`,
567
539
 
568
540
  // ── Restore state on session start/resume ─────────────────────────────────
569
541
  pi.on('session_start', async (_event, ctx) => {
570
- // Check CLI flag
571
542
  if (pi.getFlag('plan') === true) {
572
543
  planEnabled = true;
573
544
  }
@@ -585,33 +556,8 @@ After your review, update PLAN.md and START-PROMPT.md with any improvements.`,
585
556
  planEnabled = saved.data.planEnabled ?? planEnabled;
586
557
  executing = saved.data.executing ?? executing;
587
558
  planDir = saved.data.planDir ?? planDir;
588
- todos = saved.data.todos ?? todos;
589
- }
590
-
591
- // Re-scan [DONE:n] markers on resume
592
- if (executing && todos.length > 0) {
593
- let execIdx = -1;
594
- for (let i = entries.length - 1; i >= 0; i--) {
595
- const entry = entries[i] as { type: string; customType?: string };
596
- if (entry.customType === 'plan-mode-execute') {
597
- execIdx = i;
598
- break;
599
- }
600
- }
601
-
602
- const messages: AssistantMessage[] = [];
603
- for (let i = execIdx + 1; i < entries.length; i++) {
604
- const entry = entries[i];
605
- if (
606
- entry.type === 'message' &&
607
- 'message' in entry &&
608
- isAssistantMessage(entry.message as AgentMessage)
609
- ) {
610
- messages.push(entry.message as AssistantMessage);
611
- }
612
- }
613
- const allText = messages.map(getTextContent).join('\n');
614
- markCompletedSteps(allText, todos);
559
+ plan = saved.data.plan ?? plan;
560
+ executionStartIdx = saved.data.executionStartIdx ?? executionStartIdx;
615
561
  }
616
562
 
617
563
  // Apply tool restrictions, model, and thinking level