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,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash-command handlers for /init-graph, /update-graph, and /apply-graph.
|
|
3
|
+
* They defer loading the runtime and SQLite until invoked so other pi-plans tools stay usable
|
|
4
|
+
* even when the graph feature is unavailable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
loadGraphRuntime,
|
|
10
|
+
describeRuntimeIssues,
|
|
11
|
+
type RuntimeStatus,
|
|
12
|
+
} from "./runtime.ts";
|
|
13
|
+
import { resolveCanonicalWorktree } from "./paths.ts";
|
|
14
|
+
import { Store } from "./store.ts";
|
|
15
|
+
import { runIndex } from "./indexer.ts";
|
|
16
|
+
import { makeBackend } from "./parsers/javascript.ts";
|
|
17
|
+
import { PythonBackend } from "./parsers/python.ts";
|
|
18
|
+
import type { ParserBackend } from "./parser.ts";
|
|
19
|
+
import type { Language } from "./types.ts";
|
|
20
|
+
import { materialize } from "./materialize.ts";
|
|
21
|
+
import { generateSummaries, type CompletionHandle, type SummaryReport } from "./summary.ts";
|
|
22
|
+
import { readActive, getRun, setRunStatus } from "../state.ts";
|
|
23
|
+
|
|
24
|
+
interface CommandContext {
|
|
25
|
+
cwd: string;
|
|
26
|
+
hasUI: boolean;
|
|
27
|
+
ui: {
|
|
28
|
+
notify: (message: string, kind?: "info" | "warning" | "error") => void;
|
|
29
|
+
confirm: (title: string, body: string) => Promise<boolean>;
|
|
30
|
+
};
|
|
31
|
+
modelRegistry?: {
|
|
32
|
+
find?: (provider: string, id: string) => unknown;
|
|
33
|
+
complete?: (
|
|
34
|
+
model: unknown,
|
|
35
|
+
context: { messages: Array<{ role: "user"; content: string }> },
|
|
36
|
+
options: Record<string, unknown>,
|
|
37
|
+
) => Promise<{ content: Array<{ type: "text"; text: string }>; stopReason?: string }>;
|
|
38
|
+
hasConfiguredAuth?: (model: unknown) => boolean;
|
|
39
|
+
};
|
|
40
|
+
model?: unknown;
|
|
41
|
+
thinkingLevel?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function buildParsers(runtime: Awaited<ReturnType<typeof loadGraphRuntime>>["runtime"]): Promise<Record<Language, ParserBackend>> {
|
|
45
|
+
const ParserCtor = runtime.parser.Parser as unknown as new () => {
|
|
46
|
+
parse(input: string | Buffer): unknown;
|
|
47
|
+
setLanguage(language: unknown): void;
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
javascript: makeBackend("javascript", ParserCtor, runtime.parser.javascript),
|
|
51
|
+
typescript: makeBackend("typescript", ParserCtor, runtime.parser.typescript),
|
|
52
|
+
tsx: makeBackend("tsx", ParserCtor, runtime.parser.tsx),
|
|
53
|
+
python: new PythonBackend(ParserCtor, runtime.parser.python),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Bootstrap {
|
|
58
|
+
store: Store;
|
|
59
|
+
paths: ReturnType<typeof resolveCanonicalWorktree>;
|
|
60
|
+
parsers: Record<Language, ParserBackend>;
|
|
61
|
+
runtimeStatus: RuntimeStatus;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function bootstrap(ctx: CommandContext, opts: { reindex?: boolean }): Promise<Bootstrap | null> {
|
|
65
|
+
const paths = resolveCanonicalWorktree(ctx.cwd);
|
|
66
|
+
const { runtime, status } = await loadGraphRuntime();
|
|
67
|
+
if (status.issues.length > 0 && !status.sqliteAvailable && !status.parserAvailable) {
|
|
68
|
+
ctx.ui.notify(`code-graph unavailable: ${describeRuntimeIssues(status).join("; ")}`, "error");
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const store = new Store(
|
|
72
|
+
{ dbPath: paths.codeGraphDb, worktreeRoot: paths.worktreeRoot, gitCommonDir: paths.gitCommonDir },
|
|
73
|
+
runtime.sqlite,
|
|
74
|
+
);
|
|
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 parsers = await buildParsers(runtime);
|
|
83
|
+
void opts;
|
|
84
|
+
return { store, paths, parsers, runtimeStatus: status };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function denyActivePlanning(ctx: CommandContext): boolean {
|
|
88
|
+
const active = readActive(ctx.cwd);
|
|
89
|
+
if (!active) return false;
|
|
90
|
+
const run = getRun(ctx.cwd, active.run_id);
|
|
91
|
+
if (!run) return false;
|
|
92
|
+
return run.status === "planning" || run.status === "accepted";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function initGraphCommand(args: string, ctx: CommandContext): Promise<void> {
|
|
96
|
+
const flags = parseCommandArgs(args).flags;
|
|
97
|
+
const preflightPaths = resolveCanonicalWorktree(ctx.cwd);
|
|
98
|
+
const preferRebuild = flags.has("reindex") || !ctx.hasUI;
|
|
99
|
+
if (fs.existsSync(preflightPaths.codeGraphDb) && !preferRebuild) {
|
|
100
|
+
const rebuild = await ctx.ui.confirm(
|
|
101
|
+
"code-graph DB already exists",
|
|
102
|
+
`${preflightPaths.codeGraphDb}\n\nRebuild the graph with the current /init-graph flow, or sync changed paths via /update-graph?\n\nYes = rebuild the full graph\nNo = sync changed paths only`,
|
|
103
|
+
);
|
|
104
|
+
if (!rebuild) {
|
|
105
|
+
const bootstrapResult = await bootstrap(ctx, {});
|
|
106
|
+
if (!bootstrapResult) return;
|
|
107
|
+
const { store, paths, parsers } = bootstrapResult;
|
|
108
|
+
try {
|
|
109
|
+
await runChangedPathSync(args, ctx, store, paths, parsers);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
ctx.ui.notify(`code-graph update failed: ${(error as Error).message}`, "error");
|
|
112
|
+
} finally {
|
|
113
|
+
store.close();
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const bootstrapResult = await bootstrap(ctx, { reindex: flags.has("reindex") });
|
|
119
|
+
if (!bootstrapResult) return;
|
|
120
|
+
const { store, paths, parsers, runtimeStatus } = bootstrapResult;
|
|
121
|
+
try {
|
|
122
|
+
// Pre-index chore commit: snapshot any uncommitted work so the DB indexes
|
|
123
|
+
// a recoverable state (--no-commit skips this).
|
|
124
|
+
let choreCommit = "";
|
|
125
|
+
if (!flags.has("no-commit")) {
|
|
126
|
+
choreCommit = gitAddAllAndCommit(paths.worktreeRoot, "chore(code-graph): pre-init snapshot");
|
|
127
|
+
if (choreCommit) ctx.ui.notify(`code-graph pre-init commit: ${choreCommit.slice(0, 12)}`, "info");
|
|
128
|
+
}
|
|
129
|
+
const report = await runIndex({
|
|
130
|
+
store,
|
|
131
|
+
worktreeRoot: paths.worktreeRoot,
|
|
132
|
+
parsers,
|
|
133
|
+
reindex: flags.has("reindex"),
|
|
134
|
+
});
|
|
135
|
+
ctx.ui.notify(
|
|
136
|
+
`code-graph indexed ${report.functionsIndexed} function(s) in ${report.filesScanned} file(s) — ${report.edgesResolved} resolved, ${report.edgesUnresolved} unresolved`,
|
|
137
|
+
"info",
|
|
138
|
+
);
|
|
139
|
+
if (!flags.has("no-summary") && ctx.hasUI && ctx.modelRegistry?.complete && ctx.model) {
|
|
140
|
+
const handle: CompletionHandle = {
|
|
141
|
+
complete: async (request) =>
|
|
142
|
+
await ctx.modelRegistry!.complete!(ctx.model, { messages: request.messages }, {}),
|
|
143
|
+
model: () => ctx.model as { id?: string; provider?: string; api?: string; reasoning?: boolean } | undefined,
|
|
144
|
+
thinkingLevel: () => ctx.thinkingLevel,
|
|
145
|
+
hasUI: ctx.hasUI,
|
|
146
|
+
confirm: ctx.ui.confirm,
|
|
147
|
+
notify: ctx.ui.notify,
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
const summary: SummaryReport = await generateSummaries({ store, ctx: handle, skipConsent: flags.has("consent") });
|
|
151
|
+
ctx.ui.notify(
|
|
152
|
+
`code-graph summaries: ${summary.ok} ok, ${summary.failed} failed, ${summary.declined} declined`,
|
|
153
|
+
summary.failed > 0 ? "warning" : "info",
|
|
154
|
+
);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
ctx.ui.notify(`code-graph summary failed: ${(error as Error).message}`, "warning");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
ctx.ui.notify(
|
|
160
|
+
`code-graph db: ${paths.codeGraphDb} (Node ${runtimeStatus.nodeVersion}${runtimeStatus.hasExperimentalSqliteFlag ? " +sqlite-flag" : ""})`,
|
|
161
|
+
"info",
|
|
162
|
+
);
|
|
163
|
+
// Post-index snapshot: anchor drift checks to this commit + status.
|
|
164
|
+
const head = gitHead(paths.worktreeRoot) || choreCommit;
|
|
165
|
+
const uncommitted = gitStatusPorcelain(paths.worktreeRoot).map((entry) => entry.path);
|
|
166
|
+
store.upsertSnapshot(head, uncommitted);
|
|
167
|
+
ctx.ui.notify(`code-graph snapshot: ${head.slice(0, 12) || "(no commits)"} · ${uncommitted.length} uncommitted path(s)`, "info");
|
|
168
|
+
} catch (error) {
|
|
169
|
+
ctx.ui.notify(`code-graph index failed: ${(error as Error).message}`, "error");
|
|
170
|
+
} finally {
|
|
171
|
+
store.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function applyGraphCommand(args: string, ctx: CommandContext): Promise<void> {
|
|
176
|
+
if (denyActivePlanning(ctx)) {
|
|
177
|
+
ctx.ui.notify("code-graph apply refused: a planning run is currently planning or accepted.", "error");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const flags = parseCommandArgs(args).flags;
|
|
181
|
+
const bootstrapResult = await bootstrap(ctx, {});
|
|
182
|
+
if (!bootstrapResult) return;
|
|
183
|
+
const { store, paths } = bootstrapResult;
|
|
184
|
+
try {
|
|
185
|
+
const report = materialize({ store, worktreeRoot: paths.worktreeRoot, force: flags.has("force") });
|
|
186
|
+
const stale = report.files.filter((file) => file.status === "stale").length;
|
|
187
|
+
const errors = report.files.filter((file) => file.status === "error").length;
|
|
188
|
+
const ok = report.files.filter((file) => file.status === "ok").length;
|
|
189
|
+
const deleted = report.files.filter((file) => file.status === "deleted").length;
|
|
190
|
+
const skipped = report.files.filter((file) => file.status === "skipped-missing").length;
|
|
191
|
+
ctx.ui.notify(
|
|
192
|
+
`code-graph apply: ${ok} ok, ${deleted} deleted, ${stale} stale, ${skipped} skipped-missing, ${errors} error`,
|
|
193
|
+
errors > 0 ? "error" : "info",
|
|
194
|
+
);
|
|
195
|
+
const active = readActive(ctx.cwd);
|
|
196
|
+
if (active) setRunStatus(ctx.cwd, active.run_id, "executing");
|
|
197
|
+
} catch (error) {
|
|
198
|
+
ctx.ui.notify(`code-graph apply failed: ${(error as Error).message}`, "error");
|
|
199
|
+
} finally {
|
|
200
|
+
store.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function graphStatusCommand(_args: string, ctx: CommandContext): Promise<void> {
|
|
205
|
+
const bootstrapResult = await bootstrap(ctx, {});
|
|
206
|
+
if (!bootstrapResult) return;
|
|
207
|
+
const { store, paths } = bootstrapResult;
|
|
208
|
+
try {
|
|
209
|
+
const files = store.read(() => store.db.prepare("SELECT COUNT(*) AS c FROM files").get()) as { c: number };
|
|
210
|
+
const functions = store.read(() => store.db.prepare("SELECT COUNT(*) AS c FROM functions").get()) as { c: number };
|
|
211
|
+
const edges = store.read(() => store.db.prepare("SELECT COUNT(*) AS c FROM call_edges").get()) as { c: number };
|
|
212
|
+
ctx.ui.notify(
|
|
213
|
+
`code-graph: ${functions.c} functions, ${files.c} files, ${edges.c} edges — ${paths.codeGraphDb}`,
|
|
214
|
+
"info",
|
|
215
|
+
);
|
|
216
|
+
} finally {
|
|
217
|
+
store.close();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// /update-graph, /graph-drift, /enable-graph, /disable-graph
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
import { gitAddAllAndCommit, gitDiffNameOnly, gitHead, gitStatusPorcelain, parseCommandArgs } from "./git.ts";
|
|
226
|
+
import { isIndexablePath } from "./discovery.ts";
|
|
227
|
+
import { hashText } from "./parser.ts";
|
|
228
|
+
import * as fs from "node:fs";
|
|
229
|
+
import * as path from "node:path";
|
|
230
|
+
import { loadConfig, resolveStateRootOrNull, setGraphEnabled } from "../state.ts";
|
|
231
|
+
|
|
232
|
+
export interface DriftResult {
|
|
233
|
+
ok: boolean;
|
|
234
|
+
/** Invariant (a): per-file hash drift, unless pending_kind is set. */
|
|
235
|
+
hashDrift: Array<{ path: string; kind: "hash-mismatch" | "pending-update" | "pending-delete" }>;
|
|
236
|
+
/** Invariant (b): indexable uncommitted paths missing from the DB. */
|
|
237
|
+
unindexed: string[];
|
|
238
|
+
/** Invariant (c), informational: snapshot vs current git state. */
|
|
239
|
+
snapshot: { stale: boolean; headCommit: string; snapshotHead: string; uncommittedAtSnapshot: string[] };
|
|
240
|
+
pending: Array<{ path: string; kind: string }>;
|
|
241
|
+
recommendation: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Normalize a porcelain/git path ("src/a.ts", "a.ts") to the DB key form ("./a.ts"). */
|
|
245
|
+
export function toDbKey(porcelainPath: string): string {
|
|
246
|
+
return porcelainPath.includes("/") ? porcelainPath : `./${porcelainPath}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function computeDrift(store: Store, worktreeRoot: string): DriftResult {
|
|
250
|
+
const entries = gitStatusPorcelain(worktreeRoot);
|
|
251
|
+
const renameOrigPaths = new Set(
|
|
252
|
+
entries.filter((entry) => entry.origPath !== null).map((entry) => toDbKey(entry.origPath!)),
|
|
253
|
+
);
|
|
254
|
+
const porcelainPaths = entries.filter((entry) => !entry.status.includes("D")).map((entry) => toDbKey(entry.path));
|
|
255
|
+
const indexableChanged = [...new Set([...porcelainPaths, ...renameOrigPaths])].filter(isIndexablePath);
|
|
256
|
+
|
|
257
|
+
const filesRows = store.read(() =>
|
|
258
|
+
store.db.prepare(`SELECT file_dir, file_name, source_hash, pending_kind FROM files`).all(),
|
|
259
|
+
) as Array<{ file_dir: string; file_name: string; source_hash: string; pending_kind: string | null }>;
|
|
260
|
+
const byPath = new Map(filesRows.map((row) => [`${row.file_dir}/${row.file_name}`, row]));
|
|
261
|
+
|
|
262
|
+
const hashDrift: DriftResult["hashDrift"] = [];
|
|
263
|
+
const pending: DriftResult["pending"] = [];
|
|
264
|
+
const unindexed: string[] = [];
|
|
265
|
+
for (const row of filesRows) {
|
|
266
|
+
const rel = `${row.file_dir}/${row.file_name}`;
|
|
267
|
+
if (row.pending_kind === "update") {
|
|
268
|
+
pending.push({ path: rel, kind: "update" });
|
|
269
|
+
hashDrift.push({ path: rel, kind: "pending-update" });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (row.pending_kind === "delete") {
|
|
273
|
+
pending.push({ path: rel, kind: "delete" });
|
|
274
|
+
hashDrift.push({ path: rel, kind: "pending-delete" });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const absolute = path.join(worktreeRoot, row.file_dir === "." ? row.file_name : path.join(row.file_dir, row.file_name));
|
|
278
|
+
let onDisk: string | null = null;
|
|
279
|
+
try {
|
|
280
|
+
onDisk = fs.readFileSync(absolute, "utf8");
|
|
281
|
+
} catch {
|
|
282
|
+
onDisk = null;
|
|
283
|
+
}
|
|
284
|
+
if (onDisk === null) {
|
|
285
|
+
// Deleted on disk but not marked pending: untracked deletion.
|
|
286
|
+
hashDrift.push({ path: rel, kind: "hash-mismatch" });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (hashText(onDisk) !== row.source_hash) {
|
|
290
|
+
hashDrift.push({ path: rel, kind: "hash-mismatch" });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const changed of indexableChanged) {
|
|
294
|
+
if (renameOrigPaths.has(changed)) continue; // rename-old: purge is the fix, not reindex
|
|
295
|
+
if (!byPath.has(changed)) unindexed.push(changed);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const snapshot = store.readLatestSnapshot();
|
|
299
|
+
const head = gitHead(worktreeRoot);
|
|
300
|
+
const snapshotInfo = {
|
|
301
|
+
stale: snapshot !== null && snapshot.headCommit !== head,
|
|
302
|
+
headCommit: head,
|
|
303
|
+
snapshotHead: snapshot?.headCommit ?? "(none)",
|
|
304
|
+
uncommittedAtSnapshot: snapshot?.uncommittedPaths ?? [],
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const needsUpdate = hashDrift.some((item) => item.kind === "hash-mismatch") || unindexed.length > 0;
|
|
308
|
+
const needsApply = pending.length > 0;
|
|
309
|
+
const recommendation = needsUpdate
|
|
310
|
+
? "run /update-graph to reindex changed paths"
|
|
311
|
+
: needsApply
|
|
312
|
+
? "run /apply-graph to materialize pending DB edits"
|
|
313
|
+
: "in sync";
|
|
314
|
+
return {
|
|
315
|
+
ok: hashDrift.every((item) => item.kind !== "hash-mismatch") && unindexed.length === 0,
|
|
316
|
+
hashDrift,
|
|
317
|
+
unindexed,
|
|
318
|
+
snapshot: snapshotInfo,
|
|
319
|
+
pending,
|
|
320
|
+
recommendation,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function runChangedPathSync(
|
|
325
|
+
args: string,
|
|
326
|
+
ctx: CommandContext,
|
|
327
|
+
store: Store,
|
|
328
|
+
paths: Bootstrap["paths"],
|
|
329
|
+
parsers: Record<Language, ParserBackend>,
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
const { flags, values } = parseCommandArgs(args);
|
|
332
|
+
const entries = gitStatusPorcelain(paths.worktreeRoot);
|
|
333
|
+
const renameOrigPaths = new Set(
|
|
334
|
+
entries.filter((entry) => entry.origPath !== null).map((entry) => toDbKey(entry.origPath!)),
|
|
335
|
+
);
|
|
336
|
+
const porcelainPaths = entries.map((entry) => toDbKey(entry.path));
|
|
337
|
+
// --base <commit>: union porcelain with diff-vs-base so pinned-base
|
|
338
|
+
// changes (possibly already committed) are included.
|
|
339
|
+
const basePath = values.get("base");
|
|
340
|
+
const basePaths = basePath
|
|
341
|
+
? gitDiffNameOnly(paths.worktreeRoot, basePath).map(toDbKey)
|
|
342
|
+
: [];
|
|
343
|
+
const candidates = [...new Set([...porcelainPaths, ...renameOrigPaths, ...basePaths])].filter(isIndexablePath);
|
|
344
|
+
if (candidates.length === 0) {
|
|
345
|
+
ctx.ui.notify("code-graph update: no changed indexable paths — nothing to do", "info");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (flags.has("dry-run")) {
|
|
349
|
+
ctx.ui.notify(`code-graph update (dry-run): would reindex ${candidates.length} path(s):\n${candidates.join("\n")}`, "info");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const report = await runIndex({ store, worktreeRoot: paths.worktreeRoot, parsers, paths: candidates });
|
|
353
|
+
ctx.ui.notify(
|
|
354
|
+
`code-graph update: ${report.reindexedPaths.length} reindexed, ${report.purgedPaths.length} purged, ${report.functionsIndexed} function(s)`,
|
|
355
|
+
"info",
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function updateGraphCommand(args: string, ctx: CommandContext): Promise<void> {
|
|
360
|
+
const bootstrapResult = await bootstrap(ctx, {});
|
|
361
|
+
if (!bootstrapResult) return;
|
|
362
|
+
const { store, paths, parsers } = bootstrapResult;
|
|
363
|
+
try {
|
|
364
|
+
await runChangedPathSync(args, ctx, store, paths, parsers);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
ctx.ui.notify(`code-graph update failed: ${(error as Error).message}`, "error");
|
|
367
|
+
} finally {
|
|
368
|
+
store.close();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function graphDriftCommand(args: string, ctx: CommandContext): Promise<void> {
|
|
373
|
+
const { flags } = parseCommandArgs(args);
|
|
374
|
+
const bootstrapResult = await bootstrap(ctx, {});
|
|
375
|
+
if (!bootstrapResult) return;
|
|
376
|
+
const { store, paths } = bootstrapResult;
|
|
377
|
+
try {
|
|
378
|
+
const drift = computeDrift(store, paths.worktreeRoot);
|
|
379
|
+
if (flags.has("json")) {
|
|
380
|
+
const payload = flags.has("commit-aware")
|
|
381
|
+
? { ...drift, gitStatus: gitStatusPorcelain(paths.worktreeRoot) }
|
|
382
|
+
: drift;
|
|
383
|
+
ctx.ui.notify(JSON.stringify(payload, null, 2), "info");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const lines: string[] = [];
|
|
387
|
+
lines.push(`graph drift: ${drift.ok ? "OK" : "DIRTY"} — ${drift.recommendation}`);
|
|
388
|
+
for (const item of drift.hashDrift) {
|
|
389
|
+
if (item.kind === "hash-mismatch") lines.push(` (a) hash mismatch: ${item.path}`);
|
|
390
|
+
else if (item.kind === "pending-update") lines.push(` (a) pending apply (update): ${item.path}`);
|
|
391
|
+
else lines.push(` (a) pending apply (delete): ${item.path}`);
|
|
392
|
+
}
|
|
393
|
+
for (const missing of drift.unindexed) lines.push(` (b) uncommitted but unindexed: ${missing}`);
|
|
394
|
+
if (drift.snapshot.stale) {
|
|
395
|
+
lines.push(` (c) snapshot stale: recorded ${drift.snapshot.snapshotHead.slice(0, 8)} vs HEAD ${drift.snapshot.headCommit.slice(0, 8)} (run /init-graph after committing to refresh)`);
|
|
396
|
+
}
|
|
397
|
+
if (flags.has("commit-aware")) {
|
|
398
|
+
for (const entry of gitStatusPorcelain(paths.worktreeRoot)) {
|
|
399
|
+
lines.push(` (git) ${entry.status} ${entry.path}${entry.origPath ? ` (from ${entry.origPath})` : ""}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
ctx.ui.notify(lines.join("\n"), drift.ok ? "info" : "warning");
|
|
403
|
+
} catch (error) {
|
|
404
|
+
ctx.ui.notify(`graph drift failed: ${(error as Error).message}`, "error");
|
|
405
|
+
} finally {
|
|
406
|
+
store.close();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function enableGraphCommand(_args: string, ctx: CommandContext): Promise<void> {
|
|
411
|
+
setGraphEnabled(ctx.cwd, true);
|
|
412
|
+
ctx.ui.notify("code-graph enabled: agents will use graph-aware read/write/edit on indexed source files. Run /init-graph to index.", "info");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export async function disableGraphCommand(_args: string, ctx: CommandContext): Promise<void> {
|
|
416
|
+
const stateRoot = resolveStateRootOrNull(ctx.cwd);
|
|
417
|
+
if (stateRoot && loadConfig(stateRoot).graph_enabled === true) {
|
|
418
|
+
const bootstrapResult = await bootstrap(ctx, {});
|
|
419
|
+
if (bootstrapResult) {
|
|
420
|
+
const { store, paths } = bootstrapResult;
|
|
421
|
+
try {
|
|
422
|
+
const drift = computeDrift(store, paths.worktreeRoot);
|
|
423
|
+
if (!drift.ok || drift.pending.length > 0) {
|
|
424
|
+
ctx.ui.notify(
|
|
425
|
+
`code-graph disable refused: worktree/DB is dirty (${drift.recommendation}). Fix drift first, then disable.`,
|
|
426
|
+
"error",
|
|
427
|
+
);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
store.close();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
setGraphEnabled(ctx.cwd, false);
|
|
436
|
+
ctx.ui.notify("code-graph disabled: agents fall back to Read/grep/ls. Re-enable anytime with /enable-graph.", "info");
|
|
437
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery: walk the worktree, filter out unwanted directories, classify files
|
|
3
|
+
* by language and return a deterministic file order.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { isIgnoredDir } from "./paths.ts";
|
|
10
|
+
import type { Language } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
export interface DiscoveredFile {
|
|
13
|
+
fileDir: string;
|
|
14
|
+
fileName: string;
|
|
15
|
+
absolutePath: string;
|
|
16
|
+
language: Language;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const LANGUAGE_BY_EXT: Record<string, Language> = {
|
|
20
|
+
".js": "javascript",
|
|
21
|
+
".mjs": "javascript",
|
|
22
|
+
".cjs": "javascript",
|
|
23
|
+
".ts": "typescript",
|
|
24
|
+
".tsx": "tsx",
|
|
25
|
+
".py": "python",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function classify(filename: string): Language | null {
|
|
29
|
+
const ext = path.extname(filename).toLowerCase();
|
|
30
|
+
return LANGUAGE_BY_EXT[ext] ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a relative POSIX path would be indexed (used by update-graph/drift path filtering). */
|
|
34
|
+
export function isIndexablePath(relativePath: string): boolean {
|
|
35
|
+
const base = relativePath.split("/").pop() ?? relativePath;
|
|
36
|
+
return classify(base) !== null && !hasIgnoredDirectory(relativePath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hasIgnoredDirectory(relativePath: string): boolean {
|
|
40
|
+
const segments = relativePath.split(/[\\/]+/).filter(Boolean);
|
|
41
|
+
return segments.slice(0, -1).some((segment) => isIgnoredDir(segment));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runGitLsFiles(cwd: string): string[] | null {
|
|
45
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
46
|
+
for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"]) delete env[key];
|
|
47
|
+
const result = spawnSync("git", ["ls-files", "-z", "--others", "--exclude-standard"], {
|
|
48
|
+
cwd,
|
|
49
|
+
env,
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
});
|
|
52
|
+
if (result.status !== 0) return null;
|
|
53
|
+
const raw = result.stdout ?? "";
|
|
54
|
+
const files = raw.split("\0").filter((entry) => entry.length > 0);
|
|
55
|
+
if (!files.length) return null;
|
|
56
|
+
return files;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function walkFs(root: string, onFile: (abs: string) => void): void {
|
|
60
|
+
const stack = ["."];
|
|
61
|
+
while (stack.length) {
|
|
62
|
+
const rel = stack.pop()!;
|
|
63
|
+
const abs = path.join(root, rel);
|
|
64
|
+
let stat;
|
|
65
|
+
try {
|
|
66
|
+
stat = fs.lstatSync(abs);
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (stat.isSymbolicLink()) continue;
|
|
71
|
+
if (stat.isDirectory()) {
|
|
72
|
+
const name = path.basename(abs);
|
|
73
|
+
if (rel !== "." && isIgnoredDir(name)) continue;
|
|
74
|
+
for (const entry of fs.readdirSync(abs)) stack.push(path.join(rel, entry));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (stat.isFile()) onFile(abs);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface DiscoverOptions {
|
|
82
|
+
worktreeRoot: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function discoverFiles(options: DiscoverOptions): DiscoveredFile[] {
|
|
86
|
+
const gitFiles = runGitLsFiles(options.worktreeRoot);
|
|
87
|
+
const out = new Map<string, DiscoveredFile>();
|
|
88
|
+
const register = (abs: string) => {
|
|
89
|
+
if (!abs.startsWith(options.worktreeRoot + path.sep) && abs !== options.worktreeRoot) return;
|
|
90
|
+
const rel = path.relative(options.worktreeRoot, abs);
|
|
91
|
+
if (hasIgnoredDirectory(rel)) return;
|
|
92
|
+
const lang = classify(path.basename(rel));
|
|
93
|
+
if (!lang) return;
|
|
94
|
+
const posix = rel.split(path.sep).join("/");
|
|
95
|
+
const parts = posix.split("/");
|
|
96
|
+
const fileName = parts[parts.length - 1];
|
|
97
|
+
const fileDir = parts.length === 1 ? "." : parts.slice(0, -1).join("/");
|
|
98
|
+
out.set(posix, {
|
|
99
|
+
fileDir,
|
|
100
|
+
fileName,
|
|
101
|
+
absolutePath: abs,
|
|
102
|
+
language: lang,
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
if (gitFiles && gitFiles.length > 0) {
|
|
106
|
+
for (const rel of gitFiles) register(path.resolve(options.worktreeRoot, rel));
|
|
107
|
+
}
|
|
108
|
+
walkFs(options.worktreeRoot, (abs) => {
|
|
109
|
+
if (!out.has(path.relative(options.worktreeRoot, abs).split(path.sep).join("/"))) {
|
|
110
|
+
register(abs);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
return [...out.values()].sort((a, b) => {
|
|
114
|
+
const aKey = `${a.fileDir}/${a.fileName}`;
|
|
115
|
+
const bKey = `${b.fileDir}/${b.fileName}`;
|
|
116
|
+
return aKey.localeCompare(bKey);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git porcelain helpers for the code-graph loop. All calls go through
|
|
3
|
+
* spawnSync with scrubbed environment (mirrors paths.ts) and never introduce
|
|
4
|
+
* new dependencies.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
function runGit(cwd: string, args: string[]): { code: number; stdout: string; stderr: string } {
|
|
10
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
11
|
+
for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"]) delete env[key];
|
|
12
|
+
// -c core.quotepath=false keeps non-ASCII paths literal instead of octal-escaped.
|
|
13
|
+
const result = spawnSync("git", ["-c", "core.quotepath=false", ...args], { cwd, env, encoding: "utf8" });
|
|
14
|
+
return { code: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class GitError extends Error {}
|
|
18
|
+
|
|
19
|
+
function must(cwd: string, args: string[], what: string): string {
|
|
20
|
+
const result = runGit(cwd, args);
|
|
21
|
+
if (result.code !== 0) {
|
|
22
|
+
throw new GitError(`${what} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`);
|
|
23
|
+
}
|
|
24
|
+
return result.stdout;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Current HEAD commit SHA (empty string when no commits exist). */
|
|
28
|
+
export function gitHead(cwd: string): string {
|
|
29
|
+
const result = runGit(cwd, ["rev-parse", "HEAD"]);
|
|
30
|
+
if (result.code !== 0) return "";
|
|
31
|
+
return result.stdout.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PorcelainEntry {
|
|
35
|
+
/** Single-letter + optional sub-status code, e.g. "M", " M", "A", "??", "R ". */
|
|
36
|
+
status: string;
|
|
37
|
+
/** Path for normal entries; NEW path for rename/copy entries. */
|
|
38
|
+
path: string;
|
|
39
|
+
/** Original path for rename (R) / copy (C) entries; null otherwise. */
|
|
40
|
+
origPath: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse `git status --porcelain` output, including rename (R old -> new). */
|
|
44
|
+
export function parsePorcelain(stdout: string): PorcelainEntry[] {
|
|
45
|
+
const entries: PorcelainEntry[] = [];
|
|
46
|
+
for (const line of stdout.split("\n")) {
|
|
47
|
+
if (!line.trim()) continue;
|
|
48
|
+
const status = line.slice(0, 2);
|
|
49
|
+
const rest = line.slice(3);
|
|
50
|
+
if (rest.startsWith('"') && rest.endsWith('"')) {
|
|
51
|
+
// Quoted path with possible embedded quotes; renames use "old" -> "new".
|
|
52
|
+
const inner = rest.slice(1, -1);
|
|
53
|
+
const arrow = inner.indexOf('" -> "');
|
|
54
|
+
if ((status[0] === "R" || status[0] === "C") && arrow >= 0) {
|
|
55
|
+
entries.push({ status, path: inner.slice(arrow + 6), origPath: inner.slice(0, arrow) });
|
|
56
|
+
} else {
|
|
57
|
+
entries.push({ status, path: inner, origPath: null });
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const arrow = rest.indexOf(" -> ");
|
|
62
|
+
if ((status[0] === "R" || status[0] === "C") && arrow >= 0) {
|
|
63
|
+
entries.push({ status, path: rest.slice(arrow + 4), origPath: rest.slice(0, arrow) });
|
|
64
|
+
} else {
|
|
65
|
+
entries.push({ status, path: rest, origPath: null });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return entries;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `git status --porcelain` parsed; includes untracked (`??`) and rename entries. */
|
|
72
|
+
export function gitStatusPorcelain(cwd: string): PorcelainEntry[] {
|
|
73
|
+
return parsePorcelain(must(cwd, ["status", "--porcelain"], "git status"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `git diff --name-only` against HEAD (or --base commit); excludes untracked. */
|
|
77
|
+
export function gitDiffNameOnly(cwd: string, base?: string): string[] {
|
|
78
|
+
const args = base ? ["diff", "--name-only", base] : ["diff", "--name-only", "HEAD"];
|
|
79
|
+
const stdout = must(cwd, args, "git diff --name-only");
|
|
80
|
+
return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Stage everything and commit; returns the new HEAD (or "" when nothing to commit). */
|
|
84
|
+
export function gitAddAllAndCommit(cwd: string, message: string): string {
|
|
85
|
+
const status = gitStatusPorcelain(cwd);
|
|
86
|
+
if (status.length === 0) return "";
|
|
87
|
+
must(cwd, ["add", "-A"], "git add -A");
|
|
88
|
+
must(cwd, ["commit", "-m", message], "git commit");
|
|
89
|
+
return gitHead(cwd);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Parse "--flag" booleans and "--key value" pairs from a slash-command arg string. */
|
|
93
|
+
export function parseCommandArgs(args: string): { flags: Set<string>; values: Map<string, string> } {
|
|
94
|
+
const flags = new Set<string>();
|
|
95
|
+
const values = new Map<string, string>();
|
|
96
|
+
const tokens = args.split(/\s+/).filter(Boolean);
|
|
97
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
98
|
+
const token = tokens[i]!;
|
|
99
|
+
if (!token.startsWith("--")) continue;
|
|
100
|
+
if (i + 1 < tokens.length && !tokens[i + 1]!.startsWith("--")) {
|
|
101
|
+
values.set(token.slice(2), tokens[i + 1]!);
|
|
102
|
+
i++;
|
|
103
|
+
} else {
|
|
104
|
+
flags.add(token.slice(2));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { flags, values };
|
|
108
|
+
}
|