@dreki-gg/pi-plan-mode 0.16.0 → 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 (29) hide show
  1. package/CHANGELOG.md +8 -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__/plan-storage.test.ts +57 -0
  5. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +27 -16
  6. package/extensions/plan-mode/__tests__/schema.test.ts +212 -0
  7. package/extensions/plan-mode/__tests__/task-status.test.ts +74 -0
  8. package/extensions/plan-mode/__tests__/task-storage.test.ts +35 -12
  9. package/extensions/plan-mode/__tests__/utils.test.ts +19 -1
  10. package/extensions/plan-mode/constants.ts +1 -1
  11. package/extensions/plan-mode/effects/filesystem.ts +65 -0
  12. package/extensions/plan-mode/effects/runtime.ts +24 -0
  13. package/extensions/plan-mode/errors.ts +105 -0
  14. package/extensions/plan-mode/index.ts +147 -47
  15. package/extensions/plan-mode/prompts.ts +1 -0
  16. package/extensions/plan-mode/resume.ts +19 -15
  17. package/extensions/plan-mode/schema.ts +57 -0
  18. package/extensions/plan-mode/storage/atomic-write.ts +19 -1
  19. package/extensions/plan-mode/storage/plan-storage.ts +57 -29
  20. package/extensions/plan-mode/storage/plans-manifest.ts +64 -57
  21. package/extensions/plan-mode/storage/task-storage.ts +83 -45
  22. package/extensions/plan-mode/task-status.ts +46 -0
  23. package/extensions/plan-mode/tools/add-task.ts +95 -0
  24. package/extensions/plan-mode/tools/preview-prototype.ts +11 -4
  25. package/extensions/plan-mode/tools/submit-plan.ts +16 -10
  26. package/extensions/plan-mode/types.ts +8 -38
  27. package/extensions/plan-mode/utils.ts +20 -0
  28. package/package.json +2 -1
  29. package/extensions/plan-mode/__tests__/types.test.ts +0 -70
@@ -0,0 +1,65 @@
1
+ /**
2
+ * FileSystem service — the single seam for plan-mode disk I/O.
3
+ *
4
+ * Storage programs depend on this `Context.Tag` rather than touching
5
+ * `node:fs/promises` directly, which makes them trivially testable and keeps
6
+ * all failure modes typed (`PlanReadError` / `PlanWriteError`).
7
+ */
8
+
9
+ import { Context, Effect } from 'effect';
10
+ import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
11
+ import { PlanReadError, PlanWriteError } from '../errors.js';
12
+ import { writeFileAtomic } from '../storage/atomic-write.js';
13
+
14
+ export interface FileSystemService {
15
+ readonly readFileString: (path: string) => Effect.Effect<string, PlanReadError>;
16
+ readonly writeFileString: (path: string, data: string) => Effect.Effect<void, PlanWriteError>;
17
+ readonly writeFileAtomic: (path: string, data: string) => Effect.Effect<void, PlanWriteError>;
18
+ readonly makeDir: (path: string) => Effect.Effect<void, PlanWriteError>;
19
+ readonly listDirectories: (path: string) => Effect.Effect<string[], PlanReadError>;
20
+ readonly removeFile: (path: string) => Effect.Effect<void, PlanWriteError>;
21
+ }
22
+
23
+ export class FileSystem extends Context.Tag('PlanMode/FileSystem')<
24
+ FileSystem,
25
+ FileSystemService
26
+ >() {}
27
+
28
+ export const nodeFileSystemService: FileSystemService = {
29
+ readFileString: (path) =>
30
+ Effect.tryPromise({
31
+ try: () => readFile(path, 'utf-8'),
32
+ catch: (cause) => new PlanReadError({ path, cause }),
33
+ }),
34
+
35
+ writeFileString: (path, data) =>
36
+ Effect.tryPromise({
37
+ try: () => writeFile(path, data, 'utf-8'),
38
+ catch: (cause) => new PlanWriteError({ path, cause }),
39
+ }),
40
+
41
+ writeFileAtomic: (path, data) => writeFileAtomic(path, data),
42
+
43
+ makeDir: (path) =>
44
+ Effect.tryPromise({
45
+ try: async () => {
46
+ await mkdir(path, { recursive: true });
47
+ },
48
+ catch: (cause) => new PlanWriteError({ path, cause }),
49
+ }),
50
+
51
+ listDirectories: (path) =>
52
+ Effect.tryPromise({
53
+ try: async () => {
54
+ const entries = await readdir(path, { withFileTypes: true });
55
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
56
+ },
57
+ catch: (cause) => new PlanReadError({ path, cause }),
58
+ }),
59
+
60
+ removeFile: (path) =>
61
+ Effect.tryPromise({
62
+ try: () => unlink(path),
63
+ catch: (cause) => new PlanWriteError({ path, cause }),
64
+ }),
65
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Live Effect runtime for the plan-mode extension.
3
+ *
4
+ * Build the layer once inside the extension entry and run storage programs
5
+ * through the `runPlanIO` bridge so the imperative pi event handlers keep their
6
+ * `await fn(...)` shape.
7
+ */
8
+
9
+ import { Effect, Layer } from 'effect';
10
+ import { FileSystem, nodeFileSystemService } from './filesystem.js';
11
+
12
+ export function makeRuntimeLayer(): Layer.Layer<FileSystem> {
13
+ return Layer.succeed(FileSystem, nodeFileSystemService);
14
+ }
15
+
16
+ /** Build a bridge that runs storage programs against the live filesystem layer. */
17
+ export function makePlanRuntime() {
18
+ const layer = makeRuntimeLayer();
19
+ return function runPlanIO<A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> {
20
+ return Effect.runPromise(program.pipe(Effect.provide(layer)));
21
+ };
22
+ }
23
+
24
+ export type RunPlanIO = ReturnType<typeof makePlanRuntime>;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Tagged errors for plan-mode disk I/O and JSONL validation.
3
+ *
4
+ * These replace ad-hoc `throw new Error(...)` so storage programs surface
5
+ * typed, inspectable failures. They are mapped back to user-facing strings at
6
+ * the tool boundary via `errorMessage`.
7
+ */
8
+
9
+ import { Data } from 'effect';
10
+
11
+ export class PlanReadError extends Data.TaggedError('PlanReadError')<{
12
+ readonly path: string;
13
+ readonly cause: unknown;
14
+ }> {
15
+ get message(): string {
16
+ return `Failed to read ${this.path}: ${causeMessage(this.cause)}`;
17
+ }
18
+ }
19
+
20
+ export class PlanWriteError extends Data.TaggedError('PlanWriteError')<{
21
+ readonly path: string;
22
+ readonly cause: unknown;
23
+ }> {
24
+ get message(): string {
25
+ return `Failed to write ${this.path}: ${causeMessage(this.cause)}`;
26
+ }
27
+ }
28
+
29
+ export class JsonlParseError extends Data.TaggedError('JsonlParseError')<{
30
+ readonly path: string;
31
+ readonly line: number;
32
+ readonly cause?: unknown;
33
+ }> {
34
+ get message(): string {
35
+ return `Invalid JSONL in ${this.path} at line ${this.line}: ${causeMessage(this.cause)}`;
36
+ }
37
+ }
38
+
39
+ export class JsonlValidationError extends Data.TaggedError('JsonlValidationError')<{
40
+ readonly path: string;
41
+ readonly line: number;
42
+ readonly reason: string;
43
+ }> {
44
+ get message(): string {
45
+ return `Invalid record in ${this.path} at line ${this.line}: ${this.reason}`;
46
+ }
47
+ }
48
+
49
+ export class MissingMetaRecord extends Data.TaggedError('MissingMetaRecord')<{
50
+ readonly path: string;
51
+ }> {
52
+ get message(): string {
53
+ return `${this.path} is missing meta record`;
54
+ }
55
+ }
56
+
57
+ export class TaskNotFound extends Data.TaggedError('TaskNotFound')<{
58
+ readonly planDir: string;
59
+ readonly taskId: string;
60
+ }> {
61
+ get message(): string {
62
+ return `Task not found: ${this.taskId}`;
63
+ }
64
+ }
65
+
66
+ export class TasksFileNotFound extends Data.TaggedError('TasksFileNotFound')<{
67
+ readonly planDir: string;
68
+ }> {
69
+ get message(): string {
70
+ return `No tasks.jsonl found in ${this.planDir}`;
71
+ }
72
+ }
73
+
74
+ export type PlanStorageError =
75
+ | PlanReadError
76
+ | PlanWriteError
77
+ | JsonlParseError
78
+ | JsonlValidationError
79
+ | MissingMetaRecord
80
+ | TaskNotFound
81
+ | TasksFileNotFound;
82
+
83
+ export function causeMessage(cause: unknown): string {
84
+ if (cause instanceof Error) return cause.message;
85
+ return String(cause);
86
+ }
87
+
88
+ export function errorMessage(error: unknown): string {
89
+ if (error instanceof Error) return error.message;
90
+ if (typeof error === 'object' && error !== null && 'message' in error) {
91
+ const message = (error as { message?: unknown }).message;
92
+ if (typeof message === 'string') return message;
93
+ }
94
+ return String(error);
95
+ }
96
+
97
+ /** Convert any error (including tagged errors) into a native Error for the tool boundary. */
98
+ export function toNativeError(error: unknown): Error {
99
+ if (error instanceof Error) return error;
100
+ const native = new Error(errorMessage(error));
101
+ if (typeof error === 'object' && error !== null && '_tag' in error) {
102
+ native.name = String((error as { _tag: unknown })._tag);
103
+ }
104
+ return native;
105
+ }
@@ -28,21 +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';
40
42
  import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
41
43
  import { registerUpdateTaskTool } from './tools/update-task.js';
44
+ import { registerAddTaskTool } from './tools/add-task.js';
42
45
  import { isSafeCommand, isPlanPath } from './utils.js';
43
46
 
44
47
  export default function planMode(pi: ExtensionAPI): void {
45
48
  const state = new PlanModeState();
49
+ // Build the live Effect runtime once; all storage I/O runs through this bridge.
50
+ const runPlanIO = makePlanRuntime();
46
51
 
47
52
  // ── Flag ──────────────────────────────────────────────────────────────────
48
53
  pi.registerFlag('plan', {
@@ -52,7 +57,7 @@ export default function planMode(pi: ExtensionAPI): void {
52
57
  });
53
58
 
54
59
  // ── Tools ─────────────────────────────────────────────────────────────────
55
- registerSubmitPlanTool(pi, {
60
+ registerSubmitPlanTool(pi, runPlanIO, {
56
61
  onPlanSubmitted: (dir, submittedPlan) => {
57
62
  state.planDir = dir;
58
63
  state.plan = submittedPlan;
@@ -60,7 +65,7 @@ export default function planMode(pi: ExtensionAPI): void {
60
65
  },
61
66
  });
62
67
 
63
- registerPreviewPrototypeTool(pi);
68
+ registerPreviewPrototypeTool(pi, runPlanIO);
64
69
 
65
70
  registerUpdateTaskTool(pi, {
66
71
  getPlan: () => state.plan,
@@ -71,15 +76,38 @@ export default function planMode(pi: ExtensionAPI): void {
71
76
  task.status = status;
72
77
  task.updated_at = new Date().toISOString();
73
78
  if (notes) task.notes = notes;
74
- await writeTasksJsonl(
75
- state.planDir,
76
- {
77
- _type: 'meta',
78
- title: state.plan.title,
79
- plan_name: state.plan.planName,
80
- created_at: state.plan.tasks[0]?.created_at ?? task.updated_at,
81
- },
82
- 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
+ ),
83
111
  );
84
112
  state.persist(pi);
85
113
  },
@@ -92,7 +120,7 @@ export default function planMode(pi: ExtensionAPI): void {
92
120
  handler: async (args, ctx) => {
93
121
  const trimmed = args?.trim();
94
122
  if (trimmed === 'resume') {
95
- await resumePlan(state, pi, ctx);
123
+ await resumePlan(state, pi, ctx, runPlanIO);
96
124
  return;
97
125
  }
98
126
  if (state.planEnabled || state.executing) {
@@ -115,7 +143,7 @@ export default function planMode(pi: ExtensionAPI): void {
115
143
  const first =
116
144
  state.plan.tasks.find((task) => task.status === 'pending')?.id ?? state.plan.tasks[0]?.id;
117
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.`;
118
- await executeInNewSession(ctx, state.planDir, state.plan, kickoff);
146
+ await executeInNewSession(ctx, runPlanIO, state.planDir, state.plan, kickoff);
119
147
  },
120
148
  });
121
149
 
@@ -126,9 +154,18 @@ export default function planMode(pi: ExtensionAPI): void {
126
154
  ctx.ui.notify('No plan yet. Use /plan to start planning.', 'info');
127
155
  return;
128
156
  }
129
- const statusIcon = { pending: '○', done: '✓', skipped: '⊘', blocked: '✗' } as const;
157
+ const statusIcon = {
158
+ pending: '○',
159
+ done: '✓',
160
+ skipped: '⊘',
161
+ blocked: '✗',
162
+ deferred: '⏸',
163
+ } as const;
130
164
  const list = state.plan.tasks
131
- .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
+ })
132
169
  .join('\n');
133
170
  ctx.ui.notify(`Plan Progress:\n${list}`, 'info');
134
171
  },
@@ -210,10 +247,15 @@ export default function planMode(pi: ExtensionAPI): void {
210
247
 
211
248
  if (blocked.length > 0) {
212
249
  const bs = blocked[0];
213
- const info = bs.notes
250
+ let info = bs.notes
214
251
  ? `Task ${bs.id}: ${bs.description}\nReason: ${bs.notes}`
215
252
  : `Task ${bs.id}: ${bs.description}`;
216
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
+
217
259
  const choice = await ctx.ui.select(`Task blocked — ${info}\n\nWhat next?`, [
218
260
  'Skip this task',
219
261
  'Provide instructions',
@@ -224,15 +266,17 @@ export default function planMode(pi: ExtensionAPI): void {
224
266
  if (choice === 'Skip this task') {
225
267
  bs.status = 'skipped';
226
268
  bs.updated_at = new Date().toISOString();
227
- await writeTasksJsonl(
228
- state.planDir!,
229
- {
230
- _type: 'meta',
231
- title: state.plan.title,
232
- plan_name: state.plan.planName,
233
- created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at,
234
- },
235
- 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
+ ),
236
280
  );
237
281
  updateUI(state, ctx);
238
282
  state.persist(pi);
@@ -247,15 +291,17 @@ export default function planMode(pi: ExtensionAPI): void {
247
291
  bs.status = 'pending';
248
292
  bs.notes = undefined;
249
293
  bs.updated_at = new Date().toISOString();
250
- await writeTasksJsonl(
251
- state.planDir!,
252
- {
253
- _type: 'meta',
254
- title: state.plan.title,
255
- plan_name: state.plan.planName,
256
- created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at,
257
- },
258
- 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
+ ),
259
305
  );
260
306
  updateUI(state, ctx);
261
307
  state.persist(pi);
@@ -278,22 +324,76 @@ export default function planMode(pi: ExtensionAPI): void {
278
324
  }
279
325
  }
280
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
+
281
377
  // Check completion
282
378
  const allResolved = state.plan.tasks.every(
283
379
  (s) => s.status === 'done' || s.status === 'skipped',
284
380
  );
285
381
  if (allResolved) {
286
382
  if (state.planDir) {
287
- await upsertPlanEntry(state.plan.planName, { status: 'done', title: state.plan.title });
288
- await writeTasksJsonl(
289
- state.planDir,
290
- {
291
- _type: 'meta',
292
- title: state.plan.title,
293
- plan_name: state.plan.planName,
294
- created_at: state.plan.tasks[0]?.created_at ?? new Date().toISOString(),
295
- },
296
- 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
+ ),
297
397
  );
298
398
  }
299
399
  const done = state.plan.tasks.filter((s) => s.status === 'done').length;
@@ -351,16 +451,16 @@ export default function planMode(pi: ExtensionAPI): void {
351
451
  );
352
452
 
353
453
  // Check for exec-pending handoff from planning session
354
- const pending = await readAndClearExecPending();
454
+ const pending = await runPlanIO(readAndClearExecPending());
355
455
  if (pending) {
356
456
  state.planDir = pending.planDir;
357
457
  {
358
- const snapshot = await readTasksJsonl(pending.planDir);
458
+ const snapshot = await runPlanIO(readTasksJsonl(pending.planDir));
359
459
  state.plan = snapshot
360
460
  ? {
361
461
  title: snapshot.meta.title,
362
462
  planName: snapshot.meta.plan_name,
363
- handoff: (await loadHandoff(pending.planDir)) ?? '',
463
+ handoff: (await runPlanIO(loadHandoff(pending.planDir))) ?? '',
364
464
  tasks: snapshot.tasks,
365
465
  }
366
466
  : undefined;
@@ -63,6 +63,7 @@ Rules:
63
63
  - Do NOT run diagnostics, linters, test suites, or skills unless a task explicitly asks for it
64
64
  - Do NOT explore the codebase beyond what the current task requires
65
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.
66
67
 
67
68
  ## Current task
68
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);