@dreki-gg/pi-plan-mode 0.10.1 → 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 +30 -0
- package/extensions/plan-mode/__tests__/atomic-write.test.ts +43 -0
- package/extensions/plan-mode/__tests__/html-render.test.ts +43 -0
- package/extensions/plan-mode/__tests__/package-skills.test.ts +47 -0
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +37 -0
- package/extensions/plan-mode/__tests__/prompts.test.ts +38 -0
- package/extensions/plan-mode/__tests__/task-storage.test.ts +44 -0
- package/extensions/plan-mode/__tests__/types.test.ts +55 -0
- package/extensions/plan-mode/constants.ts +3 -1
- package/extensions/plan-mode/html/render.ts +67 -0
- package/extensions/plan-mode/html/templates/plan.pug +58 -0
- package/extensions/plan-mode/index.ts +79 -42
- package/extensions/plan-mode/plan-storage.ts +1 -67
- package/extensions/plan-mode/prompts.ts +32 -31
- package/extensions/plan-mode/pug.d.ts +5 -0
- package/extensions/plan-mode/resume.ts +38 -37
- package/extensions/plan-mode/storage/atomic-write.ts +53 -0
- package/extensions/plan-mode/storage/plan-storage.ts +47 -0
- package/extensions/plan-mode/storage/plans-manifest.ts +57 -0
- package/extensions/plan-mode/storage/task-storage.ts +44 -0
- package/extensions/plan-mode/tools/submit-plan.ts +40 -88
- package/extensions/plan-mode/tools/update-task.ts +82 -0
- package/extensions/plan-mode/types.ts +57 -5
- package/extensions/plan-mode/ui.ts +10 -10
- package/package.json +7 -2
- package/skills/technical-options/SKILL.md +89 -0
- package/extensions/plan-mode/plans-json.ts +0 -48
- package/extensions/plan-mode/tools/update-step.ts +0 -145
|
@@ -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 +
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
55
|
+
registerUpdateTaskTool(pi, {
|
|
54
56
|
getPlan: () => state.plan,
|
|
55
|
-
|
|
56
|
-
if (!state.plan) return;
|
|
57
|
-
state.plan.
|
|
58
|
-
if (
|
|
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
|
|
89
|
-
const
|
|
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.
|
|
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.
|
|
103
|
-
.map((s
|
|
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
|
|
171
|
+
// ── Event: agent_end — blocked tasks, completion, post-plan menu ──────────
|
|
161
172
|
pi.on('agent_end', async (_event, ctx) => {
|
|
162
|
-
// ── During execution: handle blocked
|
|
173
|
+
// ── During execution: handle blocked tasks and completion ──
|
|
163
174
|
if (state.executing && state.plan) {
|
|
164
|
-
const blocked = state.plan.
|
|
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
|
-
? `
|
|
172
|
-
: `
|
|
180
|
+
? `Task ${bs.id}: ${bs.description}\nReason: ${bs.notes}`
|
|
181
|
+
: `Task ${bs.id}: ${bs.description}`;
|
|
173
182
|
|
|
174
|
-
const choice = await ctx.ui.select(`
|
|
175
|
-
'Skip this
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
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.
|
|
184
|
-
pi.sendUserMessage('The blocked
|
|
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
|
|
197
|
+
const instructions = await ctx.ui.editor('Instructions for the blocked task:', '');
|
|
188
198
|
if (instructions?.trim()) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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
|
-
`
|
|
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,17 +222,40 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
211
222
|
}
|
|
212
223
|
|
|
213
224
|
// Check completion
|
|
214
|
-
const allResolved = state.plan.
|
|
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
|
|
218
|
-
await
|
|
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
|
|
221
|
-
|
|
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;
|
|
234
|
+
const stats = skipped > 0
|
|
235
|
+
? `${done}/${total} done, ${skipped} skipped`
|
|
236
|
+
: `${done}/${total} done`;
|
|
237
|
+
|
|
238
|
+
// Build a summary of what was actually done from task notes
|
|
239
|
+
const changeSummary = state.plan.tasks
|
|
240
|
+
.map((s) => {
|
|
241
|
+
const icon = s.status === 'done' ? '✓' : '⊘';
|
|
242
|
+
const label = `${s.id}. ${icon} ${s.description}`;
|
|
243
|
+
return s.notes ? `${label}\n ${s.notes}` : label;
|
|
244
|
+
})
|
|
222
245
|
.join('\n');
|
|
246
|
+
|
|
247
|
+
const summary = [
|
|
248
|
+
`**Plan Complete!** ✓ — ${state.plan.title}`,
|
|
249
|
+
'',
|
|
250
|
+
`> ${stats}`,
|
|
251
|
+
'',
|
|
252
|
+
'## Summary',
|
|
253
|
+
'',
|
|
254
|
+
changeSummary,
|
|
255
|
+
].join('\n');
|
|
256
|
+
|
|
223
257
|
pi.sendMessage(
|
|
224
|
-
{ customType: 'plan-complete', content:
|
|
258
|
+
{ customType: 'plan-complete', content: summary, display: true },
|
|
225
259
|
{ triggerTurn: false },
|
|
226
260
|
);
|
|
227
261
|
|
|
@@ -253,8 +287,8 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
253
287
|
|
|
254
288
|
- Missing edge cases or error handling
|
|
255
289
|
- Incorrect assumptions about the codebase
|
|
256
|
-
-
|
|
257
|
-
- Missing dependencies between
|
|
290
|
+
- Tasks that are too vague or could be misinterpreted
|
|
291
|
+
- Missing dependencies between tasks
|
|
258
292
|
- Simpler alternatives that were overlooked
|
|
259
293
|
|
|
260
294
|
After your review, call submit_plan again with the improved plan.`,
|
|
@@ -279,7 +313,10 @@ After your review, call submit_plan again with the improved plan.`,
|
|
|
279
313
|
const pending = await readAndClearExecPending();
|
|
280
314
|
if (pending) {
|
|
281
315
|
state.planDir = pending.planDir;
|
|
282
|
-
|
|
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
|
+
}
|
|
283
320
|
if (state.plan) {
|
|
284
321
|
state.executing = true;
|
|
285
322
|
state.planEnabled = false;
|
|
@@ -1,67 +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 updatePlansManifest(
|
|
52
|
-
planName: string,
|
|
53
|
-
status: 'in-progress' | 'done',
|
|
54
|
-
title?: string,
|
|
55
|
-
): Promise<void> {
|
|
56
|
-
const manifest = await readPlansJson();
|
|
57
|
-
const existing = manifest[planName];
|
|
58
|
-
const now = new Date().toISOString();
|
|
59
|
-
manifest[planName] = {
|
|
60
|
-
status,
|
|
61
|
-
title: title ?? existing?.title ?? 'Untitled plan',
|
|
62
|
-
created: existing?.created ?? now,
|
|
63
|
-
completed: status === 'done' ? now : null,
|
|
64
|
-
};
|
|
65
|
-
await mkdir('.plans', { recursive: true });
|
|
66
|
-
await writeFile('.plans/plans.json', serializePlansJson(manifest), 'utf-8');
|
|
67
|
-
}
|
|
1
|
+
export { loadHandoff, readAndClearExecPending, saveHandoff, writeExecPending } from './storage/plan-storage.js';
|
|
@@ -7,61 +7,62 @@ 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
|
|
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
|
|
17
|
-
1.
|
|
18
|
-
2.
|
|
19
|
-
3.
|
|
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
|
|
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
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
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), details, and optional depends_on task IDs
|
|
28
|
+
- prototype: optional Pug markup for a creative prototype section in the generated plan.html
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
submit_plan is finalization, not the starting point. The generated plan.html is written but not opened automatically.
|
|
31
|
+
|
|
32
|
+
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.`;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export function buildExecutionPrompt(plan: PlanData): string | undefined {
|
|
33
|
-
const remaining = plan.
|
|
34
|
-
.map((s, i) => ({ ...s, num: i + 1 }))
|
|
35
|
-
.filter((s) => s.status === 'pending');
|
|
36
|
+
const remaining = plan.tasks.filter((task) => task.status === 'pending');
|
|
36
37
|
|
|
37
38
|
if (remaining.length === 0) return undefined;
|
|
38
39
|
|
|
39
|
-
const
|
|
40
|
-
.map((
|
|
40
|
+
const taskList = remaining
|
|
41
|
+
.map((task) => `${task.id}. ${task.description}\n Details: ${task.details}`)
|
|
41
42
|
.join('\n\n');
|
|
42
43
|
|
|
43
|
-
const
|
|
44
|
+
const currentTask = remaining[0];
|
|
44
45
|
|
|
45
46
|
return `[EXECUTING PLAN — FOLLOW THE PLAN EXACTLY]
|
|
46
47
|
|
|
47
|
-
You are executing a structured plan. Your ONLY job is to implement the plan
|
|
48
|
+
You are executing a structured plan. Your ONLY job is to implement the plan tasks below, one at a time.
|
|
48
49
|
|
|
49
50
|
Rules:
|
|
50
|
-
- Work on ONE
|
|
51
|
-
- After completing each
|
|
52
|
-
- Do NOT run diagnostics, linters, test suites, or skills unless a
|
|
53
|
-
- Do NOT explore the codebase beyond what the current
|
|
54
|
-
- Do NOT deviate from the plan — if something seems wrong, call
|
|
51
|
+
- Work on ONE task at a time, starting with ${currentTask.id}
|
|
52
|
+
- After completing each task, IMMEDIATELY call update_task to mark it done with notes summarizing what you changed (files modified, key decisions)
|
|
53
|
+
- Do NOT run diagnostics, linters, test suites, or skills unless a task explicitly asks for it
|
|
54
|
+
- Do NOT explore the codebase beyond what the current task requires
|
|
55
|
+
- Do NOT deviate from the plan — if something seems wrong, call update_task with status "blocked"
|
|
55
56
|
|
|
56
|
-
## Current
|
|
57
|
-
|
|
58
|
-
Details: ${
|
|
57
|
+
## Current task
|
|
58
|
+
${currentTask.id}: ${currentTask.description}
|
|
59
|
+
Details: ${currentTask.details}
|
|
59
60
|
|
|
60
|
-
##
|
|
61
|
-
${plan.
|
|
61
|
+
## Handoff
|
|
62
|
+
${plan.handoff}
|
|
62
63
|
|
|
63
|
-
## All remaining
|
|
64
|
-
${
|
|
64
|
+
## All remaining tasks
|
|
65
|
+
${taskList}
|
|
65
66
|
|
|
66
|
-
Start with
|
|
67
|
+
Start with ${currentTask.id} NOW. When done, call update_task(task_id="${currentTask.id}", status="done", notes="<brief summary of what you did>").`;
|
|
67
68
|
}
|
|
@@ -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 {
|
|
10
|
-
import {
|
|
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
|
-
|
|
44
|
-
|
|
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.
|
|
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((
|
|
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
|
|
58
|
+
const snapshot = await readTasksJsonl(dir);
|
|
64
59
|
|
|
65
|
-
if (!
|
|
66
|
-
ctx.ui.notify(`Could not load ${dir}/
|
|
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 =
|
|
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.
|
|
74
|
-
const pendingCount = state.plan.
|
|
75
|
-
const blockedCount = state.plan.
|
|
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.
|
|
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.
|
|
85
|
-
const action = await ctx.ui.select(
|
|
86
|
-
|
|
87
|
-
|
|
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}/
|
|
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
|
|
109
|
-
if (
|
|
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
|
|
114
|
+
await writeTasksJsonl(dir, snapshot.meta, state.plan.tasks);
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
const remaining = state.plan.
|
|
115
|
-
|
|
116
|
-
|
|
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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan disk I/O — exec-pending markers and handoff documents.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
|
6
|
+
import type { ExecPendingConfig } from '../types.js';
|
|
7
|
+
import { EXEC_PENDING_FILE } from '../constants.js';
|
|
8
|
+
|
|
9
|
+
export async function writeExecPending(dir: string, config: ExecPendingConfig): Promise<void> {
|
|
10
|
+
await mkdir(dir, { recursive: true });
|
|
11
|
+
await writeFile(`${dir}/${EXEC_PENDING_FILE}`, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readAndClearExecPending(): Promise<{ planDir: string; config: ExecPendingConfig } | undefined> {
|
|
15
|
+
try {
|
|
16
|
+
const entries = await readdir('.plans', { withFileTypes: true });
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
if (!entry.isDirectory()) continue;
|
|
19
|
+
const dir = `.plans/${entry.name}`;
|
|
20
|
+
const markerPath = `${dir}/${EXEC_PENDING_FILE}`;
|
|
21
|
+
try {
|
|
22
|
+
const text = await readFile(markerPath, 'utf-8');
|
|
23
|
+
const config = JSON.parse(text) as ExecPendingConfig;
|
|
24
|
+
await unlink(markerPath);
|
|
25
|
+
return { planDir: dir, config };
|
|
26
|
+
} catch {
|
|
27
|
+
// No marker in this directory
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// .plans/ doesn't exist
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function saveHandoff(planDir: string, content: string): Promise<void> {
|
|
37
|
+
await mkdir(planDir, { recursive: true });
|
|
38
|
+
await writeFile(`${planDir}/HANDOFF.md`, content, 'utf-8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function loadHandoff(planDir: string): Promise<string | undefined> {
|
|
42
|
+
try {
|
|
43
|
+
return await readFile(`${planDir}/HANDOFF.md`, 'utf-8');
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|