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.
Files changed (38) hide show
  1. package/README.md +535 -68
  2. package/extensions/build-session.test.ts +25 -27
  3. package/extensions/build-session.ts +26 -11
  4. package/extensions/command-surface.test.ts +15 -0
  5. package/extensions/config.ts +12 -40
  6. package/extensions/files.test.ts +21 -1
  7. package/extensions/files.ts +11 -27
  8. package/extensions/framing.test.ts +1 -0
  9. package/extensions/framing.ts +5 -2
  10. package/extensions/history.test.ts +24 -0
  11. package/extensions/history.ts +42 -0
  12. package/extensions/index.ts +523 -245
  13. package/extensions/integration.test.ts +231 -0
  14. package/extensions/migration.test.ts +34 -0
  15. package/extensions/parse.ts +80 -4
  16. package/extensions/paths.ts +26 -32
  17. package/extensions/planning-and-task-breakdown.md +10 -8
  18. package/extensions/planning-method.test.ts +15 -2
  19. package/extensions/planning-method.ts +4 -2
  20. package/extensions/policy.test.ts +11 -0
  21. package/extensions/prompts.test.ts +54 -9
  22. package/extensions/prompts.ts +100 -32
  23. package/extensions/recovery.test.ts +15 -0
  24. package/extensions/review.test.ts +19 -0
  25. package/extensions/review.ts +53 -0
  26. package/extensions/state.test.ts +25 -0
  27. package/extensions/state.ts +198 -0
  28. package/extensions/task-ui.ts +59 -0
  29. package/extensions/tool-policy.ts +4 -0
  30. package/extensions/tools.test.ts +10 -0
  31. package/extensions/tools.ts +16 -0
  32. package/extensions/types.ts +53 -5
  33. package/extensions/validation.test.ts +38 -0
  34. package/extensions/workflow-policy.test.ts +76 -0
  35. package/extensions/workflow-policy.ts +117 -0
  36. package/extensions/workflow-store.test.ts +48 -0
  37. package/extensions/workflow-store.ts +165 -0
  38. package/package.json +29 -5
@@ -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 { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
8
+ import { Text } from "@earendil-works/pi-tui";
9
9
  import { Type } from "typebox";
10
- import { isSafePlanCommand } from "./bash-guard.ts";
11
- import { parseBuildPlacement, parseGoalMode, type BuildPlacement } from "./build-session.ts";
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 { isPlanArtifactPath, planFilePath, taskFilePath } from "./paths.ts";
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 BUILD_HERE_COMMAND = "/build here";
41
- const GOAL_CONTINUE_COMMAND = "/goal continue";
42
-
43
- async function startBuildInNewSession(ctx: ExtensionCommandContext, command = BUILD_HERE_COMMAND): Promise<void> {
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 formatTaskList(tasks: TaskItem[]): string {
73
- if (tasks.length === 0) return "No tasks found in .plan_task/task.md.";
74
- const lines = tasks.map((task) => {
75
- const mark = task.done ? "[x]" : "[ ]";
76
- return `${mark} ${task.id}. ${task.title}`;
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 toolsBeforePlan: string[] | undefined;
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 (toolsBeforePlan === undefined) {
172
- toolsBeforePlan = pi.getActiveTools();
173
- }
142
+ if (planToolsDelta) return;
174
143
  const config = await loadConfig(ctx.cwd);
175
- pi.setActiveTools(withAlwaysOnTools([...config.planTools, "write", "edit"]));
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 (toolsBeforePlan) {
180
- pi.setActiveTools(withAlwaysOnTools(toolsBeforePlan));
181
- toolsBeforePlan = undefined;
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
- const next = nextPendingTask(file.tasks);
225
- if (!next) {
226
- ctx.ui.notify("All tasks are complete.", "info");
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
- ctx.ui.notify(
248
- `Task ${currentTaskId} is not marked complete. Update .plan_task/task.md or call plan_task, then run /build again.`,
249
- "warning",
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
- ctx.ui.notify("All tasks are complete.", "info");
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
- mode = "idle";
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
- mode = "idle";
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
- mode = "idle";
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
- mode = "idle";
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
- action: StringEnum(["status", "complete"] as const),
307
- id: Type.Optional(Type.Number({ description: "Task id to mark complete" })),
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
- return { content: [{ type: "text", text: "No .plan_task/task.md found. Run /plan first." }] };
313
- }
314
- if (params.action === "status") {
315
- return { content: [{ type: "text", text: formatTaskList(file.tasks) }], details: { tasks: file.tasks } };
316
- }
317
- if (params.id === undefined) {
318
- return { content: [{ type: "text", text: "id is required for complete" }] };
319
- }
320
- const next = await markTaskComplete(ctx.cwd, params.id);
321
- if (!next) {
322
- return { content: [{ type: "text", text: "Could not update .plan_task/task.md" }] };
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: "Write .plan_task/plan.md and .plan_task/task.md from a file path or prompt",
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
- if (mode === "build") {
348
- const ok = ctx.hasUI
349
- ? await ctx.ui.confirm("Switch to plan mode?", "A build is in progress. Stop it and start planning?")
350
- : true;
351
- if (!ok) return;
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
- 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;
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: ExtensionCommandContext,
376
- runAll: boolean,
377
- placement: BuildPlacement = "here",
378
- options: { chainNew?: boolean; forkCommand?: string } = {},
379
- ): Promise<void> {
380
- const chainNew = options.chainNew === true;
381
- const forkCommand = options.forkCommand ?? BUILD_HERE_COMMAND;
382
- const file = await loadTaskFile(ctx.cwd);
383
- if (!file) {
384
- ctx.ui.notify("No plan found. Run /plan first.", "error");
385
- return;
386
- }
387
- if (file.tasks.length === 0) {
388
- ctx.ui.notify("task.md has no checklist items.", "error");
389
- return;
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 (!nextPendingTask(file.tasks)) {
392
- ctx.ui.notify("All tasks are complete.", "info");
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
- if (!runAll && !chainNew && placement === "new") {
396
- await startBuildInNewSession(ctx, forkCommand);
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
- continueAll = runAll;
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 the next planned task in this session. Use /build new to start it in a new session",
415
- handler: async (args, ctx) => {
416
- await beginBuild(ctx, false, parseBuildPlacement(args));
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 goalMode = parseGoalMode(args);
424
- if (goalMode === "all-new") {
425
- await beginBuild(ctx, false, "new", { forkCommand: GOAL_CONTINUE_COMMAND });
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 current plan tasks and progress",
438
- handler: async (_args, ctx) => {
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(formatTaskList(tasks), "info");
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
- toolsBeforePlan = undefined;
659
+ planToolsDelta = undefined;
469
660
  awaitingChoice = false;
470
661
  planReadyNotified = false;
471
662
  planSource = EMPTY_PLAN_SOURCE;
472
663
  resetFraming();
473
- pi.setActiveTools(withAlwaysOnTools(pi.getActiveTools()));
474
- if (!pi.getAllTools().some((tool) => tool.name === "ask_user_question")) {
475
- ctx.ui.notify(
476
- "ask_user_question is missing. Install npm:@juicesharp/rpiv-ask-user-question.",
477
- "warning",
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
- updateStatus(ctx);
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 (isToolCallEventType("bash", event)) {
539
- if (!isSafePlanCommand(event.input.command)) {
540
- return {
541
- block: true,
542
- reason: `Plan mode blocked this command. Use /build to implement.\nCommand: ${event.input.command}`,
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 ${planFilePath(ctx.cwd)} and ${taskFilePath(ctx.cwd)}.`,
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
- const file = await loadTaskFile(ctx.cwd);
561
- if (file && file.tasks.length > 0 && !planReadyNotified) {
562
- planReadyNotified = true;
563
- ctx.ui.notify(`Plan written. ${formatProgress(file.tasks)} tasks ready. Run /build to start.`, "info");
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
+