pi-plan-task 1.1.0 → 3.0.1

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