@narumitw/pi-plan-mode 0.12.0 → 0.13.1

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/README.md CHANGED
@@ -104,14 +104,15 @@ This extension maps Codex's `ModeKind::Plan` behavior onto Pi's extension API:
104
104
  ```txt
105
105
  extensions/pi-plan-mode/
106
106
  ├── src/
107
- └── plan-mode.ts
107
+ ├── plan-mode.ts # Pi entrypoint and mode state
108
+ │ └── *.ts # Package-local prompt, policy, question, and message modules
108
109
  ├── README.md
109
110
  ├── LICENSE
110
111
  ├── tsconfig.json
111
112
  └── package.json
112
113
  ```
113
114
 
114
- The package exposes its Pi extension through `package.json`:
115
+ Only `plan-mode.ts` is a Pi entrypoint; the other source modules are internal. The package exposes its Pi extension through `package.json`:
115
116
 
116
117
  ```json
117
118
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-plan-mode",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "Pi extension that adds a Codex-like read-only /plan collaboration mode.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,98 @@
1
+ const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
2
+ const PROPOSED_PLAN_MESSAGE_TYPE = "proposed-plan";
3
+ const PROPOSED_PLAN_PATTERN = /<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i;
4
+ const PROPOSED_PLAN_BLOCK_PATTERN = /<proposed_plan>\s*[\s\S]*?\s*<\/proposed_plan>/gi;
5
+
6
+ type SessionMessage = {
7
+ role?: string;
8
+ content?: unknown;
9
+ };
10
+
11
+ type TextBlock = {
12
+ type?: string;
13
+ text?: string;
14
+ };
15
+
16
+ export function extractProposedPlan(text: string) {
17
+ const match = PROPOSED_PLAN_PATTERN.exec(text);
18
+ return match?.[1]?.trim();
19
+ }
20
+
21
+ export function latestAssistantText(messages: unknown) {
22
+ if (!Array.isArray(messages)) return "";
23
+ for (const entry of [...messages].reverse()) {
24
+ const message = (entry as { message?: SessionMessage })?.message ?? (entry as SessionMessage);
25
+ if (message?.role !== "assistant") continue;
26
+ const text = messageText(message);
27
+ if (text) return text;
28
+ }
29
+ return "";
30
+ }
31
+
32
+ export function messageContainsLegacyPlanModeContextArtifact(message: unknown) {
33
+ return unwrapSessionMessage(message).customType === PLAN_CONTEXT_MESSAGE_TYPE;
34
+ }
35
+
36
+ export function messageContainsInactivePlanModeArtifact(message: unknown) {
37
+ return unwrapSessionMessage(message).customType === PROPOSED_PLAN_MESSAGE_TYPE;
38
+ }
39
+
40
+ export function stripProposedPlanBlocksFromMessage<T>(message: T): T {
41
+ const candidate = unwrapSessionMessage(message);
42
+ if (candidate.role !== "assistant") return message;
43
+
44
+ const content = stripProposedPlanBlocksFromContent(candidate.content);
45
+ if (content === candidate.content) return message;
46
+
47
+ if (isSessionMessageEntry(message)) {
48
+ return { ...message, message: { ...candidate, content } };
49
+ }
50
+ return { ...candidate, content } as T;
51
+ }
52
+
53
+ function unwrapSessionMessage(message: unknown) {
54
+ const entry = message as { message?: unknown };
55
+ return (entry.message ?? message) as { role?: string; customType?: string; content?: unknown };
56
+ }
57
+
58
+ function isSessionMessageEntry<T>(message: T): message is T & { message: SessionMessage } {
59
+ return typeof message === "object" && message !== null && "message" in message;
60
+ }
61
+
62
+ function stripProposedPlanBlocksFromContent(content: unknown) {
63
+ if (typeof content === "string") return stripProposedPlanBlocks(content);
64
+ if (!Array.isArray(content)) return content;
65
+
66
+ let changed = false;
67
+ const nextContent = content.map((block) => {
68
+ const textBlock = block as TextBlock;
69
+ if (textBlock.type !== "text" || typeof textBlock.text !== "string") return block;
70
+
71
+ const text = stripProposedPlanBlocks(textBlock.text);
72
+ if (text === textBlock.text) return block;
73
+
74
+ changed = true;
75
+ return { ...textBlock, text };
76
+ });
77
+ return changed ? nextContent : content;
78
+ }
79
+
80
+ export function stripProposedPlanBlocks(text: string) {
81
+ return text.replace(PROPOSED_PLAN_BLOCK_PATTERN, "");
82
+ }
83
+
84
+ function messageText(message: SessionMessage) {
85
+ return contentText(message.content);
86
+ }
87
+
88
+ function contentText(content: unknown): string {
89
+ if (typeof content === "string") return content;
90
+ if (!Array.isArray(content)) return "";
91
+ return content
92
+ .map((block) => {
93
+ const textBlock = block as TextBlock;
94
+ return textBlock.type === "text" && typeof textBlock.text === "string" ? textBlock.text : "";
95
+ })
96
+ .filter(Boolean)
97
+ .join("\n");
98
+ }
package/src/plan-mode.ts CHANGED
@@ -1,18 +1,35 @@
1
1
  import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ extractProposedPlan,
4
+ latestAssistantText,
5
+ messageContainsInactivePlanModeArtifact,
6
+ messageContainsLegacyPlanModeContextArtifact,
7
+ stripProposedPlanBlocksFromMessage,
8
+ } from "./message-transform.js";
9
+ import { buildPlanModePrompt } from "./prompt.js";
10
+ import {
11
+ askPlanModeQuestions,
12
+ normalizePlanModeQuestionParams,
13
+ PLAN_MODE_QUESTION_PARAMS,
14
+ PLAN_MODE_QUESTION_TOOL_NAME,
15
+ planModeQuestionAnswered,
16
+ planModeQuestionCancelled,
17
+ } from "./question-tool.js";
18
+ import {
19
+ canSelectToolInPlanMode,
20
+ isBuiltinTool,
21
+ isSafeCommand,
22
+ readCommand,
23
+ SAFE_BUILTIN_PLAN_TOOLS,
24
+ } from "./tool-policy.js";
2
25
 
3
26
  const STATE_ENTRY_TYPE = "plan-mode-state";
4
27
  const STATUS_KEY = "plan-mode";
5
28
  const PLAN_WIDGET_KEY = "plan-mode-plan";
6
- const PLAN_CONTEXT_MESSAGE_TYPE = "plan-mode-context";
7
29
  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
30
  const BLOCKED_BUILTIN_TOOLS = new Set(["edit", "write"]);
12
31
  const DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
13
32
  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
33
 
17
34
  interface CommandArgumentCompletion {
18
35
  value: string;
@@ -45,144 +62,12 @@ type TextBlock = {
45
62
  text?: string;
46
63
  };
47
64
 
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
65
  const PLAN_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
83
66
  { value: "exit", label: "exit", description: "Leave Plan mode" },
84
67
  { value: "off", label: "off", description: "Leave Plan mode" },
85
68
  { value: "tools", label: "tools", description: "Select tools allowed in Plan mode" },
86
69
  ];
87
70
 
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
71
  export default function planMode(pi: ExtensionAPI) {
187
72
  let state: PlanModeState = { enabled: false, awaitingAction: false };
188
73
  let previousTools: string[] | undefined;
@@ -693,10 +578,6 @@ export default function planMode(pi: ExtensionAPI) {
693
578
  }
694
579
  }
695
580
 
696
- function isBuiltinTool(tool: ToolInfo) {
697
- return tool.sourceInfo.source === "builtin";
698
- }
699
-
700
581
  export function completePlanArguments(argumentPrefix: string): CommandArgumentCompletion[] | null {
701
582
  const prefix = argumentPrefix.trimStart().toLowerCase();
702
583
  if (prefix === "") return [...PLAN_COMMAND_COMPLETIONS];
@@ -706,11 +587,6 @@ export function completePlanArguments(argumentPrefix: string): CommandArgumentCo
706
587
  return matches.length > 0 ? [...matches] : null;
707
588
  }
708
589
 
709
- export function canSelectToolInPlanMode(tool: ToolInfo) {
710
- if (isBuiltinTool(tool)) return SAFE_BUILTIN_PLAN_TOOLS.has(tool.name);
711
- return true;
712
- }
713
-
714
590
  function toolNameFromLegacyKey(key: string, tools: ToolInfo[]) {
715
591
  const directName = tools.find((tool) => tool.name === key)?.name;
716
592
  if (directName) return directName;
@@ -756,299 +632,6 @@ export function withoutPlanModeQuestionTool(toolNames: string[]) {
756
632
  return toolNames.filter((toolName) => toolName !== PLAN_MODE_QUESTION_TOOL_NAME);
757
633
  }
758
634
 
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
- 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;
989
- }
990
-
991
- function messageContainsInactivePlanModeArtifact(message: unknown) {
992
- const candidate = unwrapSessionMessage(message);
993
- return candidate.customType === PROPOSED_PLAN_MESSAGE_TYPE;
994
- }
995
-
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;
1007
- }
1008
-
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, "");
1038
- }
1039
-
1040
- function messageText(message: SessionMessage) {
1041
- return contentText(message.content);
1042
- }
1043
-
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
- }
635
+ export { extractProposedPlan, latestAssistantText, stripProposedPlanBlocks, stripProposedPlanBlocksFromMessage } from "./message-transform.js";
636
+ export { normalizePlanModeQuestionParams } from "./question-tool.js";
637
+ export { canSelectToolInPlanMode, isSafeCommand } from "./tool-policy.js";
package/src/prompt.ts ADDED
@@ -0,0 +1,55 @@
1
+ const PLAN_CONTEXT_MARKER = "[CODEX-LIKE PLAN MODE ACTIVE]";
2
+
3
+ export function buildPlanModePrompt() {
4
+ return `${PLAN_CONTEXT_MARKER}
5
+ # Plan Mode (Conversational)
6
+
7
+ 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.
8
+
9
+ ## Mode rules
10
+
11
+ - Stay in Plan Mode until a developer or extension explicitly exits it.
12
+ - Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
13
+ - Do not use update_plan/TODO tooling in Plan Mode; Plan Mode is conversational planning, not execution progress tracking.
14
+ - 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.
15
+ - Do not perform mutating actions: no edit/write tools, no patching, no formatting that rewrites files, no dependency installation, no commits, no migrations.
16
+
17
+ ## Phase 1 — Ground in the environment
18
+
19
+ - Explore first and ask second. Use non-mutating exploration to read files, search, inspect configuration, run read-only checks, and resolve discoverable facts.
20
+ - Before asking the user any question, perform at least one targeted non-mutating exploration pass unless no local environment or repository is available.
21
+ - 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.
22
+
23
+ ## Phase 2 — Intent chat
24
+
25
+ - Keep asking until you can clearly state the goal, success criteria, in/out of scope, constraints, current state, and key preferences/tradeoffs.
26
+ - Bias toward questions over guessing: if a high-impact ambiguity remains, do not produce a proposed plan yet.
27
+
28
+ ## Phase 3 — Implementation chat
29
+
30
+ - 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.
31
+ - 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.
32
+ - 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.
33
+
34
+ ## Finalization rule
35
+
36
+ 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:
37
+
38
+ <proposed_plan>
39
+ # Title
40
+
41
+ ## Summary
42
+ ...
43
+
44
+ ## Key Changes
45
+ ...
46
+
47
+ ## Test Plan
48
+ ...
49
+
50
+ ## Assumptions
51
+ ...
52
+ </proposed_plan>
53
+
54
+ 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.`;
55
+ }
@@ -0,0 +1,207 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const PLAN_MODE_QUESTION_TOOL_NAME = "plan_mode_question";
4
+
5
+ export type PlanModeQuestionOption = {
6
+ label: string;
7
+ description?: string;
8
+ };
9
+
10
+ export type PlanModeQuestion = {
11
+ id: string;
12
+ header: string;
13
+ question: string;
14
+ options: PlanModeQuestionOption[];
15
+ };
16
+
17
+ type PlanModeQuestionAnswer = {
18
+ id: string;
19
+ header: string;
20
+ question: string;
21
+ answer: string;
22
+ wasCustom: boolean;
23
+ optionIndex?: number;
24
+ };
25
+
26
+ type PlanModeQuestionReason = "cancelled" | "ui_unavailable" | "plan_mode_inactive" | "invalid_input";
27
+
28
+ type PlanModeQuestionDetails = {
29
+ cancelled: boolean;
30
+ reason?: PlanModeQuestionReason;
31
+ questions: PlanModeQuestion[];
32
+ answers?: PlanModeQuestionAnswer[];
33
+ };
34
+
35
+ export const PLAN_MODE_QUESTION_PARAMS = {
36
+ type: "object",
37
+ additionalProperties: false,
38
+ required: ["questions"],
39
+ properties: {
40
+ questions: {
41
+ type: "array",
42
+ minItems: 1,
43
+ maxItems: 3,
44
+ description: "Questions to show the user. Prefer 1 and do not exceed 3.",
45
+ items: {
46
+ type: "object",
47
+ additionalProperties: false,
48
+ required: ["id", "header", "question", "options"],
49
+ properties: {
50
+ id: { type: "string", description: "Stable identifier for mapping answers (snake_case)." },
51
+ header: { type: "string", description: "Short header label shown in the UI (12 or fewer chars)." },
52
+ question: { type: "string", description: "Single-sentence prompt shown to the user." },
53
+ options: {
54
+ type: "array",
55
+ minItems: 2,
56
+ maxItems: 4,
57
+ description:
58
+ "Provide 2-4 mutually exclusive choices. Put the recommended option first when there is a clear default.",
59
+ items: {
60
+ type: "object",
61
+ additionalProperties: false,
62
+ required: ["label", "description"],
63
+ properties: {
64
+ label: { type: "string", description: "User-facing label (1-5 words)." },
65
+ description: {
66
+ type: "string",
67
+ description: "One short sentence explaining impact/tradeoff if selected.",
68
+ },
69
+ },
70
+ },
71
+ },
72
+ },
73
+ },
74
+ },
75
+ },
76
+ } as const;
77
+
78
+ type NormalizePlanModeQuestionParamsResult =
79
+ | { ok: true; questions: PlanModeQuestion[] }
80
+ | { ok: false; error: string };
81
+
82
+ export function normalizePlanModeQuestionParams(input: unknown): NormalizePlanModeQuestionParamsResult {
83
+ if (!isRecord(input) || !Array.isArray(input.questions)) {
84
+ return { ok: false, error: "questions must be an array" };
85
+ }
86
+ if (input.questions.length < 1 || input.questions.length > 3) {
87
+ return { ok: false, error: "questions must contain 1-3 items" };
88
+ }
89
+
90
+ const questions: PlanModeQuestion[] = [];
91
+ for (const [questionIndex, rawQuestion] of input.questions.entries()) {
92
+ if (!isRecord(rawQuestion)) {
93
+ return { ok: false, error: `question ${questionIndex + 1} must be an object` };
94
+ }
95
+ const id = stringField(rawQuestion.id);
96
+ const header = stringField(rawQuestion.header);
97
+ const question = stringField(rawQuestion.question);
98
+ if (!id || !header || !question) {
99
+ return { ok: false, error: `question ${questionIndex + 1} requires non-empty id, header, and question` };
100
+ }
101
+ if (!Array.isArray(rawQuestion.options)) {
102
+ return { ok: false, error: `question ${questionIndex + 1} options must be an array` };
103
+ }
104
+ if (rawQuestion.options.length < 2 || rawQuestion.options.length > 4) {
105
+ return { ok: false, error: `question ${questionIndex + 1} options must contain 2-4 items` };
106
+ }
107
+ const options: PlanModeQuestionOption[] = [];
108
+ for (const [optionIndex, rawOption] of rawQuestion.options.entries()) {
109
+ if (!isRecord(rawOption)) {
110
+ return {
111
+ ok: false,
112
+ error: `question ${questionIndex + 1} option ${optionIndex + 1} must be an object`,
113
+ };
114
+ }
115
+ const label = stringField(rawOption.label);
116
+ if (!label) {
117
+ return { ok: false, error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a label` };
118
+ }
119
+ const description = stringField(rawOption.description);
120
+ if (!description) {
121
+ return {
122
+ ok: false,
123
+ error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a description`,
124
+ };
125
+ }
126
+ options.push({ label, description });
127
+ }
128
+ questions.push({ id, header, question, options });
129
+ }
130
+ return { ok: true, questions };
131
+ }
132
+
133
+ export async function askPlanModeQuestions(
134
+ questions: PlanModeQuestion[],
135
+ ctx: ExtensionContext,
136
+ ): Promise<PlanModeQuestionAnswer[] | undefined> {
137
+ const answers: PlanModeQuestionAnswer[] = [];
138
+ for (const question of questions) {
139
+ const choices = question.options.map(formatPlanModeQuestionChoice);
140
+ const otherChoice = `${question.options.length + 1}. Other (free-form)`;
141
+ const choice = await ctx.ui.select(`${question.header}: ${question.question}`, [...choices, otherChoice]);
142
+ if (!choice) return undefined;
143
+ if (choice === otherChoice) {
144
+ const customAnswer = (await ctx.ui.editor(question.question, ""))?.trim();
145
+ if (!customAnswer) return undefined;
146
+ answers.push({
147
+ id: question.id,
148
+ header: question.header,
149
+ question: question.question,
150
+ answer: customAnswer,
151
+ wasCustom: true,
152
+ });
153
+ continue;
154
+ }
155
+ const optionIndex = choices.indexOf(choice);
156
+ const option = question.options[optionIndex];
157
+ if (!option) return undefined;
158
+ answers.push({
159
+ id: question.id,
160
+ header: question.header,
161
+ question: question.question,
162
+ answer: option.label,
163
+ wasCustom: false,
164
+ optionIndex: optionIndex + 1,
165
+ });
166
+ }
167
+ return answers;
168
+ }
169
+
170
+ function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: number) {
171
+ return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
172
+ }
173
+
174
+ export function planModeQuestionAnswered(questions: PlanModeQuestion[], answers: PlanModeQuestionAnswer[]) {
175
+ return {
176
+ content: [{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: false, answers }) }],
177
+ details: { cancelled: false, questions, answers } satisfies PlanModeQuestionDetails,
178
+ };
179
+ }
180
+
181
+ export function planModeQuestionCancelled(
182
+ questions: PlanModeQuestion[],
183
+ reason: PlanModeQuestionReason,
184
+ message: string,
185
+ ) {
186
+ return {
187
+ content: [{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: true, reason, message }) }],
188
+ details: { cancelled: true, reason, questions } satisfies PlanModeQuestionDetails,
189
+ };
190
+ }
191
+
192
+ function formatPlanModeQuestionPayload(payload: {
193
+ cancelled: boolean;
194
+ reason?: PlanModeQuestionReason;
195
+ message?: string;
196
+ answers?: PlanModeQuestionAnswer[];
197
+ }) {
198
+ return JSON.stringify(payload, null, 2);
199
+ }
200
+
201
+ function isRecord(value: unknown): value is Record<string, unknown> {
202
+ return typeof value === "object" && value !== null;
203
+ }
204
+
205
+ function stringField(value: unknown) {
206
+ return typeof value === "string" ? value.trim() : undefined;
207
+ }
@@ -0,0 +1,67 @@
1
+ import type { ToolInfo } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const SAFE_BUILTIN_PLAN_TOOLS = new Set(["read", "bash", "grep", "find", "ls"]);
4
+
5
+ const MUTATING_BASH_PATTERNS = [
6
+ /\brm\b/i,
7
+ /\brmdir\b/i,
8
+ /\bmv\b/i,
9
+ /\bcp\b/i,
10
+ /\bmkdir\b/i,
11
+ /\btouch\b/i,
12
+ /\bchmod\b/i,
13
+ /\bchown\b/i,
14
+ /\bchgrp\b/i,
15
+ /\bln\b/i,
16
+ /\btee\b/i,
17
+ /\btruncate\b/i,
18
+ /\bdd\b/i,
19
+ /(^|[^<])>(?!>)/,
20
+ />>/,
21
+ /\bnpm\s+(install|uninstall|update|ci|link|publish|version)\b/i,
22
+ /\byarn\s+(add|remove|install|publish|upgrade)\b/i,
23
+ /\bpnpm\s+(add|remove|install|publish|update)\b/i,
24
+ /\bbun\s+(add|remove|install|update|publish)\b/i,
25
+ /\bpip\s+(install|uninstall)\b/i,
26
+ /\buv\s+(add|remove|sync|lock|pip\s+install)\b/i,
27
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|switch|stash|cherry-pick|revert|tag|init|clone)\b/i,
28
+ /\bsudo\b/i,
29
+ /\bsu\b/i,
30
+ /\bkill\b/i,
31
+ /\bpkill\b/i,
32
+ /\bkillall\b/i,
33
+ /\breboot\b/i,
34
+ /\bshutdown\b/i,
35
+ /\bsystemctl\s+(start|stop|restart|enable|disable)\b/i,
36
+ /\bservice\s+\S+\s+(start|stop|restart)\b/i,
37
+ /\b(vim?|nano|emacs|code|subl)\b/i,
38
+ ];
39
+
40
+ const SAFE_BASH_PATTERNS = [
41
+ /^\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,
42
+ /^\s*sed\s+-n\b/i,
43
+ /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get|ls-files|grep)\b/i,
44
+ /^\s*npm\s+(list|ls|view|info|search|outdated|audit)\b/i,
45
+ /^\s*(node|python|python3|npm|tsc|biome|ruff|ty)\s+--version\b/i,
46
+ ];
47
+
48
+ export function isBuiltinTool(tool: ToolInfo) {
49
+ return tool.sourceInfo.source === "builtin";
50
+ }
51
+
52
+ export function canSelectToolInPlanMode(tool: ToolInfo) {
53
+ if (isBuiltinTool(tool)) return SAFE_BUILTIN_PLAN_TOOLS.has(tool.name);
54
+ return true;
55
+ }
56
+
57
+ export function readCommand(input: unknown) {
58
+ const command = input as { command?: unknown } | undefined;
59
+ return typeof command?.command === "string" ? command.command : "";
60
+ }
61
+
62
+ export function isSafeCommand(command: string) {
63
+ const trimmed = command.trim();
64
+ if (!trimmed) return false;
65
+ if (MUTATING_BASH_PATTERNS.some((pattern) => pattern.test(trimmed))) return false;
66
+ return SAFE_BASH_PATTERNS.some((pattern) => pattern.test(trimmed));
67
+ }