@dreki-gg/pi-plan-mode 0.14.5 → 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,22 @@
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
+
9
+ ## 0.15.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Restrict write/edit tools to .plans/ directory only during plan phase. Add isPlanPath utility. Update prompt to document --help/man support and write restrictions.
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies []:
18
+ - @dreki-gg/pi-command-sandbox@0.3.0
19
+
3
20
  ## 0.14.5
4
21
 
5
22
  ### Patch 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
  });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { isSafeCommand } from '../utils.js';
2
+ import { isSafeCommand, isPlanPath } from '../utils.js';
3
3
 
4
4
  describe('isSafeCommand', () => {
5
5
  // ── Commands that SHOULD be allowed ──────────────────────────────────────
@@ -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
 
@@ -113,4 +115,61 @@ describe('isSafeCommand', () => {
113
115
  expect(isSafeCommand('touch newfile.ts')).toBe(false);
114
116
  });
115
117
  });
118
+
119
+ // ── Help/version commands via command-sandbox ────────────────────────────
120
+ describe('help and version commands', () => {
121
+ test('bun --help is allowed', () => {
122
+ expect(isSafeCommand('bun --help')).toBe(true);
123
+ });
124
+
125
+ test('man git is allowed', () => {
126
+ expect(isSafeCommand('man git')).toBe(true);
127
+ });
128
+
129
+ test('npm --version is allowed', () => {
130
+ expect(isSafeCommand('npm --version')).toBe(true);
131
+ });
132
+
133
+ test('rm --help is still blocked', () => {
134
+ expect(isSafeCommand('rm --help')).toBe(false);
135
+ });
136
+ });
137
+ });
138
+
139
+ describe('isPlanPath', () => {
140
+ test('relative .plans/ path', () => {
141
+ expect(isPlanPath('.plans/my-plan/context.md')).toBe(true);
142
+ });
143
+
144
+ test('absolute path containing .plans/', () => {
145
+ expect(isPlanPath('/Users/me/project/.plans/my-plan/context.md')).toBe(true);
146
+ });
147
+
148
+ test('just .plans/', () => {
149
+ expect(isPlanPath('.plans/foo')).toBe(true);
150
+ });
151
+
152
+ test('windows-style backslashes', () => {
153
+ expect(isPlanPath('.plans\\my-plan\\context.md')).toBe(true);
154
+ });
155
+
156
+ test('src/ path is blocked', () => {
157
+ expect(isPlanPath('src/index.ts')).toBe(false);
158
+ });
159
+
160
+ test('root file is blocked', () => {
161
+ expect(isPlanPath('README.md')).toBe(false);
162
+ });
163
+
164
+ test('package.json is blocked', () => {
165
+ expect(isPlanPath('package.json')).toBe(false);
166
+ });
167
+
168
+ test('path with plans but not .plans is blocked', () => {
169
+ expect(isPlanPath('src/plans/something.ts')).toBe(false);
170
+ });
171
+
172
+ test('path ending in .plans without slash is blocked', () => {
173
+ expect(isPlanPath('.plans')).toBe(false);
174
+ });
116
175
  });
@@ -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';
@@ -31,7 +38,7 @@ import { enterPlanMode, exitPlanMode, switchModel } from './phase-transitions.js
31
38
  import { resumePlan, executeInNewSession } from './resume.js';
32
39
  import { registerSubmitPlanTool } from './tools/submit-plan.js';
33
40
  import { registerUpdateTaskTool } from './tools/update-task.js';
34
- import { isSafeCommand } from './utils.js';
41
+ import { isSafeCommand, isPlanPath } from './utils.js';
35
42
 
36
43
  export default function planMode(pi: ExtensionAPI): void {
37
44
  const state = new PlanModeState();
@@ -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
  },
@@ -128,9 +142,11 @@ export default function planMode(pi: ExtensionAPI): void {
128
142
  },
129
143
  });
130
144
 
131
- // ── Event: block destructive bash in plan mode ────────────────────────────
145
+ // ── Event: block destructive bash + restrict writes in plan mode ──────────
132
146
  pi.on('tool_call', async (event) => {
133
147
  if (!state.planEnabled) return;
148
+
149
+ // Block destructive bash commands
134
150
  if (event.toolName === 'bash') {
135
151
  const command = event.input.command as string;
136
152
  if (!isSafeCommand(command)) {
@@ -140,6 +156,17 @@ export default function planMode(pi: ExtensionAPI): void {
140
156
  };
141
157
  }
142
158
  }
159
+
160
+ // Restrict write to .plans/ directory only
161
+ if (event.toolName === 'write' || event.toolName === 'edit') {
162
+ const path = event.input.path as string;
163
+ if (!isPlanPath(path)) {
164
+ return {
165
+ block: true,
166
+ reason: `Plan mode: writes are restricted to .plans/ directory only.\nPath: ${path}`,
167
+ };
168
+ }
169
+ }
143
170
  });
144
171
 
145
172
  // ── Event: filter context ─────────────────────────────────────────────────
@@ -155,7 +182,11 @@ export default function planMode(pi: ExtensionAPI): void {
155
182
  pi.on('before_agent_start', async () => {
156
183
  if (state.planEnabled) {
157
184
  return {
158
- message: { customType: 'plan-mode-context', content: buildPlanModePrompt(), display: false },
185
+ message: {
186
+ customType: 'plan-mode-context',
187
+ content: buildPlanModePrompt(),
188
+ display: false,
189
+ },
159
190
  };
160
191
  }
161
192
  if (state.executing && state.plan) {
@@ -181,17 +212,31 @@ export default function planMode(pi: ExtensionAPI): void {
181
212
  : `Task ${bs.id}: ${bs.description}`;
182
213
 
183
214
  const choice = await ctx.ui.select(`Task blocked — ${info}\n\nWhat next?`, [
184
- 'Skip this task', 'Provide instructions', 'Re-plan', 'Abort execution',
215
+ 'Skip this task',
216
+ 'Provide instructions',
217
+ 'Re-plan',
218
+ 'Abort execution',
185
219
  ]);
186
220
 
187
221
  if (choice === 'Skip this task') {
188
222
  bs.status = 'skipped';
189
223
  bs.updated_at = new Date().toISOString();
190
- 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
+ );
191
234
  updateUI(state, ctx);
192
235
  state.persist(pi);
193
236
  if (state.plan.tasks.some((s) => s.status === 'pending')) {
194
- 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
+ });
195
240
  }
196
241
  } else if (choice === 'Provide instructions') {
197
242
  const instructions = await ctx.ui.editor('Instructions for the blocked task:', '');
@@ -199,7 +244,16 @@ export default function planMode(pi: ExtensionAPI): void {
199
244
  bs.status = 'pending';
200
245
  bs.notes = undefined;
201
246
  bs.updated_at = new Date().toISOString();
202
- 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
+ );
203
257
  updateUI(state, ctx);
204
258
  state.persist(pi);
205
259
  pi.sendUserMessage(
@@ -222,18 +276,28 @@ export default function planMode(pi: ExtensionAPI): void {
222
276
  }
223
277
 
224
278
  // Check completion
225
- 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
+ );
226
282
  if (allResolved) {
227
283
  if (state.planDir) {
228
284
  await upsertPlanEntry(state.plan.planName, { status: 'done', title: state.plan.title });
229
- 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
+ );
230
295
  }
231
296
  const done = state.plan.tasks.filter((s) => s.status === 'done').length;
232
297
  const skipped = state.plan.tasks.filter((s) => s.status === 'skipped').length;
233
298
  const total = state.plan.tasks.length;
234
- const stats = skipped > 0
235
- ? `${done}/${total} done, ${skipped} skipped`
236
- : `${done}/${total} done`;
299
+ const stats =
300
+ skipped > 0 ? `${done}/${total} done, ${skipped} skipped` : `${done}/${total} done`;
237
301
 
238
302
  // Build a summary of what was actually done from task notes
239
303
  const changeSummary = state.plan.tasks
@@ -289,7 +353,14 @@ export default function planMode(pi: ExtensionAPI): void {
289
353
  state.planDir = pending.planDir;
290
354
  {
291
355
  const snapshot = await readTasksJsonl(pending.planDir);
292
- 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;
293
364
  }
294
365
  if (state.plan) {
295
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';
@@ -11,7 +11,8 @@ You are in conversational plan mode — a planning dialogue with strict bash res
11
11
 
12
12
  Restrictions:
13
13
  - Available tools: ${PLAN_TOOLS.join(', ')}
14
- - Bash is restricted to read-only commands (ls, grep, git status, etc.)
14
+ - Bash is restricted to read-only commands (ls, grep, git status, etc.) and info commands (--help, -h, --version, man)
15
+ - The write tool is restricted to .plans/ directory only — no codebase file creation or modification
15
16
  - Do NOT make product code changes during planning.
16
17
 
17
18
  Your job is to reach shared understanding before formalizing a plan:
@@ -49,9 +50,7 @@ export function buildExecutionPrompt(plan: PlanData): string | undefined {
49
50
  .join('\n\n');
50
51
 
51
52
  const currentTask = remaining[0];
52
- const currentDetails = currentTask.details
53
- ? `\nDetails: ${currentTask.details}`
54
- : '';
53
+ const currentDetails = currentTask.details ? `\nDetails: ${currentTask.details}` : '';
55
54
 
56
55
  return `[EXECUTING PLAN — FOLLOW THE PLAN EXACTLY]
57
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
  }
@@ -23,6 +23,16 @@ function isMkdirPlans(command: string): boolean {
23
23
  return /^\s*mkdir\s+(-p\s+)?\.plans(\/|\\|\s|$)/.test(command);
24
24
  }
25
25
 
26
+ /**
27
+ * Check if a file path is inside the .plans/ directory.
28
+ *
29
+ * Accepts both relative (.plans/foo) and absolute paths containing .plans/.
30
+ */
31
+ export function isPlanPath(filePath: string): boolean {
32
+ const normalized = filePath.replace(/\\/g, '/');
33
+ return /(?:^|\/)?\.plans\//.test(normalized);
34
+ }
35
+
26
36
  // ── Plan name utilities ─────────────────────────────────────────────────────
27
37
 
28
38
  export function toKebabCase(name: string): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.14.5",
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"
@@ -40,7 +40,7 @@
40
40
  ]
41
41
  },
42
42
  "dependencies": {
43
- "@dreki-gg/pi-command-sandbox": "^0.2.0",
43
+ "@dreki-gg/pi-command-sandbox": "^0.3.0",
44
44
  "pug": "^3.0.3"
45
45
  },
46
46
  "devDependencies": {