@dreki-gg/pi-plan-mode 0.13.0 → 0.14.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Refactor plan-mode to conversational planning with JSONL task storage and HTML output. Replace steps with task records, add atomic writes, Pug-based plan.html generation, and migrate manifest to JSONL. Update subagent prompts.
8
+
3
9
  ## 0.13.0
4
10
 
5
11
  ### Minor Changes
@@ -0,0 +1,43 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { writeFileAtomic } from '../storage/atomic-write.js';
6
+
7
+ let dir: string;
8
+
9
+ beforeEach(async () => {
10
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-atomic-'));
11
+ });
12
+
13
+ afterEach(async () => {
14
+ await rm(dir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe('writeFileAtomic', () => {
18
+ test('writes complete content to the target path', async () => {
19
+ const target = join(dir, 'data.txt');
20
+
21
+ await writeFileAtomic(target, 'hello world');
22
+
23
+ expect(await readFile(target, 'utf8')).toBe('hello world');
24
+ });
25
+
26
+ test('replaces existing content atomically from the caller perspective', async () => {
27
+ const target = join(dir, 'data.txt');
28
+ await writeFile(target, 'old');
29
+
30
+ await writeFileAtomic(target, 'new');
31
+
32
+ expect(await readFile(target, 'utf8')).toBe('new');
33
+ });
34
+
35
+ test('leaves target untouched when writing fails before rename', async () => {
36
+ const target = join(dir, 'data.txt');
37
+ await writeFile(target, 'original');
38
+
39
+ await expect(writeFileAtomic(target, 'next', { mode: 0o400 })).rejects.toThrow();
40
+
41
+ expect(await readFile(target, 'utf8')).toBe('original');
42
+ });
43
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { renderPlanHtml } from '../html/render.js';
3
+ import type { PlanData } from '../types.js';
4
+
5
+ const plan: PlanData = {
6
+ title: 'Refactor Auth',
7
+ planName: 'refactor-auth',
8
+ handoff: '# Handoff\nShip carefully.',
9
+ tasks: [
10
+ {
11
+ _type: 'task',
12
+ id: 't-001',
13
+ description: 'Add auth middleware',
14
+ details: 'Implement the middleware.',
15
+ status: 'pending',
16
+ created_at: '2026-05-27T12:00:00.000Z',
17
+ updated_at: '2026-05-27T12:00:00.000Z',
18
+ },
19
+ ],
20
+ };
21
+
22
+ describe('renderPlanHtml', () => {
23
+ test('renders title, task count, handoff, and tasks', () => {
24
+ const html = renderPlanHtml(plan);
25
+
26
+ expect(html).toContain('Refactor Auth');
27
+ expect(html).toContain('1 task');
28
+ expect(html).toContain('Ship carefully.');
29
+ expect(html).toContain('t-001');
30
+ expect(html).toContain('Add auth middleware');
31
+ });
32
+
33
+ test('omits prototypes section when no prototype is provided', () => {
34
+ expect(renderPlanHtml(plan)).not.toContain('Prototype');
35
+ });
36
+
37
+ test('renders optional prototype pug markup', () => {
38
+ const html = renderPlanHtml(plan, '.mockup Prototype card');
39
+
40
+ expect(html).toContain('Prototype');
41
+ expect(html).toContain('Prototype card');
42
+ });
43
+ });
@@ -0,0 +1,37 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { chdir } from 'node:process';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { readPlansManifest, upsertPlanEntry, writePlansManifest } from '../storage/plans-manifest.js';
7
+
8
+ const originalCwd = process.cwd();
9
+ let dir: string;
10
+
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 }); });
13
+
14
+ describe('plans.jsonl manifest', () => {
15
+ 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 };
17
+ await writePlansManifest([entry]);
18
+ await expect(readPlansManifest()).resolves.toEqual([entry]);
19
+ });
20
+
21
+ test('upserts new entries', async () => {
22
+ await upsertPlanEntry('new-plan', { status: 'in-progress', title: 'New Plan' });
23
+ const entries = await readPlansManifest();
24
+ expect(entries).toHaveLength(1);
25
+ expect(entries[0]?.name).toBe('new-plan');
26
+ expect(entries[0]?.title).toBe('New Plan');
27
+ });
28
+
29
+ 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 }]);
31
+ await upsertPlanEntry('p', { status: 'done', title: 'New' });
32
+ const [entry] = await readPlansManifest();
33
+ expect(entry.created_at).toBe('created');
34
+ expect(entry.status).toBe('done');
35
+ expect(entry.completed_at).toBeString();
36
+ });
37
+ });
@@ -0,0 +1,44 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { readTasksJsonl, updateTask, writeTasksJsonl } from '../storage/task-storage.js';
6
+ import type { TaskMeta, TaskRecord } from '../types.js';
7
+
8
+ let dir: string;
9
+ const now = '2026-05-27T12:00:00.000Z';
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 };
12
+
13
+ beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'plan-mode-tasks-')); });
14
+ afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
15
+
16
+ describe('tasks.jsonl storage', () => {
17
+ test('round trips meta and tasks', async () => {
18
+ await writeTasksJsonl(dir, meta, [task]);
19
+ await expect(readTasksJsonl(dir)).resolves.toEqual({ meta, tasks: [task] });
20
+ });
21
+
22
+ test('missing file returns undefined', async () => {
23
+ await expect(readTasksJsonl(dir)).resolves.toBeUndefined();
24
+ });
25
+
26
+ test('rejects corrupt lines', async () => {
27
+ await Bun.write(join(dir, 'tasks.jsonl'), `${JSON.stringify(meta)}\nnot-json\n`);
28
+ await expect(readTasksJsonl(dir)).rejects.toThrow(/Invalid JSONL/);
29
+ });
30
+
31
+ test('rejects empty files', async () => {
32
+ await Bun.write(join(dir, 'tasks.jsonl'), '');
33
+ await expect(readTasksJsonl(dir)).rejects.toThrow(/meta/);
34
+ });
35
+
36
+ test('updates a task by id and rewrites the snapshot', async () => {
37
+ await writeTasksJsonl(dir, meta, [task]);
38
+ const updated = await updateTask(dir, 't-001', { status: 'done', notes: 'finished' });
39
+
40
+ expect(updated.status).toBe('done');
41
+ expect(updated.notes).toBe('finished');
42
+ expect((await readTasksJsonl(dir))?.tasks[0]?.status).toBe('done');
43
+ });
44
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { isTaskMeta, isTaskRecord } from '../types.js';
3
+
4
+ const now = '2026-05-27T12:00:00.000Z';
5
+
6
+ describe('task record type guards', () => {
7
+ test('accepts valid task records', () => {
8
+ expect(
9
+ isTaskRecord({
10
+ _type: 'task',
11
+ id: 't-001',
12
+ description: 'Do work',
13
+ details: 'Full instructions',
14
+ status: 'pending',
15
+ depends_on: ['t-000'],
16
+ notes: 'note',
17
+ created_at: now,
18
+ updated_at: now,
19
+ }),
20
+ ).toBe(true);
21
+ });
22
+
23
+ test('rejects malformed task records', () => {
24
+ expect(isTaskRecord({ _type: 'task', id: 't-001', status: 'pending' })).toBe(false);
25
+ expect(
26
+ isTaskRecord({
27
+ _type: 'task',
28
+ id: 't-001',
29
+ description: 'Do work',
30
+ details: 'Full instructions',
31
+ status: 'unknown',
32
+ created_at: now,
33
+ updated_at: now,
34
+ }),
35
+ ).toBe(false);
36
+ });
37
+ });
38
+
39
+ describe('task meta type guard', () => {
40
+ test('accepts valid meta records', () => {
41
+ expect(
42
+ isTaskMeta({
43
+ _type: 'meta',
44
+ title: 'Refactor',
45
+ plan_name: 'refactor',
46
+ created_at: now,
47
+ }),
48
+ ).toBe(true);
49
+ });
50
+
51
+ test('rejects malformed meta records', () => {
52
+ expect(isTaskMeta({ _type: 'meta', title: 'Refactor' })).toBe(false);
53
+ expect(isTaskMeta({ _type: 'task', title: 'Refactor', plan_name: 'refactor', created_at: now })).toBe(false);
54
+ });
55
+ });
@@ -10,12 +10,13 @@ export const PLAN_TOOLS = [
10
10
  'find',
11
11
  'ls',
12
12
  'submit_plan',
13
+ 'write',
13
14
  'questionnaire',
14
15
  'search_skills',
15
16
  'subagent',
16
17
  ];
17
18
 
18
- export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_step'];
19
+ export const EXEC_TOOLS = ['read', 'bash', 'edit', 'write', 'update_task'];
19
20
 
20
21
  // ── Model + thinking presets ─────────────────────────────────────────────────
21
22
  export const PLAN_MODEL = { provider: 'anthropic', id: 'claude-opus-4-6' } as const;
@@ -0,0 +1,67 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import pug from 'pug';
5
+ import type { PlanData } from '../types.js';
6
+
7
+ const templatePath = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'plan.pug');
8
+
9
+ export function renderPlanHtml(plan: PlanData, prototype?: string): string {
10
+ const template = readFileSync(templatePath, 'utf8');
11
+ const prototypeHtml = prototype?.trim() ? pug.render(prototype) : undefined;
12
+
13
+ return pug.render(template, {
14
+ filename: templatePath,
15
+ plan,
16
+ prototypeHtml,
17
+ handoffHtml: markdownToHtml(plan.handoff),
18
+ taskCountLabel: `${plan.tasks.length} ${plan.tasks.length === 1 ? 'task' : 'tasks'}`,
19
+ generatedAt: new Date().toISOString(),
20
+ });
21
+ }
22
+
23
+ function markdownToHtml(markdown: string): string {
24
+ const lines = markdown.split(/\r?\n/);
25
+ const html: string[] = [];
26
+ let inList = false;
27
+
28
+ for (const line of lines) {
29
+ if (line.startsWith('# ')) {
30
+ if (inList) {
31
+ html.push('</ul>');
32
+ inList = false;
33
+ }
34
+ html.push(`<h1>${escapeHtml(line.slice(2))}</h1>`);
35
+ } else if (line.startsWith('## ')) {
36
+ if (inList) {
37
+ html.push('</ul>');
38
+ inList = false;
39
+ }
40
+ html.push(`<h2>${escapeHtml(line.slice(3))}</h2>`);
41
+ } else if (line.startsWith('- ')) {
42
+ if (!inList) {
43
+ html.push('<ul>');
44
+ inList = true;
45
+ }
46
+ html.push(`<li>${escapeHtml(line.slice(2))}</li>`);
47
+ } else if (line.trim()) {
48
+ if (inList) {
49
+ html.push('</ul>');
50
+ inList = false;
51
+ }
52
+ html.push(`<p>${escapeHtml(line)}</p>`);
53
+ }
54
+ }
55
+
56
+ if (inList) html.push('</ul>');
57
+ return html.join('\n');
58
+ }
59
+
60
+ function escapeHtml(value: string): string {
61
+ return value
62
+ .replaceAll('&', '&amp;')
63
+ .replaceAll('<', '&lt;')
64
+ .replaceAll('>', '&gt;')
65
+ .replaceAll('"', '&quot;')
66
+ .replaceAll("'", '&#39;');
67
+ }
@@ -0,0 +1,58 @@
1
+ doctype html
2
+ html(lang="en")
3
+ head
4
+ meta(charset="utf-8")
5
+ meta(name="viewport" content="width=device-width, initial-scale=1")
6
+ title= plan.title
7
+ style.
8
+ :root { color-scheme: dark; --bg:#0b0d12; --panel:#11141b; --muted:#8b93a7; --text:#eef1f7; --line:#242936; --accent:#8b5cf6; --done:#33d69f; --blocked:#ff6b6b; }
9
+ * { box-sizing: border-box; }
10
+ body { margin:0; background:radial-gradient(circle at top left,#1c1730,transparent 34rem),var(--bg); color:var(--text); font:14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
11
+ main { width:min(1040px, calc(100% - 48px)); margin:48px auto; }
12
+ header { display:flex; justify-content:space-between; gap:24px; align-items:flex-start; margin-bottom:28px; }
13
+ h1 { font-size:34px; line-height:1.08; margin:0 0 10px; letter-spacing:-0.04em; }
14
+ h2 { font-size:15px; text-transform:uppercase; color:var(--muted); letter-spacing:0.14em; margin:0 0 14px; }
15
+ .meta { color:var(--muted); display:flex; gap:12px; flex-wrap:wrap; }
16
+ .pill { border:1px solid var(--line); border-radius:999px; padding:6px 10px; background:rgba(255,255,255,0.03); }
17
+ section { background:rgba(17,20,27,0.82); border:1px solid var(--line); border-radius:18px; padding:22px; margin:18px 0; box-shadow:0 24px 80px rgba(0,0,0,0.24); }
18
+ .handoff :first-child { margin-top:0; }
19
+ .handoff :last-child { margin-bottom:0; }
20
+ .tasks { display:grid; gap:12px; }
21
+ .task { border:1px solid var(--line); border-radius:14px; padding:16px; background:#0f1218; }
22
+ .task-top { display:flex; gap:10px; align-items:center; margin-bottom:8px; }
23
+ .id { color:var(--accent); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
24
+ .status { margin-left:auto; color:var(--muted); }
25
+ .task.done .status { color:var(--done); }
26
+ .task.blocked .status { color:var(--blocked); }
27
+ .description { font-weight:650; }
28
+ .details { color:#c7ccda; margin:0; white-space:pre-wrap; }
29
+ .deps { color:var(--muted); margin-top:10px; font-size:12px; }
30
+ body
31
+ main
32
+ header
33
+ div
34
+ h1= plan.title
35
+ .meta
36
+ span.pill= plan.planName
37
+ span.pill= taskCountLabel
38
+ .meta
39
+ span.pill Generated #{generatedAt}
40
+ section.handoff
41
+ h2 Handoff
42
+ != handoffHtml
43
+ section
44
+ h2 Tasks
45
+ .tasks
46
+ each task in plan.tasks
47
+ article.task(class=task.status)
48
+ .task-top
49
+ span.id= task.id
50
+ span.description= task.description
51
+ span.status= task.status
52
+ p.details= task.details
53
+ if task.depends_on && task.depends_on.length
54
+ .deps Depends on #{task.depends_on.join(', ')}
55
+ if prototypeHtml
56
+ section.prototype
57
+ h2 Prototype
58
+ != prototypeHtml
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Two-phase workflow:
5
5
  * 1. PLAN phase — read-only tools + submit_plan tool + medium thinking
6
- * 2. EXECUTE phase — full tools + update_step tool + low thinking
6
+ * 2. EXECUTE phase — full tools + update_task tool + low thinking
7
7
  *
8
8
  * Commands:
9
9
  * /plan [prompt] — enter plan mode
@@ -21,14 +21,16 @@ import { Key } from '@earendil-works/pi-tui';
21
21
  import { PLAN_TOOLS, EXEC_TOOLS, PLAN_MODEL, PLAN_THINKING, EXEC_MODEL, EXEC_THINKING } from './constants.js';
22
22
  import type { ThinkingLevel } from './types.js';
23
23
  import { PlanModeState } from './state.js';
24
- import { savePlanToDisk, loadPlanFromDisk, readAndClearExecPending, updatePlansManifest } from './plan-storage.js';
24
+ import { loadHandoff, readAndClearExecPending } from './storage/plan-storage.js';
25
+ import { readTasksJsonl, writeTasksJsonl } from './storage/task-storage.js';
26
+ import { upsertPlanEntry } from './storage/plans-manifest.js';
25
27
  import { updateUI } from './ui.js';
26
28
  import { buildPlanModePrompt, buildExecutionPrompt } from './prompts.js';
27
29
  import { filterExecutionMessages, filterStalePlanMessages } from './context-filter.js';
28
30
  import { enterPlanMode, exitPlanMode, switchModel } from './phase-transitions.js';
29
31
  import { resumePlan, executeInNewSession } from './resume.js';
30
32
  import { registerSubmitPlanTool } from './tools/submit-plan.js';
31
- import { registerUpdateStepTool } from './tools/update-step.js';
33
+ import { registerUpdateTaskTool } from './tools/update-task.js';
32
34
  import { isSafeCommand } from './utils.js';
33
35
 
34
36
  export default function planMode(pi: ExtensionAPI): void {
@@ -50,12 +52,20 @@ export default function planMode(pi: ExtensionAPI): void {
50
52
  },
51
53
  });
52
54
 
53
- registerUpdateStepTool(pi, {
55
+ registerUpdateTaskTool(pi, {
54
56
  getPlan: () => state.plan,
55
- onStepUpdated: (step, status, notes) => {
56
- if (!state.plan) return;
57
- state.plan.steps[step - 1].status = status;
58
- if (notes) state.plan.steps[step - 1].notes = notes;
57
+ onTaskUpdated: async (taskId, status, notes) => {
58
+ if (!state.plan || !state.planDir) return;
59
+ const task = state.plan.tasks.find((candidate) => candidate.id === taskId);
60
+ if (!task) return;
61
+ task.status = status;
62
+ task.updated_at = new Date().toISOString();
63
+ if (notes) task.notes = notes;
64
+ await writeTasksJsonl(
65
+ 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 },
67
+ state.plan.tasks,
68
+ );
59
69
  state.persist(pi);
60
70
  },
61
71
  });
@@ -85,8 +95,9 @@ export default function planMode(pi: ExtensionAPI): void {
85
95
  ctx.ui.notify('No plan to execute.', 'error');
86
96
  return;
87
97
  }
88
- const stepList = state.plan.steps.map((s, i) => `${i + 1}. ${s.description}`).join('\n');
89
- const kickoff = `Execute the following plan: "${state.plan.title}"\n\nSteps:\n${stepList}\n\nStart with step 1. Call update_step after completing each step.`;
98
+ 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;
100
+ const kickoff = `Execute the following plan: "${state.plan.title}"\n\nTasks:\n${taskList}\n\nStart with ${first}. Call update_task after completing each task.`;
90
101
  await executeInNewSession(ctx, state.planDir, state.plan, kickoff);
91
102
  },
92
103
  });
@@ -94,13 +105,13 @@ export default function planMode(pi: ExtensionAPI): void {
94
105
  pi.registerCommand('todos', {
95
106
  description: 'Show current plan progress',
96
107
  handler: async (_args, ctx) => {
97
- if (!state.plan || state.plan.steps.length === 0) {
108
+ if (!state.plan || state.plan.tasks.length === 0) {
98
109
  ctx.ui.notify('No plan yet. Use /plan to start planning.', 'info');
99
110
  return;
100
111
  }
101
112
  const statusIcon = { pending: '○', done: '✓', skipped: '⊘', blocked: '✗' } as const;
102
- const list = state.plan.steps
103
- .map((s, i) => `${i + 1}. ${statusIcon[s.status]} ${s.description}`)
113
+ const list = state.plan.tasks
114
+ .map((s) => `${s.id}. ${statusIcon[s.status]} ${s.description}`)
104
115
  .join('\n');
105
116
  ctx.ui.notify(`Plan Progress:\n${list}`, 'info');
106
117
  },
@@ -157,42 +168,42 @@ export default function planMode(pi: ExtensionAPI): void {
157
168
  }
158
169
  });
159
170
 
160
- // ── Event: agent_end — blocked steps, completion, post-plan menu ──────────
171
+ // ── Event: agent_end — blocked tasks, completion, post-plan menu ──────────
161
172
  pi.on('agent_end', async (_event, ctx) => {
162
- // ── During execution: handle blocked steps and completion ──
173
+ // ── During execution: handle blocked tasks and completion ──
163
174
  if (state.executing && state.plan) {
164
- const blocked = state.plan.steps
165
- .map((s, i) => ({ ...s, num: i + 1 }))
166
- .filter((s) => s.status === 'blocked');
175
+ const blocked = state.plan.tasks.filter((s) => s.status === 'blocked');
167
176
 
168
177
  if (blocked.length > 0) {
169
178
  const bs = blocked[0];
170
179
  const info = bs.notes
171
- ? `Step ${bs.num}: ${bs.description}\nReason: ${bs.notes}`
172
- : `Step ${bs.num}: ${bs.description}`;
180
+ ? `Task ${bs.id}: ${bs.description}\nReason: ${bs.notes}`
181
+ : `Task ${bs.id}: ${bs.description}`;
173
182
 
174
- const choice = await ctx.ui.select(`Step blocked — ${info}\n\nWhat next?`, [
175
- 'Skip this step', 'Provide instructions', 'Re-plan', 'Abort execution',
183
+ const choice = await ctx.ui.select(`Task blocked — ${info}\n\nWhat next?`, [
184
+ 'Skip this task', 'Provide instructions', 'Re-plan', 'Abort execution',
176
185
  ]);
177
186
 
178
- if (choice === 'Skip this step') {
179
- state.plan.steps[bs.num - 1].status = 'skipped';
180
- await savePlanToDisk(state.planDir!, state.plan);
187
+ if (choice === 'Skip this task') {
188
+ bs.status = 'skipped';
189
+ 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);
181
191
  updateUI(state, ctx);
182
192
  state.persist(pi);
183
- if (state.plan.steps.some((s) => s.status === 'pending')) {
184
- pi.sendUserMessage('The blocked step has been skipped. Continue with the next step.', { deliverAs: 'followUp' });
193
+ 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' });
185
195
  }
186
196
  } else if (choice === 'Provide instructions') {
187
- const instructions = await ctx.ui.editor('Instructions for the blocked step:', '');
197
+ const instructions = await ctx.ui.editor('Instructions for the blocked task:', '');
188
198
  if (instructions?.trim()) {
189
- state.plan.steps[bs.num - 1].status = 'pending';
190
- state.plan.steps[bs.num - 1].notes = undefined;
191
- await savePlanToDisk(state.planDir!, state.plan);
199
+ bs.status = 'pending';
200
+ bs.notes = undefined;
201
+ 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);
192
203
  updateUI(state, ctx);
193
204
  state.persist(pi);
194
205
  pi.sendUserMessage(
195
- `Retry step ${bs.num} with these additional instructions: ${instructions.trim()}`,
206
+ `Retry task ${bs.id} with these additional instructions: ${instructions.trim()}`,
196
207
  { deliverAs: 'followUp' },
197
208
  );
198
209
  }
@@ -200,7 +211,7 @@ export default function planMode(pi: ExtensionAPI): void {
200
211
  } else if (choice === 'Re-plan') {
201
212
  await enterPlanMode(state, pi, ctx);
202
213
  pi.sendUserMessage(
203
- `Step ${bs.num} was blocked: ${bs.notes ?? 'no details'}. Re-analyze and create a revised plan.`,
214
+ `Task ${bs.id} was blocked: ${bs.notes ?? 'no details'}. Re-analyze and create a revised plan.`,
204
215
  { deliverAs: 'followUp' },
205
216
  );
206
217
  return;
@@ -211,24 +222,24 @@ export default function planMode(pi: ExtensionAPI): void {
211
222
  }
212
223
 
213
224
  // Check completion
214
- const allResolved = state.plan.steps.every((s) => s.status === 'done' || s.status === 'skipped');
225
+ const allResolved = state.plan.tasks.every((s) => s.status === 'done' || s.status === 'skipped');
215
226
  if (allResolved) {
216
227
  if (state.planDir) {
217
- await updatePlansManifest(state.planDir.replace(/^\.plans\//, ''), 'done', state.plan.title);
218
- await savePlanToDisk(state.planDir, state.plan);
228
+ 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);
219
230
  }
220
- const done = state.plan.steps.filter((s) => s.status === 'done').length;
221
- const skipped = state.plan.steps.filter((s) => s.status === 'skipped').length;
222
- const total = state.plan.steps.length;
231
+ const done = state.plan.tasks.filter((s) => s.status === 'done').length;
232
+ const skipped = state.plan.tasks.filter((s) => s.status === 'skipped').length;
233
+ const total = state.plan.tasks.length;
223
234
  const stats = skipped > 0
224
235
  ? `${done}/${total} done, ${skipped} skipped`
225
236
  : `${done}/${total} done`;
226
237
 
227
- // Build a summary of what was actually done from step notes
228
- const changeSummary = state.plan.steps
229
- .map((s, i) => {
238
+ // Build a summary of what was actually done from task notes
239
+ const changeSummary = state.plan.tasks
240
+ .map((s) => {
230
241
  const icon = s.status === 'done' ? '✓' : '⊘';
231
- const label = `${i + 1}. ${icon} ${s.description}`;
242
+ const label = `${s.id}. ${icon} ${s.description}`;
232
243
  return s.notes ? `${label}\n ${s.notes}` : label;
233
244
  })
234
245
  .join('\n');
@@ -276,8 +287,8 @@ export default function planMode(pi: ExtensionAPI): void {
276
287
 
277
288
  - Missing edge cases or error handling
278
289
  - Incorrect assumptions about the codebase
279
- - Steps that are too vague or could be misinterpreted
280
- - Missing dependencies between steps
290
+ - Tasks that are too vague or could be misinterpreted
291
+ - Missing dependencies between tasks
281
292
  - Simpler alternatives that were overlooked
282
293
 
283
294
  After your review, call submit_plan again with the improved plan.`,
@@ -302,7 +313,10 @@ After your review, call submit_plan again with the improved plan.`,
302
313
  const pending = await readAndClearExecPending();
303
314
  if (pending) {
304
315
  state.planDir = pending.planDir;
305
- state.plan = await loadPlanFromDisk(pending.planDir);
316
+ {
317
+ const snapshot = await readTasksJsonl(pending.planDir);
318
+ state.plan = snapshot ? { title: snapshot.meta.title, planName: snapshot.meta.plan_name, handoff: (await loadHandoff(pending.planDir)) ?? '', tasks: snapshot.tasks } : undefined;
319
+ }
306
320
  if (state.plan) {
307
321
  state.executing = true;
308
322
  state.planEnabled = false;