@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/extensions/plan-mode/__tests__/add-task.test.ts +75 -0
  3. package/extensions/plan-mode/__tests__/atomic-write.test.ts +6 -4
  4. package/extensions/plan-mode/__tests__/html-render.test.ts +26 -35
  5. package/extensions/plan-mode/__tests__/package-skills.test.ts +51 -0
  6. package/extensions/plan-mode/__tests__/plan-storage.test.ts +57 -0
  7. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +27 -16
  8. package/extensions/plan-mode/__tests__/schema.test.ts +212 -0
  9. package/extensions/plan-mode/__tests__/task-status.test.ts +74 -0
  10. package/extensions/plan-mode/__tests__/task-storage.test.ts +35 -12
  11. package/extensions/plan-mode/__tests__/utils.test.ts +19 -1
  12. package/extensions/plan-mode/constants.ts +2 -1
  13. package/extensions/plan-mode/effects/filesystem.ts +65 -0
  14. package/extensions/plan-mode/effects/runtime.ts +24 -0
  15. package/extensions/plan-mode/errors.ts +105 -0
  16. package/extensions/plan-mode/html/render.ts +10 -114
  17. package/extensions/plan-mode/html/templates/prototype.pug +25 -0
  18. package/extensions/plan-mode/index.ts +151 -48
  19. package/extensions/plan-mode/prompts.ts +5 -3
  20. package/extensions/plan-mode/resume.ts +19 -15
  21. package/extensions/plan-mode/schema.ts +57 -0
  22. package/extensions/plan-mode/storage/atomic-write.ts +19 -1
  23. package/extensions/plan-mode/storage/plan-storage.ts +57 -29
  24. package/extensions/plan-mode/storage/plans-manifest.ts +64 -57
  25. package/extensions/plan-mode/storage/task-storage.ts +83 -45
  26. package/extensions/plan-mode/task-status.ts +46 -0
  27. package/extensions/plan-mode/tools/add-task.ts +95 -0
  28. package/extensions/plan-mode/tools/preview-prototype.ts +98 -0
  29. package/extensions/plan-mode/tools/submit-plan.ts +18 -16
  30. package/extensions/plan-mode/types.ts +8 -38
  31. package/extensions/plan-mode/utils.ts +20 -0
  32. package/package.json +2 -1
  33. package/skills/planning-context/SKILL.md +42 -0
  34. package/skills/visual-prototype/SKILL.md +45 -0
  35. package/extensions/plan-mode/__tests__/types.test.ts +0 -70
  36. 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 writeTasksJsonl(
72
- state.planDir,
73
- {
74
- _type: 'meta',
75
- title: state.plan.title,
76
- plan_name: state.plan.planName,
77
- created_at: state.plan.tasks[0]?.created_at ?? task.updated_at,
78
- },
79
- state.plan.tasks,
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 = { pending: '○', done: '✓', skipped: '⊘', blocked: '✗' } as const;
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) => `${s.id}. ${statusIcon[s.status]} ${s.description}`)
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
- const info = bs.notes
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 writeTasksJsonl(
225
- state.planDir!,
226
- {
227
- _type: 'meta',
228
- title: state.plan.title,
229
- plan_name: state.plan.planName,
230
- created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at,
231
- },
232
- state.plan.tasks,
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 writeTasksJsonl(
248
- state.planDir!,
249
- {
250
- _type: 'meta',
251
- title: state.plan.title,
252
- plan_name: state.plan.planName,
253
- created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at,
254
- },
255
- state.plan.tasks,
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 upsertPlanEntry(state.plan.planName, { status: 'done', title: state.plan.title });
285
- await writeTasksJsonl(
286
- state.planDir,
287
- {
288
- _type: 'meta',
289
- title: state.plan.title,
290
- plan_name: state.plan.planName,
291
- created_at: state.plan.tasks[0]?.created_at ?? new Date().toISOString(),
292
- },
293
- state.plan.tasks,
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 review plan.html then /plan-exec or type naturally.
339
- // No menu needed: plan.jsonl + plan.html are the source of truth.
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 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
+ 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. The generated plan.html is written but not opened automatically.
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 = `${doneCount}/${state.plan.tasks.length} done, ${pendingCount} pending${blockedCount ? `, ${blockedCount} blocked` : ''}`;
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
- if (blockedCount > 0) {
123
- for (const task of state.plan.tasks) {
124
- if (task.status === 'blocked') {
125
- task.status = 'pending';
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
- export async function writeFileAtomic(
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 { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
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
- 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
+ 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 async function readAndClearExecPending(): Promise<
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
- try {
18
- const entries = await readdir('.plans', { withFileTypes: true });
19
- for (const entry of entries) {
20
- if (!entry.isDirectory()) continue;
21
- const dir = `.plans/${entry.name}`;
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
- const text = await readFile(markerPath, 'utf-8');
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
- // No marker in this directory
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
- } catch {
33
- // .plans/ doesn't exist
34
- }
35
- return undefined;
56
+ return undefined;
57
+ });
36
58
  }
37
59
 
38
- export async function saveHandoff(planDir: string, content: string): Promise<void> {
39
- await mkdir(planDir, { recursive: true });
40
- await writeFile(`${planDir}/HANDOFF.md`, content, 'utf-8');
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 async function loadHandoff(planDir: string): Promise<string | undefined> {
44
- try {
45
- return await readFile(`${planDir}/HANDOFF.md`, 'utf-8');
46
- } catch {
47
- return undefined;
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
  }