simt-dev 0.6.3 → 0.7.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 (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  10. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  15. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
@@ -4,18 +4,80 @@
4
4
  // rows. This file is the format; store.mjs is the walk that fills it. They are
5
5
  // split because the walk and the format together crossed the review trigger.
6
6
 
7
+ import { createHash } from 'node:crypto';
7
8
  import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
8
9
  import { dirname, join } from 'node:path';
9
10
 
10
- export const SQLITE_FILE = '.simt/index.sqlite';
11
- export const JSONL_FILE = '.simt/index.jsonl';
12
- export const SCHEMA_V = '3';
11
+ import { LOCAL_DIR } from '../state.mjs';
12
+
13
+ export const SQLITE_FILE = join(LOCAL_DIR, 'index.sqlite');
14
+ export const JSONL_FILE = join(LOCAL_DIR, 'index.jsonl');
15
+ const SQLITE_FILE_LEGACY = '.simt/index.sqlite';
16
+ const JSONL_FILE_LEGACY = '.simt/index.jsonl';
17
+ export const SCHEMA_V = '4';
18
+
19
+ // The FTS tables carry their tokenizer in their definition, so `if not exists`
20
+ // cannot migrate them: a v3 database would keep tokenizing new rows the v3 way
21
+ // (ASCII fold, kind and ref searchable). The 3 -> 4 migration below recreates
22
+ // them, which is the versioned replacement for the per-write wipe (Q44).
23
+ export const FTS_SCHEMA = 'create virtual table fts using fts5(kind UNINDEXED, ref UNINDEXED, body, tokenize = "unicode61 remove_diacritics 2")';
24
+ export const FTS_TRI_SCHEMA = 'create virtual table fts_tri using fts5(kind UNINDEXED, ref UNINDEXED, body, tokenize = "trigram")';
25
+
26
+ // A database written by a *newer* simt is never opened for write, and never
27
+ // read blind (review 0003 Q44): the mismatched canonical file stays with its
28
+ // owner, and this version reads and writes a sidecar named after itself, so
29
+ // two pinned versions stop rewriting one file past each other (the ADR-059
30
+ // flip-flop). An *older* database is read and written in place, and the
31
+ // migration brings it current.
32
+ export function indexFileFor(root, backend, version) {
33
+ const base = backend === 'sqlite' ? SQLITE_FILE : JSONL_FILE;
34
+ if (version === '' || version === SCHEMA_V) return join(root, base);
35
+ const dot = base.lastIndexOf('.');
36
+ return join(root, `${base.slice(0, dot)}.v${SCHEMA_V}${base.slice(dot)}`);
37
+ }
38
+
39
+ // The canonical file is opened for write when the stored version is this one
40
+ // or older - the ordered migration upgrades the older one in place. A newer
41
+ // version owns its file, and opening it here would demote the stamp.
42
+ function canonicalForWrite(version) {
43
+ const at = Number(version);
44
+ return Number.isInteger(at) && at <= Number(SCHEMA_V);
45
+ }
46
+
47
+ // Ordered schema migrations: each step upgrades a database written at `from`
48
+ // to `to`, applied in list order. A database at a version past the last step
49
+ // is not opened for write at all (indexFileFor routes it to a sidecar).
50
+ const MIGRATIONS = [
51
+ {
52
+ from: 3,
53
+ to: 4,
54
+ apply: (db) => {
55
+ db.exec('drop table if exists fts');
56
+ db.exec(FTS_SCHEMA);
57
+ db.exec('drop table if exists fts_tri');
58
+ db.exec(FTS_TRI_SCHEMA);
59
+ },
60
+ },
61
+ ];
62
+
63
+ /**
64
+ * Bring an open database up to the current schema and stamp it. `user_version`
65
+ * 0 means "written before the stamp existed": those databases carried the
66
+ * version in meta only, so every step at or below the current schema runs -
67
+ * each is idempotent on an already-migrated table set.
68
+ */
69
+ function migrateDb(db) {
70
+ const at = Number(db.prepare('pragma user_version').get()?.user_version ?? 0);
71
+ for (const step of MIGRATIONS) {
72
+ if (at === 0 || (at >= step.from && at < step.to)) step.apply(db);
73
+ }
74
+ db.exec(`pragma user_version = ${Number(SCHEMA_V)}`);
75
+ }
13
76
 
14
77
  const SCHEMA = `
15
78
  create table if not exists files(path text primary key, blob text, lang text, bytes int, pkg text);
16
79
  create table if not exists symbols(id integer primary key, path text, name text, kind text, line int, sig text, exported int);
17
80
  create table if not exists edges(src text, dst text, kind text, weight real);
18
- create virtual table if not exists fts using fts5(kind, ref, body);
19
81
  create table if not exists embeddings(path text primary key, blob text, model text, dims int, vec blob);
20
82
  create table if not exists meta(key text primary key, value text);
21
83
  `;
@@ -52,9 +114,51 @@ function tmpFor(file) {
52
114
  return `${file}.${process.pid}.${tmpSeq}.tmp`;
53
115
  }
54
116
 
55
- export async function writeSqlite(root, next, removed, symbols, edges, fts, embeddings = []) {
117
+ /**
118
+ * What the index was built from, so a reader can tell a current tree from an
119
+ * old one without re-reading any blob (review 0003 Q42).
120
+ *
121
+ * The digest covers the `git ls-files -s` listing the build consumed: same
122
+ * bytes in, same digest out. Stored beside `schema` in `meta`, written on every
123
+ * build including the no-change one, so a build that changed nothing still
124
+ * vouches for the tree it saw.
125
+ */
126
+ export function treeDigest(listed) {
127
+ const rows = [...(listed ?? [])]
128
+ .map((entry) => `${entry.blob ?? ''} ${entry.path ?? ''}`)
129
+ .sort()
130
+ .join('\n');
131
+ return createHash('sha1').update(rows, 'utf8').digest('hex');
132
+ }
133
+
134
+ export async function writeSqlite(root, next, removed, symbols, edges, fts, embeddings = [], tree = '') {
56
135
  const { DatabaseSync } = await import('node:sqlite');
57
- const file = join(root, SQLITE_FILE);
136
+ // The canonical file is written when it is this version's or older (the
137
+ // migration upgrades the older one in place, review 0003 Q44): a database at
138
+ // a newer version stays with its owner, and the build goes to the sidecar
139
+ // this version owns.
140
+ let file = join(root, SQLITE_FILE);
141
+ try {
142
+ await stat(file);
143
+ } catch {
144
+ file = join(root, SQLITE_FILE_LEGACY);
145
+ try {
146
+ await stat(file);
147
+ } catch {
148
+ file = null;
149
+ }
150
+ }
151
+ if (file) {
152
+ let version = SCHEMA_V;
153
+ try {
154
+ version = await storedVersion(file);
155
+ } catch {
156
+ version = SCHEMA_V; // unreadable: rebuild it in place, as a cold build would
157
+ }
158
+ file = canonicalForWrite(version) ? file : indexFileFor(root, 'sqlite', version);
159
+ } else {
160
+ file = join(root, SQLITE_FILE);
161
+ }
58
162
  await mkdir(dirname(file), { recursive: true });
59
163
  // A cold build goes to a temp file that is renamed over the target: three
60
164
  // concurrent cold runs used to interleave schema creation and leave a locked,
@@ -83,6 +187,7 @@ export async function writeSqlite(root, next, removed, symbols, edges, fts, embe
83
187
  db.exec('pragma synchronous = normal');
84
188
  db.exec(SCHEMA);
85
189
  db.exec('begin immediate');
190
+ migrateDb(db);
86
191
  const upsert = db.prepare(`insert into files(path, blob, lang, bytes, pkg) values (?, ?, ?, ?, ?)
87
192
  on conflict(path) do update set blob = excluded.blob, lang = excluded.lang, bytes = excluded.bytes, pkg = excluded.pkg`);
88
193
  const dropSymbols = db.prepare('delete from symbols where path = ?');
@@ -92,11 +197,13 @@ export async function writeSqlite(root, next, removed, symbols, edges, fts, embe
92
197
  const dropEdges = db.prepare('delete from edges where src = ?');
93
198
  const dropIncoming = db.prepare('delete from edges where dst = ?');
94
199
  const dropFts = db.prepare('delete from fts where ref = ?');
200
+ const dropTri = db.prepare('delete from fts_tri where ref = ?');
95
201
  const dropEmbed = db.prepare('delete from embeddings where path = ?');
96
202
  const dropFile = db.prepare('delete from files where path = ?');
97
203
  const addSymbol = db.prepare('insert into symbols(path, name, kind, line, sig, exported) values (?, ?, ?, ?, ?, ?)');
98
204
  const addEdge = db.prepare('insert into edges(src, dst, kind, weight) values (?, ?, ?, ?)');
99
205
  const addFts = db.prepare('insert into fts(kind, ref, body) values (?, ?, ?)');
206
+ const addTri = db.prepare('insert into fts_tri(kind, ref, body) values (?, ?, ?)');
100
207
  const upsertEmbed = db.prepare(`insert into embeddings(path, blob, model, dims, vec) values (?, ?, ?, ?, ?)
101
208
  on conflict(path) do update set blob = excluded.blob, model = excluded.model, dims = excluded.dims, vec = excluded.vec`);
102
209
  for (const row of next) {
@@ -104,6 +211,7 @@ export async function writeSqlite(root, next, removed, symbols, edges, fts, embe
104
211
  dropSymbols.run(row.path);
105
212
  dropEdges.run(row.path);
106
213
  dropFts.run(row.path);
214
+ dropTri.run(row.path);
107
215
  dropEmbed.run(row.path);
108
216
  }
109
217
  for (const path of removed) {
@@ -112,17 +220,27 @@ export async function writeSqlite(root, next, removed, symbols, edges, fts, embe
112
220
  dropEdges.run(path);
113
221
  dropIncoming.run(path);
114
222
  dropFts.run(path);
223
+ dropTri.run(path);
115
224
  dropEmbed.run(path);
116
225
  }
117
226
  for (const row of symbols) addSymbol.run(row.path, row.name, row.kind, row.line, row.sig, row.exported);
118
227
  for (const row of edges) addEdge.run(row.src, row.dst, row.kind, row.weight);
119
- for (const row of fts) addFts.run(row.kind, row.ref, row.body);
228
+ for (const row of fts) {
229
+ addFts.run(row.kind, row.ref, row.body);
230
+ // The trigram table carries folded raw text for CJK-bearing sections
231
+ // only; an ASCII-only section is already reachable through `fts`.
232
+ if (row.raw) addTri.run(row.kind, row.ref, row.raw);
233
+ }
120
234
  for (const row of embeddings) {
121
235
  const vecBlob = Buffer.isBuffer(row.vec) || row.vec instanceof Uint8Array ? row.vec : encodeVectorBlob(row.vec);
122
236
  const dims = Number.isInteger(row.dims) ? row.dims : Math.floor(vecBlob.byteLength / 4);
123
237
  upsertEmbed.run(row.path, row.blob ?? '', row.model ?? '', dims, vecBlob);
124
238
  }
125
- db.prepare('insert into meta(key, value) values (?, ?) on conflict(key) do update set value = excluded.value').run('schema', SCHEMA_V);
239
+ const upsertMeta = db.prepare(
240
+ 'insert into meta(key, value) values (?, ?) on conflict(key) do update set value = excluded.value',
241
+ );
242
+ upsertMeta.run('schema', SCHEMA_V);
243
+ upsertMeta.run('tree', tree);
126
244
  db.exec('commit');
127
245
  } catch (error) {
128
246
  try { db.exec('rollback'); } catch { /* a failed begin has nothing to undo */ }
@@ -133,6 +251,32 @@ export async function writeSqlite(root, next, removed, symbols, edges, fts, embe
133
251
  if (cold) await rename(target, file);
134
252
  }
135
253
 
254
+ /**
255
+ * The version a database declares: `PRAGMA user_version` when stamped, else the
256
+ * meta `schema` row databases written before the stamp carried it in. Throws
257
+ * when the file cannot be read as a database - the caller decides whether that
258
+ * is a cold start or a corrupt one.
259
+ */
260
+ async function storedVersion(file) {
261
+ const { DatabaseSync } = await import('node:sqlite');
262
+ const db = new DatabaseSync(file);
263
+ try {
264
+ const user = Number(db.prepare('pragma user_version').get()?.user_version ?? 0);
265
+ if (user > 0) return String(user);
266
+ return db.prepare("select value from meta where key = 'schema'").get()?.value ?? '';
267
+ } finally {
268
+ db.close();
269
+ }
270
+ }
271
+
272
+ /** A damaged index file is named, not silent (review 0003 Q44). */
273
+ function corruptIndexError(cause) {
274
+ const corrupt = new Error('the index file is corrupt; run the index build to replace it');
275
+ corrupt.code = 'INDEX_CORRUPT';
276
+ corrupt.cause = cause;
277
+ return corrupt;
278
+ }
279
+
136
280
  /**
137
281
  * A contended index write is a retryable race, not a broken repository: name it
138
282
  * so the command maps its own remedy instead of blaming the git checkout (Q08).
@@ -145,10 +289,46 @@ function asIndexError(error) {
145
289
  busy.cause = error;
146
290
  return busy;
147
291
  }
292
+ if (/file is not a database|database disk image|malformed|corrupt/i.test(error?.message ?? '')) {
293
+ return corruptIndexError(error);
294
+ }
148
295
  return error;
149
296
  }
150
297
 
151
- export async function writeJsonl(root, previous, next, removed, symbols, edges, fts, force, embeddings = []) {
298
+ /**
299
+ * The jsonl file this version writes to: the canonical file when it is this
300
+ * version's or unreadable, the sidecar when it belongs to another version
301
+ * (review 0003 Q44).
302
+ */
303
+ async function jsonlTargetFile(root) {
304
+ const base = join(root, JSONL_FILE);
305
+ let text = null;
306
+ try {
307
+ text = await readFile(base, 'utf8');
308
+ } catch {
309
+ try {
310
+ text = await readFile(join(root, JSONL_FILE_LEGACY), 'utf8');
311
+ } catch {
312
+ return base; // no file: cold build goes to the canonical path
313
+ }
314
+ }
315
+ try {
316
+ for (const line of text.split('\n')) {
317
+ if (line === '') continue;
318
+ const row = JSON.parse(line);
319
+ if (row.table !== 'meta' || row.key !== 'schema') continue;
320
+ const value = row.value ?? '';
321
+ // Older files are rewritten in place at the current schema; a newer one
322
+ // stays with its owner and the build goes to the sidecar.
323
+ return canonicalForWrite(value) ? base : indexFileFor(root, 'jsonl', value);
324
+ }
325
+ } catch {
326
+ return base; // unreadable: rebuild it in place
327
+ }
328
+ return base;
329
+ }
330
+
331
+ export async function writeJsonl(root, previous, next, removed, symbols, edges, fts, force, embeddings = [], tree = '') {
152
332
  const gone = new Set(removed);
153
333
  const replaced = new Map(next.map((row) => [row.path, row]));
154
334
  const drop = new Set([...replaced.keys(), ...gone]);
@@ -160,25 +340,26 @@ export async function writeJsonl(root, previous, next, removed, symbols, edges,
160
340
  }
161
341
  for (const row of replaced.values()) files.push(row);
162
342
  files.sort(byPath);
163
- const keptSymbols = force ? [] : (await readJsonl(root, 'symbols')).filter((row) => !drop.has(row.path));
343
+ const keptSymbols = force ? [] : (await readJsonl(root, 'symbols')).rows.filter((row) => !drop.has(row.path));
164
344
  // Edge ownership mirrors the sqlite backend: a changed file loses only the edges
165
345
  // it owns (src), while a removed file also drops the edges that pointed at it.
166
- const keptEdges = force ? [] : (await readJsonl(root, 'edges')).filter((row) => !drop.has(row.src) && !gone.has(row.dst));
167
- const keptFts = force ? [] : (await readJsonl(root, 'fts')).filter((row) => !drop.has(row.ref));
346
+ const keptEdges = force ? [] : (await readJsonl(root, 'edges')).rows.filter((row) => !drop.has(row.src) && !gone.has(row.dst));
347
+ const keptFts = force ? [] : (await readJsonl(root, 'fts')).rows.filter((row) => !drop.has(row.ref));
168
348
  const newEmbedPaths = new Set(embeddings.map((row) => row.path));
169
349
  const keptEmbeddings = force
170
350
  ? []
171
- : (await readJsonl(root, 'embeddings')).filter((row) => !drop.has(row.path) && !newEmbedPaths.has(row.path));
351
+ : (await readJsonl(root, 'embeddings')).rows.filter((row) => !drop.has(row.path) && !newEmbedPaths.has(row.path));
172
352
  const allSymbols = [...keptSymbols, ...symbols].sort(bySymbol);
173
353
  const allEdges = [...keptEdges, ...edges].sort(byEdge);
174
354
  const allFts = [...keptFts, ...fts].sort((a, b) => cmp(a.ref, b.ref));
175
355
  const allEmbeddings = [...keptEmbeddings, ...embeddings].sort(byPath);
176
- const file = join(root, JSONL_FILE);
356
+ const file = await jsonlTargetFile(root);
177
357
  await mkdir(dirname(file), { recursive: true });
178
358
  // Written atomically: a concurrent reader never sees a half-written file.
179
359
  const tmp = tmpFor(file);
180
360
  const lines = [
181
361
  JSON.stringify({ table: 'meta', key: 'schema', value: SCHEMA_V }),
362
+ JSON.stringify({ table: 'meta', key: 'tree', value: tree }),
182
363
  ...files.map((row) => JSON.stringify({ table: 'files', path: row.path, blob: row.blob, lang: row.lang, bytes: row.bytes, pkg: row.pkg })),
183
364
  ...allSymbols.map((row) => JSON.stringify({ table: 'symbols', path: row.path, name: row.name, kind: row.kind, line: row.line, sig: row.sig, exported: row.exported })),
184
365
  ...allEdges.map((row) => JSON.stringify({ table: 'edges', src: row.src, dst: row.dst, kind: row.kind, weight: row.weight })),
@@ -202,58 +383,127 @@ export async function writeJsonl(root, previous, next, removed, symbols, edges,
202
383
  }
203
384
 
204
385
  export async function readJsonl(root, table) {
386
+ // The pre-move path is a fallback for one release (review 0003 Q19).
205
387
  let text;
206
388
  try {
207
389
  text = await readFile(join(root, JSONL_FILE), 'utf8');
208
390
  } catch {
209
- return [];
391
+ try {
392
+ text = await readFile(join(root, JSONL_FILE_LEGACY), 'utf8');
393
+ } catch {
394
+ return { rows: [], error: null };
395
+ }
396
+ }
397
+ let schema = '';
398
+ try {
399
+ for (const line of text.split('\n')) {
400
+ if (line === '') continue;
401
+ const row = JSON.parse(line);
402
+ if (row.table === 'meta' && row.key === 'schema') schema = row.value ?? '';
403
+ }
404
+ } catch (error) {
405
+ return { rows: [], error: corruptIndexError(error) };
406
+ }
407
+ // A file a newer version owns is not read blind (review 0003 Q44): this
408
+ // version's sidecar is read instead, and a sidecar that has not been built
409
+ // yet is cold, not broken. An older file is read in place; the next write
410
+ // rewrites it at the current schema.
411
+ if (!canonicalForWrite(schema)) {
412
+ try {
413
+ text = await readFile(indexFileFor(root, 'jsonl', schema), 'utf8');
414
+ } catch {
415
+ return { rows: [], error: null };
416
+ }
210
417
  }
211
418
  const rows = [];
212
- for (const line of text.split('\n')) {
213
- if (line === '') continue;
214
- const row = JSON.parse(line);
215
- if (row.table !== table) continue;
216
- if (table === 'meta') rows.push({ key: row.key, value: row.value });
217
- else if (table === 'files') rows.push({ path: row.path, blob: row.blob, lang: row.lang, bytes: row.bytes, pkg: row.pkg });
218
- else if (table === 'symbols') rows.push({ path: row.path, name: row.name, kind: row.kind, line: row.line, sig: row.sig, exported: row.exported });
219
- else if (table === 'edges') rows.push({ src: row.src, dst: row.dst, kind: row.kind, weight: row.weight });
220
- else if (table === 'fts') rows.push({ kind: row.kind, ref: row.ref, body: row.body });
221
- else if (table === 'embeddings') {
222
- const vec = decodeVectorBlob(row.vec);
223
- rows.push({
224
- path: row.path,
225
- blob: row.blob ?? '',
226
- model: row.model ?? '',
227
- dims: Number.isInteger(row.dims) ? row.dims : vec.length,
228
- vec,
229
- });
419
+ try {
420
+ for (const line of text.split('\n')) {
421
+ if (line === '') continue;
422
+ const row = JSON.parse(line);
423
+ if (row.table !== table) continue;
424
+ if (table === 'meta') rows.push({ key: row.key, value: row.value });
425
+ else if (table === 'files') rows.push({ path: row.path, blob: row.blob, lang: row.lang, bytes: row.bytes, pkg: row.pkg });
426
+ else if (table === 'symbols') rows.push({ path: row.path, name: row.name, kind: row.kind, line: row.line, sig: row.sig, exported: row.exported });
427
+ else if (table === 'edges') rows.push({ src: row.src, dst: row.dst, kind: row.kind, weight: row.weight });
428
+ else if (table === 'fts') rows.push({ kind: row.kind, ref: row.ref, body: row.body });
429
+ else if (table === 'embeddings') {
430
+ const vec = decodeVectorBlob(row.vec);
431
+ rows.push({
432
+ path: row.path,
433
+ blob: row.blob ?? '',
434
+ model: row.model ?? '',
435
+ dims: Number.isInteger(row.dims) ? row.dims : vec.length,
436
+ vec,
437
+ });
438
+ }
230
439
  }
440
+ } catch (error) {
441
+ return { rows: [], error: corruptIndexError(error) };
231
442
  }
232
443
  if (table === 'symbols') rows.sort(bySymbol);
233
444
  else if (table === 'edges') rows.sort(byEdge);
234
445
  else if (table === 'fts') rows.sort((a, b) => cmp(a.ref, b.ref));
235
446
  else rows.sort(byPath);
236
- return rows;
447
+ return { rows, error: null };
237
448
  }
238
449
 
450
+ /**
451
+ * The sqlite file a read opens: the canonical file when it is this version's
452
+ * or older, the sidecar when it belongs to a newer one (review 0003 Q44),
453
+ * null when no index for this version exists. Throws when a present file
454
+ * cannot be read as a database - corrupt, not cold.
455
+ */
456
+ async function resolveSqliteFile(root) {
457
+ // The pre-move path is a fallback for one release (review 0003 Q19).
458
+ let file = join(root, SQLITE_FILE);
459
+ try {
460
+ await stat(file);
461
+ } catch {
462
+ file = join(root, SQLITE_FILE_LEGACY);
463
+ try {
464
+ await stat(file);
465
+ } catch {
466
+ return null;
467
+ }
468
+ }
469
+ const version = await storedVersion(file);
470
+ // An older database is readable in place: its tables carry the same columns,
471
+ // and the next write migrates it. Only a newer one is routed to a sidecar.
472
+ if (canonicalForWrite(version)) return file;
473
+ const sidecar = indexFileFor(root, 'sqlite', version);
474
+ try {
475
+ await stat(sidecar);
476
+ return sidecar;
477
+ } catch {
478
+ return null; // this version has not built its sidecar yet: cold
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Read rows from the index. Returns `{ rows, error }`: a missing index is
484
+ * `{ rows: [], error: null }` (cold), a present index that cannot be read is
485
+ * an empty row set with a named error - "locked", "no such column" and
486
+ * "corrupt" used to all look like "no results" (review 0003 Q44).
487
+ */
239
488
  export async function readSqliteRows(root, sql, params = []) {
240
489
  let DatabaseSync;
241
490
  try {
242
491
  ({ DatabaseSync } = await import('node:sqlite'));
243
492
  } catch {
244
- return [];
493
+ return { rows: [], error: null };
245
494
  }
246
- const file = join(root, SQLITE_FILE);
495
+ let file;
247
496
  try {
248
- await stat(file);
249
- } catch {
250
- return [];
497
+ file = await resolveSqliteFile(root);
498
+ } catch (error) {
499
+ return { rows: [], error: asIndexError(error) };
251
500
  }
501
+ if (!file) return { rows: [], error: null };
252
502
  const db = new DatabaseSync(file);
253
503
  try {
254
- return db.prepare(sql).all(...params);
255
- } catch {
256
- return [];
504
+ return { rows: db.prepare(sql).all(...params), error: null };
505
+ } catch (error) {
506
+ return { rows: [], error: asIndexError(error) };
257
507
  } finally {
258
508
  db.close();
259
509
  }
@@ -261,21 +511,31 @@ export async function readSqliteRows(root, sql, params = []) {
261
511
 
262
512
  export async function readSchema(root, backend) {
263
513
  if (backend === 'sqlite') {
264
- const rows = await readSqliteRows(root, "select value from meta where key = 'schema'");
514
+ const { rows } = await readSqliteRows(root, "select value from meta where key = 'schema'");
265
515
  return rows[0]?.value ?? '';
266
516
  }
267
- const rows = await readJsonl(root, 'meta');
517
+ const { rows } = await readJsonl(root, 'meta');
268
518
  return rows.find((row) => row.key === 'schema')?.value ?? '';
269
519
  }
270
520
 
521
+ /** The tree the stored index was built from, or '' when no build has vouched for one. */
522
+ export async function readTreeDigest(root, backend) {
523
+ if (backend === 'sqlite') {
524
+ const { rows } = await readSqliteRows(root, "select value from meta where key = 'tree'");
525
+ return rows[0]?.value ?? '';
526
+ }
527
+ const { rows } = await readJsonl(root, 'meta');
528
+ return rows.find((row) => row.key === 'tree')?.value ?? '';
529
+ }
530
+
271
531
  export async function countSqlite(root) {
272
532
  const symbols = await readSqliteRows(root, 'select count(*) as n from symbols');
273
533
  const edges = await readSqliteRows(root, 'select count(*) as n from edges');
274
534
  const embeddings = await readSqliteRows(root, 'select count(*) as n from embeddings');
275
535
  return {
276
- symbols: Number(symbols[0]?.n ?? 0),
277
- edges: Number(edges[0]?.n ?? 0),
278
- embeddings: Number(embeddings[0]?.n ?? 0),
536
+ symbols: Number(symbols.rows[0]?.n ?? 0),
537
+ edges: Number(edges.rows[0]?.n ?? 0),
538
+ embeddings: Number(embeddings.rows[0]?.n ?? 0),
279
539
  };
280
540
  }
281
541