@dreki-gg/pi-plan-mode 0.15.1 → 0.17.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 +19 -0
- package/extensions/plan-mode/__tests__/add-task.test.ts +75 -0
- package/extensions/plan-mode/__tests__/atomic-write.test.ts +6 -4
- package/extensions/plan-mode/__tests__/html-render.test.ts +26 -35
- package/extensions/plan-mode/__tests__/package-skills.test.ts +51 -0
- package/extensions/plan-mode/__tests__/plan-storage.test.ts +57 -0
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +27 -16
- package/extensions/plan-mode/__tests__/schema.test.ts +212 -0
- package/extensions/plan-mode/__tests__/task-status.test.ts +74 -0
- package/extensions/plan-mode/__tests__/task-storage.test.ts +35 -12
- package/extensions/plan-mode/__tests__/utils.test.ts +19 -1
- package/extensions/plan-mode/constants.ts +2 -1
- package/extensions/plan-mode/effects/filesystem.ts +65 -0
- package/extensions/plan-mode/effects/runtime.ts +24 -0
- package/extensions/plan-mode/errors.ts +105 -0
- package/extensions/plan-mode/html/render.ts +10 -114
- package/extensions/plan-mode/html/templates/prototype.pug +25 -0
- package/extensions/plan-mode/index.ts +151 -48
- package/extensions/plan-mode/prompts.ts +5 -3
- package/extensions/plan-mode/resume.ts +19 -15
- package/extensions/plan-mode/schema.ts +57 -0
- package/extensions/plan-mode/storage/atomic-write.ts +19 -1
- package/extensions/plan-mode/storage/plan-storage.ts +57 -29
- package/extensions/plan-mode/storage/plans-manifest.ts +64 -57
- package/extensions/plan-mode/storage/task-storage.ts +83 -45
- package/extensions/plan-mode/task-status.ts +46 -0
- package/extensions/plan-mode/tools/add-task.ts +95 -0
- package/extensions/plan-mode/tools/preview-prototype.ts +98 -0
- package/extensions/plan-mode/tools/submit-plan.ts +18 -16
- package/extensions/plan-mode/types.ts +8 -38
- package/extensions/plan-mode/utils.ts +20 -0
- package/package.json +2 -1
- package/skills/planning-context/SKILL.md +42 -0
- package/skills/visual-prototype/SKILL.md +45 -0
- package/extensions/plan-mode/__tests__/types.test.ts +0 -70
- package/extensions/plan-mode/html/templates/plan.pug +0 -69
|
@@ -28,20 +28,26 @@ import {
|
|
|
28
28
|
} from './constants.js';
|
|
29
29
|
import type { ThinkingLevel } from './types.js';
|
|
30
30
|
import { PlanModeState } from './state.js';
|
|
31
|
+
import { makePlanRuntime } from './effects/runtime.js';
|
|
31
32
|
import { loadHandoff, readAndClearExecPending } from './storage/plan-storage.js';
|
|
32
33
|
import { readTasksJsonl, writeTasksJsonl } from './storage/task-storage.js';
|
|
33
34
|
import { upsertPlanEntry } from './storage/plans-manifest.js';
|
|
34
35
|
import { updateUI } from './ui.js';
|
|
35
36
|
import { buildPlanModePrompt, buildExecutionPrompt } from './prompts.js';
|
|
36
37
|
import { filterExecutionMessages, filterStalePlanMessages } from './context-filter.js';
|
|
38
|
+
import { activeTasksResolved, deferredTasks } from './task-status.js';
|
|
37
39
|
import { enterPlanMode, exitPlanMode, switchModel } from './phase-transitions.js';
|
|
38
40
|
import { resumePlan, executeInNewSession } from './resume.js';
|
|
39
41
|
import { registerSubmitPlanTool } from './tools/submit-plan.js';
|
|
42
|
+
import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
|
|
40
43
|
import { registerUpdateTaskTool } from './tools/update-task.js';
|
|
44
|
+
import { registerAddTaskTool } from './tools/add-task.js';
|
|
41
45
|
import { isSafeCommand, isPlanPath } from './utils.js';
|
|
42
46
|
|
|
43
47
|
export default function planMode(pi: ExtensionAPI): void {
|
|
44
48
|
const state = new PlanModeState();
|
|
49
|
+
// Build the live Effect runtime once; all storage I/O runs through this bridge.
|
|
50
|
+
const runPlanIO = makePlanRuntime();
|
|
45
51
|
|
|
46
52
|
// ── Flag ──────────────────────────────────────────────────────────────────
|
|
47
53
|
pi.registerFlag('plan', {
|
|
@@ -51,7 +57,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
51
57
|
});
|
|
52
58
|
|
|
53
59
|
// ── Tools ─────────────────────────────────────────────────────────────────
|
|
54
|
-
registerSubmitPlanTool(pi, {
|
|
60
|
+
registerSubmitPlanTool(pi, runPlanIO, {
|
|
55
61
|
onPlanSubmitted: (dir, submittedPlan) => {
|
|
56
62
|
state.planDir = dir;
|
|
57
63
|
state.plan = submittedPlan;
|
|
@@ -59,6 +65,8 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
59
65
|
},
|
|
60
66
|
});
|
|
61
67
|
|
|
68
|
+
registerPreviewPrototypeTool(pi, runPlanIO);
|
|
69
|
+
|
|
62
70
|
registerUpdateTaskTool(pi, {
|
|
63
71
|
getPlan: () => state.plan,
|
|
64
72
|
onTaskUpdated: async (taskId, status, notes) => {
|
|
@@ -68,15 +76,38 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
68
76
|
task.status = status;
|
|
69
77
|
task.updated_at = new Date().toISOString();
|
|
70
78
|
if (notes) task.notes = notes;
|
|
71
|
-
await
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
await runPlanIO(
|
|
80
|
+
writeTasksJsonl(
|
|
81
|
+
state.planDir,
|
|
82
|
+
{
|
|
83
|
+
_type: 'meta',
|
|
84
|
+
title: state.plan.title,
|
|
85
|
+
plan_name: state.plan.planName,
|
|
86
|
+
created_at: state.plan.tasks[0]?.created_at ?? task.updated_at,
|
|
87
|
+
},
|
|
88
|
+
state.plan.tasks,
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
state.persist(pi);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
registerAddTaskTool(pi, {
|
|
96
|
+
getPlan: () => state.plan,
|
|
97
|
+
onTaskAdded: async (task) => {
|
|
98
|
+
if (!state.plan || !state.planDir) return;
|
|
99
|
+
state.plan.tasks.push(task);
|
|
100
|
+
await runPlanIO(
|
|
101
|
+
writeTasksJsonl(
|
|
102
|
+
state.planDir,
|
|
103
|
+
{
|
|
104
|
+
_type: 'meta',
|
|
105
|
+
title: state.plan.title,
|
|
106
|
+
plan_name: state.plan.planName,
|
|
107
|
+
created_at: state.plan.tasks[0]?.created_at ?? task.created_at,
|
|
108
|
+
},
|
|
109
|
+
state.plan.tasks,
|
|
110
|
+
),
|
|
80
111
|
);
|
|
81
112
|
state.persist(pi);
|
|
82
113
|
},
|
|
@@ -89,7 +120,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
89
120
|
handler: async (args, ctx) => {
|
|
90
121
|
const trimmed = args?.trim();
|
|
91
122
|
if (trimmed === 'resume') {
|
|
92
|
-
await resumePlan(state, pi, ctx);
|
|
123
|
+
await resumePlan(state, pi, ctx, runPlanIO);
|
|
93
124
|
return;
|
|
94
125
|
}
|
|
95
126
|
if (state.planEnabled || state.executing) {
|
|
@@ -112,7 +143,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
112
143
|
const first =
|
|
113
144
|
state.plan.tasks.find((task) => task.status === 'pending')?.id ?? state.plan.tasks[0]?.id;
|
|
114
145
|
const kickoff = `Execute the following plan: "${state.plan.title}"\n\nTasks:\n${taskList}\n\nStart with ${first}. Call update_task after completing each task.`;
|
|
115
|
-
await executeInNewSession(ctx, state.planDir, state.plan, kickoff);
|
|
146
|
+
await executeInNewSession(ctx, runPlanIO, state.planDir, state.plan, kickoff);
|
|
116
147
|
},
|
|
117
148
|
});
|
|
118
149
|
|
|
@@ -123,9 +154,18 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
123
154
|
ctx.ui.notify('No plan yet. Use /plan to start planning.', 'info');
|
|
124
155
|
return;
|
|
125
156
|
}
|
|
126
|
-
const statusIcon = {
|
|
157
|
+
const statusIcon = {
|
|
158
|
+
pending: '○',
|
|
159
|
+
done: '✓',
|
|
160
|
+
skipped: '⊘',
|
|
161
|
+
blocked: '✗',
|
|
162
|
+
deferred: '⏸',
|
|
163
|
+
} as const;
|
|
127
164
|
const list = state.plan.tasks
|
|
128
|
-
.map((s) =>
|
|
165
|
+
.map((s) => {
|
|
166
|
+
const marker = s.origin === 'discovered' ? ' (discovered)' : '';
|
|
167
|
+
return `${s.id}. ${statusIcon[s.status]} ${s.description}${marker}`;
|
|
168
|
+
})
|
|
129
169
|
.join('\n');
|
|
130
170
|
ctx.ui.notify(`Plan Progress:\n${list}`, 'info');
|
|
131
171
|
},
|
|
@@ -207,10 +247,15 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
207
247
|
|
|
208
248
|
if (blocked.length > 0) {
|
|
209
249
|
const bs = blocked[0];
|
|
210
|
-
|
|
250
|
+
let info = bs.notes
|
|
211
251
|
? `Task ${bs.id}: ${bs.description}\nReason: ${bs.notes}`
|
|
212
252
|
: `Task ${bs.id}: ${bs.description}`;
|
|
213
253
|
|
|
254
|
+
const pausedFollowups = deferredTasks(state.plan.tasks);
|
|
255
|
+
if (pausedFollowups.length > 0) {
|
|
256
|
+
info += `\n\nNote: ${pausedFollowups.length} follow-up(s) captured for later review (/plan resume).`;
|
|
257
|
+
}
|
|
258
|
+
|
|
214
259
|
const choice = await ctx.ui.select(`Task blocked — ${info}\n\nWhat next?`, [
|
|
215
260
|
'Skip this task',
|
|
216
261
|
'Provide instructions',
|
|
@@ -221,15 +266,17 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
221
266
|
if (choice === 'Skip this task') {
|
|
222
267
|
bs.status = 'skipped';
|
|
223
268
|
bs.updated_at = new Date().toISOString();
|
|
224
|
-
await
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
269
|
+
await runPlanIO(
|
|
270
|
+
writeTasksJsonl(
|
|
271
|
+
state.planDir!,
|
|
272
|
+
{
|
|
273
|
+
_type: 'meta',
|
|
274
|
+
title: state.plan.title,
|
|
275
|
+
plan_name: state.plan.planName,
|
|
276
|
+
created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at,
|
|
277
|
+
},
|
|
278
|
+
state.plan.tasks,
|
|
279
|
+
),
|
|
233
280
|
);
|
|
234
281
|
updateUI(state, ctx);
|
|
235
282
|
state.persist(pi);
|
|
@@ -244,15 +291,17 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
244
291
|
bs.status = 'pending';
|
|
245
292
|
bs.notes = undefined;
|
|
246
293
|
bs.updated_at = new Date().toISOString();
|
|
247
|
-
await
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
294
|
+
await runPlanIO(
|
|
295
|
+
writeTasksJsonl(
|
|
296
|
+
state.planDir!,
|
|
297
|
+
{
|
|
298
|
+
_type: 'meta',
|
|
299
|
+
title: state.plan.title,
|
|
300
|
+
plan_name: state.plan.planName,
|
|
301
|
+
created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at,
|
|
302
|
+
},
|
|
303
|
+
state.plan.tasks,
|
|
304
|
+
),
|
|
256
305
|
);
|
|
257
306
|
updateUI(state, ctx);
|
|
258
307
|
state.persist(pi);
|
|
@@ -275,22 +324,76 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
275
324
|
}
|
|
276
325
|
}
|
|
277
326
|
|
|
327
|
+
// ── Discovered follow-ups checkpoint ──
|
|
328
|
+
// Active work is done but the agent captured deferred follow-ups: keep the
|
|
329
|
+
// plan in-progress and inform the user, who decides via /plan resume.
|
|
330
|
+
const deferred = deferredTasks(state.plan.tasks);
|
|
331
|
+
if (activeTasksResolved(state.plan.tasks) && deferred.length > 0) {
|
|
332
|
+
if (state.planDir) {
|
|
333
|
+
await runPlanIO(
|
|
334
|
+
writeTasksJsonl(
|
|
335
|
+
state.planDir,
|
|
336
|
+
{
|
|
337
|
+
_type: 'meta',
|
|
338
|
+
title: state.plan.title,
|
|
339
|
+
plan_name: state.plan.planName,
|
|
340
|
+
created_at: state.plan.tasks[0]?.created_at ?? new Date().toISOString(),
|
|
341
|
+
},
|
|
342
|
+
state.plan.tasks,
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const followups = deferred
|
|
348
|
+
.map((s) => {
|
|
349
|
+
const label = `${s.id}. ⏸ ${s.description}`;
|
|
350
|
+
return s.notes ? `${label}\n ${s.notes}` : label;
|
|
351
|
+
})
|
|
352
|
+
.join('\n');
|
|
353
|
+
const followSummary = [
|
|
354
|
+
`**Plan tasks complete — ${deferred.length} follow-up(s) discovered (kept for later)**`,
|
|
355
|
+
'',
|
|
356
|
+
'Run `/plan resume` to review and decide whether to implement them.',
|
|
357
|
+
'',
|
|
358
|
+
'## Discovered follow-ups',
|
|
359
|
+
'',
|
|
360
|
+
followups,
|
|
361
|
+
].join('\n');
|
|
362
|
+
pi.sendMessage(
|
|
363
|
+
{ customType: 'plan-followups', content: followSummary, display: true },
|
|
364
|
+
{ triggerTurn: false },
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const { previousModel: dpm, previousThinking: dpt } = state;
|
|
368
|
+
state.exitPreservingPlan();
|
|
369
|
+
pi.setActiveTools(EXEC_TOOLS);
|
|
370
|
+
if (dpm) await switchModel(pi, ctx, dpm);
|
|
371
|
+
if (dpt) pi.setThinkingLevel(dpt);
|
|
372
|
+
updateUI(state, ctx);
|
|
373
|
+
state.persist(pi);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
278
377
|
// Check completion
|
|
279
378
|
const allResolved = state.plan.tasks.every(
|
|
280
379
|
(s) => s.status === 'done' || s.status === 'skipped',
|
|
281
380
|
);
|
|
282
381
|
if (allResolved) {
|
|
283
382
|
if (state.planDir) {
|
|
284
|
-
await
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
383
|
+
await runPlanIO(
|
|
384
|
+
upsertPlanEntry(state.plan.planName, { status: 'done', title: state.plan.title }),
|
|
385
|
+
);
|
|
386
|
+
await runPlanIO(
|
|
387
|
+
writeTasksJsonl(
|
|
388
|
+
state.planDir,
|
|
389
|
+
{
|
|
390
|
+
_type: 'meta',
|
|
391
|
+
title: state.plan.title,
|
|
392
|
+
plan_name: state.plan.planName,
|
|
393
|
+
created_at: state.plan.tasks[0]?.created_at ?? new Date().toISOString(),
|
|
394
|
+
},
|
|
395
|
+
state.plan.tasks,
|
|
396
|
+
),
|
|
294
397
|
);
|
|
295
398
|
}
|
|
296
399
|
const done = state.plan.tasks.filter((s) => s.status === 'done').length;
|
|
@@ -335,8 +438,8 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
335
438
|
return;
|
|
336
439
|
}
|
|
337
440
|
|
|
338
|
-
// Plan submitted — user can
|
|
339
|
-
// No menu needed: plan.jsonl +
|
|
441
|
+
// Plan submitted — user can /plan-exec or type naturally.
|
|
442
|
+
// No menu needed: plan.jsonl + HANDOFF.md are the source of truth.
|
|
340
443
|
});
|
|
341
444
|
|
|
342
445
|
// ── Event: session restore ────────────────────────────────────────────────
|
|
@@ -348,16 +451,16 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
348
451
|
);
|
|
349
452
|
|
|
350
453
|
// Check for exec-pending handoff from planning session
|
|
351
|
-
const pending = await readAndClearExecPending();
|
|
454
|
+
const pending = await runPlanIO(readAndClearExecPending());
|
|
352
455
|
if (pending) {
|
|
353
456
|
state.planDir = pending.planDir;
|
|
354
457
|
{
|
|
355
|
-
const snapshot = await readTasksJsonl(pending.planDir);
|
|
458
|
+
const snapshot = await runPlanIO(readTasksJsonl(pending.planDir));
|
|
356
459
|
state.plan = snapshot
|
|
357
460
|
? {
|
|
358
461
|
title: snapshot.meta.title,
|
|
359
462
|
planName: snapshot.meta.plan_name,
|
|
360
|
-
handoff: (await loadHandoff(pending.planDir)) ?? '',
|
|
463
|
+
handoff: (await runPlanIO(loadHandoff(pending.planDir))) ?? '',
|
|
361
464
|
tasks: snapshot.tasks,
|
|
362
465
|
}
|
|
363
466
|
: undefined;
|
|
@@ -18,7 +18,7 @@ Restrictions:
|
|
|
18
18
|
Your job is to reach shared understanding before formalizing a plan:
|
|
19
19
|
1. Understand the user's intent through dialogue. Push back on weak assumptions, name trade-offs, and ask clarifying questions when needed.
|
|
20
20
|
2. Investigate the codebase with read-only tools. Use questionnaire when explicit choices are needed.
|
|
21
|
-
3. Maintain .plans/<plan-name>/context.md
|
|
21
|
+
3. Maintain a living .plans/<plan-name>/context.md as you converge — the planning-context skill covers what to capture and how.
|
|
22
22
|
4. Only call submit_plan after the user and agent have converged on the approach.
|
|
23
23
|
|
|
24
24
|
When you are ready to finalize the plan, call submit_plan with:
|
|
@@ -26,13 +26,14 @@ When you are ready to finalize the plan, call submit_plan with:
|
|
|
26
26
|
- title: a human-readable plan title
|
|
27
27
|
- handoff: a markdown document that explains what is changing, why it matters, approach, decisions, file paths, APIs, patterns, constraints, and gotchas
|
|
28
28
|
- tasks: an array of tasks with id (e.g. "t-001"), description (≤60 chars), optional details, and optional depends_on task IDs
|
|
29
|
-
- prototype: optional Pug markup for a creative prototype section in the generated plan.html
|
|
30
29
|
|
|
31
30
|
Plan weight:
|
|
32
31
|
- **Delegation plans** (different agent/human executes): include full details in each task so an executor with zero context can follow them.
|
|
33
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.
|
|
34
33
|
|
|
35
|
-
submit_plan is finalization, not the starting point.
|
|
34
|
+
submit_plan is finalization, not the starting point. It records tasks and the handoff — it does not generate HTML.
|
|
35
|
+
|
|
36
|
+
For visual/UI/layout/style work, build a prototype with preview_prototype DURING planning, before submit_plan, so the user can react to the visual before the plan hardens. The visual-prototype skill covers when and how.
|
|
36
37
|
|
|
37
38
|
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.`;
|
|
38
39
|
}
|
|
@@ -62,6 +63,7 @@ Rules:
|
|
|
62
63
|
- Do NOT run diagnostics, linters, test suites, or skills unless a task explicitly asks for it
|
|
63
64
|
- Do NOT explore the codebase beyond what the current task requires
|
|
64
65
|
- Do NOT deviate from the plan — if something seems wrong, call update_task with status "blocked"
|
|
66
|
+
- If you notice worthwhile work OUTSIDE the current plan, call add_task to capture it as a deferred follow-up, then keep going. Do NOT implement discovered work in this run — the user reviews follow-ups later via /plan resume.
|
|
65
67
|
|
|
66
68
|
## Current task
|
|
67
69
|
${currentTask.id}: ${currentTask.description}${currentDetails}
|
|
@@ -9,11 +9,13 @@ import type {
|
|
|
9
9
|
} from '@earendil-works/pi-coding-agent';
|
|
10
10
|
import type { PlanModeState } from './state.js';
|
|
11
11
|
import type { PlanData } from './types.js';
|
|
12
|
+
import type { RunPlanIO } from './effects/runtime.js';
|
|
12
13
|
import { EXEC_THINKING, EXEC_MODEL_OPTIONS } from './constants.js';
|
|
13
14
|
import { readPlansManifest } from './storage/plans-manifest.js';
|
|
14
15
|
import { loadHandoff, writeExecPending } from './storage/plan-storage.js';
|
|
15
16
|
import { readTasksJsonl, writeTasksJsonl } from './storage/task-storage.js';
|
|
16
17
|
import { enterPlanMode } from './phase-transitions.js';
|
|
18
|
+
import { reactivateForExecution } from './task-status.js';
|
|
17
19
|
|
|
18
20
|
export async function pickExecutionModel(
|
|
19
21
|
ctx: ExtensionContext,
|
|
@@ -26,6 +28,7 @@ export async function pickExecutionModel(
|
|
|
26
28
|
|
|
27
29
|
export async function executeInNewSession(
|
|
28
30
|
ctx: ExtensionCommandContext,
|
|
31
|
+
runPlanIO: RunPlanIO,
|
|
29
32
|
dir: string,
|
|
30
33
|
_planData: PlanData,
|
|
31
34
|
kickoff: string,
|
|
@@ -33,7 +36,7 @@ export async function executeInNewSession(
|
|
|
33
36
|
const selectedModel = await pickExecutionModel(ctx);
|
|
34
37
|
if (!selectedModel) return;
|
|
35
38
|
|
|
36
|
-
await writeExecPending(dir, { model: selectedModel, thinking: EXEC_THINKING });
|
|
39
|
+
await runPlanIO(writeExecPending(dir, { model: selectedModel, thinking: EXEC_THINKING }));
|
|
37
40
|
const parentSession = ctx.sessionManager.getSessionFile();
|
|
38
41
|
|
|
39
42
|
await ctx.newSession({
|
|
@@ -48,8 +51,9 @@ export async function resumePlan(
|
|
|
48
51
|
state: PlanModeState,
|
|
49
52
|
pi: ExtensionAPI,
|
|
50
53
|
ctx: ExtensionCommandContext,
|
|
54
|
+
runPlanIO: RunPlanIO,
|
|
51
55
|
): Promise<void> {
|
|
52
|
-
const manifest = await readPlansManifest();
|
|
56
|
+
const manifest = await runPlanIO(readPlansManifest());
|
|
53
57
|
const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
|
|
54
58
|
|
|
55
59
|
if (inProgress.length === 0) {
|
|
@@ -65,7 +69,7 @@ export async function resumePlan(
|
|
|
65
69
|
|
|
66
70
|
const planName = choice.split(' — ')[0];
|
|
67
71
|
const dir = `.plans/${planName}`;
|
|
68
|
-
const snapshot = await readTasksJsonl(dir);
|
|
72
|
+
const snapshot = await runPlanIO(readTasksJsonl(dir));
|
|
69
73
|
|
|
70
74
|
if (!snapshot) {
|
|
71
75
|
ctx.ui.notify(`Could not load ${dir}/tasks.jsonl`, 'error');
|
|
@@ -76,7 +80,7 @@ export async function resumePlan(
|
|
|
76
80
|
state.plan = {
|
|
77
81
|
title: snapshot.meta.title,
|
|
78
82
|
planName: snapshot.meta.plan_name,
|
|
79
|
-
handoff: (await loadHandoff(dir)) ?? '',
|
|
83
|
+
handoff: (await runPlanIO(loadHandoff(dir))) ?? '',
|
|
80
84
|
tasks: snapshot.tasks,
|
|
81
85
|
};
|
|
82
86
|
|
|
@@ -85,8 +89,9 @@ export async function resumePlan(
|
|
|
85
89
|
).length;
|
|
86
90
|
const pendingCount = state.plan.tasks.filter((task) => task.status === 'pending').length;
|
|
87
91
|
const blockedCount = state.plan.tasks.filter((task) => task.status === 'blocked').length;
|
|
92
|
+
const deferredCount = state.plan.tasks.filter((task) => task.status === 'deferred').length;
|
|
88
93
|
|
|
89
|
-
if (pendingCount === 0 && blockedCount === 0) {
|
|
94
|
+
if (pendingCount === 0 && blockedCount === 0 && deferredCount === 0) {
|
|
90
95
|
ctx.ui.notify(
|
|
91
96
|
`Plan "${state.plan.title}" is already complete (${doneCount}/${state.plan.tasks.length} done).`,
|
|
92
97
|
'info',
|
|
@@ -96,7 +101,10 @@ export async function resumePlan(
|
|
|
96
101
|
return;
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
const summary =
|
|
104
|
+
const summary =
|
|
105
|
+
`${doneCount}/${state.plan.tasks.length} done, ${pendingCount} pending` +
|
|
106
|
+
(blockedCount ? `, ${blockedCount} blocked` : '') +
|
|
107
|
+
(deferredCount ? `, ${deferredCount} follow-up` : '');
|
|
100
108
|
const action = await ctx.ui.select(`Resume "${state.plan.title}" (${summary}) — what next?`, [
|
|
101
109
|
'Continue execution',
|
|
102
110
|
'Re-plan from scratch',
|
|
@@ -119,19 +127,15 @@ export async function resumePlan(
|
|
|
119
127
|
return;
|
|
120
128
|
}
|
|
121
129
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
task.updated_at = new Date().toISOString();
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
await writeTasksJsonl(dir, snapshot.meta, state.plan.tasks);
|
|
130
|
+
// Reactivate blocked tasks and discovered follow-ups so "Continue execution"
|
|
131
|
+
// picks them up — this is the moment the user decides to go ahead.
|
|
132
|
+
if (reactivateForExecution(state.plan.tasks, new Date().toISOString())) {
|
|
133
|
+
await runPlanIO(writeTasksJsonl(dir, snapshot.meta, state.plan.tasks));
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
const remaining = state.plan.tasks.filter((task) => task.status === 'pending');
|
|
133
137
|
const taskList = remaining.map((task) => `${task.id}. ${task.description}`).join('\n');
|
|
134
138
|
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.`;
|
|
135
139
|
|
|
136
|
-
await executeInNewSession(ctx, dir, state.plan, kickoff);
|
|
140
|
+
await executeInNewSession(ctx, runPlanIO, dir, state.plan, kickoff);
|
|
137
141
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect Schema definitions for plan-mode persisted records.
|
|
3
|
+
*
|
|
4
|
+
* These replace the hand-rolled type guards. Schemas are the single source of
|
|
5
|
+
* truth for record shape; the mutable TS interfaces in `types.ts` are kept for
|
|
6
|
+
* the imperative orchestration code (which mutates tasks in place) and are
|
|
7
|
+
* structurally compatible with the decoded values.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Schema } from 'effect';
|
|
11
|
+
|
|
12
|
+
export const TaskStatusSchema = Schema.Literal('pending', 'done', 'skipped', 'blocked', 'deferred');
|
|
13
|
+
|
|
14
|
+
export const TaskOriginSchema = Schema.Literal('plan', 'discovered');
|
|
15
|
+
|
|
16
|
+
export const TaskRecordSchema = Schema.Struct({
|
|
17
|
+
_type: Schema.Literal('task'),
|
|
18
|
+
id: Schema.String,
|
|
19
|
+
description: Schema.String,
|
|
20
|
+
details: Schema.optional(Schema.String),
|
|
21
|
+
status: TaskStatusSchema,
|
|
22
|
+
origin: Schema.optional(TaskOriginSchema),
|
|
23
|
+
depends_on: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
|
24
|
+
notes: Schema.optional(Schema.String),
|
|
25
|
+
created_at: Schema.String,
|
|
26
|
+
updated_at: Schema.String,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const TaskMetaSchema = Schema.Struct({
|
|
30
|
+
_type: Schema.Literal('meta'),
|
|
31
|
+
title: Schema.String,
|
|
32
|
+
plan_name: Schema.String,
|
|
33
|
+
created_at: Schema.String,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** A single tasks.jsonl line is either the meta record or a task record. */
|
|
37
|
+
export const TasksLineSchema = Schema.Union(TaskMetaSchema, TaskRecordSchema);
|
|
38
|
+
|
|
39
|
+
export const PlanManifestEntrySchema = Schema.Struct({
|
|
40
|
+
_type: Schema.Literal('plan'),
|
|
41
|
+
name: Schema.String,
|
|
42
|
+
status: Schema.Literal('in-progress', 'done'),
|
|
43
|
+
title: Schema.String,
|
|
44
|
+
created_at: Schema.String,
|
|
45
|
+
completed_at: Schema.NullOr(Schema.String),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const ExecPendingConfigSchema = Schema.Struct({
|
|
49
|
+
model: Schema.Struct({ provider: Schema.String, id: Schema.String }),
|
|
50
|
+
thinking: Schema.String,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const decodeTaskRecord = Schema.decodeUnknownEither(TaskRecordSchema);
|
|
54
|
+
export const decodeTaskMeta = Schema.decodeUnknownEither(TaskMetaSchema);
|
|
55
|
+
export const decodeTasksLine = Schema.decodeUnknownEither(TasksLineSchema);
|
|
56
|
+
export const decodePlanManifestEntry = Schema.decodeUnknownEither(PlanManifestEntrySchema);
|
|
57
|
+
export const decodeExecPendingConfig = Schema.decodeUnknownEither(ExecPendingConfigSchema);
|
|
@@ -1,17 +1,35 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
1
2
|
import { createWriteStream } from 'node:fs';
|
|
2
3
|
import { open, rename, rm } from 'node:fs/promises';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { PlanWriteError } from '../errors.js';
|
|
5
7
|
|
|
6
8
|
export interface AtomicWriteOptions {
|
|
7
9
|
/** Test seam: file mode for the temporary file. */
|
|
8
10
|
mode?: number;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Atomically write `data` to `path`: write to a temp file, fsync, rename into
|
|
15
|
+
* place, then best-effort fsync the directory. Failures surface as
|
|
16
|
+
* `PlanWriteError`.
|
|
17
|
+
*/
|
|
18
|
+
export function writeFileAtomic(
|
|
12
19
|
path: string,
|
|
13
20
|
data: string | Buffer,
|
|
14
21
|
options: AtomicWriteOptions = {},
|
|
22
|
+
): Effect.Effect<void, PlanWriteError> {
|
|
23
|
+
return Effect.tryPromise({
|
|
24
|
+
try: () => writeFileAtomicPromise(path, data, options),
|
|
25
|
+
catch: (cause) => new PlanWriteError({ path, cause }),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function writeFileAtomicPromise(
|
|
30
|
+
path: string,
|
|
31
|
+
data: string | Buffer,
|
|
32
|
+
options: AtomicWriteOptions,
|
|
15
33
|
): Promise<void> {
|
|
16
34
|
const dir = dirname(path);
|
|
17
35
|
const tempPath = join(dir, `.${process.pid}.${randomUUID()}.tmp`);
|
|
@@ -2,48 +2,76 @@
|
|
|
2
2
|
* Plan disk I/O — exec-pending markers and handoff documents.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { Effect, Either, Option } from 'effect';
|
|
6
|
+
import { FileSystem } from '../effects/filesystem.js';
|
|
7
|
+
import type { PlanWriteError } from '../errors.js';
|
|
8
|
+
import { decodeExecPendingConfig } from '../schema.js';
|
|
6
9
|
import type { ExecPendingConfig } from '../types.js';
|
|
7
10
|
import { EXEC_PENDING_FILE } from '../constants.js';
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
const PLANS_DIR = '.plans';
|
|
13
|
+
|
|
14
|
+
export function writeExecPending(
|
|
15
|
+
dir: string,
|
|
16
|
+
config: ExecPendingConfig,
|
|
17
|
+
): Effect.Effect<void, PlanWriteError, FileSystem> {
|
|
18
|
+
return Effect.gen(function* () {
|
|
19
|
+
const fs = yield* FileSystem;
|
|
20
|
+
yield* fs.makeDir(dir);
|
|
21
|
+
yield* fs.writeFileString(
|
|
22
|
+
`${dir}/${EXEC_PENDING_FILE}`,
|
|
23
|
+
JSON.stringify(config, null, 2) + '\n',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
12
26
|
}
|
|
13
27
|
|
|
14
|
-
export
|
|
15
|
-
{ planDir: string; config: ExecPendingConfig } | undefined
|
|
28
|
+
export function readAndClearExecPending(): Effect.Effect<
|
|
29
|
+
{ planDir: string; config: ExecPendingConfig } | undefined,
|
|
30
|
+
never,
|
|
31
|
+
FileSystem
|
|
16
32
|
> {
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
return Effect.gen(function* () {
|
|
34
|
+
const fs = yield* FileSystem;
|
|
35
|
+
const maybeDirs = yield* Effect.option(fs.listDirectories(PLANS_DIR));
|
|
36
|
+
if (Option.isNone(maybeDirs)) return undefined;
|
|
37
|
+
|
|
38
|
+
for (const name of maybeDirs.value) {
|
|
39
|
+
const dir = `${PLANS_DIR}/${name}`;
|
|
22
40
|
const markerPath = `${dir}/${EXEC_PENDING_FILE}`;
|
|
41
|
+
const maybeText = yield* Effect.option(fs.readFileString(markerPath));
|
|
42
|
+
if (Option.isNone(maybeText)) continue;
|
|
43
|
+
|
|
44
|
+
let parsed: unknown;
|
|
23
45
|
try {
|
|
24
|
-
|
|
25
|
-
const config = JSON.parse(text) as ExecPendingConfig;
|
|
26
|
-
await unlink(markerPath);
|
|
27
|
-
return { planDir: dir, config };
|
|
46
|
+
parsed = JSON.parse(maybeText.value);
|
|
28
47
|
} catch {
|
|
29
|
-
|
|
48
|
+
continue;
|
|
30
49
|
}
|
|
50
|
+
const decoded = decodeExecPendingConfig(parsed);
|
|
51
|
+
if (Either.isLeft(decoded)) continue;
|
|
52
|
+
|
|
53
|
+
yield* Effect.ignore(fs.removeFile(markerPath));
|
|
54
|
+
return { planDir: dir, config: decoded.right };
|
|
31
55
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
return undefined;
|
|
56
|
+
return undefined;
|
|
57
|
+
});
|
|
36
58
|
}
|
|
37
59
|
|
|
38
|
-
export
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
export function saveHandoff(
|
|
61
|
+
planDir: string,
|
|
62
|
+
content: string,
|
|
63
|
+
): Effect.Effect<void, PlanWriteError, FileSystem> {
|
|
64
|
+
return Effect.gen(function* () {
|
|
65
|
+
const fs = yield* FileSystem;
|
|
66
|
+
yield* fs.makeDir(planDir);
|
|
67
|
+
yield* fs.writeFileString(`${planDir}/HANDOFF.md`, content);
|
|
68
|
+
});
|
|
41
69
|
}
|
|
42
70
|
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return
|
|
48
|
-
}
|
|
71
|
+
export function loadHandoff(planDir: string): Effect.Effect<string | undefined, never, FileSystem> {
|
|
72
|
+
return Effect.gen(function* () {
|
|
73
|
+
const fs = yield* FileSystem;
|
|
74
|
+
const maybeText = yield* Effect.option(fs.readFileString(`${planDir}/HANDOFF.md`));
|
|
75
|
+
return Option.getOrUndefined(maybeText);
|
|
76
|
+
});
|
|
49
77
|
}
|