Skip to content

Releases: jgravelle/jcodemunch-mcp

v1.108.183 - a receipt says what it proves, and only that

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 21:06

#377 Phase 2, P1 + P2. Exact immutable evidence receipts, and the producer registration that decides who may mint one. Both together, because an envelope without registration is a contract anything can mint into.

The Phase 1 caveat, closed

handoff._validate_evidence attested a reference matching a served symbol id or the file component of one:

served this session:  src/auth/session.py::refresh_token#function
attests:              src/auth/session.py::refresh_token#function   correct
attests:              src/auth/session.py                           over-broad

A claim citing a whole file was attested when one unrelated symbol from it was the only thing retrieved, and a reader of the finalized handoff could not tell those two citations apart.

What a receipt is

jcodemunch.evidence/v1, read at munch://evidence/<id>. One canonical subject (symbol id, file, line range, content_sha256) bound to one snapshot (index generation, indexed and live revisions, four-state freshness, working-tree state, coverage digest, scorer pin, and the conditions the response was produced under) and to the operation that actually ran, with the id derived from exactly those three. Different snapshot means a different id.

Opt in with receipt: true on search_symbols, get_symbol_source, get_ranked_context or search_text. The response carries only the id in _meta.receipts; the body is read from the resource on demand. No new tool and no INDEX_VERSION change. Default false is byte-for-byte today's response, pinned by a test.

limitations states what a receipt cannot prove. A row served without a body carries an identity, not its bytes, and says so.

Producer registration is the load-bearing half

get_ranked_context once asserted "Do not claim this feature exists" from an early return that never called build_verdict, so eight shipped absence gates were invisible to it until 1.108.179 went looking. An early return is a second implementation of the answer and inherits nothing.

Registration makes that structural rather than remembered: minting requires a verdict of the shape the producer registered, so an exit that asserts an answer without building one cannot mint. Not as a listed exception, but because it never produced the thing a receipt is derived from. Minting also runs at the dispatcher chokepoint, so an early return cannot bypass it at all.

The input picks the contract

A handoff citing no receipt keeps the historical broadening and renders byte-identically, so no shipped caller changes. What it gains is that the finalization receipt now names the broadened reference and what backed it (evidence_precision, broadened_refs).

A handoff that does cite a receipt is held to the subject its evidence proves: a file-level reference backed only by a served symbol from that file is refused as over-broad, with its own error and its own fix.

Notes worth reading

  • Phase 3 refs keep working. An absence receipt links to the scan note_absence recorded rather than re-deriving whether it proves anything, so absence_refusal stays the single implementation of the refusal rules and a receipt read hours later cannot disagree with the gate that issued it.
  • build_symbol_verdict reports channels.index: fresh for a plain folder that has no revision at all, because it never got the four-state treatment. A get_symbol_source receipt therefore probes freshness directly; trusting that channel would repeat the 1.108.176 mistake of believing a signal that answers a slightly different question.
  • A hidden-but-honored argument was being reported as a caller mistake. compact_schemas strips rarely-used params from the published schema while the handler still accepts them, and it popped them out of the same dicts the argument contract was built from, so source_start_line and friends were flagged as ignored arguments under that setting, which downgrades the verdict and costs a well-formed call its absence evidence. Fixed by snapshotting the declared surface before the strip.
  • Not shipped, so nobody builds on it: session keying, the rest of the expiry taxonomy and finalization deep-freeze (P3); redacted rendering and the absence_scans_attested rename (P4); suite parity for jdocmunch and jdatamunch (P5).
  • Two ungated absence assertions found and not fixed here: search_symbols's semantic exit asserts absence in prose with no verdict at all, and both fusion exits emit neither. Neither can mint, which is the gate working, and tests pin that against the source.

Design by @mightydanp on #377. Suite 6025 passed plus a known 12 local-ONNX environment failures that are green in CI; Tests 9/9 and Replay green before publishing. core_compact unchanged at 3996.

v1.108.182 - a stall has a name and a ceiling

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 18:55

#375 is reopened. @dkiaulakis' re-run at 1.108.176 measured no improvement on the subtree that stalled at 1.108.169 — 268s versus 240s, both unfinished. The 1.108.171 regex fix removed one known cost centre. It could not remove the shape of the failure: provider discovery and per-file parsing were both unbounded, and both reported nothing while they ran. A stall with no ceiling and no name is the actual defect.

Three bounds.

Provider discovery has a ceiling

discover_providers ran all fourteen providers' detect() + load() inline, with no budget, before a single file is indexed. Any provider that ground took the whole index down with it.

Each provider now runs under a wall-clock budget — JCODEMUNCH_PROVIDER_BUDGET_SECONDS, default 30s, 0 disables. An overrun is skipped and named: providers_skipped on the index result plus a warning that states the consequence, that symbols are indexed but carry no context from that provider and its import edges (route mounts, template renders) are missing from the graph.

A budget cannot make a slow provider fast. It makes the gap survivable and attributable.

Source walks stop paying for dependency trees

ExpressProvider.load ran five recursive glob("**/*.js")-shaped passes and dropped node_modules by substring after the walk had already enumerated it — five full descents through a dependency tree to index none of it. _scan_package_json_forced_paths had the same defect via rglob.

New iter_source_files prunes at the walk. Measured on a 42-file project over a 9,600-file node_modules, warm and alternating: 0.639s to 0.001s for the identical 41 files.

One file cannot stall an index

parse_file_budgeted bounds a single file's parse — JCODEMUNCH_PARSE_BUDGET_SECONDS, default 20s, 0 disables. On overrun the file is skipped and named in the index result's warnings instead of the run hanging with nothing to point at. The watchdog is armed only at or above 128 KiB, so the common path parses inline exactly as before.

The limit, stated

A watchdog stops the caller waiting. It cannot stop the work. Python cannot preempt a thread and tree-sitter is C code, so an abandoned parse or provider keeps consuming CPU until it finishes or notices its cooperative deadline. These bounds make the index finish and the gap visible; they do not cap CPU.

ContextProvider.budget_expired() is the cooperative half, and it only helps where a provider polls it. Wired into the Express walk; the other thirteen providers rely on the thread watchdog alone.

Not yet reproduced on the reporter's machine. Thanks to @dkiaulakis for re-running against a closed issue and reporting a result that disagreed with ours, with the caveats on both numbers volunteered rather than buried.

tests/test_v1_108_182.py (23). Full suite 5947 passed, 7 skipped.

v1.108.181 — uncommitted work is represented, per scope

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 17:49

Item 5 of the #377 post-ship review by @mightydanp, and the last open item on the current-1.x hardening list.

Git HEAD sits perfectly still while the working tree holds a modified file, a brand-new untracked implementation, a deletion, or a rename into or out of scope. Every freshness gate we had could therefore report fresh over a corpus that had never read the file the target lives in. A scan that returns rows carries per-file freshness on those rows; a scan that returns nothing has nowhere to put it, which is exactly the answer that most needs it.

The scope now has a state of its own, disclosed as verdict.working_tree:

clean                nothing uncommitted anywhere
dirty_outside_scope  uncommitted work exists, none of it in this scope
dirty_in_scope       uncommitted work exists inside this scope
unknown              the tree could not be read
not_applicable       the subject has no revision control at all

Only dirty_in_scope can refuse the absence claim, and only when the index has not caught up. Two restrictions carry the design:

  • Work outside the scanned scope never blocks. That is the reviewer's own distinction: a dirty file elsewhere cannot invalidate a narrow proof.
  • An edit the index has already re-read is not a gap. files_not_in_index compares each dirty in-scope path against the index's per-file mtimes, so a watcher-fresh corpus keeps proving absence. Without that, the gate would fire on every developer with unsaved work in an up-to-date repo — and a signal that fires constantly is one people learn to ignore.

The refusal counts the files and names up to five, so a reader can see what the scan could not read instead of being told to distrust it.

Measured for a zero-result scan only. Probing the tree costs a git status, and pricing a subprocess into answers that do not need it would be a tax on the common path; the reading is TTL-cached and shared with the cached-negative revalidation from 1.108.178.

v1.108.180 — unknown freshness stops collapsing into fresh

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 17:06

Item 4 of the #377 post-ship review by @mightydanp: unknown freshness must not collapse into fresh.

FreshnessProbe.repo_is_stale is a Boolean, and a Boolean has nowhere to put "I could not find out". It returned False when the SHAs matched and also when either of them was missing, and the verdict rendered False as channels.index: "fresh". An index whose freshness had never been established was claiming current-snapshot equivalence, and every absence gate downstream trusted a comparison that was never made.

Four states now, and only one of them is proof:

fresh        the indexed revision equals the live one
stale        they differ
unknown      the subject HAS a revision we could not read
not_tracked  the subject has no revision at all

unknown and not_tracked get opposite treatment, on purpose. unknown is a capability we have, failing — git absent, the source root moved, the index stored no SHA — so a zero-result scan is degraded and the absence claim is refused. not_tracked is a capability the subject does not support: a plain indexed folder has no revision and never will, so it is disclosed and the absence stays citable. That is the call jDataMunch made in v1.26.0 for freshness it cannot model. Refusing instead would strip absence evidence from every folder index over a limitation that is permanent, already stated, and unrelated to whether the target exists.

A subdirectory of a checkout is tracked by the checkout above it, so the trackability test walks up the way git does; otherwise every monorepo subdir index would understate to not_tracked.

Precedence is rebuilding > partial > stale > unknown / not_tracked > fresh: a known lag is worse than an unestablished one, and both beat proven currency. Disclosed on every state, refused only for the absence claim, with the refusal naming the failed capability.

Zero blast radius for a producer that passes nothing: build_verdict without freshness= keeps its exact previous behavior, and an unrecognized value is ignored rather than propagated. The channels.index enum is closed in the published schema, so both new values are published there.

v1.108.179 — the subject has to hold still for the scan

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 16:30

Item 6 of the #377 post-ship review by @mightydanp: source and index identity checked around retrieval, not only before it.

search_symbols, search_text and get_ranked_context now capture the subject's identity before the scan and compare it after. A concurrent edit, a watcher reindex, an incremental save, a published generation or a long scan crossing a rebuild all mean the same thing: the scan describes neither the state it started against nor the one it finished in. A zero-result scan in that position is degraded, and verdict.moved_during_scan names what moved.

The check is scoped to a zero-result scan, and that scoping is what makes it affordable. A scan that returned rows read them out of a generation that really held them, and the freshness channels already disclose that the tree moved underneath. Because only a negative pays, the after-reading can bypass the TTL-cached HEAD lookup and pay for a real git rev-parse — without that, a before/after comparison inside the cache window compares one reading with itself.

A second and worse defect turned up in get_ranked_context. Its no-candidate path returned "No implementation found ... Do not claim this feature exists" in prose, with no verdict at all: no freshness, no coverage, no scan counts, and none of the absence gates, because it returned before build_verdict was ever called. The strongest absence statement in the tool was the one answer nothing was checking. It now builds the same verdict every other retrieval answer gets, so a genuine no-candidate result is citable and a scan over a stale, partial, rebuilding or moving subject is refused.

Disclosed on every state, refused only for the absence claim, expressed as a downgrade so there is no second rule to keep in sync. Working-tree movement during a scan is still not checked: the before-reading would have to pay for git status on every search. Whole-scope working-tree state is item 5.

v1.108.178 — a cached negative must prove it still describes the subject

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 15:59

Item 3 of the #377 post-ship review by @mightydanp: a cached negative must prove it still describes the subject it was measured against.

The search_symbols result cache keys on the index's indexed_at, so it invalidates on a reindex and on nothing else. That is fine for a positive result, whose rows really were in the index at that generation. It is not fine for a negative one:

1. the query is absent against snapshot A
2. the result and its fresh verdict are cached
3. the source changes without the index being rebuilt
4. the same query hits the cache
5. the old fresh/absent verdict is replayed, and an absence proof is
   minted over a state nobody scanned

New retrieval/subject_state.py captures what the answer depends on at cache-write time (index generation, .db mtime, live git HEAD, and for an absence the working-tree fingerprint) and re-checks it at cache-read time. When something moved, the verdict discloses revalidated, an absent state is downgraded to degraded, and any evidence token minted on the earlier pass is stripped, because it was issued against the state that just failed to hold. The refusal names what moved rather than falling through to a generic "the verdict was degraded".

Only a negative pays for the expensive signal. git status runs only when the cached verdict is absent, 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. That split is the reviewer's and it is right.

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.

Second defect fixed in passing: _result_cache_get copied the result and its _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 only on fixtures: the same absent query is replayed unchanged when nothing moves, and downgrades the moment an untracked file appears. Whole-scope working-tree semantics for a live absence is item 5 and is not in this release.

v1.108.177 — an omitted match is not an absent match

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 15:07

Hardening items 1, 7, 8, 9, 10, 11 and 12 from the #377 post-ship review by @mightydanp. Every one is the same defect wearing a different coat: something other than the corpus decided the response was empty, and the verdict read that emptiness as proof the target does not exist.

The packer could mint an absence proof over a corpus that contained the target. search_symbols saved the pre-packing match count and then passed the post-packing count into build_verdict. Matching symbols that did not fit the token budget left result_count == 0, the verdict reached absent, and a citable evidence_ref was issued. build_verdict now takes matches_before_packing; an empty response with matches found is degraded, and verdict.omitted is disclosed on every state. get_ranked_context reports its candidate set, so no-candidates and packed-empty stop looking alike.

search_text had three holes at once. Two bare continues meant unreadable files were never counted, so 3 unreadable of 10 read as a complete sweep of 7. A file_pattern matching zero files searched nothing and returned absent. And it never passed index freshness into its verdict, so the stale gate its sibling tools enforce could not fire there. Now verdict.incomplete.reason plus a real freshness probe.

A display preference decided whether a scan was citable. meta_fields filtering ran before the absence-evidence block: the shipped default meta_fields: [] deleted the verdict so nothing was recorded, and the narrower meta_fields: ["verdict"] deleted index_truncated and handed a truncated scan to the recorder as untruncated. The contract blocks now run against the complete internal result, and what filtering removes is re-attached as _meta.absence_evidence, which also survives compact encoding.

Also: suppress_meta is a dispatcher argument no tool schema declares, so v1.108.175 counted it as a caller mistake and cost well-formed calls their absence evidence. And evidence identity now includes the effective search operation, because a literal search and a regex search of the same string are different operations.

Same shape as every gate before it: disclose on every state, refuse only the absence claim, express the refusal as a downgrade so there is no second rule to keep in sync. 26 new tests; non-vacuity proven on the live path.

v1.108.176 — a partial index stops reporting itself fresh

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 11:18

A partial index stops reporting itself fresh. Closes #375 sub-problem C.

The reported symptom: "it has learned about 7,659 of 9,634 code files. But it still reports itself as up to date. So 'I never learned that file' and 'that file doesn't exist' look identical to every agent."

The check was not broken. It was answering a different question. Freshness compares index SHA against git HEAD, which asks is the index BEHIND the tree in time — and was being read as does the index COVER the tree. A corpus that dropped files at index time sits at the same SHA as the checkout, so fresh was literally true and completely misleading.

Underneath it, index_folder's second pass dropped files through three uncounted continues, so the corpus could end up smaller than the walk reported with nothing anywhere recording the difference — there was no number to be honest with even if freshness had wanted to be.

What changed

  • Post-discovery drops are counted by reason (outside_root, no_language, stat_failed) and persisted.
  • The coverage contract records files_accepted beside files_indexed, plus unaccounted for any remainder no named reason explains.
  • New channels.index: "partial", disclosed on every state.
  • A zero-result over a corpus with known gaps degrades: a file that never entered the corpus cannot be proven absent from it. Hits are still real hits — only the absence CLAIM is refused.

Notes

  • complete is tri-state: true / false / null-for-unknown. An index predating this accounting reports null, and null never reads as true. Every existing index behaves byte-identically until you re-index.
  • A partial walk over a wider index (subdir-merge, branch-delta) reports unknown rather than a false incomplete.
  • Channel precedence: rebuilding > partial > stale > fresh.
  • Measured before shipping, to be sure the signal is not noise: this repo indexes 672 accepted / 672 indexed, complete: true. No false positives.
  • No tool-count or INDEX_VERSION change.

Not covered by this release: the human-facing cockpit (repos_report / get_watch_status) still reports only fresh/stale, and jdocmunch/jdatamunch parity for this contract is untouched.

v1.108.175 — an ignored argument cannot prove absence

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 02:54

An ignored argument cannot prove absence.

Found live while auditing #375, against this repo. A search_text call passed regex=true. The parameter is is_regex.

Every tool reads its arguments key-by-key (arguments.get("is_regex", False)), so the flag was dropped in silence, the regex source text was searched as a LITERAL substring, nothing matched, and the response reached state: "absent" carrying "treat this as strong evidence the target is not present" plus a citable evidence_ref — over a corpus that plainly contained the target.

The dangerous half is not the wrong answer. It is that a misunderstood call minted an absence PROOF a handoff could cite.

The dispatcher now diffs each call's arguments against the tool's published inputSchema and, when keys were discarded, attaches _meta.ignored_arguments and downgrades an absent verdict to degraded with a note naming them.

Disclose on every state, refuse only the absence claim — the same shape as the rebuilding gate in 1.108.168. Results an ok scan returned were really in the index and are still the best available answer, so only the claim that nothing exists is unfounded. Expressing the refusal as a downgrade means absence_refusal does the refusing and there is no second rule to keep in sync.

Deliberately never rejects the call. Under the 1.x zero-surprise contract an unknown key has always been accepted, so a client that has been sending a harmless extra for a year must not start erroring. An unknown schema accuses nobody: an unreadable declaration returns nothing rather than guessing.

New UNIVERSAL_META_JSON in encoding/schema_driven.py: structured _meta keys every encoder preserves whether or not its own schema listed them. A per-schema allowlist is right for tool payloads and wrong for contract keys, because the failure is silent and asymmetric — the encoder drops the key, the response still looks complete, and the field that said "don't trust this" is the one that vanishes.

No schema, tool-count, or INDEX_VERSION change. Suite parity: jdatamunch-mcp v1.29.0.

v1.108.174 — an empty index says so

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 02:05

An empty index says so.

Raised in correspondence on #375. A user ran the suite for months with jdatamunch holding zero datasets and jdocmunch holding three documents, and only discovered it by going looking.

Their framing of the class is the part worth keeping: every tool answers confidently regardless of how little it holds, so an agent cannot tell "I searched everything and it is not there" from "I have almost nothing indexed". That single ambiguity is what cost them a wrong-path hour.

list_repos on an empty store now carries top-level empty: true plus a hint naming index_folder and saying why it matters, so an empty search is not read as evidence of absence.

Top-level rather than under _meta deliberately: jdoc and jdata strip _meta by default, so a nudge placed there would be deleted before the agent ever saw it. Identical key names across all three servers, so this is one contract rather than three.

Deliberately NOT attempting the "under-indexed" case. Zero indexed repositories is a fact; "too few" is an inference, and any threshold would manufacture false warnings on small repos. Only the unambiguous case ships.

Additive and silent once anything is indexed: no key, no token, no behavior change. No schema, tool-count, or INDEX_VERSION change.

Suite parity: jdatamunch-mcp v1.28.0 ships the same nudge.