@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 +22 -0
- package/README.md +16 -5
- package/package.json +5 -1
- package/skills/pi-plan-mode/SKILL.md +81 -0
- package/src/completion-tool.ts +6 -1
- package/src/plan-mode.ts +88 -45
- package/src/presentation.ts +156 -32
- package/src/prompt.ts +27 -6
- package/src/question-tool.ts +22 -6
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
|
-
##
|
|
121
|
+
## 📚 The companion skill
|
|
121
122
|
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
"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.
|
package/src/completion-tool.ts
CHANGED
|
@@ -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: [
|
|
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
|
|
78
|
+
* Which question tool the prompt may name this turn, read from the tool set
|
|
79
|
+
* the model will actually see.
|
|
76
80
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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 (
|
|
106
|
-
|
|
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,
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
}
|
package/src/presentation.ts
CHANGED
|
@@ -1,29 +1,165 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
-
|
|
76
|
-
- ${
|
|
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
|
-
-
|
|
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,
|
|
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
|
/**
|
package/src/question-tool.ts
CHANGED
|
@@ -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
|
|
195
|
-
...choices,
|
|
196
|
-
|
|
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 = (
|
|
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
|
}
|