@hank-warren/pi-plan-mode 0.1.0 → 1.0.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 +27 -0
- package/NOTICE.md +4 -0
- package/README.md +60 -246
- package/package.json +5 -5
- package/src/active-implementation-menu.ts +4 -3
- package/src/command.ts +4 -10
- package/src/completion-tool.ts +3 -1
- package/src/fresh-implementation.ts +19 -26
- package/src/interactive-ui.ts +0 -1
- package/src/plan-action-controller.ts +5 -29
- package/src/plan-action-menus.ts +16 -23
- package/src/plan-export.ts +9 -7
- package/src/plan-file.ts +85 -0
- package/src/plan-launch-menu.ts +18 -78
- package/src/plan-mode.ts +126 -505
- package/src/presentation.ts +21 -40
- package/src/prompt.ts +15 -12
- package/src/settings-menu.ts +4 -181
- package/src/settings.ts +6 -105
- package/src/state.ts +22 -118
- package/src/auto-permissions-delegation.ts +0 -122
- package/src/implementation-retention.ts +0 -122
- package/src/message-transform.ts +0 -232
- package/src/required-tools.ts +0 -22
- package/src/saved-plan-menu.ts +0 -93
- package/src/saved-plan-preflight.ts +0 -39
- package/src/tool-policy.ts +0 -563
- package/src/tool-selection.ts +0 -98
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import
|
|
4
|
-
import type {
|
|
2
|
+
import { readPlanFile } from "./plan-file.js";
|
|
3
|
+
import type { PlanModeState } from "./state.js";
|
|
5
4
|
|
|
6
5
|
type NewSessionOptions = Exclude<Parameters<ExtensionCommandContext["newSession"]>[0], undefined>;
|
|
7
6
|
type ReplacementContext = Parameters<NonNullable<NewSessionOptions["withSession"]>>[0];
|
|
8
7
|
|
|
9
8
|
export interface FreshImplementationRequest {
|
|
10
9
|
plan: string;
|
|
11
|
-
|
|
12
|
-
retention: ImplementationPlanRetention;
|
|
10
|
+
planPath: string;
|
|
13
11
|
stateEntryType: string;
|
|
14
12
|
isCurrent(): boolean;
|
|
15
13
|
}
|
|
@@ -17,7 +15,6 @@ export interface FreshImplementationRequest {
|
|
|
17
15
|
interface FreshImplementationFromStateOptions {
|
|
18
16
|
getState(): PlanModeState;
|
|
19
17
|
menuIsCurrent(): boolean;
|
|
20
|
-
retention: ImplementationPlanRetention;
|
|
21
18
|
stateEntryType: string;
|
|
22
19
|
}
|
|
23
20
|
|
|
@@ -28,8 +25,13 @@ export type FreshImplementationResult =
|
|
|
28
25
|
| { kind: "rejected" }
|
|
29
26
|
| { kind: "stale" };
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The handoff names the plan file rather than inlining the plan, so the request
|
|
30
|
+
* stays small and the agent re-reads the authoritative file if context is
|
|
31
|
+
* compacted mid-implementation.
|
|
32
|
+
*/
|
|
33
|
+
export function formatImplementationHandoff(planPath: string) {
|
|
34
|
+
return `Plan mode is now disabled. Implement the approved plan stored at ${planPath}. Read that file first; it is the source of truth and may have been edited.`;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export async function startFreshImplementationFromState(
|
|
@@ -44,10 +46,9 @@ export async function startFreshImplementationFromState(
|
|
|
44
46
|
return { kind: "rejected" } as const;
|
|
45
47
|
}
|
|
46
48
|
const initialState = options.getState();
|
|
47
|
-
const
|
|
48
|
-
const plan =
|
|
49
|
-
|
|
50
|
-
if (!plan || !source) {
|
|
49
|
+
const planPath = initialState.planPath;
|
|
50
|
+
const plan = planPath ? await readPlanFile(planPath) : undefined;
|
|
51
|
+
if (!planPath || !plan) {
|
|
51
52
|
ctx.ui.notify("No completed plan is available to implement.", "warning");
|
|
52
53
|
return { kind: "rejected" } as const;
|
|
53
54
|
}
|
|
@@ -57,15 +58,12 @@ export async function startFreshImplementationFromState(
|
|
|
57
58
|
return (
|
|
58
59
|
options.menuIsCurrent() &&
|
|
59
60
|
current.enabled === wasEnabled &&
|
|
60
|
-
|
|
61
|
-
? current.latestPlan === plan && current.latestPlanSource === source
|
|
62
|
-
: current.savedPlan === savedPlan)
|
|
61
|
+
current.planPath === planPath
|
|
63
62
|
);
|
|
64
63
|
};
|
|
65
64
|
return startFreshImplementationSession(ctx, {
|
|
66
65
|
plan,
|
|
67
|
-
|
|
68
|
-
retention: options.retention,
|
|
66
|
+
planPath,
|
|
69
67
|
stateEntryType: options.stateEntryType,
|
|
70
68
|
isCurrent,
|
|
71
69
|
});
|
|
@@ -84,19 +82,14 @@ export async function startFreshImplementationSession(
|
|
|
84
82
|
if (!(await preflightModel(ctx, request.isCurrent))) return { kind: "rejected" };
|
|
85
83
|
if (!request.isCurrent()) return { kind: "stale" };
|
|
86
84
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
plan: request.plan,
|
|
90
|
-
source: request.source,
|
|
91
|
-
startedAt: Date.now(),
|
|
92
|
-
retention: request.retention,
|
|
93
|
-
};
|
|
85
|
+
// The destination points at the same durable plan file: the plan itself is
|
|
86
|
+
// never copied, so both sessions observe later hand-edits identically.
|
|
94
87
|
const destinationState: PlanModeState = {
|
|
95
88
|
enabled: false,
|
|
96
89
|
awaitingAction: false,
|
|
97
|
-
|
|
90
|
+
planPath: request.planPath,
|
|
98
91
|
};
|
|
99
|
-
const handoff = formatImplementationHandoff(request.
|
|
92
|
+
const handoff = formatImplementationHandoff(request.planPath);
|
|
100
93
|
const parentSession = ctx.sessionManager.getSessionFile();
|
|
101
94
|
let setupError: string | undefined;
|
|
102
95
|
let kickoffError: string | undefined;
|
package/src/interactive-ui.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export { showActiveImplementationMenu } from "./active-implementation-menu.js";
|
|
2
2
|
export { showPlanModeMenu, showReadyPlanMenu } from "./plan-action-menus.js";
|
|
3
3
|
export { showPlanLaunchMenu } from "./plan-launch-menu.js";
|
|
4
|
-
export { showSavedPlanMenu } from "./saved-plan-menu.js";
|
|
5
4
|
export { showPlanModeSettings } from "./settings-menu.js";
|
|
@@ -14,9 +14,9 @@ interface PlanActionControllerOptions {
|
|
|
14
14
|
getState(): PlanModeState;
|
|
15
15
|
captureLifecycle(): MenuLifecycle;
|
|
16
16
|
statusText(): string;
|
|
17
|
-
|
|
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().
|
|
72
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/plan-action-menus.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
? [
|
|
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
|
|
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
|
-
|
|
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" | "
|
|
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: [
|
|
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
|
|
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" };
|
package/src/plan-export.ts
CHANGED
|
@@ -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,
|
|
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(
|
package/src/plan-file.ts
ADDED
|
@@ -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
|
+
}
|
package/src/plan-launch-menu.ts
CHANGED
|
@@ -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
|
|
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" | "
|
|
27
|
-
type Action = "start" | "
|
|
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:
|
|
25
|
+
start: "main",
|
|
32
26
|
screens: {
|
|
33
27
|
main: () => ({
|
|
34
28
|
kind: "actions",
|
|
35
29
|
title: "Plan mode",
|
|
36
|
-
lines: [options.statusText
|
|
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: "
|
|
34
|
+
{ id: "how", label: "How Plan mode works", to: "how" },
|
|
42
35
|
],
|
|
43
36
|
hint: "close",
|
|
44
37
|
}),
|
|
45
|
-
|
|
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
|
-
|
|
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
|
});
|