@sdsrs/code-graph 0.162.0 → 0.164.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');
@@ -48,6 +49,30 @@ const { resolveProjectRoot } = require('./project-root');
48
49
  // being quieter than usual.
49
50
  let budgetSkips = [];
50
51
 
52
+ // What this run has to say, delivered as ONE JSON envelope when the hook exits
53
+ // (sessionStartOutput): `notices` become the user-facing `systemMessage`,
54
+ // `contextParts` the model-facing `additionalContext`. Claude Code never shows
55
+ // a SessionStart hook's stderr when it exits 0, so the fifteen notices this
56
+ // hook used to write there — "CLAUDE.md was modified", "hooks look dark",
57
+ // "binary missing" — reached no one (decision D5, 2026-09-28 usage
58
+ // evaluation). Only notices a user must act on are kept. Module state, reset
59
+ // per run like budgetSkips.
60
+ let notices = [];
61
+ let contextParts = [];
62
+
63
+ function sessionStartOutput() {
64
+ if (notices.length === 0 && contextParts.length === 0) return '';
65
+ const out = {};
66
+ if (notices.length > 0) out.systemMessage = notices.join('\n');
67
+ if (contextParts.length > 0) {
68
+ out.hookSpecificOutput = {
69
+ hookEventName: 'SessionStart',
70
+ additionalContext: capContext(contextParts.join('\n')),
71
+ };
72
+ }
73
+ return JSON.stringify(out) + '\n';
74
+ }
75
+
51
76
  function budgetFor(label, defaultMs) {
52
77
  const ms = remainingMs(defaultMs);
53
78
  if (ms === null) budgetSkips.push(label);
@@ -82,132 +107,6 @@ function shouldInjectMap({ available, quietHooks, adopted } = {}) {
82
107
  return !!(available && !quietHooks && adopted);
83
108
  }
84
109
 
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
110
  function launchBackgroundAutoUpdate(spawnFn = spawn, env = process.env, { force = false } = {}) {
212
111
  try {
213
112
  // Documented opt-out (issue #40). Checked HERE as well as inside
@@ -251,10 +150,10 @@ function isHighIntentSource(source) {
251
150
  // Claude Code surfaces (same one injectProjectMap uses).
252
151
  function reportRebuild(r) {
253
152
  if (r && r.settingsRebuiltFrom) {
254
- process.stdout.write(
153
+ notices.push(
255
154
  `[code-graph] ${settingsPath()} could not be parsed and has been REBUILT. ` +
256
155
  `Your original is saved at ${r.settingsRebuiltFrom} — merge anything you ` +
257
- `still need (model / env / permissions / your own hooks) back by hand.\n`
156
+ `still need (model / env / permissions / your own hooks) back by hand.`
258
157
  );
259
158
  }
260
159
  // install()/update() have reported `manifestUnwritable` since they learned not
@@ -265,11 +164,11 @@ function reportRebuild(r) {
265
164
  // re-run install() and re-report 'installed', forever, with nothing to show
266
165
  // for it (audit 2026-08-16 review Minor tail).
267
166
  if (r && r.manifestUnwritable) {
268
- process.stdout.write(
167
+ notices.push(
269
168
  `[code-graph] The plugin manifest could not be written (${r.manifestUnwritable}). ` +
270
169
  'Hooks are registered but the install will not be remembered, so this runs again ' +
271
170
  'every session. Check permissions on ~/.claude/plugins/, then run ' +
272
- '`code-graph-mcp doctor`.\n'
171
+ '`code-graph-mcp doctor`.'
273
172
  );
274
173
  }
275
174
  return r;
@@ -360,8 +259,19 @@ function syncLifecycleConfig() {
360
259
  // exists) also heals. Previously only doctor checked staleness, so if the
361
260
  // auto-update re-register step failed silently, users kept running old hook
362
261
  // code indefinitely — the settings.json sibling of the binary-pin bug.
363
- const { surveyHookCoverage } = require('./lifecycle');
262
+ const { surveyHookCoverage, hooksFromPluginManifest } = require('./lifecycle');
364
263
  const cov = surveyHookCoverage(settings);
264
+ // Decision D2: a plugin session gets every hook from the plugin's hooks.json,
265
+ // so "missing from settings.json" is the healthy state there, and an entry
266
+ // still in settings.json (written by a pre-0.164 install, or re-added by
267
+ // hand) fires its hook a second time. install() removes ours on this path.
268
+ if (hooksFromPluginManifest(settings)) {
269
+ if (cov.present.length > 0) {
270
+ installReporting();
271
+ return 'removed-settings-hooks';
272
+ }
273
+ return 'noop';
274
+ }
365
275
  if (cov.missing.length > 0) {
366
276
  installReporting();
367
277
  return 'self-healed-missing-settings-hook';
@@ -382,9 +292,10 @@ function syncLifecycleConfig() {
382
292
  * health-check carries the verdict in `index_version_stale`. Best-effort: any
383
293
  * failure → false (never force work off a bad probe).
384
294
  *
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.
295
+ * Returns true | false | null | 'corrupt', where null means the SessionStart
296
+ * budget ran out before the probe could run. `false` says "asked, not stale";
297
+ * conflating the two would let the caller report a freshness it never
298
+ * established. 'corrupt' is health-check's `reason:"corrupt"` verdict.
388
299
  */
389
300
  function indexNeedsRevalidation(bin, cwd) {
390
301
  const budget = budgetFor('health-check', 3000);
@@ -398,7 +309,13 @@ function indexNeedsRevalidation(bin, cwd) {
398
309
  // health-check exits non-zero on an unhealthy index but still writes JSON.
399
310
  out = ((e && e.stdout) || '').toString();
400
311
  }
401
- return JSON.parse(out).index_version_stale === true;
312
+ const report = JSON.parse(out);
313
+ // A corrupt index answers every hook with nothing, and a reader never
314
+ // rebuilds it (it reports and preserves). Only an indexer does, so the
315
+ // caller must start one — before this it read as "not stale" and every
316
+ // hook stayed dark with no word to the user (hook audit 2026-09-28 P1-7).
317
+ if (report.reason === 'corrupt') return 'corrupt';
318
+ return report.index_version_stale === true;
402
319
  } catch {
403
320
  return false;
404
321
  }
@@ -434,6 +351,7 @@ function ensureIndexFresh() {
434
351
 
435
352
  let needsRefresh = false;
436
353
  let unprobed = false;
354
+ let corrupt = false;
437
355
  // Trigger 1: git HEAD newer than index mtime.
438
356
  const gitBudget = budgetFor('git-log', 2000);
439
357
  if (gitBudget === null) {
@@ -451,7 +369,8 @@ function ensureIndexFresh() {
451
369
  // Trigger 2: INDEX_VERSION mismatch (only probe when mtime looked fresh).
452
370
  if (!needsRefresh) {
453
371
  const stale = indexNeedsRevalidation(bin, cwd);
454
- if (stale === true) needsRefresh = true;
372
+ if (stale === 'corrupt') corrupt = needsRefresh = true;
373
+ else if (stale === true) needsRefresh = true;
455
374
  else if (stale === null) unprobed = true;
456
375
  }
457
376
 
@@ -467,6 +386,14 @@ function ensureIndexFresh() {
467
386
  stdio: 'ignore',
468
387
  }));
469
388
  if (child && typeof child.unref === 'function') child.unref();
389
+ if (corrupt) {
390
+ notices.push(
391
+ '[code-graph] The index at .code-graph/index.db was corrupt, so every hook had nothing to say.\n' +
392
+ ' Rebuilding it in the background; hooks resume when it finishes.\n' +
393
+ ' If this repeats: code-graph-mcp rebuild-index --confirm'
394
+ );
395
+ return 'rebuilding-corrupt';
396
+ }
470
397
  return 'refreshing';
471
398
  }
472
399
 
@@ -503,7 +430,7 @@ function verifyBinary() {
503
430
  const { findBinary } = require('./find-binary');
504
431
  const binary = findBinary();
505
432
  if (!binary) {
506
- process.stderr.write(missingBinaryMessage());
433
+ notices.push(missingBinaryMessage().trimEnd());
507
434
  return { available: false, binary: null };
508
435
  }
509
436
 
@@ -511,13 +438,11 @@ function verifyBinary() {
511
438
  try {
512
439
  fs.accessSync(binary, fs.constants.X_OK);
513
440
  } catch {
514
- process.stderr.write(
441
+ notices.push(
515
442
  `[code-graph] Binary not executable: ${binary}\n` +
516
- `Fix: chmod +x "${binary}"\n`
443
+ `Fix: chmod +x "${binary}"` +
444
+ (process.platform === 'darwin' ? `\nAlso try: xattr -d com.apple.quarantine "${binary}"` : '')
517
445
  );
518
- if (process.platform === 'darwin') {
519
- process.stderr.write(`Also try: xattr -d com.apple.quarantine "${binary}"\n`);
520
- }
521
446
  return { available: false, binary, issue: 'not-executable' };
522
447
  }
523
448
 
@@ -538,17 +463,17 @@ function verifyBinary() {
538
463
  const msg = (err.message || '') + (err.stderr ? err.stderr.toString() : '');
539
464
  if (msg.includes('quarantine') || msg.includes('not permitted') ||
540
465
  msg.includes('killed') || err.status === 137 || err.signal === 'SIGKILL') {
541
- process.stderr.write(
466
+ notices.push(
542
467
  `[code-graph] macOS Gatekeeper is blocking the binary: ${binary}\n` +
543
468
  `Fix: xattr -d com.apple.quarantine "${binary}"\n` +
544
- `Then restart Claude Code to reconnect the MCP server.\n`
469
+ 'Then restart Claude Code to reconnect the MCP server.'
545
470
  );
546
471
  return { available: false, binary, issue: 'quarantine' };
547
472
  }
548
473
  // Other errors (e.g., missing libs) — still report
549
- process.stderr.write(
474
+ notices.push(
550
475
  `[code-graph] Binary found but failed to run: ${binary}\n` +
551
- `Error: ${msg.slice(0, 200)}\n`
476
+ `Error: ${msg.slice(0, 200)}`
552
477
  );
553
478
  return { available: false, binary, issue: 'runtime-error' };
554
479
  }
@@ -623,16 +548,8 @@ function consistencyCheck(binary) {
623
548
  }
624
549
  } catch { /* skip check on error */ }
625
550
 
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
-
551
+ // Returned, not printed: stderr never reaches the user, and a version skew
552
+ // self-heals through the background auto-update (decision D5).
636
553
  return issues;
637
554
  }
638
555
 
@@ -653,6 +570,8 @@ function runSessionInit({ source } = {}) {
653
570
  // many times in one process. A carried-over array would report last run's
654
571
  // skips as this one's.
655
572
  budgetSkips = [];
573
+ notices = [];
574
+ contextParts = [];
656
575
  // GC the shared tmp dir before anything else, so it happens even on the
657
576
  // inactive / non-project early returns below — those sessions still wrote
658
577
  // cooldown flags on the way in. Cheap (one readdir + a stat per entry) and
@@ -742,13 +661,9 @@ function runSessionInit({ source } = {}) {
742
661
  return { inactive: false, nonProject: true, lifecycle, autoUpdateLaunched: false };
743
662
  }
744
663
 
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
- }
664
+ // `doctor` reports a conflicting install; a SessionStart line about it went
665
+ // to stderr, which no one sees (decision D5).
666
+ checkScopeConflict();
752
667
 
753
668
  // Verify binary availability — catch issues early with actionable diagnostics
754
669
  const binaryCheck = verifyBinary();
@@ -756,81 +671,27 @@ function runSessionInit({ source } = {}) {
756
671
  const autoUpdateLaunched = launchBackgroundAutoUpdate(spawn, process.env, { force: isHighIntentSource(source) });
757
672
  const indexFreshness = binaryCheck.available ? ensureIndexFresh() : 'skipped';
758
673
 
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 };
674
+ // SessionStart no longer writes CLAUDE.md (decision D4); maybeAutoAdopt only
675
+ // cleans this plugin's legacy memory-dir artifacts and reports the project's
676
+ // adoption state. A block that has drifted from the shipped template is the
677
+ // one case worth a notice: its guidance is out of date and only the user can
678
+ // decide to refresh or remove it. Never throws out of this hook (P1-16).
679
+ let autoAdopt = { attempted: false, reason: null };
770
680
  if (!isRelic) {
771
681
  try {
772
682
  autoAdopt = maybeAutoAdopt({ scriptPath: __dirname });
773
683
  } 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
- );
684
+ autoAdopt = { attempted: false, reason: 'threw', error: (e && e.message) || String(e) };
779
685
  }
780
686
  }
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'
790
- );
791
- }
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'
687
+ if (autoAdopt.reason === 'stale') {
688
+ notices.push(
689
+ "[code-graph] This project's CLAUDE.md carries an out-of-date code-graph block.\n" +
690
+ ` Refresh it: ${adoptCommand()}\n` +
691
+ ` Remove it: ${unadoptCommand()}\n` +
692
+ ' (SessionStart no longer edits CLAUDE.md itself; CODE_GRAPH_NO_TEMPLATE_REFRESH=1 silences this.)'
797
693
  );
798
694
  }
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
695
 
835
696
  // quietHooks: default quiet (project_map injection duplicates MEMORY.md +
836
697
  // on-demand tool). CODE_GRAPH_VERBOSE_HOOKS=1 to opt in to the dump;
@@ -842,12 +703,6 @@ function runSessionInit({ source } = {}) {
842
703
  const mapInjected = shouldInjectMap({ available: binaryCheck.available, quietHooks, adopted })
843
704
  ? injectProjectMap()
844
705
  : 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
706
  const consistencyIssues = binaryCheck.available
852
707
  ? consistencyCheck(binaryCheck.binary)
853
708
  : [];
@@ -855,18 +710,19 @@ function runSessionInit({ source } = {}) {
855
710
  // FAILED firing self-test result (and refreshes it in the background, off the
856
711
  // 5s budget); Layer B is the runtime dispatch-dark canary. Both best-effort.
857
712
  const hookFireWarn = checkHookFiring();
858
- if (hookFireWarn) process.stderr.write(hookFireWarn);
713
+ if (hookFireWarn) notices.push(hookFireWarn.trimEnd());
859
714
  const hookDarkWarn = detectHookDark();
860
- if (hookDarkWarn) process.stderr.write(hookDarkWarn);
715
+ if (hookDarkWarn) notices.push(hookDarkWarn.trimEnd());
861
716
  return {
862
717
  inactive: false, lifecycle,
863
- autoUpdateLaunched, indexFreshness, mapInjected, recentImpactInjected, binaryCheck, consistencyIssues,
718
+ autoUpdateLaunched, indexFreshness, mapInjected, binaryCheck, consistencyIssues,
864
719
  quietHooks, adopted, autoAdopted: autoAdopt.attempted,
865
720
  hookFireWarn: !!hookFireWarn, hookDarkWarn: !!hookDarkWarn,
866
721
  // Which children the 5s budget cut, in the order they were reached. Empty
867
722
  // on every healthy run; non-empty is the only signal that this SessionStart
868
723
  // did less than it looks like it did.
869
724
  budgetSkipped: budgetSkips.slice(),
725
+ notices: notices.slice(),
870
726
  };
871
727
  }
872
728
 
@@ -883,8 +739,7 @@ function injectProjectMap() {
883
739
 
884
740
  // findBinary, not bare 'code-graph-mcp' on PATH (finding #8): a stale/global
885
741
  // 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().
742
+ // even when the local index is populated.
888
743
  const { findBinary } = require('./find-binary');
889
744
  const bin = findBinary();
890
745
  if (!bin) return false;
@@ -901,14 +756,12 @@ function injectProjectMap() {
901
756
  encoding: 'utf8',
902
757
  stdio: ['pipe', 'pipe', 'pipe'],
903
758
  // 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).
759
+ // logging this `map` run as a phantom `use`.
905
760
  env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
906
761
  }));
907
762
 
908
763
  if (output && output.trim()) {
909
- process.stdout.write(
910
- '[code-graph] Project map (indexed):\n' + output.trim() + '\n'
911
- );
764
+ contextParts.push('[code-graph] Project map (indexed):\n' + output.trim());
912
765
  return true;
913
766
  }
914
767
  } catch {
@@ -917,102 +770,6 @@ function injectProjectMap() {
917
770
  return false;
918
771
  }
919
772
 
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
773
  // ── v0.67.0 hook-reliability surfaces ─────────────────────────────────
1017
774
  //
1018
775
  // Layer A (firing): surface the cached `verify-hooks-fire` result. PUSH — the
@@ -1096,17 +853,11 @@ module.exports = {
1096
853
  ensureIndexFresh,
1097
854
  indexNeedsRevalidation,
1098
855
  injectProjectMap,
1099
- injectRecentImpact,
1100
856
  verifyBinary, missingBinaryMessage, unadoptCommand,
1101
857
  consistencyCheck,
1102
858
  runSessionInit,
1103
859
  computeQuietHooks,
1104
860
  shouldInjectMap,
1105
- shouldInjectRecentImpact,
1106
- recentImpactWorthShowing,
1107
- filterSourceFiles,
1108
- parseGitStatusPaths,
1109
- formatRecentImpact,
1110
861
  hookFireWarning, checkHookFiring, analyzeHookDark, detectHookDark, // v0.67.0
1111
862
  };
1112
863
 
@@ -1132,6 +883,8 @@ if (require.main === module) {
1132
883
  // housekeeping (audit 2026-08-16 P1-16). One `[code-graph]` line, exit 0.
1133
884
  try {
1134
885
  runSessionInit({ source });
886
+ const out = sessionStartOutput();
887
+ if (out) process.stdout.write(out);
1135
888
  } catch (e) {
1136
889
  process.stderr.write(
1137
890
  `[code-graph] SessionStart hook error (${(e && e.code) || (e && e.name) || 'Error'}): ` +