@sdsrs/code-graph 0.115.0 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ A high-performance code knowledge graph server implementing the [Model Context P
22
22
  - **Embedding model** — Optional local embedding via Candle (feature-gated `embed-model`). Context reordered to prioritize structural relations over code for better embedding quality
23
23
  - **Self-healing** — Automatic SQLite corruption recovery with rebuild. Startup repair for incomplete indexing (Phase 3 failures)
24
24
  - **MCP protocol** — JSON-RPC 2.0 over stdio, plug-and-play with Claude Code, Cursor, Windsurf, and other MCP clients
25
- - **Claude Code Plugin** — First-class plugin with slash commands (`/understand`, `/trace`, `/impact`), agents, skills, auto-indexing hooks, StatusLine integration, and self-updating
25
+ - **Claude Code Plugin** — First-class plugin with skills (`explore`, `index`), a `code-explorer` agent, auto-indexing hooks, StatusLine integration, and self-updating
26
26
 
27
27
  ## Why code-graph-mcp?
28
28
 
@@ -113,7 +113,7 @@ src/
113
113
  │ └── server/ # McpServer with IndexingState + CacheState sub-structs
114
114
  ├── parser/ # Tree-sitter parsing, relation extraction, LanguageConfig dispatch
115
115
  ├── indexer/ # 3-phase pipeline, Merkle tree, file watcher
116
- ├── storage/ # SQLite schema (v6), CRUD, FTS5, migrations
116
+ ├── storage/ # SQLite schema (v10), CRUD, FTS5, migrations
117
117
  ├── graph/ # Recursive CTE call graph queries
118
118
  ├── search/ # RRF fusion search combining BM25 + vector
119
119
  ├── embedding/ # Candle embedding model (optional, masked mean pooling)
@@ -125,7 +125,7 @@ src/
125
125
 
126
126
  ### Option 1: Claude Code Plugin (Recommended)
127
127
 
128
- Install as a Claude Code plugin for the best experience — includes slash commands, agents, skills, auto-indexing hooks, StatusLine health display, and automatic updates:
128
+ Install as a Claude Code plugin for the best experience — includes skills, the `code-explorer` agent, auto-indexing hooks, StatusLine health display, and automatic updates:
129
129
 
130
130
  ```bash
131
131
  # Step 1: Add the marketplace
@@ -137,11 +137,11 @@ Install as a Claude Code plugin for the best experience — includes slash comma
137
137
 
138
138
  What you get:
139
139
  - **MCP Server** — All code-graph tools available to Claude
140
- - **Slash Commands** — `/understand <module>`, `/trace <route>`, `/impact <symbol>`
140
+ - **Skills** — `explore` (structure-first navigation before reading files) and `index` (health-check / re-index / full rebuild); see [Plugin Skills](#plugin-skills)
141
141
  - **Code Explorer Agent** — Deep code understanding expert via `code-explorer`
142
142
  - **Auto-indexing Hook** — Incremental index on every file edit (PostToolUse)
143
143
  - **StatusLine** — Real-time health display (nodes, files, watch status) — compatible with other plugins' StatusLine via composite multiplexer
144
- - **Auto-update** — Checks for new versions every 6h, updates silently
144
+ - **Auto-update** — Checks for a new version at session start (throttled to at most one check every 2 minutes). Between forced checks the re-check interval is 30 minutes after an "up to date" answer and 6 hours while an update is already pending. Updates install silently.
145
145
 
146
146
  #### Manual Update
147
147
 
@@ -306,17 +306,18 @@ Common options: `--json` (JSON output), `--compact` (compact output), `--limit N
306
306
 
307
307
  As of **v0.37.0** the CLI is [clap](https://docs.rs/clap)-based: **every subcommand has `--help`** for its full flag list (`code-graph-mcp <command> --help`), value flags accept both `--flag value` and `--flag=value`, and unknown flags or malformed arguments fail fast with a clear error and a non-zero exit code (`2`) instead of being silently ignored. For example, `trace` hides downstream middleware with `--no-middleware` (shown by default), and `snapshot` is a `create`/`inspect` subcommand pair.
308
308
 
309
- ## Plugin Slash Commands
309
+ ## Plugin Skills
310
310
 
311
- Available when installed as a Claude Code plugin:
311
+ Installing the plugin ships two skills that Claude loads on its own when the
312
+ situation matches — there are no slash commands to remember:
312
313
 
313
- | Command | Description |
314
- |---------|-------------|
315
- | `/understand <module>` | Deep dive into a module or file's architecture and relationships |
316
- | `/trace <route>` | Trace a full HTTP request flow from route to data layer |
317
- | `/impact <symbol>` | Analyze the impact scope of changing a symbol before modifying it |
318
- | `/status` | Show code-graph index status and embedding progress |
319
- | `/rebuild` | Force a full code-graph index rebuild |
314
+ | Skill | Loaded when | What it does |
315
+ |-------|-------------|--------------|
316
+ | `explore` | Starting work in unfamiliar code, or before editing a module | Routes the question to `overview` / `map` / `callgraph` / `search` / `impact` instead of reading files one at a time |
317
+ | `index` | Search returns empty or stale results, or after a large restructuring | Walks `health-check`, incremental re-index, and full rebuild |
318
+
319
+ Both are thin routers over the CLI subcommands documented above, so anything a
320
+ skill does is also runnable by hand.
320
321
 
321
322
  ## Supported Languages (19)
322
323
 
@@ -438,7 +439,10 @@ Data is stored in `.code-graph/index.db` under the project root (auto-created, g
438
439
 
439
440
  ### Prerequisites
440
441
 
441
- - Rust 1.75+ (2021 edition)
442
+ - Rust 1.95.0 (2021 edition) — the toolchain CI and the release build pin
443
+ (`dtolnay/rust-toolchain@1.95.0` in `.github/workflows/`). No older toolchain
444
+ is tested: `Cargo.lock` is lockfile **version 4**, which Cargo 1.75 cannot
445
+ read at all, and no `rust-version` floor is declared in `Cargo.toml`.
442
446
  - A C compiler (for bundled SQLite / sqlite-vec)
443
447
 
444
448
  ### Build
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.115.0",
7
+ "version": "0.117.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -323,6 +323,42 @@ function escapeRegex(s) {
323
323
  return s.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&');
324
324
  }
325
325
 
326
+ // Remove every match of `re` and heal ONLY the seam each removal leaves behind.
327
+ //
328
+ // The whole-file `out.replace(/\n{3,}/g, '\n\n')` this replaces was the last
329
+ // unscoped edit in a function whose entire contract is "touch nothing but our
330
+ // block". It rewrote the user's prose: blank-line runs inside fenced code blocks
331
+ // collapsed, and because the collapse changed bytes even when no marker was
332
+ // present, `unadopt` reported "De-blocked" for files that never held our block
333
+ // — on every SessionStart, and across every registered project on uninstall
334
+ // (audit 2026-08-16 P1-15).
335
+ //
336
+ // Seam rule: the blank lines that end up adjacent BECAUSE the block between them
337
+ // was removed collapse to one blank line (the old behavior, now local); bytes
338
+ // anywhere else are copied through untouched. A text containing no match is
339
+ // returned identical, which is what makes "changed" mean "we removed something".
340
+ function stripAndHealSeams(text, re) {
341
+ let out = '';
342
+ let cursor = 0;
343
+ re.lastIndex = 0;
344
+ for (let m = re.exec(text); m !== null; m = re.exec(text)) {
345
+ if (m[0] === '') { re.lastIndex++; continue; } // zero-width guard: never loop forever
346
+ out += text.slice(cursor, m.index);
347
+ cursor = m.index + m[0].length;
348
+ const before = /\n*$/.exec(out)[0].length;
349
+ const after = /^\n*/.exec(text.slice(cursor))[0].length;
350
+ if (before + after > 2) {
351
+ out = out.slice(0, out.length - before) + '\n\n';
352
+ // Skip the newlines we just absorbed. Safe for the scan: the skipped span
353
+ // is newlines only, and every pattern here starts at a line's first
354
+ // non-newline character.
355
+ cursor += after;
356
+ re.lastIndex = cursor;
357
+ }
358
+ }
359
+ return out + text.slice(cursor);
360
+ }
361
+
326
362
  // Strip our sentinel block — well-formed first, then self-heal orphan begin/end.
327
363
  // Shared by adopt (so re-adopt rewrites a stale/malformed block) and unadopt.
328
364
  //
@@ -352,7 +388,7 @@ function stripSentinelBlock(text) {
352
388
  `${BEGIN_LINE}(?:(?!${SENTINEL_BEGIN_SRC})[\\s\\S])*?${END_LINE}\\n?`,
353
389
  'gm'
354
390
  );
355
- let out = text.replace(wellFormed, '');
391
+ let out = stripAndHealSeams(text, wellFormed);
356
392
  // Orphan BEGIN with no matching END (truncation / partial edit): remove the
357
393
  // MARKER LINE ONLY, never the content after it.
358
394
  //
@@ -371,13 +407,17 @@ function stripSentinelBlock(text) {
371
407
  // line-anchored BEGIN *and* END, so a leftover fragment does not block
372
408
  // re-adoption — the next adopt writes a fresh, well-formed block.
373
409
  const orphanBegin = new RegExp(`${BEGIN_LINE}\\n?`, 'gm');
374
- out = out.replace(orphanBegin, '');
375
- // Orphan END line by itself — same rule, one line, nothing around it.
410
+ out = stripAndHealSeams(out, orphanBegin);
411
+ // Orphan END line by itself — same rule, one line, nothing around it. Written
412
+ // as the same line-anchored removal the other two passes use (the old
413
+ // split/filter/join spelling was a third predicate for "is this our END line",
414
+ // and every duplicated predicate in this file has drifted at least once).
376
415
  if (out.includes(SENTINEL_END)) {
377
- out = out.split('\n').filter(l => l.trim() !== SENTINEL_END).join('\n');
416
+ out = stripAndHealSeams(out, new RegExp(`${END_LINE}\\n?`, 'gm'));
378
417
  }
379
- // Collapse blank-line runs introduced by stripping mid-paragraph blocks.
380
- return out.replace(/\n{3,}/g, '\n\n');
418
+ // NOTE: no whole-file newline collapse here. Each removal above healed its own
419
+ // seam; bytes the user wrote are returned exactly as they came in.
420
+ return out;
381
421
  }
382
422
 
383
423
  function platformGuard() {
@@ -402,32 +442,62 @@ function adoptedRegistryFile(home) {
402
442
  return path.join(home || os.homedir(), '.cache', 'code-graph', 'adopted-projects.json');
403
443
  }
404
444
 
405
- function readAdoptedProjects(home) {
445
+ // Read the registry keeping WHY it failed — the same one bit lifecycle.js's
446
+ // readJsonResult exists for. Only a genuinely ABSENT (or empty) file may be
447
+ // treated as "nothing here, safe to create": everything else (EACCES, EISDIR,
448
+ // truncated JSON, wrong shape) means the file EXISTS and holds entries we cannot
449
+ // read. The old lenient reader returned `[]` for all of them and the next
450
+ // recordAdopted persisted `[thisProject]` over it — dropping every other adopted
451
+ // project, which is exactly the list `uninstall --unadopt-all` iterates, so
452
+ // their managed CLAUDE.md blocks would be stranded (audit 2026-08-16 P1-12).
453
+ function readAdoptedResult(home) {
454
+ let raw;
455
+ try {
456
+ raw = fs.readFileSync(adoptedRegistryFile(home), 'utf8');
457
+ } catch (err) {
458
+ const missing = Boolean(err) && err.code === 'ENOENT';
459
+ return { list: [], missing, unusable: !missing };
460
+ }
461
+ if (raw.trim() === '') return { list: [], missing: true, unusable: false };
406
462
  try {
407
- const list = JSON.parse(fs.readFileSync(adoptedRegistryFile(home), 'utf8'));
408
- return Array.isArray(list) ? list.filter((p) => typeof p === 'string') : [];
409
- } catch { return []; }
463
+ const parsed = JSON.parse(raw);
464
+ if (!Array.isArray(parsed)) return { list: [], missing: false, unusable: true };
465
+ return { list: parsed.filter((p) => typeof p === 'string'), missing: false, unusable: false };
466
+ } catch {
467
+ return { list: [], missing: false, unusable: true };
468
+ }
410
469
  }
411
470
 
471
+ /** Read-side contract is unchanged: a list, never a throw. */
472
+ function readAdoptedProjects(home) {
473
+ return readAdoptedResult(home).list;
474
+ }
475
+
476
+ /** @returns {boolean} true when the project is recorded (or already was). */
412
477
  function recordAdopted(projectDir, home) {
478
+ const res = readAdoptedResult(home);
479
+ if (res.unusable) return false; // never rebuild over entries we cannot read
413
480
  try {
414
481
  const file = adoptedRegistryFile(home);
415
- const list = readAdoptedProjects(home);
416
482
  const abs = path.resolve(projectDir);
417
- if (list.includes(abs)) return;
483
+ if (res.list.includes(abs)) return true;
418
484
  fs.mkdirSync(path.dirname(file), { recursive: true });
419
- writeFileAtomic(file, JSON.stringify([...list, abs], null, 2) + '\n');
420
- } catch { /* best-effort */ }
485
+ writeFileAtomic(file, JSON.stringify([...res.list, abs], null, 2) + '\n');
486
+ return true;
487
+ } catch { return false; } // best-effort: registry loss only degrades guidance
421
488
  }
422
489
 
490
+ /** @returns {boolean} true when the project is absent from the registry afterwards. */
423
491
  function removeAdopted(projectDir, home) {
492
+ const res = readAdoptedResult(home);
493
+ if (res.unusable) return false;
424
494
  try {
425
- const list = readAdoptedProjects(home);
426
495
  const abs = path.resolve(projectDir);
427
- const next = list.filter((p) => p !== abs);
428
- if (next.length === list.length) return;
496
+ const next = res.list.filter((p) => p !== abs);
497
+ if (next.length === res.list.length) return true;
429
498
  writeFileAtomic(adoptedRegistryFile(home), JSON.stringify(next, null, 2) + '\n');
430
- } catch { /* best-effort */ }
499
+ return true;
500
+ } catch { return false; }
431
501
  }
432
502
 
433
503
  function adopt({ cwd, templatePath, home } = {}) {
@@ -445,18 +515,40 @@ function adopt({ cwd, templatePath, home } = {}) {
445
515
  return { ok: false, reason: 'no-template', template: tpl };
446
516
  }
447
517
 
518
+ // Every filesystem touch below is on files the USER owns and may have made
519
+ // unreadable (a root-owned CLAUDE.md from a `sudo` session) or replaced with a
520
+ // directory. Those throw EACCES/EISDIR, and this function is called bare from
521
+ // maybeAutoAdopt → runSessionInit: one such file killed the whole SessionStart
522
+ // hook, so binary verification, index freshness and the hook self-test never
523
+ // ran (audit 2026-08-16 P1-16). Adoption is optional; the rest of the session
524
+ // is not. Every arm below returns a REASON instead of throwing.
525
+
448
526
  // 1. Install the detail doc at <cwd>/.claude/plugin_code_graph_mcp.md.
449
527
  // First line is the MANAGED_BY marker (HTML comment → invisible in rendered
450
528
  // markdown) so unadopt/needsRefresh can tell our generated copy from a user
451
529
  // file of the same name. needsRefresh strips it before the bytewise compare.
452
530
  const dDir = detailDir(effectiveCwd);
453
- if (!fs.existsSync(dDir)) fs.mkdirSync(dDir, { recursive: true });
454
531
  const dPath = detailPath(effectiveCwd);
455
- const desiredDetail = Buffer.concat([Buffer.from(`${MANAGED_BY}\n`), fs.readFileSync(tpl)]);
456
532
  let detailWritten = false;
457
- if (!fs.existsSync(dPath) || !fs.readFileSync(dPath).equals(desiredDetail)) {
458
- writeFileAtomic(dPath, desiredDetail);
459
- detailWritten = true;
533
+ let desiredDetail;
534
+ try {
535
+ desiredDetail = Buffer.concat([Buffer.from(`${MANAGED_BY}\n`), fs.readFileSync(tpl)]);
536
+ } catch (e) {
537
+ return { ok: false, reason: 'no-template', template: tpl, error: e.code || String(e) };
538
+ }
539
+ try {
540
+ if (!fs.existsSync(dDir)) fs.mkdirSync(dDir, { recursive: true });
541
+ // readFileSync on the existing copy can throw for the same reasons as
542
+ // CLAUDE.md below; an unreadable one is "different from what we want", so
543
+ // fall through to the write and let THAT report the real failure.
544
+ let current = null;
545
+ try { current = fs.readFileSync(dPath); } catch { current = null; }
546
+ if (current === null || !current.equals(desiredDetail)) {
547
+ writeFileAtomic(dPath, desiredDetail);
548
+ detailWritten = true;
549
+ }
550
+ } catch (e) {
551
+ return { ok: false, reason: 'detail-unwritable', detailPath: dPath, error: e.code || String(e) };
460
552
  }
461
553
 
462
554
  // 2. Ensure the managed block in <cwd>/CLAUDE.md. Create-if-missing, else
@@ -465,19 +557,33 @@ function adopt({ cwd, templatePath, home } = {}) {
465
557
  const cPath = claudeMdPath(effectiveCwd);
466
558
  const block = buildBlock(detectProjectType(effectiveCwd));
467
559
  const exists = fs.existsSync(cPath);
468
- const current = exists ? fs.readFileSync(cPath, 'utf8') : '';
560
+ let current = '';
561
+ if (exists) {
562
+ try {
563
+ current = fs.readFileSync(cPath, 'utf8');
564
+ } catch (e) {
565
+ // EACCES / EISDIR / EIO: the file is there and we cannot read it. Writing
566
+ // anyway would replace content we never saw, so this project simply cannot
567
+ // be adopted right now.
568
+ return { ok: false, reason: 'claude-md-unreadable', claudeMdPath: cPath, error: e.code || String(e) };
569
+ }
570
+ }
469
571
  if (current.includes(block)) {
470
- recordAdopted(effectiveCwd, home);
471
- return { ok: true, detailPath: dPath, claudeMdPath: cPath, detailWritten, claudeMdWritten: false, created: false, healed: false };
572
+ const registryRecorded = recordAdopted(effectiveCwd, home);
573
+ return { ok: true, detailPath: dPath, claudeMdPath: cPath, detailWritten, claudeMdWritten: false, created: false, healed: false, registryRecorded };
472
574
  }
473
575
  const cleaned = exists ? stripSentinelBlock(current) : '';
474
576
  const healed = exists && cleaned !== current;
475
577
  const base = cleaned.replace(/\n+$/, '');
476
578
  const prefix = base ? base + '\n\n' : '';
477
- // followLink: read-modify-write of a file the user may have symlinked.
478
- writeFileAtomic(cPath, prefix + block + '\n', { followLink: true });
479
- recordAdopted(effectiveCwd, home);
480
- return { ok: true, detailPath: dPath, claudeMdPath: cPath, detailWritten, claudeMdWritten: true, created: !exists, healed };
579
+ try {
580
+ // followLink: read-modify-write of a file the user may have symlinked.
581
+ writeFileAtomic(cPath, prefix + block + '\n', { followLink: true });
582
+ } catch (e) {
583
+ return { ok: false, reason: 'claude-md-unwritable', claudeMdPath: cPath, error: e.code || String(e) };
584
+ }
585
+ const registryRecorded = recordAdopted(effectiveCwd, home);
586
+ return { ok: true, detailPath: dPath, claudeMdPath: cPath, detailWritten, claudeMdWritten: true, created: !exists, healed, registryRecorded };
481
587
  }
482
588
 
483
589
  // "已 install" 判定:detail 文件在 + CLAUDE.md 内有我们的 sentinel 块(任意版本)。
@@ -487,7 +593,11 @@ function isAdopted({ cwd } = {}) {
487
593
  const cPath = claudeMdPath(effectiveCwd);
488
594
  const dPath = detailPath(effectiveCwd);
489
595
  if (!fs.existsSync(dPath) || !fs.existsSync(cPath)) return false;
490
- const c = fs.readFileSync(cPath, 'utf8');
596
+ // Unreadable (EACCES) or a directory (EISDIR) → "not adopted here". A throw
597
+ // out of this predicate took SessionStart down with it (P1-16), and a file we
598
+ // cannot read cannot be proven to hold our block anyway.
599
+ let c;
600
+ try { c = fs.readFileSync(cPath, 'utf8'); } catch { return false; }
491
601
  // Line-anchored for the same reason stripSentinelBlock is: a user quoting the
492
602
  // markers in prose would otherwise read as adopted, and this gates the
493
603
  // idempotent auto-adopt — so the block would never actually be written.
@@ -507,8 +617,15 @@ function needsRefresh({ cwd, templatePath } = {}) {
507
617
  return false;
508
618
  }
509
619
  // Detail-doc body drift — strip the leading MANAGED_BY marker line first.
510
- const shipped = fs.readFileSync(tpl);
511
- const current = fs.readFileSync(dPath);
620
+ // Unreadable inputs → false: a refresh we cannot decide is one we must not
621
+ // attempt (and adopt() would refuse the write anyway). Never throws — this
622
+ // runs inside the SessionStart hook (P1-16).
623
+ let shipped;
624
+ let current;
625
+ try {
626
+ shipped = fs.readFileSync(tpl);
627
+ current = fs.readFileSync(dPath);
628
+ } catch { return false; }
512
629
  let body = current;
513
630
  const nl = current.indexOf(0x0a);
514
631
  // Equality on the trimmed line, matching the unadopt guard. `includes` here
@@ -523,7 +640,9 @@ function needsRefresh({ cwd, templatePath } = {}) {
523
640
  // needsRefresh always agree on the variant — including when a project gains a
524
641
  // web-framework dep and switches type bucket, or on a sentinel version bump.
525
642
  const block = buildBlock(detectProjectType(effectiveCwd));
526
- return !fs.readFileSync(cPath, 'utf8').includes(block);
643
+ try {
644
+ return !fs.readFileSync(cPath, 'utf8').includes(block);
645
+ } catch { return false; }
527
646
  }
528
647
 
529
648
  // 检测脚本是否从 Claude Code 插件 cache 运行。
@@ -643,25 +762,45 @@ function unadopt({ cwd, home } = {}) {
643
762
  mine = h === MANAGED_BY
644
763
  || (h.startsWith(LEGACY_ADOPTED_BY) && h.endsWith('-->'));
645
764
  } catch { mine = false; }
646
- if (mine) { fs.unlinkSync(dPath); fileRemoved = true; }
765
+ // The unlink itself can fail (read-only dir, EPERM) an uninstall sweep
766
+ // must keep going for the remaining projects.
767
+ if (mine) {
768
+ try { fs.unlinkSync(dPath); fileRemoved = true; } catch { /* left behind, reported as not removed */ }
769
+ }
647
770
  }
648
771
 
649
772
  // CLAUDE.md — strip only our block. If nothing else remains, remove the file
650
773
  // we created; otherwise preserve the user's prose.
774
+ //
775
+ // Unreadable / a directory / an un-writable dir: report it and move on. This
776
+ // path runs over EVERY registered project from `uninstall --unadopt-all`, so
777
+ // one bad file used to abort the whole sweep with a raw stack trace (P1-16).
778
+ let claudeMdUnreadable = false;
779
+ let claudeMdUnwritable = false;
651
780
  if (fs.existsSync(cPath)) {
652
- const before = fs.readFileSync(cPath, 'utf8');
653
- const after = stripSentinelBlock(before);
654
- if (after !== before) {
655
- blockPruned = true;
656
- // Only delete a file we could have created. A symlinked CLAUDE.md points
657
- // at something the user owns (a shared team file, a dotfiles repo);
658
- // unlinking it removes their link, and the "file we created" rationale
659
- // does not apply. Write the stripped text through the link instead.
660
- if (after.trim() === '' && !fs.lstatSync(cPath).isSymbolicLink()) {
661
- fs.unlinkSync(cPath);
662
- claudeMdRemoved = true;
663
- } else {
664
- writeFileAtomic(cPath, after, { followLink: true });
781
+ let before;
782
+ try {
783
+ before = fs.readFileSync(cPath, 'utf8');
784
+ } catch { claudeMdUnreadable = true; }
785
+ if (before !== undefined) {
786
+ const after = stripSentinelBlock(before);
787
+ if (after !== before) {
788
+ try {
789
+ // Only delete a file we could have created. A symlinked CLAUDE.md points
790
+ // at something the user owns (a shared team file, a dotfiles repo);
791
+ // unlinking it removes their link, and the "file we created" rationale
792
+ // does not apply. Write the stripped text through the link instead.
793
+ if (after.trim() === '' && !fs.lstatSync(cPath).isSymbolicLink()) {
794
+ fs.unlinkSync(cPath);
795
+ claudeMdRemoved = true;
796
+ } else {
797
+ writeFileAtomic(cPath, after, { followLink: true });
798
+ }
799
+ // Only after the write lands: `blockPruned` drives the "De-blocked"
800
+ // line, and claiming it for a write that threw is the same class of
801
+ // false success this batch keeps finding.
802
+ blockPruned = true;
803
+ } catch { claudeMdUnwritable = true; }
665
804
  }
666
805
  }
667
806
  }
@@ -669,8 +808,12 @@ function unadopt({ cwd, home } = {}) {
669
808
  // Also sweep any legacy memory-dir remnants (uninstall before auto-migration ran).
670
809
  const migrated = migrateLegacyMemoryDir({ cwd, home });
671
810
 
672
- removeAdopted(effectiveCwd, home);
673
- return { ok: true, fileRemoved, blockPruned, claudeMdRemoved, target: dPath, claudeMdPath: cPath, migrated };
811
+ const registryUpdated = removeAdopted(effectiveCwd, home);
812
+ return {
813
+ ok: true, fileRemoved, blockPruned, claudeMdRemoved,
814
+ claudeMdUnreadable, claudeMdUnwritable, registryUpdated,
815
+ target: dPath, claudeMdPath: cPath, migrated,
816
+ };
674
817
  }
675
818
 
676
819
  function formatResult(action, result) {
@@ -688,6 +831,18 @@ function formatResult(action, result) {
688
831
  if (result.reason === 'no-template') {
689
832
  return `[code-graph] Template missing: ${result.template}`;
690
833
  }
834
+ // Name the file and the OS error: "adopt failed: claude-md-unreadable" is
835
+ // not something a user can act on, and this is the arm a root-owned
836
+ // CLAUDE.md lands in.
837
+ if (result.reason === 'claude-md-unreadable' || result.reason === 'claude-md-unwritable') {
838
+ const what = result.reason === 'claude-md-unreadable' ? 'read' : 'write';
839
+ return `[code-graph] Cannot ${what} ${result.claudeMdPath} (${result.error || 'unknown error'}).\n` +
840
+ ` Nothing was changed. Fix its permissions (or move it aside) and re-run;\n` +
841
+ ' opt out entirely with CODE_GRAPH_NO_AUTO_ADOPT=1.';
842
+ }
843
+ if (result.reason === 'detail-unwritable') {
844
+ return `[code-graph] Cannot write ${result.detailPath} (${result.error || 'unknown error'}). Nothing was changed.`;
845
+ }
691
846
  return `[code-graph] adopt failed: ${result.reason || 'unknown'}`;
692
847
  }
693
848
  const lines = [];
@@ -707,11 +862,16 @@ function formatResult(action, result) {
707
862
  if (result.claudeMdRemoved) lines.push(`[code-graph] Removed → ${result.claudeMdPath} (was code-graph-only)`);
708
863
  else if (result.blockPruned) lines.push(`[code-graph] De-blocked → ${result.claudeMdPath}`);
709
864
  if (result.fileRemoved) lines.push(`[code-graph] Removed → ${result.target}`);
865
+ if (result.claudeMdUnreadable || result.claudeMdUnwritable) {
866
+ lines.push(`[code-graph] Could not ${result.claudeMdUnreadable ? 'read' : 'write'} ${result.claudeMdPath} — ` +
867
+ 'the managed block (if any) is still there. Fix its permissions and re-run.');
868
+ }
710
869
  const m = result.migrated || {};
711
870
  if (m.memoryIndexPruned || m.legacyDetailRemoved) {
712
871
  lines.push('[code-graph] Cleaned legacy memory-dir artifacts.');
713
872
  }
714
873
  if (!result.blockPruned && !result.fileRemoved && !result.claudeMdRemoved &&
874
+ !result.claudeMdUnreadable && !result.claudeMdUnwritable &&
715
875
  !(m.memoryIndexPruned || m.legacyDetailRemoved)) {
716
876
  lines.push('[code-graph] Nothing to unadopt');
717
877
  }
@@ -177,7 +177,11 @@ function shouldCheck(state, { force = false, binaryMissing = false, binaryStale
177
177
  if (state.rateLimited) return elapsed >= RATE_LIMIT_INTERVAL_MS;
178
178
  if (binaryMissing) return true;
179
179
  if (!isUpdateSuspended(state)) {
180
- if (binaryStale) return true;
180
+ // ...and only while that heal still has a retry budget. Once it is spent,
181
+ // the bypass re-fetched the API and re-entered the ~40MB download on every
182
+ // single session (P1-14) — the same reasoning that keeps `binaryStale` out
183
+ // of the suspended branch.
184
+ if (binaryStale && !isBinaryHealExhausted(state)) return true;
181
185
  if (force) return elapsed >= SESSION_START_MIN_GAP_MS;
182
186
  }
183
187
  const interval = state.updateAvailable === false ? UP_TO_DATE_RECHECK_MS : CHECK_INTERVAL_MS;
@@ -726,9 +730,82 @@ async function downloadAndInstall(latest, {
726
730
  * on the shell-matches-latest path. Extracted + injectable so the wiring itself is
727
731
  * regression-tested, not just the predicate. Returns true iff a download promoted.
728
732
  */
729
- async function selfHealStaleBinary(latest, { needsUpdate = cachedBinaryNeedsUpdate, download = downloadBinary } = {}) {
730
- if (!needsUpdate(latest)) return false;
731
- return await download(latest);
733
+ /**
734
+ * Replace a missing/stale cached binary — BOUNDED, per target version.
735
+ *
736
+ * This had no counter, so a promote that could not land (the Windows case named
737
+ * at promoteVerifiedBinary: the running MCP server holds the .exe, rename →
738
+ * EACCES) re-downloaded ~40MB on every session forever: the caller cleared
739
+ * `updateAttempts`/`suspendedAt` unconditionally right after calling this, and
740
+ * `shouldCheck`'s `binaryStale` arm bypasses the throttle (audit 2026-08-16
741
+ * P1-14; measured 8 calls → 8 downloads).
742
+ *
743
+ * Counted the same way as selfHealGlobalPkgs, including its hard-won rule:
744
+ * success is "the binary is no longer stale", NOT "download() returned true".
745
+ * A download whose promote silently failed used to reset the budget on every
746
+ * run, which is a cap that can never be reached.
747
+ *
748
+ * The counter is deliberately SEPARATE from `updateAttempts`: that one tracks
749
+ * the plugin-shell update, and the branch this runs in resets it because the
750
+ * shell IS current. Sharing it would have made each reset re-arm the other.
751
+ *
752
+ * @returns {{healed: boolean, patch: object}} patch is spread into the state save
753
+ */
754
+ async function selfHealStaleBinary(latest, {
755
+ state = {}, needsUpdate = cachedBinaryNeedsUpdate, download = downloadBinary,
756
+ // "Present" must mean USABLE, not merely on disk. A truncated, non-executable
757
+ // or wrong-arch cached binary leaves the MCP server exactly as dead as a
758
+ // missing one, and every sibling predicate here already treats unreadable as
759
+ // needing replacement (cachedBinaryNeedsUpdate, cachedBinaryStaleVsState).
760
+ // Keying on existsSync alone put a corrupt binary under the stale budget,
761
+ // which isBinaryHealExhausted only re-arms when a NEW release ships — so five
762
+ // quick failures parked the only recovery path permanently (pre-tag review of
763
+ // the P1-14 fix).
764
+ binaryPresent = () => {
765
+ const p = cachedBinaryPath();
766
+ return fs.existsSync(p) && readBinaryVersion(p) !== null;
767
+ },
768
+ } = {}) {
769
+ if (!latest || !needsUpdate(latest)) {
770
+ // Healthy → clear any leftover counter so the next real staleness starts fresh.
771
+ return {
772
+ healed: false,
773
+ patch: state.binaryHealAttempts ? { binaryHealAttempts: 0, binaryHealVersion: null } : {},
774
+ };
775
+ }
776
+ // A MISSING binary is exempt from the attempt budget: with no engine at all
777
+ // the MCP server is dead, and `needsUpdate` returns true for "absent" too —
778
+ // letting the stale-heal counter absorb those failures would permanently
779
+ // park the only recovery path after five offline session starts (batch
780
+ // review of the P1-14 fix). The budget exists to stop re-downloading over a
781
+ // binary that RUNS but cannot be replaced (Windows EACCES-on-rename); a
782
+ // missing binary keeps the pre-P1-14 unbounded retry on purpose.
783
+ const missing = !binaryPresent();
784
+ const attempts = state.binaryHealVersion === latest.version ? (state.binaryHealAttempts || 0) : 0;
785
+ if (!missing && attempts >= MAX_UPDATE_ATTEMPTS) return { healed: false, patch: {} };
786
+ await download(latest);
787
+ // Re-read the disk, not the return value (see above).
788
+ const stillStale = needsUpdate(latest);
789
+ return {
790
+ healed: !stillStale,
791
+ patch: {
792
+ binaryHealVersion: latest.version,
793
+ binaryHealAttempts: !stillStale ? 0 : missing ? attempts : attempts + 1,
794
+ },
795
+ };
796
+ }
797
+
798
+ /**
799
+ * The stale-binary heal has spent its budget on the release we are tracking.
800
+ * Read by shouldCheck: with the heal parked, the `binaryStale` throttle bypass
801
+ * can accomplish nothing and would just re-fetch the API (and, worse, re-enter
802
+ * the download path) every session. Re-arms itself when `latestVersion` moves.
803
+ */
804
+ function isBinaryHealExhausted(state) {
805
+ return Boolean(state)
806
+ && Boolean(state.binaryHealVersion)
807
+ && state.binaryHealVersion === state.latestVersion
808
+ && (state.binaryHealAttempts || 0) >= MAX_UPDATE_ATTEMPTS;
732
809
  }
733
810
 
734
811
  // ── Global npm package self-heal ───────────────────────────
@@ -1052,7 +1129,8 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1052
1129
  // OR stale (see selfHealStaleBinary). The shell version (manifest.version)
1053
1130
  // can match latest while the cached binary lags — this is exactly the wild
1054
1131
  // failure observed in the field (shell at v0.45, binary pinned at v0.16.6).
1055
- const selfHealedBinary = await selfHealStaleBinary(latest);
1132
+ const binaryHeal = await selfHealStaleBinary(latest, { state });
1133
+ const selfHealedBinary = binaryHeal.healed;
1056
1134
 
1057
1135
  // Same for the GLOBAL npm delivery surface (the `code-graph-mcp` CLI on
1058
1136
  // PATH + any explicitly-installed platform package): nothing else ever
@@ -1078,6 +1156,10 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1078
1156
  suspendedAt: null,
1079
1157
  rateLimited: false,
1080
1158
  binaryUpdated: selfHealedBinary || state.binaryUpdated,
1159
+ // The shell-update counters above reset because the shell IS current.
1160
+ // The BINARY heal keeps its own, un-reset budget — clearing it here is
1161
+ // what made the stale-binary re-download unbounded (P1-14).
1162
+ ...binaryHeal.patch,
1081
1163
  ...globalHeal,
1082
1164
  });
1083
1165
  return selfHealedBinary
@@ -1101,7 +1183,7 @@ module.exports = {
1101
1183
  PLUGIN_ASSET_NAME,
1102
1184
  downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
1103
1185
  getPlatformAssetName,
1104
- selfHealStaleBinary,
1186
+ selfHealStaleBinary, isBinaryHealExhausted,
1105
1187
  selfHealGlobalPkgs, staleGlobalPkgs, globalPkgVersion, npmInstallGlobal,
1106
1188
  shouldHealGlobalsOnThrottle, inactiveNodeGlobalRelics,
1107
1189
  downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,