@janvitos/pi-plan-build 0.1.51 → 0.1.54

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 (4) hide show
  1. package/README.md +14 -4
  2. package/index.ts +100 -11
  3. package/package.json +2 -2
  4. package/utils.ts +17 -2
package/README.md CHANGED
@@ -29,7 +29,7 @@ Optionally keep the approved plan visible in a docked side panel while implement
29
29
  - The custom composer uses OpenCode prompt-inspired blue/orange mode colors on the rounded top-left border and left rail, complemented by Pi's border color on the right rail and rounded bottom-right border; rounded corners inherit their vertical rail colors while horizontal `╌` segments bridge the borders at both junctions, paired with a light vertical `┆` at the top right and a mode-specific bottom-left transition: thin `┆` in Plan and heavy `┇` in Build. The active composer and submitted user messages use a continuous solid thin `│` left rail in both modes. These rails use the active Pi theme's `warning` color in Plan and `thinkingLow` color in Build, and submitted messages retain their original mode after mode changes and session restores. Its metadata row shows mode, model, provider, and thinking level; cycling the thinking level updates that row directly.
30
30
  - Pi Plan Build leaves the footer untouched; Pi or another installed extension remains responsible for path, usage, model, provider, thinking, and extension-status information.
31
31
  - `/plan`, `/build`, and the `--plan` startup flag.
32
- - Per-session plans at `~/.pi/agent/plans/<session-id>.md`.
32
+ - Per-task plans at `~/.pi/agent/plans/<session-id>-001.md`, `-002.md`, etc., with the active plan and completion status persisted in the session. Existing `<session-id>.md` plans remain usable.
33
33
  - In Plan mode, built-in `edit` and `write` are restricted to the exact plan file.
34
34
  - Interactive `question`, `plan_enter`, and `plan_exit` tools.
35
35
  - Plan mode supports read-only conversation and research across multiple turns, then persists the final plan when it is ready for approval.
@@ -41,7 +41,7 @@ Optionally keep the approved plan visible in a docked side panel while implement
41
41
  - **Experimental:** In fullscreen TUI, valid checklist plans also offer **Implement step by step** when Pi Plan Build owns the optional fullscreen layout: a passive, non-overlapping docked right panel keeps the plan visible while natural-language prompts gate steps and report completed work. The panel is visual-only and never captures keyboard input. This feature is still under active development.
42
42
  - Compatible editor decorators can wrap Pi Plan Build's editor without disabling its composer. If another extension replaces rather than invokes that editor, already owns the editor before Plan Build starts, or replaces the fullscreen layout, Plan Build automatically uses reduced UI: it keeps the core Plan/Build workflow and mode status but does not replace that editor or offer a new step-by-step panel.
43
43
  - Staying in Plan mode—or pressing Escape in the approval dialog—produces a durable acknowledgement and stops the run until the user responds.
44
- - Mode state and mode-specific tool availability survive compactions, reloads, resumes, and forks.
44
+ - Plan mode state survives compactions, reloads, resumes, and forks.
45
45
  - When Pi Plan Build's custom editor is active and Pi recreates it, the latest 100 user prompts from the active session branch are restored for Up/Down history navigation.
46
46
 
47
47
  ## Requirements
@@ -82,13 +82,23 @@ Do not install more than one npm, Git, or local copy at the same time; duplicate
82
82
  | --- | --- |
83
83
  | `Alt+M` | Cycle Build and Plan in any TUI setup |
84
84
  | `Tab` | With the custom composer active, cycle modes or accept an active autocomplete selection |
85
- | `/plan` | Select Plan mode |
85
+ | `/plan` | Resume the unfinished plan, or select a new plan file after completion |
86
+ | `/plan new` | Start a separate task in Plan mode, preserving previous plan files and clearing previous step execution |
87
+ | `/plan done` | In Build mode, explicitly mark the saved plan's implementation complete |
86
88
  | `/build` | Select Build mode |
87
89
  | `pi --plan` | Start a new session in Plan mode |
88
90
  | `/build-fresh` | Start a pending clean-session implementation manually |
89
91
 
90
92
  The agent may also enter Plan mode with `plan_enter` when planning or investigation is safer than immediate execution.
91
93
 
94
+ ### Plan lifecycle
95
+
96
+ One task uses one plan file across discussion, revisions, approval, and temporary mode changes. Approval or the end of an agent turn does **not** mark implementation complete. For normal Build execution, the agent calls `plan_complete` after finishing implementation and required verification; `/plan done` is the manual equivalent. Completing the final step in step-by-step execution also marks the plan complete. The next entry into Plan mode selects a new numbered file without modifying the old one.
97
+
98
+ Use `/plan new` for an unrelated task before the current plan is finished. It preserves existing files but clears the previous step-by-step execution and pending fresh-session handoff. `/plan new` and `/plan done` require an idle agent. Cancelling step execution does not mark a plan complete.
99
+
100
+ Plan files are written only when finalizing or revising, not merely when selecting a new task. Reload/resume restores the active file and lifecycle; forks copy the tracked plan into the child session's own file. Legacy unnumbered files are retained without renaming.
101
+
92
102
  ### Plan approval
93
103
 
94
104
  When planning is complete, `plan_exit` displays the entire persisted plan and asks whether to:
@@ -136,7 +146,7 @@ The integration uses Pi 0.84.2's public fullscreen layout primitives plus a guar
136
146
 
137
147
  Normal tools remain visible so the model can inspect the project. While a Plan run is active:
138
148
 
139
- - `edit` and `write` are permitted only for the canonical session plan file;
149
+ - `edit` and `write` are permitted only for the active task's plan file;
140
150
  - the Plan prompt reserves those mutations for finalizing or explicitly revising the plan, not ordinary conversation or research;
141
151
  - other `edit` and `write` calls are blocked by the extension;
142
152
  - bash is not restricted at the permission layer, but the Plan prompt explicitly permits read-only exploration only.
package/index.ts CHANGED
@@ -39,6 +39,8 @@ import {
39
39
  classifyPlanExitChoice,
40
40
  type FreshImplementationRequest,
41
41
  decodeModeState,
42
+ decodePlanLifecycle,
43
+ type PlanLifecycle,
42
44
  extractPromptHistory,
43
45
  formatModeMetadata,
44
46
  formatModeRail,
@@ -71,7 +73,7 @@ const STATUS_KEY = "pi-plan-build-mode";
71
73
  const PLAN_STEP_CHOICE = "Implement step by step";
72
74
  const PANEL_WIDTH = 64;
73
75
  const PANEL_MIN_TERMINAL_WIDTH = 132;
74
- const MANAGED_TOOLS = new Set(["question", "plan_enter", "plan_exit", "plan_step_control", "plan_step_complete"]);
76
+ const MANAGED_TOOLS = new Set(["question", "plan_enter", "plan_exit", "plan_step_control", "plan_step_complete", "plan_complete"]);
75
77
  const MODE_ADDED_TOOLS = new Set([...MANAGED_TOOLS, "edit", "write"]);
76
78
  const EMPTY_PARAMETERS = Type.Object({});
77
79
 
@@ -83,6 +85,8 @@ interface StoredState {
83
85
  pendingReminder?: "plan" | "build";
84
86
  toolsBeforeModes?: string[];
85
87
  execution?: PlanExecutionState;
88
+ plan?: PlanLifecycle;
89
+ planSessionId?: string;
86
90
  }
87
91
 
88
92
  function shorten(filePath: string, cwd: string): string {
@@ -97,6 +101,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
97
101
  let runMode: Mode | undefined;
98
102
  let pendingReminder: PendingReminder;
99
103
  let planPath = "";
104
+ let planLifecycle: PlanLifecycle = { sequence: 1, status: "open" };
100
105
  let toolsBeforeModes: string[] = [];
101
106
  let currentContext: ExtensionContext | undefined;
102
107
  let requestEditorRender: (() => void) | undefined;
@@ -154,7 +159,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
154
159
  pi.registerEntryRenderer(PLAN_STEP_GUIDANCE_ENTRY_TYPE, renderPlanStepGuidance);
155
160
 
156
161
  function stateData(): StoredState {
157
- return { version: 1, selectedMode, pendingReminder, toolsBeforeModes, ...(execution ? { execution } : {}) };
162
+ return { version: 1, selectedMode, pendingReminder, toolsBeforeModes, plan: { ...planLifecycle }, planSessionId: currentContext?.sessionManager.getSessionId(), ...(execution ? { execution } : {}) };
158
163
  }
159
164
 
160
165
  function persist(): void {
@@ -236,6 +241,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
236
241
  return undefined;
237
242
  }
238
243
  const summary = formatPlanCompletionSummary(next);
244
+ planLifecycle = { ...planLifecycle, status: "completed" };
239
245
  execution = undefined;
240
246
  removePanelLayout();
241
247
  persist();
@@ -298,6 +304,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
298
304
  ...base,
299
305
  "question",
300
306
  "plan_enter",
307
+ ...(!execution && planLifecycle.status === "open" && fs.existsSync(planPath) ? ["plan_complete"] : []),
301
308
  ...(execution && execution.status !== "completed" ? ["plan_step_control"] : []),
302
309
  ...(activePlanStep(execution) ? ["plan_step_complete"] : []),
303
310
  ]));
@@ -314,7 +321,36 @@ export default function planBuildModes(pi: ExtensionAPI): void {
314
321
  : `No plan file exists yet. When ready to finalize, create your plan at ${planPath} using the write tool.`;
315
322
  }
316
323
 
324
+ function startNewPlan(ctx: ExtensionContext): void {
325
+ let sequence = planLifecycle.sequence;
326
+ for (const entry of ctx.sessionManager.getEntries()) {
327
+ if (entry.type !== "custom" || entry.customType !== STATE_TYPE) continue;
328
+ const saved = decodePlanLifecycle((entry.data as StoredState | undefined)?.plan);
329
+ if (saved) sequence = Math.max(sequence, saved.sequence);
330
+ }
331
+ do {
332
+ planPath = makePlanPath(path.join(getAgentDir(), "plans"), ctx.sessionManager.getSessionId(), ++sequence);
333
+ } while (fs.existsSync(planPath));
334
+ planLifecycle = { sequence, status: "open" };
335
+ freshImplementationRequest = undefined;
336
+ execution = undefined;
337
+ removePanelLayout();
338
+ pendingReminder = "plan";
339
+ persist();
340
+ }
341
+
342
+ function completeCurrentPlan(): void {
343
+ if ((runMode ?? selectedMode) !== "build") throw new Error("Switch to Build mode before completing implementation");
344
+ if (execution) throw new Error("Complete or cancel the step-by-step execution first");
345
+ if (!fs.existsSync(planPath)) throw new Error("No saved plan to complete");
346
+ planLifecycle = { ...planLifecycle, status: "completed" };
347
+ freshImplementationRequest = undefined;
348
+ persist();
349
+ applyTools("build");
350
+ }
351
+
317
352
  async function selectMode(mode: Mode, ctx: ExtensionContext, source: "manual" | "tool"): Promise<void> {
353
+ if (mode === "plan" && planLifecycle.status === "completed" && (source === "tool" || ctx.isIdle())) startNewPlan(ctx);
318
354
  if (mode === selectedMode && (source === "manual" || mode === runMode)) return;
319
355
  const previous = selectedMode;
320
356
  if (mode === "plan") await ensurePlanDirectory();
@@ -336,8 +372,32 @@ export default function planBuildModes(pi: ExtensionAPI): void {
336
372
  }
337
373
 
338
374
  pi.registerCommand("plan", {
339
- description: "Switch to Plan mode",
340
- handler: async (_args, ctx) => selectMode("plan", ctx, "manual"),
375
+ description: "Switch to Plan mode; new starts a separate task, done marks implementation complete",
376
+ getArgumentCompletions: (prefix) => ["new", "done"].filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value })),
377
+ handler: async (args, ctx) => {
378
+ const action = args.trim();
379
+ if (!action) return selectMode("plan", ctx, "manual");
380
+ if (action !== "new" && action !== "done") {
381
+ ctx.ui.notify("Usage: /plan [new|done]", "warning");
382
+ return;
383
+ }
384
+ if (!ctx.isIdle()) {
385
+ ctx.ui.notify("Wait for the agent to finish before changing the active plan.", "warning");
386
+ return;
387
+ }
388
+ if (action === "done") {
389
+ try {
390
+ completeCurrentPlan();
391
+ ctx.ui.notify("Plan completed. The next planning task will use a new file.", "info");
392
+ } catch (error) {
393
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
394
+ }
395
+ return;
396
+ }
397
+ startNewPlan(ctx);
398
+ await selectMode("plan", ctx, "manual");
399
+ ctx.ui.notify(`New plan: ${shorten(planPath, ctx.cwd)}. Previous plan files are preserved.`, "info");
400
+ },
341
401
  });
342
402
  pi.registerCommand("build", {
343
403
  description: "Switch to Build mode",
@@ -402,6 +462,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
402
462
  destinationPlanPath = makePlanPath(
403
463
  path.join(getAgentDir(), "plans"),
404
464
  sessionManager.getSessionId(),
465
+ 1,
405
466
  );
406
467
  await fs.promises.mkdir(path.dirname(destinationPlanPath), { recursive: true });
407
468
  await fs.promises.writeFile(destinationPlanPath, request.plan, "utf8");
@@ -412,6 +473,8 @@ export default function planBuildModes(pi: ExtensionAPI): void {
412
473
  selectedMode: "build",
413
474
  pendingReminder: "build",
414
475
  toolsBeforeModes: sourceTools,
476
+ plan: { sequence: 1, status: "open" },
477
+ planSessionId: sessionManager.getSessionId(),
415
478
  } satisfies StoredState);
416
479
  } catch (error: unknown) {
417
480
  setupError = error instanceof Error ? error.message : String(error);
@@ -458,6 +521,22 @@ export default function planBuildModes(pi: ExtensionAPI): void {
458
521
  },
459
522
  });
460
523
 
524
+ pi.registerTool({
525
+ name: "plan_complete",
526
+ label: "Complete Plan",
527
+ description: "Mark the current saved plan complete only after its implementation and required verification are finished, or the user explicitly confirms completion. Do not call for partial work, pauses, errors, or merely approving a plan. Preserves the plan file; the next planning task gets a new file.",
528
+ promptGuidelines: ["Call plan_complete when the current saved plan has been fully implemented and verified; do not infer completion merely from the end of a turn."],
529
+ parameters: EMPTY_PARAMETERS,
530
+ executionMode: "sequential",
531
+ async execute() {
532
+ completeCurrentPlan();
533
+ return {
534
+ content: [{ type: "text", text: "Plan marked complete and preserved. Summarize the implementation and verification for the user." }],
535
+ details: { planPath, completed: true },
536
+ };
537
+ },
538
+ });
539
+
461
540
  pi.registerTool({
462
541
  name: "plan_enter",
463
542
  label: "Enter Plan Mode",
@@ -606,6 +685,8 @@ export default function planBuildModes(pi: ExtensionAPI): void {
606
685
  name: "plan_exit",
607
686
  label: "Exit Plan Mode",
608
687
  description: PLAN_EXIT_DESCRIPTION,
688
+ promptSnippet: "Display the saved plan and request user approval",
689
+ promptGuidelines: ["Call plan_exit after finalizing the saved plan when the user asks to show, review, or approve it."],
609
690
  parameters: EMPTY_PARAMETERS,
610
691
  executionMode: "sequential",
611
692
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
@@ -733,6 +814,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
733
814
  applyTools(runMode);
734
815
  let content: string | undefined;
735
816
  if (runMode === "plan") {
817
+ if (planLifecycle.status === "completed") startNewPlan(ctx);
736
818
  await ensurePlanDirectory();
737
819
  content = buildPlanReminder(describePlanFile());
738
820
  } else if (activePlanStep(execution)) {
@@ -742,7 +824,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
742
824
  content = buildPlanStepWaitingReminder(execution.steps.map((step, index) => `${index + 1}. [${step.status}] ${step.text}`).join("\n"));
743
825
  } else if (pendingReminder === "build") {
744
826
  content = PLAN_TO_BUILD_REMINDER;
745
- if (fs.existsSync(planPath)) content += `\n\nA plan file exists at ${planPath}. You should execute the plan defined within it.`;
827
+ if (planLifecycle.status === "open" && fs.existsSync(planPath)) content += `\n\nA plan file exists at ${planPath}. You should execute the plan defined within it.`;
746
828
  }
747
829
  pendingReminder = undefined;
748
830
  persist();
@@ -750,10 +832,6 @@ export default function planBuildModes(pi: ExtensionAPI): void {
750
832
  return { message: { customType: "pi-plan-build-reminder", content, display: false } };
751
833
  });
752
834
 
753
- pi.on("turn_end", () => {
754
- applyTools(runMode ?? selectedMode);
755
- });
756
-
757
835
  pi.on("agent_settled", async (_event, ctx) => {
758
836
  runMode = undefined;
759
837
  applyTools(selectedMode);
@@ -774,7 +852,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
774
852
  composerMountingEditorFactory = undefined;
775
853
  reducedOptionalUi = false;
776
854
  reducedUiNoticeShown = false;
777
- const entries = ctx.sessionManager.getEntries();
855
+ const entries = ctx.sessionManager.getBranch();
778
856
  const latest = entries
779
857
  .filter(
780
858
  (entry: any) =>
@@ -791,7 +869,18 @@ export default function planBuildModes(pi: ExtensionAPI): void {
791
869
  toolsBeforeModes = Array.isArray(raw?.toolsBeforeModes)
792
870
  ? raw.toolsBeforeModes.filter((name): name is string => typeof name === "string" && !MANAGED_TOOLS.has(name))
793
871
  : pi.getActiveTools().filter((name) => !MANAGED_TOOLS.has(name));
794
- planPath = makePlanPath(path.join(getAgentDir(), "plans"), ctx.sessionManager.getSessionId());
872
+ const plansDir = path.join(getAgentDir(), "plans");
873
+ const legacyPath = makePlanPath(plansDir, ctx.sessionManager.getSessionId());
874
+ planLifecycle = decodePlanLifecycle(raw?.plan) ?? { sequence: fs.existsSync(legacyPath) || execution ? 0 : 1, status: "open" };
875
+ planPath = makePlanPath(plansDir, ctx.sessionManager.getSessionId(), planLifecycle.sequence);
876
+ if (event.reason === "fork" && typeof raw?.planSessionId === "string" && raw.planSessionId !== ctx.sessionManager.getSessionId()) {
877
+ const sourcePath = makePlanPath(plansDir, raw.planSessionId, planLifecycle.sequence);
878
+ if (fs.existsSync(sourcePath) && !fs.existsSync(planPath)) {
879
+ await ensurePlanDirectory();
880
+ await fs.promises.copyFile(sourcePath, planPath, fs.constants.COPYFILE_EXCL);
881
+ }
882
+ }
883
+ persist();
795
884
  if (selectedMode === "plan" || execution) await ensurePlanDirectory();
796
885
  if (execution && !fs.existsSync(planPath)) await fs.promises.writeFile(planPath, execution.planMarkdown, "utf8");
797
886
  applyTools(selectedMode);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@janvitos/pi-plan-build",
3
- "version": "0.1.51",
3
+ "version": "0.1.54",
4
4
  "description": "Plan safely, approve explicitly, then implement here or in a clean session.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "scripts": {
36
- "test": "node --experimental-strip-types --test utils.test.ts question-ui.test.ts plan-execution.test.ts plan-panel.test.ts user-message-rail.test.ts ui-compat.test.ts",
36
+ "test": "node --experimental-strip-types --test utils.test.ts question-ui.test.ts plan-execution.test.ts plan-panel.test.ts user-message-rail.test.ts ui-compat.test.ts plan-lifecycle.test.ts",
37
37
  "prepublishOnly": "npm test"
38
38
  },
39
39
  "peerDependencies": {
package/utils.ts CHANGED
@@ -230,9 +230,24 @@ export function sanitizeSessionId(value: string | undefined): string {
230
230
  return cleaned || "ephemeral";
231
231
  }
232
232
 
233
- export function makePlanPath(plansDir: string, sessionId: string | undefined): string {
233
+ export interface PlanLifecycle {
234
+ sequence: number;
235
+ status: "open" | "completed";
236
+ }
237
+
238
+ export function decodePlanLifecycle(value: unknown): PlanLifecycle | undefined {
239
+ if (!value || typeof value !== "object") return undefined;
240
+ const candidate = value as Partial<PlanLifecycle>;
241
+ if (!Number.isSafeInteger(candidate.sequence) || candidate.sequence! < 0 ||
242
+ (candidate.status !== "open" && candidate.status !== "completed")) return undefined;
243
+ return { sequence: candidate.sequence!, status: candidate.status };
244
+ }
245
+
246
+ export function makePlanPath(plansDir: string, sessionId: string | undefined, sequence = 0): string {
247
+ if (!Number.isSafeInteger(sequence) || sequence < 0) throw new Error("Invalid plan sequence");
234
248
  const root = path.resolve(plansDir);
235
- const candidate = path.resolve(root, `${sanitizeSessionId(sessionId)}.md`);
249
+ const suffix = sequence === 0 ? "" : `-${String(sequence).padStart(3, "0")}`;
250
+ const candidate = path.resolve(root, `${sanitizeSessionId(sessionId)}${suffix}.md`);
236
251
  if (path.dirname(candidate) !== root) throw new Error("Generated plan path escaped the plans directory");
237
252
  return candidate;
238
253
  }