@hank-warren/pi-plan-mode 1.3.0 → 1.5.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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # @hank-warren/pi-plan-mode
2
2
 
3
+ ## 1.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c079c51: Keep the completed plan out of model context, stage the Plan tools, and fail honestly when a plan cannot be saved.
8
+
9
+ The completed-plan card is a display-only session entry rendered through `registerEntryRenderer` instead of a message, so the plan stays visible and restorable in the transcript while never entering model context or compaction. `plan_mode_complete` returns a one-line `Plan saved to <path>.` pointer; the durable file remains the handoff.
10
+
11
+ `plan_mode_complete` now writes the file first and throws when the write fails, rather than reporting success and returning `undefined`. Prior state stays intact and the call is retryable.
12
+
13
+ `plan_mode_complete` and the `plan_mode_question` fallback activate when Plan mode is entered or restored, so a session that never plans does not carry their schemas. Ownership of `ask_user_question` is resolved by package directory and read back from the host rather than assumed from the write, and every reconcile is announced on an event, so the fallback no longer depends on hook order between packages. A headless run has no legitimate question tool, so the prompt and the finalize steer switch to a plain-text variant instead of naming a tool that both packages strip.
14
+
15
+ `AbortSignal` is wired through the question tool with exactly-once cleanup.
16
+
17
+ ## 1.4.0
18
+
19
+ ### Minor Changes
20
+
21
+ - 449f989: Ships a companion `pi-plan-mode` skill, making this a hybrid package: the system prompt stays the short enforcement surface and points at the skill for the plan-crafting craft — what decision-complete means, exploring before asking, what separates a question worth asking from one the repository already answers, and what a finished plan contains.
22
+
23
+ The footer status and the editor widget now render from one formatter, so they cannot drift, in a glyph vocabulary shared with `pi-loop`: `◆ plan · drafting`, `◆ plan · revising`, `◆ plan · ready → /plan`, `▶ plan · implementing`, each with a dim hint line in the widget. Statuslines that render extension statuses will show the new strings in place of `plan active` / `plan ready` / `plan implementing`.
24
+
3
25
  ## 1.3.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -17,6 +17,7 @@ The plan is written to a **durable file** that survives compaction, survives res
17
17
  - Two ways to implement: continue in this conversation, or open a fresh session that reads the same file.
18
18
  - `/plan export [path]` copies the plan anywhere, never overwriting an existing target.
19
19
  - Hand-edit the plan file at any time; every command and both implementation paths read from disk.
20
+ - A companion `pi-plan-mode` **skill**, loaded on demand, carrying the plan-crafting craft the prompt only points at.
20
21
 
21
22
  ## 📦 Install
22
23
 
@@ -117,13 +118,22 @@ Detection is by tool name at runtime, re-evaluated every turn — there is no de
117
118
 
118
119
  A standalone `pi-plan-mode` install loses nothing: `plan_mode_question` stays fully functional and the prompt reads exactly as it always has. It is a **legacy fallback** and is slated for removal in a future major.
119
120
 
120
- ## 📊 Statusline
121
+ ## 📚 The companion skill
121
122
 
122
- Plan mode publishes a compact status for statuslines that render extension statuses:
123
+ The package ships a `pi-plan-mode` skill (`skills/pi-plan-mode/SKILL.md`) alongside the extension. The system prompt is the enforcement surface and stays deliberately short; the skill is the depth layer it points at — what decision-complete actually means, why exploration comes before questions, what separates a question worth asking from one the repository already answered, and what belongs in a finished plan. One line in the planning prompt names it, and the model loads the body when it judges it needs it.
123
124
 
124
- - `plan active` planning is under way.
125
- - `plan ready` — a completed plan is waiting for your choice.
126
- - `plan implementing` — a plan file is active and guiding implementation.
125
+ Skill and extension version as one artifact: a skill describing a mode the installed extension does not have is a coupling failure waiting to happen.
126
+
127
+ ## 📊 Statusline and widget
128
+
129
+ The footer status and the widget above the editor render from **one formatter**, so they cannot drift, and they share a glyph vocabulary with the sibling [`pi-loop`](../pi-loop): `◆` for a state wanting a decision, `▶` for work under way.
130
+
131
+ - `◆ plan · drafting` — planning is under way.
132
+ - `◆ plan · revising` — feedback superseded a completed plan; the stored one is not current.
133
+ - `◆ plan · ready → /plan` — a completed plan is waiting for your choice.
134
+ - `▶ plan · implementing` — a plan file is active and guiding implementation.
135
+
136
+ The widget adds a dim second line naming what to do next.
127
137
 
128
138
  ## 🗂️ Package layout
129
139
 
@@ -135,6 +145,7 @@ packages/pi-plan-mode/
135
145
  │ ├── plan-file.ts # Durable plan file read/write/delete
136
146
  │ ├── interactive-ui.ts # Lazily loaded interactive menus
137
147
  │ └── *.ts # Prompt, question, export, settings modules
148
+ ├── skills/pi-plan-mode/ # Companion plan-crafting skill
138
149
  ├── test/
139
150
  ├── README.md
140
151
  ├── NOTICE.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-plan-mode",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Plan mode for Pi: research and design with a durable plan file that survives compaction.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -27,11 +27,15 @@
27
27
  "pi": {
28
28
  "extensions": [
29
29
  "./index.ts"
30
+ ],
31
+ "skills": [
32
+ "./skills/pi-plan-mode"
30
33
  ]
31
34
  },
32
35
  "files": [
33
36
  "index.ts",
34
37
  "src",
38
+ "skills",
35
39
  "README.md",
36
40
  "NOTICE.md",
37
41
  "LICENSE",
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: pi-plan-mode
3
+ description: Craft decision-complete implementation plans in Plan mode — explore before asking, ask questions worth answering, and write a plan another agent can execute without guessing. Use while Plan mode is active, before calling plan_mode_complete, and when deciding whether a request needs a plan at all.
4
+ license: MIT
5
+ ---
6
+
7
+ # pi-plan-mode
8
+
9
+ The extension enforces the mechanics: `edit` and `write` are blocked, the plan is written to a durable file, and `plan_mode_complete` is the only way out. It cannot enforce the thing that decides whether the plan was worth making: **whether a competent implementer could execute it without asking you anything.** That is this file.
10
+
11
+ ## What "decision-complete" means
12
+
13
+ A plan is finished when every decision it depends on has been made — by discovery, by the user, or by an explicitly recorded assumption. It is not finished when it is long, well organised, or reads confidently.
14
+
15
+ The test: hand the plan to someone who was not in the conversation. Every place they would have to stop and ask "which one?" is an unresolved decision, and every one of those is a defect. That includes:
16
+
17
+ - which of two plausible approaches is being taken, and why the other was rejected;
18
+ - the names and shapes of anything new — files, functions, types, settings keys, tool parameters;
19
+ - what happens on failure, and what happens to state that already exists;
20
+ - how the change is verified, in the form of the commands to run and what they should print;
21
+ - what deliberately stays out of scope.
22
+
23
+ An unresolved decision hidden behind a confident sentence is worse than an open question, because nobody will notice it until implementation stalls.
24
+
25
+ ## Phase 1: explore before asking
26
+
27
+ Read the repository first, every time. Questions are expensive — they cost the user's attention and they interrupt — so spend them only on what the code cannot tell you.
28
+
29
+ Facts the environment owns: what already exists, how the neighbours solve the same problem, what the tests cover, what conventions the repo files and `AGENTS.md` impose, what versions are pinned, what a command actually prints. **Never ask about any of them.** A question whose answer was one `rg` away tells the user you did not look, and it trains them to stop answering carefully.
30
+
31
+ Facts the environment does not own, and only the user does: product intent, priorities and tradeoffs, what "good enough" means here, which of two acceptable designs they want to live with, whether a breaking change is acceptable, deadlines and blast-radius tolerance.
32
+
33
+ Exploration also earns the right to disagree. A plan that says "this repo already does X three times, so the fourth should match" is grounded; one that invents a new pattern in a codebase that already has one is a plan the reviewer has to fight.
34
+
35
+ ## Phase 2: ask questions worth answering
36
+
37
+ Use the question tool for the decisions that are genuinely the user's. Good questions share a shape:
38
+
39
+ - **The options are real and mutually exclusive.** If one option is obviously right, it is not a question — it is a decision, and you should state it and move on.
40
+ - **Each option says what happens if it is chosen**, not just what it is called. "Store it in the session entry (survives restart, costs a write per turn)" is a choice; "session entry" is a label.
41
+ - **The recommendation comes first and is marked**, so a user who trusts you can answer in one keystroke and one who does not has the alternatives in front of them.
42
+ - **The question is answerable without reading the codebase.** If answering it requires knowing what a function currently does, that is your job, not theirs.
43
+
44
+ Batch decisions that belong to one choice into one call rather than dripping them out over several turns, and stop asking as soon as the remaining ambiguity is low-impact. When the user declines to answer, do not silently pick: state the default you are taking, mark it as an assumption in the plan, and keep the alternative visible.
45
+
46
+ A high-impact ambiguity is a reason to keep planning, never a reason to write a plan that hedges. A plan with two branches in it is two plans and nobody's decision.
47
+
48
+ ## Phase 3: write the plan
49
+
50
+ The plan is read by a human deciding whether to approve it and by an agent executing it. Both want the same thing: grouped, behaviour-level changes with the reasoning attached.
51
+
52
+ Structure that works:
53
+
54
+ - **Title and a short summary** — what changes and why, in a few sentences.
55
+ - **The approach**, including the alternatives considered and why they lost. This is the part that survives contact with the implementation; the file list is not.
56
+ - **Behaviour, interface, and data changes** — what a caller or user sees differently, and what the new names and shapes are.
57
+ - **Edge cases and failure modes**, including what happens to existing state and how the change behaves on a host that has the old version.
58
+ - **Verification** — the commands to run and what they should print, plus the manual checks that no command covers.
59
+ - **Assumptions and defaults chosen** — every place you decided rather than asked, stated plainly so it can be corrected.
60
+
61
+ What to leave out: file-by-file or symbol-by-symbol inventories, restatements of the conversation, and speculative future work. A plan is a decision record, not a diff written in prose.
62
+
63
+ Prefer specifics over adjectives. "Add a `groundRules?: string[]` to the persisted state, normalized on read, dropped when empty" is a plan; "improve state handling" is a mood.
64
+
65
+ ## Ending a turn, and ending the mode
66
+
67
+ Every planning turn ends in exactly one of two ways: a question, or `plan_mode_complete` called alone as the final action with the whole plan. Never end with prose announcing that a plan is coming — that costs a whole turn and produces nothing.
68
+
69
+ On revision, the next `plan_mode_complete` carries a **complete replacement**, not a delta. If you cannot write a complete replacement yet, keep planning and ask instead.
70
+
71
+ Once the plan is completed the user chooses what happens from the `/plan` menu: implement here, implement in a fresh session that reads the same file, export it, or discard it. The file is the source of truth from then on — it survives compaction, and the user may have hand-edited it, so re-read it before implementing rather than working from memory of what you wrote.
72
+
73
+ ## When a plan is the wrong tool
74
+
75
+ Plan mode is for work whose *shape* is uncertain. It is overhead when it is not:
76
+
77
+ - **A single obvious change** — make it. A plan for a one-line fix costs more than the fix.
78
+ - **Pure investigation with no change in view** — investigate in the conversation. Plan mode blocks the tools that would let you experiment, and there is nothing to decide yet.
79
+ - **Work whose difficulty is execution, not design** ("run this migration on 40 hosts") → the interesting part is a runbook and a rollout order, not a design decision.
80
+
81
+ Say so in one line and offer the alternative rather than producing a plan nobody needed.
@@ -57,7 +57,12 @@ export function planFromCompletionDetails(value: unknown) {
57
57
 
58
58
  export function planModeCompleted(plan: string, planPath?: string) {
59
59
  return {
60
- content: [{ type: "text" as const, text: `**Proposed Plan**\n\n${plan}` }],
60
+ content: [
61
+ {
62
+ type: "text" as const,
63
+ text: planPath ? `Plan saved to ${planPath}.` : "Plan saved.",
64
+ },
65
+ ],
61
66
  details: {
62
67
  version: PLAN_MODE_COMPLETE_VERSION,
63
68
  source: PLAN_MODE_COMPLETE_TOOL_NAME,
package/src/plan-mode.ts CHANGED
@@ -28,6 +28,8 @@ import { createPlanExportController } from "./plan-export-controller.js";
28
28
  import {
29
29
  clearPlanModeUi,
30
30
  planModeStatusText as formatPlanModeStatusText,
31
+ registerPlanModeCardRenderer,
32
+ showPlanModePlan,
31
33
  showStoredPlan,
32
34
  updatePlanModeUi,
33
35
  } from "./presentation.js";
@@ -53,6 +55,7 @@ import {
53
55
  import { type PlanModeState, readLegacyThinkingCapture, restorePlanModeState } from "./state.js";
54
56
 
55
57
  const STATE_ENTRY_TYPE = "plan-mode-state";
58
+ const ASK_USER_AVAILABILITY_EVENT = "hank:ask-user:availability";
56
59
  /**
57
60
  * Plan mode's entire enforcement surface. Everything else — bash, subagents,
58
61
  * MCP, and other extension tools — is left to the session's normal permission
@@ -72,38 +75,18 @@ const BLOCKED_TOOLS = new Set(["edit", "write"]);
72
75
  const SETTINGS_RELOAD_DEBOUNCE_MS = 75;
73
76
 
74
77
  /**
75
- * Which question tool the prompt should name this turn.
78
+ * Which question tool the prompt may name this turn, read from the tool set
79
+ * the model will actually see.
76
80
  *
77
- * Detection is by tool NAME only, with no dependency on
78
- * `@hank-warren/pi-ask-user-question`: any extension registering
79
- * `ask_user_question` is treated as the preferred implementation. It offers
80
- * previews, notes, question tabs, digit hotkeys and checkbox multi-select;
81
- * `plan_mode_question` renders through plain `ctx.ui.select` + `ctx.ui.editor`
82
- * and has none of them.
83
- *
84
- * Evaluated per turn rather than once at mode entry, so the prompt cannot go
85
- * stale if the tool set changes mid-session.
81
+ * `null` means neither is active a headless run, where both interactive
82
+ * tools are deliberately stripped. Naming one there would send the model after
83
+ * a tool it cannot call, so the prompt switches to asking in plain text.
86
84
  */
87
- function preferredQuestionTool(pi: ExtensionAPI): string {
88
- return pi.getActiveTools().includes(ASK_USER_QUESTION_TOOL)
89
- ? ASK_USER_QUESTION_TOOL
90
- : PLAN_MODE_QUESTION_TOOL;
91
- }
92
-
93
- /**
94
- * Hide `plan_mode_question` from the model whenever the better tool is present,
95
- * so it never sees two overlapping question tools and cannot call the weaker
96
- * one. The tool stays *registered* either way, so a historical transcript still
97
- * resolves it, and a host without `ask_user_question` keeps it fully functional.
98
- *
99
- * Idempotent, and writes only when something actually changes: siblings are
100
- * untouched, and repeated `before_agent_start` events are free.
101
- */
102
- function reconcileQuestionTool(pi: ExtensionAPI, preferred: string): void {
103
- if (preferred === PLAN_MODE_QUESTION_TOOL) return;
85
+ function preferredQuestionTool(pi: ExtensionAPI): string | null {
104
86
  const active = pi.getActiveTools();
105
- if (!active.includes(PLAN_MODE_QUESTION_TOOL)) return;
106
- pi.setActiveTools(active.filter((name) => name !== PLAN_MODE_QUESTION_TOOL));
87
+ if (active.includes(ASK_USER_QUESTION_TOOL)) return ASK_USER_QUESTION_TOOL;
88
+ if (active.includes(PLAN_MODE_QUESTION_TOOL)) return PLAN_MODE_QUESTION_TOOL;
89
+ return null;
107
90
  }
108
91
 
109
92
  type InteractiveUi = typeof import("./interactive-ui.js");
@@ -138,7 +121,41 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
138
121
  let menuController = new AbortController();
139
122
  let settingsWatch: ReturnType<typeof watch> | undefined;
140
123
  let settingsReloadTimer: ReturnType<typeof setTimeout> | undefined;
124
+ let planToolsActivated = false;
125
+ let currentHasUI = false;
126
+ let globalQuestionAvailable = false;
141
127
  const persistState = () => pi.appendEntry<PlanModeState>(STATE_ENTRY_TYPE, state);
128
+
129
+ const reconcilePlanToolSurface = (hasUI: boolean, availability?: boolean) => {
130
+ currentHasUI = hasUI;
131
+ const active = pi.getActiveTools();
132
+ globalQuestionAvailable = availability ?? (hasUI && active.includes(ASK_USER_QUESTION_TOOL));
133
+ const wanted = new Set(active);
134
+ const completeWanted = planToolsActivated;
135
+ const fallbackWanted = planToolsActivated && hasUI && !globalQuestionAvailable;
136
+ if (completeWanted) wanted.add(PLAN_MODE_COMPLETE_TOOL_NAME);
137
+ else wanted.delete(PLAN_MODE_COMPLETE_TOOL_NAME);
138
+ if (fallbackWanted) wanted.add(PLAN_MODE_QUESTION_TOOL);
139
+ else wanted.delete(PLAN_MODE_QUESTION_TOOL);
140
+ const next = [...wanted];
141
+ if (next.length !== active.length || next.some((name, index) => name !== active[index])) {
142
+ pi.setActiveTools(next);
143
+ }
144
+ };
145
+ const activatePlanTools = (hasUI: boolean) => {
146
+ planToolsActivated = true;
147
+ reconcilePlanToolSurface(hasUI);
148
+ };
149
+
150
+ registerPlanModeCardRenderer(pi);
151
+ pi.events.on(ASK_USER_AVAILABILITY_EVENT, (payload: unknown) => {
152
+ const available =
153
+ typeof payload === "object" && payload !== null &&
154
+ typeof (payload as { available?: unknown }).available === "boolean"
155
+ ? (payload as { available: boolean }).available
156
+ : undefined;
157
+ if (available !== undefined) reconcilePlanToolSurface(currentHasUI, available);
158
+ });
142
159
  const planExports = createPlanExportController({
143
160
  getState: () => state,
144
161
  getSettings: () => settings,
@@ -183,12 +200,14 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
183
200
  label: "Plan question",
184
201
  description:
185
202
  "Ask the user one to three Plan-mode clarification questions with meaningful options, then wait for the answer. Only available while Plan mode is active.",
203
+ // Kept, now that the tool is staged: this guidance reaches the model only
204
+ // in a session that has actually entered Plan mode.
186
205
  promptSnippet: "Ask user decision questions while Plan mode is active",
187
206
  promptGuidelines: [
188
207
  "In Plan mode, use plan_mode_question for important preferences, tradeoffs, or assumptions that cannot be discovered from read-only exploration.",
189
208
  ],
190
209
  parameters: PLAN_MODE_QUESTION_PARAMS,
191
- async execute(_toolCallId, params: unknown, _signal, _onUpdate, ctx) {
210
+ async execute(_toolCallId, params: unknown, signal, _onUpdate, ctx) {
192
211
  if (!state.enabled) {
193
212
  return planModeQuestionCancelled(
194
213
  [],
@@ -212,11 +231,20 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
212
231
 
213
232
  const sessionGeneration = menuGeneration;
214
233
  const questionWorkflowGeneration = workflowGeneration;
215
- return answerPlanModeQuestions(parsed.questions, ctx, {
216
- isCurrent: () =>
217
- sessionGeneration === menuGeneration && questionWorkflowGeneration === workflowGeneration,
218
- isEnabled: () => state.enabled,
219
- });
234
+ const questionSignal = signal
235
+ ? AbortSignal.any([signal, menuController.signal])
236
+ : menuController.signal;
237
+ return answerPlanModeQuestions(
238
+ parsed.questions,
239
+ ctx,
240
+ {
241
+ isCurrent: () =>
242
+ sessionGeneration === menuGeneration &&
243
+ questionWorkflowGeneration === workflowGeneration,
244
+ isEnabled: () => state.enabled,
245
+ },
246
+ questionSignal,
247
+ );
220
248
  },
221
249
  });
222
250
 
@@ -243,6 +271,10 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
243
271
  },
244
272
  });
245
273
 
274
+ // Registered tools remain available for transcript replay; the active set is
275
+ // narrowed at session_start rather than here, because Pi refuses action
276
+ // methods (getActiveTools/setActiveTools) during extension loading.
277
+
246
278
  pi.registerCommand("plan", {
247
279
  description: "Enter or manage Plan mode",
248
280
  getArgumentCompletions: completePlanArguments,
@@ -384,6 +416,9 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
384
416
 
385
417
  pi.on("session_start", async (event, ctx) => {
386
418
  const generation = ++menuGeneration;
419
+ planToolsActivated = false;
420
+ currentHasUI = ctx.hasUI;
421
+ reconcilePlanToolSurface(ctx.hasUI);
387
422
  refreshStateBeforeFirstAgentStart = event.reason === "new";
388
423
  menuController.abort(new DOMException("Plan-mode session replaced", "AbortError"));
389
424
  menuController = new AbortController();
@@ -401,6 +436,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
401
436
  state = { ...state, enabled: true, awaitingAction: state.planPath !== undefined };
402
437
  }
403
438
  if (persistFlagActivation) persistState();
439
+ if (state.enabled) activatePlanTools(ctx.hasUI);
404
440
  updateUi(ctx);
405
441
  });
406
442
 
@@ -430,6 +466,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
430
466
  });
431
467
 
432
468
  pi.on("before_agent_start", (event, ctx) => {
469
+ currentHasUI = ctx.hasUI;
433
470
  if (refreshStateBeforeFirstAgentStart) {
434
471
  refreshStateBeforeFirstAgentStart = false;
435
472
  restoreState(ctx);
@@ -443,11 +480,12 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
443
480
  persistState();
444
481
  updateUi(ctx);
445
482
  }
446
- // Read fresh and write immediately, exactly like pi-ask-user-question's own
447
- // reconciler, so the two hooks converge on the same result in either
448
- // execution order.
449
- const questionTool = preferredQuestionTool(pi);
450
- reconcileQuestionTool(pi, questionTool);
483
+ if (state.enabled && !planToolsActivated) activatePlanTools(ctx.hasUI);
484
+ else reconcilePlanToolSurface(ctx.hasUI);
485
+ // A headless run has no legitimate question tool, whatever the active set
486
+ // still says: pi-ask-user-question strips its own tool on this same hook,
487
+ // and hook order between the two packages is not ours to depend on.
488
+ const questionTool = ctx.hasUI ? preferredQuestionTool(pi) : null;
451
489
  if (state.enabled) {
452
490
  return { systemPrompt: `${event.systemPrompt}\n\n${buildPlanModePrompt(questionTool)}` };
453
491
  }
@@ -476,6 +514,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
476
514
 
477
515
  function enterPlanMode(ctx: ExtensionContext) {
478
516
  workflowGeneration += 1;
517
+ activatePlanTools(ctx.hasUI);
479
518
  state = { ...state, enabled: true, awaitingAction: false };
480
519
  persistState();
481
520
  updateUi(ctx);
@@ -525,20 +564,20 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
525
564
  * Writes the durable plan file and marks the plan ready. A write failure
526
565
  * keeps Plan mode active rather than silently losing the plan.
527
566
  */
528
- async function acceptCompletedPlan(plan: string, ctx: ExtensionContext) {
567
+ async function acceptCompletedPlan(plan: string, ctx: ExtensionContext): Promise<string> {
529
568
  const planPath = sessionPlanPath ?? resolveSessionPlanPath(ctx);
530
- sessionPlanPath = planPath;
531
569
  try {
532
570
  await writePlanFile(planPath, plan);
533
571
  } catch (error: unknown) {
534
572
  const detail = error instanceof Error ? error.message : String(error);
535
- ctx.ui.notify(`Unable to save the plan to ${planPath}: ${detail}`, "error");
536
- return undefined;
573
+ throw new Error(`Unable to save the plan to ${planPath}: ${detail}`);
537
574
  }
575
+ sessionPlanPath = planPath;
538
576
  state = { ...state, planPath, awaitingAction: true };
539
577
  pendingReadyNonce = ++readyPresentationNonce;
540
578
  persistState();
541
579
  updateUi(ctx);
580
+ showPlanModePlan(pi, ctx, "Proposed Plan", plan);
542
581
  return planPath;
543
582
  }
544
583
 
@@ -551,8 +590,12 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
551
590
  ctx.ui.notify("Plan mode is not active. Use /plan first.", "warning");
552
591
  return;
553
592
  }
593
+ // Same rule as the prompt: a headless run has no question tool to name.
594
+ const questionTool = ctx.hasUI ? preferredQuestionTool(pi) : null;
554
595
  sendPlanModeUserMessage(
555
- `Finalize the current implementation plan now. If any material decision remains, use ${preferredQuestionTool(pi)} instead. Otherwise call plan_mode_complete alone as your final action with the complete decision-ready plan.`,
596
+ `Finalize the current implementation plan now. If any material decision remains, ${
597
+ questionTool === null ? "ask it in plain text" : `use ${questionTool}`
598
+ } instead. Otherwise call plan_mode_complete alone as your final action with the complete decision-ready plan.`,
556
599
  ctx,
557
600
  );
558
601
  }
@@ -1,29 +1,165 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { Markdown, Text } from "@earendil-works/pi-tui";
2
+ import {
3
+ getMarkdownTheme,
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
2
7
  import { readPlanFile } from "./plan-file.js";
3
8
  import type { PlanModeState } from "./state.js";
4
9
 
5
10
  const STATUS_KEY = "plan-mode";
6
11
  const PLAN_WIDGET_KEY = "plan-mode-plan";
12
+ export const PLAN_CARD_ENTRY_TYPE = "plan-mode-card";
13
+
14
+ type PlanCardData = { title: string; plan: string };
15
+
16
+ /**
17
+ * Persisted entry data is input, not a guarantee.
18
+ *
19
+ * The renderer runs against whatever is on disk, which may predate a field, be
20
+ * truncated by a partial write, or have been hand-edited. Pi contains a
21
+ * renderer throw as an inline `[plan-mode-card] renderer failed: …` box —
22
+ * survivable, but a needlessly ugly way to say "this card is old".
23
+ */
24
+ function planCardData(value: unknown): PlanCardData | undefined {
25
+ if (typeof value !== "object" || value === null) return undefined;
26
+ const { title, plan } = value as { title?: unknown; plan?: unknown };
27
+ return typeof title === "string" && typeof plan === "string"
28
+ ? (value as PlanCardData)
29
+ : undefined;
30
+ }
31
+
32
+ /**
33
+ * The completed-plan card, as a display-only session entry.
34
+ *
35
+ * A custom *entry* rather than a message, which is what buys the property a
36
+ * message could not: Pi maps a `custom` entry to no context messages at all
37
+ * and skips it during compaction, so the plan stays visible and restorable in
38
+ * the transcript while never entering model context and never costing a
39
+ * compaction budget. The model gets a one-line `Plan saved to <path>.` from
40
+ * `plan_mode_complete` instead, and reads the durable file when it implements.
41
+ *
42
+ * pi-loop's approval card is the same mechanism for the same reason
43
+ * (`packages/pi-loop/src/presentation.ts`).
44
+ */
45
+ export function registerPlanModeCardRenderer(pi: ExtensionAPI): void {
46
+ pi.registerEntryRenderer(PLAN_CARD_ENTRY_TYPE, (entry) => {
47
+ const data = planCardData(entry.data);
48
+ if (!data) return new Text("Plan card unavailable.", 0, 0);
49
+ return new Markdown(
50
+ `**${data.title}**\n\n${data.plan}`,
51
+ 0,
52
+ 0,
53
+ getMarkdownTheme(),
54
+ );
55
+ });
56
+ }
57
+
58
+ /**
59
+ * The one thing both surfaces render.
60
+ *
61
+ * The footer and the widget say the same thing in two sizes, so they are
62
+ * formatted once. When each formatted its own they drifted — the sibling
63
+ * pi-loop shipped a loop that read as "waiting" in the footer and "running"
64
+ * above the editor for exactly that reason, and this is the same shape of
65
+ * bug waiting to happen with "ready" and "implementing".
66
+ *
67
+ * The glyphs are the family vocabulary shared with pi-loop by convention
68
+ * rather than by import: `◆` planning or ready, `▶` implementing. Six
69
+ * characters do not justify a shared package; a user reading a footer
70
+ * justifies the consistency.
71
+ */
72
+ export type PlanModePhase = "drafting" | "revising" | "ready" | "implementing";
73
+
74
+ export interface PlanModeView {
75
+ phase: PlanModePhase;
76
+ /** The footer line: plain text with a glyph, no colour. */
77
+ footer: string;
78
+ /** The widget's headline, rendered bold and themed. */
79
+ headline: string;
80
+ /** The dim second line: what to do next. */
81
+ hint: string;
82
+ /** Accent while the plan wants a decision; normal once it is being built. */
83
+ tone: "accent" | "normal";
84
+ }
85
+
86
+ export function planModeView(state: PlanModeState): PlanModeView | undefined {
87
+ if (state.enabled) {
88
+ if (state.awaitingAction) {
89
+ return {
90
+ phase: "ready",
91
+ footer: "◆ plan · ready → /plan",
92
+ headline: "◆ plan · proposed plan ready",
93
+ hint: "/plan to implement, export, or exit — or type feedback to revise.",
94
+ tone: "accent",
95
+ };
96
+ }
97
+ // A stored plan with no pending action means feedback superseded it: the
98
+ // plan on disk is no longer what is being offered, and saying "drafting"
99
+ // would hide that a completed plan is being replaced.
100
+ if (state.planPath) {
101
+ return {
102
+ phase: "revising",
103
+ footer: "◆ plan · revising",
104
+ headline: "◆ plan · revising the proposed plan",
105
+ hint: "The stored plan is superseded until the next plan_mode_complete.",
106
+ tone: "accent",
107
+ };
108
+ }
109
+ return {
110
+ phase: "drafting",
111
+ footer: "◆ plan · drafting",
112
+ headline: "◆ plan · drafting",
113
+ hint: "Explore and ask; finish with plan_mode_complete when decision-ready.",
114
+ tone: "accent",
115
+ };
116
+ }
117
+ if (state.planPath) {
118
+ return {
119
+ phase: "implementing",
120
+ footer: "▶ plan · implementing",
121
+ headline: "▶ plan · implementing",
122
+ hint: "/plan to show, replace, or clear the active plan.",
123
+ tone: "normal",
124
+ };
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ /**
130
+ * The slice of Pi's theme this widget uses. Structural rather than imported so
131
+ * the renderer keeps working against a host whose theme carries neither
132
+ * helper: both are optional, and an absent one degrades to plain text.
133
+ */
134
+ interface WidgetTheme {
135
+ bold?: (text: string) => string;
136
+ fg?: (color: string, text: string) => string;
137
+ }
138
+
139
+ type WidgetFactory = Parameters<ExtensionContext["ui"]["setWidget"]>[1];
7
140
 
8
141
  export function updatePlanModeUi(ctx: ExtensionContext, state: PlanModeState) {
9
- ctx.ui.setStatus(STATUS_KEY, formatStatus(state));
10
- if (state.enabled && state.awaitingAction) {
11
- ctx.ui.setWidget(PLAN_WIDGET_KEY, [
12
- "Proposed plan ready",
13
- "Use /plan to implement, export, or exit — or type feedback to revise.",
14
- ]);
15
- } else if (state.enabled) {
16
- ctx.ui.setWidget(PLAN_WIDGET_KEY, [
17
- "Plan mode: planning",
18
- "Finish with plan_mode_complete when decision-ready.",
19
- ]);
20
- } else if (state.planPath) {
21
- ctx.ui.setWidget(PLAN_WIDGET_KEY, [
22
- "Implementation plan active",
23
- "Use /plan to show, replace, or clear it.",
24
- ]);
25
- } else {
142
+ const view = planModeView(state);
143
+ ctx.ui.setStatus(STATUS_KEY, view?.footer);
144
+ if (!view) {
26
145
  ctx.ui.setWidget(PLAN_WIDGET_KEY, undefined);
146
+ return;
147
+ }
148
+ try {
149
+ const render = (_tui: unknown, rawTheme: unknown) => {
150
+ const theme = (rawTheme ?? {}) as WidgetTheme;
151
+ const bold = theme.bold ?? ((text: string) => text);
152
+ const headline =
153
+ view.tone === "accent"
154
+ ? (theme.fg?.("accent", bold(view.headline)) ?? bold(view.headline))
155
+ : bold(view.headline);
156
+ const hint = theme.fg?.("dim", ` ${view.hint}`) ?? ` ${view.hint}`;
157
+ return new Text(`${headline}\n${hint}`);
158
+ };
159
+ ctx.ui.setWidget(PLAN_WIDGET_KEY, render as WidgetFactory);
160
+ } catch {
161
+ // Presentation only: a host without the component form of setWidget (or a
162
+ // render failure) must never take Plan mode's state transitions with it.
27
163
  }
28
164
  }
29
165
 
@@ -65,20 +201,14 @@ export function showPlanModePlan(
65
201
  plan: string,
66
202
  ) {
67
203
  try {
68
- pi.sendMessage(
69
- {
70
- customType: "proposed-plan",
71
- content: `**${title}**\n\n${plan}`,
72
- display: true,
73
- },
74
- { triggerTurn: false },
75
- );
204
+ pi.appendEntry<PlanCardData>(PLAN_CARD_ENTRY_TYPE, { title, plan });
76
205
  } catch (error: unknown) {
77
206
  const detail = error instanceof Error ? error.message : String(error);
78
207
  ctx.ui.notify(`Unable to show completed plan: ${detail}`, "error");
79
208
  }
80
209
  }
81
210
 
211
+ /** The sentence form, for menus, notifications, and non-TUI modes. */
82
212
  export function planModeStatusText(state: PlanModeState) {
83
213
  if (state.enabled) {
84
214
  if (state.awaitingAction) return "Plan mode is active and a proposed plan is ready.";
@@ -90,9 +220,3 @@ export function planModeStatusText(state: PlanModeState) {
90
220
  if (state.planPath) return "An implementation plan is active.";
91
221
  return "Plan mode is off.";
92
222
  }
93
-
94
- function formatStatus(state: PlanModeState) {
95
- if (state.enabled) return state.awaitingAction ? "plan ready" : "plan active";
96
- if (state.planPath) return "plan implementing";
97
- return undefined;
98
- }
package/src/prompt.ts CHANGED
@@ -41,9 +41,29 @@ const QUESTION_TOOL_PROFILES: Record<string, QuestionToolProfile> = {
41
41
  *
42
42
  * The default keeps the exported function callable with no arguments and keeps
43
43
  * a standalone `pi-plan-mode` install reading exactly as it did before.
44
+ *
45
+ * Passing `null` builds the headless variant: no interactive question tool is
46
+ * active in that session, so naming one would tell the model to call a tool it
47
+ * cannot see. It asks in plain text instead.
44
48
  */
45
- export function buildPlanModePrompt(questionTool: string = PLAN_MODE_QUESTION_TOOL) {
46
- const tool = QUESTION_TOOL_PROFILES[questionTool] ?? QUESTION_TOOL_PROFILES[PLAN_MODE_QUESTION_TOOL];
49
+ export function buildPlanModePrompt(questionTool: string | null = PLAN_MODE_QUESTION_TOOL) {
50
+ const tool =
51
+ questionTool === null
52
+ ? undefined
53
+ : (QUESTION_TOOL_PROFILES[questionTool] ??
54
+ QUESTION_TOOL_PROFILES[PLAN_MODE_QUESTION_TOOL]);
55
+ const askBullet = tool
56
+ ? `Use ${tool.name} for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. ${tool.bounds} Do not include filler options.`
57
+ : "This session has no interactive question tool, so ask in plain text: put important preferences, tradeoffs, or assumption locks that non-mutating exploration cannot settle in your reply as 1-3 concise questions with 2-4 meaningful options each. Do not include filler options, and never call a question tool that is not in your tool set.";
58
+ const declineBullet = tool
59
+ ? `${tool.decline}, do not jump straight to a final plan when the missing answer is high impact. Ask one concise plain-text question or proceed only with a clearly stated low-risk assumption.`
60
+ : "If the question goes unanswered, do not jump straight to a final plan when the missing answer is high impact. Ask it again more concisely, or proceed only with a clearly stated low-risk assumption recorded in the plan.";
61
+ const endingBullet = tool
62
+ ? `If a material decision remains, use ${tool.name}. If interactive UI is unavailable, ask one concise plain-text question instead.`
63
+ : "If a material decision remains, ask one concise plain-text question instead.";
64
+ const revisionClause = tool
65
+ ? `continue planning with ${tool.name} instead of calling plan_mode_complete`
66
+ : "continue planning with a plain-text question instead of calling plan_mode_complete";
47
67
  return `${PLAN_CONTEXT_MARKER}
48
68
  # Plan Mode (Conversational)
49
69
 
@@ -51,6 +71,7 @@ You are in Plan Mode, a collaboration mode for producing a decision-complete imp
51
71
 
52
72
  ## Mode rules
53
73
 
74
+ - Read the pi-plan-mode skill before planning if it is available: it carries the plan-crafting craft — decision-completeness, exploring before asking, question quality, and what a finished plan contains.
54
75
  - Stay in Plan Mode until a developer or extension explicitly exits it.
55
76
  - Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
56
77
  - Do not use todo/checklist tooling to track execution progress in Plan Mode; Plan Mode is conversational planning, and the plan itself belongs in plan_mode_complete.
@@ -72,14 +93,14 @@ You are in Plan Mode, a collaboration mode for producing a decision-complete imp
72
93
  ## Phase 3 — Implementation chat
73
94
 
74
95
  - Once intent is stable, keep asking until the spec is decision-complete: approach, interfaces, data flow, edge cases/failure modes, testing and acceptance criteria, and any migration or compatibility constraints.
75
- - Use ${tool.name} for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. ${tool.bounds} Do not include filler options.
76
- - ${tool.decline}, do not jump straight to a final plan when the missing answer is high impact. Ask one concise plain-text question or proceed only with a clearly stated low-risk assumption.
96
+ - ${askBullet}
97
+ - ${declineBullet}
77
98
 
78
99
  ## Ending each turn
79
100
 
80
101
  Every Plan-mode turn that advances or finalizes the plan must end in exactly one of these ways:
81
102
 
82
- - If a material decision remains, use ${tool.name}. If interactive UI is unavailable, ask one concise plain-text question instead.
103
+ - ${endingBullet}
83
104
  - If the implementation plan is decision-complete, call plan_mode_complete alone as your final action. Do not call other tools in the same batch and do not emit a normal assistant response after it.
84
105
 
85
106
  If a follow-up asks only for clarification and does not change or challenge the plan, answer it directly, then call plan_mode_complete alone as the final action with the complete unchanged plan so it remains available for implementation.
@@ -100,7 +121,7 @@ Keep the plan concise, human and agent digestible, and free of open decisions. P
100
121
 
101
122
  The plan is saved to a durable file, so it survives compaction and can be re-read at any time.
102
123
 
103
- If the user requests revisions after a completed plan, the next plan_mode_complete call must contain a complete replacement, not a delta. If there is not enough information for a complete replacement, continue planning with ${tool.name} instead of calling plan_mode_complete.`;
124
+ If the user requests revisions after a completed plan, the next plan_mode_complete call must contain a complete replacement, not a delta. If there is not enough information for a complete replacement, ${revisionClause}.`;
104
125
  }
105
126
 
106
127
  /**
@@ -152,11 +152,13 @@ export async function answerPlanModeQuestions(
152
152
  questions: PlanModeQuestion[],
153
153
  ctx: ExtensionContext,
154
154
  lifecycle: { isCurrent(): boolean; isEnabled(): boolean },
155
+ signal?: AbortSignal,
155
156
  ) {
156
157
  const answers = await askPlanModeQuestions(
157
158
  questions,
158
159
  ctx,
159
- () => lifecycle.isCurrent() && lifecycle.isEnabled(),
160
+ () => lifecycle.isCurrent() && lifecycle.isEnabled() && !signal?.aborted,
161
+ signal,
160
162
  );
161
163
  if (!lifecycle.isCurrent()) {
162
164
  return planModeQuestionCancelled(
@@ -186,18 +188,22 @@ export async function askPlanModeQuestions(
186
188
  questions: PlanModeQuestion[],
187
189
  ctx: ExtensionContext,
188
190
  shouldContinue: () => boolean = () => true,
191
+ signal?: AbortSignal,
189
192
  ): Promise<PlanModeQuestionAnswer[] | undefined> {
190
193
  const answers: PlanModeQuestionAnswer[] = [];
191
194
  for (const question of questions) {
195
+ if (!shouldContinue() || signal?.aborted) return undefined;
192
196
  const choices = question.options.map(formatPlanModeQuestionChoice);
193
197
  const otherChoice = `${question.options.length + 1}. Other (free-form)`;
194
- const choice = await ctx.ui.select(`${question.header}: ${question.question}`, [
195
- ...choices,
196
- otherChoice,
197
- ]);
198
+ const choice = await raceWithAbort(
199
+ ctx.ui.select(`${question.header}: ${question.question}`, [...choices, otherChoice]),
200
+ signal,
201
+ );
198
202
  if (!shouldContinue() || !choice) return undefined;
199
203
  if (choice === otherChoice) {
200
- const customAnswer = (await ctx.ui.editor(question.question, ""))?.trim();
204
+ const customAnswer = (
205
+ await raceWithAbort(ctx.ui.editor(question.question, ""), signal)
206
+ )?.trim();
201
207
  if (!shouldContinue() || !customAnswer) return undefined;
202
208
  answers.push({
203
209
  id: question.id,
@@ -223,6 +229,16 @@ export async function askPlanModeQuestions(
223
229
  return answers;
224
230
  }
225
231
 
232
+ async function raceWithAbort<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T | undefined> {
233
+ if (!signal) return operation;
234
+ if (signal.aborted) return undefined;
235
+ return new Promise<T | undefined>((resolve, reject) => {
236
+ const abort = () => resolve(undefined);
237
+ signal.addEventListener("abort", abort, { once: true });
238
+ operation.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
239
+ });
240
+ }
241
+
226
242
  function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: number) {
227
243
  return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
228
244
  }