@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
@@ -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
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * preview_prototype tool — available during the plan phase.
3
+ *
4
+ * Renders a Pug prototype to a standalone HTML visual aid, writes it under
5
+ * .plans/_prototypes/, and best-effort opens it so the user can react to the
6
+ * visual BEFORE the plan is finalized.
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 { Effect } from 'effect';
13
+ import { spawn } from 'node:child_process';
14
+ import { join } from 'node:path';
15
+ import { FileSystem } from '../effects/filesystem.js';
16
+ import type { RunPlanIO } from '../effects/runtime.js';
17
+ import { renderPrototypeHtml } from '../html/render.js';
18
+ import { toKebabCase } from '../utils.js';
19
+
20
+ const PREVIEW_DIR = '.plans/_prototypes';
21
+
22
+ /** Best-effort open of a file in the OS default app. Never throws. */
23
+ function openInBrowser(filePath: string): void {
24
+ const command =
25
+ process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
26
+ try {
27
+ const child = spawn(command, [filePath], {
28
+ detached: true,
29
+ stdio: 'ignore',
30
+ shell: process.platform === 'win32',
31
+ });
32
+ child.on('error', () => {});
33
+ child.unref();
34
+ } catch {
35
+ // Opening is a convenience — ignore failures (headless, sandbox, etc.).
36
+ }
37
+ }
38
+
39
+ export function registerPreviewPrototypeTool(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
40
+ pi.registerTool({
41
+ name: 'preview_prototype',
42
+ label: 'Preview Prototype',
43
+ description:
44
+ 'Render a Pug prototype to a standalone HTML visual aid and open it for review during planning.',
45
+ promptSnippet: 'Render a Pug UI prototype to HTML and open it for the user to review',
46
+ promptGuidelines: [
47
+ 'Use preview_prototype during planning for visual/UI/layout/style work, before submit_plan.',
48
+ 'The prototype is a convergence aid — show it so the user can react before the plan hardens.',
49
+ 'Keep the Pug self-contained; inline any styles the prototype needs.',
50
+ ],
51
+ parameters: Type.Object({
52
+ title: Type.String({ description: 'Short title for the prototype' }),
53
+ intent: Type.String({
54
+ description: 'One-line description of what this prototype is showing',
55
+ }),
56
+ pug: Type.String({ description: 'Pug markup for the prototype body' }),
57
+ }),
58
+
59
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
60
+ const slug = toKebabCase(params.title) || 'prototype';
61
+ const filePath = join(PREVIEW_DIR, `${slug}.html`);
62
+ const html = renderPrototypeHtml(params.title, params.intent, params.pug);
63
+
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
+ );
71
+ openInBrowser(filePath);
72
+ ctx?.ui?.notify(`Prototype written to ${filePath} — opening for review.`, 'info');
73
+
74
+ return {
75
+ content: [
76
+ {
77
+ type: 'text' as const,
78
+ text: `Prototype "${params.title}" rendered to ${filePath} and opened. Ask the user for feedback before submitting the plan.`,
79
+ },
80
+ ],
81
+ details: { filePath, title: params.title },
82
+ };
83
+ },
84
+
85
+ renderCall(args, theme) {
86
+ const title = (args as { title?: string }).title ?? 'prototype';
87
+ let content = theme.fg('toolTitle', theme.bold('preview_prototype '));
88
+ content += theme.fg('accent', title);
89
+ return new Text(content, 0, 0);
90
+ },
91
+
92
+ renderResult(result, _options, theme) {
93
+ const filePath = (result.details as { filePath?: string } | undefined)?.filePath;
94
+ const label = filePath ? `✓ Prototype → ${filePath}` : '✓ Prototype rendered';
95
+ return new Text(theme.fg('success', label), 0, 0);
96
+ },
97
+ });
98
+ }
@@ -5,11 +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, writeFile } from 'node:fs/promises';
9
- import { renderPlanHtml } from '../html/render.js';
8
+ import { Effect } from 'effect';
10
9
  import { saveHandoff } from '../storage/plan-storage.js';
11
10
  import { writeTasksJsonl } from '../storage/task-storage.js';
12
11
  import { upsertPlanEntry } from '../storage/plans-manifest.js';
12
+ import type { RunPlanIO } from '../effects/runtime.js';
13
13
  import { toKebabCase } from '../utils.js';
14
14
  import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
15
15
 
@@ -17,20 +17,23 @@ export interface SubmitPlanCallbacks {
17
17
  onPlanSubmitted: (planDir: string, plan: PlanData) => void;
18
18
  }
19
19
 
20
- export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCallbacks): void {
20
+ export function registerSubmitPlanTool(
21
+ pi: ExtensionAPI,
22
+ runPlanIO: RunPlanIO,
23
+ callbacks: SubmitPlanCallbacks,
24
+ ): void {
21
25
  pi.registerTool({
22
26
  name: 'submit_plan',
23
27
  label: 'Submit Plan',
24
- description:
25
- 'Finalize a conversational plan with task IDs, JSONL storage, HANDOFF.md, and generated plan.html.',
26
- promptSnippet:
27
- 'Finalize the plan with title, handoff, tasks, dependencies, and optional prototype Pug',
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',
28
30
  promptGuidelines: [
29
31
  'Only call submit_plan after shared understanding has been reached with the user.',
30
32
  'Each task needs an id like t-001, a short description, and optional depends_on task IDs.',
31
33
  "When a different agent or human will execute the plan, include detailed implementation instructions in each task's details field.",
32
34
  'When you are planning and executing yourself (same session), use lightweight checklist-style tasks: just id + description, omit details. Put the real context in the handoff document instead.',
33
35
  'The handoff must be thorough enough that both a human reviewer and executor agent with zero prior context can understand the plan.',
36
+ 'For visual/UI work, preview a prototype with preview_prototype during planning — before submit_plan, not as part of it.',
34
37
  ],
35
38
  parameters: Type.Object({
36
39
  name: Type.String({
@@ -54,9 +57,6 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
54
57
  }),
55
58
  { minItems: 1 },
56
59
  ),
57
- prototype: Type.Optional(
58
- Type.String({ description: 'Optional Pug markup for the prototype section in plan.html' }),
59
- ),
60
60
  }),
61
61
 
62
62
  async execute(_toolCallId, params) {
@@ -81,11 +81,13 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
81
81
  }));
82
82
  const plan: PlanData = { title: params.title, planName, handoff: params.handoff, tasks };
83
83
 
84
- await mkdir(planDir, { recursive: true });
85
- await writeTasksJsonl(planDir, meta, tasks);
86
- await saveHandoff(planDir, params.handoff);
87
- await writeFile(`${planDir}/plan.html`, renderPlanHtml(plan, params.prototype), 'utf-8');
88
- 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
+ );
89
91
 
90
92
  callbacks.onPlanSubmitted(planDir, plan);
91
93
 
@@ -93,7 +95,7 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
93
95
  content: [
94
96
  {
95
97
  type: 'text' as const,
96
- text: `Plan "${params.title}" saved with ${tasks.length} tasks. Review ${planDir}/plan.html, then execute when ready.`,
98
+ text: `Plan "${params.title}" saved with ${tasks.length} tasks in ${planDir}. Execute when ready.`,
97
99
  },
98
100
  ],
99
101
  details: { planDir, plan },