@hank-warren/pi-plan-mode 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +9 -5
- package/{skills/pi-plan-mode/SKILL.md → docs/plan-craft.md} +0 -6
- package/package.json +3 -6
- package/src/command.ts +1 -1
- package/src/completion-tool.ts +5 -5
- package/src/extension-runtime.ts +0 -11
- package/src/fresh-implementation.ts +2 -2
- package/src/lifecycle.ts +79 -0
- package/src/plan-export.ts +4 -4
- package/src/plan-file.ts +1 -5
- package/src/plan-launch-menu.ts +1 -1
- package/src/plan-mode.ts +110 -174
- package/src/presentation.ts +2 -2
- package/src/prompt.ts +23 -1
- package/src/question-tool.ts +28 -11
- package/src/settings-menu.ts +6 -48
- package/src/settings-watch.ts +60 -0
- package/src/settings.ts +12 -69
- package/src/state.ts +0 -44
package/src/plan-mode.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { watch } from "node:fs";
|
|
2
|
-
import { basename, dirname } from "node:path";
|
|
3
1
|
import type {
|
|
4
2
|
ExtensionAPI,
|
|
5
3
|
ExtensionCommandContext,
|
|
@@ -13,15 +11,12 @@ import {
|
|
|
13
11
|
planModeCompleted,
|
|
14
12
|
renderPlanModeCompletion,
|
|
15
13
|
} from "./completion-tool.js";
|
|
16
|
-
import {
|
|
17
|
-
isStaleExtensionContextError,
|
|
18
|
-
onAgentSettled,
|
|
19
|
-
setPlanThinkingLevel,
|
|
20
|
-
} from "./extension-runtime.js";
|
|
14
|
+
import { isStaleExtensionContextError, onAgentSettled } from "./extension-runtime.js";
|
|
21
15
|
import {
|
|
22
16
|
formatImplementationHandoff,
|
|
23
17
|
startFreshImplementationFromState,
|
|
24
18
|
} from "./fresh-implementation.js";
|
|
19
|
+
import { createLifecycle, type LifecycleScope } from "./lifecycle.js";
|
|
25
20
|
import { deletePlanFile, planFilePathForSession, readPlanFile, writePlanFile } from "./plan-file.js";
|
|
26
21
|
import { createPlanActionController } from "./plan-action-controller.js";
|
|
27
22
|
import { createPlanExportController } from "./plan-export-controller.js";
|
|
@@ -52,10 +47,25 @@ import {
|
|
|
52
47
|
planModeSettingsPath,
|
|
53
48
|
readPlanModeSettings,
|
|
54
49
|
} from "./settings.js";
|
|
55
|
-
import {
|
|
50
|
+
import { createSettingsWatcher } from "./settings-watch.js";
|
|
51
|
+
import { type PlanModeState, restorePlanModeState } from "./state.js";
|
|
56
52
|
|
|
57
53
|
const STATE_ENTRY_TYPE = "plan-mode-state";
|
|
58
54
|
const ASK_USER_AVAILABILITY_EVENT = "hank:ask-user:availability";
|
|
55
|
+
/** Label Herdr shows while `plan_mode_question` waits; distinguishes it from an approval. */
|
|
56
|
+
export const HERDR_BLOCKED_LABEL = "plan question";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Tell Herdr this pane is waiting on a human, so a supervising agent in another
|
|
60
|
+
* pane sees the block instead of reading a stalled turn as progress. Same
|
|
61
|
+
* contract as pi-auto-permissions' `setHerdrBlocked`, duplicated rather than
|
|
62
|
+
* imported so Plan Mode has no dependency on the permissions engine. No-op
|
|
63
|
+
* outside Herdr.
|
|
64
|
+
*/
|
|
65
|
+
function setHerdrBlocked(pi: ExtensionAPI, active: boolean): void {
|
|
66
|
+
if (process.env.HERDR_ENV !== "1") return;
|
|
67
|
+
pi.events.emit("herdr:blocked", active ? { active: true, label: HERDR_BLOCKED_LABEL } : { active: false });
|
|
68
|
+
}
|
|
59
69
|
/**
|
|
60
70
|
* Plan mode's entire enforcement surface. Everything else — bash, subagents,
|
|
61
71
|
* MCP, and other extension tools — is left to the session's normal permission
|
|
@@ -67,11 +77,7 @@ const ASK_USER_AVAILABILITY_EVENT = "hank:ask-user:availability";
|
|
|
67
77
|
* was once listed here; it was a pre-1.0 upstream tool that no longer exists.)
|
|
68
78
|
*/
|
|
69
79
|
const BLOCKED_TOOLS = new Set(["edit", "write"]);
|
|
70
|
-
/**
|
|
71
|
-
* One hand-edit or menu save fans out into several filesystem events (temp file
|
|
72
|
-
* created, renamed into place). Collapsing them into one re-read keeps a save
|
|
73
|
-
* to a single load.
|
|
74
|
-
*/
|
|
80
|
+
/** Long enough to collapse one save's burst of filesystem events into one read. */
|
|
75
81
|
const SETTINGS_RELOAD_DEBOUNCE_MS = 75;
|
|
76
82
|
|
|
77
83
|
/**
|
|
@@ -115,12 +121,9 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
115
121
|
let readyPresentationNonce = 0;
|
|
116
122
|
let pendingReadyNonce: number | undefined;
|
|
117
123
|
let latestCommandContext: ExtensionCommandContext | undefined;
|
|
118
|
-
let menuGeneration = 0;
|
|
119
|
-
let workflowGeneration = 0;
|
|
120
124
|
let refreshStateBeforeFirstAgentStart = false;
|
|
121
|
-
|
|
122
|
-
let
|
|
123
|
-
let settingsReloadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
125
|
+
const lifecycle = createLifecycle();
|
|
126
|
+
let settingsWatcher: ReturnType<typeof createSettingsWatcher> | undefined;
|
|
124
127
|
let planToolsActivated = false;
|
|
125
128
|
let currentHasUI = false;
|
|
126
129
|
let globalQuestionAvailable = false;
|
|
@@ -166,7 +169,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
166
169
|
const planActions = createPlanActionController({
|
|
167
170
|
loadInteractiveUi,
|
|
168
171
|
getState: () => state,
|
|
169
|
-
captureLifecycle:
|
|
172
|
+
captureLifecycle: () => lifecycle.capture(),
|
|
170
173
|
statusText: planStatusText,
|
|
171
174
|
planPathLine: () => (state.planPath ? `Plan file: ${state.planPath}` : undefined),
|
|
172
175
|
getExportDestination: (ctx) => planExports.getDestination(ctx),
|
|
@@ -179,13 +182,11 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
179
182
|
exitReady: (ctx) => {
|
|
180
183
|
// Same had-plan branching as the /plan exit command: the menu must not
|
|
181
184
|
// claim a plan was discarded when none was ever completed.
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
);
|
|
188
|
-
});
|
|
185
|
+
const text =
|
|
186
|
+
state.planPath !== undefined
|
|
187
|
+
? "Plan mode disabled. Proposed plan discarded."
|
|
188
|
+
: "Plan mode disabled.";
|
|
189
|
+
void exitAndNotify(ctx, text);
|
|
189
190
|
},
|
|
190
191
|
});
|
|
191
192
|
|
|
@@ -229,19 +230,15 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
229
230
|
);
|
|
230
231
|
}
|
|
231
232
|
|
|
232
|
-
const
|
|
233
|
-
const
|
|
234
|
-
const questionSignal = signal
|
|
235
|
-
? AbortSignal.any([signal, menuController.signal])
|
|
236
|
-
: menuController.signal;
|
|
233
|
+
const menu = lifecycle.capture();
|
|
234
|
+
const questionSignal = signal ? AbortSignal.any([signal, menu.signal]) : menu.signal;
|
|
237
235
|
return answerPlanModeQuestions(
|
|
238
236
|
parsed.questions,
|
|
239
237
|
ctx,
|
|
240
238
|
{
|
|
241
|
-
isCurrent:
|
|
242
|
-
sessionGeneration === menuGeneration &&
|
|
243
|
-
questionWorkflowGeneration === workflowGeneration,
|
|
239
|
+
isCurrent: menu.isCurrent,
|
|
244
240
|
isEnabled: () => state.enabled,
|
|
241
|
+
onBlocked: (active) => setHerdrBlocked(pi, active),
|
|
245
242
|
},
|
|
246
243
|
questionSignal,
|
|
247
244
|
);
|
|
@@ -288,7 +285,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
288
285
|
return;
|
|
289
286
|
}
|
|
290
287
|
enterPlanMode(ctx);
|
|
291
|
-
ctx
|
|
288
|
+
notifyEnabled(ctx);
|
|
292
289
|
return;
|
|
293
290
|
}
|
|
294
291
|
if (command === "show") {
|
|
@@ -309,8 +306,8 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
309
306
|
}
|
|
310
307
|
const exportMatch = /^export(?:\s+([\s\S]+))?$/iu.exec(prompt);
|
|
311
308
|
if (exportMatch) {
|
|
312
|
-
const
|
|
313
|
-
await planExports.export(exportMatch[1], ctx,
|
|
309
|
+
const menu = lifecycle.capture();
|
|
310
|
+
await planExports.export(exportMatch[1], ctx, menu.signal, menu.isCurrent);
|
|
314
311
|
return;
|
|
315
312
|
}
|
|
316
313
|
if (command === "exit" || command === "off") {
|
|
@@ -322,8 +319,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
322
319
|
: hadPlan
|
|
323
320
|
? "Active implementation plan cleared."
|
|
324
321
|
: "Plan mode disabled.";
|
|
325
|
-
await
|
|
326
|
-
ctx.ui.notify(notification, "info");
|
|
322
|
+
await exitAndNotify(ctx, notification);
|
|
327
323
|
return;
|
|
328
324
|
}
|
|
329
325
|
if (prompt) {
|
|
@@ -361,76 +357,48 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
361
357
|
* `ctx` to explain why, would be worse than waiting for the next write. A
|
|
362
358
|
* genuinely broken file is still reported at the next session start.
|
|
363
359
|
*/
|
|
364
|
-
const loadPlanModeSettings = async (
|
|
360
|
+
const loadPlanModeSettings = async (session: LifecycleScope, ctx?: ExtensionContext) => {
|
|
365
361
|
const loaded = await readRuntimeSettings();
|
|
366
|
-
if (
|
|
362
|
+
if (!session.isCurrent()) return;
|
|
367
363
|
if (loaded.kind === "invalid" && !ctx) return;
|
|
368
364
|
settings = loaded.kind === "loaded" ? loaded.settings : {};
|
|
369
365
|
if (!ctx) return;
|
|
370
366
|
if (loaded.kind === "invalid") {
|
|
371
367
|
ctx.ui.notify(`pi-plan-mode settings ignored: ${loaded.reason}`, "warning");
|
|
372
368
|
}
|
|
373
|
-
if (loaded.notice) ctx.ui.notify(loaded.notice, "warning");
|
|
374
369
|
};
|
|
375
370
|
|
|
376
371
|
const stopPlanModeSettingsWatch = () => {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
settingsReloadTimer = undefined;
|
|
380
|
-
}
|
|
381
|
-
settingsWatch?.close();
|
|
382
|
-
settingsWatch = undefined;
|
|
372
|
+
settingsWatcher?.stop();
|
|
373
|
+
settingsWatcher = undefined;
|
|
383
374
|
};
|
|
384
375
|
|
|
385
|
-
/**
|
|
386
|
-
|
|
387
|
-
* through a temp file and an atomic rename, and a watch bound to the old inode
|
|
388
|
-
* would go deaf after the first one.
|
|
389
|
-
*/
|
|
390
|
-
const startPlanModeSettingsWatch = (generation: number) => {
|
|
376
|
+
/** An injected reader is the only source there is, so it is never watched. */
|
|
377
|
+
const startPlanModeSettingsWatch = (session: LifecycleScope) => {
|
|
391
378
|
stopPlanModeSettingsWatch();
|
|
392
379
|
if (dependencies.readSettings) return;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
// rather than miss the edit. The agent directory holds other churn, so
|
|
400
|
-
// a named entry that is not ours is ignored.
|
|
401
|
-
if (changedFile && changedFile.toString() !== watchedFile) return;
|
|
402
|
-
if (settingsReloadTimer) clearTimeout(settingsReloadTimer);
|
|
403
|
-
settingsReloadTimer = setTimeout(() => {
|
|
404
|
-
settingsReloadTimer = undefined;
|
|
405
|
-
void loadPlanModeSettings(generation);
|
|
406
|
-
}, SETTINGS_RELOAD_DEBOUNCE_MS);
|
|
407
|
-
});
|
|
408
|
-
watcher.on("error", stopPlanModeSettingsWatch);
|
|
409
|
-
settingsWatch = watcher;
|
|
410
|
-
} catch {
|
|
411
|
-
// An unwatchable directory only costs the live reload; settings still
|
|
412
|
-
// load at session start.
|
|
413
|
-
stopPlanModeSettingsWatch();
|
|
414
|
-
}
|
|
380
|
+
settingsWatcher = createSettingsWatcher({
|
|
381
|
+
path: dependencies.settingsPath ?? planModeSettingsPath(),
|
|
382
|
+
debounceMs: SETTINGS_RELOAD_DEBOUNCE_MS,
|
|
383
|
+
onChange: () => void loadPlanModeSettings(session),
|
|
384
|
+
});
|
|
385
|
+
settingsWatcher.start();
|
|
415
386
|
};
|
|
416
387
|
|
|
417
388
|
pi.on("session_start", async (event, ctx) => {
|
|
418
|
-
const
|
|
389
|
+
const session = lifecycle.nextSession("Plan-mode session replaced");
|
|
419
390
|
planToolsActivated = false;
|
|
420
391
|
currentHasUI = ctx.hasUI;
|
|
421
392
|
reconcilePlanToolSurface(ctx.hasUI);
|
|
422
393
|
refreshStateBeforeFirstAgentStart = event.reason === "new";
|
|
423
|
-
menuController.abort(new DOMException("Plan-mode session replaced", "AbortError"));
|
|
424
|
-
menuController = new AbortController();
|
|
425
394
|
pendingReadyNonce = undefined;
|
|
426
395
|
latestCommandContext = undefined;
|
|
427
396
|
settings = {};
|
|
428
397
|
sessionPlanPath = resolveSessionPlanPath(ctx);
|
|
429
398
|
restoreState(ctx);
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
startPlanModeSettingsWatch(generation);
|
|
399
|
+
await loadPlanModeSettings(session, ctx);
|
|
400
|
+
if (!session.isCurrent()) return;
|
|
401
|
+
startPlanModeSettingsWatch(session);
|
|
434
402
|
const persistFlagActivation = pi.getFlag("plan") === true && !state.enabled;
|
|
435
403
|
if (persistFlagActivation) {
|
|
436
404
|
state = { ...state, enabled: true, awaitingAction: state.planPath !== undefined };
|
|
@@ -441,9 +409,9 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
441
409
|
});
|
|
442
410
|
|
|
443
411
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
444
|
-
|
|
412
|
+
// No re-arm: nothing may become current again until a session_start.
|
|
413
|
+
lifecycle.endSession("Plan-mode session shut down");
|
|
445
414
|
stopPlanModeSettingsWatch();
|
|
446
|
-
menuController.abort(new DOMException("Plan-mode session shut down", "AbortError"));
|
|
447
415
|
pendingReadyNonce = undefined;
|
|
448
416
|
latestCommandContext = undefined;
|
|
449
417
|
refreshStateBeforeFirstAgentStart = false;
|
|
@@ -476,9 +444,7 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
476
444
|
// A new turn supersedes the previous ready plan: revision feedback
|
|
477
445
|
// re-opens planning until another plan_mode_complete arrives.
|
|
478
446
|
pendingReadyNonce = undefined;
|
|
479
|
-
|
|
480
|
-
persistState();
|
|
481
|
-
updateUi(ctx);
|
|
447
|
+
setState(ctx, { awaitingAction: false });
|
|
482
448
|
}
|
|
483
449
|
if (state.enabled && !planToolsActivated) activatePlanTools(ctx.hasUI);
|
|
484
450
|
else reconcilePlanToolSurface(ctx.hasUI);
|
|
@@ -513,39 +479,55 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
513
479
|
});
|
|
514
480
|
|
|
515
481
|
function enterPlanMode(ctx: ExtensionContext) {
|
|
516
|
-
|
|
482
|
+
lifecycle.nextWorkflow();
|
|
517
483
|
activatePlanTools(ctx.hasUI);
|
|
518
|
-
|
|
519
|
-
persistState();
|
|
520
|
-
updateUi(ctx);
|
|
484
|
+
setState(ctx, { enabled: true, awaitingAction: false });
|
|
521
485
|
}
|
|
522
486
|
|
|
523
487
|
function enterPlanModeWithPrompt(prompt: string, ctx: ExtensionContext) {
|
|
524
488
|
const previousState = state;
|
|
525
489
|
const wasEnabled = state.enabled;
|
|
526
490
|
enterPlanMode(ctx);
|
|
527
|
-
if (!wasEnabled)
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
if (sendPlanModeUserMessage(prompt, ctx)) return;
|
|
531
|
-
state = previousState;
|
|
532
|
-
persistState();
|
|
533
|
-
updateUi(ctx);
|
|
491
|
+
if (!wasEnabled) notifyEnabled(ctx);
|
|
492
|
+
sendOrRevert(prompt, ctx, previousState);
|
|
534
493
|
}
|
|
535
494
|
|
|
536
495
|
async function exitPlanMode(ctx: ExtensionContext, options: { keepPlanFile?: boolean } = {}) {
|
|
537
|
-
|
|
496
|
+
lifecycle.nextWorkflow();
|
|
538
497
|
const planPath = state.planPath;
|
|
539
498
|
pendingReadyNonce = undefined;
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
499
|
+
setState(ctx, { enabled: false, planPath: undefined, awaitingAction: false });
|
|
500
|
+
if (planPath && !options.keepPlanFile) await deletePlanFile(planPath);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Leaves Plan mode and reports it in one step, for menus and /plan alike. */
|
|
504
|
+
function exitAndNotify(
|
|
505
|
+
ctx: ExtensionContext,
|
|
506
|
+
text: string,
|
|
507
|
+
options: { keepPlanFile?: boolean } = {},
|
|
508
|
+
) {
|
|
509
|
+
return exitPlanMode(ctx, options).then(() => ctx.ui.notify(text, "info"));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function notifyEnabled(ctx: ExtensionContext) {
|
|
513
|
+
ctx.ui.notify("Plan mode enabled. I will explore and plan, but not modify files.", "info");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** State moves as one: what is remembered, what is persisted, what is shown. */
|
|
517
|
+
function setState(ctx: ExtensionContext, patch: Partial<PlanModeState>) {
|
|
518
|
+
state = { ...state, ...patch };
|
|
546
519
|
persistState();
|
|
547
520
|
updateUi(ctx);
|
|
548
|
-
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Sends the message a state change exists to produce, and puts the previous
|
|
525
|
+
* state back when the session refuses it: a mode switch the model was never
|
|
526
|
+
* told about is worse than no switch at all.
|
|
527
|
+
*/
|
|
528
|
+
function sendOrRevert(message: string, ctx: ExtensionContext, previousState: PlanModeState) {
|
|
529
|
+
if (sendPlanModeUserMessage(message, ctx)) return;
|
|
530
|
+
setState(ctx, previousState);
|
|
549
531
|
}
|
|
550
532
|
|
|
551
533
|
function sendPlanModeUserMessage(message: string, ctx: ExtensionContext) {
|
|
@@ -573,10 +555,8 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
573
555
|
throw new Error(`Unable to save the plan to ${planPath}: ${detail}`);
|
|
574
556
|
}
|
|
575
557
|
sessionPlanPath = planPath;
|
|
576
|
-
state = { ...state, planPath, awaitingAction: true };
|
|
577
558
|
pendingReadyNonce = ++readyPresentationNonce;
|
|
578
|
-
|
|
579
|
-
updateUi(ctx);
|
|
559
|
+
setState(ctx, { planPath, awaitingAction: true });
|
|
580
560
|
showPlanModePlan(pi, ctx, "Proposed Plan", plan);
|
|
581
561
|
return planPath;
|
|
582
562
|
}
|
|
@@ -616,39 +596,28 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
616
596
|
return;
|
|
617
597
|
}
|
|
618
598
|
|
|
619
|
-
|
|
599
|
+
lifecycle.nextWorkflow();
|
|
620
600
|
const previousState = state;
|
|
621
601
|
pendingReadyNonce = undefined;
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
enabled: false,
|
|
625
|
-
awaitingAction: false,
|
|
626
|
-
planPath,
|
|
627
|
-
};
|
|
628
|
-
persistState();
|
|
629
|
-
updateUi(ctx);
|
|
630
|
-
|
|
631
|
-
if (!sendPlanModeUserMessage(formatImplementationHandoff(planPath), ctx)) {
|
|
632
|
-
state = previousState;
|
|
633
|
-
persistState();
|
|
634
|
-
updateUi(ctx);
|
|
635
|
-
}
|
|
602
|
+
setState(ctx, { enabled: false, awaitingAction: false, planPath });
|
|
603
|
+
sendOrRevert(formatImplementationHandoff(planPath), ctx, previousState);
|
|
636
604
|
}
|
|
637
605
|
|
|
638
606
|
async function showLaunchMenu(ctx: ExtensionContext) {
|
|
639
|
-
const
|
|
640
|
-
if (!
|
|
607
|
+
const menu = lifecycle.capture();
|
|
608
|
+
if (!menu.isCurrent() || menu.signal.aborted) return;
|
|
641
609
|
const ui = await loadInteractiveUi();
|
|
642
|
-
if (!
|
|
610
|
+
if (!menu.isCurrent() || menu.signal.aborted) return;
|
|
643
611
|
await ui.showPlanLaunchMenu(ctx, {
|
|
644
612
|
statusText: "Status: Off.",
|
|
645
|
-
|
|
613
|
+
signal: menu.signal,
|
|
614
|
+
isCurrent: menu.isCurrent,
|
|
646
615
|
start: (signal) => {
|
|
647
|
-
if (signal.aborted || !
|
|
616
|
+
if (signal.aborted || !menu.isCurrent()) return;
|
|
648
617
|
enterPlanMode(ctx);
|
|
649
|
-
ctx
|
|
618
|
+
notifyEnabled(ctx);
|
|
650
619
|
},
|
|
651
|
-
settings: (signal) => showSettings(ctx, signal,
|
|
620
|
+
settings: (signal) => showSettings(ctx, signal, menu.isCurrent),
|
|
652
621
|
});
|
|
653
622
|
}
|
|
654
623
|
|
|
@@ -657,27 +626,25 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
657
626
|
ctx.ui.notify(planStatusText(), "info");
|
|
658
627
|
return;
|
|
659
628
|
}
|
|
660
|
-
const
|
|
661
|
-
if (!
|
|
629
|
+
const menu = lifecycle.capture();
|
|
630
|
+
if (!menu.isCurrent() || menu.signal.aborted) return;
|
|
662
631
|
const ui = await loadInteractiveUi();
|
|
663
|
-
if (!
|
|
632
|
+
if (!menu.isCurrent() || menu.signal.aborted) return;
|
|
664
633
|
await ui.showActiveImplementationMenu(ctx, {
|
|
665
634
|
statusText: planStatusText(),
|
|
666
635
|
...(state.planPath ? { planPathLine: `Plan file: ${state.planPath}` } : {}),
|
|
667
636
|
getExportDestination: () => planExports.getDestination(ctx),
|
|
668
|
-
signal:
|
|
669
|
-
isCurrent:
|
|
637
|
+
signal: menu.signal,
|
|
638
|
+
isCurrent: menu.isCurrent,
|
|
670
639
|
show: () => showStoredPlan(pi, ctx, state),
|
|
671
|
-
exportPlan: (path, signal) => planExports.export(path, ctx, signal,
|
|
672
|
-
settings: (signal) => showSettings(ctx, signal,
|
|
640
|
+
exportPlan: (path, signal) => planExports.export(path, ctx, signal, menu.isCurrent),
|
|
641
|
+
settings: (signal) => showSettings(ctx, signal, menu.isCurrent),
|
|
673
642
|
startNew: () => {
|
|
674
643
|
enterPlanMode(ctx);
|
|
675
|
-
ctx
|
|
644
|
+
notifyEnabled(ctx);
|
|
676
645
|
},
|
|
677
646
|
clear: () => {
|
|
678
|
-
void
|
|
679
|
-
ctx.ui.notify("Active implementation plan cleared.", "info");
|
|
680
|
-
});
|
|
647
|
+
void exitAndNotify(ctx, "Active implementation plan cleared.");
|
|
681
648
|
},
|
|
682
649
|
});
|
|
683
650
|
}
|
|
@@ -704,37 +671,6 @@ export default function planMode(pi: ExtensionAPI, dependencies: PlanModeDepende
|
|
|
704
671
|
return result.kind === "closed" && "reason" in result && result.reason === "close";
|
|
705
672
|
}
|
|
706
673
|
|
|
707
|
-
function captureMenuLifecycle() {
|
|
708
|
-
const sessionGeneration = menuGeneration;
|
|
709
|
-
const planWorkflowGeneration = workflowGeneration;
|
|
710
|
-
const controller = menuController;
|
|
711
|
-
return {
|
|
712
|
-
signal: controller.signal,
|
|
713
|
-
isCurrent: () =>
|
|
714
|
-
sessionGeneration === menuGeneration &&
|
|
715
|
-
planWorkflowGeneration === workflowGeneration &&
|
|
716
|
-
!controller.signal.aborted,
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
/**
|
|
721
|
-
* pi-plan-mode <= 1.2.1 raised the thinking level while planning, and because
|
|
722
|
-
* `pi.setThinkingLevel` writes through to the user's real settings, a session
|
|
723
|
-
* that died before its restore left that change durable. If the newest state
|
|
724
|
-
* entry still carries the capture and the live level still equals what Plan
|
|
725
|
-
* mode applied, put the user's level back — once. Persisting state in the new
|
|
726
|
-
* shape drops the capture, so the next session finds nothing to repair. A
|
|
727
|
-
* user who has already moved the level themselves is left alone.
|
|
728
|
-
*
|
|
729
|
-
* legacy: delete in 1.4.0
|
|
730
|
-
*/
|
|
731
|
-
function repairLegacyThinkingLevel(ctx: ExtensionContext) {
|
|
732
|
-
const legacy = readLegacyThinkingCapture(ctx.sessionManager.getBranch(), STATE_ENTRY_TYPE);
|
|
733
|
-
if (!legacy || pi.getThinkingLevel() !== legacy.applied) return;
|
|
734
|
-
setPlanThinkingLevel(pi, legacy.previous);
|
|
735
|
-
persistState();
|
|
736
|
-
}
|
|
737
|
-
|
|
738
674
|
function resolveSessionPlanPath(ctx: ExtensionContext) {
|
|
739
675
|
try {
|
|
740
676
|
return planFilePathForSession(ctx.sessionManager.getSessionId());
|
package/src/presentation.ts
CHANGED
|
@@ -69,9 +69,9 @@ export function registerPlanModeCardRenderer(pi: ExtensionAPI): void {
|
|
|
69
69
|
* characters do not justify a shared package; a user reading a footer
|
|
70
70
|
* justifies the consistency.
|
|
71
71
|
*/
|
|
72
|
-
|
|
72
|
+
type PlanModePhase = "drafting" | "revising" | "ready" | "implementing";
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
interface PlanModeView {
|
|
75
75
|
phase: PlanModePhase;
|
|
76
76
|
/** The footer line: plain text with a glyph, no colour. */
|
|
77
77
|
footer: string;
|
package/src/prompt.ts
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The plan-craft document: what decision-complete means, why exploration comes
|
|
5
|
+
* before questions, what separates a question worth asking from one the
|
|
6
|
+
* repository already answers, and what a finished plan contains.
|
|
7
|
+
*
|
|
8
|
+
* It used to ship as a skill. A skill buys one thing an injected pointer
|
|
9
|
+
* cannot: a description line in every system prompt, so the model could
|
|
10
|
+
* propose planning unprompted. Across ~220 sessions after it shipped, every
|
|
11
|
+
* read of the file happened after the Plan Mode prompt was already active —
|
|
12
|
+
* never off the description — and the model never suggested `/plan` on its
|
|
13
|
+
* own. So the line was a tax on every session that never planned (~95% of
|
|
14
|
+
* them) and bought nothing. An absolute path, injected only while Plan Mode
|
|
15
|
+
* is active, is the same document at zero cost outside it, and a hard path
|
|
16
|
+
* beats "if it is available".
|
|
17
|
+
*
|
|
18
|
+
* Resolved from this module's own location so it survives every install
|
|
19
|
+
* layout (git, npm, workspace symlink, `npm link`).
|
|
20
|
+
*/
|
|
21
|
+
export const PLAN_CRAFT_DOC = join(import.meta.dirname, "..", "docs", "plan-craft.md");
|
|
22
|
+
|
|
1
23
|
const PLAN_CONTEXT_MARKER = "[PLAN MODE ACTIVE]";
|
|
2
24
|
|
|
3
25
|
/** The built-in question tool. Used whenever nothing better is installed. */
|
|
@@ -71,7 +93,7 @@ You are in Plan Mode, a collaboration mode for producing a decision-complete imp
|
|
|
71
93
|
|
|
72
94
|
## Mode rules
|
|
73
95
|
|
|
74
|
-
-
|
|
96
|
+
- Before planning, read ${PLAN_CRAFT_DOC}: it carries the plan-crafting craft — decision-completeness, exploring before asking, question quality, and what a finished plan contains.
|
|
75
97
|
- Stay in Plan Mode until a developer or extension explicitly exits it.
|
|
76
98
|
- Treat requests to implement as requests to plan the implementation; do not edit files or carry out the plan.
|
|
77
99
|
- 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/question-tool.ts
CHANGED
|
@@ -2,12 +2,12 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
|
|
3
3
|
export const PLAN_MODE_QUESTION_TOOL_NAME = "plan_mode_question";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
type PlanModeQuestionOption = {
|
|
6
6
|
label: string;
|
|
7
7
|
description?: string;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
type PlanModeQuestion = {
|
|
11
11
|
id: string;
|
|
12
12
|
header: string;
|
|
13
13
|
question: string;
|
|
@@ -151,15 +151,32 @@ export function normalizePlanModeQuestionParams(
|
|
|
151
151
|
export async function answerPlanModeQuestions(
|
|
152
152
|
questions: PlanModeQuestion[],
|
|
153
153
|
ctx: ExtensionContext,
|
|
154
|
-
lifecycle: {
|
|
154
|
+
lifecycle: {
|
|
155
|
+
isCurrent(): boolean;
|
|
156
|
+
isEnabled(): boolean;
|
|
157
|
+
/**
|
|
158
|
+
* Called with `true` while a selector is open and `false` once it closes,
|
|
159
|
+
* however it closes. plan-mode.ts forwards this to Herdr so a supervising
|
|
160
|
+
* agent in another pane sees "blocked on a human", not "working".
|
|
161
|
+
*/
|
|
162
|
+
onBlocked?(active: boolean): void;
|
|
163
|
+
},
|
|
155
164
|
signal?: AbortSignal,
|
|
156
165
|
) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
166
|
+
lifecycle.onBlocked?.(true);
|
|
167
|
+
let answers: PlanModeQuestionAnswer[] | undefined;
|
|
168
|
+
try {
|
|
169
|
+
answers = await askPlanModeQuestions(
|
|
170
|
+
questions,
|
|
171
|
+
ctx,
|
|
172
|
+
() => lifecycle.isCurrent() && lifecycle.isEnabled() && !signal?.aborted,
|
|
173
|
+
signal,
|
|
174
|
+
);
|
|
175
|
+
} finally {
|
|
176
|
+
// In `finally` so a throw or a session replacement never leaves Herdr
|
|
177
|
+
// believing this pane is still waiting on someone.
|
|
178
|
+
lifecycle.onBlocked?.(false);
|
|
179
|
+
}
|
|
163
180
|
if (!lifecycle.isCurrent()) {
|
|
164
181
|
return planModeQuestionCancelled(
|
|
165
182
|
questions,
|
|
@@ -184,7 +201,7 @@ export async function answerPlanModeQuestions(
|
|
|
184
201
|
return planModeQuestionAnswered(questions, answers);
|
|
185
202
|
}
|
|
186
203
|
|
|
187
|
-
|
|
204
|
+
async function askPlanModeQuestions(
|
|
188
205
|
questions: PlanModeQuestion[],
|
|
189
206
|
ctx: ExtensionContext,
|
|
190
207
|
shouldContinue: () => boolean = () => true,
|
|
@@ -243,7 +260,7 @@ function formatPlanModeQuestionChoice(option: PlanModeQuestionOption, index: num
|
|
|
243
260
|
return `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`;
|
|
244
261
|
}
|
|
245
262
|
|
|
246
|
-
|
|
263
|
+
function planModeQuestionAnswered(
|
|
247
264
|
questions: PlanModeQuestion[],
|
|
248
265
|
answers: PlanModeQuestionAnswer[],
|
|
249
266
|
) {
|