Skip to content

Commit 89f9042

Browse files
jgravelleclaude
andcommitted
release: v1.108.181 — uncommitted work is represented, per scope
#377 hardening item 5 (@mightydanp), the last open item on the 1.x list. Git HEAD sits still while the tree holds a modified file, an untracked implementation, a deletion or a rename, so every freshness gate could report fresh over a corpus that had not read the file the target lives in. A scan that returns rows carries per-file freshness on them; a scan that returns nothing has nowhere to put it. subject_state.working_tree_state gives the scope its own state: clean / dirty_in_scope / dirty_outside_scope / unknown / not_applicable. Only in-scope dirt the index has not re-read can refuse the absence claim — work elsewhere cannot invalidate a narrow proof, and an edit a watcher already indexed is not a gap. Measured for a zero-result scan only, sharing the TTL-cached reading with the cached-negative revalidation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1ba02a0 commit 89f9042

11 files changed

Lines changed: 629 additions & 16 deletions

File tree

CHANGELOG.md

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

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

5+
## [1.108.181] - 2026-07-26 - uncommitted work is represented, per scope
6+
7+
### Fixed
8+
9+
- **A zero-result scan could not see the edit that would have answered it.**
10+
Item 5 of the [#377](https://github.com/jgravelle/jcodemunch-mcp/issues/377)
11+
review by @mightydanp. Git HEAD sits still while the tree holds a modified
12+
file, a brand-new untracked implementation, a deletion, or a rename into or
13+
out of scope, so every freshness gate we had could report `fresh` over a
14+
corpus that had not read the file the target lives in. A scan that returns
15+
rows carries per-file freshness on them; a scan that returns nothing has
16+
nowhere to put it.
17+
- New `subject_state.working_tree_state` gives the SCOPE a state of its own:
18+
`clean` / `dirty_in_scope` / `dirty_outside_scope` / `unknown` /
19+
`not_applicable`, disclosed as `verdict.working_tree` and wired into
20+
`search_symbols`, `search_text` and both of `get_ranked_context`'s exits.
21+
22+
### Notes
23+
24+
- **Only `dirty_in_scope` can refuse, and only when the index has not caught
25+
up.** Two restrictions do the work, and both are load-bearing:
26+
- work OUTSIDE the scanned scope never blocks, which is the reviewer's own
27+
distinction: a dirty file elsewhere does not invalidate a narrow proof;
28+
- an edit the index has ALREADY re-read is not a gap. `files_not_in_index`
29+
compares each dirty in-scope path against `index.file_mtimes`, so a
30+
watcher-fresh corpus keeps proving absence. Without that, the gate would
31+
fire on every developer with unsaved work in an up-to-date repo, and a
32+
signal that fires constantly is one people learn to ignore.
33+
- The refusal counts the files and names up to five of them, so the reader can
34+
see what the scan could not read rather than being told to distrust it.
35+
- **Measured for a zero-result scan only.** Probing the tree costs a `git
36+
status`, and pricing a subprocess into answers that do not need it would be a
37+
tax on the common path. Same discipline as items 3 and 6; the reading is
38+
TTL-cached and shared with the cached-negative revalidation.
39+
- Rename handling matters here: `R old -> new` records the NEW path, which is
40+
the one that exists in the tree and the one a search would have had to see.
41+
Quoted and backslashed paths are normalised so they compare against index
42+
paths.
43+
- `working_tree` published in `schemas/retrieval-verdict.schema.json`. New
44+
`tests/test_v1_108_181.py` (27). No tool-count or INDEX_VERSION change.
45+
546
## [1.108.180] - 2026-07-26 - unknown freshness stops collapsing into fresh
647

748
### 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.180 — **Unknown freshness stops collapsing into fresh (#377 hardening item 4).** ⚠ **`FreshnessProbe.repo_is_stale` is a BOOLEAN and a boolean has nowhere to put "I could not find out": it returned False BOTH when the SHAs matched AND when either was missing, and the verdict rendered False as `channels.index: "fresh"` — so an index whose freshness was NEVER ESTABLISHED claimed current-snapshot equivalence, and every absence gate downstream trusted a comparison that was never made.** New `FreshnessProbe.repo_freshness` → `fresh`/`stale`/`unknown`/`not_tracked`; `build_verdict(freshness=)` renders it into `channels.index`. ⚠ **`unknown` and `not_tracked` get OPPOSITE treatment on purpose: `unknown` = a capability we HAVE, failing (git absent, source root moved, index stored no SHA) ⇒ zero-result scan is `degraded`, absence REFUSED. `not_tracked` = a capability the SUBJECT does not support (a plain indexed folder has no revision, ever) ⇒ DISCLOSED, absence still citable — the jdata v1.26.0 call. Refusing there would strip absence evidence from EVERY folder index over a permanent, already-stated limitation unrelated to whether the target exists.** ⚠ **A SUBDIRECTORY of a checkout is tracked by the checkout ABOVE it — trackability walks up the way git does (`_is_git_backed`), else every monorepo subdir index silently understates to `not_tracked`.** Precedence `rebuilding` > `partial` > `stale` > `unknown`/`not_tracked` > `fresh`. ⚠ **The `channels.index` enum is CLOSED in the published schema — both new values had to be added or every affected response fails our own contract (the 1.108.168 trap, third occurrence).** Zero blast radius without `freshness=` (pinned); an unrecognized value is ignored, not propagated. New `tests/test_v1_108_180.py` (26) incl. a REAL one-commit checkout, because `fresh` is the one state a fixture cannot fake. No tool-count/INDEX_VERSION change.
4+
- **Version:** 1.108.181 — **Uncommitted work is represented, PER SCOPE (#377 hardening item 5 — closes the last open 1.x item).** ⚠ **Git HEAD sits still while the tree holds a modified file, a brand-new UNTRACKED implementation, a deletion or a rename into/out of scope — so every freshness gate we had could report `fresh` over a corpus that never read the file the target lives in. A scan that RETURNS rows carries per-file freshness ON them; a scan that returns NOTHING has nowhere to put it.** New `subject_state.working_tree_state` gives the SCOPE its own state (`clean`/`dirty_in_scope`/`dirty_outside_scope`/`unknown`/`not_applicable`) as `verdict.working_tree`, wired into `search_symbols` / `search_text` / BOTH `get_ranked_context` exits. ⚠ **TWO restrictions are what keep it from becoming noise, and both are load-bearing: (1) work OUTSIDE the scanned scope NEVER blocks (his own distinction — a dirty file elsewhere cannot invalidate a narrow proof); (2) an edit the index has ALREADY re-read is NOT a gap — `files_not_in_index` compares each dirty in-scope path against `index.file_mtimes`, so a watcher-fresh corpus keeps proving absence. Without (2) the gate fires on EVERY developer with unsaved work in an up-to-date repo, and a signal that fires constantly is one people learn to ignore.** ⚠ **Measured for a ZERO-RESULT scan only** (a `git status` per search would tax the common path); reading is TTL-cached and SHARED with the 1.108.178 cached-negative revalidation. ⚠ **Rename handling: `R old -> new` records the NEW path — the one that exists in the tree and the one a search would have had to see;** quoted/backslashed paths normalised to compare against index paths. `working_tree` published in the verdict schema. New `tests/test_v1_108_181.py` (27). No tool-count/INDEX_VERSION change.
5+
- **Prior (1.108.180):** — **Unknown freshness stops collapsing into fresh (#377 hardening item 4).** ⚠ **`FreshnessProbe.repo_is_stale` is a BOOLEAN and a boolean has nowhere to put "I could not find out": it returned False BOTH when the SHAs matched AND when either was missing, and the verdict rendered False as `channels.index: "fresh"` — so an index whose freshness was NEVER ESTABLISHED claimed current-snapshot equivalence, and every absence gate downstream trusted a comparison that was never made.** New `FreshnessProbe.repo_freshness` → `fresh`/`stale`/`unknown`/`not_tracked`; `build_verdict(freshness=)` renders it into `channels.index`. ⚠ **`unknown` and `not_tracked` get OPPOSITE treatment on purpose: `unknown` = a capability we HAVE, failing (git absent, source root moved, index stored no SHA) ⇒ zero-result scan is `degraded`, absence REFUSED. `not_tracked` = a capability the SUBJECT does not support (a plain indexed folder has no revision, ever) ⇒ DISCLOSED, absence still citable — the jdata v1.26.0 call. Refusing there would strip absence evidence from EVERY folder index over a permanent, already-stated limitation unrelated to whether the target exists.** ⚠ **A SUBDIRECTORY of a checkout is tracked by the checkout ABOVE it — trackability walks up the way git does (`_is_git_backed`), else every monorepo subdir index silently understates to `not_tracked`.** Precedence `rebuilding` > `partial` > `stale` > `unknown`/`not_tracked` > `fresh`. ⚠ **The `channels.index` enum is CLOSED in the published schema — both new values had to be added or every affected response fails our own contract (the 1.108.168 trap, third occurrence).** Zero blast radius without `freshness=` (pinned); an unrecognized value is ignored, not propagated. New `tests/test_v1_108_180.py` (26) incl. a REAL one-commit checkout, because `fresh` is the one state a fixture cannot fake. No tool-count/INDEX_VERSION change.
56
- **Prior (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.
6-
- **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.
7-
- **Older releases (1.108.177 and earlier):** see `CHANGELOG.md` — the authoritative version history (415 entries, 1.0.0 onward).
7+
- **Older releases (1.108.178 and earlier):** see `CHANGELOG.md` — the authoritative version history (416 entries, 1.0.0 onward).
88
- **INDEX_VERSION:** 17
9-
- **Tests:** 5897 passed, 7 skipped (1.108.180) + 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:** 5924 passed, 7 skipped (1.108.181) + 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. 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
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. v1.108.181 adds working_tree_state() (item 5: scope-level clean/dirty_in_scope/dirty_outside_scope/unknown/not_applicable; blocks ONLY on in-scope dirt the index has not re-read) + _parse_porcelain/_in_scope/_unreflected_in_index
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.180"
3+
version = "1.108.181"
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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,20 @@
104104
},
105105
"additionalProperties": false
106106
},
107+
"working_tree": {
108+
"type": "object",
109+
"description": "Scope-level working-tree state behind an absence claim. Measured for a zero-result scan only (probing the tree on every search would price a subprocess into answers that do not need it). Only dirty_in_scope can refuse the claim, and only when files_not_in_index is nonzero: work outside the scanned scope does not invalidate a narrow proof, and an edit the index has already re-read is not a gap.",
110+
"properties": {
111+
"state": {
112+
"enum": ["clean", "dirty_in_scope", "dirty_outside_scope", "unknown", "not_applicable"]
113+
},
114+
"files_dirty": {"type": "integer", "minimum": 0},
115+
"files_dirty_in_scope": {"type": "integer", "minimum": 0},
116+
"files_not_in_index": {"type": "integer", "minimum": 0},
117+
"sample": {"type": "array", "items": {"type": "string"}, "description": "Up to five repo-relative paths the scan could not have read at their current content."}
118+
},
119+
"additionalProperties": false
120+
},
107121
"moved_during_scan": {
108122
"type": "object",
109123
"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.",

src/jcodemunch_mcp/handoff.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,17 @@ def absence_refusal(record: Optional[dict]) -> Optional[str]:
145145
"the index did not cover the whole tree at scan time, so the target "
146146
"may sit in a file that never entered the corpus"
147147
)
148+
_tree = record.get("working_tree") or {}
149+
if _tree.get("files_not_in_index"):
150+
# Named before the generic state rule (#377 item 5). Work OUTSIDE the
151+
# scanned scope never reaches here, and neither does an edit the index
152+
# has already re-read: refusing on either would fire on every developer
153+
# with unsaved work and teach people to ignore the signal.
154+
return (
155+
f"{_tree['files_not_in_index']} uncommitted change(s) inside the scanned "
156+
"scope had not reached the index, so the target may sit in an edit the "
157+
"scan could not read"
158+
)
148159
_moved = record.get("moved_during_scan") or {}
149160
if _moved.get("reason"):
150161
# Named before the generic state rule: the scan itself was fine, and
@@ -236,6 +247,7 @@ def note_absence(tool: str, repo, query, verdict, arguments=None, truncated=Fals
236247
"incomplete": verdict.get("incomplete"),
237248
"revalidated": verdict.get("revalidated"),
238249
"moved_during_scan": verdict.get("moved_during_scan"),
250+
"working_tree": verdict.get("working_tree"),
239251
"truncated": bool(truncated),
240252
"scorer": verdict.get("scorer"),
241253
}

0 commit comments

Comments
 (0)