@hank-warren/pi-plan-mode 0.1.0 → 1.0.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.
@@ -14,9 +14,9 @@ interface PlanActionControllerOptions {
14
14
  getState(): PlanModeState;
15
15
  captureLifecycle(): MenuLifecycle;
16
16
  statusText(): string;
17
- implementationOutcome(): string;
17
+ planPathLine(): string | undefined;
18
18
  getExportDestination(ctx: ExtensionContext): PlanExportDestination;
19
- show(ctx: ExtensionContext): void;
19
+ show(ctx: ExtensionContext): void | Promise<void>;
20
20
  finalize(ctx: ExtensionContext): void;
21
21
  implementHere(ctx: ExtensionContext): void | Promise<void>;
22
22
  implementFresh(ctx: ExtensionContext, isCurrent: () => boolean): void | Promise<void>;
@@ -26,11 +26,8 @@ interface PlanActionControllerOptions {
26
26
  signal: AbortSignal,
27
27
  isCurrent: () => boolean,
28
28
  ): Promise<boolean>;
29
- settings(ctx: ExtensionContext, signal: AbortSignal, isCurrent: () => boolean): Promise<boolean>;
30
- save(ctx: ExtensionContext): void;
31
29
  stay(ctx: ExtensionContext): void;
32
30
  exitReady(ctx: ExtensionContext): void;
33
- clearSaved(ctx: ExtensionContext): void;
34
31
  }
35
32
 
36
33
  export function createPlanActionController(options: PlanActionControllerOptions) {
@@ -38,25 +35,6 @@ export function createPlanActionController(options: PlanActionControllerOptions)
38
35
  options.implementFresh(ctx, () => lifecycle.isCurrent() && !signal.aborted);
39
36
 
40
37
  return {
41
- async showSaved(ctx: ExtensionContext) {
42
- const lifecycle = options.captureLifecycle();
43
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
44
- const ui = await options.loadInteractiveUi();
45
- if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
46
- await ui.showSavedPlanMenu(ctx, {
47
- statusText: options.statusText(),
48
- implementationOutcome: options.implementationOutcome,
49
- getExportDestination: () => options.getExportDestination(ctx),
50
- signal: lifecycle.signal,
51
- isCurrent: lifecycle.isCurrent,
52
- show: () => options.show(ctx),
53
- implementHere: () => options.implementHere(ctx),
54
- implementFresh: (signal) => freshAction(ctx, lifecycle, signal),
55
- exportPlan: (path, signal) => options.exportPlan(ctx, path, signal, lifecycle.isCurrent),
56
- settings: (signal) => options.settings(ctx, signal, lifecycle.isCurrent),
57
- clear: () => options.clearSaved(ctx),
58
- });
59
- },
60
38
  async showCurrent(ctx: ExtensionContext) {
61
39
  if (!ctx.hasUI) {
62
40
  ctx.ui.notify(options.statusText(), "info");
@@ -68,8 +46,8 @@ export function createPlanActionController(options: PlanActionControllerOptions)
68
46
  if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
69
47
  await ui.showPlanModeMenu(ctx, {
70
48
  statusText: options.statusText(),
71
- hasReadyPlan: options.getState().latestPlan !== undefined,
72
- implementationOutcome: options.implementationOutcome,
49
+ hasReadyPlan: options.getState().awaitingAction,
50
+ planPathLine: options.planPathLine(),
73
51
  getExportDestination: () => options.getExportDestination(ctx),
74
52
  ...lifecycle,
75
53
  show: () => options.show(ctx),
@@ -77,7 +55,6 @@ export function createPlanActionController(options: PlanActionControllerOptions)
77
55
  implementHere: () => options.implementHere(ctx),
78
56
  implementFresh: (signal) => freshAction(ctx, lifecycle, signal),
79
57
  exportPlan: (path, signal) => options.exportPlan(ctx, path, signal, lifecycle.isCurrent),
80
- save: () => options.save(ctx),
81
58
  stay: () => options.stay(ctx),
82
59
  exit: () => options.exitReady(ctx),
83
60
  });
@@ -89,12 +66,11 @@ export function createPlanActionController(options: PlanActionControllerOptions)
89
66
  if (!lifecycle.isCurrent() || lifecycle.signal.aborted) return;
90
67
  await ui.showReadyPlanMenu(ctx, {
91
68
  ...lifecycle,
92
- implementationOutcome: options.implementationOutcome,
69
+ planPathLine: options.planPathLine(),
93
70
  getExportDestination: () => options.getExportDestination(ctx),
94
71
  implementHere: () => options.implementHere(ctx),
95
72
  implementFresh: (signal) => freshAction(ctx, lifecycle, signal),
96
73
  exportPlan: (path, signal) => options.exportPlan(ctx, path, signal, lifecycle.isCurrent),
97
- save: () => options.save(ctx),
98
74
  stay: () => undefined,
99
75
  exit: () => options.exitReady(ctx),
100
76
  });
@@ -9,20 +9,19 @@ interface MenuLifecycle {
9
9
 
10
10
  const IMPLEMENTATION_CONTEXT_LINES = [
11
11
  "Implement here keeps this planning conversation.",
12
- "Start fresh transfers only the approved plan to a new session.",
12
+ "Start fresh opens a new session that reads the same plan file.",
13
13
  ] as const;
14
14
 
15
15
  interface PlanMenuOptions extends MenuLifecycle {
16
16
  statusText: string;
17
17
  hasReadyPlan: boolean;
18
- implementationOutcome(): string;
18
+ planPathLine?: string;
19
19
  getExportDestination: PlanExportDestinationProvider;
20
- show(): void;
20
+ show(): void | Promise<void>;
21
21
  finalize(): void;
22
22
  implementHere(): void | Promise<void>;
23
23
  implementFresh(signal: AbortSignal): void | Promise<void>;
24
24
  exportPlan(path: string, signal: AbortSignal): Promise<boolean>;
25
- save(): void;
26
25
  stay(): void;
27
26
  exit(): void;
28
27
  }
@@ -35,7 +34,6 @@ export async function showPlanModeMenu(ctx: ExtensionContext, options: PlanMenuO
35
34
  | "implement-here"
36
35
  | "implement-fresh"
37
36
  | "export"
38
- | "save"
39
37
  | "stay"
40
38
  | "exit";
41
39
  const menu = defineMenu<undefined, Screen, Action, ExtensionContext>({
@@ -47,7 +45,10 @@ export async function showPlanModeMenu(ctx: ExtensionContext, options: PlanMenuO
47
45
  lines: [
48
46
  options.statusText,
49
47
  ...(options.hasReadyPlan
50
- ? [...IMPLEMENTATION_CONTEXT_LINES, options.implementationOutcome()]
48
+ ? [
49
+ ...IMPLEMENTATION_CONTEXT_LINES,
50
+ ...(options.planPathLine ? [options.planPathLine] : []),
51
+ ]
51
52
  : []),
52
53
  ],
53
54
  items: options.hasReadyPlan
@@ -62,12 +63,11 @@ export async function showPlanModeMenu(ctx: ExtensionContext, options: PlanMenuO
62
63
  {
63
64
  id: "implement-fresh",
64
65
  label: "Start fresh and implement",
65
- description: "Open a new linked session; transfer only the approved plan.",
66
+ description: "Open a new linked session that reads the same plan file.",
66
67
  action: "implement-fresh",
67
68
  busyLabel: "Starting fresh implementation session…",
68
69
  },
69
70
  { id: "export", label: "Export plan…", to: "export" },
70
- { id: "save", label: "Save for later", action: "save" },
71
71
  { id: "stay", label: "Stay in Plan mode", action: "stay" },
72
72
  { id: "exit", label: "Discard plan and exit", action: "exit" },
73
73
  ]
@@ -82,7 +82,7 @@ export async function showPlanModeMenu(ctx: ExtensionContext, options: PlanMenuO
82
82
  },
83
83
  actions: {
84
84
  show: async () => {
85
- options.show();
85
+ await options.show();
86
86
  return { kind: "close" };
87
87
  },
88
88
  finalize: async () => {
@@ -99,10 +99,6 @@ export async function showPlanModeMenu(ctx: ExtensionContext, options: PlanMenuO
99
99
  },
100
100
  export: async ({ value, signal }) =>
101
101
  (await options.exportPlan(value ?? "", signal)) ? { kind: "close" } : { kind: "rejected" },
102
- save: async () => {
103
- options.save();
104
- return { kind: "close" };
105
- },
106
102
  stay: async () => {
107
103
  options.stay();
108
104
  return { kind: "close" };
@@ -121,26 +117,28 @@ export async function showPlanModeMenu(ctx: ExtensionContext, options: PlanMenuO
121
117
  }
122
118
 
123
119
  interface ReadyPlanMenuOptions extends MenuLifecycle {
124
- implementationOutcome(): string;
120
+ planPathLine?: string;
125
121
  getExportDestination: PlanExportDestinationProvider;
126
122
  implementHere(): void | Promise<void>;
127
123
  implementFresh(signal: AbortSignal): void | Promise<void>;
128
124
  exportPlan(path: string, signal: AbortSignal): Promise<boolean>;
129
- save(): void;
130
125
  stay(): void;
131
126
  exit(): void;
132
127
  }
133
128
 
134
129
  export async function showReadyPlanMenu(ctx: ExtensionContext, options: ReadyPlanMenuOptions) {
135
130
  type Screen = "ready" | "export";
136
- type Action = "implement-here" | "implement-fresh" | "export" | "save" | "stay" | "exit";
131
+ type Action = "implement-here" | "implement-fresh" | "export" | "stay" | "exit";
137
132
  const menu = defineMenu<undefined, Screen, Action, ExtensionContext>({
138
133
  start: "ready",
139
134
  screens: {
140
135
  ready: () => ({
141
136
  kind: "actions",
142
137
  title: "Proposed plan ready. What next?",
143
- lines: [...IMPLEMENTATION_CONTEXT_LINES, options.implementationOutcome()],
138
+ lines: [
139
+ ...IMPLEMENTATION_CONTEXT_LINES,
140
+ ...(options.planPathLine ? [options.planPathLine] : []),
141
+ ],
144
142
  items: [
145
143
  {
146
144
  id: "implement-here",
@@ -151,12 +149,11 @@ export async function showReadyPlanMenu(ctx: ExtensionContext, options: ReadyPla
151
149
  {
152
150
  id: "implement-fresh",
153
151
  label: "Start fresh and implement",
154
- description: "Open a new linked session; transfer only the approved plan.",
152
+ description: "Open a new linked session that reads the same plan file.",
155
153
  action: "implement-fresh",
156
154
  busyLabel: "Starting fresh implementation session…",
157
155
  },
158
156
  { id: "export", label: "Export plan…", to: "export" },
159
- { id: "save", label: "Save for later", action: "save" },
160
157
  { id: "stay", label: "Stay in Plan mode", action: "stay" },
161
158
  { id: "exit", label: "Discard plan and exit", action: "exit" },
162
159
  ],
@@ -175,10 +172,6 @@ export async function showReadyPlanMenu(ctx: ExtensionContext, options: ReadyPla
175
172
  },
176
173
  export: async ({ value, signal }) =>
177
174
  (await options.exportPlan(value ?? "", signal)) ? { kind: "close" } : { kind: "rejected" },
178
- save: async () => {
179
- options.save();
180
- return { kind: "close" };
181
- },
182
175
  stay: async () => {
183
176
  options.stay();
184
177
  return { kind: "close" };
@@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { stripVTControlCharacters } from "node:util";
4
4
  import { type ExtensionContext, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
5
+ import { readPlanFile } from "./plan-file.js";
5
6
  import { DEFAULT_PLAN_EXPORT_PATH } from "./settings.js";
6
7
  import type { PlanModeState } from "./state.js";
7
8
 
@@ -23,6 +24,10 @@ export interface PlanExportLifecycle {
23
24
  finishReady?(): void;
24
25
  }
25
26
 
27
+ /**
28
+ * Export copies the durable plan file to a user-chosen path. The plan is read
29
+ * from disk at export time, so a hand-edited plan exports as edited.
30
+ */
26
31
  export async function exportStoredPlan(
27
32
  state: PlanModeState,
28
33
  requestedPath: string | undefined,
@@ -30,10 +35,7 @@ export async function exportStoredPlan(
30
35
  lifecycle?: PlanExportLifecycle,
31
36
  defaultPath = DEFAULT_PLAN_EXPORT_PATH,
32
37
  ) {
33
- const plan =
34
- (state.enabled ? state.latestPlan : undefined)?.trim() ??
35
- state.savedPlan?.plan.trim() ??
36
- state.activeImplementation?.plan.trim();
38
+ const plan = state.planPath ? await readPlanFile(state.planPath) : undefined;
37
39
  if (!plan) {
38
40
  const error = new Error(
39
41
  "No completed plan is available to export. Use /plan finalize when planning is complete.",
@@ -65,8 +67,7 @@ export async function exportStoredPlan(
65
67
  }
66
68
 
67
69
  if (!isCurrent()) return false;
68
- const finishedReady =
69
- state.enabled && Boolean(state.latestPlan?.trim()) && lifecycle?.finishReady !== undefined;
70
+ const finishedReady = state.enabled && state.awaitingAction && lifecycle?.finishReady !== undefined;
70
71
  if (finishedReady) lifecycle.finishReady?.();
71
72
  const detail = finishedReady ? " Plan mode disabled." : "";
72
73
  ctx.ui.notify(safeNotification(`Plan exported to ${result.path}.${detail}`), "info");
@@ -82,12 +83,13 @@ export async function exportPlanToFile(
82
83
  defaultPath = DEFAULT_PLAN_EXPORT_PATH,
83
84
  ): Promise<PlanExportResult> {
84
85
  const path = resolvePlanExportPath(requestedPath, cwd, defaultPath);
86
+ const contents = plan.endsWith("\n") ? plan : `${plan}\n`;
85
87
  await withFileMutationQueue(path, async () => {
86
88
  throwIfCancelled(signal, isCurrent);
87
89
  await mkdir(dirname(path), { recursive: true });
88
90
  throwIfCancelled(signal, isCurrent);
89
91
  try {
90
- await writeFile(path, `${plan}\n`, { encoding: "utf8", flag: "wx" });
92
+ await writeFile(path, contents, { encoding: "utf8", flag: "wx" });
91
93
  } catch (error: unknown) {
92
94
  if (isNodeError(error) && error.code === "EEXIST") {
93
95
  throw new Error(
@@ -0,0 +1,85 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { mkdir, open, rename, rm, unlink, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+
7
+ export const PLANS_DIRECTORY = "plans";
8
+ const MAX_PLAN_BYTES = 1024 * 1024;
9
+
10
+ /**
11
+ * The durable plan file is the plan. Session state stores only its path, so a
12
+ * plan survives compaction, resume, and hand-editing without ever being copied
13
+ * into model context.
14
+ */
15
+ export function plansDirectory() {
16
+ return join(getAgentDir(), PLANS_DIRECTORY);
17
+ }
18
+
19
+ /**
20
+ * Session ids come from Pi and are normally uuid-like, but they reach a
21
+ * filesystem path here: constrain them to a safe basename so a hostile or
22
+ * unusual id cannot escape the plans directory.
23
+ */
24
+ export function planFilePathForSession(sessionId: string | undefined) {
25
+ return join(plansDirectory(), `${safeSessionSegment(sessionId)}.md`);
26
+ }
27
+
28
+ function safeSessionSegment(sessionId: string | undefined) {
29
+ const normalized = (sessionId ?? "").trim().replace(/[^\w.-]/gu, "-");
30
+ const trimmed = normalized.replace(/^[.-]+/u, "").slice(0, 128);
31
+ // In-memory sessions have no id; fall back to a stable per-process name so
32
+ // the plan still persists for the lifetime of the session.
33
+ return trimmed || `session-${process.pid}`;
34
+ }
35
+
36
+ /**
37
+ * Atomic same-directory temp + rename, matching how settings are published, so
38
+ * a concurrent reader never observes a partially written plan.
39
+ */
40
+ export async function writePlanFile(path: string, plan: string): Promise<void> {
41
+ const contents = plan.endsWith("\n") ? plan : `${plan}\n`;
42
+ if (Buffer.byteLength(contents, "utf8") > MAX_PLAN_BYTES) {
43
+ throw new Error(`plan exceeds ${MAX_PLAN_BYTES} bytes`);
44
+ }
45
+ const directory = dirname(path);
46
+ await mkdir(directory, { recursive: true });
47
+ const temporaryPath = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
48
+ try {
49
+ await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx", mode: 0o600 });
50
+ await rename(temporaryPath, path);
51
+ } finally {
52
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Always read from disk. The user may hand-edit the plan while implementation
58
+ * is under way, and that edit must be what the agent and every command see.
59
+ */
60
+ export async function readPlanFile(path: string): Promise<string | undefined> {
61
+ let handle: Awaited<ReturnType<typeof open>>;
62
+ try {
63
+ handle = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ try {
68
+ const stats = await handle.stat();
69
+ if (!stats.isFile() || stats.size > MAX_PLAN_BYTES) return undefined;
70
+ const contents = await handle.readFile({ encoding: "utf8" });
71
+ return contents.trim() ? contents : undefined;
72
+ } catch {
73
+ return undefined;
74
+ } finally {
75
+ await handle.close().catch(() => undefined);
76
+ }
77
+ }
78
+
79
+ export async function deletePlanFile(path: string): Promise<void> {
80
+ await unlink(path).catch(() => undefined);
81
+ }
82
+
83
+ export async function planFileExists(path: string): Promise<boolean> {
84
+ return (await readPlanFile(path)) !== undefined;
85
+ }
@@ -1,116 +1,56 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
3
 
4
- export interface PlanLaunchTool {
5
- name: string;
6
- description: string;
7
- searchText: string;
8
- disabled: boolean;
9
- disabledReason?: string;
10
- }
11
-
12
- interface PlanLaunchMenuOptions {
4
+ export interface PlanLaunchMenuOptions {
13
5
  statusText: string;
14
- toolSummary(selectedNames: ReadonlySet<string>): string;
15
- getSelectedNames(): ReadonlySet<string>;
16
- tools: readonly PlanLaunchTool[];
17
6
  signal: AbortSignal;
18
7
  isCurrent(): boolean;
19
- initialScreen?: "main" | "tools";
20
8
  start(signal: AbortSignal): void;
21
- startWithTools(toolNames: string[], signal: AbortSignal): void;
22
9
  settings(signal: AbortSignal): Promise<boolean>;
23
10
  }
24
11
 
12
+ const HOW_IT_WORKS_LINES = [
13
+ "Plan mode is for research and design, not implementation.",
14
+ "Explore the codebase, ask decision questions, then finish with plan_mode_complete.",
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.",
17
+ "All other tools stay exactly as configured; command safety is left to your permission extension.",
18
+ "When the plan is ready: implement here, or start a fresh session that reads the same file.",
19
+ ] as const;
20
+
25
21
  export async function showPlanLaunchMenu(ctx: ExtensionContext, options: PlanLaunchMenuOptions) {
26
- type Screen = "main" | "tools" | "help";
27
- type Action = "start" | "toggle-tool" | "start-with-tools" | "settings";
28
- const selectedNames = new Set(options.getSelectedNames());
29
- let draftChanged = false;
22
+ type Screen = "main" | "how";
23
+ type Action = "start" | "settings";
30
24
  const menu = defineMenu<undefined, Screen, Action, ExtensionContext>({
31
- start: options.initialScreen ?? "main",
25
+ start: "main",
32
26
  screens: {
33
27
  main: () => ({
34
28
  kind: "actions",
35
29
  title: "Plan mode",
36
- lines: [options.statusText, options.toolSummary(selectedNames)],
30
+ lines: [options.statusText],
37
31
  items: [
38
32
  { id: "start", label: "Start Plan mode", action: "start" },
39
- { id: "tools", label: "Choose tools, then start…", to: "tools" },
40
33
  { id: "settings", label: "Settings", action: "settings" },
41
- { id: "help", label: "How Plan mode works", to: "help" },
34
+ { id: "how", label: "How Plan mode works", to: "how" },
42
35
  ],
43
36
  hint: "close",
44
37
  }),
45
- tools: () => ({
46
- kind: "multiSelect",
47
- title: "Choose Plan-mode tools",
48
- lines: [
49
- "Changes apply only when you start Plan mode.",
50
- "Non-built-in tools run at user risk.",
51
- ],
52
- enableSearch: true,
53
- viewportSize: 10,
54
- items: options.tools.map((tool) => ({
55
- id: tool.name,
56
- label: tool.name,
57
- description: tool.description,
58
- searchText: tool.searchText,
59
- selected: selectedNames.has(tool.name),
60
- disabled: tool.disabled,
61
- disabledReason: tool.disabledReason,
62
- })),
63
- action: "toggle-tool",
64
- actions: [
65
- {
66
- id: "start-with-tools",
67
- label: "Done — start Plan mode",
68
- action: "start-with-tools",
69
- },
70
- ],
71
- hint: "back",
72
- }),
73
- help: () => ({
38
+ how: () => ({
74
39
  kind: "detail",
75
40
  title: "How Plan mode works",
76
- lines: [
77
- "Plan mode uses read-only exploration to understand the project before implementation.",
78
- "The agent can ask important decision questions, then returns a complete implementation-ready plan.",
79
- "File mutation stays blocked until you explicitly choose to implement the completed plan.",
80
- ],
41
+ lines: [...HOW_IT_WORKS_LINES],
81
42
  hint: "back",
82
43
  }),
83
44
  },
84
45
  actions: {
85
46
  start: async ({ signal }) => {
86
- if (signal.aborted || !options.isCurrent()) return { kind: "rejected" };
87
47
  options.start(signal);
88
48
  return { kind: "close" };
89
49
  },
90
- "toggle-tool": async ({ itemId, selected, signal }) => {
91
- if (signal.aborted || !options.isCurrent()) return { kind: "rejected" };
92
- const tool = options.tools.find((candidate) => candidate.name === itemId);
93
- if (!tool || tool.disabled) return { kind: "rejected" };
94
- if (selected) selectedNames.add(tool.name);
95
- else selectedNames.delete(tool.name);
96
- draftChanged = true;
97
- return { kind: "stay" };
98
- },
99
- "start-with-tools": async ({ signal }) => {
100
- if (signal.aborted || !options.isCurrent()) return { kind: "rejected" };
101
- options.startWithTools(Array.from(selectedNames), signal);
102
- return { kind: "close" };
103
- },
104
50
  settings: async ({ signal }) => {
105
- if (signal.aborted || !options.isCurrent()) return { kind: "rejected" };
106
51
  const close = await options.settings(signal);
107
52
  if (signal.aborted || !options.isCurrent()) return { kind: "rejected" };
108
- if (close) return { kind: "close" };
109
- if (!draftChanged) {
110
- selectedNames.clear();
111
- for (const name of options.getSelectedNames()) selectedNames.add(name);
112
- }
113
- return { kind: "stay" };
53
+ return close ? { kind: "close" } : { kind: "stay" };
114
54
  },
115
55
  },
116
56
  });