@pify/plan-mode 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pifydev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @pify/plan-mode
2
+
3
+ Read-only planning mode for [pi](https://github.com/earendil-works/pi) with an explicit approve-then-execute gate — enforced at the tool level, not just prompted.
4
+
5
+ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install plan-mode`](https://github.com/pifydev/cli) or `pi install npm:@pify/plan-mode`.
6
+
7
+ ## What it does
8
+
9
+ - **Two ways in**: you type `/plan` (or `pi --plan`, or `Ctrl+Alt+P`) — or the agent itself calls `enter_plan_mode` before a complex task and tells you.
10
+ - **Real enforcement** via the `tool_call` hook while planning:
11
+ - `edit`/`write` are blocked — except on the current plan file (guarded plan editing);
12
+ - `bash` runs through a three-tier classifier: known read-only commands run, known mutators (rm/mv/npm install/git commit/redirects/…) are blocked, unknown commands ask you once;
13
+ - unknown custom tools need a one-time confirmation; suite read-only tools (memory_read, goal_status, …) pass.
14
+ - **Plans are files**: `write_plan` creates `.pi/plans/YYYY-MM-DD-<slug>.md` — reviewable, editable during planning, committable.
15
+ - **Claude-style exit gate**: `exit_plan_mode` presents up to 3 alternative approaches ("(Recommended)" marked) and an approval menu — implement here, implement in a **fresh session** (handoff message included), revise with feedback, or discard.
16
+ - **Thinking split**: entering plan mode raises the thinking level to `high`; your previous level is restored on exit.
17
+ - **Persistent**: mode and plan file survive `/reload`, resume, and branch switches; a `📋 plan` footer badge shows while active.
18
+
19
+ > Plan mode is tool-level workflow protection, not a sandbox: a command you confirm can still do anything you can.
20
+
21
+ ## Usage
22
+
23
+ ```
24
+ /plan # toggle plan mode
25
+ /plan add oauth login # enter + start planning this
26
+ /plan off # leave without approval
27
+ pi --plan # start a session already in plan mode
28
+ ```
29
+
30
+ ## Conflicts
31
+
32
+ `@pify/plan-mode` registers the `--plan` flag and `/plan` command, so it cannot run alongside `@narumitw/pi-plan-mode` or other plan extensions. Remove those first:
33
+
34
+ ```bash
35
+ pi remove npm:@narumitw/pi-plan-mode
36
+ pify install plan-mode
37
+ ```
38
+
39
+ ## License
40
+
41
+ MIT © [Pify maintainers](https://github.com/pifydev)
@@ -0,0 +1,382 @@
1
+ /**
2
+ * @pify/plan-mode — read-only planning with an explicit approve-then-execute
3
+ * gate.
4
+ *
5
+ * Two ways in: the user types /plan (or starts pi with --plan, or presses
6
+ * ctrl+alt+p), or the agent calls enter_plan_mode before a complex task
7
+ * (Kimi/Claude-style). While active, mutations are blocked at the tool_call
8
+ * hook: edit/write allowed only on the current plan file, bash runs through a
9
+ * three-tier read-only classifier (safe list → confirm → block), unknown
10
+ * custom tools need one-time confirmation. Plans are markdown files in
11
+ * .pi/plans/. exit_plan_mode presents up to 3 alternative approaches and an
12
+ * approval menu; approval implements here or hands off to a fresh session.
13
+ *
14
+ * This is tool-level workflow protection, not a sandbox.
15
+ *
16
+ * Design synthesis: tool_call enforcement + review flow (@narumitw/
17
+ * pi-plan-mode), guarded plan-file editing + fresh-session handoff
18
+ * (janvitos/pi-plan-build), agent-initiated tools + approach options
19
+ * (pi-muselinn-harness), three-tier shell policy + thinking split
20
+ * (@bacnh85/pi-plan), hidden system reminders (juanibiapina/pi-plan).
21
+ */
22
+ import type {
23
+ ExtensionAPI,
24
+ ExtensionContext,
25
+ } from "@earendil-works/pi-coding-agent";
26
+ import { Type } from "typebox";
27
+
28
+ import { classifyToolCall } from "../src/policy.ts";
29
+ import { createPlanFile } from "../src/plans.ts";
30
+ import {
31
+ ENTER_REMINDER,
32
+ EXIT_REMINDER,
33
+ buildHandoffMessage,
34
+ buildImplementHereMessage,
35
+ } from "../src/prompts.ts";
36
+ import { PLAN_STATE, replayBranch } from "../src/state.ts";
37
+ import { INITIAL_STATE, PLAN_THINKING, type PlanState } from "../src/types.ts";
38
+
39
+ const REMINDER_TYPE = "plan-mode-reminder";
40
+
41
+ type UiContext = ExtensionContext;
42
+
43
+ export default function planMode(pi: ExtensionAPI) {
44
+ let state: PlanState = INITIAL_STATE;
45
+ const approvedTools = new Set<string>();
46
+
47
+ // ── State & UI plumbing ──────────────────────────────────────────────
48
+
49
+ function commit(ctx: UiContext, next: PlanState): void {
50
+ state = next;
51
+ pi.appendEntry(PLAN_STATE, next);
52
+ updateBadge(ctx);
53
+ }
54
+
55
+ function updateBadge(ctx: UiContext): void {
56
+ if (!ctx.hasUI) return;
57
+ ctx.ui.setStatus("plan", state.active ? "📋 plan" : undefined);
58
+ }
59
+
60
+ function notify(ctx: UiContext, message: string, level: "info" | "warning" | "error"): void {
61
+ if (ctx.hasUI) ctx.ui.notify(message, level);
62
+ }
63
+
64
+ function sendReminder(content: string): void {
65
+ pi.sendMessage({ customType: REMINDER_TYPE, content, display: false });
66
+ }
67
+
68
+ // ── Enter / exit ─────────────────────────────────────────────────────
69
+
70
+ function enterPlanMode(ctx: UiContext): boolean {
71
+ if (state.active) return false;
72
+ const buildThinking = pi.getThinkingLevel();
73
+ commit(ctx, {
74
+ active: true,
75
+ planFile: null,
76
+ buildThinking,
77
+ enteredAt: Date.now(),
78
+ });
79
+ try {
80
+ // Planning earns deeper thought (bacnh85); restored on exit.
81
+ pi.setThinkingLevel(PLAN_THINKING as never);
82
+ } catch {
83
+ // model may not support it; fine
84
+ }
85
+ sendReminder(ENTER_REMINDER);
86
+ notify(ctx, "Plan mode ON — read-only. Write the plan with write_plan, submit with exit_plan_mode.", "info");
87
+ return true;
88
+ }
89
+
90
+ function leavePlanMode(ctx: UiContext): void {
91
+ if (!state.active) return;
92
+ const restore = state.buildThinking;
93
+ commit(ctx, { ...INITIAL_STATE });
94
+ approvedTools.clear();
95
+ if (restore) {
96
+ try {
97
+ pi.setThinkingLevel(restore as never);
98
+ } catch {
99
+ // fine
100
+ }
101
+ }
102
+ sendReminder(EXIT_REMINDER);
103
+ notify(ctx, "Plan mode OFF.", "info");
104
+ }
105
+
106
+ // ── Enforcement ──────────────────────────────────────────────────────
107
+
108
+ pi.on("tool_call", async (event, ctx) => {
109
+ if (!state.active) return undefined;
110
+
111
+ const verdict = classifyToolCall({
112
+ toolName: event.toolName,
113
+ input: (event as { input?: unknown }).input,
114
+ planFile: state.planFile,
115
+ approvedTools,
116
+ });
117
+
118
+ if (verdict.kind === "allow") return undefined;
119
+
120
+ if (verdict.kind === "confirm") {
121
+ if (!ctx.hasUI) {
122
+ return { block: true, reason: `Plan mode: ${verdict.reason} (no UI to confirm — blocked).` };
123
+ }
124
+ const ok = await ctx.ui.confirm(
125
+ "Plan mode",
126
+ `Allow this while planning?\n${verdict.reason}`,
127
+ );
128
+ if (ok) {
129
+ // Bash confirmations are per-command; custom tools are remembered.
130
+ if (event.toolName !== "bash") approvedTools.add(event.toolName);
131
+ return undefined;
132
+ }
133
+ return { block: true, reason: `Plan mode: the user declined (${verdict.reason}).` };
134
+ }
135
+
136
+ return { block: true, reason: verdict.reason };
137
+ });
138
+
139
+ // ── Lifecycle ────────────────────────────────────────────────────────
140
+
141
+ pi.on("session_start", async (_event, ctx) => {
142
+ state = replayBranch(ctx.sessionManager.getBranch() as never);
143
+ approvedTools.clear();
144
+ if (!state.active && pi.getFlag("plan") === true) {
145
+ enterPlanMode(ctx);
146
+ return;
147
+ }
148
+ updateBadge(ctx);
149
+ });
150
+
151
+ pi.on("session_tree", async (_event, ctx) => {
152
+ state = replayBranch(ctx.sessionManager.getBranch() as never);
153
+ approvedTools.clear();
154
+ updateBadge(ctx);
155
+ });
156
+
157
+ pi.registerFlag("plan", {
158
+ description: "Start the session in plan mode (read-only planning)",
159
+ type: "boolean",
160
+ default: false,
161
+ });
162
+
163
+ // ── Agent tools ──────────────────────────────────────────────────────
164
+
165
+ pi.registerTool({
166
+ name: "enter_plan_mode",
167
+ label: "Enter plan mode",
168
+ description:
169
+ "Switch into read-only plan mode before a complex or risky implementation task. Explore with " +
170
+ "read-only tools, write the plan with write_plan, then submit it with exit_plan_mode. " +
171
+ "Use when the task spans multiple files, has unclear requirements, or the user asked for a plan.",
172
+ parameters: Type.Object({}),
173
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
174
+ const entered = enterPlanMode(ctx as UiContext);
175
+ return {
176
+ content: [
177
+ {
178
+ type: "text",
179
+ text: entered
180
+ ? "Plan mode activated. Explore read-only, write the plan with write_plan, submit with exit_plan_mode."
181
+ : "Plan mode is already active.",
182
+ },
183
+ ],
184
+ details: { state },
185
+ };
186
+ },
187
+ });
188
+
189
+ pi.registerTool({
190
+ name: "write_plan",
191
+ label: "Write plan",
192
+ description:
193
+ "Write the implementation plan to a markdown file under .pi/plans/. Include the goal, concrete " +
194
+ "steps, files to touch, verification strategy, and open risks. The created file becomes editable " +
195
+ "(edit/write are unblocked for it) so the plan can be refined before exit_plan_mode.",
196
+ parameters: Type.Object({
197
+ title: Type.String({ description: "Short plan title (used for the filename)" }),
198
+ content: Type.String({ description: "Full plan in markdown" }),
199
+ }),
200
+ async execute(_id, params: { title: string; content: string }, _signal, _onUpdate, ctx) {
201
+ if (!state.active) {
202
+ throw new Error("write_plan only works in plan mode. Call enter_plan_mode first.");
203
+ }
204
+ const file = createPlanFile((ctx as UiContext).cwd, params.title, params.content);
205
+ commit(ctx as UiContext, { ...state, planFile: file });
206
+ return {
207
+ content: [{ type: "text", text: `Plan written to ${file}. Refine it or call exit_plan_mode.` }],
208
+ details: { file },
209
+ };
210
+ },
211
+ });
212
+
213
+ const APPROVE_HERE = "Approve — implement here";
214
+ const APPROVE_FRESH = "Approve — implement in a fresh session";
215
+ const REVISE = "Revise the plan";
216
+ const DISCARD = "Discard and exit plan mode";
217
+
218
+ pi.registerTool({
219
+ name: "exit_plan_mode",
220
+ label: "Exit plan mode",
221
+ description:
222
+ "Submit the plan for user approval. Write the plan with write_plan first. Optionally offer 1-3 " +
223
+ "alternative approaches (label + description; append '(Recommended)' to your recommended one; " +
224
+ "labels must not be Approve/Revise/Discard). The user approves, asks for revisions, or discards.",
225
+ parameters: Type.Object({
226
+ summary: Type.String({ description: "One-paragraph summary of the plan for the approval dialog" }),
227
+ options: Type.Optional(
228
+ Type.Array(
229
+ Type.Object({
230
+ label: Type.String({ description: "Approach name, max 80 chars" }),
231
+ description: Type.String({ description: "Trade-offs of this approach" }),
232
+ }),
233
+ { maxItems: 3 },
234
+ ),
235
+ ),
236
+ }),
237
+ async execute(
238
+ _id,
239
+ params: { summary: string; options?: Array<{ label: string; description: string }> },
240
+ _signal,
241
+ _onUpdate,
242
+ ctx,
243
+ ) {
244
+ if (!state.active) {
245
+ throw new Error("Plan mode is not active. Call enter_plan_mode first.");
246
+ }
247
+ const uiCtx = ctx as UiContext;
248
+ if (!uiCtx.hasUI) {
249
+ return {
250
+ content: [
251
+ {
252
+ type: "text",
253
+ text: "No UI available for the approval dialog. Staying in plan mode — ask the user directly how to proceed.",
254
+ },
255
+ ],
256
+ details: {},
257
+ };
258
+ }
259
+
260
+ // Step 1: pick an approach when alternatives were offered.
261
+ let approach: string | null = null;
262
+ const approaches = (params.options ?? []).filter((o) => o.label.trim());
263
+ if (approaches.length > 0) {
264
+ const lines = approaches.map((o) => `${o.label}: ${o.description}`).join("\n");
265
+ const picked = await uiCtx.ui.select(
266
+ `Plan approaches\n${params.summary}\n\n${lines}`,
267
+ [...approaches.map((o) => o.label), REVISE, DISCARD],
268
+ );
269
+ if (picked === undefined || picked === REVISE) {
270
+ const feedback = picked === REVISE ? await uiCtx.ui.input("What should change?") : undefined;
271
+ return {
272
+ content: [
273
+ { type: "text", text: `The user wants revisions.${feedback ? ` Feedback: ${feedback}` : ""} Stay in plan mode and refine the plan.` },
274
+ ],
275
+ details: {},
276
+ };
277
+ }
278
+ if (picked === DISCARD) {
279
+ leavePlanMode(uiCtx);
280
+ return {
281
+ content: [{ type: "text", text: "The user discarded the plan. Plan mode is off; await further instructions." }],
282
+ details: {},
283
+ };
284
+ }
285
+ approach = picked;
286
+ }
287
+
288
+ // Step 2: approve where?
289
+ const decision = await uiCtx.ui.select(
290
+ `Approve this plan?\n${params.summary}${state.planFile ? `\n\nPlan file: ${state.planFile}` : ""}`,
291
+ approaches.length > 0 ? [APPROVE_HERE, APPROVE_FRESH] : [APPROVE_HERE, APPROVE_FRESH, REVISE, DISCARD],
292
+ );
293
+
294
+ if (decision === undefined || decision === REVISE) {
295
+ const feedback = decision === REVISE ? await uiCtx.ui.input("What should change?") : undefined;
296
+ return {
297
+ content: [
298
+ { type: "text", text: `The user wants revisions.${feedback ? ` Feedback: ${feedback}` : ""} Stay in plan mode and refine the plan.` },
299
+ ],
300
+ details: {},
301
+ };
302
+ }
303
+ if (decision === DISCARD) {
304
+ leavePlanMode(uiCtx);
305
+ return {
306
+ content: [{ type: "text", text: "The user discarded the plan. Plan mode is off; await further instructions." }],
307
+ details: {},
308
+ };
309
+ }
310
+
311
+ const planFile = state.planFile;
312
+ leavePlanMode(uiCtx);
313
+
314
+ if (decision === APPROVE_FRESH) {
315
+ try {
316
+ const handoff = buildHandoffMessage(planFile, approach);
317
+ // newSession lives on the command context; tool ctx may carry it
318
+ // too at runtime — probe structurally and fall back if absent.
319
+ const sessionHost = uiCtx as unknown as {
320
+ newSession?: (options: {
321
+ withSession: (ctx: { sendUserMessage: (m: string) => void | Promise<void> }) => Promise<void>;
322
+ }) => Promise<{ cancelled: boolean }>;
323
+ };
324
+ if (!sessionHost.newSession) throw new Error("newSession unavailable in this context");
325
+ await sessionHost.newSession({
326
+ withSession: async (replacementCtx) => {
327
+ await replacementCtx.sendUserMessage(handoff);
328
+ },
329
+ });
330
+ return {
331
+ content: [{ type: "text", text: "Approved. A fresh session was started with the plan handoff." }],
332
+ details: { planFile, approach },
333
+ };
334
+ } catch (err) {
335
+ notify(uiCtx, `Fresh session failed (${err instanceof Error ? err.message : String(err)}) — implementing here.`, "warning");
336
+ }
337
+ }
338
+
339
+ pi.sendUserMessage(buildImplementHereMessage(planFile, approach), { deliverAs: "followUp" });
340
+ return {
341
+ content: [{ type: "text", text: "Approved. Implementation instructions were queued — plan mode is off." }],
342
+ details: { planFile, approach },
343
+ };
344
+ },
345
+ });
346
+
347
+ // ── Command & shortcut ───────────────────────────────────────────────
348
+
349
+ pi.registerCommand("plan", {
350
+ description: "Toggle read-only plan mode: /plan [off | <first planning prompt>]",
351
+ handler: async (args, ctx) => {
352
+ const text = (args ?? "").trim();
353
+ if (text.toLowerCase() === "off") {
354
+ if (!state.active) {
355
+ notify(ctx, "Plan mode is not active.", "info");
356
+ return;
357
+ }
358
+ leavePlanMode(ctx);
359
+ return;
360
+ }
361
+ if (state.active && !text) {
362
+ leavePlanMode(ctx);
363
+ return;
364
+ }
365
+ enterPlanMode(ctx);
366
+ if (text) {
367
+ pi.sendUserMessage(text);
368
+ }
369
+ },
370
+ });
371
+
372
+ pi.registerShortcut("ctrl+alt+p", {
373
+ description: "Toggle plan mode",
374
+ handler: async (ctx) => {
375
+ if (state.active) {
376
+ leavePlanMode(ctx as UiContext);
377
+ } else {
378
+ enterPlanMode(ctx as UiContext);
379
+ }
380
+ },
381
+ });
382
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@pify/plan-mode",
3
+ "version": "0.1.0",
4
+ "description": "Read-only planning mode for pi with an explicit approve-then-execute gate: enforced tool policy, plan files, approach options, fresh-session handoff",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi",
9
+ "pify",
10
+ "plan-mode"
11
+ ],
12
+ "homepage": "https://github.com/pifydev/plan-mode#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/pifydev/plan-mode/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/pifydev/plan-mode.git"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Pify maintainers",
22
+ "type": "module",
23
+ "engines": {
24
+ "node": ">=22.19.0"
25
+ },
26
+ "files": [
27
+ "extensions",
28
+ "src",
29
+ "skills",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "pi": {
34
+ "extensions": ["./extensions/plan-mode.ts"],
35
+ "skills": ["./skills"]
36
+ },
37
+ "scripts": {
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "bun test",
40
+ "prepublishOnly": "npm run typecheck && npm test"
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-coding-agent": "*",
44
+ "typebox": "*"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@earendil-works/pi-coding-agent": { "optional": true },
48
+ "typebox": { "optional": true }
49
+ },
50
+ "devDependencies": {
51
+ "@earendil-works/pi-coding-agent": "^0.84.4",
52
+ "@types/node": "^22.10.2",
53
+ "typebox": "^1.1.38",
54
+ "typescript": "^5.7.2"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ }
59
+ }
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: plan-mode
3
+ description: Use when a task is complex, risky, spans many files, or the user asks for a plan first - explains the plan-mode workflow (enter_plan_mode, write_plan, exit_plan_mode approval gate) and the read-only discipline
4
+ ---
5
+
6
+ # Plan mode
7
+
8
+ This project has the `@pify/plan-mode` extension installed. It provides a
9
+ read-only planning phase with an explicit approve-then-execute gate.
10
+
11
+ ## When to enter plan mode yourself (enter_plan_mode)
12
+
13
+ - The task spans multiple files or subsystems.
14
+ - Requirements are ambiguous and worth clarifying before touching code.
15
+ - The change is risky (migrations, deletions, public APIs).
16
+ - The user says "plan first", "don't code yet", or similar.
17
+
18
+ Do not enter plan mode for trivial single-file edits.
19
+
20
+ ## The workflow
21
+
22
+ 1. `enter_plan_mode` — switches to read-only; thinking level is raised.
23
+ 2. Explore with read, grep, find, ls, and read-only shell commands. Mutating
24
+ commands are blocked; unknown ones ask the user. Do not fight the policy —
25
+ fold blocked actions into the plan instead.
26
+ 3. Ask the user clarifying questions rather than assuming intent.
27
+ 4. `write_plan` — write the full plan (goal, concrete steps, files to touch,
28
+ verification, risks) to `.pi/plans/`. The plan file itself stays editable.
29
+ 5. `exit_plan_mode` — submit with a one-paragraph summary; optionally offer
30
+ 1-3 alternative approaches, marking one "(Recommended)". The user approves
31
+ (here or in a fresh session), requests revisions, or discards.
32
+ 6. After approval, implement the plan exactly; report any deviation.
33
+
34
+ ## While plan mode is active
35
+
36
+ - Never promise to make changes now; everything lands in the plan.
37
+ - A blocked tool call is a policy decision, not an error to retry.
package/src/plans.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** Plan files live in .pi/plans/, reviewable and committable. */
5
+ export function plansDir(cwd: string): string {
6
+ return join(cwd, ".pi", "plans");
7
+ }
8
+
9
+ export function slugify(title: string): string {
10
+ const slug = title
11
+ .toLowerCase()
12
+ .normalize("NFKD")
13
+ .replace(/[̀-ͯ]/g, "")
14
+ .replace(/[^a-z0-9]+/g, "-")
15
+ .replace(/^-+|-+$/g, "")
16
+ .slice(0, 60);
17
+ return slug || "plan";
18
+ }
19
+
20
+ function pad2(n: number): string {
21
+ return String(n).padStart(2, "0");
22
+ }
23
+
24
+ export function localDateStr(d: Date = new Date()): string {
25
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
26
+ }
27
+
28
+ /** Create the plan file, uniquified when the slug collides on the same day. */
29
+ export function createPlanFile(cwd: string, title: string, content: string): string {
30
+ const dir = plansDir(cwd);
31
+ mkdirSync(dir, { recursive: true });
32
+ const base = `${localDateStr()}-${slugify(title)}`;
33
+ let file = join(dir, `${base}.md`);
34
+ let counter = 2;
35
+ while (existsSync(file)) {
36
+ file = join(dir, `${base}-${counter}.md`);
37
+ counter++;
38
+ }
39
+ writeFileSync(file, content.endsWith("\n") ? content : `${content}\n`);
40
+ return file;
41
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { resolve } from "node:path";
2
+ import { classifyShellCommand } from "./shell.ts";
3
+ import type { PolicyVerdict } from "./types.ts";
4
+
5
+ /**
6
+ * Tool-call policy while plan mode is active (enforced via the tool_call
7
+ * block hook — the lesson from juanibiapina's deprecation is that an
8
+ * extension earns its existence through real enforcement, not prompts).
9
+ */
10
+
11
+ const READ_ONLY_BUILTINS = new Set(["read", "grep", "find", "ls"]);
12
+
13
+ /** Suite tools that are read-only by design and safe during planning. */
14
+ const SAFE_TOOL_PREFIXES = ["memory_read", "memory_search", "goal_status", "write_plan", "exit_plan_mode", "enter_plan_mode"];
15
+
16
+ export interface PolicyInput {
17
+ toolName: string;
18
+ input: unknown;
19
+ planFile: string | null;
20
+ /** Custom tool names the user already confirmed this session. */
21
+ approvedTools: ReadonlySet<string>;
22
+ }
23
+
24
+ function samePath(a: string, b: string): boolean {
25
+ const norm = (p: string) => resolve(p).replaceAll("\\", "/").toLowerCase();
26
+ return norm(a) === norm(b);
27
+ }
28
+
29
+ export function classifyToolCall(call: PolicyInput): PolicyVerdict {
30
+ const { toolName } = call;
31
+
32
+ if (READ_ONLY_BUILTINS.has(toolName)) return { kind: "allow" };
33
+
34
+ if (toolName === "edit" || toolName === "write") {
35
+ const path = (call.input as { path?: unknown })?.path;
36
+ if (typeof path === "string" && call.planFile && samePath(path, call.planFile)) {
37
+ // Guarded plan-file editing (janvitos): the plan itself is writable.
38
+ return { kind: "allow" };
39
+ }
40
+ return {
41
+ kind: "block",
42
+ reason: call.planFile
43
+ ? `Plan mode blocks '${toolName}' except on the current plan file (${call.planFile}). Use write_plan first if you have no plan file.`
44
+ : `Plan mode blocks '${toolName}'. Create a plan with write_plan; implementation starts after exit_plan_mode is approved.`,
45
+ };
46
+ }
47
+
48
+ if (toolName === "bash") {
49
+ const command = (call.input as { command?: unknown })?.command;
50
+ if (typeof command !== "string") return { kind: "block", reason: "bash call without a command" };
51
+ const verdict = classifyShellCommand(command);
52
+ if (verdict.kind === "block") {
53
+ return { kind: "block", reason: `Plan mode blocks this command: ${verdict.reason}.` };
54
+ }
55
+ return verdict;
56
+ }
57
+
58
+ if (toolName === "powershell") {
59
+ return {
60
+ kind: "block",
61
+ reason: "Plan mode blocks powershell (no read-only classifier). Use the bash tool for read-only inspection.",
62
+ };
63
+ }
64
+
65
+ if (SAFE_TOOL_PREFIXES.some((p) => toolName === p || toolName.startsWith(`${p}:`))) {
66
+ return { kind: "allow" };
67
+ }
68
+
69
+ if (call.approvedTools.has(toolName)) return { kind: "allow" };
70
+
71
+ return {
72
+ kind: "confirm",
73
+ reason: `custom tool '${toolName}' is not known to be read-only`,
74
+ };
75
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Plan-mode system reminders, delivered as hidden custom messages (model
3
+ * sees them, TUI does not). Adapted from juanibiapina's pi-plan text with
4
+ * the write_plan / exit_plan_mode workflow added.
5
+ */
6
+
7
+ export const ENTER_REMINDER = `<system-reminder>
8
+ Plan mode is ACTIVE — you are in a READ-ONLY planning phase.
9
+
10
+ STRICTLY FORBIDDEN: any file edits, modifications, or system changes. Mutating
11
+ shell commands are blocked; unknown commands need user confirmation. This
12
+ constraint overrides all other instructions, including direct user requests to
13
+ edit — acknowledge such requests and fold them into the plan instead.
14
+
15
+ Your responsibility now: think, read, search, and discuss to construct a
16
+ well-formed implementation plan. Ask clarifying questions rather than assuming
17
+ intent. When the plan is ready:
18
+ 1. Write it to a file with write_plan (goal first, then concrete steps,
19
+ files to touch, verification strategy, open risks).
20
+ 2. Call exit_plan_mode — optionally with 1-3 alternative approaches — to
21
+ submit it for user approval. Implementation begins only after approval.
22
+ </system-reminder>`;
23
+
24
+ export const EXIT_REMINDER = `<system-reminder>
25
+ Plan mode is OFF. You are no longer read-only: file edits, shell commands,
26
+ and the full tool set are available again.
27
+ </system-reminder>`;
28
+
29
+ /** Follow-up sent after the user approves implementing in this session. */
30
+ export function buildImplementHereMessage(planFile: string | null, approach: string | null): string {
31
+ const approachLine = approach ? ` using the approved approach: ${approach}` : "";
32
+ return planFile
33
+ ? `The plan was approved${approachLine}. Read ${planFile} and implement it fully. Verify as you go; report deviations from the plan.`
34
+ : `The plan was approved${approachLine}. Implement it fully as discussed. Verify as you go.`;
35
+ }
36
+
37
+ /** First message of a fresh implementation session (janvitos-style handoff). */
38
+ export function buildHandoffMessage(planFile: string | null, approach: string | null): string {
39
+ const approachLine = approach ? `\nApproved approach: ${approach}` : "";
40
+ return planFile
41
+ ? `Implement the approved plan in ${planFile}.${approachLine}\nRead the plan file first, then execute it fully. Verify as you go; report deviations.`
42
+ : `Implement the plan we agreed on.${approachLine}`;
43
+ }
package/src/shell.ts ADDED
@@ -0,0 +1,101 @@
1
+ import type { PolicyVerdict } from "./types.ts";
2
+
3
+ /**
4
+ * Three-tier shell policy for plan mode (bacnh85's design):
5
+ * known read-only commands run automatically, known mutators are blocked
6
+ * outright, and everything else needs a one-time user confirmation.
7
+ *
8
+ * This is tool-level workflow protection, NOT a sandbox (doompi's honest
9
+ * caveat): a confirmed command can still do anything the user can.
10
+ */
11
+
12
+ const SAFE_COMMANDS = new Set([
13
+ "ls", "cat", "head", "tail", "less", "more", "wc", "file", "stat", "du", "df",
14
+ "grep", "egrep", "fgrep", "rg", "find", "fd", "tree", "dirname", "basename",
15
+ "pwd", "whoami", "which", "where", "type", "env", "printenv", "date", "uname",
16
+ "echo", "printf", "sort", "uniq", "cut", "tr", "diff", "cmp", "md5sum",
17
+ "sha256sum", "readlink", "realpath", "jq", "yq", "column", "nl", "strings",
18
+ "node", "python", "python3", "bun", "deno",
19
+ ]);
20
+
21
+ /** git subcommands that only read. Everything else confirms/blocks. */
22
+ const SAFE_GIT_SUBCOMMANDS = new Set([
23
+ "status", "log", "diff", "show", "branch", "tag", "remote", "blame",
24
+ "shortlog", "describe", "rev-parse", "rev-list", "ls-files", "ls-remote",
25
+ "ls-tree", "cat-file", "reflog", "stash list", "config --get", "grep",
26
+ ]);
27
+
28
+ const MUTATOR_COMMANDS = new Set([
29
+ "rm", "rmdir", "mv", "cp", "mkdir", "touch", "tee", "chmod", "chown", "ln",
30
+ "dd", "truncate", "shred", "install", "patch", "rsync", "curl", "wget",
31
+ "npm", "npx", "yarn", "pnpm", "pip", "pip3", "cargo", "gem", "brew", "apt",
32
+ "apt-get", "yum", "dnf", "choco", "winget", "scoop", "sudo", "kill",
33
+ "killall", "shutdown", "reboot",
34
+ ]);
35
+
36
+ const MUTATOR_GIT_SUBCOMMANDS = [
37
+ "commit", "push", "add", "rm", "mv", "reset", "checkout", "switch", "merge",
38
+ "rebase", "cherry-pick", "revert", "clean", "stash push", "stash pop",
39
+ "stash drop", "apply", "am", "fetch", "pull", "clone", "init", "restore",
40
+ "config --set", "config --global", "config --local",
41
+ ];
42
+
43
+ /** Split a shell line on operators; each segment is judged independently. */
44
+ export function splitSegments(command: string): string[] {
45
+ return command
46
+ .split(/(?:\|\||&&|;|\|)/)
47
+ .map((s) => s.trim())
48
+ .filter(Boolean);
49
+ }
50
+
51
+ /** Strip leading VAR=value assignments so env prefixes cannot mask commands. */
52
+ function stripEnvPrefix(segment: string): string {
53
+ return segment.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, "");
54
+ }
55
+
56
+ function leadingCommand(segment: string): string {
57
+ const first = stripEnvPrefix(segment).split(/\s+/)[0] ?? "";
58
+ return first.replace(/^["']|["']$/g, "").split(/[\\/]/).pop()?.toLowerCase() ?? "";
59
+ }
60
+
61
+ /** Redirects can write files; only /dev/null and stderr merges are harmless. */
62
+ export function hasWritingRedirect(command: string): boolean {
63
+ const cleaned = command
64
+ .replace(/2>&1/g, "")
65
+ .replace(/[0-9]?>>?\s*\/dev\/null/g, "")
66
+ .replace(/[0-9]?>\s*&[0-9]/g, "");
67
+ return />/.test(cleaned);
68
+ }
69
+
70
+ export function classifyShellCommand(command: string): PolicyVerdict {
71
+ const trimmed = command.trim();
72
+ if (!trimmed) return { kind: "allow" };
73
+
74
+ if (hasWritingRedirect(trimmed)) {
75
+ return { kind: "block", reason: "output redirection writes files" };
76
+ }
77
+
78
+ for (const segment of splitSegments(trimmed)) {
79
+ const cmd = leadingCommand(segment);
80
+ if (cmd === "git") {
81
+ const rest = stripEnvPrefix(segment).replace(/^\S+\s*/, "").trim();
82
+ if (MUTATOR_GIT_SUBCOMMANDS.some((m) => rest.startsWith(m))) {
83
+ return { kind: "block", reason: `git ${rest.split(/\s+/)[0]} mutates the repository` };
84
+ }
85
+ const safe = [...SAFE_GIT_SUBCOMMANDS].some((s) => rest.startsWith(s));
86
+ if (!safe) return { kind: "confirm", reason: `unrecognized git subcommand: ${rest.split(/\s+/)[0] ?? ""}` };
87
+ continue;
88
+ }
89
+ if (MUTATOR_COMMANDS.has(cmd)) {
90
+ return { kind: "block", reason: `'${cmd}' modifies the system` };
91
+ }
92
+ if (!SAFE_COMMANDS.has(cmd)) {
93
+ return { kind: "confirm", reason: `unrecognized command: '${cmd}'` };
94
+ }
95
+ // Safe interpreters running inline code can still write files.
96
+ if (["node", "python", "python3", "bun", "deno"].includes(cmd) && /\s-(e|c|p|-eval)\b/.test(segment)) {
97
+ return { kind: "confirm", reason: `inline ${cmd} code can modify files` };
98
+ }
99
+ }
100
+ return { kind: "allow" };
101
+ }
package/src/state.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { INITIAL_STATE, isRecord, type BranchEntryLike, type PlanState } from "./types.ts";
2
+
3
+ export const PLAN_STATE = "plan-mode-state";
4
+
5
+ /** Snapshot-based replay: the last plan-mode-state entry on the branch wins. */
6
+ export function replayBranch(entries: BranchEntryLike[]): PlanState {
7
+ let state: PlanState = INITIAL_STATE;
8
+ for (const entry of entries) {
9
+ if (entry.type !== "custom" || entry.customType !== PLAN_STATE) continue;
10
+ const data = entry.data;
11
+ if (!isRecord(data) || typeof data.active !== "boolean") continue;
12
+ state = {
13
+ active: data.active,
14
+ planFile: typeof data.planFile === "string" ? data.planFile : null,
15
+ buildThinking: typeof data.buildThinking === "string" ? data.buildThinking : null,
16
+ enteredAt: typeof data.enteredAt === "number" ? data.enteredAt : null,
17
+ };
18
+ }
19
+ return state;
20
+ }
package/src/types.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Local structural types for @pify/plan-mode.
3
+ * No imports from pi packages: src/ typechecks and runs standalone.
4
+ */
5
+
6
+ export interface PlanState {
7
+ active: boolean;
8
+ /** Absolute path of the current plan file; edit/write to it is allowed. */
9
+ planFile: string | null;
10
+ /** Thinking level to restore when leaving plan mode. */
11
+ buildThinking: string | null;
12
+ enteredAt: number | null;
13
+ }
14
+
15
+ export const INITIAL_STATE: PlanState = {
16
+ active: false,
17
+ planFile: null,
18
+ buildThinking: null,
19
+ enteredAt: null,
20
+ };
21
+
22
+ /** Verdict for one tool call while plan mode is active. */
23
+ export type PolicyVerdict =
24
+ | { kind: "allow" }
25
+ | { kind: "confirm"; reason: string }
26
+ | { kind: "block"; reason: string };
27
+
28
+ /** Thinking level plan mode switches to (planning earns deeper thought). */
29
+ export const PLAN_THINKING = "high";
30
+
31
+ export interface BranchEntryLike {
32
+ type?: string;
33
+ customType?: string;
34
+ data?: unknown;
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ export function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }