@dreki-gg/pi-plan-mode 0.20.1 → 0.23.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 +21 -0
- package/README.md +2 -0
- package/extensions/plan-mode/__tests__/html-render.test.ts +37 -23
- package/extensions/plan-mode/__tests__/list-plans.test.ts +253 -0
- package/extensions/plan-mode/__tests__/revise-plan.test.ts +171 -0
- package/extensions/plan-mode/__tests__/set-active-plan.test.ts +72 -0
- package/extensions/plan-mode/commands/list-plans.ts +229 -0
- package/extensions/plan-mode/constants.ts +3 -0
- package/extensions/plan-mode/html/render.ts +41 -18
- package/extensions/plan-mode/index.ts +26 -5
- package/extensions/plan-mode/prompts.ts +3 -1
- package/extensions/plan-mode/resolve-plan.ts +18 -0
- package/extensions/plan-mode/tools/plan-status.ts +2 -1
- package/extensions/plan-mode/tools/preview-prototype.ts +14 -9
- package/extensions/plan-mode/tools/revise-plan.ts +189 -0
- package/extensions/plan-mode/tools/set-active-plan.ts +86 -0
- package/package.json +2 -3
- package/skills/visual-prototype/SKILL.md +4 -2
- package/extensions/plan-mode/html/templates/prototype.pug +0 -25
- package/extensions/plan-mode/pug.d.ts +0 -5
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /plans command — list, filter, and sort plans interactively.
|
|
3
|
+
*
|
|
4
|
+
* Reads from the plans manifest AND task snapshots to build a rich table
|
|
5
|
+
* with status, task counts, and dates. The user can filter by status and
|
|
6
|
+
* sort by different criteria via interactive selectors.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Effect } from 'effect';
|
|
10
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
12
|
+
import { FileSystem } from '../effects/filesystem.js';
|
|
13
|
+
import { readPlansManifest, type PlanManifestEntry } from '../storage/plans-manifest.js';
|
|
14
|
+
import { readTasksJsonl } from '../storage/task-storage.js';
|
|
15
|
+
import type { PlanStatus } from '../types.js';
|
|
16
|
+
|
|
17
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export type SortField = 'name' | 'date-asc' | 'date-desc' | 'tasks';
|
|
20
|
+
export type StatusFilter = 'all' | PlanStatus;
|
|
21
|
+
|
|
22
|
+
export interface PlanListItem {
|
|
23
|
+
name: string;
|
|
24
|
+
title: string;
|
|
25
|
+
status: PlanStatus;
|
|
26
|
+
created_at: string;
|
|
27
|
+
completed_at: string | null;
|
|
28
|
+
totalTasks: number;
|
|
29
|
+
doneTasks: number;
|
|
30
|
+
pendingTasks: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Core logic (pure, testable) ──────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export function filterPlans(plans: PlanListItem[], filter: StatusFilter): PlanListItem[] {
|
|
36
|
+
if (filter === 'all') return plans;
|
|
37
|
+
return plans.filter((p) => p.status === filter);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sortPlans(plans: PlanListItem[], sort: SortField): PlanListItem[] {
|
|
41
|
+
const sorted = [...plans];
|
|
42
|
+
switch (sort) {
|
|
43
|
+
case 'name':
|
|
44
|
+
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
|
45
|
+
break;
|
|
46
|
+
case 'date-asc':
|
|
47
|
+
sorted.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
48
|
+
break;
|
|
49
|
+
case 'date-desc':
|
|
50
|
+
sorted.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
51
|
+
break;
|
|
52
|
+
case 'tasks':
|
|
53
|
+
sorted.sort((a, b) => b.totalTasks - a.totalTasks);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
return sorted;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const STATUS_ICON: Record<PlanStatus, string> = {
|
|
60
|
+
'in-progress': '🔵',
|
|
61
|
+
done: '✅',
|
|
62
|
+
superseded: '🔄',
|
|
63
|
+
abandoned: '❌',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function formatPlanList(plans: PlanListItem[], filter: StatusFilter, sort: SortField): string {
|
|
67
|
+
if (plans.length === 0) {
|
|
68
|
+
return filter === 'all'
|
|
69
|
+
? 'No plans found in .plans/plans.jsonl'
|
|
70
|
+
: `No plans with status "${filter}"`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sortLabel: Record<SortField, string> = {
|
|
74
|
+
name: 'name',
|
|
75
|
+
'date-asc': 'oldest first',
|
|
76
|
+
'date-desc': 'newest first',
|
|
77
|
+
tasks: 'most tasks first',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const header =
|
|
81
|
+
filter === 'all'
|
|
82
|
+
? `All plans (${plans.length}) — sorted by ${sortLabel[sort]}`
|
|
83
|
+
: `Plans: ${filter} (${plans.length}) — sorted by ${sortLabel[sort]}`;
|
|
84
|
+
|
|
85
|
+
const lines = plans.map((p) => {
|
|
86
|
+
const icon = STATUS_ICON[p.status];
|
|
87
|
+
const progress =
|
|
88
|
+
p.totalTasks > 0 ? ` [${p.doneTasks}/${p.totalTasks} tasks]` : ' [no tasks]';
|
|
89
|
+
const date = p.created_at.slice(0, 10);
|
|
90
|
+
return ` ${icon} ${p.name} — ${p.title}${progress} (${date})`;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return `${header}\n${lines.join('\n')}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Data loading (Effect-based) ──────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export function loadPlanListItems(): Effect.Effect<PlanListItem[], never, FileSystem> {
|
|
99
|
+
return Effect.gen(function* () {
|
|
100
|
+
const manifest = yield* Effect.orElseSucceed(readPlansManifest(), () => [] as PlanManifestEntry[]);
|
|
101
|
+
const items: PlanListItem[] = [];
|
|
102
|
+
|
|
103
|
+
for (const entry of manifest) {
|
|
104
|
+
const dir = `.plans/${entry.name}`;
|
|
105
|
+
const snapshot = yield* Effect.orElseSucceed(readTasksJsonl(dir), () => undefined);
|
|
106
|
+
const totalTasks = snapshot?.tasks.length ?? 0;
|
|
107
|
+
const doneTasks = snapshot?.tasks.filter(
|
|
108
|
+
(t) => t.status === 'done' || t.status === 'skipped',
|
|
109
|
+
).length ?? 0;
|
|
110
|
+
const pendingTasks = snapshot?.tasks.filter((t) => t.status === 'pending').length ?? 0;
|
|
111
|
+
|
|
112
|
+
items.push({
|
|
113
|
+
name: entry.name,
|
|
114
|
+
title: entry.title,
|
|
115
|
+
status: entry.status,
|
|
116
|
+
created_at: entry.created_at,
|
|
117
|
+
completed_at: entry.completed_at,
|
|
118
|
+
totalTasks,
|
|
119
|
+
doneTasks,
|
|
120
|
+
pendingTasks,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return items;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Interactive command handler ──────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export async function handleListPlans(
|
|
131
|
+
ctx: ExtensionCommandContext,
|
|
132
|
+
runPlanIO: RunPlanIO,
|
|
133
|
+
args?: string,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const allItems = await runPlanIO(loadPlanListItems());
|
|
136
|
+
|
|
137
|
+
if (allItems.length === 0) {
|
|
138
|
+
ctx.ui.notify('No plans found in .plans/plans.jsonl', 'info');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Parse inline args: /plans [filter] [sort]
|
|
143
|
+
// e.g. /plans done tasks, /plans in-progress date-asc
|
|
144
|
+
let filter: StatusFilter = 'all';
|
|
145
|
+
let sort: SortField = 'date-desc';
|
|
146
|
+
|
|
147
|
+
if (args?.trim()) {
|
|
148
|
+
const parsed = parseArgs(args.trim());
|
|
149
|
+
filter = parsed.filter;
|
|
150
|
+
sort = parsed.sort;
|
|
151
|
+
} else {
|
|
152
|
+
// Interactive mode: ask the user
|
|
153
|
+
const filterChoice = await ctx.ui.select('Filter plans by status:', [
|
|
154
|
+
'All',
|
|
155
|
+
'In-progress',
|
|
156
|
+
'Done',
|
|
157
|
+
'Superseded',
|
|
158
|
+
'Abandoned',
|
|
159
|
+
]);
|
|
160
|
+
if (!filterChoice) return;
|
|
161
|
+
|
|
162
|
+
const filterMap: Record<string, StatusFilter> = {
|
|
163
|
+
All: 'all',
|
|
164
|
+
'In-progress': 'in-progress',
|
|
165
|
+
Done: 'done',
|
|
166
|
+
Superseded: 'superseded',
|
|
167
|
+
Abandoned: 'abandoned',
|
|
168
|
+
};
|
|
169
|
+
filter = filterMap[filterChoice] ?? 'all';
|
|
170
|
+
|
|
171
|
+
const sortChoice = await ctx.ui.select('Sort by:', [
|
|
172
|
+
'Newest first',
|
|
173
|
+
'Oldest first',
|
|
174
|
+
'Most tasks',
|
|
175
|
+
'Name',
|
|
176
|
+
]);
|
|
177
|
+
if (!sortChoice) return;
|
|
178
|
+
|
|
179
|
+
const sortMap: Record<string, SortField> = {
|
|
180
|
+
'Newest first': 'date-desc',
|
|
181
|
+
'Oldest first': 'date-asc',
|
|
182
|
+
'Most tasks': 'tasks',
|
|
183
|
+
Name: 'name',
|
|
184
|
+
};
|
|
185
|
+
sort = sortMap[sortChoice] ?? 'date-desc';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const filtered = filterPlans(allItems, filter);
|
|
189
|
+
const sorted = sortPlans(filtered, sort);
|
|
190
|
+
const output = formatPlanList(sorted, filter, sort);
|
|
191
|
+
|
|
192
|
+
ctx.ui.notify(output, 'info');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Arg parsing ──────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
const FILTER_ALIASES: Record<string, StatusFilter> = {
|
|
198
|
+
all: 'all',
|
|
199
|
+
'in-progress': 'in-progress',
|
|
200
|
+
pending: 'in-progress',
|
|
201
|
+
active: 'in-progress',
|
|
202
|
+
done: 'done',
|
|
203
|
+
completed: 'done',
|
|
204
|
+
superseded: 'superseded',
|
|
205
|
+
abandoned: 'abandoned',
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const SORT_ALIASES: Record<string, SortField> = {
|
|
209
|
+
name: 'name',
|
|
210
|
+
'date-asc': 'date-asc',
|
|
211
|
+
oldest: 'date-asc',
|
|
212
|
+
'date-desc': 'date-desc',
|
|
213
|
+
newest: 'date-desc',
|
|
214
|
+
tasks: 'tasks',
|
|
215
|
+
'task-count': 'tasks',
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export function parseArgs(raw: string): { filter: StatusFilter; sort: SortField } {
|
|
219
|
+
const tokens = raw.toLowerCase().split(/\s+/);
|
|
220
|
+
let filter: StatusFilter = 'all';
|
|
221
|
+
let sort: SortField = 'date-desc';
|
|
222
|
+
|
|
223
|
+
for (const token of tokens) {
|
|
224
|
+
if (FILTER_ALIASES[token]) filter = FILTER_ALIASES[token];
|
|
225
|
+
else if (SORT_ALIASES[token]) sort = SORT_ALIASES[token];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { filter, sort };
|
|
229
|
+
}
|
|
@@ -10,12 +10,14 @@ export const PLAN_TOOLS = [
|
|
|
10
10
|
'find',
|
|
11
11
|
'ls',
|
|
12
12
|
'submit_plan',
|
|
13
|
+
'revise_plan',
|
|
13
14
|
'preview_prototype',
|
|
14
15
|
'write',
|
|
15
16
|
'questionnaire',
|
|
16
17
|
'search_skills',
|
|
17
18
|
'subagent',
|
|
18
19
|
'plan_status',
|
|
20
|
+
'set_active_plan',
|
|
19
21
|
'update_plan',
|
|
20
22
|
'reconcile_plans',
|
|
21
23
|
];
|
|
@@ -29,6 +31,7 @@ export const EXEC_TOOLS = [
|
|
|
29
31
|
'update_tasks',
|
|
30
32
|
'add_task',
|
|
31
33
|
'plan_status',
|
|
34
|
+
'set_active_plan',
|
|
32
35
|
'update_plan',
|
|
33
36
|
'reconcile_plans',
|
|
34
37
|
];
|
|
@@ -1,24 +1,47 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Prototype document builder.
|
|
3
|
+
*
|
|
4
|
+
* A prototype is a planning-phase visual aid the agent authors freely — there
|
|
5
|
+
* is no template engine and no imposed theme. Whatever HTML the agent (or a
|
|
6
|
+
* delegated ux-designer subagent) writes is what the user sees.
|
|
7
|
+
*
|
|
8
|
+
* The only thing this does is tolerate a bare fragment: if the agent passes
|
|
9
|
+
* body-only markup instead of a complete page, we drop it into a minimal,
|
|
10
|
+
* UNSTYLED shell so the browser still renders it. No fonts, colors, or layout
|
|
11
|
+
* are injected — the design is entirely the agent's.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function escapeHtml(value: string): string {
|
|
15
|
+
return value
|
|
16
|
+
.replace(/&/g, '&')
|
|
17
|
+
.replace(/</g, '<')
|
|
18
|
+
.replace(/>/g, '>')
|
|
19
|
+
.replace(/"/g, '"');
|
|
20
|
+
}
|
|
5
21
|
|
|
6
|
-
|
|
22
|
+
/** True when the markup is already a complete HTML page. */
|
|
23
|
+
function isFullDocument(markup: string): boolean {
|
|
24
|
+
return /<!doctype\s+html|<html[\s>]/i.test(markup);
|
|
25
|
+
}
|
|
7
26
|
|
|
8
27
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* not a plan dump — no tasks, no handoff.
|
|
28
|
+
* Returns a standalone HTML document for the prototype. Full documents are
|
|
29
|
+
* passed through verbatim; fragments are wrapped in a barebones shell.
|
|
12
30
|
*/
|
|
13
|
-
export function
|
|
14
|
-
const
|
|
15
|
-
|
|
31
|
+
export function buildPrototypeDocument(title: string, html: string): string {
|
|
32
|
+
const markup = html.trim();
|
|
33
|
+
|
|
34
|
+
if (isFullDocument(markup)) return markup;
|
|
16
35
|
|
|
17
|
-
return
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
36
|
+
return `<!doctype html>
|
|
37
|
+
<html lang="en">
|
|
38
|
+
<head>
|
|
39
|
+
<meta charset="utf-8">
|
|
40
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
41
|
+
<title>${escapeHtml(title)}</title>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
${markup}
|
|
45
|
+
</body>
|
|
46
|
+
</html>`;
|
|
24
47
|
}
|
|
@@ -38,17 +38,20 @@ import { filterExecutionMessages, filterStalePlanMessages } from './context-filt
|
|
|
38
38
|
import { activeTasksResolved, deferredTasks, isPlanFinalizable } from './task-status.js';
|
|
39
39
|
import { enterPlanMode, exitPlanMode, switchModel } from './phase-transitions.js';
|
|
40
40
|
import { resumePlan, executeInNewSession } from './resume.js';
|
|
41
|
-
import { resolveActivePlan } from './resolve-plan.js';
|
|
41
|
+
import { resolveActivePlan, focusActivePlan } from './resolve-plan.js';
|
|
42
42
|
import { collectPlanDrift } from './reconcile.js';
|
|
43
43
|
import { registerSubmitPlanTool } from './tools/submit-plan.js';
|
|
44
|
+
import { registerRevisePlanTool } from './tools/revise-plan.js';
|
|
44
45
|
import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
|
|
45
46
|
import { registerUpdateTaskTool } from './tools/update-task.js';
|
|
46
47
|
import { registerUpdateTasksTool } from './tools/update-tasks.js';
|
|
47
48
|
import { registerAddTaskTool } from './tools/add-task.js';
|
|
48
49
|
import { registerPlanStatusTool } from './tools/plan-status.js';
|
|
50
|
+
import { registerSetActivePlanTool } from './tools/set-active-plan.js';
|
|
49
51
|
import { registerUpdatePlanTool } from './tools/update-plan.js';
|
|
50
52
|
import { registerReconcilePlansTool } from './tools/reconcile-plans.js';
|
|
51
53
|
import { isSafeCommand, isPlanPath } from './utils.js';
|
|
54
|
+
import { handleListPlans } from './commands/list-plans.js';
|
|
52
55
|
|
|
53
56
|
export default function planMode(pi: ExtensionAPI): void {
|
|
54
57
|
const state = new PlanModeState();
|
|
@@ -71,6 +74,15 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
71
74
|
},
|
|
72
75
|
});
|
|
73
76
|
|
|
77
|
+
registerRevisePlanTool(pi, runPlanIO, {
|
|
78
|
+
resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
|
|
79
|
+
onPlanRevised: (dir, revisedPlan) => {
|
|
80
|
+
state.planDir = dir;
|
|
81
|
+
state.plan = revisedPlan;
|
|
82
|
+
state.persist(pi);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
74
86
|
registerPreviewPrototypeTool(pi, runPlanIO);
|
|
75
87
|
|
|
76
88
|
// Shared task-write closure: mutate the in-memory task, persist tasks.jsonl,
|
|
@@ -169,6 +181,10 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
169
181
|
},
|
|
170
182
|
});
|
|
171
183
|
|
|
184
|
+
registerSetActivePlanTool(pi, {
|
|
185
|
+
setActivePlan: (name) => focusActivePlan(state, pi, runPlanIO, name),
|
|
186
|
+
});
|
|
187
|
+
|
|
172
188
|
registerUpdatePlanTool(pi, runPlanIO);
|
|
173
189
|
registerReconcilePlansTool(pi, runPlanIO);
|
|
174
190
|
|
|
@@ -220,10 +236,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
220
236
|
ctx.ui.notify('Usage: /plan focus <name>', 'info');
|
|
221
237
|
return;
|
|
222
238
|
}
|
|
223
|
-
|
|
224
|
-
state.plan = undefined;
|
|
225
|
-
state.planDir = undefined;
|
|
226
|
-
const { plan, candidates } = await resolveActivePlan(state, pi, runPlanIO, { name });
|
|
239
|
+
const { plan, candidates } = await focusActivePlan(state, pi, runPlanIO, name);
|
|
227
240
|
if (plan) {
|
|
228
241
|
ctx.ui.notify(`Focused plan: ${plan.title} (${plan.planName})`, 'info');
|
|
229
242
|
} else {
|
|
@@ -256,6 +269,14 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
256
269
|
},
|
|
257
270
|
});
|
|
258
271
|
|
|
272
|
+
pi.registerCommand('plans', {
|
|
273
|
+
description:
|
|
274
|
+
'List all plans with filtering and sorting. Usage: /plans [filter] [sort]. Filters: all, in-progress, done, superseded, abandoned. Sorts: newest, oldest, tasks, name.',
|
|
275
|
+
handler: async (args, ctx) => {
|
|
276
|
+
await handleListPlans(ctx, runPlanIO, args);
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
259
280
|
pi.registerCommand('todos', {
|
|
260
281
|
description: 'Show current plan progress',
|
|
261
282
|
handler: async (_args, ctx) => {
|
|
@@ -33,7 +33,9 @@ Plan weight:
|
|
|
33
33
|
|
|
34
34
|
submit_plan is finalization, not the starting point. It records tasks and the handoff — it does not generate HTML.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
If a plan with the same name already exists and the user asks for follow-up changes (e.g. you submitted prematurely), call revise_plan instead of submit_plan. It rewrites the existing plan in place — pass only the fields that change (title, handoff, and/or tasks); status and notes are preserved for tasks whose id is unchanged.
|
|
37
|
+
|
|
38
|
+
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. You author the HTML freely — no template engine, no imposed theme — and can delegate the markup to the ux-designer subagent for real design taste. The visual-prototype skill covers when and how.
|
|
37
39
|
|
|
38
40
|
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.`;
|
|
39
41
|
}
|
|
@@ -116,3 +116,21 @@ export async function resolveActivePlan(
|
|
|
116
116
|
|
|
117
117
|
return { plan: undefined, candidates: inProgress.map((entry) => entry.name) };
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pin a plan as the active one (the tool/command form of `/plan focus`).
|
|
122
|
+
*
|
|
123
|
+
* Clears any stale in-memory plan first so the hint always re-attaches from
|
|
124
|
+
* disk, then resolves by name. Returns the same `ResolvedPlan` shape so callers
|
|
125
|
+
* can report success or surface in-progress candidates on a miss.
|
|
126
|
+
*/
|
|
127
|
+
export async function focusActivePlan(
|
|
128
|
+
state: PlanModeState,
|
|
129
|
+
pi: ExtensionAPI,
|
|
130
|
+
runPlanIO: RunPlanIO,
|
|
131
|
+
name: string,
|
|
132
|
+
): Promise<ResolvedPlan> {
|
|
133
|
+
state.plan = undefined;
|
|
134
|
+
state.planDir = undefined;
|
|
135
|
+
return resolveActivePlan(state, pi, runPlanIO, { name });
|
|
136
|
+
}
|
|
@@ -49,6 +49,7 @@ export function registerPlanStatusTool(pi: ExtensionAPI, callbacks: PlanStatusCa
|
|
|
49
49
|
promptGuidelines: [
|
|
50
50
|
'Call plan_status when unsure whether a plan is active or which task ids exist — it is read-only and never mutates state.',
|
|
51
51
|
'Prefer it over guessing task ids; the returned ids are what update_task expects.',
|
|
52
|
+
'When it reports multiple in-progress plans, call set_active_plan to pin one before update_task / add_task.',
|
|
52
53
|
],
|
|
53
54
|
parameters: Type.Object({
|
|
54
55
|
plan: Type.Optional(
|
|
@@ -74,7 +75,7 @@ export function registerPlanStatusTool(pi: ExtensionAPI, callbacks: PlanStatusCa
|
|
|
74
75
|
})
|
|
75
76
|
.join('\n');
|
|
76
77
|
const text =
|
|
77
|
-
`No single active plan — ${summaries.length} in-progress. Pass { plan: "<name>" } to target one.\n` +
|
|
78
|
+
`No single active plan — ${summaries.length} in-progress. Pass { plan: "<name>" } or call set_active_plan to target one.\n` +
|
|
78
79
|
`Progress:\n${rows}`;
|
|
79
80
|
return {
|
|
80
81
|
content: [{ type: 'text' as const, text }],
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* preview_prototype tool — available during the plan phase.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* .plans/_prototypes/, and best-effort opens
|
|
6
|
-
* visual BEFORE the plan is finalized.
|
|
4
|
+
* Persists a freeform HTML prototype the agent authored (no template engine,
|
|
5
|
+
* no imposed theme), writes it under .plans/_prototypes/, and best-effort opens
|
|
6
|
+
* it so the user can react to the visual BEFORE the plan is finalized.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
@@ -14,7 +14,7 @@ import { spawn } from 'node:child_process';
|
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { FileSystem } from '../effects/filesystem.js';
|
|
16
16
|
import type { RunPlanIO } from '../effects/runtime.js';
|
|
17
|
-
import {
|
|
17
|
+
import { buildPrototypeDocument } from '../html/render.js';
|
|
18
18
|
import { toKebabCase } from '../utils.js';
|
|
19
19
|
|
|
20
20
|
const PREVIEW_DIR = '.plans/_prototypes';
|
|
@@ -41,25 +41,30 @@ export function registerPreviewPrototypeTool(pi: ExtensionAPI, runPlanIO: RunPla
|
|
|
41
41
|
name: 'preview_prototype',
|
|
42
42
|
label: 'Preview Prototype',
|
|
43
43
|
description:
|
|
44
|
-
'
|
|
45
|
-
promptSnippet: '
|
|
44
|
+
'Open a freeform HTML prototype for review during planning. You write the HTML — any markup, styles, fonts, and scripts you want — and the tool just persists and opens it.',
|
|
45
|
+
promptSnippet: 'Open a freeform HTML prototype for the user to review',
|
|
46
46
|
promptGuidelines: [
|
|
47
47
|
'Use preview_prototype during planning for visual/UI/layout/style work, before submit_plan.',
|
|
48
48
|
'The prototype is a convergence aid — show it so the user can react before the plan hardens.',
|
|
49
|
-
'
|
|
49
|
+
'You have full freedom over the HTML: there is no template engine and no imposed theme. Avoid generic boilerplate — design something that fits the actual product.',
|
|
50
|
+
'For real design taste, consider delegating the markup to the ux-designer subagent and passing its HTML straight through.',
|
|
51
|
+
'Pass a complete, self-contained HTML document (doctype + html/head/body). Inline any styles or scripts; assume nothing about a host page.',
|
|
50
52
|
],
|
|
51
53
|
parameters: Type.Object({
|
|
52
54
|
title: Type.String({ description: 'Short title for the prototype' }),
|
|
53
55
|
intent: Type.String({
|
|
54
56
|
description: 'One-line description of what this prototype is showing',
|
|
55
57
|
}),
|
|
56
|
-
|
|
58
|
+
html: Type.String({
|
|
59
|
+
description:
|
|
60
|
+
'Complete, self-contained HTML document for the prototype (your own markup, styles, and scripts). A bare fragment is also accepted and wrapped in a minimal unstyled shell.',
|
|
61
|
+
}),
|
|
57
62
|
}),
|
|
58
63
|
|
|
59
64
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
60
65
|
const slug = toKebabCase(params.title) || 'prototype';
|
|
61
66
|
const filePath = join(PREVIEW_DIR, `${slug}.html`);
|
|
62
|
-
const html =
|
|
67
|
+
const html = buildPrototypeDocument(params.title, params.html);
|
|
63
68
|
|
|
64
69
|
await runPlanIO(
|
|
65
70
|
Effect.gen(function* () {
|