@janvitos/pi-plan-build 0.1.52 → 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.
- package/README.md +13 -3
- package/index.ts +98 -7
- package/package.json +2 -2
- 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-
|
|
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.
|
|
@@ -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` |
|
|
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
|
|
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
|
-
|
|
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",
|
|
@@ -735,6 +814,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
|
|
|
735
814
|
applyTools(runMode);
|
|
736
815
|
let content: string | undefined;
|
|
737
816
|
if (runMode === "plan") {
|
|
817
|
+
if (planLifecycle.status === "completed") startNewPlan(ctx);
|
|
738
818
|
await ensurePlanDirectory();
|
|
739
819
|
content = buildPlanReminder(describePlanFile());
|
|
740
820
|
} else if (activePlanStep(execution)) {
|
|
@@ -744,7 +824,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
|
|
|
744
824
|
content = buildPlanStepWaitingReminder(execution.steps.map((step, index) => `${index + 1}. [${step.status}] ${step.text}`).join("\n"));
|
|
745
825
|
} else if (pendingReminder === "build") {
|
|
746
826
|
content = PLAN_TO_BUILD_REMINDER;
|
|
747
|
-
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.`;
|
|
748
828
|
}
|
|
749
829
|
pendingReminder = undefined;
|
|
750
830
|
persist();
|
|
@@ -772,7 +852,7 @@ export default function planBuildModes(pi: ExtensionAPI): void {
|
|
|
772
852
|
composerMountingEditorFactory = undefined;
|
|
773
853
|
reducedOptionalUi = false;
|
|
774
854
|
reducedUiNoticeShown = false;
|
|
775
|
-
const entries = ctx.sessionManager.
|
|
855
|
+
const entries = ctx.sessionManager.getBranch();
|
|
776
856
|
const latest = entries
|
|
777
857
|
.filter(
|
|
778
858
|
(entry: any) =>
|
|
@@ -789,7 +869,18 @@ export default function planBuildModes(pi: ExtensionAPI): void {
|
|
|
789
869
|
toolsBeforeModes = Array.isArray(raw?.toolsBeforeModes)
|
|
790
870
|
? raw.toolsBeforeModes.filter((name): name is string => typeof name === "string" && !MANAGED_TOOLS.has(name))
|
|
791
871
|
: pi.getActiveTools().filter((name) => !MANAGED_TOOLS.has(name));
|
|
792
|
-
|
|
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();
|
|
793
884
|
if (selectedMode === "plan" || execution) await ensurePlanDirectory();
|
|
794
885
|
if (execution && !fs.existsSync(planPath)) await fs.promises.writeFile(planPath, execution.planMarkdown, "utf8");
|
|
795
886
|
applyTools(selectedMode);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@janvitos/pi-plan-build",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
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
|
|
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
|
}
|