pi-plans 0.2.0 → 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 +74 -21
- package/index.ts +115 -9
- package/package.json +7 -1
- package/references/pi-planning-workflow.md +18 -3
- package/references/state-and-config.md +34 -2
- package/scripts/validate.ts +4 -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 +1125 -371
- package/src/config-command.ts +326 -0
- package/src/exec.ts +356 -686
- package/src/refine-prompts.ts +50 -0
- package/src/refine-ui-helpers.ts +71 -18
- package/src/refine-ui-state.ts +87 -21
- package/src/refine-ui.ts +210 -102
- package/src/state.ts +19 -6
- package/src/subagent.ts +163 -61
- package/tests/ask-choice.test.ts +263 -0
- package/tests/autocomplete.test.ts +6 -1
- 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 +371 -57
- package/tests/config-command.test.ts +255 -0
- package/tests/exec.test.ts +665 -241
- 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/refine-prompts.test.ts +67 -2
- package/tests/refine-ui.test.ts +337 -72
- package/tests/subagent.test.ts +26 -20
- package/tools/ask-choice.ts +158 -11
- package/tools/code-graph.ts +254 -0
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +84 -1
- package/tools/refine.ts +61 -15
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_MAX_BYTES,
|
|
4
|
+
DEFAULT_MAX_LINES,
|
|
5
|
+
createEditTool,
|
|
6
|
+
createReadTool,
|
|
7
|
+
createWriteTool,
|
|
8
|
+
truncateHead,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { isIndexablePath } from "../src/code-graph/discovery.ts";
|
|
13
|
+
import { resolveGraphMode, type GraphMode } from "../src/code-graph/mode.ts";
|
|
14
|
+
import { normalizeRelative, PathError } from "../src/code-graph/paths.ts";
|
|
15
|
+
import { updateFile } from "../src/code-graph/mutations.ts";
|
|
16
|
+
import type { Language } from "../src/code-graph/types.ts";
|
|
17
|
+
import { ensureRuntime, type CodeGraphContext, type RuntimeCacheEntry } from "./code-graph.ts";
|
|
18
|
+
|
|
19
|
+
interface GraphPathInfo {
|
|
20
|
+
absolutePath: string;
|
|
21
|
+
relativePath: string;
|
|
22
|
+
fileDir: string;
|
|
23
|
+
fileName: string;
|
|
24
|
+
language: Language;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GraphSnapshot {
|
|
28
|
+
info: GraphPathInfo;
|
|
29
|
+
text: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface FunctionRow {
|
|
33
|
+
function_name: string;
|
|
34
|
+
is_primary: number;
|
|
35
|
+
provenance_start_line: number | null;
|
|
36
|
+
provenance_end_line: number | null;
|
|
37
|
+
provenance_start_byte: number | null;
|
|
38
|
+
provenance_end_byte: number | null;
|
|
39
|
+
summary_description: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type GraphToolSet = ReturnType<typeof createGraphAwareFileTools>;
|
|
43
|
+
|
|
44
|
+
/** Files smaller than this are returned in full by default (digest not worth it). */
|
|
45
|
+
const FULL_FILE_MAX_LINES = 200;
|
|
46
|
+
/** Hard cap for digest output, header/footer lines included. */
|
|
47
|
+
const DIGEST_MAX_LINES = 50;
|
|
48
|
+
/** Native-equivalent full-read truncation hints are provided by truncateHead. */
|
|
49
|
+
|
|
50
|
+
const toolCache = new Map<string, GraphToolSet>();
|
|
51
|
+
|
|
52
|
+
function getBaseTools(cwd: string) {
|
|
53
|
+
return {
|
|
54
|
+
read: createReadTool(cwd),
|
|
55
|
+
write: createWriteTool(cwd),
|
|
56
|
+
edit: createEditTool(cwd),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const GraphReadParams = Type.Object({
|
|
61
|
+
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
|
|
62
|
+
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
|
|
63
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
|
64
|
+
full: Type.Optional(
|
|
65
|
+
Type.Boolean({
|
|
66
|
+
description:
|
|
67
|
+
"Return the whole file (through the same safety truncation as native read) instead of the default function digest",
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function languageForFileName(fileName: string): Language | null {
|
|
73
|
+
switch (path.extname(fileName).toLowerCase()) {
|
|
74
|
+
case ".js":
|
|
75
|
+
case ".mjs":
|
|
76
|
+
case ".cjs":
|
|
77
|
+
return "javascript";
|
|
78
|
+
case ".ts":
|
|
79
|
+
return "typescript";
|
|
80
|
+
case ".tsx":
|
|
81
|
+
return "tsx";
|
|
82
|
+
case ".py":
|
|
83
|
+
return "python";
|
|
84
|
+
default:
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveGraphPath(cwd: string, worktreeRoot: string, target: string): GraphPathInfo | null {
|
|
90
|
+
const absolutePath = path.resolve(cwd, target);
|
|
91
|
+
const relativePath = path.relative(worktreeRoot, absolutePath).split(path.sep).join("/");
|
|
92
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return null;
|
|
93
|
+
if (!isIndexablePath(relativePath)) return null;
|
|
94
|
+
try {
|
|
95
|
+
const { fileDir, fileName } = normalizeRelative(worktreeRoot, absolutePath);
|
|
96
|
+
const language = languageForFileName(fileName);
|
|
97
|
+
if (!language) return null;
|
|
98
|
+
return { absolutePath, relativePath, fileDir, fileName, language };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error instanceof PathError) return null;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sliceByLines(text: string, offset?: number, limit?: number): string {
|
|
106
|
+
const lines = text.split("\n");
|
|
107
|
+
const start = offset ? Math.max(0, offset - 1) : 0;
|
|
108
|
+
const end = limit !== undefined ? start + Math.max(0, limit) : lines.length;
|
|
109
|
+
return lines.slice(start, end).join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function countLines(text: string): number {
|
|
113
|
+
return text.split("\n").length;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function textContent(result: { content: Array<{ type: string; text?: string }> }): string {
|
|
117
|
+
const part = result.content.find((entry) => entry.type === "text" && typeof entry.text === "string");
|
|
118
|
+
return part?.text ?? "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function loadGraphSnapshot(entry: RuntimeCacheEntry, info: GraphPathInfo): GraphSnapshot | null {
|
|
122
|
+
const row = entry.store.read(() =>
|
|
123
|
+
entry.store.db
|
|
124
|
+
.prepare(`SELECT source_text, pending_kind FROM files WHERE file_dir = ? AND file_name = ?`)
|
|
125
|
+
.get(info.fileDir, info.fileName),
|
|
126
|
+
) as { source_text: string; pending_kind: string | null } | undefined;
|
|
127
|
+
if (!row || row.pending_kind === "delete") return null;
|
|
128
|
+
return {
|
|
129
|
+
info,
|
|
130
|
+
text: row.source_text,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function loadFunctionRows(entry: RuntimeCacheEntry, info: GraphPathInfo): FunctionRow[] {
|
|
135
|
+
return entry.store.read(() =>
|
|
136
|
+
entry.store.db
|
|
137
|
+
.prepare(
|
|
138
|
+
`SELECT function_name, is_primary, provenance_start_line, provenance_end_line,
|
|
139
|
+
provenance_start_byte, provenance_end_byte, summary_description
|
|
140
|
+
FROM functions WHERE file_dir = ? AND file_name = ?`,
|
|
141
|
+
)
|
|
142
|
+
.all(info.fileDir, info.fileName),
|
|
143
|
+
) as FunctionRow[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Provenance offsets are byte offsets: decode via Buffer, never string.slice. */
|
|
147
|
+
function describeSlice(text: string, startByte: number | null, endByte: number | null): string {
|
|
148
|
+
if (startByte === null || endByte === null || endByte <= startByte) return "";
|
|
149
|
+
try {
|
|
150
|
+
const buf = Buffer.from(text, "utf8");
|
|
151
|
+
const slice = buf.subarray(startByte, Math.min(endByte, buf.length)).toString("utf8");
|
|
152
|
+
return slice.split("\n")[0]?.trim() ?? "";
|
|
153
|
+
} catch {
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function truncatedDetails(
|
|
159
|
+
truncation: ReturnType<typeof truncateHead>,
|
|
160
|
+
totalLines: number,
|
|
161
|
+
): Record<string, unknown> {
|
|
162
|
+
// Counts only — never the content copy; the text already lives in `content`.
|
|
163
|
+
return {
|
|
164
|
+
truncated: truncation.truncated === true,
|
|
165
|
+
truncatedBy: truncation.truncatedBy ?? null,
|
|
166
|
+
totalLines,
|
|
167
|
+
outputLines: truncation.outputLines,
|
|
168
|
+
outputBytes: truncation.outputBytes,
|
|
169
|
+
maxLines: truncation.maxLines,
|
|
170
|
+
maxBytes: truncation.maxBytes,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function fullTextResult(text: string, offset?: number, limit?: number) {
|
|
175
|
+
const totalLines = countLines(text);
|
|
176
|
+
if (offset !== undefined && offset > totalLines) {
|
|
177
|
+
// Align with native read: an offset past EOF is a caller error.
|
|
178
|
+
throw new Error(`offset ${offset} beyond end of file (${totalLines} lines)`);
|
|
179
|
+
}
|
|
180
|
+
const selected = sliceByLines(text, offset, limit);
|
|
181
|
+
const truncation = truncateHead(selected, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: "text", text: truncation.content }],
|
|
184
|
+
details: truncatedDetails(truncation, totalLines),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Compact function digest: one line per named function, synthetic anonymous
|
|
190
|
+
* entries folded into a single count, capped at DIGEST_MAX_LINES with a tail
|
|
191
|
+
* pointer to the low-token graph tools.
|
|
192
|
+
*/
|
|
193
|
+
function buildDigest(info: GraphPathInfo, text: string, rows: FunctionRow[]) {
|
|
194
|
+
const totalLines = countLines(text);
|
|
195
|
+
const named: FunctionRow[] = [];
|
|
196
|
+
let anonymous = 0;
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
if (row.function_name.includes("<anonymous")) {
|
|
199
|
+
anonymous++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
named.push(row);
|
|
203
|
+
}
|
|
204
|
+
named.sort((a, b) => {
|
|
205
|
+
const primary = (b.is_primary ? 1 : 0) - (a.is_primary ? 1 : 0);
|
|
206
|
+
if (primary !== 0) return primary;
|
|
207
|
+
return (a.provenance_start_line ?? 0) - (b.provenance_start_line ?? 0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const lines: string[] = [
|
|
211
|
+
`${info.relativePath} · ${info.language} · ${named.length} functions${anonymous ? ` (+${anonymous} anonymous)` : ""}`,
|
|
212
|
+
];
|
|
213
|
+
const bodyBudget = DIGEST_MAX_LINES - 2; // header + footer
|
|
214
|
+
for (const row of named) {
|
|
215
|
+
if (lines.length >= bodyBudget - 1) break; // reserve one line for the "+M more" tail
|
|
216
|
+
const description = (row.summary_description ?? "").trim() || describeSlice(text, row.provenance_start_byte, row.provenance_end_byte);
|
|
217
|
+
const range =
|
|
218
|
+
row.provenance_start_line && row.provenance_end_line ? ` (${row.provenance_start_line}-${row.provenance_end_line})` : "";
|
|
219
|
+
lines.push(`${row.function_name}${range}${description ? ` ${description}` : ""}`);
|
|
220
|
+
}
|
|
221
|
+
const hidden = named.length - (lines.length - 1);
|
|
222
|
+
if (hidden > 0) {
|
|
223
|
+
lines.push(`…+${hidden} more (code_graph screening / get-function)`);
|
|
224
|
+
}
|
|
225
|
+
lines.push(`Use full:true for the whole file (${totalLines} lines, safety-truncated), or code_graph get-function for one function body.`);
|
|
226
|
+
return {
|
|
227
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
228
|
+
details: { functions: named.length, anonymous, shown: Math.max(0, lines.length - 2), hidden: Math.max(0, hidden), totalLines },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function createGraphReadTool(cwd: string) {
|
|
233
|
+
const base = getBaseTools(cwd).read;
|
|
234
|
+
return {
|
|
235
|
+
...base,
|
|
236
|
+
parameters: GraphReadParams,
|
|
237
|
+
async execute(
|
|
238
|
+
toolCallId: string,
|
|
239
|
+
params: { path: string; offset?: number; limit?: number; full?: boolean },
|
|
240
|
+
signal: AbortSignal | undefined,
|
|
241
|
+
onUpdate: any,
|
|
242
|
+
ctx: CodeGraphContext,
|
|
243
|
+
) {
|
|
244
|
+
const native = async (marker: string | null) => {
|
|
245
|
+
const result = await getBaseTools(ctx.cwd).read.execute(toolCallId, params, signal, onUpdate);
|
|
246
|
+
if (!marker) return result;
|
|
247
|
+
return {
|
|
248
|
+
...result,
|
|
249
|
+
content: [{ type: "text", text: `[graph-read fallback: ${marker} → native]\n${textContent(result)}` }],
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
const mode: GraphMode = resolveGraphMode(ctx.cwd);
|
|
253
|
+
if (mode === "off") return native(null);
|
|
254
|
+
if (mode === "config-unavailable") return native("config read failed");
|
|
255
|
+
const ensured = await ensureRuntime(ctx.cwd, ctx);
|
|
256
|
+
if (!ensured) return native("runtime unavailable");
|
|
257
|
+
const info = resolveGraphPath(ctx.cwd, ensured.entry.paths.worktreeRoot, params.path);
|
|
258
|
+
if (!info) return native(null); // not an indexable source file: native by design
|
|
259
|
+
const snapshot = loadGraphSnapshot(ensured.entry, info);
|
|
260
|
+
if (!snapshot) return native("not indexed");
|
|
261
|
+
const wantsFull = params.full === true || params.offset !== undefined || params.limit !== undefined;
|
|
262
|
+
if (wantsFull) return fullTextResult(snapshot.text, params.offset, params.limit);
|
|
263
|
+
const rows = loadFunctionRows(ensured.entry, info);
|
|
264
|
+
if (countLines(snapshot.text) < FULL_FILE_MAX_LINES || rows.length === 0) {
|
|
265
|
+
return fullTextResult(snapshot.text, params.offset, params.limit);
|
|
266
|
+
}
|
|
267
|
+
return buildDigest(info, snapshot.text, rows);
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function createGraphWriteTool(cwd: string) {
|
|
273
|
+
const base = getBaseTools(cwd).write;
|
|
274
|
+
return {
|
|
275
|
+
...base,
|
|
276
|
+
async execute(toolCallId: string, params: { path: string; content: string }, signal: AbortSignal | undefined, onUpdate: any, ctx: CodeGraphContext) {
|
|
277
|
+
const stage = async (marker: string | null) => {
|
|
278
|
+
const result = await getBaseTools(ctx.cwd).write.execute(toolCallId, params, signal, onUpdate);
|
|
279
|
+
if (!marker) return result;
|
|
280
|
+
return {
|
|
281
|
+
...result,
|
|
282
|
+
content: [{ type: "text", text: `[graph-write fallback: ${marker} → native]\n${textContent(result)}` }],
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
const mode: GraphMode = resolveGraphMode(ctx.cwd);
|
|
286
|
+
if (mode === "off") return stage(null);
|
|
287
|
+
if (mode === "config-unavailable") return stage("config read failed");
|
|
288
|
+
const ensured = await ensureRuntime(ctx.cwd, ctx);
|
|
289
|
+
if (!ensured) return stage("runtime unavailable");
|
|
290
|
+
const info = resolveGraphPath(ctx.cwd, ensured.entry.paths.worktreeRoot, params.path);
|
|
291
|
+
if (!info) return stage(null);
|
|
292
|
+
const mutation = updateFile(ensured.entry.store, {
|
|
293
|
+
fileDir: info.fileDir,
|
|
294
|
+
fileName: info.fileName,
|
|
295
|
+
text: params.content,
|
|
296
|
+
language: info.language,
|
|
297
|
+
});
|
|
298
|
+
if (!mutation.ok) {
|
|
299
|
+
throw new Error(`code graph write failed: ${mutation.reason ?? "unknown error"}`);
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
content: [
|
|
303
|
+
{
|
|
304
|
+
type: "text",
|
|
305
|
+
text: `code graph: staged ${mutation.created ? "new" : "updated"} file ${info.relativePath}; run /apply-graph to materialize`,
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
details: {},
|
|
309
|
+
};
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function createGraphEditTool(cwd: string) {
|
|
315
|
+
const base = getBaseTools(cwd).edit;
|
|
316
|
+
return {
|
|
317
|
+
...base,
|
|
318
|
+
async execute(toolCallId: string, params: { path: string; edits: Array<{ oldText: string; newText: string }> }, signal: AbortSignal | undefined, onUpdate: any, ctx: CodeGraphContext) {
|
|
319
|
+
const stage = async (marker: string | null) => {
|
|
320
|
+
const result = await getBaseTools(ctx.cwd).edit.execute(toolCallId, params, signal, onUpdate);
|
|
321
|
+
if (!marker) return result;
|
|
322
|
+
return {
|
|
323
|
+
...result,
|
|
324
|
+
content: [{ type: "text", text: `[graph-edit fallback: ${marker} → native]\n${textContent(result)}` }],
|
|
325
|
+
};
|
|
326
|
+
};
|
|
327
|
+
const mode: GraphMode = resolveGraphMode(ctx.cwd);
|
|
328
|
+
if (mode === "off") return stage(null);
|
|
329
|
+
if (mode === "config-unavailable") return stage("config read failed");
|
|
330
|
+
const ensured = await ensureRuntime(ctx.cwd, ctx);
|
|
331
|
+
if (!ensured) return stage("runtime unavailable");
|
|
332
|
+
const info = resolveGraphPath(ctx.cwd, ensured.entry.paths.worktreeRoot, params.path);
|
|
333
|
+
if (!info) return stage(null);
|
|
334
|
+
const snapshot = loadGraphSnapshot(ensured.entry, info);
|
|
335
|
+
if (!snapshot) {
|
|
336
|
+
throw new Error(`code graph: ${info.relativePath} is not indexed; run /update-graph or /init-graph first`);
|
|
337
|
+
}
|
|
338
|
+
let stagedText: string | null = null;
|
|
339
|
+
const graphEdit = createEditTool(ctx.cwd, {
|
|
340
|
+
operations: {
|
|
341
|
+
access: async () => {},
|
|
342
|
+
readFile: async () => Buffer.from(snapshot.text, "utf8"),
|
|
343
|
+
writeFile: async (_absolutePath: string, content: string) => {
|
|
344
|
+
stagedText = content;
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
const result = await graphEdit.execute(toolCallId, params, signal, onUpdate);
|
|
349
|
+
if (stagedText === null) {
|
|
350
|
+
throw new Error(`code graph edit failed: no staged content captured for ${info.relativePath}`);
|
|
351
|
+
}
|
|
352
|
+
const mutation = updateFile(ensured.entry.store, {
|
|
353
|
+
fileDir: info.fileDir,
|
|
354
|
+
fileName: info.fileName,
|
|
355
|
+
text: stagedText,
|
|
356
|
+
language: info.language,
|
|
357
|
+
});
|
|
358
|
+
if (!mutation.ok) {
|
|
359
|
+
throw new Error(`code graph edit failed: ${mutation.reason ?? "unknown error"}`);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
...result,
|
|
363
|
+
content: [
|
|
364
|
+
{
|
|
365
|
+
type: "text",
|
|
366
|
+
text: `${textContent(result)} (staged in code graph; run /apply-graph to materialize)`,
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
};
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function createGraphAwareFileTools(cwd: string) {
|
|
375
|
+
let tools = toolCache.get(cwd);
|
|
376
|
+
if (!tools) {
|
|
377
|
+
tools = {
|
|
378
|
+
read: createGraphReadTool(cwd),
|
|
379
|
+
write: createGraphWriteTool(cwd),
|
|
380
|
+
edit: createGraphEditTool(cwd),
|
|
381
|
+
};
|
|
382
|
+
toolCache.set(cwd, tools);
|
|
383
|
+
}
|
|
384
|
+
return tools;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function registerGraphAwareFileTools(pi: ExtensionAPI, cwd = process.cwd()): void {
|
|
388
|
+
const tools = createGraphAwareFileTools(cwd);
|
|
389
|
+
pi.registerTool(tools.read);
|
|
390
|
+
pi.registerTool(tools.write);
|
|
391
|
+
pi.registerTool(tools.edit);
|
|
392
|
+
}
|
package/tools/plans.ts
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
7
7
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Type } from "typebox";
|
|
9
|
+
import { computeDrift } from "../src/code-graph/commands.ts";
|
|
10
|
+
import { gitAddAllAndCommit } from "../src/code-graph/git.ts";
|
|
11
|
+
import { loadGraphRuntime } from "../src/code-graph/runtime.ts";
|
|
12
|
+
import { resolveCanonicalWorktree } from "../src/code-graph/paths.ts";
|
|
13
|
+
import { Store } from "../src/code-graph/store.ts";
|
|
9
14
|
import {
|
|
10
15
|
initState,
|
|
11
16
|
loadConfig,
|
|
@@ -15,6 +20,7 @@ import {
|
|
|
15
20
|
recordSubagent,
|
|
16
21
|
resolveStateRootOrNull,
|
|
17
22
|
setArtifactRoot,
|
|
23
|
+
setGraphEnabled,
|
|
18
24
|
setLanguage,
|
|
19
25
|
setRunStatus,
|
|
20
26
|
setRole,
|
|
@@ -31,9 +37,11 @@ const PlansParams = Type.Object({
|
|
|
31
37
|
"show",
|
|
32
38
|
"set-language",
|
|
33
39
|
"set-artifact-root",
|
|
40
|
+
"set-graph-enabled",
|
|
34
41
|
"set-role",
|
|
35
42
|
"start-run",
|
|
36
43
|
"set-status",
|
|
44
|
+
"final-commit",
|
|
37
45
|
"record-decision",
|
|
38
46
|
"record-ref",
|
|
39
47
|
"record-subagent",
|
|
@@ -50,6 +58,8 @@ const PlansParams = Type.Object({
|
|
|
50
58
|
languageSource: Type.Optional(StringEnum(["user", "auto"] as const)),
|
|
51
59
|
artifactRoot: Type.Optional(Type.String({ description: "set-artifact-root: planning docs root, e.g. ./docs/pi-plans" })),
|
|
52
60
|
artifactRootSource: Type.Optional(StringEnum(["user", "auto"] as const)),
|
|
61
|
+
enabled: Type.Optional(Type.Boolean({ description: "set-graph-enabled: enable/disable the code graph" })),
|
|
62
|
+
message: Type.Optional(Type.String({ description: "final-commit: commit message body" })),
|
|
53
63
|
role: Type.Optional(StringEnum(["reviewer", "criticizer"] as const)),
|
|
54
64
|
mode: Type.Optional(StringEnum(["delegated-subagent", "current-session"] as const)),
|
|
55
65
|
modelSelector: Type.Optional(
|
|
@@ -106,6 +116,54 @@ export function setRunStartAppender(appender: ((runId: string, artifactDir: stri
|
|
|
106
116
|
runStartAppender = appender;
|
|
107
117
|
}
|
|
108
118
|
|
|
119
|
+
/** plans action final-commit: gate on code-graph drift (zero pending +
|
|
120
|
+
* invariants (a)/(b) clean), then `git add -A` and commit the plan delivery.
|
|
121
|
+
* A clean tree is a safe no-op. Returns a machine-readable result. */
|
|
122
|
+
export async function finalCommit(
|
|
123
|
+
workdir: string,
|
|
124
|
+
message: string,
|
|
125
|
+
): Promise<{
|
|
126
|
+
ok: boolean;
|
|
127
|
+
committed: string | null;
|
|
128
|
+
noop: boolean;
|
|
129
|
+
reason?: string;
|
|
130
|
+
drift?: unknown;
|
|
131
|
+
}> {
|
|
132
|
+
const paths = resolveCanonicalWorktree(workdir);
|
|
133
|
+
const runtime = await loadGraphRuntime();
|
|
134
|
+
if (!runtime.status.sqliteAvailable) {
|
|
135
|
+
throw new StateError("final-commit requires node:sqlite (code graph unavailable)");
|
|
136
|
+
}
|
|
137
|
+
const store = new Store(
|
|
138
|
+
{ dbPath: paths.codeGraphDb, worktreeRoot: paths.worktreeRoot, gitCommonDir: paths.gitCommonDir },
|
|
139
|
+
runtime.runtime.sqlite,
|
|
140
|
+
);
|
|
141
|
+
try {
|
|
142
|
+
let drift: ReturnType<typeof computeDrift> | null = null;
|
|
143
|
+
try {
|
|
144
|
+
drift = computeDrift(store, paths.worktreeRoot);
|
|
145
|
+
} catch {
|
|
146
|
+
drift = null; // graph never initialized: fall through to plain commit
|
|
147
|
+
}
|
|
148
|
+
if (drift && (drift.pending.length > 0 || !drift.ok)) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
committed: null,
|
|
152
|
+
noop: false,
|
|
153
|
+
reason: `graph drift dirty: ${drift.recommendation}`,
|
|
154
|
+
drift,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const head = gitAddAllAndCommit(paths.worktreeRoot, message);
|
|
158
|
+
if (!head) {
|
|
159
|
+
return { ok: true, committed: null, noop: true, reason: "nothing to commit — tree already clean" };
|
|
160
|
+
}
|
|
161
|
+
return { ok: true, committed: head, noop: false };
|
|
162
|
+
} finally {
|
|
163
|
+
store.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
109
167
|
export function registerPlansTool(pi: ExtensionAPI): void {
|
|
110
168
|
setRunStartAppender((runId, artifactDir) => {
|
|
111
169
|
pi.appendEntry("pi-plans-run-start", { runId, artifactDir });
|
|
@@ -114,7 +172,7 @@ export function registerPlansTool(pi: ExtensionAPI): void {
|
|
|
114
172
|
name: "plans",
|
|
115
173
|
label: "Plans",
|
|
116
174
|
description:
|
|
117
|
-
"Manage pi-plans planning state in the target workspace: init/show config, set language and planning docs root plus reviewer/criticizer roles, start planning runs, record decisions/refs/subagents, and update run status. State lives in .git/pi_plans/ inside the resolved git common dir. Actions: init, show, set-language, set-artifact-root, set-role, start-run, set-status, record-decision, record-ref, record-subagent.",
|
|
175
|
+
"Manage pi-plans planning state in the target workspace: init/show config, set language and planning docs root plus reviewer/criticizer roles and the code-graph enabled flag, start planning runs, record decisions/refs/subagents, and update run status. State lives in .git/pi_plans/ inside the resolved git common dir. Actions: init, show, set-language, set-artifact-root, set-graph-enabled, set-role, start-run, set-status, final-commit, record-decision, record-ref, record-subagent.",
|
|
118
176
|
promptSnippet: "Manage pi-plans planning state, runs, and ledgers",
|
|
119
177
|
parameters: PlansParams,
|
|
120
178
|
|
|
@@ -126,12 +184,32 @@ export function registerPlansTool(pi: ExtensionAPI): void {
|
|
|
126
184
|
case "init": {
|
|
127
185
|
const ensured = initState(workdir);
|
|
128
186
|
result = { config: ensured.config, stateRoot: ensured.stateRoot, notices: ensured.notices };
|
|
187
|
+
if (ensured.config.graph_enabled === null) {
|
|
188
|
+
result = {
|
|
189
|
+
...(result as Record<string, unknown>),
|
|
190
|
+
hint: "graph_enabled is null (never asked). Ask the user once via ask_choice whether to enable the code graph (recommended: yes for repos with an initialized graph; see references/state-and-config.md), then persist with plans (action: set-graph-enabled, enabled: true|false). This question does not count against the planning-question limit.",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
129
193
|
break;
|
|
130
194
|
}
|
|
131
195
|
case "show": {
|
|
132
196
|
const config = showConfig(workdir);
|
|
133
197
|
const stateRoot = resolveStateRootOrNull(workdir);
|
|
134
198
|
result = { config, stateRoot };
|
|
199
|
+
if (config.graph_enabled === null) {
|
|
200
|
+
result = {
|
|
201
|
+
...(result as Record<string, unknown>),
|
|
202
|
+
hint: "graph_enabled is null (never asked). Ask the user once via ask_choice, then persist with plans (action: set-graph-enabled, enabled: true|false).",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case "set-graph-enabled": {
|
|
208
|
+
if (typeof params.enabled !== "boolean") {
|
|
209
|
+
throw new StateError("set-graph-enabled requires enabled (boolean)");
|
|
210
|
+
}
|
|
211
|
+
const updated = setGraphEnabled(workdir, params.enabled);
|
|
212
|
+
result = { config: updated.config, stateRoot: updated.stateRoot, notices: updated.notices };
|
|
135
213
|
break;
|
|
136
214
|
}
|
|
137
215
|
case "set-language": {
|
|
@@ -181,6 +259,11 @@ export function registerPlansTool(pi: ExtensionAPI): void {
|
|
|
181
259
|
result = setRunStatus(workdir, params.runId, params.status);
|
|
182
260
|
break;
|
|
183
261
|
}
|
|
262
|
+
case "final-commit": {
|
|
263
|
+
if (!params.message) throw new StateError("final-commit requires message");
|
|
264
|
+
result = await finalCommit(workdir, params.message);
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
184
267
|
case "record-decision": {
|
|
185
268
|
if (!params.runId || !params.decision) {
|
|
186
269
|
throw new StateError("record-decision requires runId and decision");
|