@hank-warren/pi-plan-mode 1.5.0 → 1.7.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/CHANGELOG.md +40 -0
- package/README.md +9 -5
- package/{skills/pi-plan-mode/SKILL.md → docs/plan-craft.md} +0 -6
- package/package.json +3 -6
- package/src/command.ts +1 -1
- package/src/completion-tool.ts +5 -5
- package/src/extension-runtime.ts +0 -11
- package/src/fresh-implementation.ts +2 -2
- package/src/lifecycle.ts +79 -0
- package/src/plan-export.ts +4 -4
- package/src/plan-file.ts +1 -5
- package/src/plan-launch-menu.ts +1 -1
- package/src/plan-mode.ts +110 -174
- package/src/presentation.ts +2 -2
- package/src/prompt.ts +23 -1
- package/src/question-tool.ts +28 -11
- package/src/settings-menu.ts +6 -48
- package/src/settings-watch.ts +60 -0
- package/src/settings.ts +12 -69
- package/src/state.ts +0 -44
package/src/settings-menu.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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"
|
|
27
|
-
| { kind: "invalid"; reason: string
|
|
28
|
-
| { kind: "loaded"; settings: PlanModeSettings
|
|
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
|
|
77
|
+
settingsPath = planModeSettingsPath(),
|
|
84
78
|
): Promise<PlanModeSettingsLoadResult> {
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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
|
}
|