@hank-warren/pi-plan-mode 1.2.1 → 1.4.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 +14 -0
- package/README.md +19 -11
- package/package.json +5 -1
- package/skills/pi-plan-mode/SKILL.md +81 -0
- package/src/extension-runtime.ts +9 -3
- package/src/plan-mode.ts +20 -97
- package/src/presentation.ts +105 -23
- package/src/prompt.ts +1 -0
- package/src/settings-menu.ts +32 -29
- package/src/settings.ts +4 -33
- package/src/state.ts +53 -25
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @hank-warren/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 1.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 449f989: Ships a companion `pi-plan-mode` skill, making this a hybrid package: the system prompt stays the short enforcement surface and points at the skill for the plan-crafting craft — what decision-complete means, exploring before asking, what separates a question worth asking from one the repository already answers, and what a finished plan contains.
|
|
8
|
+
|
|
9
|
+
The footer status and the editor widget now render from one formatter, so they cannot drift, in a glyph vocabulary shared with `pi-loop`: `◆ plan · drafting`, `◆ plan · revising`, `◆ plan · ready → /plan`, `▶ plan · implementing`, each with a dim hint line in the widget. Statuslines that render extension statuses will show the new strings in place of `plan active` / `plan ready` / `plan implementing`.
|
|
10
|
+
|
|
11
|
+
## 1.3.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- 9dd9833: Remove the plan-mode thinking level. Plan mode no longer mutates session-global state: `pi.setThinkingLevel` writes through to the user's real settings, so the "temporary" level was a durable change that needed three shadow state fields, a `thinking_level_select` listener and restore logic on every exit path to undo. Thinking level and model are session settings now, and whatever you choose while planning carries into implementation. The `thinkingLevel` setting is gone and `/plan settings` drops its row; an existing key in `pi-plan-mode.json` is ignored rather than rejected — preserved verbatim on save, and no longer validated, so even a garbage value keeps the file loading. A one-shot migration restores the level an interrupted pre-1.3.0 session left raised, but only while the live level still matches what plan mode applied.
|
|
16
|
+
|
|
3
17
|
## 1.2.1
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ The plan is written to a **durable file** that survives compaction, survives res
|
|
|
17
17
|
- Two ways to implement: continue in this conversation, or open a fresh session that reads the same file.
|
|
18
18
|
- `/plan export [path]` copies the plan anywhere, never overwriting an existing target.
|
|
19
19
|
- Hand-edit the plan file at any time; every command and both implementation paths read from disk.
|
|
20
|
+
- A companion `pi-plan-mode` **skill**, loaded on demand, carrying the plan-crafting craft the prompt only points at.
|
|
20
21
|
|
|
21
22
|
## 📦 Install
|
|
22
23
|
|
|
@@ -78,20 +79,17 @@ The file is read at session start and **re-read whenever it changes**, so a hand
|
|
|
78
79
|
|
|
79
80
|
```json
|
|
80
81
|
{
|
|
81
|
-
"thinkingLevel": "inherit",
|
|
82
82
|
"defaultPlanExportPath": "PLAN.md"
|
|
83
83
|
}
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
### Plan thinking
|
|
87
|
-
|
|
88
|
-
`thinkingLevel` requests a fixed thinking level while Plan mode is active. Supported values are `inherit`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. The extension restores your previous level on exit unless you changed it manually during Plan mode. Saving applies to the next Plan workflow, never one already running.
|
|
89
|
-
|
|
90
86
|
### Export destination
|
|
91
87
|
|
|
92
88
|
`defaultPlanExportPath` controls only exports that omit a path, and defaults to `PLAN.md`. Relative values resolve against the current working directory at export time. An explicit `/plan export <path>` always wins. Export never overwrites an existing file, directory, or symbolic link.
|
|
93
89
|
|
|
94
|
-
Unknown keys are preserved. Settings removed in 1.0 (`defaultPlanTools`, `bashPolicy`, `safeSubcommands`, `implementationPlanRetention`) are ignored rather than treated as errors, so an existing settings file keeps working.
|
|
90
|
+
Unknown keys are preserved. Settings removed in 1.0 (`defaultPlanTools`, `bashPolicy`, `safeSubcommands`, `implementationPlanRetention`) and in 1.3 (`thinkingLevel`) are ignored rather than treated as errors, so an existing settings file keeps working.
|
|
91
|
+
|
|
92
|
+
Thinking level and model are **session** settings, and Plan mode never changes either one. Set them with Pi's own controls; whatever you choose while planning carries into implementation, because that is what session state does.
|
|
95
93
|
|
|
96
94
|
A settings file that does not parse is reported at session start and the defaults are used. Mid-session it is ignored instead, leaving the last good settings in place: an edit is seen the moment your editor touches the file, so an unreadable one is usually a half-finished save rather than what you meant.
|
|
97
95
|
|
|
@@ -120,13 +118,22 @@ Detection is by tool name at runtime, re-evaluated every turn — there is no de
|
|
|
120
118
|
|
|
121
119
|
A standalone `pi-plan-mode` install loses nothing: `plan_mode_question` stays fully functional and the prompt reads exactly as it always has. It is a **legacy fallback** and is slated for removal in a future major.
|
|
122
120
|
|
|
123
|
-
##
|
|
121
|
+
## 📚 The companion skill
|
|
122
|
+
|
|
123
|
+
The package ships a `pi-plan-mode` skill (`skills/pi-plan-mode/SKILL.md`) alongside the extension. The system prompt is the enforcement surface and stays deliberately short; the skill is the depth layer it points at — what decision-complete actually means, why exploration comes before questions, what separates a question worth asking from one the repository already answered, and what belongs in a finished plan. One line in the planning prompt names it, and the model loads the body when it judges it needs it.
|
|
124
|
+
|
|
125
|
+
Skill and extension version as one artifact: a skill describing a mode the installed extension does not have is a coupling failure waiting to happen.
|
|
126
|
+
|
|
127
|
+
## 📊 Statusline and widget
|
|
128
|
+
|
|
129
|
+
The footer status and the widget above the editor render from **one formatter**, so they cannot drift, and they share a glyph vocabulary with the sibling [`pi-loop`](../pi-loop): `◆` for a state wanting a decision, `▶` for work under way.
|
|
124
130
|
|
|
125
|
-
|
|
131
|
+
- `◆ plan · drafting` — planning is under way.
|
|
132
|
+
- `◆ plan · revising` — feedback superseded a completed plan; the stored one is not current.
|
|
133
|
+
- `◆ plan · ready → /plan` — a completed plan is waiting for your choice.
|
|
134
|
+
- `▶ plan · implementing` — a plan file is active and guiding implementation.
|
|
126
135
|
|
|
127
|
-
|
|
128
|
-
- `plan ready` — a completed plan is waiting for your choice.
|
|
129
|
-
- `plan implementing` — a plan file is active and guiding implementation.
|
|
136
|
+
The widget adds a dim second line naming what to do next.
|
|
130
137
|
|
|
131
138
|
## 🗂️ Package layout
|
|
132
139
|
|
|
@@ -138,6 +145,7 @@ packages/pi-plan-mode/
|
|
|
138
145
|
│ ├── plan-file.ts # Durable plan file read/write/delete
|
|
139
146
|
│ ├── interactive-ui.ts # Lazily loaded interactive menus
|
|
140
147
|
│ └── *.ts # Prompt, question, export, settings modules
|
|
148
|
+
├── skills/pi-plan-mode/ # Companion plan-crafting skill
|
|
141
149
|
├── test/
|
|
142
150
|
├── README.md
|
|
143
151
|
├── NOTICE.md
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-plan-mode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Plan mode for Pi: research and design with a durable plan file that survives compaction.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -27,11 +27,15 @@
|
|
|
27
27
|
"pi": {
|
|
28
28
|
"extensions": [
|
|
29
29
|
"./index.ts"
|
|
30
|
+
],
|
|
31
|
+
"skills": [
|
|
32
|
+
"./skills/pi-plan-mode"
|
|
30
33
|
]
|
|
31
34
|
},
|
|
32
35
|
"files": [
|
|
33
36
|
"index.ts",
|
|
34
37
|
"src",
|
|
38
|
+
"skills",
|
|
35
39
|
"README.md",
|
|
36
40
|
"NOTICE.md",
|
|
37
41
|
"LICENSE",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-plan-mode
|
|
3
|
+
description: Craft decision-complete implementation plans in Plan mode — explore before asking, ask questions worth answering, and write a plan another agent can execute without guessing. Use while Plan mode is active, before calling plan_mode_complete, and when deciding whether a request needs a plan at all.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# pi-plan-mode
|
|
8
|
+
|
|
9
|
+
The extension enforces the mechanics: `edit` and `write` are blocked, the plan is written to a durable file, and `plan_mode_complete` is the only way out. It cannot enforce the thing that decides whether the plan was worth making: **whether a competent implementer could execute it without asking you anything.** That is this file.
|
|
10
|
+
|
|
11
|
+
## What "decision-complete" means
|
|
12
|
+
|
|
13
|
+
A plan is finished when every decision it depends on has been made — by discovery, by the user, or by an explicitly recorded assumption. It is not finished when it is long, well organised, or reads confidently.
|
|
14
|
+
|
|
15
|
+
The test: hand the plan to someone who was not in the conversation. Every place they would have to stop and ask "which one?" is an unresolved decision, and every one of those is a defect. That includes:
|
|
16
|
+
|
|
17
|
+
- which of two plausible approaches is being taken, and why the other was rejected;
|
|
18
|
+
- the names and shapes of anything new — files, functions, types, settings keys, tool parameters;
|
|
19
|
+
- what happens on failure, and what happens to state that already exists;
|
|
20
|
+
- how the change is verified, in the form of the commands to run and what they should print;
|
|
21
|
+
- what deliberately stays out of scope.
|
|
22
|
+
|
|
23
|
+
An unresolved decision hidden behind a confident sentence is worse than an open question, because nobody will notice it until implementation stalls.
|
|
24
|
+
|
|
25
|
+
## Phase 1: explore before asking
|
|
26
|
+
|
|
27
|
+
Read the repository first, every time. Questions are expensive — they cost the user's attention and they interrupt — so spend them only on what the code cannot tell you.
|
|
28
|
+
|
|
29
|
+
Facts the environment owns: what already exists, how the neighbours solve the same problem, what the tests cover, what conventions the repo files and `AGENTS.md` impose, what versions are pinned, what a command actually prints. **Never ask about any of them.** A question whose answer was one `rg` away tells the user you did not look, and it trains them to stop answering carefully.
|
|
30
|
+
|
|
31
|
+
Facts the environment does not own, and only the user does: product intent, priorities and tradeoffs, what "good enough" means here, which of two acceptable designs they want to live with, whether a breaking change is acceptable, deadlines and blast-radius tolerance.
|
|
32
|
+
|
|
33
|
+
Exploration also earns the right to disagree. A plan that says "this repo already does X three times, so the fourth should match" is grounded; one that invents a new pattern in a codebase that already has one is a plan the reviewer has to fight.
|
|
34
|
+
|
|
35
|
+
## Phase 2: ask questions worth answering
|
|
36
|
+
|
|
37
|
+
Use the question tool for the decisions that are genuinely the user's. Good questions share a shape:
|
|
38
|
+
|
|
39
|
+
- **The options are real and mutually exclusive.** If one option is obviously right, it is not a question — it is a decision, and you should state it and move on.
|
|
40
|
+
- **Each option says what happens if it is chosen**, not just what it is called. "Store it in the session entry (survives restart, costs a write per turn)" is a choice; "session entry" is a label.
|
|
41
|
+
- **The recommendation comes first and is marked**, so a user who trusts you can answer in one keystroke and one who does not has the alternatives in front of them.
|
|
42
|
+
- **The question is answerable without reading the codebase.** If answering it requires knowing what a function currently does, that is your job, not theirs.
|
|
43
|
+
|
|
44
|
+
Batch decisions that belong to one choice into one call rather than dripping them out over several turns, and stop asking as soon as the remaining ambiguity is low-impact. When the user declines to answer, do not silently pick: state the default you are taking, mark it as an assumption in the plan, and keep the alternative visible.
|
|
45
|
+
|
|
46
|
+
A high-impact ambiguity is a reason to keep planning, never a reason to write a plan that hedges. A plan with two branches in it is two plans and nobody's decision.
|
|
47
|
+
|
|
48
|
+
## Phase 3: write the plan
|
|
49
|
+
|
|
50
|
+
The plan is read by a human deciding whether to approve it and by an agent executing it. Both want the same thing: grouped, behaviour-level changes with the reasoning attached.
|
|
51
|
+
|
|
52
|
+
Structure that works:
|
|
53
|
+
|
|
54
|
+
- **Title and a short summary** — what changes and why, in a few sentences.
|
|
55
|
+
- **The approach**, including the alternatives considered and why they lost. This is the part that survives contact with the implementation; the file list is not.
|
|
56
|
+
- **Behaviour, interface, and data changes** — what a caller or user sees differently, and what the new names and shapes are.
|
|
57
|
+
- **Edge cases and failure modes**, including what happens to existing state and how the change behaves on a host that has the old version.
|
|
58
|
+
- **Verification** — the commands to run and what they should print, plus the manual checks that no command covers.
|
|
59
|
+
- **Assumptions and defaults chosen** — every place you decided rather than asked, stated plainly so it can be corrected.
|
|
60
|
+
|
|
61
|
+
What to leave out: file-by-file or symbol-by-symbol inventories, restatements of the conversation, and speculative future work. A plan is a decision record, not a diff written in prose.
|
|
62
|
+
|
|
63
|
+
Prefer specifics over adjectives. "Add a `groundRules?: string[]` to the persisted state, normalized on read, dropped when empty" is a plan; "improve state handling" is a mood.
|
|
64
|
+
|
|
65
|
+
## Ending a turn, and ending the mode
|
|
66
|
+
|
|
67
|
+
Every planning turn ends in exactly one of two ways: a question, or `plan_mode_complete` called alone as the final action with the whole plan. Never end with prose announcing that a plan is coming — that costs a whole turn and produces nothing.
|
|
68
|
+
|
|
69
|
+
On revision, the next `plan_mode_complete` carries a **complete replacement**, not a delta. If you cannot write a complete replacement yet, keep planning and ask instead.
|
|
70
|
+
|
|
71
|
+
Once the plan is completed the user chooses what happens from the `/plan` menu: implement here, implement in a fresh session that reads the same file, export it, or discard it. The file is the source of truth from then on — it survives compaction, and the user may have hand-edited it, so re-read it before implementing rather than working from memory of what you wrote.
|
|
72
|
+
|
|
73
|
+
## When a plan is the wrong tool
|
|
74
|
+
|
|
75
|
+
Plan mode is for work whose *shape* is uncertain. It is overhead when it is not:
|
|
76
|
+
|
|
77
|
+
- **A single obvious change** — make it. A plan for a one-line fix costs more than the fix.
|
|
78
|
+
- **Pure investigation with no change in view** — investigate in the conversation. Plan mode blocks the tools that would let you experiment, and there is nothing to decide yet.
|
|
79
|
+
- **Work whose difficulty is execution, not design** ("run this migration on 40 hosts") → the interesting part is a runbook and a rollout order, not a design decision.
|
|
80
|
+
|
|
81
|
+
Say so in one line and offer the alternative rather than producing a plan nobody needed.
|
package/src/extension-runtime.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type {
|
|
2
|
+
import type { LegacyThinkingCapture } from "./state.js";
|
|
3
3
|
|
|
4
4
|
type AgentSettledHandler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
5
5
|
|
|
@@ -11,8 +11,14 @@ export function onAgentSettled(pi: ExtensionAPI, handler: AgentSettledHandler) {
|
|
|
11
11
|
).on("agent_settled", handler);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Only the one-shot migration that undoes a pre-1.3.0 thinking-level change
|
|
16
|
+
* still calls this. Plan mode never sets the thinking level otherwise.
|
|
17
|
+
*
|
|
18
|
+
* legacy: delete in 1.4.0
|
|
19
|
+
*/
|
|
20
|
+
export function setPlanThinkingLevel(pi: ExtensionAPI, level: LegacyThinkingCapture["previous"]) {
|
|
21
|
+
(pi.setThinkingLevel as unknown as (level: string) => void)(level);
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
export function isStaleExtensionContextError(error: unknown) {
|
package/src/plan-mode.ts
CHANGED
|
@@ -46,12 +46,11 @@ import {
|
|
|
46
46
|
} from "./question-tool.js";
|
|
47
47
|
import {
|
|
48
48
|
awaitPlanModeSettingsWrites,
|
|
49
|
-
configuredThinkingLevel,
|
|
50
49
|
type PlanModeSettings,
|
|
51
50
|
planModeSettingsPath,
|
|
52
51
|
readPlanModeSettings,
|
|
53
52
|
} from "./settings.js";
|
|
54
|
-
import { type PlanModeState, restorePlanModeState } from "./state.js";
|
|
53
|
+
import { type PlanModeState, readLegacyThinkingCapture, restorePlanModeState } from "./state.js";
|
|
55
54
|
|
|
56
55
|
const STATE_ENTRY_TYPE = "plan-mode-state";
|
|
57
56
|
/**
|
|
@@ -128,7 +127,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
128
127
|
return interactiveUiPromise;
|
|
129
128
|
};
|
|
130
129
|
let state: PlanModeState = { enabled: false, awaitingAction: false };
|
|
131
|
-
let settings: PlanModeSettings = {
|
|
130
|
+
let settings: PlanModeSettings = {};
|
|
132
131
|
let sessionPlanPath: string | undefined;
|
|
133
132
|
let readyPresentationNonce = 0;
|
|
134
133
|
let pendingReadyNonce: number | undefined;
|
|
@@ -334,7 +333,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
334
333
|
const loaded = await readRuntimeSettings();
|
|
335
334
|
if (generation !== menuGeneration || menuController.signal.aborted) return;
|
|
336
335
|
if (loaded.kind === "invalid" && !ctx) return;
|
|
337
|
-
settings = loaded.kind === "loaded" ? loaded.settings : {
|
|
336
|
+
settings = loaded.kind === "loaded" ? loaded.settings : {};
|
|
338
337
|
if (!ctx) return;
|
|
339
338
|
if (loaded.kind === "invalid") {
|
|
340
339
|
ctx.ui.notify(`pi-plan-mode settings ignored: ${loaded.reason}`, "warning");
|
|
@@ -390,9 +389,10 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
390
389
|
menuController = new AbortController();
|
|
391
390
|
pendingReadyNonce = undefined;
|
|
392
391
|
latestCommandContext = undefined;
|
|
393
|
-
settings = {
|
|
392
|
+
settings = {};
|
|
394
393
|
sessionPlanPath = resolveSessionPlanPath(ctx);
|
|
395
394
|
restoreState(ctx);
|
|
395
|
+
repairLegacyThinkingLevel(ctx);
|
|
396
396
|
await loadPlanModeSettings(generation, ctx);
|
|
397
397
|
if (generation !== menuGeneration || menuController.signal.aborted) return;
|
|
398
398
|
startPlanModeSettingsWatch(generation);
|
|
@@ -400,24 +400,10 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
400
400
|
if (persistFlagActivation) {
|
|
401
401
|
state = { ...state, enabled: true, awaitingAction: state.planPath !== undefined };
|
|
402
402
|
}
|
|
403
|
-
|
|
404
|
-
if (persistFlagActivation || capturedThinkingLevel) persistState();
|
|
403
|
+
if (persistFlagActivation) persistState();
|
|
405
404
|
updateUi(ctx);
|
|
406
405
|
});
|
|
407
406
|
|
|
408
|
-
pi.on("thinking_level_select", (event) => {
|
|
409
|
-
if (!state.enabled || !state.appliedThinkingLevel) return;
|
|
410
|
-
if (event.level !== state.appliedThinkingLevel) {
|
|
411
|
-
state = {
|
|
412
|
-
...state,
|
|
413
|
-
manualThinkingLevel: event.level,
|
|
414
|
-
previousThinkingLevel: undefined,
|
|
415
|
-
appliedThinkingLevel: undefined,
|
|
416
|
-
};
|
|
417
|
-
persistState();
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
|
|
421
407
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
422
408
|
menuGeneration += 1;
|
|
423
409
|
stopPlanModeSettingsWatch();
|
|
@@ -426,9 +412,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
426
412
|
latestCommandContext = undefined;
|
|
427
413
|
refreshStateBeforeFirstAgentStart = false;
|
|
428
414
|
await awaitPlanModeSettingsWrites(dependencies.settingsPath);
|
|
429
|
-
captureManualThinkingLevel();
|
|
430
415
|
persistState();
|
|
431
|
-
if (state.enabled) restoreThinkingLevel();
|
|
432
416
|
clearUi(ctx);
|
|
433
417
|
});
|
|
434
418
|
|
|
@@ -449,7 +433,6 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
449
433
|
if (refreshStateBeforeFirstAgentStart) {
|
|
450
434
|
refreshStateBeforeFirstAgentStart = false;
|
|
451
435
|
restoreState(ctx);
|
|
452
|
-
if (state.enabled && applyPlanThinkingLevel()) persistState();
|
|
453
436
|
updateUi(ctx);
|
|
454
437
|
}
|
|
455
438
|
if (state.enabled && state.awaitingAction) {
|
|
@@ -494,7 +477,6 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
494
477
|
function enterPlanMode(ctx: ExtensionContext) {
|
|
495
478
|
workflowGeneration += 1;
|
|
496
479
|
state = { ...state, enabled: true, awaitingAction: false };
|
|
497
|
-
applyPlanThinkingLevel();
|
|
498
480
|
persistState();
|
|
499
481
|
updateUi(ctx);
|
|
500
482
|
}
|
|
@@ -507,7 +489,6 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
507
489
|
ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
|
|
508
490
|
}
|
|
509
491
|
if (sendPlanModeUserMessage(prompt, ctx)) return;
|
|
510
|
-
if (!wasEnabled) restoreThinkingLevel();
|
|
511
492
|
state = previousState;
|
|
512
493
|
persistState();
|
|
513
494
|
updateUi(ctx);
|
|
@@ -515,7 +496,6 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
515
496
|
|
|
516
497
|
async function exitPlanMode(ctx: ExtensionContext, options: { keepPlanFile?: boolean } = {}) {
|
|
517
498
|
workflowGeneration += 1;
|
|
518
|
-
const wasEnabled = state.enabled;
|
|
519
499
|
const planPath = state.planPath;
|
|
520
500
|
pendingReadyNonce = undefined;
|
|
521
501
|
state = {
|
|
@@ -523,12 +503,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
523
503
|
enabled: false,
|
|
524
504
|
planPath: undefined,
|
|
525
505
|
awaitingAction: false,
|
|
526
|
-
manualThinkingLevel: undefined,
|
|
527
506
|
};
|
|
528
|
-
if (wasEnabled) {
|
|
529
|
-
restoreThinkingLevel();
|
|
530
|
-
state = { ...state, manualThinkingLevel: undefined };
|
|
531
|
-
}
|
|
532
507
|
persistState();
|
|
533
508
|
updateUi(ctx);
|
|
534
509
|
if (planPath && !options.keepPlanFile) await deletePlanFile(planPath);
|
|
@@ -600,25 +575,18 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
600
575
|
|
|
601
576
|
workflowGeneration += 1;
|
|
602
577
|
const previousState = state;
|
|
603
|
-
const wasEnabled = state.enabled;
|
|
604
578
|
pendingReadyNonce = undefined;
|
|
605
579
|
state = {
|
|
606
580
|
...state,
|
|
607
581
|
enabled: false,
|
|
608
582
|
awaitingAction: false,
|
|
609
583
|
planPath,
|
|
610
|
-
manualThinkingLevel: undefined,
|
|
611
584
|
};
|
|
612
|
-
if (wasEnabled) {
|
|
613
|
-
restoreThinkingLevel();
|
|
614
|
-
state = { ...state, manualThinkingLevel: undefined };
|
|
615
|
-
}
|
|
616
585
|
persistState();
|
|
617
586
|
updateUi(ctx);
|
|
618
587
|
|
|
619
588
|
if (!sendPlanModeUserMessage(formatImplementationHandoff(planPath), ctx)) {
|
|
620
589
|
state = previousState;
|
|
621
|
-
if (wasEnabled) applyPlanThinkingLevel();
|
|
622
590
|
persistState();
|
|
623
591
|
updateUi(ctx);
|
|
624
592
|
}
|
|
@@ -707,66 +675,21 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
707
675
|
}
|
|
708
676
|
|
|
709
677
|
/**
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
678
|
+
* pi-plan-mode <= 1.2.1 raised the thinking level while planning, and because
|
|
679
|
+
* `pi.setThinkingLevel` writes through to the user's real settings, a session
|
|
680
|
+
* that died before its restore left that change durable. If the newest state
|
|
681
|
+
* entry still carries the capture and the live level still equals what Plan
|
|
682
|
+
* mode applied, put the user's level back — once. Persisting state in the new
|
|
683
|
+
* shape drops the capture, so the next session finds nothing to repair. A
|
|
684
|
+
* user who has already moved the level themselves is left alone.
|
|
685
|
+
*
|
|
686
|
+
* legacy: delete in 1.4.0
|
|
715
687
|
*/
|
|
716
|
-
function
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
return false;
|
|
722
|
-
}
|
|
723
|
-
const configured = configuredThinkingLevel(settings);
|
|
724
|
-
if (!configured) {
|
|
725
|
-
if (state.previousThinkingLevel === undefined && state.appliedThinkingLevel === undefined) {
|
|
726
|
-
return false;
|
|
727
|
-
}
|
|
728
|
-
state = {
|
|
729
|
-
...state,
|
|
730
|
-
previousThinkingLevel: undefined,
|
|
731
|
-
appliedThinkingLevel: undefined,
|
|
732
|
-
};
|
|
733
|
-
return true;
|
|
734
|
-
}
|
|
735
|
-
const current = pi.getThinkingLevel();
|
|
736
|
-
const capturedPrevious = state.previousThinkingLevel;
|
|
737
|
-
const capturedApplied = state.appliedThinkingLevel;
|
|
738
|
-
if (!state.appliedThinkingLevel) state.previousThinkingLevel = current;
|
|
739
|
-
if (current !== configured) setPlanThinkingLevel(pi, configured);
|
|
740
|
-
state.appliedThinkingLevel = pi.getThinkingLevel();
|
|
741
|
-
return (
|
|
742
|
-
state.previousThinkingLevel !== capturedPrevious ||
|
|
743
|
-
state.appliedThinkingLevel !== capturedApplied
|
|
744
|
-
);
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
function captureManualThinkingLevel() {
|
|
748
|
-
if (!state.appliedThinkingLevel) return;
|
|
749
|
-
const current = pi.getThinkingLevel();
|
|
750
|
-
if (current === state.appliedThinkingLevel) return;
|
|
751
|
-
state = {
|
|
752
|
-
...state,
|
|
753
|
-
manualThinkingLevel: current,
|
|
754
|
-
previousThinkingLevel: undefined,
|
|
755
|
-
appliedThinkingLevel: undefined,
|
|
756
|
-
};
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
function restoreThinkingLevel() {
|
|
760
|
-
captureManualThinkingLevel();
|
|
761
|
-
const { appliedThinkingLevel, previousThinkingLevel } = state;
|
|
762
|
-
if (
|
|
763
|
-
appliedThinkingLevel &&
|
|
764
|
-
previousThinkingLevel &&
|
|
765
|
-
pi.getThinkingLevel() === appliedThinkingLevel
|
|
766
|
-
) {
|
|
767
|
-
setPlanThinkingLevel(pi, previousThinkingLevel);
|
|
768
|
-
}
|
|
769
|
-
state = { ...state, appliedThinkingLevel: undefined, previousThinkingLevel: undefined };
|
|
688
|
+
function repairLegacyThinkingLevel(ctx: ExtensionContext) {
|
|
689
|
+
const legacy = readLegacyThinkingCapture(ctx.sessionManager.getBranch(), STATE_ENTRY_TYPE);
|
|
690
|
+
if (!legacy || pi.getThinkingLevel() !== legacy.applied) return;
|
|
691
|
+
setPlanThinkingLevel(pi, legacy.previous);
|
|
692
|
+
persistState();
|
|
770
693
|
}
|
|
771
694
|
|
|
772
695
|
function resolveSessionPlanPath(ctx: ExtensionContext) {
|
package/src/presentation.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
1
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { readPlanFile } from "./plan-file.js";
|
|
3
4
|
import type { PlanModeState } from "./state.js";
|
|
@@ -5,25 +6,111 @@ import type { PlanModeState } from "./state.js";
|
|
|
5
6
|
const STATUS_KEY = "plan-mode";
|
|
6
7
|
const PLAN_WIDGET_KEY = "plan-mode-plan";
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The one thing both surfaces render.
|
|
11
|
+
*
|
|
12
|
+
* The footer and the widget say the same thing in two sizes, so they are
|
|
13
|
+
* formatted once. When each formatted its own they drifted — the sibling
|
|
14
|
+
* pi-loop shipped a loop that read as "waiting" in the footer and "running"
|
|
15
|
+
* above the editor for exactly that reason, and this is the same shape of
|
|
16
|
+
* bug waiting to happen with "ready" and "implementing".
|
|
17
|
+
*
|
|
18
|
+
* The glyphs are the family vocabulary shared with pi-loop by convention
|
|
19
|
+
* rather than by import: `◆` planning or ready, `▶` implementing. Six
|
|
20
|
+
* characters do not justify a shared package; a user reading a footer
|
|
21
|
+
* justifies the consistency.
|
|
22
|
+
*/
|
|
23
|
+
export type PlanModePhase = "drafting" | "revising" | "ready" | "implementing";
|
|
24
|
+
|
|
25
|
+
export interface PlanModeView {
|
|
26
|
+
phase: PlanModePhase;
|
|
27
|
+
/** The footer line: plain text with a glyph, no colour. */
|
|
28
|
+
footer: string;
|
|
29
|
+
/** The widget's headline, rendered bold and themed. */
|
|
30
|
+
headline: string;
|
|
31
|
+
/** The dim second line: what to do next. */
|
|
32
|
+
hint: string;
|
|
33
|
+
/** Accent while the plan wants a decision; normal once it is being built. */
|
|
34
|
+
tone: "accent" | "normal";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function planModeView(state: PlanModeState): PlanModeView | undefined {
|
|
38
|
+
if (state.enabled) {
|
|
39
|
+
if (state.awaitingAction) {
|
|
40
|
+
return {
|
|
41
|
+
phase: "ready",
|
|
42
|
+
footer: "◆ plan · ready → /plan",
|
|
43
|
+
headline: "◆ plan · proposed plan ready",
|
|
44
|
+
hint: "/plan to implement, export, or exit — or type feedback to revise.",
|
|
45
|
+
tone: "accent",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// A stored plan with no pending action means feedback superseded it: the
|
|
49
|
+
// plan on disk is no longer what is being offered, and saying "drafting"
|
|
50
|
+
// would hide that a completed plan is being replaced.
|
|
51
|
+
if (state.planPath) {
|
|
52
|
+
return {
|
|
53
|
+
phase: "revising",
|
|
54
|
+
footer: "◆ plan · revising",
|
|
55
|
+
headline: "◆ plan · revising the proposed plan",
|
|
56
|
+
hint: "The stored plan is superseded until the next plan_mode_complete.",
|
|
57
|
+
tone: "accent",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
phase: "drafting",
|
|
62
|
+
footer: "◆ plan · drafting",
|
|
63
|
+
headline: "◆ plan · drafting",
|
|
64
|
+
hint: "Explore and ask; finish with plan_mode_complete when decision-ready.",
|
|
65
|
+
tone: "accent",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (state.planPath) {
|
|
69
|
+
return {
|
|
70
|
+
phase: "implementing",
|
|
71
|
+
footer: "▶ plan · implementing",
|
|
72
|
+
headline: "▶ plan · implementing",
|
|
73
|
+
hint: "/plan to show, replace, or clear the active plan.",
|
|
74
|
+
tone: "normal",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The slice of Pi's theme this widget uses. Structural rather than imported so
|
|
82
|
+
* the renderer keeps working against a host whose theme carries neither
|
|
83
|
+
* helper: both are optional, and an absent one degrades to plain text.
|
|
84
|
+
*/
|
|
85
|
+
interface WidgetTheme {
|
|
86
|
+
bold?: (text: string) => string;
|
|
87
|
+
fg?: (color: string, text: string) => string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type WidgetFactory = Parameters<ExtensionContext["ui"]["setWidget"]>[1];
|
|
91
|
+
|
|
8
92
|
export function updatePlanModeUi(ctx: ExtensionContext, state: PlanModeState) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"Proposed plan ready",
|
|
13
|
-
"Use /plan to implement, export, or exit — or type feedback to revise.",
|
|
14
|
-
]);
|
|
15
|
-
} else if (state.enabled) {
|
|
16
|
-
ctx.ui.setWidget(PLAN_WIDGET_KEY, [
|
|
17
|
-
"Plan mode: planning",
|
|
18
|
-
"Finish with plan_mode_complete when decision-ready.",
|
|
19
|
-
]);
|
|
20
|
-
} else if (state.planPath) {
|
|
21
|
-
ctx.ui.setWidget(PLAN_WIDGET_KEY, [
|
|
22
|
-
"Implementation plan active",
|
|
23
|
-
"Use /plan to show, replace, or clear it.",
|
|
24
|
-
]);
|
|
25
|
-
} else {
|
|
93
|
+
const view = planModeView(state);
|
|
94
|
+
ctx.ui.setStatus(STATUS_KEY, view?.footer);
|
|
95
|
+
if (!view) {
|
|
26
96
|
ctx.ui.setWidget(PLAN_WIDGET_KEY, undefined);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const render = (_tui: unknown, rawTheme: unknown) => {
|
|
101
|
+
const theme = (rawTheme ?? {}) as WidgetTheme;
|
|
102
|
+
const bold = theme.bold ?? ((text: string) => text);
|
|
103
|
+
const headline =
|
|
104
|
+
view.tone === "accent"
|
|
105
|
+
? (theme.fg?.("accent", bold(view.headline)) ?? bold(view.headline))
|
|
106
|
+
: bold(view.headline);
|
|
107
|
+
const hint = theme.fg?.("dim", ` ${view.hint}`) ?? ` ${view.hint}`;
|
|
108
|
+
return new Text(`${headline}\n${hint}`);
|
|
109
|
+
};
|
|
110
|
+
ctx.ui.setWidget(PLAN_WIDGET_KEY, render as WidgetFactory);
|
|
111
|
+
} catch {
|
|
112
|
+
// Presentation only: a host without the component form of setWidget (or a
|
|
113
|
+
// render failure) must never take Plan mode's state transitions with it.
|
|
27
114
|
}
|
|
28
115
|
}
|
|
29
116
|
|
|
@@ -79,6 +166,7 @@ export function showPlanModePlan(
|
|
|
79
166
|
}
|
|
80
167
|
}
|
|
81
168
|
|
|
169
|
+
/** The sentence form, for menus, notifications, and non-TUI modes. */
|
|
82
170
|
export function planModeStatusText(state: PlanModeState) {
|
|
83
171
|
if (state.enabled) {
|
|
84
172
|
if (state.awaitingAction) return "Plan mode is active and a proposed plan is ready.";
|
|
@@ -90,9 +178,3 @@ export function planModeStatusText(state: PlanModeState) {
|
|
|
90
178
|
if (state.planPath) return "An implementation plan is active.";
|
|
91
179
|
return "Plan mode is off.";
|
|
92
180
|
}
|
|
93
|
-
|
|
94
|
-
function formatStatus(state: PlanModeState) {
|
|
95
|
-
if (state.enabled) return state.awaitingAction ? "plan ready" : "plan active";
|
|
96
|
-
if (state.planPath) return "plan implementing";
|
|
97
|
-
return undefined;
|
|
98
|
-
}
|
package/src/prompt.ts
CHANGED
|
@@ -51,6 +51,7 @@ You are in Plan Mode, a collaboration mode for producing a decision-complete imp
|
|
|
51
51
|
|
|
52
52
|
## Mode rules
|
|
53
53
|
|
|
54
|
+
- Read the pi-plan-mode skill before planning if it is available: it carries the plan-crafting craft — decision-completeness, exploring before asking, question quality, and what a finished plan contains.
|
|
54
55
|
- Stay in Plan Mode until a developer or extension explicitly exits it.
|
|
55
56
|
- Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
|
|
56
57
|
- Do not use todo/checklist tooling to track execution progress in Plan Mode; Plan Mode is conversational planning, and the plan itself belongs in plan_mode_complete.
|
package/src/settings-menu.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
1
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { defineMenu, type RunMenuResult, runMenu } from "@narumitw/pi-tui-kit";
|
|
3
4
|
import { planExportDestination } from "./plan-export.js";
|
|
4
5
|
import {
|
|
5
6
|
configuredPlanExportPath,
|
|
6
|
-
PLAN_MODE_THINKING_LEVELS,
|
|
7
7
|
type PlanModeSettings,
|
|
8
8
|
type PlanModeSettingsLoadResult,
|
|
9
9
|
type PlanModeSettingsPatch,
|
|
@@ -18,6 +18,8 @@ interface SettingsMenuState {
|
|
|
18
18
|
settings: PlanModeSettings;
|
|
19
19
|
notice?: string;
|
|
20
20
|
reason?: string;
|
|
21
|
+
/** The removed `thinkingLevel` key is still in the file. legacy: delete in 1.4.0 */
|
|
22
|
+
hasLegacyThinkingLevel?: boolean;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export interface PlanModeSettingsMenuOptions {
|
|
@@ -34,7 +36,7 @@ export interface PlanModeSettingsMenuOptions {
|
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
type Screen = "settings" | "export";
|
|
37
|
-
type Action = "
|
|
39
|
+
type Action = "open-export" | "set-export";
|
|
38
40
|
|
|
39
41
|
export async function showPlanModeSettings(
|
|
40
42
|
ctx: ExtensionContext,
|
|
@@ -49,15 +51,16 @@ export async function showPlanModeSettings(
|
|
|
49
51
|
if (loaded.kind === "invalid") {
|
|
50
52
|
return {
|
|
51
53
|
kind: "invalid",
|
|
52
|
-
settings: {
|
|
54
|
+
settings: {},
|
|
53
55
|
notice: loaded.notice,
|
|
54
56
|
reason: loaded.reason,
|
|
55
57
|
};
|
|
56
58
|
}
|
|
57
59
|
return {
|
|
58
60
|
kind: "valid",
|
|
59
|
-
settings: loaded.kind === "loaded" ? loaded.settings : {
|
|
61
|
+
settings: loaded.kind === "loaded" ? loaded.settings : {},
|
|
60
62
|
notice: loaded.notice,
|
|
63
|
+
hasLegacyThinkingLevel: await hasLegacyThinkingLevel(settingsPath),
|
|
61
64
|
};
|
|
62
65
|
};
|
|
63
66
|
|
|
@@ -70,16 +73,8 @@ export async function showPlanModeSettings(
|
|
|
70
73
|
: {
|
|
71
74
|
kind: "settings",
|
|
72
75
|
title: "Plan Mode Settings",
|
|
73
|
-
lines: settingsLines(settingsPath, state
|
|
76
|
+
lines: settingsLines(settingsPath, state),
|
|
74
77
|
items: [
|
|
75
|
-
{
|
|
76
|
-
id: "thinkingLevel",
|
|
77
|
-
label: "Plan thinking",
|
|
78
|
-
description: "Set the thinking level when the next Plan workflow starts.",
|
|
79
|
-
currentValue: state.settings.thinkingLevel,
|
|
80
|
-
values: PLAN_MODE_THINKING_LEVELS,
|
|
81
|
-
action: "set-thinking",
|
|
82
|
-
},
|
|
83
78
|
{
|
|
84
79
|
id: "defaultPlanExportPath",
|
|
85
80
|
label: "Export destination",
|
|
@@ -107,19 +102,6 @@ export async function showPlanModeSettings(
|
|
|
107
102
|
},
|
|
108
103
|
},
|
|
109
104
|
actions: {
|
|
110
|
-
"set-thinking": async ({ ctx: actionCtx, value, signal }) => {
|
|
111
|
-
if (
|
|
112
|
-
!PLAN_MODE_THINKING_LEVELS.includes(value as (typeof PLAN_MODE_THINKING_LEVELS)[number])
|
|
113
|
-
) {
|
|
114
|
-
return { kind: "rejected" };
|
|
115
|
-
}
|
|
116
|
-
return savePatch(
|
|
117
|
-
actionCtx,
|
|
118
|
-
{ thinkingLevel: value as PlanModeSettings["thinkingLevel"] },
|
|
119
|
-
signal,
|
|
120
|
-
`Plan mode thinking level: ${value}. Applies to the next Plan workflow.`,
|
|
121
|
-
);
|
|
122
|
-
},
|
|
123
105
|
"open-export": async () => ({ kind: "to", screen: "export" }),
|
|
124
106
|
"set-export": async ({ ctx: actionCtx, value, signal }) => {
|
|
125
107
|
const defaultPlanExportPath = value?.trim() || null;
|
|
@@ -171,14 +153,35 @@ export async function showPlanModeSettings(
|
|
|
171
153
|
}
|
|
172
154
|
}
|
|
173
155
|
|
|
174
|
-
function settingsLines(settingsPath: string,
|
|
156
|
+
function settingsLines(settingsPath: string, state: SettingsMenuState) {
|
|
175
157
|
return [
|
|
176
158
|
`User settings · ${safeTerminalText(settingsPath)}`,
|
|
177
|
-
"
|
|
178
|
-
|
|
159
|
+
"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)] : []),
|
|
179
167
|
];
|
|
180
168
|
}
|
|
181
169
|
|
|
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
|
+
|
|
182
185
|
function invalidScreen(settingsPath: string, state: SettingsMenuState) {
|
|
183
186
|
return {
|
|
184
187
|
kind: "detail" as const,
|
package/src/settings.ts
CHANGED
|
@@ -7,27 +7,13 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
7
7
|
export const PLAN_MODE_SETTINGS_FILE = "pi-plan-mode.json";
|
|
8
8
|
const LEGACY_PLAN_MODE_SETTINGS_FILE = "plan-mode.json";
|
|
9
9
|
const MAX_SETTINGS_BYTES = 64 * 1024;
|
|
10
|
-
export const PLAN_MODE_THINKING_LEVELS = [
|
|
11
|
-
"inherit",
|
|
12
|
-
"off",
|
|
13
|
-
"minimal",
|
|
14
|
-
"low",
|
|
15
|
-
"medium",
|
|
16
|
-
"high",
|
|
17
|
-
"xhigh",
|
|
18
|
-
"max",
|
|
19
|
-
] as const;
|
|
20
10
|
export const DEFAULT_PLAN_EXPORT_PATH = "PLAN.md";
|
|
21
11
|
const MAX_PLAN_EXPORT_PATH_LENGTH = 4096;
|
|
22
12
|
|
|
23
|
-
export type PlanModeThinkingLevel = (typeof PLAN_MODE_THINKING_LEVELS)[number];
|
|
24
|
-
export type PlanModeFixedThinkingLevel = Exclude<PlanModeThinkingLevel, "inherit">;
|
|
25
13
|
export interface PlanModeSettings {
|
|
26
|
-
thinkingLevel: PlanModeThinkingLevel;
|
|
27
14
|
defaultPlanExportPath?: string;
|
|
28
15
|
}
|
|
29
16
|
export interface PlanModeSettingsPatch {
|
|
30
|
-
thinkingLevel?: PlanModeThinkingLevel;
|
|
31
17
|
defaultPlanExportPath?: string | null;
|
|
32
18
|
}
|
|
33
19
|
export interface UpdatePlanModeSettingsOptions {
|
|
@@ -59,21 +45,13 @@ function legacyPlanModeSettingsPath() {
|
|
|
59
45
|
|
|
60
46
|
/**
|
|
61
47
|
* Unknown top-level keys are tolerated and preserved on save. Settings removed
|
|
62
|
-
*
|
|
63
|
-
* implementationPlanRetention) therefore keep an existing file
|
|
64
|
-
* failing it closed on upgrade.
|
|
48
|
+
* over time (defaultPlanTools, bashPolicy, safeSubcommands,
|
|
49
|
+
* implementationPlanRetention, thinkingLevel) therefore keep an existing file
|
|
50
|
+
* valid instead of failing it closed on upgrade.
|
|
65
51
|
*/
|
|
66
52
|
export function normalizePlanModeSettings(value: unknown): PlanModeSettings | undefined {
|
|
67
53
|
if (!isSettingsDocument(value)) return undefined;
|
|
68
|
-
const
|
|
69
|
-
? Reflect.get(value, "thinkingLevel")
|
|
70
|
-
: "inherit";
|
|
71
|
-
if (!PLAN_MODE_THINKING_LEVELS.includes(thinkingLevel as PlanModeThinkingLevel)) {
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
74
|
-
const settings: PlanModeSettings = {
|
|
75
|
-
thinkingLevel: thinkingLevel as PlanModeThinkingLevel,
|
|
76
|
-
};
|
|
54
|
+
const settings: PlanModeSettings = {};
|
|
77
55
|
if (Object.hasOwn(value, "defaultPlanExportPath")) {
|
|
78
56
|
const defaultPlanExportPath = normalizePlanExportPath(
|
|
79
57
|
Reflect.get(value, "defaultPlanExportPath"),
|
|
@@ -143,7 +121,6 @@ export function updatePlanModeSettings(
|
|
|
143
121
|
options.signal?.throwIfAborted();
|
|
144
122
|
const current = await readSettingsDocumentForUpdate(settingsPath, legacySettingsPath);
|
|
145
123
|
const updated: SettingsDocument = { ...current };
|
|
146
|
-
if (patch.thinkingLevel !== undefined) updated.thinkingLevel = patch.thinkingLevel;
|
|
147
124
|
if (patch.defaultPlanExportPath === null) delete updated.defaultPlanExportPath;
|
|
148
125
|
else if (patch.defaultPlanExportPath !== undefined) {
|
|
149
126
|
updated.defaultPlanExportPath = patch.defaultPlanExportPath;
|
|
@@ -310,12 +287,6 @@ function safeReadError(error: unknown) {
|
|
|
310
287
|
return error instanceof Error ? error.message : String(error);
|
|
311
288
|
}
|
|
312
289
|
|
|
313
|
-
export function configuredThinkingLevel(
|
|
314
|
-
settings: PlanModeSettings,
|
|
315
|
-
): PlanModeFixedThinkingLevel | undefined {
|
|
316
|
-
return settings.thinkingLevel === "inherit" ? undefined : settings.thinkingLevel;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
290
|
export function configuredPlanExportPath(settings: PlanModeSettings) {
|
|
320
291
|
return settings.defaultPlanExportPath ?? DEFAULT_PLAN_EXPORT_PATH;
|
|
321
292
|
}
|
package/src/state.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { PLAN_MODE_THINKING_LEVELS, type PlanModeFixedThinkingLevel } from "./settings.js";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
* The plan lives on disk, so session state carries only a pointer to it
|
|
5
|
-
* the
|
|
2
|
+
* The plan lives on disk, so session state carries only a pointer to it and
|
|
3
|
+
* the ready-for-action flag. Plan mode holds no session-global state of its
|
|
4
|
+
* own: thinking level and model are session settings it never touches.
|
|
6
5
|
*/
|
|
7
6
|
export interface PlanModeState {
|
|
8
7
|
enabled: boolean;
|
|
@@ -10,9 +9,6 @@ export interface PlanModeState {
|
|
|
10
9
|
planPath?: string;
|
|
11
10
|
/** A completed plan is waiting for the user to choose how to proceed. */
|
|
12
11
|
awaitingAction: boolean;
|
|
13
|
-
previousThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
14
|
-
appliedThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
15
|
-
manualThinkingLevel?: PlanModeFixedThinkingLevel;
|
|
16
12
|
}
|
|
17
13
|
|
|
18
14
|
type SessionEntry = {
|
|
@@ -22,15 +18,7 @@ type SessionEntry = {
|
|
|
22
18
|
};
|
|
23
19
|
|
|
24
20
|
export function restorePlanModeState(entries: unknown[], stateEntryType: string): PlanModeState {
|
|
25
|
-
const
|
|
26
|
-
let entry: SessionEntry | undefined;
|
|
27
|
-
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
28
|
-
const candidate = branch[index];
|
|
29
|
-
if (candidate?.type === "custom" && candidate.customType === stateEntryType) {
|
|
30
|
-
entry = candidate;
|
|
31
|
-
break;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
21
|
+
const entry = newestStateEntry(entries, stateEntryType);
|
|
34
22
|
if (!isRecord(entry?.data)) return { enabled: false, awaitingAction: false };
|
|
35
23
|
|
|
36
24
|
const enabled = entry.data.enabled === true;
|
|
@@ -39,14 +27,54 @@ export function restorePlanModeState(entries: unknown[], stateEntryType: string)
|
|
|
39
27
|
enabled,
|
|
40
28
|
planPath,
|
|
41
29
|
awaitingAction: enabled && entry.data.awaitingAction === true && planPath !== undefined,
|
|
42
|
-
previousThinkingLevel: enabled
|
|
43
|
-
? fixedThinkingLevel(entry.data.previousThinkingLevel)
|
|
44
|
-
: undefined,
|
|
45
|
-
appliedThinkingLevel: enabled ? fixedThinkingLevel(entry.data.appliedThinkingLevel) : undefined,
|
|
46
|
-
manualThinkingLevel: enabled ? fixedThinkingLevel(entry.data.manualThinkingLevel) : undefined,
|
|
47
30
|
};
|
|
48
31
|
}
|
|
49
32
|
|
|
33
|
+
function newestStateEntry(entries: unknown[], stateEntryType: string): SessionEntry | undefined {
|
|
34
|
+
const branch = entries as SessionEntry[];
|
|
35
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
36
|
+
const candidate = branch[index];
|
|
37
|
+
if (candidate?.type === "custom" && candidate.customType === stateEntryType) return candidate;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
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
|
+
|
|
50
78
|
/**
|
|
51
79
|
* Persisted paths are only trusted when they are absolute and free of NUL, so
|
|
52
80
|
* malformed state can never redirect a read or a delete to a relative target.
|
|
@@ -58,11 +86,11 @@ function absolutePath(value: unknown) {
|
|
|
58
86
|
return normalized;
|
|
59
87
|
}
|
|
60
88
|
|
|
61
|
-
|
|
89
|
+
// legacy: delete in 1.4.0
|
|
90
|
+
function legacyThinkingLevel(value: unknown): (typeof LEGACY_THINKING_LEVELS)[number] | undefined {
|
|
62
91
|
return typeof value === "string" &&
|
|
63
|
-
value
|
|
64
|
-
|
|
65
|
-
? (value as PlanModeFixedThinkingLevel)
|
|
92
|
+
LEGACY_THINKING_LEVELS.includes(value as (typeof LEGACY_THINKING_LEVELS)[number])
|
|
93
|
+
? (value as (typeof LEGACY_THINKING_LEVELS)[number])
|
|
66
94
|
: undefined;
|
|
67
95
|
}
|
|
68
96
|
|