@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,108 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { PlanModeState } from "./state.js";
|
|
3
|
+
|
|
4
|
+
const STATUS_KEY = "plan-mode";
|
|
5
|
+
const PLAN_WIDGET_KEY = "plan-mode-plan";
|
|
6
|
+
|
|
7
|
+
export function updatePlanModeUi(
|
|
8
|
+
ctx: ExtensionContext,
|
|
9
|
+
state: PlanModeState,
|
|
10
|
+
toolSummary: () => string,
|
|
11
|
+
) {
|
|
12
|
+
ctx.ui.setStatus(STATUS_KEY, formatStatus(state));
|
|
13
|
+
if (state.enabled && state.latestPlan) {
|
|
14
|
+
ctx.ui.setWidget(PLAN_WIDGET_KEY, [
|
|
15
|
+
"Proposed plan ready",
|
|
16
|
+
"Use /plan to implement, save, revise, or exit Plan mode.",
|
|
17
|
+
]);
|
|
18
|
+
} else if (state.enabled) {
|
|
19
|
+
ctx.ui.setWidget(PLAN_WIDGET_KEY, [
|
|
20
|
+
"Plan mode: planning",
|
|
21
|
+
toolSummary(),
|
|
22
|
+
"Finish with plan_mode_complete when decision-ready.",
|
|
23
|
+
]);
|
|
24
|
+
} else if (state.savedPlan) {
|
|
25
|
+
ctx.ui.setWidget(PLAN_WIDGET_KEY, [
|
|
26
|
+
"Plan saved for later",
|
|
27
|
+
"Use /plan to show, implement, or clear it.",
|
|
28
|
+
]);
|
|
29
|
+
} else if (state.activeImplementation) {
|
|
30
|
+
ctx.ui.setWidget(PLAN_WIDGET_KEY, [
|
|
31
|
+
"Implementation plan active",
|
|
32
|
+
"Use /plan to show, replace, or clear it.",
|
|
33
|
+
]);
|
|
34
|
+
} else {
|
|
35
|
+
ctx.ui.setWidget(PLAN_WIDGET_KEY, undefined);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function clearPlanModeUi(ctx: ExtensionContext) {
|
|
40
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
41
|
+
ctx.ui.setWidget(PLAN_WIDGET_KEY, undefined);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function showStoredPlan(pi: ExtensionAPI, ctx: ExtensionContext, state: PlanModeState) {
|
|
45
|
+
const readyPlan = state.enabled ? state.latestPlan?.trim() : undefined;
|
|
46
|
+
const savedPlan = state.savedPlan?.plan.trim();
|
|
47
|
+
if (savedPlan && (ctx.mode === "print" || ctx.mode === "json")) {
|
|
48
|
+
throw new Error("Saved plan display is unavailable in print/JSON mode. Use TUI or RPC.");
|
|
49
|
+
}
|
|
50
|
+
const activePlan = state.activeImplementation?.plan.trim();
|
|
51
|
+
const plan = readyPlan ?? savedPlan ?? activePlan;
|
|
52
|
+
if (!plan) {
|
|
53
|
+
ctx.ui.notify(
|
|
54
|
+
"No completed plan is available. Use /plan finalize when planning is complete.",
|
|
55
|
+
"info",
|
|
56
|
+
);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const title = readyPlan
|
|
60
|
+
? "Proposed Plan"
|
|
61
|
+
: savedPlan
|
|
62
|
+
? "Saved Plan"
|
|
63
|
+
: "Active Implementation Plan";
|
|
64
|
+
showPlanModePlan(pi, ctx, title, plan);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function showPlanModePlan(
|
|
68
|
+
pi: ExtensionAPI,
|
|
69
|
+
ctx: ExtensionContext,
|
|
70
|
+
title: string,
|
|
71
|
+
plan: string,
|
|
72
|
+
) {
|
|
73
|
+
try {
|
|
74
|
+
pi.sendMessage(
|
|
75
|
+
{
|
|
76
|
+
customType: "proposed-plan",
|
|
77
|
+
content: `**${title}**\n\n${plan}`,
|
|
78
|
+
display: true,
|
|
79
|
+
},
|
|
80
|
+
{ triggerTurn: false },
|
|
81
|
+
);
|
|
82
|
+
} catch (error: unknown) {
|
|
83
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
84
|
+
ctx.ui.notify(`Unable to show completed plan: ${detail}`, "error");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function planModeStatusText(state: PlanModeState, toolSummary: () => string) {
|
|
89
|
+
if (state.enabled) {
|
|
90
|
+
if (state.latestPlan) {
|
|
91
|
+
return `Plan mode is active and a proposed plan is ready. ${toolSummary()}`;
|
|
92
|
+
}
|
|
93
|
+
return `Plan mode is active. ${toolSummary()} Explore, ask, and finish with plan_mode_complete when decision-ready.`;
|
|
94
|
+
}
|
|
95
|
+
if (state.savedPlan) return "A plan is saved for later.";
|
|
96
|
+
if (state.activeImplementation) return "An implementation plan is active.";
|
|
97
|
+
return "Plan mode is off.";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatStatus(state: PlanModeState) {
|
|
101
|
+
if (state.enabled) {
|
|
102
|
+
if (state.awaitingAction || state.latestPlan) return "plan ready";
|
|
103
|
+
return "plan active";
|
|
104
|
+
}
|
|
105
|
+
if (state.savedPlan) return "plan saved";
|
|
106
|
+
if (state.activeImplementation) return "plan implementing";
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { PlanModeBashPolicy } from "./settings.js";
|
|
2
|
+
|
|
3
|
+
const PLAN_CONTEXT_MARKER = "[CODEX-LIKE PLAN MODE ACTIVE]";
|
|
4
|
+
|
|
5
|
+
export function buildPlanModePrompt(options: { bashPolicy?: PlanModeBashPolicy } = {}) {
|
|
6
|
+
const bashPolicy = options.bashPolicy ?? "limited";
|
|
7
|
+
const bashRule =
|
|
8
|
+
bashPolicy === "auto-permissions"
|
|
9
|
+
? "- Bash calls outside Plan Mode's inspection allowlist may be delegated to Auto Permissions when a matching guarded rule is active. Its approval permits information gathering, not implementation: keep every Bash action non-mutating and within the planning request."
|
|
10
|
+
: "- Bash is limited by Plan Mode's reviewed inspection policy. Use only accepted non-mutating inspection commands.";
|
|
11
|
+
return `${PLAN_CONTEXT_MARKER}
|
|
12
|
+
# Plan Mode (Conversational)
|
|
13
|
+
|
|
14
|
+
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.
|
|
15
|
+
|
|
16
|
+
## Mode rules
|
|
17
|
+
|
|
18
|
+
- Stay in Plan Mode until a developer or extension explicitly exits it.
|
|
19
|
+
- Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
|
|
20
|
+
- Do not use update_plan/TODO tooling in Plan Mode; Plan Mode is conversational planning, not execution progress tracking.
|
|
21
|
+
- 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.
|
|
22
|
+
${bashRule}
|
|
23
|
+
- Do not perform mutating actions: no edit/write tools, no patching, no formatting that rewrites files, no dependency installation, no commits, no migrations.
|
|
24
|
+
|
|
25
|
+
## Phase 1 — Ground in the environment
|
|
26
|
+
|
|
27
|
+
- Explore first and ask second. Use non-mutating exploration to read files, search, inspect configuration, run read-only checks, and resolve discoverable facts.
|
|
28
|
+
- Before asking the user any question, perform at least one targeted non-mutating exploration pass unless no local environment or repository is available.
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
31
|
+
## Phase 2 — Intent chat
|
|
32
|
+
|
|
33
|
+
- Keep asking until you can clearly state the goal, success criteria, in/out of scope, constraints, current state, and key preferences/tradeoffs.
|
|
34
|
+
- Bias toward questions over guessing: if a high-impact ambiguity remains, do not produce a proposed plan yet.
|
|
35
|
+
- For an unanswered preference or tradeoff, use the recommended option only when it is low risk and record that default as an explicit assumption in the final plan.
|
|
36
|
+
|
|
37
|
+
## Phase 3 — Implementation chat
|
|
38
|
+
|
|
39
|
+
- 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.
|
|
40
|
+
- 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.
|
|
41
|
+
- 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.
|
|
42
|
+
|
|
43
|
+
## Ending each turn
|
|
44
|
+
|
|
45
|
+
Every Plan-mode turn that advances or finalizes the plan must end in exactly one of these ways:
|
|
46
|
+
|
|
47
|
+
- If a material decision remains, use plan_mode_question. If interactive UI is unavailable, ask one concise plain-text question instead.
|
|
48
|
+
- 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.
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
Never end with prose that merely announces you are about to present, write, or finalize the plan. Submit the actual plan with plan_mode_complete in that turn.
|
|
53
|
+
|
|
54
|
+
## Completion rule
|
|
55
|
+
|
|
56
|
+
Only call plan_mode_complete when the plan leaves no implementation decisions unresolved. Pass the complete plan as Markdown with:
|
|
57
|
+
|
|
58
|
+
- A clear title
|
|
59
|
+
- A brief summary
|
|
60
|
+
- Important changes to behavior, public APIs, interfaces, or types
|
|
61
|
+
- Test cases and verification scenarios
|
|
62
|
+
- Explicit assumptions and defaults chosen where needed
|
|
63
|
+
|
|
64
|
+
Keep the plan concise, human and agent digestible, and free of open decisions. Prefer grouped behavior-level changes over file-by-file or symbol-by-symbol inventories. Do not ask "should I proceed?"; plan_mode_complete opens the Plan-mode ready flow.
|
|
65
|
+
|
|
66
|
+
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, continue planning with plan_mode_question instead of calling plan_mode_complete.`;
|
|
67
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
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 =
|
|
27
|
+
| "cancelled"
|
|
28
|
+
| "ui_unavailable"
|
|
29
|
+
| "plan_mode_inactive"
|
|
30
|
+
| "invalid_input";
|
|
31
|
+
|
|
32
|
+
type PlanModeQuestionDetails = {
|
|
33
|
+
cancelled: boolean;
|
|
34
|
+
reason?: PlanModeQuestionReason;
|
|
35
|
+
questions: PlanModeQuestion[];
|
|
36
|
+
answers?: PlanModeQuestionAnswer[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const PLAN_MODE_QUESTION_PARAMS = {
|
|
40
|
+
type: "object",
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
required: ["questions"],
|
|
43
|
+
properties: {
|
|
44
|
+
questions: {
|
|
45
|
+
type: "array",
|
|
46
|
+
minItems: 1,
|
|
47
|
+
maxItems: 3,
|
|
48
|
+
description: "Questions to show the user. Prefer 1 and do not exceed 3.",
|
|
49
|
+
items: {
|
|
50
|
+
type: "object",
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
required: ["id", "header", "question", "options"],
|
|
53
|
+
properties: {
|
|
54
|
+
id: {
|
|
55
|
+
type: "string",
|
|
56
|
+
description: "Stable identifier for mapping answers (snake_case).",
|
|
57
|
+
},
|
|
58
|
+
header: {
|
|
59
|
+
type: "string",
|
|
60
|
+
description: "Short header label shown in the UI (12 or fewer chars).",
|
|
61
|
+
},
|
|
62
|
+
question: { type: "string", description: "Single-sentence prompt shown to the user." },
|
|
63
|
+
options: {
|
|
64
|
+
type: "array",
|
|
65
|
+
minItems: 2,
|
|
66
|
+
maxItems: 4,
|
|
67
|
+
description:
|
|
68
|
+
"Provide 2-4 mutually exclusive choices. Put the recommended option first when there is a clear default.",
|
|
69
|
+
items: {
|
|
70
|
+
type: "object",
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
required: ["label", "description"],
|
|
73
|
+
properties: {
|
|
74
|
+
label: { type: "string", description: "User-facing label (1-5 words)." },
|
|
75
|
+
description: {
|
|
76
|
+
type: "string",
|
|
77
|
+
description: "One short sentence explaining impact/tradeoff if selected.",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
type NormalizePlanModeQuestionParamsResult =
|
|
89
|
+
| { ok: true; questions: PlanModeQuestion[] }
|
|
90
|
+
| { ok: false; error: string };
|
|
91
|
+
|
|
92
|
+
export function normalizePlanModeQuestionParams(
|
|
93
|
+
input: unknown,
|
|
94
|
+
): NormalizePlanModeQuestionParamsResult {
|
|
95
|
+
if (!isRecord(input) || !Array.isArray(input.questions)) {
|
|
96
|
+
return { ok: false, error: "questions must be an array" };
|
|
97
|
+
}
|
|
98
|
+
if (input.questions.length < 1 || input.questions.length > 3) {
|
|
99
|
+
return { ok: false, error: "questions must contain 1-3 items" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const questions: PlanModeQuestion[] = [];
|
|
103
|
+
for (const [questionIndex, rawQuestion] of input.questions.entries()) {
|
|
104
|
+
if (!isRecord(rawQuestion)) {
|
|
105
|
+
return { ok: false, error: `question ${questionIndex + 1} must be an object` };
|
|
106
|
+
}
|
|
107
|
+
const id = stringField(rawQuestion.id);
|
|
108
|
+
const header = stringField(rawQuestion.header);
|
|
109
|
+
const question = stringField(rawQuestion.question);
|
|
110
|
+
if (!id || !header || !question) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: `question ${questionIndex + 1} requires non-empty id, header, and question`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(rawQuestion.options)) {
|
|
117
|
+
return { ok: false, error: `question ${questionIndex + 1} options must be an array` };
|
|
118
|
+
}
|
|
119
|
+
if (rawQuestion.options.length < 2 || rawQuestion.options.length > 4) {
|
|
120
|
+
return { ok: false, error: `question ${questionIndex + 1} options must contain 2-4 items` };
|
|
121
|
+
}
|
|
122
|
+
const options: PlanModeQuestionOption[] = [];
|
|
123
|
+
for (const [optionIndex, rawOption] of rawQuestion.options.entries()) {
|
|
124
|
+
if (!isRecord(rawOption)) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
error: `question ${questionIndex + 1} option ${optionIndex + 1} must be an object`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const label = stringField(rawOption.label);
|
|
131
|
+
if (!label) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a label`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const description = stringField(rawOption.description);
|
|
138
|
+
if (!description) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
error: `question ${questionIndex + 1} option ${optionIndex + 1} requires a description`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
options.push({ label, description });
|
|
145
|
+
}
|
|
146
|
+
questions.push({ id, header, question, options });
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, questions };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function answerPlanModeQuestions(
|
|
152
|
+
questions: PlanModeQuestion[],
|
|
153
|
+
ctx: ExtensionContext,
|
|
154
|
+
lifecycle: { isCurrent(): boolean; isEnabled(): boolean },
|
|
155
|
+
) {
|
|
156
|
+
const answers = await askPlanModeQuestions(
|
|
157
|
+
questions,
|
|
158
|
+
ctx,
|
|
159
|
+
() => lifecycle.isCurrent() && lifecycle.isEnabled(),
|
|
160
|
+
);
|
|
161
|
+
if (!lifecycle.isCurrent()) {
|
|
162
|
+
return planModeQuestionCancelled(
|
|
163
|
+
questions,
|
|
164
|
+
"cancelled",
|
|
165
|
+
"Plan-mode question cancelled because the session changed.",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (!lifecycle.isEnabled()) {
|
|
169
|
+
return planModeQuestionCancelled(
|
|
170
|
+
questions,
|
|
171
|
+
"plan_mode_inactive",
|
|
172
|
+
"Plan-mode question cancelled because Plan mode is no longer active.",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (!answers) {
|
|
176
|
+
return planModeQuestionCancelled(
|
|
177
|
+
questions,
|
|
178
|
+
"cancelled",
|
|
179
|
+
"User cancelled the Plan-mode question prompt.",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return planModeQuestionAnswered(questions, answers);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function askPlanModeQuestions(
|
|
186
|
+
questions: PlanModeQuestion[],
|
|
187
|
+
ctx: ExtensionContext,
|
|
188
|
+
shouldContinue: () => boolean = () => true,
|
|
189
|
+
): Promise<PlanModeQuestionAnswer[] | undefined> {
|
|
190
|
+
const answers: PlanModeQuestionAnswer[] = [];
|
|
191
|
+
for (const question of questions) {
|
|
192
|
+
const choices = question.options.map(formatPlanModeQuestionChoice);
|
|
193
|
+
const otherChoice = `${question.options.length + 1}. Other (free-form)`;
|
|
194
|
+
const choice = await ctx.ui.select(`${question.header}: ${question.question}`, [
|
|
195
|
+
...choices,
|
|
196
|
+
otherChoice,
|
|
197
|
+
]);
|
|
198
|
+
if (!shouldContinue() || !choice) return undefined;
|
|
199
|
+
if (choice === otherChoice) {
|
|
200
|
+
const customAnswer = (await ctx.ui.editor(question.question, ""))?.trim();
|
|
201
|
+
if (!shouldContinue() || !customAnswer) return undefined;
|
|
202
|
+
answers.push({
|
|
203
|
+
id: question.id,
|
|
204
|
+
header: question.header,
|
|
205
|
+
question: question.question,
|
|
206
|
+
answer: customAnswer,
|
|
207
|
+
wasCustom: true,
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const optionIndex = choices.indexOf(choice);
|
|
212
|
+
const option = question.options[optionIndex];
|
|
213
|
+
if (!option) return undefined;
|
|
214
|
+
answers.push({
|
|
215
|
+
id: question.id,
|
|
216
|
+
header: question.header,
|
|
217
|
+
question: question.question,
|
|
218
|
+
answer: option.label,
|
|
219
|
+
wasCustom: false,
|
|
220
|
+
optionIndex: optionIndex + 1,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return answers;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: number) {
|
|
227
|
+
return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function planModeQuestionAnswered(
|
|
231
|
+
questions: PlanModeQuestion[],
|
|
232
|
+
answers: PlanModeQuestionAnswer[],
|
|
233
|
+
) {
|
|
234
|
+
return {
|
|
235
|
+
content: [
|
|
236
|
+
{ type: "text" as const, text: formatPlanModeQuestionPayload({ cancelled: false, answers }) },
|
|
237
|
+
],
|
|
238
|
+
details: { cancelled: false, questions, answers } satisfies PlanModeQuestionDetails,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function planModeQuestionCancelled(
|
|
243
|
+
questions: PlanModeQuestion[],
|
|
244
|
+
reason: PlanModeQuestionReason,
|
|
245
|
+
message: string,
|
|
246
|
+
) {
|
|
247
|
+
return {
|
|
248
|
+
content: [
|
|
249
|
+
{
|
|
250
|
+
type: "text" as const,
|
|
251
|
+
text: formatPlanModeQuestionPayload({ cancelled: true, reason, message }),
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
details: { cancelled: true, reason, questions } satisfies PlanModeQuestionDetails,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function formatPlanModeQuestionPayload(payload: {
|
|
259
|
+
cancelled: boolean;
|
|
260
|
+
reason?: PlanModeQuestionReason;
|
|
261
|
+
message?: string;
|
|
262
|
+
answers?: PlanModeQuestionAnswer[];
|
|
263
|
+
}) {
|
|
264
|
+
return JSON.stringify(payload, null, 2);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
268
|
+
return typeof value === "object" && value !== null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function stringField(value: unknown) {
|
|
272
|
+
return typeof value === "string" ? value.trim() : undefined;
|
|
273
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { PLAN_MODE_COMPLETE_TOOL_NAME } from "./completion-tool.js";
|
|
2
|
+
import { PLAN_MODE_QUESTION_TOOL_NAME } from "./question-tool.js";
|
|
3
|
+
import { unique } from "./tool-selection.js";
|
|
4
|
+
|
|
5
|
+
export function withRequiredPlanModeTools(toolNames: string[]) {
|
|
6
|
+
return unique([
|
|
7
|
+
...withoutRequiredPlanModeTools(toolNames),
|
|
8
|
+
PLAN_MODE_QUESTION_TOOL_NAME,
|
|
9
|
+
PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
10
|
+
]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function withoutPlanModeQuestionTool(toolNames: string[]) {
|
|
14
|
+
return toolNames.filter((toolName) => toolName !== PLAN_MODE_QUESTION_TOOL_NAME);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function withoutRequiredPlanModeTools(toolNames: string[]) {
|
|
18
|
+
return toolNames.filter(
|
|
19
|
+
(toolName) =>
|
|
20
|
+
toolName !== PLAN_MODE_QUESTION_TOOL_NAME && toolName !== PLAN_MODE_COMPLETE_TOOL_NAME,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
|
+
import { type PlanExportDestinationProvider, planExportInputScreen } from "./plan-export-screen.js";
|
|
4
|
+
|
|
5
|
+
interface SavedPlanMenuOptions {
|
|
6
|
+
statusText: string;
|
|
7
|
+
implementationOutcome(): string;
|
|
8
|
+
getExportDestination: PlanExportDestinationProvider;
|
|
9
|
+
signal: AbortSignal;
|
|
10
|
+
isCurrent(): boolean;
|
|
11
|
+
show(): void;
|
|
12
|
+
implementHere(): void | Promise<void>;
|
|
13
|
+
implementFresh(signal: AbortSignal): void | Promise<void>;
|
|
14
|
+
exportPlan(path: string, signal: AbortSignal): Promise<boolean>;
|
|
15
|
+
settings(signal: AbortSignal): Promise<boolean>;
|
|
16
|
+
clear(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function showSavedPlanMenu(ctx: ExtensionContext, options: SavedPlanMenuOptions) {
|
|
20
|
+
if (!ctx.hasUI) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`${options.statusText} Use /plan show, /plan implement, /plan export, or /plan exit.`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
type Screen = "saved" | "export";
|
|
26
|
+
type Action = "show" | "implement-here" | "implement-fresh" | "export" | "settings" | "clear";
|
|
27
|
+
const menu = defineMenu<undefined, Screen, Action, ExtensionContext>({
|
|
28
|
+
start: "saved",
|
|
29
|
+
screens: {
|
|
30
|
+
saved: () => ({
|
|
31
|
+
kind: "actions",
|
|
32
|
+
title: "Saved plan",
|
|
33
|
+
lines: [
|
|
34
|
+
options.statusText,
|
|
35
|
+
"Implement here keeps this planning conversation.",
|
|
36
|
+
"Start fresh transfers only the approved plan to a new session.",
|
|
37
|
+
options.implementationOutcome(),
|
|
38
|
+
],
|
|
39
|
+
items: [
|
|
40
|
+
{ id: "show", label: "Show saved plan", action: "show" },
|
|
41
|
+
{
|
|
42
|
+
id: "implement-here",
|
|
43
|
+
label: "Implement here",
|
|
44
|
+
description: "Continue in this session with the planning conversation.",
|
|
45
|
+
action: "implement-here",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "implement-fresh",
|
|
49
|
+
label: "Start fresh and implement",
|
|
50
|
+
description: "Open a new linked session; transfer only the approved plan.",
|
|
51
|
+
action: "implement-fresh",
|
|
52
|
+
busyLabel: "Starting fresh implementation session…",
|
|
53
|
+
},
|
|
54
|
+
{ id: "export", label: "Export plan…", to: "export" },
|
|
55
|
+
{ id: "settings", label: "Settings", action: "settings" },
|
|
56
|
+
{ id: "clear", label: "Clear saved plan", action: "clear" },
|
|
57
|
+
],
|
|
58
|
+
hint: "close",
|
|
59
|
+
}),
|
|
60
|
+
export: () => planExportInputScreen(options.getExportDestination),
|
|
61
|
+
},
|
|
62
|
+
actions: {
|
|
63
|
+
show: async () => {
|
|
64
|
+
options.show();
|
|
65
|
+
return { kind: "close" };
|
|
66
|
+
},
|
|
67
|
+
"implement-here": async () => {
|
|
68
|
+
await options.implementHere();
|
|
69
|
+
return { kind: "close" };
|
|
70
|
+
},
|
|
71
|
+
"implement-fresh": async ({ signal }) => {
|
|
72
|
+
await options.implementFresh(signal);
|
|
73
|
+
return { kind: "close" };
|
|
74
|
+
},
|
|
75
|
+
export: async ({ value, signal }) =>
|
|
76
|
+
(await options.exportPlan(value ?? "", signal)) ? { kind: "close" } : { kind: "rejected" },
|
|
77
|
+
settings: async ({ signal }) => {
|
|
78
|
+
const close = await options.settings(signal);
|
|
79
|
+
if (signal.aborted || !options.isCurrent()) return { kind: "rejected" };
|
|
80
|
+
return close ? { kind: "close" } : { kind: "stay" };
|
|
81
|
+
},
|
|
82
|
+
clear: async () => {
|
|
83
|
+
options.clear();
|
|
84
|
+
return { kind: "close" };
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
await runMenu(ctx, menu, {
|
|
89
|
+
getState: () => undefined,
|
|
90
|
+
signal: options.signal,
|
|
91
|
+
isCurrent: options.isCurrent,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export function savedPlanBlocksNewWorkflow(ctx: ExtensionContext, hasSavedPlan: boolean) {
|
|
4
|
+
if (!hasSavedPlan) return false;
|
|
5
|
+
const message =
|
|
6
|
+
"A plan is saved for later. Implement or clear it before starting another Plan-mode workflow.";
|
|
7
|
+
if (!ctx.hasUI) throw new Error(message);
|
|
8
|
+
ctx.ui.notify(message, "warning");
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function preflightSavedPlanImplementation(
|
|
13
|
+
ctx: ExtensionContext,
|
|
14
|
+
isCurrent: () => boolean,
|
|
15
|
+
) {
|
|
16
|
+
if (ctx.mode === "print" || ctx.mode === "json") {
|
|
17
|
+
throw new Error("Saved plan implementation is unavailable in print/JSON mode. Use TUI or RPC.");
|
|
18
|
+
}
|
|
19
|
+
const model = ctx.model;
|
|
20
|
+
if (!model) {
|
|
21
|
+
ctx.ui.notify("Unable to implement saved plan: no model is selected.", "warning");
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
let auth: Awaited<ReturnType<ExtensionContext["modelRegistry"]["getApiKeyAndHeaders"]>>;
|
|
25
|
+
try {
|
|
26
|
+
auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
27
|
+
} catch (error: unknown) {
|
|
28
|
+
if (!isCurrent()) return false;
|
|
29
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
30
|
+
ctx.ui.notify(`Unable to implement saved plan: ${detail}`, "error");
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (!isCurrent()) return false;
|
|
34
|
+
if (!auth.ok) {
|
|
35
|
+
ctx.ui.notify(`Unable to implement saved plan: ${auth.error}`, "warning");
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|