@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.
@@ -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
- import { readFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import pug from 'pug';
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, '&amp;')
17
+ .replace(/</g, '&lt;')
18
+ .replace(/>/g, '&gt;')
19
+ .replace(/"/g, '&quot;');
20
+ }
5
21
 
6
- const templatePath = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'prototype.pug');
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
- * Renders a standalone prototype preview: a minimal header (title + one-line
10
- * intent) wrapping the rendered Pug body. This is a planning-phase visual aid,
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 renderPrototypeHtml(title: string, intent: string, pugBody: string): string {
14
- const template = readFileSync(templatePath, 'utf8');
15
- const prototypeHtml = pugBody.trim() ? pug.render(pugBody) : '';
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 pug.render(template, {
18
- filename: templatePath,
19
- title,
20
- intent,
21
- prototypeHtml,
22
- generatedAt: new Date().toISOString(),
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
- // Clear any stale in-memory plan so the hint re-attaches from disk.
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
- 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
+ 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
- * 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.
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 { renderPrototypeHtml } from '../html/render.js';
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
- 'Render a Pug prototype to a standalone HTML visual aid and open it for review during planning.',
45
- promptSnippet: 'Render a Pug UI prototype to HTML and open it for the user to review',
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
- 'Keep the Pug self-contained; inline any styles the prototype needs.',
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
- pug: Type.String({ description: 'Pug markup for the prototype body' }),
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 = renderPrototypeHtml(params.title, params.intent, params.pug);
67
+ const html = buildPrototypeDocument(params.title, params.html);
63
68
 
64
69
  await runPlanIO(
65
70
  Effect.gen(function* () {