pi-plan-task 1.0.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/README.md +100 -0
- package/extensions/ask-question.test.ts +91 -0
- package/extensions/ask-question.ts +145 -0
- package/extensions/bash-guard.test.ts +17 -0
- package/extensions/bash-guard.ts +94 -0
- package/extensions/config.ts +49 -0
- package/extensions/files.test.ts +59 -0
- package/extensions/files.ts +50 -0
- package/extensions/framing.test.ts +85 -0
- package/extensions/framing.ts +79 -0
- package/extensions/index.ts +549 -0
- package/extensions/parse.ts +88 -0
- package/extensions/paths.ts +39 -0
- package/extensions/plan-input.test.ts +142 -0
- package/extensions/plan-input.ts +162 -0
- package/extensions/planning-and-task-breakdown.md +287 -0
- package/extensions/planning-method.test.ts +20 -0
- package/extensions/planning-method.ts +38 -0
- package/extensions/prompts.test.ts +70 -0
- package/extensions/prompts.ts +87 -0
- package/extensions/types.ts +26 -0
- package/package.json +18 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export const PLAN_FRAMING_TYPE = "pi-plan-task-plan";
|
|
2
|
+
export const BUILD_FRAMING_TYPE = "pi-plan-task-build";
|
|
3
|
+
export const BUILD_STATUS_TYPE = "pi-plan-task-status";
|
|
4
|
+
|
|
5
|
+
const PLAN_TASK_CUSTOM_TYPES = new Set<string>([
|
|
6
|
+
PLAN_FRAMING_TYPE,
|
|
7
|
+
BUILD_FRAMING_TYPE,
|
|
8
|
+
BUILD_STATUS_TYPE,
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export type FramingMode = "idle" | "plan" | "build";
|
|
12
|
+
export type InjectionKind = "plan-framing" | "build-framing" | "build-status";
|
|
13
|
+
|
|
14
|
+
export interface FramingState {
|
|
15
|
+
planFramingDelivered: boolean;
|
|
16
|
+
framedTaskId?: number;
|
|
17
|
+
lastStatusKey?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const INITIAL_FRAMING_STATE: FramingState = {
|
|
21
|
+
planFramingDelivered: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function isPlanTaskCustomMessage(
|
|
25
|
+
message: unknown,
|
|
26
|
+
): message is { role: "custom"; customType: string } {
|
|
27
|
+
if (!message || typeof message !== "object") return false;
|
|
28
|
+
const rec = message as { role?: unknown; customType?: unknown };
|
|
29
|
+
return rec.role === "custom" && typeof rec.customType === "string" && PLAN_TASK_CUSTOM_TYPES.has(rec.customType);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function keepTypes(mode: FramingMode): Set<string> {
|
|
33
|
+
if (mode === "plan") return new Set([PLAN_FRAMING_TYPE]);
|
|
34
|
+
if (mode === "build") return new Set([BUILD_FRAMING_TYPE, BUILD_STATUS_TYPE]);
|
|
35
|
+
return new Set();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Keep only the newest framing for the current phase. Drop all injected messages while idle. */
|
|
39
|
+
export function filterFramingMessages<T>(messages: readonly T[], mode: FramingMode): T[] {
|
|
40
|
+
const keep = keepTypes(mode);
|
|
41
|
+
const lastIndex = new Map<string, number>();
|
|
42
|
+
messages.forEach((message, index) => {
|
|
43
|
+
if (!isPlanTaskCustomMessage(message) || !keep.has(message.customType)) return;
|
|
44
|
+
lastIndex.set(message.customType, index);
|
|
45
|
+
});
|
|
46
|
+
return messages.filter((message, index) => {
|
|
47
|
+
if (!isPlanTaskCustomMessage(message)) return true;
|
|
48
|
+
if (!keep.has(message.customType)) return false;
|
|
49
|
+
return lastIndex.get(message.customType) === index;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function nextInjection(
|
|
54
|
+
mode: FramingMode,
|
|
55
|
+
state: FramingState,
|
|
56
|
+
currentTaskId?: number,
|
|
57
|
+
statusKey?: string,
|
|
58
|
+
): InjectionKind | undefined {
|
|
59
|
+
if (mode === "plan") return state.planFramingDelivered ? undefined : "plan-framing";
|
|
60
|
+
if (mode !== "build" || currentTaskId === undefined) return undefined;
|
|
61
|
+
if (state.framedTaskId !== currentTaskId) return "build-framing";
|
|
62
|
+
if (statusKey !== undefined && statusKey !== state.lastStatusKey) return "build-status";
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function rememberInjection(
|
|
67
|
+
state: FramingState,
|
|
68
|
+
kind: InjectionKind,
|
|
69
|
+
taskId?: number,
|
|
70
|
+
statusKey?: string,
|
|
71
|
+
): FramingState {
|
|
72
|
+
if (kind === "plan-framing") {
|
|
73
|
+
return { ...state, planFramingDelivered: true };
|
|
74
|
+
}
|
|
75
|
+
if (kind === "build-framing") {
|
|
76
|
+
return { ...state, framedTaskId: taskId, lastStatusKey: statusKey };
|
|
77
|
+
}
|
|
78
|
+
return { ...state, lastStatusKey: statusKey };
|
|
79
|
+
}
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
isToolCallEventType,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { isSafePlanCommand } from "./bash-guard.ts";
|
|
10
|
+
import {
|
|
11
|
+
ASK_QUESTION_GUIDELINES,
|
|
12
|
+
ASK_USER_QUESTION_TOOL,
|
|
13
|
+
executeAskQuestion,
|
|
14
|
+
} from "./ask-question.ts";
|
|
15
|
+
import { ensureDefaultGlobalConfig, loadConfig } from "./config.ts";
|
|
16
|
+
import {
|
|
17
|
+
ensurePlanDir,
|
|
18
|
+
formatProgress,
|
|
19
|
+
loadTaskFile,
|
|
20
|
+
markTaskComplete,
|
|
21
|
+
nextPendingTask,
|
|
22
|
+
readOptionalFile,
|
|
23
|
+
} from "./files.ts";
|
|
24
|
+
import {
|
|
25
|
+
BUILD_FRAMING_TYPE,
|
|
26
|
+
BUILD_STATUS_TYPE,
|
|
27
|
+
filterFramingMessages,
|
|
28
|
+
INITIAL_FRAMING_STATE,
|
|
29
|
+
nextInjection,
|
|
30
|
+
PLAN_FRAMING_TYPE,
|
|
31
|
+
rememberInjection,
|
|
32
|
+
type FramingState,
|
|
33
|
+
} from "./framing.ts";
|
|
34
|
+
import { EMPTY_PLAN_SOURCE, loadPlanSource, type PlanSource } from "./plan-input.ts";
|
|
35
|
+
import { isPlanArtifactPath, planFilePath, taskFilePath } from "./paths.ts";
|
|
36
|
+
import { buildPrompt, buildRequest, buildStatus, buildStatusKey, planPrompt, planRequest } from "./prompts.ts";
|
|
37
|
+
import type { TaskItem } from "./types.ts";
|
|
38
|
+
|
|
39
|
+
type Mode = "idle" | "plan" | "build";
|
|
40
|
+
|
|
41
|
+
const CONTINUE_THIS = "Continue in this session";
|
|
42
|
+
const CONTINUE_NEW = "Continue in a new session";
|
|
43
|
+
|
|
44
|
+
const ALWAYS_ON_TOOLS = ["plan_task", ASK_USER_QUESTION_TOOL] as const;
|
|
45
|
+
|
|
46
|
+
function unique(names: string[]): string[] {
|
|
47
|
+
return [...new Set(names)];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function withAlwaysOnTools(names: string[]): string[] {
|
|
51
|
+
return unique([...names, ...ALWAYS_ON_TOOLS]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pathFromInput(input: unknown): string | undefined {
|
|
55
|
+
if (!input || typeof input !== "object") return undefined;
|
|
56
|
+
const path = (input as { path?: unknown }).path;
|
|
57
|
+
return typeof path === "string" ? path : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatTaskList(tasks: TaskItem[]): string {
|
|
61
|
+
if (tasks.length === 0) return "No tasks found in .plan_task/task.md.";
|
|
62
|
+
const lines = tasks.map((task) => {
|
|
63
|
+
const mark = task.done ? "[x]" : "[ ]";
|
|
64
|
+
return `${mark} ${task.id}. ${task.title}`;
|
|
65
|
+
});
|
|
66
|
+
return `Progress ${formatProgress(tasks)}\n${lines.join("\n")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class TaskListComponent {
|
|
70
|
+
private readonly tasks: TaskItem[];
|
|
71
|
+
private readonly theme: { fg: (name: string, text: string) => string };
|
|
72
|
+
private readonly onClose: () => void;
|
|
73
|
+
private cachedWidth?: number;
|
|
74
|
+
private cachedLines?: string[];
|
|
75
|
+
|
|
76
|
+
constructor(
|
|
77
|
+
tasks: TaskItem[],
|
|
78
|
+
theme: { fg: (name: string, text: string) => string },
|
|
79
|
+
onClose: () => void,
|
|
80
|
+
) {
|
|
81
|
+
this.tasks = tasks;
|
|
82
|
+
this.theme = theme;
|
|
83
|
+
this.onClose = onClose;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
handleInput(data: string): void {
|
|
87
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
88
|
+
this.onClose();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
render(width: number): string[] {
|
|
93
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
94
|
+
const th = this.theme;
|
|
95
|
+
const lines = ["", truncateToWidth(` ${th.fg("accent", "Plan tasks")} ${th.fg("muted", formatProgress(this.tasks))}`, width), ""];
|
|
96
|
+
if (this.tasks.length === 0) {
|
|
97
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "No tasks found. Run /plan first.")}`, width));
|
|
98
|
+
} else {
|
|
99
|
+
for (const task of this.tasks) {
|
|
100
|
+
const check = task.done ? th.fg("success", "x") : th.fg("dim", " ");
|
|
101
|
+
const title = task.done ? th.fg("dim", task.title) : th.fg("text", task.title);
|
|
102
|
+
lines.push(truncateToWidth(` [${check}] ${th.fg("accent", String(task.id))}. ${title}`, width));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
lines.push("", truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width), "");
|
|
106
|
+
this.cachedWidth = width;
|
|
107
|
+
this.cachedLines = lines;
|
|
108
|
+
return lines;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
invalidate(): void {
|
|
112
|
+
this.cachedWidth = undefined;
|
|
113
|
+
this.cachedLines = undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default async function planTaskExtension(pi: ExtensionAPI): Promise<void> {
|
|
118
|
+
await ensureDefaultGlobalConfig();
|
|
119
|
+
|
|
120
|
+
let mode: Mode = "idle";
|
|
121
|
+
let continueAll = false;
|
|
122
|
+
let currentTaskId: number | undefined;
|
|
123
|
+
let toolsBeforePlan: string[] | undefined;
|
|
124
|
+
let awaitingChoice = false;
|
|
125
|
+
let planReadyNotified = false;
|
|
126
|
+
let planSource: PlanSource = EMPTY_PLAN_SOURCE;
|
|
127
|
+
let framing: FramingState = { ...INITIAL_FRAMING_STATE };
|
|
128
|
+
|
|
129
|
+
function resetFraming(): void {
|
|
130
|
+
framing = { ...INITIAL_FRAMING_STATE };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function updateStatus(ctx: ExtensionContext, tasks?: TaskItem[]): void {
|
|
134
|
+
if (mode === "plan") {
|
|
135
|
+
ctx.ui.setStatus("pi-plan-task", ctx.ui.theme.fg("warning", "plan"));
|
|
136
|
+
ctx.ui.setWidget("pi-plan-task", undefined);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (mode === "build" && tasks && tasks.length > 0) {
|
|
140
|
+
ctx.ui.setStatus("pi-plan-task", ctx.ui.theme.fg("accent", `build ${formatProgress(tasks)}`));
|
|
141
|
+
ctx.ui.setWidget(
|
|
142
|
+
"pi-plan-task",
|
|
143
|
+
tasks.map((task) => {
|
|
144
|
+
if (task.done) {
|
|
145
|
+
return ctx.ui.theme.fg("success", "x ") + ctx.ui.theme.fg("muted", `${task.id}. ${task.title}`);
|
|
146
|
+
}
|
|
147
|
+
const prefix = task.id === currentTaskId ? ctx.ui.theme.fg("accent", "> ") : " ";
|
|
148
|
+
return `${prefix}${task.id}. ${task.title}`;
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
ctx.ui.setStatus("pi-plan-task", undefined);
|
|
154
|
+
ctx.ui.setWidget("pi-plan-task", undefined);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function applyPlanTools(ctx: ExtensionContext): Promise<void> {
|
|
158
|
+
if (toolsBeforePlan === undefined) {
|
|
159
|
+
toolsBeforePlan = pi.getActiveTools();
|
|
160
|
+
}
|
|
161
|
+
const config = await loadConfig(ctx.cwd);
|
|
162
|
+
pi.setActiveTools(withAlwaysOnTools([...config.planTools, "write", "edit"]));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function restoreTools(): void {
|
|
166
|
+
if (toolsBeforePlan) {
|
|
167
|
+
pi.setActiveTools(withAlwaysOnTools(toolsBeforePlan));
|
|
168
|
+
toolsBeforePlan = undefined;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
pi.setActiveTools(withAlwaysOnTools(pi.getActiveTools()));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function enterPlanMode(ctx: ExtensionContext): Promise<void> {
|
|
175
|
+
mode = "plan";
|
|
176
|
+
continueAll = false;
|
|
177
|
+
currentTaskId = undefined;
|
|
178
|
+
planReadyNotified = false;
|
|
179
|
+
resetFraming();
|
|
180
|
+
await ensurePlanDir(ctx.cwd);
|
|
181
|
+
await applyPlanTools(ctx);
|
|
182
|
+
updateStatus(ctx);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function enterBuildMode(ctx: ExtensionContext): Promise<void> {
|
|
186
|
+
mode = "build";
|
|
187
|
+
resetFraming();
|
|
188
|
+
restoreTools();
|
|
189
|
+
const file = await loadTaskFile(ctx.cwd);
|
|
190
|
+
updateStatus(ctx, file?.tasks);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function leaveModes(ctx: ExtensionContext): void {
|
|
194
|
+
mode = "idle";
|
|
195
|
+
continueAll = false;
|
|
196
|
+
currentTaskId = undefined;
|
|
197
|
+
resetFraming();
|
|
198
|
+
restoreTools();
|
|
199
|
+
updateStatus(ctx);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function startNextTask(ctx: ExtensionContext): Promise<boolean> {
|
|
203
|
+
const file = await loadTaskFile(ctx.cwd);
|
|
204
|
+
if (!file) {
|
|
205
|
+
ctx.ui.notify("No plan found. Run /plan first.", "error");
|
|
206
|
+
leaveModes(ctx);
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const next = nextPendingTask(file.tasks);
|
|
210
|
+
if (!next) {
|
|
211
|
+
ctx.ui.notify("All tasks are complete.", "info");
|
|
212
|
+
leaveModes(ctx);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
currentTaskId = next.id;
|
|
216
|
+
updateStatus(ctx, file.tasks);
|
|
217
|
+
pi.sendUserMessage(buildRequest(next));
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function afterBuildSettled(ctx: ExtensionContext): Promise<void> {
|
|
222
|
+
if (mode !== "build" || awaitingChoice || currentTaskId === undefined) return;
|
|
223
|
+
const file = await loadTaskFile(ctx.cwd);
|
|
224
|
+
if (!file) {
|
|
225
|
+
ctx.ui.notify("task.md is missing. Stopped.", "error");
|
|
226
|
+
leaveModes(ctx);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const current = file.tasks.find((task) => task.id === currentTaskId);
|
|
230
|
+
updateStatus(ctx, file.tasks);
|
|
231
|
+
if (!current?.done) {
|
|
232
|
+
ctx.ui.notify(
|
|
233
|
+
`Task ${currentTaskId} is not marked complete. Update .plan_task/task.md or call plan_task, then run /build again.`,
|
|
234
|
+
"warning",
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (file.tasks.every((task) => task.done)) {
|
|
239
|
+
ctx.ui.notify("All tasks are complete.", "info");
|
|
240
|
+
leaveModes(ctx);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (continueAll) {
|
|
244
|
+
await startNextTask(ctx);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!ctx.hasUI) {
|
|
248
|
+
ctx.ui.notify("Task complete. Run /build for the next task.", "info");
|
|
249
|
+
mode = "idle";
|
|
250
|
+
currentTaskId = undefined;
|
|
251
|
+
resetFraming();
|
|
252
|
+
updateStatus(ctx);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
awaitingChoice = true;
|
|
256
|
+
const choice = await ctx.ui.select("Task complete. What next?", [CONTINUE_THIS, CONTINUE_NEW]);
|
|
257
|
+
awaitingChoice = false;
|
|
258
|
+
if (choice === CONTINUE_THIS) {
|
|
259
|
+
await startNextTask(ctx);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (choice === CONTINUE_NEW) {
|
|
263
|
+
mode = "idle";
|
|
264
|
+
currentTaskId = undefined;
|
|
265
|
+
resetFraming();
|
|
266
|
+
updateStatus(ctx);
|
|
267
|
+
pi.sendUserMessage("/build-next-session", { expandPromptTemplates: true });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
mode = "idle";
|
|
271
|
+
currentTaskId = undefined;
|
|
272
|
+
resetFraming();
|
|
273
|
+
updateStatus(ctx);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pi.registerTool({
|
|
277
|
+
name: "plan_task",
|
|
278
|
+
label: "Plan Task",
|
|
279
|
+
description: "Read plan progress or mark a planned task complete in .plan_task/task.md",
|
|
280
|
+
promptSnippet: "Mark planned tasks complete and read .plan_task progress",
|
|
281
|
+
parameters: Type.Object({
|
|
282
|
+
action: StringEnum(["status", "complete"] as const),
|
|
283
|
+
id: Type.Optional(Type.Number({ description: "Task id to mark complete" })),
|
|
284
|
+
}),
|
|
285
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
286
|
+
const file = await loadTaskFile(ctx.cwd);
|
|
287
|
+
if (!file) {
|
|
288
|
+
return { content: [{ type: "text", text: "No .plan_task/task.md found. Run /plan first." }] };
|
|
289
|
+
}
|
|
290
|
+
if (params.action === "status") {
|
|
291
|
+
return { content: [{ type: "text", text: formatTaskList(file.tasks) }], details: { tasks: file.tasks } };
|
|
292
|
+
}
|
|
293
|
+
if (params.id === undefined) {
|
|
294
|
+
return { content: [{ type: "text", text: "id is required for complete" }] };
|
|
295
|
+
}
|
|
296
|
+
const next = await markTaskComplete(ctx.cwd, params.id);
|
|
297
|
+
if (!next) {
|
|
298
|
+
return { content: [{ type: "text", text: "Could not update .plan_task/task.md" }] };
|
|
299
|
+
}
|
|
300
|
+
const task = next.tasks.find((item) => item.id === params.id);
|
|
301
|
+
updateStatus(ctx, next.tasks);
|
|
302
|
+
return {
|
|
303
|
+
content: [
|
|
304
|
+
{
|
|
305
|
+
type: "text",
|
|
306
|
+
text: task?.done
|
|
307
|
+
? `Marked task ${params.id} complete. Progress ${formatProgress(next.tasks)}.`
|
|
308
|
+
: `Task ${params.id} was not found.`,
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
details: { tasks: next.tasks, completedId: params.id },
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
renderCall(args, theme) {
|
|
315
|
+
const suffix = args.id === undefined ? "" : ` #${args.id}`;
|
|
316
|
+
return new Text(theme.fg("toolTitle", theme.bold("plan_task ")) + theme.fg("muted", `${args.action}${suffix}`), 0, 0);
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
pi.registerTool({
|
|
321
|
+
name: ASK_USER_QUESTION_TOOL,
|
|
322
|
+
label: "Ask User Question",
|
|
323
|
+
description:
|
|
324
|
+
"Ask user a clarifying question with selectable options, a recommended default, and optional free-form input. Works in any mode.",
|
|
325
|
+
promptSnippet:
|
|
326
|
+
"Ask user a clarifying question with 2-4 options and a recommended default; works in any mode",
|
|
327
|
+
promptGuidelines: ASK_QUESTION_GUIDELINES,
|
|
328
|
+
parameters: Type.Object({
|
|
329
|
+
question: Type.String({ description: "The clarifying question to ask" }),
|
|
330
|
+
options: Type.Array(
|
|
331
|
+
Type.Object({
|
|
332
|
+
label: Type.String({ description: "Option label" }),
|
|
333
|
+
description: Type.Optional(Type.String({ description: "Optional explanation" })),
|
|
334
|
+
}),
|
|
335
|
+
{ description: "Options to choose from (2-4 required)", minItems: 2, maxItems: 4 },
|
|
336
|
+
),
|
|
337
|
+
recommended: Type.Optional(
|
|
338
|
+
Type.String({
|
|
339
|
+
description:
|
|
340
|
+
"Label of the recommended option (must match one option label). It is shown with a ★ marker.",
|
|
341
|
+
}),
|
|
342
|
+
),
|
|
343
|
+
allowOther: Type.Optional(Type.Boolean({ description: "Allow free-form user answer; default true" })),
|
|
344
|
+
}),
|
|
345
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
346
|
+
return executeAskQuestion(params, ctx);
|
|
347
|
+
},
|
|
348
|
+
renderCall(args, theme) {
|
|
349
|
+
const question = typeof args.question === "string" ? args.question : "";
|
|
350
|
+
return new Text(
|
|
351
|
+
theme.fg("toolTitle", theme.bold("ask_user_question ")) + theme.fg("muted", question),
|
|
352
|
+
0,
|
|
353
|
+
0,
|
|
354
|
+
);
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
pi.registerCommand("plan", {
|
|
358
|
+
description: "Write .plan_task/plan.md and .plan_task/task.md from a file path or prompt",
|
|
359
|
+
handler: async (args, ctx) => {
|
|
360
|
+
if (mode === "build") {
|
|
361
|
+
const ok = ctx.hasUI
|
|
362
|
+
? await ctx.ui.confirm("Switch to plan mode?", "A build is in progress. Stop it and start planning?")
|
|
363
|
+
: true;
|
|
364
|
+
if (!ok) return;
|
|
365
|
+
}
|
|
366
|
+
const loaded = await loadPlanSource(args, ctx.cwd);
|
|
367
|
+
if (!loaded.ok) {
|
|
368
|
+
ctx.ui.notify(loaded.error, "error");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const existing = await readOptionalFile(taskFilePath(ctx.cwd));
|
|
372
|
+
if (existing && ctx.hasUI) {
|
|
373
|
+
const ok = await ctx.ui.confirm("Overwrite existing plan?", ".plan_task already has a task list. Overwrite it?");
|
|
374
|
+
if (!ok) return;
|
|
375
|
+
}
|
|
376
|
+
planSource = loaded.source;
|
|
377
|
+
await enterPlanMode(ctx);
|
|
378
|
+
const message =
|
|
379
|
+
loaded.source.kind === "file"
|
|
380
|
+
? `Planning from ${loaded.source.displayPath}. Project writes are blocked.`
|
|
381
|
+
: "Plan mode enabled. Project writes are blocked.";
|
|
382
|
+
ctx.ui.notify(message, "info");
|
|
383
|
+
pi.sendUserMessage(planRequest(loaded.source));
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
async function beginBuild(ctx: ExtensionContext, runAll: boolean): Promise<void> {
|
|
388
|
+
const file = await loadTaskFile(ctx.cwd);
|
|
389
|
+
if (!file) {
|
|
390
|
+
ctx.ui.notify("No plan found. Run /plan first.", "error");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (file.tasks.length === 0) {
|
|
394
|
+
ctx.ui.notify("task.md has no checklist items.", "error");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (!nextPendingTask(file.tasks)) {
|
|
398
|
+
ctx.ui.notify("All tasks are complete.", "info");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
continueAll = runAll;
|
|
402
|
+
await enterBuildMode(ctx);
|
|
403
|
+
ctx.ui.notify(runAll ? "Building remaining tasks." : "Building the next task.", "info");
|
|
404
|
+
await startNextTask(ctx);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
pi.registerCommand("build", {
|
|
408
|
+
description: "Execute the next planned task",
|
|
409
|
+
handler: async (_args, ctx) => {
|
|
410
|
+
await beginBuild(ctx, false);
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
pi.registerCommand("goal", {
|
|
415
|
+
description: "Execute remaining planned tasks until the list is complete",
|
|
416
|
+
handler: async (_args, ctx) => {
|
|
417
|
+
await beginBuild(ctx, true);
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
pi.registerCommand("tasks", {
|
|
422
|
+
description: "Show current plan tasks and progress",
|
|
423
|
+
handler: async (_args, ctx) => {
|
|
424
|
+
const file = await loadTaskFile(ctx.cwd);
|
|
425
|
+
const tasks = file?.tasks ?? [];
|
|
426
|
+
if (ctx.mode !== "tui") {
|
|
427
|
+
ctx.ui.notify(formatTaskList(tasks), "info");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
await ctx.ui.custom<void>((_tui, theme, _kb, done) => new TaskListComponent(tasks, theme, () => done()));
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
pi.registerCommand("build-next-session", {
|
|
435
|
+
description: "Continue the next planned task in a new session",
|
|
436
|
+
handler: async (_args, ctx) => {
|
|
437
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
438
|
+
const result = await ctx.newSession({
|
|
439
|
+
parentSession,
|
|
440
|
+
withSession: async (nextCtx) => {
|
|
441
|
+
await nextCtx.sendUserMessage("/build", { expandPromptTemplates: true });
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
if (result.cancelled) {
|
|
445
|
+
ctx.ui.notify("New session cancelled.", "info");
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
451
|
+
mode = "idle";
|
|
452
|
+
continueAll = false;
|
|
453
|
+
currentTaskId = undefined;
|
|
454
|
+
toolsBeforePlan = undefined;
|
|
455
|
+
awaitingChoice = false;
|
|
456
|
+
planReadyNotified = false;
|
|
457
|
+
planSource = EMPTY_PLAN_SOURCE;
|
|
458
|
+
resetFraming();
|
|
459
|
+
pi.setActiveTools(withAlwaysOnTools(pi.getActiveTools()));
|
|
460
|
+
updateStatus(ctx);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
464
|
+
let statusKey: string | undefined;
|
|
465
|
+
let task: TaskItem | undefined;
|
|
466
|
+
let tasks: TaskItem[] | undefined;
|
|
467
|
+
let remaining = 0;
|
|
468
|
+
if (mode === "build" && currentTaskId !== undefined) {
|
|
469
|
+
const file = await loadTaskFile(ctx.cwd);
|
|
470
|
+
task = file?.tasks.find((item) => item.id === currentTaskId);
|
|
471
|
+
if (file && task) {
|
|
472
|
+
tasks = file.tasks;
|
|
473
|
+
statusKey = buildStatusKey(file.tasks, currentTaskId);
|
|
474
|
+
remaining = file.tasks.filter((item) => !item.done).length;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const kind = nextInjection(mode, framing, currentTaskId, statusKey);
|
|
478
|
+
if (!kind) return;
|
|
479
|
+
if (kind === "plan-framing") {
|
|
480
|
+
framing = rememberInjection(framing, kind);
|
|
481
|
+
return {
|
|
482
|
+
message: {
|
|
483
|
+
customType: PLAN_FRAMING_TYPE,
|
|
484
|
+
content: planPrompt(planSource),
|
|
485
|
+
display: false,
|
|
486
|
+
details: { phase: "plan" },
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
if (!task || !tasks || currentTaskId === undefined) return;
|
|
491
|
+
framing = rememberInjection(framing, kind, currentTaskId, statusKey);
|
|
492
|
+
if (kind === "build-framing") {
|
|
493
|
+
return {
|
|
494
|
+
message: {
|
|
495
|
+
customType: BUILD_FRAMING_TYPE,
|
|
496
|
+
content: buildPrompt(task, remaining, continueAll),
|
|
497
|
+
display: false,
|
|
498
|
+
details: { phase: "build", taskId: currentTaskId },
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
message: {
|
|
504
|
+
customType: BUILD_STATUS_TYPE,
|
|
505
|
+
content: buildStatus(tasks, currentTaskId),
|
|
506
|
+
display: false,
|
|
507
|
+
details: { phase: "build", taskId: currentTaskId },
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
pi.on("context", async (event) => {
|
|
513
|
+
return { messages: filterFramingMessages(event.messages, mode) };
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
517
|
+
if (mode !== "plan") return;
|
|
518
|
+
if (isToolCallEventType("bash", event)) {
|
|
519
|
+
if (!isSafePlanCommand(event.input.command)) {
|
|
520
|
+
return {
|
|
521
|
+
block: true,
|
|
522
|
+
reason: `Plan mode blocked this command. Use /build to implement.\nCommand: ${event.input.command}`,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
528
|
+
const path = pathFromInput(event.input);
|
|
529
|
+
if (!path || !isPlanArtifactPath(ctx.cwd, path)) {
|
|
530
|
+
return {
|
|
531
|
+
block: true,
|
|
532
|
+
reason: `Plan mode can only write ${planFilePath(ctx.cwd)} and ${taskFilePath(ctx.cwd)}.`,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
539
|
+
if (mode === "plan") {
|
|
540
|
+
const file = await loadTaskFile(ctx.cwd);
|
|
541
|
+
if (file && file.tasks.length > 0 && !planReadyNotified) {
|
|
542
|
+
planReadyNotified = true;
|
|
543
|
+
ctx.ui.notify(`Plan written. ${formatProgress(file.tasks)} tasks ready. Run /build to start.`, "info");
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
await afterBuildSettled(ctx);
|
|
548
|
+
});
|
|
549
|
+
}
|