@dreki-gg/pi-plan-mode 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/extensions/plan-mode/__tests__/agent-end-safety.test.ts +3 -3
  3. package/extensions/plan-mode/__tests__/html-render.test.ts +26 -35
  4. package/extensions/plan-mode/__tests__/package-skills.test.ts +51 -0
  5. package/extensions/plan-mode/__tests__/phase-transitions.test.ts +14 -3
  6. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +31 -5
  7. package/extensions/plan-mode/__tests__/prompts.test.ts +21 -3
  8. package/extensions/plan-mode/__tests__/task-storage.test.ts +23 -4
  9. package/extensions/plan-mode/__tests__/types.test.ts +3 -1
  10. package/extensions/plan-mode/__tests__/utils.test.ts +3 -1
  11. package/extensions/plan-mode/constants.ts +1 -0
  12. package/extensions/plan-mode/context-filter.ts +1 -4
  13. package/extensions/plan-mode/html/render.ts +10 -97
  14. package/extensions/plan-mode/html/templates/prototype.pug +25 -0
  15. package/extensions/plan-mode/index.ts +78 -17
  16. package/extensions/plan-mode/phase-transitions.ts +9 -5
  17. package/extensions/plan-mode/plan-storage.ts +6 -1
  18. package/extensions/plan-mode/prompts.ts +5 -6
  19. package/extensions/plan-mode/resume.ts +20 -5
  20. package/extensions/plan-mode/state.ts +1 -3
  21. package/extensions/plan-mode/storage/atomic-write.ts +5 -1
  22. package/extensions/plan-mode/storage/plan-storage.ts +3 -1
  23. package/extensions/plan-mode/storage/plans-manifest.ts +26 -6
  24. package/extensions/plan-mode/storage/task-storage.ts +29 -6
  25. package/extensions/plan-mode/tools/preview-prototype.ts +91 -0
  26. package/extensions/plan-mode/tools/submit-plan.ts +33 -13
  27. package/extensions/plan-mode/tools/update-task.ts +51 -11
  28. package/package.json +1 -1
  29. package/skills/planning-context/SKILL.md +42 -0
  30. package/skills/visual-prototype/SKILL.md +45 -0
  31. package/extensions/plan-mode/html/templates/plan.pug +0 -69
@@ -5,8 +5,7 @@
5
5
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
6
6
  import { Text } from '@earendil-works/pi-tui';
7
7
  import { Type } from 'typebox';
8
- import { mkdir, writeFile } from 'node:fs/promises';
9
- import { renderPlanHtml } from '../html/render.js';
8
+ import { mkdir } from 'node:fs/promises';
10
9
  import { saveHandoff } from '../storage/plan-storage.js';
11
10
  import { writeTasksJsonl } from '../storage/task-storage.js';
12
11
  import { upsertPlanEntry } from '../storage/plans-manifest.js';
@@ -21,36 +20,52 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
21
20
  pi.registerTool({
22
21
  name: 'submit_plan',
23
22
  label: 'Submit Plan',
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',
23
+ description:
24
+ 'Finalize a conversational plan with task IDs, JSONL storage, and HANDOFF.md.',
25
+ promptSnippet:
26
+ 'Finalize the plan with title, handoff, tasks, and dependencies',
26
27
  promptGuidelines: [
27
28
  'Only call submit_plan after shared understanding has been reached with the user.',
28
29
  'Each task needs an id like t-001, a short description, and optional depends_on task IDs.',
29
- 'When a different agent or human will execute the plan, include detailed implementation instructions in each task\'s details field.',
30
+ "When a different agent or human will execute the plan, include detailed implementation instructions in each task's details field.",
30
31
  'When you are planning and executing yourself (same session), use lightweight checklist-style tasks: just id + description, omit details. Put the real context in the handoff document instead.',
31
32
  'The handoff must be thorough enough that both a human reviewer and executor agent with zero prior context can understand the plan.',
33
+ 'For visual/UI work, preview a prototype with preview_prototype during planning — before submit_plan, not as part of it.',
32
34
  ],
33
35
  parameters: Type.Object({
34
- name: Type.String({ description: 'Short kebab-case name for the plan (e.g. "add-auth-middleware")' }),
36
+ name: Type.String({
37
+ description: 'Short kebab-case name for the plan (e.g. "add-auth-middleware")',
38
+ }),
35
39
  title: Type.String({ description: 'Human-readable plan title' }),
36
40
  handoff: Type.String({ description: 'Markdown content for HANDOFF.md' }),
37
41
  tasks: Type.Array(
38
42
  Type.Object({
39
43
  id: Type.String({ description: 'Stable task ID, e.g. t-001' }),
40
- description: Type.String({ description: 'Short task label for progress display (≤60 chars)' }),
41
- details: Type.Optional(Type.String({ description: 'Full implementation instructions for this task. Omit for lightweight checklist-style plans when you are executing yourself.' })),
44
+ description: Type.String({
45
+ description: 'Short task label for progress display (≤60 chars)',
46
+ }),
47
+ details: Type.Optional(
48
+ Type.String({
49
+ description:
50
+ 'Full implementation instructions for this task. Omit for lightweight checklist-style plans when you are executing yourself.',
51
+ }),
52
+ ),
42
53
  depends_on: Type.Optional(Type.Array(Type.String({ description: 'Dependency task ID' }))),
43
54
  }),
44
55
  { minItems: 1 },
45
56
  ),
46
- prototype: Type.Optional(Type.String({ description: 'Optional Pug markup for the prototype section in plan.html' })),
47
57
  }),
48
58
 
49
59
  async execute(_toolCallId, params) {
50
60
  const planName = toKebabCase(params.name);
51
61
  const planDir = `.plans/${planName}`;
52
62
  const now = new Date().toISOString();
53
- const meta: TaskMeta = { _type: 'meta', title: params.title, plan_name: planName, created_at: now };
63
+ const meta: TaskMeta = {
64
+ _type: 'meta',
65
+ title: params.title,
66
+ plan_name: planName,
67
+ created_at: now,
68
+ };
54
69
  const tasks: TaskRecord[] = params.tasks.map((task) => ({
55
70
  _type: 'task',
56
71
  id: task.id,
@@ -66,13 +81,17 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
66
81
  await mkdir(planDir, { recursive: true });
67
82
  await writeTasksJsonl(planDir, meta, tasks);
68
83
  await saveHandoff(planDir, params.handoff);
69
- await writeFile(`${planDir}/plan.html`, renderPlanHtml(plan, params.prototype), 'utf-8');
70
84
  await upsertPlanEntry(planName, { status: 'in-progress', title: params.title });
71
85
 
72
86
  callbacks.onPlanSubmitted(planDir, plan);
73
87
 
74
88
  return {
75
- content: [{ type: 'text' as const, text: `Plan "${params.title}" saved with ${tasks.length} tasks. Review ${planDir}/plan.html, then execute when ready.` }],
89
+ content: [
90
+ {
91
+ type: 'text' as const,
92
+ text: `Plan "${params.title}" saved with ${tasks.length} tasks in ${planDir}. Execute when ready.`,
93
+ },
94
+ ],
76
95
  details: { planDir, plan },
77
96
  terminate: true,
78
97
  };
@@ -91,7 +110,8 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
91
110
  const plan = (result.details as { plan?: PlanData } | undefined)?.plan;
92
111
  if (!plan) return new Text(theme.fg('success', '✓ Plan saved'), 0, 0);
93
112
  const lines = [theme.fg('success', '✓ ') + theme.fg('accent', theme.bold(plan.title)), ''];
94
- for (const task of plan.tasks) lines.push(` ${theme.fg('muted', task.id)} ${task.description}`);
113
+ for (const task of plan.tasks)
114
+ lines.push(` ${theme.fg('muted', task.id)} ${task.description}`);
95
115
  return new Text(lines.join('\n'), 0, 0);
96
116
  },
97
117
  });
@@ -10,14 +10,19 @@ import type { PlanData, TaskStatus } from '../types.js';
10
10
 
11
11
  export interface UpdateTaskCallbacks {
12
12
  getPlan: () => PlanData | undefined;
13
- onTaskUpdated: (taskId: string, status: Exclude<TaskStatus, 'pending'>, notes?: string) => void | Promise<void>;
13
+ onTaskUpdated: (
14
+ taskId: string,
15
+ status: Exclude<TaskStatus, 'pending'>,
16
+ notes?: string,
17
+ ) => void | Promise<void>;
14
18
  }
15
19
 
16
20
  export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCallbacks): void {
17
21
  pi.registerTool({
18
22
  name: 'update_task',
19
23
  label: 'Update Task',
20
- description: 'Mark a plan task as done, skipped, or blocked. If blocked, execution pauses for user intervention.',
24
+ description:
25
+ 'Mark a plan task as done, skipped, or blocked. If blocked, execution pauses for user intervention.',
21
26
  promptSnippet: 'Mark a plan task as done, skipped, or blocked',
22
27
  promptGuidelines: [
23
28
  'Call update_task after completing each plan task before moving to the next.',
@@ -27,7 +32,9 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
27
32
  parameters: Type.Object({
28
33
  task_id: Type.String({ description: 'Task ID (for example, t-001)' }),
29
34
  status: StringEnum(['done', 'skipped', 'blocked'] as const),
30
- notes: Type.Optional(Type.String({ description: 'What was done, why skipped, or why blocked' })),
35
+ notes: Type.Optional(
36
+ Type.String({ description: 'What was done, why skipped, or why blocked' }),
37
+ ),
31
38
  }),
32
39
 
33
40
  async execute(_toolCallId, params) {
@@ -37,15 +44,27 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
37
44
  const task = plan.tasks.find((candidate) => candidate.id === params.task_id);
38
45
  if (!task) throw new Error(`Task not found: ${params.task_id}`);
39
46
  if (task.status !== 'pending') {
40
- throw new Error(`Task ${params.task_id} is already "${task.status}". Only pending tasks can be updated.`);
47
+ throw new Error(
48
+ `Task ${params.task_id} is already "${task.status}". Only pending tasks can be updated.`,
49
+ );
41
50
  }
42
51
 
43
52
  await callbacks.onTaskUpdated(params.task_id, params.status, params.notes);
44
53
 
45
- const details = { task_id: params.task_id, status: params.status, notes: params.notes, description: task.description };
54
+ const details = {
55
+ task_id: params.task_id,
56
+ status: params.status,
57
+ notes: params.notes,
58
+ description: task.description,
59
+ };
46
60
  if (params.status === 'blocked') {
47
61
  return {
48
- content: [{ type: 'text' as const, text: `Task ${params.task_id} blocked. Execution paused — waiting for user input.` }],
62
+ content: [
63
+ {
64
+ type: 'text' as const,
65
+ text: `Task ${params.task_id} blocked. Execution paused — waiting for user input.`,
66
+ },
67
+ ],
49
68
  details,
50
69
  terminate: true,
51
70
  };
@@ -60,7 +79,12 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
60
79
  if (params.notes) text += ` — ${params.notes}`;
61
80
  text += next ? `\n\nNext task ${next.id}: ${next.description}` : '\n\nAll tasks resolved!';
62
81
 
63
- return { content: [{ type: 'text' as const, text }], details, terminate: !next };
82
+ // Do not terminate the turn just because the queue is empty: that would cut off
83
+ // the agent's final pass (closing summary, validation, follow-up) after the last
84
+ // task is marked done. Completion is handled out-of-band by the `agent_end`
85
+ // handler in index.ts. Only the `blocked` branch above terminates, to pause for
86
+ // user input.
87
+ return { content: [{ type: 'text' as const, text }], details };
64
88
  },
65
89
 
66
90
  renderCall(args, theme) {
@@ -68,15 +92,31 @@ export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCa
68
92
  const status = (args as { status?: string }).status ?? '';
69
93
  let content = theme.fg('toolTitle', theme.bold('update_task '));
70
94
  content += theme.fg('muted', taskId);
71
- if (status) content += ' ' + theme.fg(status === 'done' ? 'success' : status === 'skipped' ? 'warning' : 'error', status);
95
+ if (status)
96
+ content +=
97
+ ' ' +
98
+ theme.fg(
99
+ status === 'done' ? 'success' : status === 'skipped' ? 'warning' : 'error',
100
+ status,
101
+ );
72
102
  return new Text(content, 0, 0);
73
103
  },
74
104
 
75
105
  renderResult(result, _options, theme) {
76
- const details = result.details as { task_id?: string; status?: string; description?: string } | undefined;
106
+ const details = result.details as
107
+ | { task_id?: string; status?: string; description?: string }
108
+ | undefined;
77
109
  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);
110
+ const statusMap: Record<string, string> = {
111
+ done: theme.fg('success', ''),
112
+ skipped: theme.fg('warning', '⊘'),
113
+ blocked: theme.fg('error', '✗'),
114
+ };
115
+ return new Text(
116
+ `${statusMap[details.status ?? ''] ?? ''} Task ${details.task_id}: ${details.description ?? ''}`,
117
+ 0,
118
+ 0,
119
+ );
80
120
  },
81
121
  });
82
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.15.0",
3
+ "version": "0.16.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"
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: planning-context
3
+ description: Maintain a living context.md during planning to capture intent, decisions, constraints, open questions, and discarded options before finalizing a plan. Use whenever you are in plan mode and converging on an approach, especially before calling submit_plan.
4
+ ---
5
+
6
+ # Planning Context
7
+
8
+ `context.md` is the living written record of a planning conversation. It exists to slow the jump from "read the codebase" to "submit the plan" — the moment where reasoning usually gets lost. Write it as you think, not as an afterthought.
9
+
10
+ ## When to use
11
+
12
+ Use this throughout any planning session, from the first real decision onward. If you have read code and formed an opinion but have not written anything down, that is the signal to update `context.md`.
13
+
14
+ ## What it is
15
+
16
+ `.plans/<plan-name>/context.md` — a deliberation document, not a plan. It captures the *why* and the *roads not taken*, which `HANDOFF.md` and the task list deliberately omit.
17
+
18
+ ## Process
19
+
20
+ ### 1. Create it early
21
+
22
+ As soon as you understand the intent, write `.plans/<plan-name>/context.md` with the `write` tool. Do not wait until you are ready to submit.
23
+
24
+ ### 2. Keep these sections current
25
+
26
+ - **Intent** — what the user actually wants, in their terms
27
+ - **Decisions** — choices made and the reasoning behind each
28
+ - **Constraints** — technical, product, or process limits that shape the work
29
+ - **Open questions** — anything unresolved; do not submit a plan with silent unknowns
30
+ - **Discarded options** — approaches considered and rejected, with why. This is the highest-value section and the one most often skipped.
31
+
32
+ ### 3. Style
33
+
34
+ Caveman-lite: professional, tight, no filler. Bullet points over prose. Update in place as understanding shifts — do not append a changelog.
35
+
36
+ ### 4. Use it before submitting
37
+
38
+ `context.md` is the input to `submit_plan`, not a duplicate of it. Before finalizing, re-read it: every open question resolved, every decision justified. The handoff is the distilled conclusion; `context.md` is the reasoning that earned it.
39
+
40
+ ## Relationship to prototypes
41
+
42
+ For visual/UI work, a prototype is the visual sibling of `context.md` — see the `visual-prototype` skill. Both are deliberation artifacts. Written reasoning lives here; visual reasoning lives in the prototype.
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: visual-prototype
3
+ description: Build a visual HTML prototype during planning for UI, component, layout, or style changes, so the user can react to the design before the plan is finalized. Use when a plan touches frontend appearance or interaction. Not for backend-only or non-visual work.
4
+ ---
5
+
6
+ # Visual Prototype
7
+
8
+ A prototype is a **convergence artifact**, not a deliverable. When a plan changes how something looks or behaves visually, a static markdown plan cannot show the user what they are agreeing to. Build the prototype while the plan is still soft, show it, and let the user redirect you before anything is committed to `submit_plan`.
9
+
10
+ ## When to use
11
+
12
+ Use this when the plan involves any of:
13
+
14
+ - New or restyled UI components
15
+ - Layout, spacing, or visual hierarchy changes
16
+ - Color, typography, or theming changes
17
+ - Interaction states (hover, active, empty, loading, error)
18
+
19
+ Do **not** use it for backend-only, refactor-only, or otherwise non-visual work.
20
+
21
+ ## Process
22
+
23
+ ### 1. Decide there is something to see
24
+
25
+ If you cannot picture a screen or component changing, skip the prototype. A prototype for invisible work is noise.
26
+
27
+ ### 2. Build it with `preview_prototype`
28
+
29
+ Call `preview_prototype` with:
30
+
31
+ - `title` — short name for the prototype
32
+ - `intent` — one line describing what it shows
33
+ - `pug` — self-contained Pug markup for the prototype body. Inline any styles the prototype needs; assume nothing about the host page.
34
+
35
+ The tool renders the Pug, writes `.plans/_prototypes/<slug>.html`, and opens it for review.
36
+
37
+ ### 3. Get a reaction before submitting
38
+
39
+ Stop and ask the user what they think. Iterate on the prototype — call `preview_prototype` again with revisions — until the visual direction is agreed. Only then move toward `submit_plan`.
40
+
41
+ `submit_plan` never generates HTML. The prototype lives entirely in the planning phase; its job is done once the user has reacted.
42
+
43
+ ## Relationship to context.md
44
+
45
+ The prototype is the visual sibling of `context.md`. Both are deliberation artifacts that exist to slow the jump from "read the codebase" to "submit the plan." Keep `context.md` current as the living written record of intent, decisions, and open questions; use a prototype whenever the decision is visual. Resist the urge to skip straight to `submit_plan` on visual work.
@@ -1,69 +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= plan.title
7
- style.
8
- :root { color-scheme: dark; --bg:#0b0d12; --panel:#11141b; --muted:#8b93a7; --text:#eef1f7; --line:#242936; --accent:#8b5cf6; --done:#33d69f; --blocked:#ff6b6b; }
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 { display:flex; justify-content:space-between; gap:24px; align-items:flex-start; margin-bottom:28px; }
13
- h1 { font-size:34px; line-height:1.08; margin:0 0 10px; letter-spacing:-0.04em; }
14
- h2 { font-size:15px; text-transform:uppercase; color:var(--muted); letter-spacing:0.14em; margin:0 0 14px; }
15
- .meta { color:var(--muted); display:flex; gap:12px; flex-wrap:wrap; }
16
- .pill { border:1px solid var(--line); border-radius:999px; padding:6px 10px; background:rgba(255,255,255,0.03); }
17
- section { background:rgba(17,20,27,0.82); border:1px solid var(--line); border-radius:18px; padding:22px; margin:18px 0; box-shadow:0 24px 80px rgba(0,0,0,0.24); }
18
- .handoff :first-child { margin-top:0; }
19
- .handoff :last-child { margin-bottom:0; }
20
- .handoff pre { background:#161922; border:1px solid var(--line); border-radius:10px; padding:14px 16px; overflow-x:auto; margin:12px 0; }
21
- .handoff pre code { font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:13px; color:#c7ccda; background:none; padding:0; border:none; border-radius:0; }
22
- .handoff code { font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:13px; background:rgba(139,92,246,0.12); color:var(--accent); padding:2px 6px; border-radius:5px; }
23
- .handoff strong { font-weight:650; }
24
- .handoff a { color:var(--accent); text-decoration:none; }
25
- .handoff a:hover { text-decoration:underline; }
26
- .handoff h1, .handoff h3, .handoff h4 { font-size:15px; text-transform:uppercase; color:var(--muted); letter-spacing:0.14em; margin:20px 0 10px; }
27
- .handoff p { margin:8px 0; }
28
- .handoff ul { margin:8px 0; padding-left:20px; }
29
- .handoff li { margin:4px 0; }
30
- .tasks { display:grid; gap:12px; }
31
- .task { border:1px solid var(--line); border-radius:14px; padding:16px; background:#0f1218; }
32
- .task-top { display:flex; gap:10px; align-items:center; margin-bottom:8px; }
33
- .id { color:var(--accent); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
34
- .status { margin-left:auto; color:var(--muted); }
35
- .task.done .status { color:var(--done); }
36
- .task.blocked .status { color:var(--blocked); }
37
- .description { font-weight:650; }
38
- .details { color:#c7ccda; margin:0; white-space:pre-wrap; }
39
- .deps { color:var(--muted); margin-top:10px; font-size:12px; }
40
- body
41
- main
42
- header
43
- div
44
- h1= plan.title
45
- .meta
46
- span.pill= plan.planName
47
- span.pill= taskCountLabel
48
- .meta
49
- span.pill Generated #{generatedAt}
50
- section.handoff
51
- h2 Handoff
52
- != handoffHtml
53
- section
54
- h2 Tasks
55
- .tasks
56
- each task in plan.tasks
57
- article.task(class=task.status)
58
- .task-top
59
- span.id= task.id
60
- span.description= task.description
61
- span.status= task.status
62
- if task.details
63
- p.details= task.details
64
- if task.depends_on && task.depends_on.length
65
- .deps Depends on #{task.depends_on.join(', ')}
66
- if prototypeHtml
67
- section.prototype
68
- h2 Prototype
69
- != prototypeHtml