@sdsrs/code-graph 0.48.0 → 0.50.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.
@@ -4,6 +4,12 @@ const assert = require('node:assert/strict');
4
4
  const {
5
5
  shouldHint,
6
6
  shouldBlock,
7
+ classifyBlock,
8
+ extractDeclSymbols,
9
+ translateBreToRg,
10
+ buildShowDenyReason,
11
+ extractSedReadTargets,
12
+ extractUnansweredTail,
7
13
  extractPatterns,
8
14
  extractSearchPath,
9
15
  normalizeCommandPaths,
@@ -317,30 +323,81 @@ test('shouldBlock: rg with CamelCase on lib/', () => {
317
323
 
318
324
  // ── shouldBlock: should NOT block (downgrade to hint) — precision flags ─
319
325
 
320
- test('shouldBlock: grep -l (files-with-matches) → hint only', () => {
321
- assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'), false);
326
+ test('shouldBlock: grep -l (files-with-matches) → deny, grep answer covers file lists (v0.49)', () => {
327
+ assert.equal(shouldBlock('grep -rl "EmbeddingModel" src/'), true);
328
+ assert.deepEqual(classifyBlock('grep -rl "EmbeddingModel" src/'), { mode: 'grep' });
322
329
  });
323
330
 
324
- test('shouldBlock: --include=*.rs → user already filtering, hint only', () => {
325
- assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), false);
331
+ test('shouldBlock: --include=*.rs → deny, path-scoped grep answer covers it (v0.49)', () => {
332
+ assert.equal(shouldBlock('grep -rn --include="*.rs" "EmbeddingModel" src/'), true);
326
333
  });
327
334
 
328
- test('shouldBlock: --exclude-dir=tests → hint only', () => {
335
+ test('shouldBlock: --exclude=tests → hint only (answer cannot honor exclusion)', () => {
329
336
  assert.equal(shouldBlock('grep -rn --exclude=tests "EmbeddingModel" src/'), false);
330
337
  });
331
338
 
332
- test('shouldBlock: -A 3 context flag → hint only', () => {
339
+ test('shouldBlock: -L / -v inverted intents → hint only', () => {
340
+ assert.equal(shouldBlock('grep -rL "EmbeddingModel" src/'), false);
341
+ assert.equal(shouldBlock('grep -rnv "EmbeddingModel" src/'), false);
342
+ });
343
+
344
+ test('shouldBlock: -A 3 with bare identifier → hint only (cannot honor ±N lines)', () => {
333
345
  assert.equal(shouldBlock('grep -rn -A 3 "EmbeddingModel" src/'), false);
334
346
  });
335
347
 
336
- test('shouldBlock: -B 2 context flag → hint only', () => {
348
+ test('shouldBlock: -B 2 with bare identifier → hint only', () => {
337
349
  assert.equal(shouldBlock('grep -rn -B 2 "EmbeddingModel" src/'), false);
338
350
  });
339
351
 
340
- test('shouldBlock: -C 5 context flag → hint only', () => {
352
+ test('shouldBlock: -C 5 with bare identifier → hint only', () => {
341
353
  assert.equal(shouldBlock('grep -rn -C 5 "EmbeddingModel" src/'), false);
342
354
  });
343
355
 
356
+ // ── translateBreToRg (v0.49) — BRE→rust-regex dialect bridge ─────────
357
+
358
+ test('translateBreToRg: plain grep BRE alternation unescaped', () => {
359
+ assert.equal(
360
+ translateBreToRg('grep -rn "UnifiedPickerEngine\\|engine.run" src/', 'UnifiedPickerEngine\\|engine.run'),
361
+ 'UnifiedPickerEngine|engine.run');
362
+ });
363
+
364
+ test('translateBreToRg: rg patterns untouched (already extended)', () => {
365
+ assert.equal(translateBreToRg('rg "a\\|b" src/', 'a\\|b'), 'a\\|b');
366
+ });
367
+
368
+ test('translateBreToRg: grep -E untouched', () => {
369
+ assert.equal(translateBreToRg('grep -rnE "a\\|b" src/', 'a\\|b'), 'a\\|b');
370
+ });
371
+
372
+ test('translateBreToRg: unescapes groups/braces/quantifiers for plain grep', () => {
373
+ assert.equal(translateBreToRg('grep "fn \\(x\\)\\+" src/', 'fn \\(x\\)\\+'), 'fn (x)+');
374
+ });
375
+
376
+ // ── classifyBlock: show mode (v0.49) — the daagu 22/128 function-body reads ──
377
+
378
+ test('classifyBlock: declaration anchor + -A → show mode with symbols', () => {
379
+ assert.deepEqual(
380
+ classifyBlock('rg -n "def cascade_failure|def reset_task" -A 25 backend/app/'),
381
+ { mode: 'show', symbols: ['cascade_failure', 'reset_task'] });
382
+ });
383
+
384
+ test('classifyBlock: multi-decl alternation caps at 3 symbols', () => {
385
+ const c = classifyBlock('rg -n "def a_one|def b_two|class CThree|fn d_four" -A 10 src/');
386
+ assert.equal(c.mode, 'show');
387
+ assert.deepEqual(c.symbols, ['a_one', 'b_two', 'CThree']);
388
+ });
389
+
390
+ test('classifyBlock: declaration anchor WITHOUT context flag → plain grep deny', () => {
391
+ assert.deepEqual(classifyBlock('grep -rn "def fetch_user" backend/app/services/'),
392
+ { mode: 'grep' });
393
+ });
394
+
395
+ test('extractDeclSymbols: dedupes and spans fn/def/class/struct anchors', () => {
396
+ assert.deepEqual(
397
+ extractDeclSymbols(['fn alpha_one', 'struct BetaTwo', 'fn alpha_one']),
398
+ ['alpha_one', 'BetaTwo']);
399
+ });
400
+
344
401
  // ── shouldBlock: should NOT block — marker-only patterns ────────────
345
402
 
346
403
  test('shouldBlock: bare TODO marker → hint only (no cg equivalent)', () => {
@@ -402,8 +459,8 @@ test('buildBlockReason: lists cg grep + ast-search + callgraph', () => {
402
459
  assert.match(out, /code-graph-mcp callgraph/);
403
460
  });
404
461
 
405
- test('buildBlockReason: documents the escape hatch env var', () => {
406
- assert.match(buildBlockReason(), /CODE_GRAPH_NO_BLOCK_GREP=1/);
462
+ test('buildBlockReason: NEVER documents the escape hatch (v0.49 — the "THIS command only" scoping was adopted as a permanent prefix in 8s on 2026-06-12)', () => {
463
+ assert.doesNotMatch(buildBlockReason(), /CODE_GRAPH_NO_BLOCK_GREP/);
407
464
  });
408
465
 
409
466
  test('buildBlockReason: under 700-byte budget (single CC message)', () => {
@@ -583,11 +640,11 @@ test('replay: real abs-path symbol grep → BLOCK after normalization', () => {
583
640
  assert.equal(shouldBlock(norm), true);
584
641
  });
585
642
 
586
- test('replay: real abs-path -rln grep → HINT only after normalization (precision flag)', () => {
643
+ test('replay: real abs-path -rln grep → DENY after normalization (v0.49: file lists answerable)', () => {
587
644
  const cmd = `grep -rln "load_active_config_standalone" ${DAAGU}/backend/tests/ | head -5`;
588
645
  const norm = normalizeCommandPaths(cmd, DAAGU);
589
646
  assert.equal(shouldHint(norm), true);
590
- assert.equal(shouldBlock(norm), false); // -l cluster disqualifies block
647
+ assert.deepEqual(classifyBlock(norm), { mode: 'grep' }); // grep answer lists files per hit
591
648
  });
592
649
 
593
650
  test('replay: abs-path config-only grep stays silent after normalization', () => {
@@ -671,13 +728,6 @@ test('buildBlockReasonWithAnswer: NEVER advertises the bypass (v0.48 — one den
671
728
  assert.doesNotMatch(reason, /CODE_GRAPH_NO_BLOCK_GREP/);
672
729
  });
673
730
 
674
- test('buildBlockReason: scopes the escape to THIS command only', () => {
675
- const reason = buildBlockReason();
676
- assert.match(reason, /CODE_GRAPH_NO_BLOCK_GREP=1/);
677
- assert.match(reason, /THIS command only/);
678
- assert.match(reason, /not a default/);
679
- });
680
-
681
731
  test('buildBlockReasonWithAnswer: no searchPath → command has no path arg', () => {
682
732
  const reason = buildBlockReasonWithAnswer('fts5_search', undefined, {
683
733
  status: 'hits', text: 'hit', truncated: false,
@@ -692,6 +742,78 @@ test('buildBlockReasonWithAnswer: truncated flag adds marker', () => {
692
742
  assert.match(reason, /truncated/);
693
743
  });
694
744
 
745
+ // ── v0.50 compound-command tail: deny answers the grep, NOT the rest ─
746
+
747
+ test('extractUnansweredTail: `; sed` tail after piped grep (2026-06-13 real deny shape)', () => {
748
+ assert.equal(
749
+ extractUnansweredTail(
750
+ 'grep -n "mem_update\\|registerTool" tests/server.test.mjs | head -20; sed -n \'1,60p\' tests/server.test.mjs'),
751
+ "sed -n '1,60p' tests/server.test.mjs");
752
+ });
753
+
754
+ test('extractUnansweredTail: && tail is unanswered (would have run on grep success)', () => {
755
+ assert.equal(
756
+ extractUnansweredTail('grep -rn "fts5_search" src/ && wc -l src/storage/db.rs'),
757
+ 'wc -l src/storage/db.rs');
758
+ });
759
+
760
+ test('extractUnansweredTail: quoted separators are pattern text, not a tail', () => {
761
+ assert.equal(extractUnansweredTail('grep -rn "a;b" src/'), null);
762
+ assert.equal(extractUnansweredTail("grep -rn 'a && b' src/"), null);
763
+ });
764
+
765
+ test('extractUnansweredTail: pipes and redirects are the same pipeline, not a tail', () => {
766
+ assert.equal(extractUnansweredTail('grep -rn "Foo" src/ 2>&1 | head -10'), null);
767
+ });
768
+
769
+ test('extractUnansweredTail: || branch would NOT have run on hits — no tail', () => {
770
+ assert.equal(extractUnansweredTail('grep -rn "Foo" src/ || echo none'), null);
771
+ });
772
+
773
+ test('extractUnansweredTail: trailing separator with nothing after → no tail', () => {
774
+ assert.equal(extractUnansweredTail('grep -rn "Foo" src/;'), null);
775
+ });
776
+
777
+ test('buildBlockReasonWithAnswer: compound tail → note says the rest did NOT run', () => {
778
+ const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
779
+ status: 'hits', text: 'hit', truncated: false,
780
+ }, "sed -n '1,60p' tests/server.test.mjs");
781
+ assert.match(reason, /did NOT run/);
782
+ assert.match(reason, /sed -n '1,60p' tests\/server\.test\.mjs/);
783
+ });
784
+
785
+ test('buildBlockReasonWithAnswer: no tail → no compound note', () => {
786
+ const reason = buildBlockReasonWithAnswer('fts5_search', 'src/', {
787
+ status: 'hits', text: 'hit', truncated: false,
788
+ });
789
+ assert.doesNotMatch(reason, /did NOT run/);
790
+ });
791
+
792
+ test('buildShowDenyReason: compound tail → note says the rest did NOT run', () => {
793
+ const reason = buildShowDenyReason(
794
+ { status: 'hits', text: 'fn body', truncated: false },
795
+ 'cargo test -q');
796
+ assert.match(reason, /did NOT run/);
797
+ assert.match(reason, /cargo test -q/);
798
+ });
799
+
800
+ test('buildShowDenyReason: no tail → no compound note', () => {
801
+ const reason = buildShowDenyReason({ status: 'hits', text: 'fn body', truncated: false });
802
+ assert.doesNotMatch(reason, /did NOT run/);
803
+ });
804
+
805
+ test('buildBlockReason: compound tail → static deny also flags the unanswered tail', () => {
806
+ const reason = buildBlockReason("sed -n '1,60p' tests/server.test.mjs");
807
+ assert.match(reason, /did NOT run/);
808
+ assert.match(reason, /sed -n '1,60p' tests\/server\.test\.mjs/);
809
+ });
810
+
811
+ test('buildBlockReason: no tail → unchanged static deny', () => {
812
+ const reason = buildBlockReason();
813
+ assert.match(reason, /denied by code-graph hook/);
814
+ assert.doesNotMatch(reason, /did NOT run/);
815
+ });
816
+
695
817
  test('buildNoHitsFyi: names the pattern and says raw grep proceeds', () => {
696
818
  const fyi = buildNoHitsFyi('GhostSymbol');
697
819
  assert.match(fyi, /GhostSymbol/);
@@ -854,6 +976,75 @@ test('e2e: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → static deny even when stub would h
854
976
  }
855
977
  });
856
978
 
979
+ test('e2e: compound `grep …; sed` → deny answers grep AND flags the unanswered sed tail', () => {
980
+ const uniq = `StubTail${Date.now()}`;
981
+ const fixture = e2eFixture(
982
+ `process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
983
+ const cmd = `grep -n "${uniq}" src/foo.rs | head -20; sed -n '100,160p' src/foo.rs`;
984
+ try {
985
+ fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
986
+ fsE2e.writeFileSync(pathE2e.join(fixture.dir, 'src', 'foo.rs'), 'fn x() {}\n');
987
+ const res = runHook(cmd, fixture);
988
+ assert.equal(res.status, 0);
989
+ const out = JSON.parse(res.stdout);
990
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
991
+ const reason = out.hookSpecificOutput.permissionDecisionReason;
992
+ assert.match(reason, /src\/foo\.rs:7/); // grep half answered
993
+ assert.match(reason, /did NOT run/); // tail flagged honestly
994
+ assert.match(reason, /sed -n '100,160p' src\/foo\.rs/); // verbatim re-issue line
995
+ // funnel: the deny record marks that a tail note was carried
996
+ const recs = fsE2e.readFileSync(
997
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8');
998
+ const rec = JSON.parse(recs.trim().split('\n').pop());
999
+ assert.equal(rec.action, 'deny');
1000
+ assert.equal(rec.tail, true);
1001
+ } finally {
1002
+ cleanupFixture(fixture, cmd);
1003
+ }
1004
+ });
1005
+
1006
+ test('e2e: compound cmd + answer failure → STATIC deny still flags tail + records tail:true', () => {
1007
+ const uniq = `StubTailBoom${Date.now()}`;
1008
+ const fixture = e2eFixture(`process.exit(3);`);
1009
+ const cmd = `grep -n "${uniq}" src/foo.rs && cargo test -q`;
1010
+ try {
1011
+ fsE2e.mkdirSync(pathE2e.join(fixture.dir, 'src'), { recursive: true });
1012
+ fsE2e.writeFileSync(pathE2e.join(fixture.dir, 'src', 'foo.rs'), 'fn x() {}\n');
1013
+ const res = runHook(cmd, fixture);
1014
+ assert.equal(res.status, 0);
1015
+ const out = JSON.parse(res.stdout);
1016
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1017
+ const reason = out.hookSpecificOutput.permissionDecisionReason;
1018
+ assert.match(reason, /denied by code-graph hook/); // static fallback path
1019
+ assert.match(reason, /did NOT run/);
1020
+ assert.match(reason, /cargo test -q/);
1021
+ const rec = JSON.parse(fsE2e.readFileSync(
1022
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
1023
+ assert.equal(rec.answered, false);
1024
+ assert.equal(rec.tail, true);
1025
+ } finally {
1026
+ cleanupFixture(fixture, cmd);
1027
+ }
1028
+ });
1029
+
1030
+ test('e2e: simple (non-compound) denied grep → no tail field in the deny record', () => {
1031
+ const uniq = `StubNoTail${Date.now()}`;
1032
+ const fixture = e2eFixture(
1033
+ `process.stdout.write('src/foo.rs:7 fn ' + process.argv[3] + '()\\n');`);
1034
+ const cmd = `grep -rn "${uniq}" src/`;
1035
+ try {
1036
+ const res = runHook(cmd, fixture);
1037
+ const out = JSON.parse(res.stdout);
1038
+ assert.equal(out.hookSpecificOutput.permissionDecision, 'deny');
1039
+ const rec = JSON.parse(fsE2e.readFileSync(
1040
+ pathE2e.join(fixture.dir, '.code-graph', 'recommendations.jsonl'), 'utf8').trim());
1041
+ assert.equal(rec.action, 'deny');
1042
+ assert.equal('tail' in rec, false);
1043
+ } finally {
1044
+ cleanupFixture(fixture, cmd);
1045
+ }
1046
+ });
1047
+
857
1048
  // ── v0.48 subdir-cwd dark fix: resolveProjectRoot / rebaseRelativePaths ──
858
1049
  // daagu 2026-06-11: the persistent shell `cd backend/` darkened 38/40
859
1050
  // head-greps for the rest of the night — gate 5 checked process.cwd() only.
@@ -1040,3 +1231,30 @@ test('rebaseRelativePaths: glob token rebases when its glob-truncated dir exists
1040
1231
  assert.equal(
1041
1232
  sanitizeSearchPath(extractSearchPath(rebased)), 'backend/app/services/llm_engine');
1042
1233
  });
1234
+
1235
+ // ── extractSedReadTargets (v0.49) — sed-range reads feed read-fanout ──
1236
+
1237
+ test('extractSedReadTargets: plain and quoted ranges, abs and rel paths', () => {
1238
+ assert.deepEqual(
1239
+ extractSedReadTargets('sed -n 620,700p /abs/proj/backend/app/services/market.py'),
1240
+ ['/abs/proj/backend/app/services/market.py']);
1241
+ assert.deepEqual(
1242
+ extractSedReadTargets("sed -n '230,310p' backend/app/services/tushare.py"),
1243
+ ['backend/app/services/tushare.py']);
1244
+ });
1245
+
1246
+ test('extractSedReadTargets: multiple segments in one command, deduped', () => {
1247
+ const cmd = 'sed -n 60,200p src/a.py; echo ===; sed -n 250,300p src/b.py && sed -n 250,300p src/b.py';
1248
+ assert.deepEqual(extractSedReadTargets(cmd), ['src/a.py', 'src/b.py']);
1249
+ });
1250
+
1251
+ test('extractSedReadTargets: non-range sed (substitution) ignored', () => {
1252
+ assert.deepEqual(extractSedReadTargets("sed -i 's/a/b/' src/a.py"), []);
1253
+ assert.deepEqual(extractSedReadTargets('sed -n /pattern/p src/a.py'), []);
1254
+ });
1255
+
1256
+ test('extractSedReadTargets: pipeline sed after grep still extracted', () => {
1257
+ assert.deepEqual(
1258
+ extractSedReadTargets('grep -n "x" src/a.py | sed -n 1,5p src/b.py'),
1259
+ ['src/b.py']);
1260
+ });
@@ -27,6 +27,8 @@ const path = require('path');
27
27
  const crypto = require('crypto');
28
28
  const { cgTmpDir } = require('./tmp-dir');
29
29
  const { recordRecommendation } = require('./recommendation-log');
30
+ const { resolveProjectRoot } = require('./project-root');
31
+ const { runOverviewAnswer } = require('./cg-answer');
30
32
 
31
33
  // --- Configuration ---
32
34
 
@@ -119,50 +121,87 @@ function buildHint(dir) {
119
121
  return `[code-graph] 5+ Reads into ${dir}/ — \`code-graph-mcp overview ${dir}/\` gives symbols+callers in one call (MCP: \`module_overview path=${dir}\`). Skip if you need raw file contents.`;
120
122
  }
121
123
 
124
+ // v0.49 — the hint DELIVERS the overview instead of advising a tool call
125
+ // (advice measured 0/40 transfer on 2026-06-12; delivered answers satisfied
126
+ // 5/5 in place). Falls back to the advice-only line when the CLI is
127
+ // unavailable or the dir has no overview.
128
+ function buildHintWithAnswer(dir, answer) {
129
+ const lines = [
130
+ `[code-graph] 5+ Reads into ${dir}/ — module overview from the AST index (saves the remaining file-by-file reads):`,
131
+ answer.text,
132
+ ];
133
+ if (answer.truncated) {
134
+ lines.push(`(truncated — \`code-graph-mcp overview ${dir}/\` for the full map)`);
135
+ }
136
+ return lines.join('\n');
137
+ }
138
+
122
139
  function isSilenced(env = process.env) {
123
140
  return env.CODE_GRAPH_QUIET_HOOKS === '1';
124
141
  }
125
142
 
143
+ // v0.49 — answer tier opt-out, shared name with the grep hook's deny-answer
144
+ // opt-out: =1 restores advice-only hints (no CLI run inside the hook).
145
+ function isAnswerDisabled(env = process.env) {
146
+ return env.CODE_GRAPH_NO_ANSWER_IN_DENY === '1';
147
+ }
148
+
149
+ // --- Shared tracking core (also driven by pre-grep-guide's sed-range path) ---
150
+
151
+ /// Record one read of `rel` (project-root-relative source path) and fire the
152
+ /// fanout hint when the threshold crosses. Emits to stdout + records the
153
+ /// recommendation. Returns true when a hint fired.
154
+ function trackReadAndMaybeHint(root, rel, now = Date.now()) {
155
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
156
+ const dir = path.dirname(rel);
157
+ if (!dir || dir === '.' || dir === '') return false; // top-level file: not fanout
158
+
159
+ const state = loadState(root, now);
160
+ recordRead(state, dir, now);
161
+ let fired = false;
162
+ if (shouldHint(state, dir, now)) {
163
+ markHint(state, dir, now);
164
+ fired = true;
165
+ }
166
+ saveState(root, state);
167
+ if (!fired) return false;
168
+
169
+ let answer = { status: 'unavailable' };
170
+ if (!isAnswerDisabled()) {
171
+ answer = runOverviewAnswer({ cwd: root, dir });
172
+ }
173
+ const answered = answer.status === 'hits';
174
+ recordRecommendation(root, { hook: 'read', action: 'hint', answered });
175
+ process.stdout.write((answered ? buildHintWithAnswer(dir, answer) : buildHint(dir)) + '\n');
176
+ return true;
177
+ }
178
+
126
179
  // --- Main execution ---
127
180
 
128
181
  function runMain() {
129
182
  if (isSilenced()) return;
130
- const cwd = process.cwd();
131
- const dbPath = path.join(cwd, '.code-graph', 'index.db');
132
- if (!fs.existsSync(dbPath)) return;
183
+ // v0.49 walk up from the shell cwd (subdir-cwd fix; the read hook had
184
+ // recorded NOTHING in daagu history because sessions sat in backend/).
185
+ const root = resolveProjectRoot(process.cwd());
186
+ if (root === null) return;
133
187
 
134
188
  let input;
135
189
  try {
136
- input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
190
+ // fd 0, not '/dev/stdin': the path form fails ENXIO on socketpair stdin.
191
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
137
192
  } catch { return; }
138
193
 
139
194
  const filePath = (input.tool_input && input.tool_input.file_path) || '';
140
195
  if (!isSourceFile(filePath)) return;
141
196
 
142
- // Normalize to a cwd-relative path. If the file is outside cwd, skip —
143
- // a hint pointing at an unrelated dir helps no one.
197
+ // Normalize to a root-relative path. Read sends absolute paths; files
198
+ // outside the project (other repos, ~/.claude/) stay silent.
144
199
  let rel;
145
200
  try {
146
- rel = path.relative(cwd, filePath);
201
+ rel = path.relative(root, filePath);
147
202
  } catch { return; }
148
- if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return;
149
-
150
- const dir = path.dirname(rel);
151
- if (!dir || dir === '.' || dir === '') return; // top-level file: not fanout
152
203
 
153
- const now = Date.now();
154
- const state = loadState(cwd, now);
155
- recordRead(state, dir, now);
156
- let fired = false;
157
- if (shouldHint(state, dir, now)) {
158
- markHint(state, dir, now);
159
- fired = true;
160
- }
161
- saveState(cwd, state);
162
- if (fired) {
163
- recordRecommendation(cwd, { hook: 'read', action: 'hint' });
164
- process.stdout.write(buildHint(dir) + '\n');
165
- }
204
+ trackReadAndMaybeHint(root, rel);
166
205
  }
167
206
 
168
207
  if (require.main === module) {
@@ -172,6 +211,7 @@ if (require.main === module) {
172
211
  module.exports = {
173
212
  isSourceFile, dirOf, cwdHash, statePath,
174
213
  loadState, saveState, recordRead, shouldHint, markHint,
175
- buildHint, isSilenced,
214
+ buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
215
+ trackReadAndMaybeHint, // v0.49 — shared with pre-grep-guide's sed-range path
176
216
  FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS, SRC_EXT,
177
217
  };
@@ -8,7 +8,8 @@ const crypto = require('crypto');
8
8
 
9
9
  const {
10
10
  isSourceFile, dirOf, recordRead, shouldHint, markHint,
11
- buildHint, isSilenced,
11
+ buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
12
+ trackReadAndMaybeHint,
12
13
  FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS,
13
14
  loadState, saveState, statePath,
14
15
  } = require('./pre-read-guide');
@@ -243,3 +244,60 @@ test('flow: 5 reads to same dir → hint, 6th read same dir → no hint (cooldow
243
244
  recordRead(s, 'src/foo', 1005);
244
245
  assert.equal(shouldHint(s, 'src/foo', 1005), false);
245
246
  });
247
+
248
+ // ── v0.49: answer-in-hint + shared tracking core ─────────────────────
249
+
250
+ test('buildHintWithAnswer: embeds overview text and truncation pointer', () => {
251
+ const out = buildHintWithAnswer('src/storage', { text: 'Module src/storage\n conn (57 callers)', truncated: true });
252
+ assert.match(out, /^\[code-graph\] 5\+ Reads into src\/storage\//);
253
+ assert.match(out, /conn \(57 callers\)/);
254
+ assert.match(out, /truncated — `code-graph-mcp overview src\/storage\/`/);
255
+ });
256
+
257
+ test('isAnswerDisabled: CODE_GRAPH_NO_ANSWER_IN_DENY=1 → advice-only hints', () => {
258
+ assert.equal(isAnswerDisabled({ CODE_GRAPH_NO_ANSWER_IN_DENY: '1' }), true);
259
+ assert.equal(isAnswerDisabled({}), false);
260
+ });
261
+
262
+ test('trackReadAndMaybeHint: fires on 5th read with stubbed overview answer', () => {
263
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-track-'));
264
+ // Stub CLI: prints a fake overview (hook resolves binary via _CG_ANSWER_BINARY).
265
+ const stub = path.join(root, 'stub.js');
266
+ fs.writeFileSync(stub, '#!/usr/bin/env node\nprocess.stdout.write("Module overview stub: 3 symbols\\n");');
267
+ fs.chmodSync(stub, 0o755);
268
+ const oldEnv = process.env._CG_ANSWER_BINARY;
269
+ process.env._CG_ANSWER_BINARY = stub;
270
+ // .code-graph present so recordRecommendation appends.
271
+ fs.mkdirSync(path.join(root, '.code-graph'), { recursive: true });
272
+
273
+ const written = [];
274
+ const origWrite = process.stdout.write.bind(process.stdout);
275
+ process.stdout.write = (chunk) => { written.push(String(chunk)); return true; };
276
+ try {
277
+ let fired = false;
278
+ for (let i = 0; i < 5; i++) {
279
+ fired = trackReadAndMaybeHint(root, 'src/storage/file' + i + '.rs');
280
+ }
281
+ assert.equal(fired, true, '5th same-dir read must fire');
282
+ assert.match(written.join(''), /Module overview stub/, 'hint must EMBED the overview answer');
283
+ const recs = fs.readFileSync(path.join(root, '.code-graph', 'recommendations.jsonl'), 'utf8');
284
+ assert.match(recs, /"hook":"read"/);
285
+ assert.match(recs, /"answered":true/);
286
+ } finally {
287
+ process.stdout.write = origWrite;
288
+ if (oldEnv === undefined) delete process.env._CG_ANSWER_BINARY;
289
+ else process.env._CG_ANSWER_BINARY = oldEnv;
290
+ fs.rmSync(root, { recursive: true, force: true });
291
+ }
292
+ });
293
+
294
+ test('trackReadAndMaybeHint: top-level and outside-root paths never fire', () => {
295
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'readfan-skip-'));
296
+ try {
297
+ assert.equal(trackReadAndMaybeHint(root, 'main.rs'), false);
298
+ assert.equal(trackReadAndMaybeHint(root, '../other/file.rs'), false);
299
+ assert.equal(trackReadAndMaybeHint(root, '/abs/file.rs'), false);
300
+ } finally {
301
+ fs.rmSync(root, { recursive: true, force: true });
302
+ }
303
+ });
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+ // Shared project-root resolution for the PreToolUse hooks (v0.49 — extracted
3
+ // from pre-grep-guide so pre-read-guide gets the same subdir-cwd fix without a
4
+ // circular require).
5
+ //
6
+ // The hook's process.cwd() follows the PERSISTENT shell, not the project root:
7
+ // after the model runs `cd backend/`, every per-cwd gate (index.db existence,
8
+ // relative-path matching) fails silently for the rest of the session (daagu
9
+ // 2026-06-11: 38/40 head-greps dark; the read hook never recorded AT ALL).
10
+ // Walk up to the nearest ancestor holding `.code-graph/index.db`; stop at
11
+ // $HOME (checked, not crossed) and fs root.
12
+
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+
17
+ function resolveProjectRoot(startDir, opts = {}) {
18
+ const home = opts.home !== undefined ? opts.home : os.homedir();
19
+ const exists = opts.exists || fs.existsSync;
20
+ let dir = path.resolve(startDir || '.');
21
+ for (;;) {
22
+ if (exists(path.join(dir, '.code-graph', 'index.db'))) return dir;
23
+ if (dir === home) return null;
24
+ const parent = path.dirname(dir);
25
+ if (parent === dir) return null;
26
+ dir = parent;
27
+ }
28
+ }
29
+
30
+ module.exports = { resolveProjectRoot };
@@ -6,7 +6,7 @@ const fs = require('fs');
6
6
  const {
7
7
  install, update, readManifest, getPluginVersion, checkScopeConflict,
8
8
  cleanupDisabledStatusline, isPluginInactive, readJson, CACHE_DIR,
9
- settingsPath,
9
+ settingsPath, isStaleRelicContext,
10
10
  } = require('./lifecycle');
11
11
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
12
12
  const { maybeAutoAdopt, isAdopted } = require('./adopt');
@@ -55,6 +55,15 @@ function launchBackgroundAutoUpdate(spawnFn = spawn, env = process.env) {
55
55
  }
56
56
 
57
57
  function syncLifecycleConfig() {
58
+ // v0.49.1: stale-relic guard. A still-running Claude Code process fires
59
+ // SessionStart from the plugin-cache dir it loaded at startup; after
60
+ // auto-update installs a newer version, those old scripts would see
61
+ // `manifest.version !== currentVersion` below and — direction-blind —
62
+ // call update(), dragging manifest + every settings.json hook path back
63
+ // to the old dir (upgrade↔downgrade ping-pong, observed live 2026-06-12).
64
+ // installed_plugins.json is the authority on which install may self-heal.
65
+ if (isStaleRelicContext()) return 'deferred-to-active-install';
66
+
58
67
  const manifest = readManifest();
59
68
  const currentVersion = getPluginVersion();
60
69
 
@@ -81,6 +90,14 @@ function syncLifecycleConfig() {
81
90
  install();
82
91
  return 'self-healed-bad-path';
83
92
  }
93
+ // v0.49.1: also self-heal when the composite path exists but is not the one
94
+ // we'd write now (old plugin-cache version dir that still exists on disk —
95
+ // invisible to the existence check above; same fault class as the binary pin).
96
+ const { compositeCommand } = require('./lifecycle');
97
+ if (settings.statusLine.command !== compositeCommand()) {
98
+ install();
99
+ return 'self-healed-stale-statusline';
100
+ }
84
101
  // Self-heal if any hook command points to a non-existent script (path pollution)
85
102
  if (settings.hooks) {
86
103
  for (const entries of Object.values(settings.hooks)) {
@@ -101,18 +118,20 @@ function syncLifecycleConfig() {
101
118
  // (e.g. user manually edited settings.json, or settings.json got rewritten
102
119
  // by another tool that didn't preserve our entries). Without this, the
103
120
  // user silently loses PreToolUse/PostToolUse hooks until next plugin update.
104
- const { isOurHookEntry, buildSettingsHookEntries } = require('./lifecycle');
105
- const desired = buildSettingsHookEntries();
106
- for (const [event, desiredEntries] of Object.entries(desired)) {
107
- const presentMatchers = new Set(
108
- (settings.hooks?.[event] || []).filter(isOurHookEntry).map(e => e.matcher || '*')
109
- );
110
- for (const e of desiredEntries) {
111
- if (!presentMatchers.has(e.matcher || '*')) {
112
- install();
113
- return 'self-healed-missing-settings-hook';
114
- }
115
- }
121
+ // v0.49.1: upgraded from matcher-presence to surveyHookCoverage so a
122
+ // present-but-stale command path (old plugin-cache version dir that still
123
+ // exists) also heals. Previously only doctor checked staleness, so if the
124
+ // auto-update re-register step failed silently, users kept running old hook
125
+ // code indefinitely — the settings.json sibling of the binary-pin bug.
126
+ const { surveyHookCoverage } = require('./lifecycle');
127
+ const cov = surveyHookCoverage(settings);
128
+ if (cov.missing.length > 0) {
129
+ install();
130
+ return 'self-healed-missing-settings-hook';
131
+ }
132
+ if (cov.stale.length > 0) {
133
+ install();
134
+ return 'self-healed-stale-settings-hook';
116
135
  }
117
136
  return 'noop';
118
137
  }
@@ -298,6 +317,11 @@ function runSessionInit() {
298
317
  }
299
318
 
300
319
  const lifecycle = syncLifecycleConfig();
320
+ // v0.49.1: a stale relic (see isStaleRelicContext) must not write ANY
321
+ // versioned state — that includes the adoption template: maybeAutoAdopt's
322
+ // drift-refresh would "refresh" MEMORY.md back to the relic's OLD shipped
323
+ // template, the adoption-surface twin of the settings.json downgrade war.
324
+ const isRelic = lifecycle === 'deferred-to-active-install';
301
325
 
302
326
  // Verify binary availability — catch issues early with actionable diagnostics
303
327
  const binaryCheck = verifyBinary();
@@ -308,7 +332,7 @@ function runSessionInit() {
308
332
  // v0.9.0 C' 上下文感知默认:插件模式下首次 SessionStart 自动 adopt。
309
333
  // v0.11.0: 已 adopt 的项目如果 shipped template 漂移也会触发一次刷新。
310
334
  // 两种情况都发一次 stderr 提示,让用户知道发生了什么 + 如何回退。
311
- const autoAdopt = maybeAutoAdopt({ scriptPath: __dirname });
335
+ const autoAdopt = isRelic ? { attempted: false, result: null } : maybeAutoAdopt({ scriptPath: __dirname });
312
336
  if (autoAdopt.attempted && autoAdopt.result && autoAdopt.result.ok) {
313
337
  if (autoAdopt.reason === 'refreshed') {
314
338
  process.stderr.write(
@@ -38,6 +38,14 @@ type: reference
38
38
 
39
39
  ## 何时调用 MCP/CLI(替代多步 Grep/Read)
40
40
 
41
+ > **v0.49 起 CLI 优先**:Claude Code 里 MCP 工具是 deferred(首次调用前要
42
+ > ToolSearch 加载),而 Bash 永远在线——真实编程夜(2026-06-12)观测到的全部
43
+ > 转化都是 CLI 调用。结构化查询的最快路径是 Bash 直呼
44
+ > `code-graph-mcp callgraph X / show X / overview <dir> / grep "pat" / impact X`。
45
+ > `grep` 是 drop-in 替代:`-F` 字面 / `-i` / `-w` / `-l` / `-A/-B/-C N` 上下文 /
46
+ > 多路径 / `--max-count 0`,退出码兼容 grep(0/1/2),召回达 git-grep 级
47
+ > (tracked-but-gitignored 也能搜到),每条命中标注所属 fn/class。
48
+ >
41
49
  > v0.10.0 起:tools/list 默认只暴露 7 个核心工具;下表"进阶 5"中的工具
42
50
  > 已从 tools/list 隐藏以节省 session 启动 tokens。**Claude Code 里请走 CLI
43
51
  > 子命令**(MCP schema 不在 list,Claude Code 的 ToolSearch 不会加载,直接