@dotdotgod/pi 0.1.21

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.
@@ -0,0 +1,830 @@
1
+ /**
2
+ * Customized Plan Mode Extension
3
+ *
4
+ * Safe exploration mode for code analysis and docs/plan plan-file management.
5
+ */
6
+
7
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
8
+ import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { Key } from "@earendil-works/pi-tui";
11
+ import { spawnSync } from "node:child_process";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { isAbsolute, join, relative, resolve } from "node:path";
14
+ import { recordContextMetric } from "../context-metrics/utils.js";
15
+ import { buildLoadPrompt, collectSnapshot } from "../load-project/utils.js";
16
+ import {
17
+ buildPlanCompactionInstructions,
18
+ buildPlanModeContextPrompt,
19
+ detectPlanExecutionIntent,
20
+ extractTodoItems,
21
+ formatCompactImpactSummary,
22
+ formatReferenceExpansionSummary,
23
+ hasExplicitBracketReferences,
24
+ hasLikelyFuzzyReferences,
25
+ resolveMentionedPlanPath,
26
+ resolvePlanModeTools,
27
+ getCurrentPlanReadmePath,
28
+ getPlanCompactionReason,
29
+ selectPlanImpactPaths,
30
+ shouldAllowPlanModeBashCommand,
31
+ shouldPromptForPlanChoice,
32
+ shouldShapePlanningContextOnAgentStart,
33
+ markCompletedSteps,
34
+ type PlanCompactionFocus,
35
+ type TodoItem,
36
+ } from "./utils.js";
37
+
38
+ const PLAN_DIRECTORY = "docs/plan";
39
+ const ARCHIVE_DIRECTORY = "docs/archive";
40
+
41
+ const NORMAL_MODE_TOOLS = [
42
+ "read",
43
+ "bash",
44
+ "edit",
45
+ "write",
46
+ "grep",
47
+ "find",
48
+ "ls",
49
+ "web_search",
50
+ "code_search",
51
+ "fetch_content",
52
+ "get_search_content",
53
+ "subagent",
54
+ "ctx_batch_execute",
55
+ "ctx_execute",
56
+ "ctx_execute_file",
57
+ "ctx_search",
58
+ "ctx_index",
59
+ "ctx_fetch_and_index",
60
+ ];
61
+
62
+ function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
63
+ return m.role === "assistant" && Array.isArray(m.content);
64
+ }
65
+
66
+ function getMessageText(message: AgentMessage): string {
67
+ if (!("content" in message)) return "";
68
+ const content = message.content;
69
+ if (typeof content === "string") return content;
70
+ if (!Array.isArray(content)) return "";
71
+ return content
72
+ .filter((block): block is TextContent => block.type === "text")
73
+ .map((block) => block.text)
74
+ .join("\n");
75
+ }
76
+
77
+ function truncateText(text: string, limit = 500): string {
78
+ const normalized = text.replace(/\s+/g, " ").trim();
79
+ return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
80
+ }
81
+
82
+ function normalizeToolPath(path: string): string {
83
+ return path.replace(/^@/, "");
84
+ }
85
+
86
+ function isKebabCaseDirectory(name: string): boolean {
87
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
88
+ }
89
+
90
+ function isUpperSnakeMarkdownFile(name: string): boolean {
91
+ return /^[A-Z0-9]+(?:_[A-Z0-9]+)*\.md$/.test(name);
92
+ }
93
+
94
+ function isMarkdownPathInside(cwd: string, path: string, directory: string): boolean {
95
+ const targetPath = resolve(cwd, normalizeToolPath(path));
96
+ const basePath = resolve(cwd, directory);
97
+ const relativePath = relative(basePath, targetPath);
98
+ const isInsideDirectory = relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
99
+ if (!isInsideDirectory) return false;
100
+
101
+ const segments = relativePath.split(/[\\/]+/);
102
+ const fileName = segments[segments.length - 1];
103
+ if (!fileName || !isUpperSnakeMarkdownFile(fileName)) return false;
104
+
105
+ return segments.slice(0, -1).every(isKebabCaseDirectory);
106
+ }
107
+
108
+ function isManagedPlanMarkdownPath(cwd: string, path: string): boolean {
109
+ return isMarkdownPathInside(cwd, path, PLAN_DIRECTORY) || isMarkdownPathInside(cwd, path, ARCHIVE_DIRECTORY);
110
+ }
111
+
112
+ function isActivePlanMarkdownPath(cwd: string, path: string): boolean {
113
+ return isMarkdownPathInside(cwd, path, PLAN_DIRECTORY);
114
+ }
115
+
116
+ function planPathExists(cwd: string, path: string): boolean {
117
+ return existsSync(resolve(cwd, path));
118
+ }
119
+
120
+ interface PlanCliCommandResult {
121
+ ok: boolean;
122
+ label?: string;
123
+ data?: unknown;
124
+ stdout?: string;
125
+ error?: string;
126
+ }
127
+
128
+ function runDotdotgodCli(cwd: string, args: string[]): PlanCliCommandResult {
129
+ const localCli = join(cwd, "packages/cli/bin/dotdotgod.mjs");
130
+ const candidates = existsSync(localCli)
131
+ ? [
132
+ { command: process.execPath, args: [localCli, ...args], label: "local workspace CLI" },
133
+ { command: "dotdotgod", args, label: "dotdotgod" },
134
+ ]
135
+ : [{ command: "dotdotgod", args, label: "dotdotgod" }];
136
+
137
+ const errors: string[] = [];
138
+ for (const candidate of candidates) {
139
+ const result = spawnSync(candidate.command, candidate.args, {
140
+ cwd,
141
+ encoding: "utf8",
142
+ stdio: ["ignore", "pipe", "pipe"],
143
+ timeout: 10_000,
144
+ maxBuffer: 1024 * 1024,
145
+ });
146
+ const stdout = result.stdout?.trim() ?? "";
147
+ if (stdout) {
148
+ try {
149
+ return { ok: true, label: candidate.label, data: JSON.parse(stdout), stdout };
150
+ } catch {
151
+ if (result.status === 0) return { ok: true, label: candidate.label, stdout };
152
+ }
153
+ }
154
+ errors.push(`${candidate.label}: ${result.error?.message ?? result.stderr?.trim() ?? `exit ${String(result.status)}`}`);
155
+ }
156
+
157
+ return { ok: false, error: errors.join("; ") };
158
+ }
159
+
160
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
161
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
162
+ }
163
+
164
+ function formatPlanCliContextSummary(validate: PlanCliCommandResult, snapshot: PlanCliCommandResult, impacts: Array<{ path: string; result: PlanCliCommandResult }>): string {
165
+ const lines = ["dotdotgod CLI planning context:"];
166
+ if (!validate.ok) return "";
167
+ const validateData = asRecord(validate.data);
168
+ const errors = Array.isArray(validateData?.errors) ? validateData.errors.length : 0;
169
+ lines.push(`- Validate: source=${validate.label ?? "dotdotgod"}; ok=${String(validateData?.ok ?? true)}; errors=${errors}`);
170
+
171
+ const snapshotData = asRecord(snapshot.data);
172
+ const cache = asRecord(snapshotData?.cache);
173
+ const metadata = asRecord(snapshotData?.metadata);
174
+ const graph = asRecord(snapshotData?.graph) ?? asRecord(cache?.graph);
175
+ if (snapshot.ok && snapshotData) {
176
+ lines.push(`- Index: status=${String(cache?.status ?? "unknown")}; schema=${String(cache?.schemaVersion ?? metadata?.schemaVersion ?? "unknown")}; indexedFiles=${String(cache?.indexedFiles ?? "unknown")}; graph=${String(graph?.nodes ?? "unknown")} nodes/${String(graph?.edges ?? "unknown")} edges; refreshed=${String(metadata?.cacheRefreshed ?? false)}; reason=${String(metadata?.refreshReason ?? "unknown")}`);
177
+ }
178
+
179
+ for (const impact of impacts) {
180
+ lines.push(impact.result.ok ? formatCompactImpactSummary(impact.path, impact.result.data) : `- Impact: skipped or unavailable for ${impact.path}.`);
181
+ }
182
+ return lines.join("\n");
183
+ }
184
+
185
+ function getToolPath(input: unknown): string | undefined {
186
+ if (!input || typeof input !== "object") return undefined;
187
+ const path = (input as { path?: unknown }).path;
188
+ return typeof path === "string" ? path : undefined;
189
+ }
190
+
191
+ export default function planModeExtension(pi: ExtensionAPI): void {
192
+ let planModeEnabled = false;
193
+ let executionMode = false;
194
+ let todoItems: TodoItem[] = [];
195
+ let activePlanTouched = false;
196
+ let pendingPlanChoicePath: string | undefined;
197
+ let planCompactionInFlight = false;
198
+ let lastPlanCompactionEntryCount: number | undefined;
199
+ let lastPlanCompactionReason: string | undefined;
200
+ let planningLoadInFlight = false;
201
+ let lastPlanningLoadEntryCount: number | undefined;
202
+ let pendingPlanningLoadAfterCompaction = false;
203
+ let pendingPlanningLoadPrompt: string | undefined;
204
+ let pendingPlanningLoadReason: string | undefined;
205
+ let planningContextShapePending = false;
206
+ let planModeFullPromptInjected = false;
207
+ let planningCliContextSummary: string | undefined;
208
+ let planningCliContextChecked = false;
209
+ let lastPlanningRequest: string | undefined;
210
+ let currentPlanPath: string | undefined;
211
+ let touchedPlanArchivePaths: string[] = [];
212
+ let activePlanModeTools: string[] = [];
213
+
214
+ pi.registerFlag("plan", {
215
+ description: "Start in plan mode (safe exploration plus docs/plan updates)",
216
+ type: "boolean",
217
+ default: false,
218
+ });
219
+ pi.registerFlag("plan-extra-tools", {
220
+ description: "Comma-separated extra tool names to allow in Plan Mode when those tools are installed",
221
+ type: "string",
222
+ default: "",
223
+ });
224
+
225
+ function updateStatus(ctx: ExtensionContext): void {
226
+ if (executionMode && todoItems.length > 0) {
227
+ const completed = todoItems.filter((t) => t.completed).length;
228
+ ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
229
+ } else if (planModeEnabled) {
230
+ ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
231
+ } else {
232
+ ctx.ui.setStatus("plan-mode", undefined);
233
+ }
234
+ }
235
+
236
+ function getSessionEntryCount(ctx: ExtensionContext): number {
237
+ return ctx.sessionManager.getEntries().length;
238
+ }
239
+
240
+ function buildCurrentWorkFocus(): PlanCompactionFocus {
241
+ const completed = todoItems.filter((item) => item.completed).length;
242
+ const activePlanPaths = [
243
+ ...(currentPlanPath ? [currentPlanPath] : []),
244
+ ...touchedPlanArchivePaths.filter((path) => path.startsWith("docs/plan/")),
245
+ ];
246
+ const focus: PlanCompactionFocus = {
247
+ activePlanPaths,
248
+ touchedMemoryPaths: touchedPlanArchivePaths,
249
+ pendingLoadAfterCompaction: pendingPlanningLoadAfterCompaction || Boolean(pendingPlanningLoadPrompt),
250
+ constraints: [
251
+ "Use pnpm for workspace commands",
252
+ "Plan Mode blocks source/config mutation until execution mode",
253
+ "Keep docs/archive/README.md included as the archive map",
254
+ "Exclude docs/archive/** bodies by default unless targeted",
255
+ ],
256
+ };
257
+ if (lastPlanningRequest) focus.task = lastPlanningRequest;
258
+ if (todoItems.length > 0) focus.todoSummary = `${completed}/${todoItems.length} completed`;
259
+ return focus;
260
+ }
261
+
262
+ function requestPlanningCompaction(ctx: ExtensionContext, reason: string): void {
263
+ if (planCompactionInFlight) return;
264
+
265
+ const entryCount = getSessionEntryCount(ctx);
266
+ if (lastPlanCompactionEntryCount !== undefined && entryCount - lastPlanCompactionEntryCount < 5) {
267
+ return;
268
+ }
269
+
270
+ const focus = buildCurrentWorkFocus();
271
+ planCompactionInFlight = true;
272
+ lastPlanCompactionReason = reason;
273
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:compaction-request", { reason, entryCount, focus });
274
+ ctx.ui.notify("Planning context is large; compacting before continuing.", "info");
275
+ ctx.compact({
276
+ customInstructions: buildPlanCompactionInstructions(reason, focus),
277
+ onComplete: () => {
278
+ planCompactionInFlight = false;
279
+ lastPlanCompactionEntryCount = getSessionEntryCount(ctx);
280
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:compaction-complete", { reason, entryCount: lastPlanCompactionEntryCount });
281
+ ctx.ui.notify("Planning compaction completed.", "info");
282
+ refreshPlanCliContextIfAvailable(ctx);
283
+ if (pendingPlanningLoadAfterCompaction) {
284
+ pendingPlanningLoadAfterCompaction = false;
285
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:load-after-compaction", { reason });
286
+ requestPlanningLoadIfNeeded(ctx);
287
+ }
288
+ persistState();
289
+ },
290
+ onError: (error) => {
291
+ planCompactionInFlight = false;
292
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:compaction-error", { reason, error: error.message });
293
+ ctx.ui.notify(`Planning compaction failed: ${error.message}`, "warning");
294
+ persistState();
295
+ },
296
+ });
297
+ }
298
+
299
+ function hasRecentProjectMemoryLoad(ctx: ExtensionContext, currentEntryCount: number): boolean {
300
+ const entries = ctx.sessionManager.getEntries();
301
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
302
+ const entry = entries[i] as { type?: string; customType?: string; data?: { entryCount?: number } };
303
+ if (entry.type === "custom" && entry.customType === "project-memory-load") {
304
+ const loadEntryCount = entry.data?.entryCount ?? i;
305
+ return currentEntryCount - loadEntryCount < 25;
306
+ }
307
+ }
308
+ return false;
309
+ }
310
+
311
+ function requestPlanningLoadIfNeeded(ctx: ExtensionContext): void {
312
+ if (!planModeEnabled || executionMode || planningLoadInFlight || planCompactionInFlight || pendingPlanningLoadPrompt) return;
313
+
314
+ const entryCount = getSessionEntryCount(ctx);
315
+ if (lastPlanningLoadEntryCount !== undefined && entryCount - lastPlanningLoadEntryCount < 10) {
316
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:load-skipped", { reason: "debounced", entryCount });
317
+ return;
318
+ }
319
+ if (hasRecentProjectMemoryLoad(ctx, entryCount)) {
320
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:load-skipped", { reason: "recent-project-memory-load", entryCount });
321
+ return;
322
+ }
323
+
324
+ lastPlanningLoadEntryCount = entryCount;
325
+ pendingPlanningLoadPrompt = buildLoadPrompt(ctx.cwd, "", collectSnapshot(ctx.cwd));
326
+ pendingPlanningLoadReason = "plan-mode-context-shaping";
327
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:load-queued", { entryCount, reason: pendingPlanningLoadReason });
328
+ pi.appendEntry("project-memory-load", { reason: pendingPlanningLoadReason, entryCount, queued: true });
329
+ if (ctx.hasUI) {
330
+ ctx.ui.notify("Project memory looks missing or stale; queued curated project memory load for planning.", "info");
331
+ }
332
+ persistState();
333
+ }
334
+
335
+ function flushPendingPlanningLoad(ctx: ExtensionContext): boolean {
336
+ if (!pendingPlanningLoadPrompt || planningLoadInFlight || executionMode) return false;
337
+ planningLoadInFlight = true;
338
+ const prompt = pendingPlanningLoadPrompt;
339
+ const reason = pendingPlanningLoadReason ?? "plan-mode-context-shaping";
340
+ try {
341
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
342
+ pendingPlanningLoadPrompt = undefined;
343
+ pendingPlanningLoadReason = undefined;
344
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:load-flushed", { reason, entryCount: getSessionEntryCount(ctx) });
345
+ return true;
346
+ } catch (error) {
347
+ const message = error instanceof Error ? error.message : String(error);
348
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:load-flush-error", { reason, error: message });
349
+ if (ctx.hasUI) ctx.ui.notify(`Planning project-memory load is still queued: ${message}`, "warning");
350
+ return false;
351
+ } finally {
352
+ planningLoadInFlight = false;
353
+ persistState();
354
+ }
355
+ }
356
+
357
+ function shouldLoadForPlanning(ctx: ExtensionContext): boolean {
358
+ if (!planModeEnabled || executionMode || planningLoadInFlight || pendingPlanningLoadPrompt) return false;
359
+ const entryCount = getSessionEntryCount(ctx);
360
+ if (lastPlanningLoadEntryCount !== undefined && entryCount - lastPlanningLoadEntryCount < 10) return false;
361
+ return !hasRecentProjectMemoryLoad(ctx, entryCount);
362
+ }
363
+
364
+ function refreshPlanCliContextIfAvailable(ctx: ExtensionContext): void {
365
+ if (planningCliContextChecked || !planModeEnabled || executionMode) return;
366
+ planningCliContextChecked = true;
367
+ const validate = runDotdotgodCli(ctx.cwd, ["validate", ctx.cwd, "--include-local-memory", "--check-index", "--json"]);
368
+ if (!validate.ok) {
369
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:cli-context-unavailable", { error: validate.error });
370
+ persistState();
371
+ return;
372
+ }
373
+
374
+ const snapshot = runDotdotgodCli(ctx.cwd, ["load-snapshot", ctx.cwd, "--json"]);
375
+ let currentPlanContent: string | undefined;
376
+ if (currentPlanPath) {
377
+ try {
378
+ currentPlanContent = readFileSync(resolve(ctx.cwd, currentPlanPath), "utf8");
379
+ } catch {
380
+ currentPlanContent = undefined;
381
+ }
382
+ }
383
+ const impactPaths = selectPlanImpactPaths(ctx.cwd, lastPlanningRequest, currentPlanPath, currentPlanContent, touchedPlanArchivePaths, planPathExists);
384
+ const impacts = impactPaths.map((path) => ({ path, result: runDotdotgodCli(ctx.cwd, ["graph", "impact", ctx.cwd, "--changed", path, "--compact", "--json"]) }));
385
+ const contextParts = [formatPlanCliContextSummary(validate, snapshot, impacts)];
386
+ let referenceExpansionSummary = "";
387
+ const hasExplicitReferences = hasExplicitBracketReferences(lastPlanningRequest);
388
+ const hasFuzzyReferences = hasLikelyFuzzyReferences((lastPlanningRequest ?? "").replace(/\[\[[^\]\n]+\]\]/g, " "));
389
+ const shouldExpandReferences = hasExplicitReferences || hasFuzzyReferences;
390
+ if (shouldExpandReferences) {
391
+ const expansionArgs = ["expand", ctx.cwd, lastPlanningRequest ?? "", "--json", "--with-impact"];
392
+ if (hasFuzzyReferences) expansionArgs.push("--fuzzy");
393
+ const expansion = runDotdotgodCli(ctx.cwd, expansionArgs);
394
+ if (expansion.ok) {
395
+ referenceExpansionSummary = formatReferenceExpansionSummary(expansion.data);
396
+ if (referenceExpansionSummary) contextParts.push(referenceExpansionSummary);
397
+ } else {
398
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:reference-expansion-unavailable", { error: expansion.error });
399
+ }
400
+ }
401
+ planningCliContextSummary = contextParts.filter(Boolean).join("\n\n");
402
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:cli-context", { hasSummary: Boolean(planningCliContextSummary), impactPaths, referenceExpansion: Boolean(referenceExpansionSummary) });
403
+ persistState();
404
+ }
405
+
406
+ function readPlanTodos(cwd: string, planPath: string): TodoItem[] {
407
+ try {
408
+ return extractTodoItems(readFileSync(resolve(cwd, planPath), "utf8"));
409
+ } catch {
410
+ return [];
411
+ }
412
+ }
413
+
414
+ function startExplicitPlanExecutionIfRequested(ctx: ExtensionContext): boolean {
415
+ const request = lastPlanningRequest ?? "";
416
+ if (!planModeEnabled || executionMode || !detectPlanExecutionIntent(request)) return false;
417
+
418
+ const planPath = resolveMentionedPlanPath(ctx.cwd, request, currentPlanPath, touchedPlanArchivePaths, planPathExists);
419
+ if (!planPath) return false;
420
+
421
+ currentPlanPath = planPath;
422
+ todoItems = readPlanTodos(ctx.cwd, planPath);
423
+ planModeEnabled = false;
424
+ executionMode = todoItems.length > 0;
425
+ activePlanTouched = false;
426
+ pendingPlanChoicePath = undefined;
427
+ planningContextShapePending = false;
428
+ pendingPlanningLoadAfterCompaction = false;
429
+ pendingPlanningLoadPrompt = undefined;
430
+ pendingPlanningLoadReason = undefined;
431
+ activePlanModeTools = [];
432
+ pi.setActiveTools(NORMAL_MODE_TOOLS);
433
+ updateStatus(ctx);
434
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:execution-start", { todoCount: todoItems.length, planPath, explicit: true });
435
+ persistState();
436
+ return true;
437
+ }
438
+
439
+ function shapePlanningContextIfNeeded(ctx: ExtensionContext): void {
440
+ if (!planModeEnabled || executionMode) return;
441
+ const reason = getPlanCompactionReason(ctx.getContextUsage());
442
+ const loadNeeded = shouldLoadForPlanning(ctx);
443
+ if (reason) {
444
+ pendingPlanningLoadAfterCompaction = loadNeeded;
445
+ if (loadNeeded) {
446
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:load-deferred-until-after-compaction", { reason });
447
+ }
448
+ requestPlanningCompaction(ctx, reason);
449
+ persistState();
450
+ return;
451
+ }
452
+ refreshPlanCliContextIfAvailable(ctx);
453
+ requestPlanningLoadIfNeeded(ctx);
454
+ }
455
+
456
+ function togglePlanMode(ctx: ExtensionContext): void {
457
+ planModeEnabled = !planModeEnabled;
458
+ executionMode = false;
459
+ todoItems = [];
460
+ activePlanTouched = false;
461
+ pendingPlanChoicePath = undefined;
462
+ if (planModeEnabled) currentPlanPath = undefined;
463
+ planModeFullPromptInjected = false;
464
+ planningCliContextSummary = undefined;
465
+ planningCliContextChecked = false;
466
+
467
+ if (planModeEnabled) {
468
+ planningContextShapePending = true;
469
+ activePlanModeTools = getPlanModeTools();
470
+ pi.setActiveTools(activePlanModeTools);
471
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:enabled", { entryCount: getSessionEntryCount(ctx), tools: activePlanModeTools });
472
+ ctx.ui.notify(`Plan mode enabled. Tools: ${activePlanModeTools.join(", ")}`);
473
+ } else {
474
+ planningContextShapePending = false;
475
+ activePlanModeTools = [];
476
+ pi.setActiveTools(NORMAL_MODE_TOOLS);
477
+ ctx.ui.notify("Plan mode disabled. Full access restored.");
478
+ }
479
+ updateStatus(ctx);
480
+ }
481
+
482
+ function getPlanModeTools(): string[] {
483
+ const availableTools = pi.getAllTools().map((tool) => tool.name);
484
+ return resolvePlanModeTools(pi.getFlag("plan-extra-tools"), availableTools);
485
+ }
486
+
487
+ function persistState(): void {
488
+ pi.appendEntry("plan-mode", {
489
+ enabled: planModeEnabled,
490
+ todos: todoItems,
491
+ executing: executionMode,
492
+ activePlanTouched,
493
+ pendingPlanChoicePath,
494
+ lastPlanCompactionEntryCount,
495
+ lastPlanCompactionReason,
496
+ lastPlanningLoadEntryCount,
497
+ pendingPlanningLoadAfterCompaction,
498
+ pendingPlanningLoadPrompt,
499
+ pendingPlanningLoadReason,
500
+ planningContextShapePending,
501
+ planModeFullPromptInjected,
502
+ planningCliContextSummary,
503
+ planningCliContextChecked,
504
+ lastPlanningRequest,
505
+ currentPlanPath,
506
+ touchedPlanArchivePaths,
507
+ });
508
+ }
509
+
510
+ pi.registerCommand("plan", {
511
+ description: "Toggle plan mode (safe exploration plus docs/plan updates)",
512
+ handler: async (_args, ctx) => togglePlanMode(ctx),
513
+ });
514
+
515
+ pi.registerCommand("todos", {
516
+ description: "Show current plan progress",
517
+ handler: async (_args, ctx) => {
518
+ if (todoItems.length === 0) {
519
+ ctx.ui.notify("No active plan. Create one with /plan first.", "info");
520
+ return;
521
+ }
522
+ const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
523
+ ctx.ui.notify(`Plan Progress:\n${list}`, "info");
524
+ },
525
+ });
526
+
527
+ pi.registerShortcut(Key.ctrlAlt("p"), {
528
+ description: "Toggle plan mode",
529
+ handler: async (ctx) => togglePlanMode(ctx),
530
+ });
531
+
532
+ pi.on("tool_call", async (event, ctx) => {
533
+ if (!planModeEnabled) return;
534
+
535
+ if (event.toolName === "bash") {
536
+ const command = event.input.command as string;
537
+ const decision = await shouldAllowPlanModeBashCommand(command, {
538
+ hasUI: ctx.hasUI,
539
+ confirm: (title, message) => ctx.ui.confirm(title, message),
540
+ });
541
+ if (!decision.allow) {
542
+ return {
543
+ block: true,
544
+ reason: decision.reason ?? "Plan mode: command blocked.",
545
+ };
546
+ }
547
+ return;
548
+ }
549
+
550
+ if (event.toolName === "write" || event.toolName === "edit") {
551
+ const path = getToolPath(event.input);
552
+ if (!path || !isManagedPlanMarkdownPath(ctx.cwd, path)) {
553
+ return {
554
+ block: true,
555
+ reason: `Plan mode: ${event.toolName} is only allowed for markdown plan files under ${PLAN_DIRECTORY}/ or ${ARCHIVE_DIRECTORY}/. Directories must be kebab-case and markdown file names must be UPPER_SNAKE_CASE.md. Use execution mode for source changes.`,
556
+ };
557
+ }
558
+ const normalizedPath = normalizeToolPath(path).replace(/\\/g, "/");
559
+ if (!touchedPlanArchivePaths.includes(normalizedPath)) {
560
+ touchedPlanArchivePaths = [...touchedPlanArchivePaths, normalizedPath].slice(-12);
561
+ }
562
+ if (isActivePlanMarkdownPath(ctx.cwd, path)) {
563
+ activePlanTouched = true;
564
+ currentPlanPath = getCurrentPlanReadmePath(path) ?? currentPlanPath;
565
+ pendingPlanChoicePath = currentPlanPath;
566
+ }
567
+ }
568
+ });
569
+
570
+ pi.on("context", async (event) => {
571
+ if (planModeEnabled) return;
572
+
573
+ return {
574
+ messages: event.messages.filter((m) => {
575
+ const msg = m as AgentMessage & { customType?: string };
576
+ if (msg.customType === "plan-mode-context") return false;
577
+ if (msg.role !== "user") return true;
578
+
579
+ const content = msg.content;
580
+ if (typeof content === "string") {
581
+ return !content.includes("[PLAN MODE ACTIVE]");
582
+ }
583
+ if (Array.isArray(content)) {
584
+ return !content.some(
585
+ (c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
586
+ );
587
+ }
588
+ return true;
589
+ }),
590
+ };
591
+ });
592
+
593
+ function updateLatestPlanningRequest(ctx: ExtensionContext): void {
594
+ const latestUserEntry = [...ctx.sessionManager.getEntries()].reverse().find((entry) => {
595
+ const candidate = entry as { type?: string; message?: AgentMessage };
596
+ return candidate.type === "message" && candidate.message?.role === "user";
597
+ }) as { message?: AgentMessage } | undefined;
598
+ const latestText = latestUserEntry?.message ? truncateText(getMessageText(latestUserEntry.message)) : "";
599
+ if (latestText && !latestText.includes("[PLAN MODE ACTIVE]") && !latestText.startsWith("Load the dotdotgod project memory.")) {
600
+ lastPlanningRequest = latestText;
601
+ }
602
+ }
603
+
604
+ pi.on("before_agent_start", async (_event, ctx) => {
605
+ if (planModeEnabled && !executionMode) {
606
+ updateLatestPlanningRequest(ctx);
607
+ startExplicitPlanExecutionIfRequested(ctx);
608
+ }
609
+
610
+ if (shouldShapePlanningContextOnAgentStart({ planModeEnabled, executionMode, planningContextShapePending })) {
611
+ planningContextShapePending = false;
612
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:initial-context-shape", { entryCount: getSessionEntryCount(ctx) });
613
+ shapePlanningContextIfNeeded(ctx);
614
+ persistState();
615
+ }
616
+
617
+ if (planModeEnabled) {
618
+ if (activePlanModeTools.length === 0) activePlanModeTools = getPlanModeTools();
619
+ const baseContent = buildPlanModeContextPrompt(planModeFullPromptInjected, activePlanModeTools);
620
+ const content = planningCliContextSummary ? `${baseContent}\n\n${planningCliContextSummary}` : baseContent;
621
+ planModeFullPromptInjected = true;
622
+ persistState();
623
+ return {
624
+ message: {
625
+ customType: "plan-mode-context",
626
+ content,
627
+ display: false,
628
+ },
629
+ };
630
+ }
631
+
632
+ if (executionMode && todoItems.length > 0) {
633
+ const remaining = todoItems.filter((t) => !t.completed);
634
+ const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
635
+ return {
636
+ message: {
637
+ customType: "plan-execution-context",
638
+ content: `[EXECUTING PLAN - Full tool access enabled]
639
+
640
+ Active plan: ${currentPlanPath ?? "unknown"}
641
+
642
+ Remaining plan steps:
643
+ ${todoList}
644
+
645
+ Execute each step in order.
646
+ After completing any step, include its [DONE:n] tag in the same assistant response.
647
+ Final responses after implementation or verification MUST include [DONE:n] for every step completed in that turn.
648
+ Example: after completing step 1, include [DONE:1]. If steps 1 and 2 are both complete, include [DONE:1] [DONE:2].
649
+ When implementation and verification are complete, move the completed task directory from docs/plan/<task-slug>/ to docs/archive/plan/<task-slug>/ as the final housekeeping step and include the archive step's [DONE:n] tag.
650
+
651
+ If an out-of-scope change is required, stop and ask the user for confirmation.`,
652
+ display: false,
653
+ },
654
+ };
655
+ }
656
+ });
657
+
658
+ pi.on("turn_end", async (event, ctx) => {
659
+ if (planModeEnabled && !executionMode) {
660
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:turn-end", { entryCount: getSessionEntryCount(ctx) });
661
+ }
662
+
663
+ if (!executionMode || todoItems.length === 0) return;
664
+ if (!isAssistantMessage(event.message)) return;
665
+
666
+ const text = getMessageText(event.message);
667
+ if (markCompletedSteps(text, todoItems) > 0) {
668
+ updateStatus(ctx);
669
+ }
670
+ persistState();
671
+ });
672
+
673
+ pi.on("agent_end", async (event, ctx) => {
674
+ if (executionMode && todoItems.length > 0) {
675
+ if (todoItems.every((t) => t.completed)) {
676
+ executionMode = false;
677
+ todoItems = [];
678
+ pi.setActiveTools(NORMAL_MODE_TOOLS);
679
+ updateStatus(ctx);
680
+ persistState();
681
+ }
682
+ return;
683
+ }
684
+
685
+ if (!planModeEnabled) return;
686
+ const shouldShowChoice = shouldPromptForPlanChoice({ planModeEnabled, executionMode, hasUI: ctx.hasUI, pendingPlanChoicePath, activePlanTouched });
687
+ if (!shouldShowChoice) {
688
+ if (!pendingPlanChoicePath && !activePlanTouched && flushPendingPlanningLoad(ctx)) return;
689
+ return;
690
+ }
691
+ activePlanTouched = false;
692
+
693
+ const inferredPlanPath = pendingPlanChoicePath ?? currentPlanPath ?? getCurrentPlanReadmePath(touchedPlanArchivePaths.find((path) => path.startsWith("docs/plan/")) ?? "");
694
+ const savedTodos = inferredPlanPath ? readPlanTodos(ctx.cwd, inferredPlanPath) : [];
695
+ if (savedTodos.length > 0) {
696
+ todoItems = savedTodos;
697
+ } else {
698
+ const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
699
+ if (lastAssistant) {
700
+ const extracted = extractTodoItems(getMessageText(lastAssistant));
701
+ if (extracted.length > 0) {
702
+ todoItems = extracted;
703
+ }
704
+ }
705
+ }
706
+
707
+ const actionChoices = [
708
+ todoItems.length > 0 ? "Execute the plan (track progress)" : "Execute the plan",
709
+ "Stay in plan mode",
710
+ "Refine the plan",
711
+ ];
712
+ const choice = await ctx.ui.select("Plan mode - choose next action", actionChoices);
713
+ pendingPlanChoicePath = undefined;
714
+
715
+ if (choice?.startsWith("Execute the plan")) {
716
+ planModeEnabled = false;
717
+ planningContextShapePending = false;
718
+ executionMode = todoItems.length > 0;
719
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:execution-start", { todoCount: todoItems.length, planPath: inferredPlanPath });
720
+ pi.setActiveTools(NORMAL_MODE_TOOLS);
721
+ updateStatus(ctx);
722
+
723
+ const firstTodo = todoItems[0];
724
+ const execMessage = firstTodo
725
+ ? `Execute the plan${inferredPlanPath ? ` in ${inferredPlanPath}` : ""}. Start with: ${firstTodo.text}`
726
+ : inferredPlanPath
727
+ ? `Execute the plan in ${inferredPlanPath}.`
728
+ : "Execute the plan you just created.";
729
+ pi.sendMessage(
730
+ { customType: "plan-mode-execute", content: execMessage, display: true },
731
+ { triggerTurn: true },
732
+ );
733
+ persistState();
734
+ } else if (choice === "Refine the plan") {
735
+ const refinement = await ctx.ui.editor("Refine the plan:", "");
736
+ if (refinement?.trim()) {
737
+ pi.sendUserMessage(refinement.trim());
738
+ }
739
+ persistState();
740
+ } else {
741
+ persistState();
742
+ }
743
+ });
744
+
745
+ pi.on("session_start", async (_event, ctx) => {
746
+ if (pi.getFlag("plan") === true) {
747
+ planModeEnabled = true;
748
+ planningContextShapePending = true;
749
+ }
750
+
751
+ const entries = ctx.sessionManager.getEntries();
752
+
753
+ const planModeEntry = entries
754
+ .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
755
+ .pop() as
756
+ | {
757
+ data?: {
758
+ enabled: boolean;
759
+ todos?: TodoItem[];
760
+ executing?: boolean;
761
+ activePlanTouched?: boolean;
762
+ pendingPlanChoicePath?: string;
763
+ lastPlanCompactionEntryCount?: number;
764
+ lastPlanCompactionReason?: string;
765
+ lastPlanningLoadEntryCount?: number;
766
+ pendingPlanningLoadAfterCompaction?: boolean;
767
+ pendingPlanningLoadPrompt?: string;
768
+ pendingPlanningLoadReason?: string;
769
+ planningContextShapePending?: boolean;
770
+ planModeFullPromptInjected?: boolean;
771
+ planningCliContextSummary?: string;
772
+ planningCliContextChecked?: boolean;
773
+ lastPlanningRequest?: string;
774
+ currentPlanPath?: string;
775
+ touchedPlanArchivePaths?: string[];
776
+ };
777
+ }
778
+ | undefined;
779
+
780
+ if (planModeEntry?.data) {
781
+ planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
782
+ todoItems = planModeEntry.data.todos ?? todoItems;
783
+ executionMode = planModeEntry.data.executing ?? executionMode;
784
+ activePlanTouched = planModeEntry.data.activePlanTouched ?? activePlanTouched;
785
+ pendingPlanChoicePath = planModeEntry.data.pendingPlanChoicePath ?? pendingPlanChoicePath;
786
+ lastPlanCompactionEntryCount = planModeEntry.data.lastPlanCompactionEntryCount ?? lastPlanCompactionEntryCount;
787
+ lastPlanCompactionReason = planModeEntry.data.lastPlanCompactionReason ?? lastPlanCompactionReason;
788
+ lastPlanningLoadEntryCount = planModeEntry.data.lastPlanningLoadEntryCount ?? lastPlanningLoadEntryCount;
789
+ pendingPlanningLoadAfterCompaction = planModeEntry.data.pendingPlanningLoadAfterCompaction ?? pendingPlanningLoadAfterCompaction;
790
+ pendingPlanningLoadPrompt = planModeEntry.data.pendingPlanningLoadPrompt ?? pendingPlanningLoadPrompt;
791
+ pendingPlanningLoadReason = planModeEntry.data.pendingPlanningLoadReason ?? pendingPlanningLoadReason;
792
+ planningContextShapePending = planModeEntry.data.planningContextShapePending ?? planningContextShapePending;
793
+ planModeFullPromptInjected = planModeEntry.data.planModeFullPromptInjected ?? planModeFullPromptInjected;
794
+ planningCliContextSummary = planModeEntry.data.planningCliContextSummary ?? planningCliContextSummary;
795
+ planningCliContextChecked = planModeEntry.data.planningCliContextChecked ?? planningCliContextChecked;
796
+ lastPlanningRequest = planModeEntry.data.lastPlanningRequest ?? lastPlanningRequest;
797
+ currentPlanPath = planModeEntry.data.currentPlanPath ?? currentPlanPath;
798
+ touchedPlanArchivePaths = planModeEntry.data.touchedPlanArchivePaths ?? touchedPlanArchivePaths;
799
+ }
800
+
801
+ const isResume = planModeEntry !== undefined;
802
+ if (isResume && executionMode && todoItems.length > 0) {
803
+ let executeIndex = -1;
804
+ for (let i = entries.length - 1; i >= 0; i--) {
805
+ const entry = entries[i] as { type: string; customType?: string };
806
+ if (entry.customType === "plan-mode-execute") {
807
+ executeIndex = i;
808
+ break;
809
+ }
810
+ }
811
+
812
+ const messages: AssistantMessage[] = [];
813
+ for (let i = executeIndex + 1; i < entries.length; i++) {
814
+ const entry = entries[i];
815
+ if (entry && entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) {
816
+ messages.push(entry.message as AssistantMessage);
817
+ }
818
+ }
819
+ const allText = messages.map(getMessageText).join("\n");
820
+ markCompletedSteps(allText, todoItems);
821
+ }
822
+
823
+ if (planModeEnabled) {
824
+ activePlanModeTools = getPlanModeTools();
825
+ pi.setActiveTools(activePlanModeTools);
826
+ recordContextMetric(ctx, (name) => pi.getFlag(name), "plan-mode:session-start-enabled", { entryCount: getSessionEntryCount(ctx), tools: activePlanModeTools });
827
+ }
828
+ updateStatus(ctx);
829
+ });
830
+ }