@sdsrs/code-graph 0.116.0 → 0.118.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
 
@@ -109,14 +109,18 @@ Real-world benchmarks comparing code-graph-mcp tools against traditional approac
109
109
  ```
110
110
  src/
111
111
  ├── domain.rs # Shared constants, relation types, env-var config
112
+ ├── resolve.rs # Shared symbol resolution + ambiguity verdicts (CLI and MCP)
113
+ ├── outcome.rs # Retrieval-adoption metrics from session transcripts
114
+ ├── cli/ # Every `code-graph-mcp <cmd>` subcommand (one file per command)
112
115
  ├── mcp/ # MCP protocol layer (JSON-RPC, tool registry, server)
113
116
  │ └── server/ # McpServer with IndexingState + CacheState sub-structs
114
117
  ├── parser/ # Tree-sitter parsing, relation extraction, LanguageConfig dispatch
115
118
  ├── indexer/ # 3-phase pipeline, Merkle tree, file watcher
116
- ├── storage/ # SQLite schema (v6), CRUD, FTS5, migrations
119
+ ├── storage/ # SQLite schema (v10), CRUD, FTS5, migrations
117
120
  ├── graph/ # Recursive CTE call graph queries
118
121
  ├── search/ # RRF fusion search combining BM25 + vector
119
122
  ├── embedding/ # Candle embedding model (optional, masked mean pooling)
123
+ ├── snapshot/ # Portable graph snapshots (create / verify / install)
120
124
  ├── sandbox/ # Context compressor with token estimation
121
125
  └── utils/ # Language detection, config
122
126
  ```
@@ -125,7 +129,7 @@ src/
125
129
 
126
130
  ### Option 1: Claude Code Plugin (Recommended)
127
131
 
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:
132
+ 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
133
 
130
134
  ```bash
131
135
  # Step 1: Add the marketplace
@@ -137,11 +141,11 @@ Install as a Claude Code plugin for the best experience — includes slash comma
137
141
 
138
142
  What you get:
139
143
  - **MCP Server** — All code-graph tools available to Claude
140
- - **Slash Commands** — `/understand <module>`, `/trace <route>`, `/impact <symbol>`
144
+ - **Skills** — `explore` (structure-first navigation before reading files) and `index` (health-check / re-index / full rebuild); see [Plugin Skills](#plugin-skills)
141
145
  - **Code Explorer Agent** — Deep code understanding expert via `code-explorer`
142
146
  - **Auto-indexing Hook** — Incremental index on every file edit (PostToolUse)
143
147
  - **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
148
+ - **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
149
 
146
150
  #### Manual Update
147
151
 
@@ -301,22 +305,38 @@ All tools are also available as CLI subcommands for shell scripts, hooks, and te
301
305
  | `incremental-index` | — | Run incremental index update (auto-creates DB if needed) |
302
306
  | `health-check` | `get_index_status` | Query index status and freshness |
303
307
  | `benchmark` | — | Benchmark index speed, query latency, token savings |
308
+ | `affected [files…]` | — | Changed files → the test files to re-run (`--stdin`, `--depth`) |
309
+ | `tour [path]` | — | Dependency-ordered reading order for a repo or subtree |
310
+ | `centrality` | `project_map` (`include_centrality=true`) | Rank architectural chokepoints (betweenness over the call graph) |
311
+ | `cycles` | — | Detect circular import dependencies (file-level) |
312
+ | `surprising` | — | Surface unexpected cross-module couplings (uncertain edges) |
313
+ | `report` | — | Consolidated code-health report (summary + all analyses) |
314
+ | `stats` | — | Aggregate session metrics from `.code-graph/usage.jsonl` |
315
+ | `outcome` | — | Retrieval adoption from session transcripts (field-MRR; read-only) |
316
+ | `rebuild-index` | `rebuild_index` | Drop and rebuild the index from scratch (requires `--confirm`) |
317
+ | `reindex` | — | Incremental refresh; `--from-snapshot` refetches the published snapshot |
318
+ | `snapshot create\|inspect` | — | Build or inspect a portable graph snapshot |
319
+ | `doctor` | — | Diagnose and repair environment issues |
320
+ | `adopt` | — | Install the steering block into the project `CLAUDE.md` + detail doc |
321
+ | `unadopt` | — | Remove the steering block + detail doc |
322
+ | `serve` | — | Start the MCP JSON-RPC server on stdio (the default with no subcommand) |
304
323
 
305
324
  Common options: `--json` (JSON output), `--compact` (compact output), `--limit N`, `--depth N`, `--file <path>`.
306
325
 
307
326
  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
327
 
309
- ## Plugin Slash Commands
328
+ ## Plugin Skills
310
329
 
311
- Available when installed as a Claude Code plugin:
330
+ Installing the plugin ships two skills that Claude loads on its own when the
331
+ situation matches — there are no slash commands to remember:
312
332
 
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 |
333
+ | Skill | Loaded when | What it does |
334
+ |-------|-------------|--------------|
335
+ | `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 |
336
+ | `index` | Search returns empty or stale results, or after a large restructuring | Walks `health-check`, incremental re-index, and full rebuild |
337
+
338
+ Both are thin routers over the CLI subcommands documented above, so anything a
339
+ skill does is also runnable by hand.
320
340
 
321
341
  ## Supported Languages (19)
322
342
 
@@ -438,7 +458,10 @@ Data is stored in `.code-graph/index.db` under the project root (auto-created, g
438
458
 
439
459
  ### Prerequisites
440
460
 
441
- - Rust 1.75+ (2021 edition)
461
+ - Rust 1.95.0 (2021 edition) — the toolchain CI and the release build pin
462
+ (`dtolnay/rust-toolchain@1.95.0` in `.github/workflows/`). No older toolchain
463
+ is tested: `Cargo.lock` is lockfile **version 4**, which Cargo 1.75 cannot
464
+ read at all, and no `rust-version` floor is declared in `Cargo.toml`.
442
465
  - A C compiler (for bundled SQLite / sqlite-vec)
443
466
 
444
467
  ### Build
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.116.0",
7
+ "version": "0.118.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
  }