@dreki-gg/pi-plan-mode 0.15.0 → 0.16.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 (31) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/extensions/plan-mode/__tests__/agent-end-safety.test.ts +3 -3
  3. package/extensions/plan-mode/__tests__/html-render.test.ts +26 -35
  4. package/extensions/plan-mode/__tests__/package-skills.test.ts +51 -0
  5. package/extensions/plan-mode/__tests__/phase-transitions.test.ts +14 -3
  6. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +31 -5
  7. package/extensions/plan-mode/__tests__/prompts.test.ts +21 -3
  8. package/extensions/plan-mode/__tests__/task-storage.test.ts +23 -4
  9. package/extensions/plan-mode/__tests__/types.test.ts +3 -1
  10. package/extensions/plan-mode/__tests__/utils.test.ts +3 -1
  11. package/extensions/plan-mode/constants.ts +1 -0
  12. package/extensions/plan-mode/context-filter.ts +1 -4
  13. package/extensions/plan-mode/html/render.ts +10 -97
  14. package/extensions/plan-mode/html/templates/prototype.pug +25 -0
  15. package/extensions/plan-mode/index.ts +78 -17
  16. package/extensions/plan-mode/phase-transitions.ts +9 -5
  17. package/extensions/plan-mode/plan-storage.ts +6 -1
  18. package/extensions/plan-mode/prompts.ts +5 -6
  19. package/extensions/plan-mode/resume.ts +20 -5
  20. package/extensions/plan-mode/state.ts +1 -3
  21. package/extensions/plan-mode/storage/atomic-write.ts +5 -1
  22. package/extensions/plan-mode/storage/plan-storage.ts +3 -1
  23. package/extensions/plan-mode/storage/plans-manifest.ts +26 -6
  24. package/extensions/plan-mode/storage/task-storage.ts +29 -6
  25. package/extensions/plan-mode/tools/preview-prototype.ts +91 -0
  26. package/extensions/plan-mode/tools/submit-plan.ts +33 -13
  27. package/extensions/plan-mode/tools/update-task.ts +51 -11
  28. package/package.json +1 -1
  29. package/skills/planning-context/SKILL.md +42 -0
  30. package/skills/visual-prototype/SKILL.md +45 -0
  31. package/extensions/plan-mode/html/templates/plan.pug +0 -69
@@ -18,7 +18,14 @@
18
18
 
19
19
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
20
20
  import { Key } from '@earendil-works/pi-tui';
21
- import { PLAN_TOOLS, EXEC_TOOLS, PLAN_MODEL, PLAN_THINKING, EXEC_MODEL, EXEC_THINKING } from './constants.js';
21
+ import {
22
+ PLAN_TOOLS,
23
+ EXEC_TOOLS,
24
+ PLAN_MODEL,
25
+ PLAN_THINKING,
26
+ EXEC_MODEL,
27
+ EXEC_THINKING,
28
+ } from './constants.js';
22
29
  import type { ThinkingLevel } from './types.js';
23
30
  import { PlanModeState } from './state.js';
24
31
  import { loadHandoff, readAndClearExecPending } from './storage/plan-storage.js';
@@ -30,6 +37,7 @@ import { filterExecutionMessages, filterStalePlanMessages } from './context-filt
30
37
  import { enterPlanMode, exitPlanMode, switchModel } from './phase-transitions.js';
31
38
  import { resumePlan, executeInNewSession } from './resume.js';
32
39
  import { registerSubmitPlanTool } from './tools/submit-plan.js';
40
+ import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
33
41
  import { registerUpdateTaskTool } from './tools/update-task.js';
34
42
  import { isSafeCommand, isPlanPath } from './utils.js';
35
43
 
@@ -52,6 +60,8 @@ export default function planMode(pi: ExtensionAPI): void {
52
60
  },
53
61
  });
54
62
 
63
+ registerPreviewPrototypeTool(pi);
64
+
55
65
  registerUpdateTaskTool(pi, {
56
66
  getPlan: () => state.plan,
57
67
  onTaskUpdated: async (taskId, status, notes) => {
@@ -63,7 +73,12 @@ export default function planMode(pi: ExtensionAPI): void {
63
73
  if (notes) task.notes = notes;
64
74
  await writeTasksJsonl(
65
75
  state.planDir,
66
- { _type: 'meta', title: state.plan.title, plan_name: state.plan.planName, created_at: state.plan.tasks[0]?.created_at ?? task.updated_at },
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
+ },
67
82
  state.plan.tasks,
68
83
  );
69
84
  state.persist(pi);
@@ -72,7 +87,8 @@ export default function planMode(pi: ExtensionAPI): void {
72
87
 
73
88
  // ── Commands ──────────────────────────────────────────────────────────────
74
89
  pi.registerCommand('plan', {
75
- description: 'Enter plan mode, optionally with a starting prompt. Use "/plan resume" to pick up an existing plan.',
90
+ description:
91
+ 'Enter plan mode, optionally with a starting prompt. Use "/plan resume" to pick up an existing plan.',
76
92
  handler: async (args, ctx) => {
77
93
  const trimmed = args?.trim();
78
94
  if (trimmed === 'resume') {
@@ -96,7 +112,8 @@ export default function planMode(pi: ExtensionAPI): void {
96
112
  return;
97
113
  }
98
114
  const taskList = state.plan.tasks.map((task) => `${task.id}. ${task.description}`).join('\n');
99
- const first = state.plan.tasks.find((task) => task.status === 'pending')?.id ?? state.plan.tasks[0]?.id;
115
+ const first =
116
+ state.plan.tasks.find((task) => task.status === 'pending')?.id ?? state.plan.tasks[0]?.id;
100
117
  const kickoff = `Execute the following plan: "${state.plan.title}"\n\nTasks:\n${taskList}\n\nStart with ${first}. Call update_task after completing each task.`;
101
118
  await executeInNewSession(ctx, state.planDir, state.plan, kickoff);
102
119
  },
@@ -168,7 +185,11 @@ export default function planMode(pi: ExtensionAPI): void {
168
185
  pi.on('before_agent_start', async () => {
169
186
  if (state.planEnabled) {
170
187
  return {
171
- message: { customType: 'plan-mode-context', content: buildPlanModePrompt(), display: false },
188
+ message: {
189
+ customType: 'plan-mode-context',
190
+ content: buildPlanModePrompt(),
191
+ display: false,
192
+ },
172
193
  };
173
194
  }
174
195
  if (state.executing && state.plan) {
@@ -194,17 +215,31 @@ export default function planMode(pi: ExtensionAPI): void {
194
215
  : `Task ${bs.id}: ${bs.description}`;
195
216
 
196
217
  const choice = await ctx.ui.select(`Task blocked — ${info}\n\nWhat next?`, [
197
- 'Skip this task', 'Provide instructions', 'Re-plan', 'Abort execution',
218
+ 'Skip this task',
219
+ 'Provide instructions',
220
+ 'Re-plan',
221
+ 'Abort execution',
198
222
  ]);
199
223
 
200
224
  if (choice === 'Skip this task') {
201
225
  bs.status = 'skipped';
202
226
  bs.updated_at = new Date().toISOString();
203
- await writeTasksJsonl(state.planDir!, { _type: 'meta', title: state.plan.title, plan_name: state.plan.planName, created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at }, state.plan.tasks);
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,
236
+ );
204
237
  updateUI(state, ctx);
205
238
  state.persist(pi);
206
239
  if (state.plan.tasks.some((s) => s.status === 'pending')) {
207
- pi.sendUserMessage('The blocked task has been skipped. Continue with the next task.', { deliverAs: 'followUp' });
240
+ pi.sendUserMessage('The blocked task has been skipped. Continue with the next task.', {
241
+ deliverAs: 'followUp',
242
+ });
208
243
  }
209
244
  } else if (choice === 'Provide instructions') {
210
245
  const instructions = await ctx.ui.editor('Instructions for the blocked task:', '');
@@ -212,7 +247,16 @@ export default function planMode(pi: ExtensionAPI): void {
212
247
  bs.status = 'pending';
213
248
  bs.notes = undefined;
214
249
  bs.updated_at = new Date().toISOString();
215
- await writeTasksJsonl(state.planDir!, { _type: 'meta', title: state.plan.title, plan_name: state.plan.planName, created_at: state.plan.tasks[0]?.created_at ?? bs.updated_at }, state.plan.tasks);
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,
259
+ );
216
260
  updateUI(state, ctx);
217
261
  state.persist(pi);
218
262
  pi.sendUserMessage(
@@ -235,18 +279,28 @@ export default function planMode(pi: ExtensionAPI): void {
235
279
  }
236
280
 
237
281
  // Check completion
238
- const allResolved = state.plan.tasks.every((s) => s.status === 'done' || s.status === 'skipped');
282
+ const allResolved = state.plan.tasks.every(
283
+ (s) => s.status === 'done' || s.status === 'skipped',
284
+ );
239
285
  if (allResolved) {
240
286
  if (state.planDir) {
241
287
  await upsertPlanEntry(state.plan.planName, { status: 'done', title: state.plan.title });
242
- await writeTasksJsonl(state.planDir, { _type: 'meta', title: state.plan.title, plan_name: state.plan.planName, created_at: state.plan.tasks[0]?.created_at ?? new Date().toISOString() }, state.plan.tasks);
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,
297
+ );
243
298
  }
244
299
  const done = state.plan.tasks.filter((s) => s.status === 'done').length;
245
300
  const skipped = state.plan.tasks.filter((s) => s.status === 'skipped').length;
246
301
  const total = state.plan.tasks.length;
247
- const stats = skipped > 0
248
- ? `${done}/${total} done, ${skipped} skipped`
249
- : `${done}/${total} done`;
302
+ const stats =
303
+ skipped > 0 ? `${done}/${total} done, ${skipped} skipped` : `${done}/${total} done`;
250
304
 
251
305
  // Build a summary of what was actually done from task notes
252
306
  const changeSummary = state.plan.tasks
@@ -284,8 +338,8 @@ export default function planMode(pi: ExtensionAPI): void {
284
338
  return;
285
339
  }
286
340
 
287
- // Plan submitted — user can review plan.html then /plan-exec or type naturally.
288
- // No menu needed: plan.jsonl + plan.html are the source of truth.
341
+ // Plan submitted — user can /plan-exec or type naturally.
342
+ // No menu needed: plan.jsonl + HANDOFF.md are the source of truth.
289
343
  });
290
344
 
291
345
  // ── Event: session restore ────────────────────────────────────────────────
@@ -302,7 +356,14 @@ export default function planMode(pi: ExtensionAPI): void {
302
356
  state.planDir = pending.planDir;
303
357
  {
304
358
  const snapshot = await readTasksJsonl(pending.planDir);
305
- state.plan = snapshot ? { title: snapshot.meta.title, planName: snapshot.meta.plan_name, handoff: (await loadHandoff(pending.planDir)) ?? '', tasks: snapshot.tasks } : undefined;
359
+ state.plan = snapshot
360
+ ? {
361
+ title: snapshot.meta.title,
362
+ planName: snapshot.meta.plan_name,
363
+ handoff: (await loadHandoff(pending.planDir)) ?? '',
364
+ tasks: snapshot.tasks,
365
+ }
366
+ : undefined;
306
367
  }
307
368
  if (state.plan) {
308
369
  state.executing = true;
@@ -5,7 +5,14 @@
5
5
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
6
6
  import type { PlanModeState } from './state.js';
7
7
  import type { ThinkingLevel } from './types.js';
8
- import { PLAN_TOOLS, EXEC_TOOLS, PLAN_MODEL, PLAN_THINKING, EXEC_MODEL, EXEC_THINKING } from './constants.js';
8
+ import {
9
+ PLAN_TOOLS,
10
+ EXEC_TOOLS,
11
+ PLAN_MODEL,
12
+ PLAN_THINKING,
13
+ EXEC_MODEL,
14
+ EXEC_THINKING,
15
+ } from './constants.js';
9
16
  import { updateUI } from './ui.js';
10
17
 
11
18
  export async function switchModel(
@@ -40,10 +47,7 @@ export async function enterPlanMode(
40
47
  pi.setActiveTools(PLAN_TOOLS);
41
48
  await switchModel(pi, ctx, PLAN_MODEL);
42
49
  pi.setThinkingLevel(PLAN_THINKING);
43
- ctx.ui.notify(
44
- `Plan mode ON — ${PLAN_MODEL.provider}/${PLAN_MODEL.id}:${PLAN_THINKING}`,
45
- 'info',
46
- );
50
+ ctx.ui.notify(`Plan mode ON — ${PLAN_MODEL.provider}/${PLAN_MODEL.id}:${PLAN_THINKING}`, 'info');
47
51
  updateUI(state, ctx);
48
52
  state.persist(pi);
49
53
  }
@@ -1 +1,6 @@
1
- export { loadHandoff, readAndClearExecPending, saveHandoff, writeExecPending } from './storage/plan-storage.js';
1
+ export {
2
+ loadHandoff,
3
+ readAndClearExecPending,
4
+ saveHandoff,
5
+ writeExecPending,
6
+ } from './storage/plan-storage.js';
@@ -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
  }
@@ -50,9 +51,7 @@ export function buildExecutionPrompt(plan: PlanData): string | undefined {
50
51
  .join('\n\n');
51
52
 
52
53
  const currentTask = remaining[0];
53
- const currentDetails = currentTask.details
54
- ? `\nDetails: ${currentTask.details}`
55
- : '';
54
+ const currentDetails = currentTask.details ? `\nDetails: ${currentTask.details}` : '';
56
55
 
57
56
  return `[EXECUTING PLAN — FOLLOW THE PLAN EXACTLY]
58
57
 
@@ -2,7 +2,11 @@
2
2
  * Resume and execution handoff — pick up in-progress plans, model picker, new session handoff.
3
3
  */
4
4
 
5
- import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
5
+ import type {
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ ExtensionCommandContext,
9
+ } from '@earendil-works/pi-coding-agent';
6
10
  import type { PlanModeState } from './state.js';
7
11
  import type { PlanData } from './types.js';
8
12
  import { EXEC_THINKING, EXEC_MODEL_OPTIONS } from './constants.js';
@@ -11,7 +15,9 @@ import { loadHandoff, writeExecPending } from './storage/plan-storage.js';
11
15
  import { readTasksJsonl, writeTasksJsonl } from './storage/task-storage.js';
12
16
  import { enterPlanMode } from './phase-transitions.js';
13
17
 
14
- export async function pickExecutionModel(ctx: ExtensionContext): Promise<{ provider: string; id: string } | undefined> {
18
+ export async function pickExecutionModel(
19
+ ctx: ExtensionContext,
20
+ ): Promise<{ provider: string; id: string } | undefined> {
15
21
  const labels = EXEC_MODEL_OPTIONS.map((o) => o.label);
16
22
  const choice = await ctx.ui.select('Execute with:', labels);
17
23
  if (!choice) return undefined;
@@ -38,7 +44,11 @@ export async function executeInNewSession(
38
44
  });
39
45
  }
40
46
 
41
- export async function resumePlan(state: PlanModeState, pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
47
+ export async function resumePlan(
48
+ state: PlanModeState,
49
+ pi: ExtensionAPI,
50
+ ctx: ExtensionCommandContext,
51
+ ): Promise<void> {
42
52
  const manifest = await readPlansManifest();
43
53
  const inProgress = manifest.filter((entry) => entry.status === 'in-progress');
44
54
 
@@ -70,12 +80,17 @@ export async function resumePlan(state: PlanModeState, pi: ExtensionAPI, ctx: Ex
70
80
  tasks: snapshot.tasks,
71
81
  };
72
82
 
73
- const doneCount = state.plan.tasks.filter((task) => task.status === 'done' || task.status === 'skipped').length;
83
+ const doneCount = state.plan.tasks.filter(
84
+ (task) => task.status === 'done' || task.status === 'skipped',
85
+ ).length;
74
86
  const pendingCount = state.plan.tasks.filter((task) => task.status === 'pending').length;
75
87
  const blockedCount = state.plan.tasks.filter((task) => task.status === 'blocked').length;
76
88
 
77
89
  if (pendingCount === 0 && blockedCount === 0) {
78
- ctx.ui.notify(`Plan "${state.plan.title}" is already complete (${doneCount}/${state.plan.tasks.length} done).`, 'info');
90
+ ctx.ui.notify(
91
+ `Plan "${state.plan.title}" is already complete (${doneCount}/${state.plan.tasks.length} done).`,
92
+ 'info',
93
+ );
79
94
  state.plan = undefined;
80
95
  state.planDir = undefined;
81
96
  return;
@@ -25,9 +25,7 @@ export class PlanModeState {
25
25
  }
26
26
 
27
27
  restore(entries: Array<{ type: string; customType?: string; data?: PersistedState }>): void {
28
- const saved = entries
29
- .filter((e) => e.type === 'custom' && e.customType === 'plan-mode')
30
- .pop();
28
+ const saved = entries.filter((e) => e.type === 'custom' && e.customType === 'plan-mode').pop();
31
29
  if (saved?.data) {
32
30
  this.planEnabled = saved.data.planEnabled ?? this.planEnabled;
33
31
  this.executing = saved.data.executing ?? this.executing;
@@ -8,7 +8,11 @@ export interface AtomicWriteOptions {
8
8
  mode?: number;
9
9
  }
10
10
 
11
- export async function writeFileAtomic(path: string, data: string | Buffer, options: AtomicWriteOptions = {}): Promise<void> {
11
+ export async function writeFileAtomic(
12
+ path: string,
13
+ data: string | Buffer,
14
+ options: AtomicWriteOptions = {},
15
+ ): Promise<void> {
12
16
  const dir = dirname(path);
13
17
  const tempPath = join(dir, `.${process.pid}.${randomUUID()}.tmp`);
14
18
  let completed = false;
@@ -11,7 +11,9 @@ export async function writeExecPending(dir: string, config: ExecPendingConfig):
11
11
  await writeFile(`${dir}/${EXEC_PENDING_FILE}`, JSON.stringify(config, null, 2) + '\n', 'utf-8');
12
12
  }
13
13
 
14
- export async function readAndClearExecPending(): Promise<{ planDir: string; config: ExecPendingConfig } | undefined> {
14
+ export async function readAndClearExecPending(): Promise<
15
+ { planDir: string; config: ExecPendingConfig } | undefined
16
+ > {
15
17
  try {
16
18
  const entries = await readdir('.plans', { withFileTypes: true });
17
19
  for (const entry of entries) {
@@ -14,13 +14,22 @@ export interface PlanManifestEntry {
14
14
 
15
15
  export async function readPlansManifest(): Promise<PlanManifestEntry[]> {
16
16
  let text: string;
17
- try { text = await readFile(MANIFEST_PATH, 'utf8'); } catch { return []; }
17
+ try {
18
+ text = await readFile(MANIFEST_PATH, 'utf8');
19
+ } catch {
20
+ return [];
21
+ }
18
22
  const entries: PlanManifestEntry[] = [];
19
23
  for (const [index, raw] of text.split(/\r?\n/).entries()) {
20
24
  if (!raw.trim()) continue;
21
25
  let parsed: unknown;
22
- try { parsed = JSON.parse(raw); } catch (error) { throw new Error(`Invalid plans.jsonl at line ${index + 1}: ${(error as Error).message}`); }
23
- if (!isPlanManifestEntry(parsed)) throw new Error(`Invalid plans.jsonl record at line ${index + 1}`);
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}`);
30
+ }
31
+ if (!isPlanManifestEntry(parsed))
32
+ throw new Error(`Invalid plans.jsonl record at line ${index + 1}`);
24
33
  entries.push(parsed);
25
34
  }
26
35
  return entries;
@@ -28,11 +37,15 @@ export async function readPlansManifest(): Promise<PlanManifestEntry[]> {
28
37
 
29
38
  export async function writePlansManifest(entries: PlanManifestEntry[]): Promise<void> {
30
39
  await mkdir('.plans', { recursive: true });
31
- const content = entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
40
+ const content =
41
+ entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
32
42
  await writeFileAtomic(MANIFEST_PATH, content);
33
43
  }
34
44
 
35
- export async function upsertPlanEntry(name: string, updates: { status: 'in-progress' | 'done'; title?: string }): Promise<void> {
45
+ export async function upsertPlanEntry(
46
+ name: string,
47
+ updates: { status: 'in-progress' | 'done'; title?: string },
48
+ ): Promise<void> {
36
49
  const entries = await readPlansManifest();
37
50
  const now = new Date().toISOString();
38
51
  const index = entries.findIndex((entry) => entry.name === name);
@@ -53,5 +66,12 @@ export async function upsertPlanEntry(name: string, updates: { status: 'in-progr
53
66
  function isPlanManifestEntry(value: unknown): value is PlanManifestEntry {
54
67
  if (typeof value !== 'object' || value === null) return false;
55
68
  const record = value as Record<string, unknown>;
56
- return record._type === 'plan' && typeof record.name === 'string' && (record.status === 'in-progress' || record.status === 'done') && typeof record.title === 'string' && typeof record.created_at === 'string' && (record.completed_at === null || typeof record.completed_at === 'string');
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
+ );
57
77
  }
@@ -5,11 +5,18 @@ import { writeFileAtomic } from './atomic-write.js';
5
5
 
6
6
  const TASKS_FILE = 'tasks.jsonl';
7
7
 
8
- export interface TasksSnapshot { meta: TaskMeta; tasks: TaskRecord[] }
8
+ export interface TasksSnapshot {
9
+ meta: TaskMeta;
10
+ tasks: TaskRecord[];
11
+ }
9
12
 
10
13
  export async function readTasksJsonl(planDir: string): Promise<TasksSnapshot | undefined> {
11
14
  let text: string;
12
- try { text = await readFile(join(planDir, TASKS_FILE), 'utf8'); } catch { return undefined; }
15
+ try {
16
+ text = await readFile(join(planDir, TASKS_FILE), 'utf8');
17
+ } catch {
18
+ return undefined;
19
+ }
13
20
  if (!text.trim()) throw new Error('tasks.jsonl is missing meta record');
14
21
 
15
22
  let meta: TaskMeta | undefined;
@@ -17,7 +24,11 @@ export async function readTasksJsonl(planDir: string): Promise<TasksSnapshot | u
17
24
  for (const [index, raw] of text.split(/\r?\n/).entries()) {
18
25
  if (!raw.trim()) continue;
19
26
  let parsed: unknown;
20
- try { parsed = JSON.parse(raw); } catch (error) { throw new Error(`Invalid JSONL at line ${index + 1}: ${(error as Error).message}`); }
27
+ try {
28
+ parsed = JSON.parse(raw);
29
+ } catch (error) {
30
+ throw new Error(`Invalid JSONL at line ${index + 1}: ${(error as Error).message}`);
31
+ }
21
32
  if (isTaskMeta(parsed)) meta = parsed;
22
33
  else if (isTaskRecord(parsed)) tasks.push(parsed);
23
34
  else throw new Error(`Invalid tasks.jsonl record at line ${index + 1}`);
@@ -26,18 +37,30 @@ export async function readTasksJsonl(planDir: string): Promise<TasksSnapshot | u
26
37
  return { meta, tasks };
27
38
  }
28
39
 
29
- export async function writeTasksJsonl(planDir: string, meta: TaskMeta, tasks: TaskRecord[]): Promise<void> {
40
+ export async function writeTasksJsonl(
41
+ planDir: string,
42
+ meta: TaskMeta,
43
+ tasks: TaskRecord[],
44
+ ): Promise<void> {
30
45
  await mkdir(planDir, { recursive: true });
31
46
  const content = [meta, ...tasks].map((record) => JSON.stringify(record)).join('\n') + '\n';
32
47
  await writeFileAtomic(join(planDir, TASKS_FILE), content);
33
48
  }
34
49
 
35
- export async function updateTask(planDir: string, taskId: string, updates: Partial<Omit<TaskRecord, '_type' | 'id' | 'created_at'>>): Promise<TaskRecord> {
50
+ export async function updateTask(
51
+ planDir: string,
52
+ taskId: string,
53
+ updates: Partial<Omit<TaskRecord, '_type' | 'id' | 'created_at'>>,
54
+ ): Promise<TaskRecord> {
36
55
  const snapshot = await readTasksJsonl(planDir);
37
56
  if (!snapshot) throw new Error(`No tasks.jsonl found in ${planDir}`);
38
57
  const index = snapshot.tasks.findIndex((task) => task.id === taskId);
39
58
  if (index === -1) throw new Error(`Task not found: ${taskId}`);
40
- const updated: TaskRecord = { ...snapshot.tasks[index], ...updates, updated_at: new Date().toISOString() };
59
+ const updated: TaskRecord = {
60
+ ...snapshot.tasks[index],
61
+ ...updates,
62
+ updated_at: new Date().toISOString(),
63
+ };
41
64
  snapshot.tasks[index] = updated;
42
65
  await writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
43
66
  return updated;
@@ -0,0 +1,91 @@
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 { mkdir, writeFile } from 'node:fs/promises';
13
+ import { spawn } from 'node:child_process';
14
+ import { join } from 'node:path';
15
+ import { renderPrototypeHtml } from '../html/render.js';
16
+ import { toKebabCase } from '../utils.js';
17
+
18
+ const PREVIEW_DIR = '.plans/_prototypes';
19
+
20
+ /** Best-effort open of a file in the OS default app. Never throws. */
21
+ function openInBrowser(filePath: string): void {
22
+ const command =
23
+ process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
24
+ try {
25
+ const child = spawn(command, [filePath], {
26
+ detached: true,
27
+ stdio: 'ignore',
28
+ shell: process.platform === 'win32',
29
+ });
30
+ child.on('error', () => {});
31
+ child.unref();
32
+ } catch {
33
+ // Opening is a convenience — ignore failures (headless, sandbox, etc.).
34
+ }
35
+ }
36
+
37
+ export function registerPreviewPrototypeTool(pi: ExtensionAPI): void {
38
+ pi.registerTool({
39
+ name: 'preview_prototype',
40
+ label: 'Preview Prototype',
41
+ description:
42
+ 'Render a Pug prototype to a standalone HTML visual aid and open it for review during planning.',
43
+ promptSnippet: 'Render a Pug UI prototype to HTML and open it for the user to review',
44
+ promptGuidelines: [
45
+ 'Use preview_prototype during planning for visual/UI/layout/style work, before submit_plan.',
46
+ 'The prototype is a convergence aid — show it so the user can react before the plan hardens.',
47
+ 'Keep the Pug self-contained; inline any styles the prototype needs.',
48
+ ],
49
+ parameters: Type.Object({
50
+ title: Type.String({ description: 'Short title for the prototype' }),
51
+ intent: Type.String({
52
+ description: 'One-line description of what this prototype is showing',
53
+ }),
54
+ pug: Type.String({ description: 'Pug markup for the prototype body' }),
55
+ }),
56
+
57
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
58
+ const slug = toKebabCase(params.title) || 'prototype';
59
+ const filePath = join(PREVIEW_DIR, `${slug}.html`);
60
+ const html = renderPrototypeHtml(params.title, params.intent, params.pug);
61
+
62
+ await mkdir(PREVIEW_DIR, { recursive: true });
63
+ await writeFile(filePath, html, 'utf-8');
64
+ openInBrowser(filePath);
65
+ ctx?.ui?.notify(`Prototype written to ${filePath} — opening for review.`, 'info');
66
+
67
+ return {
68
+ content: [
69
+ {
70
+ type: 'text' as const,
71
+ text: `Prototype "${params.title}" rendered to ${filePath} and opened. Ask the user for feedback before submitting the plan.`,
72
+ },
73
+ ],
74
+ details: { filePath, title: params.title },
75
+ };
76
+ },
77
+
78
+ renderCall(args, theme) {
79
+ const title = (args as { title?: string }).title ?? 'prototype';
80
+ let content = theme.fg('toolTitle', theme.bold('preview_prototype '));
81
+ content += theme.fg('accent', title);
82
+ return new Text(content, 0, 0);
83
+ },
84
+
85
+ renderResult(result, _options, theme) {
86
+ const filePath = (result.details as { filePath?: string } | undefined)?.filePath;
87
+ const label = filePath ? `✓ Prototype → ${filePath}` : '✓ Prototype rendered';
88
+ return new Text(theme.fg('success', label), 0, 0);
89
+ },
90
+ });
91
+ }