@hank-warren/pi-plan-mode 1.4.0 → 1.6.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.
@@ -1,10 +1,59 @@
1
- import { Text } from "@earendil-works/pi-tui";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { Markdown, Text } from "@earendil-works/pi-tui";
2
+ import {
3
+ getMarkdownTheme,
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
3
7
  import { readPlanFile } from "./plan-file.js";
4
8
  import type { PlanModeState } from "./state.js";
5
9
 
6
10
  const STATUS_KEY = "plan-mode";
7
11
  const PLAN_WIDGET_KEY = "plan-mode-plan";
12
+ export const PLAN_CARD_ENTRY_TYPE = "plan-mode-card";
13
+
14
+ type PlanCardData = { title: string; plan: string };
15
+
16
+ /**
17
+ * Persisted entry data is input, not a guarantee.
18
+ *
19
+ * The renderer runs against whatever is on disk, which may predate a field, be
20
+ * truncated by a partial write, or have been hand-edited. Pi contains a
21
+ * renderer throw as an inline `[plan-mode-card] renderer failed: …` box —
22
+ * survivable, but a needlessly ugly way to say "this card is old".
23
+ */
24
+ function planCardData(value: unknown): PlanCardData | undefined {
25
+ if (typeof value !== "object" || value === null) return undefined;
26
+ const { title, plan } = value as { title?: unknown; plan?: unknown };
27
+ return typeof title === "string" && typeof plan === "string"
28
+ ? (value as PlanCardData)
29
+ : undefined;
30
+ }
31
+
32
+ /**
33
+ * The completed-plan card, as a display-only session entry.
34
+ *
35
+ * A custom *entry* rather than a message, which is what buys the property a
36
+ * message could not: Pi maps a `custom` entry to no context messages at all
37
+ * and skips it during compaction, so the plan stays visible and restorable in
38
+ * the transcript while never entering model context and never costing a
39
+ * compaction budget. The model gets a one-line `Plan saved to <path>.` from
40
+ * `plan_mode_complete` instead, and reads the durable file when it implements.
41
+ *
42
+ * pi-loop's approval card is the same mechanism for the same reason
43
+ * (`packages/pi-loop/src/presentation.ts`).
44
+ */
45
+ export function registerPlanModeCardRenderer(pi: ExtensionAPI): void {
46
+ pi.registerEntryRenderer(PLAN_CARD_ENTRY_TYPE, (entry) => {
47
+ const data = planCardData(entry.data);
48
+ if (!data) return new Text("Plan card unavailable.", 0, 0);
49
+ return new Markdown(
50
+ `**${data.title}**\n\n${data.plan}`,
51
+ 0,
52
+ 0,
53
+ getMarkdownTheme(),
54
+ );
55
+ });
56
+ }
8
57
 
9
58
  /**
10
59
  * The one thing both surfaces render.
@@ -20,9 +69,9 @@ const PLAN_WIDGET_KEY = "plan-mode-plan";
20
69
  * characters do not justify a shared package; a user reading a footer
21
70
  * justifies the consistency.
22
71
  */
23
- export type PlanModePhase = "drafting" | "revising" | "ready" | "implementing";
72
+ type PlanModePhase = "drafting" | "revising" | "ready" | "implementing";
24
73
 
25
- export interface PlanModeView {
74
+ interface PlanModeView {
26
75
  phase: PlanModePhase;
27
76
  /** The footer line: plain text with a glyph, no colour. */
28
77
  footer: string;
@@ -152,14 +201,7 @@ export function showPlanModePlan(
152
201
  plan: string,
153
202
  ) {
154
203
  try {
155
- pi.sendMessage(
156
- {
157
- customType: "proposed-plan",
158
- content: `**${title}**\n\n${plan}`,
159
- display: true,
160
- },
161
- { triggerTurn: false },
162
- );
204
+ pi.appendEntry<PlanCardData>(PLAN_CARD_ENTRY_TYPE, { title, plan });
163
205
  } catch (error: unknown) {
164
206
  const detail = error instanceof Error ? error.message : String(error);
165
207
  ctx.ui.notify(`Unable to show completed plan: ${detail}`, "error");
package/src/prompt.ts CHANGED
@@ -41,9 +41,29 @@ const QUESTION_TOOL_PROFILES: Record<string, QuestionToolProfile> = {
41
41
  *
42
42
  * The default keeps the exported function callable with no arguments and keeps
43
43
  * a standalone `pi-plan-mode` install reading exactly as it did before.
44
+ *
45
+ * Passing `null` builds the headless variant: no interactive question tool is
46
+ * active in that session, so naming one would tell the model to call a tool it
47
+ * cannot see. It asks in plain text instead.
44
48
  */
45
- export function buildPlanModePrompt(questionTool: string = PLAN_MODE_QUESTION_TOOL) {
46
- const tool = QUESTION_TOOL_PROFILES[questionTool] ?? QUESTION_TOOL_PROFILES[PLAN_MODE_QUESTION_TOOL];
49
+ export function buildPlanModePrompt(questionTool: string | null = PLAN_MODE_QUESTION_TOOL) {
50
+ const tool =
51
+ questionTool === null
52
+ ? undefined
53
+ : (QUESTION_TOOL_PROFILES[questionTool] ??
54
+ QUESTION_TOOL_PROFILES[PLAN_MODE_QUESTION_TOOL]);
55
+ const askBullet = tool
56
+ ? `Use ${tool.name} for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. ${tool.bounds} Do not include filler options.`
57
+ : "This session has no interactive question tool, so ask in plain text: put important preferences, tradeoffs, or assumption locks that non-mutating exploration cannot settle in your reply as 1-3 concise questions with 2-4 meaningful options each. Do not include filler options, and never call a question tool that is not in your tool set.";
58
+ const declineBullet = tool
59
+ ? `${tool.decline}, do not jump straight to a final plan when the missing answer is high impact. Ask one concise plain-text question or proceed only with a clearly stated low-risk assumption.`
60
+ : "If the question goes unanswered, do not jump straight to a final plan when the missing answer is high impact. Ask it again more concisely, or proceed only with a clearly stated low-risk assumption recorded in the plan.";
61
+ const endingBullet = tool
62
+ ? `If a material decision remains, use ${tool.name}. If interactive UI is unavailable, ask one concise plain-text question instead.`
63
+ : "If a material decision remains, ask one concise plain-text question instead.";
64
+ const revisionClause = tool
65
+ ? `continue planning with ${tool.name} instead of calling plan_mode_complete`
66
+ : "continue planning with a plain-text question instead of calling plan_mode_complete";
47
67
  return `${PLAN_CONTEXT_MARKER}
48
68
  # Plan Mode (Conversational)
49
69
 
@@ -73,14 +93,14 @@ You are in Plan Mode, a collaboration mode for producing a decision-complete imp
73
93
  ## Phase 3 — Implementation chat
74
94
 
75
95
  - Once intent is stable, keep asking until the spec is decision-complete: approach, interfaces, data flow, edge cases/failure modes, testing and acceptance criteria, and any migration or compatibility constraints.
76
- - Use ${tool.name} for important preferences, tradeoffs, or assumption locks that cannot be discovered by non-mutating exploration. ${tool.bounds} Do not include filler options.
77
- - ${tool.decline}, do not jump straight to a final plan when the missing answer is high impact. Ask one concise plain-text question or proceed only with a clearly stated low-risk assumption.
96
+ - ${askBullet}
97
+ - ${declineBullet}
78
98
 
79
99
  ## Ending each turn
80
100
 
81
101
  Every Plan-mode turn that advances or finalizes the plan must end in exactly one of these ways:
82
102
 
83
- - If a material decision remains, use ${tool.name}. If interactive UI is unavailable, ask one concise plain-text question instead.
103
+ - ${endingBullet}
84
104
  - If the implementation plan is decision-complete, call plan_mode_complete alone as your final action. Do not call other tools in the same batch and do not emit a normal assistant response after it.
85
105
 
86
106
  If a follow-up asks only for clarification and does not change or challenge the plan, answer it directly, then call plan_mode_complete alone as the final action with the complete unchanged plan so it remains available for implementation.
@@ -101,7 +121,7 @@ Keep the plan concise, human and agent digestible, and free of open decisions. P
101
121
 
102
122
  The plan is saved to a durable file, so it survives compaction and can be re-read at any time.
103
123
 
104
- If the user requests revisions after a completed plan, the next plan_mode_complete call must contain a complete replacement, not a delta. If there is not enough information for a complete replacement, continue planning with ${tool.name} instead of calling plan_mode_complete.`;
124
+ If the user requests revisions after a completed plan, the next plan_mode_complete call must contain a complete replacement, not a delta. If there is not enough information for a complete replacement, ${revisionClause}.`;
105
125
  }
106
126
 
107
127
  /**
@@ -2,12 +2,12 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  export const PLAN_MODE_QUESTION_TOOL_NAME = "plan_mode_question";
4
4
 
5
- export type PlanModeQuestionOption = {
5
+ type PlanModeQuestionOption = {
6
6
  label: string;
7
7
  description?: string;
8
8
  };
9
9
 
10
- export type PlanModeQuestion = {
10
+ type PlanModeQuestion = {
11
11
  id: string;
12
12
  header: string;
13
13
  question: string;
@@ -152,11 +152,13 @@ export async function answerPlanModeQuestions(
152
152
  questions: PlanModeQuestion[],
153
153
  ctx: ExtensionContext,
154
154
  lifecycle: { isCurrent(): boolean; isEnabled(): boolean },
155
+ signal?: AbortSignal,
155
156
  ) {
156
157
  const answers = await askPlanModeQuestions(
157
158
  questions,
158
159
  ctx,
159
- () => lifecycle.isCurrent() && lifecycle.isEnabled(),
160
+ () => lifecycle.isCurrent() && lifecycle.isEnabled() && !signal?.aborted,
161
+ signal,
160
162
  );
161
163
  if (!lifecycle.isCurrent()) {
162
164
  return planModeQuestionCancelled(
@@ -182,22 +184,26 @@ export async function answerPlanModeQuestions(
182
184
  return planModeQuestionAnswered(questions, answers);
183
185
  }
184
186
 
185
- export async function askPlanModeQuestions(
187
+ async function askPlanModeQuestions(
186
188
  questions: PlanModeQuestion[],
187
189
  ctx: ExtensionContext,
188
190
  shouldContinue: () => boolean = () => true,
191
+ signal?: AbortSignal,
189
192
  ): Promise<PlanModeQuestionAnswer[] | undefined> {
190
193
  const answers: PlanModeQuestionAnswer[] = [];
191
194
  for (const question of questions) {
195
+ if (!shouldContinue() || signal?.aborted) return undefined;
192
196
  const choices = question.options.map(formatPlanModeQuestionChoice);
193
197
  const otherChoice = `${question.options.length + 1}. Other (free-form)`;
194
- const choice = await ctx.ui.select(`${question.header}: ${question.question}`, [
195
- ...choices,
196
- otherChoice,
197
- ]);
198
+ const choice = await raceWithAbort(
199
+ ctx.ui.select(`${question.header}: ${question.question}`, [...choices, otherChoice]),
200
+ signal,
201
+ );
198
202
  if (!shouldContinue() || !choice) return undefined;
199
203
  if (choice === otherChoice) {
200
- const customAnswer = (await ctx.ui.editor(question.question, ""))?.trim();
204
+ const customAnswer = (
205
+ await raceWithAbort(ctx.ui.editor(question.question, ""), signal)
206
+ )?.trim();
201
207
  if (!shouldContinue() || !customAnswer) return undefined;
202
208
  answers.push({
203
209
  id: question.id,
@@ -223,11 +229,21 @@ export async function askPlanModeQuestions(
223
229
  return answers;
224
230
  }
225
231
 
232
+ async function raceWithAbort<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T | undefined> {
233
+ if (!signal) return operation;
234
+ if (signal.aborted) return undefined;
235
+ return new Promise<T | undefined>((resolve, reject) => {
236
+ const abort = () => resolve(undefined);
237
+ signal.addEventListener("abort", abort, { once: true });
238
+ operation.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
239
+ });
240
+ }
241
+
226
242
  function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: number) {
227
243
  return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
228
244
  }
229
245
 
230
- export function planModeQuestionAnswered(
246
+ function planModeQuestionAnswered(
231
247
  questions: PlanModeQuestion[],
232
248
  answers: PlanModeQuestionAnswer[],
233
249
  ) {
@@ -1,4 +1,3 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
2
  import { defineMenu, type RunMenuResult, runMenu } from "@narumitw/pi-tui-kit";
4
3
  import { planExportDestination } from "./plan-export.js";
@@ -16,17 +15,13 @@ import {
16
15
  interface SettingsMenuState {
17
16
  kind: "valid" | "invalid";
18
17
  settings: PlanModeSettings;
19
- notice?: string;
20
18
  reason?: string;
21
- /** The removed `thinkingLevel` key is still in the file. legacy: delete in 1.4.0 */
22
- hasLegacyThinkingLevel?: boolean;
23
19
  }
24
20
 
25
- export interface PlanModeSettingsMenuOptions {
21
+ interface PlanModeSettingsMenuOptions {
26
22
  signal: AbortSignal;
27
23
  isCurrent(): boolean;
28
24
  settingsPath?: string;
29
- legacySettingsPath?: string;
30
25
  readSettings?: (settingsPath?: string) => Promise<PlanModeSettingsLoadResult>;
31
26
  updateSettings?: (
32
27
  patch: PlanModeSettingsPatch,
@@ -49,19 +44,9 @@ export async function showPlanModeSettings(
49
44
  const loadState = async (): Promise<SettingsMenuState> => {
50
45
  const loaded = await readSettings(options.settingsPath);
51
46
  if (loaded.kind === "invalid") {
52
- return {
53
- kind: "invalid",
54
- settings: {},
55
- notice: loaded.notice,
56
- reason: loaded.reason,
57
- };
47
+ return { kind: "invalid", settings: {}, reason: loaded.reason };
58
48
  }
59
- return {
60
- kind: "valid",
61
- settings: loaded.kind === "loaded" ? loaded.settings : {},
62
- notice: loaded.notice,
63
- hasLegacyThinkingLevel: await hasLegacyThinkingLevel(settingsPath),
64
- };
49
+ return { kind: "valid", settings: loaded.kind === "loaded" ? loaded.settings : {} };
65
50
  };
66
51
 
67
52
  const menu = defineMenu<SettingsMenuState, Screen, Action, ExtensionContext>({
@@ -73,7 +58,7 @@ export async function showPlanModeSettings(
73
58
  : {
74
59
  kind: "settings",
75
60
  title: "Plan Mode Settings",
76
- lines: settingsLines(settingsPath, state),
61
+ lines: settingsLines(settingsPath),
77
62
  items: [
78
63
  {
79
64
  id: "defaultPlanExportPath",
@@ -132,11 +117,7 @@ export async function showPlanModeSettings(
132
117
  ) {
133
118
  if (signal.aborted || !options.isCurrent()) return { kind: "rejected" as const };
134
119
  try {
135
- const saved = await updateSettings(patch, {
136
- settingsPath: options.settingsPath,
137
- legacySettingsPath: options.legacySettingsPath,
138
- signal,
139
- });
120
+ const saved = await updateSettings(patch, { settingsPath: options.settingsPath, signal });
140
121
  if (options.isCurrent()) options.onSaved(saved);
141
122
  if (signal.aborted || !options.isCurrent()) return { kind: "rejected" as const };
142
123
  actionCtx.ui.notify(successMessage, "info");
@@ -153,35 +134,13 @@ export async function showPlanModeSettings(
153
134
  }
154
135
  }
155
136
 
156
- function settingsLines(settingsPath: string, state: SettingsMenuState) {
137
+ function settingsLines(settingsPath: string) {
157
138
  return [
158
139
  `User settings · ${safeTerminalText(settingsPath)}`,
159
140
  "The export destination applies to its next action.",
160
- // legacy: delete in 1.4.0
161
- ...(state.hasLegacyThinkingLevel
162
- ? [
163
- "thinkingLevel is no longer used — thinking is a session setting and Plan mode never changes it.",
164
- ]
165
- : []),
166
- ...(state.notice ? [safeTerminalText(state.notice)] : []),
167
141
  ];
168
142
  }
169
143
 
170
- /**
171
- * The removed key is preserved verbatim on save, so the only way to know it is
172
- * still there is to look at the file. A read failure simply hides the notice.
173
- *
174
- * legacy: delete in 1.4.0
175
- */
176
- async function hasLegacyThinkingLevel(settingsPath: string) {
177
- try {
178
- const parsed: unknown = JSON.parse(await readFile(settingsPath, "utf8"));
179
- return typeof parsed === "object" && parsed !== null && Object.hasOwn(parsed, "thinkingLevel");
180
- } catch {
181
- return false;
182
- }
183
- }
184
-
185
144
  function invalidScreen(settingsPath: string, state: SettingsMenuState) {
186
145
  return {
187
146
  kind: "detail" as const,
@@ -189,7 +148,6 @@ function invalidScreen(settingsPath: string, state: SettingsMenuState) {
189
148
  lines: [
190
149
  `Invalid settings file. Fix ${safeTerminalText(settingsPath)} before saving.`,
191
150
  safeTerminalText(state.reason ?? "The settings file is invalid."),
192
- ...(state.notice ? [safeTerminalText(state.notice)] : []),
193
151
  ],
194
152
  hint: "back" as const,
195
153
  };
@@ -0,0 +1,60 @@
1
+ import { watch } from "node:fs";
2
+ import { basename, dirname } from "node:path";
3
+
4
+ interface SettingsWatcherOptions {
5
+ /** The settings file to follow; its directory is what actually gets watched. */
6
+ path: string;
7
+ debounceMs: number;
8
+ onChange(): void;
9
+ }
10
+
11
+ /**
12
+ * Follows one settings file for out-of-band edits.
13
+ *
14
+ * The watch is on the file's *directory* rather than the file itself: saves go
15
+ * through a temp file and an atomic rename, and a watch bound to the old inode
16
+ * would go deaf after the first one. One hand-edit or menu save also fans out
17
+ * into several filesystem events (temp file created, renamed into place), so
18
+ * `debounceMs` collapses them into a single `onChange`.
19
+ */
20
+ export function createSettingsWatcher(options: SettingsWatcherOptions) {
21
+ const watchedFile = basename(options.path);
22
+ let watcher: ReturnType<typeof watch> | undefined;
23
+ let reloadTimer: ReturnType<typeof setTimeout> | undefined;
24
+
25
+ const stop = () => {
26
+ if (reloadTimer) {
27
+ clearTimeout(reloadTimer);
28
+ reloadTimer = undefined;
29
+ }
30
+ watcher?.close();
31
+ watcher = undefined;
32
+ };
33
+
34
+ return {
35
+ start() {
36
+ stop();
37
+ try {
38
+ const started = watch(dirname(options.path), { persistent: false }, (event, changed) => {
39
+ if (event !== "rename" && event !== "change") return;
40
+ // A null filename means the platform could not name the entry; reload
41
+ // rather than miss the edit. The directory holds other churn, so a
42
+ // named entry that is not ours is ignored.
43
+ if (changed && changed.toString() !== watchedFile) return;
44
+ if (reloadTimer) clearTimeout(reloadTimer);
45
+ reloadTimer = setTimeout(() => {
46
+ reloadTimer = undefined;
47
+ options.onChange();
48
+ }, options.debounceMs);
49
+ });
50
+ started.on("error", stop);
51
+ watcher = started;
52
+ } catch {
53
+ // An unwatchable directory only costs the live reload; settings still
54
+ // load at session start.
55
+ stop();
56
+ }
57
+ },
58
+ stop,
59
+ };
60
+ }
package/src/settings.ts CHANGED
@@ -5,7 +5,6 @@ import { basename, dirname, join } from "node:path";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  export const PLAN_MODE_SETTINGS_FILE = "pi-plan-mode.json";
8
- const LEGACY_PLAN_MODE_SETTINGS_FILE = "plan-mode.json";
9
8
  const MAX_SETTINGS_BYTES = 64 * 1024;
10
9
  export const DEFAULT_PLAN_EXPORT_PATH = "PLAN.md";
11
10
  const MAX_PLAN_EXPORT_PATH_LENGTH = 4096;
@@ -18,14 +17,13 @@ export interface PlanModeSettingsPatch {
18
17
  }
19
18
  export interface UpdatePlanModeSettingsOptions {
20
19
  settingsPath?: string;
21
- legacySettingsPath?: string;
22
20
  signal?: AbortSignal;
23
21
  beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>;
24
22
  }
25
23
  export type PlanModeSettingsLoadResult =
26
- | { kind: "missing"; notice?: string }
27
- | { kind: "invalid"; reason: string; notice?: string }
28
- | { kind: "loaded"; settings: PlanModeSettings; notice?: string };
24
+ | { kind: "missing" }
25
+ | { kind: "invalid"; reason: string }
26
+ | { kind: "loaded"; settings: PlanModeSettings };
29
27
 
30
28
  type SettingsDocument = Record<string, unknown>;
31
29
  type SettingsSnapshot = {
@@ -39,10 +37,6 @@ export function planModeSettingsPath() {
39
37
  return join(getAgentDir(), PLAN_MODE_SETTINGS_FILE);
40
38
  }
41
39
 
42
- function legacyPlanModeSettingsPath() {
43
- return join(getAgentDir(), LEGACY_PLAN_MODE_SETTINGS_FILE);
44
- }
45
-
46
40
  /**
47
41
  * Unknown top-level keys are tolerated and preserved on save. Settings removed
48
42
  * over time (defaultPlanTools, bashPolicy, safeSubcommands,
@@ -80,34 +74,10 @@ function normalizePlanExportPath(value: unknown) {
80
74
  }
81
75
 
82
76
  export async function readPlanModeSettings(
83
- settingsPath?: string,
77
+ settingsPath = planModeSettingsPath(),
84
78
  ): Promise<PlanModeSettingsLoadResult> {
85
- if (settingsPath) {
86
- await awaitPlanModeSettingsWrites(settingsPath);
87
- return (await readSettingsSnapshot(settingsPath)).result;
88
- }
89
- const canonicalPath = planModeSettingsPath();
90
- await awaitPlanModeSettingsWrites(canonicalPath);
91
- const canonical = await readSettingsSnapshot(canonicalPath);
92
- const legacyPath = legacyPlanModeSettingsPath();
93
- if (canonical.result.kind !== "missing") {
94
- return (await pathExists(legacyPath))
95
- ? {
96
- ...canonical.result,
97
- notice: `${LEGACY_PLAN_MODE_SETTINGS_FILE} ignored because ${PLAN_MODE_SETTINGS_FILE} takes precedence.`,
98
- }
99
- : canonical.result;
100
- }
101
-
102
- const legacy = await readSettingsSnapshot(legacyPath);
103
- const raced = await readSettingsSnapshot(canonicalPath);
104
- if (raced.result.kind !== "missing") return raced.result;
105
- return legacy.result.kind === "loaded"
106
- ? {
107
- ...legacy.result,
108
- notice: `Using legacy ${LEGACY_PLAN_MODE_SETTINGS_FILE}; rename it to ${PLAN_MODE_SETTINGS_FILE}. The legacy file was not modified.`,
109
- }
110
- : legacy.result;
79
+ await awaitPlanModeSettingsWrites(settingsPath);
80
+ return (await readSettingsSnapshot(settingsPath)).result;
111
81
  }
112
82
 
113
83
  export function updatePlanModeSettings(
@@ -115,11 +85,9 @@ export function updatePlanModeSettings(
115
85
  options: UpdatePlanModeSettingsOptions = {},
116
86
  ): Promise<PlanModeSettings> {
117
87
  const settingsPath = options.settingsPath ?? planModeSettingsPath();
118
- const legacySettingsPath =
119
- options.legacySettingsPath ?? (options.settingsPath ? undefined : legacyPlanModeSettingsPath());
120
88
  return enqueueMutation(settingsPath, async () => {
121
89
  options.signal?.throwIfAborted();
122
- const current = await readSettingsDocumentForUpdate(settingsPath, legacySettingsPath);
90
+ const current = await readSettingsDocumentForUpdate(settingsPath);
123
91
  const updated: SettingsDocument = { ...current };
124
92
  if (patch.defaultPlanExportPath === null) delete updated.defaultPlanExportPath;
125
93
  else if (patch.defaultPlanExportPath !== undefined) {
@@ -152,27 +120,12 @@ function enqueueMutation<T>(settingsPath: string, mutation: () => Promise<T>): P
152
120
  return result;
153
121
  }
154
122
 
155
- async function readSettingsDocumentForUpdate(
156
- settingsPath: string,
157
- legacySettingsPath: string | undefined,
158
- ): Promise<SettingsDocument> {
159
- const canonical = await readSettingsSnapshot(settingsPath);
160
- if (canonical.result.kind === "loaded") return canonical.document ?? {};
161
- if (canonical.result.kind === "invalid") {
162
- throw invalidSettingsError(settingsPath, canonical.result.reason);
163
- }
164
- if (!legacySettingsPath) return {};
165
-
166
- const legacy = await readSettingsSnapshot(legacySettingsPath);
167
- const raced = await readSettingsSnapshot(settingsPath);
168
- if (raced.result.kind === "loaded") return raced.document ?? {};
169
- if (raced.result.kind === "invalid") {
170
- throw invalidSettingsError(settingsPath, raced.result.reason);
171
- }
172
- if (legacy.result.kind === "invalid") {
173
- throw invalidSettingsError(legacySettingsPath, legacy.result.reason);
123
+ async function readSettingsDocumentForUpdate(settingsPath: string): Promise<SettingsDocument> {
124
+ const snapshot = await readSettingsSnapshot(settingsPath);
125
+ if (snapshot.result.kind === "invalid") {
126
+ throw invalidSettingsError(settingsPath, snapshot.result.reason);
174
127
  }
175
- return legacy.document ?? {};
128
+ return snapshot.result.kind === "loaded" ? (snapshot.document ?? {}) : {};
176
129
  }
177
130
 
178
131
  async function readSettingsSnapshot(settingsPath: string): Promise<SettingsSnapshot> {
@@ -264,16 +217,6 @@ function isSettingsDocument(value: unknown): value is SettingsDocument {
264
217
  return typeof value === "object" && value !== null && !Array.isArray(value);
265
218
  }
266
219
 
267
- async function pathExists(path: string) {
268
- try {
269
- const handle = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
270
- await handle.close();
271
- return true;
272
- } catch (error: unknown) {
273
- return !(isNodeError(error) && error.code === "ENOENT");
274
- }
275
- }
276
-
277
220
  function invalidSettingsError(settingsPath: string, reason: string) {
278
221
  return new Error(`pi-plan-mode settings at ${settingsPath} are invalid: ${reason}`);
279
222
  }
package/src/state.ts CHANGED
@@ -39,42 +39,6 @@ function newestStateEntry(entries: unknown[], stateEntryType: string): SessionEn
39
39
  return undefined;
40
40
  }
41
41
 
42
- // legacy: delete in 1.4.0
43
- const LEGACY_THINKING_LEVELS = [
44
- "off",
45
- "minimal",
46
- "low",
47
- "medium",
48
- "high",
49
- "xhigh",
50
- "max",
51
- ] as const;
52
-
53
- // legacy: delete in 1.4.0
54
- export type LegacyThinkingCapture = {
55
- previous: (typeof LEGACY_THINKING_LEVELS)[number];
56
- applied: (typeof LEGACY_THINKING_LEVELS)[number];
57
- };
58
-
59
- /**
60
- * Reads the thinking-level capture written by pi-plan-mode <= 1.2.1, so a
61
- * session interrupted while Plan mode held a raised level can have the user's
62
- * level put back once. Both halves must be present and valid: a partial or
63
- * absent capture is nothing to repair.
64
- *
65
- * legacy: delete in 1.4.0
66
- */
67
- export function readLegacyThinkingCapture(
68
- entries: unknown[],
69
- stateEntryType: string,
70
- ): LegacyThinkingCapture | undefined {
71
- const entry = newestStateEntry(entries, stateEntryType);
72
- if (!isRecord(entry?.data)) return undefined;
73
- const previous = legacyThinkingLevel(entry.data.previousThinkingLevel);
74
- const applied = legacyThinkingLevel(entry.data.appliedThinkingLevel);
75
- return previous && applied ? { previous, applied } : undefined;
76
- }
77
-
78
42
  /**
79
43
  * Persisted paths are only trusted when they are absolute and free of NUL, so
80
44
  * malformed state can never redirect a read or a delete to a relative target.
@@ -86,14 +50,6 @@ function absolutePath(value: unknown) {
86
50
  return normalized;
87
51
  }
88
52
 
89
- // legacy: delete in 1.4.0
90
- function legacyThinkingLevel(value: unknown): (typeof LEGACY_THINKING_LEVELS)[number] | undefined {
91
- return typeof value === "string" &&
92
- LEGACY_THINKING_LEVELS.includes(value as (typeof LEGACY_THINKING_LEVELS)[number])
93
- ? (value as (typeof LEGACY_THINKING_LEVELS)[number])
94
- : undefined;
95
- }
96
-
97
53
  function isRecord(value: unknown): value is Record<string, unknown> {
98
54
  return typeof value === "object" && value !== null && !Array.isArray(value);
99
55
  }