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.
Files changed (65) hide show
  1. package/README.md +74 -21
  2. package/index.ts +115 -9
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +18 -3
  5. package/references/state-and-config.md +34 -2
  6. package/scripts/validate.ts +4 -0
  7. package/src/code-graph/commands.ts +437 -0
  8. package/src/code-graph/discovery.ts +118 -0
  9. package/src/code-graph/git.ts +108 -0
  10. package/src/code-graph/identity.ts +59 -0
  11. package/src/code-graph/indexer.ts +281 -0
  12. package/src/code-graph/materialize.ts +166 -0
  13. package/src/code-graph/mode.ts +28 -0
  14. package/src/code-graph/mutations.ts +160 -0
  15. package/src/code-graph/parser.ts +51 -0
  16. package/src/code-graph/parsers/javascript.ts +35 -0
  17. package/src/code-graph/parsers/python.ts +160 -0
  18. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  19. package/src/code-graph/paths.ts +85 -0
  20. package/src/code-graph/prompts.ts +18 -0
  21. package/src/code-graph/resolver.ts +69 -0
  22. package/src/code-graph/runtime.ts +158 -0
  23. package/src/code-graph/schema.ts +135 -0
  24. package/src/code-graph/screening.ts +82 -0
  25. package/src/code-graph/store.ts +278 -0
  26. package/src/code-graph/summary.ts +435 -0
  27. package/src/code-graph/types.ts +163 -0
  28. package/src/compaction.ts +1125 -371
  29. package/src/config-command.ts +326 -0
  30. package/src/exec.ts +356 -686
  31. package/src/refine-prompts.ts +50 -0
  32. package/src/refine-ui-helpers.ts +71 -18
  33. package/src/refine-ui-state.ts +87 -21
  34. package/src/refine-ui.ts +210 -102
  35. package/src/state.ts +19 -6
  36. package/src/subagent.ts +163 -61
  37. package/tests/ask-choice.test.ts +263 -0
  38. package/tests/autocomplete.test.ts +6 -1
  39. package/tests/code-graph-apply.test.ts +185 -0
  40. package/tests/code-graph-commands.test.ts +211 -0
  41. package/tests/code-graph-db.test.ts +166 -0
  42. package/tests/code-graph-discovery.test.ts +38 -0
  43. package/tests/code-graph-git.test.ts +94 -0
  44. package/tests/code-graph-index.test.ts +175 -0
  45. package/tests/code-graph-loop.e2e.test.ts +159 -0
  46. package/tests/code-graph-mutations.test.ts +117 -0
  47. package/tests/code-graph-parser.test.ts +85 -0
  48. package/tests/code-graph-rollback.test.ts +100 -0
  49. package/tests/code-graph-summary-batching.test.ts +518 -0
  50. package/tests/code-graph-summary.test.ts +148 -0
  51. package/tests/compaction.test.ts +371 -57
  52. package/tests/config-command.test.ts +255 -0
  53. package/tests/exec.test.ts +665 -241
  54. package/tests/fixtures/code-graph/sample.js +36 -0
  55. package/tests/fixtures/code-graph/sample.py +20 -0
  56. package/tests/fixtures/code-graph/sample.ts +15 -0
  57. package/tests/graph-aware-file-tools.test.ts +411 -0
  58. package/tests/refine-prompts.test.ts +67 -2
  59. package/tests/refine-ui.test.ts +337 -72
  60. package/tests/subagent.test.ts +26 -20
  61. package/tools/ask-choice.ts +158 -11
  62. package/tools/code-graph.ts +254 -0
  63. package/tools/graph-aware-file-tools.ts +392 -0
  64. package/tools/plans.ts +84 -1
  65. 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");