pi-plan-task 1.0.2 → 3.0.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 +639 -58
- package/extensions/build-session.test.ts +27 -12
- package/extensions/build-session.ts +40 -6
- package/extensions/command-surface.test.ts +11 -0
- package/extensions/config.ts +12 -40
- package/extensions/files.test.ts +21 -1
- package/extensions/files.ts +11 -27
- package/extensions/framing.test.ts +1 -0
- package/extensions/framing.ts +5 -2
- package/extensions/history.test.ts +24 -0
- package/extensions/history.ts +42 -0
- package/extensions/index.ts +596 -208
- package/extensions/integration.test.ts +232 -0
- package/extensions/migration.test.ts +34 -0
- package/extensions/parse.ts +80 -4
- package/extensions/paths.ts +26 -32
- package/extensions/planning-and-task-breakdown.md +10 -8
- package/extensions/planning-method.test.ts +15 -2
- package/extensions/planning-method.ts +4 -2
- package/extensions/policy.test.ts +11 -0
- package/extensions/prompts.test.ts +54 -9
- package/extensions/prompts.ts +100 -32
- package/extensions/recovery.test.ts +15 -0
- package/extensions/review.test.ts +19 -0
- package/extensions/review.ts +53 -0
- package/extensions/state.test.ts +25 -0
- package/extensions/state.ts +198 -0
- package/extensions/task-ui.ts +48 -0
- package/extensions/tool-policy.ts +4 -0
- package/extensions/tools.test.ts +10 -0
- package/extensions/tools.ts +16 -0
- package/extensions/types.ts +53 -5
- package/extensions/validation.test.ts +38 -0
- package/extensions/workflow-policy.test.ts +76 -0
- package/extensions/workflow-policy.ts +117 -0
- package/extensions/workflow-store.test.ts +48 -0
- package/extensions/workflow-store.ts +165 -0
- package/package.json +29 -5
package/extensions/index.ts
CHANGED
|
@@ -1,21 +1,19 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
3
|
import {
|
|
3
4
|
type ExtensionAPI,
|
|
4
5
|
type ExtensionCommandContext,
|
|
5
6
|
type ExtensionContext,
|
|
6
|
-
isToolCallEventType,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
8
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
9
9
|
import { Type } from "typebox";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import { ensureDefaultGlobalConfig, loadConfig } from "./config.ts";
|
|
10
|
+
import { buildHandoffCommand, parseBuildOptions, type BuildPlacement } from "./build-session.ts";
|
|
11
|
+
import { ensureDefaultGlobalConfig, isAllowedPlanTool, loadConfig } from "./config.ts";
|
|
13
12
|
import {
|
|
14
13
|
ensurePlanDir,
|
|
15
14
|
formatProgress,
|
|
15
|
+
hasPendingTasks,
|
|
16
16
|
loadTaskFile,
|
|
17
|
-
markTaskComplete,
|
|
18
|
-
nextPendingTask,
|
|
19
17
|
readOptionalFile,
|
|
20
18
|
} from "./files.ts";
|
|
21
19
|
import {
|
|
@@ -29,35 +27,40 @@ import {
|
|
|
29
27
|
type FramingState,
|
|
30
28
|
} from "./framing.ts";
|
|
31
29
|
import { EMPTY_PLAN_SOURCE, loadPlanSource, type PlanSource } from "./plan-input.ts";
|
|
32
|
-
import {
|
|
30
|
+
import { markTaskPendingInMarkdown, parseTaskMarkdown } from "./parse.ts";
|
|
31
|
+
import { draftPlanFilePath, draftTaskFilePath, isPlanArtifactPath, planFilePath, taskFilePath } from "./paths.ts";
|
|
33
32
|
import { buildPrompt, buildRequest, buildStatus, buildStatusKey, planPrompt, planRequest } from "./prompts.ts";
|
|
34
|
-
import type { TaskItem } from "./types.ts";
|
|
33
|
+
import type { PlanState, TaskItem } from "./types.ts";
|
|
34
|
+
import { formatTaskList, TaskListComponent } from "./task-ui.ts";
|
|
35
|
+
import { initialState, structureHash, transition, updateTaskState } from "./state.ts";
|
|
36
|
+
import { normalizeReviewResult, validatePlan, textDiff } from "./review.ts";
|
|
37
|
+
import { listPlanSnapshots } from "./history.ts";
|
|
38
|
+
import { addTools, removeAddedTools, type ToolDelta } from "./tools.ts";
|
|
39
|
+
import { assertApprovedStructure, blockCurrentTask, completeCurrentTask, reworkFromTask, unblockTask, verifyCurrentTask, classifyQueue } from "./workflow-policy.ts";
|
|
40
|
+
import { commitDraft, initializeDraftWithState, loadStateLocked, mutateWorkflow, readCurrentArtifacts, readDraftArtifacts, saveStateLocked } from "./workflow-store.ts";
|
|
35
41
|
|
|
36
42
|
type Mode = "idle" | "plan" | "build";
|
|
37
43
|
|
|
38
44
|
const CONTINUE_THIS = "Continue in this session";
|
|
39
45
|
const CONTINUE_NEW = "Continue in a new session";
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
async function chooseBuildPlacement(ctx: ExtensionCommandContext): Promise<"here" | "new" | undefined> {
|
|
43
|
-
if (!ctx.hasUI) return "here";
|
|
44
|
-
const choice = await ctx.ui.select("Start this task where?", [CONTINUE_THIS, CONTINUE_NEW]);
|
|
45
|
-
if (choice === CONTINUE_THIS) return "here";
|
|
46
|
-
if (choice === CONTINUE_NEW) return "new";
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
46
|
+
const BUILD_ONE_HERE_COMMAND = "/build";
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
function contentKey(content: string): string {
|
|
49
|
+
return createHash("sha256").update(content).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
async function startBuildInNewSession(ctx: ExtensionCommandContext, command = BUILD_ONE_HERE_COMMAND, clean = false): Promise<boolean> {
|
|
51
52
|
const parentSession = ctx.sessionManager.getSessionFile();
|
|
52
53
|
const result = await ctx.newSession({
|
|
53
|
-
parentSession,
|
|
54
|
+
parentSession: clean ? undefined : parentSession,
|
|
54
55
|
withSession: async (nextCtx) => {
|
|
55
|
-
await nextCtx.sendUserMessage(
|
|
56
|
+
await nextCtx.sendUserMessage(command, { expandPromptTemplates: true });
|
|
56
57
|
},
|
|
57
58
|
});
|
|
58
59
|
if (result.cancelled) {
|
|
59
|
-
ctx.ui.notify("New session cancelled.", "info");
|
|
60
|
+
ctx.ui.notify("New session cancelled; the plan remains approved.", "info");
|
|
61
|
+
return false;
|
|
60
62
|
}
|
|
63
|
+
return true;
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
const ALWAYS_ON_TOOLS = ["plan_task", "ask_user_question"] as const;
|
|
@@ -76,74 +79,30 @@ function pathFromInput(input: unknown): string | undefined {
|
|
|
76
79
|
return typeof path === "string" ? path : undefined;
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
});
|
|
85
|
-
return `Progress ${formatProgress(tasks)}\n${lines.join("\n")}`;
|
|
82
|
+
function assertApprovedWorkflow(state: PlanState, plan: string, task: string): void {
|
|
83
|
+
assertApprovedStructure(state, plan, task, structureHash);
|
|
84
|
+
let tasks: TaskItem[];
|
|
85
|
+
try { tasks = parseTaskMarkdown(task); } catch (error) { throw new Error(`Invalid approved task file: ${String(error)}`); }
|
|
86
|
+
const validation = validatePlan(plan, task, tasks);
|
|
87
|
+
if (!validation.valid) throw new Error(`Approved plan contract is invalid: ${validation.errors.join("; ")}`);
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
class TaskListComponent {
|
|
89
|
-
private readonly tasks: TaskItem[];
|
|
90
|
-
private readonly theme: { fg: (name: string, text: string) => string };
|
|
91
|
-
private readonly onClose: () => void;
|
|
92
|
-
private cachedWidth?: number;
|
|
93
|
-
private cachedLines?: string[];
|
|
94
|
-
|
|
95
|
-
constructor(
|
|
96
|
-
tasks: TaskItem[],
|
|
97
|
-
theme: { fg: (name: string, text: string) => string },
|
|
98
|
-
onClose: () => void,
|
|
99
|
-
) {
|
|
100
|
-
this.tasks = tasks;
|
|
101
|
-
this.theme = theme;
|
|
102
|
-
this.onClose = onClose;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
handleInput(data: string): void {
|
|
106
|
-
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
107
|
-
this.onClose();
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
render(width: number): string[] {
|
|
112
|
-
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
113
|
-
const th = this.theme;
|
|
114
|
-
const lines = ["", truncateToWidth(` ${th.fg("accent", "Plan tasks")} ${th.fg("muted", formatProgress(this.tasks))}`, width), ""];
|
|
115
|
-
if (this.tasks.length === 0) {
|
|
116
|
-
lines.push(truncateToWidth(` ${th.fg("dim", "No tasks found. Run /plan first.")}`, width));
|
|
117
|
-
} else {
|
|
118
|
-
for (const task of this.tasks) {
|
|
119
|
-
const check = task.done ? th.fg("success", "x") : th.fg("dim", " ");
|
|
120
|
-
const title = task.done ? th.fg("dim", task.title) : th.fg("text", task.title);
|
|
121
|
-
lines.push(truncateToWidth(` [${check}] ${th.fg("accent", String(task.id))}. ${title}`, width));
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
lines.push("", truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width), "");
|
|
125
|
-
this.cachedWidth = width;
|
|
126
|
-
this.cachedLines = lines;
|
|
127
|
-
return lines;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
invalidate(): void {
|
|
131
|
-
this.cachedWidth = undefined;
|
|
132
|
-
this.cachedLines = undefined;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
90
|
|
|
136
91
|
export default async function planTaskExtension(pi: ExtensionAPI): Promise<void> {
|
|
137
92
|
await ensureDefaultGlobalConfig();
|
|
138
93
|
|
|
139
94
|
let mode: Mode = "idle";
|
|
140
95
|
let continueAll = false;
|
|
96
|
+
let approvalEachTask = false;
|
|
97
|
+
let continueNew = false;
|
|
141
98
|
let currentTaskId: number | undefined;
|
|
142
|
-
let
|
|
99
|
+
let planToolsDelta: ToolDelta | undefined;
|
|
143
100
|
let awaitingChoice = false;
|
|
144
101
|
let planReadyNotified = false;
|
|
145
102
|
let planSource: PlanSource = EMPTY_PLAN_SOURCE;
|
|
146
103
|
let framing: FramingState = { ...INITIAL_FRAMING_STATE };
|
|
104
|
+
let planState = initialState();
|
|
105
|
+
let stateRestorable = true;
|
|
147
106
|
|
|
148
107
|
function resetFraming(): void {
|
|
149
108
|
framing = { ...INITIAL_FRAMING_STATE };
|
|
@@ -174,25 +133,26 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
174
133
|
}
|
|
175
134
|
|
|
176
135
|
async function applyPlanTools(ctx: ExtensionContext): Promise<void> {
|
|
177
|
-
if (
|
|
178
|
-
toolsBeforePlan = pi.getActiveTools();
|
|
179
|
-
}
|
|
136
|
+
if (planToolsDelta) return;
|
|
180
137
|
const config = await loadConfig(ctx.cwd);
|
|
181
|
-
|
|
138
|
+
const required = withAlwaysOnTools([...config.planTools, "write", "edit"]);
|
|
139
|
+
planToolsDelta = addTools(pi.getActiveTools(), required);
|
|
140
|
+
pi.setActiveTools(unique([...pi.getActiveTools(), ...required]));
|
|
182
141
|
}
|
|
183
142
|
|
|
143
|
+
|
|
184
144
|
function restoreTools(): void {
|
|
185
|
-
if (
|
|
186
|
-
pi.setActiveTools(
|
|
187
|
-
|
|
188
|
-
return;
|
|
145
|
+
if (planToolsDelta) {
|
|
146
|
+
pi.setActiveTools(removeAddedTools(pi.getActiveTools(), planToolsDelta));
|
|
147
|
+
planToolsDelta = undefined;
|
|
189
148
|
}
|
|
190
|
-
pi.setActiveTools(withAlwaysOnTools(pi.getActiveTools()));
|
|
191
149
|
}
|
|
192
150
|
|
|
193
151
|
async function enterPlanMode(ctx: ExtensionContext): Promise<void> {
|
|
194
152
|
mode = "plan";
|
|
153
|
+
if (planState.status !== "planning") planState = transition(planState, "planning");
|
|
195
154
|
continueAll = false;
|
|
155
|
+
continueNew = false;
|
|
196
156
|
currentTaskId = undefined;
|
|
197
157
|
planReadyNotified = false;
|
|
198
158
|
resetFraming();
|
|
@@ -212,6 +172,7 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
212
172
|
function leaveModes(ctx: ExtensionContext): void {
|
|
213
173
|
mode = "idle";
|
|
214
174
|
continueAll = false;
|
|
175
|
+
continueNew = false;
|
|
215
176
|
currentTaskId = undefined;
|
|
216
177
|
resetFraming();
|
|
217
178
|
restoreTools();
|
|
@@ -225,13 +186,50 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
225
186
|
leaveModes(ctx);
|
|
226
187
|
return false;
|
|
227
188
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
189
|
+
try {
|
|
190
|
+
const current = await readCurrentArtifacts(ctx.cwd);
|
|
191
|
+
assertApprovedWorkflow(planState, current.plan, current.task);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (planState.status !== "ready") planState = transition(planState, "ready");
|
|
194
|
+
planState = { ...planState, approvedStructureHash: undefined, currentTaskId: undefined, failureReason: error instanceof Error ? error.message : String(error) };
|
|
195
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
196
|
+
ctx.ui.notify(planState.failureReason ?? "Plan approval is no longer valid.", "error");
|
|
231
197
|
leaveModes(ctx);
|
|
232
198
|
return false;
|
|
233
199
|
}
|
|
200
|
+
const decision = classifyQueue(file, planState);
|
|
201
|
+
if (decision.kind === "all-complete") {
|
|
202
|
+
const allVerified = file.tasks.every((task) => planState.tasks?.[String(task.id)]?.status === "verified");
|
|
203
|
+
if (!allVerified) {
|
|
204
|
+
ctx.ui.notify("Checklist is complete but task verification state is inconsistent. Execution stopped.", "error");
|
|
205
|
+
leaveModes(ctx);
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
if (planState.status === "approved") planState = transition(planState, "executing");
|
|
209
|
+
planState = transition(planState, "completed");
|
|
210
|
+
planState = { ...planState, currentTaskId: undefined };
|
|
211
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
212
|
+
pi.events.emit("pi-plan-task:execution-finished", { version: 1, cwd: ctx.cwd, planPath: planFilePath(ctx.cwd), taskPath: taskFilePath(ctx.cwd), planHash: planState.approvedStructureHash, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
213
|
+
ctx.ui.notify("All tasks are verified and complete.", "info");
|
|
214
|
+
leaveModes(ctx);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
if (decision.kind === "all-remaining-blocked") {
|
|
218
|
+
if (planState.status !== "blocked") planState = transition(planState, "blocked");
|
|
219
|
+
planState = { ...planState, currentTaskId: undefined };
|
|
220
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
221
|
+
const reasons = decision.taskIds.map((id) => `${id}: ${planState.tasks?.[String(id)]?.reason ?? "blocked"}`).join("; ");
|
|
222
|
+
ctx.ui.notify(`Execution blocked. ${reasons}`, "warning");
|
|
223
|
+
leaveModes(ctx);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
const next = decision.task;
|
|
234
227
|
currentTaskId = next.id;
|
|
228
|
+
const existing = planState.tasks?.[String(next.id)];
|
|
229
|
+
planState = updateTaskState(planState, next.id, existing?.status === "implementation-complete" ? "implementation-complete" : "pending", { startedAt: new Date().toISOString() });
|
|
230
|
+
planState = { ...planState, currentTaskId: next.id };
|
|
231
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
232
|
+
pi.events.emit("pi-plan-task:task-started", { version: 1, cwd: ctx.cwd, planPath: planFilePath(ctx.cwd), taskPath: taskFilePath(ctx.cwd), planHash: planState.approvedStructureHash, taskId: next.id, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
235
233
|
updateStatus(ctx, file.tasks);
|
|
236
234
|
pi.sendUserMessage(buildRequest(next));
|
|
237
235
|
return true;
|
|
@@ -247,28 +245,73 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
247
245
|
}
|
|
248
246
|
const current = file.tasks.find((task) => task.id === currentTaskId);
|
|
249
247
|
updateStatus(ctx, file.tasks);
|
|
248
|
+
const runtime = planState.tasks?.[String(currentTaskId)];
|
|
249
|
+
if (runtime?.status === "blocked") {
|
|
250
|
+
if (continueAll) { await startNextTask(ctx); return; }
|
|
251
|
+
if (continueNew) {
|
|
252
|
+
const decision = classifyQueue(file, planState);
|
|
253
|
+
if (decision.kind === "runnable") {
|
|
254
|
+
mode = "idle"; currentTaskId = undefined; resetFraming(); updateStatus(ctx);
|
|
255
|
+
pi.sendUserMessage("/build new", { expandPromptTemplates: true });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
await startNextTask(ctx);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
ctx.ui.notify(`Task ${currentTaskId} is blocked: ${runtime.reason ?? "no reason provided"}`, "warning");
|
|
262
|
+
leaveModes(ctx);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
250
265
|
if (!current?.done) {
|
|
251
|
-
|
|
252
|
-
`Task ${currentTaskId} is
|
|
253
|
-
|
|
254
|
-
);
|
|
266
|
+
const message = runtime?.status === "implementation-complete"
|
|
267
|
+
? `Task ${currentTaskId} implementation is complete but verification is pending. Call plan_task verify with the verification result.`
|
|
268
|
+
: `Task ${currentTaskId} is still pending. Complete the implementation before continuing.`;
|
|
269
|
+
ctx.ui.notify(message, "warning");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (runtime?.status !== "verified") {
|
|
273
|
+
ctx.ui.notify(`Task ${currentTaskId} checklist/state mismatch: task is checked but not verified.`, "error");
|
|
274
|
+
leaveModes(ctx);
|
|
255
275
|
return;
|
|
256
276
|
}
|
|
257
277
|
if (file.tasks.every((task) => task.done)) {
|
|
258
|
-
|
|
278
|
+
const allVerified = file.tasks.every((task) => planState.tasks?.[String(task.id)]?.status === "verified");
|
|
279
|
+
if (!allVerified) { ctx.ui.notify("Not all completed tasks are verified.", "error"); leaveModes(ctx); return; }
|
|
280
|
+
ctx.ui.notify("All tasks are verified and complete.", "info");
|
|
281
|
+
planState = transition(planState, "completed");
|
|
282
|
+
planState = { ...planState, currentTaskId: undefined };
|
|
283
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
284
|
+
pi.events.emit("pi-plan-task:execution-finished", { version: 1, cwd: ctx.cwd, planPath: planFilePath(ctx.cwd), taskPath: taskFilePath(ctx.cwd), planHash: planState.approvedStructureHash, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
259
285
|
leaveModes(ctx);
|
|
260
286
|
return;
|
|
261
287
|
}
|
|
288
|
+
if (approvalEachTask) {
|
|
289
|
+
if (!ctx.hasUI) {
|
|
290
|
+
ctx.ui.notify("Task complete. Per-task approval requires UI; run /build to continue.", "info");
|
|
291
|
+
leaveModes(ctx);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
awaitingChoice = true;
|
|
295
|
+
const review = await ctx.ui.select(`Task ${currentTaskId} verified. What next?`, ["Continue to next task", "Stop execution"]);
|
|
296
|
+
awaitingChoice = false;
|
|
297
|
+
if (review !== "Continue to next task") { leaveModes(ctx); return; }
|
|
298
|
+
}
|
|
262
299
|
if (continueAll) {
|
|
263
300
|
await startNextTask(ctx);
|
|
264
301
|
return;
|
|
265
302
|
}
|
|
266
|
-
if (
|
|
267
|
-
ctx.ui.notify("Task complete. Run /build for the next task.", "info");
|
|
303
|
+
if (continueNew) {
|
|
268
304
|
mode = "idle";
|
|
269
305
|
currentTaskId = undefined;
|
|
270
306
|
resetFraming();
|
|
271
307
|
updateStatus(ctx);
|
|
308
|
+
ctx.ui.notify("Task complete. Opening a new session for the next task.", "info");
|
|
309
|
+
pi.sendUserMessage("/build new", { expandPromptTemplates: true });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!ctx.hasUI) {
|
|
313
|
+
ctx.ui.notify("Task complete. Run /build for the next task.", "info");
|
|
314
|
+
leaveModes(ctx);
|
|
272
315
|
return;
|
|
273
316
|
}
|
|
274
317
|
awaitingChoice = true;
|
|
@@ -283,7 +326,7 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
283
326
|
currentTaskId = undefined;
|
|
284
327
|
resetFraming();
|
|
285
328
|
updateStatus(ctx);
|
|
286
|
-
pi.sendUserMessage("/build
|
|
329
|
+
pi.sendUserMessage("/build new", { expandPromptTemplates: true });
|
|
287
330
|
return;
|
|
288
331
|
}
|
|
289
332
|
mode = "idle";
|
|
@@ -298,37 +341,60 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
298
341
|
description: "Read plan progress or mark a planned task complete in .plan_task/task.md",
|
|
299
342
|
promptSnippet: "Mark planned tasks complete and read .plan_task progress",
|
|
300
343
|
parameters: Type.Object({
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
344
|
+
action: StringEnum(["status", "complete", "verify", "block", "unblock"] as const),
|
|
345
|
+
id: Type.Optional(Type.Number({ description: "Task id to update" })),
|
|
346
|
+
reason: Type.Optional(Type.String({ description: "Reason for blocking or verification result" })),
|
|
347
|
+
}),
|
|
304
348
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
305
349
|
const file = await loadTaskFile(ctx.cwd);
|
|
306
|
-
if (!file) {
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
350
|
+
if (!file) return { content: [{ type: "text", text: "No .plan_task/task.md found. Run /plan first." }], details: {} };
|
|
351
|
+
if (params.action === "status") return { content: [{ type: "text", text: formatTaskList(file.tasks) }], details: { tasks: file.tasks } };
|
|
352
|
+
if (params.id === undefined) return { content: [{ type: "text", text: "id is required" }], details: {} };
|
|
353
|
+
const id = params.id;
|
|
354
|
+
let eventName: string | undefined;
|
|
355
|
+
let resultText = "";
|
|
356
|
+
let approvalRevoked = false;
|
|
357
|
+
try {
|
|
358
|
+
const result = await mutateWorkflow(ctx.cwd, ({ state, plan, task }) => {
|
|
359
|
+
try { assertApprovedWorkflow(state, plan, task); } catch (error) {
|
|
360
|
+
approvalRevoked = true;
|
|
361
|
+
const ready = transition(state, "ready");
|
|
362
|
+
return { state: { ...ready, approvedStructureHash: undefined, currentTaskId: undefined, failureReason: error instanceof Error ? error.message : String(error) } };
|
|
363
|
+
}
|
|
364
|
+
const parsed = { raw: task, tasks: parseTaskMarkdown(task) };
|
|
365
|
+
let mutation;
|
|
366
|
+
if (params.action === "complete") {
|
|
367
|
+
mutation = completeCurrentTask(state, parsed, id);
|
|
368
|
+
eventName = "pi-plan-task:task-completed";
|
|
369
|
+
resultText = `Task ${id} implementation complete. Verification is required before it can advance.`;
|
|
370
|
+
} else if (params.action === "verify") {
|
|
371
|
+
mutation = verifyCurrentTask(state, parsed, id, params.reason);
|
|
372
|
+
eventName = "pi-plan-task:task-verified";
|
|
373
|
+
} else if (params.action === "block") {
|
|
374
|
+
mutation = blockCurrentTask(state, parsed, id, params.reason);
|
|
375
|
+
eventName = "pi-plan-task:task-blocked";
|
|
376
|
+
resultText = `Task ${id} blocked: ${params.reason?.trim() ?? ""}`;
|
|
377
|
+
} else {
|
|
378
|
+
mutation = unblockTask(state, parsed, id);
|
|
379
|
+
}
|
|
380
|
+
if (!mutation.ok) throw new Error(mutation.error);
|
|
381
|
+
if (params.action === "verify") resultText = `Task ${id} verified. Progress ${formatProgress(parseTaskMarkdown(mutation.value.task))}.`;
|
|
382
|
+
if (params.action === "unblock") resultText = `Task ${id} unblocked and restored to ${mutation.value.state.tasks?.[String(id)]?.status}.`;
|
|
383
|
+
return { state: mutation.value.state, task: mutation.value.task };
|
|
384
|
+
});
|
|
385
|
+
planState = result.state;
|
|
386
|
+
if (approvalRevoked) {
|
|
387
|
+
currentTaskId = undefined;
|
|
388
|
+
leaveModes(ctx);
|
|
389
|
+
throw new Error(planState.failureReason ?? "Plan approval was revoked.");
|
|
390
|
+
}
|
|
391
|
+
const nextTasks = parseTaskMarkdown(result.task);
|
|
392
|
+
updateStatus(ctx, nextTasks);
|
|
393
|
+
if (eventName) pi.events.emit(eventName, { version: 1, cwd: ctx.cwd, planPath: planFilePath(ctx.cwd), taskPath: taskFilePath(ctx.cwd), planHash: planState.approvedStructureHash, taskId: id, reason: params.reason, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
394
|
+
return { content: [{ type: "text", text: resultText }], details: { taskId: id, status: planState.tasks?.[String(id)]?.status, tasks: nextTasks } };
|
|
395
|
+
} catch (error) {
|
|
396
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
318
397
|
}
|
|
319
|
-
const task = next.tasks.find((item) => item.id === params.id);
|
|
320
|
-
updateStatus(ctx, next.tasks);
|
|
321
|
-
return {
|
|
322
|
-
content: [
|
|
323
|
-
{
|
|
324
|
-
type: "text",
|
|
325
|
-
text: task?.done
|
|
326
|
-
? `Marked task ${params.id} complete. Progress ${formatProgress(next.tasks)}.`
|
|
327
|
-
: `Task ${params.id} was not found.`,
|
|
328
|
-
},
|
|
329
|
-
],
|
|
330
|
-
details: { tasks: next.tasks, completedId: params.id },
|
|
331
|
-
};
|
|
332
398
|
},
|
|
333
399
|
renderCall(args, theme) {
|
|
334
400
|
const suffix = args.id === undefined ? "" : ` #${args.id}`;
|
|
@@ -336,85 +402,326 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
336
402
|
},
|
|
337
403
|
});
|
|
338
404
|
|
|
405
|
+
async function startPlanRequest(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
406
|
+
if (mode === "build") {
|
|
407
|
+
const ok = ctx.hasUI
|
|
408
|
+
? await ctx.ui.confirm("Switch to plan mode?", "A build is in progress. Stop it and start planning?")
|
|
409
|
+
: true;
|
|
410
|
+
if (!ok) return;
|
|
411
|
+
}
|
|
412
|
+
const loaded = await loadPlanSource(args, ctx.cwd);
|
|
413
|
+
if (!loaded.ok) {
|
|
414
|
+
ctx.ui.notify(loaded.error, "error");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
let existingTask: string | undefined;
|
|
418
|
+
let existingPlan: string | undefined;
|
|
419
|
+
try {
|
|
420
|
+
existingTask = await readOptionalFile(taskFilePath(ctx.cwd));
|
|
421
|
+
existingPlan = await readOptionalFile(planFilePath(ctx.cwd));
|
|
422
|
+
} catch (error) {
|
|
423
|
+
ctx.ui.notify(`Could not inspect existing plan files: ${String(error)}`, "error");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const hasExistingWork = (existingTask !== undefined && hasPendingTasks(existingTask, true)) || (existingPlan !== undefined && hasPendingTasks(existingPlan, false));
|
|
427
|
+
if (hasExistingWork) {
|
|
428
|
+
if (!ctx.hasUI) { ctx.ui.notify("An incomplete plan already exists. Refusing to replace it in non-interactive mode.", "error"); return; }
|
|
429
|
+
const ok = await ctx.ui.confirm("Start a different plan?", "The current work is incomplete. It will be snapshotted only when the new draft validates successfully.");
|
|
430
|
+
if (!ok) return;
|
|
431
|
+
}
|
|
432
|
+
stateRestorable = true;
|
|
433
|
+
const config = await loadConfig(ctx.cwd);
|
|
434
|
+
planState = {
|
|
435
|
+
...initialState(config.executionMode),
|
|
436
|
+
status: "planning",
|
|
437
|
+
workId: contentKey(`${ctx.cwd}:${planRequest(loaded.source)}:${Date.now()}`).slice(0, 16),
|
|
438
|
+
planningRequest: planRequest(loaded.source),
|
|
439
|
+
planningBaselineHash: structureHash("", ""),
|
|
440
|
+
};
|
|
441
|
+
planState = await initializeDraftWithState(ctx.cwd, planState);
|
|
442
|
+
planSource = loaded.source;
|
|
443
|
+
await enterPlanMode(ctx);
|
|
444
|
+
const message = loaded.source.kind === "file"
|
|
445
|
+
? `Planning from ${loaded.source.displayPath}. Only draft plan files are writable.`
|
|
446
|
+
: "Plan mode enabled. Only draft plan files are writable.";
|
|
447
|
+
ctx.ui.notify(message, "info");
|
|
448
|
+
pi.sendUserMessage(planRequest(loaded.source));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function approvePlan(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
452
|
+
const persisted = await loadStateLocked(ctx.cwd);
|
|
453
|
+
if (persisted) planState = persisted;
|
|
454
|
+
const selected = args.trim() || (ctx.hasUI ? ((await ctx.ui.select("Approve plan: choose next action", ["current", "new", "fresh", "revise", "cancel"])) ?? "cancel") : "current");
|
|
455
|
+
const choice = selected.toLowerCase();
|
|
456
|
+
const revisionAllowed = choice === "revise" && ["ready", "approved", "blocked", "completed"].includes(planState.status);
|
|
457
|
+
if (planState.status !== "ready" && !revisionAllowed) { ctx.ui.notify(`Plan is not awaiting approval (status: ${planState.status}).`, "warning"); return; }
|
|
458
|
+
if (!["current", "new", "fresh", "revise", "cancel"].includes(choice)) {
|
|
459
|
+
ctx.ui.notify("Usage: /plan approve [current|new|fresh]", "error");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const approvalConfig = await loadConfig(ctx.cwd);
|
|
463
|
+
if (approvalConfig.executionMode === "external" && (choice === "new" || choice === "fresh")) {
|
|
464
|
+
ctx.ui.notify("External execution mode does not start local sessions.", "warning");
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (choice === "cancel") {
|
|
468
|
+
planState = transition(planState, "idle");
|
|
469
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
470
|
+
leaveModes(ctx);
|
|
471
|
+
ctx.ui.notify("Plan approval cancelled.", "info");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (choice === "revise") {
|
|
475
|
+
const feedback = ctx.hasUI ? await ctx.ui.input("Revision feedback", "Explain what should change") : "";
|
|
476
|
+
if (!feedback?.trim()) { ctx.ui.notify("Revision cancelled; current plan is unchanged.", "info"); return; }
|
|
477
|
+
const current = await readCurrentArtifacts(ctx.cwd);
|
|
478
|
+
planState = transition(planState, "planning");
|
|
479
|
+
planState = { ...planState, planningRequest: `Revise the current plan: ${feedback.trim()}`, planningBaselineHash: structureHash(current.plan, current.task), approvedStructureHash: undefined, failureReason: feedback.trim() };
|
|
480
|
+
planState = await initializeDraftWithState(ctx.cwd, planState, current);
|
|
481
|
+
await enterPlanMode(ctx);
|
|
482
|
+
pi.sendUserMessage(`Revise the current draft with this feedback:\n${feedback.trim()}`);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const current = await readCurrentArtifacts(ctx.cwd);
|
|
486
|
+
const parsed = parseTaskMarkdown(current.task);
|
|
487
|
+
const validation = validatePlan(current.plan, current.task, parsed);
|
|
488
|
+
if (!validation.valid) { ctx.ui.notify(`Plan validation failed: ${validation.errors.join("; ")}`, "error"); return; }
|
|
489
|
+
const approvedStructureHash = structureHash(current.plan, current.task);
|
|
490
|
+
planState = transition(planState, "approved");
|
|
491
|
+
planState = { ...planState, approvedStructureHash, draftStructureHash: approvedStructureHash, executionMode: approvalConfig.executionMode, failureReason: undefined };
|
|
492
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
493
|
+
const payload = { version: 1, cwd: ctx.cwd, planPath: planFilePath(ctx.cwd), taskPath: taskFilePath(ctx.cwd), planHash: approvedStructureHash, sessionFile: ctx.sessionManager.getSessionFile() };
|
|
494
|
+
pi.events.emit("pi-plan-task:plan-approved", payload);
|
|
495
|
+
if (approvalConfig.executionMode === "external") {
|
|
496
|
+
leaveModes(ctx);
|
|
497
|
+
ctx.ui.notify("Plan approved. External execution event emitted.", "info");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (choice === "new" || choice === "fresh") {
|
|
501
|
+
await startBuildInNewSession(ctx, BUILD_ONE_HERE_COMMAND, choice === "fresh");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
await beginBuild(ctx, false, "here");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function rejectPlan(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
508
|
+
const persisted = await loadStateLocked(ctx.cwd);
|
|
509
|
+
if (persisted) planState = persisted;
|
|
510
|
+
if (planState.status !== "ready") { ctx.ui.notify(`Plan is not awaiting review (status: ${planState.status}).`, "warning"); return; }
|
|
511
|
+
const feedback = args.trim() || (ctx.hasUI ? await ctx.ui.input("Plan feedback", "Explain what should change") : "");
|
|
512
|
+
const current = await readCurrentArtifacts(ctx.cwd);
|
|
513
|
+
planState = transition(planState, "planning");
|
|
514
|
+
planState = { ...planState, planningRequest: `Revise the current plan: ${feedback || "Plan rejected without feedback."}`, planningBaselineHash: structureHash(current.plan, current.task), approvedStructureHash: undefined, failureReason: feedback || "Plan rejected without feedback." };
|
|
515
|
+
planState = await initializeDraftWithState(ctx.cwd, planState, current);
|
|
516
|
+
pi.events.emit("pi-plan-task:plan-rejected", { version: 1, cwd: ctx.cwd, planPath: planFilePath(ctx.cwd), taskPath: taskFilePath(ctx.cwd), planHash: planState.draftStructureHash, feedback, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
517
|
+
await enterPlanMode(ctx);
|
|
518
|
+
ctx.ui.notify("Plan rejected. Revise the draft and submit it again.", "info");
|
|
519
|
+
if (feedback) pi.sendUserMessage(`Revise the current draft with this review feedback:\n${feedback}`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function showPlanStatus(ctx: ExtensionCommandContext): Promise<void> {
|
|
523
|
+
const persisted = await loadStateLocked(ctx.cwd);
|
|
524
|
+
const file = await loadTaskFile(ctx.cwd);
|
|
525
|
+
ctx.ui.notify(`Plan status: ${persisted?.status ?? "idle"}\nApproved hash: ${persisted?.approvedStructureHash ?? "none"}\nWork id: ${persisted?.workId ?? "none"}\nProgress: ${file ? formatProgress(file.tasks) : "0/0"}\nCurrent task: ${persisted?.currentTaskId ?? "none"}`, "info");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function showPlanHistory(ctx: ExtensionCommandContext): Promise<void> {
|
|
529
|
+
const items = await listPlanSnapshots(ctx.cwd);
|
|
530
|
+
ctx.ui.notify(items.length ? items.map((item) => `v${item.version} ${item.createdAt}`).join("\n") : "No plan history.", "info");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function showPlanDiff(ctx: ExtensionCommandContext): Promise<void> {
|
|
534
|
+
const items = await listPlanSnapshots(ctx.cwd);
|
|
535
|
+
if (!items.length) { ctx.ui.notify("No plan history.", "info"); return; }
|
|
536
|
+
const currentPlan = (await readOptionalFile(planFilePath(ctx.cwd))) ?? "";
|
|
537
|
+
const currentTask = (await readOptionalFile(taskFilePath(ctx.cwd))) ?? "";
|
|
538
|
+
ctx.ui.notify(textDiff(`${items.at(-1)!.plan}\n${items.at(-1)!.task}`, `${currentPlan}\n${currentTask}`) || "No changes.", "info");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function reviewPlan(ctx: ExtensionCommandContext): Promise<void> {
|
|
542
|
+
try {
|
|
543
|
+
const result = await pi.exec("plannotator", ["annotate", planFilePath(ctx.cwd), "--markdown", "--json"]);
|
|
544
|
+
const review = normalizeReviewResult(result.stdout.trim());
|
|
545
|
+
if (review?.decision === "approved") { pi.sendUserMessage("/plan approve current", { expandPromptTemplates: true }); return; }
|
|
546
|
+
if (review?.decision === "rejected" && review.feedback) { pi.sendUserMessage(`/plan reject ${review.feedback}`, { expandPromptTemplates: true }); return; }
|
|
547
|
+
if (result.code !== 0) ctx.ui.notify("Plannotator is unavailable or review failed; use /plan approve or /plan reject.", "warning");
|
|
548
|
+
} catch {
|
|
549
|
+
ctx.ui.notify("Plannotator is unavailable; use /plan approve or /plan reject.", "warning");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const planSubcommands = ["review", "approve", "reject", "revise", "status", "history", "diff", "request"];
|
|
339
554
|
pi.registerCommand("plan", {
|
|
340
|
-
description: "
|
|
555
|
+
description: "Create, review, approve, reject, or inspect a plan",
|
|
556
|
+
getArgumentCompletions: (prefix) => {
|
|
557
|
+
const items = planSubcommands.map((name) => ({ value: name, label: name }));
|
|
558
|
+
const filtered = items.filter((item) => item.value.startsWith(prefix.trim().toLowerCase()));
|
|
559
|
+
return filtered.length ? filtered : null;
|
|
560
|
+
},
|
|
341
561
|
handler: async (args, ctx) => {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
562
|
+
const trimmed = args.trim();
|
|
563
|
+
const [rawAction = "", ...rest] = trimmed.split(/\s+/);
|
|
564
|
+
const action = rawAction.toLowerCase();
|
|
565
|
+
const actionArgs = rest.join(" ");
|
|
566
|
+
switch (action) {
|
|
567
|
+
case "review": await reviewPlan(ctx); return;
|
|
568
|
+
case "approve": await approvePlan(actionArgs, ctx); return;
|
|
569
|
+
case "reject": await rejectPlan(actionArgs, ctx); return;
|
|
570
|
+
case "revise": await approvePlan("revise", ctx); return;
|
|
571
|
+
case "status": await showPlanStatus(ctx); return;
|
|
572
|
+
case "history": await showPlanHistory(ctx); return;
|
|
573
|
+
case "diff": await showPlanDiff(ctx); return;
|
|
574
|
+
case "request": await startPlanRequest(actionArgs, ctx); return;
|
|
575
|
+
default: await startPlanRequest(args, ctx);
|
|
347
576
|
}
|
|
348
|
-
const loaded = await loadPlanSource(args, ctx.cwd);
|
|
349
|
-
if (!loaded.ok) {
|
|
350
|
-
ctx.ui.notify(loaded.error, "error");
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
const existing = await readOptionalFile(taskFilePath(ctx.cwd));
|
|
354
|
-
if (existing && ctx.hasUI) {
|
|
355
|
-
const ok = await ctx.ui.confirm("Overwrite existing plan?", ".plan_task already has a task list. Overwrite it?");
|
|
356
|
-
if (!ok) return;
|
|
357
|
-
}
|
|
358
|
-
planSource = loaded.source;
|
|
359
|
-
await enterPlanMode(ctx);
|
|
360
|
-
const message =
|
|
361
|
-
loaded.source.kind === "file"
|
|
362
|
-
? `Planning from ${loaded.source.displayPath}. Project writes are blocked.`
|
|
363
|
-
: "Plan mode enabled. Project writes are blocked.";
|
|
364
|
-
ctx.ui.notify(message, "info");
|
|
365
|
-
pi.sendUserMessage(planRequest(loaded.source));
|
|
366
577
|
},
|
|
367
578
|
});
|
|
368
579
|
|
|
369
580
|
async function beginBuild(
|
|
370
581
|
ctx: ExtensionCommandContext,
|
|
371
582
|
runAll: boolean,
|
|
372
|
-
placement: BuildPlacement = "
|
|
583
|
+
placement: BuildPlacement = "here",
|
|
584
|
+
options: { chainNew?: boolean; approvalEachTask?: boolean } = {},
|
|
373
585
|
): Promise<void> {
|
|
374
|
-
const
|
|
375
|
-
if (!
|
|
376
|
-
|
|
586
|
+
const persisted = await loadStateLocked(ctx.cwd);
|
|
587
|
+
if (!persisted) { ctx.ui.notify("No plan state found. Run /plan first.", "error"); return; }
|
|
588
|
+
planState = persisted;
|
|
589
|
+
const executionConfig = await loadConfig(ctx.cwd);
|
|
590
|
+
if (executionConfig.executionMode === "external") { ctx.ui.notify("External execution mode is enabled; waiting for an external executor.", "info"); return; }
|
|
591
|
+
if (planState.status !== "approved" && planState.status !== "executing") {
|
|
592
|
+
ctx.ui.notify(`Plan must be approved before execution (status: ${planState.status}). Use /plan approve.`, "error");
|
|
377
593
|
return;
|
|
378
594
|
}
|
|
379
|
-
|
|
380
|
-
|
|
595
|
+
const file = await loadTaskFile(ctx.cwd);
|
|
596
|
+
if (!file || file.tasks.length === 0) { ctx.ui.notify("A valid task.md with checklist tasks is required.", "error"); return; }
|
|
597
|
+
if (!planState.approvedStructureHash) { ctx.ui.notify("Plan has no approved structure hash. Re-approve it.", "error"); return; }
|
|
598
|
+
try {
|
|
599
|
+
const current = await readCurrentArtifacts(ctx.cwd);
|
|
600
|
+
assertApprovedWorkflow(planState, current.plan, current.task);
|
|
601
|
+
} catch (error) {
|
|
602
|
+
planState = transition(planState, "ready");
|
|
603
|
+
planState = { ...planState, approvedStructureHash: undefined, currentTaskId: undefined, failureReason: error instanceof Error ? error.message : String(error) };
|
|
604
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
605
|
+
ctx.ui.notify("Plan files or contract changed after approval. Review and approve again.", "error");
|
|
381
606
|
return;
|
|
382
607
|
}
|
|
383
|
-
|
|
384
|
-
|
|
608
|
+
const queue = classifyQueue(file, planState);
|
|
609
|
+
if (queue.kind === "all-complete") { await enterBuildMode(ctx); await startNextTask(ctx); return; }
|
|
610
|
+
if (queue.kind === "all-remaining-blocked") { await enterBuildMode(ctx); await startNextTask(ctx); return; }
|
|
611
|
+
|
|
612
|
+
const continuingAllNew = planState.continueMode === "all-new";
|
|
613
|
+
const persistedAllNew = continuingAllNew && placement === "here" && !runAll;
|
|
614
|
+
const chainNew = options.chainNew === true || persistedAllNew;
|
|
615
|
+
const approval = options.approvalEachTask === true || (continuingAllNew && planState.approvalEachTask === true);
|
|
616
|
+
const continueMode: PlanState["continueMode"] = chainNew ? "all-new" : runAll ? "all-here" : "single";
|
|
617
|
+
|
|
618
|
+
if (placement === "new" || placement === "fresh") {
|
|
619
|
+
const command = buildHandoffCommand({ scope: runAll ? "all" : "one", placement, approval });
|
|
620
|
+
const pendingState: PlanState = { ...planState, continueMode: continuingAllNew || (runAll && placement === "new") ? "all-new" : runAll ? "all-here" : "single", approvalEachTask: approval, currentTaskId: undefined };
|
|
621
|
+
planState = pendingState;
|
|
622
|
+
currentTaskId = undefined;
|
|
623
|
+
await saveStateLocked(ctx.cwd, pendingState);
|
|
624
|
+
const started = await startBuildInNewSession(ctx, command, placement === "fresh");
|
|
625
|
+
if (!started) { planState = { ...pendingState, continueMode: undefined, approvalEachTask: false }; await saveStateLocked(ctx.cwd, planState); }
|
|
385
626
|
return;
|
|
386
627
|
}
|
|
387
|
-
if (
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
continueAll = runAll;
|
|
628
|
+
if (planState.status === "approved") planState = transition(planState, "executing");
|
|
629
|
+
planState = { ...planState, currentTaskId: undefined, approvalEachTask: approval, continueMode };
|
|
630
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
631
|
+
continueAll = runAll && !chainNew;
|
|
632
|
+
approvalEachTask = approval;
|
|
633
|
+
continueNew = chainNew;
|
|
396
634
|
await enterBuildMode(ctx);
|
|
397
|
-
ctx.ui.notify(runAll ? "Building remaining tasks." : "Building the next task.", "info");
|
|
635
|
+
ctx.ui.notify(chainNew ? "Building the next task; remaining tasks will use new sessions." : runAll ? "Building remaining tasks." : "Building the next task.", "info");
|
|
398
636
|
await startNextTask(ctx);
|
|
399
637
|
}
|
|
400
638
|
|
|
639
|
+
const buildSubcommands = ["all", "new", "fresh", "--approval"];
|
|
401
640
|
pi.registerCommand("build", {
|
|
402
|
-
description: "
|
|
641
|
+
description: "Execute one task or all remaining tasks, here or in a new session",
|
|
642
|
+
getArgumentCompletions: (prefix) => {
|
|
643
|
+
const token = prefix.trim().split(/\s+/).at(-1)?.toLowerCase() ?? "";
|
|
644
|
+
const items = buildSubcommands.map((name) => ({ value: name, label: name }));
|
|
645
|
+
const filtered = items.filter((item) => item.value.startsWith(token));
|
|
646
|
+
return filtered.length ? filtered : null;
|
|
647
|
+
},
|
|
403
648
|
handler: async (args, ctx) => {
|
|
404
|
-
|
|
649
|
+
const parsed = parseBuildOptions(args);
|
|
650
|
+
if (!parsed.ok) { ctx.ui.notify(parsed.error, "error"); return; }
|
|
651
|
+
const options = parsed.options;
|
|
652
|
+
await beginBuild(
|
|
653
|
+
ctx,
|
|
654
|
+
options.scope === "all",
|
|
655
|
+
options.placement,
|
|
656
|
+
{
|
|
657
|
+
chainNew: options.scope === "all" && options.placement === "new",
|
|
658
|
+
approvalEachTask: options.scope === "all" && options.approval,
|
|
659
|
+
},
|
|
660
|
+
);
|
|
405
661
|
},
|
|
406
662
|
});
|
|
407
663
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
664
|
+
async function reviewTask(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
665
|
+
const file = await loadTaskFile(ctx.cwd);
|
|
666
|
+
const requestedId = Number(args.trim());
|
|
667
|
+
const task = Number.isFinite(requestedId) && requestedId > 0
|
|
668
|
+
? file?.tasks.find((item) => item.id === requestedId)
|
|
669
|
+
: currentTaskId === undefined
|
|
670
|
+
? file?.tasks.find((item) => !item.done)
|
|
671
|
+
: file?.tasks.find((item) => item.id === currentTaskId);
|
|
672
|
+
if (!task) { ctx.ui.notify("No matching task to review.", "info"); return; }
|
|
673
|
+
const sections: string[] = [];
|
|
674
|
+
try {
|
|
675
|
+
const [unstaged, staged, status] = await Promise.all([
|
|
676
|
+
pi.exec("git", ["diff", "--", "."]),
|
|
677
|
+
pi.exec("git", ["diff", "--cached", "--", "."]),
|
|
678
|
+
pi.exec("git", ["status", "--short"]),
|
|
679
|
+
]);
|
|
680
|
+
if (unstaged.stdout.trim()) sections.push(`Unstaged:\n${unstaged.stdout}`);
|
|
681
|
+
if (staged.stdout.trim()) sections.push(`Staged:\n${staged.stdout}`);
|
|
682
|
+
const untracked = status.stdout.split(/\r?\n/).filter((line) => line.startsWith("??")).join("\n");
|
|
683
|
+
if (untracked) sections.push(`Untracked:\n${untracked}`);
|
|
684
|
+
} catch { sections.push("(no git diff available)"); }
|
|
685
|
+
const full = `Task ${task.id}: ${task.title}\n\n${task.body}\n\nWorking tree:\n${sections.join("\n\n") || "(clean)"}`;
|
|
686
|
+
const lines = full.split(/\r?\n/);
|
|
687
|
+
let output = lines.slice(0, 2000).join("\n");
|
|
688
|
+
if (Buffer.byteLength(output, "utf8") > 50_000) output = Buffer.from(output, "utf8").subarray(0, 50_000).toString("utf8");
|
|
689
|
+
if (output.length < full.length) output += "\n\n[Output truncated. Use git diff, git diff --cached, and git status --short for full details.]";
|
|
690
|
+
ctx.ui.notify(output, "info");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function reworkTask(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
694
|
+
const id = Number(args.trim());
|
|
695
|
+
if (!Number.isInteger(id) || id <= 0) { ctx.ui.notify("Provide a positive task id.", "error"); return; }
|
|
696
|
+
try {
|
|
697
|
+
const result = await mutateWorkflow(ctx.cwd, ({ state, task }) => {
|
|
698
|
+
const file = { raw: task, tasks: parseTaskMarkdown(task) };
|
|
699
|
+
const mutation = reworkFromTask(state, file, id);
|
|
700
|
+
if (!mutation.ok) throw new Error(mutation.error);
|
|
701
|
+
return { state: mutation.value.state, task: mutation.value.task };
|
|
702
|
+
});
|
|
703
|
+
planState = result.state;
|
|
704
|
+
leaveModes(ctx);
|
|
705
|
+
ctx.ui.notify(`Task ${id} and all later tasks reopened. Run /plan approve before building.`, "info");
|
|
706
|
+
} catch (error) {
|
|
707
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
708
|
+
}
|
|
709
|
+
}
|
|
414
710
|
|
|
415
711
|
pi.registerCommand("tasks", {
|
|
416
|
-
description: "Show
|
|
417
|
-
|
|
712
|
+
description: "Show tasks, review a task, or reopen a task for rework",
|
|
713
|
+
getArgumentCompletions: (prefix) => {
|
|
714
|
+
const items = ["review", "rework"].map((name) => ({ value: name, label: name }));
|
|
715
|
+
const filtered = items.filter((item) => item.value.startsWith(prefix.trim().toLowerCase()));
|
|
716
|
+
return filtered.length ? filtered : null;
|
|
717
|
+
},
|
|
718
|
+
handler: async (args, ctx) => {
|
|
719
|
+
const [rawAction = "", ...rest] = args.trim().split(/\s+/);
|
|
720
|
+
const action = rawAction.toLowerCase();
|
|
721
|
+
const actionArgs = rest.join(" ");
|
|
722
|
+
if (action === "review") { await reviewTask(actionArgs, ctx); return; }
|
|
723
|
+
if (action === "rework") { await reworkTask(actionArgs, ctx); return; }
|
|
724
|
+
if (action) { ctx.ui.notify("Usage: /tasks [review [id]|rework <id>]", "error"); return; }
|
|
418
725
|
const file = await loadTaskFile(ctx.cwd);
|
|
419
726
|
const tasks = file?.tasks ?? [];
|
|
420
727
|
if (ctx.mode !== "tui") {
|
|
@@ -425,37 +732,67 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
425
732
|
},
|
|
426
733
|
});
|
|
427
734
|
|
|
428
|
-
pi.registerCommand("build-next-session", {
|
|
429
|
-
description: "Continue the next planned task in a new session",
|
|
430
|
-
handler: async (_args, ctx) => {
|
|
431
|
-
await startBuildInNewSession(ctx);
|
|
432
|
-
},
|
|
433
|
-
});
|
|
434
|
-
|
|
435
735
|
pi.on("session_start", async (_event, ctx) => {
|
|
436
736
|
mode = "idle";
|
|
437
737
|
continueAll = false;
|
|
738
|
+
continueNew = false;
|
|
438
739
|
currentTaskId = undefined;
|
|
439
|
-
|
|
740
|
+
planToolsDelta = undefined;
|
|
440
741
|
awaitingChoice = false;
|
|
441
742
|
planReadyNotified = false;
|
|
442
743
|
planSource = EMPTY_PLAN_SOURCE;
|
|
443
744
|
resetFraming();
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
ctx.
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
745
|
+
stateRestorable = true;
|
|
746
|
+
try {
|
|
747
|
+
const persisted = await loadStateLocked(ctx.cwd);
|
|
748
|
+
if (persisted) {
|
|
749
|
+
planState = persisted;
|
|
750
|
+
currentTaskId = persisted.currentTaskId;
|
|
751
|
+
approvalEachTask = persisted.approvalEachTask === true;
|
|
752
|
+
continueAll = persisted.continueMode === "all-here";
|
|
753
|
+
continueNew = persisted.continueMode === "all-new";
|
|
754
|
+
if (["approved", "executing", "blocked"].includes(persisted.status) && persisted.approvedStructureHash) {
|
|
755
|
+
try {
|
|
756
|
+
const current = await readCurrentArtifacts(ctx.cwd);
|
|
757
|
+
assertApprovedWorkflow(persisted, current.plan, current.task);
|
|
758
|
+
if (persisted.status === "executing" && persisted.currentTaskId !== undefined) await enterBuildMode(ctx);
|
|
759
|
+
} catch (error) {
|
|
760
|
+
planState = transition(persisted, "ready");
|
|
761
|
+
planState = { ...planState, approvedStructureHash: undefined, currentTaskId: undefined, failureReason: error instanceof Error ? error.message : String(error) };
|
|
762
|
+
currentTaskId = undefined;
|
|
763
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
764
|
+
ctx.ui.notify("Plan files or contract changed after approval; approval was revoked.", "warning");
|
|
765
|
+
}
|
|
766
|
+
} else if (persisted.status === "planning") {
|
|
767
|
+
planSource = persisted.planningRequest ? { kind: "prompt", prompt: persisted.planningRequest } : EMPTY_PLAN_SOURCE;
|
|
768
|
+
await enterPlanMode(ctx);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
} catch (error) {
|
|
772
|
+
planState = initialState();
|
|
773
|
+
stateRestorable = false;
|
|
774
|
+
ctx.ui.notify(`Workflow state could not be restored; execution is disabled: ${String(error)}`, "error");
|
|
450
775
|
}
|
|
451
|
-
|
|
776
|
+
pi.setActiveTools(withAlwaysOnTools(pi.getActiveTools()));
|
|
777
|
+
if (!pi.getAllTools().some((tool) => tool.name === "ask_user_question")) ctx.ui.notify("ask_user_question is missing. Install npm:@juicesharp/rpiv-ask-user-question.", "warning");
|
|
778
|
+
const file = await loadTaskFile(ctx.cwd).catch(() => undefined);
|
|
779
|
+
updateStatus(ctx, file?.tasks);
|
|
452
780
|
});
|
|
781
|
+
async function persistSession(ctx: ExtensionContext): Promise<void> {
|
|
782
|
+
if (!stateRestorable) return;
|
|
783
|
+
await saveStateLocked(ctx.cwd, { ...planState, currentTaskId, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
784
|
+
}
|
|
785
|
+
pi.on("session_shutdown", async (_event, ctx) => persistSession(ctx));
|
|
786
|
+
pi.on("session_before_switch", async (_event, ctx) => persistSession(ctx));
|
|
787
|
+
pi.on("session_before_fork", async (_event, ctx) => persistSession(ctx));
|
|
453
788
|
|
|
454
789
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
455
790
|
let statusKey: string | undefined;
|
|
456
791
|
let task: TaskItem | undefined;
|
|
457
792
|
let tasks: TaskItem[] | undefined;
|
|
458
793
|
let remaining = 0;
|
|
794
|
+
let planContent = "";
|
|
795
|
+
let planKey = "";
|
|
459
796
|
if (mode === "build" && currentTaskId !== undefined) {
|
|
460
797
|
const file = await loadTaskFile(ctx.cwd);
|
|
461
798
|
task = file?.tasks.find((item) => item.id === currentTaskId);
|
|
@@ -463,9 +800,15 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
463
800
|
tasks = file.tasks;
|
|
464
801
|
statusKey = buildStatusKey(file.tasks, currentTaskId);
|
|
465
802
|
remaining = file.tasks.filter((item) => !item.done).length;
|
|
803
|
+
try {
|
|
804
|
+
planContent = (await readOptionalFile(planFilePath(ctx.cwd))) ?? "";
|
|
805
|
+
} catch (error) {
|
|
806
|
+
ctx.ui.notify(`Could not read ${planFilePath(ctx.cwd)}: ${String(error)}`, "warning");
|
|
807
|
+
}
|
|
808
|
+
planKey = contentKey(planContent);
|
|
466
809
|
}
|
|
467
810
|
}
|
|
468
|
-
const kind = nextInjection(mode, framing, currentTaskId, statusKey);
|
|
811
|
+
const kind = nextInjection(mode, framing, currentTaskId, statusKey, planKey);
|
|
469
812
|
if (!kind) return;
|
|
470
813
|
if (kind === "plan-framing") {
|
|
471
814
|
framing = rememberInjection(framing, kind);
|
|
@@ -479,12 +822,12 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
479
822
|
};
|
|
480
823
|
}
|
|
481
824
|
if (!task || !tasks || currentTaskId === undefined) return;
|
|
482
|
-
framing = rememberInjection(framing, kind, currentTaskId, statusKey);
|
|
825
|
+
framing = rememberInjection(framing, kind, currentTaskId, statusKey, planKey);
|
|
483
826
|
if (kind === "build-framing") {
|
|
484
827
|
return {
|
|
485
828
|
message: {
|
|
486
829
|
customType: BUILD_FRAMING_TYPE,
|
|
487
|
-
content: buildPrompt(task, remaining, continueAll),
|
|
830
|
+
content: buildPrompt(task, remaining, continueAll, planContent, planState.tasks?.[String(currentTaskId)]?.status === "implementation-complete"),
|
|
488
831
|
display: false,
|
|
489
832
|
details: { phase: "build", taskId: currentTaskId },
|
|
490
833
|
},
|
|
@@ -506,35 +849,80 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
506
849
|
|
|
507
850
|
pi.on("tool_call", async (event, ctx) => {
|
|
508
851
|
if (mode !== "plan") return;
|
|
509
|
-
if (
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
return;
|
|
852
|
+
if (event.toolName === "plan_task" && (event.input as { action?: unknown }).action === "complete") {
|
|
853
|
+
return {
|
|
854
|
+
block: true,
|
|
855
|
+
reason: "Plan mode cannot mark implementation tasks complete. Use /build after the plan is approved.",
|
|
856
|
+
};
|
|
517
857
|
}
|
|
518
858
|
if (event.toolName === "write" || event.toolName === "edit") {
|
|
519
859
|
const path = pathFromInput(event.input);
|
|
520
860
|
if (!path || !isPlanArtifactPath(ctx.cwd, path)) {
|
|
521
861
|
return {
|
|
522
862
|
block: true,
|
|
523
|
-
reason: `Plan mode can only write ${
|
|
863
|
+
reason: `Plan mode can only write ${draftPlanFilePath(ctx.cwd)} and ${draftTaskFilePath(ctx.cwd)}.`,
|
|
524
864
|
};
|
|
525
865
|
}
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (!isAllowedPlanTool(event.toolName)) {
|
|
869
|
+
return {
|
|
870
|
+
block: true,
|
|
871
|
+
reason: `Plan mode blocked non-read-only tool: ${event.toolName}. Use /build to implement.`,
|
|
872
|
+
};
|
|
526
873
|
}
|
|
527
874
|
});
|
|
528
875
|
|
|
529
876
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
530
877
|
if (mode === "plan") {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
878
|
+
if (planReadyNotified) return;
|
|
879
|
+
let draft: { plan: string; task: string };
|
|
880
|
+
try { draft = await readDraftArtifacts(ctx.cwd); } catch { return; }
|
|
881
|
+
if (!draft.plan.trim() || !draft.task.trim()) return;
|
|
882
|
+
const draftHash = structureHash(draft.plan, draft.task);
|
|
883
|
+
if (draftHash === planState.planningBaselineHash) {
|
|
884
|
+
ctx.ui.notify("Draft is unchanged; update both draft files before submission.", "warning");
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
let tasks: TaskItem[];
|
|
888
|
+
try { tasks = parseTaskMarkdown(draft.task); } catch (error) { ctx.ui.notify(`Draft task file is invalid: ${String(error)}`, "error"); return; }
|
|
889
|
+
const previous = await loadTaskFile(ctx.cwd).catch(() => undefined);
|
|
890
|
+
let submittedTask = draft.task;
|
|
891
|
+
const runtimeTasks: NonNullable<PlanState["tasks"]> = {};
|
|
892
|
+
for (const task of tasks) {
|
|
893
|
+
const old = previous?.tasks.find((item) => item.id === task.id);
|
|
894
|
+
const oldRuntime = planState.tasks?.[String(task.id)];
|
|
895
|
+
const canPreserveVerified = task.done && old?.done && old.title === task.title && old.body === task.body && oldRuntime?.status === "verified" && Boolean(oldRuntime.verification?.trim());
|
|
896
|
+
if (canPreserveVerified) runtimeTasks[String(task.id)] = oldRuntime!;
|
|
897
|
+
else {
|
|
898
|
+
if (task.done) submittedTask = markTaskPendingInMarkdown(submittedTask, task.id);
|
|
899
|
+
runtimeTasks[String(task.id)] = { status: "pending" };
|
|
900
|
+
}
|
|
535
901
|
}
|
|
902
|
+
tasks = parseTaskMarkdown(submittedTask);
|
|
903
|
+
const validation = validatePlan(draft.plan, submittedTask, tasks);
|
|
904
|
+
if (!validation.valid) { ctx.ui.notify(`Plan validation failed: ${validation.errors.join("; ")}`, "error"); return; }
|
|
905
|
+
planState = transition(planState, "ready");
|
|
906
|
+
planState = { ...planState, planningBaselineHash: undefined, tasks: runtimeTasks, currentTaskId: undefined, approvedStructureHash: undefined, failureReason: undefined };
|
|
907
|
+
planState = await commitDraft(ctx.cwd, planState, { plan: draft.plan, task: submittedTask });
|
|
908
|
+
planReadyNotified = true;
|
|
909
|
+
pi.events.emit("pi-plan-task:plan-ready", { version: 1, cwd: ctx.cwd, planPath: planFilePath(ctx.cwd), taskPath: taskFilePath(ctx.cwd), planHash: planState.draftStructureHash, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
910
|
+
if (!ctx.hasUI) {
|
|
911
|
+
ctx.ui.notify(`Plan written. ${formatProgress(tasks)} tasks ready. Run /plan approve.`, "info");
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
awaitingChoice = true;
|
|
915
|
+
const choice = await ctx.ui.select(`Plan ready (${formatProgress(tasks)}). What next?`, [
|
|
916
|
+
"current", "new", "fresh", "review", "revise", "reject", "later",
|
|
917
|
+
]);
|
|
918
|
+
awaitingChoice = false;
|
|
919
|
+
if (choice === "current" || choice === "new" || choice === "fresh") pi.sendUserMessage(`/plan approve ${choice}`, { expandPromptTemplates: true });
|
|
920
|
+
else if (choice === "review" || choice === "revise" || choice === "reject") pi.sendUserMessage(`/plan ${choice}`, { expandPromptTemplates: true });
|
|
921
|
+
else ctx.ui.notify("Plan is ready. Run /plan approve when you want to execute it.", "info");
|
|
536
922
|
return;
|
|
537
923
|
}
|
|
538
924
|
await afterBuildSettled(ctx);
|
|
539
925
|
});
|
|
540
926
|
}
|
|
927
|
+
|
|
928
|
+
|