knodin 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/cli.js CHANGED
@@ -35,7 +35,7 @@ import { diagnoseFailure, } from "../src/failure-diagnosis.js";
35
35
  import { gitExecutable } from "../src/git-executable.js";
36
36
  import { decorateGraphQueryResult, inspectGraphQueryHealth } from "../src/graph-query-health.js";
37
37
  import { createIndexActivityReporter } from "../src/index-activity.js";
38
- import { InitializationHealthError, initializeRepository, inspectRepositoryIntegrationStatus, readRepositoryIntegrationConfig, refreshFromGitEvent, } from "../src/init.js";
38
+ import { InitializationHealthError, initializeRepository, inspectRepositoryIntegrationStatus, readRepositoryIntegrationConfig, refreshFromGitEvent, repairLifecycleRouting, } from "../src/init.js";
39
39
  import { createInitProgressRenderer } from "../src/init-progress.js";
40
40
  import { attachLifecycleHealth, attachRepairLifecycle } from "../src/lifecycle-health.js";
41
41
  import { acknowledgeUpdateFailure, queryOwnerAvailability, readManagerUpdateState, readUpdateJournal, resolveManagerOwnership, setManualPin, unpinUpdate, updateAttention, writeManagerUpdateState, } from "../src/manager-update.js";
@@ -60,7 +60,7 @@ import { KNODIN_VERSION } from "../src/version.js";
60
60
  import { writeVisualization, } from "../src/visualization.js";
61
61
  import { waitForFresh } from "../src/wait-for-fresh.js";
62
62
  import { inspectWorktrees, reconcileWorktrees, removeManagedWorktree, } from "../src/worktree-lifecycle.js";
63
- import { indexOrSeed } from "../src/worktree-seed.js";
63
+ import { indexOrSeed, seedWorktreeIndex } from "../src/worktree-seed.js";
64
64
  function explicitScope(args) {
65
65
  const index = args.indexOf("--scope");
66
66
  if (index >= 0) {
@@ -138,7 +138,7 @@ function formatRepairHuman(result) {
138
138
  }
139
139
  if (result.verified) {
140
140
  if (result.lifecycle?.status === "degraded")
141
- return `Repair verified: graph is healthy (${coverage.indexedFiles} indexed files, ${coverage.filesWithSymbols} files with symbols), but lifecycle routing is degraded. Run \`knodin init\`, then \`knodin status\`.\n`;
141
+ return `Repair verified: graph is healthy (${coverage.indexedFiles} indexed files, ${coverage.filesWithSymbols} files with symbols), but lifecycle routing is degraded. Run \`knodin repair --lifecycle\`, then \`knodin status\`.\n`;
142
142
  return `Repair verified: graph is healthy (${coverage.indexedFiles} indexed files, ${coverage.filesWithSymbols} files with symbols).\n`;
143
143
  }
144
144
  const outstanding = result.outstandingIssues?.total ??
@@ -198,7 +198,7 @@ function formatLifecycleLine(lifecycle, isMirror) {
198
198
  if (lifecycle.status === "healthy")
199
199
  return "Hooks: installed and executable.\n";
200
200
  const issue = lifecycle.issues[0] ?? "refresh capability is not verified";
201
- return `Lifecycle refresh: ${lifecycle.status}; ${issue}. Run \`knodin init\`.\n`;
201
+ return `Lifecycle refresh: ${lifecycle.status}; ${issue}. Run \`knodin repair --lifecycle\`.\n`;
202
202
  }
203
203
  /** Renders the top extensions of a tally as `.md 180, .yml 74, +3 more`. */
204
204
  function formatTally(counts, limit = 4) {
@@ -1522,6 +1522,7 @@ async function main() {
1522
1522
  let result;
1523
1523
  let repairOutput;
1524
1524
  let repairWasPlan = false;
1525
+ let repairWasLifecycle = false;
1525
1526
  let repairExitCode = 0;
1526
1527
  let statusWasWatched = false;
1527
1528
  let agentEventOutput = false;
@@ -2381,6 +2382,27 @@ async function main() {
2381
2382
  repairWasPlan = true;
2382
2383
  break;
2383
2384
  }
2385
+ if (options.lifecycle) {
2386
+ result = await repairLifecycleRouting(repo, { command: runtimeCommand });
2387
+ repairOutput = options.output;
2388
+ repairWasLifecycle = true;
2389
+ break;
2390
+ }
2391
+ // Cold start only. With no database at all there is nothing to rebuild
2392
+ // *from*, so repair would index the whole corpus while a sibling
2393
+ // worktree already holds a reusable baseline — reflink-copying it and
2394
+ // reconciling the differing paths is seconds against minutes
2395
+ // (KNODIN-14). Repair's contract is untouched: it still rebuilds from
2396
+ // this working tree, it just starts from a baseline instead of zero.
2397
+ //
2398
+ // A database that exists but is damaged is deliberately excluded. That
2399
+ // is the case repair exists for, and seeding it would replace a
2400
+ // verifiable local rebuild with another checkout's state.
2401
+ if (!fs.existsSync(resolveDbPath(repo))) {
2402
+ const seed = await seedWorktreeIndex(repo, engine, KNODIN_SCHEMA_VERSION);
2403
+ if (seed.seeded)
2404
+ process.stderr.write("[repair:seed] Seeded baseline from an indexed sibling worktree\n");
2405
+ }
2384
2406
  const progressMode = resolveRepairProgressMode(options, process.env, process.stderr.isTTY);
2385
2407
  const renderer = progressMode === "tty"
2386
2408
  ? createProgressWorkerRenderer("repair-progress-worker", {
@@ -2787,6 +2809,12 @@ async function main() {
2787
2809
  }
2788
2810
  result = decorateGraphQueryResult(result, verifiedQueryHealth.state, verifiedQueryHealth.graph.freshness);
2789
2811
  }
2812
+ // A target that does not exist is operator error, not a negative
2813
+ // answer, so it exits non-zero like an unavailable graph rather than
2814
+ // like real dead code (KNODIN-28). `resolved` with zero rows keeps
2815
+ // exit 0: the symbol exists and genuinely has no callers.
2816
+ if (result?.targetResolution === "not-found")
2817
+ process.exitCode = 1;
2790
2818
  break;
2791
2819
  }
2792
2820
  case "rename": {
@@ -2999,6 +3027,15 @@ async function main() {
2999
3027
  process.stdout.write(formatConfigureStatusHuman(boundedResult));
3000
3028
  return;
3001
3029
  }
3030
+ if (cmd === "repair" && repairOutput === "human" && repairWasLifecycle) {
3031
+ // A lifecycle repair carries no graph coverage, so it cannot go through
3032
+ // the graph-repair renderer, and saying nothing about what it left alone
3033
+ // would omit the only reason to reach for it (KNODIN-31).
3034
+ const lifecycle = boundedResult;
3035
+ process.stdout.write(`Lifecycle routing rewritten: ${lifecycle.gitHooks.length} managed Git hook(s) and the background indexer, in ${lifecycle.hooksDirectory}. Agent configuration untouched. Run \`knodin status\` to confirm.\n`);
3036
+ process.exitCode = finalExitCode;
3037
+ return;
3038
+ }
3002
3039
  if (cmd === "repair" && repairOutput === "human" && !repairWasPlan) {
3003
3040
  process.stdout.write(formatRepairHuman(boundedResult));
3004
3041
  process.exitCode = finalExitCode;
@@ -309,6 +309,7 @@ function createCliProgram(capture = () => { }) {
309
309
  .addOption(option("--timeout <seconds>", "deadline", "number"));
310
310
  leaf(program, "repair", "audit and reconcile graph state", capture)
311
311
  .option("--plan", "report the repair plan without mutation")
312
+ .option("--lifecycle", "rewrite lifecycle hooks only, leaving agent configuration untouched")
312
313
  .option("--jsonl", "stream JSONL progress")
313
314
  .option("--progress <mode>", "tty, jsonl, plain, or none")
314
315
  .option("--progress-interval <duration>", "progress interval such as 750ms, 30s, or 2m");
@@ -826,6 +826,97 @@ function freshnessMechanismFor(repoPath, policy) {
826
826
  // Bumped whenever any repo is (re)indexed, so cached community-detection results
827
827
  // below can be invalidated cheaply instead of recomputed on every call.
828
828
  export const KNODIN_SCHEMA_VERSION = 24;
829
+ /**
830
+ * Reciprocal Rank Fusion's smoothing constant. Was written as a bare `60` at
831
+ * each use site; named so the ranking formula can be read and changed in one
832
+ * place. Adjacent ranks differ by roughly 0.00026 at this k, which is the unit
833
+ * every other ranking weight has to be reasoned about in.
834
+ */
835
+ const RRF_K = 60;
836
+ /**
837
+ * How hard graph centrality pulls on ranking, as a fraction of one full RRF
838
+ * component.
839
+ *
840
+ * Semantic similarity alone ranks a minutes-old duplicate above the
841
+ * implementation it duplicates: the duplicate is short and made almost entirely
842
+ * of the query's own words, while the canonical version is diluted by a real
843
+ * body. The searcher then reads their own new leaf as evidence that nothing
844
+ * existed (KNODIN-15). Community and degree data was already in the graph and
845
+ * already in the output; it simply was not weighted into ranking.
846
+ *
847
+ * 0.05 caps the boost at `0.05 / (RRF_K + 1)` ≈ 0.00082, about three rank
848
+ * positions. That is deliberately modest, because the opposite failure is
849
+ * equally real and lands on the same symbols: a *seam* is by definition a
850
+ * well-documented symbol with no callers, and those are already the hardest
851
+ * things to retrieve (KNODIN-28, KNODIN-30). A large centrality weight would
852
+ * bury them to fix this, trading one silent miss for another. So a connected
853
+ * implementation climbs past neighbours it is scoring near, and does not
854
+ * override a decisively stronger semantic match.
855
+ *
856
+ * Zero-in-degree symbols are excluded from the centrality ranking rather than
857
+ * ranked last, so an uncalled symbol is never *penalised* — it only forgoes a
858
+ * boost it has no evidence for.
859
+ */
860
+ const CENTRALITY_RRF_WEIGHT = 0.05;
861
+ /**
862
+ * The live ranking weights, exported so a benchmark can record which values
863
+ * produced its numbers.
864
+ *
865
+ * Comparing two ranking runs whose constants differed is not a measurement, and
866
+ * the difference is invisible unless the run writes them down. Reading them here
867
+ * rather than copying them into the benchmark keeps that record from going stale
868
+ * the first time someone tunes a weight.
869
+ */
870
+ /**
871
+ * How hard a partial (OR) lexical match pulls, as a fraction of one full RRF
872
+ * component, and how many of them are considered.
873
+ *
874
+ * The weight buys a statable property: at 0.75 a top-ranked partial match
875
+ * contributes `0.75 / (RRF_K + 1)` ≈ 0.0123, which is enough to lift a symbol
876
+ * sitting around semantic rank 90 above an unsupported semantic rank 1. So
877
+ * lexical evidence can rescue a symbol the embedder under-ranked, and cannot
878
+ * resurrect one the embedder judged irrelevant. At 1.0 the loose channel becomes
879
+ * a full peer of semantic similarity and wins from arbitrarily deep, which is
880
+ * over-correction.
881
+ *
882
+ * The limit is a separate lever and conflating the two is how this gets
883
+ * mistuned. RRF has a floor — the hundredth partial match still scores
884
+ * `w / (RRF_K + 100)` — so an unbounded channel gives a meaningful shove to a
885
+ * hundred symbols at once on any query full of common words. Truncating raises
886
+ * the per-item floor slightly while applying it to under a third as many rows,
887
+ * which is the trade that actually reduces noise.
888
+ *
889
+ * Both are starting points measured against `benchmarks/evaluations/c103-prose-retrieval`,
890
+ * not settled constants. Re-run it before changing either.
891
+ */
892
+ const LOOSE_LEXICAL_RRF_WEIGHT = 0.75;
893
+ /**
894
+ * How many query words the quorum check will probe, one bounded FTS query each.
895
+ *
896
+ * The check exists because a single incidental token match is not evidence, and
897
+ * this index cannot tell the difference on its own: FTS5 here has no stemming
898
+ * and does not split identifiers, so "validations" misses "validation" and
899
+ * `SessionAuthenticator` is one opaque token. The cap keeps a pathologically
900
+ * long query from turning into a pathological number of probes; beyond it the
901
+ * leading words decide, which is where the discriminating terms usually are.
902
+ */
903
+ const LEXICAL_QUORUM_TOKEN_CAP = 8;
904
+ const LOOSE_LEXICAL_LIMIT = 30;
905
+ /**
906
+ * The live ranking weights, exported so a benchmark can record which values
907
+ * produced its numbers.
908
+ *
909
+ * Comparing two ranking runs whose constants differed is not a measurement, and
910
+ * the difference is invisible unless the run writes them down. Reading them here
911
+ * rather than copying them into the benchmark keeps that record from going stale
912
+ * the first time someone tunes a weight.
913
+ */
914
+ export const RANKING_CONSTANTS = {
915
+ rrfK: RRF_K,
916
+ centralityWeight: CENTRALITY_RRF_WEIGHT,
917
+ looseLexicalWeight: LOOSE_LEXICAL_RRF_WEIGHT,
918
+ looseLexicalLimit: LOOSE_LEXICAL_LIMIT,
919
+ };
829
920
  const LAST_REBUILD_SCHEMA_VERSION = 24;
830
921
  let indexGeneration = 0;
831
922
  const resourceReachabilityCache = new Map();
@@ -1631,12 +1722,48 @@ function createLanguageMemo() {
1631
1722
  return name;
1632
1723
  };
1633
1724
  }
1725
+ /**
1726
+ * Node types a `//` line can carry across the grammars this indexes. They
1727
+ * disagree on the name, so matching one of them is not enough on its own — see
1728
+ * `isLineComment` below, which also requires the `//` prefix.
1729
+ */
1730
+ const LINE_COMMENT_NODE_TYPES = new Set(["comment", "line_comment", "hash_comment"]);
1634
1731
  /** Clean up and format comment/docstring blocks in JavaScript/TypeScript. */
1635
1732
  function getPrecedingComment(node) {
1636
1733
  let target = node;
1637
1734
  while (target.parent && target.parent.startPosition.row === target.startPosition.row) {
1638
1735
  target = target.parent;
1639
1736
  }
1737
+ // Only decorators and modifiers may sit between a doc comment and the thing
1738
+ // it documents. The walk used to step over ANY five siblings looking for a
1739
+ // comment, so a symbol with no doc comment of its own adopted whichever
1740
+ // comment preceded a nearby earlier symbol — attaching prose to a symbol it
1741
+ // was never written about, in both the embedding input and the FTS `summary`
1742
+ // column.
1743
+ //
1744
+ // That is not a corner case. Measured on this repository before the fix, 857
1745
+ // of 1,329 documented symbols under `src/` — 64% — shared summary text with
1746
+ // another symbol, and spot checks showed plain misattribution rather than
1747
+ // genuine repetition: an interface carrying a neighbouring function's
1748
+ // docstring, two unrelated types sharing a third symbol's sentence. Prose
1749
+ // retrieval matching that text then returns the wrong symbol confidently,
1750
+ // which is the same class of failure as the truncation above and strictly
1751
+ // worse: absent prose loses a hit, misattributed prose manufactures one.
1752
+ //
1753
+ // Stopping at the first non-skippable node is the whole fix. A row-adjacency
1754
+ // check was tried alongside it and removed: decorators are children of the
1755
+ // declaration in some grammars and siblings in others, so "directly above"
1756
+ // is not portable, and it silently dropped the doc comment of every
1757
+ // decorated class. The walk already refuses to cross a declaration, which is
1758
+ // what the misattribution needed.
1759
+ const SKIPPABLE_BEFORE_DOC = new Set([
1760
+ "decorator",
1761
+ "export",
1762
+ "default",
1763
+ "async",
1764
+ "abstract",
1765
+ "declare",
1766
+ ]);
1640
1767
  let prev = target.previousSibling;
1641
1768
  let count = 0;
1642
1769
  while (prev && count < 5) {
@@ -1646,6 +1773,8 @@ function getPrecedingComment(node) {
1646
1773
  prev.type === "line_comment") {
1647
1774
  break;
1648
1775
  }
1776
+ if (!SKIPPABLE_BEFORE_DOC.has(prev.type))
1777
+ return null;
1649
1778
  prev = prev.previousSibling;
1650
1779
  count++;
1651
1780
  }
@@ -1654,6 +1783,48 @@ function getPrecedingComment(node) {
1654
1783
  prev.type === "hash_comment" ||
1655
1784
  prev.type === "block_comment" ||
1656
1785
  prev.type === "line_comment")) {
1786
+ // Walk back over a run of consecutive `//` lines and rejoin them.
1787
+ //
1788
+ // tree-sitter gives every `//` line its OWN comment node, so `prev` is the
1789
+ // LAST line of a block, and the `split("\n")` below could never fire for
1790
+ // this style. Every multi-line `//` doc comment in every indexed
1791
+ // repository was therefore truncated to its final line before it reached
1792
+ // the embedding or the FTS index — silently, since a one-line summary
1793
+ // looks perfectly well-formed.
1794
+ //
1795
+ // That is a retrieval defect, not a cosmetic one: the first line of a doc
1796
+ // comment is usually the sentence that says what the thing is for, which
1797
+ // is exactly what a capability question matches on. `/* */` blocks are a
1798
+ // single node and were never affected, so the damage was invisible in any
1799
+ // codebase that preferred them.
1800
+ //
1801
+ // Adjacency is required: a blank line or any code between two comment
1802
+ // nodes ends the block, so a stray earlier comment is not absorbed.
1803
+ // Keyed on the `//` PREFIX rather than on one node type. The surrounding
1804
+ // checks accept `comment`, `line_comment`, `hash_comment` and
1805
+ // `block_comment` because grammars disagree about the name, and an
1806
+ // earlier version of this rejoin tested `type === "comment"` alone — so
1807
+ // in any grammar that calls a `//` line `line_comment`, multi-line
1808
+ // comments went on being truncated to their last line while appearing
1809
+ // fixed everywhere else. The prefix is the thing that actually decides
1810
+ // whether a node is one line of a multi-line run.
1811
+ const isLineComment = (node) => LINE_COMMENT_NODE_TYPES.has(node.type) && node.text.trim().startsWith("//");
1812
+ if (isLineComment(prev)) {
1813
+ const lines = [];
1814
+ let line = prev;
1815
+ while (line &&
1816
+ isLineComment(line) &&
1817
+ // Consecutive source lines, and nothing but whitespace between them.
1818
+ (lines.length === 0 || line.endPosition.row + 1 === prev.startPosition.row)) {
1819
+ lines.unshift(line.text.trim());
1820
+ prev = line;
1821
+ line = line.previousSibling;
1822
+ }
1823
+ return lines
1824
+ .map((entry) => entry.replace(/^\/\/+\s*/, ""))
1825
+ .join("\n")
1826
+ .trim();
1827
+ }
1657
1828
  let text = prev.text.trim();
1658
1829
  if (text.startsWith("/*")) {
1659
1830
  text = text
@@ -6988,6 +7159,34 @@ function repairPathsAffectTypeScriptDi(db, repoPath, repairPaths) {
6988
7159
  // measurements.
6989
7160
  const DEFAULT_EMBEDDING_BATCH_SIZE = 1;
6990
7161
  const MAX_EMBEDDING_BATCH_SIZE = 32;
7162
+ /**
7163
+ * Total characters embedded per symbol.
7164
+ *
7165
+ * A `EMBEDDING_SOURCE_BUDGET` capping the source body inside this was tried and
7166
+ * reverted. It looked like a win — the diluted implementation's similarity rose
7167
+ * 0.163 to 0.212 against its own fresh duplicate — but that was measured on the
7168
+ * suite's bag-of-words test embedder, and re-measuring on the real MiniLM model
7169
+ * across two body shapes, three query phrasings and both budgets showed the cap
7170
+ * never changes the winner in any of the twelve configurations. Its apparent
7171
+ * benefit came from the fixture: `fillerBody` emitted forty copies of one line,
7172
+ * and truncating identical lines removes a redundancy bag-of-words punishes and
7173
+ * a transformer largely ignores. On realistic varied code the cap is neutral to
7174
+ * slightly negative.
7175
+ *
7176
+ * The general lesson, which cost a forced re-embed to learn: a short document
7177
+ * made almost entirely of query tokens beats a long one under cosine
7178
+ * similarity, and no reweighting of a single vector's inputs changes that.
7179
+ * KNODIN-15's remedy is a non-similarity signal — graph degree, in the rank
7180
+ * fusion — not the representation.
7181
+ */
7182
+ const EMBEDDING_INPUT_BUDGET = 1000;
7183
+ /**
7184
+ * The text embedded for one symbol: what it is called, what kind of thing it
7185
+ * is, what its docstring says it does, and then its body.
7186
+ */
7187
+ function embeddingInputFor(symbol, source) {
7188
+ return `Name: ${symbol.name}\nKind: ${symbol.kind}\nSummary: ${symbol.summary || ""}\nSource:\n${source}`.slice(0, EMBEDDING_INPUT_BUDGET);
7189
+ }
6991
7190
  // A full embedding pass can touch many symbols per source file. Keep only a
6992
7191
  // bounded LRU of split lines so large repositories avoid re-reading a file for
6993
7192
  // every symbol without turning source preparation into an unbounded memory sink.
@@ -7182,10 +7381,7 @@ async function indexEmbeddings(db, repoPath, progress, signal) {
7182
7381
  throwIfAborted(signal);
7183
7382
  const prepared = missing.slice(offset, offset + batchSize).map((sym) => {
7184
7383
  const source = sourceForEmbedding(sym.filePath, sym.startLine, sym.endLine);
7185
- return {
7186
- symbol: sym,
7187
- text: `Name: ${sym.name}\nKind: ${sym.kind}\nSummary: ${sym.summary || ""}\nSource:\n${source}`.slice(0, 1000),
7188
- };
7384
+ return { symbol: sym, text: embeddingInputFor(sym, source) };
7189
7385
  });
7190
7386
  const generated = await embedPreparedBatch(prepared, onModelProgress);
7191
7387
  throwIfAborted(signal);
@@ -8004,50 +8200,6 @@ const freshnessChecks = new Map();
8004
8200
  * deterministic, rather than in wall-clock milliseconds, which is flaky.
8005
8201
  */
8006
8202
  const freshnessStats = { probes: 0, reconciles: 0, cacheHits: 0 };
8007
- /** Read HEAD without spawning Git. `null` means this is not a Git checkout;
8008
- * `undefined` means Git metadata exists but could not be resolved safely. */
8009
- function readGitHeadFast(repoPath) {
8010
- const marker = path.join(repoPath, ".git");
8011
- if (!fs.existsSync(marker))
8012
- return null;
8013
- try {
8014
- const markerStat = fs.statSync(marker);
8015
- const gitDir = markerStat.isDirectory()
8016
- ? marker
8017
- : path.resolve(repoPath, fs
8018
- .readFileSync(marker, "utf8")
8019
- .trim()
8020
- .replace(/^gitdir:\s*/, ""));
8021
- const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
8022
- if (/^[a-f0-9]{40}$/i.test(head))
8023
- return head.toLowerCase();
8024
- const refPrefix = "ref: ";
8025
- const ref = head.startsWith(refPrefix) ? head.slice(refPrefix.length).trim() : "";
8026
- if (!ref)
8027
- return undefined;
8028
- let commonDir = gitDir;
8029
- const commonMarker = path.join(gitDir, "commondir");
8030
- if (fs.existsSync(commonMarker))
8031
- commonDir = path.resolve(gitDir, fs.readFileSync(commonMarker, "utf8").trim());
8032
- for (const base of [gitDir, commonDir]) {
8033
- const loose = path.join(base, ref);
8034
- if (fs.existsSync(loose))
8035
- return fs.readFileSync(loose, "utf8").trim().toLowerCase();
8036
- }
8037
- const packed = path.join(commonDir, "packed-refs");
8038
- if (!fs.existsSync(packed))
8039
- return undefined;
8040
- return fs
8041
- .readFileSync(packed, "utf8")
8042
- .split("\n")
8043
- .find((line) => line.endsWith(` ${ref}`))
8044
- ?.split(" ", 1)[0]
8045
- ?.toLowerCase();
8046
- }
8047
- catch {
8048
- return undefined;
8049
- }
8050
- }
8051
8203
  /**
8052
8204
  * Paths the live watcher for `repoPath` currently has registered, flattened to
8053
8205
  * repo-relative form. Empty when no watcher is running (test mode, or after
@@ -8272,15 +8424,20 @@ async function ensureIndexFresh(repoPath, db) {
8272
8424
  // already reached its event queue. Git worktrees therefore take the bounded
8273
8425
  // porcelain proof on every answer. The long watcher lease remains safe for
8274
8426
  // non-Git directories, where the watcher is the only new-file signal.
8427
+ //
8428
+ // The lease used to be paired with a spawn-free HEAD read, so a leased
8429
+ // answer could still be invalidated by a commit. Once git-backed repos took
8430
+ // `lease = 0` that read became unreachable — it was called only when `.git`
8431
+ // was absent, which was its own first bail-out — and the term it fed was
8432
+ // inert. Both are gone (KNODIN-16); a lease is now reached only where there
8433
+ // is no HEAD to check.
8275
8434
  const gitBacked = fs.existsSync(path.join(key, ".git"));
8276
8435
  const lease = gitBacked
8277
8436
  ? 0
8278
8437
  : watchQueues.get(key)?.ready
8279
8438
  ? WATCHED_FRESHNESS_LEASE_MS
8280
8439
  : FRESHNESS_PROBE_TTL_MS;
8281
- const head = lease === WATCHED_FRESHNESS_LEASE_MS ? readGitHeadFast(key) : null;
8282
- const headUnchanged = head === null || (head !== undefined && head === getMeta(db, "lastIndexedHead"));
8283
- if (cached && Date.now() - cached.at < lease && headUnchanged) {
8440
+ if (cached && Date.now() - cached.at < lease) {
8284
8441
  freshnessStats.cacheHits++;
8285
8442
  return cached.staleness;
8286
8443
  }
@@ -14447,9 +14604,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14447
14604
  .split(/\s+/)
14448
14605
  .filter((word) => word.length >= 4)
14449
14606
  .map((word) => `"${word}"`);
14450
- const runFts = (separator) => {
14451
- if (tokens.length === 0)
14452
- return [];
14607
+ const runFtsMatch = (match) => {
14453
14608
  try {
14454
14609
  const statement = repo.db.query(`
14455
14610
  SELECT symbolId FROM symbols_fts
@@ -14458,7 +14613,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14458
14613
  LIMIT 100
14459
14614
  `);
14460
14615
  const ids = statement
14461
- .all(tokens.join(separator))
14616
+ .all(match)
14462
14617
  .map(({ symbolId }) => symbolId)
14463
14618
  .filter((id) => allowedIds.has(id));
14464
14619
  statement.finalize();
@@ -14469,8 +14624,43 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14469
14624
  return [];
14470
14625
  }
14471
14626
  };
14627
+ const runFts = (separator) => tokens.length === 0 ? [] : runFtsMatch(tokens.join(separator));
14628
+ const runFtsTokens = (subset) => subset.length === 0 ? [] : runFtsMatch(subset.join(" OR "));
14472
14629
  const and = measurePerfPhaseSync("fts", () => runFts(" "));
14473
- const or = tokens.length > 1 ? measurePerfPhaseSync("fts", () => runFts(" OR ")) : and;
14630
+ // The OR set, restricted to candidates matching at least two of the
14631
+ // query's words.
14632
+ //
14633
+ // A single incidental token match is not evidence. FTS5 here has no
14634
+ // stemming and does not split identifiers, so for "session
14635
+ // authentication and token validations" the class that actually does
14636
+ // the work matches NOTHING — its docstring says "sessions" and
14637
+ // "validation" — while a handler whose docstring happens to contain
14638
+ // "session" matches once and collects the full partial-match credit.
14639
+ // Fusing that put the caller above the implementation it calls.
14640
+ //
14641
+ // Requiring a quorum keeps the signal that matters (a symbol echoing
14642
+ // several of the query's words) and drops the one that misleads. It
14643
+ // costs one bounded FTS query per token, which is why it is capped.
14644
+ const or = measurePerfPhaseSync("fts", () => {
14645
+ if (tokens.length < 2)
14646
+ return and;
14647
+ const loose = runFts(" OR ");
14648
+ if (loose.length === 0)
14649
+ return loose;
14650
+ const hits = new Map();
14651
+ for (const token of tokens.slice(0, LEXICAL_QUORUM_TOKEN_CAP)) {
14652
+ for (const id of runFtsTokens([token])) {
14653
+ hits.set(id, (hits.get(id) ?? 0) + 1);
14654
+ }
14655
+ }
14656
+ // No fallback when nothing clears the bar. An earlier version
14657
+ // returned the unfiltered set in that case, which reinstated the
14658
+ // exact failure the quorum exists to prevent — and did so
14659
+ // precisely in the small-corpus situation where one weak match is
14660
+ // most likely to be the only one. A lexical channel with nothing
14661
+ // trustworthy to say should say nothing.
14662
+ return loose.filter((id) => (hits.get(id) ?? 0) >= 2);
14663
+ });
14474
14664
  const lexicalSeeds = or
14475
14665
  .map((id) => snapshot.rowById.get(id))
14476
14666
  .filter((row) => row !== undefined)
@@ -14526,6 +14716,15 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14526
14716
  // 1. Generate query embedding once
14527
14717
  const queryVec = await generateMeasuredEmbedding(query, true);
14528
14718
  const rankedCandidates = [];
14719
+ // Inbound reference counts for the centrality term. Computed once for the
14720
+ // federation rather than per repo, and only on the route that actually
14721
+ // fuses ranks — the structural and exact-match routes above return before
14722
+ // here and must keep their deterministic ordering.
14723
+ //
14724
+ // Not wrapped in a perf phase: the phase keys are a closed set that the
14725
+ // instrumentation contract asserts on, and adding one to measure a fix
14726
+ // would change what that contract describes.
14727
+ const referenceDegree = computeResolvedReferenceDegree(allRepos, formatPath);
14529
14728
  let totalMatches = 0;
14530
14729
  for (const repo of allRepos) {
14531
14730
  const db = repo.db;
@@ -14574,12 +14773,35 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14574
14773
  }
14575
14774
  // Sort by semantic score descending
14576
14775
  const sortedSemantics = semanticMatches.sort((a, b) => b.score - a.score);
14577
- const semanticScoresAreTied = sortedSemantics.length > 1 &&
14578
- sortedSemantics.every((item) => item.score === sortedSemantics[0]?.score);
14579
14776
  // 3. Reuse the lexical and bounded-graph ranks computed before query inference.
14777
+ //
14778
+ // Both operators are fused, as separate channels. The selection used to
14779
+ // be `semanticScoresAreTied ? or : and`, and semantic scores are never
14780
+ // all bit-identical under a real embedding model — so the OR set was
14781
+ // computed on every query and then discarded on every query.
14782
+ //
14783
+ // That made the AND clause the entire lexical channel, and for a query
14784
+ // phrased as prose the AND clause is empty: it demands one symbol
14785
+ // containing every surviving query word, function words included.
14786
+ // Nothing matches, so `ftsPart` was zero for every candidate and
14787
+ // "hybrid search" was pure vector search with a centrality nudge. The
14788
+ // docstring's BM25 evidence existed the whole time, in the OR set that
14789
+ // was thrown away (KNODIN-30).
14790
+ //
14791
+ // Fusing both rather than falling back from one to the other avoids a
14792
+ // discontinuity triggered by corpus contents rather than query intent:
14793
+ // with a fallback, one incidental symbol matching every word would flip
14794
+ // the channel from a broad candidate set to a single row, silently.
14795
+ //
14796
+ // `and ⊆ or` by construction — same tokens, narrower operator — so an
14797
+ // AND match collects BOTH terms while an OR-only match collects one.
14798
+ // The precision bonus falls out of that containment exactly, with no
14799
+ // extra machinery to express or tune.
14580
14800
  const lexical = lexicalRanks.get(repo.path);
14581
- const baseLexical = semanticScoresAreTied ? lexical?.or : lexical?.and;
14582
- const ftsMatches = [...new Set(baseLexical ?? [])].filter((id) => allowedIds.has(id));
14801
+ const strictMatches = [...new Set(lexical?.and ?? [])].filter((id) => allowedIds.has(id));
14802
+ const looseMatches = [...new Set(lexical?.or ?? [])]
14803
+ .filter((id) => allowedIds.has(id))
14804
+ .slice(0, LOOSE_LEXICAL_LIMIT);
14583
14805
  // Rank mappings for Reciprocal Rank Fusion (RRF)
14584
14806
  const semanticRankMap = new Map();
14585
14807
  const semanticById = new Map();
@@ -14587,19 +14809,57 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14587
14809
  semanticRankMap.set(item.id, idx + 1);
14588
14810
  semanticById.set(item.id, item);
14589
14811
  });
14590
- const ftsRankMap = new Map();
14591
- ftsMatches.forEach((id, idx) => {
14592
- ftsRankMap.set(id, idx + 1);
14812
+ const strictRankMap = new Map();
14813
+ strictMatches.forEach((id, idx) => {
14814
+ strictRankMap.set(id, idx + 1);
14593
14815
  });
14816
+ const looseRankMap = new Map();
14817
+ looseMatches.forEach((id, idx) => {
14818
+ looseRankMap.set(id, idx + 1);
14819
+ });
14820
+ // Rank the matched candidates that anything actually depends on, most
14821
+ // depended-upon first. Symbols with no inbound references are left out
14822
+ // entirely rather than ranked last — see CENTRALITY_RRF_WEIGHT.
14823
+ const centralityRankMap = new Map();
14824
+ {
14825
+ const connected = [];
14826
+ for (const id of new Set([
14827
+ ...semanticRankMap.keys(),
14828
+ ...strictRankMap.keys(),
14829
+ ...looseRankMap.keys(),
14830
+ ])) {
14831
+ const row = semanticById.get(id)?.row ?? snapshot.rowById.get(id);
14832
+ if (!row)
14833
+ continue;
14834
+ const inDegree = referenceDegree.get(`${formatPath(repo.path, row.filePath)}::${row.name}`)
14835
+ ?.inDegree ?? 0;
14836
+ if (inDegree > 0)
14837
+ connected.push({ id, inDegree });
14838
+ }
14839
+ connected.sort((a, b) => b.inDegree - a.inDegree || a.id - b.id);
14840
+ connected.forEach((item, idx) => {
14841
+ centralityRankMap.set(item.id, idx + 1);
14842
+ });
14843
+ }
14594
14844
  // Perform Reciprocal Rank Fusion (RRF)
14595
14845
  const topRrf = measurePerfPhaseSync("fusion", () => {
14596
- const allMatchedIds = new Set([...semanticRankMap.keys(), ...ftsRankMap.keys()]);
14846
+ const allMatchedIds = new Set([
14847
+ ...semanticRankMap.keys(),
14848
+ ...strictRankMap.keys(),
14849
+ ...looseRankMap.keys(),
14850
+ ]);
14597
14851
  const rrfResults = Array.from(allMatchedIds).map((id) => {
14598
14852
  const semRank = semanticRankMap.get(id);
14599
- const ftsRank = ftsRankMap.get(id);
14600
- const semPart = semRank !== undefined ? 1 / (60 + semRank) : 0;
14601
- const ftsPart = ftsRank !== undefined ? 1 / (60 + ftsRank) : 0;
14602
- return { id, rrfScore: semPart + ftsPart };
14853
+ const strictRank = strictRankMap.get(id);
14854
+ const looseRank = looseRankMap.get(id);
14855
+ const centralityRank = centralityRankMap.get(id);
14856
+ const semPart = semRank !== undefined ? 1 / (RRF_K + semRank) : 0;
14857
+ const strictPart = strictRank !== undefined ? 1 / (RRF_K + strictRank) : 0;
14858
+ const loosePart = looseRank !== undefined ? LOOSE_LEXICAL_RRF_WEIGHT * (1 / (RRF_K + looseRank)) : 0;
14859
+ const centralityPart = centralityRank !== undefined
14860
+ ? CENTRALITY_RRF_WEIGHT * (1 / (RRF_K + centralityRank))
14861
+ : 0;
14862
+ return { id, rrfScore: semPart + strictPart + loosePart + centralityPart };
14603
14863
  });
14604
14864
  // Sort by RRF score descending and take top N
14605
14865
  return rrfResults.sort((a, b) => b.rrfScore - a.rrfScore).slice(0, candidateLimit);
@@ -14775,6 +15035,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14775
15035
  ...(impactOptions.mode !== "file" ? ["impact"] : []),
14776
15036
  ]);
14777
15037
  let selectedTarget;
15038
+ let targetResolution;
14778
15039
  if (target && symbolPatterns.has(pattern)) {
14779
15040
  const resolved = resolveSymbolRows(db, primary.path, target, selector);
14780
15041
  if (resolved.ambiguity)
@@ -14804,6 +15065,26 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14804
15065
  ambiguity: resolved.ambiguity,
14805
15066
  };
14806
15067
  selectedTarget = resolved.selected;
15068
+ // Graph health and target resolution are separate axes. A healthy,
15069
+ // fresh graph answering `count: 0` for a misspelled symbol was
15070
+ // byte-identical to one answering for real dead code, and the
15071
+ // freshness stamp made the typo read as an authoritative negative
15072
+ // (KNODIN-28). Say which of the two happened.
15073
+ //
15074
+ // `resolveSymbolRows` only sees the primary database, so a symbol
15075
+ // defined in a federated repo must be checked before it is called
15076
+ // missing — otherwise cross-repo queries would report every target
15077
+ // as a typo.
15078
+ targetResolution = resolved.selected
15079
+ ? "resolved"
15080
+ : allRepos.some((r) => {
15081
+ const stmt = r.db.query("SELECT filePath FROM symbols WHERE name = ? LIMIT 1");
15082
+ const row = stmt.get(target);
15083
+ stmt.finalize();
15084
+ return row != null;
15085
+ })
15086
+ ? "resolved"
15087
+ : "not-found";
14807
15088
  }
14808
15089
  const finish = (rows, extra = {}) => {
14809
15090
  for (const row of rows) {
@@ -14822,6 +15103,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14822
15103
  count: rows.length,
14823
15104
  results: rows.slice(0, cap),
14824
15105
  ...(rows.length > cap ? { hasMore: true } : {}),
15106
+ ...(targetResolution ? { targetResolution } : {}),
14825
15107
  ...extra,
14826
15108
  };
14827
15109
  };
@@ -15004,26 +15286,41 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15004
15286
  const refs = pattern === "callers_of"
15005
15287
  ? findCallersFederated(allRepos, defRepo, targetName, 1, new Set(), defFile, selectedTarget?.identity ?? null)
15006
15288
  : findCalleesFederated(allRepos, defRepo, targetName, 1, new Set(), defFile, selectedTarget?.identity ?? null);
15007
- const seen = new Set();
15289
+ // One row per caller symbol, but carrying every call site it has.
15290
+ // Collapsing to the first line lost the rest and made `count`
15291
+ // read as a call-site count (KNODIN-29).
15292
+ const byCaller = new Map();
15008
15293
  const rows = [];
15294
+ let callSiteCount = 0;
15009
15295
  for (const r of refs) {
15010
15296
  const file = formatPath(r.repoPath || defRepo, r.filePath);
15011
15297
  const key = `${r.symbol}|${file}`;
15012
- if (seen.has(key))
15298
+ callSiteCount++;
15299
+ const existing = byCaller.get(key);
15300
+ if (existing) {
15301
+ if (r.lineNumber !== undefined && !existing.callSiteLines?.includes(r.lineNumber))
15302
+ existing.callSiteLines?.push(r.lineNumber);
15013
15303
  continue;
15014
- seen.add(key);
15304
+ }
15015
15305
  const rr = (r.repoPath ? allRepos.find((x) => x.path === r.repoPath) : primary)?.db
15016
15306
  .query("SELECT * FROM symbols WHERE name = ? AND filePath = ? LIMIT 1")
15017
15307
  .get(r.symbol, r.filePath);
15018
- rows.push({
15308
+ const row = {
15019
15309
  symbol: r.symbol,
15020
15310
  file,
15021
15311
  line: r.lineNumber,
15022
15312
  kind: r.kind ?? "call",
15313
+ callSiteLines: r.lineNumber !== undefined ? [r.lineNumber] : [],
15023
15314
  ...(rr ? { identity: symbolIdentity(r.repoPath || defRepo, rr) } : {}),
15024
- });
15315
+ };
15316
+ byCaller.set(key, row);
15317
+ rows.push(row);
15025
15318
  }
15026
- return finish(rows);
15319
+ for (const row of rows) {
15320
+ row.callSiteLines?.sort((a, b) => a - b);
15321
+ row.line = row.callSiteLines?.[0] ?? row.line;
15322
+ }
15323
+ return finish(rows, { callSiteCount });
15027
15324
  }
15028
15325
  case "imports_of": {
15029
15326
  const stmt = db.query("SELECT DISTINCT toFile, kind FROM dependencies WHERE fromFile = ?");
@@ -101,15 +101,21 @@ export function decorateGraphQueryResult(value, graphState = "healthy", freshnes
101
101
  };
102
102
  }
103
103
  const root = value;
104
- const noMatch = hasNoMatches(root) && root.ambiguity === undefined;
104
+ // An unresolved target is not a no-match: nothing was searched for. Calling
105
+ // it `no-match` is what let a misspelled symbol read as proof that nothing
106
+ // calls it (KNODIN-28). Ambiguity is exempted for the same reason.
107
+ const notFound = root.targetResolution === "not-found";
108
+ const noMatch = hasNoMatches(root) && root.ambiguity === undefined && !notFound;
109
+ const state = notFound ? "target-not-found" : noMatch ? "no-match" : graphState;
105
110
  return {
106
111
  ...root,
107
112
  ...(freshness ? { freshness } : {}),
113
+ ...(notFound && root.status === undefined ? { status: "target-not-found" } : {}),
108
114
  ...(noMatch && root.status === undefined ? { status: "no-match" } : {}),
109
115
  availability: {
110
- state: noMatch ? "no-match" : graphState,
116
+ state,
111
117
  graphState,
112
- outcome: noMatch ? "no-match" : "matches",
118
+ outcome: notFound ? "target-not-found" : noMatch ? "no-match" : "matches",
113
119
  },
114
120
  };
115
121
  }
package/dist/src/init.js CHANGED
@@ -1458,6 +1458,87 @@ export async function initializeRepository(repo, options) {
1458
1458
  lifecycleLease.release();
1459
1459
  }
1460
1460
  }
1461
+ /**
1462
+ * Rewrite lifecycle routing — the managed Git hooks and the background refresh
1463
+ * script — without reading or writing a single line of agent configuration.
1464
+ *
1465
+ * This exists because a team-integrated repository whose hooks had died had no
1466
+ * safe exit (KNODIN-31). `repair` fixes graph rows and leaves the dead indexer
1467
+ * dead, so the schema drifts again; `init` rewrites tracked `AGENTS.md` /
1468
+ * `GEMINI.md` / `.mcp.json`, which is churn every teammate diffs on a branch
1469
+ * that has nothing to do with it; and `configure --scope personal` strips the
1470
+ * shared block for everyone else on that branch. The one clean option,
1471
+ * `init --scope personal`, is precisely the one refused in that state.
1472
+ *
1473
+ * So fault and remedy are matched in scope: the fault is in the hooks'
1474
+ * contents, and this rewrites the hooks' contents. It deliberately does not
1475
+ * index, does not touch graph rows, and never enters the `configureIntegration`
1476
+ * block — a broken hook should not be able to change a scope preference. Run
1477
+ * plain `knodin repair` alongside it when graph content is also damaged.
1478
+ */
1479
+ export async function repairLifecycleRouting(repoPath, options) {
1480
+ const resolvedRepo = path.resolve(repoPath);
1481
+ if (runGit(resolvedRepo, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true") {
1482
+ throw new Error(`knodin repair --lifecycle: not a Git worktree: ${resolvedRepo}`);
1483
+ }
1484
+ const mirror = lookupMirror(resolvedRepo);
1485
+ if (mirror) {
1486
+ throw new Error(`knodin repair --lifecycle: ${resolvedRepo} is a read-only mirror of ${mirror.url}. Mirrors carry no lifecycle hooks by design, so there is no routing here to repair.`);
1487
+ }
1488
+ if (!path.isAbsolute(options.command[0])) {
1489
+ throw new Error("knodin repair --lifecycle: packaged runtime command must be an absolute path");
1490
+ }
1491
+ const lifecycleLease = acquireRepairLease(resolvedRepo, "init");
1492
+ try {
1493
+ const knodinHooksDir = path.join(resolvedRepo, ".knodin", "hooks");
1494
+ const hookInstallation = await prepareHookInstallation(resolvedRepo);
1495
+ const hooksDir = hookInstallation.hooksDir;
1496
+ await Promise.all([
1497
+ fs.promises.mkdir(knodinHooksDir, { recursive: true }),
1498
+ fs.promises.mkdir(hooksDir, { recursive: true }),
1499
+ ]);
1500
+ const backgroundPath = path.join(knodinHooksDir, "background-index.sh");
1501
+ await fs.promises.writeFile(backgroundPath, backgroundScript(options.command), {
1502
+ encoding: "utf-8",
1503
+ mode: 0o755,
1504
+ });
1505
+ await fs.promises.chmod(backgroundPath, 0o755);
1506
+ const hookManagerIntegration = installHookManagerIntegration(resolvedRepo);
1507
+ for (const hookName of HOOK_NAMES) {
1508
+ let preservedOriginal;
1509
+ if (hookInstallation.trackedHooksDir) {
1510
+ const relativeHook = `${hookInstallation.trackedHooksDir}/${hookName}`;
1511
+ const sourceHook = path.join(resolvedRepo, relativeHook);
1512
+ try {
1513
+ const stat = await fs.promises.stat(sourceHook);
1514
+ preservedOriginal =
1515
+ stat.isFile() && (stat.mode & 0o111) !== 0 ? `repo:${relativeHook}` : null;
1516
+ }
1517
+ catch {
1518
+ preservedOriginal = null;
1519
+ }
1520
+ }
1521
+ await installManagedHook(hooksDir, hookName, preservedOriginal);
1522
+ }
1523
+ for (const entry of await fs.promises.readdir(hooksDir, { withFileTypes: true })) {
1524
+ if (entry.isFile())
1525
+ await preventLefthookAutoInstall(path.join(hooksDir, entry.name));
1526
+ }
1527
+ const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath, lifecycleLease.token);
1528
+ await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), { force: true });
1529
+ return {
1530
+ backgroundIndexer: ".knodin/hooks/background-index.sh",
1531
+ hooksDirectory: path.relative(resolvedRepo, hooksDir) || ".",
1532
+ gitHooks: [...HOOK_NAMES],
1533
+ hookManagerIntegration,
1534
+ lifecycleRefresh,
1535
+ configurationChanges: [],
1536
+ };
1537
+ }
1538
+ finally {
1539
+ lifecycleLease.release();
1540
+ }
1541
+ }
1461
1542
  async function matchesManagedFile(filePath, expected, executable) {
1462
1543
  try {
1463
1544
  const [content, stat] = await Promise.all([
@@ -169,6 +169,41 @@ function recordedInterpreterIfMissing(backgroundScriptPath) {
169
169
  // wrong and the next environment without that fallback breaks silently.
170
170
  return recorded;
171
171
  }
172
+ /**
173
+ * The interpreter a pre-fallback generated script pins, when it pins one.
174
+ *
175
+ * Scripts written before the `KNODIN_NODE` fallback (EASFDC-8497) invoke an
176
+ * absolute interpreter path with nothing to fall back to. Homebrew deletes its
177
+ * Cellar directory on upgrade, so `.../Cellar/node/<version>/bin/node`
178
+ * evaporates and every background refresh afterwards dies with exit 127 — in
179
+ * the background, where the only trace is a log file nobody reads. The graph
180
+ * then drifts until someone happens to run `status`. Observed across three
181
+ * repositories on one machine from a single `brew upgrade` (KNODIN-32), so the
182
+ * blast radius is every checkout initialized before the fix, not one.
183
+ *
184
+ * `recordedInterpreterIfMissing` cannot see this: it keys off a `KNODIN_NODE=`
185
+ * line, and these scripts have none, so the most dangerous shape reads as
186
+ * healthy. Detection is the absence of that line in a script that is otherwise
187
+ * recognisably ours.
188
+ *
189
+ * Returns null when the script is current, unreadable, or shaped unfamiliarly.
190
+ * A script we cannot parse is not evidence of a pinned interpreter, and
191
+ * guessing would invent a failure rather than report one.
192
+ */
193
+ function pinnedInterpreterWithoutFallback(backgroundScriptPath) {
194
+ let contents;
195
+ try {
196
+ contents = fs.readFileSync(backgroundScriptPath, "utf8");
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ if (/^KNODIN_NODE=/m.test(contents))
202
+ return null;
203
+ if (!contents.includes("hook-refresh"))
204
+ return null;
205
+ return /'(\/[^']*\/bin\/node)'/.exec(contents)?.[1] ?? null;
206
+ }
172
207
  function readRefreshFailure(repo) {
173
208
  const failurePath = path.join(repo, ".knodin", "hooks", HOOK_FAILURE_FILE);
174
209
  if (!fs.existsSync(failurePath))
@@ -253,7 +288,15 @@ export function inspectLifecycleHealth(repoPath) {
253
288
  issues.push("background indexer is missing or not executable");
254
289
  const staleInterpreter = backgroundReady ? recordedInterpreterIfMissing(background) : null;
255
290
  if (staleInterpreter)
256
- issues.push(`background indexer records a Node interpreter that no longer exists (${staleInterpreter}); run \`knodin init\` to rewrite the hook`);
291
+ issues.push(`background indexer records a Node interpreter that no longer exists (${staleInterpreter}); run \`knodin repair --lifecycle\` to rewrite the hook`);
292
+ // Reported even while that interpreter still exists. The failure is latent
293
+ // by construction — the script works right up until the next `brew upgrade`
294
+ // deletes the path out from under it, and then fails silently in the
295
+ // background. Waiting for the breakage would mean reporting it only after
296
+ // the graph had already drifted (KNODIN-32).
297
+ const pinnedInterpreter = backgroundReady && !staleInterpreter ? pinnedInterpreterWithoutFallback(background) : null;
298
+ if (pinnedInterpreter)
299
+ issues.push(`background indexer predates the interpreter fallback and pins ${pinnedInterpreter} with no alternative; a Node upgrade will break refresh silently — run \`knodin repair --lifecycle\` to rewrite the hook`);
257
300
  const lastError = readRefreshFailure(repo);
258
301
  if (lastError)
259
302
  issues.push(`${lastError} See .knodin/indexer.log for details`);
@@ -299,7 +342,7 @@ export function attachLifecycleHealth(repo, graph) {
299
342
  },
300
343
  repairSteps: [
301
344
  ...graph.repairSteps,
302
- "Run `knodin init` to restore managed lifecycle hooks.",
345
+ "Run `knodin repair --lifecycle` to restore managed lifecycle hooks without touching agent configuration.",
303
346
  "Run `knodin status` again to verify graph and refresh-path health.",
304
347
  ],
305
348
  lifecycle,
@@ -312,7 +355,7 @@ export function attachRepairLifecycle(repo, result) {
312
355
  ...result,
313
356
  lifecycle,
314
357
  nextSteps: lifecycle.status === "degraded"
315
- ? ["Run `knodin init` to restore lifecycle routing, then `knodin status`."]
358
+ ? ["Run `knodin repair --lifecycle` to restore lifecycle routing, then `knodin status`."]
316
359
  : [],
317
360
  };
318
361
  }
@@ -23,6 +23,7 @@ export function parseRepairCliArgs(args) {
23
23
  let json = false;
24
24
  let jsonl = false;
25
25
  let plan = false;
26
+ let lifecycle = false;
26
27
  const seen = new Set();
27
28
  const markSeen = (flag) => {
28
29
  if (seen.has(flag))
@@ -42,6 +43,11 @@ export function parseRepairCliArgs(args) {
42
43
  plan = true;
43
44
  continue;
44
45
  }
46
+ if (argument === "--lifecycle") {
47
+ markSeen(argument);
48
+ lifecycle = true;
49
+ continue;
50
+ }
45
51
  if (argument === "--json" || argument === "--jsonl") {
46
52
  markSeen(argument);
47
53
  if (argument === "--json")
@@ -86,12 +92,18 @@ export function parseRepairCliArgs(args) {
86
92
  }
87
93
  if (json && jsonl)
88
94
  throw new Error("knodin repair: --json and --jsonl are mutually exclusive");
95
+ // `--plan` explains what a graph repair would do; a lifecycle repair does not
96
+ // touch the graph, so there is nothing for it to explain. Refusing beats
97
+ // printing a graph plan for a run that would not carry it out.
98
+ if (plan && lifecycle)
99
+ throw new Error("knodin repair: --plan and --lifecycle are mutually exclusive");
89
100
  return {
90
101
  progress,
91
102
  progressExplicit,
92
103
  progressIntervalMs,
93
104
  output: jsonl ? "jsonl" : json ? "json" : "human",
94
105
  plan,
106
+ lifecycle,
95
107
  };
96
108
  }
97
109
  export function createRepairPlan(health) {
@@ -1052,6 +1052,22 @@ function validateKnodinArgs(args) {
1052
1052
  if (args.client !== undefined &&
1053
1053
  !["claude", "codex", "gemini", "copilot", "antigravity"].includes(args.client))
1054
1054
  throw new Error("knodin: invalid client");
1055
+ // `auditRange`/`auditBase`/`auditHead` select a range of pull requests for
1056
+ // `prs`, and nothing else consumes them. `review` used to accept them and
1057
+ // silently review HEAD~1..HEAD instead, returning `base: "HEAD~1"` under a
1058
+ // full `staleness: fresh` envelope — so it did not merely fail to answer the
1059
+ // question asked, it confidently answered a different one, and a caller who
1060
+ // did not inspect `base` would read the result as authoritative for their
1061
+ // range (KNODIN-27). An unused-parameter error is always better than that.
1062
+ //
1063
+ // The review equivalent is `diffScope: "compare"` with `from`/`toRevision`,
1064
+ // which is what the CLI's `--scope compare --from X --to Y` maps to.
1065
+ const auditOnly = ["auditRange", "auditBase", "auditHead"].filter((name) => args[name] !== undefined);
1066
+ if (auditOnly.length > 0 && args.operation !== "prs")
1067
+ throw new Error(`knodin ${args.operation}: ${auditOnly.join(", ")} ${auditOnly.length === 1 ? "is" : "are"} accepted only by \`prs\`` +
1068
+ (args.operation === "review"
1069
+ ? '; to review a revision range pass diffScope: "compare" with `from` and `toRevision`'
1070
+ : ""));
1055
1071
  }
1056
1072
  function compactExplainSource(result) {
1057
1073
  const source = result.source
@@ -0,0 +1,281 @@
1
+ # knodin 0.12.0
2
+
3
+ Ten open defects worked as one batch, plus three more found while investigating
4
+ them. Nearly all are the same failure in different clothing: a result that is
5
+ *complete* and a result that is *empty* arriving in the same shape, so the reader
6
+ cannot tell which one they got. One is worse than that, and it goes first.
7
+
8
+ No schema change and no re-index. An earlier draft of this release carried both,
9
+ for an embedding change that measured well on the test embedder and turned out to
10
+ do nothing on the real model. That story is told below, because the way it was
11
+ nearly shipped is more instructive than the fix — and because the same mistake,
12
+ caught a second time, is what led to the retrieval benchmark this release adds.
13
+
14
+ ## `review` answered a different question and said nothing about it
15
+
16
+ The MCP gateway accepted `auditRange`, `auditBase` and `auditHead` on `review`,
17
+ discarded them, and reviewed `HEAD~1..HEAD` instead. The response reported
18
+ `base: HEAD~1` truthfully — but carried `staleness: fresh`, `availability:
19
+ healthy` and a full freshness envelope, so unless the caller happened to inspect
20
+ `base`, it read as authoritative for the range they had asked about.
21
+
22
+ Measured against a real branch, the difference was one changed file versus
23
+ twenty. That is not a near-miss; it is a review of the wrong thing, presented
24
+ with the same confidence as a review of the right thing. It was found while
25
+ running a product validation, where the conclusion would otherwise have been
26
+ "review does not catch this defect" — a false negative manufactured entirely out
27
+ of discarded input, and reported as a finding.
28
+
29
+ Those three parameters belong to `prs`, and only to `prs`; the schema had always
30
+ said so and nothing enforced it. They are now refused on every other operation,
31
+ and the refusal on `review` names the parameter that would have worked
32
+ (`diffScope: "compare"` with `from`/`toRevision`). An unused-parameter error is
33
+ always better than a confident answer to an unasked question.
34
+
35
+ ## `count: 0` could not tell a typo from dead code
36
+
37
+ On a healthy, fresh graph, `knodin query callers_of someMisspelling` returned
38
+ `count: 0`, `staleness: fresh`, exit 0 — byte-identical to the answer for a real
39
+ symbol that genuinely has no callers.
40
+
41
+ The uninitialized-graph case was fixed earlier and now fails loudly, which made
42
+ this one worse rather than better: before that fix an empty result at least felt
43
+ untrustworthy, and afterwards it arrived with a certificate. The freshness stamp
44
+ certifies the **graph**; it says nothing about whether the **target** resolved.
45
+ Those are separate axes and are now reported separately. A query for a symbol
46
+ that exists in no open repository answers `targetResolution: "not-found"`,
47
+ renders as `target-not-found` rather than `no-match`, and exits non-zero, since
48
+ a misspelling is operator error rather than a negative result. A real symbol
49
+ with no callers still answers `resolved` and exits 0 — that is the honest
50
+ negative, and it is now distinguishable from the typo.
51
+
52
+ This matters most for the symbol it is easiest to lose: a seam is *by
53
+ definition* a well-documented symbol with no callers yet, so `count: 0` was
54
+ destroying exactly the signal that would have said "use this one". One session
55
+ nearly hand-rolled a duplicate of a blessed seam for that reason.
56
+
57
+ ## `callers_of` counted callers and read as call sites
58
+
59
+ `callers_of` answers "which symbols call X", and its output — `count: N` above
60
+ one line per row — reads as "N call sites, here they are". When a function calls
61
+ the target twice, only the first line appeared and the count was lower than the
62
+ number of call sites. Ground truth of four calls reported as three.
63
+
64
+ Anyone sizing an edit from that count under-sizes it. Worse, the reporting
65
+ session initially scored a correct answer as a bug, because cross-checking with
66
+ grep found a call the tool had "missed" — it had not missed it, it had merged
67
+ it. Each row now carries `callSiteLines` with every line for that caller, and
68
+ the result carries `callSiteCount` alongside `count`, which is documented as
69
+ counting rows. Renaming the field alone would not have been enough: it would
70
+ have left the reader doing arithmetic they had no data for.
71
+
72
+ ## A team repo with dead hooks had no safe way out
73
+
74
+ Every exit mutated something. Plain `repair` fixed graph rows and left the
75
+ background indexer dead, so the schema drifted again. `init` rewrote tracked
76
+ `AGENTS.md`, `GEMINI.md` and `.mcp.json` — churn every teammate diffs, on a
77
+ branch that has nothing to do with it. `configure --scope personal` stripped the
78
+ shared block for everyone else. The one clean option, `init --scope personal`,
79
+ is precisely the one refused while tracked team integration is active.
80
+
81
+ `knodin repair --lifecycle` rewrites the managed Git hooks and the background
82
+ refresh script and touches nothing else — no indexing, no graph rows, and it
83
+ never enters the agent-configuration path at any scope. Fault and remedy now
84
+ match in scope: the fault is in the hooks' contents, so the hooks' contents are
85
+ what changes. Every remediation message that used to say "run `knodin init`" for
86
+ a lifecycle fault now points here instead.
87
+
88
+ This does not settle the separate question of whether a per-developer scope
89
+ preference should mutate team-shared tracked files. It removes the trap.
90
+
91
+ ## Hooks that predate the interpreter fallback are now named before they break
92
+
93
+ Scripts generated before the `KNODIN_NODE` fallback pin an absolute Homebrew
94
+ Cellar interpreter with nothing to fall back to. Homebrew deletes that directory
95
+ on upgrade, so every background refresh afterwards dies with exit 127 — in the
96
+ background, where the only trace is a log file — and the graph drifts until
97
+ someone happens to run `status`. One `brew upgrade` did this to three
98
+ repositories on one machine in a day.
99
+
100
+ The existing check could not see these at all: it keys off a `KNODIN_NODE=`
101
+ line, and these scripts have none, so the most dangerous shape read as healthy.
102
+ Health now reports a script that predates the fallback **while its interpreter
103
+ still exists**, because the whole point is that it is a time bomb rather than an
104
+ already-detonated one. A script it cannot recognise is still left alone; an
105
+ unparseable hook is not evidence of a pinned interpreter, and guessing would
106
+ invent a failure rather than report one.
107
+
108
+ ## Search ranks a depended-upon implementation above its own fresh duplicate
109
+
110
+ Semantic similarity alone ranked a minutes-old duplicate above the
111
+ implementation it duplicated. The duplicate is short and made almost entirely of
112
+ the query's own words; the canonical version is diluted by an actual body. The
113
+ searcher read their own new leaf as evidence that nothing existed.
114
+
115
+ Degree is now weighted into rank fusion, capped at about three rank positions.
116
+ It is deliberately a small weight, because the opposite failure is equally real
117
+ and lands on the *same symbols*: a seam is a documented symbol with no callers,
118
+ and a large centrality weight would bury seams to fix duplicates — trading one
119
+ silent miss for another. A connected implementation climbs past neighbours it
120
+ was scoring near; it does not win from anywhere. Symbols with no inbound
121
+ references are excluded from the centrality ranking rather than ranked last, so
122
+ an uncalled symbol is never penalised, it only forgoes a boost it has no
123
+ evidence for.
124
+
125
+ An accompanying change — bounding the source body inside the embedding budget —
126
+ was written, measured, justified in a comment, given a schema bump, and then
127
+ **reverted before release**. On the deterministic bag-of-words embedder the suite
128
+ runs, it raised the diluted implementation's similarity from 0.163 to 0.212. On
129
+ the real MiniLM model it does nothing: across two body shapes, three query
130
+ phrasings and both budgets, the duplicate wins all twelve times, and the cap
131
+ never moves the winner. Its apparent benefit came from the test fixture, which
132
+ emitted forty copies of one line — truncating identical lines removes a
133
+ redundancy bag-of-words punishes and a transformer largely ignores.
134
+
135
+ The general lesson is worth more than the change would have been: a short
136
+ document made almost entirely of query tokens beats a long one under cosine
137
+ similarity, and no reweighting of a single vector's inputs changes that. Graph
138
+ degree is the right remedy here precisely because it is not a similarity signal.
139
+
140
+ ## Prose queries got no lexical signal at all, and now do
141
+
142
+ A query phrased as prose did not surface the symbol whose docstring restates it;
143
+ symbols sharing a word or two in their *identifier* won instead. The ticket
144
+ framed this as docstring prose being under-weighted against code in the
145
+ embedding. That premise turned out to be false — for the reported symbol the
146
+ embedded text is already about 95% docstring, and it still lost.
147
+
148
+ The actual cause was in rank fusion. The lexical channel selected the AND-matched
149
+ set unless every semantic score was bit-identical, which never happens under a
150
+ real model, so the OR set was computed on every query and discarded on every
151
+ query. And for a query phrased as prose the AND clause is empty by construction:
152
+ it demands one symbol containing every surviving query word, function words
153
+ included. Nothing matches, so the lexical contribution was zero for every
154
+ candidate and "hybrid search" was pure vector search. The docstring's BM25
155
+ evidence existed the whole time, in the set being thrown away.
156
+
157
+ Both operators are now fused as separate channels, the partial one at 0.75 weight
158
+ and truncated to its top 30. Because the AND set is a subset of the OR set by
159
+ construction, an exact match collects both terms and a partial match collects
160
+ one — so the precision bonus falls out of that containment rather than needing
161
+ machinery of its own. The weight buys a property worth stating: lexical evidence
162
+ can rescue a symbol the embedder under-ranked, and cannot resurrect one the
163
+ embedder judged irrelevant.
164
+
165
+ A partial match must also clear a quorum: it has to echo at least two of the
166
+ query's words. A single incidental token is not evidence, and this index cannot
167
+ tell the difference unaided — FTS5 here has no stemming and does not split
168
+ identifiers, so for "session authentication and token validations" the class
169
+ that does the work matches *nothing* (its docstring says "sessions" and
170
+ "validation") while a handler containing "session" matches once. Without the
171
+ quorum that handler outranked the authenticator, and no weight fixed it: every
172
+ value from 0.75 down to 0.15 flipped the same way, because the problem was which
173
+ symbol earned credit rather than how much.
174
+
175
+ Measured on the new benchmark below: **MRR 0.5178 → 0.754, recall@10 0.80 →
176
+ 0.925**, with 19 of 40 queries improving, 4 regressing and 17 unchanged — a
177
+ two-sided exact sign test at p = 0.0026. Dev and test halves moved together,
178
+ which is what you want when nothing was tuned against the labels. The largest
179
+ gains were symbols that had been effectively unreachable: rank 24 → 1, 28 → 3,
180
+ 14 → 2.
181
+
182
+ ## Doc comments were being lost, and misattributed
183
+
184
+ Two defects in how a doc comment is matched to the thing it documents. Both fed
185
+ the embedding input and the FTS `summary` column, so both corrupted retrieval
186
+ directly — and they point in opposite directions, which is why neither showed up
187
+ as an obvious symptom.
188
+
189
+ **Multi-line `//` comments kept only their last line.** tree-sitter gives each
190
+ `//` line its own node and the extractor read a single previous sibling, so the
191
+ loop meant to rejoin lines could never fire for this style. The first line of a
192
+ doc comment is usually the sentence saying what the thing is for — exactly what a
193
+ capability question matches on — so this silently removed the most useful text
194
+ and left a grammatical fragment that still looked like a well-formed summary.
195
+ Block comments are one node and were never affected, which is why the damage
196
+ stayed invisible in codebases that prefer them.
197
+
198
+ **And a symbol with no doc comment adopted its neighbour's.** The sibling walk
199
+ stepped over as many as five non-comment nodes hunting for a comment, so prose
200
+ written about one symbol was embedded against another. Measured on this
201
+ repository before the fix: **857 of 1,329 documented symbols under `src/` — 64% —
202
+ shared their summary text with another symbol**, and spot checks showed plain
203
+ misattribution rather than genuine repetition, including an interface carrying a
204
+ neighbouring function's docstring. After the fix, 718 symbols are documented and
205
+ **none** share text.
206
+
207
+ That second one is the worse of the pair. Absent prose merely loses a hit;
208
+ misattributed prose manufactures a confident wrong one.
209
+
210
+ A related gap is recorded but not fixed: a decorated declaration still never
211
+ receives its doc comment, because the decorator is a sibling of the declaration
212
+ but a child of the exporting statement. It was verified to predate both fixes and
213
+ is pinned by a characterization test.
214
+
215
+ ## A benchmark for retrieval quality, and why it could not be a test
216
+
217
+ `npm run bench:c103` measures whether a capability question phrased as prose
218
+ finds the symbol implementing it, through the real ranker and the real embedding
219
+ model, over knodin's own source at a pinned commit. Forty hand-written queries,
220
+ 15 dev / 25 test, MRR primary.
221
+
222
+ It exists as an opt-in benchmark rather than a spec because the fast suite
223
+ installs a deterministic bag-of-words embedder, and that embedder produces the
224
+ **opposite** answer on exactly this case: an identifier match beats a docstring
225
+ match on it, while the docstring wins by 2.75x on the real model. Two ranking
226
+ assertions written for this release were deleted for pinning that inversion. A
227
+ test that asserts the reverse of production is worse than no test — it fails
228
+ changes that help users and passes changes that hurt them.
229
+
230
+ So the fast suite now asserts only ranking properties no choice of embedder can
231
+ change, which in practice means rank arithmetic over graph structure, and
232
+ retrieval quality is measured where it can be measured honestly. The existing
233
+ `c86` evaluation could not host this: it ranks raw text by exact cosine, never
234
+ touching fusion or the ranking weights, and is saturated at recall@5 = 1.0.
235
+
236
+ ## What is not fixed
237
+
238
+ **Mean-pooling dilution inside long docstrings.** Only one sentence of a
239
+ multi-sentence docstring may be on topic, and averaged over the whole comment it
240
+ becomes a minority of the vector while a short, entirely-on-topic competitor does
241
+ not. Splitting docstrings into sentence chunks and max-pooling was prototyped
242
+ against the real model and moves the reported symbol from rank 4 of 5 to rank 2 —
243
+ but it needs roughly 1.5x the vectors, a composite primary key, and a re-embed
244
+ migration.
245
+
246
+ It is deliberately **not** in this release. The lexical fix above addressed the
247
+ same failure for no migration at all, and the benchmark now says prose retrieval
248
+ sits at MRR 0.754 with recall@10 of 0.95. Chunking should be re-measured against
249
+ that new baseline before anyone pays for it — which is the whole reason the
250
+ benchmark was built first.
251
+
252
+ Worth stating plainly: the benchmark corpus is knodin's own source, and the
253
+ defect was reported against a different codebase. The improvement is real and
254
+ significant here; it has not been demonstrated there.
255
+
256
+ ## Smaller things
257
+
258
+ `repair` now seeds from an indexed sibling worktree when there is **no** database
259
+ at all, reflink-copying a baseline and reconciling only the differing paths
260
+ instead of rebuilding the corpus from scratch. A database that exists but is
261
+ damaged is deliberately excluded — that is the case `repair` exists for, and
262
+ seeding it would replace a verifiable local rebuild with another checkout's
263
+ state.
264
+
265
+ `readGitHeadFast` is deleted. Its caller invoked it only when `.git` was absent,
266
+ which was its own first bail-out, so the two conditions were exactly
267
+ anti-correlated and the function could only ever return `null` — making the
268
+ `&& headUnchanged` term it fed inert. Coverage confirmed zero hits across the
269
+ whole suite. Git history settled which side was the bug: the fast HEAD read
270
+ landed first, and the later `lease = 0` for git-backed repositories superseded
271
+ it.
272
+
273
+ Two timing-sensitive specs no longer produce false pre-push failures. Both had
274
+ been widened only for instrumented coverage runs, on the theory that
275
+ instrumentation caused the contention. It does not — the failures reproduce on
276
+ ordinary uninstrumented runs under host load — and the uninstrumented run is
277
+ exactly the one developers gate on. The budgets are now unconditional, matching
278
+ the correction already recorded in `SUBPROCESS_BUDGET_MS`. A timeout that fires
279
+ on a loaded machine reports a failure that did not happen, and a gate whose
280
+ failures have to be re-run in isolation before they can be believed is not
281
+ doing its job.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "knodin": {
5
5
  "compatibility": "breaking"
6
6
  },
@@ -64,6 +64,7 @@
64
64
  "docs/releases/0.10.7.md",
65
65
  "docs/releases/0.10.8.md",
66
66
  "docs/releases/0.11.0.md",
67
+ "docs/releases/0.12.0.md",
67
68
  "docs/releases/0.3.0.md",
68
69
  "docs/releases/0.4.0.md",
69
70
  "docs/releases/0.4.1.md",
@@ -122,6 +123,7 @@
122
123
  "check:hygiene": "node scripts/check-repository-hygiene.mjs",
123
124
  "check:roadmap": "tsx scripts/check-roadmap.ts",
124
125
  "verify:c79": "tsx scripts/verify-c79.ts",
126
+ "bench:c103": "tsx benchmarks/evaluations/c103-prose-retrieval/runner.ts",
125
127
  "bench:c80": "tsx benchmarks/evaluations/c80-structural-routing/runner.ts",
126
128
  "verify:c80": "tsx scripts/verify-c80.ts",
127
129
  "bench:c81": "tsx benchmarks/evaluations/c81-powered-benchmark/runner.ts",