@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.
- package/README.md +5 -6
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/agents/code-explorer.md +15 -12
- package/claude-plugin/hooks/hooks.json +91 -1
- package/claude-plugin/scripts/adopt.js +49 -37
- package/claude-plugin/scripts/cg-answer.js +7 -0
- package/claude-plugin/scripts/covering-tests.js +1 -2
- package/claude-plugin/scripts/doctor.js +40 -1
- package/claude-plugin/scripts/incremental-index.js +4 -1
- package/claude-plugin/scripts/lifecycle.js +49 -13
- package/claude-plugin/scripts/post-grep-inject.js +58 -3
- package/claude-plugin/scripts/pre-edit-guide.js +122 -83
- package/claude-plugin/scripts/pre-grep-guide.js +243 -11
- package/claude-plugin/scripts/pre-read-guide.js +42 -28
- package/claude-plugin/scripts/project-root.js +23 -1
- package/claude-plugin/scripts/session-edits.js +67 -5
- package/claude-plugin/scripts/session-init.js +101 -348
- package/claude-plugin/scripts/stop-impact.js +244 -18
- package/claude-plugin/scripts/subagent-start.js +8 -0
- package/claude-plugin/scripts/user-prompt-context.js +48 -16
- package/claude-plugin/skills/explore/SKILL.md +10 -7
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/claude-plugin/templates/plugin_code_graph_mcp.md +58 -169
- package/package.json +6 -6
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
386
|
-
* before the probe could run. `false` says "asked, not stale";
|
|
387
|
-
* two would let the caller report a freshness it never
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
441
|
+
notices.push(
|
|
515
442
|
`[code-graph] Binary not executable: ${binary}\n` +
|
|
516
|
-
`Fix: chmod +x "${binary}"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
+
notices.push(
|
|
550
475
|
`[code-graph] Binary found but failed to run: ${binary}\n` +
|
|
551
|
-
`Error: ${msg.slice(0, 200)}
|
|
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
|
-
//
|
|
627
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
//
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
|
|
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:
|
|
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.
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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)
|
|
713
|
+
if (hookFireWarn) notices.push(hookFireWarn.trimEnd());
|
|
859
714
|
const hookDarkWarn = detectHookDark();
|
|
860
|
-
if (hookDarkWarn)
|
|
715
|
+
if (hookDarkWarn) notices.push(hookDarkWarn.trimEnd());
|
|
861
716
|
return {
|
|
862
717
|
inactive: false, lifecycle,
|
|
863
|
-
autoUpdateLaunched, indexFreshness, mapInjected,
|
|
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
|
|
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
|
|
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
|
-
|
|
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'}): ` +
|