@dreki-gg/pi-plan-mode 0.21.0 → 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 +12 -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/commands/list-plans.ts +229 -0
- package/extensions/plan-mode/html/render.ts +41 -18
- package/extensions/plan-mode/index.ts +9 -0
- package/extensions/plan-mode/prompts.ts +1 -1
- package/extensions/plan-mode/tools/preview-prototype.ts +14 -9
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.23.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- `preview_prototype` is now freeform. The tool dropped its Pug dependency and the imposed wrapper template (fixed dark theme, badge header, purple-accent panel) that made every prototype look the same. It now takes a `html` parameter — a complete, self-contained HTML document the agent authors with full freedom over markup, fonts, colors, layout, and inline scripts — and only persists and opens it. A bare fragment is tolerated and wrapped in a minimal unstyled shell. The `visual-prototype` skill and plan-mode prompt now steer the agent toward product-fitting designs and toward delegating the markup to the `ux-designer` subagent for real design taste, instead of generic boilerplate.
|
|
8
|
+
|
|
9
|
+
## 0.22.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- Add `/plans` command to list, filter, and sort plans interactively. Supports filtering by status (in-progress, done, superseded, abandoned) and sorting by date, task count, or name. Works both interactively and with inline args (e.g. `/plans done oldest`).
|
|
14
|
+
|
|
3
15
|
## 0.21.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1,34 +1,48 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
describe('
|
|
5
|
-
test('
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
import { buildPrototypeDocument } from '../html/render.js';
|
|
3
|
+
|
|
4
|
+
describe('buildPrototypeDocument', () => {
|
|
5
|
+
test('returns a full HTML document untouched, with no imposed wrapper', () => {
|
|
6
|
+
const doc = `<!doctype html>
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head><meta charset="utf-8"><title>My own design</title></head>
|
|
9
|
+
<body style="background: salmon"><h1>Hand-crafted</h1></body>
|
|
10
|
+
</html>`;
|
|
11
|
+
|
|
12
|
+
const html = buildPrototypeDocument('Sidebar redesign', doc);
|
|
13
|
+
|
|
14
|
+
// Author's document is preserved verbatim — no badge, no theme, no panel.
|
|
15
|
+
expect(html).toBe(doc.trim());
|
|
16
|
+
expect(html).not.toContain('Prototype ·');
|
|
17
|
+
expect(html).not.toContain('class="prototype"');
|
|
18
|
+
expect(html).not.toContain('Inter');
|
|
11
19
|
});
|
|
12
20
|
|
|
13
|
-
test('
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
expect(html).
|
|
17
|
-
expect(html).toContain('Product card');
|
|
21
|
+
test('recognizes a full document via <html> even without a doctype', () => {
|
|
22
|
+
const doc = '<html><body><main>Hello</main></body></html>';
|
|
23
|
+
const html = buildPrototypeDocument('Card', doc);
|
|
24
|
+
expect(html).toBe(doc);
|
|
18
25
|
});
|
|
19
26
|
|
|
20
|
-
test('
|
|
21
|
-
const html =
|
|
27
|
+
test('wraps a bare fragment in a minimal, unstyled shell', () => {
|
|
28
|
+
const html = buildPrototypeDocument('Card', '<div class="card">Product card</div>');
|
|
22
29
|
|
|
23
|
-
expect(html).
|
|
24
|
-
expect(html).
|
|
25
|
-
expect(html).
|
|
30
|
+
expect(html).toMatch(/^<!doctype html>/i);
|
|
31
|
+
expect(html).toContain('<title>Card</title>');
|
|
32
|
+
expect(html).toContain('<div class="card">Product card</div>');
|
|
33
|
+
// The shell imposes no theme of its own.
|
|
34
|
+
expect(html).not.toContain('background');
|
|
35
|
+
expect(html).not.toContain('Inter');
|
|
26
36
|
});
|
|
27
37
|
|
|
28
|
-
test('
|
|
29
|
-
const html =
|
|
38
|
+
test('escapes the title when used in the fallback shell', () => {
|
|
39
|
+
const html = buildPrototypeDocument('A & B <script>', '<p>hi</p>');
|
|
40
|
+
expect(html).toContain('<title>A & B <script></title>');
|
|
41
|
+
});
|
|
30
42
|
|
|
31
|
-
|
|
32
|
-
|
|
43
|
+
test('handles an empty body without throwing', () => {
|
|
44
|
+
const html = buildPrototypeDocument('Empty', '');
|
|
45
|
+
expect(html).toMatch(/^<!doctype html>/i);
|
|
46
|
+
expect(html).toContain('<title>Empty</title>');
|
|
33
47
|
});
|
|
34
48
|
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { chdir } from 'node:process';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { makePlanRuntime } from '../effects/runtime.js';
|
|
7
|
+
import { upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
8
|
+
import { writeTasksJsonl } from '../storage/task-storage.js';
|
|
9
|
+
import type { TaskMeta, TaskRecord, PlanStatus } from '../types.js';
|
|
10
|
+
import {
|
|
11
|
+
filterPlans,
|
|
12
|
+
sortPlans,
|
|
13
|
+
formatPlanList,
|
|
14
|
+
parseArgs,
|
|
15
|
+
loadPlanListItems,
|
|
16
|
+
type PlanListItem,
|
|
17
|
+
type SortField,
|
|
18
|
+
type StatusFilter,
|
|
19
|
+
} from '../commands/list-plans.js';
|
|
20
|
+
|
|
21
|
+
const runPlanIO = makePlanRuntime();
|
|
22
|
+
const originalCwd = process.cwd();
|
|
23
|
+
let dir: string;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-list-'));
|
|
27
|
+
chdir(dir);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
chdir(originalCwd);
|
|
32
|
+
await rm(dir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const meta = (name: string): TaskMeta => ({
|
|
38
|
+
_type: 'meta',
|
|
39
|
+
title: `Title ${name}`,
|
|
40
|
+
plan_name: name,
|
|
41
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const task = (id: string, status: 'done' | 'pending' = 'done'): TaskRecord => ({
|
|
45
|
+
_type: 'task',
|
|
46
|
+
id,
|
|
47
|
+
description: `task ${id}`,
|
|
48
|
+
status,
|
|
49
|
+
origin: 'plan',
|
|
50
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
51
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function makePlanItem(overrides: Partial<PlanListItem> & { name: string }): PlanListItem {
|
|
55
|
+
return {
|
|
56
|
+
title: `Title ${overrides.name}`,
|
|
57
|
+
status: 'in-progress',
|
|
58
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
59
|
+
completed_at: null,
|
|
60
|
+
totalTasks: 3,
|
|
61
|
+
doneTasks: 1,
|
|
62
|
+
pendingTasks: 2,
|
|
63
|
+
...overrides,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── filterPlans ──────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
describe('filterPlans', () => {
|
|
70
|
+
const plans: PlanListItem[] = [
|
|
71
|
+
makePlanItem({ name: 'alpha', status: 'in-progress' }),
|
|
72
|
+
makePlanItem({ name: 'beta', status: 'done' }),
|
|
73
|
+
makePlanItem({ name: 'gamma', status: 'abandoned' }),
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
test('returns all when filter is "all"', () => {
|
|
77
|
+
expect(filterPlans(plans, 'all')).toHaveLength(3);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('filters by in-progress', () => {
|
|
81
|
+
const result = filterPlans(plans, 'in-progress');
|
|
82
|
+
expect(result).toHaveLength(1);
|
|
83
|
+
expect(result[0].name).toBe('alpha');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('filters by done', () => {
|
|
87
|
+
const result = filterPlans(plans, 'done');
|
|
88
|
+
expect(result).toHaveLength(1);
|
|
89
|
+
expect(result[0].name).toBe('beta');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('filters by abandoned', () => {
|
|
93
|
+
const result = filterPlans(plans, 'abandoned');
|
|
94
|
+
expect(result).toHaveLength(1);
|
|
95
|
+
expect(result[0].name).toBe('gamma');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('returns empty when no match', () => {
|
|
99
|
+
expect(filterPlans(plans, 'superseded')).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── sortPlans ────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe('sortPlans', () => {
|
|
106
|
+
const plans: PlanListItem[] = [
|
|
107
|
+
makePlanItem({ name: 'charlie', created_at: '2026-03-01T00:00:00.000Z', totalTasks: 2 }),
|
|
108
|
+
makePlanItem({ name: 'alpha', created_at: '2026-01-01T00:00:00.000Z', totalTasks: 5 }),
|
|
109
|
+
makePlanItem({ name: 'bravo', created_at: '2026-02-01T00:00:00.000Z', totalTasks: 1 }),
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
test('sorts by name', () => {
|
|
113
|
+
const result = sortPlans(plans, 'name');
|
|
114
|
+
expect(result.map((p) => p.name)).toEqual(['alpha', 'bravo', 'charlie']);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('sorts by date ascending (oldest first)', () => {
|
|
118
|
+
const result = sortPlans(plans, 'date-asc');
|
|
119
|
+
expect(result.map((p) => p.name)).toEqual(['alpha', 'bravo', 'charlie']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('sorts by date descending (newest first)', () => {
|
|
123
|
+
const result = sortPlans(plans, 'date-desc');
|
|
124
|
+
expect(result.map((p) => p.name)).toEqual(['charlie', 'bravo', 'alpha']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('sorts by task count (most tasks first)', () => {
|
|
128
|
+
const result = sortPlans(plans, 'tasks');
|
|
129
|
+
expect(result.map((p) => p.name)).toEqual(['alpha', 'charlie', 'bravo']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('does not mutate the original array', () => {
|
|
133
|
+
const original = [...plans];
|
|
134
|
+
sortPlans(plans, 'name');
|
|
135
|
+
expect(plans.map((p) => p.name)).toEqual(original.map((p) => p.name));
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── formatPlanList ───────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe('formatPlanList', () => {
|
|
142
|
+
test('shows message when empty and filter is all', () => {
|
|
143
|
+
expect(formatPlanList([], 'all', 'date-desc')).toContain('No plans found');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('shows filter-specific message when empty', () => {
|
|
147
|
+
expect(formatPlanList([], 'done', 'date-desc')).toContain('No plans with status "done"');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('includes plan details in output', () => {
|
|
151
|
+
const plans = [makePlanItem({ name: 'alpha', title: 'Alpha Plan', totalTasks: 5, doneTasks: 3 })];
|
|
152
|
+
const output = formatPlanList(plans, 'all', 'date-desc');
|
|
153
|
+
expect(output).toContain('alpha');
|
|
154
|
+
expect(output).toContain('Alpha Plan');
|
|
155
|
+
expect(output).toContain('3/5 tasks');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('shows header with count and sort', () => {
|
|
159
|
+
const plans = [makePlanItem({ name: 'a' }), makePlanItem({ name: 'b' })];
|
|
160
|
+
const output = formatPlanList(plans, 'all', 'tasks');
|
|
161
|
+
expect(output).toContain('All plans (2)');
|
|
162
|
+
expect(output).toContain('most tasks first');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── parseArgs ────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
describe('parseArgs', () => {
|
|
169
|
+
test('parses filter only', () => {
|
|
170
|
+
expect(parseArgs('done')).toEqual({ filter: 'done', sort: 'date-desc' });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('parses sort only', () => {
|
|
174
|
+
expect(parseArgs('oldest')).toEqual({ filter: 'all', sort: 'date-asc' });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('parses filter and sort together', () => {
|
|
178
|
+
expect(parseArgs('in-progress tasks')).toEqual({ filter: 'in-progress', sort: 'tasks' });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('accepts aliases', () => {
|
|
182
|
+
expect(parseArgs('pending newest')).toEqual({ filter: 'in-progress', sort: 'date-desc' });
|
|
183
|
+
expect(parseArgs('active oldest')).toEqual({ filter: 'in-progress', sort: 'date-asc' });
|
|
184
|
+
expect(parseArgs('completed name')).toEqual({ filter: 'done', sort: 'name' });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('defaults to all + date-desc for unknown tokens', () => {
|
|
188
|
+
expect(parseArgs('unknown gibberish')).toEqual({ filter: 'all', sort: 'date-desc' });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('is case-insensitive', () => {
|
|
192
|
+
expect(parseArgs('DONE TASKS')).toEqual({ filter: 'done', sort: 'tasks' });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── loadPlanListItems (integration) ──────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
describe('loadPlanListItems', () => {
|
|
199
|
+
test('returns empty for no plans', async () => {
|
|
200
|
+
const items = await runPlanIO(loadPlanListItems());
|
|
201
|
+
expect(items).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('loads plans with task counts', async () => {
|
|
205
|
+
await runPlanIO(upsertPlanEntry('alpha', { status: 'in-progress', title: 'Alpha' }));
|
|
206
|
+
await runPlanIO(
|
|
207
|
+
writeTasksJsonl('.plans/alpha', meta('alpha'), [
|
|
208
|
+
task('t-001', 'done'),
|
|
209
|
+
task('t-002', 'pending'),
|
|
210
|
+
task('t-003', 'pending'),
|
|
211
|
+
]),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const items = await runPlanIO(loadPlanListItems());
|
|
215
|
+
expect(items).toHaveLength(1);
|
|
216
|
+
expect(items[0].name).toBe('alpha');
|
|
217
|
+
expect(items[0].totalTasks).toBe(3);
|
|
218
|
+
expect(items[0].doneTasks).toBe(1);
|
|
219
|
+
expect(items[0].pendingTasks).toBe(2);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('handles plans without task files', async () => {
|
|
223
|
+
await runPlanIO(upsertPlanEntry('orphan', { status: 'in-progress', title: 'Orphan' }));
|
|
224
|
+
|
|
225
|
+
const items = await runPlanIO(loadPlanListItems());
|
|
226
|
+
expect(items).toHaveLength(1);
|
|
227
|
+
expect(items[0].totalTasks).toBe(0);
|
|
228
|
+
expect(items[0].doneTasks).toBe(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('loads multiple plans', async () => {
|
|
232
|
+
await runPlanIO(upsertPlanEntry('alpha', { status: 'in-progress', title: 'Alpha' }));
|
|
233
|
+
await runPlanIO(upsertPlanEntry('beta', { status: 'done', title: 'Beta' }));
|
|
234
|
+
await runPlanIO(
|
|
235
|
+
writeTasksJsonl('.plans/alpha', meta('alpha'), [task('t-001', 'pending')]),
|
|
236
|
+
);
|
|
237
|
+
await runPlanIO(
|
|
238
|
+
writeTasksJsonl('.plans/beta', meta('beta'), [task('t-001', 'done'), task('t-002', 'done')]),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const items = await runPlanIO(loadPlanListItems());
|
|
242
|
+
expect(items).toHaveLength(2);
|
|
243
|
+
|
|
244
|
+
const alpha = items.find((i) => i.name === 'alpha')!;
|
|
245
|
+
expect(alpha.status).toBe('in-progress');
|
|
246
|
+
expect(alpha.totalTasks).toBe(1);
|
|
247
|
+
|
|
248
|
+
const beta = items.find((i) => i.name === 'beta')!;
|
|
249
|
+
expect(beta.status).toBe('done');
|
|
250
|
+
expect(beta.totalTasks).toBe(2);
|
|
251
|
+
expect(beta.doneTasks).toBe(2);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -51,6 +51,7 @@ import { registerSetActivePlanTool } from './tools/set-active-plan.js';
|
|
|
51
51
|
import { registerUpdatePlanTool } from './tools/update-plan.js';
|
|
52
52
|
import { registerReconcilePlansTool } from './tools/reconcile-plans.js';
|
|
53
53
|
import { isSafeCommand, isPlanPath } from './utils.js';
|
|
54
|
+
import { handleListPlans } from './commands/list-plans.js';
|
|
54
55
|
|
|
55
56
|
export default function planMode(pi: ExtensionAPI): void {
|
|
56
57
|
const state = new PlanModeState();
|
|
@@ -268,6 +269,14 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
268
269
|
},
|
|
269
270
|
});
|
|
270
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
|
+
|
|
271
280
|
pi.registerCommand('todos', {
|
|
272
281
|
description: 'Show current plan progress',
|
|
273
282
|
handler: async (_args, ctx) => {
|
|
@@ -35,7 +35,7 @@ submit_plan is finalization, not the starting point. It records tasks and the ha
|
|
|
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
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. The visual-prototype skill covers when and how.
|
|
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.
|
|
39
39
|
|
|
40
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.`;
|
|
41
41
|
}
|
|
@@ -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* () {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-plan-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -41,8 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@dreki-gg/pi-command-sandbox": "^0.3.0",
|
|
44
|
-
"effect": "^3.21.2"
|
|
45
|
-
"pug": "^3.0.3"
|
|
44
|
+
"effect": "^3.21.2"
|
|
46
45
|
},
|
|
47
46
|
"devDependencies": {
|
|
48
47
|
"@types/node": "24",
|
|
@@ -30,9 +30,11 @@ Call `preview_prototype` with:
|
|
|
30
30
|
|
|
31
31
|
- `title` — short name for the prototype
|
|
32
32
|
- `intent` — one line describing what it shows
|
|
33
|
-
- `
|
|
33
|
+
- `html` — a complete, self-contained HTML document you author **with full freedom**. There is no template engine and no imposed theme: pick your own markup, fonts, colors, layout, and inline `<style>`/`<script>`. Assume nothing about a host page.
|
|
34
34
|
|
|
35
|
-
The tool
|
|
35
|
+
The tool persists your HTML to `.plans/_prototypes/<slug>.html` and opens it for review. It does not wrap, restyle, or theme your markup — what you write is what the user sees. (A bare fragment is tolerated and dropped into a minimal unstyled shell, but prefer sending a full document.)
|
|
36
|
+
|
|
37
|
+
**Avoid generic boilerplate.** A dark dashboard with a purple accent and a card is not a design — it is slop. Design something that fits the actual product. For real design taste, delegate the markup to the `ux-designer` subagent and pass its HTML straight through `preview_prototype`.
|
|
36
38
|
|
|
37
39
|
### 3. Get a reaction before submitting
|
|
38
40
|
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
doctype html
|
|
2
|
-
html(lang="en")
|
|
3
|
-
head
|
|
4
|
-
meta(charset="utf-8")
|
|
5
|
-
meta(name="viewport" content="width=device-width, initial-scale=1")
|
|
6
|
-
title= title
|
|
7
|
-
style.
|
|
8
|
-
:root { color-scheme: dark; --bg:#0b0d12; --panel:#11141b; --muted:#8b93a7; --text:#eef1f7; --line:#242936; --accent:#8b5cf6; }
|
|
9
|
-
* { box-sizing: border-box; }
|
|
10
|
-
body { margin:0; background:radial-gradient(circle at top left,#1c1730,transparent 34rem),var(--bg); color:var(--text); font:14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
11
|
-
main { width:min(1040px, calc(100% - 48px)); margin:48px auto; }
|
|
12
|
-
header { margin-bottom:24px; }
|
|
13
|
-
h1 { font-size:30px; line-height:1.1; margin:0 0 8px; letter-spacing:-0.04em; }
|
|
14
|
-
.intent { color:var(--muted); margin:0; }
|
|
15
|
-
.badge { display:inline-block; border:1px solid var(--line); border-radius:999px; padding:5px 10px; background:rgba(255,255,255,0.03); color:var(--muted); font-size:12px; margin-bottom:14px; }
|
|
16
|
-
.prototype { background:rgba(17,20,27,0.82); border:1px solid var(--line); border-radius:18px; padding:22px; box-shadow:0 24px 80px rgba(0,0,0,0.24); }
|
|
17
|
-
body
|
|
18
|
-
main
|
|
19
|
-
header
|
|
20
|
-
span.badge Prototype · #{generatedAt}
|
|
21
|
-
h1= title
|
|
22
|
-
if intent
|
|
23
|
-
p.intent= intent
|
|
24
|
-
section.prototype
|
|
25
|
-
!= prototypeHtml
|