@hank-warren/pi-plan-mode 0.1.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/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +313 -0
- package/index.ts +1 -0
- package/package.json +47 -0
- package/src/active-implementation-menu.ts +68 -0
- package/src/auto-permissions-delegation.ts +122 -0
- package/src/command.ts +30 -0
- package/src/completion-tool.ts +91 -0
- package/src/extension-runtime.ts +24 -0
- package/src/fresh-implementation.ts +213 -0
- package/src/implementation-retention.ts +122 -0
- package/src/index.ts +1 -0
- package/src/interactive-ui.ts +5 -0
- package/src/message-transform.ts +232 -0
- package/src/plan-action-controller.ts +103 -0
- package/src/plan-action-menus.ts +197 -0
- package/src/plan-export-controller.ts +38 -0
- package/src/plan-export-screen.ts +19 -0
- package/src/plan-export.ts +145 -0
- package/src/plan-launch-menu.ts +122 -0
- package/src/plan-mode.ts +1037 -0
- package/src/presentation.ts +108 -0
- package/src/prompt.ts +67 -0
- package/src/question-tool.ts +273 -0
- package/src/required-tools.ts +22 -0
- package/src/saved-plan-menu.ts +93 -0
- package/src/saved-plan-preflight.ts +39 -0
- package/src/settings-menu.ts +384 -0
- package/src/settings.ts +420 -0
- package/src/state.ts +167 -0
- package/src/tool-policy.ts +563 -0
- package/src/tool-selection.ts +98 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import type { ExtensionContext, ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { defineMenu, type RunMenuResult, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import { PLAN_MODE_COMPLETE_TOOL_NAME } from "./completion-tool.js";
|
|
4
|
+
import { retentionLabel } from "./implementation-retention.js";
|
|
5
|
+
import { planExportDestination } from "./plan-export.js";
|
|
6
|
+
import { PLAN_MODE_QUESTION_TOOL_NAME } from "./question-tool.js";
|
|
7
|
+
import {
|
|
8
|
+
configuredBashPolicy,
|
|
9
|
+
configuredImplementationPlanRetention,
|
|
10
|
+
configuredPlanExportPath,
|
|
11
|
+
IMPLEMENTATION_PLAN_RETENTIONS,
|
|
12
|
+
PLAN_MODE_BASH_POLICIES,
|
|
13
|
+
PLAN_MODE_THINKING_LEVELS,
|
|
14
|
+
type PlanModeBashPolicy,
|
|
15
|
+
type PlanModeSettings,
|
|
16
|
+
type PlanModeSettingsLoadResult,
|
|
17
|
+
type PlanModeSettingsPatch,
|
|
18
|
+
planModeSettingsPath,
|
|
19
|
+
readPlanModeSettings,
|
|
20
|
+
type UpdatePlanModeSettingsOptions,
|
|
21
|
+
updatePlanModeSettings,
|
|
22
|
+
} from "./settings.js";
|
|
23
|
+
import { canSelectToolInPlanMode } from "./tool-policy.js";
|
|
24
|
+
import { defaultPlanModeToolNames, toolPolicyLabel } from "./tool-selection.js";
|
|
25
|
+
|
|
26
|
+
interface SettingsMenuState {
|
|
27
|
+
kind: "valid" | "invalid";
|
|
28
|
+
settings: PlanModeSettings;
|
|
29
|
+
notice?: string;
|
|
30
|
+
reason?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PlanModeSettingsMenuOptions {
|
|
34
|
+
tools: readonly ToolInfo[];
|
|
35
|
+
signal: AbortSignal;
|
|
36
|
+
isCurrent(): boolean;
|
|
37
|
+
settingsPath?: string;
|
|
38
|
+
legacySettingsPath?: string;
|
|
39
|
+
readSettings?: (settingsPath?: string) => Promise<PlanModeSettingsLoadResult>;
|
|
40
|
+
updateSettings?: (
|
|
41
|
+
patch: PlanModeSettingsPatch,
|
|
42
|
+
options?: UpdatePlanModeSettingsOptions,
|
|
43
|
+
) => Promise<PlanModeSettings>;
|
|
44
|
+
onSaved(settings: PlanModeSettings): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Screen = "settings" | "tools" | "export";
|
|
48
|
+
type Action =
|
|
49
|
+
| "set-thinking"
|
|
50
|
+
| "open-tools"
|
|
51
|
+
| "toggle-tool"
|
|
52
|
+
| "reset-tools"
|
|
53
|
+
| "set-bash-policy"
|
|
54
|
+
| "set-retention"
|
|
55
|
+
| "open-export"
|
|
56
|
+
| "set-export";
|
|
57
|
+
|
|
58
|
+
export async function showPlanModeSettings(
|
|
59
|
+
ctx: ExtensionContext,
|
|
60
|
+
options: PlanModeSettingsMenuOptions,
|
|
61
|
+
): Promise<RunMenuResult> {
|
|
62
|
+
const settingsPath = options.settingsPath ?? planModeSettingsPath();
|
|
63
|
+
const readSettings = options.readSettings ?? readPlanModeSettings;
|
|
64
|
+
const updateSettings = options.updateSettings ?? updatePlanModeSettings;
|
|
65
|
+
const tools = options.tools.filter(
|
|
66
|
+
(tool) =>
|
|
67
|
+
tool.name !== PLAN_MODE_QUESTION_TOOL_NAME && tool.name !== PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const loadState = async (): Promise<SettingsMenuState> => {
|
|
71
|
+
const loaded = await readSettings(options.settingsPath);
|
|
72
|
+
if (loaded.kind === "invalid") {
|
|
73
|
+
return {
|
|
74
|
+
kind: "invalid",
|
|
75
|
+
settings: { thinkingLevel: "inherit" },
|
|
76
|
+
notice: loaded.notice,
|
|
77
|
+
reason: loaded.reason,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
kind: "valid",
|
|
82
|
+
settings: loaded.kind === "loaded" ? loaded.settings : { thinkingLevel: "inherit" },
|
|
83
|
+
notice: loaded.notice,
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const menu = defineMenu<SettingsMenuState, Screen, Action, ExtensionContext>({
|
|
88
|
+
start: "settings",
|
|
89
|
+
screens: {
|
|
90
|
+
settings: ({ state }) =>
|
|
91
|
+
state.kind === "invalid"
|
|
92
|
+
? invalidScreen(settingsPath, state)
|
|
93
|
+
: {
|
|
94
|
+
kind: "settings",
|
|
95
|
+
title: "Plan Mode Settings",
|
|
96
|
+
lines: settingsLines(settingsPath, state.notice),
|
|
97
|
+
items: [
|
|
98
|
+
{
|
|
99
|
+
id: "thinkingLevel",
|
|
100
|
+
label: "Plan thinking",
|
|
101
|
+
description: "Set the thinking level when the next Plan workflow starts.",
|
|
102
|
+
currentValue: state.settings.thinkingLevel,
|
|
103
|
+
values: PLAN_MODE_THINKING_LEVELS,
|
|
104
|
+
action: "set-thinking",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "defaultPlanTools",
|
|
108
|
+
label: "Plan tools",
|
|
109
|
+
description:
|
|
110
|
+
"Choose persistent defaults; a launch-time selection still overrides them for that session.",
|
|
111
|
+
currentValue: defaultToolsValue(state.settings.defaultPlanTools),
|
|
112
|
+
action: "open-tools",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "bashPolicy",
|
|
116
|
+
label: "Bash policy",
|
|
117
|
+
description:
|
|
118
|
+
"Use Plan mode's allowlist or selectively defer matching guarded commands to Auto Permissions.",
|
|
119
|
+
currentValue: bashPolicyLabel(configuredBashPolicy(state.settings)),
|
|
120
|
+
values: PLAN_MODE_BASH_POLICIES.map(bashPolicyLabel),
|
|
121
|
+
action: "set-bash-policy",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: "implementationPlanRetention",
|
|
125
|
+
label: "After Implement",
|
|
126
|
+
description: "Choose how long the accepted plan keeps guiding implementation.",
|
|
127
|
+
currentValue: retentionLabel(
|
|
128
|
+
configuredImplementationPlanRetention(state.settings),
|
|
129
|
+
),
|
|
130
|
+
values: IMPLEMENTATION_PLAN_RETENTIONS.map(retentionLabel),
|
|
131
|
+
action: "set-retention",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "defaultPlanExportPath",
|
|
135
|
+
label: "Export destination",
|
|
136
|
+
description: "Set the destination used when an export omits its path.",
|
|
137
|
+
currentValue: safeTerminalText(configuredPlanExportPath(state.settings)),
|
|
138
|
+
action: "open-export",
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
tools: ({ state }) => ({
|
|
143
|
+
kind: "multiSelect",
|
|
144
|
+
title: "Default Plan-mode tools",
|
|
145
|
+
lines: [
|
|
146
|
+
"Changes apply when a later Plan workflow starts; required Plan tools stay enabled.",
|
|
147
|
+
"Non-built-in tools run at user risk.",
|
|
148
|
+
],
|
|
149
|
+
enableSearch: true,
|
|
150
|
+
viewportSize: 10,
|
|
151
|
+
items: defaultToolItems(tools, state.settings.defaultPlanTools),
|
|
152
|
+
action: "toggle-tool",
|
|
153
|
+
actions: [
|
|
154
|
+
{
|
|
155
|
+
id: "reset-tools",
|
|
156
|
+
label: "Use automatic safe built-ins",
|
|
157
|
+
action: "reset-tools",
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
hint: "back",
|
|
161
|
+
}),
|
|
162
|
+
export: ({ state }) => {
|
|
163
|
+
const configured = configuredPlanExportPath(state.settings);
|
|
164
|
+
const destination = planExportDestination(configured, ctx.cwd);
|
|
165
|
+
return {
|
|
166
|
+
kind: "input",
|
|
167
|
+
title: "Export destination",
|
|
168
|
+
lines: [
|
|
169
|
+
`Configured: ${destination.configuredPath}`,
|
|
170
|
+
`Resolves here to: ${destination.resolvedPath}`,
|
|
171
|
+
"Submit an empty value to reset to PLAN.md. Changes affect the next export.",
|
|
172
|
+
],
|
|
173
|
+
placeholder: configured,
|
|
174
|
+
action: "set-export",
|
|
175
|
+
hint: "back",
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
actions: {
|
|
180
|
+
"set-thinking": async ({ ctx: actionCtx, value, signal }) => {
|
|
181
|
+
if (
|
|
182
|
+
!PLAN_MODE_THINKING_LEVELS.includes(value as (typeof PLAN_MODE_THINKING_LEVELS)[number])
|
|
183
|
+
) {
|
|
184
|
+
return { kind: "rejected" };
|
|
185
|
+
}
|
|
186
|
+
return savePatch(
|
|
187
|
+
actionCtx,
|
|
188
|
+
{ thinkingLevel: value as PlanModeSettings["thinkingLevel"] },
|
|
189
|
+
signal,
|
|
190
|
+
`Plan mode thinking level: ${value}. Applies to the next Plan workflow.`,
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
"open-tools": async () => ({ kind: "to", screen: "tools" }),
|
|
194
|
+
"set-bash-policy": async ({ ctx: actionCtx, value, signal }) => {
|
|
195
|
+
const bashPolicy = bashPolicyFromLabel(value);
|
|
196
|
+
if (!bashPolicy) return { kind: "rejected" };
|
|
197
|
+
return savePatch(
|
|
198
|
+
actionCtx,
|
|
199
|
+
{ bashPolicy },
|
|
200
|
+
signal,
|
|
201
|
+
bashPolicy === "auto-permissions"
|
|
202
|
+
? "Bash policy: selectively delegated to Auto Permissions guarded rules."
|
|
203
|
+
: "Bash policy: Plan-mode inspection allowlist.",
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
"set-retention": async ({ ctx: actionCtx, value, signal }) => {
|
|
207
|
+
const implementationPlanRetention = retentionFromLabel(value);
|
|
208
|
+
if (!implementationPlanRetention) return { kind: "rejected" };
|
|
209
|
+
return savePatch(
|
|
210
|
+
actionCtx,
|
|
211
|
+
{ implementationPlanRetention },
|
|
212
|
+
signal,
|
|
213
|
+
`After Implement: ${retentionLabel(implementationPlanRetention)}. Applies to the next Implement action.`,
|
|
214
|
+
);
|
|
215
|
+
},
|
|
216
|
+
"open-export": async () => ({ kind: "to", screen: "export" }),
|
|
217
|
+
"set-export": async ({ ctx: actionCtx, value, signal }) => {
|
|
218
|
+
const defaultPlanExportPath = value?.trim() || null;
|
|
219
|
+
const result = await savePatch(
|
|
220
|
+
actionCtx,
|
|
221
|
+
{ defaultPlanExportPath },
|
|
222
|
+
signal,
|
|
223
|
+
defaultPlanExportPath
|
|
224
|
+
? `Default Plan export destination: ${safeTerminalText(defaultPlanExportPath)}.`
|
|
225
|
+
: "Default Plan export destination reset to PLAN.md.",
|
|
226
|
+
);
|
|
227
|
+
return result.kind === "stay" ? { kind: "to", screen: "settings" } : result;
|
|
228
|
+
},
|
|
229
|
+
"toggle-tool": async ({ ctx: actionCtx, state, itemId, selected, signal }) => {
|
|
230
|
+
const tool = tools.find((candidate) => candidate.name === itemId);
|
|
231
|
+
if (!tool || !canSelectToolInPlanMode(tool)) return { kind: "rejected" };
|
|
232
|
+
const names = explicitToolNames(tools, state.settings.defaultPlanTools);
|
|
233
|
+
const next = selected
|
|
234
|
+
? Array.from(new Set([...names, tool.name]))
|
|
235
|
+
: names.filter((name) => name !== tool.name);
|
|
236
|
+
return savePatch(
|
|
237
|
+
actionCtx,
|
|
238
|
+
{ defaultPlanTools: next },
|
|
239
|
+
signal,
|
|
240
|
+
`Default Plan-mode tools: ${next.length === 0 ? "required tools only" : `${next.length} selected`}.`,
|
|
241
|
+
);
|
|
242
|
+
},
|
|
243
|
+
"reset-tools": async ({ ctx: actionCtx, state, signal }) => {
|
|
244
|
+
if (state.settings.defaultPlanTools === undefined) return { kind: "stay" };
|
|
245
|
+
return savePatch(
|
|
246
|
+
actionCtx,
|
|
247
|
+
{ defaultPlanTools: null },
|
|
248
|
+
signal,
|
|
249
|
+
"Default Plan-mode tools: automatic safe built-ins.",
|
|
250
|
+
);
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return runMenu(ctx, menu, {
|
|
256
|
+
getState: loadState,
|
|
257
|
+
signal: options.signal,
|
|
258
|
+
isCurrent: options.isCurrent,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
async function savePatch(
|
|
262
|
+
actionCtx: ExtensionContext,
|
|
263
|
+
patch: PlanModeSettingsPatch,
|
|
264
|
+
signal: AbortSignal,
|
|
265
|
+
successMessage: string,
|
|
266
|
+
) {
|
|
267
|
+
if (signal.aborted || !options.isCurrent()) return { kind: "rejected" as const };
|
|
268
|
+
try {
|
|
269
|
+
const saved = await updateSettings(patch, {
|
|
270
|
+
settingsPath: options.settingsPath,
|
|
271
|
+
legacySettingsPath: options.legacySettingsPath,
|
|
272
|
+
signal,
|
|
273
|
+
});
|
|
274
|
+
if (options.isCurrent()) options.onSaved(saved);
|
|
275
|
+
if (signal.aborted || !options.isCurrent()) return { kind: "rejected" as const };
|
|
276
|
+
actionCtx.ui.notify(successMessage, "info");
|
|
277
|
+
return { kind: "stay" as const };
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (!signal.aborted && options.isCurrent()) {
|
|
280
|
+
actionCtx.ui.notify(
|
|
281
|
+
`Could not save Plan mode settings; the previous value remains: ${safeTerminalText(formatError(error))}`,
|
|
282
|
+
"error",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return { kind: "rejected" as const };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function settingsLines(settingsPath: string, notice: string | undefined) {
|
|
291
|
+
return [
|
|
292
|
+
`User settings · ${safeTerminalText(settingsPath)}`,
|
|
293
|
+
"Plan defaults apply to the next workflow; handoff and export choices apply to their next action.",
|
|
294
|
+
...(notice ? [safeTerminalText(notice)] : []),
|
|
295
|
+
];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function invalidScreen(settingsPath: string, state: SettingsMenuState) {
|
|
299
|
+
return {
|
|
300
|
+
kind: "detail" as const,
|
|
301
|
+
title: "Plan Mode Settings · Read only",
|
|
302
|
+
lines: [
|
|
303
|
+
`Invalid settings file. Fix ${safeTerminalText(settingsPath)} before saving.`,
|
|
304
|
+
safeTerminalText(state.reason ?? "The settings file is invalid."),
|
|
305
|
+
...(state.notice ? [safeTerminalText(state.notice)] : []),
|
|
306
|
+
],
|
|
307
|
+
hint: "back" as const,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const BASH_POLICY_LABELS: Record<PlanModeBashPolicy, string> = {
|
|
312
|
+
limited: "Plan-mode allowlist",
|
|
313
|
+
"auto-permissions": "Auto Permissions guarded rules",
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
function bashPolicyLabel(policy: PlanModeBashPolicy) {
|
|
317
|
+
return BASH_POLICY_LABELS[policy];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function bashPolicyFromLabel(value: string | undefined) {
|
|
321
|
+
return PLAN_MODE_BASH_POLICIES.find((policy) => bashPolicyLabel(policy) === value);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function retentionFromLabel(value: string | undefined) {
|
|
325
|
+
return IMPLEMENTATION_PLAN_RETENTIONS.find((retention) => retentionLabel(retention) === value);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function defaultToolsValue(configured: string[] | undefined) {
|
|
329
|
+
if (configured === undefined) return "Automatic safe built-ins";
|
|
330
|
+
if (configured.length === 0) return "Required tools only";
|
|
331
|
+
return `${configured.length} selected`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function defaultToolItems(tools: readonly ToolInfo[], configured: string[] | undefined) {
|
|
335
|
+
const selected = new Set(explicitToolNames(tools, configured));
|
|
336
|
+
const availableNames = new Set(tools.map((tool) => tool.name));
|
|
337
|
+
const items = tools.map((tool) => {
|
|
338
|
+
const selectable = canSelectToolInPlanMode(tool);
|
|
339
|
+
const policy = toolPolicyLabel(tool);
|
|
340
|
+
const description = tool.description ?? "No description available";
|
|
341
|
+
return {
|
|
342
|
+
id: tool.name,
|
|
343
|
+
label: tool.name,
|
|
344
|
+
description: `${policy} · ${description}`,
|
|
345
|
+
searchText: `${policy} ${description}`,
|
|
346
|
+
selected: selected.has(tool.name),
|
|
347
|
+
disabled: !selectable,
|
|
348
|
+
disabledReason: selectable ? undefined : "Blocked by Plan-mode policy",
|
|
349
|
+
};
|
|
350
|
+
});
|
|
351
|
+
for (const name of configured ?? []) {
|
|
352
|
+
if (availableNames.has(name)) continue;
|
|
353
|
+
items.push({
|
|
354
|
+
id: name,
|
|
355
|
+
label: name,
|
|
356
|
+
description: "unavailable · Retained in settings but unavailable in this session",
|
|
357
|
+
searchText: "unavailable retained settings",
|
|
358
|
+
selected: true,
|
|
359
|
+
disabled: true,
|
|
360
|
+
disabledReason: "Unavailable in this session; reset defaults to remove unavailable names",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return items;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function explicitToolNames(tools: readonly ToolInfo[], configured: string[] | undefined) {
|
|
367
|
+
return configured === undefined
|
|
368
|
+
? defaultPlanModeToolNames([...tools], undefined)
|
|
369
|
+
: [...configured];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function safeTerminalText(value: string) {
|
|
373
|
+
return [...value]
|
|
374
|
+
.map((character) => {
|
|
375
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
376
|
+
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) ? " " : character;
|
|
377
|
+
})
|
|
378
|
+
.join("")
|
|
379
|
+
.trim();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function formatError(error: unknown) {
|
|
383
|
+
return error instanceof Error ? error.message : String(error);
|
|
384
|
+
}
|