@janvitos/pi-plan-build 0.1.27 → 0.1.30
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 +2 -2
- package/index.ts +40 -13
- package/package.json +2 -2
- package/question-ui.ts +36 -10
- package/utils.ts +14 -0
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ When planning is complete, `plan_exit` displays the entire persisted plan and as
|
|
|
74
74
|
2. start a clean linked implementation session; or
|
|
75
75
|
3. stay in Plan mode.
|
|
76
76
|
|
|
77
|
-
Selecting **Start fresh and implement** stops the current run and automatically dispatches `/build-fresh`. Pi 0.84.2 or newer is required for extension command dispatch from an injected user message. The command creates a linked child session, copies the approved plan to its canonical plan file, switches it to Build, and starts implementation without transferring the planning conversation.
|
|
77
|
+
Selecting **Start fresh and implement** stops the current run and automatically dispatches `/build-fresh`. Pi 0.84.2 or newer is required for extension command dispatch from an injected user message. The command creates a linked child session, copies the approved plan to its canonical plan file, preserves the model and thinking level selected for the action, switches it to Build, and starts implementation without transferring the planning conversation.
|
|
78
78
|
|
|
79
79
|
Selecting **Stay in Plan mode**, or pressing Escape while the approval dialog is open, displays:
|
|
80
80
|
|
|
@@ -111,7 +111,7 @@ npm test
|
|
|
111
111
|
npm pack --dry-run
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
The tests cover state decoding, safe plan paths, mutation restrictions, deferred transitions, mode and provider rendering, session-based prompt history restoration, complete plan rendering, approval decisions, stop behavior, fresh-session handoff content, and question formatting.
|
|
114
|
+
The tests cover state decoding, safe plan paths, mutation restrictions, deferred transitions, mode and provider rendering, session-based prompt history restoration, complete plan rendering, approval decisions, stop behavior, fresh-session settings and handoff content, and question formatting.
|
|
115
115
|
|
|
116
116
|
### Publishing
|
|
117
117
|
|
package/index.ts
CHANGED
|
@@ -14,10 +14,12 @@ import {
|
|
|
14
14
|
import {
|
|
15
15
|
applyManualSelection,
|
|
16
16
|
buildFreshImplementationHandoff,
|
|
17
|
+
buildFreshImplementationRequest,
|
|
17
18
|
buildPlanExitFreshResult,
|
|
18
19
|
buildPlanExitStayResult,
|
|
19
20
|
buildPlanReviewMessage,
|
|
20
21
|
classifyPlanExitChoice,
|
|
22
|
+
type FreshImplementationRequest,
|
|
21
23
|
decodeModeState,
|
|
22
24
|
extractPromptHistory,
|
|
23
25
|
formatFooterCwd,
|
|
@@ -73,7 +75,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
|
|
|
73
75
|
let toolsBeforeModes: string[] = [];
|
|
74
76
|
let currentContext: ExtensionContext | undefined;
|
|
75
77
|
let requestEditorRender: (() => void) | undefined;
|
|
76
|
-
let
|
|
78
|
+
let freshImplementationRequest: FreshImplementationRequest | undefined;
|
|
77
79
|
|
|
78
80
|
pi.registerFlag("plan", {
|
|
79
81
|
description: "Start in Plan mode",
|
|
@@ -160,28 +162,47 @@ export default function planBuildModes(pi: ExtensionAPI): void {
|
|
|
160
162
|
pi.registerCommand("build-fresh", {
|
|
161
163
|
description: "Start a clean linked session and implement the plan selected in plan_exit",
|
|
162
164
|
handler: async (_args, ctx) => {
|
|
163
|
-
const
|
|
164
|
-
if (!
|
|
165
|
+
const request = freshImplementationRequest;
|
|
166
|
+
if (!request) {
|
|
165
167
|
ctx.ui.notify("No fresh implementation is pending. Choose ‘Start fresh and implement’ from plan_exit first.", "warning");
|
|
166
168
|
return;
|
|
167
169
|
}
|
|
168
170
|
if (selectedMode !== "plan") {
|
|
169
|
-
|
|
171
|
+
freshImplementationRequest = undefined;
|
|
170
172
|
ctx.ui.notify("Fresh implementation is no longer available because Plan mode is not active.", "warning");
|
|
171
173
|
return;
|
|
172
174
|
}
|
|
173
175
|
if (ctx.mode === "print" || ctx.mode === "json") {
|
|
174
176
|
throw new Error("Fresh implementation requires TUI or RPC mode");
|
|
175
177
|
}
|
|
176
|
-
if (!
|
|
178
|
+
if (!request.model) {
|
|
177
179
|
ctx.ui.notify("Cannot start implementation because no model is selected.", "warning");
|
|
178
180
|
return;
|
|
179
181
|
}
|
|
182
|
+
const currentModel = ctx.model;
|
|
183
|
+
const implementationModel = ctx.modelRegistry.find(request.model.provider, request.model.id)
|
|
184
|
+
?? (currentModel?.provider === request.model.provider && currentModel.id === request.model.id ? currentModel : undefined);
|
|
185
|
+
if (!implementationModel) {
|
|
186
|
+
ctx.ui.notify(`Cannot start implementation because ${request.model.provider}/${request.model.id} is unavailable.`, "warning");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const modelSelected = await pi.setModel(implementationModel);
|
|
191
|
+
if (modelSelected === false) {
|
|
192
|
+
ctx.ui.notify(`Cannot start implementation because no API key is available for ${request.model.provider}/${request.model.id}.`, "warning");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
pi.setThinkingLevel(request.thinkingLevel);
|
|
196
|
+
} catch (error: unknown) {
|
|
197
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
198
|
+
ctx.ui.notify(`Cannot start implementation with ${request.model.provider}/${request.model.id}: ${detail}`, "warning");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
180
201
|
|
|
181
|
-
|
|
202
|
+
freshImplementationRequest = undefined;
|
|
182
203
|
const parentSession = ctx.sessionManager.getSessionFile();
|
|
183
204
|
const sourceTools = [...toolsBeforeModes];
|
|
184
|
-
const handoff = buildFreshImplementationHandoff(plan);
|
|
205
|
+
const handoff = buildFreshImplementationHandoff(request.plan);
|
|
185
206
|
let destinationPlanPath = "";
|
|
186
207
|
let setupError: string | undefined;
|
|
187
208
|
let kickoffError: string | undefined;
|
|
@@ -195,7 +216,9 @@ export default function planBuildModes(pi: ExtensionAPI): void {
|
|
|
195
216
|
sessionManager.getSessionId(),
|
|
196
217
|
);
|
|
197
218
|
await fs.promises.mkdir(path.dirname(destinationPlanPath), { recursive: true });
|
|
198
|
-
await fs.promises.writeFile(destinationPlanPath, plan, "utf8");
|
|
219
|
+
await fs.promises.writeFile(destinationPlanPath, request.plan, "utf8");
|
|
220
|
+
sessionManager.appendModelChange(request.model.provider, request.model.id);
|
|
221
|
+
sessionManager.appendThinkingLevelChange(request.thinkingLevel);
|
|
199
222
|
sessionManager.appendCustomEntry(STATE_TYPE, {
|
|
200
223
|
version: 1,
|
|
201
224
|
selectedMode: "build",
|
|
@@ -232,11 +255,11 @@ export default function planBuildModes(pi: ExtensionAPI): void {
|
|
|
232
255
|
},
|
|
233
256
|
});
|
|
234
257
|
if (result.cancelled) {
|
|
235
|
-
|
|
258
|
+
freshImplementationRequest = request;
|
|
236
259
|
ctx.ui.notify("Fresh implementation cancelled; the source plan remains available.", "info");
|
|
237
260
|
}
|
|
238
261
|
} catch (error: unknown) {
|
|
239
|
-
|
|
262
|
+
freshImplementationRequest = request;
|
|
240
263
|
const detail = error instanceof Error ? error.message : String(error);
|
|
241
264
|
try {
|
|
242
265
|
ctx.ui.notify(`Unable to start a fresh implementation session: ${detail}`, "error");
|
|
@@ -296,19 +319,23 @@ export default function planBuildModes(pi: ExtensionAPI): void {
|
|
|
296
319
|
));
|
|
297
320
|
const decision = classifyPlanExitChoice(selection.choice);
|
|
298
321
|
if (decision === "stay") {
|
|
299
|
-
|
|
322
|
+
freshImplementationRequest = undefined;
|
|
300
323
|
pi.appendEntry(MODE_NOTICE_ENTRY_TYPE, { message: PLAN_EXIT_STAY_ACKNOWLEDGEMENT });
|
|
301
324
|
return buildPlanExitStayResult(planPath, selection.cancelled);
|
|
302
325
|
}
|
|
303
326
|
if (decision === "implement-fresh") {
|
|
304
|
-
|
|
327
|
+
freshImplementationRequest = buildFreshImplementationRequest(
|
|
328
|
+
plan,
|
|
329
|
+
ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
|
|
330
|
+
pi.getThinkingLevel(),
|
|
331
|
+
);
|
|
305
332
|
pi.sendUserMessage("/build-fresh", {
|
|
306
333
|
deliverAs: "followUp",
|
|
307
334
|
expandPromptTemplates: true,
|
|
308
335
|
});
|
|
309
336
|
return buildPlanExitFreshResult(planPath);
|
|
310
337
|
}
|
|
311
|
-
|
|
338
|
+
freshImplementationRequest = undefined;
|
|
312
339
|
await selectMode("build", ctx, "tool");
|
|
313
340
|
return {
|
|
314
341
|
content: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@janvitos/pi-plan-build",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
4
4
|
"description": "Plan safely, approve explicitly, then implement here or in a clean session.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
|
-
"test": "node --experimental-strip-types --test utils.test.ts",
|
|
32
|
+
"test": "node --experimental-strip-types --test utils.test.ts question-ui.test.ts",
|
|
33
33
|
"prepublishOnly": "npm test"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
package/question-ui.ts
CHANGED
|
@@ -22,6 +22,16 @@ export const QuestionParameters = Type.Object({
|
|
|
22
22
|
|
|
23
23
|
export type QuestionAnswer = QuestionAnswerData;
|
|
24
24
|
|
|
25
|
+
const QUESTION_NOTICE_ENTRY_TYPE = "pi-plan-build-question-notice";
|
|
26
|
+
const QUESTION_CANCELLED_MESSAGE = "You chose not to answer the question(s). Awaiting your instructions.";
|
|
27
|
+
|
|
28
|
+
class QuestionCancelledError extends Error {
|
|
29
|
+
constructor() {
|
|
30
|
+
super("User cancelled the question");
|
|
31
|
+
this.name = "QuestionCancelledError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
async function askOne(
|
|
26
36
|
ctx: any,
|
|
27
37
|
prompt: {
|
|
@@ -31,6 +41,7 @@ async function askOne(
|
|
|
31
41
|
multiple?: boolean;
|
|
32
42
|
custom?: boolean;
|
|
33
43
|
},
|
|
44
|
+
signal: AbortSignal | undefined,
|
|
34
45
|
): Promise<QuestionAnswer> {
|
|
35
46
|
const allowCustom = prompt.custom !== false;
|
|
36
47
|
const labels = prompt.options.map((option) =>
|
|
@@ -41,11 +52,11 @@ async function askOne(
|
|
|
41
52
|
|
|
42
53
|
if (!prompt.multiple) {
|
|
43
54
|
const choices = [...labels, ...(allowCustom ? ["Type your own answer"] : [])];
|
|
44
|
-
const choice = await ctx.ui.select(`${prompt.header}: ${prompt.question}`, choices);
|
|
45
|
-
if (!choice) throw new
|
|
55
|
+
const choice = await ctx.ui.select(`${prompt.header}: ${prompt.question}`, choices, { signal });
|
|
56
|
+
if (!choice) throw new QuestionCancelledError();
|
|
46
57
|
if (allowCustom && choice === "Type your own answer") {
|
|
47
|
-
const custom = await ctx.ui.input(prompt.header, prompt.question);
|
|
48
|
-
if (!custom?.trim()) throw new
|
|
58
|
+
const custom = await ctx.ui.input(prompt.header, prompt.question, { signal });
|
|
59
|
+
if (!custom?.trim()) throw new QuestionCancelledError();
|
|
49
60
|
selected.push(custom.trim());
|
|
50
61
|
usedCustom = true;
|
|
51
62
|
} else {
|
|
@@ -59,11 +70,11 @@ async function askOne(
|
|
|
59
70
|
...(allowCustom ? ["Add a custom answer"] : []),
|
|
60
71
|
...(selected.length ? [`Done (${selected.join(", ")})`] : []),
|
|
61
72
|
];
|
|
62
|
-
const choice = await ctx.ui.select(`${prompt.header}: ${prompt.question}`, choices);
|
|
63
|
-
if (!choice) throw new
|
|
73
|
+
const choice = await ctx.ui.select(`${prompt.header}: ${prompt.question}`, choices, { signal });
|
|
74
|
+
if (!choice) throw new QuestionCancelledError();
|
|
64
75
|
if (choice.startsWith("Done (")) break;
|
|
65
76
|
if (choice === "Add a custom answer") {
|
|
66
|
-
const custom = await ctx.ui.input(prompt.header, prompt.question);
|
|
77
|
+
const custom = await ctx.ui.input(prompt.header, prompt.question, { signal });
|
|
67
78
|
if (custom?.trim()) {
|
|
68
79
|
selected.push(custom.trim());
|
|
69
80
|
usedCustom = true;
|
|
@@ -81,17 +92,31 @@ async function askOne(
|
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
95
|
+
pi.registerEntryRenderer<{ message: string }>(QUESTION_NOTICE_ENTRY_TYPE, (entry, _options, theme) => {
|
|
96
|
+
const message = typeof entry.data?.message === "string" ? entry.data.message : QUESTION_CANCELLED_MESSAGE;
|
|
97
|
+
return new Text(theme.fg("warning", message), 0, 0);
|
|
98
|
+
});
|
|
84
99
|
pi.registerTool({
|
|
85
100
|
name: "question",
|
|
86
101
|
label: "Question",
|
|
87
102
|
description: `Use this tool when you need to ask the user questions during execution. This allows you to gather preferences, clarify ambiguous instructions, get implementation decisions, or offer choices. When custom is enabled (default), do not add an Other option yourself. Put the recommended option first and suffix its label with "(Recommended)".`,
|
|
88
103
|
parameters: QuestionParameters,
|
|
89
104
|
executionMode: "sequential",
|
|
90
|
-
async execute(_toolCallId, params,
|
|
105
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
91
106
|
if (!ctx.hasUI) throw new Error("The question tool requires an interactive TUI or RPC client");
|
|
92
107
|
if (params.questions.length === 0) throw new Error("At least one question is required");
|
|
93
108
|
const answers: QuestionAnswer[] = [];
|
|
94
|
-
|
|
109
|
+
try {
|
|
110
|
+
for (const prompt of params.questions) answers.push(await askOne(ctx, prompt, signal));
|
|
111
|
+
} catch (error: unknown) {
|
|
112
|
+
if (!(error instanceof QuestionCancelledError)) throw error;
|
|
113
|
+
pi.appendEntry(QUESTION_NOTICE_ENTRY_TYPE, { message: QUESTION_CANCELLED_MESSAGE });
|
|
114
|
+
return {
|
|
115
|
+
content: [{ type: "text", text: QUESTION_CANCELLED_MESSAGE }],
|
|
116
|
+
details: { cancelled: true },
|
|
117
|
+
terminate: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
95
120
|
const formatted = formatQuestionAnswers(answers);
|
|
96
121
|
return {
|
|
97
122
|
content: [{ type: "text", text: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.` }],
|
|
@@ -103,7 +128,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
103
128
|
return new Text(theme.fg("toolTitle", theme.bold(`question (${count})`)), 0, 0);
|
|
104
129
|
},
|
|
105
130
|
renderResult(result, _options, theme) {
|
|
106
|
-
const details = result.details as { answers?: QuestionAnswer[] } | undefined;
|
|
131
|
+
const details = result.details as { answers?: QuestionAnswer[]; cancelled?: boolean } | undefined;
|
|
132
|
+
if (details?.cancelled) return new Text(theme.fg("warning", "Question(s) skipped"), 0, 0);
|
|
107
133
|
if (!details?.answers) return new Text(theme.fg("warning", "Question cancelled"), 0, 0);
|
|
108
134
|
return new Text(details.answers.map((a) => `${theme.fg("success", "✓")} ${a.header}: ${a.answers.join(", ")}`).join("\n"), 0, 0);
|
|
109
135
|
},
|
package/utils.ts
CHANGED
|
@@ -174,6 +174,20 @@ export function buildPlanExitFreshResult(planPath: string) {
|
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
export interface FreshImplementationRequest {
|
|
178
|
+
plan: string;
|
|
179
|
+
model?: { provider: string; id: string };
|
|
180
|
+
thinkingLevel: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function buildFreshImplementationRequest(
|
|
184
|
+
plan: string,
|
|
185
|
+
model: { provider: string; id: string } | undefined,
|
|
186
|
+
thinkingLevel: string,
|
|
187
|
+
): FreshImplementationRequest {
|
|
188
|
+
return { plan, model, thinkingLevel };
|
|
189
|
+
}
|
|
190
|
+
|
|
177
191
|
export function buildFreshImplementationHandoff(plan: string): string {
|
|
178
192
|
return `Plan mode is now disabled. Full tool access is restored. Implement this approved plan now:\n\n${plan}`;
|
|
179
193
|
}
|