knodin 0.12.0 → 0.12.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
@@ -195,8 +195,15 @@ function formatLifecycleLine(lifecycle, isMirror) {
195
195
  return "";
196
196
  if (isMirror)
197
197
  return "Hooks: not applicable; a mirror is refreshed explicitly, not by Git events.\n";
198
- if (lifecycle.status === "healthy")
199
- return "Hooks: installed and executable.\n";
198
+ if (lifecycle.status === "healthy") {
199
+ // A silent self-heal would leave the user unable to explain why the hook's
200
+ // interpreter changed; a degraded report for a fixed condition would cry
201
+ // wolf. Say what happened, once, on the healthy line.
202
+ const heal = lifecycle.interpreterSelfHeal;
203
+ return heal
204
+ ? `Hooks: installed and executable; background indexer interpreter self-healed (${heal.from} -> ${heal.to}).\n`
205
+ : "Hooks: installed and executable.\n";
206
+ }
200
207
  const issue = lifecycle.issues[0] ?? "refresh capability is not verified";
201
208
  return `Lifecycle refresh: ${lifecycle.status}; ${issue}. Run \`knodin repair --lifecycle\`.\n`;
202
209
  }
@@ -449,24 +456,34 @@ function formatStatusHuman(result) {
449
456
  // led with an arbitrary source filename, so an absent graph read as damage
450
457
  // proportional to repository size. `missing.files` still carries the repair
451
458
  // worklist — only the count and the headline shown to a human change here.
452
- const outstanding = result.coverage.countsUnknown
453
- ? result.missing.records.length
454
- : result.missing.files.length + result.missing.records.length;
455
- const firstIssue = result.coverage.countsUnknown
456
- ? result.missing.records[0]
457
- : (result.missing.files[0] ?? result.missing.records[0]);
458
- const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
459
+ const issueItems = result.coverage.countsUnknown
460
+ ? result.missing.records
461
+ : [...result.missing.files, ...result.missing.records];
462
+ const outstanding = issueItems.length;
463
+ // One issue keeps the established single-line shape; more are listed bounded
464
+ // rather than hidden behind "First issue" — a report that names one problem
465
+ // out of several sends the user to fix it and then surprises them with the
466
+ // rest, one status invocation at a time (KNODIN-38).
467
+ const shownIssues = issueItems.slice(0, 5);
468
+ const hiddenIssues = outstanding - shownIssues.length;
469
+ const detail = shownIssues.length === 0
470
+ ? ""
471
+ : shownIssues.length === 1
472
+ ? ` First issue: ${shownIssues[0]}.`
473
+ : ` Issues: ${shownIssues.join("; ")}${hiddenIssues > 0 ? `; +${hiddenIssues} more (see --json)` : ""}.`;
459
474
  // A linked worktree with no database is the one case where `repair` is both
460
475
  // the wrong first step and the expensive one: `init` seeds from an indexed
461
476
  // sibling and reconciles only what differs, while `repair` builds from
462
477
  // scratch. The engine has already worked out that this is that case, so
463
478
  // defer to the step it wrote rather than recomputing the judgement here.
464
479
  const worktreeStep = result.repairSteps?.find((step) => step.includes("per-worktree"));
465
- const repairCommand = worktreeStep ??
466
- (result.lifecycle?.status === "degraded" &&
467
- result.missing.records.every((record) => result.lifecycle?.issues.includes(record))
468
- ? "Run `knodin init`."
469
- : "Run `knodin repair`.");
480
+ // Lifecycle-only damage takes `repair --lifecycle`, not `init`: the graph is
481
+ // healthy, and the engine's own repairSteps already say so — prescribing
482
+ // `init` here contradicted the lifecycle line printed directly beneath it.
483
+ const lifecycleOnly = result.lifecycle?.status === "degraded" &&
484
+ (result.coverage.countsUnknown || result.missing.files.length === 0) &&
485
+ result.missing.records.every((record) => result.lifecycle?.issues.includes(record));
486
+ const repairCommand = worktreeStep ?? (lifecycleOnly ? "Run `knodin repair --lifecycle`." : "Run `knodin repair`.");
470
487
  return `Graph or lifecycle needs repair: ${outstanding} issue(s) found (${coverage}).${detail} ${repairCommand}\n${lifecycleLine}${integrationLine}`;
471
488
  }
472
489
  function humanLabel(key) {
@@ -1867,13 +1884,20 @@ async function main() {
1867
1884
  if (client !== undefined &&
1868
1885
  !["claude", "codex", "gemini", "copilot", "antigravity"].includes(client))
1869
1886
  throw new Error("knodin doctor: --client must be claude, codex, gemini, copilot, or antigravity");
1870
- const unsupported = rest.filter((argument, index) => argument !== "--client" && rest[index - 1] !== "--client");
1887
+ const unsupported = rest.filter((argument, index) => argument !== "--client" && argument !== "--deep" && rest[index - 1] !== "--client");
1871
1888
  if (unsupported.length > 0)
1872
1889
  throw new Error(`knodin doctor: unknown option ${unsupported[0]}`);
1873
1890
  const diagnosis = await diagnoseInstallation(repo, {
1874
1891
  currentVersion: KNODIN_VERSION,
1875
1892
  runtimeCommand: [...runtimeCommand, "serve"],
1876
- graph: await engine.status(repo, { audit: "deep" }),
1893
+ // Adaptive, like `status`: the persisted-audit path runs the same
1894
+ // drift probe, so a stale graph still reports stale, while a forced
1895
+ // deep audit on a very large repository cost doctor two full stat
1896
+ // sweeps every run (KNODIN-39). `--deep` keeps the exact audit
1897
+ // available on demand.
1898
+ graph: await engine.status(repo, {
1899
+ audit: rest.includes("--deep") ? "deep" : "adaptive",
1900
+ }),
1877
1901
  client,
1878
1902
  });
1879
1903
  const manager = diagnosis.manager.name;
@@ -1884,7 +1908,23 @@ async function main() {
1884
1908
  : "unknown",
1885
1909
  env: process.env,
1886
1910
  });
1887
- result = diagnosis;
1911
+ // One MCP probe backs every client row; the human listing printed the
1912
+ // identical initialize/toolsList block five times, reading as five
1913
+ // independent server checks (KNODIN-40). JSON keeps the per-client
1914
+ // shape for compatibility.
1915
+ const agents = diagnosis.agents;
1916
+ result = jsonOutput
1917
+ ? diagnosis
1918
+ : {
1919
+ ...diagnosis,
1920
+ agents: {
1921
+ ...agents,
1922
+ clients: agents.clients?.map((clientRow) => ({
1923
+ ...clientRow,
1924
+ server: "shared MCP probe; see the mcp section",
1925
+ })),
1926
+ },
1927
+ };
1888
1928
  break;
1889
1929
  }
1890
1930
  case "system": {
@@ -355,12 +355,19 @@ function parseRetentionReceipt(value) {
355
355
  }
356
356
  function readInstalledRetention(options) {
357
357
  const locations = homes(options);
358
+ const receiptPath = path.join(locations.stateRoot, "receipt.json");
358
359
  const policy = parseRetentionPolicy(readJson(locations.policy));
359
- const receipt = parseRetentionReceipt(readJson(path.join(locations.stateRoot, "receipt.json")));
360
+ const receipt = parseRetentionReceipt(readJson(receiptPath));
361
+ // A machine where NEITHER file exists never had retention installed; that is
362
+ // a clean state, not a broken one, and reporting "missing-or-invalid" for it
363
+ // taught every fresh doctor run to cry attention-required (KNODIN-40). Any
364
+ // on-disk trace — even an unparseable file — is installation evidence and
365
+ // keeps the strict issue reporting.
366
+ const installEvidence = fs.existsSync(locations.policy) || fs.existsSync(receiptPath);
360
367
  const issues = [];
361
- if (!policy)
368
+ if (installEvidence && !policy)
362
369
  issues.push("policy-missing-or-invalid");
363
- if (!receipt)
370
+ if (installEvidence && !receipt)
364
371
  issues.push("receipt-missing-or-invalid");
365
372
  if (policy && receipt && digest(`${JSON.stringify(policy, null, 2)}\n`) !== receipt.policyDigest)
366
373
  issues.push("policy-receipt-mismatch");
@@ -373,7 +380,7 @@ function readInstalledRetention(options) {
373
380
  issues.push(`scheduler-missing: ${artifact.path}`);
374
381
  }
375
382
  }
376
- return { policy, receipt, issues };
383
+ return { policy, receipt, issues, installEvidence };
377
384
  }
378
385
  function commandRunner(command, args) {
379
386
  const result = childProcess.spawnSync(command, args, { encoding: "utf8" });
@@ -571,11 +578,18 @@ export function installBackupRetention(roots, options = {}) {
571
578
  }
572
579
  export function retentionStatus(options = {}) {
573
580
  const locations = homes(options);
574
- const { policy, receipt, issues } = readInstalledRetention(options);
581
+ const { policy, receipt, issues, installEvidence } = readInstalledRetention(options);
575
582
  const lastRun = readJson(path.join(locations.stateRoot, "last-run.json"));
576
583
  return {
577
584
  schemaVersion: 1,
578
- status: issues.length === 0 ? "healthy" : policy || receipt ? "attention-required" : "not-installed",
585
+ // Keyed on on-disk evidence rather than parse success: a never-installed
586
+ // machine is "not-installed" with no issues, while an installed-but-broken
587
+ // state (either file present, parseable or not) keeps attention-required.
588
+ status: !installEvidence && issues.length === 0
589
+ ? "not-installed"
590
+ : issues.length === 0
591
+ ? "healthy"
592
+ : "attention-required",
579
593
  installed: Boolean(policy && receipt),
580
594
  policy,
581
595
  receipt,
@@ -171,15 +171,68 @@ function managedConfigurationRemediation(repo, run) {
171
171
  paths.push(relative);
172
172
  }
173
173
  }
174
+ // One `ls-files` and one `diff` for every managed path together: the previous
175
+ // per-path pair spawned ~2 git processes per entry, and each `git diff` must
176
+ // load and refresh the repository index — seconds apiece on a 900k-file
177
+ // checkout, for identical answers (KNODIN-39).
178
+ const trackedResult = run("git", ["-C", repo, "ls-files", "-z", "--", ...paths]);
179
+ const trackedFiles = trackedResult.status === 0 ? trackedResult.stdout.split("\0").filter(Boolean) : [];
180
+ // A managed DIRECTORY (a receipted skill dir) never appears in ls-files
181
+ // output itself — only files under it do — so membership alone would read
182
+ // every tracked skill dir as untracked, matching neither the old
183
+ // `--error-unmatch` semantics nor reality (review on KNODIN-39). The output
184
+ // is already limited to the managed pathspecs, so the prefix scan is tiny.
185
+ const isTrackedManaged = (relative) => trackedFiles.includes(relative) || trackedFiles.some((file) => file.startsWith(`${relative}/`));
186
+ const trackedManaged = paths.filter(isTrackedManaged);
187
+ // Ignored paths need no "add to .git/info/exclude" advice: the instruction
188
+ // previously fired for every present, untracked, knodin-looking file whether
189
+ // or not the exclude entry already existed (KNODIN-40). One batched
190
+ // check-ignore answers for all of them; a non-zero status just means none
191
+ // are ignored.
192
+ const ignoredPaths = new Set();
193
+ const untrackedPresent = paths.filter((relative) => !isTrackedManaged(relative) && fs.existsSync(path.join(repo, relative)));
194
+ if (untrackedPresent.length > 0) {
195
+ const checkIgnore = run("git", ["-C", repo, "check-ignore", "-z", "--stdin"], {
196
+ input: `${untrackedPresent.join("\0")}\0`,
197
+ });
198
+ for (const relative of checkIgnore.stdout.split("\0").filter(Boolean))
199
+ ignoredPaths.add(relative);
200
+ }
201
+ // Guarding on trackedManaged (not the raw ls-files output) matters: with no
202
+ // tracked managed paths, `git diff --` with an empty pathspec list would
203
+ // diff the entire repository (review on KNODIN-39).
204
+ const diffSections = [];
205
+ if (trackedManaged.length > 0) {
206
+ const batchedDiff = run("git", [
207
+ "-C",
208
+ repo,
209
+ "diff",
210
+ "--no-ext-diff",
211
+ "--unified=3",
212
+ "--",
213
+ ...trackedManaged,
214
+ ]);
215
+ if (batchedDiff.status === 0 && batchedDiff.stdout) {
216
+ for (const section of batchedDiff.stdout.split(/^(?=diff --git )/m)) {
217
+ const header = /^diff --git a\/(.+) b\//.exec(section);
218
+ if (header?.[1])
219
+ diffSections.push({ file: header[1], section });
220
+ }
221
+ }
222
+ }
223
+ // A managed directory owns every section for files beneath it; a managed
224
+ // file owns only its own. This mirrors what the old per-path `git diff -- p`
225
+ // returned for each shape.
226
+ const diffFor = (relative) => diffSections
227
+ .filter(({ file }) => file === relative || file.startsWith(`${relative}/`))
228
+ .map(({ section }) => section)
229
+ .join("");
174
230
  return paths
175
231
  .map((relative) => {
176
232
  const absolute = path.join(repo, relative);
177
233
  const present = fs.existsSync(absolute);
178
- const trackedResult = run("git", ["-C", repo, "ls-files", "--error-unmatch", "--", relative]);
179
- const tracked = trackedResult.status === 0;
180
- const diffResult = tracked
181
- ? run("git", ["-C", repo, "diff", "--no-ext-diff", "--unified=3", "--", relative])
182
- : { status: 0, stdout: "", stderr: "" };
234
+ const tracked = isTrackedManaged(relative);
235
+ const diffResult = { status: 0, stdout: diffFor(relative), stderr: "" };
183
236
  const receipted = fs.existsSync(path.join(absolute, ".knodin-managed.json"));
184
237
  let entryOwned = false;
185
238
  if (present && !receipted) {
@@ -195,7 +248,7 @@ function managedConfigurationRemediation(repo, run) {
195
248
  instructions.push(`Review the diff, then restore manually with: git restore --source=HEAD -- ${relative}`);
196
249
  if (!tracked && receipted)
197
250
  instructions.push(`Review the Knodin receipt, then remove manually if unwanted: ${relative}`);
198
- if (!tracked && present && (receipted || entryOwned))
251
+ if (!tracked && present && (receipted || entryOwned) && !ignoredPaths.has(relative))
199
252
  instructions.push(`To retain it locally without committing, add ${relative} to .git/info/exclude manually.`);
200
253
  return {
201
254
  path: relative,
@@ -1728,6 +1728,34 @@ function createLanguageMemo() {
1728
1728
  * `isLineComment` below, which also requires the `//` prefix.
1729
1729
  */
1730
1730
  const LINE_COMMENT_NODE_TYPES = new Set(["comment", "line_comment", "hash_comment"]);
1731
+ /**
1732
+ * Repo-relative files that reference `symbol` and are test files, read from raw
1733
+ * reference rows rather than from resolved callers.
1734
+ *
1735
+ * Resolved callers cannot answer this. A call inside an anonymous callback --
1736
+ * `it("...", () => { target() })`, which is how essentially all test code is
1737
+ * written -- has no enclosing NAMED symbol, so it is stored with a null
1738
+ * `callerSymbol` and every caller-resolution path drops it. Measured on this
1739
+ * repository: 29,158 of 31,467 references from test files (92.7%) have a null
1740
+ * caller, against 390 of 18,399 (2.1%) from production files. So a coverage
1741
+ * question answered from callers is roughly 93% blind in exactly the files it is
1742
+ * asking about, and reported `untested: true` for 642 symbols that have tests
1743
+ * (KNODIN-36).
1744
+ *
1745
+ * The reference row itself carries `callerFile`, which is all this question
1746
+ * needs. Attributing the call to the file instead would be the other repair, and
1747
+ * `call-graph.spec.ts` deliberately forbids it: a file path must never appear as
1748
+ * a caller. So the fix belongs here, at the question, not in the graph.
1749
+ *
1750
+ * Shared by `explain`'s `untested`, `tests_for`, and `detectKnowledgeGaps` so the
1751
+ * definition of "covered by a test" cannot drift between them.
1752
+ */
1753
+ function testCallerFilesFor(db, symbol, relativeFile) {
1754
+ const statement = db.query('SELECT DISTINCT callerFile FROM "references" WHERE calleeSymbol = ? AND (calleeFile = ? OR calleeFile IS NULL)');
1755
+ const rows = statement.all(symbol, relativeFile);
1756
+ statement.finalize();
1757
+ return rows.map((row) => row.callerFile).filter((file) => isTestFilePath(file));
1758
+ }
1731
1759
  /** Clean up and format comment/docstring blocks in JavaScript/TypeScript. */
1732
1760
  function getPrecedingComment(node) {
1733
1761
  let target = node;
@@ -8199,7 +8227,7 @@ const freshnessChecks = new Map();
8199
8227
  * Work counters for the guard. Tests assert its cost in *invocations*, which is
8200
8228
  * deterministic, rather than in wall-clock milliseconds, which is flaky.
8201
8229
  */
8202
- const freshnessStats = { probes: 0, reconciles: 0, cacheHits: 0 };
8230
+ const freshnessStats = { probes: 0, reconciles: 0, cacheHits: 0, probeOverflows: 0 };
8203
8231
  /**
8204
8232
  * Paths the live watcher for `repoPath` currently has registered, flattened to
8205
8233
  * repo-relative form. Empty when no watcher is running (test mode, or after
@@ -8270,6 +8298,7 @@ export function resetFreshnessStats() {
8270
8298
  freshnessStats.probes = 0;
8271
8299
  freshnessStats.reconciles = 0;
8272
8300
  freshnessStats.cacheHits = 0;
8301
+ freshnessStats.probeOverflows = 0;
8273
8302
  freshnessProbes.clear();
8274
8303
  }
8275
8304
  /**
@@ -8288,10 +8317,19 @@ function gitWorkTreeProbe(repoPath) {
8288
8317
  cwd: repoPath,
8289
8318
  encoding: "utf-8",
8290
8319
  stdio: ["pipe", "pipe", "ignore"],
8320
+ // Node's default 1 MB maxBuffer holds only a few thousand dirty
8321
+ // porcelain lines; a large dirty tree then threw here and silently
8322
+ // demoted the probe to the bounded mtime fallback (KNODIN-39).
8323
+ maxBuffer: 64 * 1024 * 1024,
8291
8324
  })
8292
8325
  .split("\n");
8293
8326
  }
8294
- catch {
8327
+ catch (error) {
8328
+ // An overflow is a degradation worth attributing — the fallback path is
8329
+ // bounded and can only answer "unknown" — so it is counted where the perf
8330
+ // harness and tests can see it, unlike a plain not-a-git-repo failure.
8331
+ if (error.code === "ENOBUFS")
8332
+ freshnessStats.probeOverflows += 1;
8295
8333
  return null;
8296
8334
  }
8297
8335
  const head = lines[0]?.trim();
@@ -12449,7 +12487,20 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
12449
12487
  })
12450
12488
  : [];
12451
12489
  const allBlastFiles = Array.from(new Set(allCallers.map((c) => c.filePath)));
12452
- const untested = !allCallers.some((c) => isTestFilePath(c.filePath));
12490
+ // Asked of raw reference rows, not of resolved callers: a test that calls
12491
+ // this symbol from inside an anonymous `it(...)` callback has no caller
12492
+ // symbol and is invisible to `allCallers` (KNODIN-36). `untested: true`
12493
+ // is a positive assertion with no zero-count to invite doubt, so it has
12494
+ // to be answered from the evidence that actually exists.
12495
+ // Three values, not two. Without the defining repository's database this
12496
+ // question cannot be answered, and the previous fallback -- guessing from
12497
+ // resolved callers -- is precisely the collapse that made this field
12498
+ // wrong in the first place: it turned "cannot tell" into a confident
12499
+ // `true`. `untested` is now simply absent when the evidence is absent,
12500
+ // so a caller reading it gets a fact or nothing, never a guess.
12501
+ const untested = targetDb
12502
+ ? testCallerFilesFor(targetDb, primaryDef.name, primaryDef.filePath).length === 0
12503
+ : undefined;
12453
12504
  const minimal = detailLevel === "minimal";
12454
12505
  const cap = minimal ? 25 : 200;
12455
12506
  const truncated = allCallers.length > cap ||
@@ -15459,6 +15510,60 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15459
15510
  seen.add(key);
15460
15511
  rows.push({ symbol: r.symbol, file, line: r.lineNumber });
15461
15512
  }
15513
+ // Resolved callers miss almost every real test. A call inside an
15514
+ // anonymous `it(...)` callback has no enclosing named symbol, so it
15515
+ // carries a null `callerSymbol` and `findCallersFederated` drops it
15516
+ // (92.7% of this repository's test-file references, against 2.1% of
15517
+ // production ones). Reading the raw rows recovers them (KNODIN-36).
15518
+ //
15519
+ // These rows carry no caller name because there genuinely is none,
15520
+ // and inventing one -- the file path, or the enclosing `describe`
15521
+ // title -- would put a non-symbol in a `symbol` field that
15522
+ // `call-graph.spec.ts` explicitly forbids. `symbol` is therefore
15523
+ // omitted and the file and line carry the answer, which is what the
15524
+ // question asked for anyway: which test covers this.
15525
+ for (const repo of allRepos) {
15526
+ if (defFile && repo.path !== defRepo)
15527
+ continue;
15528
+ // MIN(line) grouped by file, not DISTINCT(file, line). The rows are
15529
+ // deduped by file below, so a plain DISTINCT would leave SQLite free
15530
+ // to hand back whichever line its query plan reached first when a
15531
+ // test file references the symbol more than once -- a value that can
15532
+ // change between runs and plans, which is a flaky test waiting to be
15533
+ // written. The first reference is both stable and the more useful
15534
+ // one to report.
15535
+ // With no defining file the file filter is dropped rather than bound
15536
+ // to "", which would degrade the clause to `calleeFile IS NULL` and
15537
+ // silently discard every reference that does carry a file -- turning
15538
+ // "I do not know where this is defined" into "it is defined nowhere",
15539
+ // and under-reporting coverage for exactly the symbols we know least
15540
+ // about (ADR 008). findCallersFederated omits the filter in the same
15541
+ // situation; these two must not disagree.
15542
+ const anonymous = defFile
15543
+ ? (() => {
15544
+ const stmt = repo.db.query('SELECT callerFile, MIN(line) AS line FROM "references" WHERE calleeSymbol = ? AND (calleeFile = ? OR calleeFile IS NULL) AND callerSymbol IS NULL GROUP BY callerFile ORDER BY callerFile');
15545
+ const out = stmt.all(target, defFile);
15546
+ stmt.finalize();
15547
+ return out;
15548
+ })()
15549
+ : (() => {
15550
+ const stmt = repo.db.query('SELECT callerFile, MIN(line) AS line FROM "references" WHERE calleeSymbol = ? AND callerSymbol IS NULL GROUP BY callerFile ORDER BY callerFile');
15551
+ const out = stmt.all(target);
15552
+ stmt.finalize();
15553
+ return out;
15554
+ })();
15555
+ for (const row of anonymous) {
15556
+ const file = formatPath(repo.path, row.callerFile);
15557
+ if (!isTestFilePath(file))
15558
+ continue;
15559
+ const key = `|${file}`;
15560
+ if (seen.has(key))
15561
+ continue;
15562
+ seen.add(key);
15563
+ rows.push({ file, line: row.line });
15564
+ }
15565
+ }
15566
+ rows.sort((a, b) => (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0));
15462
15567
  return finish(rows);
15463
15568
  }
15464
15569
  case "rename_preview": {
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 { stableInterpreterPath } from "./node-runtime.js";
10
+ import { stableInterpreterCandidates } from "./node-runtime.js";
11
11
  import { acquireRepairLease, LIFECYCLE_LEASE_TOKEN_ENV } from "./repair-lease.js";
12
12
  import { installKnodinSkills, removeKnodinSkills } from "./skill-management.js";
13
13
  import { registerInitializedWorktree } from "./worktree-lifecycle.js";
@@ -434,18 +434,33 @@ function backgroundScript(command) {
434
434
  // silently switch which runtime executes, which is its own bug.
435
435
  const [interpreter, ...rest] = command;
436
436
  const invocation = ['"$KNODIN_NODE"', ...rest.map(shellQuote)].join(" ");
437
- // Recorded through `stableInterpreterPath` so a Homebrew interpreter is
437
+ // Recorded through `stableInterpreterCandidates` so a Homebrew interpreter is
438
438
  // written as its version-stable `opt` path rather than the versioned Cellar
439
- // path Node reports. Without it the preferred path is guaranteed to break on
440
- // the next `brew upgrade` and every hook leans on the fallback below to stay
441
- // alive which works, but means the recorded path is wrong from the moment
442
- // it is written (EASFDC-8497).
443
- const preferred = shellQuote(stableInterpreterPath(interpreter ?? process.execPath));
439
+ // path Node reports (EASFDC-8497), and so the script carries an ordered list
440
+ // of stable runtimes (version-manager shims, Homebrew opt paths) that engage
441
+ // only once the recorded path is dead. Version-manager installs (mise, nvm,
442
+ // fnm, asdf, volta) delete their versioned directories on upgrade exactly
443
+ // like Homebrew's Cellar, and no eager rewrite exists for them — the shim
444
+ // resolves per-directory configuration, so promoting it while the recorded
445
+ // path still works could silently switch runtimes (KNODIN-38).
446
+ const resolution = stableInterpreterCandidates(interpreter ?? process.execPath);
447
+ const preferred = shellQuote(resolution.preferred);
448
+ const fallbackBlock = resolution.fallbacks.length > 0
449
+ ? String.raw `if [ ! -x "$KNODIN_NODE" ]; then
450
+ for KNODIN_NODE_CANDIDATE in ${resolution.fallbacks.map(shellQuote).join(" ")}; do
451
+ if [ -x "$KNODIN_NODE_CANDIDATE" ]; then
452
+ KNODIN_NODE="$KNODIN_NODE_CANDIDATE"
453
+ break
454
+ fi
455
+ done
456
+ fi
457
+ `
458
+ : "";
444
459
  return String.raw `#!/bin/sh
445
460
  # knodin packaged background refresh. Generated by knodin init.
446
461
  set -u
447
462
  KNODIN_NODE=${preferred}
448
- if [ ! -x "$KNODIN_NODE" ]; then
463
+ ${fallbackBlock}if [ ! -x "$KNODIN_NODE" ]; then
449
464
  KNODIN_NODE="$(command -v node 2>/dev/null || true)"
450
465
  # No interpreter at all: fall through to the recorded path so the failure is
451
466
  # the existing loud "not found", not a silent no-op.
@@ -954,12 +969,34 @@ function withoutManagedExcludeBlock(content) {
954
969
  after += 1;
955
970
  return `${content.slice(0, start > 0 ? start - 1 : 0)}\n${content.slice(after)}`;
956
971
  }
957
- async function excludeUntrackedPaths(repo, candidates) {
958
- const safe = [...new Set(candidates)]
959
- .filter((candidate) => !isTracked(repo, candidate))
960
- .sort(compareBytes);
961
- if (safe.length === 0)
972
+ /** The entries currently inside the knodin-owned exclude block, verbatim. */
973
+ function managedExcludeBlockEntries(content) {
974
+ let start = content.indexOf(EXCLUDE_BLOCK_START);
975
+ while (start > 0 && content[start - 1] !== "\n")
976
+ start = content.indexOf(EXCLUDE_BLOCK_START, start + 1);
977
+ if (start < 0)
962
978
  return [];
979
+ const end = content.indexOf(EXCLUDE_BLOCK_END, start + EXCLUDE_BLOCK_START.length);
980
+ if (end < 0)
981
+ return [];
982
+ return content
983
+ .slice(start + EXCLUDE_BLOCK_START.length, end)
984
+ .split(/\r?\n/)
985
+ .map((line) => line.trim())
986
+ .filter(Boolean);
987
+ }
988
+ /**
989
+ * MERGES into the managed block rather than rewriting it from `candidates`
990
+ * alone. Rewriting was the SalesforceCI regression (KNODIN-40): init calls this
991
+ * up to twice per run with different candidate lists, so the hook-manager pass
992
+ * erased the agent-config entries written moments earlier, and a later bare
993
+ * `knodin init` shrank the block to `.knodin/` — leaving knodin-owned files as
994
+ * untracked dirt in `git status`. Entries already in the block survive as long
995
+ * as they remain untracked; only a tracked path (or an explicit
996
+ * `removeManagedExcludes`, which team and cli-only transitions call first) can
997
+ * remove one.
998
+ */
999
+ async function excludeUntrackedPaths(repo, candidates) {
963
1000
  const rawExcludePath = runGit(repo, ["rev-parse", "--git-path", "info/exclude"]).trim();
964
1001
  const excludePath = path.isAbsolute(rawExcludePath)
965
1002
  ? rawExcludePath
@@ -972,6 +1009,11 @@ async function excludeUntrackedPaths(repo, candidates) {
972
1009
  if (error.code !== "ENOENT")
973
1010
  throw error;
974
1011
  }
1012
+ const safe = [...new Set([...managedExcludeBlockEntries(existing), ...candidates])]
1013
+ .filter((candidate) => !isTracked(repo, candidate))
1014
+ .sort(compareBytes);
1015
+ if (safe.length === 0)
1016
+ return [];
975
1017
  const startMarker = "# knodin:start";
976
1018
  const endMarker = "# knodin:end";
977
1019
  const outside = withoutManagedExcludeBlock(existing);
@@ -983,6 +1025,40 @@ async function excludeUntrackedPaths(repo, candidates) {
983
1025
  await fs.promises.writeFile(excludePath, `${prefix}${block}`, "utf-8");
984
1026
  return owned;
985
1027
  }
1028
+ /**
1029
+ * Personal-scope files whose exclude entry is worth restoring during repair.
1030
+ * `.mcp.json` is absent deliberately: personal scope removes it rather than
1031
+ * writing it, and `.vscode/mcp.json` only exists when copilot was configured.
1032
+ */
1033
+ const PERSONAL_AGENT_CONFIG_PATHS = [
1034
+ ".codex/config.toml",
1035
+ ".gemini/settings.json",
1036
+ ".agents/mcp_config.json",
1037
+ ".vscode/mcp.json",
1038
+ ];
1039
+ /**
1040
+ * Restore managed exclude entries a whole-block rewrite erased (KNODIN-40).
1041
+ * Candidates are limited to files that exist, mention knodin, and belong to a
1042
+ * personal-scope integration — repair must never start excluding files it
1043
+ * cannot show it owns. The merge inside `excludeUntrackedPaths` keeps whatever
1044
+ * else the block already carries (a hook manager's local config, for one).
1045
+ */
1046
+ async function reconcileManagedExcludes(repo) {
1047
+ if (readRepositoryIntegrationConfig(repo)?.scope !== "personal")
1048
+ return [];
1049
+ const candidates = [
1050
+ ...(isTracked(repo, ".knodin") ? [] : [".knodin/"]),
1051
+ ...PERSONAL_AGENT_CONFIG_PATHS.filter((relative) => {
1052
+ try {
1053
+ return fs.readFileSync(path.join(repo, relative), "utf-8").includes("knodin");
1054
+ }
1055
+ catch {
1056
+ return false;
1057
+ }
1058
+ }),
1059
+ ];
1060
+ return candidates.length > 0 ? excludeUntrackedPaths(repo, candidates) : [];
1061
+ }
986
1062
  async function removeManagedExcludes(repo) {
987
1063
  const rawExcludePath = runGit(repo, ["rev-parse", "--git-path", "info/exclude"]).trim();
988
1064
  const excludePath = path.isAbsolute(rawExcludePath)
@@ -1526,12 +1602,14 @@ export async function repairLifecycleRouting(repoPath, options) {
1526
1602
  }
1527
1603
  const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath, lifecycleLease.token);
1528
1604
  await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), { force: true });
1605
+ const excluded = await reconcileManagedExcludes(resolvedRepo);
1529
1606
  return {
1530
1607
  backgroundIndexer: ".knodin/hooks/background-index.sh",
1531
1608
  hooksDirectory: path.relative(resolvedRepo, hooksDir) || ".",
1532
1609
  gitHooks: [...HOOK_NAMES],
1533
1610
  hookManagerIntegration,
1534
1611
  lifecycleRefresh,
1612
+ excluded,
1535
1613
  configurationChanges: [],
1536
1614
  };
1537
1615
  }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { compareBytes } from "./compare.js";
5
5
  import { isMirror, resolveDbPath } from "./engine/state-paths.js";
6
6
  import { inspectLefthookIntegration, isActiveLefthookHook } from "./hook-manager-integration.js";
7
+ import { resolveHealthyInterpreter } from "./node-runtime.js";
7
8
  const MANAGED_MARKER = "KNODIN MANAGED HOOK";
8
9
  const HOOK_NAMES = ["post-commit", "post-checkout", "post-merge", "post-rewrite"];
9
10
  const HOOK_FAILURE_FILE = "last-refresh-failure";
@@ -204,6 +205,44 @@ function pinnedInterpreterWithoutFallback(backgroundScriptPath) {
204
205
  return null;
205
206
  return /'(\/[^']*\/bin\/node)'/.exec(contents)?.[1] ?? null;
206
207
  }
208
+ /**
209
+ * Rewrite a dead recorded interpreter to a proven-healthy stable one, in place.
210
+ *
211
+ * Detection without repair made this a recurring chore: every `brew upgrade
212
+ * node` (or mise/nvm upgrade) killed the recorded path, health reported
213
+ * degraded, and the user ran `repair --lifecycle` by hand — which re-recorded
214
+ * the repairing process's own versioned path and planted the next failure
215
+ * (KNODIN-38). The fix is narrow by design: only the quoted interpreter path
216
+ * changes, the replacement must exist AND report a supported Node version
217
+ * (`resolveHealthyInterpreter` executes it), and the write is atomic
218
+ * (tmp+rename, mode preserved) so a hook running concurrently keeps the copy
219
+ * it already read.
220
+ *
221
+ * Returns null — leaving the degraded report exactly as it was — when no
222
+ * replacement can be proven healthy, when the script cannot be written, or
223
+ * when the replacement contains a single quote the recorded format cannot
224
+ * carry. Degraded-but-honest beats healed-but-guessed.
225
+ */
226
+ export function selfHealRecordedInterpreter(backgroundScriptPath, recorded, resolve = resolveHealthyInterpreter) {
227
+ const replacement = resolve(recorded);
228
+ if (!replacement || replacement.includes("'"))
229
+ return null;
230
+ const temporary = `${backgroundScriptPath}.self-heal.${process.pid}`;
231
+ try {
232
+ const contents = fs.readFileSync(backgroundScriptPath, "utf8");
233
+ const updated = contents.split(`'${recorded}'`).join(`'${replacement}'`);
234
+ if (updated === contents)
235
+ return null;
236
+ const mode = fs.statSync(backgroundScriptPath).mode & 0o777;
237
+ fs.writeFileSync(temporary, updated, { mode });
238
+ fs.renameSync(temporary, backgroundScriptPath);
239
+ return { from: recorded, to: replacement };
240
+ }
241
+ catch {
242
+ fs.rmSync(temporary, { force: true });
243
+ return null;
244
+ }
245
+ }
207
246
  function readRefreshFailure(repo) {
208
247
  const failurePath = path.join(repo, ".knodin", "hooks", HOOK_FAILURE_FILE);
209
248
  if (!fs.existsSync(failurePath))
@@ -216,7 +255,15 @@ function readRefreshFailure(repo) {
216
255
  return "last background refresh failed";
217
256
  }
218
257
  }
219
- /** Read-only proof that committed Git activity can still reach knodin's indexer. */
258
+ /**
259
+ * Proof that committed Git activity can still reach knodin's indexer.
260
+ *
261
+ * Read-only, with one deliberate exception: a recorded interpreter that no
262
+ * longer exists is rewritten in place when a proven-healthy replacement
263
+ * resolves (`selfHealRecordedInterpreter`), because reporting that condition
264
+ * without fixing it made every Node upgrade a manual `repair --lifecycle`
265
+ * chore (KNODIN-38).
266
+ */
220
267
  export function inspectLifecycleHealth(repoPath) {
221
268
  const repo = path.resolve(repoPath);
222
269
  // A mirror deliberately has no lifecycle hooks: its working tree is replaced
@@ -240,6 +287,7 @@ export function inspectLifecycleHealth(repoPath) {
240
287
  lastError: null,
241
288
  hookManager: null,
242
289
  routing: "unavailable",
290
+ interpreterSelfHeal: null,
243
291
  };
244
292
  }
245
293
  if (!fs.existsSync(resolveDbPath(repo))) {
@@ -257,6 +305,7 @@ export function inspectLifecycleHealth(repoPath) {
257
305
  lastError: null,
258
306
  hookManager: null,
259
307
  routing: "unavailable",
308
+ interpreterSelfHeal: null,
260
309
  };
261
310
  }
262
311
  let hooksDirectory;
@@ -278,6 +327,7 @@ export function inspectLifecycleHealth(repoPath) {
278
327
  lastError: null,
279
328
  hookManager: null,
280
329
  routing: "unavailable",
330
+ interpreterSelfHeal: null,
281
331
  };
282
332
  }
283
333
  const { activeHooks, missingHooks, hookManager, routing } = inspectManagedHooks(repo, hooksDirectory);
@@ -286,7 +336,16 @@ export function inspectLifecycleHealth(repoPath) {
286
336
  const issues = missingHooks.map((hook) => `${hook} no longer routes through knodin's active Git hook path`);
287
337
  if (!backgroundReady)
288
338
  issues.push("background indexer is missing or not executable");
289
- const staleInterpreter = backgroundReady ? recordedInterpreterIfMissing(background) : null;
339
+ // A dead recorded interpreter is repaired here, not just reported: the
340
+ // replacement must prove itself healthy, and when nothing does the original
341
+ // degraded report stands untouched (KNODIN-38).
342
+ let staleInterpreter = backgroundReady ? recordedInterpreterIfMissing(background) : null;
343
+ let interpreterSelfHeal = null;
344
+ if (staleInterpreter) {
345
+ interpreterSelfHeal = selfHealRecordedInterpreter(background, staleInterpreter);
346
+ if (interpreterSelfHeal)
347
+ staleInterpreter = null;
348
+ }
290
349
  if (staleInterpreter)
291
350
  issues.push(`background indexer records a Node interpreter that no longer exists (${staleInterpreter}); run \`knodin repair --lifecycle\` to rewrite the hook`);
292
351
  // Reported even while that interpreter still exists. The failure is latent
@@ -315,6 +374,7 @@ export function inspectLifecycleHealth(repoPath) {
315
374
  lastError,
316
375
  hookManager,
317
376
  routing,
377
+ interpreterSelfHeal,
318
378
  };
319
379
  }
320
380
  /** Merge graph and refresh-path health without reporting absent evidence as healthy. */
@@ -188,3 +188,99 @@ export function stableInterpreterPath(executable, exists = (candidate) => fs.exi
188
188
  const candidate = `${prefix}/opt/${formula}/${remainder}`;
189
189
  return exists(candidate) ? candidate : executable;
190
190
  }
191
+ /**
192
+ * The version manager's stable shim for a versioned install path, when the
193
+ * manager has one. mise, asdf, and volta shims survive `<manager> upgrade`
194
+ * because the manager repoints them; the versioned install directory the
195
+ * recorded `execPath` names does not. nvm has no on-disk shim (it is a shell
196
+ * function), so an nvm path yields nothing here.
197
+ */
198
+ function versionManagerShim(executable) {
199
+ const mise = /^(.*)\/installs\/node\/[^/]+\/bin\/node$/.exec(executable);
200
+ if (mise)
201
+ return `${mise[1]}/shims/node`;
202
+ const asdf = /^(.*)\/installs\/nodejs\/[^/]+\/bin\/node$/.exec(executable);
203
+ if (asdf)
204
+ return `${asdf[1]}/shims/node`;
205
+ const volta = /^(.*)\/tools\/image\/node\/[^/]+\/bin\/node$/.exec(executable);
206
+ if (volta)
207
+ return `${volta[1]}/bin/node`;
208
+ const fnm = /^(.*)\/node-versions\/[^/]+\/installation\/bin\/node$/.exec(executable);
209
+ if (fnm)
210
+ return `${fnm[1]}/aliases/default/bin/node`;
211
+ return null;
212
+ }
213
+ /**
214
+ * A version-stable interpreter plus an ordered fallback list for anything that
215
+ * records an interpreter path for later (the background refresh hook).
216
+ *
217
+ * The preferred path stays EXACT except for the Homebrew Cellar→opt rewrite,
218
+ * which is the same binary by construction. A version-manager shim is
219
+ * deliberately NOT promoted to preferred while the recorded path still exists:
220
+ * shims resolve per-directory configuration, so an eager rewrite could
221
+ * silently switch which runtime executes. Shims belong in the fallback list,
222
+ * where they only engage once the recorded path is dead — at which point any
223
+ * working stable runtime beats a vanished one.
224
+ */
225
+ export function stableInterpreterCandidates(executable, options = {}) {
226
+ const exists = options.exists ?? ((candidate) => fs.existsSync(candidate));
227
+ const env = options.env ?? process.env;
228
+ const home = options.homeDirectory ?? os.homedir();
229
+ const preferred = stableInterpreterPath(executable, exists);
230
+ const absolute = (value) => value && path.isAbsolute(value) ? value : undefined;
231
+ const miseRoot = absolute(env.MISE_DATA_DIR) ?? path.join(home, ".local", "share", "mise");
232
+ const asdfRoot = absolute(env.ASDF_DATA_DIR) ?? path.join(home, ".asdf");
233
+ const voltaRoot = absolute(env.VOLTA_HOME) ?? path.join(home, ".volta");
234
+ const fnmRoot = absolute(env.FNM_DIR);
235
+ // KNODIN_NODE_RUNTIME leads: it is the user's explicit override, and
236
+ // `runtimeCandidates` above treats it as highest priority — the derived shim
237
+ // must not outrank it once the recorded path is dead. A non-absolute value
238
+ // is skipped rather than thrown here (the launcher already throws for it at
239
+ // startup): this derivation runs inside status/doctor health checks, where
240
+ // a bad environment variable must degrade a fallback list, not the command.
241
+ const ordered = [
242
+ absolute(env.KNODIN_NODE_RUNTIME),
243
+ versionManagerShim(executable),
244
+ "/opt/homebrew/opt/node@24/bin/node",
245
+ "/opt/homebrew/opt/node/bin/node",
246
+ "/usr/local/opt/node@24/bin/node",
247
+ "/usr/local/opt/node/bin/node",
248
+ path.join(miseRoot, "shims", "node"),
249
+ path.join(home, ".mise", "shims", "node"),
250
+ path.join(asdfRoot, "shims", "node"),
251
+ path.join(voltaRoot, "bin", "node"),
252
+ fnmRoot ? path.join(fnmRoot, "aliases", "default", "bin", "node") : null,
253
+ ];
254
+ const fallbacks = [];
255
+ const seen = new Set([preferred, executable]);
256
+ for (const candidate of ordered) {
257
+ if (!candidate || seen.has(candidate) || !exists(candidate))
258
+ continue;
259
+ seen.add(candidate);
260
+ fallbacks.push(candidate);
261
+ if (fallbacks.length >= 4)
262
+ break;
263
+ }
264
+ return { preferred, fallbacks };
265
+ }
266
+ /**
267
+ * A working replacement for a recorded interpreter that no longer exists, or
268
+ * null when none can be proven healthy. Candidates are the stable derivation
269
+ * of the dead path plus the stable fallback list; each is executed to confirm
270
+ * it actually reports a supported Node version — a shim can resolve to an
271
+ * unsupported runtime through per-directory configuration, and recording an
272
+ * unverified path would trade one dead interpreter for another.
273
+ */
274
+ export function resolveHealthyInterpreter(deadExecutable, options = {}) {
275
+ const exists = options.exists ?? ((candidate) => fs.existsSync(candidate));
276
+ const probe = options.probe ?? probeNodeVersion;
277
+ const { preferred, fallbacks } = stableInterpreterCandidates(deadExecutable, options);
278
+ const candidates = preferred === deadExecutable ? fallbacks : [preferred, ...fallbacks];
279
+ for (const candidate of candidates) {
280
+ if (!exists(candidate))
281
+ continue;
282
+ if (isSupported(probe(candidate)))
283
+ return candidate;
284
+ }
285
+ return null;
286
+ }
@@ -7,6 +7,17 @@ import { resolveDbPath } from "./engine/state-paths.js";
7
7
  import { summarizeAdoption } from "./session-telemetry.js";
8
8
  export const TELEMETRY_TOKENIZER = "gpt-tokenizer@3.4.0:o200k_base";
9
9
  export const countOutputTokens = (value) => encode(value).length;
10
+ /** Retrieval stages the engine can report; anything else is dropped. */
11
+ const RETRIEVAL_ROUTE_STEPS = new Set([
12
+ "stable-identity",
13
+ "exact-name",
14
+ "exact-path",
15
+ "lexical",
16
+ "bounded-graph",
17
+ "embeddings",
18
+ ]);
19
+ /** Embedding-coverage states the engine can report; anything else is dropped. */
20
+ const SEMANTIC_READINESS_STATES = new Set(["ready", "partial", "absent"]);
10
21
  export const DEFAULT_TELEMETRY_RETENTION_DAYS = 30;
11
22
  const DEFAULT_INPUT = ".knodin-telemetry.jsonl";
12
23
  function lineCount(value) {
@@ -105,6 +116,28 @@ export function measureOutput(options) {
105
116
  const status = typeof options.output.status === "string" ? options.output.status : undefined;
106
117
  const availability = options.output.availability;
107
118
  const freshness = options.output.freshness;
119
+ // Retrieval-quality fields, lifted so a repository where knodin underperforms
120
+ // is diagnosable from a shared bundle instead of from a hand-written field
121
+ // report (KNODIN-37). All four already travel in the response and all four are
122
+ // source-free metadata, so this persists nothing the privacy contract forbids.
123
+ //
124
+ // `semanticReadiness` is the most diagnostic of them: a partially embedded
125
+ // index returns confident SHORT answers, which is indistinguishable from the
126
+ // query being wrong unless the readiness is recorded beside the result.
127
+ const retrieval = options.output.retrieval;
128
+ const retrievalRoute = Array.isArray(retrieval?.route)
129
+ ? retrieval.route.filter((step) => typeof step === "string")
130
+ : undefined;
131
+ const embeddingsUsed = typeof retrieval?.embeddingsUsed === "boolean" ? retrieval.embeddingsUsed : undefined;
132
+ const semanticReadiness = typeof options.output.semanticReadiness === "string"
133
+ ? options.output.semanticReadiness
134
+ : undefined;
135
+ const results = options.output.results;
136
+ const resultCount = Array.isArray(results)
137
+ ? results.length
138
+ : typeof options.output.count === "number"
139
+ ? options.output.count
140
+ : undefined;
108
141
  const fidelity = options.output.fidelity;
109
142
  const database = resolveDbPath(fs.realpathSync(options.repo));
110
143
  const latencyMs = Math.round((performance.now() - options.startedAt) * 100) / 100;
@@ -137,6 +170,15 @@ export function measureOutput(options) {
137
170
  compressionFidelity,
138
171
  confidence: baseline ? "measured" : "unavailable",
139
172
  truncated: options.truncated,
173
+ // Non-empty or absent, never `[]`. An empty route asserts that no retrieval
174
+ // stage ran, which is a claim about the query; a route that is unknown or
175
+ // wholly unreadable is the absence of one. The sanitizer downstream makes
176
+ // the same distinction, and a record that collapsed it here would hand the
177
+ // sanitizer a falsehood to faithfully preserve (ADR 008).
178
+ ...(retrievalRoute && retrievalRoute.length > 0 ? { retrievalRoute } : {}),
179
+ ...(embeddingsUsed !== undefined ? { embeddingsUsed } : {}),
180
+ ...(semanticReadiness ? { semanticReadiness } : {}),
181
+ ...(resultCount !== undefined ? { resultCount } : {}),
140
182
  detailMode: options.detailMode,
141
183
  tokenizer: TELEMETRY_TOKENIZER,
142
184
  schemaTokens: options.schemaTokens,
@@ -232,7 +274,12 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
232
274
  sessionEvents: sessionEvents.length,
233
275
  };
234
276
  }
235
- function sanitizeTelemetryRecord(record) {
277
+ /**
278
+ * Exported for the retrieval-quality spec: the allowlist is the guarantee that
279
+ * an unexpected field never reaches a shareable file, and that guarantee is
280
+ * only worth anything if it is tested directly rather than through a caller.
281
+ */
282
+ export function sanitizeTelemetryRecord(record) {
236
283
  return {
237
284
  schemaVersion: record.schemaVersion,
238
285
  at: record.at,
@@ -252,6 +299,38 @@ function sanitizeTelemetryRecord(record) {
252
299
  compressionFidelity: record.compressionFidelity,
253
300
  confidence: record.confidence,
254
301
  truncated: record.truncated,
302
+ // Retrieval-quality fields (KNODIN-37). Validated rather than copied: the
303
+ // allowlist is the reason an unexpected field can never reach the file, and
304
+ // a new entry that trusts its input would quietly defeat that. Route steps
305
+ // are constrained to a known vocabulary and the array is bounded, so a
306
+ // malformed response cannot smuggle arbitrary strings into a shareable
307
+ // record.
308
+ // Omitted when nothing survives the allowlist, not emitted as `[]`. An
309
+ // empty array reads as "no retrieval stage ran", which is a fact; a route
310
+ // whose every step was unrecognised is the absence of a readable fact, and
311
+ // the two must not share a representation (ADR 008). Caught in review of
312
+ // this very change, which is the telemetry meant to diagnose exactly this
313
+ // class of confusion.
314
+ ...(() => {
315
+ if (!Array.isArray(record.retrievalRoute))
316
+ return {};
317
+ const route = record.retrievalRoute
318
+ .filter((step) => RETRIEVAL_ROUTE_STEPS.has(step))
319
+ .slice(0, 8);
320
+ return route.length > 0 ? { retrievalRoute: route } : {};
321
+ })(),
322
+ ...(typeof record.embeddingsUsed === "boolean"
323
+ ? { embeddingsUsed: record.embeddingsUsed }
324
+ : {}),
325
+ ...(typeof record.semanticReadiness === "string" &&
326
+ SEMANTIC_READINESS_STATES.has(record.semanticReadiness)
327
+ ? { semanticReadiness: record.semanticReadiness }
328
+ : {}),
329
+ ...(typeof record.resultCount === "number" &&
330
+ Number.isInteger(record.resultCount) &&
331
+ record.resultCount >= 0
332
+ ? { resultCount: record.resultCount }
333
+ : {}),
255
334
  detailMode: record.detailMode,
256
335
  tokenizer: record.tokenizer,
257
336
  schemaTokens: record.schemaTokens,
@@ -0,0 +1,98 @@
1
+ # knodin 0.12.1
2
+
3
+ A P1 correctness fix, a field made honest, and telemetry that lets a badly
4
+ performing repository explain itself. No schema change and no re-index.
5
+
6
+ ## `explain` said "untested" about symbols that have tests
7
+
8
+ `explain` reported `untested: true`, and `tests_for` returned `count: 0`, for
9
+ symbols with dedicated unit tests — on a healthy, fresh graph.
10
+
11
+ Test code lives almost entirely inside anonymous callbacks passed to `it(...)`
12
+ and `describe(...)`. Such a call has no enclosing *named* symbol, so it is
13
+ recorded with a null caller and every caller-resolution path drops it. Measured
14
+ on this repository: **29,158 of 31,467 references from test files (92.7%) had no
15
+ caller symbol, against 390 of 18,399 (2.1%) from production files.** A coverage
16
+ question answered from callers was therefore about 93% blind in exactly the files
17
+ it was asking about, and 642 symbols with real tests reported as untested.
18
+
19
+ This is the worst shape a wrong answer can take. An empty result invites a second
20
+ look; a confident positive does not. The field was written into a security
21
+ proposal as a precondition blocking a change and reached a pull request before a
22
+ reviewer caught it.
23
+
24
+ Both questions now read the raw reference rows, which carry the calling file.
25
+
26
+ Two alternatives were rejected on evidence rather than taste. Attributing the
27
+ call to the *file* would put a non-symbol into a `symbol` field, which a
28
+ checked-in contract forbids outright. Indexing `describe`/`it` titles as symbols
29
+ would add roughly ten thousand rows and perturb statistics, dead-code detection,
30
+ communities, embeddings, and the centrality term added in 0.12.0. Reading the
31
+ rows changes no caller, so search ranking is bit-identical — verified rather than
32
+ assumed.
33
+
34
+ Rows for anonymous callers carry a file and a line and no symbol, because there
35
+ genuinely is none.
36
+
37
+ ## `untested` is now three-valued
38
+
39
+ The first version of that fix still collapsed "cannot tell" into a guess: without
40
+ the defining repository's database it inferred from resolved callers, which is
41
+ the same blindness that made the field wrong. `untested` is now simply **absent**
42
+ when the evidence is absent. A caller gets a fact or nothing, never a guess.
43
+
44
+ The control matters as much as the fix: a genuinely uncovered symbol still
45
+ reports `untested: true`. A field that is never true would be as broken as one
46
+ that is always true, and only that second test tells them apart.
47
+
48
+ ## Telemetry can now explain a repository where knodin underperforms
49
+
50
+ knodin has been reported as performing poorly in other people's repositories,
51
+ and there was no way to find out why without someone writing a field report by
52
+ hand. The signals that explain it already travelled in every response and were
53
+ being discarded.
54
+
55
+ Four are now persisted with the existing opt-in local telemetry:
56
+ `retrievalRoute`, `embeddingsUsed`, `semanticReadiness`, and `resultCount`.
57
+
58
+ `semanticReadiness` is the most diagnostic of them. A partially embedded index
59
+ returns confident *short* answers, which is indistinguishable from a poorly
60
+ phrased query unless the readiness is recorded beside the result.
61
+
62
+ All four are source-free metadata, so the privacy contract is unchanged: no
63
+ source, no query text, no paths, no identities, still opt-in, still local-only,
64
+ still no transport. Query *shape* — the remaining diagnostic gap — is
65
+ deliberately excluded, because persisting query text would falsify the
66
+ no-source-egress property the product is built on.
67
+
68
+ The sanitizer's allowlist gained these fields with validation rather than
69
+ pass-through. That allowlist is the only reason an unexpected field cannot reach
70
+ a shareable file, and an entry that trusted its input would quietly defeat it.
71
+
72
+ ## A design principle, written down
73
+
74
+ ADR 008 records what eleven defects across 0.12.0 and this release turned out to
75
+ have in common:
76
+
77
+ > A check that collapses "no" and "don't know" into a single answer will
78
+ > eventually return the wrong one, and it will do so confidently.
79
+
80
+ `untested: true`, `count: 0` for a misspelling, a review answering a different
81
+ range, staleness defaulting to `fresh` when it was missing. Two instances in the
82
+ ADR are deliberately not knodin bugs — another product's CI gate reporting a
83
+ network timeout as a security advisory, and an agent truncating its own terminal
84
+ output and concluding a remote did not exist. The shape belongs to any predicate
85
+ whose failure path and negative path are the same path.
86
+
87
+ ## Measured and not shipped
88
+
89
+ Three attempts at the lexical retrieval channel were implemented, measured, and
90
+ reverted: asymmetric token floors, Porter stemming, and grading a partial match
91
+ by how many query words it echoed. On a 44-query real-model benchmark they
92
+ scored 0.7112, 0.7389, and "no measurable difference" against a 0.7635 baseline,
93
+ and **none bought a single point of recall** — including on four queries added
94
+ specifically to favour morphological matching.
95
+
96
+ The reproductions behind them were sound. The inference was not: a demonstrated
97
+ defect does not imply that removing it is an improvement. The vector channel was
98
+ already carrying what the lexical channel could not see.
@@ -0,0 +1,88 @@
1
+ # knodin 0.12.2
2
+
3
+ Three field reports from one very large Salesforce repository (903k files):
4
+ a lifecycle fault that came back after every Node upgrade, a doctor that took
5
+ 48 seconds to say so, and a handful of reports that were wrong in ways that
6
+ taught people to ignore them. No schema change and no re-index.
7
+
8
+ ## The interpreter that died on every `brew upgrade node` now heals itself
9
+
10
+ The background refresh hook records the absolute path of the Node interpreter
11
+ that wrote it. Node reports `process.execPath` as a fully resolved path, so a
12
+ stable `/opt/homebrew/bin/node` was recorded as a versioned Cellar path — and
13
+ mise, nvm, fnm, asdf, and volta versioned installs were recorded verbatim.
14
+ Every Node upgrade deleted that path, health reported "a Node interpreter that
15
+ no longer exists," and the prescribed `repair --lifecycle` re-recorded the
16
+ repairing process's own versioned path: the fix planted the next failure. This
17
+ loop had no exit, only a chore per upgrade per repository.
18
+
19
+ Three changes close it:
20
+
21
+ - **Recording is version-stable where that is safe.** The Homebrew Cellar path
22
+ is still rewritten to its durable `opt` form (the same binary by
23
+ construction). A version-manager path stays exact — a shim resolves
24
+ per-directory configuration, so promoting it while the recorded path still
25
+ works could silently switch which runtime executes.
26
+ - **The generated script carries an ordered fallback list** — the explicit
27
+ `KNODIN_NODE_RUNTIME` override first, then the recording manager's shim,
28
+ then known stable runtimes — so a single dead path never stops refresh.
29
+ - **Health repairs what it detects.** When the recorded interpreter is gone
30
+ and a replacement both exists and *proves itself* (it is executed and must
31
+ report a supported Node version), `status` and `doctor` rewrite the one
32
+ recorded line atomically and say so:
33
+ `background indexer interpreter self-healed (<old> -> <new>)`. When nothing
34
+ can be proven healthy, the degraded report stands untouched — degraded but
35
+ honest beats healed but guessed.
36
+
37
+ ## `doctor` on a 903k-file repository: 48 seconds of redundant evidence
38
+
39
+ Doctor forced a full deep audit — two complete stat sweeps — on every run,
40
+ ignoring the persisted audit that `status` already trusts, and then spawned
41
+ two git processes per managed configuration path, each `git diff` reloading
42
+ the 903k-entry index. Doctor now audits adaptively exactly as `status` does
43
+ (the persisted-audit path runs the same drift probe, so a stale graph still
44
+ reports stale — accuracy was the constraint, and it is regression-tested),
45
+ with `doctor --deep` keeping the exact audit one flag away. The managed-path
46
+ inspection is one `git ls-files` and one `git diff` for everything, aware
47
+ that a managed *directory* is tracked through the files beneath it and never
48
+ runs a diff with an empty pathspec.
49
+
50
+ One silent accuracy hazard was found on the way: the working-tree probe used
51
+ Node's default 1 MB buffer for `git status --porcelain`, so a few thousand
52
+ dirty paths overflowed it and quietly demoted freshness checking to the
53
+ bounded mtime fallback. The buffer is now 64 MB and an overflow is counted
54
+ where tests and the perf harness can see it.
55
+
56
+ ## Reports that cried wolf, and one that hid the pack
57
+
58
+ - **`.git/info/exclude` entries no longer vanish.** The managed exclude block
59
+ was rewritten from each caller's candidate list alone, so on a lefthook
60
+ repository the hook-manager pass erased the agent-config entries written
61
+ moments earlier, and a later bare `knodin init` shrank the block to
62
+ `.knodin/` — leaving knodin-owned files as permanent untracked dirt in
63
+ `git status`. The block now merges, and `repair --lifecycle` restores lost
64
+ entries for personal-scope repositories (only files that exist, mention
65
+ knodin, and match the receipt scope — repair never excludes what it cannot
66
+ show it owns).
67
+ - **A machine that never installed backup retention is `not-installed`, not
68
+ broken.** Doctor reported `attention-required` with
69
+ `policy-missing-or-invalid` and `receipt-missing-or-invalid` on machines
70
+ with neither file and zero backups. Absence of both files is a clean state;
71
+ any on-disk trace — even an unparseable file — still counts as installation
72
+ evidence and keeps the strict reporting.
73
+ - **Status names every issue, and the right remedy.** A lifecycle-only fault
74
+ used to print "Run `knodin init`." directly above a line that correctly said
75
+ `repair --lifecycle` — advice that contradicts itself teaches people to
76
+ read neither. The remediation now matches the fault, and status lists up to
77
+ five issues (`+N more`, full list under `--json`) instead of only the first.
78
+ - **Doctor prints its MCP probe once.** One probe backs every client row; the
79
+ human listing rendered it five times as if five servers had been tested.
80
+ JSON keeps the per-client shape for compatibility.
81
+ - **Doctor stops telling you to add exclude entries you already have.** The
82
+ "add to `.git/info/exclude` manually" instruction now consults
83
+ `git check-ignore` first.
84
+
85
+ Field verification for the recurring interpreter loop: run
86
+ `knodin repair --lifecycle` once with this version installed; the next Node
87
+ upgrade should self-heal on the first `status` instead of demanding another
88
+ repair.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "knodin": {
5
- "compatibility": "breaking"
5
+ "compatibility": "compatible"
6
6
  },
7
7
  "description": "knodin — source-evidenced local code intelligence with known bounds. Stable identity, fresh evidence, truthful budgets, and recoverable bounded views.",
8
8
  "license": "MIT",
@@ -65,6 +65,8 @@
65
65
  "docs/releases/0.10.8.md",
66
66
  "docs/releases/0.11.0.md",
67
67
  "docs/releases/0.12.0.md",
68
+ "docs/releases/0.12.1.md",
69
+ "docs/releases/0.12.2.md",
68
70
  "docs/releases/0.3.0.md",
69
71
  "docs/releases/0.4.0.md",
70
72
  "docs/releases/0.4.1.md",