pi-plans 0.2.0 → 0.3.1

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 (75) hide show
  1. package/README.md +90 -26
  2. package/agents/ref-analyst.md +18 -0
  3. package/index.ts +121 -9
  4. package/package.json +16 -1
  5. package/references/pi-planning-workflow.md +21 -6
  6. package/references/state-and-config.md +52 -5
  7. package/scripts/validate.ts +5 -0
  8. package/skills/plan-with-refs/SKILL.md +3 -3
  9. package/src/code-graph/commands.ts +483 -0
  10. package/src/code-graph/discovery.ts +118 -0
  11. package/src/code-graph/git.ts +108 -0
  12. package/src/code-graph/identity.ts +59 -0
  13. package/src/code-graph/indexer.ts +281 -0
  14. package/src/code-graph/materialize.ts +166 -0
  15. package/src/code-graph/mode.ts +28 -0
  16. package/src/code-graph/mutations.ts +160 -0
  17. package/src/code-graph/parser.ts +51 -0
  18. package/src/code-graph/parsers/javascript.ts +35 -0
  19. package/src/code-graph/parsers/python.ts +160 -0
  20. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  21. package/src/code-graph/paths.ts +85 -0
  22. package/src/code-graph/prompts.ts +18 -0
  23. package/src/code-graph/resolver.ts +69 -0
  24. package/src/code-graph/runtime.ts +158 -0
  25. package/src/code-graph/schema.ts +135 -0
  26. package/src/code-graph/screening.ts +82 -0
  27. package/src/code-graph/store.ts +278 -0
  28. package/src/code-graph/summary.ts +435 -0
  29. package/src/code-graph/types.ts +163 -0
  30. package/src/compaction.ts +1125 -371
  31. package/src/config-command.ts +361 -0
  32. package/src/exec.ts +508 -693
  33. package/src/guard.ts +14 -1
  34. package/src/refine-prompts.ts +109 -0
  35. package/src/refine-ui-helpers.ts +71 -18
  36. package/src/refine-ui-state.ts +88 -22
  37. package/src/refine-ui.ts +210 -102
  38. package/src/state.ts +36 -7
  39. package/src/subagent.ts +164 -61
  40. package/src/termination-prompt.ts +22 -0
  41. package/tests/analyze-refs.test.ts +265 -0
  42. package/tests/ask-choice.test.ts +264 -0
  43. package/tests/autocomplete.test.ts +6 -1
  44. package/tests/code-graph-apply-action.test.ts +173 -0
  45. package/tests/code-graph-apply.test.ts +185 -0
  46. package/tests/code-graph-commands.test.ts +211 -0
  47. package/tests/code-graph-db.test.ts +166 -0
  48. package/tests/code-graph-discovery.test.ts +38 -0
  49. package/tests/code-graph-git.test.ts +94 -0
  50. package/tests/code-graph-index.test.ts +175 -0
  51. package/tests/code-graph-loop.e2e.test.ts +159 -0
  52. package/tests/code-graph-mutations.test.ts +117 -0
  53. package/tests/code-graph-parser.test.ts +85 -0
  54. package/tests/code-graph-rollback.test.ts +100 -0
  55. package/tests/code-graph-summary-batching.test.ts +518 -0
  56. package/tests/code-graph-summary.test.ts +148 -0
  57. package/tests/compaction.test.ts +371 -57
  58. package/tests/config-command.test.ts +263 -0
  59. package/tests/exec.test.ts +808 -241
  60. package/tests/fixtures/code-graph/sample.js +36 -0
  61. package/tests/fixtures/code-graph/sample.py +20 -0
  62. package/tests/fixtures/code-graph/sample.ts +15 -0
  63. package/tests/graph-aware-file-tools.test.ts +411 -0
  64. package/tests/guard.test.ts +27 -1
  65. package/tests/plans.test.ts +10 -0
  66. package/tests/refine-prompts.test.ts +101 -2
  67. package/tests/refine-ui.test.ts +371 -72
  68. package/tests/state.test.ts +32 -0
  69. package/tests/subagent.test.ts +48 -20
  70. package/tools/analyze-refs.ts +263 -0
  71. package/tools/ask-choice.ts +159 -11
  72. package/tools/code-graph.ts +277 -0
  73. package/tools/graph-aware-file-tools.ts +392 -0
  74. package/tools/plans.ts +97 -2
  75. package/tools/refine.ts +61 -15
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Schema definition and migrations for the code-graph SQLite database. The
3
+ * database is intentionally normalized — JSON views are derived from the
4
+ * edge tables at query time and never stored alongside them.
5
+ */
6
+
7
+ export const CURRENT_SCHEMA_VERSION = 2;
8
+
9
+ export const SCHEMA_STATEMENTS: string[] = [
10
+ `CREATE TABLE IF NOT EXISTS graph_meta (
11
+ schema_version INTEGER PRIMARY KEY,
12
+ worktree_root TEXT NOT NULL,
13
+ git_common_dir TEXT NOT NULL,
14
+ parser_versions TEXT NOT NULL,
15
+ updated_at TEXT NOT NULL
16
+ )`,
17
+ `CREATE TABLE IF NOT EXISTS files (
18
+ file_dir TEXT NOT NULL,
19
+ file_name TEXT NOT NULL,
20
+ language TEXT NOT NULL,
21
+ source_hash TEXT NOT NULL,
22
+ source_text TEXT NOT NULL,
23
+ pending_kind TEXT,
24
+ updated_at TEXT NOT NULL,
25
+ PRIMARY KEY (file_dir, file_name)
26
+ )`,
27
+ `CREATE TABLE IF NOT EXISTS file_entries (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ file_dir TEXT NOT NULL,
30
+ file_name TEXT NOT NULL,
31
+ ordinal INTEGER NOT NULL,
32
+ kind TEXT NOT NULL,
33
+ function_name TEXT,
34
+ start_byte INTEGER NOT NULL,
35
+ end_byte INTEGER NOT NULL,
36
+ text TEXT NOT NULL,
37
+ UNIQUE (file_dir, file_name, ordinal)
38
+ )`,
39
+ `CREATE INDEX IF NOT EXISTS idx_file_entries_file ON file_entries (file_dir, file_name, ordinal)`,
40
+ `CREATE TABLE IF NOT EXISTS functions (
41
+ file_dir TEXT NOT NULL,
42
+ file_name TEXT NOT NULL,
43
+ function_name TEXT NOT NULL,
44
+ language TEXT NOT NULL,
45
+ kind TEXT NOT NULL,
46
+ full_code TEXT NOT NULL,
47
+ full_code_hash TEXT NOT NULL,
48
+ render_code TEXT NOT NULL,
49
+ render_code_hash TEXT NOT NULL,
50
+ parent TEXT,
51
+ container TEXT,
52
+ move_supported INTEGER NOT NULL,
53
+ is_primary INTEGER NOT NULL,
54
+ overload_signatures TEXT,
55
+ provenance_start_byte INTEGER NOT NULL,
56
+ provenance_end_byte INTEGER NOT NULL,
57
+ provenance_start_line INTEGER NOT NULL,
58
+ provenance_start_col INTEGER NOT NULL,
59
+ provenance_end_line INTEGER NOT NULL,
60
+ provenance_end_col INTEGER NOT NULL,
61
+ summary_description TEXT,
62
+ summary_inputs TEXT,
63
+ summary_outputs TEXT,
64
+ summary_status TEXT,
65
+ summary_model TEXT,
66
+ summary_schema_version INTEGER,
67
+ summary_effective_effort TEXT,
68
+ summary_error TEXT,
69
+ summary_updated_at TEXT,
70
+ version INTEGER NOT NULL DEFAULT 1,
71
+ PRIMARY KEY (file_dir, file_name, function_name)
72
+ )`,
73
+ `CREATE TABLE IF NOT EXISTS call_edges (
74
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
75
+ from_file_dir TEXT NOT NULL,
76
+ from_file_name TEXT NOT NULL,
77
+ from_function TEXT NOT NULL,
78
+ to_file_dir TEXT,
79
+ to_file_name TEXT,
80
+ to_function TEXT,
81
+ to_callee_text TEXT NOT NULL,
82
+ kind TEXT NOT NULL,
83
+ resolution TEXT NOT NULL,
84
+ reason TEXT,
85
+ provenance_start_byte INTEGER NOT NULL,
86
+ provenance_end_byte INTEGER NOT NULL,
87
+ provenance_start_line INTEGER NOT NULL,
88
+ provenance_start_col INTEGER NOT NULL,
89
+ provenance_end_line INTEGER NOT NULL,
90
+ provenance_end_col INTEGER NOT NULL
91
+ )`,
92
+ `CREATE INDEX IF NOT EXISTS idx_call_edges_from ON call_edges (from_file_dir, from_file_name, from_function)`,
93
+ `CREATE INDEX IF NOT EXISTS idx_call_edges_to ON call_edges (to_file_dir, to_file_name, to_function)`,
94
+ `CREATE TABLE IF NOT EXISTS change_log (
95
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
96
+ kind TEXT NOT NULL,
97
+ detail TEXT NOT NULL,
98
+ recorded_at TEXT NOT NULL
99
+ )`,
100
+ `CREATE TABLE IF NOT EXISTS reindex_conflicts (
101
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
102
+ file_dir TEXT NOT NULL,
103
+ file_name TEXT NOT NULL,
104
+ kind TEXT NOT NULL,
105
+ detail TEXT NOT NULL,
106
+ recorded_at TEXT NOT NULL
107
+ )`,
108
+ `CREATE TABLE IF NOT EXISTS code_graph_snapshot (
109
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
110
+ head_commit TEXT NOT NULL,
111
+ uncommitted_paths TEXT NOT NULL,
112
+ recorded_at TEXT NOT NULL
113
+ )`,
114
+ ];
115
+
116
+ export const FUNCTION_RECORDS_VIEW = `
117
+ CREATE VIEW IF NOT EXISTS function_records AS
118
+ SELECT
119
+ f.file_dir AS file_dir,
120
+ f.file_name AS file_name,
121
+ f.function_name AS function_name,
122
+ f.language AS language,
123
+ f.kind AS kind,
124
+ f.parent AS parent,
125
+ f.container AS container,
126
+ f.move_supported AS move_supported,
127
+ f.is_primary AS is_primary,
128
+ f.provenance_start_byte AS provenance_start_byte,
129
+ f.provenance_end_byte AS provenance_end_byte,
130
+ (SELECT json_group_array(json_object('file_dir', e.from_file_dir, 'file_name', e.from_file_name, 'function_name', e.from_function))
131
+ FROM call_edges e WHERE e.to_file_dir = f.file_dir AND e.to_file_name = f.file_name AND e.to_function = f.function_name AND e.resolution = 'resolved') AS in_links_json,
132
+ (SELECT json_group_array(json_object('file_dir', e.to_file_dir, 'file_name', e.to_file_name, 'function_name', e.to_function))
133
+ FROM call_edges e WHERE e.from_file_dir = f.file_dir AND e.from_file_name = f.file_name AND e.from_function = f.function_name AND e.resolution = 'resolved') AS out_links_json
134
+ FROM functions f
135
+ `;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Read-only screening queries that exclude full_code from the response.
3
+ * Used by the agent-facing tool and `/graph-status`.
4
+ */
5
+
6
+ import { Store } from "./store.ts";
7
+ import type { FunctionScreening, Language } from "./types.ts";
8
+
9
+ export interface ScreeningOptions {
10
+ store: Store;
11
+ language?: Language;
12
+ functionNameLike?: string;
13
+ limit?: number;
14
+ }
15
+
16
+ export function screeningQuery(opts: ScreeningOptions): FunctionScreening[] {
17
+ const conditions: string[] = [];
18
+ const params: Array<string | number> = [];
19
+ if (opts.language) {
20
+ conditions.push("f.language = ?");
21
+ params.push(opts.language);
22
+ }
23
+ if (opts.functionNameLike) {
24
+ conditions.push("f.function_name LIKE ?");
25
+ params.push(`%${opts.functionNameLike}%`);
26
+ }
27
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
28
+ const limit = Math.max(1, Math.min(opts.limit ?? 100, 500));
29
+ const rows = opts.store
30
+ .read(() =>
31
+ opts.store.db
32
+ .prepare(
33
+ `SELECT
34
+ f.file_dir AS file_dir,
35
+ f.file_name AS file_name,
36
+ f.function_name AS function_name,
37
+ f.language AS language,
38
+ f.kind AS kind,
39
+ f.summary_description AS description,
40
+ f.summary_inputs AS inputs_json,
41
+ f.summary_outputs AS outputs_json,
42
+ f.summary_status AS summary_status,
43
+ f.version AS version,
44
+ (SELECT json_group_array(json_object('file_dir', e.from_file_dir, 'file_name', e.from_file_name, 'function_name', e.from_function))
45
+ FROM call_edges e WHERE e.to_file_dir = f.file_dir AND e.to_file_name = f.file_name AND e.to_function = f.function_name AND e.resolution = 'resolved') AS in_json,
46
+ (SELECT json_group_array(json_object('file_dir', e.to_file_dir, 'file_name', e.to_file_name, 'function_name', e.to_function))
47
+ FROM call_edges e WHERE e.from_file_dir = f.file_dir AND e.from_file_name = f.file_name AND e.from_function = f.function_name AND e.resolution = 'resolved') AS out_json
48
+ FROM functions f
49
+ ${where}
50
+ ORDER BY f.file_dir, f.file_name, f.function_name
51
+ LIMIT ${limit}`,
52
+ )
53
+ .all(...params),
54
+ ) as Array<{
55
+ file_dir: string;
56
+ file_name: string;
57
+ function_name: string;
58
+ language: Language;
59
+ kind: string;
60
+ description: string | null;
61
+ inputs_json: string | null;
62
+ outputs_json: string | null;
63
+ summary_status: string | null;
64
+ version: number;
65
+ in_json: string | null;
66
+ out_json: string | null;
67
+ }>;
68
+ return rows.map((row) => ({
69
+ fileDir: row.file_dir,
70
+ fileName: row.file_name,
71
+ functionName: row.function_name,
72
+ language: row.language,
73
+ kind: row.kind as FunctionScreening["kind"],
74
+ description: row.description,
75
+ inputs: row.inputs_json ? (JSON.parse(row.inputs_json) as string[]) : null,
76
+ outputs: row.outputs_json ? (JSON.parse(row.outputs_json) as string[]) : null,
77
+ version: row.version,
78
+ summaryStatus: row.summary_status as FunctionScreening["summaryStatus"],
79
+ inLinks: row.in_json ? (JSON.parse(row.in_json) as FunctionScreening["inLinks"]) : [],
80
+ outLinks: row.out_json ? (JSON.parse(row.out_json) as FunctionScreening["outLinks"]) : [],
81
+ }));
82
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * SQLite handle, prepared statements and BEGIN IMMEDIATE write transactions
3
+ * for the code-graph module.
4
+ */
5
+
6
+ import type { DatabaseSync, StatementSync } from "node:sqlite";
7
+ import { CURRENT_SCHEMA_VERSION, FUNCTION_RECORDS_VIEW, SCHEMA_STATEMENTS } from "./schema.ts";
8
+ import { PathError } from "./paths.ts";
9
+ import type { CodeGraphSnapshot, GraphMeta } from "./types.ts";
10
+
11
+ export interface StoreOptions {
12
+ dbPath: string;
13
+ worktreeRoot: string;
14
+ gitCommonDir: string;
15
+ parserVersions?: Record<string, string>;
16
+ readonly?: boolean;
17
+ }
18
+
19
+ export class Store {
20
+ readonly db: DatabaseSync;
21
+ readonly dbPath: string;
22
+ readonly worktreeRoot: string;
23
+ readonly gitCommonDir: string;
24
+
25
+ private prepared: Map<string, StatementSync> = new Map();
26
+ private readonly mode: "rw" | "ro";
27
+
28
+ constructor(opts: StoreOptions, sqlite: typeof import("node:sqlite")) {
29
+ this.dbPath = opts.dbPath;
30
+ this.worktreeRoot = opts.worktreeRoot;
31
+ this.gitCommonDir = opts.gitCommonDir;
32
+ this.mode = opts.readonly ? "ro" : "rw";
33
+ this.db = new sqlite.DatabaseSync(opts.dbPath, {
34
+ open: true,
35
+ readOnly: opts.readonly === true,
36
+ enableForeignKeyConstraints: true,
37
+ });
38
+ this.bootstrap(opts);
39
+ }
40
+
41
+ private bootstrap(opts: StoreOptions): void {
42
+ this.db.exec("PRAGMA foreign_keys = ON");
43
+ this.db.exec("PRAGMA journal_mode = WAL");
44
+ this.db.exec("PRAGMA busy_timeout = 5000");
45
+ const existing = this.db
46
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='graph_meta'")
47
+ .get();
48
+ if (!existing) {
49
+ this.applyMigrations();
50
+ this.upsertMeta({
51
+ schemaVersion: CURRENT_SCHEMA_VERSION,
52
+ worktreeRoot: opts.worktreeRoot,
53
+ gitCommonDir: opts.gitCommonDir,
54
+ parserVersions: opts.parserVersions ?? {},
55
+ updatedAt: new Date().toISOString(),
56
+ });
57
+ return;
58
+ }
59
+ const row = this.db.prepare("SELECT MAX(schema_version) AS v FROM graph_meta").get() as { v: number } | undefined;
60
+ const version = row?.v ?? 0;
61
+ if (version < CURRENT_SCHEMA_VERSION) {
62
+ this.applyIncrementalMigrations(version, opts);
63
+ this.upsertMeta({
64
+ schemaVersion: CURRENT_SCHEMA_VERSION,
65
+ worktreeRoot: opts.worktreeRoot,
66
+ gitCommonDir: opts.gitCommonDir,
67
+ parserVersions: opts.parserVersions ?? {},
68
+ updatedAt: new Date().toISOString(),
69
+ });
70
+ }
71
+ }
72
+
73
+ /** Step-level idempotent incremental migration: each step checks its target
74
+ * artifact (PRAGMA table_info / sqlite_master) before applying, so a crash
75
+ * mid-upgrade can be safely re-entered. schema_version advances only after
76
+ * all steps complete. */
77
+ private applyIncrementalMigrations(fromVersion: number, opts: StoreOptions): void {
78
+ this.tx(() => {
79
+ if (fromVersion < 2) {
80
+ const snapshotTable = this.db
81
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='code_graph_snapshot'")
82
+ .get();
83
+ if (!snapshotTable) {
84
+ this.db.exec(
85
+ `CREATE TABLE code_graph_snapshot (
86
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ head_commit TEXT NOT NULL,
88
+ uncommitted_paths TEXT NOT NULL,
89
+ recorded_at TEXT NOT NULL
90
+ )`,
91
+ );
92
+ }
93
+ const columns = this.db.prepare("PRAGMA table_info(files)").all() as Array<{ name: string }>;
94
+ if (!columns.some((column) => column.name === "pending_kind")) {
95
+ this.db.exec("ALTER TABLE files ADD COLUMN pending_kind TEXT");
96
+ }
97
+ const initialSnapshot = this.db
98
+ .prepare("SELECT COUNT(*) AS c FROM code_graph_snapshot")
99
+ .get() as { c: number };
100
+ if (initialSnapshot.c === 0) {
101
+ this.db
102
+ .prepare(
103
+ `INSERT INTO code_graph_snapshot (head_commit, uncommitted_paths, recorded_at)
104
+ VALUES (?, ?, ?)`,
105
+ )
106
+ .run("", "[]", new Date().toISOString());
107
+ }
108
+ }
109
+ void opts;
110
+ });
111
+ }
112
+
113
+ private applyMigrations(): void {
114
+ for (const stmt of SCHEMA_STATEMENTS) this.db.exec(stmt);
115
+ this.db.exec(FUNCTION_RECORDS_VIEW);
116
+ this.db
117
+ .prepare(
118
+ `INSERT INTO graph_meta (schema_version, worktree_root, git_common_dir, parser_versions, updated_at)
119
+ VALUES (?, ?, ?, ?, ?)
120
+ ON CONFLICT(schema_version) DO UPDATE SET
121
+ worktree_root = excluded.worktree_root,
122
+ git_common_dir = excluded.git_common_dir,
123
+ parser_versions = excluded.parser_versions,
124
+ updated_at = excluded.updated_at`,
125
+ )
126
+ .run(
127
+ CURRENT_SCHEMA_VERSION,
128
+ this.worktreeRoot,
129
+ this.gitCommonDir,
130
+ JSON.stringify({}),
131
+ new Date().toISOString(),
132
+ );
133
+ }
134
+
135
+ upsertMeta(meta: GraphMeta): void {
136
+ this.db
137
+ .prepare(
138
+ `INSERT INTO graph_meta (schema_version, worktree_root, git_common_dir, parser_versions, updated_at)
139
+ VALUES (?, ?, ?, ?, ?)
140
+ ON CONFLICT(schema_version) DO UPDATE SET
141
+ worktree_root = excluded.worktree_root,
142
+ git_common_dir = excluded.git_common_dir,
143
+ parser_versions = excluded.parser_versions,
144
+ updated_at = excluded.updated_at`,
145
+ )
146
+ .run(
147
+ meta.schemaVersion,
148
+ meta.worktreeRoot,
149
+ meta.gitCommonDir,
150
+ JSON.stringify(meta.parserVersions),
151
+ meta.updatedAt,
152
+ );
153
+ }
154
+
155
+ upsertSnapshot(headCommit: string, uncommittedPaths: string[]): void {
156
+ this.tx(() => {
157
+ this.db
158
+ .prepare(
159
+ `INSERT INTO code_graph_snapshot (head_commit, uncommitted_paths, recorded_at)
160
+ VALUES (?, ?, ?)`,
161
+ )
162
+ .run(headCommit, JSON.stringify(uncommittedPaths), new Date().toISOString());
163
+ });
164
+ }
165
+
166
+ readLatestSnapshot(): CodeGraphSnapshot | null {
167
+ const row = this.db
168
+ .prepare(
169
+ `SELECT id, head_commit, uncommitted_paths, recorded_at
170
+ FROM code_graph_snapshot ORDER BY id DESC LIMIT 1`,
171
+ )
172
+ .get() as
173
+ | { id: number; head_commit: string; uncommitted_paths: string; recorded_at: string }
174
+ | undefined;
175
+ if (!row) return null;
176
+ return {
177
+ id: row.id,
178
+ headCommit: row.head_commit,
179
+ uncommittedPaths: JSON.parse(row.uncommitted_paths) as string[],
180
+ recordedAt: row.recorded_at,
181
+ };
182
+ }
183
+
184
+ readMeta(): GraphMeta | null {
185
+ const row = this.db
186
+ .prepare(
187
+ `SELECT schema_version, worktree_root, git_common_dir, parser_versions, updated_at
188
+ FROM graph_meta ORDER BY schema_version DESC LIMIT 1`,
189
+ )
190
+ .get() as {
191
+ schema_version: number;
192
+ worktree_root: string;
193
+ git_common_dir: string;
194
+ parser_versions: string;
195
+ updated_at: string;
196
+ } | undefined;
197
+ if (!row) return null;
198
+ return {
199
+ schemaVersion: row.schema_version,
200
+ worktreeRoot: row.worktree_root,
201
+ gitCommonDir: row.git_common_dir,
202
+ parserVersions: JSON.parse(row.parser_versions) as Record<string, string>,
203
+ updatedAt: row.updated_at,
204
+ };
205
+ }
206
+
207
+ checkWorktree(currentWorktreeRoot: string, currentGitCommonDir: string): void {
208
+ const meta = this.readMeta();
209
+ if (!meta) return;
210
+ if (meta.worktreeRoot !== currentWorktreeRoot || meta.gitCommonDir !== currentGitCommonDir) {
211
+ throw new PathError(
212
+ `code_graph.db belongs to ${meta.worktreeRoot} (${meta.gitCommonDir}); current worktree is ${currentWorktreeRoot} (${currentGitCommonDir}). Refusing to share the DB across worktrees.`,
213
+ );
214
+ }
215
+ }
216
+
217
+ /** Run a function inside a write transaction. Uses BEGIN IMMEDIATE so that
218
+ * reads cannot later upgrade to writes and bypass busy timeouts. */
219
+ tx<T>(fn: () => T): T {
220
+ if (this.mode === "ro") throw new Error("store is opened read-only; cannot begin a write transaction");
221
+ this.db.exec("BEGIN IMMEDIATE");
222
+ try {
223
+ const result = fn();
224
+ this.db.exec("COMMIT");
225
+ return result;
226
+ } catch (error) {
227
+ try {
228
+ this.db.exec("ROLLBACK");
229
+ } catch {
230
+ /* rollback failure is non-fatal after a write error */
231
+ }
232
+ throw error;
233
+ }
234
+ }
235
+
236
+ read<T>(fn: () => T): T {
237
+ this.db.exec("BEGIN");
238
+ try {
239
+ const result = fn();
240
+ this.db.exec("COMMIT");
241
+ return result;
242
+ } catch (error) {
243
+ try {
244
+ this.db.exec("ROLLBACK");
245
+ } catch {
246
+ /* ignore */
247
+ }
248
+ throw error;
249
+ }
250
+ }
251
+
252
+ prepare(key: string, sql: string): StatementSync {
253
+ let stmt = this.prepared.get(key);
254
+ if (!stmt) {
255
+ stmt = this.db.prepare(sql);
256
+ this.prepared.set(key, stmt);
257
+ }
258
+ return stmt;
259
+ }
260
+
261
+ close(): void {
262
+ try {
263
+ this.db.close();
264
+ } catch {
265
+ /* best effort */
266
+ }
267
+ }
268
+
269
+ exists(): boolean {
270
+ return true;
271
+ }
272
+ }
273
+
274
+ /** Helper for tests: run a function inside a fresh in-memory store. */
275
+ export async function openStore(opts: StoreOptions): Promise<Store> {
276
+ const sqlite = await import("node:sqlite");
277
+ return new Store(opts, sqlite);
278
+ }