knodin 0.12.1 → 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,
@@ -8227,7 +8227,7 @@ const freshnessChecks = new Map();
8227
8227
  * Work counters for the guard. Tests assert its cost in *invocations*, which is
8228
8228
  * deterministic, rather than in wall-clock milliseconds, which is flaky.
8229
8229
  */
8230
- const freshnessStats = { probes: 0, reconciles: 0, cacheHits: 0 };
8230
+ const freshnessStats = { probes: 0, reconciles: 0, cacheHits: 0, probeOverflows: 0 };
8231
8231
  /**
8232
8232
  * Paths the live watcher for `repoPath` currently has registered, flattened to
8233
8233
  * repo-relative form. Empty when no watcher is running (test mode, or after
@@ -8298,6 +8298,7 @@ export function resetFreshnessStats() {
8298
8298
  freshnessStats.probes = 0;
8299
8299
  freshnessStats.reconciles = 0;
8300
8300
  freshnessStats.cacheHits = 0;
8301
+ freshnessStats.probeOverflows = 0;
8301
8302
  freshnessProbes.clear();
8302
8303
  }
8303
8304
  /**
@@ -8316,10 +8317,19 @@ function gitWorkTreeProbe(repoPath) {
8316
8317
  cwd: repoPath,
8317
8318
  encoding: "utf-8",
8318
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,
8319
8324
  })
8320
8325
  .split("\n");
8321
8326
  }
8322
- 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;
8323
8333
  return null;
8324
8334
  }
8325
8335
  const head = lines[0]?.trim();
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
+ }
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "knodin": {
5
5
  "compatibility": "compatible"
6
6
  },
@@ -66,6 +66,7 @@
66
66
  "docs/releases/0.11.0.md",
67
67
  "docs/releases/0.12.0.md",
68
68
  "docs/releases/0.12.1.md",
69
+ "docs/releases/0.12.2.md",
69
70
  "docs/releases/0.3.0.md",
70
71
  "docs/releases/0.4.0.md",
71
72
  "docs/releases/0.4.1.md",