@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.
- package/CHANGELOG.md +101 -0
- package/extensions/plan-mode/__tests__/agent-end-safety.test.ts +125 -0
- package/extensions/plan-mode/{utils.test.ts → __tests__/utils.test.ts} +7 -1
- package/extensions/plan-mode/constants.ts +33 -0
- package/extensions/plan-mode/context-filter.ts +32 -0
- package/extensions/plan-mode/index.ts +193 -515
- package/extensions/plan-mode/phase-transitions.ts +87 -0
- package/extensions/plan-mode/plan-storage.ts +67 -0
- package/extensions/plan-mode/plans-json.ts +1 -5
- package/extensions/plan-mode/prompts.ts +67 -0
- package/extensions/plan-mode/resume.ts +121 -0
- package/extensions/plan-mode/state.ts +47 -0
- package/extensions/plan-mode/tools/submit-plan.ts +144 -0
- package/extensions/plan-mode/tools/update-step.ts +145 -0
- package/extensions/plan-mode/types.ts +32 -0
- package/extensions/plan-mode/ui.ts +39 -0
- package/extensions/plan-mode/utils.ts +1 -82
- package/package.json +4 -8
- package/node_modules/@dreki-gg/pi-command-sandbox/dist/index.d.mts +0 -104
- package/node_modules/@dreki-gg/pi-command-sandbox/dist/index.mjs +0 -396
- package/node_modules/@dreki-gg/pi-command-sandbox/package.json +0 -42
|
@@ -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
|
|
6
|
-
*
|
|
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
|
|
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
|
|
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 {
|
|
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 {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} from './
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
233
|
-
|
|
67
|
+
const trimmed = args?.trim();
|
|
68
|
+
if (trimmed === 'resume') {
|
|
69
|
+
await resumePlan(state, pi, ctx);
|
|
234
70
|
return;
|
|
235
71
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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 (
|
|
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
|
|
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) =>
|
|
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
|
-
// ──
|
|
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
|
-
// ──
|
|
134
|
+
// ── Event: filter context ─────────────────────────────────────────────────
|
|
289
135
|
pi.on('context', async (event) => {
|
|
290
|
-
if (planEnabled) return;
|
|
291
|
-
|
|
292
|
-
messages: event.messages.
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
// ──
|
|
397
|
-
pi.on('
|
|
398
|
-
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
227
|
+
|
|
228
|
+
const { previousModel: pm, previousThinking: pt } = state;
|
|
229
|
+
state.reset();
|
|
458
230
|
pi.setActiveTools(EXEC_TOOLS);
|
|
459
|
-
if (
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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,
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
270
|
+
// ── Event: session restore ────────────────────────────────────────────────
|
|
569
271
|
pi.on('session_start', async (_event, ctx) => {
|
|
570
|
-
|
|
571
|
-
if (pi.getFlag('plan') === true) {
|
|
572
|
-
planEnabled = true;
|
|
573
|
-
}
|
|
272
|
+
if (pi.getFlag('plan') === true) state.planEnabled = true;
|
|
574
273
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
}
|