@sdsrs/code-graph 0.73.1 → 0.74.1

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.
@@ -1,29 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- // adopt / unadopt — writes plugin_code_graph_mcp.md into this project's
4
- // Claude Code auto-memory dir (~/.claude/projects/<slug>/memory/, also
5
- // read/written by claude-mem-lite) and maintains a sentinel-bracketed index
6
- // entry in MEMORY.md. Idempotent. Used by invited-memory pattern with
7
- // CODE_GRAPH_QUIET_HOOKS=1.
3
+ // adopt / unadopt — installs the code-graph steering into this project:
4
+ // <cwd>/CLAUDE.md — a concise, sentinel-bracketed managed block
5
+ // (always-loaded; the router/decision summary)
6
+ // <cwd>/.claude/plugin_code_graph_mcp.md the full decision table
7
+ // (on-demand; NOT auto-loaded each session)
8
+ // Idempotent. Replaces the pre-v0.74 "adopt into the auto-memory dir" scheme,
9
+ // which wrote into ~/.claude/projects/<slug>/memory/ (MEMORY.md sentinel +
10
+ // detail file) — equal weight to CLAUDE.md but polluting claude-mem-lite's
11
+ // index. migrateLegacyMemoryDir() cleans those legacy artifacts on upgrade.
8
12
  const fs = require('fs');
9
13
  const path = require('path');
10
14
  const os = require('os');
11
15
  const { PROJECT_MARKERS, isProjectRoot, isNonProjectCwd } = require('./project-detect');
12
16
 
13
- const SENTINEL_BEGIN = '<!-- code-graph-mcp:begin v1 -->';
17
+ // Managed-block sentinels. Bumped to v2 when the steering target moved from the
18
+ // auto-memory dir's MEMORY.md (v1) to the project's CLAUDE.md (v2). The strip
19
+ // regex matches ANY version so migration can remove the legacy v1 MEMORY.md block.
20
+ const SENTINEL_VERSION = 'v2';
21
+ const SENTINEL_BEGIN = `<!-- code-graph-mcp:begin ${SENTINEL_VERSION} -->`;
14
22
  const SENTINEL_END = '<!-- code-graph-mcp:end -->';
15
- // Collision-detection marker. Slug encoding `[^a-zA-Z0-9-]'-'` is lossy,
16
- // so two cwds (e.g. /foo/bar and /foo bar) can resolve to the same memory
17
- // dir. Adopt records its absolute cwd as the file's first-line HTML comment;
18
- // re-adopt from a different cwd surfaces a warning.
19
- const ADOPTED_BY_RE = /^<!-- adopted-by: (.+?) -->\r?\n?/;
20
- function readAdoptedBy(filePath) {
21
- try {
22
- const first = fs.readFileSync(filePath, 'utf8').split('\n', 1)[0];
23
- const m = first.match(/^<!-- adopted-by: (.+?) -->/);
24
- return m ? m[1] : null;
25
- } catch { return null; }
26
- }
23
+ const SENTINEL_BEGIN_SRC = '<!-- code-graph-mcp:begin[^>]*-->';
24
+ // Marker on the first line of the installed .claude/plugin_code_graph_mcp.md so
25
+ // unadopt/needsRefresh can distinguish our generated copy from a user's own file
26
+ // of the same name (and so needsRefresh strips it before the bytewise compare).
27
+ const MANAGED_BY = '<!-- managed-by: code-graph-mcp -->';
28
+ // Legacy first-line marker on the old memory-dir detail file; migration deletes
29
+ // only files carrying it (never a user file that happens to share the name).
30
+ const LEGACY_ADOPTED_BY = '<!-- adopted-by:';
27
31
  // Atomic write (tmp in same dir → rename) so a crash mid-write can't leave a
28
32
  // half-written MEMORY.md / detail file — the dir is shared with claude-mem-lite,
29
33
  // which reads MEMORY.md on every keyword match. Mirrors lifecycle.js
@@ -41,40 +45,68 @@ function writeFileAtomic(filePath, data) {
41
45
  throw e;
42
46
  }
43
47
  }
44
- // One-liner per MEMORY.md spec ("each entry should be one line"). All routing
45
- // triggers from prior multi-line block preserved verbatim collapsing to single
46
- // line is a structural fix, not a signal change. Decision table lives in the
47
- // linked plugin_code_graph_mcp.md; this line is the router. Tag syntax
48
- // `[tag1, tag2]` per spec for explicit keyword matching.
49
- //
50
- // Generic default — used when no project-type markers detected (e.g. /tmp,
51
- // scratch dirs, mixed repos). Per-type variants live in `buildIndexLine` and
52
- // are computed per-cwd at adopt + needsRefresh time. Adopted-project receives
53
- // the typed variant; everyone else falls back to this canonical line.
54
- // Tags MUST be ≥4 chars and topic-specific (per claudemd §11-EXT Tag-specificity).
55
- // Generic single-word English tags (impact / refs / overview / semantic / deps /
56
- // trace / route / similar) substring-match release-notes / commit-message prose
57
- // via the §11 read-the-file hook regex (word-boundary + 0–2 declension chars),
58
- // producing false-positive denies. Each tag below aligns with its MCP tool name
59
- // (impact_analysis / find_references / module_overview / …) so hyphenated literals
60
- // never collide with natural prose.
61
- const INDEX_LINE =
62
- '- [code-graph-mcp](plugin_code_graph_mcp.md) ' +
63
- '[impact-analysis, callgraph, find-references, module-overview, semantic-search, ast-search, dead-code, find-similar-code, dependency-graph, trace-http-chain] — ' +
64
- '改 X 影响面/谁调用 X/X 被谁用/看 X 源码/Y 模块长啥样/概念查询 优先于 Grep;字面匹配走 Grep。' +
65
- 'Bash 直呼 CLI 最快(零加载):`code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`;' +
66
- 'MCP 核心 7(get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map),决策表见全文';
67
-
68
- // memdir L1 升格 (per sdscc 重构方案 §5.0): the INDEX_LINE that lands in
69
- // MEMORY.md is what Claude sees first on every keyword match. Tailoring it
70
- // per project type primes the right tools and demotes the irrelevant ones —
71
- // e.g. a Rust CLI never benefits from `trace_http_chain` priming, and a React
72
- // frontend cares more about `find_references` for rename audits than `impact`.
73
- //
74
- // Detection is cheap substring-on-marker (no AST, no graph): the cost is one
75
- // fs.readFileSync per cwd. Failure mode is silent fall-back to 'generic' —
76
- // false-negatives are strictly safer than false-positives that promote the
77
- // wrong tool.
48
+ // The managed block written into <cwd>/CLAUDE.md. Concise + always-loaded: a
49
+ // scannable trigger table that primes the right tool, ending with a pointer to
50
+ // the full table at .claude/plugin_code_graph_mcp.md (opened on demand, never
51
+ // auto-loaded). Project-type tailoring swaps a couple of rows (web → HTTP-route
52
+ // tracing; frontend reference audits) body of the detail doc is unchanged.
53
+ const BLOCK_HEADING = '## Code Graph (repo-wide AST index)';
54
+
55
+ function buildTriggerRows(projectType = 'generic') {
56
+ const base = [
57
+ ['Who calls X / what X calls', '`code-graph-mcp callgraph X`'],
58
+ ['Impact before editing a fn', '`code-graph-mcp impact X`'],
59
+ ['Unfamiliar dir / module', '`code-graph-mcp overview <dir>`'],
60
+ ['Symbol source / signature', '`code-graph-mcp show X`'],
61
+ ['Concept search (no exact name)', '`code-graph-mcp search "…"` (vector: MCP `semantic_code_search`)'],
62
+ ['grep + AST context', '`code-graph-mcp grep "pat" [paths]`'],
63
+ ];
64
+ switch (projectType) {
65
+ case 'web-rs':
66
+ case 'web-node':
67
+ case 'web-py':
68
+ case 'web-go':
69
+ // HTTP route handler chain matters; insert after the impact row.
70
+ return [base[0], base[1],
71
+ ['HTTP route → handler chain', '`code-graph-mcp trace "GET /api/x"`'],
72
+ ...base.slice(2)];
73
+ case 'frontend':
74
+ // Rename/refactor audits dominate; surface find-references explicitly.
75
+ return [base[0],
76
+ ['Rename / refactor audit (refs)', '`code-graph-mcp refs X`'],
77
+ ...base.slice(1)];
78
+ default:
79
+ return base;
80
+ }
81
+ }
82
+
83
+ // Build the full sentinel-wrapped managed block for a project type. Deterministic
84
+ // (same type → byte-identical) so needsRefresh can bytewise-detect drift.
85
+ function buildBlock(projectType = 'generic') {
86
+ const rows = buildTriggerRows(projectType);
87
+ const table = ['| Intent | Command |', '|--------|---------|']
88
+ .concat(rows.map(([intent, cmd]) => `| ${intent} | ${cmd} |`))
89
+ .join('\n');
90
+ const body = [
91
+ BLOCK_HEADING,
92
+ '',
93
+ 'AST + FTS + vector index of the whole repo — prefer over multi-round Grep/Read for',
94
+ 'structural queries (LSP only sees open files; this sees everything). Fastest path = Bash CLI:',
95
+ '',
96
+ table,
97
+ '',
98
+ "Still use Grep for literal strings/regex in non-code files; still Read files you'll edit.",
99
+ 'Full command + MCP-tool table: `.claude/plugin_code_graph_mcp.md`',
100
+ ].join('\n');
101
+ return `${SENTINEL_BEGIN}\n${body}\n${SENTINEL_END}`;
102
+ }
103
+
104
+ // Project-type detection tailors the CLAUDE.md block's trigger rows (buildBlock):
105
+ // a Rust CLI never benefits from HTTP-route tracing, a React frontend cares more
106
+ // about find-references for rename audits. Detection is cheap substring-on-marker
107
+ // (no AST, no graph): one fs.readFileSync per cwd. Failure mode is silent
108
+ // fall-back to 'generic' — false-negatives are strictly safer than false-positives
109
+ // that promote the wrong tool.
78
110
  function readFileQuiet(p) {
79
111
  try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
80
112
  }
@@ -241,53 +273,12 @@ function detectProjectType(cwd = process.cwd(), env = process.env) {
241
273
  return 'generic';
242
274
  }
243
275
 
244
- // Build the MEMORY.md index line for a project type. The 'generic' bucket
245
- // returns the canonical INDEX_LINE so untyped projects (and the existing
246
- // adopt.test.js fixtures, which use empty tmp dirs) stay byte-identical.
247
- //
248
- // For typed projects, the difference from generic is the tag set + the lead
249
- // sentence — body of plugin_code_graph_mcp.md is unchanged. Decision table
250
- // stays one source of truth; the index line just primes which subset matters
251
- // most for THIS project.
252
- function buildIndexLine(projectType = 'generic') {
253
- const prefix = '- [code-graph-mcp](plugin_code_graph_mcp.md) ';
254
- // v0.49 — CLI form leads: in Claude Code the MCP tools are deferred (need a
255
- // ToolSearch load before first call) while Bash is always live; the only
256
- // conversions observed in real coding nights were CLI invocations.
257
- const coreSuffix =
258
- 'Bash 直呼 CLI 最快(零加载):`code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`;' +
259
- 'MCP 核心 7(get_call_graph/module_overview/semantic_code_search/ast_search/find_references/get_ast_node/project_map),决策表见全文';
260
- switch (projectType) {
261
- case 'web-rs':
262
- case 'web-node':
263
- case 'web-py':
264
- case 'web-go':
265
- return prefix +
266
- '[trace-http-chain, http-route, callgraph, impact-analysis, find-references, module-overview, semantic-search, dependency-graph] — ' +
267
- 'HTTP 路由→handler 链路用 trace_http_chain(或 get_call_graph route_path=);改 handler 影响面用 impact;' +
268
- '其他结构化查询同上 优先于 Grep。' + coreSuffix;
269
- case 'frontend':
270
- return prefix +
271
- '[find-references, module-overview, semantic-search, callgraph, impact-analysis, ast-search] — ' +
272
- '组件重命名/重构用 find_references(含 imports/inherits);模块层级用 module_overview;' +
273
- '改 props/接口前用 impact 看下游;HTTP route 通常不适用。' + coreSuffix;
274
- case 'rust':
275
- case 'go':
276
- case 'python':
277
- case 'node':
278
- return prefix +
279
- '[callgraph, impact-analysis, find-references, module-overview, semantic-search, ast-search, dead-code, dependency-graph] — ' +
280
- '改 X 影响面/谁调用 X/Y 模块 优先于 Grep;HTTP route 追踪通常不适用(无 web 框架);' +
281
- '字面匹配走 Grep。' + coreSuffix;
282
- case 'generic':
283
- default:
284
- return INDEX_LINE;
285
- }
286
- }
287
276
  const TEMPLATE_PATH = path.resolve(__dirname, '..', 'templates', 'plugin_code_graph_mcp.md');
288
277
  const TARGET_NAME = 'plugin_code_graph_mcp.md';
289
278
 
290
- // Claude Code slug convention: every non-alphanumeric-non-hyphen char `-`.
279
+ // LEGACY (pre-v0.74) memory-dir path now used ONLY by migrateLegacyMemoryDir
280
+ // to locate and remove the old MEMORY.md sentinel + detail file. Claude Code slug
281
+ // convention: every non-alphanumeric-non-hyphen char → `-`.
291
282
  // `/mnt/data_ssd/dev/proj` → `-mnt-data-ssd-dev-proj`
292
283
  // `/home/sds/.claude/x` → `-home-sds--claude-x` (double-dash from `/.`)
293
284
  //
@@ -300,6 +291,13 @@ function memoryDir(cwd = process.cwd(), home = os.homedir()) {
300
291
  return path.join(claudeDir, 'projects', slug, 'memory');
301
292
  }
302
293
 
294
+ // New-scheme targets: steering lives in the project tree, not the memory dir.
295
+ // CLAUDE.md is auto-loaded each session (concise block); .claude/<detail> is
296
+ // opened on demand. Both keyed off the project cwd — no lossy slug, no collision.
297
+ function claudeMdPath(cwd = process.cwd()) { return path.join(cwd, 'CLAUDE.md'); }
298
+ function detailDir(cwd = process.cwd()) { return path.join(cwd, '.claude'); }
299
+ function detailPath(cwd = process.cwd()) { return path.join(detailDir(cwd), TARGET_NAME); }
300
+
303
301
  function escapeRegex(s) {
304
302
  return s.replace(/[\\/[\]^$.*+?()|{}]/g, '\\$&');
305
303
  }
@@ -307,16 +305,18 @@ function escapeRegex(s) {
307
305
  // Strip our sentinel block — well-formed first, then self-heal orphan begin/end.
308
306
  // Shared by adopt (so re-adopt rewrites a stale/malformed block) and unadopt.
309
307
  function stripSentinelBlock(text) {
308
+ // Match ANY begin version (v1 legacy MEMORY.md block, v2 CLAUDE.md block) so a
309
+ // single strip handles both the new target and the legacy migration cleanup.
310
310
  const wellFormed = new RegExp(
311
- `${escapeRegex(SENTINEL_BEGIN)}[\\s\\S]*?${escapeRegex(SENTINEL_END)}\\n?`, 'g'
311
+ `${SENTINEL_BEGIN_SRC}[\\s\\S]*?${escapeRegex(SENTINEL_END)}\\n?`, 'g'
312
312
  );
313
313
  let out = text.replace(wellFormed, '');
314
314
  // Orphan BEGIN with no matching END (truncation / partial edit).
315
- // Strip from BEGIN to the next blank line or EOF — the file is shared with
316
- // claude-mem-lite, so we must not eat past a blank-line boundary.
317
- if (out.includes(SENTINEL_BEGIN)) {
315
+ // Strip from BEGIN to the next blank line or EOF — the file may be shared with
316
+ // claude-mem-lite (legacy MEMORY.md), so we must not eat past a blank-line boundary.
317
+ if (new RegExp(SENTINEL_BEGIN_SRC).test(out)) {
318
318
  out = out.replace(
319
- new RegExp(`${escapeRegex(SENTINEL_BEGIN)}[\\s\\S]*?(?=\\n\\n|$)`, 'g'),
319
+ new RegExp(`${SENTINEL_BEGIN_SRC}[\\s\\S]*?(?=\\n\\n|$)`, 'g'),
320
320
  ''
321
321
  );
322
322
  }
@@ -339,103 +339,89 @@ function platformGuard() {
339
339
  // now lives in project-detect.js — the single activation gate shared with
340
340
  // mcp-launcher.js and session-init.js. Imported above and re-exported below.
341
341
 
342
- function adopt({ cwd, home, templatePath } = {}) {
342
+ function adopt({ cwd, templatePath } = {}) {
343
343
  const blocked = platformGuard();
344
344
  if (blocked) return blocked;
345
345
 
346
346
  const effectiveCwd = cwd || process.cwd();
347
- // Gate adoption on a real-project cwd BEFORE touching the filesystem. The
348
- // check must run even when the memory dir already exists: Claude Code
349
- // pre-creates ~/.claude/projects/<slug>/memory for every session (including
350
- // the ~2035 headless /tmp mem-lite calls), and the old guard — nested inside
351
- // `if (!fs.existsSync(dir))` — was bypassed in exactly that case, letting
352
- // /tmp get adopted (sentinel written into its MEMORY.md). See project-detect.js.
347
+ // Gate on a real-project cwd BEFORE touching the filesystem — Claude Code also
348
+ // spawns headless /tmp sessions (claude-mem-lite); we must leave those alone.
353
349
  if (isNonProjectCwd(effectiveCwd)) {
354
- return { ok: false, reason: 'not-a-project', dir: memoryDir(cwd, home), cwd: effectiveCwd };
350
+ return { ok: false, reason: 'not-a-project', cwd: effectiveCwd };
355
351
  }
356
- const dir = memoryDir(cwd, home);
357
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
358
- const target = path.join(dir, TARGET_NAME);
359
352
  const tpl = templatePath || TEMPLATE_PATH;
360
353
  if (!fs.existsSync(tpl)) {
361
354
  return { ok: false, reason: 'no-template', template: tpl };
362
355
  }
363
- // Slug-collision detection: read prior adopted-by marker before overwrite.
364
- let collisionWith = null;
365
- if (fs.existsSync(target)) {
366
- const prevCwd = readAdoptedBy(target);
367
- if (prevCwd && prevCwd !== effectiveCwd) collisionWith = prevCwd;
368
- }
369
- // Write marker + template. Marker is HTML comment → invisible in rendered
370
- // markdown but preserved by needsRefresh's bytewise compare (skipped via
371
- // ADOPTED_BY_RE strip below).
372
- const tplBody = fs.readFileSync(tpl);
373
- const marker = Buffer.from(`<!-- adopted-by: ${effectiveCwd} -->\n`);
374
- writeFileAtomic(target, Buffer.concat([marker, tplBody]));
375
-
376
- const indexPath = path.join(dir, 'MEMORY.md');
377
- const index = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '# Memory Index\n';
378
- // Per-project index line: tagged tools + lead sentence tailored to the
379
- // detected project type. Falls back to the canonical INDEX_LINE for
380
- // generic / untyped cwds (preserves byte-identity with prior versions).
381
- const indexLine = buildIndexLine(detectProjectType(effectiveCwd));
382
- const desiredBlock = `${SENTINEL_BEGIN}\n${indexLine}\n${SENTINEL_END}`;
383
-
384
- // Already-adopted-and-well-formed: skip the write entirely.
385
- if (index.includes(desiredBlock)) {
386
- return { ok: true, target, indexPath, indexed: false, healed: false, collisionWith };
356
+
357
+ // 1. Install the detail doc at <cwd>/.claude/plugin_code_graph_mcp.md.
358
+ // First line is the MANAGED_BY marker (HTML comment → invisible in rendered
359
+ // markdown) so unadopt/needsRefresh can tell our generated copy from a user
360
+ // file of the same name. needsRefresh strips it before the bytewise compare.
361
+ const dDir = detailDir(effectiveCwd);
362
+ if (!fs.existsSync(dDir)) fs.mkdirSync(dDir, { recursive: true });
363
+ const dPath = detailPath(effectiveCwd);
364
+ const desiredDetail = Buffer.concat([Buffer.from(`${MANAGED_BY}\n`), fs.readFileSync(tpl)]);
365
+ let detailWritten = false;
366
+ if (!fs.existsSync(dPath) || !fs.readFileSync(dPath).equals(desiredDetail)) {
367
+ writeFileAtomic(dPath, desiredDetail);
368
+ detailWritten = true;
387
369
  }
388
370
 
389
- const cleaned = stripSentinelBlock(index);
390
- const healed = cleaned !== index;
391
- const base = cleaned.endsWith('\n') ? cleaned : cleaned + '\n';
392
- writeFileAtomic(indexPath, base + desiredBlock + '\n');
393
- return { ok: true, target, indexPath, indexed: true, healed, collisionWith };
371
+ // 2. Ensure the managed block in <cwd>/CLAUDE.md. Create-if-missing, else
372
+ // inject only our sentinel block is managed; the user's own prose is never
373
+ // touched. Per-project trigger rows tailored to the detected project type.
374
+ const cPath = claudeMdPath(effectiveCwd);
375
+ const block = buildBlock(detectProjectType(effectiveCwd));
376
+ const exists = fs.existsSync(cPath);
377
+ const current = exists ? fs.readFileSync(cPath, 'utf8') : '';
378
+ if (current.includes(block)) {
379
+ return { ok: true, detailPath: dPath, claudeMdPath: cPath, detailWritten, claudeMdWritten: false, created: false, healed: false };
380
+ }
381
+ const cleaned = exists ? stripSentinelBlock(current) : '';
382
+ const healed = exists && cleaned !== current;
383
+ const base = cleaned.replace(/\n+$/, '');
384
+ const prefix = base ? base + '\n\n' : '';
385
+ writeFileAtomic(cPath, prefix + block + '\n');
386
+ return { ok: true, detailPath: dPath, claudeMdPath: cPath, detailWritten, claudeMdWritten: true, created: !exists, healed };
394
387
  }
395
388
 
396
- // v0.9.0 — "已 adopt" 判定:template 文件在 + MEMORY.md 内有我们的 sentinel 块。
389
+ // "已 install" 判定:detail 文件在 + CLAUDE.md 内有我们的 sentinel 块(任意版本)。
397
390
  // 用在 maybeAutoAdopt 里做幂等门,也用在 session-init 里推导 quietHooks。
398
- function isAdopted({ cwd, home } = {}) {
399
- const dir = memoryDir(cwd, home);
400
- const target = path.join(dir, TARGET_NAME);
401
- const indexPath = path.join(dir, 'MEMORY.md');
402
- if (!fs.existsSync(target) || !fs.existsSync(indexPath)) return false;
403
- const index = fs.readFileSync(indexPath, 'utf8');
404
- return index.includes(SENTINEL_BEGIN) && index.includes(SENTINEL_END);
391
+ function isAdopted({ cwd } = {}) {
392
+ const effectiveCwd = cwd || process.cwd();
393
+ const cPath = claudeMdPath(effectiveCwd);
394
+ const dPath = detailPath(effectiveCwd);
395
+ if (!fs.existsSync(dPath) || !fs.existsSync(cPath)) return false;
396
+ const c = fs.readFileSync(cPath, 'utf8');
397
+ return new RegExp(SENTINEL_BEGIN_SRC).test(c) && c.includes(SENTINEL_END);
405
398
  }
406
399
 
407
- // v0.11.0 — shipped template / INDEX_LINE 与已落地版本出现漂移时返回 true
408
- // 让已 adopt 的项目在下次 SessionStart 自动对齐到插件最新决策表,避免"老用户
409
- // 永远停留在首次 adopt 时的 snapshot"。手动编辑会被覆盖——锁定方式:
410
- // CODE_GRAPH_NO_TEMPLATE_REFRESH=1。
411
- function needsRefresh({ cwd, home, templatePath } = {}) {
412
- const dir = memoryDir(cwd, home);
413
- const target = path.join(dir, TARGET_NAME);
414
- const indexPath = path.join(dir, 'MEMORY.md');
400
+ // shipped template / 管理块 与已落地版本出现漂移时返回 true。让已 install 的项目
401
+ // 在下次 SessionStart 自动对齐到插件最新决策表(含 v1→v2 升级、项目类型变更)。
402
+ // 手动编辑会被覆盖——锁定方式:CODE_GRAPH_NO_TEMPLATE_REFRESH=1。
403
+ function needsRefresh({ cwd, templatePath } = {}) {
404
+ const effectiveCwd = cwd || process.cwd();
405
+ const cPath = claudeMdPath(effectiveCwd);
406
+ const dPath = detailPath(effectiveCwd);
415
407
  const tpl = templatePath || TEMPLATE_PATH;
416
- if (!fs.existsSync(target) || !fs.existsSync(tpl) || !fs.existsSync(indexPath)) {
408
+ if (!fs.existsSync(dPath) || !fs.existsSync(cPath) || !fs.existsSync(tpl)) {
417
409
  return false;
418
410
  }
411
+ // Detail-doc body drift — strip the leading MANAGED_BY marker line first.
419
412
  const shipped = fs.readFileSync(tpl);
420
- const current = fs.readFileSync(target);
421
- // Strip the leading "<!-- adopted-by: ... -->\n" collision marker (D fix)
422
- // before bytewise comparing — its presence/path naturally diverges from
423
- // the shipped template.
413
+ const current = fs.readFileSync(dPath);
424
414
  let body = current;
425
415
  const nl = current.indexOf(0x0a);
426
- if (nl > 0 && ADOPTED_BY_RE.test(current.subarray(0, nl + 1).toString())) {
416
+ if (nl > 0 && current.subarray(0, nl).toString().includes('managed-by: code-graph-mcp')) {
427
417
  body = current.subarray(nl + 1);
428
418
  }
429
419
  if (!shipped.equals(body)) return true;
430
- const index = fs.readFileSync(indexPath, 'utf8');
431
- // Compare against the typed INDEX_LINE for this project. Detection is
432
- // deterministic (file-existence + substring scan) so adopt and needsRefresh
433
- // always agree on the variant. Drift triggers refresh — including when a
434
- // project gains a web framework dep and switches type bucket.
435
- const effectiveCwd = cwd || process.cwd();
436
- const indexLine = buildIndexLine(detectProjectType(effectiveCwd));
437
- const desiredBlock = `${SENTINEL_BEGIN}\n${indexLine}\n${SENTINEL_END}`;
438
- return !index.includes(desiredBlock);
420
+ // CLAUDE.md managed-block drift. Detection is deterministic so adopt and
421
+ // needsRefresh always agree on the variant including when a project gains a
422
+ // web-framework dep and switches type bucket, or on a sentinel version bump.
423
+ const block = buildBlock(detectProjectType(effectiveCwd));
424
+ return !fs.readFileSync(cPath, 'utf8').includes(block);
439
425
  }
440
426
 
441
427
  // 检测脚本是否从 Claude Code 插件 cache 运行。
@@ -454,66 +440,120 @@ function isPluginModeInstall(scriptPath = __dirname) {
454
440
  return false;
455
441
  }
456
442
 
457
- // C' 上下文感知默认(v0.9.0):插件模式下首次 SessionStart 静默 adopt。
458
- // /plugin install 本身已构成知情同意;npm / npx / checkout 保持 opt-in。
459
- // 退出:CODE_GRAPH_NO_AUTO_ADOPT=1。
443
+ // One-time per-project cleanup of the legacy memory-dir adoption (pre-v0.74):
444
+ // the v1 sentinel block in MEMORY.md + the adopted-by-marked detail file under
445
+ // ~/.claude/projects/<slug>/memory/. Touches only the CURRENT project's memory
446
+ // dir (a single known path derived from cwd) — no ~/.claude traversal (§8 SAFETY).
447
+ // Idempotent + guarded: only strips OUR sentinel, only deletes a file carrying
448
+ // the adopted-by marker. Safe to run every SessionStart.
449
+ function migrateLegacyMemoryDir({ cwd, home } = {}) {
450
+ const result = { memoryIndexPruned: false, legacyDetailRemoved: false };
451
+ if (platformGuard()) return result;
452
+ const dir = memoryDir(cwd, home);
453
+ const legacyDetail = path.join(dir, TARGET_NAME);
454
+ if (fs.existsSync(legacyDetail)) {
455
+ try {
456
+ const head = fs.readFileSync(legacyDetail, 'utf8').split('\n', 1)[0];
457
+ if (head.startsWith(LEGACY_ADOPTED_BY)) {
458
+ fs.unlinkSync(legacyDetail);
459
+ result.legacyDetailRemoved = true;
460
+ }
461
+ } catch { /* unreadable → leave it */ }
462
+ }
463
+ const legacyIndex = path.join(dir, 'MEMORY.md');
464
+ if (fs.existsSync(legacyIndex)) {
465
+ try {
466
+ const before = fs.readFileSync(legacyIndex, 'utf8');
467
+ const after = stripSentinelBlock(before);
468
+ if (after !== before) {
469
+ writeFileAtomic(legacyIndex, after);
470
+ result.memoryIndexPruned = true;
471
+ }
472
+ } catch { /* unreadable → leave it */ }
473
+ }
474
+ return result;
475
+ }
476
+
477
+ // 上下文感知默认:插件模式下首次 SessionStart 静默安装(创建/注入 CLAUDE.md 块 +
478
+ // .claude/ detail 文件)。/plugin install 本身已构成知情同意;npm / npx / 裸 checkout
479
+ // 保持 opt-in。退出:CODE_GRAPH_NO_AUTO_ADOPT=1。每次 SessionStart 先清理旧 memory-dir
480
+ // 制品(升级自动迁移),再安装/刷新。
460
481
  function maybeAutoAdopt({ cwd, home, env, scriptPath } = {}) {
461
482
  env = env || process.env;
483
+ // Consistent return shape: every path carries `migrated`. The two pre-gate
484
+ // early returns run before migration, so they report the zero result.
485
+ const noMigration = { memoryIndexPruned: false, legacyDetailRemoved: false };
462
486
  if (env.CODE_GRAPH_NO_AUTO_ADOPT === '1') {
463
- return { attempted: false, reason: 'opted-out' };
487
+ return { attempted: false, reason: 'opted-out', migrated: noMigration };
464
488
  }
465
489
  if (!isPluginModeInstall(scriptPath || __dirname)) {
466
- return { attempted: false, reason: 'not-plugin-mode' };
490
+ return { attempted: false, reason: 'not-plugin-mode', migrated: noMigration };
467
491
  }
468
- if (isAdopted({ cwd, home })) {
469
- // v0.11.0: shipped template / INDEX_LINE 漂移时重跑 adopt 对齐。
492
+ // Clean legacy memory-dir artifacts before installing the new CLAUDE.md scheme.
493
+ const migrated = migrateLegacyMemoryDir({ cwd, home });
494
+ if (isAdopted({ cwd })) {
495
+ // shipped template / 管理块 漂移时重跑 adopt 对齐。
470
496
  // opt-out: CODE_GRAPH_NO_TEMPLATE_REFRESH=1(锁定手动编辑)。
471
- if (env.CODE_GRAPH_NO_TEMPLATE_REFRESH !== '1' && needsRefresh({ cwd, home })) {
472
- const result = adopt({ cwd, home });
473
- return { attempted: true, reason: 'refreshed', result };
497
+ if (env.CODE_GRAPH_NO_TEMPLATE_REFRESH !== '1' && needsRefresh({ cwd })) {
498
+ const result = adopt({ cwd });
499
+ return { attempted: true, reason: 'refreshed', result, migrated };
474
500
  }
475
- return { attempted: false, reason: 'already-adopted' };
501
+ return { attempted: false, reason: 'already-adopted', migrated };
476
502
  }
477
- const result = adopt({ cwd, home });
478
- return { attempted: true, reason: 'adopted', result };
503
+ const result = adopt({ cwd });
504
+ return { attempted: true, reason: 'adopted', result, migrated };
479
505
  }
480
506
 
481
507
  function unadopt({ cwd, home } = {}) {
482
508
  const blocked = platformGuard();
483
509
  if (blocked) return blocked;
484
510
 
485
- const dir = memoryDir(cwd, home);
486
- const target = path.join(dir, TARGET_NAME);
487
- const indexPath = path.join(dir, 'MEMORY.md');
511
+ const effectiveCwd = cwd || process.cwd();
512
+ const cPath = claudeMdPath(effectiveCwd);
513
+ const dPath = detailPath(effectiveCwd);
488
514
  let fileRemoved = false;
489
- let indexPruned = false;
490
-
491
- if (fs.existsSync(target)) {
492
- fs.unlinkSync(target);
493
- fileRemoved = true;
515
+ let blockPruned = false;
516
+ let claudeMdRemoved = false;
517
+
518
+ // Detail file — guard on our marker so a user's same-named file is never deleted.
519
+ if (fs.existsSync(dPath)) {
520
+ let mine = false;
521
+ try {
522
+ const head = fs.readFileSync(dPath, 'utf8').split('\n', 1)[0];
523
+ mine = head.includes(MANAGED_BY) || head.startsWith(LEGACY_ADOPTED_BY);
524
+ } catch { mine = false; }
525
+ if (mine) { fs.unlinkSync(dPath); fileRemoved = true; }
494
526
  }
495
- if (fs.existsSync(indexPath)) {
496
- const before = fs.readFileSync(indexPath, 'utf8');
527
+
528
+ // CLAUDE.md strip only our block. If nothing else remains, remove the file
529
+ // we created; otherwise preserve the user's prose.
530
+ if (fs.existsSync(cPath)) {
531
+ const before = fs.readFileSync(cPath, 'utf8');
497
532
  const after = stripSentinelBlock(before);
498
533
  if (after !== before) {
499
- writeFileAtomic(indexPath, after);
500
- indexPruned = true;
534
+ blockPruned = true;
535
+ if (after.trim() === '') {
536
+ fs.unlinkSync(cPath);
537
+ claudeMdRemoved = true;
538
+ } else {
539
+ writeFileAtomic(cPath, after);
540
+ }
501
541
  }
502
542
  }
503
- return { ok: true, fileRemoved, indexPruned, target, indexPath };
543
+
544
+ // Also sweep any legacy memory-dir remnants (uninstall before auto-migration ran).
545
+ const migrated = migrateLegacyMemoryDir({ cwd, home });
546
+
547
+ return { ok: true, fileRemoved, blockPruned, claudeMdRemoved, target: dPath, claudeMdPath: cPath, migrated };
504
548
  }
505
549
 
506
550
  function formatResult(action, result) {
507
551
  if (!result.ok && result.reason === 'windows-not-supported') {
508
- return '[code-graph] adopt/unadopt are POSIX-only claude-mem-lite slug ' +
509
- 'convention on Windows is unverified. Edit MEMORY.md manually to opt in.';
552
+ return '[code-graph] adopt/unadopt are POSIX-only on this build. ' +
553
+ 'Edit CLAUDE.md manually to opt in.';
510
554
  }
511
555
  if (action === 'adopt') {
512
556
  if (!result.ok) {
513
- if (result.reason === 'no-memory-dir') {
514
- return `[code-graph] Memory dir not found: ${result.dir}\n` +
515
- ' Run \`claude\` at least once in this project to create it.';
516
- }
517
557
  if (result.reason === 'not-a-project') {
518
558
  return `[code-graph] Not a project root: ${result.cwd}\n` +
519
559
  ' No project marker (.git, Cargo.toml, package.json, pyproject.toml, ...).\n' +
@@ -524,28 +564,31 @@ function formatResult(action, result) {
524
564
  }
525
565
  return `[code-graph] adopt failed: ${result.reason || 'unknown'}`;
526
566
  }
527
- const lines = [`[code-graph] Adopted → ${result.target}`];
528
- if (result.collisionWith) {
529
- lines.push(`[code-graph] slug collision: this dir was previously adopted by ${result.collisionWith}.`);
530
- lines.push('[code-graph] Memory dir is sharedsentinels overwritten. ' +
531
- 'Investigate path encoding clash (Claude Code slug = path with non-[a-zA-Z0-9-] → "-").');
532
- }
533
- if (result.healed) lines.push(`[code-graph] Healed malformed sentinel block ${result.indexPath}`);
534
- else if (result.indexed) lines.push(`[code-graph] Indexed ${result.indexPath}`);
535
- else lines.push(`[code-graph] Index already up-to-date — no write`);
536
- // v0.17.0: SessionStart project_map injection is OFF by default (regardless
537
- // of adoption). Adoption now only governs MEMORY.md sentinel + decision-table
538
- // refresh; the noisy hook needs an explicit opt-in.
539
- lines.push('[code-graph] Active. SessionStart project_map injection: OFF (default).');
540
- lines.push('[code-graph] Opt in to map dump: CODE_GRAPH_VERBOSE_HOOKS=1');
541
- lines.push('[code-graph] Legacy override: CODE_GRAPH_QUIET_HOOKS=0 (force noisy) / =1 (force quiet)');
567
+ const lines = [];
568
+ if (result.created) lines.push(`[code-graph] Created → ${result.claudeMdPath} (code-graph block)`);
569
+ else if (result.claudeMdWritten) lines.push(`[code-graph] Updated code-graph block ${result.claudeMdPath}`);
570
+ else lines.push('[code-graph] CLAUDE.md block already up-to-date no write');
571
+ if (result.healed) lines.push('[code-graph] Healed a malformed prior block.');
572
+ lines.push(`[code-graph] Detail table → ${result.detailPath}`);
573
+ lines.push('[code-graph] CLAUDE.md is git-tracked — commit the block, or gitignore');
574
+ lines.push('[code-graph] .claude/plugin_code_graph_mcp.md (a generated copy) as you prefer.');
575
+ lines.push('[code-graph] Reverse: code-graph-mcp unadopt');
576
+ lines.push('[code-graph] Opt out: CODE_GRAPH_NO_AUTO_ADOPT=1 in ~/.claude/settings.json env');
542
577
  return lines.join('\n');
543
578
  }
544
579
  if (action === 'unadopt') {
545
580
  const lines = [];
581
+ if (result.claudeMdRemoved) lines.push(`[code-graph] Removed → ${result.claudeMdPath} (was code-graph-only)`);
582
+ else if (result.blockPruned) lines.push(`[code-graph] De-blocked → ${result.claudeMdPath}`);
546
583
  if (result.fileRemoved) lines.push(`[code-graph] Removed → ${result.target}`);
547
- if (result.indexPruned) lines.push(`[code-graph] De-indexed → ${result.indexPath}`);
548
- if (!result.fileRemoved && !result.indexPruned) lines.push('[code-graph] Nothing to unadopt');
584
+ const m = result.migrated || {};
585
+ if (m.memoryIndexPruned || m.legacyDetailRemoved) {
586
+ lines.push('[code-graph] Cleaned legacy memory-dir artifacts.');
587
+ }
588
+ if (!result.blockPruned && !result.fileRemoved && !result.claudeMdRemoved &&
589
+ !(m.memoryIndexPruned || m.legacyDetailRemoved)) {
590
+ lines.push('[code-graph] Nothing to unadopt');
591
+ }
549
592
  return lines.join('\n');
550
593
  }
551
594
  return '';
@@ -561,8 +604,10 @@ if (require.main === module) {
561
604
  module.exports = {
562
605
  adopt, unadopt, memoryDir, formatResult, stripSentinelBlock,
563
606
  isAdopted, isPluginModeInstall, maybeAutoAdopt, needsRefresh, isProjectRoot,
564
- detectProjectType, buildIndexLine,
607
+ detectProjectType, buildBlock, buildTriggerRows, migrateLegacyMemoryDir,
608
+ claudeMdPath, detailDir, detailPath,
565
609
  extractCargoRuntimeDeps, extractPyRuntimeDeps, extractGoDirectRequires,
566
- SENTINEL_BEGIN, SENTINEL_END, INDEX_LINE, TEMPLATE_PATH, TARGET_NAME,
610
+ SENTINEL_BEGIN, SENTINEL_END, SENTINEL_BEGIN_SRC, SENTINEL_VERSION,
611
+ MANAGED_BY, TEMPLATE_PATH, TARGET_NAME,
567
612
  PROJECT_MARKERS, PROJECT_TYPES, isNonProjectCwd,
568
613
  };