@pixelsnis/pi-plan-mode 0.1.2

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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@pixelsnis/pi-plan-mode",
3
+ "version": "0.1.2",
4
+ "description": "Plan and Build modes for Pi with deny-by-default planning tools and explicit plan approval.",
5
+ "type": "module",
6
+ "files": [
7
+ "index.ts",
8
+ "plan-file.ts",
9
+ "review-ui.ts",
10
+ "README.md",
11
+ "skills/plan-writing/SKILL.md"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/pixelsnis/pi-extensions.git",
16
+ "directory": "extensions/plan-mode"
17
+ },
18
+ "keywords": [
19
+ "pi-package",
20
+ "pi-extension",
21
+ "pi",
22
+ "plan-mode"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/pi-coding-agent": "*",
29
+ "@earendil-works/pi-tui": "*",
30
+ "typebox": "*"
31
+ },
32
+ "pi": {
33
+ "extensions": [
34
+ "./index.ts"
35
+ ],
36
+ "skills": [
37
+ "./skills/plan-writing"
38
+ ]
39
+ }
40
+ }
package/plan-file.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open, realpath, mkdtemp } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ export const MAX_PLAN_BYTES = 200 * 1024;
7
+
8
+ function safeSlug(value: string): string {
9
+ return value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "plan";
10
+ }
11
+
12
+ async function createSecureDirectory(path: string): Promise<string> {
13
+ try {
14
+ const before = await lstat(path);
15
+ if (before.isSymbolicLink() || !before.isDirectory()) {
16
+ throw new Error(`Plan directory must be a real directory: ${path}`);
17
+ }
18
+ } catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
20
+ }
21
+ await mkdir(path, { recursive: true, mode: 0o700 });
22
+ const canonical = await realpath(path);
23
+ if (canonical !== resolve(path)) throw new Error(`Refusing redirected plan directory: ${path}`);
24
+ return canonical;
25
+ }
26
+
27
+ export async function createPlanPath(options: {
28
+ cwd: string;
29
+ sessionId: string;
30
+ gitAdminDir?: string;
31
+ }): Promise<string> {
32
+ let planDir: string;
33
+ if (options.gitAdminDir) {
34
+ const gitDir = await realpath(resolve(options.gitAdminDir));
35
+ planDir = await createSecureDirectory(join(gitDir, "implementation-plans"));
36
+ } else {
37
+ const project = safeSlug(basename(resolve(options.cwd)));
38
+ planDir = await realpath(await mkdtemp(join(tmpdir(), `${project}-implementation-plans.`)));
39
+ }
40
+ const session = safeSlug(options.sessionId).slice(-20) || "session";
41
+ const filename = `plan-${session}-${Date.now()}-plan.md`;
42
+ return join(planDir, filename);
43
+ }
44
+
45
+ async function assertPlanPath(path: string): Promise<string> {
46
+ if (!path || !resolve(path).endsWith("-plan.md") || resolve(path) !== path) {
47
+ throw new Error("Invalid extension-owned plan file path");
48
+ }
49
+ const folder = dirname(path);
50
+ if (await realpath(folder) !== folder) throw new Error("Refusing a redirected plan directory");
51
+ try {
52
+ const info = await lstat(path);
53
+ if (info.isSymbolicLink() || !info.isFile() || info.nlink > 1) {
54
+ throw new Error("Plan path must be a regular, non-linked file");
55
+ }
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
58
+ }
59
+ return path;
60
+ }
61
+
62
+ export async function writePlan(path: string, content: string): Promise<void> {
63
+ if (typeof content !== "string" || content.trim().length === 0) {
64
+ throw new Error("The plan must be non-empty Markdown");
65
+ }
66
+ if (Buffer.byteLength(content, "utf8") > MAX_PLAN_BYTES) {
67
+ throw new Error(`Plan exceeds the ${MAX_PLAN_BYTES / 1024} KiB limit`);
68
+ }
69
+ const target = await assertPlanPath(path);
70
+ const noFollow = constants.O_NOFOLLOW ?? 0;
71
+ const nonBlock = constants.O_NONBLOCK ?? 0;
72
+ const file = await open(target, constants.O_WRONLY | constants.O_CREAT | noFollow | nonBlock, 0o600);
73
+ try {
74
+ const info = await file.stat();
75
+ if (!info.isFile() || info.nlink > 1) throw new Error("Plan target is not a private regular file");
76
+ await file.truncate(0);
77
+ await file.writeFile(content, { encoding: "utf8" });
78
+ } finally {
79
+ await file.close();
80
+ }
81
+ }
82
+
83
+ export async function readPlan(path: string): Promise<string> {
84
+ const target = await assertPlanPath(path);
85
+ const file = await open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
86
+ try {
87
+ const info = await file.stat();
88
+ if (!info.isFile() || info.nlink > 1) throw new Error("Plan target is not a regular private file");
89
+ if (info.size <= 0) throw new Error("Plan file is empty");
90
+ if (info.size > MAX_PLAN_BYTES) throw new Error("Plan file exceeds the 200 KiB limit");
91
+ return await file.readFile({ encoding: "utf8" });
92
+ } finally {
93
+ await file.close();
94
+ }
95
+ }
package/review-ui.ts ADDED
@@ -0,0 +1,88 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
3
+ import { Markdown, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+
5
+ export type ReviewChoice = "refine" | "execute-new" | "execute-here" | "cancel";
6
+
7
+ function fit(text: string, width: number): string {
8
+ return width <= 0 ? "" : truncateToWidth(text, width, "");
9
+ }
10
+
11
+ function border(width: number, left: string, fill: string, right: string): string {
12
+ if (width <= 1) return fit(fill, width);
13
+ return left + fill.repeat(Math.max(0, width - 2)) + right;
14
+ }
15
+
16
+ function framedLine(text: string, width: number): string {
17
+ if (width < 4) return fit(text, width);
18
+ const contentWidth = width - 4;
19
+ const content = truncateToWidth(text, contentWidth, "");
20
+ const padding = Math.max(0, contentWidth - visibleWidth(content));
21
+ return `│ ${content}${" ".repeat(padding)} │`;
22
+ }
23
+
24
+ function reviewLayout(rows: number): { topPadding: number; viewportHeight: number } {
25
+ const topPadding = Math.min(3, Math.max(0, rows - 10));
26
+ return { topPadding, viewportHeight: Math.max(1, rows - 8 - topPadding) };
27
+ }
28
+
29
+ export async function showPlanReview(
30
+ ctx: ExtensionContext,
31
+ path: string,
32
+ content: string,
33
+ ): Promise<ReviewChoice | undefined> {
34
+ return ctx.ui.custom<ReviewChoice>((tui, theme, _keybindings, done) => {
35
+ const markdown = new Markdown(content, 0, 0, getMarkdownTheme());
36
+ let scrollTop = 0;
37
+
38
+ const component = {
39
+ render(width: number): string[] {
40
+ const safeWidth = Math.max(1, width);
41
+ const innerWidth = Math.max(1, safeWidth - 4);
42
+ const contentLines = markdown.render(innerWidth);
43
+ const { topPadding, viewportHeight } = reviewLayout(tui.terminal.rows);
44
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
45
+ scrollTop = Math.min(Math.max(0, scrollTop), maxScroll);
46
+
47
+ const lines = [
48
+ ...Array.from({ length: topPadding }, () => ""),
49
+ border(safeWidth, "╭", "─", "╮"),
50
+ framedLine(theme.fg("accent", theme.bold("Plan review · approval required")), safeWidth),
51
+ framedLine(theme.fg("dim", path), safeWidth),
52
+ ];
53
+ for (let i = 0; i < viewportHeight; i++) {
54
+ lines.push(framedLine(contentLines[scrollTop + i] ?? "", safeWidth));
55
+ }
56
+ const range = contentLines.length === 0
57
+ ? "0 lines"
58
+ : `${scrollTop + 1}-${Math.min(scrollTop + viewportHeight, contentLines.length)} of ${contentLines.length}`;
59
+ lines.push(framedLine(theme.fg("muted", `Scroll ${range} · ↑↓/PgUp/PgDn · Home/End`), safeWidth));
60
+ lines.push(framedLine(theme.fg("accent", "[R] Refine [N] Approve & Execute [H] Approve & Execute Here [Esc] Cancel"), safeWidth));
61
+ lines.push(border(safeWidth, "╰", "─", "╯"));
62
+ return lines.map((line) => fit(line, safeWidth));
63
+ },
64
+ handleInput(data: string): void {
65
+ const { viewportHeight } = reviewLayout(tui.terminal.rows);
66
+ const total = markdown.render(Math.max(1, tui.terminal.columns - 4)).length;
67
+ const maxScroll = Math.max(0, total - viewportHeight);
68
+ if (matchesKey(data, "escape")) return done("cancel");
69
+ if (matchesKey(data, "r")) return done("refine");
70
+ if (matchesKey(data, "n")) return done("execute-new");
71
+ if (matchesKey(data, "h")) return done("execute-here");
72
+ if (matchesKey(data, "up") || matchesKey(data, "k")) scrollTop = Math.max(0, scrollTop - 1);
73
+ else if (matchesKey(data, "down") || matchesKey(data, "j")) scrollTop = Math.min(maxScroll, scrollTop + 1);
74
+ else if (matchesKey(data, "pageUp")) scrollTop = Math.max(0, scrollTop - viewportHeight);
75
+ else if (matchesKey(data, "pageDown")) scrollTop = Math.min(maxScroll, scrollTop + viewportHeight);
76
+ else if (matchesKey(data, "home")) scrollTop = 0;
77
+ else if (matchesKey(data, "end")) scrollTop = maxScroll;
78
+ else return;
79
+ tui.requestRender();
80
+ },
81
+ invalidate(): void {
82
+ markdown.invalidate();
83
+ },
84
+ };
85
+
86
+ return component;
87
+ });
88
+ }
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: plan-writing
3
+ description: Use when preparing or revising an implementation plan for a multi-step software change, or handing an approved plan to a fresh-context implementer.
4
+ ---
5
+
6
+ # Writing Implementation Plans
7
+
8
+ An implementation plan is an execution specification: an implementer who has not seen the planning conversation should be able to follow it in order, make no design decisions, and tell when each step is complete.
9
+
10
+ ## Explore before planning
11
+
12
+ 1. Read the full request, specifications, project instructions, and relevant source/configuration. Find existing utilities and callers before proposing new ones.
13
+ 2. During planning, do not edit application files or run state-changing commands. The only file to create or update is the temporary plan.
14
+ 3. Ground paths, symbols, interfaces, behavior, and commands in sources inspected during this task. Mark unconfirmed details `unverified — confirm first`; never present guesses as facts.
15
+ 4. Resolve uncertainty by inspection first. Ask only when a real unresolved preference changes behavior, scope, or architecture. Recommend a default and give a fallback for any assumption that could block implementation.
16
+ 5. Draft and revise the plan as you learn. Reuse an existing plan only when it is for this same task.
17
+
18
+ ## Plan location
19
+
20
+ Keep the plan outside the tracked working tree. For a Git project, use the checkout's Git administrative directory:
21
+
22
+ ```bash
23
+ project_root="$(git rev-parse --show-toplevel)"
24
+ git_admin_dir="$(git -C "$project_root" rev-parse --absolute-git-dir)"
25
+ mkdir -p "$git_admin_dir/implementation-plans"
26
+ ```
27
+
28
+ Save to `$git_admin_dir/implementation-plans/<short-kebab-case-slug>-plan.md`. For a non-Git project, create a unique directory under `${TMPDIR:-/tmp}` prefixed with the project directory name, and save the plan there. When using Plan Mode's `plan_save`, use and report only the exact relative path it returns; the extension may retain the canonical absolute path internally, but callers must not substitute or expose it. Never put a temporary plan in tracked documentation or commit it.
29
+
30
+ ## Required plan structure
31
+
32
+ Use this template, scaling its depth to the change:
33
+
34
+ ```markdown
35
+ # [Feature] Implementation Plan
36
+
37
+ **Context:** [2–4 sentences: request, need, intended outcome.]
38
+
39
+ **Approach:** [Short overview of ordered changes.]
40
+
41
+ **Constraints:** [Only load-bearing requirements.]
42
+
43
+ **Assumptions and contingencies:** [User-overridable decisions and fallback, or “None.”]
44
+
45
+ ## Implementation steps
46
+
47
+ ### Step 1: [Behavior or deliverable]
48
+
49
+ **Depends on:** [Earlier steps or “None”]
50
+ **Files:** [Exact paths and create/modify actions]
51
+ **Interfaces:** [Exact signatures, schemas, callers, errors, or “None”]
52
+ **Change:** [Concrete ordered actions and boundary/error handling]
53
+ **Done when:** [Observable success condition]
54
+
55
+ - [ ] [Executable action]
56
+ - [ ] [Executable action]
57
+
58
+ ## Critical files and anchors
59
+
60
+ - `[verified/path]`, `[symbol/region]` — [why it matters]
61
+
62
+ ## Verification
63
+
64
+ - [Action/input] → [observable expected result]
65
+
66
+ ## Assumptions and contingencies
67
+
68
+ [Only decisions the user may override; include fallback, or “None.”]
69
+ ```
70
+
71
+ Each implementation step must specify the exact files, behavior, interfaces and data shapes, error/empty/invalid/boundary cases, and an observable completion condition. Order dependent work; mark parallel work only when interfaces and files are independent. Identify every caller affected by a changed interface or provide an exact search command. Critical files should be verified and limited to five. Keep assumptions user-overridable; resolve implementation decisions in the steps instead of deferring them.
72
+
73
+ ## Verification and scope
74
+
75
+ Include at least one behavior-specific check with its input/action and expected result. Give exact commands and prerequisites when tests are requested or required by project instructions. Otherwise, do not add tests, commits, documentation, or cleanup tasks unless requested or required; state verification limits when no check is authorized.
76
+
77
+ ## Fresh-context handoff
78
+
79
+ When handing work to a fresh Pi session through Plan Mode approval, rely on the session's existing working directory; do not inject or request an absolute project root. Refer to the approved plan using only the exact relative path returned by `plan_save`, and include the exact step or scope and execution constraints. The handoff itself must direct the new implementer to:
80
+
81
+ 1. Inspect `git status --short --branch` from the existing working directory, preserving existing changes.
82
+ 2. Read project instructions for the assigned files.
83
+ 3. Read the plan context, constraints, applicable assumptions, the full assigned step, and its named sources/interfaces.
84
+ 4. Stop and report any conflict or missing required decision instead of guessing.
85
+
86
+ The implementer must make only the assigned changes, follow the plan in order, run only authorized/required verification, and report changed files, commands/results, remaining issues, and commits.