@narumitw/pi-plan-mode 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plan-mode.ts CHANGED
@@ -1,18 +1,48 @@
1
- import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ToolInfo,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ extractProposedPlan,
8
+ latestAssistantText,
9
+ parseProposedPlan,
10
+ messageContainsInactivePlanModeArtifact,
11
+ messageContainsLegacyPlanModeContextArtifact,
12
+ stripProposedPlanBlocksFromMessage,
13
+ } from "./message-transform.js";
14
+ import { buildPlanModePrompt } from "./prompt.js";
15
+ import {
16
+ askPlanModeQuestions,
17
+ normalizePlanModeQuestionParams,
18
+ PLAN_MODE_QUESTION_PARAMS,
19
+ PLAN_MODE_QUESTION_TOOL_NAME,
20
+ planModeQuestionAnswered,
21
+ planModeQuestionCancelled,
22
+ } from "./question-tool.js";
23
+ import {
24
+ canSelectToolInPlanMode,
25
+ classifyPlanModeTool,
26
+ isBuiltinTool,
27
+ isSafeCommand,
28
+ readCommand,
29
+ SAFE_BUILTIN_PLAN_TOOLS,
30
+ } from "./tool-policy.js";
31
+ import {
32
+ configuredThinkingLevel,
33
+ PLAN_MODE_THINKING_LEVELS,
34
+ readPlanModeSettings,
35
+ type PlanModeFixedThinkingLevel,
36
+ type PlanModeSettings,
37
+ } from "./settings.js";
2
38
 
3
39
  const STATE_ENTRY_TYPE = "plan-mode-state";
4
40
  const STATUS_KEY = "plan-mode";
5
41
  const PLAN_WIDGET_KEY = "plan-mode-plan";
6
- const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
7
42
  const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
8
- const PLAN_MODE_QUESTION_TOOL_NAME = "plan_mode_question";
9
- const PLAN_CONTEXT_MARKER = "[CODEX-LIKE PLAN MODE ACTIVE]";
10
- const SAFE_BUILTIN_PLAN_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
11
43
  const BLOCKED_BUILTIN_TOOLS = new Set(["edit", "write"]);
12
44
  const DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
13
45
  const TOOL_SELECTOR_PAGE_SIZE = 10;
14
- const PROPOSED_PLAN_PATTERN = /<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i;
15
- const PROPOSED_PLAN_BLOCK_PATTERN = /<proposed_plan>\s*[\s\S]*?\s*<\/proposed_plan>/gi;
16
46
 
17
47
  interface CommandArgumentCompletion {
18
48
  value: string;
@@ -26,6 +56,9 @@ interface PlanModeState {
26
56
  awaitingAction: boolean;
27
57
  selectedToolNames?: string[];
28
58
  selectedToolKeys?: string[];
59
+ previousThinkingLevel?: PlanModeFixedThinkingLevel;
60
+ appliedThinkingLevel?: PlanModeFixedThinkingLevel;
61
+ manualThinkingLevel?: PlanModeFixedThinkingLevel;
29
62
  }
30
63
 
31
64
  type SessionEntry = {
@@ -45,146 +78,15 @@ type TextBlock = {
45
78
  text?: string;
46
79
  };
47
80
 
48
- type PlanModeQuestionOption = {
49
- label: string;
50
- description?: string;
51
- };
52
-
53
- type PlanModeQuestion = {
54
- id: string;
55
- header: string;
56
- question: string;
57
- options: PlanModeQuestionOption[];
58
- };
59
-
60
- type PlanModeQuestionParams = {
61
- questions: PlanModeQuestion[];
62
- };
63
-
64
- type PlanModeQuestionAnswer = {
65
- id: string;
66
- header: string;
67
- question: string;
68
- answer: string;
69
- wasCustom: boolean;
70
- optionIndex?: number;
71
- };
72
-
73
- type PlanModeQuestionReason = "cancelled" | "ui_unavailable" | "plan_mode_inactive" | "invalid_input";
74
-
75
- type PlanModeQuestionDetails = {
76
- cancelled: boolean;
77
- reason?: PlanModeQuestionReason;
78
- questions: PlanModeQuestion[];
79
- answers?: PlanModeQuestionAnswer[];
80
- };
81
-
82
81
  const PLAN_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
83
82
  { value: "exit", label: "exit", description: "Leave Plan mode" },
84
83
  { value: "off", label: "off", description: "Leave Plan mode" },
85
84
  { value: "tools", label: "tools", description: "Select tools allowed in Plan mode" },
86
85
  ];
87
86
 
88
- const PLAN_MODE_QUESTION_PARAMS = {
89
- type: "object",
90
- additionalProperties: false,
91
- required: ["questions"],
92
- properties: {
93
- questions: {
94
- type: "array",
95
- minItems: 1,
96
- maxItems: 3,
97
- description: "Questions to show the user. Prefer 1 and do not exceed 3.",
98
- items: {
99
- type: "object",
100
- additionalProperties: false,
101
- required: ["id", "header", "question", "options"],
102
- properties: {
103
- id: {
104
- type: "string",
105
- description: "Stable identifier for mapping answers (snake_case).",
106
- },
107
- header: {
108
- type: "string",
109
- description: "Short header label shown in the UI (12 or fewer chars).",
110
- },
111
- question: {
112
- type: "string",
113
- description: "Single-sentence prompt shown to the user.",
114
- },
115
- options: {
116
- type: "array",
117
- minItems: 2,
118
- maxItems: 4,
119
- description:
120
- "Provide 2-4 mutually exclusive choices. Put the recommended option first when there is a clear default.",
121
- items: {
122
- type: "object",
123
- additionalProperties: false,
124
- required: ["label", "description"],
125
- properties: {
126
- label: {
127
- type: "string",
128
- description: "User-facing label (1-5 words).",
129
- },
130
- description: {
131
- type: "string",
132
- description: "One short sentence explaining impact/tradeoff if selected.",
133
- },
134
- },
135
- },
136
- },
137
- },
138
- },
139
- },
140
- },
141
- } as const;
142
-
143
- const MUTATING_BASH_PATTERNS = [
144
- /\brm\b/i,
145
- /\brmdir\b/i,
146
- /\bmv\b/i,
147
- /\bcp\b/i,
148
- /\bmkdir\b/i,
149
- /\btouch\b/i,
150
- /\bchmod\b/i,
151
- /\bchown\b/i,
152
- /\bchgrp\b/i,
153
- /\bln\b/i,
154
- /\btee\b/i,
155
- /\btruncate\b/i,
156
- /\bdd\b/i,
157
- /(^|[^<])>(?!>)/,
158
- />>/,
159
- /\bnpm\s+(install|uninstall|update|ci|link|publish|version)\b/i,
160
- /\byarn\s+(add|remove|install|publish|upgrade)\b/i,
161
- /\bpnpm\s+(add|remove|install|publish|update)\b/i,
162
- /\bbun\s+(add|remove|install|update|publish)\b/i,
163
- /\bpip\s+(install|uninstall)\b/i,
164
- /\buv\s+(add|remove|sync|lock|pip\s+install)\b/i,
165
- /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|switch|stash|cherry-pick|revert|tag|init|clone)\b/i,
166
- /\bsudo\b/i,
167
- /\bsu\b/i,
168
- /\bkill\b/i,
169
- /\bpkill\b/i,
170
- /\bkillall\b/i,
171
- /\breboot\b/i,
172
- /\bshutdown\b/i,
173
- /\bsystemctl\s+(start|stop|restart|enable|disable)\b/i,
174
- /\bservice\s+\S+\s+(start|stop|restart)\b/i,
175
- /\b(vim?|nano|emacs|code|subl)\b/i,
176
- ];
177
-
178
- const SAFE_BASH_PATTERNS = [
179
- /^\s*(cat|head|tail|less|more|grep|find|ls|pwd|echo|printf|wc|sort|uniq|diff|file|stat|du|df|tree|which|whereis|type|env|printenv|uname|whoami|id|date|uptime|ps|jq|awk|rg|fd|bat|eza)\b/i,
180
- /^\s*sed\s+-n\b/i,
181
- /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get|ls-files|grep)\b/i,
182
- /^\s*npm\s+(list|ls|view|info|search|outdated|audit)\b/i,
183
- /^\s*(node|python|python3|npm|tsc|biome|ruff|ty)\s+--version\b/i,
184
- ];
185
-
186
87
  export default function planMode(pi: ExtensionAPI) {
187
88
  let state: PlanModeState = { enabled: false, awaitingAction: false };
89
+ let settings: PlanModeSettings = { thinkingLevel: "inherit" };
188
90
  let previousTools: string[] | undefined;
189
91
 
190
92
  pi.registerFlag("plan", {
@@ -267,25 +169,65 @@ export default function planMode(pi: ExtensionAPI) {
267
169
  },
268
170
  });
269
171
 
270
- pi.on("session_start", (_event, ctx) => {
172
+ pi.on("session_start", async (_event, ctx) => {
173
+ settings = { thinkingLevel: "inherit" };
174
+ const loadedSettings = await readPlanModeSettings();
175
+ if (loadedSettings.kind === "loaded") settings = loadedSettings.settings;
176
+ else if (loadedSettings.kind === "invalid") {
177
+ ctx.ui.notify(`pi-plan-mode settings ignored: ${loadedSettings.reason}`, "warning");
178
+ }
271
179
  restoreState(ctx);
272
180
  if (pi.getFlag("plan") === true) state.enabled = true;
273
- if (state.enabled) activatePlanModeTools();
274
- else deactivatePlanModeQuestionTool();
181
+ if (state.enabled) {
182
+ activatePlanModeTools();
183
+ applyPlanThinkingLevel();
184
+ } else deactivatePlanModeQuestionTool();
275
185
  updateUi(ctx);
276
186
  });
277
187
 
188
+ pi.on("thinking_level_select", (event) => {
189
+ if (!state.enabled || !state.appliedThinkingLevel) return;
190
+ if (event.level !== state.appliedThinkingLevel) {
191
+ state = {
192
+ ...state,
193
+ manualThinkingLevel: event.level,
194
+ previousThinkingLevel: undefined,
195
+ appliedThinkingLevel: undefined,
196
+ };
197
+ persistState();
198
+ }
199
+ });
200
+
278
201
  pi.on("session_shutdown", (_event, ctx) => {
202
+ captureManualThinkingLevel();
279
203
  persistState();
204
+ if (state.enabled) {
205
+ restoreTools();
206
+ restoreThinkingLevel();
207
+ }
280
208
  clearUi(ctx);
281
209
  });
282
210
 
283
211
  pi.on("tool_call", async (event) => {
284
212
  if (!state.enabled) return;
285
- if (isBlockedBuiltinToolName(event.toolName)) {
213
+ if (event.toolName === "update_plan") {
214
+ return {
215
+ block: true,
216
+ reason:
217
+ "Plan mode blocks update_plan because it tracks execution progress rather than conversational planning.",
218
+ };
219
+ }
220
+ const calledTool = toolByName(event.toolName);
221
+ if (calledTool && classifyPlanModeTool(calledTool) === "blocked") {
286
222
  return {
287
223
  block: true,
288
- reason: `Plan mode blocks built-in mutating tool '${event.toolName}'. Use /plan and choose implementation when the plan is ready.`,
224
+ reason: `Plan mode blocks built-in tool '${event.toolName}' because its policy class is blocked.`,
225
+ };
226
+ }
227
+ if (!calledTool && BLOCKED_BUILTIN_TOOLS.has(event.toolName)) {
228
+ return {
229
+ block: true,
230
+ reason: `Plan mode blocks built-in tool '${event.toolName}' because its metadata is unavailable.`,
289
231
  };
290
232
  }
291
233
  if (event.toolName !== "bash" || !isBuiltinToolName(event.toolName)) return;
@@ -328,12 +270,16 @@ export default function planMode(pi: ExtensionAPI) {
328
270
  if (!state.enabled) return;
329
271
 
330
272
  const text = latestAssistantText(event.messages);
331
- const proposedPlan = extractProposedPlan(text);
332
- if (!proposedPlan) {
273
+ const parsedPlan = parseProposedPlan(text);
274
+ if (parsedPlan.kind !== "valid") {
275
+ if (parsedPlan.kind !== "absent") {
276
+ ctx.ui.notify(invalidPlanMessage(parsedPlan.kind), "warning");
277
+ }
333
278
  persistState();
334
279
  updateUi(ctx);
335
280
  return;
336
281
  }
282
+ const proposedPlan = parsedPlan.plan;
337
283
 
338
284
  state = { ...state, latestPlan: proposedPlan, awaitingAction: true };
339
285
  persistState();
@@ -359,6 +305,7 @@ export default function planMode(pi: ExtensionAPI) {
359
305
  if (!state.enabled) previousTools = withoutPlanModeQuestionTool(safeGetActiveTools());
360
306
  state = { ...state, enabled: true, awaitingAction: false };
361
307
  activatePlanModeTools();
308
+ applyPlanThinkingLevel();
362
309
  persistState();
363
310
  updateUi(ctx);
364
311
  }
@@ -369,20 +316,37 @@ export default function planMode(pi: ExtensionAPI) {
369
316
  if (!wasEnabled) {
370
317
  ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
371
318
  }
372
- sendPlanModeUserMessage(prompt, ctx);
319
+ if (!sendPlanModeUserMessage(prompt, ctx) && !wasEnabled) exitPlanMode(ctx);
373
320
  }
374
321
 
375
322
  function exitPlanMode(ctx: ExtensionContext) {
376
323
  const wasEnabled = state.enabled;
377
- state = { ...state, enabled: false, latestPlan: undefined, awaitingAction: false };
378
- if (wasEnabled) restoreTools();
324
+ state = {
325
+ ...state,
326
+ enabled: false,
327
+ latestPlan: undefined,
328
+ awaitingAction: false,
329
+ manualThinkingLevel: undefined,
330
+ };
331
+ if (wasEnabled) {
332
+ restoreTools();
333
+ restoreThinkingLevel();
334
+ state = { ...state, manualThinkingLevel: undefined };
335
+ }
379
336
  persistState();
380
337
  updateUi(ctx);
381
338
  }
382
339
 
383
340
  function sendPlanModeUserMessage(message: string, ctx: ExtensionContext) {
384
- if (ctx.isIdle()) pi.sendUserMessage(message);
385
- else pi.sendUserMessage(message, { deliverAs: "followUp" });
341
+ try {
342
+ if (ctx.isIdle()) pi.sendUserMessage(message);
343
+ else pi.sendUserMessage(message, { deliverAs: "followUp" });
344
+ return true;
345
+ } catch (error: unknown) {
346
+ const detail = error instanceof Error ? error.message : String(error);
347
+ ctx.ui.notify(`Unable to send Plan-mode message: ${detail}`, "error");
348
+ return false;
349
+ }
386
350
  }
387
351
 
388
352
  function scheduleAfterCurrentAgentRun(task: () => Promise<void> | void) {
@@ -403,10 +367,16 @@ export default function planMode(pi: ExtensionAPI) {
403
367
  return;
404
368
  }
405
369
 
406
- sendPlanModeUserMessage(
370
+ const sent = sendPlanModeUserMessage(
407
371
  `Plan mode is now disabled. Full tool access is restored. Implement this proposed plan now:\n\n${plan}`,
408
372
  ctx,
409
373
  );
374
+ if (!sent) {
375
+ enterPlanMode(ctx);
376
+ state = { ...state, latestPlan: plan, awaitingAction: true };
377
+ persistState();
378
+ updateUi(ctx);
379
+ }
410
380
  }
411
381
 
412
382
  async function showPlanMenu(ctx: ExtensionContext) {
@@ -596,11 +566,58 @@ export default function planMode(pi: ExtensionAPI) {
596
566
  }
597
567
 
598
568
  function restoreTools() {
599
- const restoredTools = previousTools && previousTools.length > 0 ? previousTools : DEFAULT_TOOLS;
569
+ const restoredTools = previousTools ?? DEFAULT_TOOLS;
600
570
  pi.setActiveTools(withoutPlanModeQuestionTool(restoredTools));
601
571
  previousTools = undefined;
602
572
  }
603
573
 
574
+ function applyPlanThinkingLevel() {
575
+ if (state.manualThinkingLevel) {
576
+ if (pi.getThinkingLevel() !== state.manualThinkingLevel) {
577
+ pi.setThinkingLevel(state.manualThinkingLevel);
578
+ }
579
+ return;
580
+ }
581
+ const configured = configuredThinkingLevel(settings);
582
+ if (!configured) {
583
+ state = {
584
+ ...state,
585
+ previousThinkingLevel: undefined,
586
+ appliedThinkingLevel: undefined,
587
+ };
588
+ return;
589
+ }
590
+ const current = pi.getThinkingLevel();
591
+ if (!state.appliedThinkingLevel) state.previousThinkingLevel = current;
592
+ if (current !== configured) pi.setThinkingLevel(configured);
593
+ state.appliedThinkingLevel = pi.getThinkingLevel();
594
+ }
595
+
596
+ function captureManualThinkingLevel() {
597
+ if (!state.appliedThinkingLevel) return;
598
+ const current = pi.getThinkingLevel();
599
+ if (current === state.appliedThinkingLevel) return;
600
+ state = {
601
+ ...state,
602
+ manualThinkingLevel: current,
603
+ previousThinkingLevel: undefined,
604
+ appliedThinkingLevel: undefined,
605
+ };
606
+ }
607
+
608
+ function restoreThinkingLevel() {
609
+ captureManualThinkingLevel();
610
+ const { appliedThinkingLevel, previousThinkingLevel } = state;
611
+ if (
612
+ appliedThinkingLevel &&
613
+ previousThinkingLevel &&
614
+ pi.getThinkingLevel() === appliedThinkingLevel
615
+ ) {
616
+ pi.setThinkingLevel(previousThinkingLevel);
617
+ }
618
+ state = { ...state, appliedThinkingLevel: undefined, previousThinkingLevel: undefined };
619
+ }
620
+
604
621
  function deactivatePlanModeQuestionTool() {
605
622
  const activeTools = safeGetActiveTools();
606
623
  const filteredTools = withoutPlanModeQuestionTool(activeTools);
@@ -622,18 +639,29 @@ export default function planMode(pi: ExtensionAPI) {
622
639
  }
623
640
 
624
641
  function restoreState(ctx: ExtensionContext) {
642
+ state = { enabled: false, awaitingAction: false };
625
643
  const entries = ctx.sessionManager.getEntries() as SessionEntry[];
626
644
  const entry = entries
627
645
  .filter((candidate) => candidate.type === "custom" && candidate.customType === STATE_ENTRY_TYPE)
628
646
  .pop();
629
- if (!entry?.data) return;
630
- const enabled = entry.data.enabled ?? false;
647
+ if (!isRecord(entry?.data)) return;
648
+ const enabled = entry.data.enabled === true;
631
649
  state = {
632
650
  enabled,
633
- latestPlan: enabled ? entry.data.latestPlan : undefined,
634
- awaitingAction: enabled ? (entry.data.awaitingAction ?? false) : false,
635
- selectedToolNames: entry.data.selectedToolNames,
636
- selectedToolKeys: entry.data.selectedToolKeys,
651
+ latestPlan:
652
+ enabled && typeof entry.data.latestPlan === "string" ? entry.data.latestPlan : undefined,
653
+ awaitingAction: enabled && entry.data.awaitingAction === true,
654
+ selectedToolNames: stringArray(entry.data.selectedToolNames),
655
+ selectedToolKeys: stringArray(entry.data.selectedToolKeys),
656
+ previousThinkingLevel: enabled
657
+ ? fixedThinkingLevel(entry.data.previousThinkingLevel)
658
+ : undefined,
659
+ appliedThinkingLevel: enabled
660
+ ? fixedThinkingLevel(entry.data.appliedThinkingLevel)
661
+ : undefined,
662
+ manualThinkingLevel: enabled
663
+ ? fixedThinkingLevel(entry.data.manualThinkingLevel)
664
+ : undefined,
637
665
  };
638
666
  }
639
667
 
@@ -677,12 +705,6 @@ export default function planMode(pi: ExtensionAPI) {
677
705
  return `Tools: ${names.length > 0 ? names.join(", ") : "none"}`;
678
706
  }
679
707
 
680
- function isBlockedBuiltinToolName(toolName: string) {
681
- if (!BLOCKED_BUILTIN_TOOLS.has(toolName)) return false;
682
- const tool = toolByName(toolName);
683
- return tool ? isBuiltinTool(tool) : true;
684
- }
685
-
686
708
  function isBuiltinToolName(toolName: string) {
687
709
  const tool = toolByName(toolName);
688
710
  return tool ? isBuiltinTool(tool) : toolName === "bash";
@@ -693,10 +715,6 @@ export default function planMode(pi: ExtensionAPI) {
693
715
  }
694
716
  }
695
717
 
696
- function isBuiltinTool(tool: ToolInfo) {
697
- return tool.sourceInfo.source === "builtin";
698
- }
699
-
700
718
  export function completePlanArguments(argumentPrefix: string): CommandArgumentCompletion[] | null {
701
719
  const prefix = argumentPrefix.trimStart().toLowerCase();
702
720
  if (prefix === "") return [...PLAN_COMMAND_COMPLETIONS];
@@ -706,11 +724,6 @@ export function completePlanArguments(argumentPrefix: string): CommandArgumentCo
706
724
  return matches.length > 0 ? [...matches] : null;
707
725
  }
708
726
 
709
- export function canSelectToolInPlanMode(tool: ToolInfo) {
710
- if (isBuiltinTool(tool)) return SAFE_BUILTIN_PLAN_TOOLS.has(tool.name);
711
- return true;
712
- }
713
-
714
727
  function toolNameFromLegacyKey(key: string, tools: ToolInfo[]) {
715
728
  const directName = tools.find((tool) => tool.name === key)?.name;
716
729
  if (directName) return directName;
@@ -731,11 +744,11 @@ function formatToolChoice(tool: ToolInfo, selected: boolean, index: number) {
731
744
  }
732
745
 
733
746
  function toolPolicyLabel(tool: ToolInfo) {
734
- if (isBuiltinTool(tool)) {
735
- if (!SAFE_BUILTIN_PLAN_TOOLS.has(tool.name)) return "built-in blocked";
736
- return tool.name === "bash" ? "built-in limited" : "built-in";
737
- }
738
- return `user risk: ${toolSourceLabel(tool)}`;
747
+ const policy = classifyPlanModeTool(tool);
748
+ if (policy === "read-only") return "built-in read-only";
749
+ if (policy === "limited") return "built-in limited";
750
+ if (policy === "blocked") return "built-in blocked";
751
+ return `user opt-in: ${toolSourceLabel(tool)}`;
739
752
  }
740
753
 
741
754
  function toolSourceLabel(tool: ToolInfo) {
@@ -748,307 +761,50 @@ function unique(values: string[]) {
748
761
  return Array.from(new Set(values));
749
762
  }
750
763
 
751
- export function withRequiredPlanModeTools(toolNames: string[]) {
752
- return unique([...withoutPlanModeQuestionTool(toolNames), PLAN_MODE_QUESTION_TOOL_NAME]);
753
- }
754
-
755
- export function withoutPlanModeQuestionTool(toolNames: string[]) {
756
- return toolNames.filter((toolName) => toolName !== PLAN_MODE_QUESTION_TOOL_NAME);
757
- }
758
-
759
- type NormalizePlanModeQuestionParamsResult =
760
- | { ok: true; questions: PlanModeQuestion[] }
761
- | { ok: false; error: string };
762
-
763
- export function normalizePlanModeQuestionParams(input: unknown): NormalizePlanModeQuestionParamsResult {
764
- if (!isRecord(input) || !Array.isArray(input.questions)) {
765
- return { ok: false, error: "questions must be an array" };
766
- }
767
- if (input.questions.length < 1 || input.questions.length > 3) {
768
- return { ok: false, error: "questions must contain 1-3 items" };
769
- }
770
-
771
- const questions: PlanModeQuestion[] = [];
772
- for (const [questionIndex, rawQuestion] of input.questions.entries()) {
773
- if (!isRecord(rawQuestion)) {
774
- return { ok: false, error: `question ${questionIndex + 1} must be an object` };
775
- }
776
-
777
- const id = stringField(rawQuestion.id);
778
- const header = stringField(rawQuestion.header);
779
- const question = stringField(rawQuestion.question);
780
- if (!id || !header || !question) {
781
- return {
782
- ok: false,
783
- error: `question ${questionIndex + 1} requires non-empty id, header, and question`,
784
- };
785
- }
786
-
787
- if (!Array.isArray(rawQuestion.options)) {
788
- return { ok: false, error: `question ${questionIndex + 1} options must be an array` };
789
- }
790
- if (rawQuestion.options.length < 2 || rawQuestion.options.length > 4) {
791
- return { ok: false, error: `question ${questionIndex + 1} options must contain 2-4 items` };
792
- }
793
-
794
- const options: PlanModeQuestionOption[] = [];
795
- for (const [optionIndex, rawOption] of rawQuestion.options.entries()) {
796
- if (!isRecord(rawOption)) {
797
- return {
798
- ok: false,
799
- error: `question ${questionIndex + 1} option ${optionIndex + 1} must be an object`,
800
- };
801
- }
802
-
803
- const label = stringField(rawOption.label);
804
- if (!label) {
805
- return {
806
- ok: false,
807
- error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a label`,
808
- };
809
- }
810
- const description = stringField(rawOption.description);
811
- if (!description) {
812
- return {
813
- ok: false,
814
- error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a description`,
815
- };
816
- }
817
- options.push({ label, description });
818
- }
819
-
820
- questions.push({ id, header, question, options });
821
- }
822
-
823
- return { ok: true, questions };
824
- }
825
-
826
- async function askPlanModeQuestions(
827
- questions: PlanModeQuestion[],
828
- ctx: ExtensionContext,
829
- ): Promise<PlanModeQuestionAnswer[] | undefined> {
830
- const answers: PlanModeQuestionAnswer[] = [];
831
- for (const question of questions) {
832
- const choices = question.options.map(formatPlanModeQuestionChoice);
833
- const otherChoice = `${question.options.length + 1}. Other (free-form)`;
834
- const choice = await ctx.ui.select(`${question.header}: ${question.question}`, [...choices, otherChoice]);
835
- if (!choice) return undefined;
836
-
837
- if (choice === otherChoice) {
838
- const customAnswer = (await ctx.ui.editor(question.question, ""))?.trim();
839
- if (!customAnswer) return undefined;
840
- answers.push({
841
- id: question.id,
842
- header: question.header,
843
- question: question.question,
844
- answer: customAnswer,
845
- wasCustom: true,
846
- });
847
- continue;
848
- }
849
-
850
- const optionIndex = choices.indexOf(choice);
851
- const option = question.options[optionIndex];
852
- if (!option) return undefined;
853
- answers.push({
854
- id: question.id,
855
- header: question.header,
856
- question: question.question,
857
- answer: option.label,
858
- wasCustom: false,
859
- optionIndex: optionIndex + 1,
860
- });
861
- }
862
- return answers;
863
- }
864
-
865
- function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: number) {
866
- return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
867
- }
868
-
869
- function planModeQuestionAnswered(questions: PlanModeQuestion[], answers: PlanModeQuestionAnswer[]) {
870
- return {
871
- content: [{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: false, answers }) }],
872
- details: { cancelled: false, questions, answers } satisfies PlanModeQuestionDetails,
873
- };
874
- }
875
-
876
- function planModeQuestionCancelled(
877
- questions: PlanModeQuestion[],
878
- reason: PlanModeQuestionReason,
879
- message: string,
880
- ) {
881
- return {
882
- content: [{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: true, reason, message }) }],
883
- details: { cancelled: true, reason, questions } satisfies PlanModeQuestionDetails,
884
- };
885
- }
886
-
887
- function formatPlanModeQuestionPayload(payload: {
888
- cancelled: boolean;
889
- reason?: PlanModeQuestionReason;
890
- message?: string;
891
- answers?: PlanModeQuestionAnswer[];
892
- }) {
893
- return JSON.stringify(payload, null, 2);
894
- }
895
-
896
764
  function isRecord(value: unknown): value is Record<string, unknown> {
897
- return typeof value === "object" && value !== null;
898
- }
899
-
900
- function stringField(value: unknown) {
901
- return typeof value === "string" ? value.trim() : undefined;
902
- }
903
-
904
- function buildPlanModePrompt() {
905
- return `${PLAN_CONTEXT_MARKER}
906
- # Plan Mode (Conversational)
907
-
908
- You are in Plan Mode, a Codex-like collaboration mode for producing a decision-complete implementation plan. Chat your way to the plan before finalizing it. A final plan must leave no implementation decisions unresolved.
909
-
910
- ## Mode rules
911
-
912
- - Stay in Plan Mode until a developer or extension explicitly exits it.
913
- - Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
914
- - Do not use update_plan/TODO tooling in Plan Mode; Plan Mode is conversational planning, not execution progress tracking.
915
- - Plan Mode manages built-in tool safety only. Non-built-in tools are disabled by default and may be enabled by the user at their own risk.
916
- - Do not perform mutating actions: no edit/write tools, no patching, no formatting that rewrites files, no dependency installation, no commits, no migrations.
917
-
918
- ## Phase 1 — Ground in the environment
919
-
920
- - Explore first and ask second. Use non-mutating exploration to read files, search, inspect configuration, run read-only checks, and resolve discoverable facts.
921
- - Before asking the user any question, perform at least one targeted non-mutating exploration pass unless no local environment or repository is available.
922
- - Do not ask questions that can be answered from repository or system truth. Ask only when multiple plausible choices remain, a needed identifier/context is missing, or the ambiguity is product intent.
923
-
924
- ## Phase 2 — Intent chat
925
-
926
- - Keep asking until you can clearly state the goal, success criteria, in/out of scope, constraints, current state, and key preferences/tradeoffs.
927
- - Bias toward questions over guessing: if a high-impact ambiguity remains, do not produce a proposed plan yet.
928
-
929
- ## Phase 3 — Implementation chat
930
-
931
- - 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.
932
- - Use plan_mode_question for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. Ask 1-3 concise questions with 2-4 meaningful options. Do not include filler options.
933
- - If plan_mode_question returns cancelled or ui_unavailable, 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.
934
-
935
- ## Finalization rule
936
-
937
- Only output the final plan when it is decision-complete and leaves no decisions to the implementer. When presenting the official plan, output exactly one proposed plan block and keep the tags exactly as shown:
938
-
939
- <proposed_plan>
940
- # Title
941
-
942
- ## Summary
943
- ...
944
-
945
- ## Key Changes
946
- ...
947
-
948
- ## Test Plan
949
- ...
950
-
951
- ## Assumptions
952
- ...
953
- </proposed_plan>
954
-
955
- Keep the proposed plan concise, human and agent digestible, and free of open decisions. Do not ask "should I proceed?" in the final output; the Plan-mode ready menu handles implementation, staying in Plan mode, or exit.`;
956
- }
957
-
958
- function readCommand(input: unknown) {
959
- const command = input as { command?: unknown } | undefined;
960
- return typeof command?.command === "string" ? command.command : "";
961
- }
962
-
963
- export function isSafeCommand(command: string) {
964
- const trimmed = command.trim();
965
- if (!trimmed) return false;
966
- if (MUTATING_BASH_PATTERNS.some((pattern) => pattern.test(trimmed))) return false;
967
- return SAFE_BASH_PATTERNS.some((pattern) => pattern.test(trimmed));
968
- }
969
-
970
- export function extractProposedPlan(text: string) {
971
- const match = PROPOSED_PLAN_PATTERN.exec(text);
972
- return match?.[1]?.trim();
973
- }
974
-
975
- export function latestAssistantText(messages: unknown) {
976
- if (!Array.isArray(messages)) return "";
977
- for (const entry of [...messages].reverse()) {
978
- const message = (entry as { message?: SessionMessage })?.message ?? (entry as SessionMessage);
979
- if (message?.role !== "assistant") continue;
980
- const text = messageText(message);
981
- if (text) return text;
982
- }
983
- return "";
984
- }
985
-
986
- function messageContainsLegacyPlanModeContextArtifact(message: unknown) {
987
- const candidate = unwrapSessionMessage(message);
988
- return candidate.customType === PLAN_CONTEXT_MESSAGE_TYPE;
765
+ return typeof value === "object" && value !== null && !Array.isArray(value);
989
766
  }
990
767
 
991
- function messageContainsInactivePlanModeArtifact(message: unknown) {
992
- const candidate = unwrapSessionMessage(message);
993
- return candidate.customType === PROPOSED_PLAN_MESSAGE_TYPE;
768
+ function stringArray(value: unknown) {
769
+ return Array.isArray(value) && value.every((item): item is string => typeof item === "string")
770
+ ? unique(value)
771
+ : undefined;
994
772
  }
995
773
 
996
- export function stripProposedPlanBlocksFromMessage<T>(message: T): T {
997
- const candidate = unwrapSessionMessage(message);
998
- if (candidate.role !== "assistant") return message;
999
-
1000
- const content = stripProposedPlanBlocksFromContent(candidate.content);
1001
- if (content === candidate.content) return message;
1002
-
1003
- if (isSessionMessageEntry(message)) {
1004
- return { ...message, message: { ...candidate, content } };
1005
- }
1006
- return { ...candidate, content } as T;
774
+ function fixedThinkingLevel(value: unknown): PlanModeFixedThinkingLevel | undefined {
775
+ return typeof value === "string" &&
776
+ value !== "inherit" &&
777
+ PLAN_MODE_THINKING_LEVELS.includes(value as (typeof PLAN_MODE_THINKING_LEVELS)[number])
778
+ ? (value as PlanModeFixedThinkingLevel)
779
+ : undefined;
1007
780
  }
1008
781
 
1009
- function unwrapSessionMessage(message: unknown) {
1010
- const entry = message as { message?: unknown };
1011
- return (entry.message ?? message) as { role?: string; customType?: string; content?: unknown };
1012
- }
1013
-
1014
- function isSessionMessageEntry<T>(message: T): message is T & { message: SessionMessage } {
1015
- return typeof message === "object" && message !== null && "message" in message;
1016
- }
1017
-
1018
- function stripProposedPlanBlocksFromContent(content: unknown) {
1019
- if (typeof content === "string") return stripProposedPlanBlocks(content);
1020
- if (!Array.isArray(content)) return content;
1021
-
1022
- let changed = false;
1023
- const nextContent = content.map((block) => {
1024
- const textBlock = block as TextBlock;
1025
- if (textBlock.type !== "text" || typeof textBlock.text !== "string") return block;
1026
-
1027
- const text = stripProposedPlanBlocks(textBlock.text);
1028
- if (text === textBlock.text) return block;
1029
-
1030
- changed = true;
1031
- return { ...textBlock, text };
1032
- });
1033
- return changed ? nextContent : content;
1034
- }
1035
-
1036
- export function stripProposedPlanBlocks(text: string) {
1037
- return text.replace(PROPOSED_PLAN_BLOCK_PATTERN, "");
782
+ export function withRequiredPlanModeTools(toolNames: string[]) {
783
+ return unique([...withoutPlanModeQuestionTool(toolNames), PLAN_MODE_QUESTION_TOOL_NAME]);
1038
784
  }
1039
785
 
1040
- function messageText(message: SessionMessage) {
1041
- return contentText(message.content);
786
+ export function withoutPlanModeQuestionTool(toolNames: string[]) {
787
+ return toolNames.filter((toolName) => toolName !== PLAN_MODE_QUESTION_TOOL_NAME);
1042
788
  }
1043
789
 
1044
- function contentText(content: unknown): string {
1045
- if (typeof content === "string") return content;
1046
- if (!Array.isArray(content)) return "";
1047
- return content
1048
- .map((block) => {
1049
- const textBlock = block as TextBlock;
1050
- return textBlock.type === "text" && typeof textBlock.text === "string" ? textBlock.text : "";
1051
- })
1052
- .filter(Boolean)
1053
- .join("\n");
1054
- }
790
+ function invalidPlanMessage(kind: "empty" | "multiple" | "malformed" | "unclosed") {
791
+ const detail = {
792
+ empty: "the block is empty",
793
+ multiple: "more than one plan block was produced",
794
+ malformed: "the tags must be on their own lines",
795
+ unclosed: "the closing tag is missing",
796
+ }[kind];
797
+ return `Proposed plan is not ready: ${detail}. Continue Plan mode and produce one complete non-empty <proposed_plan> block.`;
798
+ }
799
+
800
+ export {
801
+ extractProposedPlan,
802
+ latestAssistantText,
803
+ parseProposedPlan,
804
+ stripProposedPlanBlocks,
805
+ stripProposedPlanBlocksFromMessage,
806
+ } from "./message-transform.js";
807
+ export { buildPlanModePrompt } from "./prompt.js";
808
+ export { normalizePlanModeQuestionParams } from "./question-tool.js";
809
+ export { normalizePlanModeSettings, readPlanModeSettings } from "./settings.js";
810
+ export { canSelectToolInPlanMode, classifyPlanModeTool, isSafeCommand } from "./tool-policy.js";