@hank-warren/pi-plan-mode 1.1.1 → 1.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # @hank-warren/pi-plan-mode
2
2
 
3
+ ## 1.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 3fbf632: Persist the thinking-level capture taken while restoring plan mode. The two restore paths applied the configured level without writing the captured previous one, and Pi's `setThinkingLevel` writes the new level into the user's settings — so a session that ended without `session_shutdown` lost the only record of the original level and the next restore captured the plan level as "previous", permanently raising the user's default. An unchanged restore still writes nothing.
8
+
9
+ ## 1.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 358aa1f: re-read `pi-plan-mode.json` when it changes instead of only at session start
14
+
15
+ The settings file was read once per session, so a hand-edit — or a Settings save
16
+ from another session — did nothing until the next restart. That sat badly next to
17
+ the plan file, which has been hand-editable and read from disk since 1.0.
18
+
19
+ Plan mode now watches the settings file's directory (not the file: saves land
20
+ through a temp file and an atomic rename, which replaces the inode) and re-reads
21
+ on a 75 ms debounce, so one save costs one load.
22
+
23
+ An unparseable file is still reported and replaced by the defaults at session
24
+ start. Mid-session it is ignored and the last good settings stay in place: a
25
+ reload sees the file the moment an editor touches it, so an invalid read is
26
+ usually a partial write, and there is no context to report it through anyway.
27
+ Deleting the file mid-session does restore the defaults.
28
+
29
+ Session start now also honours the injected `settingsPath` dependency when
30
+ reading, which previously only the Settings menu used — the two could disagree
31
+ about which file they were looking at.
32
+
3
33
  ## 1.1.1
4
34
 
5
35
  ### Patch Changes
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  `@hank-warren/pi-plan-mode` adds a `/plan` mode to Pi for research and design. You gather information, ask questions, and land on a plan — then implement it, either in the same conversation or in a fresh one.
6
6
 
7
- **Plan mode is a mode of intent, not a permission system.** It blocks `edit`, `write`, and `update_plan` while planning and leaves every other tool exactly as you configured it. Command safety stays with your permission extension (for example [`@hank-warren/pi-auto-permissions`](../pi-auto-permissions)), which already reviews Bash. The only tool Plan mode ever removes from the active set is its own `plan_mode_question`, and only when a better questionnaire is installed (see below), so it cannot break other extensions.
7
+ **Plan mode is a mode of intent, not a permission system.** It blocks `edit` and `write` while planning and leaves every other tool exactly as you configured it. Command safety stays with your permission extension (for example [`@hank-warren/pi-auto-permissions`](../pi-auto-permissions)), which already reviews Bash. The only tool Plan mode ever removes from the active set is its own `plan_mode_question`, and only when a better questionnaire is installed (see below), so it cannot break other extensions.
8
8
 
9
9
  The plan is written to a **durable file** that survives compaction, survives resume, and can be hand-edited.
10
10
 
@@ -45,7 +45,9 @@ pi -e npm:@hank-warren/pi-plan-mode
45
45
 
46
46
  `--plan` starts a session directly in Plan mode.
47
47
 
48
- While Plan mode is active, ask the agent to design the change. It can read, search, and run commands, but `edit`, `write`, and `update_plan` are blocked. When the plan is decision-complete, the agent calls `plan_mode_complete` and the plan is written to disk.
48
+ While Plan mode is active, ask the agent to design the change. It can read, search, and run commands, but `edit` and `write` are blocked. When the plan is decision-complete, the agent calls `plan_mode_complete` and the plan is written to disk.
49
+
50
+ A completed plan is not final until you act on it: just type feedback to revise — the next planning turn supersedes the proposed plan, and the next `plan_mode_complete` replaces it.
49
51
 
50
52
  From a completed plan you can:
51
53
 
@@ -72,6 +74,8 @@ Writes are atomic (temp file plus rename), so a reader never sees a partial plan
72
74
 
73
75
  Open **Settings** from the `/plan` menu, or edit `$PI_CODING_AGENT_DIR/pi-plan-mode.json` (normally `~/.pi/agent/pi-plan-mode.json`). The file is optional.
74
76
 
77
+ The file is read at session start and **re-read whenever it changes**, so a hand-edit — or a save from another session — applies without restarting. Like the plan file itself, it is edited on disk and read from disk.
78
+
75
79
  ```json
76
80
  {
77
81
  "thinkingLevel": "inherit",
@@ -89,9 +93,11 @@ Open **Settings** from the `/plan` menu, or edit `$PI_CODING_AGENT_DIR/pi-plan-m
89
93
 
90
94
  Unknown keys are preserved. Settings removed in 1.0 (`defaultPlanTools`, `bashPolicy`, `safeSubcommands`, `implementationPlanRetention`) are ignored rather than treated as errors, so an existing settings file keeps working.
91
95
 
96
+ A settings file that does not parse is reported at session start and the defaults are used. Mid-session it is ignored instead, leaving the last good settings in place: an edit is seen the moment your editor touches the file, so an unreadable one is usually a half-finished save rather than what you meant.
97
+
92
98
  ## 🔐 What Plan mode does and does not enforce
93
99
 
94
- Plan mode blocks exactly three tools while planning: `edit`, `write`, and `update_plan`. That is the whole enforcement surface.
100
+ Plan mode blocks exactly two tools while planning: `edit` and `write`. That is the whole enforcement surface. Checklist tools (a `todo` extension, for example) are deliberately not blocked — a task list is ephemeral planning scratch, and the planning prompt steers the model away from execution-progress tracking.
95
101
 
96
102
  It deliberately does **not** police Bash, subagents, MCP tools, or any other extension tool. Those decisions belong to your permission layer, which can see the whole session and judge each call. Pair Plan mode with a permission extension such as `@hank-warren/pi-auto-permissions` if you want command review during planning.
97
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-plan-mode",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Plan mode for Pi: research and design with a durable plan file that survives compaction.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -10,6 +10,7 @@ interface MenuLifecycle {
10
10
  const IMPLEMENTATION_CONTEXT_LINES = [
11
11
  "Implement here keeps this planning conversation.",
12
12
  "Start fresh opens a new session that reads the same plan file.",
13
+ "Or just type feedback to revise — the next completed plan supersedes this one.",
13
14
  ] as const;
14
15
 
15
16
  interface PlanMenuOptions extends MenuLifecycle {
@@ -13,7 +13,7 @@ const HOW_IT_WORKS_LINES = [
13
13
  "Plan mode is for research and design, not implementation.",
14
14
  "Explore the codebase, ask decision questions, then finish with plan_mode_complete.",
15
15
  "The plan is written to a durable file that survives compaction and can be hand-edited.",
16
- "Built-in edit, write, and update_plan are blocked while planning.",
16
+ "Built-in edit and write are blocked while planning.",
17
17
  "All other tools stay exactly as configured; command safety is left to your permission extension.",
18
18
  "When the plan is ready: implement here, or start a fresh session that reads the same file.",
19
19
  ] as const;
package/src/plan-mode.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { watch } from "node:fs";
2
+ import { basename, dirname } from "node:path";
1
3
  import type {
2
4
  ExtensionAPI,
3
5
  ExtensionCommandContext,
@@ -46,6 +48,7 @@ import {
46
48
  awaitPlanModeSettingsWrites,
47
49
  configuredThinkingLevel,
48
50
  type PlanModeSettings,
51
+ planModeSettingsPath,
49
52
  readPlanModeSettings,
50
53
  } from "./settings.js";
51
54
  import { type PlanModeState, restorePlanModeState } from "./state.js";
@@ -56,8 +59,18 @@ const STATE_ENTRY_TYPE = "plan-mode-state";
56
59
  * MCP, and other extension tools — is left to the session's normal permission
57
60
  * layer (for example @hank-warren/pi-auto-permissions), so Plan mode never
58
61
  * mutates the active tool set and never fights other extensions for it.
62
+ * Checklist tools (a `todo` extension, for example) are deliberately not
63
+ * blocked: a task list is ephemeral planning scratch, and the planning prompt
64
+ * already steers the model away from execution-progress tooling. (`update_plan`
65
+ * was once listed here; it was a pre-1.0 upstream tool that no longer exists.)
59
66
  */
60
- const BLOCKED_TOOLS = new Set(["edit", "write", "update_plan"]);
67
+ const BLOCKED_TOOLS = new Set(["edit", "write"]);
68
+ /**
69
+ * One hand-edit or menu save fans out into several filesystem events (temp file
70
+ * created, renamed into place). Collapsing them into one re-read keeps a save
71
+ * to a single load.
72
+ */
73
+ const SETTINGS_RELOAD_DEBOUNCE_MS = 75;
61
74
 
62
75
  /**
63
76
  * Which question tool the prompt should name this turn.
@@ -124,6 +137,8 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
124
137
  let workflowGeneration = 0;
125
138
  let refreshStateBeforeFirstAgentStart = false;
126
139
  let menuController = new AbortController();
140
+ let settingsWatch: ReturnType<typeof watch> | undefined;
141
+ let settingsReloadTimer: ReturnType<typeof setTimeout> | undefined;
127
142
  const persistState = () => pi.appendEntry<PlanModeState>(STATE_ENTRY_TYPE, state);
128
143
  const planExports = createPlanExportController({
129
144
  getState: () => state,
@@ -146,8 +161,14 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
146
161
  exportPlan: (ctx, path, signal, isCurrent) => planExports.export(path, ctx, signal, isCurrent),
147
162
  stay: updateUi,
148
163
  exitReady: (ctx) => {
164
+ // Same had-plan branching as the /plan exit command: the menu must not
165
+ // claim a plan was discarded when none was ever completed.
166
+ const hadPlan = state.planPath !== undefined;
149
167
  void exitPlanMode(ctx).then(() => {
150
- ctx.ui.notify("Plan mode disabled. Proposed plan discarded.", "info");
168
+ ctx.ui.notify(
169
+ hadPlan ? "Plan mode disabled. Proposed plan discarded." : "Plan mode disabled.",
170
+ "info",
171
+ );
151
172
  });
152
173
  },
153
174
  });
@@ -295,6 +316,73 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
295
316
  },
296
317
  });
297
318
 
319
+ const readRuntimeSettings = () =>
320
+ dependencies.readSettings?.() ?? readPlanModeSettings(dependencies.settingsPath);
321
+
322
+ /**
323
+ * `ctx` present means this is the session-start load: problems are reported
324
+ * and anything unusable falls back to defaults.
325
+ *
326
+ * `ctx` absent means a watch-triggered reload, which keeps the last good
327
+ * settings when the file no longer parses. A hand-edit is observed the moment
328
+ * the editor touches the file, so an invalid read is usually a half-written
329
+ * save rather than intent — discarding a working export path for it, with no
330
+ * `ctx` to explain why, would be worse than waiting for the next write. A
331
+ * genuinely broken file is still reported at the next session start.
332
+ */
333
+ const loadPlanModeSettings = async (generation: number, ctx?: ExtensionContext) => {
334
+ const loaded = await readRuntimeSettings();
335
+ if (generation !== menuGeneration || menuController.signal.aborted) return;
336
+ if (loaded.kind === "invalid" && !ctx) return;
337
+ settings = loaded.kind === "loaded" ? loaded.settings : { thinkingLevel: "inherit" };
338
+ if (!ctx) return;
339
+ if (loaded.kind === "invalid") {
340
+ ctx.ui.notify(`pi-plan-mode settings ignored: ${loaded.reason}`, "warning");
341
+ }
342
+ if (loaded.notice) ctx.ui.notify(loaded.notice, "warning");
343
+ };
344
+
345
+ const stopPlanModeSettingsWatch = () => {
346
+ if (settingsReloadTimer) {
347
+ clearTimeout(settingsReloadTimer);
348
+ settingsReloadTimer = undefined;
349
+ }
350
+ settingsWatch?.close();
351
+ settingsWatch = undefined;
352
+ };
353
+
354
+ /**
355
+ * Watches the settings file's directory rather than the file itself: saves go
356
+ * through a temp file and an atomic rename, and a watch bound to the old inode
357
+ * would go deaf after the first one.
358
+ */
359
+ const startPlanModeSettingsWatch = (generation: number) => {
360
+ stopPlanModeSettingsWatch();
361
+ if (dependencies.readSettings) return;
362
+ const watchedPath = dependencies.settingsPath ?? planModeSettingsPath();
363
+ const watchedFile = basename(watchedPath);
364
+ try {
365
+ const watcher = watch(dirname(watchedPath), { persistent: false }, (event, changedFile) => {
366
+ if (event !== "rename" && event !== "change") return;
367
+ // A null filename means the platform could not name the entry; reload
368
+ // rather than miss the edit. The agent directory holds other churn, so
369
+ // a named entry that is not ours is ignored.
370
+ if (changedFile && changedFile.toString() !== watchedFile) return;
371
+ if (settingsReloadTimer) clearTimeout(settingsReloadTimer);
372
+ settingsReloadTimer = setTimeout(() => {
373
+ settingsReloadTimer = undefined;
374
+ void loadPlanModeSettings(generation);
375
+ }, SETTINGS_RELOAD_DEBOUNCE_MS);
376
+ });
377
+ watcher.on("error", stopPlanModeSettingsWatch);
378
+ settingsWatch = watcher;
379
+ } catch {
380
+ // An unwatchable directory only costs the live reload; settings still
381
+ // load at session start.
382
+ stopPlanModeSettingsWatch();
383
+ }
384
+ };
385
+
298
386
  pi.on("session_start", async (event, ctx) => {
299
387
  const generation = ++menuGeneration;
300
388
  refreshStateBeforeFirstAgentStart = event.reason === "new";
@@ -305,19 +393,15 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
305
393
  settings = { thinkingLevel: "inherit" };
306
394
  sessionPlanPath = resolveSessionPlanPath(ctx);
307
395
  restoreState(ctx);
308
- const loadedSettings = await (dependencies.readSettings?.() ?? readPlanModeSettings());
396
+ await loadPlanModeSettings(generation, ctx);
309
397
  if (generation !== menuGeneration || menuController.signal.aborted) return;
310
- if (loadedSettings.kind === "loaded") settings = loadedSettings.settings;
311
- else if (loadedSettings.kind === "invalid") {
312
- ctx.ui.notify(`pi-plan-mode settings ignored: ${loadedSettings.reason}`, "warning");
313
- }
314
- if (loadedSettings.notice) ctx.ui.notify(loadedSettings.notice, "warning");
398
+ startPlanModeSettingsWatch(generation);
315
399
  const persistFlagActivation = pi.getFlag("plan") === true && !state.enabled;
316
400
  if (persistFlagActivation) {
317
401
  state = { ...state, enabled: true, awaitingAction: state.planPath !== undefined };
318
402
  }
319
- if (state.enabled) applyPlanThinkingLevel();
320
- if (persistFlagActivation) persistState();
403
+ const capturedThinkingLevel = state.enabled ? applyPlanThinkingLevel() : false;
404
+ if (persistFlagActivation || capturedThinkingLevel) persistState();
321
405
  updateUi(ctx);
322
406
  });
323
407
 
@@ -336,6 +420,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
336
420
 
337
421
  pi.on("session_shutdown", async (_event, ctx) => {
338
422
  menuGeneration += 1;
423
+ stopPlanModeSettingsWatch();
339
424
  menuController.abort(new DOMException("Plan-mode session shut down", "AbortError"));
340
425
  pendingReadyNonce = undefined;
341
426
  latestCommandContext = undefined;
@@ -348,7 +433,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
348
433
  });
349
434
 
350
435
  /**
351
- * The complete enforcement surface: three static built-in names. Plan mode
436
+ * The complete enforcement surface: two static built-in names. Plan mode
352
437
  * does not classify, inspect, or filter any other tool.
353
438
  */
354
439
  pi.on("tool_call", async (event) => {
@@ -356,10 +441,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
356
441
  if (!BLOCKED_TOOLS.has(event.toolName)) return;
357
442
  return {
358
443
  block: true,
359
- reason:
360
- event.toolName === "update_plan"
361
- ? "Plan mode blocks update_plan because it tracks execution progress rather than conversational planning."
362
- : `Plan mode blocks '${event.toolName}' because planning must not mutate files. Finish the plan with plan_mode_complete, then implement.`,
444
+ reason: `Plan mode blocks '${event.toolName}' because planning must not mutate files. Finish the plan with plan_mode_complete, then implement.`,
363
445
  };
364
446
  });
365
447
 
@@ -367,7 +449,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
367
449
  if (refreshStateBeforeFirstAgentStart) {
368
450
  refreshStateBeforeFirstAgentStart = false;
369
451
  restoreState(ctx);
370
- if (state.enabled) applyPlanThinkingLevel();
452
+ if (state.enabled && applyPlanThinkingLevel()) persistState();
371
453
  updateUi(ctx);
372
454
  }
373
455
  if (state.enabled && state.awaitingAction) {
@@ -624,26 +706,42 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
624
706
  };
625
707
  }
626
708
 
627
- function applyPlanThinkingLevel() {
709
+ /**
710
+ * Returns true when the captured thinking levels changed, so the restore
711
+ * paths can persist that capture. It is the only record of the user's level:
712
+ * `setPlanThinkingLevel` goes through Pi's `setThinkingLevel`, which writes
713
+ * the new level into the user's settings, so a capture left only in memory is
714
+ * lost if the session ends without `session_shutdown`.
715
+ */
716
+ function applyPlanThinkingLevel(): boolean {
628
717
  if (state.manualThinkingLevel) {
629
718
  if (pi.getThinkingLevel() !== state.manualThinkingLevel) {
630
719
  setPlanThinkingLevel(pi, state.manualThinkingLevel);
631
720
  }
632
- return;
721
+ return false;
633
722
  }
634
723
  const configured = configuredThinkingLevel(settings);
635
724
  if (!configured) {
725
+ if (state.previousThinkingLevel === undefined && state.appliedThinkingLevel === undefined) {
726
+ return false;
727
+ }
636
728
  state = {
637
729
  ...state,
638
730
  previousThinkingLevel: undefined,
639
731
  appliedThinkingLevel: undefined,
640
732
  };
641
- return;
733
+ return true;
642
734
  }
643
735
  const current = pi.getThinkingLevel();
736
+ const capturedPrevious = state.previousThinkingLevel;
737
+ const capturedApplied = state.appliedThinkingLevel;
644
738
  if (!state.appliedThinkingLevel) state.previousThinkingLevel = current;
645
739
  if (current !== configured) setPlanThinkingLevel(pi, configured);
646
740
  state.appliedThinkingLevel = pi.getThinkingLevel();
741
+ return (
742
+ state.previousThinkingLevel !== capturedPrevious ||
743
+ state.appliedThinkingLevel !== capturedApplied
744
+ );
647
745
  }
648
746
 
649
747
  function captureManualThinkingLevel() {
@@ -10,7 +10,7 @@ export function updatePlanModeUi(ctx: ExtensionContext, state: PlanModeState) {
10
10
  if (state.enabled && state.awaitingAction) {
11
11
  ctx.ui.setWidget(PLAN_WIDGET_KEY, [
12
12
  "Proposed plan ready",
13
- "Use /plan to implement, export, revise, or exit Plan mode.",
13
+ "Use /plan to implement, export, or exit or type feedback to revise.",
14
14
  ]);
15
15
  } else if (state.enabled) {
16
16
  ctx.ui.setWidget(PLAN_WIDGET_KEY, [
@@ -48,7 +48,13 @@ export async function showStoredPlan(
48
48
  );
49
49
  return;
50
50
  }
51
- const title = state.enabled ? "Proposed Plan" : "Active Implementation Plan";
51
+ // enabled without awaitingAction but with a stored plan means revision
52
+ // feedback superseded the completed plan: show it, but never as current.
53
+ const title = state.enabled
54
+ ? state.awaitingAction
55
+ ? "Proposed Plan"
56
+ : "Superseded Proposed Plan (revision in progress — awaiting a new plan_mode_complete)"
57
+ : "Active Implementation Plan";
52
58
  showPlanModePlan(pi, ctx, title, plan);
53
59
  }
54
60
 
@@ -76,6 +82,9 @@ export function showPlanModePlan(
76
82
  export function planModeStatusText(state: PlanModeState) {
77
83
  if (state.enabled) {
78
84
  if (state.awaitingAction) return "Plan mode is active and a proposed plan is ready.";
85
+ if (state.planPath) {
86
+ return "Plan mode is active; revision in progress. The stored plan is superseded until the next plan_mode_complete.";
87
+ }
79
88
  return "Plan mode is active. Explore, ask, and finish with plan_mode_complete when decision-ready.";
80
89
  }
81
90
  if (state.planPath) return "An implementation plan is active.";
package/src/prompt.ts CHANGED
@@ -53,7 +53,7 @@ You are in Plan Mode, a collaboration mode for producing a decision-complete imp
53
53
 
54
54
  - Stay in Plan Mode until a developer or extension explicitly exits it.
55
55
  - Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
56
- - Do not use update_plan/TODO tooling in Plan Mode; Plan Mode is conversational planning, not execution progress tracking.
56
+ - Do not use todo/checklist tooling to track execution progress in Plan Mode; Plan Mode is conversational planning, and the plan itself belongs in plan_mode_complete.
57
57
  - Do not perform mutating actions: no edit/write tools, no patching, no formatting that rewrites files, no dependency installation, no commits, no migrations.
58
58
  - Gather information freely: read files, search, inspect configuration, and run read-only commands.
59
59