pi-plans 0.1.2 → 0.3.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.
Files changed (76) hide show
  1. package/README.md +98 -19
  2. package/index.ts +147 -66
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +21 -3
  5. package/references/state-and-config.md +35 -3
  6. package/scripts/validate.ts +4 -0
  7. package/src/autocomplete.ts +163 -0
  8. package/src/code-graph/commands.ts +437 -0
  9. package/src/code-graph/discovery.ts +118 -0
  10. package/src/code-graph/git.ts +108 -0
  11. package/src/code-graph/identity.ts +59 -0
  12. package/src/code-graph/indexer.ts +281 -0
  13. package/src/code-graph/materialize.ts +166 -0
  14. package/src/code-graph/mode.ts +28 -0
  15. package/src/code-graph/mutations.ts +160 -0
  16. package/src/code-graph/parser.ts +51 -0
  17. package/src/code-graph/parsers/javascript.ts +35 -0
  18. package/src/code-graph/parsers/python.ts +160 -0
  19. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  20. package/src/code-graph/paths.ts +85 -0
  21. package/src/code-graph/prompts.ts +18 -0
  22. package/src/code-graph/resolver.ts +69 -0
  23. package/src/code-graph/runtime.ts +158 -0
  24. package/src/code-graph/schema.ts +135 -0
  25. package/src/code-graph/screening.ts +82 -0
  26. package/src/code-graph/store.ts +278 -0
  27. package/src/code-graph/summary.ts +435 -0
  28. package/src/code-graph/types.ts +163 -0
  29. package/src/compaction.ts +1256 -0
  30. package/src/config-command.ts +326 -0
  31. package/src/exec.ts +519 -625
  32. package/src/plan.ts +37 -0
  33. package/src/query-hook.ts +82 -0
  34. package/src/refine-prompts.ts +50 -0
  35. package/src/refine-ui-helpers.ts +142 -0
  36. package/src/refine-ui-state.ts +144 -0
  37. package/src/refine-ui.ts +430 -0
  38. package/src/state.ts +24 -29
  39. package/src/subagent.ts +299 -70
  40. package/tests/ask-choice.test.ts +263 -0
  41. package/tests/autocomplete.test.ts +147 -0
  42. package/tests/code-graph-apply.test.ts +185 -0
  43. package/tests/code-graph-commands.test.ts +211 -0
  44. package/tests/code-graph-db.test.ts +166 -0
  45. package/tests/code-graph-discovery.test.ts +38 -0
  46. package/tests/code-graph-git.test.ts +94 -0
  47. package/tests/code-graph-index.test.ts +175 -0
  48. package/tests/code-graph-loop.e2e.test.ts +159 -0
  49. package/tests/code-graph-mutations.test.ts +117 -0
  50. package/tests/code-graph-parser.test.ts +85 -0
  51. package/tests/code-graph-rollback.test.ts +100 -0
  52. package/tests/code-graph-summary-batching.test.ts +518 -0
  53. package/tests/code-graph-summary.test.ts +148 -0
  54. package/tests/compaction.test.ts +388 -0
  55. package/tests/config-command.test.ts +255 -0
  56. package/tests/exec.test.ts +751 -422
  57. package/tests/execute-plan.test.ts +65 -0
  58. package/tests/fixtures/code-graph/sample.js +36 -0
  59. package/tests/fixtures/code-graph/sample.py +20 -0
  60. package/tests/fixtures/code-graph/sample.ts +15 -0
  61. package/tests/graph-aware-file-tools.test.ts +411 -0
  62. package/tests/plan.test.ts +11 -1
  63. package/tests/plans.test.ts +6 -5
  64. package/tests/query-hook.test.ts +82 -0
  65. package/tests/refine-prompts.test.ts +67 -2
  66. package/tests/refine-ui.test.ts +392 -0
  67. package/tests/state.test.ts +12 -15
  68. package/tests/subagent.test.ts +120 -0
  69. package/tools/ask-choice.ts +180 -11
  70. package/tools/code-graph.ts +254 -0
  71. package/tools/execute-plan.ts +7 -39
  72. package/tools/graph-aware-file-tools.ts +392 -0
  73. package/tools/plans.ts +84 -18
  74. package/tools/refine.ts +180 -80
  75. package/src/execution-panel.ts +0 -633
  76. package/tests/execution-panel.test.ts +0 -234
@@ -0,0 +1,430 @@
1
+ import type { ExtensionCommandContext, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, OverlayHandle, TUI } from "@earendil-works/pi-tui";
3
+ import type { SubagentProgressEvent, SubagentResult } from "./subagent.ts";
4
+ import {
5
+ matchesEscape as localMatchesEscape,
6
+ truncateToWidth as localTruncateToWidth,
7
+ visibleWidth as localVisibleWidth,
8
+ wrapTextWithAnsi as localWrapTextWithAnsi,
9
+ } from "./refine-ui-helpers.ts";
10
+ import {
11
+ applyRefineProgress,
12
+ applyRefineResult,
13
+ statusLabel,
14
+ type RefineLaneState,
15
+ type RefineTranscriptEntry,
16
+ type RefineTranscriptEntryType,
17
+ type RefineOverlayRole,
18
+ } from "./refine-ui-state.ts";
19
+
20
+ let _piTui: typeof import("@earendil-works/pi-tui") | undefined;
21
+ let _piTuiAttempted = false;
22
+
23
+ async function loadPiTui(): Promise<typeof import("@earendil-works/pi-tui") | undefined> {
24
+ if (_piTuiAttempted) return _piTui;
25
+ _piTuiAttempted = true;
26
+ try {
27
+ _piTui = await import("@earendil-works/pi-tui");
28
+ } catch {
29
+ _piTui = undefined;
30
+ }
31
+ return _piTui;
32
+ }
33
+
34
+ void loadPiTui();
35
+
36
+ function truncateToWidth(text: string, width: number, ellipsis = ""): string {
37
+ try {
38
+ return _piTui ? _piTui.truncateToWidth(text, width, ellipsis) : localTruncateToWidth(text, width, ellipsis);
39
+ } catch {
40
+ return localTruncateToWidth(text, width, ellipsis);
41
+ }
42
+ }
43
+
44
+ function visibleWidth(text: string): number {
45
+ try {
46
+ return _piTui ? _piTui.visibleWidth(text) : localVisibleWidth(text);
47
+ } catch {
48
+ return localVisibleWidth(text);
49
+ }
50
+ }
51
+
52
+ function wrapTextWithAnsi(text: string, width: number): string[] {
53
+ try {
54
+ return _piTui ? _piTui.wrapTextWithAnsi(text, width) : localWrapTextWithAnsi(text, width);
55
+ } catch {
56
+ return localWrapTextWithAnsi(text, width);
57
+ }
58
+ }
59
+
60
+ type RefineKey = "escape" | "tab" | "shift+tab" | "up" | "down" | "pageUp" | "pageDown";
61
+
62
+ function fallbackKey(data: string, key: RefineKey): boolean {
63
+ const sequences: Record<RefineKey, string[]> = {
64
+ escape: ["\x1b", "\x1b\x1b"],
65
+ tab: ["\t"],
66
+ "shift+tab": ["\x1b[Z"],
67
+ up: ["\x1b[A", "\x1bOA"],
68
+ down: ["\x1b[B", "\x1bOB"],
69
+ pageUp: ["\x1b[5~"],
70
+ pageDown: ["\x1b[6~"],
71
+ };
72
+ return sequences[key].includes(data);
73
+ }
74
+
75
+ function matchesKey(data: string, key: RefineKey): boolean {
76
+ try {
77
+ return _piTui ? _piTui.matchesKey(data, key) : fallbackKey(data, key);
78
+ } catch {
79
+ return fallbackKey(data, key);
80
+ }
81
+ }
82
+
83
+ function handleEscape(data: string): boolean {
84
+ try {
85
+ return _piTui ? _piTui.matchesKey(data, "escape") : localMatchesEscape(data);
86
+ } catch {
87
+ return localMatchesEscape(data);
88
+ }
89
+ }
90
+
91
+ export type { RefineLaneState, RefineLaneStatus, RefineOverlayRole, RefineTranscriptEntry, RefineTranscriptEntryType } from "./refine-ui-state.ts";
92
+
93
+ const OVERLAY_MIN_WIDTH = 72;
94
+ const OVERLAY_MIN_HEIGHT = 18;
95
+ const OVERLAY_MAX_HEIGHT = 32;
96
+ const OVERLAY_HEIGHT_RATIO = 0.78;
97
+ const STREAMING_PREVIEW_LINES = 3;
98
+ const OVERLAY_CHROME_LINES = 4; // top border + title row + footer row + bottom border
99
+
100
+ export function getTerminalRowCount(): number {
101
+ const raw = (process.stdout as { rows?: number }).rows;
102
+ return typeof raw === "number" && raw > 0 ? raw : 30;
103
+ }
104
+
105
+ export function pickOverlayHeight(): number {
106
+ const rows = getTerminalRowCount();
107
+ const target = Math.max(OVERLAY_MIN_HEIGHT, Math.floor(rows * OVERLAY_HEIGHT_RATIO));
108
+ return Math.min(OVERLAY_MAX_HEIGHT, target);
109
+ }
110
+
111
+ /** The overlay manager resolves the 78%/72-column size; render accepts its resolved width. */
112
+ export function pickOverlayWidth(width: number): number {
113
+ return Math.max(24, width);
114
+ }
115
+
116
+ function borderColor(theme: Theme, selected = false): ThemeColor {
117
+ return selected ? "borderAccent" : "border";
118
+ }
119
+
120
+ function renderRow(theme: Theme, content: string, innerWidth: number, selected = false): string {
121
+ const truncated = truncateToWidth(content, innerWidth, "");
122
+ const width = visibleWidth(truncated);
123
+ const filler = innerWidth > width ? " ".repeat(innerWidth - width) : "";
124
+ return `${theme.fg(borderColor(theme, selected), "│")}${truncated}${filler}${theme.fg(borderColor(theme, selected), "│")}`;
125
+ }
126
+
127
+ function renderBorderLine(theme: Theme, innerWidth: number, edge: "top" | "bottom", selected = false): string {
128
+ const left = edge === "top" ? "┌" : "└";
129
+ const right = edge === "top" ? "┐" : "┘";
130
+ return theme.fg(borderColor(theme, selected), `${left}${"─".repeat(innerWidth)}${right}`);
131
+ }
132
+
133
+ function fitLine(line: string, width: number): string {
134
+ return visibleWidth(line) > width ? truncateToWidth(line, width, "") : line;
135
+ }
136
+
137
+ function laneColor(status: RefineLaneState["status"]): ThemeColor {
138
+ switch (status) {
139
+ case "complete": return "success";
140
+ case "failed": return "error";
141
+ case "cancelled": return "warning";
142
+ case "running": return "accent";
143
+ case "queued": return "muted";
144
+ }
145
+ }
146
+
147
+ function entryBadge(entry: RefineTranscriptEntry, theme: Theme): string {
148
+ const streaming = entry.streaming ? theme.fg("warning", " ▍") : "";
149
+ switch (entry.type) {
150
+ case "assistant-text":
151
+ return theme.fg("accent", theme.bold(" assistant ")) + streaming;
152
+ case "thinking":
153
+ return theme.fg("warning", theme.bold(" thinking ")) + streaming;
154
+ case "tool-call":
155
+ return theme.fg("accent", theme.bold(` tool ${entry.toolName ?? "call"} `)) + streaming;
156
+ case "tool-result":
157
+ return theme.fg(entry.isError ? "error" : "success", theme.bold(` ${entry.isError ? "error" : "result"} ${entry.toolName ?? ""} `)) + streaming;
158
+ case "diagnostic":
159
+ return theme.fg("error", theme.bold(" diagnostic "));
160
+ }
161
+ }
162
+
163
+ function wrapTranscriptText(text: string, width: number): string[] {
164
+ const sourceLines = text.replace(/\r\n/g, "\n").split("\n");
165
+ return sourceLines.flatMap((line) => line ? wrapTextWithAnsi(line, Math.max(1, width)) : [""]);
166
+ }
167
+
168
+ function previewTranscriptText(entry: RefineTranscriptEntry, width: number): { lines: string[]; truncated: boolean } {
169
+ const lines = wrapTranscriptText(entry.text, Math.max(1, width));
170
+ if (!entry.streaming || entry.type === "thinking" || lines.length <= STREAMING_PREVIEW_LINES) {
171
+ return { lines, truncated: false };
172
+ }
173
+ return { lines: lines.slice(-STREAMING_PREVIEW_LINES), truncated: true };
174
+ }
175
+
176
+ function footerText(laneCount: number): string {
177
+ const parts = ["Esc 关闭", "↑/↓ 滚动", "PgUp/PgDn 翻页"];
178
+ if (laneCount > 1) parts.push("Tab & Shift + Tab 切换 lane");
179
+ return parts.join(" · ");
180
+ }
181
+
182
+ function buildTranscriptLines(entries: RefineTranscriptEntry[], theme: Theme, width: number): string[] {
183
+ const lines: string[] = [];
184
+ for (const entry of entries) {
185
+ lines.push(entryBadge(entry, theme));
186
+ const preview = previewTranscriptText(entry, Math.max(1, width - 2));
187
+ if (preview.truncated) lines.push(` ${theme.fg("dim", "…")}`);
188
+ for (const line of preview.lines) {
189
+ const styled = entry.type === "thinking"
190
+ ? theme.fg("warning", line)
191
+ : entry.type === "tool-result" && entry.isError
192
+ ? theme.fg("error", line)
193
+ : entry.type === "diagnostic"
194
+ ? theme.fg("error", line)
195
+ : theme.fg("dim", line);
196
+ lines.push(` ${styled}`);
197
+ }
198
+ }
199
+ return lines;
200
+ }
201
+
202
+ function summaryFor(role: RefineOverlayRole, lanes: RefineLaneState[], modelLabel?: string): string {
203
+ const complete = lanes.filter((lane) => lane.status === "complete").length;
204
+ const terminal = lanes.filter((lane) => ["complete", "failed", "cancelled"].includes(lane.status)).length;
205
+ const running = lanes.filter((lane) => lane.status === "running").length;
206
+ const title = role === "reviewer" ? "Reviewer" : "Criticizer";
207
+ const visibleTitle = modelLabel ? `${title} (${modelLabel})` : title;
208
+ const state = terminal === lanes.length ? "done" : running > 0 ? `${running} running` : "queued";
209
+ return `${visibleTitle} · ${complete}/${lanes.length} done · ${state}`;
210
+ }
211
+
212
+ export class RefineOverlayComponent implements Component {
213
+ private readonly theme: Theme;
214
+ private readonly role: RefineOverlayRole;
215
+ private readonly lanes: RefineLaneState[];
216
+ private readonly onCancel: () => void;
217
+ private readonly tui?: TUI;
218
+ private readonly modelLabel?: string;
219
+ private selectedLane = 0;
220
+ private disposed = false;
221
+
222
+ constructor(theme: Theme, role: RefineOverlayRole, lanes: RefineLaneState[], onCancel: () => void, tui?: TUI, modelLabel?: string) {
223
+ this.theme = theme;
224
+ this.role = role;
225
+ this.lanes = lanes;
226
+ this.onCancel = onCancel;
227
+ this.tui = tui;
228
+ this.modelLabel = modelLabel;
229
+ this.tui?.terminal?.write?.("\x1b[?1000h\x1b[?1006h");
230
+ }
231
+
232
+ handleInput(data: string): void {
233
+ if (this.disposed) return;
234
+ if (handleEscape(data)) {
235
+ this.onCancel();
236
+ return;
237
+ }
238
+ if (this.lanes.length > 1 && matchesKey(data, "tab")) {
239
+ this.selectedLane = (this.selectedLane + 1) % this.lanes.length;
240
+ this.tui?.requestRender();
241
+ return;
242
+ }
243
+ if (this.lanes.length > 1 && matchesKey(data, "shift+tab")) {
244
+ this.selectedLane = (this.selectedLane - 1 + this.lanes.length) % this.lanes.length;
245
+ this.tui?.requestRender();
246
+ return;
247
+ }
248
+ const lane = this.lanes[this.selectedLane];
249
+ if (!lane) return;
250
+ const viewport = Math.max(1, lane.viewportHeight ?? 1);
251
+ if (matchesKey(data, "up")) lane.scrollOffset -= 1;
252
+ else if (matchesKey(data, "down")) lane.scrollOffset += 1;
253
+ else if (matchesKey(data, "pageUp")) lane.scrollOffset -= Math.max(1, viewport - 1);
254
+ else if (matchesKey(data, "pageDown")) lane.scrollOffset += Math.max(1, viewport - 1);
255
+ else {
256
+ const mouse = data.match(/^\x1b\[<(\d+);\d+;\d+[Mm]$/);
257
+ if (!mouse || (Number(mouse[1]) & 64) !== 64) return;
258
+ lane.scrollOffset += (Number(mouse[1]) & 1) === 0 ? -3 : 3;
259
+ }
260
+ lane.followTranscript = false;
261
+ this.tui?.requestRender();
262
+ }
263
+
264
+ dispose(): void {
265
+ if (this.disposed) return;
266
+ this.disposed = true;
267
+ this.tui?.terminal?.write?.("\x1b[?1000l\x1b[?1006l");
268
+ }
269
+
270
+ render(width: number): string[] {
271
+ const dialogWidth = pickOverlayWidth(width);
272
+ const innerWidth = Math.max(22, dialogWidth - 2);
273
+ const dialogHeight = pickOverlayHeight();
274
+ const laneCount = Math.max(1, this.lanes.length);
275
+ const paneHeight = Math.max(3, Math.floor((dialogHeight - OVERLAY_CHROME_LINES) / laneCount));
276
+ const title = summaryFor(this.role, this.lanes, this.modelLabel);
277
+ const lines: string[] = [renderBorderLine(this.theme, innerWidth, "top")];
278
+ lines.push(renderRow(this.theme, this.theme.fg("accent", this.theme.bold(title)), innerWidth, false));
279
+
280
+ if (this.lanes.length === 0) {
281
+ lines.push(renderRow(this.theme, this.theme.fg("dim", "No active lanes"), innerWidth));
282
+ } else {
283
+ for (let index = 0; index < this.lanes.length; index++) {
284
+ lines.push(...this.renderPane(this.lanes[index]!, innerWidth, paneHeight, index === this.selectedLane));
285
+ }
286
+ }
287
+ lines.push(renderRow(this.theme, this.theme.fg("dim", footerText(this.lanes.length)), innerWidth));
288
+ lines.push(renderBorderLine(this.theme, innerWidth, "bottom"));
289
+ return lines.map((line) => fitLine(line, width));
290
+ }
291
+
292
+ private renderPane(lane: RefineLaneState, innerWidth: number, paneHeight: number, selected: boolean): string[] {
293
+ const transcriptWidth = Math.max(1, innerWidth - 2);
294
+ const transcriptLines = buildTranscriptLines(lane.transcript, this.theme, transcriptWidth);
295
+ const viewportHeight = Math.max(1, paneHeight - 3);
296
+ lane.viewportHeight = viewportHeight;
297
+ const maxScroll = Math.max(0, transcriptLines.length - viewportHeight);
298
+ if (lane.followTranscript) lane.scrollOffset = maxScroll;
299
+ else {
300
+ lane.scrollOffset = Math.max(0, Math.min(lane.scrollOffset, maxScroll));
301
+ if (lane.scrollOffset >= maxScroll) lane.followTranscript = true;
302
+ }
303
+ const hiddenAbove = lane.scrollOffset;
304
+ const hiddenBelow = Math.max(0, maxScroll - lane.scrollOffset);
305
+ const scrollCount = hiddenAbove || hiddenBelow ? ` ↑${hiddenAbove} ↓${hiddenBelow}` : "";
306
+ const status = this.theme.fg(laneColor(lane.status), statusLabel(lane.status));
307
+ const phase = lane.phase ? this.theme.fg("muted", ` · ${lane.phase}`) : "";
308
+ const marker = selected ? "▸ " : " ";
309
+ const header = `${marker}${this.theme.fg("accent", this.theme.bold(lane.label))} ${status}${phase}${this.theme.fg("dim", scrollCount)}`;
310
+ const visible = transcriptLines.slice(lane.scrollOffset, lane.scrollOffset + viewportHeight);
311
+ const lines = [renderBorderLine(this.theme, innerWidth, "top", selected), renderRow(this.theme, header, innerWidth, selected)];
312
+ for (const line of visible) lines.push(renderRow(this.theme, line, innerWidth, selected));
313
+ for (let i = visible.length; i < viewportHeight; i++) lines.push(renderRow(this.theme, "", innerWidth, selected));
314
+ lines.push(renderBorderLine(this.theme, innerWidth, "bottom", selected));
315
+ return lines;
316
+ }
317
+
318
+ invalidate(): void {}
319
+ }
320
+
321
+ export interface RefineOverlayContext {
322
+ ui: ExtensionContext["ui"];
323
+ hasUI: boolean;
324
+ }
325
+
326
+ /** Owns the public overlay and exposes progress updates without owning child processes. */
327
+ export class RefineOverlayController {
328
+ readonly lanes: RefineLaneState[];
329
+ private readonly role: RefineOverlayRole;
330
+ private readonly onCancel: () => void;
331
+ private handle: OverlayHandle | undefined;
332
+ private done: ((result: undefined) => void) | undefined;
333
+ private component: RefineOverlayComponent | undefined;
334
+ private overlayPromise: Promise<void> | undefined;
335
+ private tui: TUI | undefined;
336
+ private closed = false;
337
+
338
+ constructor(role: RefineOverlayRole, laneIds: Array<{ id: string; label?: string }>, onCancel: () => void) {
339
+ this.role = role;
340
+ this.onCancel = onCancel;
341
+ this.lanes = laneIds.map((lane) => ({
342
+ id: lane.id,
343
+ label: lane.label ?? lane.id,
344
+ status: "queued",
345
+ phase: "queued",
346
+ detail: "",
347
+ transcript: [],
348
+ currentTurnIndex: 0,
349
+ scrollOffset: 0,
350
+ followTranscript: true,
351
+ viewportHeight: 1,
352
+ }));
353
+ }
354
+
355
+ open(ctx: RefineOverlayContext, modelLabel?: string): void {
356
+ if (!ctx.hasUI || this.overlayPromise) return;
357
+
358
+ this.overlayPromise = ctx.ui
359
+ .custom<void>(
360
+ (_tui, theme, _keybindings, done) => {
361
+ this.tui = _tui;
362
+ this.done = done;
363
+ this.component = new RefineOverlayComponent(theme, this.role, this.lanes, () => this.cancel(), _tui, modelLabel);
364
+ if (this.closed) done(undefined);
365
+ return this.component;
366
+ },
367
+ {
368
+ overlay: true,
369
+ overlayOptions: {
370
+ width: "78%",
371
+ minWidth: OVERLAY_MIN_WIDTH,
372
+ maxHeight: "78%",
373
+ anchor: "top-center",
374
+ margin: { top: 1, left: 2, right: 2 },
375
+ },
376
+ onHandle: (handle) => {
377
+ this.handle = handle;
378
+ handle.focus();
379
+ if (this.closed) handle.hide();
380
+ },
381
+ },
382
+ )
383
+ .then(() => undefined)
384
+ .catch(() => undefined);
385
+ }
386
+
387
+ update(laneId: string, event: SubagentProgressEvent): void {
388
+ if (this.closed) return;
389
+ const lane = this.lanes.find((candidate) => candidate.id === laneId);
390
+ if (!lane) return;
391
+ applyRefineProgress(lane, event);
392
+ this.tui?.requestRender();
393
+ }
394
+
395
+ complete(laneId: string, result: SubagentResult): void {
396
+ if (this.closed) return;
397
+ const lane = this.lanes.find((candidate) => candidate.id === laneId);
398
+ if (lane) {
399
+ applyRefineResult(lane, result);
400
+ this.tui?.requestRender();
401
+ }
402
+ }
403
+
404
+ cancel(): void {
405
+ // Esc only closes the overlay; the refiner child keeps running to natural
406
+ // completion and its result still flows through the tool result path.
407
+ void this.close();
408
+ }
409
+
410
+ async close(): Promise<void> {
411
+ if (!this.closed) {
412
+ this.closed = true;
413
+ this.component?.dispose();
414
+ if (this.done) this.done(undefined);
415
+ else this.handle?.hide();
416
+ this.component = undefined;
417
+ this.tui = undefined;
418
+ }
419
+ await this.overlayPromise;
420
+ }
421
+
422
+ isClosed(): boolean {
423
+ return this.closed;
424
+ }
425
+
426
+ }
427
+
428
+ export function refineOverlayContext(ctx: ExtensionContext | ExtensionCommandContext): RefineOverlayContext {
429
+ return { ui: ctx.ui, hasUI: ctx.hasUI };
430
+ }
package/src/state.ts CHANGED
@@ -34,21 +34,18 @@ export interface RoleConfig {
34
34
  confirmed_at: string | null;
35
35
  }
36
36
 
37
- export interface ExecutionConfig {
38
- model_selector: string | null;
39
- source: SettingSource;
40
- updated_at: string | null;
41
- }
42
37
 
43
38
  export interface PlansConfig {
44
39
  schema: number;
45
40
  language: LanguageConfig;
46
41
  reviewer: RoleConfig;
47
42
  criticizer: RoleConfig;
48
- execution: ExecutionConfig;
49
43
  artifact_root: string;
50
44
  artifact_root_source: SettingSource;
51
45
  artifact_root_updated_at: string | null;
46
+ /** null = never asked; the plans tool surfaces a hint so the agent asks once. */
47
+ graph_enabled: boolean | null;
48
+ graph_enabled_updated_at: string | null;
52
49
  }
53
50
 
54
51
  const DEFAULT_ARTIFACT_ROOT = "./docs/pi-plans";
@@ -61,6 +58,13 @@ function normalizeArtifactRoot(config: PlansConfig): PlansConfig {
61
58
  return config;
62
59
  }
63
60
 
61
+ type LegacyPlansConfig = PlansConfig & { execution?: unknown };
62
+
63
+ function normalizeLegacyExecutionConfig(config: LegacyPlansConfig): PlansConfig {
64
+ const { execution: _execution, ...rest } = config;
65
+ return rest as PlansConfig;
66
+ }
67
+
64
68
  export const DEFAULT_CONFIG: PlansConfig = {
65
69
  schema: 1,
66
70
  language: { tag: null, source: "unset", updated_at: null },
@@ -76,14 +80,11 @@ export const DEFAULT_CONFIG: PlansConfig = {
76
80
  name_prefix: "pi-plans-criticizer",
77
81
  confirmed_at: null,
78
82
  },
79
- execution: {
80
- model_selector: null,
81
- source: "unset",
82
- updated_at: null,
83
- },
84
83
  artifact_root: DEFAULT_ARTIFACT_ROOT,
85
84
  artifact_root_source: "unset",
86
85
  artifact_root_updated_at: null,
86
+ graph_enabled: null,
87
+ graph_enabled_updated_at: null,
87
88
  };
88
89
 
89
90
  export const VALID_ROLE_MODES = new Set(["delegated-subagent", "current-session"]);
@@ -268,7 +269,8 @@ export function loadConfig(stateRoot: string): PlansConfig {
268
269
  } catch (error) {
269
270
  throw new StateError(`invalid config.json: ${(error as Error).message}`);
270
271
  }
271
- return normalizeArtifactRoot(deepMergeDefaults(data as PlansConfig, DEFAULT_CONFIG));
272
+ const merged = deepMergeDefaults(data as LegacyPlansConfig, DEFAULT_CONFIG as LegacyPlansConfig);
273
+ return normalizeLegacyExecutionConfig(normalizeArtifactRoot(merged as PlansConfig));
272
274
  }
273
275
 
274
276
  function noticeIfSubdir(workdir: string, notices: string[]): void {
@@ -328,18 +330,10 @@ export function setArtifactRoot(workdir: string, artifactRoot: string, source: "
328
330
  return { config, stateRoot, notices };
329
331
  }
330
332
 
331
- export interface SetExecutionModelOptions {
332
- modelSelector?: string;
333
- source: "user" | "auto";
334
- }
335
-
336
- export function setExecutionModel(workdir: string, options: SetExecutionModelOptions): EnsureResult {
333
+ export function setGraphEnabled(workdir: string, enabled: boolean): EnsureResult {
337
334
  const { config, stateRoot, notices } = ensureState(workdir);
338
- config.execution = {
339
- model_selector: options.modelSelector === undefined || options.modelSelector === "inherit" ? null : options.modelSelector,
340
- source: options.source,
341
- updated_at: utcNow(),
342
- };
335
+ config.graph_enabled = enabled;
336
+ config.graph_enabled_updated_at = utcNow();
343
337
  atomicWriteJson(path.join(stateRoot, "config.json"), config);
344
338
  return { config, stateRoot, notices };
345
339
  }
@@ -372,8 +366,13 @@ export function setRole(workdir: string, options: SetRoleOptions): EnsureResult
372
366
  return { config, stateRoot, notices };
373
367
  }
374
368
 
375
- // ---------------------------------------------------------------------------
376
- // Runs
369
+ export function updateConfig(workdir: string, updater: (config: PlansConfig) => PlansConfig): EnsureResult {
370
+ const { config, stateRoot, notices } = ensureState(workdir);
371
+ const next = updater(structuredClone(config));
372
+ atomicWriteJson(path.join(stateRoot, "config.json"), next);
373
+ return { config: next, stateRoot, notices };
374
+ }
375
+
377
376
  // ---------------------------------------------------------------------------
378
377
 
379
378
  const SLUG_RE = /[^a-z0-9]+/g;
@@ -520,7 +519,3 @@ export function setRunStatus(workdir: string, runId: string, status: string): Ru
520
519
  atomicWriteJson(runPath, run);
521
520
  return run;
522
521
  }
523
-
524
- export function refsCacheDir(): string {
525
- return path.join(os.homedir(), ".cache", "pi-plans", "refs");
526
- }