Skip to content

Commit fcdecba

Browse files
jgravelleclaude
andcommitted
release: v1.108.117 — _meta.verdict on the file & symbol read tools
Phase 2 of the retrieval-verdict work. get_file_content, get_file_outline, and get_symbol_source now emit _meta.verdict: a miss returns state=absent plus did_you_mean near-miss candidates (exact-basename/same-name ranked first), and successful reads carry state=ok for parity with search_symbols. get_file_outline separates "not indexed" from "indexed but no symbols". Additive only: error strings and existing keys unchanged. No INDEX_VERSION bump, no tool-count or schema change, inline compute. 13 new tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a4cc927 commit fcdecba

7 files changed

Lines changed: 427 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,37 @@
22

33
All notable changes to jcodemunch-mcp are documented here.
44

5+
## [1.108.117] - 2026-07-10 - `_meta.verdict` on the file & symbol read tools
6+
7+
### Added
8+
9+
- **`get_file_content`, `get_file_outline`, and `get_symbol_source` now emit
10+
`_meta.verdict`** — the same honesty contract that already covers the search tools now
11+
covers the read tools, where an agent most often burns tokens chasing a path or symbol
12+
id that isn't there. A miss returns `state: "absent"` plus a `did_you_mean` list of
13+
near-miss candidates, so the agent corrects the target instead of retrying the same
14+
wrong one.
15+
- **Path near-misses** rank an exact-basename match in a different directory (right
16+
filename, wrong folder) ahead of stem substring matches. **Symbol near-misses** rank a
17+
same-name symbol in another file/kind ahead of substring matches. Both are computed
18+
only on the miss path.
19+
- **`get_file_outline` distinguishes "file not indexed" from "file indexed but exposes no
20+
symbols"** — the latter is a real `absent` (a data/config file, or constructs the parser
21+
does not surface) with no suggestions, so the agent does not re-request the outline
22+
expecting a different result.
23+
24+
### Changed
25+
26+
- The file/symbol verdict logic lives in the same `retrieval/verdict.py` module as the
27+
search verdict (`build_file_verdict` / `build_symbol_verdict` plus index-aware
28+
`file_verdict_for_index` / `symbol_verdict_for_index` wrappers). Successful reads now
29+
also carry `_meta.verdict: {state: "ok"}` so the field is present on every outcome,
30+
matching `search_symbols`. All additive — error strings and existing keys are unchanged.
31+
32+
NO INDEX_VERSION bump; no tool-count or input-schema change; inline compute, no new
33+
background or network behavior. `tests/test_negative_evidence.py` extended (13 new cases:
34+
path/symbol near-miss ranking, the three file-tool states, and the batch/single symbol paths).
35+
536
## [1.108.116] - 2026-07-10 - Unified retrieval verdict (`_meta.verdict`)
637

738
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "jcodemunch-mcp"
3-
version = "1.108.116"
3+
version = "1.108.117"
44
description = "Token-efficient MCP server for source code exploration via tree-sitter AST parsing"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/jcodemunch_mcp/retrieval/verdict.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,213 @@ def build_verdict(
158158
negative_evidence["related_existing"] = did_you_mean
159159

160160
return {"verdict": verdict, "negative_evidence": negative_evidence}
161+
162+
163+
def suggest_paths(
164+
requested_path: Optional[str],
165+
source_files: Optional[Sequence[str]],
166+
cap: int = 5,
167+
) -> list:
168+
"""Indexed paths that plausibly match a missing ``requested_path``.
169+
170+
Exact-basename matches in a different directory come first (the agent had
171+
the filename right, the directory wrong), then stem substring matches. The
172+
requested path itself is never suggested.
173+
"""
174+
if not requested_path or not source_files:
175+
return []
176+
req = str(requested_path).replace("\\", "/")
177+
req_base = req.rsplit("/", 1)[-1].lower()
178+
req_stem = req_base.rsplit(".", 1)[0] if "." in req_base else req_base
179+
exact: list = []
180+
partial: list = []
181+
seen: set = set()
182+
for f in source_files:
183+
norm = str(f).replace("\\", "/")
184+
if norm == req or f in seen:
185+
continue
186+
base = norm.rsplit("/", 1)[-1].lower()
187+
stem = base.rsplit(".", 1)[0] if "." in base else base
188+
if base == req_base:
189+
exact.append(f)
190+
seen.add(f)
191+
elif req_stem and len(req_stem) >= 3 and (req_stem in stem or stem in req_stem):
192+
partial.append(f)
193+
seen.add(f)
194+
return (exact + partial)[:cap]
195+
196+
197+
def _symbol_name_of(symbol_id: Optional[str]) -> str:
198+
"""Bare name from a symbol id like ``path::Name#kind`` (or a plain name)."""
199+
if not symbol_id:
200+
return ""
201+
s = str(symbol_id)
202+
if "::" in s:
203+
s = s.rsplit("::", 1)[-1]
204+
if "#" in s:
205+
s = s.split("#", 1)[0]
206+
return s.lower()
207+
208+
209+
def suggest_symbol_ids(
210+
requested_id: Optional[str],
211+
symbols: Optional[Sequence[dict]],
212+
cap: int = 5,
213+
) -> list:
214+
"""Indexed symbol ids whose name matches a missing ``requested_id``.
215+
216+
Same-name symbols (right name, wrong file/kind) rank ahead of substring
217+
matches. Operates on the index's raw symbol dicts.
218+
"""
219+
name = _symbol_name_of(requested_id)
220+
if not name or not symbols:
221+
return []
222+
exact: list = []
223+
partial: list = []
224+
seen: set = set()
225+
for s in symbols:
226+
sid = s.get("id")
227+
if not sid or sid == requested_id or sid in seen:
228+
continue
229+
sname = str(s.get("name", "")).lower()
230+
if not sname:
231+
continue
232+
if sname == name:
233+
exact.append(sid)
234+
seen.add(sid)
235+
elif len(name) >= 3 and (name in sname or sname in name):
236+
partial.append(sid)
237+
seen.add(sid)
238+
if len(exact) >= cap:
239+
break
240+
return (exact + partial)[:cap]
241+
242+
243+
def build_file_verdict(
244+
*,
245+
present: bool,
246+
requested_path: Optional[str] = None,
247+
source_files: Optional[Sequence[str]] = None,
248+
index_stale: bool = False,
249+
empty_symbols: bool = False,
250+
) -> dict:
251+
"""`_meta.verdict` for the file-read tools.
252+
253+
* ``present=False`` — the path is not in the index: ``absent`` plus a
254+
``did_you_mean`` list of near-miss paths.
255+
* ``present=True, empty_symbols=True`` — the file is indexed but yields no
256+
symbols (data/config file, or constructs the parser does not surface):
257+
``absent`` with no suggestions, so the agent does not retry the outline.
258+
* otherwise — ``ok``.
259+
"""
260+
if not present:
261+
state = STATE_ABSENT
262+
note = "Path is not in the index. " + _NOTES[STATE_ABSENT]
263+
suggestions = suggest_paths(requested_path, source_files)
264+
elif empty_symbols:
265+
state = STATE_ABSENT
266+
note = (
267+
"File is indexed but exposes no extractable symbols (a data/config "
268+
"file, or constructs the parser does not surface). Re-requesting the "
269+
"outline will not change this."
270+
)
271+
suggestions = []
272+
else:
273+
state = STATE_OK
274+
note = _NOTES[STATE_OK]
275+
suggestions = []
276+
verdict = {
277+
"state": state,
278+
"channels": {"index": "stale" if index_stale else "fresh"},
279+
"note": note,
280+
}
281+
if suggestions:
282+
verdict["did_you_mean"] = suggestions
283+
return verdict
284+
285+
286+
def symbol_verdict_for_index(
287+
index,
288+
*,
289+
found_count: int,
290+
requested_id: Optional[str] = None,
291+
) -> dict:
292+
"""Index-aware wrapper over :func:`build_symbol_verdict`."""
293+
return build_symbol_verdict(
294+
found_count=found_count,
295+
requested_id=requested_id,
296+
symbols=getattr(index, "symbols", None) if found_count == 0 else None,
297+
index_stale=_index_is_stale(index),
298+
)
299+
300+
301+
def _index_source_files(index) -> list:
302+
"""Best-effort list of indexed source paths (keys of ``file_languages``)."""
303+
langs = getattr(index, "file_languages", None)
304+
if isinstance(langs, dict):
305+
return list(langs.keys())
306+
return []
307+
308+
309+
def _index_is_stale(index) -> bool:
310+
"""Whether the index SHA lags the live git HEAD (never raises)."""
311+
try:
312+
from .freshness import FreshnessProbe
313+
314+
probe = FreshnessProbe(
315+
source_root=getattr(index, "source_root", "") or None,
316+
indexed_at=getattr(index, "indexed_at", ""),
317+
index_sha=getattr(index, "git_head", None),
318+
file_mtimes=getattr(index, "file_mtimes", None),
319+
)
320+
return probe.repo_is_stale
321+
except Exception:
322+
return False
323+
324+
325+
def file_verdict_for_index(
326+
index,
327+
*,
328+
present: bool,
329+
requested_path: Optional[str] = None,
330+
empty_symbols: bool = False,
331+
) -> dict:
332+
"""Index-aware wrapper over :func:`build_file_verdict` for the file tools."""
333+
return build_file_verdict(
334+
present=present,
335+
requested_path=requested_path,
336+
source_files=_index_source_files(index) if not present else None,
337+
index_stale=_index_is_stale(index),
338+
empty_symbols=empty_symbols,
339+
)
340+
341+
342+
def build_symbol_verdict(
343+
*,
344+
found_count: int,
345+
requested_id: Optional[str] = None,
346+
symbols: Optional[Sequence[dict]] = None,
347+
index_stale: bool = False,
348+
) -> dict:
349+
"""`_meta.verdict` for ``get_symbol_source``.
350+
351+
``found_count == 0`` yields ``absent`` plus ``did_you_mean`` symbol ids that
352+
share the requested name; any resolved symbol yields ``ok`` (a partial batch
353+
is still a hit).
354+
"""
355+
if found_count == 0:
356+
state = STATE_ABSENT
357+
note = "Symbol id is not in the index. " + _NOTES[STATE_ABSENT]
358+
suggestions = suggest_symbol_ids(requested_id, symbols)
359+
else:
360+
state = STATE_OK
361+
note = _NOTES[STATE_OK]
362+
suggestions = []
363+
verdict = {
364+
"state": state,
365+
"channels": {"index": "stale" if index_stale else "fresh"},
366+
"note": note,
367+
}
368+
if suggestions:
369+
verdict["did_you_mean"] = suggestions
370+
return verdict

src/jcodemunch_mcp/tools/get_file_content.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import time
44
from typing import Optional
55

6+
from ..retrieval.verdict import file_verdict_for_index
67
from ..storage import IndexStore, cost_avoided, estimate_savings, record_savings
78
from ._utils import index_status_to_tool_error, resolve_repo
89

@@ -28,7 +29,15 @@ def get_file_content(
2829
if not index:
2930
return index_status_to_tool_error(store.inspect_index(owner, name))
3031
if not index.has_source_file(file_path):
31-
return {"error": f"File not found: {file_path}"}
32+
return {
33+
"error": f"File not found: {file_path}",
34+
"file": file_path,
35+
"_meta": {
36+
"verdict": file_verdict_for_index(
37+
index, present=False, requested_path=file_path
38+
)
39+
},
40+
}
3241

3342
content = store.get_file_content(owner, name, file_path, _index=index)
3443
if content is None:
@@ -89,5 +98,6 @@ def get_file_content(
8998
"tokens_saved": tokens_saved,
9099
"total_tokens_saved": total_saved,
91100
**cost_avoided(tokens_saved, total_saved),
101+
"verdict": file_verdict_for_index(index, present=True),
92102
},
93103
}

src/jcodemunch_mcp/tools/get_file_outline.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from ..storage import IndexStore, record_savings, estimate_savings, cost_avoided
99
from ..parser import Symbol, build_symbol_tree
10+
from ..retrieval.verdict import file_verdict_for_index
1011
from ._utils import load_repo_index_or_error
1112

1213

@@ -26,6 +27,11 @@ def _get_file_outline_single(
2627
"language": "",
2728
"file_summary": "",
2829
"symbols": [],
30+
"_meta": {
31+
"verdict": file_verdict_for_index(
32+
index, present=False, requested_path=file_path
33+
)
34+
},
2935
}
3036

3137
# Filter symbols to this file
@@ -57,6 +63,9 @@ def _get_file_outline_single(
5763
"tokens_saved": tokens_saved,
5864
"total_tokens_saved": total_saved,
5965
**cost_avoided(tokens_saved, total_saved),
66+
"verdict": file_verdict_for_index(
67+
index, present=True, empty_symbols=True
68+
),
6069
"tip": "Tip: use file_paths=[...] to query multiple files in one call.",
6170
},
6271
}
@@ -84,6 +93,7 @@ def _get_file_outline_single(
8493
"tokens_saved": tokens_saved,
8594
"total_tokens_saved": total_saved,
8695
**cost_avoided(tokens_saved, total_saved),
96+
"verdict": file_verdict_for_index(index, present=True),
8797
"tip": "Tip: use file_paths=[...] to query multiple files in one call.",
8898
},
8999
}

src/jcodemunch_mcp/tools/get_symbol.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pathlib import Path
88
from typing import Optional
99

10+
from ..retrieval.verdict import suggest_symbol_ids, symbol_verdict_for_index
1011
from ..storage import IndexStore, record_savings, estimate_savings, cost_avoided as _cost_avoided
1112
from ._utils import index_status_to_tool_error, resolve_repo, resolve_fqn
1213

@@ -282,7 +283,11 @@ def get_symbol_source(
282283
symbol = index.get_symbol(sid)
283284

284285
if not symbol:
285-
errors_out.append({"id": sid, "error": f"Symbol not found: {sid}"})
286+
err = {"id": sid, "error": f"Symbol not found: {sid}"}
287+
_sug = suggest_symbol_ids(sid, index.symbols)
288+
if _sug:
289+
err["did_you_mean"] = _sug
290+
errors_out.append(err)
286291
continue
287292

288293
source = store.get_symbol_content(owner, name, sid, _index=index)
@@ -420,15 +425,26 @@ def get_symbol_source(
420425
if batch_mode:
421426
meta["symbol_count"] = len(symbols_out)
422427
meta["freshness"] = _probe.summary(symbols_out)
428+
meta["verdict"] = symbol_verdict_for_index(
429+
index,
430+
found_count=len(symbols_out),
431+
requested_id=(ids[0] if len(ids) == 1 else None),
432+
)
423433
if _runtime_summary:
424434
meta["runtime_freshness"] = _runtime_summary
425435
return {"symbols": symbols_out, "errors": errors_out, "_meta": meta}
426436

427437
# Single mode: flat object or error
428438
if errors_out:
429-
return {"error": errors_out[0]["error"]}
439+
verdict = symbol_verdict_for_index(
440+
index, found_count=0, requested_id=errors_out[0]["id"]
441+
)
442+
return {"error": errors_out[0]["error"], "_meta": {"verdict": verdict}}
430443
result = symbols_out[0]
431444
meta["hint"] = "Use get_context_bundle(symbol_id) to retrieve source + imports in one call"
445+
meta["verdict"] = symbol_verdict_for_index(
446+
index, found_count=len(symbols_out), requested_id=symbol_id
447+
)
432448
meta["freshness"] = _probe.summary(symbols_out)
433449
if _runtime_summary:
434450
meta["runtime_freshness"] = _runtime_summary

0 commit comments

Comments
 (0)