pi-plan-task 1.1.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +535 -68
- package/extensions/build-session.test.ts +25 -27
- package/extensions/build-session.ts +26 -11
- package/extensions/command-surface.test.ts +15 -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 +523 -245
- package/extensions/integration.test.ts +231 -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 +59 -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 { parseBuildOptions } 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,46 @@ 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, formatWorkflowStatus, TaskListComponent } from "./task-ui.ts";
|
|
35
|
+
import { initialState, structureHash, transition, updateTaskState } from "./state.ts";
|
|
36
|
+
import { normalizeReviewResult, validatePlan } from "./review.ts";
|
|
37
|
+
import { addTools, removeAddedTools, type ToolDelta } from "./tools.ts";
|
|
38
|
+
import { assertApprovedStructure, blockCurrentTask, completeCurrentTask, reworkFromTask, unblockTask, verifyCurrentTask, classifyQueue } from "./workflow-policy.ts";
|
|
39
|
+
import { commitDraft, initializeDraftWithState, loadStateLocked, mutateWorkflow, readCurrentArtifacts, readDraftArtifacts, saveStateLocked } from "./workflow-store.ts";
|
|
35
40
|
|
|
36
41
|
type Mode = "idle" | "plan" | "build";
|
|
37
42
|
|
|
38
43
|
const CONTINUE_THIS = "Continue in this session";
|
|
39
44
|
const CONTINUE_NEW = "Continue in a new session";
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
const CONTINUE_STOP = "Stop";
|
|
46
|
+
const BUILD_ONE_HERE_COMMAND = "/build";
|
|
47
|
+
const REMOVED_PLAN_COMMANDS: Record<string, string> = {
|
|
48
|
+
review: "Removed. After a plan is submitted, choose review if Plannotator is available, or run /plan approve or /plan reject.",
|
|
49
|
+
revise: "Removed. Use /plan reject [feedback] to revise a submitted plan.",
|
|
50
|
+
status: "Removed. Run /tasks to see status, progress, and the approved hash.",
|
|
51
|
+
history: "Removed. Submitted snapshots are in .plan_task/history/.",
|
|
52
|
+
diff: "Removed. Compare the current files with the newest snapshot in .plan_task/history/.",
|
|
53
|
+
} as const;
|
|
54
|
+
function contentKey(content: string): string {
|
|
55
|
+
return createHash("sha256").update(content).digest("hex");
|
|
56
|
+
}
|
|
57
|
+
async function startBuildInNewSession(ctx: ExtensionCommandContext, command = BUILD_ONE_HERE_COMMAND, clean = false): Promise<boolean> {
|
|
44
58
|
const parentSession = ctx.sessionManager.getSessionFile();
|
|
45
59
|
const result = await ctx.newSession({
|
|
46
|
-
parentSession,
|
|
60
|
+
parentSession: clean ? undefined : parentSession,
|
|
47
61
|
withSession: async (nextCtx) => {
|
|
48
62
|
await nextCtx.sendUserMessage(command, { expandPromptTemplates: true });
|
|
49
63
|
},
|
|
50
64
|
});
|
|
51
65
|
if (result.cancelled) {
|
|
52
|
-
ctx.ui.notify("New session cancelled.", "info");
|
|
66
|
+
ctx.ui.notify("New session cancelled; the plan remains approved.", "info");
|
|
67
|
+
return false;
|
|
53
68
|
}
|
|
69
|
+
return true;
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
const ALWAYS_ON_TOOLS = ["plan_task", "ask_user_question"] as const;
|
|
@@ -69,62 +85,14 @@ function pathFromInput(input: unknown): string | undefined {
|
|
|
69
85
|
return typeof path === "string" ? path : undefined;
|
|
70
86
|
}
|
|
71
87
|
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
});
|
|
78
|
-
return `Progress ${formatProgress(tasks)}\n${lines.join("\n")}`;
|
|
88
|
+
function assertApprovedWorkflow(state: PlanState, plan: string, task: string): void {
|
|
89
|
+
assertApprovedStructure(state, plan, task, structureHash);
|
|
90
|
+
let tasks: TaskItem[];
|
|
91
|
+
try { tasks = parseTaskMarkdown(task); } catch (error) { throw new Error(`Invalid approved task file: ${String(error)}`); }
|
|
92
|
+
const validation = validatePlan(plan, task, tasks);
|
|
93
|
+
if (!validation.valid) throw new Error(`Approved plan contract is invalid: ${validation.errors.join("; ")}`);
|
|
79
94
|
}
|
|
80
95
|
|
|
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
96
|
|
|
129
97
|
export default async function planTaskExtension(pi: ExtensionAPI): Promise<void> {
|
|
130
98
|
await ensureDefaultGlobalConfig();
|
|
@@ -132,12 +100,15 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
132
100
|
let mode: Mode = "idle";
|
|
133
101
|
let continueAll = false;
|
|
134
102
|
let continueNew = false;
|
|
103
|
+
let pendingHandoff: "new" | "fresh" | undefined;
|
|
135
104
|
let currentTaskId: number | undefined;
|
|
136
|
-
let
|
|
105
|
+
let planToolsDelta: ToolDelta | undefined;
|
|
137
106
|
let awaitingChoice = false;
|
|
138
107
|
let planReadyNotified = false;
|
|
139
108
|
let planSource: PlanSource = EMPTY_PLAN_SOURCE;
|
|
140
109
|
let framing: FramingState = { ...INITIAL_FRAMING_STATE };
|
|
110
|
+
let planState = initialState();
|
|
111
|
+
let stateRestorable = true;
|
|
141
112
|
|
|
142
113
|
function resetFraming(): void {
|
|
143
114
|
framing = { ...INITIAL_FRAMING_STATE };
|
|
@@ -168,24 +139,24 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
168
139
|
}
|
|
169
140
|
|
|
170
141
|
async function applyPlanTools(ctx: ExtensionContext): Promise<void> {
|
|
171
|
-
if (
|
|
172
|
-
toolsBeforePlan = pi.getActiveTools();
|
|
173
|
-
}
|
|
142
|
+
if (planToolsDelta) return;
|
|
174
143
|
const config = await loadConfig(ctx.cwd);
|
|
175
|
-
|
|
144
|
+
const required = withAlwaysOnTools([...config.planTools, "write", "edit"]);
|
|
145
|
+
planToolsDelta = addTools(pi.getActiveTools(), required);
|
|
146
|
+
pi.setActiveTools(unique([...pi.getActiveTools(), ...required]));
|
|
176
147
|
}
|
|
177
148
|
|
|
149
|
+
|
|
178
150
|
function restoreTools(): void {
|
|
179
|
-
if (
|
|
180
|
-
pi.setActiveTools(
|
|
181
|
-
|
|
182
|
-
return;
|
|
151
|
+
if (planToolsDelta) {
|
|
152
|
+
pi.setActiveTools(removeAddedTools(pi.getActiveTools(), planToolsDelta));
|
|
153
|
+
planToolsDelta = undefined;
|
|
183
154
|
}
|
|
184
|
-
pi.setActiveTools(withAlwaysOnTools(pi.getActiveTools()));
|
|
185
155
|
}
|
|
186
156
|
|
|
187
157
|
async function enterPlanMode(ctx: ExtensionContext): Promise<void> {
|
|
188
158
|
mode = "plan";
|
|
159
|
+
if (planState.status !== "planning") planState = transition(planState, "planning");
|
|
189
160
|
continueAll = false;
|
|
190
161
|
continueNew = false;
|
|
191
162
|
currentTaskId = undefined;
|
|
@@ -208,6 +179,7 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
208
179
|
mode = "idle";
|
|
209
180
|
continueAll = false;
|
|
210
181
|
continueNew = false;
|
|
182
|
+
pendingHandoff = undefined;
|
|
211
183
|
currentTaskId = undefined;
|
|
212
184
|
resetFraming();
|
|
213
185
|
restoreTools();
|
|
@@ -221,18 +193,67 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
221
193
|
leaveModes(ctx);
|
|
222
194
|
return false;
|
|
223
195
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
196
|
+
try {
|
|
197
|
+
const current = await readCurrentArtifacts(ctx.cwd);
|
|
198
|
+
assertApprovedWorkflow(planState, current.plan, current.task);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (planState.status !== "ready") planState = transition(planState, "ready");
|
|
201
|
+
planState = { ...planState, approvedStructureHash: undefined, currentTaskId: undefined, failureReason: error instanceof Error ? error.message : String(error) };
|
|
202
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
203
|
+
ctx.ui.notify(planState.failureReason ?? "Plan approval is no longer valid.", "error");
|
|
227
204
|
leaveModes(ctx);
|
|
228
205
|
return false;
|
|
229
206
|
}
|
|
207
|
+
const decision = classifyQueue(file, planState);
|
|
208
|
+
if (decision.kind === "all-complete") {
|
|
209
|
+
const allVerified = file.tasks.every((task) => planState.tasks?.[String(task.id)]?.status === "verified");
|
|
210
|
+
if (!allVerified) {
|
|
211
|
+
ctx.ui.notify("Checklist is complete but task verification state is inconsistent. Execution stopped.", "error");
|
|
212
|
+
leaveModes(ctx);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (planState.status === "approved") planState = transition(planState, "executing");
|
|
216
|
+
planState = transition(planState, "completed");
|
|
217
|
+
planState = { ...planState, currentTaskId: undefined };
|
|
218
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
219
|
+
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() });
|
|
220
|
+
ctx.ui.notify("All tasks are verified and complete.", "info");
|
|
221
|
+
leaveModes(ctx);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
if (decision.kind === "all-remaining-blocked") {
|
|
225
|
+
if (planState.status !== "blocked") planState = transition(planState, "blocked");
|
|
226
|
+
planState = { ...planState, currentTaskId: undefined };
|
|
227
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
228
|
+
const reasons = decision.taskIds.map((id) => `${id}: ${planState.tasks?.[String(id)]?.reason ?? "blocked"}`).join("; ");
|
|
229
|
+
ctx.ui.notify(`Execution blocked. ${reasons}`, "warning");
|
|
230
|
+
leaveModes(ctx);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
const next = decision.task;
|
|
230
234
|
currentTaskId = next.id;
|
|
235
|
+
const existing = planState.tasks?.[String(next.id)];
|
|
236
|
+
planState = updateTaskState(planState, next.id, existing?.status === "implementation-complete" ? "implementation-complete" : "pending", { startedAt: new Date().toISOString() });
|
|
237
|
+
planState = { ...planState, currentTaskId: next.id };
|
|
238
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
239
|
+
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
240
|
updateStatus(ctx, file.tasks);
|
|
232
241
|
pi.sendUserMessage(buildRequest(next));
|
|
233
242
|
return true;
|
|
234
243
|
}
|
|
235
244
|
|
|
245
|
+
async function queueBuildHandoff(ctx: ExtensionContext, message?: string): Promise<void> {
|
|
246
|
+
mode = "idle";
|
|
247
|
+
currentTaskId = undefined;
|
|
248
|
+
resetFraming();
|
|
249
|
+
updateStatus(ctx);
|
|
250
|
+
pendingHandoff = "new";
|
|
251
|
+
planState = { ...planState, continueMode: "all-new", currentTaskId: undefined };
|
|
252
|
+
if (stateRestorable) await saveStateLocked(ctx.cwd, planState);
|
|
253
|
+
if (message) ctx.ui.notify(message, "info");
|
|
254
|
+
pi.sendUserMessage(BUILD_ONE_HERE_COMMAND, { expandPromptTemplates: true });
|
|
255
|
+
}
|
|
256
|
+
|
|
236
257
|
async function afterBuildSettled(ctx: ExtensionContext): Promise<void> {
|
|
237
258
|
if (mode !== "build" || awaitingChoice || currentTaskId === undefined) return;
|
|
238
259
|
const file = await loadTaskFile(ctx.cwd);
|
|
@@ -243,15 +264,42 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
243
264
|
}
|
|
244
265
|
const current = file.tasks.find((task) => task.id === currentTaskId);
|
|
245
266
|
updateStatus(ctx, file.tasks);
|
|
267
|
+
const runtime = planState.tasks?.[String(currentTaskId)];
|
|
268
|
+
if (runtime?.status === "blocked") {
|
|
269
|
+
if (continueAll) { await startNextTask(ctx); return; }
|
|
270
|
+
if (continueNew) {
|
|
271
|
+
const decision = classifyQueue(file, planState);
|
|
272
|
+
if (decision.kind === "runnable") {
|
|
273
|
+
await queueBuildHandoff(ctx);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
await startNextTask(ctx);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
ctx.ui.notify(`Task ${currentTaskId} is blocked: ${runtime.reason ?? "no reason provided"}`, "warning");
|
|
280
|
+
leaveModes(ctx);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
246
283
|
if (!current?.done) {
|
|
247
|
-
|
|
248
|
-
`Task ${currentTaskId} is
|
|
249
|
-
|
|
250
|
-
);
|
|
284
|
+
const message = runtime?.status === "implementation-complete"
|
|
285
|
+
? `Task ${currentTaskId} implementation is complete but verification is pending. Call plan_task verify with the verification result.`
|
|
286
|
+
: `Task ${currentTaskId} is still pending. Complete the implementation before continuing.`;
|
|
287
|
+
ctx.ui.notify(message, "warning");
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (runtime?.status !== "verified") {
|
|
291
|
+
ctx.ui.notify(`Task ${currentTaskId} checklist/state mismatch: task is checked but not verified.`, "error");
|
|
292
|
+
leaveModes(ctx);
|
|
251
293
|
return;
|
|
252
294
|
}
|
|
253
295
|
if (file.tasks.every((task) => task.done)) {
|
|
254
|
-
|
|
296
|
+
const allVerified = file.tasks.every((task) => planState.tasks?.[String(task.id)]?.status === "verified");
|
|
297
|
+
if (!allVerified) { ctx.ui.notify("Not all completed tasks are verified.", "error"); leaveModes(ctx); return; }
|
|
298
|
+
ctx.ui.notify("All tasks are verified and complete.", "info");
|
|
299
|
+
planState = transition(planState, "completed");
|
|
300
|
+
planState = { ...planState, currentTaskId: undefined };
|
|
301
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
302
|
+
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
303
|
leaveModes(ctx);
|
|
256
304
|
return;
|
|
257
305
|
}
|
|
@@ -260,41 +308,26 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
260
308
|
return;
|
|
261
309
|
}
|
|
262
310
|
if (continueNew) {
|
|
263
|
-
|
|
264
|
-
currentTaskId = undefined;
|
|
265
|
-
resetFraming();
|
|
266
|
-
updateStatus(ctx);
|
|
267
|
-
ctx.ui.notify("Task complete. Opening a new session for the next task.", "info");
|
|
268
|
-
pi.sendUserMessage("/goal-next-session", { expandPromptTemplates: true });
|
|
311
|
+
await queueBuildHandoff(ctx, "Task complete. Opening a new session for the next task.");
|
|
269
312
|
return;
|
|
270
313
|
}
|
|
271
314
|
if (!ctx.hasUI) {
|
|
272
315
|
ctx.ui.notify("Task complete. Run /build for the next task.", "info");
|
|
273
|
-
|
|
274
|
-
currentTaskId = undefined;
|
|
275
|
-
resetFraming();
|
|
276
|
-
updateStatus(ctx);
|
|
316
|
+
leaveModes(ctx);
|
|
277
317
|
return;
|
|
278
318
|
}
|
|
279
319
|
awaitingChoice = true;
|
|
280
|
-
const choice = await ctx.ui.select("Task complete. What next?", [CONTINUE_THIS, CONTINUE_NEW]);
|
|
320
|
+
const choice = await ctx.ui.select("Task complete. What next?", [CONTINUE_THIS, CONTINUE_NEW, CONTINUE_STOP]);
|
|
281
321
|
awaitingChoice = false;
|
|
282
322
|
if (choice === CONTINUE_THIS) {
|
|
283
323
|
await startNextTask(ctx);
|
|
284
324
|
return;
|
|
285
325
|
}
|
|
286
326
|
if (choice === CONTINUE_NEW) {
|
|
287
|
-
|
|
288
|
-
currentTaskId = undefined;
|
|
289
|
-
resetFraming();
|
|
290
|
-
updateStatus(ctx);
|
|
291
|
-
pi.sendUserMessage("/build-next-session", { expandPromptTemplates: true });
|
|
327
|
+
await queueBuildHandoff(ctx, "Task complete. Opening a new session for the next task.");
|
|
292
328
|
return;
|
|
293
329
|
}
|
|
294
|
-
|
|
295
|
-
currentTaskId = undefined;
|
|
296
|
-
resetFraming();
|
|
297
|
-
updateStatus(ctx);
|
|
330
|
+
leaveModes(ctx);
|
|
298
331
|
}
|
|
299
332
|
|
|
300
333
|
pi.registerTool({
|
|
@@ -303,37 +336,60 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
303
336
|
description: "Read plan progress or mark a planned task complete in .plan_task/task.md",
|
|
304
337
|
promptSnippet: "Mark planned tasks complete and read .plan_task progress",
|
|
305
338
|
parameters: Type.Object({
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
339
|
+
action: StringEnum(["status", "complete", "verify", "block", "unblock"] as const),
|
|
340
|
+
id: Type.Optional(Type.Number({ description: "Task id to update" })),
|
|
341
|
+
reason: Type.Optional(Type.String({ description: "Reason for blocking or verification result" })),
|
|
342
|
+
}),
|
|
309
343
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
310
344
|
const file = await loadTaskFile(ctx.cwd);
|
|
311
|
-
if (!file) {
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
345
|
+
if (!file) return { content: [{ type: "text", text: "No .plan_task/task.md found. Run /plan first." }], details: {} };
|
|
346
|
+
if (params.action === "status") return { content: [{ type: "text", text: formatTaskList(file.tasks) }], details: { tasks: file.tasks } };
|
|
347
|
+
if (params.id === undefined) return { content: [{ type: "text", text: "id is required" }], details: {} };
|
|
348
|
+
const id = params.id;
|
|
349
|
+
let eventName: string | undefined;
|
|
350
|
+
let resultText = "";
|
|
351
|
+
let approvalRevoked = false;
|
|
352
|
+
try {
|
|
353
|
+
const result = await mutateWorkflow(ctx.cwd, ({ state, plan, task }) => {
|
|
354
|
+
try { assertApprovedWorkflow(state, plan, task); } catch (error) {
|
|
355
|
+
approvalRevoked = true;
|
|
356
|
+
const ready = transition(state, "ready");
|
|
357
|
+
return { state: { ...ready, approvedStructureHash: undefined, currentTaskId: undefined, failureReason: error instanceof Error ? error.message : String(error) } };
|
|
358
|
+
}
|
|
359
|
+
const parsed = { raw: task, tasks: parseTaskMarkdown(task) };
|
|
360
|
+
let mutation;
|
|
361
|
+
if (params.action === "complete") {
|
|
362
|
+
mutation = completeCurrentTask(state, parsed, id);
|
|
363
|
+
eventName = "pi-plan-task:task-completed";
|
|
364
|
+
resultText = `Task ${id} implementation complete. Verification is required before it can advance.`;
|
|
365
|
+
} else if (params.action === "verify") {
|
|
366
|
+
mutation = verifyCurrentTask(state, parsed, id, params.reason);
|
|
367
|
+
eventName = "pi-plan-task:task-verified";
|
|
368
|
+
} else if (params.action === "block") {
|
|
369
|
+
mutation = blockCurrentTask(state, parsed, id, params.reason);
|
|
370
|
+
eventName = "pi-plan-task:task-blocked";
|
|
371
|
+
resultText = `Task ${id} blocked: ${params.reason?.trim() ?? ""}`;
|
|
372
|
+
} else {
|
|
373
|
+
mutation = unblockTask(state, parsed, id);
|
|
374
|
+
}
|
|
375
|
+
if (!mutation.ok) throw new Error(mutation.error);
|
|
376
|
+
if (params.action === "verify") resultText = `Task ${id} verified. Progress ${formatProgress(parseTaskMarkdown(mutation.value.task))}.`;
|
|
377
|
+
if (params.action === "unblock") resultText = `Task ${id} unblocked and restored to ${mutation.value.state.tasks?.[String(id)]?.status}.`;
|
|
378
|
+
return { state: mutation.value.state, task: mutation.value.task };
|
|
379
|
+
});
|
|
380
|
+
planState = result.state;
|
|
381
|
+
if (approvalRevoked) {
|
|
382
|
+
currentTaskId = undefined;
|
|
383
|
+
leaveModes(ctx);
|
|
384
|
+
throw new Error(planState.failureReason ?? "Plan approval was revoked.");
|
|
385
|
+
}
|
|
386
|
+
const nextTasks = parseTaskMarkdown(result.task);
|
|
387
|
+
updateStatus(ctx, nextTasks);
|
|
388
|
+
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() });
|
|
389
|
+
return { content: [{ type: "text", text: resultText }], details: { taskId: id, status: planState.tasks?.[String(id)]?.status, tasks: nextTasks } };
|
|
390
|
+
} catch (error) {
|
|
391
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
323
392
|
}
|
|
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
393
|
},
|
|
338
394
|
renderCall(args, theme) {
|
|
339
395
|
const suffix = args.id === undefined ? "" : ` #${args.id}`;
|
|
@@ -341,122 +397,256 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
341
397
|
},
|
|
342
398
|
});
|
|
343
399
|
|
|
400
|
+
async function startPlanRequest(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
401
|
+
if (mode === "build") {
|
|
402
|
+
const ok = ctx.hasUI
|
|
403
|
+
? await ctx.ui.confirm("Switch to plan mode?", "A build is in progress. Stop it and start planning?")
|
|
404
|
+
: true;
|
|
405
|
+
if (!ok) return;
|
|
406
|
+
}
|
|
407
|
+
const loaded = await loadPlanSource(args, ctx.cwd);
|
|
408
|
+
if (!loaded.ok) {
|
|
409
|
+
ctx.ui.notify(loaded.error, "error");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
let existingTask: string | undefined;
|
|
413
|
+
let existingPlan: string | undefined;
|
|
414
|
+
try {
|
|
415
|
+
existingTask = await readOptionalFile(taskFilePath(ctx.cwd));
|
|
416
|
+
existingPlan = await readOptionalFile(planFilePath(ctx.cwd));
|
|
417
|
+
} catch (error) {
|
|
418
|
+
ctx.ui.notify(`Could not inspect existing plan files: ${String(error)}`, "error");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const hasExistingWork = (existingTask !== undefined && hasPendingTasks(existingTask, true)) || (existingPlan !== undefined && hasPendingTasks(existingPlan, false));
|
|
422
|
+
if (hasExistingWork) {
|
|
423
|
+
if (!ctx.hasUI) { ctx.ui.notify("An incomplete plan already exists. Refusing to replace it in non-interactive mode.", "error"); return; }
|
|
424
|
+
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.");
|
|
425
|
+
if (!ok) return;
|
|
426
|
+
}
|
|
427
|
+
stateRestorable = true;
|
|
428
|
+
const config = await loadConfig(ctx.cwd);
|
|
429
|
+
planState = {
|
|
430
|
+
...initialState(config.executionMode),
|
|
431
|
+
status: "planning",
|
|
432
|
+
workId: contentKey(`${ctx.cwd}:${planRequest(loaded.source)}:${Date.now()}`).slice(0, 16),
|
|
433
|
+
planningRequest: planRequest(loaded.source),
|
|
434
|
+
planningBaselineHash: structureHash("", ""),
|
|
435
|
+
};
|
|
436
|
+
planState = await initializeDraftWithState(ctx.cwd, planState);
|
|
437
|
+
planSource = loaded.source;
|
|
438
|
+
await enterPlanMode(ctx);
|
|
439
|
+
const message = loaded.source.kind === "file"
|
|
440
|
+
? `Planning from ${loaded.source.displayPath}. Only draft plan files are writable.`
|
|
441
|
+
: "Plan mode enabled. Only draft plan files are writable.";
|
|
442
|
+
ctx.ui.notify(message, "info");
|
|
443
|
+
pi.sendUserMessage(planRequest(loaded.source));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function approvePlan(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
447
|
+
const persisted = await loadStateLocked(ctx.cwd);
|
|
448
|
+
if (persisted) planState = persisted;
|
|
449
|
+
if (args.trim()) {
|
|
450
|
+
ctx.ui.notify("Usage: /plan approve\nPlacement flags were removed. Approve first, then run /build or choose here/new/fresh when prompted.", "error");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (planState.status !== "ready") { ctx.ui.notify(`Plan is not awaiting approval (status: ${planState.status}).`, "warning"); return; }
|
|
454
|
+
const approvalConfig = await loadConfig(ctx.cwd);
|
|
455
|
+
const current = await readCurrentArtifacts(ctx.cwd);
|
|
456
|
+
const parsed = parseTaskMarkdown(current.task);
|
|
457
|
+
const validation = validatePlan(current.plan, current.task, parsed);
|
|
458
|
+
if (!validation.valid) { ctx.ui.notify(`Plan validation failed: ${validation.errors.join("; ")}`, "error"); return; }
|
|
459
|
+
const approvedStructureHash = structureHash(current.plan, current.task);
|
|
460
|
+
planState = transition(planState, "approved");
|
|
461
|
+
planState = { ...planState, approvedStructureHash, draftStructureHash: approvedStructureHash, executionMode: approvalConfig.executionMode, failureReason: undefined };
|
|
462
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
463
|
+
pi.events.emit("pi-plan-task:plan-approved", { version: 1, cwd: ctx.cwd, planPath: planFilePath(ctx.cwd), taskPath: taskFilePath(ctx.cwd), planHash: approvedStructureHash, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
464
|
+
leaveModes(ctx);
|
|
465
|
+
if (approvalConfig.executionMode === "external") {
|
|
466
|
+
ctx.ui.notify("Plan approved. External execution event emitted.", "info");
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (!ctx.hasUI) {
|
|
470
|
+
ctx.ui.notify("Plan approved. Run /build or /build all.", "info");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
awaitingChoice = true;
|
|
474
|
+
const choice = await ctx.ui.select("Plan approved. Where should the next task run?", ["here", "new", "fresh"]);
|
|
475
|
+
awaitingChoice = false;
|
|
476
|
+
if (choice === "new" || choice === "fresh") {
|
|
477
|
+
await startBuildInNewSession(ctx, BUILD_ONE_HERE_COMMAND, choice === "fresh");
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (choice === "here") {
|
|
481
|
+
await beginBuild(ctx, false);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
ctx.ui.notify("Plan approved. Run /build when you want to execute.", "info");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function rejectPlan(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
488
|
+
const persisted = await loadStateLocked(ctx.cwd);
|
|
489
|
+
if (persisted) planState = persisted;
|
|
490
|
+
if (!["ready", "approved", "blocked", "completed"].includes(planState.status)) {
|
|
491
|
+
ctx.ui.notify(`Plan cannot be rejected from ${planState.status}.`, "warning");
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const feedback = args.trim() || (ctx.hasUI ? await ctx.ui.input("Plan feedback", "Explain what should change") : "");
|
|
495
|
+
const current = await readCurrentArtifacts(ctx.cwd);
|
|
496
|
+
planState = transition(planState, "planning");
|
|
497
|
+
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." };
|
|
498
|
+
planState = await initializeDraftWithState(ctx.cwd, planState, current);
|
|
499
|
+
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() });
|
|
500
|
+
await enterPlanMode(ctx);
|
|
501
|
+
ctx.ui.notify("Plan rejected. Revise the draft and submit it again.", "info");
|
|
502
|
+
if (feedback) pi.sendUserMessage(`Revise the current draft with this review feedback:\n${feedback}`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function reviewPlan(ctx: ExtensionContext): Promise<void> {
|
|
506
|
+
try {
|
|
507
|
+
const result = await pi.exec("plannotator", ["annotate", planFilePath(ctx.cwd), "--markdown", "--json"]);
|
|
508
|
+
const review = normalizeReviewResult(result.stdout.trim());
|
|
509
|
+
if (review?.decision === "approved") { pi.sendUserMessage("/plan approve", { expandPromptTemplates: true }); return; }
|
|
510
|
+
if (review?.decision === "rejected" && review.feedback) { pi.sendUserMessage(`/plan reject ${review.feedback}`, { expandPromptTemplates: true }); return; }
|
|
511
|
+
if (result.code !== 0) ctx.ui.notify("Plannotator is unavailable or review failed; use /plan approve or /plan reject.", "warning");
|
|
512
|
+
} catch {
|
|
513
|
+
ctx.ui.notify("Plannotator is unavailable; use /plan approve or /plan reject.", "warning");
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const planSubcommands = ["approve", "reject", "request"];
|
|
344
518
|
pi.registerCommand("plan", {
|
|
345
|
-
description: "
|
|
519
|
+
description: "Create, approve, or reject a plan",
|
|
520
|
+
getArgumentCompletions: (prefix) => {
|
|
521
|
+
const items = planSubcommands.map((name) => ({ value: name, label: name }));
|
|
522
|
+
const filtered = items.filter((item) => item.value.startsWith(prefix.trim().toLowerCase()));
|
|
523
|
+
return filtered.length ? filtered : null;
|
|
524
|
+
},
|
|
346
525
|
handler: async (args, ctx) => {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const loaded = await loadPlanSource(args, ctx.cwd);
|
|
354
|
-
if (!loaded.ok) {
|
|
355
|
-
ctx.ui.notify(loaded.error, "error");
|
|
526
|
+
const trimmed = args.trim();
|
|
527
|
+
const [rawAction = "", ...rest] = trimmed.split(/\s+/);
|
|
528
|
+
const action = rawAction.toLowerCase();
|
|
529
|
+
const actionArgs = rest.join(" ");
|
|
530
|
+
if (action in REMOVED_PLAN_COMMANDS) {
|
|
531
|
+
ctx.ui.notify(REMOVED_PLAN_COMMANDS[action]!, "error");
|
|
356
532
|
return;
|
|
357
533
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
534
|
+
switch (action) {
|
|
535
|
+
case "approve": await approvePlan(actionArgs, ctx); return;
|
|
536
|
+
case "reject": await rejectPlan(actionArgs, ctx); return;
|
|
537
|
+
case "request": await startPlanRequest(actionArgs, ctx); return;
|
|
538
|
+
default: await startPlanRequest(args, ctx);
|
|
362
539
|
}
|
|
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
540
|
},
|
|
372
541
|
});
|
|
373
542
|
|
|
374
|
-
async function beginBuild(
|
|
375
|
-
ctx
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
543
|
+
async function beginBuild(ctx: ExtensionCommandContext, runAll: boolean): Promise<void> {
|
|
544
|
+
const persisted = await loadStateLocked(ctx.cwd);
|
|
545
|
+
if (!persisted) { ctx.ui.notify("No plan state found. Run /plan first.", "error"); return; }
|
|
546
|
+
planState = persisted;
|
|
547
|
+
const executionConfig = await loadConfig(ctx.cwd);
|
|
548
|
+
if (executionConfig.executionMode === "external") { ctx.ui.notify("External execution mode is enabled; waiting for an external executor.", "info"); return; }
|
|
549
|
+
if (pendingHandoff) {
|
|
550
|
+
const clean = pendingHandoff === "fresh";
|
|
551
|
+
pendingHandoff = undefined;
|
|
552
|
+
if (!runAll) {
|
|
553
|
+
const started = await startBuildInNewSession(ctx, BUILD_ONE_HERE_COMMAND, clean);
|
|
554
|
+
if (!started) {
|
|
555
|
+
planState = { ...planState, continueMode: undefined };
|
|
556
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
390
560
|
}
|
|
391
|
-
if (
|
|
392
|
-
ctx.ui.notify(
|
|
561
|
+
if (planState.status !== "approved" && planState.status !== "executing") {
|
|
562
|
+
ctx.ui.notify(`Plan must be approved before execution (status: ${planState.status}). Use /plan approve.`, "error");
|
|
393
563
|
return;
|
|
394
564
|
}
|
|
395
|
-
|
|
396
|
-
|
|
565
|
+
const file = await loadTaskFile(ctx.cwd);
|
|
566
|
+
if (!file || file.tasks.length === 0) { ctx.ui.notify("A valid task.md with checklist tasks is required.", "error"); return; }
|
|
567
|
+
if (!planState.approvedStructureHash) { ctx.ui.notify("Plan has no approved structure hash. Re-approve it.", "error"); return; }
|
|
568
|
+
try {
|
|
569
|
+
const current = await readCurrentArtifacts(ctx.cwd);
|
|
570
|
+
assertApprovedWorkflow(planState, current.plan, current.task);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
planState = transition(planState, "ready");
|
|
573
|
+
planState = { ...planState, approvedStructureHash: undefined, currentTaskId: undefined, failureReason: error instanceof Error ? error.message : String(error) };
|
|
574
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
575
|
+
ctx.ui.notify("Plan files or contract changed after approval. Review and approve again.", "error");
|
|
397
576
|
return;
|
|
398
577
|
}
|
|
399
|
-
|
|
578
|
+
const queue = classifyQueue(file, planState);
|
|
579
|
+
if (queue.kind === "all-complete") { await enterBuildMode(ctx); await startNextTask(ctx); return; }
|
|
580
|
+
if (queue.kind === "all-remaining-blocked") { await enterBuildMode(ctx); await startNextTask(ctx); return; }
|
|
581
|
+
|
|
582
|
+
const chainNew = planState.continueMode === "all-new" && !runAll;
|
|
583
|
+
const continueMode: PlanState["continueMode"] = chainNew ? "all-new" : runAll ? "all-here" : "single";
|
|
584
|
+
if (planState.status === "approved") planState = transition(planState, "executing");
|
|
585
|
+
planState = { ...planState, currentTaskId: undefined, approvalEachTask: false, continueMode };
|
|
586
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
587
|
+
continueAll = runAll && !chainNew;
|
|
400
588
|
continueNew = chainNew;
|
|
401
589
|
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
|
-
);
|
|
590
|
+
ctx.ui.notify(chainNew ? "Building the next task; remaining tasks will use new sessions." : runAll ? "Building remaining tasks." : "Building the next task.", "info");
|
|
410
591
|
await startNextTask(ctx);
|
|
411
592
|
}
|
|
412
593
|
|
|
413
594
|
pi.registerCommand("build", {
|
|
414
|
-
description: "Execute
|
|
415
|
-
|
|
416
|
-
|
|
595
|
+
description: "Execute one task or all remaining tasks in this session",
|
|
596
|
+
getArgumentCompletions: (prefix) => {
|
|
597
|
+
const token = prefix.trim().split(/\s+/).at(-1)?.toLowerCase() ?? "";
|
|
598
|
+
const items = [{ value: "all", label: "all" }];
|
|
599
|
+
const filtered = items.filter((item) => item.value.startsWith(token));
|
|
600
|
+
return filtered.length ? filtered : null;
|
|
417
601
|
},
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
pi.registerCommand("goal", {
|
|
421
|
-
description: "Execute remaining planned tasks until complete. Use /goal new for one new session per task",
|
|
422
602
|
handler: async (args, ctx) => {
|
|
423
|
-
const
|
|
424
|
-
if (
|
|
425
|
-
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
if (goalMode === "chain-here") {
|
|
429
|
-
await beginBuild(ctx, false, "here", { chainNew: true });
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
await beginBuild(ctx, true);
|
|
603
|
+
const parsed = parseBuildOptions(args);
|
|
604
|
+
if (!parsed.ok) { ctx.ui.notify(parsed.error, "error"); return; }
|
|
605
|
+
await beginBuild(ctx, parsed.options.scope === "all");
|
|
433
606
|
},
|
|
434
607
|
});
|
|
435
608
|
|
|
609
|
+
async function reworkTask(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
610
|
+
const id = Number(args.trim());
|
|
611
|
+
if (!Number.isInteger(id) || id <= 0) { ctx.ui.notify("Provide a positive task id.", "error"); return; }
|
|
612
|
+
try {
|
|
613
|
+
const result = await mutateWorkflow(ctx.cwd, ({ state, task }) => {
|
|
614
|
+
const file = { raw: task, tasks: parseTaskMarkdown(task) };
|
|
615
|
+
const mutation = reworkFromTask(state, file, id);
|
|
616
|
+
if (!mutation.ok) throw new Error(mutation.error);
|
|
617
|
+
return { state: mutation.value.state, task: mutation.value.task };
|
|
618
|
+
});
|
|
619
|
+
planState = result.state;
|
|
620
|
+
leaveModes(ctx);
|
|
621
|
+
ctx.ui.notify(`Task ${id} and all later tasks reopened. Run /plan approve before building.`, "info");
|
|
622
|
+
} catch (error) {
|
|
623
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
436
627
|
pi.registerCommand("tasks", {
|
|
437
|
-
description: "Show
|
|
438
|
-
|
|
628
|
+
description: "Show plan status and tasks, or reopen a task for rework",
|
|
629
|
+
getArgumentCompletions: (prefix) => {
|
|
630
|
+
const items = [{ value: "rework", label: "rework" }];
|
|
631
|
+
const filtered = items.filter((item) => item.value.startsWith(prefix.trim().toLowerCase()));
|
|
632
|
+
return filtered.length ? filtered : null;
|
|
633
|
+
},
|
|
634
|
+
handler: async (args, ctx) => {
|
|
635
|
+
const [rawAction = "", ...rest] = args.trim().split(/\s+/);
|
|
636
|
+
const action = rawAction.toLowerCase();
|
|
637
|
+
const actionArgs = rest.join(" ");
|
|
638
|
+
if (action === "review") { ctx.ui.notify("Removed. Run /tasks for status and the checklist, or use git diff for working-tree details.", "error"); return; }
|
|
639
|
+
if (action === "rework") { await reworkTask(actionArgs, ctx); return; }
|
|
640
|
+
if (action) { ctx.ui.notify("Usage: /tasks [rework <id>]", "error"); return; }
|
|
641
|
+
const persisted = await loadStateLocked(ctx.cwd);
|
|
642
|
+
if (persisted) planState = persisted;
|
|
439
643
|
const file = await loadTaskFile(ctx.cwd);
|
|
440
644
|
const tasks = file?.tasks ?? [];
|
|
441
645
|
if (ctx.mode !== "tui") {
|
|
442
|
-
ctx.ui.notify(
|
|
646
|
+
ctx.ui.notify(formatWorkflowStatus(planState, tasks), "info");
|
|
443
647
|
return;
|
|
444
648
|
}
|
|
445
|
-
await ctx.ui.custom<void>((_tui, theme, _kb, done) => new TaskListComponent(tasks, theme, () => done()));
|
|
446
|
-
},
|
|
447
|
-
});
|
|
448
|
-
|
|
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);
|
|
649
|
+
await ctx.ui.custom<void>((_tui, theme, _kb, done) => new TaskListComponent(tasks, theme, () => done(), planState.status));
|
|
460
650
|
},
|
|
461
651
|
});
|
|
462
652
|
|
|
@@ -464,27 +654,63 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
464
654
|
mode = "idle";
|
|
465
655
|
continueAll = false;
|
|
466
656
|
continueNew = false;
|
|
657
|
+
pendingHandoff = undefined;
|
|
467
658
|
currentTaskId = undefined;
|
|
468
|
-
|
|
659
|
+
planToolsDelta = undefined;
|
|
469
660
|
awaitingChoice = false;
|
|
470
661
|
planReadyNotified = false;
|
|
471
662
|
planSource = EMPTY_PLAN_SOURCE;
|
|
472
663
|
resetFraming();
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
ctx.
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
664
|
+
stateRestorable = true;
|
|
665
|
+
try {
|
|
666
|
+
const persisted = await loadStateLocked(ctx.cwd);
|
|
667
|
+
if (persisted) {
|
|
668
|
+
planState = persisted;
|
|
669
|
+
currentTaskId = persisted.currentTaskId;
|
|
670
|
+
continueAll = persisted.continueMode === "all-here";
|
|
671
|
+
continueNew = persisted.continueMode === "all-new";
|
|
672
|
+
if (["approved", "executing", "blocked"].includes(persisted.status) && persisted.approvedStructureHash) {
|
|
673
|
+
try {
|
|
674
|
+
const current = await readCurrentArtifacts(ctx.cwd);
|
|
675
|
+
assertApprovedWorkflow(persisted, current.plan, current.task);
|
|
676
|
+
if (persisted.status === "executing" && persisted.currentTaskId !== undefined) await enterBuildMode(ctx);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
planState = transition(persisted, "ready");
|
|
679
|
+
planState = { ...planState, approvedStructureHash: undefined, currentTaskId: undefined, failureReason: error instanceof Error ? error.message : String(error) };
|
|
680
|
+
currentTaskId = undefined;
|
|
681
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
682
|
+
ctx.ui.notify("Plan files or contract changed after approval; approval was revoked.", "warning");
|
|
683
|
+
}
|
|
684
|
+
} else if (persisted.status === "planning") {
|
|
685
|
+
planSource = persisted.planningRequest ? { kind: "prompt", prompt: persisted.planningRequest } : EMPTY_PLAN_SOURCE;
|
|
686
|
+
await enterPlanMode(ctx);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
} catch (error) {
|
|
690
|
+
planState = initialState();
|
|
691
|
+
stateRestorable = false;
|
|
692
|
+
ctx.ui.notify(`Workflow state could not be restored; execution is disabled: ${String(error)}`, "error");
|
|
479
693
|
}
|
|
480
|
-
|
|
694
|
+
pi.setActiveTools(withAlwaysOnTools(pi.getActiveTools()));
|
|
695
|
+
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");
|
|
696
|
+
const file = await loadTaskFile(ctx.cwd).catch(() => undefined);
|
|
697
|
+
updateStatus(ctx, file?.tasks);
|
|
481
698
|
});
|
|
699
|
+
async function persistSession(ctx: ExtensionContext): Promise<void> {
|
|
700
|
+
if (!stateRestorable) return;
|
|
701
|
+
await saveStateLocked(ctx.cwd, { ...planState, currentTaskId, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
702
|
+
}
|
|
703
|
+
pi.on("session_shutdown", async (_event, ctx) => persistSession(ctx));
|
|
704
|
+
pi.on("session_before_switch", async (_event, ctx) => persistSession(ctx));
|
|
705
|
+
pi.on("session_before_fork", async (_event, ctx) => persistSession(ctx));
|
|
482
706
|
|
|
483
707
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
484
708
|
let statusKey: string | undefined;
|
|
485
709
|
let task: TaskItem | undefined;
|
|
486
710
|
let tasks: TaskItem[] | undefined;
|
|
487
711
|
let remaining = 0;
|
|
712
|
+
let planContent = "";
|
|
713
|
+
let planKey = "";
|
|
488
714
|
if (mode === "build" && currentTaskId !== undefined) {
|
|
489
715
|
const file = await loadTaskFile(ctx.cwd);
|
|
490
716
|
task = file?.tasks.find((item) => item.id === currentTaskId);
|
|
@@ -492,9 +718,15 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
492
718
|
tasks = file.tasks;
|
|
493
719
|
statusKey = buildStatusKey(file.tasks, currentTaskId);
|
|
494
720
|
remaining = file.tasks.filter((item) => !item.done).length;
|
|
721
|
+
try {
|
|
722
|
+
planContent = (await readOptionalFile(planFilePath(ctx.cwd))) ?? "";
|
|
723
|
+
} catch (error) {
|
|
724
|
+
ctx.ui.notify(`Could not read ${planFilePath(ctx.cwd)}: ${String(error)}`, "warning");
|
|
725
|
+
}
|
|
726
|
+
planKey = contentKey(planContent);
|
|
495
727
|
}
|
|
496
728
|
}
|
|
497
|
-
const kind = nextInjection(mode, framing, currentTaskId, statusKey);
|
|
729
|
+
const kind = nextInjection(mode, framing, currentTaskId, statusKey, planKey);
|
|
498
730
|
if (!kind) return;
|
|
499
731
|
if (kind === "plan-framing") {
|
|
500
732
|
framing = rememberInjection(framing, kind);
|
|
@@ -508,12 +740,12 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
508
740
|
};
|
|
509
741
|
}
|
|
510
742
|
if (!task || !tasks || currentTaskId === undefined) return;
|
|
511
|
-
framing = rememberInjection(framing, kind, currentTaskId, statusKey);
|
|
743
|
+
framing = rememberInjection(framing, kind, currentTaskId, statusKey, planKey);
|
|
512
744
|
if (kind === "build-framing") {
|
|
513
745
|
return {
|
|
514
746
|
message: {
|
|
515
747
|
customType: BUILD_FRAMING_TYPE,
|
|
516
|
-
content: buildPrompt(task, remaining, continueAll),
|
|
748
|
+
content: buildPrompt(task, remaining, continueAll, planContent, planState.tasks?.[String(currentTaskId)]?.status === "implementation-complete"),
|
|
517
749
|
display: false,
|
|
518
750
|
details: { phase: "build", taskId: currentTaskId },
|
|
519
751
|
},
|
|
@@ -535,35 +767,81 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
535
767
|
|
|
536
768
|
pi.on("tool_call", async (event, ctx) => {
|
|
537
769
|
if (mode !== "plan") return;
|
|
538
|
-
if (
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
return;
|
|
770
|
+
if (event.toolName === "plan_task" && (event.input as { action?: unknown }).action === "complete") {
|
|
771
|
+
return {
|
|
772
|
+
block: true,
|
|
773
|
+
reason: "Plan mode cannot mark implementation tasks complete. Use /build after the plan is approved.",
|
|
774
|
+
};
|
|
546
775
|
}
|
|
547
776
|
if (event.toolName === "write" || event.toolName === "edit") {
|
|
548
777
|
const path = pathFromInput(event.input);
|
|
549
778
|
if (!path || !isPlanArtifactPath(ctx.cwd, path)) {
|
|
550
779
|
return {
|
|
551
780
|
block: true,
|
|
552
|
-
reason: `Plan mode can only write ${
|
|
781
|
+
reason: `Plan mode can only write ${draftPlanFilePath(ctx.cwd)} and ${draftTaskFilePath(ctx.cwd)}.`,
|
|
553
782
|
};
|
|
554
783
|
}
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (!isAllowedPlanTool(event.toolName)) {
|
|
787
|
+
return {
|
|
788
|
+
block: true,
|
|
789
|
+
reason: `Plan mode blocked non-read-only tool: ${event.toolName}. Use /build to implement.`,
|
|
790
|
+
};
|
|
555
791
|
}
|
|
556
792
|
});
|
|
557
793
|
|
|
558
794
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
559
795
|
if (mode === "plan") {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
796
|
+
if (planReadyNotified) return;
|
|
797
|
+
let draft: { plan: string; task: string };
|
|
798
|
+
try { draft = await readDraftArtifacts(ctx.cwd); } catch { return; }
|
|
799
|
+
if (!draft.plan.trim() || !draft.task.trim()) return;
|
|
800
|
+
const draftHash = structureHash(draft.plan, draft.task);
|
|
801
|
+
if (draftHash === planState.planningBaselineHash) {
|
|
802
|
+
ctx.ui.notify("Draft is unchanged; update both draft files before submission.", "warning");
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
let tasks: TaskItem[];
|
|
806
|
+
try { tasks = parseTaskMarkdown(draft.task); } catch (error) { ctx.ui.notify(`Draft task file is invalid: ${String(error)}`, "error"); return; }
|
|
807
|
+
const previous = await loadTaskFile(ctx.cwd).catch(() => undefined);
|
|
808
|
+
let submittedTask = draft.task;
|
|
809
|
+
const runtimeTasks: NonNullable<PlanState["tasks"]> = {};
|
|
810
|
+
for (const task of tasks) {
|
|
811
|
+
const old = previous?.tasks.find((item) => item.id === task.id);
|
|
812
|
+
const oldRuntime = planState.tasks?.[String(task.id)];
|
|
813
|
+
const canPreserveVerified = task.done && old?.done && old.title === task.title && old.body === task.body && oldRuntime?.status === "verified" && Boolean(oldRuntime.verification?.trim());
|
|
814
|
+
if (canPreserveVerified) runtimeTasks[String(task.id)] = oldRuntime!;
|
|
815
|
+
else {
|
|
816
|
+
if (task.done) submittedTask = markTaskPendingInMarkdown(submittedTask, task.id);
|
|
817
|
+
runtimeTasks[String(task.id)] = { status: "pending" };
|
|
818
|
+
}
|
|
564
819
|
}
|
|
820
|
+
tasks = parseTaskMarkdown(submittedTask);
|
|
821
|
+
const validation = validatePlan(draft.plan, submittedTask, tasks);
|
|
822
|
+
if (!validation.valid) { ctx.ui.notify(`Plan validation failed: ${validation.errors.join("; ")}`, "error"); return; }
|
|
823
|
+
planState = transition(planState, "ready");
|
|
824
|
+
planState = { ...planState, planningBaselineHash: undefined, tasks: runtimeTasks, currentTaskId: undefined, approvedStructureHash: undefined, failureReason: undefined };
|
|
825
|
+
planState = await commitDraft(ctx.cwd, planState, { plan: draft.plan, task: submittedTask });
|
|
826
|
+
planReadyNotified = true;
|
|
827
|
+
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() });
|
|
828
|
+
if (!ctx.hasUI) {
|
|
829
|
+
ctx.ui.notify(`Plan written. ${formatProgress(tasks)} tasks ready. Run /plan approve.`, "info");
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
awaitingChoice = true;
|
|
833
|
+
const choice = await ctx.ui.select(`Plan ready (${formatProgress(tasks)}). What next?`, [
|
|
834
|
+
"approve", "review", "reject", "later",
|
|
835
|
+
]);
|
|
836
|
+
awaitingChoice = false;
|
|
837
|
+
if (choice === "approve") pi.sendUserMessage("/plan approve", { expandPromptTemplates: true });
|
|
838
|
+
else if (choice === "review") await reviewPlan(ctx);
|
|
839
|
+
else if (choice === "reject") pi.sendUserMessage("/plan reject", { expandPromptTemplates: true });
|
|
840
|
+
else ctx.ui.notify("Plan is ready. Run /plan approve when you want to execute it.", "info");
|
|
565
841
|
return;
|
|
566
842
|
}
|
|
567
843
|
await afterBuildSettled(ctx);
|
|
568
844
|
});
|
|
569
845
|
}
|
|
846
|
+
|
|
847
|
+
|