@dreki-gg/pi-plan-mode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # @dreki-gg/pi-plan-mode
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release.
6
+ - Add Cursor-like planning workflow for pi with read-only planning, questionnaire-first clarification, domain-model handoffs, and implementation-plan generation.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # @dreki-gg/pi-plan-mode
2
+
3
+ Cursor-like planning workflow for [pi](https://github.com/badlogic/pi-mono).
4
+
5
+ It gives pi a dedicated **plan mode** that:
6
+ - locks the agent into a read-only planning pass
7
+ - nudges it to use `questionnaire` before planning when scope is unclear
8
+ - hands off to `/skill:domain-model` when you want terminology/design pressure-testing
9
+ - hands off to `/skill:create-implementation-plans` when you want self-contained `*.plan.md` files
10
+ - restores full tool access for execution once the plan is approved
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pi install npm:@dreki-gg/pi-plan-mode
16
+ ```
17
+
18
+ Recommended companions:
19
+
20
+ ```bash
21
+ pi install npm:@dreki-gg/pi-questionnaire
22
+ ```
23
+
24
+ And make sure these skills are available globally or project-locally if you want the full workflow:
25
+ - `domain-model`
26
+ - `create-implementation-plans`
27
+
28
+ If those skills are missing, the extension falls back to plain prompts that emulate the same workflow.
29
+
30
+ ## What it provides
31
+
32
+ | Feature | Name | Notes |
33
+ |---|---|---|
34
+ | Flag | `--plan` | Start pi in read-only planning mode |
35
+ | Command | `/plan [prompt]` | Enable plan mode or immediately send a planning prompt |
36
+ | Command | `/plan-status` | Show current phase + extracted plan steps |
37
+ | Command | `/plan-domain [prompt]` | Stress-test the current plan against the domain model |
38
+ | Command | `/plan-plans [prompt]` | Generate self-contained implementation plan files |
39
+ | Command | `/plan-execute [prompt]` | Restore full tool access and execute the approved plan |
40
+ | Shortcut | `Ctrl+Alt+P` | Toggle plan mode |
41
+
42
+ ## Workflow
43
+
44
+ ### 1. Start planning
45
+
46
+ ```text
47
+ /plan add a plan mode similar to Cursor for this repo
48
+ ```
49
+
50
+ While planning, the extension restricts tools to a read-only set such as:
51
+ - `read`
52
+ - `bash` (allowlisted read-only commands only)
53
+ - `grep`
54
+ - `find`
55
+ - `ls`
56
+ - `questionnaire` (if installed)
57
+ - `lsp` / `context7_*` (if installed)
58
+
59
+ The system prompt tells pi to:
60
+ - inspect the real codebase first
61
+ - use `questionnaire` when the task is still underspecified
62
+ - respond with a numbered plan under a `Plan:` header
63
+ - stay read-only
64
+
65
+ ### 2. Stress-test the design
66
+
67
+ ```text
68
+ /plan-domain
69
+ ```
70
+
71
+ If `domain-model` is installed, the extension invokes:
72
+
73
+ ```text
74
+ /skill:domain-model
75
+ ```
76
+
77
+ Otherwise it sends an equivalent fallback prompt.
78
+
79
+ ### 3. Write implementation plan files
80
+
81
+ ```text
82
+ /plan-plans
83
+ ```
84
+
85
+ This temporarily enables `edit`/`write`, but only for plan-file authoring. The prompt explicitly forbids product-code changes in this phase.
86
+
87
+ If `create-implementation-plans` is installed, the extension invokes:
88
+
89
+ ```text
90
+ /skill:create-implementation-plans
91
+ ```
92
+
93
+ Otherwise it falls back to a plain prompt that asks pi to create grounded `*.plan.md` files.
94
+
95
+ ### 4. Execute
96
+
97
+ ```text
98
+ /plan-execute
99
+ ```
100
+
101
+ That restores the original tool set and asks pi to execute the approved plan. If the plan has numbered steps, the extension tracks progress via `[DONE:n]` tags.
102
+
103
+ ## Notes
104
+
105
+ - Planning mode is **hard enforced** by tool restriction, not just prompt wording.
106
+ - `bash` is restricted to read-only commands while planning.
107
+ - Plan steps are extracted from `Plan:` sections and shown in a widget/status area.
108
+ - State is persisted across session resume and tree navigation.
109
+ - The implementation-plan phase is a **controlled write phase** intended for planning docs, not code changes.
@@ -0,0 +1,664 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
2
+ import { Key, type AutocompleteItem } from '@mariozechner/pi-tui';
3
+ import {
4
+ extractTodoItems,
5
+ formatTodoList,
6
+ isSafeCommand,
7
+ markCompletedSteps,
8
+ type TodoItem,
9
+ } from './utils.js';
10
+
11
+ type WorkflowPhase = 'off' | 'planning' | 'plan-files' | 'executing';
12
+
13
+ interface PersistedState {
14
+ phase: WorkflowPhase;
15
+ todos: TodoItem[];
16
+ }
17
+
18
+ interface BeforeAgentStartCompatEvent {
19
+ systemPromptOptions?: {
20
+ selectedTools?: string[];
21
+ };
22
+ }
23
+
24
+ const STATE_ENTRY = 'plan-mode-state';
25
+ const CLEAR_VALUES = new Set(['', 'off', 'none', 'disable', 'exit']);
26
+ const PLANNING_TOOL_PATTERNS = [
27
+ 'read',
28
+ 'bash',
29
+ 'grep',
30
+ 'find',
31
+ 'ls',
32
+ 'questionnaire',
33
+ 'lsp',
34
+ ] as const;
35
+ const PLAN_FILE_TOOL_PATTERNS = [...PLANNING_TOOL_PATTERNS, 'edit', 'write'] as const;
36
+
37
+ function isAssistantMessage(message: unknown): message is {
38
+ role: 'assistant';
39
+ content: Array<{ type: string; text?: string }>;
40
+ } {
41
+ return (
42
+ typeof message === 'object' &&
43
+ message !== null &&
44
+ 'role' in message &&
45
+ (message as { role?: unknown }).role === 'assistant' &&
46
+ 'content' in message &&
47
+ Array.isArray((message as { content?: unknown }).content)
48
+ );
49
+ }
50
+
51
+ function getMessageText(message: unknown): string {
52
+ if (!isAssistantMessage(message)) return '';
53
+ return message.content
54
+ .filter(
55
+ (block): block is { type: 'text'; text: string } =>
56
+ block.type === 'text' && typeof block.text === 'string',
57
+ )
58
+ .map((block) => block.text)
59
+ .join('\n');
60
+ }
61
+
62
+ function matchesToolPattern(name: string, pattern: string): boolean {
63
+ if (pattern.endsWith('*')) return name.startsWith(pattern.slice(0, -1));
64
+ return name === pattern;
65
+ }
66
+
67
+ function resolveToolNames(allToolNames: string[], patterns: readonly string[]): string[] {
68
+ return allToolNames.filter((name) =>
69
+ patterns.some(
70
+ (pattern) =>
71
+ matchesToolPattern(name, pattern) ||
72
+ (pattern === 'context7_*' && name.startsWith('context7_')),
73
+ ),
74
+ );
75
+ }
76
+
77
+ function findSavedState(ctx: ExtensionContext): PersistedState | undefined {
78
+ const branchEntries = ctx.sessionManager.getBranch?.() ?? ctx.sessionManager.getEntries?.() ?? [];
79
+ let lastState: PersistedState | undefined;
80
+
81
+ for (const entry of branchEntries) {
82
+ if (entry.type !== 'custom' || entry.customType !== STATE_ENTRY) continue;
83
+ const data = entry.data as PersistedState | undefined;
84
+ if (data && typeof data.phase === 'string' && Array.isArray(data.todos)) lastState = data;
85
+ }
86
+
87
+ return lastState;
88
+ }
89
+
90
+ function buildPlanningInstructions(
91
+ ctx: ExtensionContext,
92
+ activeTools: string[],
93
+ todos: TodoItem[],
94
+ ): string {
95
+ const sections = [
96
+ 'PLAN MODE ACTIVE.',
97
+ 'Run a Cursor-style planning pass before implementation.',
98
+ `Enabled tools: ${activeTools.length > 0 ? activeTools.join(', ') : '(none)'}`,
99
+ 'Rules:',
100
+ '- Stay read-only. Do not edit files or claim to have changed code.',
101
+ '- Inspect the real codebase before proposing work. Do not guess from filenames.',
102
+ '- If requirements are underspecified and the questionnaire tool is available, use it to ask 1-5 structured clarifying questions before finalizing the plan.',
103
+ '- Produce a concrete numbered plan under a `Plan:` header.',
104
+ '- Include open questions, assumptions, risks, and sequencing when relevant.',
105
+ '- If the user wants terminology hardened or the design pressure-tested, point them to the domain-model workflow.',
106
+ '- If the user wants self-contained handoff files, point them to the implementation-plan workflow.',
107
+ ];
108
+
109
+ if (ctx.hasUI) {
110
+ sections.push(
111
+ 'After the planning response, the extension may offer next-step choices like domain-model review, implementation-plan generation, or execution.',
112
+ );
113
+ }
114
+
115
+ if (todos.length > 0) {
116
+ sections.push(`Current plan draft:\n${formatTodoList(todos)}`);
117
+ }
118
+
119
+ return sections.join('\n');
120
+ }
121
+
122
+ function buildPlanFileInstructions(activeTools: string[], todos: TodoItem[]): string {
123
+ const sections = [
124
+ 'PLAN FILE AUTHORING MODE ACTIVE.',
125
+ `Enabled tools: ${activeTools.length > 0 ? activeTools.join(', ') : '(none)'}`,
126
+ 'You may use edit/write in this phase, but only to author planning artifacts such as `*.plan.md`, `CONTEXT.md`, or ADR docs requested by the workflow.',
127
+ 'Do not implement product code in this phase.',
128
+ 'Ground every plan file in the current codebase state.',
129
+ ];
130
+
131
+ if (todos.length > 0) {
132
+ sections.push(`Current approved plan:\n${formatTodoList(todos)}`);
133
+ }
134
+
135
+ return sections.join('\n');
136
+ }
137
+
138
+ function buildExecutionInstructions(todos: TodoItem[]): string {
139
+ const sections = [
140
+ 'PLAN EXECUTION MODE ACTIVE.',
141
+ 'Execute the approved plan in small verified steps.',
142
+ 'After completing a plan step, include a `[DONE:n]` tag in the response so progress can be tracked.',
143
+ ];
144
+
145
+ if (todos.length > 0) {
146
+ sections.push(`Current checklist:\n${formatTodoList(todos)}`);
147
+ }
148
+
149
+ return sections.join('\n');
150
+ }
151
+
152
+ export default function planModeExtension(pi: ExtensionAPI) {
153
+ let phase: WorkflowPhase = 'off';
154
+ let restoreToolNames: string[] | null = null;
155
+ let todoItems: TodoItem[] = [];
156
+ let returnToPlanningAfterNextAgentEnd = false;
157
+
158
+ function getPlanCommandCompletions(argumentText: string): AutocompleteItem[] | null {
159
+ if (argumentText.trim().includes(' ')) return null;
160
+
161
+ const query = argumentText.trim().toLowerCase();
162
+ const items: AutocompleteItem[] = [
163
+ {
164
+ value: 'status',
165
+ label: 'status',
166
+ description: `Show the current plan workflow state (currently ${phase})`,
167
+ },
168
+ {
169
+ value: 'domain',
170
+ label: 'domain',
171
+ description: 'Stress-test the current plan with a domain-model review',
172
+ },
173
+ {
174
+ value: 'plans',
175
+ label: 'plans',
176
+ description: 'Generate self-contained implementation plan files',
177
+ },
178
+ {
179
+ value: 'execute',
180
+ label: 'execute',
181
+ description: 'Leave read-only planning and execute the approved plan',
182
+ },
183
+ {
184
+ value: 'off',
185
+ label: 'off',
186
+ description: 'Disable plan mode',
187
+ },
188
+ ];
189
+
190
+ const filtered = items.filter((item) => {
191
+ if (!query) return true;
192
+ return (
193
+ item.value.toLowerCase().startsWith(query) ||
194
+ item.label?.toLowerCase().includes(query) ||
195
+ item.description?.toLowerCase().includes(query)
196
+ );
197
+ });
198
+
199
+ return filtered.length > 0 ? filtered : null;
200
+ }
201
+
202
+ pi.registerFlag('plan', {
203
+ description: 'Start in plan mode (Cursor-style planning workflow)',
204
+ type: 'boolean',
205
+ default: false,
206
+ });
207
+
208
+ function persistState() {
209
+ pi.appendEntry<PersistedState>(STATE_ENTRY, {
210
+ phase,
211
+ todos: todoItems.map((item) => ({ ...item })),
212
+ });
213
+ }
214
+
215
+ function updateUi(ctx: ExtensionContext) {
216
+ if (phase === 'off') {
217
+ ctx.ui.setStatus('plan-mode', undefined);
218
+ ctx.ui.setWidget('plan-mode-todos', undefined);
219
+ return;
220
+ }
221
+
222
+ if (phase === 'planning') {
223
+ ctx.ui.setStatus('plan-mode', ctx.ui.theme.fg('warning', 'plan'));
224
+ } else if (phase === 'plan-files') {
225
+ ctx.ui.setStatus('plan-mode', ctx.ui.theme.fg('accent', 'plan:files'));
226
+ } else {
227
+ const completed = todoItems.filter((item) => item.completed).length;
228
+ const total = todoItems.length;
229
+ ctx.ui.setStatus(
230
+ 'plan-mode',
231
+ ctx.ui.theme.fg('success', total > 0 ? `plan:exec ${completed}/${total}` : 'plan:exec'),
232
+ );
233
+ }
234
+
235
+ if (todoItems.length === 0) {
236
+ ctx.ui.setWidget('plan-mode-todos', undefined);
237
+ return;
238
+ }
239
+
240
+ const lines = todoItems.map((item) => {
241
+ if (item.completed) {
242
+ return ctx.ui.theme.fg('success', `☑ ${ctx.ui.theme.strikethrough(item.text)}`);
243
+ }
244
+ const marker = phase === 'executing' ? '☐' : '•';
245
+ return `${ctx.ui.theme.fg('muted', `${marker} `)}${item.text}`;
246
+ });
247
+ ctx.ui.setWidget('plan-mode-todos', lines);
248
+ }
249
+
250
+ function ensureRestoreTools() {
251
+ if (restoreToolNames) return;
252
+ restoreToolNames = [...pi.getActiveTools()];
253
+ }
254
+
255
+ function applyPhaseTools(targetPhase: WorkflowPhase) {
256
+ const allToolNames = pi.getAllTools().map((tool) => tool.name);
257
+
258
+ if (targetPhase === 'planning') {
259
+ const resolved = resolveToolNames(allToolNames, [...PLANNING_TOOL_PATTERNS, 'context7_*']);
260
+ if (resolved.length > 0) pi.setActiveTools(resolved);
261
+ return;
262
+ }
263
+
264
+ if (targetPhase === 'plan-files') {
265
+ const resolved = resolveToolNames(allToolNames, [...PLAN_FILE_TOOL_PATTERNS, 'context7_*']);
266
+ if (resolved.length > 0) pi.setActiveTools(resolved);
267
+ return;
268
+ }
269
+
270
+ if (targetPhase === 'executing' && restoreToolNames) {
271
+ pi.setActiveTools([...restoreToolNames]);
272
+ }
273
+ }
274
+
275
+ function setPhase(
276
+ nextPhase: WorkflowPhase,
277
+ ctx: ExtensionContext,
278
+ options?: { notify?: string },
279
+ ) {
280
+ if (phase === 'off' && nextPhase !== 'off') ensureRestoreTools();
281
+ if (nextPhase === 'off' && restoreToolNames) {
282
+ pi.setActiveTools([...restoreToolNames]);
283
+ restoreToolNames = null;
284
+ } else {
285
+ applyPhaseTools(nextPhase);
286
+ }
287
+
288
+ phase = nextPhase;
289
+ updateUi(ctx);
290
+ persistState();
291
+
292
+ if (options?.notify) ctx.ui.notify(options.notify, 'info');
293
+ }
294
+
295
+ async function sendPlanningPrompt(prompt: string, ctx: ExtensionContext) {
296
+ const commandCtx = ctx as ExtensionContext & { waitForIdle?: () => Promise<void> };
297
+ if (typeof commandCtx.waitForIdle === 'function') {
298
+ await commandCtx.waitForIdle();
299
+ }
300
+ pi.sendUserMessage(prompt.trim());
301
+ }
302
+
303
+ function availableCommand(name: string): string | undefined {
304
+ const commands = pi.getCommands?.() ?? [];
305
+ return commands.find((command) => command.name === name)?.name;
306
+ }
307
+
308
+ async function startPlanning(prompt: string | undefined, ctx: ExtensionContext) {
309
+ setPhase('planning', ctx, {
310
+ notify:
311
+ phase === 'off'
312
+ ? 'Plan mode enabled. Ask pi to inspect, question, and plan before implementation.'
313
+ : undefined,
314
+ });
315
+
316
+ const trimmed = prompt?.trim();
317
+ if (!trimmed) return;
318
+ await sendPlanningPrompt(trimmed, ctx);
319
+ }
320
+
321
+ async function runDomainWorkflow(args: string | undefined, ctx: ExtensionContext) {
322
+ setPhase('planning', ctx, { notify: 'Running domain-model review in read-only plan mode.' });
323
+
324
+ const planText =
325
+ todoItems.length > 0 ? formatTodoList(todoItems) : 'No extracted plan steps yet.';
326
+ const trimmedArgs = args?.trim();
327
+ const promptText =
328
+ trimmedArgs && trimmedArgs.length > 0
329
+ ? trimmedArgs
330
+ : `Stress-test the current plan against the existing domain model and terminology. Challenge ambiguous terms, invent concrete scenarios, compare claims against the codebase, and suggest any CONTEXT.md or ADR updates only when justified.\n\nCurrent plan:\n${planText}`;
331
+ const skillCommand = availableCommand('skill:domain-model');
332
+
333
+ if (skillCommand) {
334
+ const suffix =
335
+ trimmedArgs && trimmedArgs.length > 0
336
+ ? trimmedArgs
337
+ : `Stress-test the current plan against the domain model.\n\nCurrent plan:\n${planText}`;
338
+ await sendPlanningPrompt(`/${skillCommand} ${suffix}`.trim(), ctx);
339
+ return;
340
+ }
341
+
342
+ await sendPlanningPrompt(promptText, ctx);
343
+ }
344
+
345
+ async function runPlanFileWorkflow(args: string | undefined, ctx: ExtensionContext) {
346
+ setPhase('plan-files', ctx, {
347
+ notify:
348
+ 'Plan-file authoring enabled. pi may write planning docs, but should not implement code.',
349
+ });
350
+ returnToPlanningAfterNextAgentEnd = true;
351
+
352
+ const planText =
353
+ todoItems.length > 0 ? formatTodoList(todoItems) : 'No extracted plan steps yet.';
354
+ const trimmedArgs = args?.trim();
355
+ const promptText =
356
+ trimmedArgs && trimmedArgs.length > 0
357
+ ? trimmedArgs
358
+ : `Create self-contained implementation plan files for the current approved plan. Ground every file in the real codebase, document exact APIs and file paths, and write the plans to docs/plans unless the repo or user prefers another location.\n\nCurrent plan:\n${planText}`;
359
+ const skillCommand = availableCommand('skill:create-implementation-plans');
360
+
361
+ if (skillCommand) {
362
+ const suffix =
363
+ trimmedArgs && trimmedArgs.length > 0
364
+ ? trimmedArgs
365
+ : `Create self-contained implementation plan files for the current approved plan.\n\nCurrent plan:\n${planText}`;
366
+ await sendPlanningPrompt(`/${skillCommand} ${suffix}`.trim(), ctx);
367
+ return;
368
+ }
369
+
370
+ await sendPlanningPrompt(promptText, ctx);
371
+ }
372
+
373
+ async function startExecution(args: string | undefined, ctx: ExtensionContext) {
374
+ setPhase('executing', ctx, {
375
+ notify: 'Plan execution mode enabled. Full tool access restored.',
376
+ });
377
+
378
+ const trimmedArgs = args?.trim();
379
+ if (trimmedArgs && trimmedArgs.length > 0) {
380
+ await sendPlanningPrompt(trimmedArgs, ctx);
381
+ return;
382
+ }
383
+
384
+ const defaultPrompt =
385
+ todoItems.length > 0
386
+ ? `Execute the approved plan in order.\n\nPlan:\n${formatTodoList(todoItems)}\n\nStart with step 1 and include [DONE:n] tags as each step is completed.`
387
+ : 'Execute the approved plan in small verified steps. Include [DONE:n] tags as each major plan step is completed.';
388
+ await sendPlanningPrompt(defaultPrompt, ctx);
389
+ }
390
+
391
+ function showStatus(ctx: ExtensionContext) {
392
+ const lines = [`Phase: ${phase}`];
393
+ if (todoItems.length > 0) {
394
+ const completed = todoItems.filter((item) => item.completed).length;
395
+ lines.push(`Plan steps: ${completed}/${todoItems.length} complete`);
396
+ lines.push(
397
+ '',
398
+ ...todoItems.map((item) => `${item.step}. ${item.completed ? '✓' : '○'} ${item.text}`),
399
+ );
400
+ }
401
+ ctx.ui.notify(lines.join('\n'), 'info');
402
+ }
403
+
404
+ async function showPlanMenu(ctx: ExtensionContext) {
405
+ if (!ctx.hasUI) {
406
+ showStatus(ctx);
407
+ return;
408
+ }
409
+
410
+ const choice = await ctx.ui.select('Plan mode', [
411
+ 'Stay in planning mode',
412
+ 'Stress-test with domain-model',
413
+ 'Generate implementation plan files',
414
+ 'Execute current plan',
415
+ 'Show status',
416
+ 'Disable plan mode',
417
+ ]);
418
+
419
+ if (!choice || choice === 'Stay in planning mode') return;
420
+ if (choice === 'Stress-test with domain-model') {
421
+ await runDomainWorkflow(undefined, ctx);
422
+ return;
423
+ }
424
+ if (choice === 'Generate implementation plan files') {
425
+ await runPlanFileWorkflow(undefined, ctx);
426
+ return;
427
+ }
428
+ if (choice === 'Execute current plan') {
429
+ await startExecution(undefined, ctx);
430
+ return;
431
+ }
432
+ if (choice === 'Show status') {
433
+ showStatus(ctx);
434
+ return;
435
+ }
436
+
437
+ setPhase('off', ctx, { notify: 'Plan mode disabled.' });
438
+ }
439
+
440
+ pi.registerCommand('plan', {
441
+ description: 'Enable plan mode, send a planning prompt, or manage the plan workflow',
442
+ getArgumentCompletions: getPlanCommandCompletions,
443
+ handler: async (args, ctx) => {
444
+ const raw = args?.trim() ?? '';
445
+ if (!raw) {
446
+ if (phase === 'off') {
447
+ await startPlanning(undefined, ctx);
448
+ } else {
449
+ await showPlanMenu(ctx);
450
+ }
451
+ return;
452
+ }
453
+
454
+ const lower = raw.toLowerCase();
455
+ if (CLEAR_VALUES.has(lower)) {
456
+ setPhase('off', ctx, { notify: 'Plan mode disabled.' });
457
+ return;
458
+ }
459
+ if (lower === 'status') {
460
+ showStatus(ctx);
461
+ return;
462
+ }
463
+ if (lower === 'domain') {
464
+ await runDomainWorkflow(undefined, ctx);
465
+ return;
466
+ }
467
+ if (lower === 'plans' || lower === 'files') {
468
+ await runPlanFileWorkflow(undefined, ctx);
469
+ return;
470
+ }
471
+ if (lower === 'execute' || lower === 'run') {
472
+ await startExecution(undefined, ctx);
473
+ return;
474
+ }
475
+
476
+ await startPlanning(raw, ctx);
477
+ },
478
+ });
479
+
480
+ pi.registerCommand('plan-status', {
481
+ description: 'Show current plan workflow phase and extracted plan steps',
482
+ handler: async (_args, ctx) => {
483
+ showStatus(ctx);
484
+ },
485
+ });
486
+
487
+ pi.registerCommand('plan-domain', {
488
+ description: 'Run a domain-model stress test for the current plan',
489
+ handler: async (args, ctx) => {
490
+ await runDomainWorkflow(args, ctx);
491
+ },
492
+ });
493
+
494
+ pi.registerCommand('plan-plans', {
495
+ description: 'Generate self-contained implementation plan files for the current plan',
496
+ handler: async (args, ctx) => {
497
+ await runPlanFileWorkflow(args, ctx);
498
+ },
499
+ });
500
+
501
+ pi.registerCommand('plan-execute', {
502
+ description: 'Leave read-only planning and execute the current approved plan',
503
+ handler: async (args, ctx) => {
504
+ await startExecution(args, ctx);
505
+ },
506
+ });
507
+
508
+ pi.registerShortcut(Key.ctrlAlt('p'), {
509
+ description: 'Toggle plan mode',
510
+ handler: async (ctx) => {
511
+ if (phase === 'off') setPhase('planning', ctx, { notify: 'Plan mode enabled.' });
512
+ else setPhase('off', ctx, { notify: 'Plan mode disabled.' });
513
+ },
514
+ });
515
+
516
+ pi.on('tool_call', async (event) => {
517
+ if (phase !== 'planning' || event.toolName !== 'bash') return;
518
+
519
+ const command = event.input.command as string;
520
+ if (isSafeCommand(command)) return;
521
+
522
+ return {
523
+ block: true,
524
+ reason: `Plan mode blocks non-read-only bash commands. Disable plan mode or use /plan-plans for controlled plan-file authoring.\nCommand: ${command}`,
525
+ };
526
+ });
527
+
528
+ pi.on('before_agent_start', async (event, ctx) => {
529
+ if (phase === 'off') return;
530
+
531
+ const compatEvent = event as typeof event & BeforeAgentStartCompatEvent;
532
+ const activeTools = compatEvent.systemPromptOptions?.selectedTools ?? pi.getActiveTools();
533
+
534
+ if (phase === 'planning') {
535
+ return {
536
+ systemPrompt: `${event.systemPrompt}\n\n${buildPlanningInstructions(ctx, activeTools, todoItems)}`,
537
+ };
538
+ }
539
+
540
+ if (phase === 'plan-files') {
541
+ return {
542
+ systemPrompt: `${event.systemPrompt}\n\n${buildPlanFileInstructions(activeTools, todoItems)}`,
543
+ };
544
+ }
545
+
546
+ return {
547
+ systemPrompt: `${event.systemPrompt}\n\n${buildExecutionInstructions(todoItems)}`,
548
+ };
549
+ });
550
+
551
+ pi.on('turn_end', async (event, ctx) => {
552
+ if (phase !== 'executing' || todoItems.length === 0) return;
553
+ const text = getMessageText(event.message);
554
+ if (!text) return;
555
+
556
+ if (markCompletedSteps(text, todoItems) > 0) {
557
+ updateUi(ctx);
558
+ persistState();
559
+ }
560
+ });
561
+
562
+ pi.on('agent_end', async (event, ctx) => {
563
+ if (returnToPlanningAfterNextAgentEnd && phase === 'plan-files') {
564
+ returnToPlanningAfterNextAgentEnd = false;
565
+ setPhase('planning', ctx, { notify: 'Returned to read-only plan mode.' });
566
+ }
567
+
568
+ if (
569
+ phase === 'executing' &&
570
+ todoItems.length > 0 &&
571
+ todoItems.every((item) => item.completed)
572
+ ) {
573
+ setPhase('off', ctx, { notify: 'Plan execution complete.' });
574
+ todoItems = [];
575
+ updateUi(ctx);
576
+ persistState();
577
+ return;
578
+ }
579
+
580
+ if (phase !== 'planning') return;
581
+
582
+ const lastAssistantMessage = [...event.messages]
583
+ .reverse()
584
+ .find((message) => isAssistantMessage(message));
585
+ if (!lastAssistantMessage) return;
586
+
587
+ const extracted = extractTodoItems(getMessageText(lastAssistantMessage));
588
+ if (extracted.length > 0) {
589
+ todoItems = extracted;
590
+ updateUi(ctx);
591
+ persistState();
592
+ }
593
+
594
+ if (todoItems.length === 0 || !ctx.hasUI) return;
595
+
596
+ const choice = await ctx.ui.select('Plan ready — what next?', [
597
+ 'Stay in planning mode',
598
+ 'Stress-test with domain-model',
599
+ 'Generate implementation plan files',
600
+ 'Execute current plan',
601
+ 'Refine the plan',
602
+ 'Disable plan mode',
603
+ ]);
604
+
605
+ if (!choice || choice === 'Stay in planning mode') return;
606
+ if (choice === 'Stress-test with domain-model') {
607
+ await runDomainWorkflow(undefined, ctx);
608
+ return;
609
+ }
610
+ if (choice === 'Generate implementation plan files') {
611
+ await runPlanFileWorkflow(undefined, ctx);
612
+ return;
613
+ }
614
+ if (choice === 'Execute current plan') {
615
+ await startExecution(undefined, ctx);
616
+ return;
617
+ }
618
+ if (choice === 'Refine the plan') {
619
+ const refinement = await ctx.ui.editor('Refine the plan:', '');
620
+ if (refinement?.trim()) await sendPlanningPrompt(refinement, ctx);
621
+ return;
622
+ }
623
+
624
+ setPhase('off', ctx, { notify: 'Plan mode disabled.' });
625
+ });
626
+
627
+ async function restoreState(ctx: ExtensionContext) {
628
+ const savedState = findSavedState(ctx);
629
+
630
+ if (pi.getFlag('plan') === true && !savedState) {
631
+ ensureRestoreTools();
632
+ phase = 'planning';
633
+ applyPhaseTools(phase);
634
+ updateUi(ctx);
635
+ persistState();
636
+ return;
637
+ }
638
+
639
+ if (!savedState) {
640
+ phase = 'off';
641
+ todoItems = [];
642
+ updateUi(ctx);
643
+ return;
644
+ }
645
+
646
+ todoItems = savedState.todos.map((item) => ({ ...item }));
647
+ phase = savedState.phase;
648
+
649
+ if (phase === 'planning' || phase === 'plan-files') {
650
+ ensureRestoreTools();
651
+ applyPhaseTools(phase);
652
+ }
653
+
654
+ updateUi(ctx);
655
+ }
656
+
657
+ pi.on('session_start', async (_event, ctx) => {
658
+ await restoreState(ctx);
659
+ });
660
+
661
+ pi.on('session_tree', async (_event, ctx) => {
662
+ await restoreState(ctx);
663
+ });
664
+ }
@@ -0,0 +1,164 @@
1
+ export interface TodoItem {
2
+ step: number;
3
+ text: string;
4
+ completed: boolean;
5
+ }
6
+
7
+ const DESTRUCTIVE_PATTERNS = [
8
+ /\brm\b/i,
9
+ /\brmdir\b/i,
10
+ /\bmv\b/i,
11
+ /\bcp\b/i,
12
+ /\bmkdir\b/i,
13
+ /\btouch\b/i,
14
+ /\bchmod\b/i,
15
+ /\bchown\b/i,
16
+ /\bchgrp\b/i,
17
+ /\bln\b/i,
18
+ /\btee\b/i,
19
+ /\btruncate\b/i,
20
+ /\bdd\b/i,
21
+ /\bshred\b/i,
22
+ /(^|[^<])>(?!>)/,
23
+ />>/,
24
+ /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
25
+ /\byarn\s+(add|remove|install|publish)/i,
26
+ /\bpnpm\s+(add|remove|install|publish)/i,
27
+ /\bpip\s+(install|uninstall)/i,
28
+ /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
29
+ /\bbrew\s+(install|uninstall|upgrade)/i,
30
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
31
+ /\bsudo\b/i,
32
+ /\bsu\b/i,
33
+ /\bkill\b/i,
34
+ /\bpkill\b/i,
35
+ /\bkillall\b/i,
36
+ /\breboot\b/i,
37
+ /\bshutdown\b/i,
38
+ /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
39
+ /\bservice\s+\S+\s+(start|stop|restart)/i,
40
+ /\b(vim?|nano|emacs|code|subl)\b/i,
41
+ ];
42
+
43
+ const SAFE_PATTERNS = [
44
+ /^\s*cat\b/,
45
+ /^\s*head\b/,
46
+ /^\s*tail\b/,
47
+ /^\s*less\b/,
48
+ /^\s*more\b/,
49
+ /^\s*grep\b/,
50
+ /^\s*find\b/,
51
+ /^\s*ls\b/,
52
+ /^\s*pwd\b/,
53
+ /^\s*echo\b/,
54
+ /^\s*printf\b/,
55
+ /^\s*wc\b/,
56
+ /^\s*sort\b/,
57
+ /^\s*uniq\b/,
58
+ /^\s*diff\b/,
59
+ /^\s*file\b/,
60
+ /^\s*stat\b/,
61
+ /^\s*du\b/,
62
+ /^\s*df\b/,
63
+ /^\s*tree\b/,
64
+ /^\s*which\b/,
65
+ /^\s*whereis\b/,
66
+ /^\s*type\b/,
67
+ /^\s*env\b/,
68
+ /^\s*printenv\b/,
69
+ /^\s*uname\b/,
70
+ /^\s*whoami\b/,
71
+ /^\s*id\b/,
72
+ /^\s*date\b/,
73
+ /^\s*cal\b/,
74
+ /^\s*uptime\b/,
75
+ /^\s*ps\b/,
76
+ /^\s*top\b/,
77
+ /^\s*htop\b/,
78
+ /^\s*free\b/,
79
+ /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
80
+ /^\s*git\s+ls-/i,
81
+ /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
82
+ /^\s*yarn\s+(list|info|why|audit)/i,
83
+ /^\s*node\s+--version/i,
84
+ /^\s*python\s+--version/i,
85
+ /^\s*curl\s/i,
86
+ /^\s*wget\s+-O\s*-/i,
87
+ /^\s*jq\b/,
88
+ /^\s*sed\s+-n/i,
89
+ /^\s*awk\b/,
90
+ /^\s*rg\b/,
91
+ /^\s*fd\b/,
92
+ /^\s*bat\b/,
93
+ /^\s*eza\b/,
94
+ ];
95
+
96
+ export function isSafeCommand(command: string): boolean {
97
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command));
98
+ const isSafe = SAFE_PATTERNS.some((pattern) => pattern.test(command));
99
+ return !isDestructive && isSafe;
100
+ }
101
+
102
+ export function cleanStepText(text: string): string {
103
+ let cleaned = text
104
+ .replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1')
105
+ .replace(/`([^`]+)`/g, '$1')
106
+ .replace(
107
+ /^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
108
+ '',
109
+ )
110
+ .replace(/\s+/g, ' ')
111
+ .trim();
112
+
113
+ if (cleaned.length > 0) cleaned = cleaned[0].toUpperCase() + cleaned.slice(1);
114
+ if (cleaned.length > 80) cleaned = `${cleaned.slice(0, 77)}...`;
115
+ return cleaned;
116
+ }
117
+
118
+ export function extractTodoItems(message: string): TodoItem[] {
119
+ const items: TodoItem[] = [];
120
+ const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
121
+ if (!headerMatch) return items;
122
+
123
+ const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
124
+ const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
125
+
126
+ for (const match of planSection.matchAll(numberedPattern)) {
127
+ const text = match[2]
128
+ .trim()
129
+ .replace(/\*{1,2}$/, '')
130
+ .trim();
131
+
132
+ if (text.length <= 5) continue;
133
+ if (text.startsWith('`') || text.startsWith('/') || text.startsWith('-')) continue;
134
+
135
+ const cleaned = cleanStepText(text);
136
+ if (cleaned.length <= 3) continue;
137
+
138
+ items.push({ step: items.length + 1, text: cleaned, completed: false });
139
+ }
140
+
141
+ return items;
142
+ }
143
+
144
+ export function extractDoneSteps(message: string): number[] {
145
+ const steps: number[] = [];
146
+ for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
147
+ const step = Number(match[1]);
148
+ if (Number.isFinite(step)) steps.push(step);
149
+ }
150
+ return steps;
151
+ }
152
+
153
+ export function markCompletedSteps(text: string, items: TodoItem[]): number {
154
+ const doneSteps = extractDoneSteps(text);
155
+ for (const step of doneSteps) {
156
+ const item = items.find((candidate) => candidate.step === step);
157
+ if (item) item.completed = true;
158
+ }
159
+ return doneSteps.length;
160
+ }
161
+
162
+ export function formatTodoList(items: TodoItem[]): string {
163
+ return items.map((item) => `${item.step}. ${item.text}`).join('\n');
164
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@dreki-gg/pi-plan-mode",
3
+ "version": "0.1.0",
4
+ "description": "Cursor-like planning workflow for pi — read-only plan mode, questionnaire-first clarification, domain-model handoffs, and implementation-plan generation",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "author": "Juan Albarran <jalbarrandev@gmail.com>",
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/dreki-gg/pi-extensions",
13
+ "directory": "packages/plan-mode"
14
+ },
15
+ "type": "module",
16
+ "files": [
17
+ "extensions",
18
+ "README.md",
19
+ "CHANGELOG.md",
20
+ "package.json"
21
+ ],
22
+ "scripts": {
23
+ "typecheck": "tsc --noEmit",
24
+ "lint": "oxlint extensions",
25
+ "format": "oxfmt --write extensions",
26
+ "format:check": "oxfmt --check extensions"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./extensions/plan-mode"
31
+ ]
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "24",
35
+ "oxfmt": "^0.43.0",
36
+ "oxlint": "^1.58.0",
37
+ "typescript": "^6.0.0"
38
+ },
39
+ "peerDependencies": {
40
+ "@mariozechner/pi-coding-agent": "*",
41
+ "@mariozechner/pi-tui": "*"
42
+ }
43
+ }