@hank-warren/pi-plan-mode 1.4.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 +14 -0
- package/package.json +1 -1
- package/src/completion-tool.ts +6 -1
- package/src/plan-mode.ts +88 -45
- package/src/presentation.ts +52 -10
- package/src/prompt.ts +26 -6
- package/src/question-tool.ts +22 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 1.4.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/package.json
CHANGED
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,10 +1,59 @@
|
|
|
1
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
-
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";
|
|
3
7
|
import { readPlanFile } from "./plan-file.js";
|
|
4
8
|
import type { PlanModeState } from "./state.js";
|
|
5
9
|
|
|
6
10
|
const STATUS_KEY = "plan-mode";
|
|
7
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
|
+
}
|
|
8
57
|
|
|
9
58
|
/**
|
|
10
59
|
* The one thing both surfaces render.
|
|
@@ -152,14 +201,7 @@ export function showPlanModePlan(
|
|
|
152
201
|
plan: string,
|
|
153
202
|
) {
|
|
154
203
|
try {
|
|
155
|
-
pi.
|
|
156
|
-
{
|
|
157
|
-
customType: "proposed-plan",
|
|
158
|
-
content: `**${title}**\n\n${plan}`,
|
|
159
|
-
display: true,
|
|
160
|
-
},
|
|
161
|
-
{ triggerTurn: false },
|
|
162
|
-
);
|
|
204
|
+
pi.appendEntry<PlanCardData>(PLAN_CARD_ENTRY_TYPE, { title, plan });
|
|
163
205
|
} catch (error: unknown) {
|
|
164
206
|
const detail = error instanceof Error ? error.message : String(error);
|
|
165
207
|
ctx.ui.notify(`Unable to show completed plan: ${detail}`, "error");
|
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
|
|
|
@@ -73,14 +93,14 @@ You are in Plan Mode, a collaboration mode for producing a decision-complete imp
|
|
|
73
93
|
## Phase 3 — Implementation chat
|
|
74
94
|
|
|
75
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.
|
|
76
|
-
-
|
|
77
|
-
- ${
|
|
96
|
+
- ${askBullet}
|
|
97
|
+
- ${declineBullet}
|
|
78
98
|
|
|
79
99
|
## Ending each turn
|
|
80
100
|
|
|
81
101
|
Every Plan-mode turn that advances or finalizes the plan must end in exactly one of these ways:
|
|
82
102
|
|
|
83
|
-
-
|
|
103
|
+
- ${endingBullet}
|
|
84
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.
|
|
85
105
|
|
|
86
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.
|
|
@@ -101,7 +121,7 @@ Keep the plan concise, human and agent digestible, and free of open decisions. P
|
|
|
101
121
|
|
|
102
122
|
The plan is saved to a durable file, so it survives compaction and can be re-read at any time.
|
|
103
123
|
|
|
104
|
-
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}.`;
|
|
105
125
|
}
|
|
106
126
|
|
|
107
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
|
}
|