@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
@@ -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
  }
@@ -1,6 +1,9 @@
1
- import { mkdir, readFile } from 'node:fs/promises';
2
- import { writeFileAtomic } from './atomic-write.js';
1
+ import { Effect, Either, Option } from 'effect';
2
+ import { FileSystem } from '../effects/filesystem.js';
3
+ import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
4
+ import { decodePlanManifestEntry } from '../schema.js';
3
5
 
6
+ const MANIFEST_DIR = '.plans';
4
7
  const MANIFEST_PATH = '.plans/plans.jsonl';
5
8
 
6
9
  export interface PlanManifestEntry {
@@ -12,66 +15,70 @@ export interface PlanManifestEntry {
12
15
  completed_at: string | null;
13
16
  }
14
17
 
15
- export async function readPlansManifest(): Promise<PlanManifestEntry[]> {
16
- let text: string;
17
- try {
18
- text = await readFile(MANIFEST_PATH, 'utf8');
19
- } catch {
20
- return [];
21
- }
22
- const entries: PlanManifestEntry[] = [];
23
- for (const [index, raw] of text.split(/\r?\n/).entries()) {
24
- if (!raw.trim()) continue;
25
- let parsed: unknown;
26
- try {
27
- parsed = JSON.parse(raw);
28
- } catch (error) {
29
- throw new Error(`Invalid plans.jsonl at line ${index + 1}: ${(error as Error).message}`);
18
+ type ReadError = JsonlParseError | JsonlValidationError;
19
+
20
+ export function readPlansManifest(): Effect.Effect<PlanManifestEntry[], ReadError, FileSystem> {
21
+ return Effect.gen(function* () {
22
+ const fs = yield* FileSystem;
23
+ // A missing or unreadable manifest is treated as "no plans".
24
+ const maybeText = yield* Effect.option(fs.readFileString(MANIFEST_PATH));
25
+ if (Option.isNone(maybeText)) return [];
26
+
27
+ const entries: PlanManifestEntry[] = [];
28
+ for (const [index, raw] of maybeText.value.split(/\r?\n/).entries()) {
29
+ if (!raw.trim()) continue;
30
+ const line = index + 1;
31
+
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = JSON.parse(raw);
35
+ } catch (cause) {
36
+ return yield* Effect.fail(new JsonlParseError({ path: MANIFEST_PATH, line, cause }));
37
+ }
38
+
39
+ const decoded = decodePlanManifestEntry(parsed);
40
+ if (Either.isLeft(decoded)) {
41
+ return yield* Effect.fail(
42
+ new JsonlValidationError({ path: MANIFEST_PATH, line, reason: decoded.left.message }),
43
+ );
44
+ }
45
+ entries.push(decoded.right);
30
46
  }
31
- if (!isPlanManifestEntry(parsed))
32
- throw new Error(`Invalid plans.jsonl record at line ${index + 1}`);
33
- entries.push(parsed);
34
- }
35
- return entries;
47
+ return entries;
48
+ });
36
49
  }
37
50
 
38
- export async function writePlansManifest(entries: PlanManifestEntry[]): Promise<void> {
39
- await mkdir('.plans', { recursive: true });
40
- const content =
41
- entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
42
- await writeFileAtomic(MANIFEST_PATH, content);
51
+ export function writePlansManifest(
52
+ entries: PlanManifestEntry[],
53
+ ): Effect.Effect<void, PlanWriteError, FileSystem> {
54
+ return Effect.gen(function* () {
55
+ const fs = yield* FileSystem;
56
+ yield* fs.makeDir(MANIFEST_DIR);
57
+ const content =
58
+ entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
59
+ yield* fs.writeFileAtomic(MANIFEST_PATH, content);
60
+ });
43
61
  }
44
62
 
45
- export async function upsertPlanEntry(
63
+ export function upsertPlanEntry(
46
64
  name: string,
47
65
  updates: { status: 'in-progress' | 'done'; title?: string },
48
- ): Promise<void> {
49
- const entries = await readPlansManifest();
50
- const now = new Date().toISOString();
51
- const index = entries.findIndex((entry) => entry.name === name);
52
- const existing = index === -1 ? undefined : entries[index];
53
- const entry: PlanManifestEntry = {
54
- _type: 'plan',
55
- name,
56
- status: updates.status,
57
- title: updates.title ?? existing?.title ?? 'Untitled plan',
58
- created_at: existing?.created_at ?? now,
59
- completed_at: updates.status === 'done' ? now : null,
60
- };
61
- if (index === -1) entries.push(entry);
62
- else entries[index] = entry;
63
- await writePlansManifest(entries);
64
- }
65
-
66
- function isPlanManifestEntry(value: unknown): value is PlanManifestEntry {
67
- if (typeof value !== 'object' || value === null) return false;
68
- const record = value as Record<string, unknown>;
69
- return (
70
- record._type === 'plan' &&
71
- typeof record.name === 'string' &&
72
- (record.status === 'in-progress' || record.status === 'done') &&
73
- typeof record.title === 'string' &&
74
- typeof record.created_at === 'string' &&
75
- (record.completed_at === null || typeof record.completed_at === 'string')
76
- );
66
+ ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
67
+ return Effect.gen(function* () {
68
+ const entries = yield* readPlansManifest();
69
+ const now = new Date().toISOString();
70
+ const index = entries.findIndex((entry) => entry.name === name);
71
+ const existing = index === -1 ? undefined : entries[index];
72
+ const entry: PlanManifestEntry = {
73
+ _type: 'plan',
74
+ name,
75
+ status: updates.status,
76
+ title: updates.title ?? existing?.title ?? 'Untitled plan',
77
+ created_at: existing?.created_at ?? now,
78
+ completed_at: updates.status === 'done' ? now : null,
79
+ };
80
+ if (index === -1) entries.push(entry);
81
+ else entries[index] = entry;
82
+ yield* writePlansManifest(entries);
83
+ });
77
84
  }
@@ -1,7 +1,16 @@
1
- import { mkdir, readFile } from 'node:fs/promises';
1
+ import { Effect, Either, Option } from 'effect';
2
2
  import { join } from 'node:path';
3
- import { isTaskMeta, isTaskRecord, type TaskMeta, type TaskRecord } from '../types.js';
4
- import { writeFileAtomic } from './atomic-write.js';
3
+ import { FileSystem } from '../effects/filesystem.js';
4
+ import {
5
+ JsonlParseError,
6
+ JsonlValidationError,
7
+ MissingMetaRecord,
8
+ PlanWriteError,
9
+ TaskNotFound,
10
+ TasksFileNotFound,
11
+ } from '../errors.js';
12
+ import { decodeTasksLine } from '../schema.js';
13
+ import type { TaskMeta, TaskRecord } from '../types.js';
5
14
 
6
15
  const TASKS_FILE = 'tasks.jsonl';
7
16
 
@@ -10,58 +19,87 @@ export interface TasksSnapshot {
10
19
  tasks: TaskRecord[];
11
20
  }
12
21
 
13
- export async function readTasksJsonl(planDir: string): Promise<TasksSnapshot | undefined> {
14
- let text: string;
15
- try {
16
- text = await readFile(join(planDir, TASKS_FILE), 'utf8');
17
- } catch {
18
- return undefined;
19
- }
20
- if (!text.trim()) throw new Error('tasks.jsonl is missing meta record');
22
+ type ReadError = JsonlParseError | JsonlValidationError | MissingMetaRecord;
21
23
 
22
- let meta: TaskMeta | undefined;
23
- const tasks: TaskRecord[] = [];
24
- for (const [index, raw] of text.split(/\r?\n/).entries()) {
25
- if (!raw.trim()) continue;
26
- let parsed: unknown;
27
- try {
28
- parsed = JSON.parse(raw);
29
- } catch (error) {
30
- throw new Error(`Invalid JSONL at line ${index + 1}: ${(error as Error).message}`);
24
+ export function readTasksJsonl(
25
+ planDir: string,
26
+ ): Effect.Effect<TasksSnapshot | undefined, ReadError, FileSystem> {
27
+ const path = join(planDir, TASKS_FILE);
28
+ return Effect.gen(function* () {
29
+ const fs = yield* FileSystem;
30
+ // A read failure (missing or unreadable file) is treated as "no snapshot".
31
+ const maybeText = yield* Effect.option(fs.readFileString(path));
32
+ if (Option.isNone(maybeText)) return undefined;
33
+
34
+ const text = maybeText.value;
35
+ if (!text.trim()) return yield* Effect.fail(new MissingMetaRecord({ path }));
36
+
37
+ let meta: TaskMeta | undefined;
38
+ const tasks: TaskRecord[] = [];
39
+ for (const [index, raw] of text.split(/\r?\n/).entries()) {
40
+ if (!raw.trim()) continue;
41
+ const line = index + 1;
42
+
43
+ let parsed: unknown;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ } catch (cause) {
47
+ return yield* Effect.fail(new JsonlParseError({ path, line, cause }));
48
+ }
49
+
50
+ const decoded = decodeTasksLine(parsed);
51
+ if (Either.isLeft(decoded)) {
52
+ return yield* Effect.fail(
53
+ new JsonlValidationError({ path, line, reason: decoded.left.message }),
54
+ );
55
+ }
56
+
57
+ const record = decoded.right;
58
+ if (record._type === 'meta') meta = record;
59
+ else tasks.push(record);
31
60
  }
32
- if (isTaskMeta(parsed)) meta = parsed;
33
- else if (isTaskRecord(parsed)) tasks.push(parsed);
34
- else throw new Error(`Invalid tasks.jsonl record at line ${index + 1}`);
35
- }
36
- if (!meta) throw new Error('tasks.jsonl is missing meta record');
37
- return { meta, tasks };
61
+
62
+ if (!meta) return yield* Effect.fail(new MissingMetaRecord({ path }));
63
+ return { meta, tasks };
64
+ });
38
65
  }
39
66
 
40
- export async function writeTasksJsonl(
67
+ export function writeTasksJsonl(
41
68
  planDir: string,
42
69
  meta: TaskMeta,
43
70
  tasks: TaskRecord[],
44
- ): Promise<void> {
45
- await mkdir(planDir, { recursive: true });
46
- const content = [meta, ...tasks].map((record) => JSON.stringify(record)).join('\n') + '\n';
47
- await writeFileAtomic(join(planDir, TASKS_FILE), content);
71
+ ): Effect.Effect<void, PlanWriteError, FileSystem> {
72
+ return Effect.gen(function* () {
73
+ const fs = yield* FileSystem;
74
+ yield* fs.makeDir(planDir);
75
+ const content = [meta, ...tasks].map((record) => JSON.stringify(record)).join('\n') + '\n';
76
+ yield* fs.writeFileAtomic(join(planDir, TASKS_FILE), content);
77
+ });
48
78
  }
49
79
 
50
- export async function updateTask(
80
+ export function updateTask(
51
81
  planDir: string,
52
82
  taskId: string,
53
83
  updates: Partial<Omit<TaskRecord, '_type' | 'id' | 'created_at'>>,
54
- ): Promise<TaskRecord> {
55
- const snapshot = await readTasksJsonl(planDir);
56
- if (!snapshot) throw new Error(`No tasks.jsonl found in ${planDir}`);
57
- const index = snapshot.tasks.findIndex((task) => task.id === taskId);
58
- if (index === -1) throw new Error(`Task not found: ${taskId}`);
59
- const updated: TaskRecord = {
60
- ...snapshot.tasks[index],
61
- ...updates,
62
- updated_at: new Date().toISOString(),
63
- };
64
- snapshot.tasks[index] = updated;
65
- await writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
66
- return updated;
84
+ ): Effect.Effect<
85
+ TaskRecord,
86
+ ReadError | PlanWriteError | TasksFileNotFound | TaskNotFound,
87
+ FileSystem
88
+ > {
89
+ return Effect.gen(function* () {
90
+ const snapshot = yield* readTasksJsonl(planDir);
91
+ if (!snapshot) return yield* Effect.fail(new TasksFileNotFound({ planDir }));
92
+
93
+ const index = snapshot.tasks.findIndex((task) => task.id === taskId);
94
+ if (index === -1) return yield* Effect.fail(new TaskNotFound({ planDir, taskId }));
95
+
96
+ const updated: TaskRecord = {
97
+ ...snapshot.tasks[index],
98
+ ...updates,
99
+ updated_at: new Date().toISOString(),
100
+ };
101
+ snapshot.tasks[index] = updated;
102
+ yield* writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
103
+ return updated;
104
+ });
67
105
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Pure helpers for reasoning about task status at execution checkpoints.
3
+ *
4
+ * `deferred` tasks are discovered follow-ups kept out of the active queue, so
5
+ * they are excluded from "resolved" checks but block plan finalization.
6
+ */
7
+
8
+ import type { TaskRecord } from './types.js';
9
+
10
+ export function deferredTasks(tasks: readonly TaskRecord[]): TaskRecord[] {
11
+ return tasks.filter((task) => task.status === 'deferred');
12
+ }
13
+
14
+ /**
15
+ * True when no active work remains — every task is done, skipped, or deferred
16
+ * (nothing pending or blocked).
17
+ */
18
+ export function activeTasksResolved(tasks: readonly TaskRecord[]): boolean {
19
+ return tasks.every(
20
+ (task) => task.status === 'done' || task.status === 'skipped' || task.status === 'deferred',
21
+ );
22
+ }
23
+
24
+ /**
25
+ * True when the plan can be marked complete: active work is resolved AND there
26
+ * are no deferred follow-ups awaiting the user's decision.
27
+ */
28
+ export function isPlanFinalizable(tasks: readonly TaskRecord[]): boolean {
29
+ return activeTasksResolved(tasks) && !tasks.some((task) => task.status === 'deferred');
30
+ }
31
+
32
+ /**
33
+ * Reactivate tasks for a resumed run: blocked tasks and deferred follow-ups
34
+ * become pending (mutated in place). Returns true if anything changed.
35
+ */
36
+ export function reactivateForExecution(tasks: TaskRecord[], timestamp: string): boolean {
37
+ let changed = false;
38
+ for (const task of tasks) {
39
+ if (task.status === 'blocked' || task.status === 'deferred') {
40
+ task.status = 'pending';
41
+ task.updated_at = timestamp;
42
+ changed = true;
43
+ }
44
+ }
45
+ return changed;
46
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * add_task tool — available during execution.
3
+ *
4
+ * Lets the agent capture a follow-up task it discovered while implementing.
5
+ * The task is recorded as a *deferred* `discovered` task so it stays out of the
6
+ * active execution queue — the user reviews and decides via `/plan resume`.
7
+ */
8
+
9
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
10
+ import { Text } from '@earendil-works/pi-tui';
11
+ import { Type } from 'typebox';
12
+ import type { PlanData, TaskRecord } from '../types.js';
13
+ import { nextTaskId } from '../utils.js';
14
+
15
+ export interface AddTaskCallbacks {
16
+ getPlan: () => PlanData | undefined;
17
+ onTaskAdded: (task: TaskRecord) => void | Promise<void>;
18
+ }
19
+
20
+ export function registerAddTaskTool(pi: ExtensionAPI, callbacks: AddTaskCallbacks): void {
21
+ pi.registerTool({
22
+ name: 'add_task',
23
+ label: 'Add Task',
24
+ description:
25
+ 'Capture a follow-up task you discovered while implementing. It is recorded as a deferred task for the user to review later — it is NOT executed in this run.',
26
+ promptSnippet: 'Capture a discovered follow-up as a deferred task for later review',
27
+ promptGuidelines: [
28
+ 'Use add_task when you notice worthwhile work outside the current plan while implementing.',
29
+ 'Discovered tasks are deferred for the user to review — do NOT implement them now; continue with the planned tasks.',
30
+ 'Give a clear reason so the user can decide whether the follow-up is worth doing.',
31
+ ],
32
+ parameters: Type.Object({
33
+ description: Type.String({ description: 'Short task label (≤60 chars)' }),
34
+ reason: Type.String({
35
+ description: 'Why this follow-up matters — what you noticed and why it is worth doing',
36
+ }),
37
+ details: Type.Optional(
38
+ Type.String({ description: 'Optional fuller implementation notes for the follow-up' }),
39
+ ),
40
+ depends_on: Type.Optional(Type.Array(Type.String({ description: 'Dependency task ID' }))),
41
+ }),
42
+
43
+ async execute(_toolCallId, params) {
44
+ const plan = callbacks.getPlan();
45
+ if (!plan) throw new Error('No active plan. Cannot add a follow-up task.');
46
+
47
+ const now = new Date().toISOString();
48
+ const task: TaskRecord = {
49
+ _type: 'task',
50
+ id: nextTaskId(plan.tasks.map((candidate) => candidate.id)),
51
+ description: params.description.slice(0, 60),
52
+ details: params.details ?? '',
53
+ status: 'deferred',
54
+ origin: 'discovered',
55
+ depends_on: params.depends_on,
56
+ notes: params.reason,
57
+ created_at: now,
58
+ updated_at: now,
59
+ };
60
+
61
+ await callbacks.onTaskAdded(task);
62
+
63
+ const deferred = plan.tasks.filter((candidate) => candidate.status === 'deferred').length;
64
+ return {
65
+ content: [
66
+ {
67
+ type: 'text' as const,
68
+ text: `Captured follow-up ${task.id}: ${task.description} (deferred for review). ${deferred} follow-up(s) pending. Continue with the planned tasks — do not implement this now.`,
69
+ },
70
+ ],
71
+ details: { task_id: task.id, description: task.description, reason: params.reason },
72
+ };
73
+ },
74
+
75
+ renderCall(args, theme) {
76
+ const description = (args as { description?: string }).description ?? '';
77
+ let content = theme.fg('toolTitle', theme.bold('add_task '));
78
+ content += theme.fg('accent', '+ ');
79
+ content += description;
80
+ return new Text(content, 0, 0);
81
+ },
82
+
83
+ renderResult(result, _options, theme) {
84
+ const details = result.details as { task_id?: string; description?: string } | undefined;
85
+ if (!details) return new Text(theme.fg('dim', 'Follow-up captured'), 0, 0);
86
+ return new Text(
87
+ theme.fg('accent', '+ ') +
88
+ `${theme.fg('muted', details.task_id ?? '')} ${details.description ?? ''} ` +
89
+ theme.fg('dim', '(deferred)'),
90
+ 0,
91
+ 0,
92
+ );
93
+ },
94
+ });
95
+ }
@@ -9,9 +9,11 @@
9
9
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
10
10
  import { Text } from '@earendil-works/pi-tui';
11
11
  import { Type } from 'typebox';
12
- import { mkdir, writeFile } from 'node:fs/promises';
12
+ import { Effect } from 'effect';
13
13
  import { spawn } from 'node:child_process';
14
14
  import { join } from 'node:path';
15
+ import { FileSystem } from '../effects/filesystem.js';
16
+ import type { RunPlanIO } from '../effects/runtime.js';
15
17
  import { renderPrototypeHtml } from '../html/render.js';
16
18
  import { toKebabCase } from '../utils.js';
17
19
 
@@ -34,7 +36,7 @@ function openInBrowser(filePath: string): void {
34
36
  }
35
37
  }
36
38
 
37
- export function registerPreviewPrototypeTool(pi: ExtensionAPI): void {
39
+ export function registerPreviewPrototypeTool(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
38
40
  pi.registerTool({
39
41
  name: 'preview_prototype',
40
42
  label: 'Preview Prototype',
@@ -59,8 +61,13 @@ export function registerPreviewPrototypeTool(pi: ExtensionAPI): void {
59
61
  const filePath = join(PREVIEW_DIR, `${slug}.html`);
60
62
  const html = renderPrototypeHtml(params.title, params.intent, params.pug);
61
63
 
62
- await mkdir(PREVIEW_DIR, { recursive: true });
63
- await writeFile(filePath, html, 'utf-8');
64
+ await runPlanIO(
65
+ Effect.gen(function* () {
66
+ const fs = yield* FileSystem;
67
+ yield* fs.makeDir(PREVIEW_DIR);
68
+ yield* fs.writeFileString(filePath, html);
69
+ }),
70
+ );
64
71
  openInBrowser(filePath);
65
72
  ctx?.ui?.notify(`Prototype written to ${filePath} — opening for review.`, 'info');
66
73
 
@@ -5,10 +5,11 @@
5
5
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
6
6
  import { Text } from '@earendil-works/pi-tui';
7
7
  import { Type } from 'typebox';
8
- import { mkdir } from 'node:fs/promises';
8
+ import { Effect } from 'effect';
9
9
  import { saveHandoff } from '../storage/plan-storage.js';
10
10
  import { writeTasksJsonl } from '../storage/task-storage.js';
11
11
  import { upsertPlanEntry } from '../storage/plans-manifest.js';
12
+ import type { RunPlanIO } from '../effects/runtime.js';
12
13
  import { toKebabCase } from '../utils.js';
13
14
  import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
14
15
 
@@ -16,14 +17,16 @@ export interface SubmitPlanCallbacks {
16
17
  onPlanSubmitted: (planDir: string, plan: PlanData) => void;
17
18
  }
18
19
 
19
- export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCallbacks): void {
20
+ export function registerSubmitPlanTool(
21
+ pi: ExtensionAPI,
22
+ runPlanIO: RunPlanIO,
23
+ callbacks: SubmitPlanCallbacks,
24
+ ): void {
20
25
  pi.registerTool({
21
26
  name: 'submit_plan',
22
27
  label: 'Submit Plan',
23
- description:
24
- 'Finalize a conversational plan with task IDs, JSONL storage, and HANDOFF.md.',
25
- promptSnippet:
26
- 'Finalize the plan with title, handoff, tasks, and dependencies',
28
+ description: 'Finalize a conversational plan with task IDs, JSONL storage, and HANDOFF.md.',
29
+ promptSnippet: 'Finalize the plan with title, handoff, tasks, and dependencies',
27
30
  promptGuidelines: [
28
31
  'Only call submit_plan after shared understanding has been reached with the user.',
29
32
  'Each task needs an id like t-001, a short description, and optional depends_on task IDs.',
@@ -78,10 +81,13 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
78
81
  }));
79
82
  const plan: PlanData = { title: params.title, planName, handoff: params.handoff, tasks };
80
83
 
81
- await mkdir(planDir, { recursive: true });
82
- await writeTasksJsonl(planDir, meta, tasks);
83
- await saveHandoff(planDir, params.handoff);
84
- await upsertPlanEntry(planName, { status: 'in-progress', title: params.title });
84
+ await runPlanIO(
85
+ Effect.gen(function* () {
86
+ yield* writeTasksJsonl(planDir, meta, tasks);
87
+ yield* saveHandoff(planDir, params.handoff);
88
+ yield* upsertPlanEntry(planName, { status: 'in-progress', title: params.title });
89
+ }),
90
+ );
85
91
 
86
92
  callbacks.onPlanSubmitted(planDir, plan);
87
93