simt-dev 0.6.2 → 0.6.3

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 (76) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  5. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  6. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  7. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  8. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  9. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  10. package/docs/decisions/README.md +44 -0
  11. package/package.json +1 -1
  12. package/rules/mdc/preview-postflight.mdc +5 -6
  13. package/rules/stacks/android.md +7 -8
  14. package/rules/stacks/cloudflare-workers.md +1 -1
  15. package/rules/stacks/container.md +2 -0
  16. package/rules/stacks/jvm.md +1 -1
  17. package/rules/stacks/react-native.md +3 -5
  18. package/rules/stacks/react-web.md +9 -9
  19. package/src/agenthooks/decide.mjs +101 -10
  20. package/src/agenthooks/policy.mjs +81 -5
  21. package/src/agenthooks/shell.mjs +523 -0
  22. package/src/argv.mjs +54 -9
  23. package/src/commands/agenthook.mjs +25 -3
  24. package/src/commands/ci.mjs +28 -3
  25. package/src/commands/completions.mjs +27 -0
  26. package/src/commands/decide.mjs +14 -1
  27. package/src/commands/doctor.mjs +367 -0
  28. package/src/commands/explain.mjs +52 -0
  29. package/src/commands/help.mjs +66 -17
  30. package/src/commands/hooks.mjs +208 -60
  31. package/src/commands/ignore.mjs +152 -0
  32. package/src/commands/index.mjs +54 -100
  33. package/src/commands/init.mjs +82 -12
  34. package/src/commands/lease.mjs +136 -0
  35. package/src/commands/lessons.mjs +12 -0
  36. package/src/commands/mcp.mjs +2 -2
  37. package/src/commands/pack.mjs +9 -0
  38. package/src/commands/plan.mjs +13 -1
  39. package/src/commands/plugin.mjs +16 -1
  40. package/src/commands/prbody.mjs +45 -0
  41. package/src/commands/repoindex.mjs +3 -0
  42. package/src/commands/rules.mjs +145 -105
  43. package/src/commands/shared.mjs +36 -5
  44. package/src/commands/skills.mjs +293 -205
  45. package/src/commands/skillsinstall.mjs +100 -30
  46. package/src/commands/slice.mjs +353 -0
  47. package/src/commands/status.mjs +62 -0
  48. package/src/commands/sync.mjs +110 -15
  49. package/src/commands/undo.mjs +106 -0
  50. package/src/commands/upgrade.mjs +70 -8
  51. package/src/completions.mjs +191 -0
  52. package/src/diff.mjs +254 -0
  53. package/src/explain.mjs +258 -0
  54. package/src/federation/config.mjs +44 -0
  55. package/src/graph/load.mjs +14 -5
  56. package/src/hooks/dispatcher.mjs +110 -0
  57. package/src/hooks/gates.mjs +14 -3
  58. package/src/hooks/resolve.mjs +14 -0
  59. package/src/ignore.mjs +133 -0
  60. package/src/index/persist.mjs +66 -9
  61. package/src/journal.mjs +326 -0
  62. package/src/lease.mjs +113 -0
  63. package/src/mcp/server.mjs +15 -1
  64. package/src/mcp/tools.mjs +296 -256
  65. package/src/memory/decide.mjs +6 -1
  66. package/src/memory/lessons.mjs +12 -0
  67. package/src/output/index.mjs +82 -12
  68. package/src/package-version.mjs +49 -1
  69. package/src/packs/manage.mjs +13 -4
  70. package/src/plan/index.mjs +6 -0
  71. package/src/plugins.mjs +27 -6
  72. package/src/prbody.mjs +251 -0
  73. package/src/projection/manifest.mjs +13 -2
  74. package/src/projection/shared.mjs +4 -1
  75. package/src/secrets.mjs +231 -0
  76. package/src/status.mjs +152 -0
@@ -29,6 +29,7 @@ import { CLI } from '../name.mjs';
29
29
  import { MANIFEST_FILE } from '../name.mjs';
30
30
  import { assertVersionPin } from '../package-version.mjs';
31
31
  import { recordRun } from '../ledger/index.mjs';
32
+ import { renderFindings, scanStaged } from '../secrets.mjs';
32
33
 
33
34
  const exec = promisify(execFile);
34
35
 
@@ -121,6 +122,37 @@ export async function runGates(event, { root, manifest }) {
121
122
  });
122
123
  continue;
123
124
  }
125
+
126
+ // A built-in gate runs here, in this process: no script, no command, no
127
+ // tool on disk (ADR-100). It gets the same ledger line and the same
128
+ // fail-closed reporting as every other gate; only the source of the bytes
129
+ // it runs over differs. Keyed on the RESOLUTION rather than the gate, so a
130
+ // declared command for this name takes the shell path below instead.
131
+ if (resolution.builtin !== undefined) {
132
+ const startedBuiltin = Date.now();
133
+ const outcome = await runBuiltinGate(resolution.builtin, { root, staged });
134
+ try {
135
+ await recordRun(root, {
136
+ cmd: `builtin:${resolution.builtin}`,
137
+ exit: outcome.ok ? 0 : 1,
138
+ ms: Date.now() - startedBuiltin,
139
+ output: outcome.output,
140
+ });
141
+ } catch {
142
+ // Recorded by absence: the next evidence read for this tree has no line.
143
+ }
144
+ results.push({
145
+ name,
146
+ status: outcome.ok ? 'passed' : 'failed',
147
+ detail: outcome.detail,
148
+ output: outcome.ok ? '' : outcome.output,
149
+ ...(outcome.remedy !== undefined ? { remedy: outcome.remedy } : {}),
150
+ adr: gate.adr,
151
+ exitCode: outcome.ok ? 0 : 1,
152
+ });
153
+ continue;
154
+ }
155
+
124
156
  if (resolution.argv === null) {
125
157
  results.push({ name, status: 'skipped', detail: resolution.detail, adr: gate.adr, exitCode: null });
126
158
  continue;
@@ -241,6 +273,31 @@ export async function runGates(event, { root, manifest }) {
241
273
  return { event, gates: results, failed: results.some((r) => r.status === 'failed') };
242
274
  }
243
275
 
276
+ /**
277
+ * The lines that report one event's gate run: the event, what was declared, every
278
+ * gate with its disposition, its reason and its remedy, and the verdict.
279
+ *
280
+ * Shared by `hooks run` and `slice finish` rather than written twice (ADR-002: the
281
+ * second real use is what the extraction exists for). Both run this dispatcher over
282
+ * the same event, and two renderings of one run are two ways for one of them to
283
+ * quietly stop printing a gate that failed.
284
+ *
285
+ * @param {string} event the hook event as git spells it
286
+ * @param {string[]} declared the gate names the manifest declares for it
287
+ * @param {object} result the value `runGates` returned
288
+ * @returns {string[]} the report lines, newline-free, in print order
289
+ */
290
+ export function renderGateReport(event, declared, result) {
291
+ const lines = [`hooks ${event}`, `gates ${(declared ?? []).join(', ') || '(none declared)'}`];
292
+ for (const g of result.gates) {
293
+ lines.push(`${g.status.toUpperCase().padEnd(8)} ${g.name.padEnd(20)} ${g.detail}${g.adr ? ` [${g.adr}]` : ''}`);
294
+ if (g.remedy) lines.push(` remedy: ${g.remedy}`);
295
+ if (g.output) lines.push(...g.output.split('\n').map((l) => ` ${l}`));
296
+ }
297
+ lines.push(result.failed ? 'BLOCKED at least one gate failed' : 'PASSED');
298
+ return lines;
299
+ }
300
+
244
301
  async function run(argv, cwd) {
245
302
  try {
246
303
  const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, maxBuffer: 8 * 1024 * 1024 });
@@ -250,6 +307,59 @@ async function run(argv, cwd) {
250
307
  }
251
308
  }
252
309
 
310
+ /** The built-in gates this dispatcher can run, by name. */
311
+ const BUILTIN_GATES = new Map([['secret-scan', scanStaged]]);
312
+
313
+ /** How many findings the report prints; the detail carries the true count. */
314
+ const BUILTIN_REPORT_MAX = 12;
315
+
316
+ /**
317
+ * Run a `builtin` gate in this process (ADR-100).
318
+ * Every outcome is a verdict with a reason: a built-in that does not exist, a
319
+ * diff git cannot read, or a secret found each fail with their own detail —
320
+ * none of them can degrade into a pass, which is the same fail-closed contract
321
+ * an unresolvable script gate has.
322
+ */
323
+ async function runBuiltinGate(name, { root, staged }) {
324
+ const scan = BUILTIN_GATES.get(name);
325
+ if (scan === undefined) {
326
+ return {
327
+ ok: false,
328
+ detail: `no built-in implementation for gate "${name}"; this is a bug in ${CLI}`,
329
+ remedy: `remove "${name}" from ${MANIFEST_FILE} gates`,
330
+ output: '',
331
+ };
332
+ }
333
+ if (staged.length === 0) {
334
+ return { ok: true, detail: 'nothing staged; no added line to scan', output: '' };
335
+ }
336
+ const result = await scan(root);
337
+ if (result.error !== null) {
338
+ return {
339
+ ok: false,
340
+ detail: `the staged diff could not be read: ${result.error}`,
341
+ remedy: 'the gate fails closed; inspect the repository state and commit again',
342
+ output: '',
343
+ };
344
+ }
345
+ if (result.findings.length === 0) {
346
+ return { ok: true, detail: `${result.lines} added line(s) scanned, no secret found`, output: '' };
347
+ }
348
+ const files = new Set(result.findings.map((f) => f.file));
349
+ const shown = result.findings.slice(0, BUILTIN_REPORT_MAX);
350
+ const more = result.findings.length - shown.length;
351
+ return {
352
+ ok: false,
353
+ detail: `${result.findings.length} probable secret(s) in ${files.size} staged file(s)`,
354
+ remedy:
355
+ 'remove the value from the staged file (or unstage it with git restore --staged <path>) ' +
356
+ 'and commit again; the report never prints the matched bytes',
357
+ output:
358
+ renderFindings(shown) +
359
+ (more > 0 ? `\n … ${more} more finding(s) not shown` : ''),
360
+ };
361
+ }
362
+
253
363
  /**
254
364
  * Run a gate's resolution, as a shell line or as an argv.
255
365
  *
@@ -1,7 +1,7 @@
1
1
  import { MANIFEST_FILE } from '../name.mjs';
2
2
  // Gate definitions for the shared hook dispatcher.
3
3
  //
4
- // A gate has one of three kinds, and the choice is not cosmetic:
4
+ // A gate has one of four kinds, and the choice is not cosmetic:
5
5
  //
6
6
  // staged run a formatter over the STAGED FILES ONLY, then re-stage them.
7
7
  // Whole-repo formatting from a pre-commit hook would rewrite
@@ -14,13 +14,18 @@ import { MANIFEST_FILE } from '../name.mjs';
14
14
  // know what monkeyc or a Kotlin script path looks like in one repo
15
15
  // and something else in another. Plan 0001 section 3.2 keeps repo
16
16
  // commands and paths local; this is that ruling in code.
17
+ // builtin run inside this binary, with nothing to resolve on disk. For a
18
+ // check whose whole value is that a fresh clone has it: the staged-
19
+ // diff secret scan needs no script, no command and no tool in
20
+ // node_modules, and a gate that needed an install step would be a
21
+ // gate the repository that most needs it does not have (ADR-100).
17
22
  //
18
23
  // ADR-010 note: no gate invokes `npx` or `npm exec`. Script gates go through
19
24
  // `npm run`, and staged gates resolve the formatter from the project's own
20
25
  // `node_modules/.bin`, which is the declared dependency's binary rather than
21
26
  // one fetched at hook time.
22
27
 
23
- export const GATE_KINDS = new Set(['staged', 'script', 'command']);
28
+ export const GATE_KINDS = new Set(['staged', 'script', 'command', 'builtin']);
24
29
 
25
30
  /** Extensions a `staged` formatter gate will hand to the tool. */
26
31
  export const FORMATTABLE = new Set([
@@ -29,7 +34,7 @@ export const FORMATTABLE = new Set([
29
34
  ]);
30
35
 
31
36
  /**
32
- * @typedef {{name: string, kind: 'staged'|'script'|'command', adr: string,
37
+ * @typedef {{name: string, kind: 'staged'|'script'|'command'|'builtin', adr: string,
33
38
  * script?: string, tools?: string[], why: string}} Gate
34
39
  */
35
40
 
@@ -88,6 +93,12 @@ export const GATES = {
88
93
  adr: 'ADR-002',
89
94
  why: 'a protocol change is a two-repo change; the drift check is what makes half of it fail loudly',
90
95
  },
96
+ 'secret-scan': {
97
+ name: 'secret-scan',
98
+ kind: 'builtin',
99
+ adr: 'ADR-100',
100
+ why: 'a staged diff is the last moment a secret can still be kept out of history, and the scan must be runnable by a clone with nothing installed',
101
+ },
91
102
  };
92
103
 
93
104
  export function getGate(name) {
@@ -119,6 +119,20 @@ export async function resolveGate(gate, { root, pkg, staged, commands }) {
119
119
  };
120
120
  }
121
121
 
122
+ if (gate.kind === 'builtin') {
123
+ // Nothing on disk to resolve: the implementation IS this binary, which is
124
+ // the whole point of the kind (ADR-100). A declared command was handled
125
+ // above and still wins, so a repository can swap the built-in for its own
126
+ // program exactly the way it can for any other gate.
127
+ return {
128
+ ok: true,
129
+ kind: gate.kind,
130
+ argv: null,
131
+ builtin: gate.name,
132
+ detail: `built-in: ${gate.name}`,
133
+ };
134
+ }
135
+
122
136
  if (gate.kind === 'staged') {
123
137
  const tool = await resolveStagedTool(root, gate);
124
138
  if (tool.path === null) {
package/src/ignore.mjs ADDED
@@ -0,0 +1,133 @@
1
+ // One ignore list, four readers' dialects (plan 0003 R52).
2
+ //
3
+ // Cursor, Aider, Gemini and Claude each want the same fact — "the agent must
4
+ // not load these paths" — in a different place: three gitignore-style files and
5
+ // one array inside a JSON settings object. Hand-maintaining four copies is four
6
+ // chances to forget one, and a forget is silent: the agent reads the file that
7
+ // did get updated and confidently loads the path the other three excluded. So
8
+ // the list lives in exactly one file (`.simtignore`, at the repository root,
9
+ // where a gitignore-syntax list already belongs) and everything else is
10
+ // rendered from it.
11
+ //
12
+ // Two ownership rules, because the four targets are not equally ours:
13
+ //
14
+ // - The three ignore files are GENERATED: whatever they held is what the list
15
+ // says after `simt ignore --yes`. A line that is in one and not in the list
16
+ // is drift between four files that claim to agree, and the plan prints it
17
+ // as a removal before anything is written.
18
+ // - `.claude/settings.json` is the READER'S file that merely contains our
19
+ // entries. We merge: every key, every permission and every deny entry that
20
+ // was already there survives byte-for-byte, and we only add `Read(...)`
21
+ // lines. In particular we never REMOVE a deny entry, because deleting a
22
+ // permission is a security decision that belongs to whoever wrote it — an
23
+ // entry dropped from `.simtignore` leaves a stale deny in Claude (safe
24
+ // direction) while the three generated files converge immediately.
25
+
26
+ /** The one list. gitignore syntax: one path or glob per line, `#` comments. */
27
+ export const IGNORE_FILE = '.simtignore';
28
+
29
+ /** The three readers that take the list verbatim. */
30
+ const TEXT_TARGETS = ['.cursorignore', '.aiderignore', '.geminiignore'];
31
+
32
+ /** The reader that takes it as `permissions.deny` `Read(...)` entries. */
33
+ export const CLAUDE_SETTINGS = '.claude/settings.json';
34
+
35
+ /**
36
+ * Parse the list: trim, drop blanks and `#` comments, keep entries verbatim.
37
+ *
38
+ * Verbatim matters — `.env.local` and `secrets/` and `*.pem` are the reader's
39
+ * globs, and normalising them would be this tool guessing at another tool's
40
+ * pattern language.
41
+ */
42
+ export function parseIgnoreList(text) {
43
+ return String(text)
44
+ .split('\n')
45
+ .map((line) => line.replace(/\r$/, '').trim())
46
+ .filter((line) => line !== '' && !line.startsWith('#'));
47
+ }
48
+
49
+ /** The bytes of one of the three generated ignore files. */
50
+ export function renderTextIgnore(entries) {
51
+ return entries.length === 0 ? '' : `${entries.join('\n')}\n`;
52
+ }
53
+
54
+ /** The list as Claude deny entries: `Read(.env)`, `Read(secrets/**)`, … */
55
+ export function denyEntries(entries) {
56
+ return entries.map((entry) => `Read(${entry})`);
57
+ }
58
+
59
+ /**
60
+ * Merge the list into `.claude/settings.json`, preserving everything else.
61
+ *
62
+ * @param {string|null} existingText current bytes, or null when absent
63
+ * @param {string[]} entries the parsed list
64
+ * @returns {{ok: true, text: string} | {ok: false, error: string}} — a settings
65
+ * file that is not valid JSON OBJECT is refused rather than replaced: the one
66
+ * failure that loses a reader's whole Claude configuration is a renderer that
67
+ * "repairs" what it could not parse.
68
+ */
69
+ export function renderClaudeSettings(existingText, entries) {
70
+ let settings = {};
71
+ if (existingText !== null) {
72
+ try {
73
+ settings = JSON.parse(existingText);
74
+ } catch (error) {
75
+ return { ok: false, error: `${CLAUDE_SETTINGS} is not valid JSON: ${error.message}` };
76
+ }
77
+ if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) {
78
+ return { ok: false, error: `${CLAUDE_SETTINGS} is not a JSON object; it cannot hold permissions` };
79
+ }
80
+ }
81
+
82
+ const current =
83
+ settings.permissions !== null &&
84
+ typeof settings.permissions === 'object' &&
85
+ !Array.isArray(settings.permissions)
86
+ ? { ...settings.permissions }
87
+ : {};
88
+ const existing = Array.isArray(current.deny)
89
+ ? current.deny.filter((entry) => typeof entry === 'string')
90
+ : [];
91
+ const wanted = denyEntries(entries);
92
+ // Existing entries keep their order and their bytes; ours append in list
93
+ // order, deduplicated so a re-run is byte-idempotent.
94
+ current.deny = [...existing, ...wanted.filter((entry) => !existing.includes(entry))];
95
+ settings.permissions = current;
96
+
97
+ return { ok: true, text: `${JSON.stringify(settings, null, 2)}\n` };
98
+ }
99
+
100
+ /**
101
+ * The four targets with their desired bytes, from the current bytes of each.
102
+ *
103
+ * @param {string[]} entries the parsed list
104
+ * @param {(path: string) => Promise<string|null>} read current bytes or null
105
+ * @returns {Promise<{ok: true, targets: {path: string, action: 'create'|'update'|'unchanged',
106
+ * before: string|null, after: string}[]} | {ok: false, error: string}>}
107
+ */
108
+ export async function planIgnore(entries, read) {
109
+ const text = renderTextIgnore(entries);
110
+ const targets = [];
111
+
112
+ for (const path of TEXT_TARGETS) {
113
+ const before = await read(path);
114
+ targets.push({
115
+ path,
116
+ action: before === null ? 'create' : before === text ? 'unchanged' : 'update',
117
+ before,
118
+ after: text,
119
+ });
120
+ }
121
+
122
+ const claudeBefore = await read(CLAUDE_SETTINGS);
123
+ const merged = renderClaudeSettings(claudeBefore, entries);
124
+ if (!merged.ok) return { ok: false, error: merged.error };
125
+ targets.push({
126
+ path: CLAUDE_SETTINGS,
127
+ action: claudeBefore === null ? 'create' : claudeBefore === merged.text ? 'unchanged' : 'update',
128
+ before: claudeBefore,
129
+ after: merged.text,
130
+ });
131
+
132
+ return { ok: true, targets };
133
+ }
@@ -4,7 +4,7 @@
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 { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
7
+ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
8
8
  import { dirname, join } from 'node:path';
9
9
 
10
10
  export const SQLITE_FILE = '.simt/index.sqlite';
@@ -44,18 +44,53 @@ export function decodeVectorBlob(blob) {
44
44
  return new Float32Array(copy.buffer);
45
45
  }
46
46
 
47
+ let tmpSeq = 0;
48
+
49
+ /** A per-writer temp file: pid plus a sequence, so in-process concurrent writers meet on no shared path. */
50
+ function tmpFor(file) {
51
+ tmpSeq += 1;
52
+ return `${file}.${process.pid}.${tmpSeq}.tmp`;
53
+ }
54
+
47
55
  export async function writeSqlite(root, next, removed, symbols, edges, fts, embeddings = []) {
48
56
  const { DatabaseSync } = await import('node:sqlite');
49
57
  const file = join(root, SQLITE_FILE);
50
58
  await mkdir(dirname(file), { recursive: true });
51
- const db = new DatabaseSync(file);
59
+ // A cold build goes to a temp file that is renamed over the target: three
60
+ // concurrent cold runs used to interleave schema creation and leave a locked,
61
+ // table-less database behind (Q08). Each writer builds its own complete file
62
+ // and the rename is atomic, so the last writer wins with a whole index.
63
+ let cold = false;
52
64
  try {
65
+ await stat(file);
66
+ } catch {
67
+ cold = true;
68
+ }
69
+ const target = cold ? tmpFor(file) : file;
70
+ if (cold) {
71
+ try {
72
+ await stat(target);
73
+ const { unlink } = await import('node:fs/promises');
74
+ await unlink(target);
75
+ } catch {
76
+ // No stale temp file: build fresh.
77
+ }
78
+ }
79
+ const db = new DatabaseSync(target);
80
+ try {
81
+ db.exec('pragma journal_mode = WAL');
82
+ db.exec('pragma busy_timeout = 5000');
83
+ db.exec('pragma synchronous = normal');
53
84
  db.exec(SCHEMA);
54
- db.exec('begin');
85
+ db.exec('begin immediate');
55
86
  const upsert = db.prepare(`insert into files(path, blob, lang, bytes, pkg) values (?, ?, ?, ?, ?)
56
87
  on conflict(path) do update set blob = excluded.blob, lang = excluded.lang, bytes = excluded.bytes, pkg = excluded.pkg`);
57
88
  const dropSymbols = db.prepare('delete from symbols where path = ?');
58
- const dropEdges = db.prepare('delete from edges where src = ? or dst = ?');
89
+ // An importer owns its outgoing edges: re-extracting a changed file drops
90
+ // only `src = ?`, so the unchanged importers' edges pointing into it survive
91
+ // (Q07). A removed file's incoming edges dangle, so they drop with it.
92
+ const dropEdges = db.prepare('delete from edges where src = ?');
93
+ const dropIncoming = db.prepare('delete from edges where dst = ?');
59
94
  const dropFts = db.prepare('delete from fts where ref = ?');
60
95
  const dropEmbed = db.prepare('delete from embeddings where path = ?');
61
96
  const dropFile = db.prepare('delete from files where path = ?');
@@ -67,14 +102,15 @@ export async function writeSqlite(root, next, removed, symbols, edges, fts, embe
67
102
  for (const row of next) {
68
103
  upsert.run(row.path, row.blob, row.lang, row.bytes, row.pkg);
69
104
  dropSymbols.run(row.path);
70
- dropEdges.run(row.path, row.path);
105
+ dropEdges.run(row.path);
71
106
  dropFts.run(row.path);
72
107
  dropEmbed.run(row.path);
73
108
  }
74
109
  for (const path of removed) {
75
110
  dropFile.run(path);
76
111
  dropSymbols.run(path);
77
- dropEdges.run(path, path);
112
+ dropEdges.run(path);
113
+ dropIncoming.run(path);
78
114
  dropFts.run(path);
79
115
  dropEmbed.run(path);
80
116
  }
@@ -90,10 +126,26 @@ export async function writeSqlite(root, next, removed, symbols, edges, fts, embe
90
126
  db.exec('commit');
91
127
  } catch (error) {
92
128
  try { db.exec('rollback'); } catch { /* a failed begin has nothing to undo */ }
93
- throw error;
129
+ throw asIndexError(error);
94
130
  } finally {
95
131
  db.close();
96
132
  }
133
+ if (cold) await rename(target, file);
134
+ }
135
+
136
+ /**
137
+ * A contended index write is a retryable race, not a broken repository: name it
138
+ * so the command maps its own remedy instead of blaming the git checkout (Q08).
139
+ */
140
+ function asIndexError(error) {
141
+ const code = error?.code ?? error?.errno;
142
+ if (code === 'SQLITE_BUSY' || code === 5 || /database is locked/i.test(error?.message ?? '')) {
143
+ const busy = new Error('another index run is writing; wait a moment and run again');
144
+ busy.code = 'INDEX_BUSY';
145
+ busy.cause = error;
146
+ return busy;
147
+ }
148
+ return error;
97
149
  }
98
150
 
99
151
  export async function writeJsonl(root, previous, next, removed, symbols, edges, fts, force, embeddings = []) {
@@ -109,7 +161,9 @@ export async function writeJsonl(root, previous, next, removed, symbols, edges,
109
161
  for (const row of replaced.values()) files.push(row);
110
162
  files.sort(byPath);
111
163
  const keptSymbols = force ? [] : (await readJsonl(root, 'symbols')).filter((row) => !drop.has(row.path));
112
- const keptEdges = force ? [] : (await readJsonl(root, 'edges')).filter((row) => !drop.has(row.src) && !drop.has(row.dst));
164
+ // Edge ownership mirrors the sqlite backend: a changed file loses only the edges
165
+ // 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));
113
167
  const keptFts = force ? [] : (await readJsonl(root, 'fts')).filter((row) => !drop.has(row.ref));
114
168
  const newEmbedPaths = new Set(embeddings.map((row) => row.path));
115
169
  const keptEmbeddings = force
@@ -121,6 +175,8 @@ export async function writeJsonl(root, previous, next, removed, symbols, edges,
121
175
  const allEmbeddings = [...keptEmbeddings, ...embeddings].sort(byPath);
122
176
  const file = join(root, JSONL_FILE);
123
177
  await mkdir(dirname(file), { recursive: true });
178
+ // Written atomically: a concurrent reader never sees a half-written file.
179
+ const tmp = tmpFor(file);
124
180
  const lines = [
125
181
  JSON.stringify({ table: 'meta', key: 'schema', value: SCHEMA_V }),
126
182
  ...files.map((row) => JSON.stringify({ table: 'files', path: row.path, blob: row.blob, lang: row.lang, bytes: row.bytes, pkg: row.pkg })),
@@ -140,7 +196,8 @@ export async function writeJsonl(root, previous, next, removed, symbols, edges,
140
196
  });
141
197
  }),
142
198
  ];
143
- await writeFile(file, `${lines.join('\n')}\n`);
199
+ await writeFile(tmp, `${lines.join('\n')}\n`);
200
+ await rename(tmp, file);
144
201
  return { symbols: allSymbols.length, edges: allEdges.length, embeddings: allEmbeddings.length };
145
202
  }
146
203