@janvitos/pi-plan-build 0.1.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/LICENSE +21 -0
- package/README.md +121 -0
- package/index.ts +417 -0
- package/package.json +45 -0
- package/prompts.ts +69 -0
- package/question-ui.ts +111 -0
- package/utils.ts +121 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 janvitos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Pi Plan & Build
|
|
2
|
+
|
|
3
|
+
**Plan safely, approve explicitly, then implement here or in a clean session.**
|
|
4
|
+
|
|
5
|
+
A global [Pi coding agent](https://github.com/badlogic/pi-mono) extension that adds persistent **Plan** and **Build** modes, guarded plan-file editing, interactive planning questions, full-plan review, explicit approval, and clean-session implementation handoffs.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- New sessions start in **Build** mode.
|
|
10
|
+
- Bare `Tab` cycles **Build → Plan → Build** and replaces normal Tab autocomplete.
|
|
11
|
+
- `[Build]` and `[Plan]` status indicators in Pi's footer.
|
|
12
|
+
- `/plan`, `/build`, and the `--plan` startup flag.
|
|
13
|
+
- Per-session plans at `~/.pi/agent/plans/<session-id>.md`.
|
|
14
|
+
- In Plan mode, built-in `edit` and `write` are restricted to the exact plan file.
|
|
15
|
+
- Interactive `question`, `plan_enter`, and `plan_exit` tools.
|
|
16
|
+
- The complete saved plan is rendered in the transcript before approval—without the built-in write preview's truncation.
|
|
17
|
+
- Three approval actions:
|
|
18
|
+
- **Switch to Build and implement here**
|
|
19
|
+
- **Start fresh and implement**
|
|
20
|
+
- **Stay in Plan mode**
|
|
21
|
+
- Staying in Plan mode produces a durable acknowledgement and stops the run until the user responds.
|
|
22
|
+
- Mode state survives reloads, resumes, and forks.
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- Pi `0.84.1` or newer
|
|
27
|
+
- Node.js `22.6` or newer for the test command
|
|
28
|
+
- TUI or RPC UI support for interactive questions and approval dialogs
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
Install the npm package with Pi's package manager:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi install npm:@janvitos/pi-plan-build
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Pi stores npm packages under `~/.pi/agent/npm/`; `~/.pi/agent/extensions/` is reserved for directly auto-discovered extension files and directories. Start a new Pi process after installation, or run `/reload` in an existing session.
|
|
39
|
+
|
|
40
|
+
### Install from GitHub
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pi install git:github.com/janvitos/pi-plan-build
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Local development install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
git clone https://github.com/janvitos/pi-plan-build.git ~/src/pi-plan-build
|
|
50
|
+
ln -s ~/src/pi-plan-build ~/.pi/agent/extensions/pi-plan-build
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Do not install more than one npm, Git, or local copy at the same time; duplicate extension loads cause command and flag conflicts.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
| Action | Result |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `Tab` | Cycle Build and Plan |
|
|
60
|
+
| `/plan` | Select Plan mode |
|
|
61
|
+
| `/build` | Select Build mode |
|
|
62
|
+
| `pi --plan` | Start a new session in Plan mode |
|
|
63
|
+
| `/build-fresh` | Confirm a pending clean-session implementation |
|
|
64
|
+
|
|
65
|
+
The agent may also enter Plan mode with `plan_enter` when planning or investigation is safer than immediate execution.
|
|
66
|
+
|
|
67
|
+
### Plan approval
|
|
68
|
+
|
|
69
|
+
When planning is complete, `plan_exit` displays the entire persisted plan and asks whether to:
|
|
70
|
+
|
|
71
|
+
1. implement in the current session;
|
|
72
|
+
2. prepare a clean linked implementation session; or
|
|
73
|
+
3. stay in Plan mode.
|
|
74
|
+
|
|
75
|
+
Selecting **Start fresh and implement** stops the current run and pre-fills `/build-fresh`. Press Enter to confirm. Pi only exposes session creation to user-invoked command contexts, so this confirmation is required. The command creates a linked child session, copies the approved plan to its canonical plan file, switches it to Build, and starts implementation without transferring the planning conversation.
|
|
76
|
+
|
|
77
|
+
Selecting **Stay in Plan mode** displays:
|
|
78
|
+
|
|
79
|
+
> Staying in Plan mode. Let me know when you’re ready to revise or implement the plan.
|
|
80
|
+
|
|
81
|
+
The agent then stops and waits for the next user message.
|
|
82
|
+
|
|
83
|
+
## Plan-mode permissions
|
|
84
|
+
|
|
85
|
+
Normal tools remain visible so the model can inspect the project. While a Plan run is active:
|
|
86
|
+
|
|
87
|
+
- `edit` and `write` are permitted only for the canonical session plan file;
|
|
88
|
+
- other `edit` and `write` calls are blocked by the extension;
|
|
89
|
+
- bash is not restricted at the permission layer, but the Plan prompt explicitly permits read-only exploration only.
|
|
90
|
+
|
|
91
|
+
This mirrors the intended permission-oriented workflow rather than hiding normal tool schemas.
|
|
92
|
+
|
|
93
|
+
## Design and attribution
|
|
94
|
+
|
|
95
|
+
Pi Plan & Build is an independent extension with its own workflow and UI behavior. Its original mode prompts and transition semantics were informed by OpenCode 1.18.16, while clean-session implementation ideas were informed by the former `pi-plan-mode` extension. Those behaviors have since been adapted and extended for Pi; this project is not affiliated with either project.
|
|
96
|
+
|
|
97
|
+
The Plan workflow uses Pi's native exploration tools directly and does not bundle or require subagents.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npm test
|
|
103
|
+
npm pack --dry-run
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The tests cover state decoding, safe plan paths, mutation restrictions, deferred transitions, complete plan rendering, approval decisions, stop behavior, fresh-session handoff content, and question formatting.
|
|
107
|
+
|
|
108
|
+
### Publishing
|
|
109
|
+
|
|
110
|
+
After authenticating an npm account with access to the `@janvitos` scope, validate and publish the public package:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm publish --dry-run
|
|
114
|
+
npm publish
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The `prepublishOnly` hook runs the test suite before either command publishes a package.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
[MIT](LICENSE)
|
package/index.ts
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CustomEditor, getAgentDir, getMarkdownTheme, type EntryRenderer, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Key, Markdown, matchesKey, Text } from "@earendil-works/pi-tui";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import { registerQuestionTool } from "./question-ui.ts";
|
|
8
|
+
import {
|
|
9
|
+
buildPlanReminder,
|
|
10
|
+
PLAN_ENTER_DESCRIPTION,
|
|
11
|
+
PLAN_EXIT_DESCRIPTION,
|
|
12
|
+
PLAN_TO_BUILD_REMINDER,
|
|
13
|
+
} from "./prompts.ts";
|
|
14
|
+
import {
|
|
15
|
+
applyManualSelection,
|
|
16
|
+
buildFreshImplementationHandoff,
|
|
17
|
+
buildPlanExitFreshResult,
|
|
18
|
+
buildPlanExitStayResult,
|
|
19
|
+
buildPlanReviewMessage,
|
|
20
|
+
classifyPlanExitChoice,
|
|
21
|
+
decodeModeState,
|
|
22
|
+
isAllowedPlanMutation,
|
|
23
|
+
makePlanPath,
|
|
24
|
+
nextMode,
|
|
25
|
+
PLAN_EXIT_APPROVE_CHOICE,
|
|
26
|
+
PLAN_EXIT_FRESH_CHOICE,
|
|
27
|
+
PLAN_EXIT_STAY_ACKNOWLEDGEMENT,
|
|
28
|
+
PLAN_EXIT_STAY_CHOICE,
|
|
29
|
+
type Mode,
|
|
30
|
+
unique,
|
|
31
|
+
} from "./utils.ts";
|
|
32
|
+
|
|
33
|
+
const STATE_TYPE = "pi-plan-build-state";
|
|
34
|
+
const LEGACY_STATE_TYPE = "opencode-modes-state";
|
|
35
|
+
const PLAN_REVIEW_ENTRY_TYPE = "pi-plan-build-review";
|
|
36
|
+
const LEGACY_PLAN_REVIEW_ENTRY_TYPE = "opencode-plan-review";
|
|
37
|
+
const MODE_NOTICE_ENTRY_TYPE = "pi-plan-build-notice";
|
|
38
|
+
const LEGACY_MODE_NOTICE_ENTRY_TYPE = "opencode-mode-notice";
|
|
39
|
+
const STATUS_KEY = "pi-plan-build-mode";
|
|
40
|
+
const MANAGED_TOOLS = new Set(["question", "plan_enter", "plan_exit"]);
|
|
41
|
+
const MODE_ADDED_TOOLS = new Set([...MANAGED_TOOLS, "edit", "write"]);
|
|
42
|
+
const EMPTY_PARAMETERS = Type.Object({});
|
|
43
|
+
|
|
44
|
+
type PendingReminder = "plan" | "build" | undefined;
|
|
45
|
+
interface StoredState {
|
|
46
|
+
version: 1;
|
|
47
|
+
selectedMode: Mode;
|
|
48
|
+
pendingReminder?: "plan" | "build";
|
|
49
|
+
toolsBeforeModes?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function shorten(filePath: string, cwd: string): string {
|
|
53
|
+
const relative = path.relative(cwd, filePath);
|
|
54
|
+
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) return relative;
|
|
55
|
+
const home = os.homedir();
|
|
56
|
+
return filePath.startsWith(`${home}${path.sep}`) ? `~${filePath.slice(home.length)}` : filePath;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default function planBuildModes(pi: ExtensionAPI): void {
|
|
60
|
+
let selectedMode: Mode = "build";
|
|
61
|
+
let runMode: Mode | undefined;
|
|
62
|
+
let pendingReminder: PendingReminder;
|
|
63
|
+
let planPath = "";
|
|
64
|
+
let toolsBeforeModes: string[] = [];
|
|
65
|
+
let currentContext: ExtensionContext | undefined;
|
|
66
|
+
let freshImplementationPlan: string | undefined;
|
|
67
|
+
|
|
68
|
+
pi.registerFlag("plan", {
|
|
69
|
+
description: "Start in Plan mode",
|
|
70
|
+
type: "boolean",
|
|
71
|
+
default: false,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
registerQuestionTool(pi);
|
|
75
|
+
const renderPlanReview: EntryRenderer<{ plan: string }> = (entry) => {
|
|
76
|
+
const plan = typeof entry.data?.plan === "string" ? entry.data.plan : "Plan unavailable";
|
|
77
|
+
return new Markdown(buildPlanReviewMessage(plan), 0, 0, getMarkdownTheme());
|
|
78
|
+
};
|
|
79
|
+
const renderModeNotice: EntryRenderer<{ message: string }> = (entry, _options, theme) => {
|
|
80
|
+
const message = typeof entry.data?.message === "string" ? entry.data.message : "Plan mode unchanged.";
|
|
81
|
+
return new Text(theme.fg("warning", message), 0, 0);
|
|
82
|
+
};
|
|
83
|
+
pi.registerEntryRenderer<{ plan: string }>(PLAN_REVIEW_ENTRY_TYPE, renderPlanReview);
|
|
84
|
+
pi.registerEntryRenderer<{ plan: string }>(LEGACY_PLAN_REVIEW_ENTRY_TYPE, renderPlanReview);
|
|
85
|
+
pi.registerEntryRenderer<{ message: string }>(MODE_NOTICE_ENTRY_TYPE, renderModeNotice);
|
|
86
|
+
pi.registerEntryRenderer<{ message: string }>(LEGACY_MODE_NOTICE_ENTRY_TYPE, renderModeNotice);
|
|
87
|
+
|
|
88
|
+
function stateData(): StoredState {
|
|
89
|
+
return { version: 1, selectedMode, pendingReminder, toolsBeforeModes };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function persist(): void {
|
|
93
|
+
pi.appendEntry(STATE_TYPE, stateData());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
97
|
+
const label = selectedMode === "plan"
|
|
98
|
+
? ctx.ui.theme.fg("warning", "Plan")
|
|
99
|
+
: ctx.ui.theme.fg("border", "Build");
|
|
100
|
+
ctx.ui.setStatus(STATUS_KEY, `[${label}]`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function discoverUnmanagedTools(): void {
|
|
104
|
+
const additions = pi.getActiveTools().filter((name) => !MODE_ADDED_TOOLS.has(name) && !toolsBeforeModes.includes(name));
|
|
105
|
+
toolsBeforeModes = unique([...toolsBeforeModes, ...additions]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function applyTools(mode: Mode): void {
|
|
109
|
+
discoverUnmanagedTools();
|
|
110
|
+
const base = [...toolsBeforeModes];
|
|
111
|
+
if (mode === "plan") {
|
|
112
|
+
pi.setActiveTools(unique([...base, "edit", "write", "question", "plan_exit"]));
|
|
113
|
+
} else {
|
|
114
|
+
pi.setActiveTools(unique([...base, "question", "plan_enter"]));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function ensurePlanDirectory(): Promise<void> {
|
|
119
|
+
await fs.promises.mkdir(path.dirname(planPath), { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function selectMode(mode: Mode, ctx: ExtensionContext, source: "manual" | "tool"): Promise<void> {
|
|
123
|
+
if (mode === selectedMode && (source === "manual" || mode === runMode)) return;
|
|
124
|
+
const previous = selectedMode;
|
|
125
|
+
if (mode === "plan") await ensurePlanDirectory();
|
|
126
|
+
|
|
127
|
+
if (source === "manual") {
|
|
128
|
+
const next = applyManualSelection(mode, runMode, ctx.isIdle());
|
|
129
|
+
selectedMode = next.selectedMode;
|
|
130
|
+
runMode = next.runMode;
|
|
131
|
+
pendingReminder = previous === mode ? pendingReminder : mode;
|
|
132
|
+
if (ctx.isIdle()) applyTools(mode);
|
|
133
|
+
} else {
|
|
134
|
+
selectedMode = mode;
|
|
135
|
+
runMode = mode;
|
|
136
|
+
pendingReminder = undefined;
|
|
137
|
+
applyTools(mode);
|
|
138
|
+
}
|
|
139
|
+
updateStatus(ctx);
|
|
140
|
+
persist();
|
|
141
|
+
ctx.ui.notify(`${mode === "plan" ? "Plan" : "Build"} mode selected`, "info");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
pi.registerCommand("plan", {
|
|
145
|
+
description: "Switch to Plan mode",
|
|
146
|
+
handler: async (_args, ctx) => selectMode("plan", ctx, "manual"),
|
|
147
|
+
});
|
|
148
|
+
pi.registerCommand("build", {
|
|
149
|
+
description: "Switch to Build mode",
|
|
150
|
+
handler: async (_args, ctx) => selectMode("build", ctx, "manual"),
|
|
151
|
+
});
|
|
152
|
+
pi.registerCommand("build-fresh", {
|
|
153
|
+
description: "Start a clean linked session and implement the plan selected in plan_exit",
|
|
154
|
+
handler: async (_args, ctx) => {
|
|
155
|
+
const plan = freshImplementationPlan;
|
|
156
|
+
if (!plan) {
|
|
157
|
+
ctx.ui.notify("No fresh implementation is pending. Choose ‘Start fresh and implement’ from plan_exit first.", "warning");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (selectedMode !== "plan") {
|
|
161
|
+
freshImplementationPlan = undefined;
|
|
162
|
+
ctx.ui.notify("Fresh implementation is no longer available because Plan mode is not active.", "warning");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (ctx.mode === "print" || ctx.mode === "json") {
|
|
166
|
+
throw new Error("Fresh implementation requires TUI or RPC mode");
|
|
167
|
+
}
|
|
168
|
+
if (!ctx.model) {
|
|
169
|
+
ctx.ui.notify("Cannot start implementation because no model is selected.", "warning");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
freshImplementationPlan = undefined;
|
|
174
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
175
|
+
const sourceTools = [...toolsBeforeModes];
|
|
176
|
+
const handoff = buildFreshImplementationHandoff(plan);
|
|
177
|
+
let destinationPlanPath = "";
|
|
178
|
+
let setupError: string | undefined;
|
|
179
|
+
let kickoffError: string | undefined;
|
|
180
|
+
try {
|
|
181
|
+
const result = await ctx.newSession({
|
|
182
|
+
...(parentSession ? { parentSession } : {}),
|
|
183
|
+
setup: async (sessionManager) => {
|
|
184
|
+
try {
|
|
185
|
+
destinationPlanPath = makePlanPath(
|
|
186
|
+
path.join(getAgentDir(), "plans"),
|
|
187
|
+
sessionManager.getSessionId(),
|
|
188
|
+
);
|
|
189
|
+
await fs.promises.mkdir(path.dirname(destinationPlanPath), { recursive: true });
|
|
190
|
+
await fs.promises.writeFile(destinationPlanPath, plan, "utf8");
|
|
191
|
+
sessionManager.appendCustomEntry(STATE_TYPE, {
|
|
192
|
+
version: 1,
|
|
193
|
+
selectedMode: "build",
|
|
194
|
+
pendingReminder: "build",
|
|
195
|
+
toolsBeforeModes: sourceTools,
|
|
196
|
+
} satisfies StoredState);
|
|
197
|
+
} catch (error: unknown) {
|
|
198
|
+
setupError = error instanceof Error ? error.message : String(error);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
withSession: async (replacementCtx) => {
|
|
202
|
+
if (setupError) {
|
|
203
|
+
replacementCtx.ui.setEditorText(handoff);
|
|
204
|
+
replacementCtx.ui.notify(
|
|
205
|
+
`Fresh session opened, but setup failed: ${setupError}. The implementation request is in the editor.`,
|
|
206
|
+
"error",
|
|
207
|
+
);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
await replacementCtx.sendUserMessage(handoff);
|
|
212
|
+
replacementCtx.ui.notify(
|
|
213
|
+
`Fresh implementation session started with plan ${shorten(destinationPlanPath, replacementCtx.cwd)}.`,
|
|
214
|
+
"info",
|
|
215
|
+
);
|
|
216
|
+
} catch (error: unknown) {
|
|
217
|
+
kickoffError = error instanceof Error ? error.message : String(error);
|
|
218
|
+
replacementCtx.ui.setEditorText(handoff);
|
|
219
|
+
replacementCtx.ui.notify(
|
|
220
|
+
`Fresh session opened, but implementation did not start: ${kickoffError}. The request is in the editor.`,
|
|
221
|
+
"error",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
if (result.cancelled) {
|
|
227
|
+
freshImplementationPlan = plan;
|
|
228
|
+
ctx.ui.notify("Fresh implementation cancelled; the source plan remains available.", "info");
|
|
229
|
+
}
|
|
230
|
+
} catch (error: unknown) {
|
|
231
|
+
freshImplementationPlan = plan;
|
|
232
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
233
|
+
try {
|
|
234
|
+
ctx.ui.notify(`Unable to start a fresh implementation session: ${detail}`, "error");
|
|
235
|
+
} catch {
|
|
236
|
+
// The source command context may be stale after partial session replacement.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
pi.registerTool({
|
|
243
|
+
name: "plan_enter",
|
|
244
|
+
label: "Enter Plan Mode",
|
|
245
|
+
description: PLAN_ENTER_DESCRIPTION,
|
|
246
|
+
parameters: EMPTY_PARAMETERS,
|
|
247
|
+
executionMode: "sequential",
|
|
248
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
249
|
+
await selectMode("plan", ctx, "tool");
|
|
250
|
+
const exists = fs.existsSync(planPath);
|
|
251
|
+
const info = exists
|
|
252
|
+
? `A plan file already exists at ${planPath}. You can read it and make incremental edits using the edit tool.`
|
|
253
|
+
: `No plan file exists yet. You should create your plan at ${planPath} using the write tool.`;
|
|
254
|
+
return {
|
|
255
|
+
content: [{ type: "text", text: buildPlanReminder(info) }],
|
|
256
|
+
details: { mode: "plan", planPath },
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
renderCall(_args, theme) {
|
|
260
|
+
return new Text(theme.fg("toolTitle", theme.bold("Enter Plan mode")), 0, 0);
|
|
261
|
+
},
|
|
262
|
+
renderResult(_result, _options, theme) {
|
|
263
|
+
return new Text(theme.fg("success", "Switched to Plan mode"), 0, 0);
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
pi.registerTool({
|
|
268
|
+
name: "plan_exit",
|
|
269
|
+
label: "Exit Plan Mode",
|
|
270
|
+
description: PLAN_EXIT_DESCRIPTION,
|
|
271
|
+
parameters: EMPTY_PARAMETERS,
|
|
272
|
+
executionMode: "sequential",
|
|
273
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
274
|
+
if (!ctx.hasUI) throw new Error("plan_exit requires an interactive TUI or RPC client");
|
|
275
|
+
let plan: string;
|
|
276
|
+
try {
|
|
277
|
+
plan = await fs.promises.readFile(planPath, "utf8");
|
|
278
|
+
} catch (error: unknown) {
|
|
279
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
280
|
+
throw new Error(`Cannot request plan approval because the plan file could not be read: ${detail}`);
|
|
281
|
+
}
|
|
282
|
+
if (!plan.trim()) throw new Error("Cannot request plan approval because the plan file is empty");
|
|
283
|
+
pi.appendEntry(PLAN_REVIEW_ENTRY_TYPE, { plan, planPath });
|
|
284
|
+
const displayPath = shorten(planPath, ctx.cwd);
|
|
285
|
+
const choice = await ctx.ui.select(
|
|
286
|
+
`Build Agent: Plan at ${displayPath} is complete. What would you like to do?`,
|
|
287
|
+
[PLAN_EXIT_APPROVE_CHOICE, PLAN_EXIT_FRESH_CHOICE, PLAN_EXIT_STAY_CHOICE],
|
|
288
|
+
);
|
|
289
|
+
const decision = classifyPlanExitChoice(choice);
|
|
290
|
+
if (decision === "stay") {
|
|
291
|
+
freshImplementationPlan = undefined;
|
|
292
|
+
pi.appendEntry(MODE_NOTICE_ENTRY_TYPE, { message: PLAN_EXIT_STAY_ACKNOWLEDGEMENT });
|
|
293
|
+
return buildPlanExitStayResult(planPath, choice === undefined);
|
|
294
|
+
}
|
|
295
|
+
if (decision === "implement-fresh") {
|
|
296
|
+
freshImplementationPlan = plan;
|
|
297
|
+
ctx.ui.setEditorText("/build-fresh");
|
|
298
|
+
return buildPlanExitFreshResult(planPath);
|
|
299
|
+
}
|
|
300
|
+
freshImplementationPlan = undefined;
|
|
301
|
+
await selectMode("build", ctx, "tool");
|
|
302
|
+
return {
|
|
303
|
+
content: [
|
|
304
|
+
{
|
|
305
|
+
type: "text",
|
|
306
|
+
text: `${PLAN_TO_BUILD_REMINDER}\n\nA plan file exists at ${planPath}. The plan has been approved; execute the plan now.`,
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
details: { approved: true, mode: "build", planPath },
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
renderCall(_args, theme) {
|
|
313
|
+
return new Text(theme.fg("toolTitle", theme.bold("Request plan approval")), 0, 0);
|
|
314
|
+
},
|
|
315
|
+
renderResult(result, _options, theme, context) {
|
|
316
|
+
const details = result.details as { approved?: boolean; action?: string } | undefined;
|
|
317
|
+
if (details?.action === "implement-fresh" && !context.isError) {
|
|
318
|
+
return new Text(
|
|
319
|
+
theme.fg("success", "Clean-session implementation selected — press Enter to run /build-fresh."),
|
|
320
|
+
0,
|
|
321
|
+
0,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (details?.approved === true && !context.isError) {
|
|
325
|
+
return new Text(theme.fg("success", "Plan approved; switched to Build mode"), 0, 0);
|
|
326
|
+
}
|
|
327
|
+
return new Text(theme.fg("warning", PLAN_EXIT_STAY_ACKNOWLEDGEMENT), 0, 0);
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
332
|
+
if (runMode !== "plan" || (event.toolName !== "edit" && event.toolName !== "write")) return;
|
|
333
|
+
const inputPath = (event.input as { path?: unknown }).path;
|
|
334
|
+
if (isAllowedPlanMutation(ctx.cwd, inputPath, planPath)) return;
|
|
335
|
+
return {
|
|
336
|
+
block: true,
|
|
337
|
+
reason: `Plan mode only permits edit/write access to the plan file: ${planPath}`,
|
|
338
|
+
};
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
342
|
+
runMode = selectedMode;
|
|
343
|
+
applyTools(runMode);
|
|
344
|
+
let content: string | undefined;
|
|
345
|
+
if (runMode === "plan") {
|
|
346
|
+
await ensurePlanDirectory();
|
|
347
|
+
const exists = fs.existsSync(planPath);
|
|
348
|
+
content = buildPlanReminder(
|
|
349
|
+
exists
|
|
350
|
+
? `A plan file already exists at ${planPath}. You can read it and make incremental edits using the edit tool.`
|
|
351
|
+
: `No plan file exists yet. You should create your plan at ${planPath} using the write tool.`,
|
|
352
|
+
);
|
|
353
|
+
} else if (pendingReminder === "build") {
|
|
354
|
+
content = PLAN_TO_BUILD_REMINDER;
|
|
355
|
+
if (fs.existsSync(planPath)) content += `\n\nA plan file exists at ${planPath}. You should execute the plan defined within it.`;
|
|
356
|
+
}
|
|
357
|
+
pendingReminder = undefined;
|
|
358
|
+
persist();
|
|
359
|
+
if (!content) return;
|
|
360
|
+
return { message: { customType: "pi-plan-build-reminder", content, display: false } };
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
364
|
+
runMode = undefined;
|
|
365
|
+
applyTools(selectedMode);
|
|
366
|
+
updateStatus(ctx);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
370
|
+
currentContext = ctx;
|
|
371
|
+
const entries = ctx.sessionManager.getEntries();
|
|
372
|
+
const latest = entries
|
|
373
|
+
.filter(
|
|
374
|
+
(entry: any) =>
|
|
375
|
+
entry.type === "custom" &&
|
|
376
|
+
(entry.customType === STATE_TYPE || entry.customType === LEGACY_STATE_TYPE),
|
|
377
|
+
)
|
|
378
|
+
.pop() as { data?: unknown } | undefined;
|
|
379
|
+
const decoded = decodeModeState(latest?.data);
|
|
380
|
+
const raw = latest?.data as StoredState | undefined;
|
|
381
|
+
selectedMode = decoded?.selectedMode ?? (pi.getFlag("plan") === true ? "plan" : "build");
|
|
382
|
+
pendingReminder = raw?.pendingReminder ?? (decoded ? undefined : pi.getFlag("plan") === true ? "plan" : undefined);
|
|
383
|
+
toolsBeforeModes = Array.isArray(raw?.toolsBeforeModes)
|
|
384
|
+
? raw.toolsBeforeModes.filter((name): name is string => typeof name === "string" && !MANAGED_TOOLS.has(name))
|
|
385
|
+
: pi.getActiveTools().filter((name) => !MANAGED_TOOLS.has(name));
|
|
386
|
+
planPath = makePlanPath(path.join(getAgentDir(), "plans"), ctx.sessionManager.getSessionId());
|
|
387
|
+
if (selectedMode === "plan") await ensurePlanDirectory();
|
|
388
|
+
applyTools(selectedMode);
|
|
389
|
+
updateStatus(ctx);
|
|
390
|
+
|
|
391
|
+
if (ctx.mode === "tui") {
|
|
392
|
+
class ModeEditor extends CustomEditor {
|
|
393
|
+
onCycle?: () => void;
|
|
394
|
+
override handleInput(data: string): void {
|
|
395
|
+
if (matchesKey(data, Key.tab)) {
|
|
396
|
+
this.onCycle?.();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
super.handleInput(data);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
403
|
+
const editor = new ModeEditor(tui, theme, keybindings);
|
|
404
|
+
editor.onCycle = () => {
|
|
405
|
+
if (currentContext) void selectMode(nextMode(selectedMode), currentContext, "manual");
|
|
406
|
+
};
|
|
407
|
+
return editor;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
413
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
414
|
+
ctx.ui.setEditorComponent(undefined);
|
|
415
|
+
currentContext = undefined;
|
|
416
|
+
});
|
|
417
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@janvitos/pi-plan-build",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Plan safely, approve explicitly, then implement here or in a clean session.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "janvitos",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/janvitos/pi-plan-build.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi-package",
|
|
14
|
+
"pi-coding-agent",
|
|
15
|
+
"plan-mode",
|
|
16
|
+
"build-mode",
|
|
17
|
+
"developer-tools"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.6"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"index.ts",
|
|
24
|
+
"prompts.ts",
|
|
25
|
+
"question-ui.ts",
|
|
26
|
+
"utils.ts"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --experimental-strip-types --test utils.test.ts",
|
|
33
|
+
"prepublishOnly": "npm test"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
37
|
+
"@earendil-works/pi-tui": "*",
|
|
38
|
+
"typebox": "*"
|
|
39
|
+
},
|
|
40
|
+
"pi": {
|
|
41
|
+
"extensions": [
|
|
42
|
+
"./index.ts"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|
package/prompts.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Prompt text pinned to OpenCode 1.18.16. The subagent phases are intentionally
|
|
2
|
+
// adapted to direct Pi exploration/design, as documented in README.md.
|
|
3
|
+
|
|
4
|
+
export const PLAN_TO_BUILD_REMINDER = `<system-reminder>
|
|
5
|
+
Your operational mode has changed from plan to build.
|
|
6
|
+
You are no longer in read-only mode.
|
|
7
|
+
You are permitted to make file changes, run shell commands, and utilize your arsenal of tools as needed.
|
|
8
|
+
</system-reminder>`;
|
|
9
|
+
|
|
10
|
+
export function buildPlanReminder(planInfo: string): string {
|
|
11
|
+
return `<system-reminder>
|
|
12
|
+
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
|
|
13
|
+
|
|
14
|
+
## Plan File Info:
|
|
15
|
+
${planInfo}
|
|
16
|
+
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
|
|
17
|
+
|
|
18
|
+
## Plan Workflow
|
|
19
|
+
|
|
20
|
+
### Phase 1: Initial Understanding
|
|
21
|
+
Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions.
|
|
22
|
+
|
|
23
|
+
1. Focus on understanding the user's request and the code associated with their request.
|
|
24
|
+
2. Explore the codebase directly with Pi's read, grep, find, ls, and read-only shell operations. Read the minimum set of high-value files needed to understand existing patterns and testing.
|
|
25
|
+
3. After exploring the code, use the question tool to clarify ambiguities in the user request up front.
|
|
26
|
+
|
|
27
|
+
### Phase 2: Design
|
|
28
|
+
Goal: Design an implementation approach.
|
|
29
|
+
|
|
30
|
+
Design the implementation directly based on the user's intent and your exploration results. Consider simplicity, correctness, maintainability, existing patterns, edge cases, and verification. Skip extended design only for truly trivial tasks such as typo fixes, single-line changes, or simple renames.
|
|
31
|
+
|
|
32
|
+
### Phase 3: Review
|
|
33
|
+
Goal: Review the design and ensure alignment with the user's intentions.
|
|
34
|
+
1. Read the critical files identified during exploration to deepen your understanding.
|
|
35
|
+
2. Ensure that the design aligns with the user's original request.
|
|
36
|
+
3. Use question tool to clarify any remaining questions with the user.
|
|
37
|
+
|
|
38
|
+
### Phase 4: Final Plan
|
|
39
|
+
Goal: Write your final plan to the plan file (the only file you can edit).
|
|
40
|
+
- Include only your recommended approach, not all alternatives.
|
|
41
|
+
- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively.
|
|
42
|
+
- Include the paths of critical files to be modified.
|
|
43
|
+
- Include a verification section describing how to test the changes end-to-end (run the code, use available tools, run tests).
|
|
44
|
+
|
|
45
|
+
### Phase 5: Call plan_exit tool
|
|
46
|
+
At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call plan_exit to indicate to the user that you are done planning.
|
|
47
|
+
This is critical - your turn should only end with either asking the user a question or calling plan_exit. Do not stop unless it's for these 2 reasons.
|
|
48
|
+
|
|
49
|
+
**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does.
|
|
50
|
+
|
|
51
|
+
NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
|
|
52
|
+
</system-reminder>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const PLAN_ENTER_DESCRIPTION = `Use this tool when the user asks you to plan, when a request needs investigation before implementation, or when switching to the plan agent is the safest next step. The tool changes the current continuation to Plan mode.`;
|
|
56
|
+
|
|
57
|
+
export const PLAN_EXIT_DESCRIPTION = `Use this tool when you have completed the planning phase and are ready to exit plan agent.
|
|
58
|
+
|
|
59
|
+
This tool displays the complete plan and asks the user whether to implement it in this session, prepare a clean-session implementation, or stay in Plan mode.
|
|
60
|
+
|
|
61
|
+
Call this tool:
|
|
62
|
+
- After you have written a complete plan to the plan file
|
|
63
|
+
- After you have clarified any questions with the user
|
|
64
|
+
- When you are confident the plan is ready for implementation
|
|
65
|
+
|
|
66
|
+
Do NOT call this tool:
|
|
67
|
+
- Before you have created or finalized the plan
|
|
68
|
+
- If you still have unanswered questions about the implementation
|
|
69
|
+
- If the user has indicated they want to continue planning`;
|
package/question-ui.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { formatQuestionAnswers, type QuestionAnswerData } from "./utils.ts";
|
|
5
|
+
|
|
6
|
+
const OptionSchema = Type.Object({
|
|
7
|
+
label: Type.String({ description: "Display label for the option" }),
|
|
8
|
+
description: Type.Optional(Type.String({ description: "Explanation shown with the option" })),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const PromptSchema = Type.Object({
|
|
12
|
+
question: Type.String({ description: "The question to ask" }),
|
|
13
|
+
header: Type.String({ description: "Short header (12 characters or fewer)", maxLength: 12 }),
|
|
14
|
+
options: Type.Array(OptionSchema, { minItems: 2, maxItems: 4 }),
|
|
15
|
+
multiple: Type.Optional(Type.Boolean({ description: "Allow more than one selection" })),
|
|
16
|
+
custom: Type.Optional(Type.Boolean({ description: "Allow a custom answer; defaults to true" })),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const QuestionParameters = Type.Object({
|
|
20
|
+
questions: Type.Array(PromptSchema, { minItems: 1, description: "Questions to ask the user" }),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type QuestionAnswer = QuestionAnswerData;
|
|
24
|
+
|
|
25
|
+
async function askOne(
|
|
26
|
+
ctx: any,
|
|
27
|
+
prompt: {
|
|
28
|
+
question: string;
|
|
29
|
+
header: string;
|
|
30
|
+
options: Array<{ label: string; description?: string }>;
|
|
31
|
+
multiple?: boolean;
|
|
32
|
+
custom?: boolean;
|
|
33
|
+
},
|
|
34
|
+
): Promise<QuestionAnswer> {
|
|
35
|
+
const allowCustom = prompt.custom !== false;
|
|
36
|
+
const labels = prompt.options.map((option) =>
|
|
37
|
+
option.description ? `${option.label} — ${option.description}` : option.label,
|
|
38
|
+
);
|
|
39
|
+
const selected: string[] = [];
|
|
40
|
+
let usedCustom = false;
|
|
41
|
+
|
|
42
|
+
if (!prompt.multiple) {
|
|
43
|
+
const choices = [...labels, ...(allowCustom ? ["Type your own answer"] : [])];
|
|
44
|
+
const choice = await ctx.ui.select(`${prompt.header}: ${prompt.question}`, choices);
|
|
45
|
+
if (!choice) throw new Error("User cancelled the question");
|
|
46
|
+
if (allowCustom && choice === "Type your own answer") {
|
|
47
|
+
const custom = await ctx.ui.input(prompt.header, prompt.question);
|
|
48
|
+
if (!custom?.trim()) throw new Error("User cancelled the question");
|
|
49
|
+
selected.push(custom.trim());
|
|
50
|
+
usedCustom = true;
|
|
51
|
+
} else {
|
|
52
|
+
selected.push(prompt.options[labels.indexOf(choice)]?.label ?? choice);
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
const remaining = prompt.options.map((option) => option.label);
|
|
56
|
+
while (true) {
|
|
57
|
+
const choices = [
|
|
58
|
+
...remaining.map((label) => `Add: ${label}`),
|
|
59
|
+
...(allowCustom ? ["Add a custom answer"] : []),
|
|
60
|
+
...(selected.length ? [`Done (${selected.join(", ")})`] : []),
|
|
61
|
+
];
|
|
62
|
+
const choice = await ctx.ui.select(`${prompt.header}: ${prompt.question}`, choices);
|
|
63
|
+
if (!choice) throw new Error("User cancelled the question");
|
|
64
|
+
if (choice.startsWith("Done (")) break;
|
|
65
|
+
if (choice === "Add a custom answer") {
|
|
66
|
+
const custom = await ctx.ui.input(prompt.header, prompt.question);
|
|
67
|
+
if (custom?.trim()) {
|
|
68
|
+
selected.push(custom.trim());
|
|
69
|
+
usedCustom = true;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const label = choice.slice("Add: ".length);
|
|
74
|
+
selected.push(label);
|
|
75
|
+
remaining.splice(remaining.indexOf(label), 1);
|
|
76
|
+
if (remaining.length === 0 && !allowCustom) break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { question: prompt.question, header: prompt.header, answers: selected, custom: usedCustom };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
84
|
+
pi.registerTool({
|
|
85
|
+
name: "question",
|
|
86
|
+
label: "Question",
|
|
87
|
+
description: `Use this tool when you need to ask the user questions during execution. This allows you to gather preferences, clarify ambiguous instructions, get implementation decisions, or offer choices. When custom is enabled (default), do not add an Other option yourself. Put the recommended option first and suffix its label with "(Recommended)".`,
|
|
88
|
+
parameters: QuestionParameters,
|
|
89
|
+
executionMode: "sequential",
|
|
90
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
91
|
+
if (!ctx.hasUI) throw new Error("The question tool requires an interactive TUI or RPC client");
|
|
92
|
+
if (params.questions.length === 0) throw new Error("At least one question is required");
|
|
93
|
+
const answers: QuestionAnswer[] = [];
|
|
94
|
+
for (const prompt of params.questions) answers.push(await askOne(ctx, prompt));
|
|
95
|
+
const formatted = formatQuestionAnswers(answers);
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: "text", text: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.` }],
|
|
98
|
+
details: { answers },
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
renderCall(args, theme) {
|
|
102
|
+
const count = Array.isArray(args.questions) ? args.questions.length : 0;
|
|
103
|
+
return new Text(theme.fg("toolTitle", theme.bold(`question (${count})`)), 0, 0);
|
|
104
|
+
},
|
|
105
|
+
renderResult(result, _options, theme) {
|
|
106
|
+
const details = result.details as { answers?: QuestionAnswer[] } | undefined;
|
|
107
|
+
if (!details?.answers) return new Text(theme.fg("warning", "Question cancelled"), 0, 0);
|
|
108
|
+
return new Text(details.answers.map((a) => `${theme.fg("success", "✓")} ${a.header}: ${a.answers.join(", ")}`).join("\n"), 0, 0);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
package/utils.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export type Mode = "build" | "plan";
|
|
4
|
+
|
|
5
|
+
export const PLAN_EXIT_APPROVE_CHOICE = "Switch to Build and implement here";
|
|
6
|
+
export const PLAN_EXIT_FRESH_CHOICE = "Start fresh and implement";
|
|
7
|
+
export const PLAN_EXIT_STAY_CHOICE = "Stay in Plan mode";
|
|
8
|
+
export const PLAN_EXIT_STAY_ACKNOWLEDGEMENT =
|
|
9
|
+
"Staying in Plan mode. Let me know when you’re ready to revise or implement the plan.";
|
|
10
|
+
|
|
11
|
+
export type PlanExitDecision = "implement-here" | "implement-fresh" | "stay";
|
|
12
|
+
|
|
13
|
+
export function classifyPlanExitChoice(choice: string | undefined): PlanExitDecision {
|
|
14
|
+
if (choice === PLAN_EXIT_APPROVE_CHOICE) return "implement-here";
|
|
15
|
+
if (choice === PLAN_EXIT_FRESH_CHOICE) return "implement-fresh";
|
|
16
|
+
return "stay";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildPlanReviewMessage(plan: string): string {
|
|
20
|
+
return `# Plan for Review\n\n${plan}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildPlanExitFreshResult(planPath: string) {
|
|
24
|
+
return {
|
|
25
|
+
content: [
|
|
26
|
+
{
|
|
27
|
+
type: "text" as const,
|
|
28
|
+
text: "The user selected clean-session implementation. Stop now; the /build-fresh command has been prepared for the user to submit.",
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
details: { approved: true, action: "implement-fresh" as const, mode: "plan" as const, planPath },
|
|
32
|
+
terminate: true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildFreshImplementationHandoff(plan: string): string {
|
|
37
|
+
return `Plan mode is now disabled. Full tool access is restored. Implement this approved plan now:\n\n${plan}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildPlanExitStayResult(planPath: string, cancelled: boolean) {
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: "text" as const,
|
|
45
|
+
text: "The user chose to stay in Plan mode. Stop now and wait for their next message before doing any further planning or taking any other action.",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
details: { approved: false, mode: "plan" as const, planPath, cancelled },
|
|
49
|
+
terminate: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PersistedModeState {
|
|
54
|
+
version: 1;
|
|
55
|
+
selectedMode: Mode;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isMode(value: unknown): value is Mode {
|
|
59
|
+
return value === "build" || value === "plan";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function decodeModeState(value: unknown): PersistedModeState | undefined {
|
|
63
|
+
if (!value || typeof value !== "object") return undefined;
|
|
64
|
+
const candidate = value as { version?: unknown; selectedMode?: unknown; mode?: unknown };
|
|
65
|
+
const mode = isMode(candidate.selectedMode) ? candidate.selectedMode : isMode(candidate.mode) ? candidate.mode : undefined;
|
|
66
|
+
if (!mode) return undefined;
|
|
67
|
+
return { version: 1, selectedMode: mode };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function sanitizeSessionId(value: string | undefined): string {
|
|
71
|
+
const cleaned = (value ?? "ephemeral")
|
|
72
|
+
.normalize("NFKC")
|
|
73
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
74
|
+
.replace(/^[-.]+|[-.]+$/g, "")
|
|
75
|
+
.slice(0, 120);
|
|
76
|
+
return cleaned || "ephemeral";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function makePlanPath(plansDir: string, sessionId: string | undefined): string {
|
|
80
|
+
const root = path.resolve(plansDir);
|
|
81
|
+
const candidate = path.resolve(root, `${sanitizeSessionId(sessionId)}.md`);
|
|
82
|
+
if (path.dirname(candidate) !== root) throw new Error("Generated plan path escaped the plans directory");
|
|
83
|
+
return candidate;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveToolPath(cwd: string, inputPath: unknown): string | undefined {
|
|
87
|
+
if (typeof inputPath !== "string" || inputPath.trim() === "") return undefined;
|
|
88
|
+
const withoutAt = inputPath.startsWith("@") ? inputPath.slice(1) : inputPath;
|
|
89
|
+
return path.resolve(cwd, withoutAt);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isAllowedPlanMutation(cwd: string, inputPath: unknown, planPath: string): boolean {
|
|
93
|
+
const resolved = resolveToolPath(cwd, inputPath);
|
|
94
|
+
return resolved !== undefined && resolved === path.resolve(planPath);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface QuestionAnswerData {
|
|
98
|
+
question: string;
|
|
99
|
+
header: string;
|
|
100
|
+
answers: string[];
|
|
101
|
+
custom: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function formatQuestionAnswers(answers: QuestionAnswerData[]): string {
|
|
105
|
+
return answers.map((answer) => `"${answer.question}"="${answer.answers.length ? answer.answers.join(", ") : "Unanswered"}"`).join(", ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function nextMode(mode: Mode): Mode {
|
|
109
|
+
return mode === "build" ? "plan" : "build";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function applyManualSelection(selectedMode: Mode, runMode: Mode | undefined, idle: boolean): {
|
|
113
|
+
selectedMode: Mode;
|
|
114
|
+
runMode: Mode | undefined;
|
|
115
|
+
} {
|
|
116
|
+
return { selectedMode, runMode: idle ? selectedMode : runMode };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function unique(values: string[]): string[] {
|
|
120
|
+
return [...new Set(values)];
|
|
121
|
+
}
|