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.
- package/README.md +98 -19
- package/index.ts +147 -66
- package/package.json +7 -1
- package/references/pi-planning-workflow.md +21 -3
- package/references/state-and-config.md +35 -3
- package/scripts/validate.ts +4 -0
- package/src/autocomplete.ts +163 -0
- package/src/code-graph/commands.ts +437 -0
- package/src/code-graph/discovery.ts +118 -0
- package/src/code-graph/git.ts +108 -0
- package/src/code-graph/identity.ts +59 -0
- package/src/code-graph/indexer.ts +281 -0
- package/src/code-graph/materialize.ts +166 -0
- package/src/code-graph/mode.ts +28 -0
- package/src/code-graph/mutations.ts +160 -0
- package/src/code-graph/parser.ts +51 -0
- package/src/code-graph/parsers/javascript.ts +35 -0
- package/src/code-graph/parsers/python.ts +160 -0
- package/src/code-graph/parsers/tree-sitter.ts +316 -0
- package/src/code-graph/paths.ts +85 -0
- package/src/code-graph/prompts.ts +18 -0
- package/src/code-graph/resolver.ts +69 -0
- package/src/code-graph/runtime.ts +158 -0
- package/src/code-graph/schema.ts +135 -0
- package/src/code-graph/screening.ts +82 -0
- package/src/code-graph/store.ts +278 -0
- package/src/code-graph/summary.ts +435 -0
- package/src/code-graph/types.ts +163 -0
- package/src/compaction.ts +1256 -0
- package/src/config-command.ts +326 -0
- package/src/exec.ts +519 -625
- package/src/plan.ts +37 -0
- package/src/query-hook.ts +82 -0
- package/src/refine-prompts.ts +50 -0
- package/src/refine-ui-helpers.ts +142 -0
- package/src/refine-ui-state.ts +144 -0
- package/src/refine-ui.ts +430 -0
- package/src/state.ts +24 -29
- package/src/subagent.ts +299 -70
- package/tests/ask-choice.test.ts +263 -0
- package/tests/autocomplete.test.ts +147 -0
- package/tests/code-graph-apply.test.ts +185 -0
- package/tests/code-graph-commands.test.ts +211 -0
- package/tests/code-graph-db.test.ts +166 -0
- package/tests/code-graph-discovery.test.ts +38 -0
- package/tests/code-graph-git.test.ts +94 -0
- package/tests/code-graph-index.test.ts +175 -0
- package/tests/code-graph-loop.e2e.test.ts +159 -0
- package/tests/code-graph-mutations.test.ts +117 -0
- package/tests/code-graph-parser.test.ts +85 -0
- package/tests/code-graph-rollback.test.ts +100 -0
- package/tests/code-graph-summary-batching.test.ts +518 -0
- package/tests/code-graph-summary.test.ts +148 -0
- package/tests/compaction.test.ts +388 -0
- package/tests/config-command.test.ts +255 -0
- package/tests/exec.test.ts +751 -422
- package/tests/execute-plan.test.ts +65 -0
- package/tests/fixtures/code-graph/sample.js +36 -0
- package/tests/fixtures/code-graph/sample.py +20 -0
- package/tests/fixtures/code-graph/sample.ts +15 -0
- package/tests/graph-aware-file-tools.test.ts +411 -0
- package/tests/plan.test.ts +11 -1
- package/tests/plans.test.ts +6 -5
- package/tests/query-hook.test.ts +82 -0
- package/tests/refine-prompts.test.ts +67 -2
- package/tests/refine-ui.test.ts +392 -0
- package/tests/state.test.ts +12 -15
- package/tests/subagent.test.ts +120 -0
- package/tools/ask-choice.ts +180 -11
- package/tools/code-graph.ts +254 -0
- package/tools/execute-plan.ts +7 -39
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +84 -18
- package/tools/refine.ts +180 -80
- package/src/execution-panel.ts +0 -633
- package/tests/execution-panel.test.ts +0 -234
package/tools/ask-choice.ts
CHANGED
|
@@ -14,8 +14,119 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
14
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { Text } from "@earendil-works/pi-tui";
|
|
16
16
|
import { Type } from "typebox";
|
|
17
|
+
import { disableAutoComplete, enableAutoComplete, isAutoCompleteEnabled, recordAskChoice } from "../src/autocomplete.ts";
|
|
18
|
+
import { truncateToWidth, visibleWidth } from "../src/refine-ui-helpers.ts";
|
|
17
19
|
import { normalizeWorkdir, readActive, recordDecision } from "../src/state.ts";
|
|
18
20
|
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Panel fitting: pi's ExtensionSelectorComponent renders each option as an
|
|
23
|
+
// auto-wrapping Text with NO height cap — an oversized panel exceeds the
|
|
24
|
+
// terminal rows and the TUI thrashes (flicker). These helpers sanitize and
|
|
25
|
+
// shrink the question/labels before they reach ctx.ui.select.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export const STATUS_BAR_HEIGHT = 1;
|
|
29
|
+
export const PANEL_SAFETY_MARGIN = 2;
|
|
30
|
+
export const PANEL_CHROME_LINES = 9; // 8 measured in extension-selector.js (DynamicBorder×2 + Spacer×4 + title + keyHint) + 1 slack
|
|
31
|
+
/** Each option renders in at most three wrapped lines (user-facing contract). */
|
|
32
|
+
export const OPTION_MAX_LINES = 3;
|
|
33
|
+
/**
|
|
34
|
+
* Per-row width overhead, pinned to extension-selector.js: DynamicBorder 1 +
|
|
35
|
+
* Text padding 1 + selected marker "→ " 2 = 4, plus 2 columns of slack for
|
|
36
|
+
* word-wrap inefficiency. Re-verify against that file if pi changes its layout.
|
|
37
|
+
*/
|
|
38
|
+
export const SELECTOR_WIDTH_OVERHEAD = 6;
|
|
39
|
+
export const FALLBACK_COLUMNS = 100;
|
|
40
|
+
export const FALLBACK_ROWS = 30;
|
|
41
|
+
/** Minimal-form floor for tiny terminals (stage-3 width). */
|
|
42
|
+
const MINIMAL_LINE_WIDTH = 20;
|
|
43
|
+
/**
|
|
44
|
+
* Truncation floor for fixed tail labels (Other…/Auto-complete/Auto-refine
|
|
45
|
+
* loop): the longest magic prefix ("Auto-refine loop", 16 cols) plus slack.
|
|
46
|
+
* These labels drive startsWith() answer routing and must never lose it.
|
|
47
|
+
*/
|
|
48
|
+
const FIXED_LABEL_FLOOR = 18;
|
|
49
|
+
|
|
50
|
+
export interface PanelItem {
|
|
51
|
+
/** Label without description (degradation stage 1+). */
|
|
52
|
+
core: string;
|
|
53
|
+
/** Full display label: core + description (degradation stage 0). */
|
|
54
|
+
display: string;
|
|
55
|
+
/** Fixed tail labels (Other…/Auto-complete/Auto-refine loop): truncation keeps at least the magic prefix. */
|
|
56
|
+
fixed?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FittedPanel {
|
|
60
|
+
question: string;
|
|
61
|
+
labels: string[];
|
|
62
|
+
/** True when even the minimal form exceeds the terminal budget. */
|
|
63
|
+
overflowWarned: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sanitizeLine(text: string): string {
|
|
67
|
+
return text.replace(/\r\n|\n|\r/g, " ");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function truncateWithDotDot(text: string, budget: number): string {
|
|
71
|
+
if (visibleWidth(text) <= budget) return text;
|
|
72
|
+
return `${truncateToWidth(text, Math.max(0, budget - 2), "")}..`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function truncateForItem(item: PanelItem, budget: number): string {
|
|
76
|
+
const effective = item.fixed ? Math.max(budget, FIXED_LABEL_FLOOR) : budget;
|
|
77
|
+
return truncateWithDotDot(item.display, effective);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function wrappedLineCount(text: string, lineWidth: number): number {
|
|
81
|
+
return Math.max(1, Math.ceil(visibleWidth(text) / lineWidth));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Sanitize and shrink the question/labels so the projected panel height stays
|
|
86
|
+
* under rows − statusBar − margin. Degradation order (D-001): per-label 3-line
|
|
87
|
+
* budget → strip descriptions → labels to one line → truncate the question →
|
|
88
|
+
* minimal 20-column form (overflowWarned; never fails closed).
|
|
89
|
+
*/
|
|
90
|
+
export function fitAskChoicePanel(question: string, items: PanelItem[], columns: number, rows: number): FittedPanel {
|
|
91
|
+
const cols = columns > 0 ? columns : FALLBACK_COLUMNS;
|
|
92
|
+
const termRows = rows > 0 ? rows : FALLBACK_ROWS;
|
|
93
|
+
const lineBudget = Math.max(20, cols - SELECTOR_WIDTH_OVERHEAD);
|
|
94
|
+
const rowBudget = Math.max(10, termRows - STATUS_BAR_HEIGHT - PANEL_SAFETY_MARGIN);
|
|
95
|
+
|
|
96
|
+
const cleanQuestion = sanitizeLine(question);
|
|
97
|
+
const clean = items.map((item) => ({ core: sanitizeLine(item.core), display: sanitizeLine(item.display), fixed: item.fixed === true }));
|
|
98
|
+
|
|
99
|
+
const projected = (q: string, ls: string[]) =>
|
|
100
|
+
PANEL_CHROME_LINES + wrappedLineCount(q, lineBudget) + ls.reduce((sum, l) => sum + wrappedLineCount(l, lineBudget), 0);
|
|
101
|
+
|
|
102
|
+
// Stage 0: full display labels, each within the 3-line budget.
|
|
103
|
+
// (Signature note: items carry {core, display} because D-001 stage 1 strips
|
|
104
|
+
// descriptions, which a plain string list cannot express.)
|
|
105
|
+
let currentQuestion = cleanQuestion;
|
|
106
|
+
let currentLabels = clean.map((item) => truncateForItem(item, OPTION_MAX_LINES * lineBudget));
|
|
107
|
+
|
|
108
|
+
if (projected(currentQuestion, currentLabels) >= rowBudget) {
|
|
109
|
+
// Stage 1: strip descriptions (core labels only).
|
|
110
|
+
currentLabels = clean.map((item) => truncateForItem({ ...item, display: item.core }, OPTION_MAX_LINES * lineBudget));
|
|
111
|
+
}
|
|
112
|
+
if (projected(currentQuestion, currentLabels) >= rowBudget) {
|
|
113
|
+
// Stage 2: labels to a single line.
|
|
114
|
+
currentLabels = clean.map((item) => truncateForItem({ ...item, display: item.core }, lineBudget));
|
|
115
|
+
}
|
|
116
|
+
if (projected(currentQuestion, currentLabels) >= rowBudget) {
|
|
117
|
+
// Stage 3: truncate the question too.
|
|
118
|
+
currentQuestion = truncateWithDotDot(currentQuestion, lineBudget);
|
|
119
|
+
}
|
|
120
|
+
let overflowWarned = false;
|
|
121
|
+
if (projected(currentQuestion, currentLabels) >= rowBudget) {
|
|
122
|
+
// Minimal form for tiny terminals: 20-column floor; still over → warn, never fail closed.
|
|
123
|
+
currentQuestion = truncateWithDotDot(cleanQuestion, MINIMAL_LINE_WIDTH);
|
|
124
|
+
currentLabels = clean.map((item) => truncateForItem({ ...item, display: item.core }, MINIMAL_LINE_WIDTH));
|
|
125
|
+
overflowWarned = projected(currentQuestion, currentLabels) >= rowBudget;
|
|
126
|
+
}
|
|
127
|
+
return { question: currentQuestion, labels: currentLabels, overflowWarned };
|
|
128
|
+
}
|
|
129
|
+
|
|
19
130
|
const Option = Type.Object({
|
|
20
131
|
label: Type.String({ description: "Option label" }),
|
|
21
132
|
description: Type.Optional(Type.String({ description: "Short tradeoff that matters, shown to the user" })),
|
|
@@ -32,6 +143,12 @@ const AskChoiceParams = Type.Object({
|
|
|
32
143
|
"Offer the Auto-complete option (default true). MUST be false for the execution handoff, install waivers, publishing, deployment, merge, push, credential use, or any external-state change.",
|
|
33
144
|
}),
|
|
34
145
|
),
|
|
146
|
+
trailing: Type.Optional(
|
|
147
|
+
StringEnum(["auto-refine-loop"] as const, {
|
|
148
|
+
description:
|
|
149
|
+
'Replace the trailing Auto-complete option with "Auto-refine loop" (post-execution amelioration prompt). Selecting it returns instructions to ask the rounds/termination follow-up; Auto-complete is suppressed entirely for this question.',
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
35
152
|
workdir: Type.Optional(Type.String({ description: "Target workspace; default current working directory" })),
|
|
36
153
|
});
|
|
37
154
|
|
|
@@ -47,7 +164,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
47
164
|
name: "ask_choice",
|
|
48
165
|
label: "Ask Choice",
|
|
49
166
|
description:
|
|
50
|
-
"Ask the user one planning or refinement question as a numbered choice prompt: recommended option first, alternatives next, then Other and Auto-complete. One question per call. Use for every user-facing planning question, the final scope confirmation, refinement-mode questions, language/role/model settings, and the execution handoff (with autoComplete: false).",
|
|
167
|
+
"Ask the user one planning or refinement question as a numbered choice prompt: recommended option first, alternatives next, then Other and Auto-complete. One question per call. Use for every user-facing planning question, the final scope confirmation, refinement-mode questions, language/role/model settings, and the execution handoff (with autoComplete: false). The optional trailing parameter swaps the trailing option to Auto-refine loop for the post-execution amelioration prompt.",
|
|
51
168
|
promptSnippet: "Ask structured planning questions with recommended/Other/Auto-complete ordering",
|
|
52
169
|
promptGuidelines: [
|
|
53
170
|
"Use ask_choice for every pi-plans question to the user instead of plain-text questions; it enforces option ordering and records decisions.",
|
|
@@ -58,7 +175,10 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
58
175
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
59
176
|
const workdir = normalizeWorkdir(params.workdir ?? ctx.cwd);
|
|
60
177
|
const allowOther = params.allowOther ?? true;
|
|
61
|
-
|
|
178
|
+
// Param normalization: a trailing option replaces Auto-complete entirely,
|
|
179
|
+
// so an erroneously passed autoComplete flag is suppressed here.
|
|
180
|
+
const trailing = params.trailing;
|
|
181
|
+
const autoComplete = (params.autoComplete ?? true) && trailing === undefined;
|
|
62
182
|
const options = params.options;
|
|
63
183
|
if (options.length === 0) throw new Error("ask_choice requires at least one option");
|
|
64
184
|
const recommended = options.find((option) => option.recommended) ?? options[0];
|
|
@@ -85,14 +205,28 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
85
205
|
source,
|
|
86
206
|
});
|
|
87
207
|
|
|
208
|
+
// Once enabled for this planning run, eligible questions answer with the
|
|
209
|
+
// recommendation without opening another UI prompt.
|
|
210
|
+
if (autoComplete && isAutoCompleteEnabled(ctx)) {
|
|
211
|
+
recordAskChoice(ctx, true);
|
|
212
|
+
record(recommended.label, "auto-complete");
|
|
213
|
+
return {
|
|
214
|
+
content: [{ type: "text", text: `Auto-complete selected the recommended option: ${recommended.label}` }],
|
|
215
|
+
details: details(recommended.label, "auto-complete"),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
88
219
|
// No UI (print/json mode): planning questions may auto-complete;
|
|
89
220
|
// questions without Auto-complete must stop and wait for the user.
|
|
90
221
|
if (!ctx.hasUI) {
|
|
91
222
|
if (!autoComplete) {
|
|
223
|
+
disableAutoComplete(ctx, "non-interactive boundary");
|
|
92
224
|
throw new Error(
|
|
93
225
|
"No UI available and this question must not be auto-completed (execution handoff or external-state change). Stop and wait for the user.",
|
|
94
226
|
);
|
|
95
227
|
}
|
|
228
|
+
enableAutoComplete(ctx);
|
|
229
|
+
recordAskChoice(ctx, true);
|
|
96
230
|
record(recommended.label, "auto-complete");
|
|
97
231
|
return {
|
|
98
232
|
content: [
|
|
@@ -105,17 +239,32 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
105
239
|
};
|
|
106
240
|
}
|
|
107
241
|
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
242
|
+
const AUTO_REFINE_LOOP_LABEL =
|
|
243
|
+
"Auto-refine loop (run refinement rounds until no high-severity finding or the 5-round cap)";
|
|
244
|
+
const panelItems: PanelItem[] = options.map((option, index) => {
|
|
245
|
+
const core = `${index + 1}. ${option.label}${option === recommended ? " (recommended)" : ""}`;
|
|
246
|
+
let display = `${index + 1}. ${option.label}`;
|
|
247
|
+
if (option === recommended) display += " (recommended)";
|
|
248
|
+
if (option.description) display += ` — ${option.description}`;
|
|
249
|
+
return { core, display };
|
|
113
250
|
});
|
|
114
|
-
if (allowOther)
|
|
115
|
-
if (autoComplete)
|
|
251
|
+
if (allowOther) panelItems.push({ core: "Other… (type your own answer)", display: "Other… (type your own answer)", fixed: true });
|
|
252
|
+
if (autoComplete) panelItems.push({ core: "Auto-complete (take the recommended option)", display: "Auto-complete (take the recommended option)", fixed: true });
|
|
253
|
+
else if (trailing) panelItems.push({ core: AUTO_REFINE_LOOP_LABEL, display: AUTO_REFINE_LOOP_LABEL, fixed: true });
|
|
116
254
|
|
|
117
|
-
const
|
|
255
|
+
const panel = fitAskChoicePanel(
|
|
256
|
+
params.question,
|
|
257
|
+
panelItems,
|
|
258
|
+
process.stdout.columns ?? 0,
|
|
259
|
+
process.stdout.rows ?? 0,
|
|
260
|
+
);
|
|
261
|
+
if (panel.overflowWarned) {
|
|
262
|
+
ctx.ui.notify?.("Terminal too small: the ask_choice panel may overflow even in its minimal form.", "warning");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const selected = await ctx.ui.select(panel.question, panel.labels);
|
|
118
266
|
if (selected === undefined) {
|
|
267
|
+
disableAutoComplete(ctx, "question cancelled");
|
|
119
268
|
return {
|
|
120
269
|
content: [
|
|
121
270
|
{
|
|
@@ -128,6 +277,8 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
128
277
|
}
|
|
129
278
|
|
|
130
279
|
if (autoComplete && selected.startsWith("Auto-complete")) {
|
|
280
|
+
enableAutoComplete(ctx);
|
|
281
|
+
recordAskChoice(ctx, true);
|
|
131
282
|
record(recommended.label, "auto-complete");
|
|
132
283
|
return {
|
|
133
284
|
content: [
|
|
@@ -140,14 +291,30 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
140
291
|
};
|
|
141
292
|
}
|
|
142
293
|
|
|
294
|
+
if (trailing && selected.startsWith("Auto-refine loop")) {
|
|
295
|
+
recordAskChoice(ctx, false);
|
|
296
|
+
record("Auto-refine loop", "user");
|
|
297
|
+
return {
|
|
298
|
+
content: [
|
|
299
|
+
{
|
|
300
|
+
type: "text",
|
|
301
|
+
text: `User selected Auto-refine loop. Immediately ask the follow-up with ask_choice (autoComplete: false, in the session language): how should the amelioration loop terminate? Options (recommended first): 1. until no high-severity finding (hard cap 5 rounds) 2. 1 round 3. 2 rounds 4. 3 rounds. Then run the loop per the completion instructions: each round calls refine (role: "reviewer", target: "implementation"), accepts findings on evidence, applies fixes, re-runs relevant tests, and continues until the termination condition or the 5-round cap.`,
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
details: details("Auto-refine loop", "user"),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
143
308
|
if (allowOther && selected.startsWith("Other…")) {
|
|
144
309
|
const typed = await ctx.ui.input(`${params.question} — your answer:`);
|
|
145
310
|
if (typed === undefined || !typed.trim()) {
|
|
311
|
+
disableAutoComplete(ctx, "free-form answer cancelled");
|
|
146
312
|
return {
|
|
147
313
|
content: [{ type: "text", text: "User cancelled the free-form answer. Ask again or stop." }],
|
|
148
314
|
details: details(null, "cancelled"),
|
|
149
315
|
};
|
|
150
316
|
}
|
|
317
|
+
recordAskChoice(ctx, false);
|
|
151
318
|
const answer = typed.trim();
|
|
152
319
|
record(answer, "user");
|
|
153
320
|
return {
|
|
@@ -156,14 +323,16 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
156
323
|
};
|
|
157
324
|
}
|
|
158
325
|
|
|
159
|
-
const index =
|
|
326
|
+
const index = panel.labels.indexOf(selected);
|
|
160
327
|
const option = index >= 0 && index < options.length ? options[index] : undefined;
|
|
161
328
|
if (!option) {
|
|
329
|
+
recordAskChoice(ctx, false);
|
|
162
330
|
return {
|
|
163
331
|
content: [{ type: "text", text: `User selected: ${selected}` }],
|
|
164
332
|
details: details(selected, "user"),
|
|
165
333
|
};
|
|
166
334
|
}
|
|
335
|
+
recordAskChoice(ctx, false);
|
|
167
336
|
record(option.label, "user");
|
|
168
337
|
return {
|
|
169
338
|
content: [{ type: "text", text: `User selected: ${index + 1}. ${option.label}` }],
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-facing graph tool. Eagerly avoids importing node:sqlite or the
|
|
3
|
+
* parsers at module load; both are loaded on first action call.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import {
|
|
10
|
+
loadGraphRuntime,
|
|
11
|
+
describeRuntimeIssues,
|
|
12
|
+
type RuntimeStatus,
|
|
13
|
+
} from "../src/code-graph/runtime.ts";
|
|
14
|
+
import { resolveCanonicalWorktree } from "../src/code-graph/paths.ts";
|
|
15
|
+
import type { WorktreePaths } from "../src/code-graph/paths.ts";
|
|
16
|
+
import { Store } from "../src/code-graph/store.ts";
|
|
17
|
+
import { makeBackend } from "../src/code-graph/parsers/javascript.ts";
|
|
18
|
+
import { PythonBackend } from "../src/code-graph/parsers/python.ts";
|
|
19
|
+
import type { ParserBackend } from "../src/code-graph/parser.ts";
|
|
20
|
+
import { hashText } from "../src/code-graph/parser.ts";
|
|
21
|
+
import type { Language } from "../src/code-graph/types.ts";
|
|
22
|
+
import { screeningQuery } from "../src/code-graph/screening.ts";
|
|
23
|
+
import { deleteFile, listPending, updateFile, updateFunction } from "../src/code-graph/mutations.ts";
|
|
24
|
+
|
|
25
|
+
const CodeGraphParams = Type.Object({
|
|
26
|
+
action: StringEnum(
|
|
27
|
+
[
|
|
28
|
+
"status",
|
|
29
|
+
"screening",
|
|
30
|
+
"get-function",
|
|
31
|
+
"update-function",
|
|
32
|
+
"update-file",
|
|
33
|
+
"delete-file",
|
|
34
|
+
"list-pending",
|
|
35
|
+
"reindex",
|
|
36
|
+
"manifest",
|
|
37
|
+
] as const,
|
|
38
|
+
{ description: "Code graph action to perform" },
|
|
39
|
+
),
|
|
40
|
+
workdir: Type.Optional(Type.String({ description: "Target workspace directory" })),
|
|
41
|
+
language: Type.Optional(StringEnum(["javascript", "typescript", "tsx", "python"] as const)),
|
|
42
|
+
functionName: Type.Optional(Type.String()),
|
|
43
|
+
fileDir: Type.Optional(Type.String({ description: "File directory (POSIX, '.' for root)" })),
|
|
44
|
+
fileName: Type.Optional(Type.String({ description: "File name without directory" })),
|
|
45
|
+
fullCode: Type.Optional(Type.String({ description: "New function body text for update-function" })),
|
|
46
|
+
text: Type.Optional(Type.String({ description: "New whole-file text for update-file" })),
|
|
47
|
+
limit: Type.Optional(Type.Number()),
|
|
48
|
+
force: Type.Optional(Type.Boolean()),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export type CodeGraphContext = Parameters<Parameters<ExtensionAPI["registerTool"]>[0]["execute"]>[4];
|
|
52
|
+
|
|
53
|
+
export interface RuntimeCacheEntry {
|
|
54
|
+
runtime: Awaited<ReturnType<typeof loadGraphRuntime>>["runtime"];
|
|
55
|
+
parsers: Record<Language, ParserBackend>;
|
|
56
|
+
paths: WorktreePaths;
|
|
57
|
+
store: Store;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let runtimeCache: RuntimeCacheEntry | null = null;
|
|
61
|
+
|
|
62
|
+
export async function ensureRuntime(workdir: string, ctx: CodeGraphContext): Promise<{ entry: RuntimeCacheEntry; status: RuntimeStatus } | null> {
|
|
63
|
+
const { runtime, status } = await loadGraphRuntime();
|
|
64
|
+
if (status.issues.length > 0 && !status.sqliteAvailable && !status.parserAvailable) {
|
|
65
|
+
ctx.ui?.notify?.(`code-graph unavailable: ${describeRuntimeIssues(status).join("; ")}`, "warning");
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const paths = resolveCanonicalWorktree(workdir);
|
|
69
|
+
if (!runtimeCache || runtimeCache.paths.codeGraphDb !== paths.codeGraphDb) {
|
|
70
|
+
if (runtimeCache) {
|
|
71
|
+
runtimeCache.store.close();
|
|
72
|
+
runtimeCache = null;
|
|
73
|
+
}
|
|
74
|
+
const store = new Store({ dbPath: paths.codeGraphDb, worktreeRoot: paths.worktreeRoot, gitCommonDir: paths.gitCommonDir }, runtime.sqlite);
|
|
75
|
+
try {
|
|
76
|
+
store.checkWorktree(paths.worktreeRoot, paths.gitCommonDir);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
store.close();
|
|
79
|
+
ctx.ui?.notify?.(`code-graph: ${(error as Error).message}`, "error");
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const ParserCtor = runtime.parser.Parser as unknown as new () => { parse(input: string | Buffer): unknown; setLanguage(language: unknown): void };
|
|
83
|
+
const parsers: Record<Language, ParserBackend> = {
|
|
84
|
+
javascript: makeBackend("javascript", ParserCtor, runtime.parser.javascript),
|
|
85
|
+
typescript: makeBackend("typescript", ParserCtor, runtime.parser.typescript),
|
|
86
|
+
tsx: makeBackend("tsx", ParserCtor, runtime.parser.tsx),
|
|
87
|
+
python: new PythonBackend(ParserCtor, runtime.parser.python),
|
|
88
|
+
};
|
|
89
|
+
runtimeCache = { runtime, parsers, paths, store };
|
|
90
|
+
}
|
|
91
|
+
return { entry: runtimeCache, status };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function registerCodeGraphTool(pi: ExtensionAPI): void {
|
|
95
|
+
pi.registerTool({
|
|
96
|
+
name: "code_graph",
|
|
97
|
+
label: "Code Graph",
|
|
98
|
+
description:
|
|
99
|
+
"Read-only code-graph actions: status, screening (no full_code), function read, reindex guard, manifest summary. No agent mutation API in v1.",
|
|
100
|
+
promptSnippet: "Read-only code graph queries",
|
|
101
|
+
parameters: CodeGraphParams,
|
|
102
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
103
|
+
const workdir = params.workdir ?? ctx.cwd;
|
|
104
|
+
const ensured = await ensureRuntime(workdir, ctx);
|
|
105
|
+
if (!ensured) {
|
|
106
|
+
return {
|
|
107
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "runtime unavailable" }) }],
|
|
108
|
+
details: {},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const { entry } = ensured;
|
|
112
|
+
switch (params.action) {
|
|
113
|
+
case "status":
|
|
114
|
+
return {
|
|
115
|
+
content: [
|
|
116
|
+
{
|
|
117
|
+
type: "text",
|
|
118
|
+
text: JSON.stringify({
|
|
119
|
+
ok: true,
|
|
120
|
+
dbPath: entry.paths.codeGraphDb,
|
|
121
|
+
worktreeRoot: entry.paths.worktreeRoot,
|
|
122
|
+
files: entry.store.read(() => entry.store.db.prepare("SELECT COUNT(*) AS c FROM files").get()) as { c: number } | undefined,
|
|
123
|
+
functions: entry.store.read(() => entry.store.db.prepare("SELECT COUNT(*) AS c FROM functions").get()) as { c: number } | undefined,
|
|
124
|
+
}),
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
details: {},
|
|
128
|
+
};
|
|
129
|
+
case "screening": {
|
|
130
|
+
const items = screeningQuery({
|
|
131
|
+
store: entry.store,
|
|
132
|
+
language: params.language,
|
|
133
|
+
functionNameLike: params.functionName,
|
|
134
|
+
limit: params.limit ?? 100,
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
content: [{ type: "text", text: JSON.stringify({ ok: true, items }) }],
|
|
138
|
+
details: {},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
case "get-function": {
|
|
142
|
+
if (!params.functionName) {
|
|
143
|
+
return {
|
|
144
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "functionName required" }) }],
|
|
145
|
+
details: {},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const row = entry.store.read(() =>
|
|
149
|
+
entry.store.db
|
|
150
|
+
.prepare(
|
|
151
|
+
`SELECT file_dir, file_name, function_name, full_code, render_code,
|
|
152
|
+
full_code_hash, render_code_hash, version, kind
|
|
153
|
+
FROM functions
|
|
154
|
+
WHERE function_name = ? LIMIT 1`,
|
|
155
|
+
)
|
|
156
|
+
.get(params.functionName),
|
|
157
|
+
) as
|
|
158
|
+
| {
|
|
159
|
+
file_dir: string;
|
|
160
|
+
file_name: string;
|
|
161
|
+
function_name: string;
|
|
162
|
+
full_code: string;
|
|
163
|
+
render_code: string;
|
|
164
|
+
full_code_hash: string;
|
|
165
|
+
render_code_hash: string;
|
|
166
|
+
version: number;
|
|
167
|
+
kind: string;
|
|
168
|
+
}
|
|
169
|
+
| undefined;
|
|
170
|
+
if (!row) {
|
|
171
|
+
return {
|
|
172
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "not found" }) }],
|
|
173
|
+
details: {},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
content: [{ type: "text", text: JSON.stringify({ ok: true, function: row }) }],
|
|
178
|
+
details: {},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
case "update-function": {
|
|
182
|
+
if (!params.fileDir || !params.fileName || !params.functionName || typeof params.fullCode !== "string") {
|
|
183
|
+
return {
|
|
184
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "fileDir, fileName, functionName, and fullCode are required" }) }],
|
|
185
|
+
details: {},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const result = updateFunction(entry.store, {
|
|
189
|
+
fileDir: params.fileDir,
|
|
190
|
+
fileName: params.fileName,
|
|
191
|
+
functionName: params.functionName,
|
|
192
|
+
fullCode: params.fullCode,
|
|
193
|
+
});
|
|
194
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: {} };
|
|
195
|
+
}
|
|
196
|
+
case "update-file": {
|
|
197
|
+
if (!params.fileDir || !params.fileName || typeof params.text !== "string") {
|
|
198
|
+
return {
|
|
199
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "fileDir, fileName, and text are required" }) }],
|
|
200
|
+
details: {},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const result = updateFile(entry.store, {
|
|
204
|
+
fileDir: params.fileDir,
|
|
205
|
+
fileName: params.fileName,
|
|
206
|
+
text: params.text,
|
|
207
|
+
...(params.language ? { language: params.language } : {}),
|
|
208
|
+
});
|
|
209
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: {} };
|
|
210
|
+
}
|
|
211
|
+
case "delete-file": {
|
|
212
|
+
if (!params.fileDir || !params.fileName) {
|
|
213
|
+
return {
|
|
214
|
+
content: [{ type: "text", text: JSON.stringify({ ok: false, reason: "fileDir and fileName are required" }) }],
|
|
215
|
+
details: {},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const result = deleteFile(entry.store, { fileDir: params.fileDir, fileName: params.fileName });
|
|
219
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: {} };
|
|
220
|
+
}
|
|
221
|
+
case "list-pending": {
|
|
222
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: true, pending: listPending(entry.store) }) }], details: {} };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case "reindex":
|
|
226
|
+
return {
|
|
227
|
+
content: [
|
|
228
|
+
{
|
|
229
|
+
type: "text",
|
|
230
|
+
text: JSON.stringify({
|
|
231
|
+
ok: false,
|
|
232
|
+
reason: "reindex must run via the /init-graph slash command (D-013)",
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
details: {},
|
|
237
|
+
};
|
|
238
|
+
case "manifest": {
|
|
239
|
+
const rows = entry.store.read(() =>
|
|
240
|
+
entry.store.db
|
|
241
|
+
.prepare(
|
|
242
|
+
`SELECT file_dir, file_name, COUNT(*) AS c FROM file_entries GROUP BY file_dir, file_name ORDER BY file_dir, file_name`,
|
|
243
|
+
)
|
|
244
|
+
.all(),
|
|
245
|
+
) as Array<{ file_dir: string; file_name: string; c: number }>;
|
|
246
|
+
return {
|
|
247
|
+
content: [{ type: "text", text: JSON.stringify({ ok: true, manifest: rows }) }],
|
|
248
|
+
details: {},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
package/tools/execute-plan.ts
CHANGED
|
@@ -9,25 +9,12 @@ import { Type } from "typebox";
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import {
|
|
12
|
-
chooseExecutionModelSelection,
|
|
13
|
-
snapshotCurrentModelSelector,
|
|
14
12
|
startExecution,
|
|
15
|
-
type ExecutionModelState,
|
|
16
13
|
} from "../src/exec.ts";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
14
|
+
import { disableAutoComplete } from "../src/autocomplete.ts";
|
|
15
|
+
import { latestPlanVersion, parseChecklist, parseImplItems } from "../src/plan.ts";
|
|
16
|
+
import { normalizeWorkdir, readActive } from "../src/state.ts";
|
|
19
17
|
|
|
20
|
-
function loadExecutionModelConfig(workdir: string): ExecutionConfig {
|
|
21
|
-
const root = resolveStateRootOrNull(workdir);
|
|
22
|
-
if (!root) {
|
|
23
|
-
return { model_selector: null, source: "unset", updated_at: null };
|
|
24
|
-
}
|
|
25
|
-
try {
|
|
26
|
-
return loadConfig(root).execution;
|
|
27
|
-
} catch {
|
|
28
|
-
return { model_selector: null, source: "unset", updated_at: null };
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
18
|
|
|
32
19
|
const ExecutePlanParams = Type.Object({
|
|
33
20
|
planPath: Type.Optional(
|
|
@@ -82,6 +69,7 @@ export async function executeHandoff(
|
|
|
82
69
|
}
|
|
83
70
|
const implItems = parseImplItems(planText);
|
|
84
71
|
|
|
72
|
+
disableAutoComplete(ctx, "execution handoff");
|
|
85
73
|
if (!ctx.hasUI) {
|
|
86
74
|
return {
|
|
87
75
|
status: "error",
|
|
@@ -99,33 +87,13 @@ export async function executeHandoff(
|
|
|
99
87
|
return { status: "declined", message: "User declined execution. Stay in planning; ask how to proceed.", planPath };
|
|
100
88
|
}
|
|
101
89
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const planningSelector = snapshotCurrentModelSelector(ctx);
|
|
105
|
-
const selection = await chooseExecutionModelSelection(
|
|
106
|
-
ctx,
|
|
107
|
-
"Execution model",
|
|
108
|
-
configuredExecution,
|
|
109
|
-
async (selector) => {
|
|
110
|
-
await setExecutionModel(workdir, { modelSelector: selector, source: "user" });
|
|
111
|
-
},
|
|
112
|
-
);
|
|
113
|
-
if (!selection) {
|
|
114
|
-
return { status: "error", message: "No usable execution model; execution cancelled." };
|
|
115
|
-
}
|
|
116
|
-
if (configuredExecution.source === "unset" || configuredExecution.model_selector !== null) {
|
|
117
|
-
modelState = { planningSelector, executionSelector: selection.selector };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
await startExecution(getCurrentApi(), ctx, planPath, items, modelState, implItems);
|
|
121
|
-
const countedImpls = implItems.filter((impl) => items.some((item) => extractCoverage(item.text).includes(impl.id)));
|
|
122
|
-
const modelNote = modelState ? ` Execution model: ${modelState.executionSelector}.` : "";
|
|
123
|
-
const scopeNote = countedImpls.length ? ` Tracking ${countedImpls.length} implementation item(s).` : "";
|
|
90
|
+
await startExecution(getCurrentApi(), ctx, planPath, items, implItems);
|
|
91
|
+
const scopeNote = implItems.length ? ` Tracking ${implItems.length} implementation item(s).` : "";
|
|
124
92
|
return {
|
|
125
93
|
status: "executing",
|
|
126
94
|
planPath,
|
|
127
95
|
itemCount: items.length,
|
|
128
|
-
message: `Execution approved. ${items.length} verifier item(s) queued; implement in dependency order and mark verified items with [DONE:VC-xxx].${scopeNote}
|
|
96
|
+
message: `Execution approved. ${items.length} verifier item(s) queued; implement in dependency order and mark verified items with [DONE:VC-xxx].${scopeNote}`,
|
|
129
97
|
};
|
|
130
98
|
}
|
|
131
99
|
|