@dreki-gg/pi-plan-mode 0.6.4 → 0.10.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,88 +1,38 @@
1
1
  /**
2
- * Plan Mode Extension
2
+ * Plan Mode Extension — Thin orchestrator
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
9
- *
10
- * Plans live in `.plans/<kebab-name>/PLAN.md` with a `START-PROMPT.md` sibling for clean handoff.
5
+ * 1. PLAN phase — read-only tools + submit_plan tool + medium thinking
6
+ * 2. EXECUTE phase full tools + update_step tool + low thinking
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 { 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';
19
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
24
20
  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';
33
-
34
- // ── Tool sets ────────────────────────────────────────────────────────────────
35
- // Plan phase: read-only + edit/write (for .plans/ files only, enforced by prompt)
36
- const PLAN_TOOLS = [
37
- 'read',
38
- 'bash',
39
- 'grep',
40
- 'find',
41
- 'ls',
42
- 'edit',
43
- 'write',
44
- 'questionnaire',
45
- 'search_skills',
46
- ];
47
- const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'search_skills'];
48
-
49
- // ── Model + thinking presets ─────────────────────────────────────────────────
50
- const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
51
- const PLAN_THINKING = 'medium' as const;
52
-
53
- const EXEC_MODEL = { provider: 'openai', id: 'gpt-5.5' } as const;
54
- const EXEC_THINKING = 'low' as const;
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';
30
+ import { registerSubmitPlanTool } from './tools/submit-plan.js';
31
+ import { registerUpdateStepTool } from './tools/update-step.js';
32
+ import { isSafeCommand } from './utils.js';
55
33
 
56
- type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
57
-
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
- // ── Extension ────────────────────────────────────────────────────────────────
79
34
  export default function planMode(pi: ExtensionAPI): void {
80
- let planEnabled = false;
81
- let executing = false;
82
- let planDir: string | undefined;
83
- let todos: TodoItem[] = [];
84
- let previousThinking: ThinkingLevel | undefined;
85
- let previousModel: { provider: string; id: string } | undefined;
35
+ const state = new PlanModeState();
86
36
 
87
37
  // ── Flag ──────────────────────────────────────────────────────────────────
88
38
  pi.registerFlag('plan', {
@@ -91,178 +41,85 @@ export default function planMode(pi: ExtensionAPI): void {
91
41
  default: false,
92
42
  });
93
43
 
94
- // ── State persistence ─────────────────────────────────────────────────────
95
- function persist(): void {
96
- pi.appendEntry<PersistedState>('plan-mode', {
97
- planEnabled,
98
- executing,
99
- planDir,
100
- todos,
101
- });
102
- }
103
-
104
- // ── plans.json tracking ───────────────────────────────────────────────────
105
- async function updatePlansManifest(
106
- planName: string,
107
- status: 'in-progress' | 'done',
108
- title?: string,
109
- ): Promise<void> {
110
- const manifest = await readPlansJson();
111
- const existing = manifest[planName];
112
- const now = new Date().toISOString();
113
-
114
- manifest[planName] = {
115
- status,
116
- title: title ?? existing?.title ?? 'Untitled plan',
117
- created: existing?.created ?? now,
118
- completed: status === 'done' ? now : null,
119
- };
120
-
121
- await mkdir('.plans', { recursive: true });
122
- const content = serializePlansJson(manifest);
123
- await writeFile('.plans/plans.json', content, 'utf-8');
124
- }
125
-
126
- // ── UI updates ────────────────────────────────────────────────────────────
127
- function updateUI(ctx: ExtensionContext): void {
128
- const { theme } = ctx.ui;
129
-
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}`));
133
- } else if (planEnabled) {
134
- ctx.ui.setStatus('plan-mode', theme.fg('warning', '📝 plan'));
135
- } else {
136
- ctx.ui.setStatus('plan-mode', undefined);
137
- }
138
-
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));
143
- }
144
- return `${theme.fg('muted', '☐ ')}${item.text}`;
145
- });
146
- ctx.ui.setWidget('plan-todos', lines);
147
- } else {
148
- ctx.ui.setWidget('plan-todos', undefined);
149
- }
150
- }
151
-
152
- // ── Model switching ───────────────────────────────────────────────────────
153
- async function switchModel(
154
- ctx: ExtensionContext,
155
- preset: { provider: string; id: string },
156
- ): Promise<boolean> {
157
- const model = ctx.modelRegistry.find(preset.provider, preset.id);
158
- if (!model) {
159
- ctx.ui.notify(`Model ${preset.provider}/${preset.id} not found`, 'error');
160
- return false;
161
- }
162
- const ok = await pi.setModel(model);
163
- if (!ok) {
164
- ctx.ui.notify(`No API key for ${preset.provider}/${preset.id}`, 'error');
165
- return false;
166
- }
167
- return true;
168
- }
169
-
170
- // ── Phase transitions ─────────────────────────────────────────────────────
171
- async function enterPlanMode(ctx: ExtensionContext): Promise<void> {
172
- planEnabled = true;
173
- executing = false;
174
- planDir = undefined;
175
- todos = [];
176
- previousThinking = pi.getThinkingLevel() as ThinkingLevel;
177
- previousModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
178
- pi.setActiveTools(PLAN_TOOLS);
179
- await switchModel(ctx, PLAN_MODEL);
180
- pi.setThinkingLevel(PLAN_THINKING);
181
- ctx.ui.notify(
182
- `Plan mode ON — ${PLAN_MODEL.provider}/${PLAN_MODEL.id}:${PLAN_THINKING}`,
183
- 'info',
184
- );
185
- updateUI(ctx);
186
- persist();
187
- }
188
-
189
- async function exitPlanMode(ctx: ExtensionContext): Promise<void> {
190
- planEnabled = false;
191
- executing = false;
192
- planDir = undefined;
193
- todos = [];
194
- pi.setActiveTools(EXEC_TOOLS);
195
- if (previousModel) {
196
- await switchModel(ctx, previousModel);
197
- }
198
- if (previousThinking) {
199
- pi.setThinkingLevel(previousThinking);
200
- }
201
- ctx.ui.notify('Plan mode OFF — original model restored', 'info');
202
- updateUI(ctx);
203
- persist();
204
- }
205
-
206
- async function startExecution(ctx: ExtensionContext): Promise<void> {
207
- planEnabled = false;
208
- executing = true;
209
- pi.setActiveTools(EXEC_TOOLS);
210
- await switchModel(ctx, EXEC_MODEL);
211
- pi.setThinkingLevel(EXEC_THINKING);
212
- ctx.ui.notify(
213
- `Executing plan — ${EXEC_MODEL.provider}/${EXEC_MODEL.id}:${EXEC_THINKING}`,
214
- 'info',
215
- );
216
- updateUI(ctx);
217
- persist();
218
- }
44
+ // ── Tools ─────────────────────────────────────────────────────────────────
45
+ registerSubmitPlanTool(pi, {
46
+ onPlanSubmitted: (dir, submittedPlan) => {
47
+ state.planDir = dir;
48
+ state.plan = submittedPlan;
49
+ state.persist(pi);
50
+ },
51
+ });
219
52
 
220
- async function togglePlanMode(ctx: ExtensionContext): Promise<void> {
221
- if (planEnabled || executing) {
222
- await exitPlanMode(ctx);
223
- } else {
224
- await enterPlanMode(ctx);
225
- }
226
- }
53
+ registerUpdateStepTool(pi, {
54
+ getPlan: () => state.plan,
55
+ onStepUpdated: (step, status, notes) => {
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);
60
+ },
61
+ });
227
62
 
228
63
  // ── Commands ──────────────────────────────────────────────────────────────
229
64
  pi.registerCommand('plan', {
230
- 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.',
231
66
  handler: async (args, ctx) => {
232
- if (planEnabled || executing) {
233
- await togglePlanMode(ctx);
67
+ const trimmed = args?.trim();
68
+ if (trimmed === 'resume') {
69
+ await resumePlan(state, pi, ctx);
234
70
  return;
235
71
  }
236
- await enterPlanMode(ctx);
237
- const prompt = args?.trim();
238
- if (prompt) {
239
- pi.sendUserMessage(prompt);
72
+ if (state.planEnabled || state.executing) {
73
+ await exitPlanMode(state, pi, ctx);
74
+ return;
240
75
  }
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;
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);
241
91
  },
242
92
  });
243
93
 
244
94
  pi.registerCommand('todos', {
245
95
  description: 'Show current plan progress',
246
96
  handler: async (_args, ctx) => {
247
- if (todos.length === 0) {
97
+ if (!state.plan || state.plan.steps.length === 0) {
248
98
  ctx.ui.notify('No plan yet. Use /plan to start planning.', 'info');
249
99
  return;
250
100
  }
251
- const list = todos.map((t, i) => `${i + 1}. ${t.completed ? '✓' : ''} ${t.text}`).join('\n');
101
+ const statusIcon = { pending: '○', done: '✓', skipped: '', blocked: '' } as const;
102
+ const list = state.plan.steps
103
+ .map((s, i) => `${i + 1}. ${statusIcon[s.status]} ${s.description}`)
104
+ .join('\n');
252
105
  ctx.ui.notify(`Plan Progress:\n${list}`, 'info');
253
106
  },
254
107
  });
255
108
 
256
109
  pi.registerShortcut(Key.ctrlAlt('p'), {
257
110
  description: 'Toggle plan mode',
258
- 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
+ },
259
118
  });
260
119
 
261
- // ── Block destructive bash in plan mode ───────────────────────────────────
120
+ // ── Event: block destructive bash in plan mode ────────────────────────────
262
121
  pi.on('tool_call', async (event) => {
263
- if (!planEnabled) return;
264
-
265
- // Block bash commands that aren't on the safe allowlist
122
+ if (!state.planEnabled) return;
266
123
  if (event.toolName === 'bash') {
267
124
  const command = event.input.command as string;
268
125
  if (!isSafeCommand(command)) {
@@ -272,270 +129,127 @@ export default function planMode(pi: ExtensionAPI): void {
272
129
  };
273
130
  }
274
131
  }
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
132
  });
287
133
 
288
- // ── Filter stale plan context when not planning ───────────────────────────
134
+ // ── Event: filter context ─────────────────────────────────────────────────
289
135
  pi.on('context', async (event) => {
290
- if (planEnabled) return;
291
- return {
292
- messages: event.messages.filter((m) => {
293
- const msg = m as AgentMessage & { customType?: string };
294
- if (msg.customType === 'plan-mode-context') return false;
295
- if (msg.role !== 'user') return true;
296
- const content = msg.content;
297
- if (typeof content === 'string') {
298
- return !content.includes('[PLAN MODE ACTIVE]');
299
- }
300
- if (Array.isArray(content)) {
301
- return !content.some(
302
- (c) => c.type === 'text' && (c as TextContent).text?.includes('[PLAN MODE ACTIVE]'),
303
- );
304
- }
305
- return true;
306
- }),
307
- };
136
+ if (state.planEnabled) return;
137
+ if (state.executing && state.executionStartIdx !== undefined) {
138
+ return { messages: filterExecutionMessages(event.messages, state.executionStartIdx) };
139
+ }
140
+ return { messages: filterStalePlanMessages(event.messages) };
308
141
  });
309
142
 
310
- // ── Inject context for each phase ─────────────────────────────────────────
143
+ // ── Event: inject phase prompts ───────────────────────────────────────────
311
144
  pi.on('before_agent_start', async () => {
312
- if (planEnabled) {
313
- return {
314
- message: {
315
- customType: 'plan-mode-context',
316
- content: `[PLAN MODE ACTIVE]
317
- You are in plan mode — a planning phase with strict bash restrictions.
318
-
319
- Restrictions:
320
- - Available tools: ${PLAN_TOOLS.join(', ')}
321
- - 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
-
324
- Your task:
325
- 1. Analyze the codebase thoroughly using the available read-only tools
326
- 2. Ask clarifying questions if needed (use the questionnaire tool)
327
- 3. Produce a detailed, concrete plan
328
-
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
355
-
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/\`.`,
361
- display: false,
362
- },
363
- };
364
- }
365
-
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');
145
+ if (state.planEnabled) {
369
146
  return {
370
- message: {
371
- customType: 'plan-execution-context',
372
- content: `[EXECUTING PLAN — Full tool access enabled]
373
-
374
- Remaining steps:
375
- ${todoList}
376
-
377
- Execute each step in order. You MUST include [DONE:n] in your response after completing each step before moving to the next one.`,
378
- display: false,
379
- },
147
+ message: { customType: 'plan-mode-context', content: buildPlanModePrompt(), display: false },
380
148
  };
381
149
  }
382
- });
383
-
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);
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
+ }
392
157
  }
393
- persist();
394
158
  });
395
159
 
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
160
+ // ── Event: agent_end blocked steps, completion, post-plan menu ──────────
161
+ pi.on('agent_end', async (_event, ctx) => {
162
+ // ── During execution: handle blocked steps and completion ──
163
+ if (state.executing && state.plan) {
164
+ const blocked = state.plan.steps
165
+ .map((s, i) => ({ ...s, num: i + 1 }))
166
+ .filter((s) => s.status === 'blocked');
167
+
168
+ if (blocked.length > 0) {
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}`;
173
+
174
+ const choice = await ctx.ui.select(`Step blocked — ${info}\n\nWhat next?`, [
175
+ 'Skip this step', 'Provide instructions', 'Re-plan', 'Abort execution',
176
+ ]);
177
+
178
+ if (choice === 'Skip this step') {
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')) {
184
+ pi.sendUserMessage('The blocked step has been skipped. Continue with the next step.', { deliverAs: 'followUp' });
185
+ }
186
+ } else if (choice === 'Provide instructions') {
187
+ const instructions = await ctx.ui.editor('Instructions for the blocked step:', '');
188
+ if (instructions?.trim()) {
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);
194
+ pi.sendUserMessage(
195
+ `Retry step ${bs.num} with these additional instructions: ${instructions.trim()}`,
196
+ { deliverAs: 'followUp' },
197
+ );
198
+ }
199
+ return;
200
+ } else if (choice === 'Re-plan') {
201
+ await enterPlanMode(state, pi, ctx);
202
+ pi.sendUserMessage(
203
+ `Step ${bs.num} was blocked: ${bs.notes ?? 'no details'}. Re-analyze and create a revised plan.`,
204
+ { deliverAs: 'followUp' },
205
+ );
206
+ return;
207
+ } else if (choice === 'Abort execution') {
208
+ await exitPlanMode(state, pi, ctx);
209
+ return;
419
210
  }
420
211
  }
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
212
 
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
441
- if (planDir) {
442
- const planName = planDir.replace(/^\.plans\//, '');
443
- await updatePlansManifest(planName, 'done');
213
+ // Check completion
214
+ const allResolved = state.plan.steps.every((s) => s.status === 'done' || s.status === 'skipped');
215
+ if (allResolved) {
216
+ if (state.planDir) {
217
+ await updatePlansManifest(state.planDir.replace(/^\.plans\//, ''), 'done', state.plan.title);
218
+ await savePlanToDisk(state.planDir, state.plan);
444
219
  }
445
-
446
- const list = todos.map((t) => `~~${t.text}~~`).join('\n');
220
+ const list = state.plan.steps
221
+ .map((s) => s.status === 'done' ? `~~${s.description}~~` : `⊘ ~~${s.description}~~`)
222
+ .join('\n');
447
223
  pi.sendMessage(
448
- {
449
- customType: 'plan-complete',
450
- content: `**Plan Complete!** ✓\n\n${list}`,
451
- display: true,
452
- },
224
+ { customType: 'plan-complete', content: `**Plan Complete!** ✓\n\n${list}`, display: true },
453
225
  { triggerTurn: false },
454
226
  );
455
- executing = false;
456
- todos = [];
457
- planDir = undefined;
227
+
228
+ const { previousModel: pm, previousThinking: pt } = state;
229
+ state.reset();
458
230
  pi.setActiveTools(EXEC_TOOLS);
459
- if (previousModel) {
460
- await switchModel(ctx, previousModel);
461
- }
462
- if (previousThinking) {
463
- pi.setThinkingLevel(previousThinking);
464
- }
465
- updateUI(ctx);
466
- persist();
231
+ if (pm) await switchModel(pi, ctx, pm);
232
+ if (pt) pi.setThinkingLevel(pt);
233
+ updateUI(state, ctx);
234
+ state.persist(pi);
235
+ return;
467
236
  }
468
237
  return;
469
238
  }
470
239
 
471
- if (!planEnabled || !ctx.hasUI) return;
240
+ // ── After plan submission: show post-plan menu ──
241
+ if (!state.planEnabled || !ctx.hasUI) return;
242
+ if (!state.planDir || !state.plan) return;
472
243
 
473
- // Check if plan files were created by looking for planDir
474
- if (!planDir) return;
475
-
476
- // Show menu
477
244
  const choice = await ctx.ui.select('Plan ready — what next?', [
478
- 'Execute Plan',
479
- 'Refine Plan',
480
- 'Follow up',
481
- 'Exit plan mode',
245
+ 'Execute Plan', 'Refine Plan', 'Follow up', 'Exit plan mode',
482
246
  ]);
483
247
 
484
248
  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
- await startExecution(ctx);
511
- updateUI(ctx);
512
-
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
- }
249
+ pi.sendUserMessage('/plan-exec', { deliverAs: 'followUp' });
533
250
  } 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:
251
+ pi.sendUserMessage(
252
+ `Review the plan you just created with an adversarial lens. Challenge assumptions, find gaps, identify risks, and look for:
539
253
 
540
254
  - Missing edge cases or error handling
541
255
  - Incorrect assumptions about the codebase
@@ -543,88 +257,52 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
543
257
  - Missing dependencies between steps
544
258
  - Simpler alternatives that were overlooked
545
259
 
546
- After your review, update PLAN.md and START-PROMPT.md with any improvements.`,
547
- display: true,
548
- },
549
- { triggerTurn: true, deliverAs: 'followUp' },
260
+ After your review, call submit_plan again with the improved plan.`,
261
+ { deliverAs: 'followUp' },
550
262
  );
551
263
  } else if (choice === 'Follow up') {
552
- const followUp = await ctx.ui.editor('Follow-up instructions for the planner:', '');
553
- 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
- );
562
- }
264
+ // No-op: dismiss menu, let user type naturally
563
265
  } else if (choice === 'Exit plan mode') {
564
- await exitPlanMode(ctx);
266
+ await exitPlanMode(state, pi, ctx);
565
267
  }
566
268
  });
567
269
 
568
- // ── Restore state on session start/resume ─────────────────────────────────
270
+ // ── Event: session restore ────────────────────────────────────────────────
569
271
  pi.on('session_start', async (_event, ctx) => {
570
- // Check CLI flag
571
- if (pi.getFlag('plan') === true) {
572
- planEnabled = true;
573
- }
272
+ if (pi.getFlag('plan') === true) state.planEnabled = true;
574
273
 
575
- // Restore persisted state
576
- const entries = ctx.sessionManager.getEntries();
577
- const saved = entries
578
- .filter(
579
- (e: { type: string; customType?: string }) =>
580
- e.type === 'custom' && e.customType === 'plan-mode',
581
- )
582
- .pop() as { data?: PersistedState } | undefined;
583
-
584
- if (saved?.data) {
585
- planEnabled = saved.data.planEnabled ?? planEnabled;
586
- executing = saved.data.executing ?? executing;
587
- 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
- }
274
+ state.restore(
275
+ ctx.sessionManager.getEntries() as Array<{ type: string; customType?: string; data?: any }>,
276
+ );
601
277
 
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
- }
278
+ // Check for exec-pending handoff from planning session
279
+ const pending = await readAndClearExecPending();
280
+ if (pending) {
281
+ state.planDir = pending.planDir;
282
+ state.plan = await loadPlanFromDisk(pending.planDir);
283
+ if (state.plan) {
284
+ state.executing = true;
285
+ state.planEnabled = false;
286
+ pi.setActiveTools(EXEC_TOOLS);
287
+ await switchModel(pi, ctx, pending.config.model);
288
+ pi.setThinkingLevel(pending.config.thinking as ThinkingLevel);
289
+ updateUI(state, ctx);
290
+ state.persist(pi);
291
+ return;
612
292
  }
613
- const allText = messages.map(getTextContent).join('\n');
614
- markCompletedSteps(allText, todos);
615
293
  }
616
294
 
617
295
  // Apply tool restrictions, model, and thinking level
618
- if (planEnabled) {
296
+ if (state.planEnabled) {
619
297
  pi.setActiveTools(PLAN_TOOLS);
620
- await switchModel(ctx, PLAN_MODEL);
298
+ await switchModel(pi, ctx, PLAN_MODEL);
621
299
  pi.setThinkingLevel(PLAN_THINKING);
622
- } else if (executing) {
300
+ } else if (state.executing) {
623
301
  pi.setActiveTools(EXEC_TOOLS);
624
- await switchModel(ctx, EXEC_MODEL);
302
+ await switchModel(pi, ctx, EXEC_MODEL);
625
303
  pi.setThinkingLevel(EXEC_THINKING);
626
304
  }
627
305
 
628
- updateUI(ctx);
306
+ updateUI(state, ctx);
629
307
  });
630
308
  }