Skip to content

Commit 5ab2834

Browse files
jgravelleclaude
andcommitted
release: v1.108.179 — the subject has to hold still for the scan
#377 hardening item 6 (@mightydanp). search_symbols, search_text and get_ranked_context capture the subject's identity before retrieval and compare it after. A concurrent edit, watcher reindex, incremental save, published generation or a long scan crossing a rebuild means the scan describes neither the state it started against nor the one it finished in, so a zero-result scan in that position is degraded with moved_during_scan naming what moved. The after-reading bypasses the TTL-cached HEAD lookup: inside that window a before/after comparison would compare one reading with itself. Scoping the check to a zero-result scan is what makes that affordable. Also: get_ranked_context's no-candidate path returned 'Do not claim this feature exists' in prose with no verdict at all, so no absence gate could reach it. It now builds the same verdict as every other retrieval answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1e2face commit 5ab2834

11 files changed

Lines changed: 454 additions & 18 deletions

File tree

CHANGELOG.md

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

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

5+
## [1.108.179] - 2026-07-26 - the subject has to hold still for the scan
6+
7+
### Fixed
8+
9+
- **A scan could start against one state and finish against another, and still
10+
prove absence.** Item 6 of the
11+
[#377](https://github.com/jgravelle/jcodemunch-mcp/issues/377) review by
12+
@mightydanp. `search_symbols`, `search_text` and `get_ranked_context` now
13+
capture the subject's identity before retrieval and compare it after: a
14+
concurrent edit, a watcher reindex, an incremental save, a published
15+
generation or a long scan crossing a rebuild all mean the scan describes
16+
neither the state it started against nor the one it finished in. A zero-result
17+
scan in that position is `degraded`, with `verdict.moved_during_scan` naming
18+
what moved.
19+
- **`get_ranked_context`'s no-candidate path carried no verdict at all.** It
20+
returned "No implementation found ... Do not claim this feature exists" in
21+
prose, with no freshness, no coverage, no scan counts and none of the absence
22+
gates, because it returned before `build_verdict` was ever called. It now
23+
builds the same verdict every other retrieval answer gets, so a genuine
24+
no-candidate result is citable and a scan over a stale, partial, rebuilding or
25+
moving subject is refused.
26+
27+
### Notes
28+
29+
- **The check is scoped to a zero-result scan, and that is what makes it
30+
affordable.** A scan that returned rows read them out of a generation that
31+
really held them, and the freshness channels already disclose that the tree
32+
moved underneath. The after-reading therefore bypasses the TTL-cached HEAD
33+
lookup and pays for a real `git rev-parse`: without that, a before/after
34+
comparison inside the cache window compares one reading with itself.
35+
- Disclosed on every state, refused only for the absence claim, expressed as a
36+
downgrade so `handoff.absence_refusal` does the refusing. Its message names
37+
the movement rather than falling through to "the verdict was degraded".
38+
- `subject_state.changed` now takes the *when* of the comparison, so the cached
39+
replay (v1.108.178) and the live scan produce sentences a reader can tell
40+
apart: "after this scan was cached" versus "while the scan was running".
41+
- Working-tree movement DURING a scan is still not checked: the before-reading
42+
would have to pay for `git status` on every search to make that comparison
43+
possible. Whole-scope working-tree state is item 5.
44+
- `moved_during_scan` published in `schemas/retrieval-verdict.schema.json`. New
45+
`tests/test_v1_108_179.py` (19), all three tools covered by one parametrized
46+
end-to-end case. No tool-count or INDEX_VERSION change.
47+
548
## [1.108.178] - 2026-07-26 - a cached negative must prove it still describes the subject
649

750
### Fixed

CLAUDE.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# jcodemunch-mcp — Project Brief
22

33
## Current State
4-
- **Version:** 1.108.178 — **A cached negative must prove it still describes the subject (#377 hardening item 3).** ⚠ **The `search_symbols` result cache keys on `indexed_at`, so it invalidates on a REINDEX and NOTHING ELSE: a query absent against snapshot A cached its `fresh`/`absent` verdict, the source changed WITHOUT a rebuild, the same query hit the cache, and the old verdict was replayed — minting an absence proof against a state nobody scanned.** New `retrieval/subject_state.py` captures index generation + `.db` mtime + live git HEAD (+ the working-tree fingerprint for an absence) at cache-WRITE time and re-checks at cache-READ time; on a move it discloses `verdict.revalidated`, downgrades `absent` → `degraded`, and STRIPS any token minted on the earlier pass (it was issued against the state that just failed to hold). ⚠ **Only a NEGATIVE pays for `git status` — the one answer a working-tree edit can falsify while the index and its generation sit still. A cached positive costs a few stats and keeps serving with disclosure: its rows were really in the index at that generation.** ⚠ **UNKNOWN is never reported as CHANGED** (a non-git checkout knows none of this and did not change either; treating unknown as moved would refuse every absence there — same shape as tri-state `coverage.complete`). ⚠ **Second bug fixed in passing: `_result_cache_get` copied the result and `_meta` but NOT the nested `verdict`, so the `evidence_ref` the DISPATCHER attaches after the tool returns landed in the STORED entry and was replayed to every later hit.** ⚠ **Verified on a REAL checkout, not just fixtures: unchanged tree replays `absent` identically; an untracked file appearing flips the next hit to `degraded` + `revalidated.stale_cache`.** Working-tree state is scoped to the CACHED-negative path here — whole-scope semantics for a LIVE absence is item 5, NOT in this release. `revalidated` published in the verdict schema. New `tests/test_v1_108_178.py` (20). No tool-count/INDEX_VERSION change.
4+
- **Version:** 1.108.179 — **The subject has to hold still for the scan (#377 hardening item 6).** `search_symbols` / `search_text` / `get_ranked_context` now capture subject identity BEFORE retrieval and compare AFTER: a concurrent edit, watcher reindex, incremental save, published generation or a long scan crossing a rebuild all mean the scan describes NEITHER the state it started against NOR the one it finished in. Zero-result + movement ⇒ `degraded` + `verdict.moved_during_scan` naming what moved. ⚠ **Scoped to a ZERO-RESULT scan, and that scoping is what makes it affordable: the AFTER reading bypasses the TTL-cached HEAD lookup and pays for a real `git rev-parse`, because a before/after comparison INSIDE the 2s cache window compares one reading with itself.** A scan that returned rows read them from a generation that really held them, and the freshness channels already disclose the tree moved. ⚠ **Second find, worse than the one being fixed: `get_ranked_context`'s no-candidate EARLY RETURN carried NO verdict at all** — it asserted "No implementation found ... Do not claim this feature exists" in PROSE with no freshness, no coverage, no scan counts and none of the absence gates, because it returned before `build_verdict` was ever called. Now builds the same verdict every other retrieval answer gets (so a genuine no-candidate result is CITABLE, and a stale/partial/rebuilding/moving one is REFUSED). `subject_state.changed(when=)` now distinguishes the two callers' sentences: "after this scan was cached" (1.108.178 cache replay) vs "while the scan was running" (this). ⚠ **Working-tree movement DURING a scan is still NOT checked** — the BEFORE reading would have to pay `git status` on every search; whole-scope tree state is item 5. `moved_during_scan` published in the verdict schema. New `tests/test_v1_108_179.py` (19), all three tools in one parametrized e2e. No tool-count/INDEX_VERSION change.
5+
- **Prior (1.108.178):** — **A cached negative must prove it still describes the subject (#377 hardening item 3).** ⚠ **The `search_symbols` result cache keys on `indexed_at`, so it invalidates on a REINDEX and NOTHING ELSE: a query absent against snapshot A cached its `fresh`/`absent` verdict, the source changed WITHOUT a rebuild, the same query hit the cache, and the old verdict was replayed — minting an absence proof against a state nobody scanned.** New `retrieval/subject_state.py` captures index generation + `.db` mtime + live git HEAD (+ the working-tree fingerprint for an absence) at cache-WRITE time and re-checks at cache-READ time; on a move it discloses `verdict.revalidated`, downgrades `absent` → `degraded`, and STRIPS any token minted on the earlier pass (it was issued against the state that just failed to hold). ⚠ **Only a NEGATIVE pays for `git status` — the one answer a working-tree edit can falsify while the index and its generation sit still. A cached positive costs a few stats and keeps serving with disclosure: its rows were really in the index at that generation.** ⚠ **UNKNOWN is never reported as CHANGED** (a non-git checkout knows none of this and did not change either; treating unknown as moved would refuse every absence there — same shape as tri-state `coverage.complete`). ⚠ **Second bug fixed in passing: `_result_cache_get` copied the result and `_meta` but NOT the nested `verdict`, so the `evidence_ref` the DISPATCHER attaches after the tool returns landed in the STORED entry and was replayed to every later hit.** ⚠ **Verified on a REAL checkout, not just fixtures: unchanged tree replays `absent` identically; an untracked file appearing flips the next hit to `degraded` + `revalidated.stale_cache`.** Working-tree state is scoped to the CACHED-negative path here — whole-scope semantics for a LIVE absence is item 5, NOT in this release. `revalidated` published in the verdict schema. New `tests/test_v1_108_178.py` (20). No tool-count/INDEX_VERSION change.
56
- **Prior (1.108.177):** — **An omitted match is not an absent match (#377 post-ship review by @mightydanp, hardening items 1/7/8/9/10/11/12).** ⚠ **The false-attestation path was real and LIVE: `search_symbols` SAVED `heap_count` before packing and then passed the POST-packing count into `build_verdict`, so matches that did not fit the token budget left `result_count == 0` → `absent` → citable `evidence_ref` over a corpus that CONTAINED the target.** Reproduced on the live path (token_budget=1 → 1 match found, 0 returned). Fix: `build_verdict(matches_before_packing=)` → `degraded` + `verdict.omitted` disclosed on EVERY state; `search_symbols` passes the heap, `get_ranked_context` passes the candidate set (the zero-candidate case returns earlier, so anything reaching the verdict really matched). ⚠ **`search_text` had THREE holes at once: two bare `continue`s (unsafe path, `OSError`) meant 3 unreadable of 10 read as a complete sweep of 7; a `file_pattern` matching NOTHING returned `absent`; and it NEVER passed `index_stale`, so the stale gate its siblings enforce could not fire there.** Now `verdict.incomplete.reason` ∈ {`unreadable_inputs`, `empty_scope`} + a real `FreshnessProbe`. ⚠ **Item 10 is the ORDERING one: `meta_fields` filtering ran BEFORE the absence block, so the shipped default `meta_fields: []` deleted the verdict and the evidence was never recorded, and the narrower `meta_fields: ["verdict"]` deleted `index_truncated` and handed `note_absence` a TRUNCATED scan as untruncated — minting exactly the ref the truncation gate exists to refuse. A DISPLAY preference must never decide whether a scan counts as complete.** The argument-contract and absence blocks now run on the COMPLETE internal result, and what filtering removes is re-attached as `_meta.absence_evidence` (jdoc/jdata's carrier shape); `absence_evidence` joins `UNIVERSAL_META_JSON` so compaction cannot drop it (item 11). ⚠ **Found while wiring that, NOT reported: `suppress_meta` is a DISPATCHER argument no tool schema declares, so v1.108.175 counted it as a caller mistake, downgraded the verdict, and cost well-formed calls their absence evidence** — now in `PROTOCOL_KEYS` with `_current_model`. Item 12: `is_regex`/`decorator`/`semantic*`/`fuzzy*`/`fusion`/`sort_by`/`strategy` join `_SCOPE_ARGS` (a literal and a regex search of the same string are DIFFERENT operations and must not collide on one id). Legacy `negative_evidence` suppressed on packed-empty/incomplete — `search_symbols` renders it as "Do not claim this feature exists", which is FALSE when the packer dropped the matches. `omitted` + `incomplete` published in `schemas/retrieval-verdict.schema.json`. New `tests/test_v1_108_177.py` (26); non-vacuity proven on the live path (the same scan reported the old way is `absent` and mints a ref). No tool-count/INDEX_VERSION change.
6-
- **Prior (1.108.176):** — **A partial index stops reporting itself fresh (#375 sub-problem C — the one the customer gated on).** Reported symptom: **"7,659 of 9,634 code files learned, but it still reports itself up to date, so 'I never learned that file' and 'that file doesn't exist' look identical to every agent"** — the ambiguity that moved a paying user's default code lookup to another tool. ⚠ **The check was NOT broken; it answered a DIFFERENT QUESTION. Freshness compares index SHA vs git HEAD = "is the index BEHIND the tree in TIME", and was being read as "does the index COVER the tree". A corpus that dropped files sits at the SAME SHA, so `fresh` was literally true and completely misleading.** Root cause underneath: `index_folder`'s SECOND PASS (`file_mtimes` build, ~line 1980) dropped files with **THREE UNCOUNTED `continue`s** (validate_path fail, relative_to fail, no-language) — the corpus could end up smaller than the walk reported with **nothing anywhere recording the difference**. Fix: count drops by reason (`outside_root`/`no_language`/`stat_failed`), persist `files_accepted` beside `files_indexed` + `unaccounted` for the unexplained remainder, and add **`channels.index: "partial"`**. ⚠ **`complete` is TRI-STATE — true/false/None-for-unknown; a pre-accounting index reports None and None MUST NEVER read as true, so every existing index is byte-identical until re-indexed.** ⚠ **DISCLOSE every state, REFUSE only the absence CLAIM** (a file that never entered the corpus can't be proven absent from it); refusal-as-downgrade means `absence_refusal` does the work, no second rule. Precedence `rebuilding` > `partial` > `stale` > `fresh`. ⚠ **A partial walk over a WIDER index (v1.96 subdir-merge / branch-delta) reports UNKNOWN, not incomplete** — else those modes fire a permanent false alarm. ⚠ **The published `retrieval-verdict` schema closes BOTH `channels` and the `coverage` block, so both needed extending or every mid-gap response fails OUR OWN contract (v1.108.168 trap, fired AGAIN here) — caught only by validating the PROJECTED `index_coverage_meta` shape, not the raw persisted report.** Measured on this repo before shipping to confirm the signal isn't noise: **672 accepted / 672 indexed / `complete: true`, zero false positives.** New `tests/test_v1_108_176.py` (20); non-vacuity proven (gate off → 2 provably-missing files still yield `absent` + `fresh` + `absent:cda93cdd5447`). No tool-count/INDEX_VERSION change.
7-
- **Older releases (1.108.175 and earlier):** see `CHANGELOG.md` — the authoritative version history (413 entries, 1.0.0 onward).
7+
- **Older releases (1.108.176 and earlier):** see `CHANGELOG.md` — the authoritative version history (414 entries, 1.0.0 onward).
88
- **INDEX_VERSION:** 17
9-
- **Tests:** 5852 passed, 7 skipped (1.108.178) + the KNOWN 12 local-ONNX `test_semantic_search` env failures (green in CI on all 4 ubuntu jobs — never read them as a regression)
9+
- **Tests:** 5871 passed, 7 skipped (1.108.179) + the KNOWN 12 local-ONNX `test_semantic_search` env failures (green in CI on all 4 ubuntu jobs — never read them as a regression)
1010
- **Python:** >=3.10
1111
- **Tool count:** 90 in `full` (front door hidden; +1 v1.108.111 `get_parity_map`, +1 v1.108.112 `get_decorator_census`, +1 v1.108.113 `get_architecture_metrics`); `tool_surface=counter` exposes a 3-tool front door (`order`/`menu`/`route`) instead
1212

@@ -50,7 +50,7 @@ src/jcodemunch_mcp/
5050
enrichment/
5151
lsp_bridge.py # LSP bridge — opt-in compiler-grade call graph resolution via pyright/gopls/ts-language-server/rust-analyzer; LSPServer lifecycle, LSPBridge multi-server manager, enrich_call_graph_with_lsp() + enrich_dispatch_edges() (interface/trait dispatch resolution)
5252
retrieval/
53-
subject_state.py # (v1.108.178) #377 item 3: what a scan's answer depends on, cheap enough to re-check. capture() at cache-WRITE (index generation, .db mtime, live git HEAD, + working-tree fingerprint ONLY for an absence) / changed() at cache-READ / revalidate_verdict() downgrades a replayed `absent` and strips the stale evidence token. UNKNOWN is never a change
53+
subject_state.py # (v1.108.178) #377 item 3: what a scan's answer depends on, cheap enough to re-check. capture() at cache-WRITE (index generation, .db mtime, live git HEAD, + working-tree fingerprint ONLY for an absence) / changed() at cache-READ / revalidate_verdict() downgrades a replayed `absent` and strips the stale evidence token. UNKNOWN is never a change. v1.108.179 adds moved_during_scan() (item 6: before/after identity around a scan, fresh_head bypasses the TTL cache) + changed(when=) so the cached-replay and live-scan refusals read differently
5454
signal_fusion.py # Weighted Reciprocal Rank (WRR) fusion: lexical + structural + similarity + identity channels
5555
regret.py # (v1.108.68) analyze_regret: mines the ranking_events ledger for SIX retrieval-regret signals (requery_churn/low_confidence/thin_result/ambiguous_top/stale_at_query/vocabulary_gap) as severity-ranked clusters. Pure read via token_tracker.ranking_db_query; no new tables. Consumed by suggest_corrections + the digest one-liner
5656
summarizer/

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.178"
3+
version = "1.108.179"
44
description = "Token-efficient MCP server for source code exploration via tree-sitter AST parsing"
55
readme = "README.md"
66
requires-python = ">=3.10"

schemas/retrieval-verdict.schema.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@
101101
},
102102
"additionalProperties": false
103103
},
104+
"moved_during_scan": {
105+
"type": "object",
106+
"description": "The subject changed between the start of this scan and its end (concurrent edit, watcher reindex, incremental save, published generation, or a long scan crossing a rebuild). Checked for a zero-result scan only; the state is then 'degraded', because a scan whose subject moved describes neither the state it started against nor the one it finished in.",
107+
"properties": {
108+
"reason": {"type": "string"}
109+
},
110+
"additionalProperties": false
111+
},
104112
"revalidated": {
105113
"type": "object",
106114
"description": "This response was replayed from the result cache and the subject it was measured against has since moved. Disclosed on every state; an 'absent' verdict is downgraded to 'degraded' and any evidence token minted on the earlier pass is stripped, because a cached negative cannot prove absence against a state it never saw.",

src/jcodemunch_mcp/handoff.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,14 @@ def absence_refusal(record: Optional[dict]) -> Optional[str]:
137137
"the index did not cover the whole tree at scan time, so the target "
138138
"may sit in a file that never entered the corpus"
139139
)
140+
_moved = record.get("moved_during_scan") or {}
141+
if _moved.get("reason"):
142+
# Named before the generic state rule: the scan itself was fine, and
143+
# what disqualifies it is that the subject did not hold still for it.
144+
return (
145+
f"the subject moved while the scan ran ({_moved['reason']}), so the scan "
146+
"describes neither the state it started against nor the one it finished in"
147+
)
140148
_reval = record.get("revalidated") or {}
141149
if _reval.get("stale_cache"):
142150
# Named before the generic state rule: this scan really did reach
@@ -219,6 +227,7 @@ def note_absence(tool: str, repo, query, verdict, arguments=None, truncated=Fals
219227
"omitted": verdict.get("omitted"),
220228
"incomplete": verdict.get("incomplete"),
221229
"revalidated": verdict.get("revalidated"),
230+
"moved_during_scan": verdict.get("moved_during_scan"),
222231
"truncated": bool(truncated),
223232
"scorer": verdict.get("scorer"),
224233
}

0 commit comments

Comments
 (0)