@dreki-gg/pi-plan-mode 0.13.0 → 0.14.2

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.
Files changed (29) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/extensions/plan-mode/__tests__/atomic-write.test.ts +43 -0
  3. package/extensions/plan-mode/__tests__/html-render.test.ts +43 -0
  4. package/extensions/plan-mode/__tests__/phase-transitions.test.ts +42 -0
  5. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +37 -0
  6. package/extensions/plan-mode/__tests__/prompts.test.ts +39 -1
  7. package/extensions/plan-mode/__tests__/task-storage.test.ts +52 -0
  8. package/extensions/plan-mode/__tests__/types.test.ts +68 -0
  9. package/extensions/plan-mode/constants.ts +2 -1
  10. package/extensions/plan-mode/html/render.ts +67 -0
  11. package/extensions/plan-mode/html/templates/plan.pug +59 -0
  12. package/extensions/plan-mode/index.ts +60 -72
  13. package/extensions/plan-mode/phase-transitions.ts +1 -1
  14. package/extensions/plan-mode/plan-storage.ts +1 -80
  15. package/extensions/plan-mode/prompts.ts +37 -32
  16. package/extensions/plan-mode/pug.d.ts +5 -0
  17. package/extensions/plan-mode/resume.ts +38 -37
  18. package/extensions/plan-mode/state.ts +7 -0
  19. package/extensions/plan-mode/storage/atomic-write.ts +53 -0
  20. package/extensions/plan-mode/storage/plan-storage.ts +47 -0
  21. package/extensions/plan-mode/storage/plans-manifest.ts +57 -0
  22. package/extensions/plan-mode/storage/task-storage.ts +44 -0
  23. package/extensions/plan-mode/tools/submit-plan.ts +41 -81
  24. package/extensions/plan-mode/tools/update-task.ts +82 -0
  25. package/extensions/plan-mode/types.ts +57 -4
  26. package/extensions/plan-mode/ui.ts +18 -11
  27. package/package.json +3 -2
  28. package/extensions/plan-mode/plans-json.ts +0 -48
  29. package/extensions/plan-mode/tools/update-step.ts +0 -146
@@ -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');
@@ -260,34 +271,8 @@ export default function planMode(pi: ExtensionAPI): void {
260
271
  return;
261
272
  }
262
273
 
263
- // ── After plan submission: show post-plan menu ──
264
- if (!state.planEnabled || !ctx.hasUI) return;
265
- if (!state.planDir || !state.plan) return;
266
-
267
- const choice = await ctx.ui.select('Plan ready — what next?', [
268
- 'Execute Plan', 'Refine Plan', 'Follow up', 'Exit plan mode',
269
- ]);
270
-
271
- if (choice === 'Execute Plan') {
272
- pi.sendUserMessage('/plan-exec', { deliverAs: 'followUp' });
273
- } else if (choice === 'Refine Plan') {
274
- pi.sendUserMessage(
275
- `Review the plan you just created with an adversarial lens. Challenge assumptions, find gaps, identify risks, and look for:
276
-
277
- - Missing edge cases or error handling
278
- - Incorrect assumptions about the codebase
279
- - Steps that are too vague or could be misinterpreted
280
- - Missing dependencies between steps
281
- - Simpler alternatives that were overlooked
282
-
283
- After your review, call submit_plan again with the improved plan.`,
284
- { deliverAs: 'followUp' },
285
- );
286
- } else if (choice === 'Follow up') {
287
- // No-op: dismiss menu, let user type naturally
288
- } else if (choice === 'Exit plan mode') {
289
- await exitPlanMode(state, pi, ctx);
290
- }
274
+ // Plan submitted user can review plan.html then /plan-exec or type naturally.
275
+ // No menu needed: plan.jsonl + plan.html are the source of truth.
291
276
  });
292
277
 
293
278
  // ── Event: session restore ────────────────────────────────────────────────
@@ -302,7 +287,10 @@ After your review, call submit_plan again with the improved plan.`,
302
287
  const pending = await readAndClearExecPending();
303
288
  if (pending) {
304
289
  state.planDir = pending.planDir;
305
- state.plan = await loadPlanFromDisk(pending.planDir);
290
+ {
291
+ 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;
293
+ }
306
294
  if (state.plan) {
307
295
  state.executing = true;
308
296
  state.planEnabled = false;
@@ -54,7 +54,7 @@ export async function exitPlanMode(
54
54
  ctx: ExtensionContext,
55
55
  ): Promise<void> {
56
56
  const { previousModel, previousThinking } = state;
57
- state.reset();
57
+ state.exitPreservingPlan();
58
58
  pi.setActiveTools(EXEC_TOOLS);
59
59
  if (previousModel) {
60
60
  await switchModel(pi, ctx, previousModel);
@@ -1,80 +1 @@
1
- /**
2
- * Plan disk I/O — save/load plans, exec-pending markers, and manifest updates.
3
- */
4
-
5
- import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
6
- import { readPlansJson, serializePlansJson } from './plans-json.js';
7
- import type { PlanData, ExecPendingConfig } from './types.js';
8
- import { EXEC_PENDING_FILE } from './constants.js';
9
-
10
- export async function savePlanToDisk(planDir: string, plan: PlanData): Promise<void> {
11
- await mkdir(planDir, { recursive: true });
12
- await writeFile(`${planDir}/plan.json`, JSON.stringify(plan, null, 2) + '\n', 'utf-8');
13
- }
14
-
15
- export async function loadPlanFromDisk(dir: string): Promise<PlanData | undefined> {
16
- try {
17
- const text = await readFile(`${dir}/plan.json`, 'utf-8');
18
- return JSON.parse(text) as PlanData;
19
- } catch {
20
- return undefined;
21
- }
22
- }
23
-
24
- export async function writeExecPending(dir: string, config: ExecPendingConfig): Promise<void> {
25
- await mkdir(dir, { recursive: true });
26
- await writeFile(`${dir}/${EXEC_PENDING_FILE}`, JSON.stringify(config, null, 2) + '\n', 'utf-8');
27
- }
28
-
29
- export async function readAndClearExecPending(): Promise<{ planDir: string; config: ExecPendingConfig } | undefined> {
30
- try {
31
- const entries = await readdir('.plans', { withFileTypes: true });
32
- for (const entry of entries) {
33
- if (!entry.isDirectory()) continue;
34
- const dir = `.plans/${entry.name}`;
35
- const markerPath = `${dir}/${EXEC_PENDING_FILE}`;
36
- try {
37
- const text = await readFile(markerPath, 'utf-8');
38
- const config = JSON.parse(text) as ExecPendingConfig;
39
- await unlink(markerPath);
40
- return { planDir: dir, config };
41
- } catch {
42
- // No marker in this directory
43
- }
44
- }
45
- } catch {
46
- // .plans/ doesn't exist
47
- }
48
- return undefined;
49
- }
50
-
51
- export async function saveHandoff(planDir: string, content: string): Promise<void> {
52
- await mkdir(planDir, { recursive: true });
53
- await writeFile(`${planDir}/HANDOFF.md`, content, 'utf-8');
54
- }
55
-
56
- export async function loadHandoff(planDir: string): Promise<string | undefined> {
57
- try {
58
- return await readFile(`${planDir}/HANDOFF.md`, 'utf-8');
59
- } catch {
60
- return undefined;
61
- }
62
- }
63
-
64
- export async function updatePlansManifest(
65
- planName: string,
66
- status: 'in-progress' | 'done',
67
- title?: string,
68
- ): Promise<void> {
69
- const manifest = await readPlansJson();
70
- const existing = manifest[planName];
71
- const now = new Date().toISOString();
72
- manifest[planName] = {
73
- status,
74
- title: title ?? existing?.title ?? 'Untitled plan',
75
- created: existing?.created ?? now,
76
- completed: status === 'done' ? now : null,
77
- };
78
- await mkdir('.plans', { recursive: true });
79
- await writeFile('.plans/plans.json', serializePlansJson(manifest), 'utf-8');
80
- }
1
+ export { loadHandoff, readAndClearExecPending, saveHandoff, writeExecPending } from './storage/plan-storage.js';
@@ -7,66 +7,71 @@ import { PLAN_TOOLS } from './constants.js';
7
7
 
8
8
  export function buildPlanModePrompt(): string {
9
9
  return `[PLAN MODE ACTIVE]
10
- You are in plan mode — a planning phase with strict bash restrictions.
10
+ You are in conversational plan mode — a planning dialogue with strict bash restrictions.
11
11
 
12
12
  Restrictions:
13
13
  - Available tools: ${PLAN_TOOLS.join(', ')}
14
14
  - Bash is restricted to read-only commands (ls, grep, git status, etc.)
15
+ - Do NOT make product code changes during planning.
15
16
 
16
- Your task:
17
- 1. Analyze the codebase thoroughly using the available read-only tools
18
- 2. Ask clarifying questions if needed (use the questionnaire tool)
19
- 3. Produce a detailed, concrete plan
17
+ Your job is to reach shared understanding before formalizing a plan:
18
+ 1. Understand the user's intent through dialogue. Push back on weak assumptions, name trade-offs, and ask clarifying questions when needed.
19
+ 2. Investigate the codebase with read-only tools. Use questionnaire when explicit choices are needed.
20
+ 3. Maintain .plans/<plan-name>/context.md with the write tool as the living planning context in caveman-lite style: professional, tight, no filler. Capture intent, decisions, constraints, open questions, and discarded options.
21
+ 4. Only call submit_plan after the user and agent have converged on the approach.
20
22
 
21
- When you are ready to finalize the plan, call the submit_plan tool with:
23
+ When you are ready to finalize the plan, call submit_plan with:
22
24
  - name: a short kebab-case name (e.g. "add-auth-middleware")
23
25
  - title: a human-readable plan title
24
- - handoff: a markdown document that explains:
25
- 1. **What** change is being made and **why** it matters
26
- 2. The approach and key design decisions
27
- 3. Relevant codebase context: file paths, APIs, patterns, constraints, and gotchas
28
- This document is saved as HANDOFF.md and must be clear to both human reviewers and executor agents.
29
- - steps: an array of steps, each with a short description (≤60 chars for display) and detailed implementation instructions
26
+ - handoff: a markdown document that explains what is changing, why it matters, approach, decisions, file paths, APIs, patterns, constraints, and gotchas
27
+ - tasks: an array of tasks with id (e.g. "t-001"), description (≤60 chars), optional details, and optional depends_on task IDs
28
+ - prototype: optional Pug markup for a creative prototype section in the generated plan.html
30
29
 
31
- Do NOT attempt to make product code changes — only analyze and plan.
32
- Do NOT write files manually use submit_plan to finalize the plan.
30
+ Plan weight:
31
+ - **Delegation plans** (different agent/human executes): include full details in each task so an executor with zero context can follow them.
32
+ - **Self-execution plans** (you plan and execute in the same session): use lightweight checklist-style tasks — just id + description, skip details. The handoff doc carries the real context.
33
+
34
+ submit_plan is finalization, not the starting point. The generated plan.html is written but not opened automatically.
33
35
 
34
36
  When facing a significant technical decision with multiple viable approaches (architecture, API design, implementation strategy), use the technical-options skill: you generate the competing proposals yourself, then use the subagent tool to fan out voting agents for evaluation. Do not delegate the entire workflow to a subagent — you are the planner, you drive the process.`;
35
37
  }
36
38
 
37
39
  export function buildExecutionPrompt(plan: PlanData): string | undefined {
38
- const remaining = plan.steps
39
- .map((s, i) => ({ ...s, num: i + 1 }))
40
- .filter((s) => s.status === 'pending');
40
+ const remaining = plan.tasks.filter((task) => task.status === 'pending');
41
41
 
42
42
  if (remaining.length === 0) return undefined;
43
43
 
44
- const stepList = remaining
45
- .map((s) => `${s.num}. ${s.description}\n Details: ${s.details}`)
44
+ const taskList = remaining
45
+ .map((task) => {
46
+ const line = `${task.id}. ${task.description}`;
47
+ return task.details ? `${line}\n Details: ${task.details}` : line;
48
+ })
46
49
  .join('\n\n');
47
50
 
48
- const currentStep = remaining[0];
51
+ const currentTask = remaining[0];
52
+ const currentDetails = currentTask.details
53
+ ? `\nDetails: ${currentTask.details}`
54
+ : '';
49
55
 
50
56
  return `[EXECUTING PLAN — FOLLOW THE PLAN EXACTLY]
51
57
 
52
- You are executing a structured plan. Your ONLY job is to implement the plan steps below, one at a time.
58
+ You are executing a structured plan. Your ONLY job is to implement the plan tasks below, one at a time.
53
59
 
54
60
  Rules:
55
- - Work on ONE step at a time, starting with step ${currentStep.num}
56
- - After completing each step, IMMEDIATELY call update_step to mark it done with notes summarizing what you changed (files modified, key decisions)
57
- - Do NOT run diagnostics, linters, test suites, or skills unless a step explicitly asks for it
58
- - Do NOT explore the codebase beyond what the current step requires
59
- - Do NOT deviate from the plan — if something seems wrong, call update_step with status "blocked"
61
+ - Work on ONE task at a time, starting with ${currentTask.id}
62
+ - After completing each task, IMMEDIATELY call update_task to mark it done with notes summarizing what you changed (files modified, key decisions)
63
+ - Do NOT run diagnostics, linters, test suites, or skills unless a task explicitly asks for it
64
+ - Do NOT explore the codebase beyond what the current task requires
65
+ - Do NOT deviate from the plan — if something seems wrong, call update_task with status "blocked"
60
66
 
61
- ## Current step
62
- Step ${currentStep.num}: ${currentStep.description}
63
- Details: ${currentStep.details}
67
+ ## Current task
68
+ ${currentTask.id}: ${currentTask.description}${currentDetails}
64
69
 
65
70
  ## Handoff
66
71
  ${plan.handoff}
67
72
 
68
- ## All remaining steps
69
- ${stepList}
73
+ ## All remaining tasks
74
+ ${taskList}
70
75
 
71
- Start with step ${currentStep.num} NOW. When done, call update_step(step=${currentStep.num}, status="done", notes="<brief summary of what you did>").`;
76
+ Start with ${currentTask.id} NOW. When done, call update_task(task_id="${currentTask.id}", status="done", notes="<brief summary of what you did>").`;
72
77
  }
@@ -0,0 +1,5 @@
1
+ declare module 'pug' {
2
+ export function render(template: string, options?: Record<string, unknown>): string;
3
+ const pug: { render: typeof render };
4
+ export default pug;
5
+ }
@@ -6,13 +6,12 @@ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@e
6
6
  import type { PlanModeState } from './state.js';
7
7
  import type { PlanData } from './types.js';
8
8
  import { EXEC_THINKING, EXEC_MODEL_OPTIONS } from './constants.js';
9
- import { readPlansJson } from './plans-json.js';
10
- import { loadPlanFromDisk, writeExecPending, savePlanToDisk } from './plan-storage.js';
9
+ import { readPlansManifest } from './storage/plans-manifest.js';
10
+ import { loadHandoff, writeExecPending } from './storage/plan-storage.js';
11
+ import { readTasksJsonl, writeTasksJsonl } from './storage/task-storage.js';
11
12
  import { enterPlanMode } from './phase-transitions.js';
12
13
 
13
- export async function pickExecutionModel(
14
- ctx: ExtensionContext,
15
- ): Promise<{ provider: string; id: string } | undefined> {
14
+ export async function pickExecutionModel(ctx: ExtensionContext): Promise<{ provider: string; id: string } | undefined> {
16
15
  const labels = EXEC_MODEL_OPTIONS.map((o) => o.label);
17
16
  const choice = await ctx.ui.select('Execute with:', labels);
18
17
  if (!choice) return undefined;
@@ -39,20 +38,16 @@ export async function executeInNewSession(
39
38
  });
40
39
  }
41
40
 
42
- export async function resumePlan(
43
- state: PlanModeState,
44
- pi: ExtensionAPI,
45
- ctx: ExtensionCommandContext,
46
- ): Promise<void> {
47
- const manifest = await readPlansJson();
48
- const inProgress = Object.entries(manifest).filter(([, e]) => e.status === 'in-progress');
41
+ export async function resumePlan(state: PlanModeState, pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
42
+ const manifest = await readPlansManifest();
43
+ const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
49
44
 
50
45
  if (inProgress.length === 0) {
51
- ctx.ui.notify('No in-progress plans found in .plans/plans.json', 'info');
46
+ ctx.ui.notify('No in-progress plans found in .plans/plans.jsonl', 'info');
52
47
  return;
53
48
  }
54
49
 
55
- const options = inProgress.map(([name, entry]) => `${name} — ${entry.title}`);
50
+ const options = inProgress.map((entry) => `${entry.name} — ${entry.title}`);
56
51
  options.push('Cancel');
57
52
 
58
53
  const choice = await ctx.ui.select('Resume which plan?', options);
@@ -60,32 +55,38 @@ export async function resumePlan(
60
55
 
61
56
  const planName = choice.split(' — ')[0];
62
57
  const dir = `.plans/${planName}`;
63
- const loaded = await loadPlanFromDisk(dir);
58
+ const snapshot = await readTasksJsonl(dir);
64
59
 
65
- if (!loaded) {
66
- ctx.ui.notify(`Could not load ${dir}/plan.json`, 'error');
60
+ if (!snapshot) {
61
+ ctx.ui.notify(`Could not load ${dir}/tasks.jsonl`, 'error');
67
62
  return;
68
63
  }
69
64
 
70
65
  state.planDir = dir;
71
- state.plan = loaded;
66
+ state.plan = {
67
+ title: snapshot.meta.title,
68
+ planName: snapshot.meta.plan_name,
69
+ handoff: (await loadHandoff(dir)) ?? '',
70
+ tasks: snapshot.tasks,
71
+ };
72
72
 
73
- const doneCount = state.plan.steps.filter((s) => s.status === 'done' || s.status === 'skipped').length;
74
- const pendingCount = state.plan.steps.filter((s) => s.status === 'pending').length;
75
- const blockedCount = state.plan.steps.filter((s) => s.status === 'blocked').length;
73
+ const doneCount = state.plan.tasks.filter((task) => task.status === 'done' || task.status === 'skipped').length;
74
+ const pendingCount = state.plan.tasks.filter((task) => task.status === 'pending').length;
75
+ const blockedCount = state.plan.tasks.filter((task) => task.status === 'blocked').length;
76
76
 
77
77
  if (pendingCount === 0 && blockedCount === 0) {
78
- ctx.ui.notify(`Plan "${state.plan.title}" is already complete (${doneCount}/${state.plan.steps.length} done).`, 'info');
78
+ ctx.ui.notify(`Plan "${state.plan.title}" is already complete (${doneCount}/${state.plan.tasks.length} done).`, 'info');
79
79
  state.plan = undefined;
80
80
  state.planDir = undefined;
81
81
  return;
82
82
  }
83
83
 
84
- const summary = `${doneCount}/${state.plan.steps.length} done, ${pendingCount} pending${blockedCount ? `, ${blockedCount} blocked` : ''}`;
85
- const action = await ctx.ui.select(
86
- `Resume "${state.plan.title}" (${summary}) — what next?`,
87
- ['Continue execution', 'Re-plan from scratch', 'Cancel'],
88
- );
84
+ const summary = `${doneCount}/${state.plan.tasks.length} done, ${pendingCount} pending${blockedCount ? `, ${blockedCount} blocked` : ''}`;
85
+ const action = await ctx.ui.select(`Resume "${state.plan.title}" (${summary}) — what next?`, [
86
+ 'Continue execution',
87
+ 'Re-plan from scratch',
88
+ 'Cancel',
89
+ ]);
89
90
 
90
91
  if (!action || action === 'Cancel') {
91
92
  state.plan = undefined;
@@ -98,24 +99,24 @@ export async function resumePlan(
98
99
  const planDirPath = state.planDir;
99
100
  await enterPlanMode(state, pi, ctx);
100
101
  pi.sendUserMessage(
101
- `There is an existing plan "${planTitle}" at ${planDirPath}/plan.json. Review it and create a revised plan using submit_plan. Keep the same plan name ("${planName}").`,
102
+ `There is an existing plan "${planTitle}" at ${planDirPath}/tasks.jsonl. Review it and create a revised plan using submit_plan. Keep the same plan name ("${planName}").`,
102
103
  );
103
104
  return;
104
105
  }
105
106
 
106
- // Unblock any blocked steps
107
107
  if (blockedCount > 0) {
108
- for (const step of state.plan.steps) {
109
- if (step.status === 'blocked') step.status = 'pending';
108
+ for (const task of state.plan.tasks) {
109
+ if (task.status === 'blocked') {
110
+ task.status = 'pending';
111
+ task.updated_at = new Date().toISOString();
112
+ }
110
113
  }
111
- await savePlanToDisk(dir, state.plan);
114
+ await writeTasksJsonl(dir, snapshot.meta, state.plan.tasks);
112
115
  }
113
116
 
114
- const remaining = state.plan.steps
115
- .map((s, i) => ({ ...s, num: i + 1 }))
116
- .filter((s) => s.status === 'pending');
117
- const stepList = remaining.map((s) => `${s.num}. ${s.description}`).join('\n');
118
- const kickoff = `Resuming plan: "${state.plan.title}"\n\nCompleted: ${doneCount}/${state.plan.steps.length} steps\n\nRemaining steps:\n${stepList}\n\nContinue from step ${remaining[0].num}. Call update_step after completing each step.`;
117
+ const remaining = state.plan.tasks.filter((task) => task.status === 'pending');
118
+ const taskList = remaining.map((task) => `${task.id}. ${task.description}`).join('\n');
119
+ const kickoff = `Resuming plan: "${state.plan.title}"\n\nCompleted: ${doneCount}/${state.plan.tasks.length} tasks\n\nRemaining tasks:\n${taskList}\n\nContinue from ${remaining[0]?.id}. Call update_task after completing each task.`;
119
120
 
120
121
  await executeInNewSession(ctx, dir, state.plan, kickoff);
121
122
  }
@@ -44,4 +44,11 @@ export class PlanModeState {
44
44
  this.plan = undefined;
45
45
  this.executionStartIdx = undefined;
46
46
  }
47
+
48
+ /** Exit plan/execution mode but keep plan data for update_task tracking. */
49
+ exitPreservingPlan(): void {
50
+ this.planEnabled = false;
51
+ this.executing = false;
52
+ this.executionStartIdx = undefined;
53
+ }
47
54
  }
@@ -0,0 +1,53 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { open, rename, rm } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ export interface AtomicWriteOptions {
7
+ /** Test seam: file mode for the temporary file. */
8
+ mode?: number;
9
+ }
10
+
11
+ export async function writeFileAtomic(path: string, data: string | Buffer, options: AtomicWriteOptions = {}): Promise<void> {
12
+ const dir = dirname(path);
13
+ const tempPath = join(dir, `.${process.pid}.${randomUUID()}.tmp`);
14
+ let completed = false;
15
+
16
+ try {
17
+ await writeAndSync(tempPath, data, options.mode);
18
+ await rename(tempPath, path);
19
+ completed = true;
20
+ await syncDirectory(dir);
21
+ } finally {
22
+ if (!completed) {
23
+ await rm(tempPath, { force: true }).catch(() => undefined);
24
+ }
25
+ }
26
+ }
27
+
28
+ async function writeAndSync(path: string, data: string | Buffer, mode?: number): Promise<void> {
29
+ await new Promise<void>((resolve, reject) => {
30
+ const stream = createWriteStream(path, { flags: 'wx', mode });
31
+ stream.once('error', reject);
32
+ stream.once('finish', resolve);
33
+ stream.end(data);
34
+ });
35
+
36
+ const handle = await open(path, 'r+');
37
+ try {
38
+ await handle.sync();
39
+ } finally {
40
+ await handle.close();
41
+ }
42
+ }
43
+
44
+ async function syncDirectory(dir: string): Promise<void> {
45
+ // Directory fsync is best-effort: supported on Unix, not always elsewhere.
46
+ const handle = await open(dir, 'r').catch(() => undefined);
47
+ if (!handle) return;
48
+ try {
49
+ await handle.sync().catch(() => undefined);
50
+ } finally {
51
+ await handle.close().catch(() => undefined);
52
+ }
53
+ }