pi-plan-task 1.0.2 → 3.0.1

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