@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.
- package/CHANGELOG.md +17 -0
- package/extensions/plan-mode/__tests__/agent-end-safety.test.ts +3 -3
- package/extensions/plan-mode/__tests__/html-render.test.ts +26 -35
- package/extensions/plan-mode/__tests__/package-skills.test.ts +51 -0
- package/extensions/plan-mode/__tests__/phase-transitions.test.ts +14 -3
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +31 -5
- package/extensions/plan-mode/__tests__/prompts.test.ts +21 -3
- package/extensions/plan-mode/__tests__/task-storage.test.ts +23 -4
- package/extensions/plan-mode/__tests__/types.test.ts +3 -1
- package/extensions/plan-mode/__tests__/utils.test.ts +3 -1
- package/extensions/plan-mode/constants.ts +1 -0
- package/extensions/plan-mode/context-filter.ts +1 -4
- package/extensions/plan-mode/html/render.ts +10 -97
- package/extensions/plan-mode/html/templates/prototype.pug +25 -0
- package/extensions/plan-mode/index.ts +78 -17
- package/extensions/plan-mode/phase-transitions.ts +9 -5
- package/extensions/plan-mode/plan-storage.ts +6 -1
- package/extensions/plan-mode/prompts.ts +5 -6
- package/extensions/plan-mode/resume.ts +20 -5
- package/extensions/plan-mode/state.ts +1 -3
- package/extensions/plan-mode/storage/atomic-write.ts +5 -1
- package/extensions/plan-mode/storage/plan-storage.ts +3 -1
- package/extensions/plan-mode/storage/plans-manifest.ts +26 -6
- package/extensions/plan-mode/storage/task-storage.ts +29 -6
- package/extensions/plan-mode/tools/preview-prototype.ts +91 -0
- package/extensions/plan-mode/tools/submit-plan.ts +33 -13
- package/extensions/plan-mode/tools/update-task.ts +51 -11
- package/package.json +1 -1
- package/skills/planning-context/SKILL.md +42 -0
- package/skills/visual-prototype/SKILL.md +45 -0
- 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 {
|
|
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
|
-
{
|
|
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:
|
|
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 =
|
|
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: {
|
|
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',
|
|
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(
|
|
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.', {
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
|
288
|
-
// No menu needed: plan.jsonl +
|
|
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
|
|
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 {
|
|
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
|
}
|
|
@@ -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
|
|
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.
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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<
|
|
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 {
|
|
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 {
|
|
23
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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(
|
|
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(
|
|
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 = {
|
|
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
|
+
}
|