@dreki-gg/pi-plan-mode 0.10.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/extensions/plan-mode/__tests__/atomic-write.test.ts +43 -0
  3. package/extensions/plan-mode/__tests__/html-render.test.ts +43 -0
  4. package/extensions/plan-mode/__tests__/package-skills.test.ts +47 -0
  5. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +37 -0
  6. package/extensions/plan-mode/__tests__/prompts.test.ts +38 -0
  7. package/extensions/plan-mode/__tests__/task-storage.test.ts +44 -0
  8. package/extensions/plan-mode/__tests__/types.test.ts +55 -0
  9. package/extensions/plan-mode/constants.ts +3 -1
  10. package/extensions/plan-mode/html/render.ts +67 -0
  11. package/extensions/plan-mode/html/templates/plan.pug +58 -0
  12. package/extensions/plan-mode/index.ts +79 -42
  13. package/extensions/plan-mode/plan-storage.ts +1 -67
  14. package/extensions/plan-mode/prompts.ts +32 -31
  15. package/extensions/plan-mode/pug.d.ts +5 -0
  16. package/extensions/plan-mode/resume.ts +38 -37
  17. package/extensions/plan-mode/storage/atomic-write.ts +53 -0
  18. package/extensions/plan-mode/storage/plan-storage.ts +47 -0
  19. package/extensions/plan-mode/storage/plans-manifest.ts +57 -0
  20. package/extensions/plan-mode/storage/task-storage.ts +44 -0
  21. package/extensions/plan-mode/tools/submit-plan.ts +40 -88
  22. package/extensions/plan-mode/tools/update-task.ts +82 -0
  23. package/extensions/plan-mode/types.ts +57 -5
  24. package/extensions/plan-mode/ui.ts +10 -10
  25. package/package.json +7 -2
  26. package/skills/technical-options/SKILL.md +89 -0
  27. package/extensions/plan-mode/plans-json.ts +0 -48
  28. package/extensions/plan-mode/tools/update-step.ts +0 -145
@@ -0,0 +1,57 @@
1
+ import { mkdir, readFile } from 'node:fs/promises';
2
+ import { writeFileAtomic } from './atomic-write.js';
3
+
4
+ const MANIFEST_PATH = '.plans/plans.jsonl';
5
+
6
+ export interface PlanManifestEntry {
7
+ _type: 'plan';
8
+ name: string;
9
+ status: 'in-progress' | 'done';
10
+ title: string;
11
+ created_at: string;
12
+ completed_at: string | null;
13
+ }
14
+
15
+ export async function readPlansManifest(): Promise<PlanManifestEntry[]> {
16
+ let text: string;
17
+ try { text = await readFile(MANIFEST_PATH, 'utf8'); } catch { return []; }
18
+ const entries: PlanManifestEntry[] = [];
19
+ for (const [index, raw] of text.split(/\r?\n/).entries()) {
20
+ if (!raw.trim()) continue;
21
+ let parsed: unknown;
22
+ try { parsed = JSON.parse(raw); } catch (error) { throw new Error(`Invalid plans.jsonl at line ${index + 1}: ${(error as Error).message}`); }
23
+ if (!isPlanManifestEntry(parsed)) throw new Error(`Invalid plans.jsonl record at line ${index + 1}`);
24
+ entries.push(parsed);
25
+ }
26
+ return entries;
27
+ }
28
+
29
+ export async function writePlansManifest(entries: PlanManifestEntry[]): Promise<void> {
30
+ await mkdir('.plans', { recursive: true });
31
+ const content = entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
32
+ await writeFileAtomic(MANIFEST_PATH, content);
33
+ }
34
+
35
+ export async function upsertPlanEntry(name: string, updates: { status: 'in-progress' | 'done'; title?: string }): Promise<void> {
36
+ const entries = await readPlansManifest();
37
+ const now = new Date().toISOString();
38
+ const index = entries.findIndex((entry) => entry.name === name);
39
+ const existing = index === -1 ? undefined : entries[index];
40
+ const entry: PlanManifestEntry = {
41
+ _type: 'plan',
42
+ name,
43
+ status: updates.status,
44
+ title: updates.title ?? existing?.title ?? 'Untitled plan',
45
+ created_at: existing?.created_at ?? now,
46
+ completed_at: updates.status === 'done' ? now : null,
47
+ };
48
+ if (index === -1) entries.push(entry);
49
+ else entries[index] = entry;
50
+ await writePlansManifest(entries);
51
+ }
52
+
53
+ function isPlanManifestEntry(value: unknown): value is PlanManifestEntry {
54
+ if (typeof value !== 'object' || value === null) return false;
55
+ const record = value as Record<string, unknown>;
56
+ return record._type === 'plan' && typeof record.name === 'string' && (record.status === 'in-progress' || record.status === 'done') && typeof record.title === 'string' && typeof record.created_at === 'string' && (record.completed_at === null || typeof record.completed_at === 'string');
57
+ }
@@ -0,0 +1,44 @@
1
+ import { mkdir, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { isTaskMeta, isTaskRecord, type TaskMeta, type TaskRecord } from '../types.js';
4
+ import { writeFileAtomic } from './atomic-write.js';
5
+
6
+ const TASKS_FILE = 'tasks.jsonl';
7
+
8
+ export interface TasksSnapshot { meta: TaskMeta; tasks: TaskRecord[] }
9
+
10
+ export async function readTasksJsonl(planDir: string): Promise<TasksSnapshot | undefined> {
11
+ let text: string;
12
+ try { text = await readFile(join(planDir, TASKS_FILE), 'utf8'); } catch { return undefined; }
13
+ if (!text.trim()) throw new Error('tasks.jsonl is missing meta record');
14
+
15
+ let meta: TaskMeta | undefined;
16
+ const tasks: TaskRecord[] = [];
17
+ for (const [index, raw] of text.split(/\r?\n/).entries()) {
18
+ if (!raw.trim()) continue;
19
+ let parsed: unknown;
20
+ try { parsed = JSON.parse(raw); } catch (error) { throw new Error(`Invalid JSONL at line ${index + 1}: ${(error as Error).message}`); }
21
+ if (isTaskMeta(parsed)) meta = parsed;
22
+ else if (isTaskRecord(parsed)) tasks.push(parsed);
23
+ else throw new Error(`Invalid tasks.jsonl record at line ${index + 1}`);
24
+ }
25
+ if (!meta) throw new Error('tasks.jsonl is missing meta record');
26
+ return { meta, tasks };
27
+ }
28
+
29
+ export async function writeTasksJsonl(planDir: string, meta: TaskMeta, tasks: TaskRecord[]): Promise<void> {
30
+ await mkdir(planDir, { recursive: true });
31
+ const content = [meta, ...tasks].map((record) => JSON.stringify(record)).join('\n') + '\n';
32
+ await writeFileAtomic(join(planDir, TASKS_FILE), content);
33
+ }
34
+
35
+ export async function updateTask(planDir: string, taskId: string, updates: Partial<Omit<TaskRecord, '_type' | 'id' | 'created_at'>>): Promise<TaskRecord> {
36
+ const snapshot = await readTasksJsonl(planDir);
37
+ if (!snapshot) throw new Error(`No tasks.jsonl found in ${planDir}`);
38
+ const index = snapshot.tasks.findIndex((task) => task.id === taskId);
39
+ if (index === -1) throw new Error(`Task not found: ${taskId}`);
40
+ const updated: TaskRecord = { ...snapshot.tasks[index], ...updates, updated_at: new Date().toISOString() };
41
+ snapshot.tasks[index] = updated;
42
+ await writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
43
+ return updated;
44
+ }
@@ -1,17 +1,17 @@
1
1
  /**
2
2
  * submit_plan tool — available during the plan phase.
3
- *
4
- * The planner calls this to submit a structured plan with typed steps.
5
- * Writes `.plans/<name>/plan.json` and updates the plans.json manifest.
6
3
  */
7
4
 
8
5
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
9
6
  import { Text } from '@earendil-works/pi-tui';
10
7
  import { Type } from 'typebox';
11
8
  import { mkdir, writeFile } from 'node:fs/promises';
12
- import { readPlansJson, serializePlansJson } from '../plans-json.js';
9
+ import { renderPlanHtml } from '../html/render.js';
10
+ import { saveHandoff } from '../storage/plan-storage.js';
11
+ import { writeTasksJsonl } from '../storage/task-storage.js';
12
+ import { upsertPlanEntry } from '../storage/plans-manifest.js';
13
13
  import { toKebabCase } from '../utils.js';
14
- import type { PlanData, PlanStep } from '../types.js';
14
+ import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
15
15
 
16
16
  export interface SubmitPlanCallbacks {
17
17
  onPlanSubmitted: (planDir: string, plan: PlanData) => void;
@@ -21,81 +21,56 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
21
21
  pi.registerTool({
22
22
  name: 'submit_plan',
23
23
  label: 'Submit Plan',
24
- description:
25
- 'Submit a structured plan with a title, context, numbered steps, and risks. ' +
26
- 'Each step has a short description (for progress display) and detailed implementation instructions. ' +
27
- 'This finalizes the plan and writes it to .plans/<name>/plan.json.',
28
- promptSnippet:
29
- 'Submit a structured plan with title, context, steps (description + details), and risks',
24
+ description: 'Finalize a conversational plan with task IDs, JSONL storage, HANDOFF.md, and generated plan.html.',
25
+ promptSnippet: 'Finalize the plan with title, handoff, tasks, dependencies, and optional prototype Pug',
30
26
  promptGuidelines: [
31
- 'Call submit_plan once when you have finished analyzing the codebase and are ready to finalize your plan.',
32
- 'Each submit_plan step description should be a short label (≤60 chars). Put full implementation instructions in the details field.',
33
- 'The submit_plan context field must include all codebase findings, relevant file paths, APIs, patterns, and constraints so an executor with zero prior context can implement the plan.',
27
+ 'Only call submit_plan after shared understanding has been reached with the user.',
28
+ 'Each task needs an id like t-001, a short description, detailed implementation instructions, and optional depends_on task IDs.',
29
+ 'The handoff must be thorough enough that both a human reviewer and executor agent with zero prior context can understand the plan.',
34
30
  ],
35
31
  parameters: Type.Object({
36
- name: Type.String({
37
- description: 'Short kebab-case name for the plan (e.g. "add-auth-middleware")',
38
- }),
32
+ name: Type.String({ description: 'Short kebab-case name for the plan (e.g. "add-auth-middleware")' }),
39
33
  title: Type.String({ description: 'Human-readable plan title' }),
40
- context: Type.String({
41
- description:
42
- 'Complete codebase context: relevant file paths, APIs, patterns, constraints, and gotchas',
43
- }),
44
- steps: Type.Array(
34
+ handoff: Type.String({ description: 'Markdown content for HANDOFF.md' }),
35
+ tasks: Type.Array(
45
36
  Type.Object({
46
- description: Type.String({
47
- description: 'Short step label for progress display (≤60 chars)',
48
- }),
49
- details: Type.String({
50
- description: 'Full implementation instructions for this step',
51
- }),
37
+ id: Type.String({ description: 'Stable task ID, e.g. t-001' }),
38
+ description: Type.String({ description: 'Short task label for progress display (≤60 chars)' }),
39
+ details: Type.String({ description: 'Full implementation instructions for this task' }),
40
+ depends_on: Type.Optional(Type.Array(Type.String({ description: 'Dependency task ID' }))),
52
41
  }),
53
42
  { minItems: 1 },
54
43
  ),
55
- risks: Type.String({ description: 'Open questions, assumptions, and risks' }),
44
+ prototype: Type.Optional(Type.String({ description: 'Optional Pug markup for the prototype section in plan.html' })),
56
45
  }),
57
46
 
58
47
  async execute(_toolCallId, params) {
59
48
  const planName = toKebabCase(params.name);
60
49
  const planDir = `.plans/${planName}`;
61
-
62
- const steps: PlanStep[] = params.steps.map((s) => ({
63
- description: s.description.slice(0, 60),
64
- details: s.details,
65
- status: 'pending' as const,
50
+ const now = new Date().toISOString();
51
+ const meta: TaskMeta = { _type: 'meta', title: params.title, plan_name: planName, created_at: now };
52
+ const tasks: TaskRecord[] = params.tasks.map((task) => ({
53
+ _type: 'task',
54
+ id: task.id,
55
+ description: task.description.slice(0, 60),
56
+ details: task.details,
57
+ status: 'pending',
58
+ depends_on: task.depends_on,
59
+ created_at: now,
60
+ updated_at: now,
66
61
  }));
62
+ const plan: PlanData = { title: params.title, planName, handoff: params.handoff, tasks };
67
63
 
68
- const plan: PlanData = {
69
- title: params.title,
70
- context: params.context,
71
- steps,
72
- risks: params.risks,
73
- };
74
-
75
- // Write plan.json
76
64
  await mkdir(planDir, { recursive: true });
77
- await writeFile(`${planDir}/plan.json`, JSON.stringify(plan, null, 2) + '\n', 'utf-8');
78
-
79
- // Update plans.json manifest
80
- const manifest = await readPlansJson();
81
- const now = new Date().toISOString();
82
- manifest[planName] = {
83
- status: 'in-progress',
84
- title: params.title,
85
- created: manifest[planName]?.created ?? now,
86
- completed: null,
87
- };
88
- await writeFile('.plans/plans.json', serializePlansJson(manifest), 'utf-8');
65
+ await writeTasksJsonl(planDir, meta, tasks);
66
+ await saveHandoff(planDir, params.handoff);
67
+ await writeFile(`${planDir}/plan.html`, renderPlanHtml(plan, params.prototype), 'utf-8');
68
+ await upsertPlanEntry(planName, { status: 'in-progress', title: params.title });
89
69
 
90
70
  callbacks.onPlanSubmitted(planDir, plan);
91
71
 
92
72
  return {
93
- content: [
94
- {
95
- type: 'text' as const,
96
- text: `Plan "${params.title}" saved with ${steps.length} steps. Waiting for user to review and execute.`,
97
- },
98
- ],
73
+ content: [{ type: 'text' as const, text: `Plan "${params.title}" saved with ${tasks.length} tasks. Review ${planDir}/plan.html, then execute when ready.` }],
99
74
  details: { planDir, plan },
100
75
  terminate: true,
101
76
  };
@@ -106,38 +81,15 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
106
81
  const title = (args as { title?: string }).title ?? '';
107
82
  let content = theme.fg('toolTitle', theme.bold('submit_plan '));
108
83
  content += theme.fg('accent', name);
109
- if (title) {
110
- content += ' ' + theme.fg('dim', `"${title}"`);
111
- }
84
+ if (title) content += ' ' + theme.fg('dim', `"${title}"`);
112
85
  return new Text(content, 0, 0);
113
86
  },
114
87
 
115
88
  renderResult(result, _options, theme) {
116
- const details = result.details as { plan?: PlanData } | undefined;
117
- const plan = details?.plan;
118
- if (!plan) {
119
- return new Text(theme.fg('success', '✓ Plan saved'), 0, 0);
120
- }
121
-
122
- const lines: string[] = [];
123
-
124
- // Title
125
- lines.push(theme.fg('success', '✓ ') + theme.fg('accent', theme.bold(plan.title)));
126
- lines.push('');
127
-
128
- // Steps
129
- for (let i = 0; i < plan.steps.length; i++) {
130
- const step = plan.steps[i];
131
- const num = theme.fg('muted', `${i + 1}.`);
132
- lines.push(` ${num} ${step.description}`);
133
- }
134
-
135
- // Risks
136
- if (plan.risks) {
137
- lines.push('');
138
- lines.push(theme.fg('warning', '⚠ Risks: ') + theme.fg('dim', plan.risks));
139
- }
140
-
89
+ const plan = (result.details as { plan?: PlanData } | undefined)?.plan;
90
+ if (!plan) return new Text(theme.fg('success', '✓ Plan saved'), 0, 0);
91
+ const lines = [theme.fg('success', '✓ ') + theme.fg('accent', theme.bold(plan.title)), ''];
92
+ for (const task of plan.tasks) lines.push(` ${theme.fg('muted', task.id)} ${task.description}`);
141
93
  return new Text(lines.join('\n'), 0, 0);
142
94
  },
143
95
  });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * update_task tool — available during the execution phase.
3
+ */
4
+
5
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
6
+ import { StringEnum } from '@earendil-works/pi-ai';
7
+ import { Text } from '@earendil-works/pi-tui';
8
+ import { Type } from 'typebox';
9
+ import type { PlanData, TaskStatus } from '../types.js';
10
+
11
+ export interface UpdateTaskCallbacks {
12
+ getPlan: () => PlanData | undefined;
13
+ onTaskUpdated: (taskId: string, status: Exclude<TaskStatus, 'pending'>, notes?: string) => void | Promise<void>;
14
+ }
15
+
16
+ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCallbacks): void {
17
+ pi.registerTool({
18
+ name: 'update_task',
19
+ label: 'Update Task',
20
+ description: 'Mark a plan task as done, skipped, or blocked. If blocked, execution pauses for user intervention.',
21
+ promptSnippet: 'Mark a plan task as done, skipped, or blocked',
22
+ promptGuidelines: [
23
+ 'Call update_task after completing each plan task before moving to the next.',
24
+ 'Always include notes summarizing what was done, why skipped, or why blocked.',
25
+ 'Use update_task with status "blocked" and explain the reason in notes if a task cannot be completed.',
26
+ ],
27
+ parameters: Type.Object({
28
+ task_id: Type.String({ description: 'Task ID (for example, t-001)' }),
29
+ status: StringEnum(['done', 'skipped', 'blocked'] as const),
30
+ notes: Type.Optional(Type.String({ description: 'What was done, why skipped, or why blocked' })),
31
+ }),
32
+
33
+ async execute(_toolCallId, params) {
34
+ const plan = callbacks.getPlan();
35
+ if (!plan) throw new Error('No active plan. Cannot update task.');
36
+
37
+ const task = plan.tasks.find((candidate) => candidate.id === params.task_id);
38
+ if (!task) throw new Error(`Task not found: ${params.task_id}`);
39
+ if (task.status !== 'pending') {
40
+ throw new Error(`Task ${params.task_id} is already "${task.status}". Only pending tasks can be updated.`);
41
+ }
42
+
43
+ await callbacks.onTaskUpdated(params.task_id, params.status, params.notes);
44
+
45
+ const details = { task_id: params.task_id, status: params.status, notes: params.notes, description: task.description };
46
+ if (params.status === 'blocked') {
47
+ return {
48
+ content: [{ type: 'text' as const, text: `Task ${params.task_id} blocked. Execution paused — waiting for user input.` }],
49
+ details,
50
+ terminate: true,
51
+ };
52
+ }
53
+
54
+ const done = plan.tasks.filter((candidate) => candidate.status === 'done').length;
55
+ const skipped = plan.tasks.filter((candidate) => candidate.status === 'skipped').length;
56
+ const resolved = done + skipped;
57
+ const next = plan.tasks.find((candidate) => candidate.status === 'pending');
58
+ const statusEmoji = params.status === 'done' ? '✓' : '⊘';
59
+ let text = `${statusEmoji} Task ${params.task_id} ${params.status}. Progress: ${resolved}/${plan.tasks.length}`;
60
+ if (params.notes) text += ` — ${params.notes}`;
61
+ text += next ? `\n\nNext task ${next.id}: ${next.description}` : '\n\nAll tasks resolved!';
62
+
63
+ return { content: [{ type: 'text' as const, text }], details, terminate: !next };
64
+ },
65
+
66
+ renderCall(args, theme) {
67
+ const taskId = (args as { task_id?: string }).task_id ?? '?';
68
+ const status = (args as { status?: string }).status ?? '';
69
+ let content = theme.fg('toolTitle', theme.bold('update_task '));
70
+ content += theme.fg('muted', taskId);
71
+ if (status) content += ' ' + theme.fg(status === 'done' ? 'success' : status === 'skipped' ? 'warning' : 'error', status);
72
+ return new Text(content, 0, 0);
73
+ },
74
+
75
+ renderResult(result, _options, theme) {
76
+ const details = result.details as { task_id?: string; status?: string; description?: string } | undefined;
77
+ if (!details) return new Text(theme.fg('dim', 'Updated'), 0, 0);
78
+ const statusMap: Record<string, string> = { done: theme.fg('success', '✓'), skipped: theme.fg('warning', '⊘'), blocked: theme.fg('error', '✗') };
79
+ return new Text(`${statusMap[details.status ?? ''] ?? ''} Task ${details.task_id}: ${details.description ?? ''}`, 0, 0);
80
+ },
81
+ });
82
+ }
@@ -2,18 +2,32 @@
2
2
  * Shared types for plan mode.
3
3
  */
4
4
 
5
- export interface PlanStep {
5
+ export type TaskStatus = 'pending' | 'done' | 'skipped' | 'blocked';
6
+
7
+ export interface TaskRecord {
8
+ _type: 'task';
9
+ id: string;
6
10
  description: string;
7
11
  details: string;
8
- status: 'pending' | 'done' | 'skipped' | 'blocked';
12
+ status: TaskStatus;
13
+ depends_on?: string[];
9
14
  notes?: string;
15
+ created_at: string;
16
+ updated_at: string;
17
+ }
18
+
19
+ export interface TaskMeta {
20
+ _type: 'meta';
21
+ title: string;
22
+ plan_name: string;
23
+ created_at: string;
10
24
  }
11
25
 
12
26
  export interface PlanData {
13
27
  title: string;
14
- context: string;
15
- steps: PlanStep[];
16
- risks: string;
28
+ planName: string;
29
+ handoff: string;
30
+ tasks: TaskRecord[];
17
31
  }
18
32
 
19
33
  export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
@@ -30,3 +44,41 @@ export interface PersistedState {
30
44
  plan: PlanData | undefined;
31
45
  executionStartIdx: number | undefined;
32
46
  }
47
+
48
+ const TASK_STATUSES = new Set<TaskStatus>(['pending', 'done', 'skipped', 'blocked']);
49
+
50
+ function isRecord(value: unknown): value is Record<string, unknown> {
51
+ return typeof value === 'object' && value !== null;
52
+ }
53
+
54
+ function isStringArray(value: unknown): value is string[] {
55
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
56
+ }
57
+
58
+ export function isTaskRecord(value: unknown): value is TaskRecord {
59
+ if (!isRecord(value)) return false;
60
+
61
+ return (
62
+ value._type === 'task' &&
63
+ typeof value.id === 'string' &&
64
+ typeof value.description === 'string' &&
65
+ typeof value.details === 'string' &&
66
+ typeof value.status === 'string' &&
67
+ TASK_STATUSES.has(value.status as TaskStatus) &&
68
+ (value.depends_on === undefined || isStringArray(value.depends_on)) &&
69
+ (value.notes === undefined || typeof value.notes === 'string') &&
70
+ typeof value.created_at === 'string' &&
71
+ typeof value.updated_at === 'string'
72
+ );
73
+ }
74
+
75
+ export function isTaskMeta(value: unknown): value is TaskMeta {
76
+ if (!isRecord(value)) return false;
77
+
78
+ return (
79
+ value._type === 'meta' &&
80
+ typeof value.title === 'string' &&
81
+ typeof value.plan_name === 'string' &&
82
+ typeof value.created_at === 'string'
83
+ );
84
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Plan mode UI — status bar and step widget rendering.
2
+ * Plan mode UI — status bar and task widget rendering.
3
3
  */
4
4
 
5
5
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
@@ -9,8 +9,8 @@ export function updateUI(state: PlanModeState, ctx: ExtensionContext): void {
9
9
  const { theme } = ctx.ui;
10
10
 
11
11
  if (state.executing && state.plan) {
12
- const done = state.plan.steps.filter((s) => s.status === 'done').length;
13
- const total = state.plan.steps.length;
12
+ const done = state.plan.tasks.filter((task) => task.status === 'done').length;
13
+ const total = state.plan.tasks.length;
14
14
  ctx.ui.setStatus('plan-mode', theme.fg('accent', `📋 exec ${done}/${total}`));
15
15
  } else if (state.planEnabled) {
16
16
  ctx.ui.setStatus('plan-mode', theme.fg('warning', '📝 plan'));
@@ -19,17 +19,17 @@ export function updateUI(state: PlanModeState, ctx: ExtensionContext): void {
19
19
  }
20
20
 
21
21
  if (state.executing && state.plan) {
22
- const lines = state.plan.steps.map((step, i) => {
23
- const num = `${i + 1}. `;
24
- switch (step.status) {
22
+ const lines = state.plan.tasks.map((task) => {
23
+ const label = `${task.id} ${task.description}`;
24
+ switch (task.status) {
25
25
  case 'done':
26
- return theme.fg('success', '✓ ') + theme.fg('muted', theme.strikethrough(num + step.description));
26
+ return theme.fg('success', '✓ ') + theme.fg('muted', theme.strikethrough(label));
27
27
  case 'skipped':
28
- return theme.fg('warning', '⊘ ') + theme.fg('muted', theme.strikethrough(num + step.description));
28
+ return theme.fg('warning', '⊘ ') + theme.fg('muted', theme.strikethrough(label));
29
29
  case 'blocked':
30
- return theme.fg('error', '✗ ') + theme.fg('error', num + step.description);
30
+ return theme.fg('error', '✗ ') + theme.fg('error', label);
31
31
  default:
32
- return theme.fg('muted', '☐ ') + (num + step.description);
32
+ return theme.fg('muted', '☐ ') + label;
33
33
  }
34
34
  });
35
35
  ctx.ui.setWidget('plan-todos', lines);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.10.1",
3
+ "version": "0.14.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"
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "files": [
20
20
  "extensions",
21
+ "skills",
21
22
  "bin",
22
23
  "README.md",
23
24
  "CHANGELOG.md",
@@ -33,10 +34,14 @@
33
34
  "pi": {
34
35
  "extensions": [
35
36
  "./extensions/plan-mode"
37
+ ],
38
+ "skills": [
39
+ "./skills"
36
40
  ]
37
41
  },
38
42
  "dependencies": {
39
- "@dreki-gg/pi-command-sandbox": "^0.2.0"
43
+ "@dreki-gg/pi-command-sandbox": "^0.2.0",
44
+ "pug": "^3.0.3"
40
45
  },
41
46
  "devDependencies": {
42
47
  "@types/node": "24",
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: technical-options
3
+ description: Generate and rank competing options for a technical decision using parallel evaluators. Use when the user wants a structured comparison of implementation approaches, architecture alternatives, or engineering tradeoffs before choosing one. Not for binary yes/no or pure preference decisions.
4
+ ---
5
+
6
+ # Technical Options
7
+
8
+ Generate competing proposals for a technical decision, challenge the framing, then fan out to parallel voting agents for ranking.
9
+
10
+ ## Process
11
+
12
+ ### 1. Understand the problem
13
+
14
+ - Read relevant code and context
15
+ - Identify the core constraint or failure mode driving the decision
16
+ - Note available APIs, tools, and integration points
17
+
18
+ ### 2. Research (optional)
19
+
20
+ Use `web_search` + `web_visit` if external patterns would help. Skip for purely internal decisions. If those tools are unavailable, proceed from repo context only.
21
+
22
+ ### 3. Generate 3–5 proposals
23
+
24
+ Each proposal needs:
25
+ - **Letter** (A–E) and a short memorable name
26
+ - **One-paragraph description** with the key mechanism
27
+ - **Pros and cons** — honest tradeoffs, not sales pitches
28
+
29
+ Rules:
30
+ - Span the solution space — don't cluster around one idea
31
+ - Include a simplest-possible option and a most-robust option
32
+ - Include an unconventional or contrarian option when possible
33
+ - 3 proposals for small choices, 4–5 for architectural decisions
34
+
35
+ ### 4. Challenge the framing
36
+
37
+ Before voting, spawn one `advisor` agent (or equivalent) with this task:
38
+
39
+ > "Here are N proposals for [problem]. What important constraint, framing assumption, or missing approach is absent? If you find a materially distinct option the proposals don't cover, describe it. If the slate is well-shaped, say so."
40
+
41
+ Only amend the proposal slate if the challenger surfaces a genuinely distinct option. Do not add variations of existing proposals.
42
+
43
+ ### 5. Fan out to 3 voting agents
44
+
45
+ Use `subagent` in parallel mode with 3 tasks. Each voter gets identical proposals but a different evaluation preamble. The lenses are the contract; agent names are flexible — use `advisor`, `reviewer`, or whatever is available:
46
+
47
+ **Lens 1 — Pragmatic engineer:**
48
+ > "You value shipping speed and simplicity. You penalize over-engineering. A complex solution must justify its complexity with concrete failure modes the simple one can't handle."
49
+
50
+ **Lens 2 — Reliability engineer:**
51
+ > "You value robustness, crash recovery, and correctness over speed. You penalize approaches that work in happy paths but have edge-case blind spots. If two are equally correct, prefer the one that fails louder."
52
+
53
+ **Lens 3 — Maintainability reviewer:**
54
+ > "You value clean abstractions, testability, and long-term ownership cost. You penalize approaches that fight the framework or create implicit coupling. The best solution is one a new team member understands in 5 minutes."
55
+
56
+ Each voter must output a strict ranking:
57
+ ```
58
+ 1st: [Letter] — [reason]
59
+ 2nd: [Letter] — [reason]
60
+ ...
61
+ ```
62
+
63
+ Scale to 5 voters only for high-stakes decisions (new system boundaries, irreversible migrations, public API design).
64
+
65
+ ### 6. Tally and present
66
+
67
+ **Scoring**: Borda count — 1st = N points, 2nd = N-1, ..., last = 1.
68
+
69
+ **Tiebreaker**: Most 1st-place votes. If still tied, present the split to the user honestly and let them decide — do NOT force a winner.
70
+
71
+ Present in this order:
72
+ 1. **Problem framing** (one sentence)
73
+ 2. **Proposals** (A–E with names)
74
+ 3. **Vote table** with per-voter rankings and scores
75
+ 4. **Winner** with consensus reasoning
76
+ 5. **Dissent** — where voters disagreed and why
77
+ 6. **Recommendation** — winner, plus any elements worth borrowing from runner-up
78
+ 7. **Question to user** — proceed, combine, or reconsider?
79
+
80
+ ### 7. Confirm with user
81
+
82
+ Do NOT auto-implement. Ask whether to proceed with the winner, combine elements, or reconsider.
83
+
84
+ ## When NOT to use this
85
+
86
+ - Binary yes/no decisions — just discuss pros/cons
87
+ - Decisions with an obviously correct answer — just do it
88
+ - Pure preference decisions with no technical tradeoffs — ask the user
89
+ - Reversible decisions where trying the simplest option first is cheap
@@ -1,48 +0,0 @@
1
- /**
2
- * Reads and writes `.plans/plans.json` — the tracking manifest for plan lifecycle.
3
- *
4
- * Schema:
5
- * ```json
6
- * {
7
- * "<plan-name>": {
8
- * "status": "in-progress" | "done",
9
- * "title": "Human-readable plan title",
10
- * "created": "2026-05-08T12:00:00.000Z",
11
- * "completed": "2026-05-08T13:00:00.000Z" | null
12
- * }
13
- * }
14
- * ```
15
- */
16
-
17
- export interface PlanEntry {
18
- status: 'in-progress' | 'done';
19
- title: string;
20
- created: string;
21
- completed: string | null;
22
- }
23
-
24
- export type PlansManifest = Record<string, PlanEntry>;
25
-
26
- import { readFile } from 'node:fs/promises';
27
-
28
- const PLANS_JSON = '.plans/plans.json';
29
-
30
- /** Read plans.json, returning current manifest (empty object if missing). */
31
- export async function readPlansJson(): Promise<PlansManifest> {
32
- try {
33
- const text = await readFile(PLANS_JSON, 'utf-8');
34
- if (text.trim()) {
35
- return JSON.parse(text) as PlansManifest;
36
- }
37
- } catch {
38
- // File doesn't exist or isn't valid JSON
39
- }
40
- return {};
41
- }
42
-
43
- /** Serialize the manifest to a formatted JSON string. */
44
- export function serializePlansJson(manifest: PlansManifest): string {
45
- return `${JSON.stringify(manifest, null, 2)}\n`;
46
- }
47
-
48
-