pi-plan-task 1.1.0 → 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 +633 -68
- package/extensions/build-session.test.ts +25 -27
- package/extensions/build-session.ts +38 -11
- 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 +583 -224
- 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,28 +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
|
-
const GOAL_CONTINUE_COMMAND = "/goal continue";
|
|
46
|
+
const BUILD_ONE_HERE_COMMAND = "/build";
|
|
42
47
|
|
|
43
|
-
|
|
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> {
|
|
44
52
|
const parentSession = ctx.sessionManager.getSessionFile();
|
|
45
53
|
const result = await ctx.newSession({
|
|
46
|
-
parentSession,
|
|
54
|
+
parentSession: clean ? undefined : parentSession,
|
|
47
55
|
withSession: async (nextCtx) => {
|
|
48
56
|
await nextCtx.sendUserMessage(command, { expandPromptTemplates: true });
|
|
49
57
|
},
|
|
50
58
|
});
|
|
51
59
|
if (result.cancelled) {
|
|
52
|
-
ctx.ui.notify("New session cancelled.", "info");
|
|
60
|
+
ctx.ui.notify("New session cancelled; the plan remains approved.", "info");
|
|
61
|
+
return false;
|
|
53
62
|
}
|
|
63
|
+
return true;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
const ALWAYS_ON_TOOLS = ["plan_task", "ask_user_question"] as const;
|
|
@@ -69,75 +79,30 @@ function pathFromInput(input: unknown): string | undefined {
|
|
|
69
79
|
return typeof path === "string" ? path : undefined;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
});
|
|
78
|
-
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("; ")}`);
|
|
79
88
|
}
|
|
80
89
|
|
|
81
|
-
class TaskListComponent {
|
|
82
|
-
private readonly tasks: TaskItem[];
|
|
83
|
-
private readonly theme: { fg: (name: string, text: string) => string };
|
|
84
|
-
private readonly onClose: () => void;
|
|
85
|
-
private cachedWidth?: number;
|
|
86
|
-
private cachedLines?: string[];
|
|
87
|
-
|
|
88
|
-
constructor(
|
|
89
|
-
tasks: TaskItem[],
|
|
90
|
-
theme: { fg: (name: string, text: string) => string },
|
|
91
|
-
onClose: () => void,
|
|
92
|
-
) {
|
|
93
|
-
this.tasks = tasks;
|
|
94
|
-
this.theme = theme;
|
|
95
|
-
this.onClose = onClose;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
handleInput(data: string): void {
|
|
99
|
-
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
100
|
-
this.onClose();
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
render(width: number): string[] {
|
|
105
|
-
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
106
|
-
const th = this.theme;
|
|
107
|
-
const lines = ["", truncateToWidth(` ${th.fg("accent", "Plan tasks")} ${th.fg("muted", formatProgress(this.tasks))}`, width), ""];
|
|
108
|
-
if (this.tasks.length === 0) {
|
|
109
|
-
lines.push(truncateToWidth(` ${th.fg("dim", "No tasks found. Run /plan first.")}`, width));
|
|
110
|
-
} else {
|
|
111
|
-
for (const task of this.tasks) {
|
|
112
|
-
const check = task.done ? th.fg("success", "x") : th.fg("dim", " ");
|
|
113
|
-
const title = task.done ? th.fg("dim", task.title) : th.fg("text", task.title);
|
|
114
|
-
lines.push(truncateToWidth(` [${check}] ${th.fg("accent", String(task.id))}. ${title}`, width));
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
lines.push("", truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width), "");
|
|
118
|
-
this.cachedWidth = width;
|
|
119
|
-
this.cachedLines = lines;
|
|
120
|
-
return lines;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
invalidate(): void {
|
|
124
|
-
this.cachedWidth = undefined;
|
|
125
|
-
this.cachedLines = undefined;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
90
|
|
|
129
91
|
export default async function planTaskExtension(pi: ExtensionAPI): Promise<void> {
|
|
130
92
|
await ensureDefaultGlobalConfig();
|
|
131
93
|
|
|
132
94
|
let mode: Mode = "idle";
|
|
133
95
|
let continueAll = false;
|
|
96
|
+
let approvalEachTask = false;
|
|
134
97
|
let continueNew = false;
|
|
135
98
|
let currentTaskId: number | undefined;
|
|
136
|
-
let
|
|
99
|
+
let planToolsDelta: ToolDelta | undefined;
|
|
137
100
|
let awaitingChoice = false;
|
|
138
101
|
let planReadyNotified = false;
|
|
139
102
|
let planSource: PlanSource = EMPTY_PLAN_SOURCE;
|
|
140
103
|
let framing: FramingState = { ...INITIAL_FRAMING_STATE };
|
|
104
|
+
let planState = initialState();
|
|
105
|
+
let stateRestorable = true;
|
|
141
106
|
|
|
142
107
|
function resetFraming(): void {
|
|
143
108
|
framing = { ...INITIAL_FRAMING_STATE };
|
|
@@ -168,24 +133,24 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
168
133
|
}
|
|
169
134
|
|
|
170
135
|
async function applyPlanTools(ctx: ExtensionContext): Promise<void> {
|
|
171
|
-
if (
|
|
172
|
-
toolsBeforePlan = pi.getActiveTools();
|
|
173
|
-
}
|
|
136
|
+
if (planToolsDelta) return;
|
|
174
137
|
const config = await loadConfig(ctx.cwd);
|
|
175
|
-
|
|
138
|
+
const required = withAlwaysOnTools([...config.planTools, "write", "edit"]);
|
|
139
|
+
planToolsDelta = addTools(pi.getActiveTools(), required);
|
|
140
|
+
pi.setActiveTools(unique([...pi.getActiveTools(), ...required]));
|
|
176
141
|
}
|
|
177
142
|
|
|
143
|
+
|
|
178
144
|
function restoreTools(): void {
|
|
179
|
-
if (
|
|
180
|
-
pi.setActiveTools(
|
|
181
|
-
|
|
182
|
-
return;
|
|
145
|
+
if (planToolsDelta) {
|
|
146
|
+
pi.setActiveTools(removeAddedTools(pi.getActiveTools(), planToolsDelta));
|
|
147
|
+
planToolsDelta = undefined;
|
|
183
148
|
}
|
|
184
|
-
pi.setActiveTools(withAlwaysOnTools(pi.getActiveTools()));
|
|
185
149
|
}
|
|
186
150
|
|
|
187
151
|
async function enterPlanMode(ctx: ExtensionContext): Promise<void> {
|
|
188
152
|
mode = "plan";
|
|
153
|
+
if (planState.status !== "planning") planState = transition(planState, "planning");
|
|
189
154
|
continueAll = false;
|
|
190
155
|
continueNew = false;
|
|
191
156
|
currentTaskId = undefined;
|
|
@@ -221,13 +186,50 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
221
186
|
leaveModes(ctx);
|
|
222
187
|
return false;
|
|
223
188
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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");
|
|
197
|
+
leaveModes(ctx);
|
|
198
|
+
return false;
|
|
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");
|
|
227
223
|
leaveModes(ctx);
|
|
228
224
|
return false;
|
|
229
225
|
}
|
|
226
|
+
const next = decision.task;
|
|
230
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() });
|
|
231
233
|
updateStatus(ctx, file.tasks);
|
|
232
234
|
pi.sendUserMessage(buildRequest(next));
|
|
233
235
|
return true;
|
|
@@ -243,18 +245,57 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
243
245
|
}
|
|
244
246
|
const current = file.tasks.find((task) => task.id === currentTaskId);
|
|
245
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
|
+
}
|
|
246
265
|
if (!current?.done) {
|
|
247
|
-
|
|
248
|
-
`Task ${currentTaskId} is
|
|
249
|
-
|
|
250
|
-
);
|
|
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);
|
|
251
275
|
return;
|
|
252
276
|
}
|
|
253
277
|
if (file.tasks.every((task) => task.done)) {
|
|
254
|
-
|
|
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() });
|
|
255
285
|
leaveModes(ctx);
|
|
256
286
|
return;
|
|
257
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
|
+
}
|
|
258
299
|
if (continueAll) {
|
|
259
300
|
await startNextTask(ctx);
|
|
260
301
|
return;
|
|
@@ -265,15 +306,12 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
265
306
|
resetFraming();
|
|
266
307
|
updateStatus(ctx);
|
|
267
308
|
ctx.ui.notify("Task complete. Opening a new session for the next task.", "info");
|
|
268
|
-
pi.sendUserMessage("/
|
|
309
|
+
pi.sendUserMessage("/build new", { expandPromptTemplates: true });
|
|
269
310
|
return;
|
|
270
311
|
}
|
|
271
312
|
if (!ctx.hasUI) {
|
|
272
313
|
ctx.ui.notify("Task complete. Run /build for the next task.", "info");
|
|
273
|
-
|
|
274
|
-
currentTaskId = undefined;
|
|
275
|
-
resetFraming();
|
|
276
|
-
updateStatus(ctx);
|
|
314
|
+
leaveModes(ctx);
|
|
277
315
|
return;
|
|
278
316
|
}
|
|
279
317
|
awaitingChoice = true;
|
|
@@ -288,7 +326,7 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
288
326
|
currentTaskId = undefined;
|
|
289
327
|
resetFraming();
|
|
290
328
|
updateStatus(ctx);
|
|
291
|
-
pi.sendUserMessage("/build
|
|
329
|
+
pi.sendUserMessage("/build new", { expandPromptTemplates: true });
|
|
292
330
|
return;
|
|
293
331
|
}
|
|
294
332
|
mode = "idle";
|
|
@@ -303,37 +341,60 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
303
341
|
description: "Read plan progress or mark a planned task complete in .plan_task/task.md",
|
|
304
342
|
promptSnippet: "Mark planned tasks complete and read .plan_task progress",
|
|
305
343
|
parameters: Type.Object({
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
}),
|
|
309
348
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
310
349
|
const file = await loadTaskFile(ctx.cwd);
|
|
311
|
-
if (!file) {
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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));
|
|
323
397
|
}
|
|
324
|
-
const task = next.tasks.find((item) => item.id === params.id);
|
|
325
|
-
updateStatus(ctx, next.tasks);
|
|
326
|
-
return {
|
|
327
|
-
content: [
|
|
328
|
-
{
|
|
329
|
-
type: "text",
|
|
330
|
-
text: task?.done
|
|
331
|
-
? `Marked task ${params.id} complete. Progress ${formatProgress(next.tasks)}.`
|
|
332
|
-
: `Task ${params.id} was not found.`,
|
|
333
|
-
},
|
|
334
|
-
],
|
|
335
|
-
details: { tasks: next.tasks, completedId: params.id },
|
|
336
|
-
};
|
|
337
398
|
},
|
|
338
399
|
renderCall(args, theme) {
|
|
339
400
|
const suffix = args.id === undefined ? "" : ` #${args.id}`;
|
|
@@ -341,33 +402,178 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
341
402
|
},
|
|
342
403
|
});
|
|
343
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"];
|
|
344
554
|
pi.registerCommand("plan", {
|
|
345
|
-
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
|
+
},
|
|
346
561
|
handler: async (args, ctx) => {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
return;
|
|
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);
|
|
357
576
|
}
|
|
358
|
-
const existing = await readOptionalFile(taskFilePath(ctx.cwd));
|
|
359
|
-
if (existing && ctx.hasUI) {
|
|
360
|
-
const ok = await ctx.ui.confirm("Overwrite existing plan?", ".plan_task already has a task list. Overwrite it?");
|
|
361
|
-
if (!ok) return;
|
|
362
|
-
}
|
|
363
|
-
planSource = loaded.source;
|
|
364
|
-
await enterPlanMode(ctx);
|
|
365
|
-
const message =
|
|
366
|
-
loaded.source.kind === "file"
|
|
367
|
-
? `Planning from ${loaded.source.displayPath}. Project writes are blocked.`
|
|
368
|
-
: "Plan mode enabled. Project writes are blocked.";
|
|
369
|
-
ctx.ui.notify(message, "info");
|
|
370
|
-
pi.sendUserMessage(planRequest(loaded.source));
|
|
371
577
|
},
|
|
372
578
|
});
|
|
373
579
|
|
|
@@ -375,67 +581,147 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
375
581
|
ctx: ExtensionCommandContext,
|
|
376
582
|
runAll: boolean,
|
|
377
583
|
placement: BuildPlacement = "here",
|
|
378
|
-
options: { chainNew?: boolean;
|
|
584
|
+
options: { chainNew?: boolean; approvalEachTask?: boolean } = {},
|
|
379
585
|
): Promise<void> {
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (file.tasks.length === 0) {
|
|
388
|
-
ctx.ui.notify("task.md has no checklist items.", "error");
|
|
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");
|
|
389
593
|
return;
|
|
390
594
|
}
|
|
391
|
-
|
|
392
|
-
|
|
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");
|
|
393
606
|
return;
|
|
394
607
|
}
|
|
395
|
-
|
|
396
|
-
|
|
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); }
|
|
397
626
|
return;
|
|
398
627
|
}
|
|
399
|
-
|
|
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;
|
|
400
633
|
continueNew = chainNew;
|
|
401
634
|
await enterBuildMode(ctx);
|
|
402
|
-
ctx.ui.notify(
|
|
403
|
-
runAll
|
|
404
|
-
? "Building remaining tasks."
|
|
405
|
-
: chainNew
|
|
406
|
-
? "Building the next task. Remaining tasks will each start in a new session."
|
|
407
|
-
: "Building the next task.",
|
|
408
|
-
"info",
|
|
409
|
-
);
|
|
635
|
+
ctx.ui.notify(chainNew ? "Building the next task; remaining tasks will use new sessions." : runAll ? "Building remaining tasks." : "Building the next task.", "info");
|
|
410
636
|
await startNextTask(ctx);
|
|
411
637
|
}
|
|
412
638
|
|
|
639
|
+
const buildSubcommands = ["all", "new", "fresh", "--approval"];
|
|
413
640
|
pi.registerCommand("build", {
|
|
414
|
-
description: "Execute
|
|
415
|
-
|
|
416
|
-
|
|
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;
|
|
417
647
|
},
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
pi.registerCommand("goal", {
|
|
421
|
-
description: "Execute remaining planned tasks until complete. Use /goal new for one new session per task",
|
|
422
648
|
handler: async (args, ctx) => {
|
|
423
|
-
const
|
|
424
|
-
if (
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
+
);
|
|
433
661
|
},
|
|
434
662
|
});
|
|
435
663
|
|
|
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
|
+
}
|
|
710
|
+
|
|
436
711
|
pi.registerCommand("tasks", {
|
|
437
|
-
description: "Show
|
|
438
|
-
|
|
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; }
|
|
439
725
|
const file = await loadTaskFile(ctx.cwd);
|
|
440
726
|
const tasks = file?.tasks ?? [];
|
|
441
727
|
if (ctx.mode !== "tui") {
|
|
@@ -446,45 +732,67 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
446
732
|
},
|
|
447
733
|
});
|
|
448
734
|
|
|
449
|
-
pi.registerCommand("build-next-session", {
|
|
450
|
-
description: "Continue the next planned task in a new session",
|
|
451
|
-
handler: async (_args, ctx) => {
|
|
452
|
-
await startBuildInNewSession(ctx);
|
|
453
|
-
},
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
pi.registerCommand("goal-next-session", {
|
|
457
|
-
description: "Continue the next planned task in a new session, then keep chaining new sessions",
|
|
458
|
-
handler: async (_args, ctx) => {
|
|
459
|
-
await startBuildInNewSession(ctx, GOAL_CONTINUE_COMMAND);
|
|
460
|
-
},
|
|
461
|
-
});
|
|
462
|
-
|
|
463
735
|
pi.on("session_start", async (_event, ctx) => {
|
|
464
736
|
mode = "idle";
|
|
465
737
|
continueAll = false;
|
|
466
738
|
continueNew = false;
|
|
467
739
|
currentTaskId = undefined;
|
|
468
|
-
|
|
740
|
+
planToolsDelta = undefined;
|
|
469
741
|
awaitingChoice = false;
|
|
470
742
|
planReadyNotified = false;
|
|
471
743
|
planSource = EMPTY_PLAN_SOURCE;
|
|
472
744
|
resetFraming();
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
ctx.
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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");
|
|
479
775
|
}
|
|
480
|
-
|
|
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);
|
|
481
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));
|
|
482
788
|
|
|
483
789
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
484
790
|
let statusKey: string | undefined;
|
|
485
791
|
let task: TaskItem | undefined;
|
|
486
792
|
let tasks: TaskItem[] | undefined;
|
|
487
793
|
let remaining = 0;
|
|
794
|
+
let planContent = "";
|
|
795
|
+
let planKey = "";
|
|
488
796
|
if (mode === "build" && currentTaskId !== undefined) {
|
|
489
797
|
const file = await loadTaskFile(ctx.cwd);
|
|
490
798
|
task = file?.tasks.find((item) => item.id === currentTaskId);
|
|
@@ -492,9 +800,15 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
492
800
|
tasks = file.tasks;
|
|
493
801
|
statusKey = buildStatusKey(file.tasks, currentTaskId);
|
|
494
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);
|
|
495
809
|
}
|
|
496
810
|
}
|
|
497
|
-
const kind = nextInjection(mode, framing, currentTaskId, statusKey);
|
|
811
|
+
const kind = nextInjection(mode, framing, currentTaskId, statusKey, planKey);
|
|
498
812
|
if (!kind) return;
|
|
499
813
|
if (kind === "plan-framing") {
|
|
500
814
|
framing = rememberInjection(framing, kind);
|
|
@@ -508,12 +822,12 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
508
822
|
};
|
|
509
823
|
}
|
|
510
824
|
if (!task || !tasks || currentTaskId === undefined) return;
|
|
511
|
-
framing = rememberInjection(framing, kind, currentTaskId, statusKey);
|
|
825
|
+
framing = rememberInjection(framing, kind, currentTaskId, statusKey, planKey);
|
|
512
826
|
if (kind === "build-framing") {
|
|
513
827
|
return {
|
|
514
828
|
message: {
|
|
515
829
|
customType: BUILD_FRAMING_TYPE,
|
|
516
|
-
content: buildPrompt(task, remaining, continueAll),
|
|
830
|
+
content: buildPrompt(task, remaining, continueAll, planContent, planState.tasks?.[String(currentTaskId)]?.status === "implementation-complete"),
|
|
517
831
|
display: false,
|
|
518
832
|
details: { phase: "build", taskId: currentTaskId },
|
|
519
833
|
},
|
|
@@ -535,35 +849,80 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
535
849
|
|
|
536
850
|
pi.on("tool_call", async (event, ctx) => {
|
|
537
851
|
if (mode !== "plan") return;
|
|
538
|
-
if (
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
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
|
+
};
|
|
546
857
|
}
|
|
547
858
|
if (event.toolName === "write" || event.toolName === "edit") {
|
|
548
859
|
const path = pathFromInput(event.input);
|
|
549
860
|
if (!path || !isPlanArtifactPath(ctx.cwd, path)) {
|
|
550
861
|
return {
|
|
551
862
|
block: true,
|
|
552
|
-
reason: `Plan mode can only write ${
|
|
863
|
+
reason: `Plan mode can only write ${draftPlanFilePath(ctx.cwd)} and ${draftTaskFilePath(ctx.cwd)}.`,
|
|
553
864
|
};
|
|
554
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
|
+
};
|
|
555
873
|
}
|
|
556
874
|
});
|
|
557
875
|
|
|
558
876
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
559
877
|
if (mode === "plan") {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
+
}
|
|
564
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");
|
|
565
922
|
return;
|
|
566
923
|
}
|
|
567
924
|
await afterBuildSettled(ctx);
|
|
568
925
|
});
|
|
569
926
|
}
|
|
927
|
+
|
|
928
|
+
|