@dreki-gg/pi-plan-mode 0.15.0 → 0.15.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.15.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix execution stopping after the final task is marked done. `update_task` no longer terminates the turn merely because the task queue is empty, so the agent can run its closing summary / validation pass before the `agent_end` completion handler takes over. The `blocked` branch still terminates to pause for user input.
8
+
3
9
  ## 0.15.0
4
10
 
5
11
  ### Minor Changes
@@ -102,9 +102,9 @@ describe('agent_end handler safety', () => {
102
102
  if (violations.length > 0) {
103
103
  throw new Error(
104
104
  `Found sendUserMessage calls inside agent_end without deliverAs.\n` +
105
- `The agent is still "processing" during agent_end, so deliverAs is required.\n\n` +
106
- `Violations:\n${violations.map((v) => ` - ${v}`).join('\n')}\n\n` +
107
- `Fix: add { deliverAs: 'followUp' } to each call.`,
105
+ `The agent is still "processing" during agent_end, so deliverAs is required.\n\n` +
106
+ `Violations:\n${violations.map((v) => ` - ${v}`).join('\n')}\n\n` +
107
+ `Fix: add { deliverAs: 'followUp' } to each call.`,
108
108
  );
109
109
  }
110
110
  });
@@ -4,10 +4,21 @@ import type { PlanData, TaskRecord } from '../types.js';
4
4
 
5
5
  function makePlan(overrides?: Partial<PlanData>): PlanData {
6
6
  const task: TaskRecord = {
7
- _type: 'task', id: 't-001', description: 'Do work', details: 'Details',
8
- status: 'pending', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
7
+ _type: 'task',
8
+ id: 't-001',
9
+ description: 'Do work',
10
+ details: 'Details',
11
+ status: 'pending',
12
+ created_at: '2026-01-01T00:00:00Z',
13
+ updated_at: '2026-01-01T00:00:00Z',
14
+ };
15
+ return {
16
+ title: 'Test Plan',
17
+ planName: 'test-plan',
18
+ handoff: '# Handoff',
19
+ tasks: [task],
20
+ ...overrides,
9
21
  };
10
- return { title: 'Test Plan', planName: 'test-plan', handoff: '# Handoff', tasks: [task], ...overrides };
11
22
  }
12
23
 
13
24
  describe('PlanModeState', () => {
@@ -3,17 +3,34 @@ import { chdir } from 'node:process';
3
3
  import { mkdtemp, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
- import { readPlansManifest, upsertPlanEntry, writePlansManifest } from '../storage/plans-manifest.js';
6
+ import {
7
+ readPlansManifest,
8
+ upsertPlanEntry,
9
+ writePlansManifest,
10
+ } from '../storage/plans-manifest.js';
7
11
 
8
12
  const originalCwd = process.cwd();
9
13
  let dir: string;
10
14
 
11
- beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'plan-mode-manifest-')); chdir(dir); });
12
- afterEach(async () => { chdir(originalCwd); await rm(dir, { recursive: true, force: true }); });
15
+ beforeEach(async () => {
16
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-manifest-'));
17
+ chdir(dir);
18
+ });
19
+ afterEach(async () => {
20
+ chdir(originalCwd);
21
+ await rm(dir, { recursive: true, force: true });
22
+ });
13
23
 
14
24
  describe('plans.jsonl manifest', () => {
15
25
  test('round trips entries', async () => {
16
- const entry = { _type: 'plan' as const, name: 'refactor', status: 'in-progress' as const, title: 'Refactor', created_at: 'now', completed_at: null };
26
+ const entry = {
27
+ _type: 'plan' as const,
28
+ name: 'refactor',
29
+ status: 'in-progress' as const,
30
+ title: 'Refactor',
31
+ created_at: 'now',
32
+ completed_at: null,
33
+ };
17
34
  await writePlansManifest([entry]);
18
35
  await expect(readPlansManifest()).resolves.toEqual([entry]);
19
36
  });
@@ -27,7 +44,16 @@ describe('plans.jsonl manifest', () => {
27
44
  });
28
45
 
29
46
  test('upserts existing entries without changing created_at', async () => {
30
- await writePlansManifest([{ _type: 'plan', name: 'p', status: 'in-progress', title: 'Old', created_at: 'created', completed_at: null }]);
47
+ await writePlansManifest([
48
+ {
49
+ _type: 'plan',
50
+ name: 'p',
51
+ status: 'in-progress',
52
+ title: 'Old',
53
+ created_at: 'created',
54
+ completed_at: null,
55
+ },
56
+ ]);
31
57
  await upsertPlanEntry('p', { status: 'done', title: 'New' });
32
58
  const [entry] = await readPlansManifest();
33
59
  expect(entry.created_at).toBe('created');
@@ -5,7 +5,15 @@ import type { PlanData, TaskRecord } from '../types.js';
5
5
 
6
6
  const now = '2026-01-01T00:00:00Z';
7
7
  function makeTask(overrides?: Partial<TaskRecord>): TaskRecord {
8
- return { _type: 'task', id: 't-001', description: 'Do work', status: 'pending', created_at: now, updated_at: now, ...overrides };
8
+ return {
9
+ _type: 'task',
10
+ id: 't-001',
11
+ description: 'Do work',
12
+ status: 'pending',
13
+ created_at: now,
14
+ updated_at: now,
15
+ ...overrides,
16
+ };
9
17
  }
10
18
 
11
19
  describe('buildPlanModePrompt', () => {
@@ -54,13 +62,23 @@ describe('buildExecutionPrompt', () => {
54
62
  });
55
63
 
56
64
  test('includes Details line when task has details', () => {
57
- const plan: PlanData = { title: 'Test', planName: 'test', handoff: '# H', tasks: [makeTask({ details: 'Full instructions here' })] };
65
+ const plan: PlanData = {
66
+ title: 'Test',
67
+ planName: 'test',
68
+ handoff: '# H',
69
+ tasks: [makeTask({ details: 'Full instructions here' })],
70
+ };
58
71
  const prompt = buildExecutionPrompt(plan)!;
59
72
  expect(prompt).toContain('Details: Full instructions here');
60
73
  });
61
74
 
62
75
  test('returns undefined when no pending tasks', () => {
63
- const plan: PlanData = { title: 'Test', planName: 'test', handoff: '# H', tasks: [makeTask({ status: 'done' })] };
76
+ const plan: PlanData = {
77
+ title: 'Test',
78
+ planName: 'test',
79
+ handoff: '# H',
80
+ tasks: [makeTask({ status: 'done' })],
81
+ };
64
82
  expect(buildExecutionPrompt(plan)).toBeUndefined();
65
83
  });
66
84
  });
@@ -8,10 +8,22 @@ import type { TaskMeta, TaskRecord } from '../types.js';
8
8
  let dir: string;
9
9
  const now = '2026-05-27T12:00:00.000Z';
10
10
  const meta: TaskMeta = { _type: 'meta', title: 'Plan', plan_name: 'plan', created_at: now };
11
- const task: TaskRecord = { _type: 'task', id: 't-001', description: 'Do work', details: 'Details', status: 'pending', created_at: now, updated_at: now };
11
+ const task: TaskRecord = {
12
+ _type: 'task',
13
+ id: 't-001',
14
+ description: 'Do work',
15
+ details: 'Details',
16
+ status: 'pending',
17
+ created_at: now,
18
+ updated_at: now,
19
+ };
12
20
 
13
- beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'plan-mode-tasks-')); });
14
- afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
21
+ beforeEach(async () => {
22
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-tasks-'));
23
+ });
24
+ afterEach(async () => {
25
+ await rm(dir, { recursive: true, force: true });
26
+ });
15
27
 
16
28
  describe('tasks.jsonl storage', () => {
17
29
  test('round trips meta and tasks', async () => {
@@ -20,7 +32,14 @@ describe('tasks.jsonl storage', () => {
20
32
  });
21
33
 
22
34
  test('round trips tasks without details (lightweight checklist)', async () => {
23
- const lightweight: TaskRecord = { _type: 'task', id: 't-002', description: 'Quick fix', status: 'pending', created_at: now, updated_at: now };
35
+ const lightweight: TaskRecord = {
36
+ _type: 'task',
37
+ id: 't-002',
38
+ description: 'Quick fix',
39
+ status: 'pending',
40
+ created_at: now,
41
+ updated_at: now,
42
+ };
24
43
  await writeTasksJsonl(dir, meta, [lightweight]);
25
44
  const result = await readTasksJsonl(dir);
26
45
  expect(result?.tasks[0]?.id).toBe('t-002');
@@ -63,6 +63,8 @@ describe('task meta type guard', () => {
63
63
 
64
64
  test('rejects malformed meta records', () => {
65
65
  expect(isTaskMeta({ _type: 'meta', title: 'Refactor' })).toBe(false);
66
- expect(isTaskMeta({ _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now })).toBe(false);
66
+ expect(
67
+ isTaskMeta({ _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now }),
68
+ ).toBe(false);
67
69
  });
68
70
  });
@@ -14,7 +14,9 @@ describe('isSafeCommand', () => {
14
14
 
15
15
  test('command with 2>/dev/null stderr redirect', () => {
16
16
  expect(
17
- isSafeCommand('cat .release-please-manifest.json 2>/dev/null; echo "---"; cat release-please-config.json 2>/dev/null'),
17
+ isSafeCommand(
18
+ 'cat .release-please-manifest.json 2>/dev/null; echo "---"; cat release-please-config.json 2>/dev/null',
19
+ ),
18
20
  ).toBe(true);
19
21
  });
20
22
 
@@ -4,10 +4,7 @@
4
4
  */
5
5
 
6
6
  /** Drop everything before the execution start index. */
7
- export function filterExecutionMessages<T>(
8
- messages: T[],
9
- executionStartIdx: number,
10
- ): T[] {
7
+ export function filterExecutionMessages<T>(messages: T[], executionStartIdx: number): T[] {
11
8
  return messages.filter((_m, i) => i >= executionStartIdx);
12
9
  }
13
10
 
@@ -32,7 +32,10 @@ function markdownToHtml(markdown: string): string {
32
32
  // Fenced code blocks
33
33
  if (line.startsWith('```')) {
34
34
  if (!inCode) {
35
- if (inList) { html.push('</ul>'); inList = false; }
35
+ if (inList) {
36
+ html.push('</ul>');
37
+ inList = false;
38
+ }
36
39
  inCode = true;
37
40
  codeLang = line.slice(3).trim();
38
41
  codeLines.length = 0;
@@ -53,7 +56,10 @@ function markdownToHtml(markdown: string): string {
53
56
  // Headings
54
57
  const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
55
58
  if (headingMatch) {
56
- if (inList) { html.push('</ul>'); inList = false; }
59
+ if (inList) {
60
+ html.push('</ul>');
61
+ inList = false;
62
+ }
57
63
  const level = headingMatch[1].length;
58
64
  html.push(`<h${level}>${inlineMarkdown(escapeHtml(headingMatch[2]))}</h${level}>`);
59
65
  continue;
@@ -62,19 +68,28 @@ function markdownToHtml(markdown: string): string {
62
68
  // List items (- or *)
63
69
  const listMatch = line.match(/^\s*[-*]\s+(.*)$/);
64
70
  if (listMatch) {
65
- if (!inList) { html.push('<ul>'); inList = true; }
71
+ if (!inList) {
72
+ html.push('<ul>');
73
+ inList = true;
74
+ }
66
75
  html.push(`<li>${inlineMarkdown(escapeHtml(listMatch[1]))}</li>`);
67
76
  continue;
68
77
  }
69
78
 
70
79
  // Blank line
71
80
  if (!line.trim()) {
72
- if (inList) { html.push('</ul>'); inList = false; }
81
+ if (inList) {
82
+ html.push('</ul>');
83
+ inList = false;
84
+ }
73
85
  continue;
74
86
  }
75
87
 
76
88
  // Paragraph
77
- if (inList) { html.push('</ul>'); inList = false; }
89
+ if (inList) {
90
+ html.push('</ul>');
91
+ inList = false;
92
+ }
78
93
  html.push(`<p>${inlineMarkdown(escapeHtml(line))}</p>`);
79
94
  }
80
95
 
@@ -88,17 +103,19 @@ function markdownToHtml(markdown: string): string {
88
103
 
89
104
  /** Converts inline markdown (bold, italic, inline code, links) in already-escaped HTML. */
90
105
  function inlineMarkdown(text: string): string {
91
- return text
92
- // Inline code: `code`
93
- .replace(/`([^`]+)`/g, '<code>$1</code>')
94
- // Bold: **text** or __text__
95
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
96
- .replace(/__(.+?)__/g, '<strong>$1</strong>')
97
- // Italic: *text* or _text_
98
- .replace(/\*(.+?)\*/g, '<em>$1</em>')
99
- .replace(/_(.+?)_/g, '<em>$1</em>')
100
- // Links: [text](url)
101
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
106
+ return (
107
+ text
108
+ // Inline code: `code`
109
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
110
+ // Bold: **text** or __text__
111
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
112
+ .replace(/__(.+?)__/g, '<strong>$1</strong>')
113
+ // Italic: *text* or _text_
114
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
115
+ .replace(/_(.+?)_/g, '<em>$1</em>')
116
+ // Links: [text](url)
117
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
118
+ );
102
119
  }
103
120
 
104
121
  function escapeHtml(value: string): string {
@@ -18,7 +18,14 @@
18
18
 
19
19
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
20
20
  import { Key } from '@earendil-works/pi-tui';
21
- import { PLAN_TOOLS, EXEC_TOOLS, PLAN_MODEL, PLAN_THINKING, EXEC_MODEL, EXEC_THINKING } from './constants.js';
21
+ import {
22
+ PLAN_TOOLS,
23
+ EXEC_TOOLS,
24
+ PLAN_MODEL,
25
+ PLAN_THINKING,
26
+ EXEC_MODEL,
27
+ EXEC_THINKING,
28
+ } from './constants.js';
22
29
  import type { ThinkingLevel } from './types.js';
23
30
  import { PlanModeState } from './state.js';
24
31
  import { loadHandoff, readAndClearExecPending } from './storage/plan-storage.js';
@@ -63,7 +70,12 @@ export default function planMode(pi: ExtensionAPI): void {
63
70
  if (notes) task.notes = notes;
64
71
  await writeTasksJsonl(
65
72
  state.planDir,
66
- { _type: 'meta', title: state.plan.title, plan_name: state.plan.planName, created_at: state.plan.tasks[0]?.created_at ?? task.updated_at },
73
+ {
74
+ _type: 'meta',
75
+ title: state.plan.title,
76
+ plan_name: state.plan.planName,
77
+ created_at: state.plan.tasks[0]?.created_at ?? task.updated_at,
78
+ },
67
79
  state.plan.tasks,
68
80
  );
69
81
  state.persist(pi);
@@ -72,7 +84,8 @@ export default function planMode(pi: ExtensionAPI): void {
72
84
 
73
85
  // ── Commands ──────────────────────────────────────────────────────────────
74
86
  pi.registerCommand('plan', {
75
- description: 'Enter plan mode, optionally with a starting prompt. Use "/plan resume" to pick up an existing plan.',
87
+ description:
88
+ 'Enter plan mode, optionally with a starting prompt. Use "/plan resume" to pick up an existing plan.',
76
89
  handler: async (args, ctx) => {
77
90
  const trimmed = args?.trim();
78
91
  if (trimmed === 'resume') {
@@ -96,7 +109,8 @@ export default function planMode(pi: ExtensionAPI): void {
96
109
  return;
97
110
  }
98
111
  const taskList = state.plan.tasks.map((task) => `${task.id}. ${task.description}`).join('\n');
99
- const first = state.plan.tasks.find((task) => task.status === 'pending')?.id ?? state.plan.tasks[0]?.id;
112
+ const first =
113
+ state.plan.tasks.find((task) => task.status === 'pending')?.id ?? state.plan.tasks[0]?.id;
100
114
  const kickoff = `Execute the following plan: "${state.plan.title}"\n\nTasks:\n${taskList}\n\nStart with ${first}. Call update_task after completing each task.`;
101
115
  await executeInNewSession(ctx, state.planDir, state.plan, kickoff);
102
116
  },
@@ -168,7 +182,11 @@ export default function planMode(pi: ExtensionAPI): void {
168
182
  pi.on('before_agent_start', async () => {
169
183
  if (state.planEnabled) {
170
184
  return {
171
- message: { customType: 'plan-mode-context', content: buildPlanModePrompt(), display: false },
185
+ message: {
186
+ customType: 'plan-mode-context',
187
+ content: buildPlanModePrompt(),
188
+ display: false,
189
+ },
172
190
  };
173
191
  }
174
192
  if (state.executing && state.plan) {
@@ -194,17 +212,31 @@ export default function planMode(pi: ExtensionAPI): void {
194
212
  : `Task ${bs.id}: ${bs.description}`;
195
213
 
196
214
  const choice = await ctx.ui.select(`Task blocked — ${info}\n\nWhat next?`, [
197
- 'Skip this task', 'Provide instructions', 'Re-plan', 'Abort execution',
215
+ 'Skip this task',
216
+ 'Provide instructions',
217
+ 'Re-plan',
218
+ 'Abort execution',
198
219
  ]);
199
220
 
200
221
  if (choice === 'Skip this task') {
201
222
  bs.status = 'skipped';
202
223
  bs.updated_at = new Date().toISOString();
203
- await writeTasksJsonl(state.planDir!, { _type: 'meta', title: state.plan.title, plan_name: state.plan.planName, created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at }, state.plan.tasks);
224
+ await writeTasksJsonl(
225
+ state.planDir!,
226
+ {
227
+ _type: 'meta',
228
+ title: state.plan.title,
229
+ plan_name: state.plan.planName,
230
+ created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at,
231
+ },
232
+ state.plan.tasks,
233
+ );
204
234
  updateUI(state, ctx);
205
235
  state.persist(pi);
206
236
  if (state.plan.tasks.some((s) => s.status === 'pending')) {
207
- pi.sendUserMessage('The blocked task has been skipped. Continue with the next task.', { deliverAs: 'followUp' });
237
+ pi.sendUserMessage('The blocked task has been skipped. Continue with the next task.', {
238
+ deliverAs: 'followUp',
239
+ });
208
240
  }
209
241
  } else if (choice === 'Provide instructions') {
210
242
  const instructions = await ctx.ui.editor('Instructions for the blocked task:', '');
@@ -212,7 +244,16 @@ export default function planMode(pi: ExtensionAPI): void {
212
244
  bs.status = 'pending';
213
245
  bs.notes = undefined;
214
246
  bs.updated_at = new Date().toISOString();
215
- await writeTasksJsonl(state.planDir!, { _type: 'meta', title: state.plan.title, plan_name: state.plan.planName, created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at }, state.plan.tasks);
247
+ await writeTasksJsonl(
248
+ state.planDir!,
249
+ {
250
+ _type: 'meta',
251
+ title: state.plan.title,
252
+ plan_name: state.plan.planName,
253
+ created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at,
254
+ },
255
+ state.plan.tasks,
256
+ );
216
257
  updateUI(state, ctx);
217
258
  state.persist(pi);
218
259
  pi.sendUserMessage(
@@ -235,18 +276,28 @@ export default function planMode(pi: ExtensionAPI): void {
235
276
  }
236
277
 
237
278
  // Check completion
238
- const allResolved = state.plan.tasks.every((s) => s.status === 'done' || s.status === 'skipped');
279
+ const allResolved = state.plan.tasks.every(
280
+ (s) => s.status === 'done' || s.status === 'skipped',
281
+ );
239
282
  if (allResolved) {
240
283
  if (state.planDir) {
241
284
  await upsertPlanEntry(state.plan.planName, { status: 'done', title: state.plan.title });
242
- await writeTasksJsonl(state.planDir, { _type: 'meta', title: state.plan.title, plan_name: state.plan.planName, created_at: state.plan.tasks[0]?.created_at ?? new Date().toISOString() }, state.plan.tasks);
285
+ await writeTasksJsonl(
286
+ state.planDir,
287
+ {
288
+ _type: 'meta',
289
+ title: state.plan.title,
290
+ plan_name: state.plan.planName,
291
+ created_at: state.plan.tasks[0]?.created_at ?? new Date().toISOString(),
292
+ },
293
+ state.plan.tasks,
294
+ );
243
295
  }
244
296
  const done = state.plan.tasks.filter((s) => s.status === 'done').length;
245
297
  const skipped = state.plan.tasks.filter((s) => s.status === 'skipped').length;
246
298
  const total = state.plan.tasks.length;
247
- const stats = skipped > 0
248
- ? `${done}/${total} done, ${skipped} skipped`
249
- : `${done}/${total} done`;
299
+ const stats =
300
+ skipped > 0 ? `${done}/${total} done, ${skipped} skipped` : `${done}/${total} done`;
250
301
 
251
302
  // Build a summary of what was actually done from task notes
252
303
  const changeSummary = state.plan.tasks
@@ -302,7 +353,14 @@ export default function planMode(pi: ExtensionAPI): void {
302
353
  state.planDir = pending.planDir;
303
354
  {
304
355
  const snapshot = await readTasksJsonl(pending.planDir);
305
- state.plan = snapshot ? { title: snapshot.meta.title, planName: snapshot.meta.plan_name, handoff: (await loadHandoff(pending.planDir)) ?? '', tasks: snapshot.tasks } : undefined;
356
+ state.plan = snapshot
357
+ ? {
358
+ title: snapshot.meta.title,
359
+ planName: snapshot.meta.plan_name,
360
+ handoff: (await loadHandoff(pending.planDir)) ?? '',
361
+ tasks: snapshot.tasks,
362
+ }
363
+ : undefined;
306
364
  }
307
365
  if (state.plan) {
308
366
  state.executing = true;
@@ -5,7 +5,14 @@
5
5
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
6
6
  import type { PlanModeState } from './state.js';
7
7
  import type { ThinkingLevel } from './types.js';
8
- import { PLAN_TOOLS, EXEC_TOOLS, PLAN_MODEL, PLAN_THINKING, EXEC_MODEL, EXEC_THINKING } from './constants.js';
8
+ import {
9
+ PLAN_TOOLS,
10
+ EXEC_TOOLS,
11
+ PLAN_MODEL,
12
+ PLAN_THINKING,
13
+ EXEC_MODEL,
14
+ EXEC_THINKING,
15
+ } from './constants.js';
9
16
  import { updateUI } from './ui.js';
10
17
 
11
18
  export async function switchModel(
@@ -40,10 +47,7 @@ export async function enterPlanMode(
40
47
  pi.setActiveTools(PLAN_TOOLS);
41
48
  await switchModel(pi, ctx, PLAN_MODEL);
42
49
  pi.setThinkingLevel(PLAN_THINKING);
43
- ctx.ui.notify(
44
- `Plan mode ON — ${PLAN_MODEL.provider}/${PLAN_MODEL.id}:${PLAN_THINKING}`,
45
- 'info',
46
- );
50
+ ctx.ui.notify(`Plan mode ON — ${PLAN_MODEL.provider}/${PLAN_MODEL.id}:${PLAN_THINKING}`, 'info');
47
51
  updateUI(state, ctx);
48
52
  state.persist(pi);
49
53
  }
@@ -1 +1,6 @@
1
- export { loadHandoff, readAndClearExecPending, saveHandoff, writeExecPending } from './storage/plan-storage.js';
1
+ export {
2
+ loadHandoff,
3
+ readAndClearExecPending,
4
+ saveHandoff,
5
+ writeExecPending,
6
+ } from './storage/plan-storage.js';
@@ -50,9 +50,7 @@ export function buildExecutionPrompt(plan: PlanData): string | undefined {
50
50
  .join('\n\n');
51
51
 
52
52
  const currentTask = remaining[0];
53
- const currentDetails = currentTask.details
54
- ? `\nDetails: ${currentTask.details}`
55
- : '';
53
+ const currentDetails = currentTask.details ? `\nDetails: ${currentTask.details}` : '';
56
54
 
57
55
  return `[EXECUTING PLAN — FOLLOW THE PLAN EXACTLY]
58
56
 
@@ -2,7 +2,11 @@
2
2
  * Resume and execution handoff — pick up in-progress plans, model picker, new session handoff.
3
3
  */
4
4
 
5
- import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
5
+ import type {
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ ExtensionCommandContext,
9
+ } from '@earendil-works/pi-coding-agent';
6
10
  import type { PlanModeState } from './state.js';
7
11
  import type { PlanData } from './types.js';
8
12
  import { EXEC_THINKING, EXEC_MODEL_OPTIONS } from './constants.js';
@@ -11,7 +15,9 @@ import { loadHandoff, writeExecPending } from './storage/plan-storage.js';
11
15
  import { readTasksJsonl, writeTasksJsonl } from './storage/task-storage.js';
12
16
  import { enterPlanMode } from './phase-transitions.js';
13
17
 
14
- export async function pickExecutionModel(ctx: ExtensionContext): Promise<{ provider: string; id: string } | undefined> {
18
+ export async function pickExecutionModel(
19
+ ctx: ExtensionContext,
20
+ ): Promise<{ provider: string; id: string } | undefined> {
15
21
  const labels = EXEC_MODEL_OPTIONS.map((o) => o.label);
16
22
  const choice = await ctx.ui.select('Execute with:', labels);
17
23
  if (!choice) return undefined;
@@ -38,7 +44,11 @@ export async function executeInNewSession(
38
44
  });
39
45
  }
40
46
 
41
- export async function resumePlan(state: PlanModeState, pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
47
+ export async function resumePlan(
48
+ state: PlanModeState,
49
+ pi: ExtensionAPI,
50
+ ctx: ExtensionCommandContext,
51
+ ): Promise<void> {
42
52
  const manifest = await readPlansManifest();
43
53
  const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
44
54
 
@@ -70,12 +80,17 @@ export async function resumePlan(state: PlanModeState, pi: ExtensionAPI, ctx: Ex
70
80
  tasks: snapshot.tasks,
71
81
  };
72
82
 
73
- const doneCount = state.plan.tasks.filter((task) => task.status === 'done' || task.status === 'skipped').length;
83
+ const doneCount = state.plan.tasks.filter(
84
+ (task) => task.status === 'done' || task.status === 'skipped',
85
+ ).length;
74
86
  const pendingCount = state.plan.tasks.filter((task) => task.status === 'pending').length;
75
87
  const blockedCount = state.plan.tasks.filter((task) => task.status === 'blocked').length;
76
88
 
77
89
  if (pendingCount === 0 && blockedCount === 0) {
78
- ctx.ui.notify(`Plan "${state.plan.title}" is already complete (${doneCount}/${state.plan.tasks.length} done).`, 'info');
90
+ ctx.ui.notify(
91
+ `Plan "${state.plan.title}" is already complete (${doneCount}/${state.plan.tasks.length} done).`,
92
+ 'info',
93
+ );
79
94
  state.plan = undefined;
80
95
  state.planDir = undefined;
81
96
  return;
@@ -25,9 +25,7 @@ export class PlanModeState {
25
25
  }
26
26
 
27
27
  restore(entries: Array<{ type: string; customType?: string; data?: PersistedState }>): void {
28
- const saved = entries
29
- .filter((e) => e.type === 'custom' && e.customType === 'plan-mode')
30
- .pop();
28
+ const saved = entries.filter((e) => e.type === 'custom' && e.customType === 'plan-mode').pop();
31
29
  if (saved?.data) {
32
30
  this.planEnabled = saved.data.planEnabled ?? this.planEnabled;
33
31
  this.executing = saved.data.executing ?? this.executing;
@@ -8,7 +8,11 @@ export interface AtomicWriteOptions {
8
8
  mode?: number;
9
9
  }
10
10
 
11
- export async function writeFileAtomic(path: string, data: string | Buffer, options: AtomicWriteOptions = {}): Promise<void> {
11
+ export async function writeFileAtomic(
12
+ path: string,
13
+ data: string | Buffer,
14
+ options: AtomicWriteOptions = {},
15
+ ): Promise<void> {
12
16
  const dir = dirname(path);
13
17
  const tempPath = join(dir, `.${process.pid}.${randomUUID()}.tmp`);
14
18
  let completed = false;
@@ -11,7 +11,9 @@ export async function writeExecPending(dir: string, config: ExecPendingConfig):
11
11
  await writeFile(`${dir}/${EXEC_PENDING_FILE}`, JSON.stringify(config, null, 2) + '\n', 'utf-8');
12
12
  }
13
13
 
14
- export async function readAndClearExecPending(): Promise<{ planDir: string; config: ExecPendingConfig } | undefined> {
14
+ export async function readAndClearExecPending(): Promise<
15
+ { planDir: string; config: ExecPendingConfig } | undefined
16
+ > {
15
17
  try {
16
18
  const entries = await readdir('.plans', { withFileTypes: true });
17
19
  for (const entry of entries) {
@@ -14,13 +14,22 @@ export interface PlanManifestEntry {
14
14
 
15
15
  export async function readPlansManifest(): Promise<PlanManifestEntry[]> {
16
16
  let text: string;
17
- try { text = await readFile(MANIFEST_PATH, 'utf8'); } catch { return []; }
17
+ try {
18
+ text = await readFile(MANIFEST_PATH, 'utf8');
19
+ } catch {
20
+ return [];
21
+ }
18
22
  const entries: PlanManifestEntry[] = [];
19
23
  for (const [index, raw] of text.split(/\r?\n/).entries()) {
20
24
  if (!raw.trim()) continue;
21
25
  let parsed: unknown;
22
- try { parsed = JSON.parse(raw); } catch (error) { throw new Error(`Invalid plans.jsonl at line ${index + 1}: ${(error as Error).message}`); }
23
- if (!isPlanManifestEntry(parsed)) throw new Error(`Invalid plans.jsonl record at line ${index + 1}`);
26
+ try {
27
+ parsed = JSON.parse(raw);
28
+ } catch (error) {
29
+ throw new Error(`Invalid plans.jsonl at line ${index + 1}: ${(error as Error).message}`);
30
+ }
31
+ if (!isPlanManifestEntry(parsed))
32
+ throw new Error(`Invalid plans.jsonl record at line ${index + 1}`);
24
33
  entries.push(parsed);
25
34
  }
26
35
  return entries;
@@ -28,11 +37,15 @@ export async function readPlansManifest(): Promise<PlanManifestEntry[]> {
28
37
 
29
38
  export async function writePlansManifest(entries: PlanManifestEntry[]): Promise<void> {
30
39
  await mkdir('.plans', { recursive: true });
31
- const content = entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
40
+ const content =
41
+ entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
32
42
  await writeFileAtomic(MANIFEST_PATH, content);
33
43
  }
34
44
 
35
- export async function upsertPlanEntry(name: string, updates: { status: 'in-progress' | 'done'; title?: string }): Promise<void> {
45
+ export async function upsertPlanEntry(
46
+ name: string,
47
+ updates: { status: 'in-progress' | 'done'; title?: string },
48
+ ): Promise<void> {
36
49
  const entries = await readPlansManifest();
37
50
  const now = new Date().toISOString();
38
51
  const index = entries.findIndex((entry) => entry.name === name);
@@ -53,5 +66,12 @@ export async function upsertPlanEntry(name: string, updates: { status: 'in-progr
53
66
  function isPlanManifestEntry(value: unknown): value is PlanManifestEntry {
54
67
  if (typeof value !== 'object' || value === null) return false;
55
68
  const record = value as Record<string, unknown>;
56
- return record._type === 'plan' && typeof record.name === 'string' && (record.status === 'in-progress' || record.status === 'done') && typeof record.title === 'string' && typeof record.created_at === 'string' && (record.completed_at === null || typeof record.completed_at === 'string');
69
+ return (
70
+ record._type === 'plan' &&
71
+ typeof record.name === 'string' &&
72
+ (record.status === 'in-progress' || record.status === 'done') &&
73
+ typeof record.title === 'string' &&
74
+ typeof record.created_at === 'string' &&
75
+ (record.completed_at === null || typeof record.completed_at === 'string')
76
+ );
57
77
  }
@@ -5,11 +5,18 @@ import { writeFileAtomic } from './atomic-write.js';
5
5
 
6
6
  const TASKS_FILE = 'tasks.jsonl';
7
7
 
8
- export interface TasksSnapshot { meta: TaskMeta; tasks: TaskRecord[] }
8
+ export interface TasksSnapshot {
9
+ meta: TaskMeta;
10
+ tasks: TaskRecord[];
11
+ }
9
12
 
10
13
  export async function readTasksJsonl(planDir: string): Promise<TasksSnapshot | undefined> {
11
14
  let text: string;
12
- try { text = await readFile(join(planDir, TASKS_FILE), 'utf8'); } catch { return undefined; }
15
+ try {
16
+ text = await readFile(join(planDir, TASKS_FILE), 'utf8');
17
+ } catch {
18
+ return undefined;
19
+ }
13
20
  if (!text.trim()) throw new Error('tasks.jsonl is missing meta record');
14
21
 
15
22
  let meta: TaskMeta | undefined;
@@ -17,7 +24,11 @@ export async function readTasksJsonl(planDir: string): Promise<TasksSnapshot | u
17
24
  for (const [index, raw] of text.split(/\r?\n/).entries()) {
18
25
  if (!raw.trim()) continue;
19
26
  let parsed: unknown;
20
- try { parsed = JSON.parse(raw); } catch (error) { throw new Error(`Invalid JSONL at line ${index + 1}: ${(error as Error).message}`); }
27
+ try {
28
+ parsed = JSON.parse(raw);
29
+ } catch (error) {
30
+ throw new Error(`Invalid JSONL at line ${index + 1}: ${(error as Error).message}`);
31
+ }
21
32
  if (isTaskMeta(parsed)) meta = parsed;
22
33
  else if (isTaskRecord(parsed)) tasks.push(parsed);
23
34
  else throw new Error(`Invalid tasks.jsonl record at line ${index + 1}`);
@@ -26,18 +37,30 @@ export async function readTasksJsonl(planDir: string): Promise<TasksSnapshot | u
26
37
  return { meta, tasks };
27
38
  }
28
39
 
29
- export async function writeTasksJsonl(planDir: string, meta: TaskMeta, tasks: TaskRecord[]): Promise<void> {
40
+ export async function writeTasksJsonl(
41
+ planDir: string,
42
+ meta: TaskMeta,
43
+ tasks: TaskRecord[],
44
+ ): Promise<void> {
30
45
  await mkdir(planDir, { recursive: true });
31
46
  const content = [meta, ...tasks].map((record) => JSON.stringify(record)).join('\n') + '\n';
32
47
  await writeFileAtomic(join(planDir, TASKS_FILE), content);
33
48
  }
34
49
 
35
- export async function updateTask(planDir: string, taskId: string, updates: Partial<Omit<TaskRecord, '_type' | 'id' | 'created_at'>>): Promise<TaskRecord> {
50
+ export async function updateTask(
51
+ planDir: string,
52
+ taskId: string,
53
+ updates: Partial<Omit<TaskRecord, '_type' | 'id' | 'created_at'>>,
54
+ ): Promise<TaskRecord> {
36
55
  const snapshot = await readTasksJsonl(planDir);
37
56
  if (!snapshot) throw new Error(`No tasks.jsonl found in ${planDir}`);
38
57
  const index = snapshot.tasks.findIndex((task) => task.id === taskId);
39
58
  if (index === -1) throw new Error(`Task not found: ${taskId}`);
40
- const updated: TaskRecord = { ...snapshot.tasks[index], ...updates, updated_at: new Date().toISOString() };
59
+ const updated: TaskRecord = {
60
+ ...snapshot.tasks[index],
61
+ ...updates,
62
+ updated_at: new Date().toISOString(),
63
+ };
41
64
  snapshot.tasks[index] = updated;
42
65
  await writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
43
66
  return updated;
@@ -21,36 +21,54 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
21
21
  pi.registerTool({
22
22
  name: 'submit_plan',
23
23
  label: 'Submit Plan',
24
- description: 'Finalize a conversational plan with task IDs, JSONL storage, HANDOFF.md, and generated plan.html.',
25
- promptSnippet: 'Finalize the plan with title, handoff, tasks, dependencies, and optional prototype Pug',
24
+ description:
25
+ 'Finalize a conversational plan with task IDs, JSONL storage, HANDOFF.md, and generated plan.html.',
26
+ promptSnippet:
27
+ 'Finalize the plan with title, handoff, tasks, dependencies, and optional prototype Pug',
26
28
  promptGuidelines: [
27
29
  'Only call submit_plan after shared understanding has been reached with the user.',
28
30
  'Each task needs an id like t-001, a short description, and optional depends_on task IDs.',
29
- 'When a different agent or human will execute the plan, include detailed implementation instructions in each task\'s details field.',
31
+ "When a different agent or human will execute the plan, include detailed implementation instructions in each task's details field.",
30
32
  'When you are planning and executing yourself (same session), use lightweight checklist-style tasks: just id + description, omit details. Put the real context in the handoff document instead.',
31
33
  'The handoff must be thorough enough that both a human reviewer and executor agent with zero prior context can understand the plan.',
32
34
  ],
33
35
  parameters: Type.Object({
34
- name: Type.String({ description: 'Short kebab-case name for the plan (e.g. "add-auth-middleware")' }),
36
+ name: Type.String({
37
+ description: 'Short kebab-case name for the plan (e.g. "add-auth-middleware")',
38
+ }),
35
39
  title: Type.String({ description: 'Human-readable plan title' }),
36
40
  handoff: Type.String({ description: 'Markdown content for HANDOFF.md' }),
37
41
  tasks: Type.Array(
38
42
  Type.Object({
39
43
  id: Type.String({ description: 'Stable task ID, e.g. t-001' }),
40
- description: Type.String({ description: 'Short task label for progress display (≤60 chars)' }),
41
- details: Type.Optional(Type.String({ description: 'Full implementation instructions for this task. Omit for lightweight checklist-style plans when you are executing yourself.' })),
44
+ description: Type.String({
45
+ description: 'Short task label for progress display (≤60 chars)',
46
+ }),
47
+ details: Type.Optional(
48
+ Type.String({
49
+ description:
50
+ 'Full implementation instructions for this task. Omit for lightweight checklist-style plans when you are executing yourself.',
51
+ }),
52
+ ),
42
53
  depends_on: Type.Optional(Type.Array(Type.String({ description: 'Dependency task ID' }))),
43
54
  }),
44
55
  { minItems: 1 },
45
56
  ),
46
- prototype: Type.Optional(Type.String({ description: 'Optional Pug markup for the prototype section in plan.html' })),
57
+ prototype: Type.Optional(
58
+ Type.String({ description: 'Optional Pug markup for the prototype section in plan.html' }),
59
+ ),
47
60
  }),
48
61
 
49
62
  async execute(_toolCallId, params) {
50
63
  const planName = toKebabCase(params.name);
51
64
  const planDir = `.plans/${planName}`;
52
65
  const now = new Date().toISOString();
53
- const meta: TaskMeta = { _type: 'meta', title: params.title, plan_name: planName, created_at: now };
66
+ const meta: TaskMeta = {
67
+ _type: 'meta',
68
+ title: params.title,
69
+ plan_name: planName,
70
+ created_at: now,
71
+ };
54
72
  const tasks: TaskRecord[] = params.tasks.map((task) => ({
55
73
  _type: 'task',
56
74
  id: task.id,
@@ -72,7 +90,12 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
72
90
  callbacks.onPlanSubmitted(planDir, plan);
73
91
 
74
92
  return {
75
- content: [{ type: 'text' as const, text: `Plan "${params.title}" saved with ${tasks.length} tasks. Review ${planDir}/plan.html, then execute when ready.` }],
93
+ content: [
94
+ {
95
+ type: 'text' as const,
96
+ text: `Plan "${params.title}" saved with ${tasks.length} tasks. Review ${planDir}/plan.html, then execute when ready.`,
97
+ },
98
+ ],
76
99
  details: { planDir, plan },
77
100
  terminate: true,
78
101
  };
@@ -91,7 +114,8 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
91
114
  const plan = (result.details as { plan?: PlanData } | undefined)?.plan;
92
115
  if (!plan) return new Text(theme.fg('success', '✓ Plan saved'), 0, 0);
93
116
  const lines = [theme.fg('success', '✓ ') + theme.fg('accent', theme.bold(plan.title)), ''];
94
- for (const task of plan.tasks) lines.push(` ${theme.fg('muted', task.id)} ${task.description}`);
117
+ for (const task of plan.tasks)
118
+ lines.push(` ${theme.fg('muted', task.id)} ${task.description}`);
95
119
  return new Text(lines.join('\n'), 0, 0);
96
120
  },
97
121
  });
@@ -10,14 +10,19 @@ import type { PlanData, TaskStatus } from '../types.js';
10
10
 
11
11
  export interface UpdateTaskCallbacks {
12
12
  getPlan: () => PlanData | undefined;
13
- onTaskUpdated: (taskId: string, status: Exclude<TaskStatus, 'pending'>, notes?: string) => void | Promise<void>;
13
+ onTaskUpdated: (
14
+ taskId: string,
15
+ status: Exclude<TaskStatus, 'pending'>,
16
+ notes?: string,
17
+ ) => void | Promise<void>;
14
18
  }
15
19
 
16
20
  export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCallbacks): void {
17
21
  pi.registerTool({
18
22
  name: 'update_task',
19
23
  label: 'Update Task',
20
- description: 'Mark a plan task as done, skipped, or blocked. If blocked, execution pauses for user intervention.',
24
+ description:
25
+ 'Mark a plan task as done, skipped, or blocked. If blocked, execution pauses for user intervention.',
21
26
  promptSnippet: 'Mark a plan task as done, skipped, or blocked',
22
27
  promptGuidelines: [
23
28
  'Call update_task after completing each plan task before moving to the next.',
@@ -27,7 +32,9 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
27
32
  parameters: Type.Object({
28
33
  task_id: Type.String({ description: 'Task ID (for example, t-001)' }),
29
34
  status: StringEnum(['done', 'skipped', 'blocked'] as const),
30
- notes: Type.Optional(Type.String({ description: 'What was done, why skipped, or why blocked' })),
35
+ notes: Type.Optional(
36
+ Type.String({ description: 'What was done, why skipped, or why blocked' }),
37
+ ),
31
38
  }),
32
39
 
33
40
  async execute(_toolCallId, params) {
@@ -37,15 +44,27 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
37
44
  const task = plan.tasks.find((candidate) => candidate.id === params.task_id);
38
45
  if (!task) throw new Error(`Task not found: ${params.task_id}`);
39
46
  if (task.status !== 'pending') {
40
- throw new Error(`Task ${params.task_id} is already "${task.status}". Only pending tasks can be updated.`);
47
+ throw new Error(
48
+ `Task ${params.task_id} is already "${task.status}". Only pending tasks can be updated.`,
49
+ );
41
50
  }
42
51
 
43
52
  await callbacks.onTaskUpdated(params.task_id, params.status, params.notes);
44
53
 
45
- const details = { task_id: params.task_id, status: params.status, notes: params.notes, description: task.description };
54
+ const details = {
55
+ task_id: params.task_id,
56
+ status: params.status,
57
+ notes: params.notes,
58
+ description: task.description,
59
+ };
46
60
  if (params.status === 'blocked') {
47
61
  return {
48
- content: [{ type: 'text' as const, text: `Task ${params.task_id} blocked. Execution paused — waiting for user input.` }],
62
+ content: [
63
+ {
64
+ type: 'text' as const,
65
+ text: `Task ${params.task_id} blocked. Execution paused — waiting for user input.`,
66
+ },
67
+ ],
49
68
  details,
50
69
  terminate: true,
51
70
  };
@@ -60,7 +79,12 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
60
79
  if (params.notes) text += ` — ${params.notes}`;
61
80
  text += next ? `\n\nNext task ${next.id}: ${next.description}` : '\n\nAll tasks resolved!';
62
81
 
63
- return { content: [{ type: 'text' as const, text }], details, terminate: !next };
82
+ // Do not terminate the turn just because the queue is empty: that would cut off
83
+ // the agent's final pass (closing summary, validation, follow-up) after the last
84
+ // task is marked done. Completion is handled out-of-band by the `agent_end`
85
+ // handler in index.ts. Only the `blocked` branch above terminates, to pause for
86
+ // user input.
87
+ return { content: [{ type: 'text' as const, text }], details };
64
88
  },
65
89
 
66
90
  renderCall(args, theme) {
@@ -68,15 +92,31 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
68
92
  const status = (args as { status?: string }).status ?? '';
69
93
  let content = theme.fg('toolTitle', theme.bold('update_task '));
70
94
  content += theme.fg('muted', taskId);
71
- if (status) content += ' ' + theme.fg(status === 'done' ? 'success' : status === 'skipped' ? 'warning' : 'error', status);
95
+ if (status)
96
+ content +=
97
+ ' ' +
98
+ theme.fg(
99
+ status === 'done' ? 'success' : status === 'skipped' ? 'warning' : 'error',
100
+ status,
101
+ );
72
102
  return new Text(content, 0, 0);
73
103
  },
74
104
 
75
105
  renderResult(result, _options, theme) {
76
- const details = result.details as { task_id?: string; status?: string; description?: string } | undefined;
106
+ const details = result.details as
107
+ | { task_id?: string; status?: string; description?: string }
108
+ | undefined;
77
109
  if (!details) return new Text(theme.fg('dim', 'Updated'), 0, 0);
78
- const statusMap: Record<string, string> = { done: theme.fg('success', '✓'), skipped: theme.fg('warning', '⊘'), blocked: theme.fg('error', '✗') };
79
- return new Text(`${statusMap[details.status ?? ''] ?? ''} Task ${details.task_id}: ${details.description ?? ''}`, 0, 0);
110
+ const statusMap: Record<string, string> = {
111
+ done: theme.fg('success', ''),
112
+ skipped: theme.fg('warning', '⊘'),
113
+ blocked: theme.fg('error', '✗'),
114
+ };
115
+ return new Text(
116
+ `${statusMap[details.status ?? ''] ?? ''} Task ${details.task_id}: ${details.description ?? ''}`,
117
+ 0,
118
+ 0,
119
+ );
80
120
  },
81
121
  });
82
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
5
5
  "keywords": [
6
6
  "pi-package"