knodin 0.10.0 → 0.10.2

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/dist/bin/cli.js CHANGED
@@ -27,7 +27,7 @@ import { exportContext, grepPackedArtifact, readPackedArtifact } from "../src/co
27
27
  import { clearDiagnostics, collectDiagnostics, diagnosticsStatus, disableDiagnostics, enableDiagnostics, inspectDiagnosticsBundle, persistDiagnosticsPreview, recordDiagnosticFailure, } from "../src/diagnostics.js";
28
28
  import { getDocSection, listDocTopics } from "../src/docs-sections.js";
29
29
  import { diagnoseInstallation } from "../src/doctor.js";
30
- import { createEngine, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PATTERNS, } from "../src/engine/index.js";
30
+ import { createEngine, describeThrown, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PATTERNS, } from "../src/engine/index.js";
31
31
  import { runSeal } from "../src/engine/seal-command.js";
32
32
  import { runSealedQuery } from "../src/engine/sealed-query.js";
33
33
  import { resolveDbPath } from "../src/engine/state-paths.js";
@@ -199,10 +199,16 @@ function formatCoverageGaps(skipped) {
199
199
  if (byExtension)
200
200
  clauses.push(`not indexed: ${byExtension}`);
201
201
  const unparsed = formatTally(skipped.unparsedByExtension);
202
+ // "not parsed", not "parsed but empty". Nothing in this tally was parsed:
203
+ // it collects files with no tree-sitter grammar, minified sources, and files
204
+ // whose indexing threw. Calling that "parsed but empty" asserts the parser
205
+ // looked and found nothing, which is the one thing it did not do — and it
206
+ // made a real outage unreadable, since a dead wasm module dumped thousands
207
+ // of never-parsed files into this bucket under a label saying they were fine.
202
208
  if (unparsed)
203
- clauses.push(`parsed but empty: ${unparsed}`);
209
+ clauses.push(`not parsed: ${unparsed}`);
204
210
  if (skipped.unparsedUnknown)
205
- clauses.push("parsed-but-empty tally unavailable — run a full index; this is a lower bound");
211
+ clauses.push("not-parsed tally unavailable — run a full index; this is a lower bound");
206
212
  const count = skipped.unparsedUnknown ? `${skipped.total}+` : `${skipped.total}`;
207
213
  return `. Not in graph: ${count} files (${clauses.join("; ")})`;
208
214
  }
@@ -2124,17 +2130,27 @@ async function main() {
2124
2130
  const agents = scope === "team" ? [] : integrationAgents(repo);
2125
2131
  const paths = await initializeRepository(repo, {
2126
2132
  command: runtimeCommand,
2127
- index: indexOrSeed(engine, KNODIN_SCHEMA_VERSION),
2133
+ // Not `indexOrSeed`: seeding exists for a worktree with no database,
2134
+ // and this command has already refused to run without one. Indexing
2135
+ // the written files directly is the whole point of the narrow scope.
2136
+ index: (target, indexOptions, files) => engine.index(target, files, false, indexOptions),
2137
+ indexScope: "configuration",
2128
2138
  scope,
2129
2139
  agents,
2130
2140
  allowTrackedTransition: true,
2131
2141
  auditConfigurationChanges: true,
2132
2142
  configureIntegration: true,
2133
2143
  });
2144
+ const indexed = paths.scopedIndexPaths ?? [];
2134
2145
  result = {
2135
2146
  status: "success",
2136
2147
  message: `knodin agent integration changed to ${scope}.`,
2137
- graphInitialization: "refreshed",
2148
+ // Say what happened. Claiming "refreshed" after touching a few
2149
+ // configuration files misdescribes the graph's state, and did so most
2150
+ // misleadingly on a repository whose graph was in fact empty.
2151
+ graphInitialization: indexed.length === 0
2152
+ ? "unchanged — no configuration files needed indexing"
2153
+ : `configuration files indexed (${indexed.length})`,
2138
2154
  nextAction: "run `knodin status` and reload the configured client",
2139
2155
  paths,
2140
2156
  };
@@ -2985,7 +3001,7 @@ catch (err) {
2985
3001
  error: err,
2986
3002
  });
2987
3003
  const correlation = diagnostic.recorded ? ` [diagnostic ${diagnostic.correlationId}]` : "";
2988
- console.error(`${err instanceof Error ? err.message : String(err)}${correlation}`);
3004
+ console.error(`${describeThrown(err)}${correlation}`);
2989
3005
  process.exit(1);
2990
3006
  }
2991
3007
  }
@@ -4938,13 +4938,22 @@ async function indexRazorFile(content, relativePath, _repoPath, db) {
4938
4938
  const parser = new Parser();
4939
4939
  parser.setLanguage(language);
4940
4940
  const tree = parser.parse(projected);
4941
- if (!tree)
4941
+ if (!tree) {
4942
+ parser.delete();
4942
4943
  return;
4944
+ }
4943
4945
  // `filePath` drives the extractor's language-specific branches, so it must
4944
4946
  // look like C#; the rows are persisted against the real Razor path below.
4945
- const extracted = extractSymbolsAndReferences(tree.rootNode, false, false, "razor-projection.cs");
4946
- tree.delete();
4947
- parser.delete();
4947
+ // The `finally` matters: a throw out of the extractor previously leaked both
4948
+ // objects, on exactly the malformed inputs most likely to throw.
4949
+ let extracted;
4950
+ try {
4951
+ extracted = extractSymbolsAndReferences(tree.rootNode, false, false, "razor-projection.cs");
4952
+ }
4953
+ finally {
4954
+ freeQuietly(tree);
4955
+ freeQuietly(parser);
4956
+ }
4948
4957
  // The synthetic wrapper class exists only to make members parseable.
4949
4958
  const definitions = extracted.definitions.filter((def) => def.name !== "__RazorCode");
4950
4959
  db.run("BEGIN TRANSACTION;");
@@ -6178,6 +6187,58 @@ const INDEX_WRITE_BATCH_SIZE = (() => {
6178
6187
  const raw = Number(process.env.KNODIN_INDEX_WRITE_BATCH_SIZE);
6179
6188
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 200;
6180
6189
  })();
6190
+ /**
6191
+ * True for an Emscripten abort: the wasm module itself calling `exit()`.
6192
+ *
6193
+ * This is categorically different from "this file did not parse" and must never
6194
+ * be handled by a per-file recovery path. The module does not come back — every
6195
+ * subsequent parse, even on a freshly constructed `Parser`, fails with the same
6196
+ * error. Treating it per-file turns one real failure into thousands of
6197
+ * misattributed ones against innocent files, and still reports the run as
6198
+ * successful, so the graph ends up quietly missing everything after the abort.
6199
+ */
6200
+ export function isWasmAbort(error) {
6201
+ return (typeof error === "object" &&
6202
+ error !== null &&
6203
+ error.name === "ExitStatus");
6204
+ }
6205
+ /**
6206
+ * Render any throwable as a message, including one that is not an `Error`.
6207
+ *
6208
+ * `error instanceof Error ? error.message : String(error)` is the reflex, and it
6209
+ * is wrong for exactly the failure this release exists to surface: Emscripten's
6210
+ * `ExitStatus` is a plain object carrying `name`/`message`/`status`, so the
6211
+ * ternary falls through to `String(error)` and prints `[object Object]` —
6212
+ * destroying the diagnosis at the last step, in the CLI's top-level handler and
6213
+ * in the parse worker's hand-serialization alike.
6214
+ */
6215
+ export function describeThrown(error) {
6216
+ if (error instanceof Error)
6217
+ return error.message;
6218
+ if (typeof error === "object" && error !== null) {
6219
+ const { name, message } = error;
6220
+ if (typeof message === "string" && message.length > 0)
6221
+ return typeof name === "string" && name.length > 0 ? `${name}: ${message}` : message;
6222
+ }
6223
+ return String(error);
6224
+ }
6225
+ /**
6226
+ * Free a wasm object without letting the free itself throw.
6227
+ *
6228
+ * Used only where the free sits in a `finally`. If the module has aborted, its
6229
+ * heap is already gone and `delete()` throws too — from a `finally` that
6230
+ * replaces the in-flight error with a second, less informative one, losing the
6231
+ * cause at exactly the moment it matters. There is nothing to reclaim from a
6232
+ * dead module anyway, so dropping that failure loses nothing real.
6233
+ */
6234
+ function freeQuietly(target) {
6235
+ try {
6236
+ target.delete();
6237
+ }
6238
+ catch {
6239
+ // Intentionally ignored; see above.
6240
+ }
6241
+ }
6181
6242
  /**
6182
6243
  * Read, parse, and extract one file through the generic tree-sitter path.
6183
6244
  *
@@ -6212,40 +6273,78 @@ export async function extractGenericFileForIndex(absolutePath, relativePath, rep
6212
6273
  tree = measurePerfPhaseSync("parse_extract", () => parser.parse(parseContent));
6213
6274
  }
6214
6275
  catch (e) {
6276
+ // Rethrow BEFORE attempting to free: once the module has aborted its heap
6277
+ // is gone, there is nothing left to reclaim, and calling delete() would
6278
+ // throw a second ExitStatus that replaces the real cause.
6279
+ if (isWasmAbort(e))
6280
+ throw e;
6281
+ // Otherwise free before returning. Skipping one file must not also cost a
6282
+ // parser, or a repository full of pathological inputs would leak its way
6283
+ // to an abort through the recovery path itself.
6284
+ freeQuietly(parser);
6215
6285
  console.warn(`knodin: skipped ${relativePath} (parser error: ${e.message})`);
6216
6286
  return { kind: "skipped", reason: e.message };
6217
6287
  }
6218
- if (!tree)
6288
+ if (!tree) {
6289
+ freeQuietly(parser);
6219
6290
  return { kind: "skipped", reason: "parser returned no tree" };
6220
- const mcpRegistrations = /\.[cm]?[jt]sx?$/.test(absolutePath) && /@modelcontextprotocol\/sdk/.test(content)
6221
- ? extractMcpToolRegistrations(content, relativePath, tree.rootNode)
6222
- : [];
6223
- const isPython = absolutePath.endsWith(".py");
6224
- const isApex = absolutePath.endsWith(".cls") || absolutePath.endsWith(".trigger");
6225
- const isVisualforce = absolutePath.endsWith(".page") || absolutePath.endsWith(".component");
6226
- const isPrisma = absolutePath.endsWith(".prisma");
6227
- const isWorkday = absolutePath.endsWith(".clp") ||
6228
- absolutePath.endsWith(".ws") ||
6229
- (absolutePath.endsWith(".xml") && isWorkdayStudioFile(content, absolutePath));
6230
- const special = (route) => ({
6231
- kind: "special",
6232
- route,
6233
- content,
6234
- parseContent,
6235
- tree: tree,
6236
- });
6237
- if (isWorkday)
6238
- return special("workday");
6239
- if (isVisualforce)
6240
- return special("visualforce");
6241
- if (isSql)
6242
- return special("sql");
6243
- if (isPrisma)
6244
- return special("prisma");
6245
- return {
6246
- kind: "result",
6247
- result: buildFileIndexResult(tree, content, isPython, isApex, absolutePath, relativePath, repoPath, mcpRegistrations),
6248
- };
6291
+ }
6292
+ // Everything from here on can throw — `extractMcpToolRegistrations` and the
6293
+ // symbol extractor both walk attacker-shaped input — and this runs once per
6294
+ // file, so a leak on the throwing path scales with repository size exactly
6295
+ // like the leak this whole change removes. The `finally` is the guarantee;
6296
+ // `handedOff` is the one case where the tree deliberately escapes.
6297
+ let handedOff = false;
6298
+ try {
6299
+ const mcpRegistrations = /\.[cm]?[jt]sx?$/.test(absolutePath) && /@modelcontextprotocol\/sdk/.test(content)
6300
+ ? extractMcpToolRegistrations(content, relativePath, tree.rootNode)
6301
+ : [];
6302
+ const isPython = absolutePath.endsWith(".py");
6303
+ const isApex = absolutePath.endsWith(".cls") || absolutePath.endsWith(".trigger");
6304
+ const isVisualforce = absolutePath.endsWith(".page") || absolutePath.endsWith(".component");
6305
+ const isPrisma = absolutePath.endsWith(".prisma");
6306
+ const isWorkday = absolutePath.endsWith(".clp") ||
6307
+ absolutePath.endsWith(".ws") ||
6308
+ (absolutePath.endsWith(".xml") && isWorkdayStudioFile(content, absolutePath));
6309
+ const special = (route) => {
6310
+ handedOff = true;
6311
+ freeQuietly(parser);
6312
+ return {
6313
+ kind: "special",
6314
+ route,
6315
+ content,
6316
+ parseContent,
6317
+ tree: tree,
6318
+ };
6319
+ };
6320
+ // The parser has done its job by here and nothing downstream needs it: a
6321
+ // `Tree` owns its own wasm memory and stays readable after its parser is
6322
+ // freed (pinned by parser-lifetime.spec.ts, because a use-after-free here
6323
+ // would surface as corrupt nodes rather than a crash). Freeing it at the
6324
+ // point of return is what makes the escaping-tree routes below safe to
6325
+ // hand out — the caller then owns exactly one object, the tree.
6326
+ if (isWorkday)
6327
+ return special("workday");
6328
+ if (isVisualforce)
6329
+ return special("visualforce");
6330
+ if (isSql)
6331
+ return special("sql");
6332
+ if (isPrisma)
6333
+ return special("prisma");
6334
+ // `FileIndexResult` is plain data — it must be, since it is structured-cloned
6335
+ // out of a parse worker — so both objects are dead the moment it is built,
6336
+ // and the `finally` below frees them.
6337
+ return {
6338
+ kind: "result",
6339
+ result: buildFileIndexResult(tree, content, isPython, isApex, absolutePath, relativePath, repoPath, mcpRegistrations),
6340
+ };
6341
+ }
6342
+ finally {
6343
+ if (!handedOff) {
6344
+ freeQuietly(tree);
6345
+ freeQuietly(parser);
6346
+ }
6347
+ }
6249
6348
  }
6250
6349
  /**
6251
6350
  * The single source of truth for indexer routing.
@@ -6405,20 +6504,35 @@ async function indexFile(absolutePath, relativePath, repoPath, db, unparsed, col
6405
6504
  return;
6406
6505
  if (extraction.kind === "special") {
6407
6506
  const { content, parseContent, tree } = extraction;
6408
- switch (extraction.route) {
6409
- case "workday":
6410
- await indexWorkdayFile(content, tree, relativePath, repoPath, db);
6411
- return;
6412
- case "visualforce":
6413
- await indexVisualforceFile(content, tree, relativePath, repoPath, db);
6414
- return;
6415
- case "sql":
6416
- await indexSqlFile(parseContent, tree, relativePath, repoPath, db);
6417
- return;
6418
- case "prisma":
6419
- await indexPrismaFile(content, tree, relativePath, repoPath, db);
6420
- return;
6507
+ // The tree outlived the function that parsed it, so this is the only
6508
+ // place that can free it. `finally` rather than a delete per branch,
6509
+ // because a throw inside an indexer would otherwise leak silently on
6510
+ // exactly the inputs most likely to throw.
6511
+ //
6512
+ // The cases `break` rather than `return` so the single exit below is
6513
+ // reachable. Returning from inside the `try` left that trailing
6514
+ // statement unreachable — dead code the compiler flagged and coverage
6515
+ // could never account for.
6516
+ try {
6517
+ switch (extraction.route) {
6518
+ case "workday":
6519
+ await indexWorkdayFile(content, tree, relativePath, repoPath, db);
6520
+ break;
6521
+ case "visualforce":
6522
+ await indexVisualforceFile(content, tree, relativePath, repoPath, db);
6523
+ break;
6524
+ case "sql":
6525
+ await indexSqlFile(parseContent, tree, relativePath, repoPath, db);
6526
+ break;
6527
+ case "prisma":
6528
+ await indexPrismaFile(content, tree, relativePath, repoPath, db);
6529
+ break;
6530
+ }
6421
6531
  }
6532
+ finally {
6533
+ freeQuietly(tree);
6534
+ }
6535
+ return;
6422
6536
  }
6423
6537
  if (collectResult) {
6424
6538
  collectResult(extraction.result);
@@ -6428,6 +6542,13 @@ async function indexFile(absolutePath, relativePath, repoPath, db, unparsed, col
6428
6542
  }
6429
6543
  }
6430
6544
  catch (error) {
6545
+ // A dead wasm module is not this file's fault and is not survivable.
6546
+ // Swallowing it here is what made one abort look like thousands of
6547
+ // broken files followed by a successful run: every file after the abort
6548
+ // failed identically, got tallied as a coverage gap, and the missing
6549
+ // symbols became indistinguishable from files that genuinely have none.
6550
+ if (isWasmAbort(error))
6551
+ throw error;
6431
6552
  console.error(`Error indexing file ${relativePath}:`, error);
6432
6553
  if (unparsed)
6433
6554
  tallyOne(unparsed, extensionBucket(relativePath));
@@ -7790,11 +7911,16 @@ export function getWatcherQueueStats(repoPath) {
7790
7911
  generationAdvances: 0,
7791
7912
  };
7792
7913
  }
7793
- /** Inject one queue failure for the R45 recovery regression test. */
7794
- export function failNextWatcherFlush(repoPath) {
7914
+ /**
7915
+ * Inject one queue failure for the R45 recovery regression test.
7916
+ *
7917
+ * `kind: "wasm-abort"` injects an Emscripten-shaped abort instead of an
7918
+ * ordinary error, which the flush must treat as terminal rather than retryable.
7919
+ */
7920
+ export function failNextWatcherFlush(repoPath, kind = "error") {
7795
7921
  const queue = watchQueues.get(path.resolve(repoPath));
7796
7922
  if (queue)
7797
- queue.failNextFlush = true;
7923
+ queue.failNextFlush = kind;
7798
7924
  }
7799
7925
  /** Hold one queue flush at a deterministic boundary for R45 backpressure tests. */
7800
7926
  export function gateNextWatcherFlushForTests(repoPath, gate) {
@@ -8210,7 +8336,18 @@ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_F
8210
8336
  if (testGate)
8211
8337
  await testGate;
8212
8338
  if (queue.failNextFlush) {
8339
+ const injected = queue.failNextFlush;
8213
8340
  queue.failNextFlush = false;
8341
+ if (injected === "wasm-abort") {
8342
+ // Deliberately NOT an Error: the real ExitStatus is a plain object,
8343
+ // and an Error-shaped stand-in would let a naive `instanceof Error`
8344
+ // check pass and hide the very bug this pins.
8345
+ throw Object.assign(Object.create(null), {
8346
+ name: "ExitStatus",
8347
+ message: "Program terminated with exit(1)",
8348
+ status: 1,
8349
+ });
8350
+ }
8214
8351
  throw new Error("injected watcher flush failure");
8215
8352
  }
8216
8353
  for (const relativePath of paths) {
@@ -8238,6 +8375,16 @@ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_F
8238
8375
  catch (error) {
8239
8376
  queue.errors++;
8240
8377
  console.error(`Error flushing watcher updates for ${resolvedRepoPath}:`, error);
8378
+ if (isWasmAbort(error)) {
8379
+ // Unrecoverable and process-wide: re-queueing would retry the same
8380
+ // files every debounce interval, forever, each pass failing at the
8381
+ // first parse and logging again. Stop the watcher instead — the
8382
+ // graph is stale from here and only a fresh process can fix it.
8383
+ queue.closed = true;
8384
+ queue.pending.clear();
8385
+ watcherStates.set(resolvedRepoPath, { watcher: "disabled" });
8386
+ break;
8387
+ }
8241
8388
  for (const relativePath of paths)
8242
8389
  queue.pending.add(relativePath);
8243
8390
  if (!queue.closed) {
@@ -8732,23 +8879,34 @@ export async function getOrInitDb(repoPath, options = {}) {
8732
8879
  tree = parser.parse(source);
8733
8880
  }
8734
8881
  catch {
8882
+ parser.delete();
8735
8883
  backfillComplete = false;
8736
8884
  continue;
8737
8885
  }
8738
- if (!tree)
8886
+ if (!tree) {
8887
+ parser.delete();
8739
8888
  continue;
8740
- db.run("DELETE FROM mcp_tools WHERE filePath = ?", [filePath]);
8741
- for (const registration of extractMcpToolRegistrations(source, filePath, tree.rootNode)) {
8742
- db.run("INSERT INTO mcp_tools(name, description, schemaSymbol, handlerSymbol, filePath, line, confidence, associationKey) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
8743
- registration.name,
8744
- registration.description ?? null,
8745
- registration.schemaSymbol ?? null,
8746
- registration.handlerSymbol ?? null,
8747
- filePath,
8748
- registration.line,
8749
- registration.confidence,
8750
- registration.associationKey ?? null,
8751
- ]);
8889
+ }
8890
+ // This loop runs once per indexed file, so anything it fails to
8891
+ // free scales with repository size.
8892
+ try {
8893
+ db.run("DELETE FROM mcp_tools WHERE filePath = ?", [filePath]);
8894
+ for (const registration of extractMcpToolRegistrations(source, filePath, tree.rootNode)) {
8895
+ db.run("INSERT INTO mcp_tools(name, description, schemaSymbol, handlerSymbol, filePath, line, confidence, associationKey) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
8896
+ registration.name,
8897
+ registration.description ?? null,
8898
+ registration.schemaSymbol ?? null,
8899
+ registration.handlerSymbol ?? null,
8900
+ filePath,
8901
+ registration.line,
8902
+ registration.confidence,
8903
+ registration.associationKey ?? null,
8904
+ ]);
8905
+ }
8906
+ }
8907
+ finally {
8908
+ freeQuietly(tree);
8909
+ freeQuietly(parser);
8752
8910
  }
8753
8911
  }
8754
8912
  catch {
@@ -11874,7 +12032,15 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
11874
12032
  if (lang) {
11875
12033
  const parser = new Parser();
11876
12034
  parser.setLanguage(lang);
11877
- tree = parser.parse(source.text);
12035
+ // The binding dies with this block, so nothing else could ever
12036
+ // free it — including when `parse` itself throws, which the
12037
+ // outer `catch` only logs. The tree stays valid without it.
12038
+ try {
12039
+ tree = parser.parse(source.text);
12040
+ }
12041
+ finally {
12042
+ freeQuietly(parser);
12043
+ }
11878
12044
  }
11879
12045
  }
11880
12046
  }
@@ -11940,6 +12106,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
11940
12106
  symbolRisks.push(risk);
11941
12107
  }
11942
12108
  }
12109
+ // Last use of this file's tree; the next iteration reassigns the
12110
+ // binding, so not freeing here loses it for the rest of the review.
12111
+ if (tree)
12112
+ freeQuietly(tree);
12113
+ tree = null;
11943
12114
  }
11944
12115
  }
11945
12116
  finally {
@@ -15483,7 +15654,18 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15483
15654
  return undefined;
15484
15655
  const parser = new Parser();
15485
15656
  parser.setLanguage(language);
15486
- const tree = parser.parse(content);
15657
+ // This runs once per traversal edge, not once per file, so the
15658
+ // same file can be reparsed many times within a single query.
15659
+ // Both objects are freed on every exit from this closure — the
15660
+ // parser here even when `parse` throws, which the outer `catch`
15661
+ // would otherwise swallow along with the allocation.
15662
+ let tree;
15663
+ try {
15664
+ tree = parser.parse(content);
15665
+ }
15666
+ finally {
15667
+ freeQuietly(parser);
15668
+ }
15487
15669
  let matched;
15488
15670
  const visit = (node) => {
15489
15671
  if (matched ||
@@ -15502,17 +15684,26 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15502
15684
  for (const child of node.namedChildren)
15503
15685
  visit(child);
15504
15686
  };
15505
- visit(tree.rootNode);
15506
- const argumentsNode = matched?.childForFieldName("arguments");
15507
- if (!matched || !argumentsNode)
15687
+ if (!tree)
15508
15688
  return undefined;
15509
- return {
15510
- kind: "call-arguments",
15511
- arguments: argumentsNode.namedChildren.map((node) => node.text),
15512
- evidence: matched.text.slice(0, 240),
15513
- grounding: "tree-sitter",
15514
- heuristic: true,
15515
- };
15689
+ try {
15690
+ visit(tree.rootNode);
15691
+ const argumentsNode = matched?.childForFieldName("arguments");
15692
+ if (!matched || !argumentsNode)
15693
+ return undefined;
15694
+ return {
15695
+ kind: "call-arguments",
15696
+ // `.text` copies into JS strings, so the returned evidence
15697
+ // holds no reference into the tree being freed.
15698
+ arguments: argumentsNode.namedChildren.map((node) => node.text),
15699
+ evidence: matched.text.slice(0, 240),
15700
+ grounding: "tree-sitter",
15701
+ heuristic: true,
15702
+ };
15703
+ }
15704
+ finally {
15705
+ freeQuietly(tree);
15706
+ }
15516
15707
  }
15517
15708
  catch {
15518
15709
  return undefined;
@@ -1,5 +1,5 @@
1
1
  import { parentPort } from "node:worker_threads";
2
- import { extractGenericFileForIndex } from "./index.js";
2
+ import { describeThrown, extractGenericFileForIndex, isWasmAbort } from "./index.js";
3
3
  /**
4
4
  * Parse-pool worker entry.
5
5
  *
@@ -32,6 +32,10 @@ port.on("message", (task) => {
32
32
  break;
33
33
  default:
34
34
  // `special` carries a live tree; hand the file back instead.
35
+ // Free it first — this branch is the tree's only owner, and the
36
+ // main thread reparses the file from scratch, so leaving it
37
+ // alive leaks wasm memory per file until the module aborts.
38
+ extraction.tree.delete();
35
39
  response = { id: task.id, kind: "special" };
36
40
  break;
37
41
  }
@@ -40,11 +44,20 @@ port.on("message", (task) => {
40
44
  // Serialize by hand. A thrown Error clones without its message on some
41
45
  // paths, and losing the message turns a diagnosable failure into a
42
46
  // mystery at exactly the moment the pool decides whether to retry.
43
- response = {
44
- id: task.id,
45
- kind: "error",
46
- message: error instanceof Error ? error.message : String(error),
47
- };
47
+ //
48
+ // `instanceof Error` is not enough on its own: Emscripten's `ExitStatus`
49
+ // is a plain object with `name`/`message`, so the naive ternary reports
50
+ // the wasm abort as "[object Object]". Shared with the CLI's top-level
51
+ // handler, which had the identical bug — one rule, one place.
52
+ response = { id: task.id, kind: "error", message: describeThrown(error) };
53
+ // The module is dead and does not come back: every later task in THIS
54
+ // worker would fail identically and fall back to the main thread, so the
55
+ // pool would quietly lose a lane for the rest of the run without any
56
+ // signal that it had. Report this file, then exit so the pool replaces
57
+ // the worker (the exit is deferred so the message actually flushes; if
58
+ // it does not, the pool's exit handler retries the file anyway).
59
+ if (isWasmAbort(error))
60
+ setImmediate(() => process.exit(1));
48
61
  }
49
62
  port.postMessage(response);
50
63
  })();
package/dist/src/init.js CHANGED
@@ -7,7 +7,7 @@ import { compareBytes } from "./compare.js";
7
7
  import { isIndexableSourcePath } from "./engine/source-policy.js";
8
8
  import { lookupMirror } from "./engine/state-paths.js";
9
9
  import { inspectLefthookIntegration, installHookManagerIntegration, isActiveLefthookHook, } from "./hook-manager-integration.js";
10
- import { acquireRepairLease } from "./repair-lease.js";
10
+ import { acquireRepairLease, LIFECYCLE_LEASE_TOKEN_ENV } from "./repair-lease.js";
11
11
  import { installKnodinSkills, removeKnodinSkills } from "./skill-management.js";
12
12
  import { registerInitializedWorktree } from "./worktree-lifecycle.js";
13
13
  const MANAGED_MARKER = "KNODIN MANAGED HOOK";
@@ -230,6 +230,63 @@ function fileSignature(filePath, allowOversized = false) {
230
230
  throw error;
231
231
  }
232
232
  }
233
+ /**
234
+ * The configuration files a `configure` run actually changed, as repo-relative
235
+ * paths that indexing can accept.
236
+ *
237
+ * `configure` used to hand the indexer no file list at all, which means "the
238
+ * whole repository" — a whole-tree walk to account for a change to three
239
+ * dotfiles, and minutes of work on a large checkout. Narrowing it here keeps
240
+ * the cost a function of the configuration, not of the repository.
241
+ *
242
+ * Directories and anything outside the worktree are dropped: they are audited
243
+ * for change reporting but are not indexable files.
244
+ *
245
+ * So is anything the prune policy excludes. `.git/` and `.knodin/` are INSIDE
246
+ * the worktree in an ordinary checkout, so the `..` check below does not
247
+ * exclude them — it only happens to, in a linked worktree, where the git common
248
+ * dir lives elsewhere. Without this filter `configure` hands the indexer
249
+ * `.git/hooks/post-commit`, `.git/info/exclude` and `.knodin/integration.json`,
250
+ * which the explicit-file index path does not re-filter, so it records
251
+ * `index_state` rows for paths a full index would never visit and the next
252
+ * reconciliation then has to purge.
253
+ */
254
+ function changedConfigurationFiles(repo, targets, before) {
255
+ const after = mutationSnapshot(targets, true);
256
+ const changed = [];
257
+ for (const target of targets) {
258
+ if (before.get(target.label) === after.get(target.label))
259
+ continue;
260
+ const relative = path.relative(repo, target.absolute);
261
+ if (relative.startsWith("..") || path.isAbsolute(relative))
262
+ continue;
263
+ // The SAME predicate a full index uses, not merely the prune check.
264
+ // `isIndexableSourcePath` is the canonical candidate policy shared by
265
+ // full-repository collection, the watcher, repair selection and hook
266
+ // refresh, so filtering through it makes this list a strict subset of what
267
+ // `init` would have indexed.
268
+ //
269
+ // Anything looser indexes files no other path in the system ever selects:
270
+ // `.mcp.json` and `.codex/config.toml` are configuration, not source, and
271
+ // including them writes `index_state` rows that the next reconciliation
272
+ // purges again — churn that looks like drift.
273
+ if (!isIndexableSourcePath(relative))
274
+ continue;
275
+ let stat;
276
+ try {
277
+ stat = fs.statSync(target.absolute);
278
+ }
279
+ catch {
280
+ // Removed by this run. There is nothing to index, and pruning the
281
+ // deleted rows is the lifecycle refresh's job, not this list's.
282
+ continue;
283
+ }
284
+ if (!stat.isFile())
285
+ continue;
286
+ changed.push(relative);
287
+ }
288
+ return changed.sort(compareBytes);
289
+ }
233
290
  function mutationSnapshot(targets, allowOversized = false) {
234
291
  const snapshot = new Map();
235
292
  for (const target of targets) {
@@ -357,10 +414,29 @@ export async function refreshFromGitEvent(repo, event, index) {
357
414
  }
358
415
  }
359
416
  function backgroundScript(command) {
360
- const invocation = command.map(shellQuote).join(" ");
417
+ // The interpreter is resolved at RUN time, not baked in. `process.execPath`
418
+ // is symlink-resolved by Node, so a stable `/opt/homebrew/bin/node` becomes
419
+ // a version-pinned `/opt/homebrew/Cellar/node/<version>/bin/node`. Homebrew
420
+ // deletes that directory on upgrade, and every hook generated beforehand
421
+ // then dies with exit 127 — silently, because refresh runs in the
422
+ // background, so the graph just stops keeping up.
423
+ //
424
+ // The generated path stays PREFERRED, so nothing changes while it exists;
425
+ // the fallback only engages once it does not. Falling back first would
426
+ // silently switch which runtime executes, which is its own bug.
427
+ const [interpreter, ...rest] = command;
428
+ const invocation = ['"$KNODIN_NODE"', ...rest.map(shellQuote)].join(" ");
429
+ const preferred = shellQuote(interpreter ?? process.execPath);
361
430
  return String.raw `#!/bin/sh
362
431
  # knodin packaged background refresh. Generated by knodin init.
363
432
  set -u
433
+ KNODIN_NODE=${preferred}
434
+ if [ ! -x "$KNODIN_NODE" ]; then
435
+ KNODIN_NODE="$(command -v node 2>/dev/null || true)"
436
+ # No interpreter at all: fall through to the recorded path so the failure is
437
+ # the existing loud "not found", not a silent no-op.
438
+ [ -n "$KNODIN_NODE" ] || KNODIN_NODE=${preferred}
439
+ fi
364
440
  REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
365
441
  [ -n "$REPO_ROOT" ] || exit 0
366
442
  cd "$REPO_ROOT" || exit 0
@@ -460,7 +536,7 @@ function refreshProcessorIsRunning(repo) {
460
536
  return false;
461
537
  }
462
538
  }
463
- function drainQueuedLifecycleEvents(repo, backgroundPath) {
539
+ function drainQueuedLifecycleEvents(repo, backgroundPath, leaseToken) {
464
540
  const before = queuedLifecycleEvents(repo);
465
541
  if (before.length === 0)
466
542
  return { state: "fresh", queuedEvents: 0 };
@@ -469,6 +545,13 @@ function drainQueuedLifecycleEvents(repo, backgroundPath) {
469
545
  encoding: "utf-8",
470
546
  stdio: ["ignore", "pipe", "pipe"],
471
547
  timeout: 30_000,
548
+ // The caller holds the lifecycle lease while this runs. Without handing
549
+ // the token down, the child re-enters knodin, meets its own parent's
550
+ // lease, and refuses — so the drain could never succeed and init
551
+ // reported failure on every run.
552
+ env: leaseToken
553
+ ? { ...process.env, [LIFECYCLE_LEASE_TOKEN_ENV]: leaseToken }
554
+ : { ...process.env },
472
555
  });
473
556
  if (result.status !== 0) {
474
557
  const detail = result.error?.message || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
@@ -1283,13 +1366,25 @@ export async function initializeRepository(repo, options) {
1283
1366
  // Configuration is graph-visible, so explicit configuration must complete
1284
1367
  // before the final index and health verification. Plain init reaches this
1285
1368
  // point without touching agent instructions, MCP files, skills, or receipts.
1286
- const indexResult = await options.index(resolvedRepo, {
1287
- onProgress: options.onProgress,
1288
- });
1289
- if (isIndexResult(indexResult) && indexResult.verification.status !== "healthy") {
1290
- throw new InitializationHealthError(indexResult);
1369
+ //
1370
+ // "Graph-visible" justifies indexing the files configuration wrote. It does
1371
+ // not justify walking the whole repository to find them, which is what an
1372
+ // undefined file list means.
1373
+ const scopedIndexPaths = options.indexScope === "configuration"
1374
+ ? changedConfigurationFiles(resolvedRepo, mutationTargets, beforeMutation)
1375
+ : undefined;
1376
+ if (!scopedIndexPaths || scopedIndexPaths.length > 0) {
1377
+ const indexResult = await options.index(resolvedRepo, { onProgress: options.onProgress }, scopedIndexPaths);
1378
+ // Only a full index has verified the whole graph, so only a full index
1379
+ // may fail the run on graph health. A configuration-scoped pass saw a
1380
+ // handful of files and knows nothing about the rest.
1381
+ if (!scopedIndexPaths &&
1382
+ isIndexResult(indexResult) &&
1383
+ indexResult.verification.status !== "healthy") {
1384
+ throw new InitializationHealthError(indexResult);
1385
+ }
1291
1386
  }
1292
- const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath);
1387
+ const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath, lifecycleLease.token);
1293
1388
  await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), {
1294
1389
  force: true,
1295
1390
  });
@@ -1310,6 +1405,7 @@ export async function initializeRepository(repo, options) {
1310
1405
  excluded,
1311
1406
  lifecycleRefresh,
1312
1407
  hookManagerIntegration,
1408
+ scopedIndexPaths,
1313
1409
  };
1314
1410
  }
1315
1411
  finally {
@@ -200,8 +200,19 @@ export class RepositoryWorker extends EventEmitter {
200
200
  }
201
201
  const { requestId, operation } = context;
202
202
  try {
203
+ // Same race as the startup one below, one phase earlier: the queueAbort
204
+ // arm above reports CLIENT_DISCONNECTED, and this re-check reported
205
+ // CANCELLED for the identical event whenever the queue wait resolved in
206
+ // the same instant the abort landed.
207
+ //
208
+ // The supervisor observes only "the signal aborted" and cannot tell a
209
+ // disconnect from an explicit cancel; every abort-listener arm here
210
+ // already calls that a disconnect, so the re-checks now agree with them.
211
+ // The distinction belongs to the layer that knows why the signal fired —
212
+ // superviseMcpRequest, which raises REQUEST_CANCELLED for a client
213
+ // cancellation it can actually identify.
203
214
  if (signal.aborted)
204
- throw codedError("KNODIN_REQUEST_CANCELLED", "request cancelled before worker dispatch");
215
+ throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected before worker dispatch");
205
216
  if (!this.child) {
206
217
  if (this.restartTimes.length >= MAX_RESTARTS)
207
218
  throw codedError("KNODIN_RESTART_LIMIT", "graph worker restart limit reached");
@@ -233,8 +244,20 @@ export class RepositoryWorker extends EventEmitter {
233
244
  if (startupAbort)
234
245
  signal.removeEventListener("abort", startupAbort);
235
246
  });
247
+ // Same event as the startupAbort arm above, same code. Both describe an
248
+ // abort arriving during worker startup; which one reports it is decided
249
+ // by whether the startup race settles as ready or as aborted in the same
250
+ // instant. Reporting different codes made the SAME client disconnect
251
+ // surface as CLIENT_DISCONNECTED or REQUEST_CANCELLED depending on
252
+ // scheduling — stable on an idle machine, unstable under load, which is
253
+ // worse than a consistently wrong value because it invites trust.
254
+ //
255
+ // These codes name a CAUSE, not a phase. classifyMcpFailure maps them to
256
+ // distinct kinds in the reliability journal, so the race was also
257
+ // recording one disconnect under two kinds and silently contaminating
258
+ // any disconnect-versus-cancellation rate computed from it.
236
259
  if (signal.aborted)
237
- throw codedError("KNODIN_REQUEST_CANCELLED", "request cancelled during worker startup");
260
+ throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected during worker startup");
238
261
  const predecessorTraceId = this.lastFailureTraceId;
239
262
  if (predecessorTraceId)
240
263
  recordMcpLifecycle(context, "restart", { predecessorTraceId });
@@ -92,10 +92,34 @@ function removeStaleLeaseOrThrow(target, operation) {
92
92
  throw error;
93
93
  }
94
94
  }
95
+ /**
96
+ * Environment channel by which a lease holder tells a process it spawns that
97
+ * the lease it is about to meet is its OWN.
98
+ *
99
+ * `knodin init` holds the lease and then drains the queued lifecycle refresh by
100
+ * spawning the background hook, which re-enters knodin and tries to take the
101
+ * same lease. Without this the child sees a live pid — its own parent — and
102
+ * refuses, so init can never drain its own queue and reports failure on every
103
+ * run while telling the user to run init again.
104
+ */
105
+ export const LIFECYCLE_LEASE_TOKEN_ENV = "KNODIN_LIFECYCLE_LEASE_TOKEN";
95
106
  /** Atomically serialize repair post-processing across independent CLI/MCP processes. */
96
107
  export function acquireRepairLease(repo, operation = "repair") {
97
108
  const target = leasePath(repo);
98
109
  fs.mkdirSync(path.dirname(target), { recursive: true });
110
+ // Re-entrant ONLY for a token that matches the lease actually on disk. A
111
+ // stale or forged token falls through to the normal contention check, so a
112
+ // genuinely concurrent mutation from another process is still refused —
113
+ // this makes the lease stop blocking its own holder, it does not disable it.
114
+ const inheritedToken = process.env[LIFECYCLE_LEASE_TOKEN_ENV];
115
+ if (inheritedToken) {
116
+ const held = readLease(target);
117
+ if (held?.nonce === inheritedToken) {
118
+ // The holder releases it. Releasing here would drop the lock while the
119
+ // parent still believes it holds it.
120
+ return { release() { } };
121
+ }
122
+ }
99
123
  const lifecycleCommand = [
100
124
  "repair",
101
125
  "init",
@@ -124,6 +148,7 @@ export function acquireRepairLease(repo, operation = "repair") {
124
148
  writeLease(target, record);
125
149
  writeActivity(repo, record);
126
150
  return {
151
+ token: record.nonce,
127
152
  release() {
128
153
  try {
129
154
  const current = readLease(target);
@@ -0,0 +1,83 @@
1
+ # knodin 0.10.1
2
+
3
+ Three defects, none of which a test suite would have found on its own. Two were
4
+ found by upgrading knodin on a real machine: one only appears after Homebrew
5
+ moves the Node binary, the other only when a lifecycle event is already queued.
6
+ The third is a scheduling race that reported the same cause under two different
7
+ codes, and it surfaced as an unrelated pre-push failure after six clean runs.
8
+
9
+ ## Background refresh survives a moved Node binary
10
+
11
+ `knodin init` wrote the generated hook with a fully-resolved path to whichever
12
+ Node was running at the time. `process.execPath` is symlink-resolved, so a
13
+ stable `/opt/homebrew/bin/node` became a version-pinned
14
+ `/opt/homebrew/Cellar/node/<version>/bin/node`.
15
+
16
+ Homebrew deletes that directory on upgrade, so every hook written beforehand
17
+ died with exit 127 — silently, because refresh runs in the background. The
18
+ graph simply stopped keeping up, and only a `knodin status` run revealed it.
19
+ It affected every repository initialized before the upgrade, not one.
20
+
21
+ The hook now resolves its interpreter when it runs. The recorded path stays
22
+ preferred, so nothing changes while it exists; `PATH` is consulted only once it
23
+ does not. Falling back first would silently change which runtime executes,
24
+ which is its own bug.
25
+
26
+ Hooks generated before this release still carry the old absolute path. They
27
+ keep failing until regenerated with `knodin init`; the fix prevents recurrence
28
+ after the next Node upgrade rather than repairing them retroactively.
29
+
30
+ ## `knodin init` can drain its own lifecycle queue
31
+
32
+ `init` takes the lifecycle lease, then drains queued events by spawning the
33
+ background hook. The hook re-entered knodin, met the same lease, saw a live
34
+ pid — its own parent — and refused. `init` reported "queued lifecycle refresh
35
+ could not be drained (exit 1)" on every run, and `knodin status` then advised
36
+ running `init`, which reproduced the failure exactly.
37
+
38
+ Worse, the failed drain wrote a failure marker that `status` kept reporting, so
39
+ a transient condition looked permanent while the graph was in fact intact — in
40
+ the observed case 5,883 of 5,883 files indexed and 5,059 carrying symbols.
41
+
42
+ A lease holder now passes its nonce to processes it spawns, and a child whose
43
+ token matches the lease on disk is admitted re-entrantly. Re-entry returns a
44
+ no-op release, because releasing there would drop the lock while the parent
45
+ still believes it holds it.
46
+
47
+ The lease is not weakened. A missing token, or one that does not match the
48
+ lease on disk, still hits the existing contention check, so a genuinely
49
+ concurrent mutation from another process is refused exactly as before.
50
+
51
+ ## One abort cause, one code
52
+
53
+ Worker startup and queue admission are each a race between "ready" and
54
+ "aborted". Each arm had an abort listener rejecting with
55
+ `KNODIN_CLIENT_DISCONNECTED` and a follow-up `signal.aborted` re-check
56
+ rejecting with `KNODIN_REQUEST_CANCELLED`, so the same client disconnect
57
+ surfaced as either code depending on which arm settled first.
58
+
59
+ That is worse than a consistently wrong code. It was stable on an idle machine
60
+ and unstable under load, so it looked trustworthy everywhere anyone would have
61
+ checked it — it surfaced as a pre-push gate failure on an unrelated change
62
+ after six clean runs.
63
+
64
+ It also fed the reliability journal, where the two codes map to distinct kinds.
65
+ One disconnect was recorded under two kinds, quietly skewing any rate computed
66
+ from them. **That contamination is retroactive and cannot be repaired:** journal
67
+ entries written before this release cannot be reattributed, so a
68
+ cancellation-versus-disconnect rate spanning the 0.10.1 boundary should be read
69
+ as suspect rather than corrected.
70
+
71
+ The taxonomy is unchanged. `superviseMcpRequest` can still tell an explicit
72
+ cancellation from a disconnect; the supervisor cannot, so it now names only the
73
+ cause it actually knows.
74
+
75
+ ## Homebrew formula retention
76
+
77
+ The `Retain versioned Homebrew formula` job ran on a GitHub-hosted runner whose
78
+ ephemeral IP is not on the organization's allow list, so it failed at checkout
79
+ with a 403 on every release since it was added — and it cloned
80
+ `knodin/knodin`, a stale source mirror, rather than `knodin/homebrew-tap`.
81
+
82
+ Both are corrected. Every formula through 0.10.0 was retained by hand; this is
83
+ the first release where that job can do it.
@@ -0,0 +1,76 @@
1
+ # knodin 0.10.2
2
+
3
+ Two defects found by running 0.10.1 against a 902,960-file Salesforce checkout.
4
+ One of them means graphs built by earlier releases on large repositories are
5
+ incomplete in a way that nothing reported.
6
+
7
+ ## Parsers were never freed, and the run reported success anyway
8
+
9
+ Indexing built a tree-sitter `Parser` for every file and freed neither it nor
10
+ the resulting syntax tree. web-tree-sitter objects live in the Emscripten heap
11
+ and are reclaimed only by an explicit `delete()`; of the five parse sites in the
12
+ engine, exactly one had it.
13
+
14
+ The heap therefore grew with every file until allocation failed and the wasm
15
+ module called `abort()`. Measured on a 219-byte XML file: leaking died at the
16
+ 87,305th parse with `ExitStatus: Program terminated with exit(1)`, while freeing
17
+ survived 200,000 with no failure.
18
+
19
+ Three things went wrong from there, and the last is the reason this is a
20
+ correctness fix rather than a performance one.
21
+
22
+ The module never comes back. After the first abort, every later parse failed
23
+ identically — verified on freshly constructed parsers.
24
+
25
+ The error then named the wrong file. Every file processed after the abort was
26
+ reported as failing regardless of its contents, which is why the observed
27
+ failure list was a contiguous alphabetical run of Salesforce package manifests.
28
+ Those files are fine; all 144 of them parse cleanly on a shared parser.
29
+
30
+ And the failure was swallowed. The per-file handler logged, counted the file as
31
+ a coverage gap, and continued, so the run finished successfully. Files that were
32
+ never parsed became indistinguishable from files that genuinely contain no
33
+ symbols, and every later query answered confidently from a graph that was
34
+ quietly missing everything after the abort.
35
+
36
+ All five sites now free both objects on every path, including early returns and
37
+ error paths. A wasm abort is no longer treated as a per-file problem: it fails
38
+ the run, because it is not survivable and pretending otherwise is what turned
39
+ one real failure into thousands of misattributed ones.
40
+
41
+ **Reindex to trust an existing graph.** If a large repository was indexed with
42
+ an earlier release, symbol coverage may be short by an unknown amount and
43
+ nothing in `knodin status` would have said so. Smaller repositories that never
44
+ approached the threshold are unaffected.
45
+
46
+ The coverage line also stops claiming more than it knows. Files with no
47
+ tree-sitter grammar, minified sources, and files whose indexing failed were all
48
+ reported as `parsed but empty`, which asserts the parser looked and found
49
+ nothing — the one thing it did not do. They are now reported as `not parsed`.
50
+
51
+ ## `knodin configure` no longer indexes the entire repository
52
+
53
+ `configure` refuses to run against an uninitialized repository on the grounds
54
+ that it "changes agent integration only", and then passed the indexer no file
55
+ list, which means every file in the tree. On a large checkout that is minutes of
56
+ work to account for a change to three dotfiles, with nothing in the output
57
+ saying an index was happening at all.
58
+
59
+ It now indexes only the files it actually wrote that a full index would also
60
+ have selected, and reports what it did instead of claiming the graph was
61
+ refreshed. That claim was most misleading precisely where it mattered: on a
62
+ repository whose graph was empty, `configure` silently rebuilt it from scratch,
63
+ which is `init`'s job and is what made a routine scope change look inexplicably
64
+ slow.
65
+
66
+ In practice `knodin configure --scope personal` now indexes nothing at all.
67
+ Personal scope writes only `.agents/`, `.codex/` and `.gemini/`, none of which
68
+ is source, so there is nothing for the indexer to do and it is not invoked.
69
+ Team scope still indexes the agent instructions it writes.
70
+
71
+ The filter is the same predicate a full index uses, rather than a looser one
72
+ written for this path. Anything looser records `index_state` rows for files no
73
+ other part of the system ever selects, which the next reconciliation then
74
+ purges — churn that reads as drift.
75
+
76
+ `knodin init` is unchanged and still indexes everything and verifies health.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "knodin": {
5
5
  "compatibility": "breaking"
6
6
  },
@@ -72,6 +72,8 @@
72
72
  "docs/releases/0.8.7.md",
73
73
  "docs/releases/0.9.0.md",
74
74
  "docs/releases/0.10.0.md",
75
+ "docs/releases/0.10.1.md",
76
+ "docs/releases/0.10.2.md",
75
77
  "docs/assets/knodin-favicon.svg",
76
78
  "docs/SYSTEMS-AND-RELATIONSHIPS.md",
77
79
  "docs/TELEMETRY.md",