@sdsrs/code-graph 0.163.0 → 0.165.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.
@@ -10,7 +10,8 @@ const {
10
10
  } = require('./lifecycle');
11
11
  const { UPDATE_STATE_FILE } = require('./cache-paths');
12
12
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
13
- const { maybeAutoAdopt, isAdopted, unadopt, unadoptCommand } = require('./adopt');
13
+ const { maybeAutoAdopt, isAdopted, unadopt, unadoptCommand, adoptCommand } = require('./adopt');
14
+ const { capContext } = require('./hook-emit');
14
15
  const { isNonProjectCwd } = require('./project-detect');
15
16
  const { hidden } = require('./proc-opts');
16
17
  const { installHookFailOpen, remainingMs } = require('./hook-fail-open');
@@ -18,6 +19,7 @@ const { installHookFailOpen, remainingMs } = require('./hook-fail-open');
18
19
  // treats any throw as "nothing to conclude", so a lazy require in there would
19
20
  // turn a resolution failure into a silent disable (pre-tag review, JS-08).
20
21
  const { resolveProjectRoot } = require('./project-root');
22
+ const { isOwnedPath, openOwned } = require('./recommendation-log');
21
23
 
22
24
  // ── SessionStart budget (audit 2026-09-05 NEW-05) ─────────────────────────
23
25
  //
@@ -48,6 +50,30 @@ const { resolveProjectRoot } = require('./project-root');
48
50
  // being quieter than usual.
49
51
  let budgetSkips = [];
50
52
 
53
+ // What this run has to say, delivered as ONE JSON envelope when the hook exits
54
+ // (sessionStartOutput): `notices` become the user-facing `systemMessage`,
55
+ // `contextParts` the model-facing `additionalContext`. Claude Code never shows
56
+ // a SessionStart hook's stderr when it exits 0, so the fifteen notices this
57
+ // hook used to write there — "CLAUDE.md was modified", "hooks look dark",
58
+ // "binary missing" — reached no one (decision D5, 2026-09-28 usage
59
+ // evaluation). Only notices a user must act on are kept. Module state, reset
60
+ // per run like budgetSkips.
61
+ let notices = [];
62
+ let contextParts = [];
63
+
64
+ function sessionStartOutput() {
65
+ if (notices.length === 0 && contextParts.length === 0) return '';
66
+ const out = {};
67
+ if (notices.length > 0) out.systemMessage = notices.join('\n');
68
+ if (contextParts.length > 0) {
69
+ out.hookSpecificOutput = {
70
+ hookEventName: 'SessionStart',
71
+ additionalContext: capContext(contextParts.join('\n')),
72
+ };
73
+ }
74
+ return JSON.stringify(out) + '\n';
75
+ }
76
+
51
77
  function budgetFor(label, defaultMs) {
52
78
  const ms = remainingMs(defaultMs);
53
79
  if (ms === null) budgetSkips.push(label);
@@ -82,132 +108,6 @@ function shouldInjectMap({ available, quietHooks, adopted } = {}) {
82
108
  return !!(available && !quietHooks && adopted);
83
109
  }
84
110
 
85
- // v0.63 — SessionStart "live context": the recent-change blast radius from the
86
- // AST index. UNLIKE injectProjectMap (default-OFF because the static module map
87
- // duplicates the CLAUDE.md block + the on-demand project_map tool), this is git-delta-
88
- // derived — it changes every session and the static CLAUDE.md block cannot carry it, so it earns
89
- // being ON by default for adopted projects. It is the graph-unique, actionable
90
- // counterpart to mem's SessionStart dashboard (which pushes recent activity to
91
- // create engagement). Selectivity is automatic: a session with no recently
92
- // changed *source* files (a deps-only or release commit, a clean checkout off a
93
- // non-code commit) injects nothing — zero standing-context cost when idle.
94
- //
95
- // Gating differs from the static map on purpose: it respects only the hard
96
- // kill-switch (CODE_GRAPH_QUIET_HOOKS=1) and a dedicated opt-out, NOT the
97
- // default-quiet flag that suppresses the duplicative map.
98
- function shouldInjectRecentImpact({ available, adopted, env = {} } = {}) {
99
- if (!available || !adopted) return false;
100
- if (env.CODE_GRAPH_QUIET_HOOKS === '1') return false;
101
- if (env.CODE_GRAPH_NO_RECENT_IMPACT === '1') return false;
102
- return true;
103
- }
104
-
105
- // Pure: given whether there are uncommitted (WIP) source changes and the
106
- // SessionStart `source`, decide if the recent-impact injection is worth its
107
- // standing-context cost. Marginal-information argument, not measured:
108
- // - WIP present → active work; the blast radius of what you're editing is the
109
- // highest-value case → always show.
110
- // - clean tree → we fall back to the LAST COMMIT. On a cold `startup` that's
111
- // low value (you just made it, or you're starting on something unrelated),
112
- // and the project's own evidence is that SessionStart dumps tend to go
113
- // unreferenced ([[project_cross_project_interference]]) → suppress. On a
114
- // resume (`clear`/`compact`/`resume`) the same last-commit reminder helps
115
- // re-establish context → show.
116
- // Unknown source (direct calls / tests) defaults to showing — only an explicit
117
- // cold `startup` with no WIP is suppressed.
118
- function recentImpactWorthShowing({ isWip, source } = {}) {
119
- if (isWip) return true;
120
- return source !== 'startup';
121
- }
122
-
123
- // Source-file extensions the indexer extracts symbols from (AST-bearing). Config
124
- // /lockfile/doc changes have no graph blast radius, so they're filtered out
125
- // before the `affected` call — keeps the "Changed:" line free of Cargo.lock noise
126
- // and avoids spending the CLI call on a commit that only bumped versions.
127
- const RECENT_SRC_EXT =
128
- /\.(rs|ts|tsx|js|jsx|mjs|cjs|py|go|java|rb|php|swift|kt|kts|dart|c|h|cc|cpp|hpp|cs|sh|bash)$/i;
129
-
130
- // Pure: filter file paths to indexed source files, capped. Accepts EITHER a raw
131
- // `git diff --name-only` blob (string, split on newline) OR an already-parsed
132
- // path array (from parseGitStatusPaths) — the array form is essential: passing
133
- // the parser's array output to a string-only signature silently returned [] and
134
- // broke WIP detection (caught by the composing test). Cap guards message length
135
- // and the affected call's argv on a sweeping refactor.
136
- function filterSourceFiles(input, cap = 25) {
137
- const lines = Array.isArray(input)
138
- ? input
139
- : (typeof input === 'string' ? input.split('\n') : []);
140
- return lines
141
- .map(s => s.trim())
142
- .filter(Boolean)
143
- .filter(f => RECENT_SRC_EXT.test(f))
144
- .slice(0, cap);
145
- }
146
-
147
- // Pure: extract file paths from `git status --porcelain` output. One call covers
148
- // tracked changes (staged + unstaged) AND untracked files — the diff-only path
149
- // MISSED untracked new source files you're actively editing (finding #3). Format
150
- // is `XY␣PATH`, or `XY␣ORIG -> PATH` for renames (take the new path). Git quotes
151
- // paths with special chars; best-effort unquote.
152
- function parseGitStatusPaths(statusOutput) {
153
- if (!statusOutput || typeof statusOutput !== 'string') return [];
154
- const paths = [];
155
- for (const line of statusOutput.split('\n')) {
156
- if (line.length < 4) continue; // need 2 status chars + space + ≥1 path char
157
- let rest = line.slice(3); // skip the XY status columns + separator space
158
- const arrow = rest.indexOf(' -> '); // rename/copy → the path after the arrow is current
159
- if (arrow >= 0) rest = rest.slice(arrow + 4);
160
- rest = rest.trim();
161
- if (rest.startsWith('"') && rest.endsWith('"')) rest = rest.slice(1, -1);
162
- if (rest) paths.push(rest);
163
- }
164
- return paths;
165
- }
166
-
167
- // Above ~15 direct dependents the per-name list is noise, not signal:
168
- // information scales INVERSELY with blast size. A high-fanout node (a constants
169
- // module, a shared util) "touches everything", so its actionable content
170
- // collapses to "this is high-risk — run the full suite"; the arbitrary first-N
171
- // dependent names add nothing. Below the threshold the specific dependents ARE
172
- // the signal (edit one fn → these 3 callers). So scale detail to actionability.
173
- const FANOUT_LIST_MAX = 15;
174
-
175
- // Pure: render the injected text from the parsed `affected --json` payload.
176
- // Returns null when there's nothing graph-relevant to say (no indexed dependents),
177
- // so the caller injects nothing rather than an empty banner.
178
- function formatRecentImpact(changed, affected, dependentCap = 6) {
179
- if (!Array.isArray(changed) || changed.length === 0) return null;
180
- const all = (affected && Array.isArray(affected.affected_files)) ? affected.affected_files : [];
181
- if (all.length === 0) return null;
182
- const direct = all.filter(a => a.depth === 1 && !a.is_test).map(a => a.path);
183
- const testCount = Array.isArray(affected.tests) ? affected.tests.length : all.filter(a => a.is_test).length;
184
-
185
- const changedShown = changed.slice(0, 8).join(', ') + (changed.length > 8 ? `, +${changed.length - 8}` : '');
186
- const lines = [
187
- '[code-graph] Recent changes — blast radius from the AST index (graph-only; not in MEMORY.md):',
188
- ` Changed: ${changedShown}`,
189
- ];
190
- if (direct.length > FANOUT_LIST_MAX) {
191
- // High fanout: the name list is noise; surface risk + test scope only.
192
- lines.push(` High-fanout change — ${all.length} file(s) impacted (${direct.length} direct); run the full suite (${testCount} test file(s)).`);
193
- } else {
194
- lines.push(` Impacts ${all.length} file(s) (${direct.length} direct dependent(s)), ${testCount} test file(s) to re-run.`);
195
- if (direct.length > 0) {
196
- const shown = direct.slice(0, dependentCap).join(', ');
197
- const more = direct.length > dependentCap ? `, +${direct.length - dependentCap} more` : '';
198
- lines.push(` Direct dependents: ${shown}${more}`);
199
- }
200
- }
201
- // Runnable verbatim when ≤4 changed; above that, show 4 + an explicit count
202
- // rather than a bare "…" — a pasted "…" command yields a SMALLER blast than the
203
- // numbers above (the computation used all changed files, up to the cap) — finding #4.
204
- const runCmd = changed.length <= 4
205
- ? `code-graph-mcp affected ${changed.join(' ')}`
206
- : `code-graph-mcp affected ${changed.slice(0, 4).join(' ')} (+${changed.length - 4} more changed file(s) — pass all for the full blast)`;
207
- lines.push(` Re-run impacted tests: ${runCmd}`);
208
- return lines.join('\n');
209
- }
210
-
211
111
  function launchBackgroundAutoUpdate(spawnFn = spawn, env = process.env, { force = false } = {}) {
212
112
  try {
213
113
  // Documented opt-out (issue #40). Checked HERE as well as inside
@@ -251,10 +151,10 @@ function isHighIntentSource(source) {
251
151
  // Claude Code surfaces (same one injectProjectMap uses).
252
152
  function reportRebuild(r) {
253
153
  if (r && r.settingsRebuiltFrom) {
254
- process.stdout.write(
154
+ notices.push(
255
155
  `[code-graph] ${settingsPath()} could not be parsed and has been REBUILT. ` +
256
156
  `Your original is saved at ${r.settingsRebuiltFrom} — merge anything you ` +
257
- `still need (model / env / permissions / your own hooks) back by hand.\n`
157
+ `still need (model / env / permissions / your own hooks) back by hand.`
258
158
  );
259
159
  }
260
160
  // install()/update() have reported `manifestUnwritable` since they learned not
@@ -265,11 +165,11 @@ function reportRebuild(r) {
265
165
  // re-run install() and re-report 'installed', forever, with nothing to show
266
166
  // for it (audit 2026-08-16 review Minor tail).
267
167
  if (r && r.manifestUnwritable) {
268
- process.stdout.write(
168
+ notices.push(
269
169
  `[code-graph] The plugin manifest could not be written (${r.manifestUnwritable}). ` +
270
170
  'Hooks are registered but the install will not be remembered, so this runs again ' +
271
171
  'every session. Check permissions on ~/.claude/plugins/, then run ' +
272
- '`code-graph-mcp doctor`.\n'
172
+ '`code-graph-mcp doctor`.'
273
173
  );
274
174
  }
275
175
  return r;
@@ -360,8 +260,19 @@ function syncLifecycleConfig() {
360
260
  // exists) also heals. Previously only doctor checked staleness, so if the
361
261
  // auto-update re-register step failed silently, users kept running old hook
362
262
  // code indefinitely — the settings.json sibling of the binary-pin bug.
363
- const { surveyHookCoverage } = require('./lifecycle');
263
+ const { surveyHookCoverage, hooksFromPluginManifest } = require('./lifecycle');
364
264
  const cov = surveyHookCoverage(settings);
265
+ // Decision D2: a plugin session gets every hook from the plugin's hooks.json,
266
+ // so "missing from settings.json" is the healthy state there, and an entry
267
+ // still in settings.json (written by a pre-0.164 install, or re-added by
268
+ // hand) fires its hook a second time. install() removes ours on this path.
269
+ if (hooksFromPluginManifest(settings)) {
270
+ if (cov.present.length > 0) {
271
+ installReporting();
272
+ return 'removed-settings-hooks';
273
+ }
274
+ return 'noop';
275
+ }
365
276
  if (cov.missing.length > 0) {
366
277
  installReporting();
367
278
  return 'self-healed-missing-settings-hook';
@@ -382,9 +293,10 @@ function syncLifecycleConfig() {
382
293
  * health-check carries the verdict in `index_version_stale`. Best-effort: any
383
294
  * failure → false (never force work off a bad probe).
384
295
  *
385
- * Returns true | false | null, where null means the SessionStart budget ran out
386
- * before the probe could run. `false` says "asked, not stale"; conflating the
387
- * two would let the caller report a freshness it never established.
296
+ * Returns true | false | null | 'corrupt', where null means the SessionStart
297
+ * budget ran out before the probe could run. `false` says "asked, not stale";
298
+ * conflating the two would let the caller report a freshness it never
299
+ * established. 'corrupt' is health-check's `reason:"corrupt"` verdict.
388
300
  */
389
301
  function indexNeedsRevalidation(bin, cwd) {
390
302
  const budget = budgetFor('health-check', 3000);
@@ -398,7 +310,13 @@ function indexNeedsRevalidation(bin, cwd) {
398
310
  // health-check exits non-zero on an unhealthy index but still writes JSON.
399
311
  out = ((e && e.stdout) || '').toString();
400
312
  }
401
- return JSON.parse(out).index_version_stale === true;
313
+ const report = JSON.parse(out);
314
+ // A corrupt index answers every hook with nothing, and a reader never
315
+ // rebuilds it (it reports and preserves). Only an indexer does, so the
316
+ // caller must start one — before this it read as "not stale" and every
317
+ // hook stayed dark with no word to the user (hook audit 2026-09-28 P1-7).
318
+ if (report.reason === 'corrupt') return 'corrupt';
319
+ return report.index_version_stale === true;
402
320
  } catch {
403
321
  return false;
404
322
  }
@@ -434,6 +352,7 @@ function ensureIndexFresh() {
434
352
 
435
353
  let needsRefresh = false;
436
354
  let unprobed = false;
355
+ let corrupt = false;
437
356
  // Trigger 1: git HEAD newer than index mtime.
438
357
  const gitBudget = budgetFor('git-log', 2000);
439
358
  if (gitBudget === null) {
@@ -451,7 +370,8 @@ function ensureIndexFresh() {
451
370
  // Trigger 2: INDEX_VERSION mismatch (only probe when mtime looked fresh).
452
371
  if (!needsRefresh) {
453
372
  const stale = indexNeedsRevalidation(bin, cwd);
454
- if (stale === true) needsRefresh = true;
373
+ if (stale === 'corrupt') corrupt = needsRefresh = true;
374
+ else if (stale === true) needsRefresh = true;
455
375
  else if (stale === null) unprobed = true;
456
376
  }
457
377
 
@@ -467,6 +387,14 @@ function ensureIndexFresh() {
467
387
  stdio: 'ignore',
468
388
  }));
469
389
  if (child && typeof child.unref === 'function') child.unref();
390
+ if (corrupt) {
391
+ notices.push(
392
+ '[code-graph] The index at .code-graph/index.db was corrupt, so every hook had nothing to say.\n' +
393
+ ' Rebuilding it in the background; hooks resume when it finishes.\n' +
394
+ ' If this repeats: code-graph-mcp rebuild-index --confirm'
395
+ );
396
+ return 'rebuilding-corrupt';
397
+ }
470
398
  return 'refreshing';
471
399
  }
472
400
 
@@ -503,7 +431,7 @@ function verifyBinary() {
503
431
  const { findBinary } = require('./find-binary');
504
432
  const binary = findBinary();
505
433
  if (!binary) {
506
- process.stderr.write(missingBinaryMessage());
434
+ notices.push(missingBinaryMessage().trimEnd());
507
435
  return { available: false, binary: null };
508
436
  }
509
437
 
@@ -511,13 +439,11 @@ function verifyBinary() {
511
439
  try {
512
440
  fs.accessSync(binary, fs.constants.X_OK);
513
441
  } catch {
514
- process.stderr.write(
442
+ notices.push(
515
443
  `[code-graph] Binary not executable: ${binary}\n` +
516
- `Fix: chmod +x "${binary}"\n`
444
+ `Fix: chmod +x "${binary}"` +
445
+ (process.platform === 'darwin' ? `\nAlso try: xattr -d com.apple.quarantine "${binary}"` : '')
517
446
  );
518
- if (process.platform === 'darwin') {
519
- process.stderr.write(`Also try: xattr -d com.apple.quarantine "${binary}"\n`);
520
- }
521
447
  return { available: false, binary, issue: 'not-executable' };
522
448
  }
523
449
 
@@ -538,17 +464,17 @@ function verifyBinary() {
538
464
  const msg = (err.message || '') + (err.stderr ? err.stderr.toString() : '');
539
465
  if (msg.includes('quarantine') || msg.includes('not permitted') ||
540
466
  msg.includes('killed') || err.status === 137 || err.signal === 'SIGKILL') {
541
- process.stderr.write(
467
+ notices.push(
542
468
  `[code-graph] macOS Gatekeeper is blocking the binary: ${binary}\n` +
543
469
  `Fix: xattr -d com.apple.quarantine "${binary}"\n` +
544
- `Then restart Claude Code to reconnect the MCP server.\n`
470
+ 'Then restart Claude Code to reconnect the MCP server.'
545
471
  );
546
472
  return { available: false, binary, issue: 'quarantine' };
547
473
  }
548
474
  // Other errors (e.g., missing libs) — still report
549
- process.stderr.write(
475
+ notices.push(
550
476
  `[code-graph] Binary found but failed to run: ${binary}\n` +
551
- `Error: ${msg.slice(0, 200)}\n`
477
+ `Error: ${msg.slice(0, 200)}`
552
478
  );
553
479
  return { available: false, binary, issue: 'runtime-error' };
554
480
  }
@@ -623,16 +549,8 @@ function consistencyCheck(binary) {
623
549
  }
624
550
  } catch { /* skip check on error */ }
625
551
 
626
- // Output warnings to stderr
627
- if (issues.length > 0) {
628
- const lines = [`[code-graph] ${issues.length} consistency issue(s):`];
629
- issues.forEach((issue, i) => {
630
- lines.push(` ${i + 1}. ${issue.msg}`);
631
- lines.push(` → ${issue.fix}`);
632
- });
633
- process.stderr.write(lines.join('\n') + '\n');
634
- }
635
-
552
+ // Returned, not printed: stderr never reaches the user, and a version skew
553
+ // self-heals through the background auto-update (decision D5).
636
554
  return issues;
637
555
  }
638
556
 
@@ -648,11 +566,51 @@ function samePath(a, b) {
648
566
  return real(a) === real(b);
649
567
  }
650
568
 
569
+ /**
570
+ * Whether to show the out-of-date-block notice in this project, recording it
571
+ * as shown. Once per shipped-template fingerprint: 0.164.0 showed it at every
572
+ * session start. Recorded in `.code-graph/` only when that directory already
573
+ * exists — creating it would put an unexcluded directory in `git status`, the
574
+ * side effect D3/D4 removed. Anything unrecordable (no fingerprint, no
575
+ * directory, a failed write) shows the notice: a failure must never silence it.
576
+ */
577
+ function staleNoticeDue(cwd, fingerprint) {
578
+ if (!fingerprint) return true;
579
+ const dir = path.join(cwd, '.code-graph');
580
+ const marker = path.join(dir, 'stale-block-notice');
581
+ // `.code-graph/` is repo content: a clone can put a symlink or a hard link
582
+ // where the record goes (tar a FIFO), and a plain read or write follows it
583
+ // out of the project. Only a single-link regular file in a real directory is
584
+ // read or written — recommendation-log.js's guards; anything else is
585
+ // unrecordable.
586
+ const what = 'the stale-block notice record';
587
+ let dirStat;
588
+ try { dirStat = fs.lstatSync(dir); } catch { return true; }
589
+ if (!isOwnedPath(dir, 'dir', dirStat, what) || !isOwnedPath(marker, 'file', undefined, what)) return true;
590
+ let fd;
591
+ try {
592
+ fd = openOwned(marker,
593
+ fs.constants.O_RDWR | fs.constants.O_CREAT | (fs.constants.O_NONBLOCK || 0), what);
594
+ } catch { return true; }
595
+ if (fd === null) return true;
596
+ try {
597
+ const buf = Buffer.alloc(64);
598
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
599
+ if (buf.toString('utf8', 0, n).trim() === fingerprint) return false;
600
+ fs.ftruncateSync(fd, 0);
601
+ fs.writeSync(fd, fingerprint + '\n', 0);
602
+ } catch { /* unrecorded: shown again next session */ }
603
+ finally { fs.closeSync(fd); }
604
+ return true;
605
+ }
606
+
651
607
  function runSessionInit({ source } = {}) {
652
608
  // Fresh per run: this is module state, and the test suite calls this function
653
609
  // many times in one process. A carried-over array would report last run's
654
610
  // skips as this one's.
655
611
  budgetSkips = [];
612
+ notices = [];
613
+ contextParts = [];
656
614
  // GC the shared tmp dir before anything else, so it happens even on the
657
615
  // inactive / non-project early returns below — those sessions still wrote
658
616
  // cooldown flags on the way in. Cheap (one readdir + a stat per entry) and
@@ -742,13 +700,9 @@ function runSessionInit({ source } = {}) {
742
700
  return { inactive: false, nonProject: true, lifecycle, autoUpdateLaunched: false };
743
701
  }
744
702
 
745
- const conflict = checkScopeConflict();
746
- if (conflict) {
747
- process.stderr.write(
748
- `[code-graph] Warning: conflicting install detected — ${conflict.existingId} (${conflict.scope || 'unknown'} scope). ` +
749
- `Use /plugin to remove one to avoid config conflicts.\n`
750
- );
751
- }
703
+ // `doctor` reports a conflicting install; a SessionStart line about it went
704
+ // to stderr, which no one sees (decision D5).
705
+ checkScopeConflict();
752
706
 
753
707
  // Verify binary availability — catch issues early with actionable diagnostics
754
708
  const binaryCheck = verifyBinary();
@@ -756,81 +710,28 @@ function runSessionInit({ source } = {}) {
756
710
  const autoUpdateLaunched = launchBackgroundAutoUpdate(spawn, process.env, { force: isHighIntentSource(source) });
757
711
  const indexFreshness = binaryCheck.available ? ensureIndexFresh() : 'skipped';
758
712
 
759
- // 上下文感知默认:插件模式下首次 SessionStart 自动安装(创建/注入 CLAUDE.md 块 +
760
- // .claude/ detail 文件),并清理旧 memory-dir 制品(升级自动迁移)。shipped 漂移
761
- // 时刷新。三种情况发一次 stderr 提示,让用户知道发生了什么 + 如何回退。
762
- // Adoption is OPTIONAL; the rest of this hook is not. It touches files the
763
- // user owns (CLAUDE.md, .claude/) which can be unreadable, a directory, or on
764
- // a read-only mount — and a throw here used to abort every remaining step
765
- // (map injection, recent impact, consistency check, both hook canaries) with a
766
- // raw stack trace (audit 2026-08-16 P1-16). adopt() now returns reasons rather
767
- // than throwing; this is the belt to that suspenders, so a future unguarded
768
- // read inside it cannot take the session down again.
769
- let autoAdopt = { attempted: false, result: null };
713
+ // SessionStart no longer writes CLAUDE.md (decision D4); maybeAutoAdopt only
714
+ // cleans this plugin's legacy memory-dir artifacts and reports the project's
715
+ // adoption state. A block that has drifted from the shipped template is the
716
+ // one case worth a notice: its guidance is out of date and only the user can
717
+ // decide to refresh or remove it. Never throws out of this hook (P1-16).
718
+ let autoAdopt = { attempted: false, reason: null };
770
719
  if (!isRelic) {
771
720
  try {
772
721
  autoAdopt = maybeAutoAdopt({ scriptPath: __dirname });
773
722
  } catch (e) {
774
- autoAdopt = { attempted: true, reason: 'threw', result: null, error: (e && e.message) || String(e) };
775
- process.stderr.write(
776
- `[code-graph] Skipped CLAUDE.md adoption for this project (${(e && e.code) || (e && e.message) || 'unknown error'}).\n` +
777
- ' Everything else in this session start continues normally.\n'
778
- );
723
+ autoAdopt = { attempted: false, reason: 'threw', error: (e && e.message) || String(e) };
779
724
  }
780
725
  }
781
- if (autoAdopt.result && autoAdopt.result.ok === false &&
782
- (autoAdopt.result.reason === 'claude-md-unreadable' || autoAdopt.result.reason === 'claude-md-unwritable' ||
783
- autoAdopt.result.reason === 'detail-unwritable')) {
784
- // A refusal is not a silent no-op: the user's steering block is NOT
785
- // installed/refreshed, and only this line says so.
786
- process.stderr.write(
787
- `[code-graph] Could not install the CLAUDE.md steering block (${autoAdopt.result.reason}: ` +
788
- `${autoAdopt.result.error || 'unknown'}). Nothing was changed.\n` +
789
- ' Opt out permanently: CODE_GRAPH_NO_AUTO_ADOPT=1\n'
726
+ if (autoAdopt.reason === 'stale' && staleNoticeDue(process.cwd(), autoAdopt.fingerprint)) {
727
+ notices.push(
728
+ "[code-graph] This project's CLAUDE.md carries an out-of-date code-graph block.\n" +
729
+ ` Refresh it: ${adoptCommand()}\n` +
730
+ ` Remove it: ${unadoptCommand()}\n` +
731
+ ' (Shown once per shipped template. SessionStart no longer edits CLAUDE.md itself;\n' +
732
+ ' CODE_GRAPH_NO_TEMPLATE_REFRESH=1 silences this.)'
790
733
  );
791
734
  }
792
- const migrated = autoAdopt.migrated || {};
793
- if (migrated.memoryIndexPruned || migrated.legacyDetailRemoved) {
794
- process.stderr.write(
795
- '[code-graph] Migrated to CLAUDE.md steering — cleaned legacy memory-dir artifacts\n' +
796
- ' (MEMORY.md sentinel + detail file). Your other memories are untouched.\n'
797
- );
798
- }
799
- if (autoAdopt.attempted && autoAdopt.result && autoAdopt.result.ok) {
800
- if (autoAdopt.reason === 'refreshed') {
801
- // Name BOTH refreshed surfaces: the drift-refresh fully overwrites the
802
- // generated detail doc, so a user who hand-edited it must learn why
803
- // their edits vanished and how to lock the file.
804
- const detailNote = autoAdopt.result.detailWritten
805
- ? ' + .claude/plugin_code_graph_mcp.md (manual edits to that generated file are overwritten)'
806
- : '';
807
- process.stderr.write(
808
- `[code-graph] Refreshed CLAUDE.md decision block to latest shipped version${detailNote}.\n` +
809
- ' Lock files: CODE_GRAPH_NO_TEMPLATE_REFRESH=1 in ~/.claude/settings.json env\n'
810
- );
811
- } else {
812
- process.stderr.write(
813
- '[code-graph] Installed code-graph block into project CLAUDE.md (plugin install → knowing consent).\n' +
814
- ' Detail table: .claude/plugin_code_graph_mcp.md (generated; safe to gitignore)\n' +
815
- ' Opt out: CODE_GRAPH_NO_AUTO_ADOPT=1 in ~/.claude/settings.json env\n' +
816
- ` Reverse: ${unadoptCommand()}\n`
817
- );
818
- }
819
- // `adopt()` has returned `registryRecorded` since it stopped throwing on a
820
- // broken registry, and nothing read it. The consequence is not cosmetic:
821
- // `uninstall()` walks that registry to strip our managed block from every
822
- // adopted project's CLAUDE.md, so an unrecorded project keeps the block
823
- // FOREVER after uninstall, with no plugin code left to remove it — the
824
- // teardown-asymmetry class this repo has already been bitten by (audit
825
- // 2026-08-16 review Minor tail).
826
- if (autoAdopt.result.registryRecorded === false) {
827
- process.stderr.write(
828
- '[code-graph] Note: this project could not be recorded in the adopted-projects registry,\n' +
829
- ' so `/plugin uninstall` will NOT strip the block from this CLAUDE.md.\n' +
830
- ` Remove it by hand with \`${unadoptCommand()}\` before uninstalling.\n`
831
- );
832
- }
833
- }
834
735
 
835
736
  // quietHooks: default quiet (project_map injection duplicates MEMORY.md +
836
737
  // on-demand tool). CODE_GRAPH_VERBOSE_HOOKS=1 to opt in to the dump;
@@ -842,12 +743,6 @@ function runSessionInit({ source } = {}) {
842
743
  const mapInjected = shouldInjectMap({ available: binaryCheck.available, quietHooks, adopted })
843
744
  ? injectProjectMap()
844
745
  : false;
845
- // v0.63 — live context: recent-change blast radius. Default-ON for adopted
846
- // projects (separate gate from the duplicative static map); self-selecting
847
- // (nothing injected when no source files changed recently).
848
- const recentImpactInjected = shouldInjectRecentImpact({ available: binaryCheck.available, adopted, env: process.env })
849
- ? injectRecentImpact({ source })
850
- : false;
851
746
  const consistencyIssues = binaryCheck.available
852
747
  ? consistencyCheck(binaryCheck.binary)
853
748
  : [];
@@ -855,18 +750,19 @@ function runSessionInit({ source } = {}) {
855
750
  // FAILED firing self-test result (and refreshes it in the background, off the
856
751
  // 5s budget); Layer B is the runtime dispatch-dark canary. Both best-effort.
857
752
  const hookFireWarn = checkHookFiring();
858
- if (hookFireWarn) process.stderr.write(hookFireWarn);
753
+ if (hookFireWarn) notices.push(hookFireWarn.trimEnd());
859
754
  const hookDarkWarn = detectHookDark();
860
- if (hookDarkWarn) process.stderr.write(hookDarkWarn);
755
+ if (hookDarkWarn) notices.push(hookDarkWarn.trimEnd());
861
756
  return {
862
757
  inactive: false, lifecycle,
863
- autoUpdateLaunched, indexFreshness, mapInjected, recentImpactInjected, binaryCheck, consistencyIssues,
758
+ autoUpdateLaunched, indexFreshness, mapInjected, binaryCheck, consistencyIssues,
864
759
  quietHooks, adopted, autoAdopted: autoAdopt.attempted,
865
760
  hookFireWarn: !!hookFireWarn, hookDarkWarn: !!hookDarkWarn,
866
761
  // Which children the 5s budget cut, in the order they were reached. Empty
867
762
  // on every healthy run; non-empty is the only signal that this SessionStart
868
763
  // did less than it looks like it did.
869
764
  budgetSkipped: budgetSkips.slice(),
765
+ notices: notices.slice(),
870
766
  };
871
767
  }
872
768
 
@@ -883,8 +779,7 @@ function injectProjectMap() {
883
779
 
884
780
  // findBinary, not bare 'code-graph-mcp' on PATH (finding #8): a stale/global
885
781
  // PATH binary reads a different/older index and returns "(empty project)"
886
- // even when the local index is populated — the sibling bug injectRecentImpact
887
- // already avoided via findBinary().
782
+ // even when the local index is populated.
888
783
  const { findBinary } = require('./find-binary');
889
784
  const bin = findBinary();
890
785
  if (!bin) return false;
@@ -901,14 +796,12 @@ function injectProjectMap() {
901
796
  encoding: 'utf8',
902
797
  stdio: ['pipe', 'pipe', 'pipe'],
903
798
  // Hook-internal delivery, not a model conversion — keep record_cli_use from
904
- // logging this `map` run as a phantom `use` (mirror injectRecentImpact's affected call).
799
+ // logging this `map` run as a phantom `use`.
905
800
  env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
906
801
  }));
907
802
 
908
803
  if (output && output.trim()) {
909
- process.stdout.write(
910
- '[code-graph] Project map (indexed):\n' + output.trim() + '\n'
911
- );
804
+ contextParts.push('[code-graph] Project map (indexed):\n' + output.trim());
912
805
  return true;
913
806
  }
914
807
  } catch {
@@ -917,102 +810,6 @@ function injectProjectMap() {
917
810
  return false;
918
811
  }
919
812
 
920
- /**
921
- * v0.63 — inject the recent-change blast radius (graph-unique "live" context).
922
- * Changed source files = working-tree changes vs HEAD (WIP), else the last commit.
923
- * The last-commit fallback is suppressed on a cold `startup` (low marginal value)
924
- * via recentImpactWorthShowing. One bounded `affected --json` call; degrades to
925
- * silent no-op on any error. Records a `live_impact` event so `stats` can see the
926
- * feature fire (else it's a dark metric). Returns true iff something was injected.
927
- */
928
- function injectRecentImpact({ source } = {}) {
929
- try {
930
- // Index + telemetry live at the canonical root (worktree → main checkout,
931
- // subdir → project root); git WIP detection stays in the SESSION dir — the
932
- // worktree's branch state is what this session edits. Repo-relative git
933
- // paths translate 1:1 to root-relative index paths (a worktree mirrors the
934
- // checkout layout).
935
- const { resolveProjectRoot } = require('./project-root');
936
- const sessionDir = process.cwd();
937
- const cwd = resolveProjectRoot(sessionDir) || sessionDir;
938
- const dbPath = path.join(cwd, '.code-graph', 'index.db');
939
- if (!fs.existsSync(dbPath)) return false;
940
-
941
- // WIP = ALL working-tree changes (staged + unstaged + UNTRACKED) in one
942
- // `git status` call — the old diff-only path missed untracked new source
943
- // files you're actively editing (finding #3). Clean tree → fall back to the
944
- // last commit. Timeouts tightened (finding #1): worst-case cap sum is now
945
- // status(1s) + HEAD~1(1s) + affected(1.5s) = 3.5s, comfortably under the 5s
946
- // SessionStart hook budget; the old 2+2+3=7s could get the whole hook killed.
947
- // Each git call re-reads the budget and builds its own opts rather than
948
- // sharing one object: the fallback `git diff` runs AFTER `git status` has
949
- // already spent wall clock, so a timeout computed once would let the pair
950
- // overshoot by up to its own value. `hidden(...)` stays inline at both call
951
- // sites — windows-hide.test.js follows a `const x = hidden(...)` binding but
952
- // not one returned from a helper, and that guard is right to be that strict.
953
- let changed = [];
954
- let isWip = false;
955
- try {
956
- const statusMs = budgetFor('git-status', 1000);
957
- if (statusMs === null) return false;
958
- changed = filterSourceFiles(parseGitStatusPaths(
959
- execSync('git status --porcelain --untracked-files=all',
960
- hidden({ cwd: sessionDir, timeout: statusMs, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }))));
961
- isWip = changed.length > 0;
962
- // Clean tree → fall back to what the last commit touched.
963
- if (!isWip) {
964
- const diffMs = budgetFor('git-diff', 1000);
965
- if (diffMs === null) return false;
966
- changed = filterSourceFiles(
967
- execSync('git diff --name-only HEAD~1 HEAD',
968
- hidden({ cwd: sessionDir, timeout: diffMs, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })));
969
- }
970
- } catch {
971
- return false; // not a git repo / no commits — nothing to diff
972
- }
973
- if (changed.length === 0) return false;
974
-
975
- // Marginal-value gate: cold startup + clean tree (last-commit fallback) → skip,
976
- // before spending the affected CLI call.
977
- if (!recentImpactWorthShowing({ isWip, source })) return false;
978
-
979
- const { findBinary } = require('./find-binary');
980
- const bin = findBinary();
981
- if (!bin) return false;
982
-
983
- const affectedBudget = budgetFor('affected', 1500);
984
- if (affectedBudget === null) return false;
985
-
986
- let affected;
987
- try {
988
- const raw = execFileSync(bin, ['affected', ...changed, '--json'], hidden({
989
- cwd, timeout: affectedBudget, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
990
- env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
991
- }));
992
- affected = JSON.parse(raw);
993
- } catch {
994
- return false;
995
- }
996
-
997
- const text = formatRecentImpact(changed, affected);
998
- if (!text) return false;
999
- process.stdout.write(text + '\n');
1000
-
1001
- // Instrumentation (step 3a): record that the injection fired so `stats`
1002
- // surfaces it instead of the feature being dark. Carries the blast/direct
1003
- // counts + WIP flag for later reference-rate / A-B analysis. Best-effort.
1004
- try {
1005
- const all = Array.isArray(affected.affected_files) ? affected.affected_files : [];
1006
- const direct = all.filter(a => a.depth === 1 && !a.is_test).length;
1007
- const { recordRecommendation } = require('./recommendation-log');
1008
- recordRecommendation(cwd, { hook: 'session', action: 'live_impact', blast: all.length, direct, wip: isWip });
1009
- } catch { /* telemetry must never break the injection */ }
1010
- return true;
1011
- } catch {
1012
- return false;
1013
- }
1014
- }
1015
-
1016
813
  // ── v0.67.0 hook-reliability surfaces ─────────────────────────────────
1017
814
  //
1018
815
  // Layer A (firing): surface the cached `verify-hooks-fire` result. PUSH — the
@@ -1096,17 +893,11 @@ module.exports = {
1096
893
  ensureIndexFresh,
1097
894
  indexNeedsRevalidation,
1098
895
  injectProjectMap,
1099
- injectRecentImpact,
1100
896
  verifyBinary, missingBinaryMessage, unadoptCommand,
1101
897
  consistencyCheck,
1102
- runSessionInit,
898
+ runSessionInit, staleNoticeDue,
1103
899
  computeQuietHooks,
1104
900
  shouldInjectMap,
1105
- shouldInjectRecentImpact,
1106
- recentImpactWorthShowing,
1107
- filterSourceFiles,
1108
- parseGitStatusPaths,
1109
- formatRecentImpact,
1110
901
  hookFireWarning, checkHookFiring, analyzeHookDark, detectHookDark, // v0.67.0
1111
902
  };
1112
903
 
@@ -1132,6 +923,8 @@ if (require.main === module) {
1132
923
  // housekeeping (audit 2026-08-16 P1-16). One `[code-graph]` line, exit 0.
1133
924
  try {
1134
925
  runSessionInit({ source });
926
+ const out = sessionStartOutput();
927
+ if (out) process.stdout.write(out);
1135
928
  } catch (e) {
1136
929
  process.stderr.write(
1137
930
  `[code-graph] SessionStart hook error (${(e && e.code) || (e && e.name) || 'Error'}): ` +