Skip to content

fix: 15 bugs from whole-src bug hunt (correctness, security, memory, contract)#180

Closed
paulnsorensen wants to merge 98 commits into
mainfrom
fix/src-bug-hunt
Closed

fix: 15 bugs from whole-src bug hunt (correctness, security, memory, contract)#180
paulnsorensen wants to merge 98 commits into
mainfrom
fix/src-bug-hunt

Conversation

@paulnsorensen

Copy link
Copy Markdown
Collaborator

Whole-src/ bug hunt (no diff in scope) run as a 10-dimension /age fan-out, reconciled and re-verified against source. 15 of 17 selected findings fixed across 6 file-disjoint commits; 2 deferred to follow-ups (below). A fresh-context reviewer re-derived every behavioral fix against source and returned PASS.

Gates

  • cargo fmt --check ✓ · cargo clippy --all-targets -- -D warnings ✓ · cargo test 835 passed, 0 failed
  • Version untouched at 0.8.4 (fork law).

Fixed

Blockers

  • edit/apply.rsappend/Cursor::Tail inserted a spurious blank line and dropped the trailing newline on every append to a newline-terminated file. Now resolves before the phantom split-row.
  • prompts/mcp-base.md — shipped instructions told agents tilth_write takes edits: "..." (a string) while the code requires a JSON array. Corrected on all three surfaces (prompt, AGENTS.md, schema); byte-locks updated.

High

  • diff/mod.rs — git argument injection: agent-controlled source/log reached git diff/git log unguarded (--output=… overwrites arbitrary files outside cwd confinement). Leading-dash guard at both sinks.
  • edit/apply.rs — two zero-width inserts at the same index applied in reversed author order.
  • mcp/tools/write.rsmove_file silently clobbered an existing destination. Now rejected.
  • prompts/mcp-edit.md — instructions claimed tilth_search emits a [path#TAG] header (it never does). Removed.
  • cache.rs + index/bloom.rs — unbounded server-lifetime caches; now clru-bounded (mirrors edit/snapshots.rs), bloom sized on unique idents.

Medium

  • diff/parse.rs — hunk-internal lines starting -- /++ (e.g. deleted SQL comments) were misparsed as file headers and dropped. Header branches now gated to outside-hunk.
  • mcp/iso.rsif_modified_since silently rejected fractional-second/offset ISO timestamps (JS toISOString()), disabling cache redaction. Now accepted.
  • mcp/mod.rs + grok.rstilth_grok validated a budget param it ignored; now honored + advertised.
  • mcp/tools/list.rs — invalid globs silently dropped; now rejected loudly.
  • edit/snapshots.rs + session.rs — snapshot-key normalization centralized (impl AsRef<Path>), removing caller-shadowed-invariant risk.
  • mcp/tools/read.rs — fuzzy resolution used the process cwd instead of the per-call cwd.
  • install.rs — hand-rolled TOML writer (no quote escaping) + lexer replaced with toml_edit.
  • search/symbol.rs + content.rs — duplicated file-walk preamble extracted to accept_walk_entry.

Deferred (flagged, not dropped — each warrants its own PR)

  • Consider documenting swift support #16 search/mod.rs god-module refactor — a 3.2k-line-file, zero-behavior-change extraction; too large to fold into a bug-fix PR.
  • chore: automate npm publish on release tags #8 .tilthignore producer-move (security, high) — investigation showed the deny must cover multiple independent read paths (incl. git diff shelling out, which no Rust fs-guard covers, and a cache path that bypasses read_file). A partial guard would falsely signal "secrets safe everywhere". Needs a dedicated, verified PR — recommend scheduling first.

Full report: .cheese/age/src-bug-hunt.md · cure log: .cheese/cure/src-bug-hunt.md.

🤖 Generated with Claude Code

paulnsorensen and others added 30 commits April 18, 2026 19:12
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each FileDiff independently parses old/new sources, runs tree-sitter,
and matches symbols. Switching the overlay build from `.iter()` to
`.par_iter()` lets multi-file diffs fan out across cores — a meaningful
win for large PRs where each file does non-trivial AST work.

compute_overlay is pure (no shared mutable state) and FileDiff/DiffSource
are Send+Sync, so the change is mechanical.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
tilth_edit now takes `files: [{path, edits}]` instead of single
`path`+`edits`. Each file is processed independently — a hash mismatch
on one does not block siblings — and per-file results are reported
under `## <path>` headers separated by `---`. Max 20 files per call.

Reused the existing per-request timeout (timeout::request_timeout, 90s
default) instead of introducing a separate TILTH_BATCH_TIMEOUT — every
tool call already runs under spawn_with_timeout, so the batch deadline
is covered by the outer wall-clock guard.

SERVER_INSTRUCTIONS, EDIT_MODE_EXTRA, and AGENTS.md updated to lead
with "ALWAYS BATCH" and to document the new files: [...] shape.
tilth_read paths schema now declares minItems:1/maxItems:20 to match
runtime enforcement.

Bumps version to 0.7.0 (breaking schema change for tilth_edit).

Tests:
  - batch_edit_two_files_succeeds
  - batch_edit_partial_failure_does_not_block_siblings
  - batch_edit_over_limit_rejected
  - batch_edit_all_failed_returns_err
  - batch_edit_empty_files_array_rejected

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The is_ok_and expression in read/mod.rs fits on a single line per
cargo fmt rules. No behavior change.
Mirror the read_batch pattern: pull tool_edit's per-file loop out of
mcp.rs into a new edit::apply_batch that rayon-parallelizes independent
file work. MCP layer now handles only JSON parsing + input validation.

- edit.rs gains FileEditTask (moved from mcp.rs), apply_batch, apply_one
- apply_one owns canonicalize + package_root + blast_radius
- tool_edit shrinks from ~70 lines to 30: parse, cap at 20, delegate
- Edits now run in parallel across files, matching read_batch behavior

No behavior change: section headers, separators, partial-failure
semantics, and Err-when-all-fail all preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Extract parse_edit_entry helper from parse_file_edit (was 61 lines, triple-nested)
- Drop unused _cache param from tool_edit
- Fix timeout.rs warning threshold to fire once per crossing (>= -> ==)
- Remove Session leak from edit::apply_batch signature; record_read moves back to mcp::tool_edit
- Collapse apply_one passthrough into outcome-shaping helper over FileEditTask
- Demote EditResult and apply_edits to module-private
- Add missing tilth_diff block to AGENTS.md (sync with SERVER_INSTRUCTIONS)
- Strengthen batch_edit_* test assertions: scope hash-mismatch check to per-file sections, replace unwrap() with expect() on file reads
feat(mcp): timeout module + batch edit + diff parallelization (supersedes #1, #2)
…upstream

# Conflicts:
#	AGENTS.md
#	Cargo.lock
#	Cargo.toml
#	npm/package.json
#	src/mcp.rs
Sync upstream: v0.8.0 + markdown AST refactor + install/opencode fixes
NIH audit findings — replace hand-rolled implementations with
maintained crates.

- error.rs: thiserror v2 derives Display/Error, drops 25 lines of
  mechanical write! formatting.
- index/bloom.rs: drop hand-rolled BloomFilter (double_hash,
  combined_hash, hash_with_seed, ~105 LOC). BloomFilterCache now
  wraps fastbloom, which is SIMD-optimized and the de facto crate.
  Drop test_bloom_filter_sizing (asserted private fields of the
  removed type).
- diff/matching.rs: jaccard_similarity (whitespace-set Jaccard) ->
  strsim::sorensen_dice (character-bigram). Same 0.0..=1.0 range,
  same 0.8 fuzzy threshold; existing fuzzy_match and
  below_fuzzy_threshold tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
refactor: adopt thiserror, fastbloom, and strsim
)

* refactor(mcp): extract prompts to markdown files, wire with include_str!

Move SERVER_INSTRUCTIONS and EDIT_MODE_EXTRA from inline Rust string
literals (src/mcp.rs:39-113) to prompts/mcp-base.md and prompts/mcp-edit.md.
Compile them back in via include_str! so the source of truth is editable prose
rather than escape-heavy Rust constants.

Add build_instructions() helper to trim trailing whitespace from included
files and re-inject blank-line separators between sections, preserving
byte-identical MCP wire output.

Add scripts/regen-agents-md.sh to regenerate AGENTS.md as a build artifact
from the two prompt files (marked with a "generated from" header comment).

Add 4 unit tests pinning anchor strings, blank-line separator invariant,
overview prepending, and no-trailing-whitespace for wire safety.

Update CLAUDE.md to point to prompts/ as the source of truth and note the
regen workflow.

Verified: MCP wire bytes are identical to pre-refactor in both --mcp and
--mcp --edit modes. All 356 tests pass. No clippy warnings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(prompts): improve clarity + add markdown lint integration

Refactor prompts/mcp-base.md and prompts/mcp-edit.md for better scanning:
- Top-weight strategic imperatives ("ALWAYS BATCH", "DO NOT use Grep", etc.)
- Group by intent: Core Principles → Tools → Behaviors
- Add section headers for readability (## Core Principles, ## Tools)
- Use code fences for JSON request shape to improve clarity
- Inline tools catalog with consistent structure (usage: → kind: → output:)
- Better separation of concerns (e.g., "ALWAYS group edits" vs "Behavior:")

Add markdown linting via markdownlint-cli2:
- .markdownlint.json: lenient config tailored for technical prose
  (disabled first-line-h1, blanks-around-lists, blanks-around-fences)
- prek.toml: hook for local pre-commit markdown linting + auto-fix
- .github/workflows/ci.yml: CI job for markdown linting via GitHub action
- scripts/regen-agents-md.sh: auto-fixes AGENTS.md after regeneration

Update unit test anchors to match the improved prompt text (last line changed
from the "DO NOT re-read" instruction to the tilth_diff output format).

All 356 tests pass. Markdown linting is clean. MCP wire bytes preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: untrack prek.toml (local-only via .git/info/exclude)

prek is a personal pre-commit choice — upstream contributors may use a
different framework or none at all. Move prek.toml to local-only
.git/info/exclude so the file remains usable for hookwiring on this
clone but doesn't impose tooling on others.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(mcp): byte-stable prompt embedding + deterministic regen

Address Copilot review on PR #13:

- src/mcp.rs: switch trim() → trim_end() in build_instructions so only
  trailing whitespace is stripped. trim() also removed leading bytes,
  which would silently swallow a leading blank line if a prompt file
  ever started with one.
- scripts/regen-agents-md.sh: strip trailing newlines from each prompt
  source via command substitution, then emit exactly one blank-line
  separator. Makes AGENTS.md regeneration deterministic regardless of
  trailing blank lines in prompts/*.md.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* refactor: replace hand-rolled NIH utilities with maintained crates

- Drop custom Unicode-aware edit_distance in favor of strsim::levenshtein
  (already a dep; StringWrapper iterates chars, preserving scalar-level
  semantics).
- Replace hand-coded percent_decode + hex_val with percent-encoding 2.3.
- Collapse cfg-split home_dir to a one-line wrapper around the home crate
  (cargo-team maintained).

Net -65 LOC across three files; behavior parity verified by existing tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: restore Unicode-distance and percent-decode coverage

Re-add the assertions deleted in the prior commit, now exercising the
library calls instead of the deleted hand-rolled functions. Locks in
the contracts the production code depends on:

- strsim::levenshtein operates on Unicode scalars (CJK + emoji ranks).
- percent_encoding::percent_decode_str preserves malformed % sequences
  literally rather than dropping them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: encapsulate utility deps behind src/util.rs

Single internal facade (edit_distance, percent_decode, home_dir) so
strsim, percent-encoding, and home are referenced from exactly one
place. Call sites stay stable across future library swaps.

The contract tests (Unicode-scalar distance, malformed % preservation)
move to util.rs alongside the wrappers they exercise.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: keep local wrappers for multi-call-site lib helpers

Replaces the src/util.rs facade approach with the lighter-touch rule:
keep a private wrapper alongside the call sites only when there are
multiple of them. Addresses Copilot review feedback in the same pass.

- src/read/mod.rs: restore private edit_distance(a, b) wrapper around
  strsim::levenshtein (2 call sites). Restore Unicode-scalar regression
  test so a future swap to a byte-level distance fails loudly.
- src/install.rs: restore home_dir() wrapper (4 call sites). Keep an
  actionable error message that names HOME / USERPROFILE.
- src/mcp.rs: inline the percent-decoding (single call site). Use
  decode_utf8() with a fallback to the raw input on invalid UTF-8,
  matching the previous String::from_utf8(...).unwrap_or_else(...) on
  the hand-rolled implementation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(search): kind="symbol" no longer leaks comment/string hits

Searching for a Python class via tilth_search(kind="symbol") returned
the literal comment line "# class Foo" as a usage match alongside the
real "class Foo:" definition. find_usages was running a ripgrep
word-boundary pass with no AST filter, so any comment or string token
that matched the query name leaked into "symbol" results.

Make kind="symbol" strict by default — declarations only, no comment
or string hits. Add kind="any" for callers who want the prior mixed
behavior. SERVER_INSTRUCTIONS and AGENTS.md updated together.

Test: src/search/symbol.rs::strict_mode_drops_comment_and_usage_matches —
on a fixture with "# class Foo", real "class Foo:", and a "foo = Foo()"
usage, kind="symbol" returns the single definition; kind="any" returns
all three.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(search): replace strict bool with SymbolMode enum

The strict-mode toggle was plumbed as a bare bool through six call
sites (search_symbol_expanded, search_multi_symbol_expanded, the raw
helper, the basic helper, two tests). Magic literals like
`symbol::search(..., false)` left readers guessing what the boolean
meant, and the MCP layer was already mapping a string enum
("symbol" | "any") down into that bool — losing the taxonomy at the
boundary.

Introduce `pub enum SymbolMode { Strict, Any }` next to `search`,
mirroring the MCP `kind` knob 1:1. The MCP dispatch performs the
string→enum mapping explicitly inside the `"symbol" | "any"` arm,
so the surface vocabulary travels through the API unchanged instead
of being silently re-encoded as a bool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(search): default CLI to SymbolMode::Strict, skip usage scan when strict

Addresses copilot review on PR #15:

- CLI dispatch sites for explicit symbol queries (QueryType::Symbol and the
  multi-symbol comma path) now pass SymbolMode::Strict, matching the MCP
  kind="symbol" semantic. Concept/Fallthrough cascades keep Any since they
  are exploratory queries that fall back to content search.
- Thread mode parameter through search_symbol and search_symbol_raw so the
  API is consistent — no caller silently picks Any.
- symbol::search now skips regex compilation and find_usages entirely on
  the Strict path; previously the full ripgrep scan ran and was discarded.
- Tighten tilth_search kind schema description: declarations include
  keyword/heading fallbacks for code without grammars and for markdown,
  not exclusively tree-sitter AST.

* feat(cli): add --kind flag for symbol-search mode, plus age cleanups

Cures three findings from .cheese/age/strict-symbol-kind.md:

1. high/correctness — restore the CLI escape hatch to Any mode. The prior
   commit moved QueryType::Symbol to SymbolMode::Strict on the CLI but
   left users with no way to opt back in. Adds --kind=symbol|any
   (default symbol), threads SymbolMode through pub run / run_full /
   run_expanded / run_inner / run_query_expanded / run_query_basic, and
   re-exports SymbolMode at the crate root so main.rs can build the
   CliKind → SymbolMode mapping. Concept and Fallthrough remain on Any
   regardless of --kind because those classifier outcomes are
   exploratory and have no fallback path on the expanded route.

2. medium/assertions — tighten the SymbolMode::Any regression test.
   Replaces the > 1 length predicate with an exact == 3 count plus
   line-membership checks for the comment hit (line 1), the class
   definition (line 2), and the usage (line 5).

3. medium/deslop — flatten the inner kind→mode match in mcp.rs into a
   plain if/else, removing the unreachable! branch that was dead by
   construction (the outer match already gates kind to "symbol"|"any").

* docs(search): clarify SymbolMode and tilth_search kind options

- SymbolMode::Strict doc: note keyword + markdown-heading fallbacks
  alongside tree-sitter, so callers don't assume AST-only output.
- prompts/mcp-base.md: add the kind="regex" option and clarify that
  kind="any" includes usage call-sites, not just comment/string mentions.
- AGENTS.md: regenerated from updated prompts source.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* perf(diff): parallelise overlay construction across files

Extracted from #61 part 4 (rayon parallelisation, diff overlay half).
Independent of the timeout (#82) and batch-edit work — touches only
src/diff/mod.rs.

`compute_overlay` is pure per-`FileDiff` work: each file independently
fetches old/new content, runs tree-sitter, and matches symbols. Switching
the overlay build from `.iter()` to `.par_iter()` lets multi-file diffs
fan out across cores — a meaningful win on large PRs where each file does
non-trivial AST work.

Thread safety: `compute_overlay` constructs its own
`tree_sitter::Parser::new()` per call inside
`lang::outline::get_outline_entries` — no shared mutable state crosses
worker boundaries. `FileDiff` and `DiffSource` are Send+Sync via auto
trait derivation.

Scope deliberately limited to `diff::diff()` — left the per-commit
overlay loop in `diff_log()` alone since each commit's overlay set is
typically small. Also left the `tool_read` batch parallelisation from
the original #61 out of this PR pending the deadline-preservation
discussion (review item #5).

Test plan
- cargo build — clean
- cargo test — 344 pass
- cargo clippy -- -D warnings — clean
- cargo fmt --check — clean

* refactor(error): adopt thiserror for Display/Error derives

NIH audit finding — replace 25 lines of mechanical write! formatting
in `src/error.rs` with `thiserror::Error` derives. Each variant gets
an inline `#[error(...)]` attribute; the manual `impl Display` and
empty `impl std::error::Error` blocks are removed.

Net: +11 / -40 in src/error.rs; one new dependency (thiserror v2).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(diff): adopt strsim::sorensen_dice for fuzzy symbol matching

NIH audit finding — replace hand-rolled `jaccard_similarity`
(whitespace-set Jaccard) in `src/diff/matching.rs` Phase 3 fuzzy
matcher with `strsim::sorensen_dice` (character-bigram). Same
[0.0, 1.0] range, same 0.8 threshold, no rescaling needed.

Net: +1 / -15 in src/diff/matching.rs; one new dependency (strsim).

Existing tests `fuzzy_match` and `below_fuzzy_threshold` still pass
at the unchanged 0.8 threshold.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(install): adopt home crate for cross-platform home dir lookup

Replace the cfg-split `$HOME` / `$USERPROFILE` block in `home_dir()` with
a one-liner around `home::home_dir()`. The `home` crate is maintained by
the cargo team itself (rust-lang/cargo) and handles a corner case the
hand-rolled version misses: an empty `$HOME` is treated as missing rather
than silently producing an empty `PathBuf` that joins onto relative paths.

Behavior preserved:
- Same env-var precedence ($HOME on unix, $USERPROFILE on windows).
- Wrapper kept (4 call sites) with an actionable error message that names
  both env vars.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(mcp): adopt percent-encoding crate for file:// URI decoding

Replace the hand-rolled `percent_decode` + `hex_val` helpers
(`src/mcp.rs`) with `percent_encoding::percent_decode_str`. Single call
site in `extract_root_from_response`, so the helpers are inlined rather
than wrapped.

The fallback contract on invalid UTF-8 is preserved: rather than
substituting U+FFFD replacement characters via `decode_utf8_lossy`,
fall back to the raw undecoded input so the subsequent `is_dir()` check
rejects mangled paths cleanly.

The unit-test for the deleted helper (`percent_decode_basic`) is
removed; end-to-end coverage of the same code path lives in
`extract_root_percent_encoded_uri`, which exercises a real `file://`
URI with `%20` resolving to a real directory.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(read): adopt strsim::levenshtein for filename / heading suggestions

Replace the 16-LOC hand-rolled Wagner-Fischer implementation in
`src/read/mod.rs` with a thin wrapper around `strsim::levenshtein`. Two
call sites — `suggest_similar` (filename "did you mean?") and
`suggest_headings` (markdown heading suggestion) — so the wrapper is
kept rather than inlined.

`strsim::levenshtein` wraps `generic_levenshtein` over `StringWrapper`,
which iterates `.chars()`. Same Unicode-scalar semantics as the
hand-rolled version: a single CJK or emoji glyph counts as one edit
unit, not 3-4 bytes. The `edit_distance_is_unicode_aware` regression
test (CJK + emoji + ASCII) is updated to lock in this contract — if
`strsim` ever switches to byte-level distance, the test fails loudly.

Note: PR #88 (`refactor-strsim` for diff fuzzy matching) also adds
`strsim = "0.11"`. Whichever lands first, the second can drop the
duplicate Cargo.toml line in a trivial rebase.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: char-boundary panic in is_minified_by_name with multi-byte UTF-8

stem.len() - 4 is a byte offset, not a char offset — it can land
inside a CJK or emoji character, causing a panic. Use
char_indices().nth_back(3) to find the byte offset of the 4th
character from the end instead.

* Update dependencies in Cargo.toml

Fixed an error I did when manually resolving a conflict

* chore: bump version to v0.8.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: jensenojs <jensenojs@qq.com>
Co-authored-by: jahala <jahala@gmail.com>
* fix(mcp): record_read counts only files whose edits committed

tool_edit was calling session.record_read for every Ready FileEditTask
before apply_batch ran, so hash mismatches, IO failures, and other
per-file failures still incremented the "Files read" counter. The counter
is supposed to reflect file content that actually flowed back to the LLM;
failed edits return only an error message.

Change apply_one to return (String, Option<PathBuf>) so the success
carrier exposes the path that committed. Wrap apply_batch's success in
a new BatchOutcome { output, applied } so the wire layer can iterate the
applied list and record_read only for those paths. The error semantic is
preserved: apply_batch still returns Err iff every file failed.

Adds four boundary tests at the wire layer (hash mismatch, IO failure,
all-parse-error, parse+good mix) and a #[cfg(test)] reads_count
accessor on Session for direct assertions.

* fix(mcp): address Copilot review on PR #19

- Reject empty `edits` array at parse time so `BatchOutcome.applied`
  truly means "a write occurred" — closes the hole where empty edits
  hit the early-return in `apply_edits` without writing the file and
  still inflated `record_read`.
- Derive the wrong-hash anchor at runtime (XOR-flip) instead of the
  hardcoded `1:fff` sentinel that had a ~1/4096 collision risk against
  the 12-bit `line_hash`.
- Reword the gating comment — hash mismatch errors do return hashlined
  file context, so "no usable content" was inaccurate; "didn't change
  the file" is the actual reason these shouldn't count as reads.

Adds a regression test that an empty `edits` array surfaces a parse
error without bumping the session read counter.
* feat(edit): tree-sitter parse check on post-edit writes

After tilth_edit writes a file, parse pre- and post-edit content with
the language's tree-sitter grammar. Surface only *new* ERROR/MISSING
nodes (pre-existing errors stay silent). Multiset diff by (kind, detail)
handles line shifts from edits robustly.

Output: "── parse ──" block listing top 10 new errors with line numbers.
Caps at 10 lines, summarizes remainder. No rollback, no opt-out flag.
Languages without grammars (Dockerfile, Make) skip silently.

Adds src/edit_parse_check.rs (339 LOC, 11 unit tests) and wires it into
EditResult::Applied. 2 integration tests in src/edit.rs. All 370 tests
pass; CI gate (cargo clippy -- -D warnings) clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(edit): address Copilot review on post-edit parse check

- Drop redundant clone of post-parse error list in `check()`; compute
  `total_post` from `post.len()` first, then move `post` into
  `multiset_subtract`. Avoids duplicating every `detail` string on
  heavily broken files.
- Clarify module and `multiset_subtract` doc comments: the key is
  `(ErrorKind, detail)`, not `(is_missing, kind, detail)` — make it
  unambiguous that `ErrorKind` is the local Error|Missing enum and not
  a tree-sitter node kind. Note explicitly that positions are not part
  of the key (so pre-existing errors below an edit site stay silent).
- Document `ParseError::col` as 0-indexed (internal sort key, never
  rendered) and confirm `line` as 1-indexed.
- Align spec with the shipped implementation: multiset key, output
  format (`X new, Y total` suffix), and module path
  (`src/edit_parse_check.rs`, kept flat — no `edit/` promotion).
- Add `parse_block_per_file_in_batch_independent`: regression test
  asserting each file in a multi-file batch reports its own parse
  block (or absence thereof) independently.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(mcp): require batch arrays for tilth_read and tilth_files

Session analytics showed 82.5% of tilth_read calls used the singular
`path` parameter and 77%+ of tilth_files calls used the singular
`pattern` — the schemas exposed both forms, so Claude defaulted to the
simpler singular. Removing the escape hatch forces callers to use
`paths: [...]` and `patterns: [...]` even for single items, matching
the pattern tilth_edit already enforces with `files: [...]`.

Schema changes:
- tilth_read: drop `path`; require `paths` array. `section`/`sections`/`full`
  now require a single-element `paths` array.
- tilth_files: drop `pattern`; require `patterns` array.

Updated MCP instructions and AGENTS.md to reflect the new API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(mcp): type-specific errors and accurate comment for batch params

Copilot review feedback on PR #20:
- Distinguish missing from wrong-type for `paths` and `patterns`: a
  scalar like `paths: "a.rs"` now reports "must be an array" instead
  of "missing required parameter", which is clearer for callers
  migrating off the singular form.
- Correct the comment above `read_batch`: per-file smart view still
  applies; what the batch path skips is the related-file hint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…ate first (#14)

* feat(read): shared budget across sections array; later sections truncate first

Adds `read_ranges_with_budget`, a sibling of the merged `read_ranges` that
walks a shared token budget forward across the input ranges. Each section
deducts from the remaining budget as it is emitted; once the budget is
exhausted, later sections are replaced with a single
`... section omitted (budget exhausted) ...` line under their existing
`─── lines X-Y ───` marker so callers can still see which ranges were
dropped. If the very first section alone exceeds the budget, fall back to
the standard `crate::budget::apply` truncator on header + first block and
omit the rest.

Block resolution is shared with the unbudgeted path via a new private
`collect_blocks` helper — no behavior change for `tilth_read` calls
without a budget. The MCP `tilth_read` path now threads the request
budget into `read_ranges_with_budget` instead of post-truncating the
concatenated output, so section boundaries stay aligned with marker
boundaries.

No marker changes (keeps upstream's `─── lines X-Y ───`); no per-section
metadata surface (left for a follow-up that rides on the `_meta`
plumbing).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(read): strict token cap in read_ranges_with_budget

Address Copilot review on PR #14:

- Break after the i==0 over-budget truncation so later blocks don't
  append omission markers on top of an already-budget-capped output.
- Account for omission marker tokens in the remaining budget; once even
  the marker would overflow, stop emitting. Earlier markers signal that
  omission occurred.
- Strengthen budgeted tests with estimate_tokens(out) <= budget
  assertions and add a pathological-budget test that forces some
  omission markers to themselves be dropped.

The function is now a strict cap: returned output never exceeds the
caller-supplied budget. The mcp tilth_read multi-section path can rely
on this and skip a redundant apply_budget pass.

* fix(read): stabilize budgeted-section tests across temp-dir lengths

CI on Linux (`/tmp/...`) was failing two tests that pass on macOS
(`/var/folders/.../T/...`). The file header in `read_ranges_with_budget`
embeds the absolute path, so the shorter Linux path leaves ~6 more tokens
of budget — enough at budget=120 / 50-char lines to fit a second chunk
that the assertions expect to be omitted.

Widen the chunks (200-char lines, budget=240) so the cliff between
"first chunk fits" and "second chunk fits" is far wider than any
plausible path-length variance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* perf(diff): parallelise overlay construction across files

Extracted from #61 part 4 (rayon parallelisation, diff overlay half).
Independent of the timeout (#82) and batch-edit work — touches only
src/diff/mod.rs.

`compute_overlay` is pure per-`FileDiff` work: each file independently
fetches old/new content, runs tree-sitter, and matches symbols. Switching
the overlay build from `.iter()` to `.par_iter()` lets multi-file diffs
fan out across cores — a meaningful win on large PRs where each file does
non-trivial AST work.

Thread safety: `compute_overlay` constructs its own
`tree_sitter::Parser::new()` per call inside
`lang::outline::get_outline_entries` — no shared mutable state crosses
worker boundaries. `FileDiff` and `DiffSource` are Send+Sync via auto
trait derivation.

Scope deliberately limited to `diff::diff()` — left the per-commit
overlay loop in `diff_log()` alone since each commit's overlay set is
typically small. Also left the `tool_read` batch parallelisation from
the original #61 out of this PR pending the deadline-preservation
discussion (review item #5).

Test plan
- cargo build — clean
- cargo test — 344 pass
- cargo clippy -- -D warnings — clean
- cargo fmt --check — clean

* feat(mcp): batch tilth_edit — accept files: [{path, edits}]

Extracted from #61 part 3 (batch tilth_edit). Independent of the timeout
(#82) and diff-overlay (#83) extractions; touches src/edit.rs (the new
apply_batch + FileEditTask), src/mcp.rs (new tool_edit + parsing helpers
+ schema), and AGENTS.md (user-facing docs).

What changes for callers
- tilth_edit now takes {"files": [{"path", "edits"}, ...]} instead of
  {"path", "edits"}. Each file is processed independently in parallel —
  a hash mismatch on one file no longer blocks its siblings.
- Per-file results render as Markdown sections (## <path>) joined by ---
  separators. Returns isError only when every file failed.
- Cap of 20 files per call, surfaced both in the JSON Schema (maxItems)
  and at runtime.

Why the schema break is safe
- MCP serves tools/list dynamically on every session; clients always get
  the current inputSchema. The LLM sees the new shape on next connect
  and adapts. There is no cached-schema problem to migrate around.

Review fixes from #61 layered in here
- #4 (duplicate paths → data corruption): detect_duplicate_paths now
  rejects the whole batch up front when two Ready tasks resolve to the
  same canonical path. Falls back to the literal path for files that
  don't exist yet, so the dedup key is still well-defined.
- Medium (parse_file_edit short-circuit): parse_edit_entry returns the
  failing edit index in its error message, so the LLM can fix exactly
  the right entry instead of guessing which edit was malformed.
- Medium (tests use tempfile::tempdir): new batch tests use tempdir
  rather than fixed-name paths in std::env::temp_dir.

Implementation shape
- src/edit.rs: pub apply_batch + pub FileEditTask::{Ready, ParseError},
  with apply_one / render_applied keeping the parallel closure trivial.
  apply_edits and EditResult are now private — callers go through
  apply_batch.
- src/mcp.rs: tool_edit shrinks from ~85 lines of inline JSON parsing to
  parse → cap-check → dedup-check → record_read → apply_batch. JSON
  parsing stays at the MCP wire boundary; parallel I/O and blast radius
  live next to apply_edits.

Test plan
- cargo build — clean
- cargo test — 348 pass (4 new in edit::tests covering two-file success,
  partial failure, all-failed, and parse-error surfacing — all using
  tempfile::tempdir)
- cargo clippy -- -D warnings — clean
- cargo fmt --check — clean

* refactor(error): adopt thiserror for Display/Error derives

NIH audit finding — replace 25 lines of mechanical write! formatting
in `src/error.rs` with `thiserror::Error` derives. Each variant gets
an inline `#[error(...)]` attribute; the manual `impl Display` and
empty `impl std::error::Error` blocks are removed.

Net: +11 / -40 in src/error.rs; one new dependency (thiserror v2).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(diff): adopt strsim::sorensen_dice for fuzzy symbol matching

NIH audit finding — replace hand-rolled `jaccard_similarity`
(whitespace-set Jaccard) in `src/diff/matching.rs` Phase 3 fuzzy
matcher with `strsim::sorensen_dice` (character-bigram). Same
[0.0, 1.0] range, same 0.8 threshold, no rescaling needed.

Net: +1 / -15 in src/diff/matching.rs; one new dependency (strsim).

Existing tests `fuzzy_match` and `below_fuzzy_threshold` still pass
at the unchanged 0.8 threshold.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(install): adopt home crate for cross-platform home dir lookup

Replace the cfg-split `$HOME` / `$USERPROFILE` block in `home_dir()` with
a one-liner around `home::home_dir()`. The `home` crate is maintained by
the cargo team itself (rust-lang/cargo) and handles a corner case the
hand-rolled version misses: an empty `$HOME` is treated as missing rather
than silently producing an empty `PathBuf` that joins onto relative paths.

Behavior preserved:
- Same env-var precedence ($HOME on unix, $USERPROFILE on windows).
- Wrapper kept (4 call sites) with an actionable error message that names
  both env vars.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(mcp): adopt percent-encoding crate for file:// URI decoding

Replace the hand-rolled `percent_decode` + `hex_val` helpers
(`src/mcp.rs`) with `percent_encoding::percent_decode_str`. Single call
site in `extract_root_from_response`, so the helpers are inlined rather
than wrapped.

The fallback contract on invalid UTF-8 is preserved: rather than
substituting U+FFFD replacement characters via `decode_utf8_lossy`,
fall back to the raw undecoded input so the subsequent `is_dir()` check
rejects mangled paths cleanly.

The unit-test for the deleted helper (`percent_decode_basic`) is
removed; end-to-end coverage of the same code path lives in
`extract_root_percent_encoded_uri`, which exercises a real `file://`
URI with `%20` resolving to a real directory.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(read): adopt strsim::levenshtein for filename / heading suggestions

Replace the 16-LOC hand-rolled Wagner-Fischer implementation in
`src/read/mod.rs` with a thin wrapper around `strsim::levenshtein`. Two
call sites — `suggest_similar` (filename "did you mean?") and
`suggest_headings` (markdown heading suggestion) — so the wrapper is
kept rather than inlined.

`strsim::levenshtein` wraps `generic_levenshtein` over `StringWrapper`,
which iterates `.chars()`. Same Unicode-scalar semantics as the
hand-rolled version: a single CJK or emoji glyph counts as one edit
unit, not 3-4 bytes. The `edit_distance_is_unicode_aware` regression
test (CJK + emoji + ASCII) is updated to lock in this contract — if
`strsim` ever switches to byte-level distance, the test fails loudly.

Note: PR #88 (`refactor-strsim` for diff fuzzy matching) also adds
`strsim = "0.11"`. Whichever lands first, the second can drop the
duplicate Cargo.toml line in a trivial rebase.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: char-boundary panic in is_minified_by_name with multi-byte UTF-8

stem.len() - 4 is a byte offset, not a char offset — it can land
inside a CJK or emoji character, causing a panic. Use
char_indices().nth_back(3) to find the byte offset of the 4th
character from the end instead.

* Update dependencies in Cargo.toml

Fixed an error I did when manually resolving a conflict

* chore: bump version to v0.8.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(bloom): adopt fastbloom for BloomFilter implementation

NIH audit finding — drop ~105 LOC of hand-rolled bloom filter math
in `src/index/bloom.rs` (`double_hash`, `combined_hash`,
`hash_with_seed`, optimal-bit/hash sizing) in favor of `fastbloom`,
the SIMD-optimized de facto Rust crate.

The `extract_identifiers` byte state machine — the unique-to-tilth
piece — stays. `BloomFilterCache` now wraps `fastbloom::BloomFilter`
constructed via `with_false_pos(0.01).expected_items(n)`.

Drop `test_bloom_filter_sizing` — it asserted private fields
(`num_bits`, `num_hashes`) of the removed type. Equivalent guarantees
are part of fastbloom's own test suite.

Net: +5 / -133 in src/index/bloom.rs; one new dependency (fastbloom).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(mcp): extract per-request timeout into its own module

Extracted from #61 part 2 (timeout module). Depends on the Services
struct from #63 (already merged) for the per-instance ThreadTracker.

Moves the inline mpsc::recv_timeout machinery in handle_tool_call into
src/timeout.rs as spawn_with_timeout, backed by crossbeam-channel's
select! { default(timeout) => ... } — the sync-world equivalent of
Future.get(timeout, unit).

Two correctness changes vs. the original #61 timeout draft, addressing
the review feedback there:

- ThreadTracker::is_at_cap uses Ordering::Acquire (was Relaxed). Pairs
  with the Release in record_timeout / record_finish_after_timeout, so
  a load that observes the new count also observes any state the
  incrementing thread published before it.
- The deadline arm now CAS-claims the timeout *before* incrementing the
  tracker (was: increment, then CAS, then conditionally roll back). The
  worker, if it lost the CAS, spins on a new timeout_acked AtomicBool
  before decrementing — so the tracker can never go negative and a
  concurrent is_at_cap() can never observe an inflated count that gets
  rolled back. Closes the false-rejection window the reviewer flagged
  near the hard cap.

Also wires an upfront tracker.is_at_cap() check in handle_tool_call so
the server refuses new work cleanly under sustained pressure instead of
piling on more abandoned threads.

ThreadTracker is owned by Services rather than a static global, so unit
tests instantiate their own and don't serialise on shared state.

No behaviour change for the happy path: same 90s default, same
TILTH_TIMEOUT env override, same client-visible "tool timed out" /
"tool panicked" messages.

Test plan
- cargo build — clean
- cargo test — 349 pass (5 new in timeout::tests covering the CAS
  roundtrip, fast path, panic surfacing, saturated tracker, and env
  parsing)
- cargo clippy -- -D warnings — clean
- cargo fmt --check — clean

* fix(timeout): address PR 82 review feedback

- Switch tracker RMWs to AcqRel — canonical pessimistic ordering for an
  atomic counter read from another thread. Release/Acquire was sufficient
  for the counter value alone, but AcqRel documents the cross-thread
  contract more explicitly so the next reader doesn't have to re-derive
  that the value composes with the rest of the spawn state machine.
- Add std::thread::yield_now() after spin_loop() in wait_for_timeout_ack
  so a worker scheduled before the main thread on a single-CPU container
  surrenders the rest of its quantum instead of burning ~10ms before the
  ack becomes visible.
- Doc-comment ABANDONED_THREAD_WARN as a deliberate warn-once policy so a
  future maintainer doesn't switch the deadline arm's `==` check back to
  `>=`. The hard cap at MAX_ABANDONED_THREADS bounds the silence past the
  threshold.
- Mark SpawnFailure as #[non_exhaustive] so a future failure mode
  (e.g. OS-level thread spawn failure) can be added without churning
  every call site.
- Relax abandoned_counter_roundtrips_through_cas deadline from 2s to 5s
  to reduce flake risk on contended CI runners.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(edit): address PR 84 review — path dedup, empty edits, encapsulation

- Moves normalize_path_key and detect_duplicate_paths from mcp.rs to edit.rs
- Adds macOS case-insensitive APFS dedup (ASCII-lowercasing on cfg target_os)
- Adds runtime validation for empty edits array (was schema-only; now parse error)
- Moves dedup gate into apply_batch to prevent wire-layer bypass
- Simplifies apply_one/render_applied signatures (&Arc<T> → &T with deref-coercion)
- Updates AGENTS.md documentation (isError semantics, parse-error behavior)
- Adds 5 tests (case aliases, empty edits, integration gate)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: sync upstream and refine MCP batching prompts

Agent-Logs-Url: https://github.com/paulnsorensen/tilth/sessions/9f361125-ebc7-4c8a-b551-c55fe19ee8d3

Co-authored-by: paulnsorensen <429793+paulnsorensen@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Paul Sorensen <paulnsorensen@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: jensenojs <jensenojs@qq.com>
Co-authored-by: jahala <jahala@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: paulnsorensen <429793+paulnsorensen@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…_write, ISO timestamps (#23)

* feat(mcp): overhaul tool surface — batched queries, tilth_list, tilth_write, ISO timestamps

Implements .cheese/specs/tilth-mcp-overhaul.md.

- New tilth_list (tree output) and tilth_write (hash/overwrite/append + strict
  auto-fix probe that re-applies on single-match relocation).
- tilth_search accepts queries: [{query, glob?, kind?}] (max 10) with
  "Results as of <ISO>" header; kind:any collapses to symbol.
- tilth_read accepts singular path:, suffix grammar (#n-m, #n, #heading,
  #symbol), mode: auto|full|signature, if_modified_since.
- expand_match emits <line>:<hash>|<content> under edit_mode.
- tilth_list walker honors SKIP_DIRS (.git, node_modules, target, ...).
- tilth_write overwrite/append canonicalize and refuse out-of-scope writes;
  fail closed when scope canonicalization fails.
- Prompts collapsed to thin overview; per-tool descriptions lead with worked
  examples. Verbatim "auto-fixed: <old> → <new>" signal emitted to match
  prompts/mcp-edit.md contract.
- tilth_files and tilth_edit retained as transitional aliases.
- 37 new tests, dead make_entry helper removed from diff/matching.rs.

Gates: cargo test 442 passing, cargo clippy -D warnings clean, cargo fmt clean.

Co-Authored-By: WOZCODE <contact@withwoz.com>

* refactor(mcp): drop tilth_files/tilth_edit aliases, tighten read mode validation

Removes the legacy `tilth_files` and `tilth_edit` tool dispatches now that
their functionality is folded into `tilth_search` and `tilth_write`.

- `tool_read` now rejects unknown `mode` values up front and drops the
  redundant `full` boolean (mode="full" is the single source of truth).
- Adds `ViewMode::Signature` so signature-mode reads display correctly
  in headers instead of falling back to `auto`.
- Deletes `read_ranges_with_budget` and its tests — no longer reachable
  after the v2 surface stopped routing through it.
- Slims `prompts/mcp-edit.md` (and regenerates AGENTS.md): tool docs now
  live on the tool description itself, not duplicated in the prompt.
- Minor: `map_or` cleanup in `mcp_v2::results_header`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(mcp): convert to barrel module — mcp/mod.rs + mcp/v2.rs

Aligns with the rest of the codebase (search/, diff/, lang/, read/,
index/ are all directory modules) and eliminates the version-suffixed
sibling file. Pure structural move:

- src/mcp.rs       → src/mcp/mod.rs
- src/mcp_v2.rs    → src/mcp/v2.rs   (now declared inside mcp/mod.rs)
- crate::mcp_v2::* → crate::mcp::v2::*
- include_str! paths bumped one level up for the prompts/ files.

No behavior changes; all 443 unit tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(mcp): split v2 helpers into thematic submodules

Drops the version-suffixed `v2` namespace and groups its contents by
purpose, matching how the rest of the codebase organizes helpers:

- src/mcp/path_suffix.rs — PathSuffix + parse_path_with_suffix
- src/mcp/iso.rs         — ISO-8601 timestamps, results_header,
                           with_header, file_changed_since, unchanged_stub
- src/mcp/tree.rs        — render_tree (tilth_list output)
- src/mcp/write.rs       — write_overwrite, write_append, AutoFixResult,
                           auto_fix_locate, fresh_region

Each split file owns its own #[cfg(test)] mod. No behavior changes;
all 443 unit tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(mcp): extract small dispatchers into src/mcp/tools/

Creates the tools/ submodule that will eventually own every tilth_*
dispatcher. This commit moves the simple ones plus the shared helpers:

  src/mcp/tools/
    mod.rs       — resolve_scope, apply_budget (private to mcp)
    diff.rs      — tool_diff
    deps.rs      — tool_deps
    session.rs   — tool_session
    list.rs      — tool_list
    files.rs     — #[cfg(test)] tool_files (kept for advertisement guards)

mod.rs imports them via `use tools::{...}` so dispatch_tool and existing
tests call the same names. tool_read / tool_search / tool_write and
their helpers stay in mod.rs for now (follow-up commits).

No behavior changes; all 443 unit tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(mcp): extract tool_read + helpers into tools/read.rs

Moves the largest read-side dispatcher and its outline-mode helpers:

  src/mcp/tools/read.rs
    - tool_read
    - read_single_with_suffix
    - find_symbol_entry
    - resolve_symbol_range
    - should_auto_signature
    - read_signature_file
    - render_signature_entries

mod.rs gains tool_read to its `use tools::{...}` line; dispatch_tool
keeps calling the unqualified name. tool_search and tool_write still
live in mod.rs for now.

No behavior changes; all 443 unit tests still pass. mod.rs shrinks
from 3454 to 3137 lines.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(mcp): extract tool_search + helpers into tools/search.rs

Moves the search dispatcher and its private helpers:

  src/mcp/tools/search.rs
    - tool_search           (queries[] batch + single-query routing)
    - tool_search_single    (per-kind dispatcher: symbol/any/content/regex/callers)
    - search_merged_default (the `any`/default merged-section path)
    - redact_unchanged_search_sections + flush_search_section
    - is_search_section_heading
    - search_result_path

mod.rs gains tool_search to its `use tools::{...}` line; apply_budget
is no longer needed at this level (each tool brings its own
`super::apply_budget` reference now).

No behavior changes; all 443 unit tests still pass. mod.rs shrinks
from 3137 to 2863 lines.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(mcp): extract tool_write + helpers into tools/write.rs

Moves the write-side dispatcher and its 11-function support cast:

  src/mcp/tools/write.rs
    - tool_write              (hash/overwrite/append batching)
    - path_within_scope       (canonical-path containment guard)
    - render_text_diff
    - HashOriginal + capture_hash_original
    - reapply_at_relocation
    - probe_one_auto_fix
    - append_per_file_auto_fix
    - try_auto_fix
    - parse_file_edit + parse_edit_entry
    - tool_edit               (#[cfg(test)] legacy-alias regression target)

The new `tools::write` module is at a distinct path from the existing
`mcp::write` helpers (write_overwrite / write_append / auto_fix_locate
/ fresh_region); the dispatcher reaches into the helper module via
`crate::mcp::write::*`.

mod.rs's std::fmt::Write is now test-only — only the test module uses
write! macros against String now.

No behavior changes; all 443 unit tests still pass. mod.rs shrinks
from 2863 to 2327 lines.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(mcp): extract tool_definitions into tools/definitions.rs

The 238-line JSON-schema declaration for every advertised tilth tool
now lives next to its dispatchers under `mcp::tools::definitions`.
handle_request reaches it via the `use tools::tool_definitions`
re-export.

No behavior changes; all 443 unit tests still pass. mod.rs shrinks
from 2327 to 2091 lines — the bulk of what remains is the server loop
(run, handle_request, dispatch_tool, handle_tool_call, write_error,
run_tool_with_timeout) plus the in-source test module.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: WOZCODE <contact@withwoz.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(mcp): externalize tool descriptions to prompts/tools/*.md

Tool descriptions were inlined in definitions.rs; moving them to external markdown allows independent iteration on MCP instructions without coupling to Rust code and simplifies versioning for hosts that read prompts directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(mcp): add language tag to list.md fenced block

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…mat (#25)

* docs(mcp): steer toward calibrated defaults; document hash prefix format

Tool descriptions now lead with explicit default-steering language so
agents don't over-specify `kind` (tilth_search) or `mode` (tilth_read)
on calls where the merged / auto-sized default is strictly better. The
canonical examples now omit those params so the doc walks the talk.

tilth_read also gains a concrete output-format paragraph documenting
the `<line>:<3-hex-hash>|<content>` prefix with a worked example and
its role as the drift-safe anchor for tilth_write hash-mode edits.
tilth_search.expand cross-references the same format.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(mcp): clarify hash anchor format and kind tradeoff per Copilot

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Adds `insta` as a dev-dependency and snapshots each of the six tool
descriptions produced by `tool_definitions(true)`. Snapshots live under
`src/mcp/tools/snapshots/` and are committed to git, so any change to
`prompts/tools/*.md` (BOM, CRLF, prose edits, accidental truncation)
shows up as a `.snap` diff in PR review. Use `cargo insta review` to
accept intentional changes.

Also tightens the module doc on `src/mcp/tools/definitions.rs` — calls
out `include_str!` as compile-time embedding and `.trim_end()` as a
per-call step, replacing the ambiguous "stripped at load time" wording.
* feat(mcp): act on 27d production feedback — six surgical fixes

Six fixes from 27d production usage data:

- F1 structured JSON cache-token header: replace the prose `Results as of
  <ts>` prefix with a single-line `{"if_modified_since": "<ts>"}` JSON
  object so agents can pattern-match the field instead of parsing prose.
  `if_modified_since` round-trip was 0 / 2,042 in the window.
- F2 per-tool batching directives: prepend the `ALWAYS group … ONE
  tilth_<tool> call … Never call twice in a row.` shape to search.md,
  read.md, list.md, mirroring write.md. Back-to-back same-session call
  rates were search 83% / read 72% / edit 60%.
- F3 empty-result differentiation: emit Files matched glob / Files
  searched / Content hits counts plus a kind-aware hint when total is
  zero, so agents can tell glob misses from content misses from symbol
  misses. 38% of searches returned zero matches with no diagnostic.
- F5 drop dead `context` field: 0 / 498 uses. Schema, prompt sentence,
  args parsing, all-internal plumbing, and the rank::context_proximity
  branch removed.
- F6 anchor counter-example in write.md: WRONG/RIGHT block covering the
  three production failure modes (body included, bare line, trailing
  pipe). 10+ of 27 edit errors were bad anchors.
- F7 hash-mismatch auto-fix audit: new integration test
  `tool_write_auto_fix_shift_returns_fresh_region_not_relocation`
  documents that `probe_one_auto_fix` cannot recover the original body
  when the file has shifted — `capture_hash_original` reads from the
  current file at the agent's claimed line, so the 12-bit hash alone
  cannot reconstruct a relocated body. Tighten the prompt to advertise
  only the stale-hash-same-line case the implementation actually
  supports, matching the 0 / 257 `auto-fixed:` production rate.

Quality gates: cargo fmt --check, cargo clippy -- -D warnings, and
cargo test (453 passed) all green. AGENTS.md regenerated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(mcp): press hardening — 4 integration tests for F1/F3/F5

Lock the cooked contracts at the tool_search seam:

- F1 first-line is parseable one-line JSON cache token
- F3 zero-match emits the new empty header with the per-kind hint
- F3 glob-zero overrides the kind hint with the glob-mismatch line
- F5 stray `context` field is tolerated silently

cargo test: 457 passed (3 suites). No defects exposed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(search): cure pass 1 — dedup walker stat filter; lift empty-stats; use serde_json

Three medium-stake findings from /age applied in /cure --auto:

- efficiency: lift count_files_for_empty out of multi_symbol_inner's
  per-query loop. A 5-query batch where every query is empty now walks
  the scope once instead of five times. Lazy `Option` initialised on
  first empty query, reused for the rest.
- complexity: extract passes_stat_filter() + MAX_SEARCH_FILE_SIZE at
  the search module root so count_files_for_empty and content::search
  consult one rule for the minified-by-name + size cap. The
  byte-content minified detector stays inline in content::search since
  it requires file bytes; the asymmetry is now a documented invariant.
- nih: with_header builds the JSON cache-token line via serde_json so
  the encoder owns escaping. The current iso_ts format is constrained
  to YYYY-MM-DDTHH:MM:SSZ but a future widening would have silently
  broken the manual writer.

cargo test: 457 passed (3 suites). clippy + fmt clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(cheese): age pass 2 — auto chain clean

Cure pass 1 closed all three medium-stake findings (efficiency, complexity,
nih) without introducing new ones. No high- or medium-stake regressions
surface in the second age pass. The chain stops here; the second cure
pass is not needed.

cargo test: 453 lib / 457 across 3 suites. clippy + fmt clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: drop .cheese/* phase artifacts from PR

These are per-PR cheese pipeline reports (spec, cook, press, age, cure)
that should not land in the repo. The two pre-existing specs in
.cheese/specs/ from prior PRs are untouched.

* fix(mcp): address Copilot review on PR #27

Three review comments from copilot-pull-request-reviewer:

- src/mcp/tools/search.rs:213 — F5 dead-context cleanup completed.
  `search_callers_expanded` and `rank_callers` no longer carry the
  `context: Option<&Path>` parameter; the dead branch in `rank_callers`
  is gone. Both call sites in `tool_search_single` and
  `search_merged_default`, plus `lib::run_callers`, drop the hardcoded
  `None`.

- src/format.rs:84 — split the shared `EmptyHint::Content | Regex`
  hint into two arms. Content now says "no content matches" (literal
  was misleading agents into thinking they had submitted a regex);
  Regex keeps "regex matched zero content".

- snapshots — regenerated `tilth_search`, `tilth_read`, `tilth_list`,
  `tilth_write` via `cargo insta accept`. The search snapshot drift
  caused the CI `check` job failure on the prior commit.

Tests updated: `format::tests::empty_header_content_branch` and
`mcp::tests::tool_search_zero_matches_emits_empty_header_with_kind_hint`
assert on the new Content wording.

A fourth comment (count_files_for_empty perf concern) is replied to
on the thread — the walker pass is stat-only on the empty-results
branch and threading counts through would touch 4 search functions;
deferring to a follow-up if benchmarks show the cost.

Quality gates: cargo fmt --check, cargo clippy -- -D warnings,
cargo test (458 passed) all green.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…#28)

When `diff: true` reveals an additional edit, models tend to fire a
second tilth_write immediately. Steer them to collect every visible
follow-up and issue one grouped call, optionally after a verification
step, matching the batching discipline the other tool prompts enforce.

Co-authored-by: Claude <noreply@anthropic.com>
…als (#29)

* feat(read): mode=stripped + view-meta header for adaptive output signals

Adds two related capabilities to tilth_read:

1. mode=stripped — whole-file read with plain comments, debug logs, and
   consecutive blank lines removed via the existing strip_noise heuristic
   (same pass used by tilth_search match expansion). Doc comments and
   TODO/FIXME markers are preserved. Lines render with original 1-indexed
   numbers in a left gutter so the agent sees both surviving content and
   which line numbers were dropped. Hashlines are intentionally suppressed
   even in edit mode — stripped output is non-contiguous with the file on
   disk and cannot round-trip through tilth_write. Suffix-takes-priority
   rule applies (path#range wins; mode flag is dropped). Non-code files
   fall through to the default view, consistent with mode=signature.

2. View-meta JSON header — generalizes PR #27's if_modified_since cache
   token into a single first-line JSON object that carries any structured
   signals at once. Fields: view, original_line_count, next_view,
   lines_stripped, truncated, truncated_at_line. Emitted only when the
   response is anything less than full content (mode=auto on a small file
   stays bare-body). Implicit promotion (auto + large) advertises
   next_view: "full"; explicit shape requests omit next_view since the
   LLM picked the view on purpose. Budget-clipped reads merge truncation
   fields into the same header so the host parses one line for "showing
   1–N of M lines" rendering.

Plumbing:
- src/budget.rs: apply_with_info(text, budget) returns
  (String, Option<TruncationInfo>) carrying at_line + original_line_count.
  apply() becomes a thin wrapper.
- src/mcp/iso.rs: with_meta_header(now, meta, body) merges optional cache
  token with optional view-meta into one JSON line. with_header is now a
  thin wrapper for the timestamp-only case.
- src/mcp/tools/read.rs: read_signature_file and read_stripped_file
  surface line counts so the dispatcher builds view-meta without
  re-reading. New finalize_response helper applies budget then merges
  truncation fields into the existing meta object.
- src/types.rs: ViewMode::Stripped variant + Display.

Tests: 10 new (475 total, +10 from 465). Coverage: stripped behavior on
code, gutter line numbers, edit-mode hashline suppression, suffix-wins
rule, error message lists new mode, auto-signature view-meta with
next_view, explicit signature without next_view, small-file no-meta path,
auto-outline markdown, budget truncation meta fields.

Quality gates: cargo fmt --check, cargo clippy -- -D warnings,
cargo test (475 passed, 3 suites) all green. AGENTS.md regenerated;
tilth_read insta snapshot accepted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(read): address Copilot review + age findings on view-meta path

Copilot review fixes:
- budget.rs: UTF-8 panic on emoji-heavy single-line content (cut_point
  falling back to max_bytes past safe_max char boundary)
- count_lines: drop UTF-8 String allocation, byte-scan with memchr
- finalize_response: subtract JSON header tokens from body budget so
  the rendered response stays under the user-requested cap
- read.rs: mode=stripped no longer advertises next_view (explicit shape
  request, matches mode=signature contract)
- prompts/tools/read.md: call out that stripped uses a plain gutter,
  no hash anchors, no round-trip through tilth_write

Age findings:
- read.rs FromLine: replace fs::read_to_string + lines().count() with
  count_lines helper (same UTF-8/alloc bug class as the count_lines fix)
- tool_read: extract respond_signature / respond_stripped to shrink
  the dispatch surface
- iso.rs with_meta_header: take Map<String, Value> directly, drop the
  defensive non-object branch (type system now enforces it)
- budget.rs: tighten apply_with_info / TruncationInfo to pub(crate)
- finalize_response docstring: document the two-layer budget reserve
- mcp/mod.rs: add regression test asserting rendered response stays
  under the requested budget

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The rfind("\n\n") logic was matching position 0 (the header/body separator)
for code-rendered output, causing clean_body to be empty. Filter out position
0 so truncation lands in the body content. Add test verifying code lines
survive tight budget constraints.

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
* feat(read): batch tilth_read surfaces missing paths in not-found section

Spec item 1 (HIGH). A batch read with paths like
["real.py", "test_name_function"] previously returned only real.py with
the missing entry as an inline `# path — error: not found:` block that
read like a regular file header. Now the inline multi-file loop tracks
unresolved paths and appends a trailing `── not found ──` section
listing each missing entry in input order. The whole call still
returns Ok per spec ("Don't error the whole call").

Tests cover mixed success, all-missing, ordering + valid-before-section
positioning, and the single-path boundary (still Err by design).

* docs(mcp): clarify tilth_diff/write/search tool descriptions

Spec items 3-6 (MEDIUM/LOW), doc-only. No runtime behavior change.
Per CLAUDE.md: top-weight new guidance, prefer DO NOT over IMPORTANT
for weaker models.

- tilth_diff (item 3): distinguish scope: (filter to file path) from
  search: (filter hunk text). Models were calling search: expecting
  symbol-name filtering and getting empty results.
- tilth_write (item 4): promote diff:true to the first example,
  showing the `── diff ──` output shape. Eliminates a follow-up read
  (~1-2 turns / ~$0.10 per task) when the model knows to ask for it.
- tilth_search (items 5, 6a): kind=symbol takes bare identifiers
  ("Abort"), not full signatures; comma-split tokenisation only
  applies to kind=symbol — multi-query content/regex must use the
  queries:[] array form.

Insta snapshots updated to match. AGENTS.md regen is a no-op
(prompts/mcp-{base,edit}.md untouched).

* fix(read): symbol-miss routes to ── not found ──, drop dead read_batch

Multi-file batch reads had two asymmetric "not found" code paths:
the file-existence check used the ── not found ── footer, while a
`#symbol` suffix that didn't resolve surfaced an inline error string
in the content stream. Pre-resolve symbol suffixes so misses land in
the same footer as missing files, using the qualified `<path>#<symbol>`
form so both classes of miss are visible in one place.

Same pass: remove the unreachable `read_batch` fallback (the wrapping
OR-chain was tautological — `mode_str == "auto"` is the default and
every other mode value also triggers the per-path branch). Delete
the now-orphaned rayon and Session imports from src/read/mod.rs.

Schema: add a `description` on tilth_write's `files` field so the
shape ("array of {path, mode, edits |content}") shows up in tools/list,
not just in the runtime missing-parameter error.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* perf(read): parallelize multi-file batch via rayon

The multi-file branch of tilth_read was sequential per-path. Other
batch MCP layers (tilth_write apply_batch, tilth_search defs/usages)
already use rayon for parallel work; bring read into parity.

Workers are pure except for Session.record_read (atomic + Mutex
internally) and OutlineCache access (DashMap), both Sync. Outcomes
collect through a PerPath enum so par_iter().collect() preserves
input order before partitioning into content vs ── not found ──.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(read): only true symbol misses route to ── not found ──

resolve_symbol_range collapsed three None cases into one: I/O failure,
non-code file, and genuine symbol-absent. The new pre-check in the
multi-file batch routed all three to the ── not found ── footer,
misclassifying I/O and file-type errors as "you typed the wrong
symbol name."

Split the lookup into a SymbolLookup{Found, Missing, PreconditionFailed}
enum. Only Missing (file parsed cleanly, symbol absent) routes to the
footer; PreconditionFailed falls through to the existing inline error
path. Keep resolve_symbol_range as a back-compat shim for the
single-file read_single_with_suffix path.

Add boundary test: a `#symbol` target on a non-code file must not
appear in the not-found footer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
paulnsorensen and others added 27 commits June 28, 2026 23:47
* feat: MCP instructions — required params, symbol/callers steer, shell routing (#86 #87 #88)

- Add per-verb required-array line (tilth_read → paths, tilth_write → files, tilth_list → patterns, tilth_search → queries)
- Add absolute-root rule for relative scopes
- Steer tilth_search toward kind:symbol/kind:callers for code navigation with concrete example
- Strengthen shell file-IO routing from 'prefer' to DO NOT framing; add sed -n/head/tail forms
- Regenerate AGENTS.md from prompts/mcp-base.md

Issues #86 #87 #88

* feat: tilth_write hash mode seeds new/empty files (#93)

- Allow single line-1 anchored edit on missing or 0-line files to create/seed them
- Add is_seed_insert() guard and seed_file() helper in src/edit.rs
- Preserve hash-anchor contract for existing non-empty files
- Add regression tests: seed_nonexistent_file_creates_it, seed_empty_file_fills_it, nonexistent_file_non_seed_edit_still_errors
- Update existing tests that relied on line-1 NotFound to use line-2 anchor
- Add end-to-end hash_mode_seeds_nonexistent_file_via_tool_write test

Issue #93

* fix(mcp): sync byte-lock baselines with updated mcp-base.md prompt

The prompt edits in this PR grew SERVER_INSTRUCTIONS from 5460 to 6107
bytes but left the byte-lock test baselines in src/mcp/mod.rs unchanged,
turning CI red. Update the three drifted assertions:
- SERVER_INSTRUCTIONS.len(): 5460 -> 6107
- build_instructions(false, "").len(): 5460 -> 6107
- edit.len(): 8366 -> 9013 (6107 + 2906 EDIT_MODE_EXTRA)

AGENTS.md already in lockstep (regen produces no diff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#96)

* feat(edit): port whole-file-tag hashline subsystem (PR1: core + tests)

Add src/edit/ subsystem — a faithful Rust port of oh-my-pi's whole-file-tag
edit model: tag, parser, block, apply, snapshots, recovery, mismatch. Adds
twox-hash, diffy, clru. Not yet wired to the MCP; unreferenced surface is
#[allow(dead_code)] so clippy -D warnings stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(edit): harden whole-file-tag subsystem (press pass)

Add end-to-end integration tests across the real parse_sections → apply_ops
→ SnapshotStore → try_recover pipeline (happy path, external-drift recovery,
fabricated-tag rejection), boundary coverage for apply (empty/single-line
files, first/last line, adjacent vs shared-line ranges, CRLF), snapshot
per-file-cap and 4-version-ring exact boundaries, and strengthen weak
existence/ceiling assertions to value/eviction checks. No behaviour changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(edit): apply age findings — payload sigil fidelity, apply ordering, canonical keys (cure pass 1)

- apply: order-independent boundary insert+range (ranged splice before same-idx insert)
- parser: track per-row bare flag; gutter-strip only non-+-authored rows + numeric-keyed-dict guard
- recovery: derive store key via normalize_path_key (single canonical-key owner)
- add gated_apply composed egress (seenLines gate -> apply); narrow apply_ops/lower_ops/anchor_lines/apply_line_ops
- parse_op_header: extract block_payload_anchor / ins_cursor helpers
- lower_ops: resolve outline once per call for block ops
- thiserror derives for ApplyError/ParseError/MismatchError (messages byte-identical)
- SnapshotStore::head_tag avoids full snapshot clone; Cow rows in apply_line_ops
- pin error-field assertions; add same-content re-record + canonical-key + gated-apply tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(edit): apply PR review findings — gate error fidelity, collision-safe snapshot dedup

- check_seen_lines: skip the gate when lower_ops fails so apply_ops surfaces
  the real ApplyError instead of a bogus UnseenAnchor{line:0}
- SnapshotStore::record: dedup on tag AND text so a 16-bit tag collision
  between different contents stores both versions instead of keeping stale text
- document the +-prefix rule for header/op-shaped bare payload rows
- add # Errors rustdoc sections; drop a test section divider
- proving tests for the gate-skip and collision-dedup behaviours

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nalyzer-ssr) (#97)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(mcp): swap tilth_write/tilth_read to whole-file-tag model (PR2: MCP wiring)

Rewrite the tilth_write schema/dispatch to the op-grammar blob, switch
edit-mode tilth_read emission to [path#TAG] + numbered lines recording
snapshot + seenLines, wire SnapshotStore into session state keyed by
canonical realpath, dispatch through gated_apply/try_recover with MV/section
path confinement, hoist find_entry_by_name to lang/outline, map MismatchError
into TilthError, and update the byte-lock tests + regenerated AGENTS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): don't record seenLines for outline edit-mode reads

An outlined view (large file, mode:auto, no suffix) emits no [path#TAG]
and no numbered lines, yet both the single- and multi-path read handlers
recorded SeenSpec::Whole. The snapshot store merges seen lines for
identical content, so a later range read of the unchanged file inherited
the all-lines set — defeating the unseen-anchor gate. Record nothing when
the emitted view is an outline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): harden whole-file-tag MCP wiring (press pass)

Add 5 tests over the tool_read→tool_write seam: multi-section batch,
duplicate-path dedup, symbol-span seen-lines gate (find_entry_by_name
hoist parity), signature-records-nothing, and no-trailing-newline
round-trip. No defects exposed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): apply age findings — drift-branch gates, record-path unification, diffy diff (cure pass 1)

Drift-branch rework: enforce the seen-lines provenance gate on drift, carry
pure REM/MV file ops through content drift, and derive the file op via the
canonical FileOp::from_ops guard (shared with lower_ops) so the drift path
rejects the same op combinations the matched/tagless paths do. Route a 16-bit
tag collision (recorded snapshot text != live) through recovery instead of
silently overwriting live drift.

Read record-path: one should_record_edit_snapshot predicate for all three
dispatch sites; read_single_with_suffix now returns the SeenSpec it resolved so
symbol/heading spans are computed once (no double parse); markdown #heading
reads record the heading span, not whole-file seen-lines.

Also: Session narrow snapshot methods, SectionCtx bundle, diffy real diff for
diff:true, too-large-to-tag note on the write None arm, canonical seen_paths
dedup key, ByteScale weighs seen_lines, read_ranges doc-comment restored, and
strengthened MV/REM assertions plus an AGENTS.md-vs-prompts sync test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): reject fabricated-tag REM/MV; resolve heading span once (cure pass 2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): apply PR #98 review findings (affinage cure)

- Document that absolute tilth_write section paths are confined to root
  (or the server's startup dir when root is omitted) — schema description,
  prompts/mcp-edit.md, and regenerated AGENTS.md kept in lockstep; byte-lock
  tests updated for the intentional prompt edit.
- Skip the full-file read + seen materialization in record_edit_snapshot
  for files over DEFAULT_PER_FILE_CAP, whose snapshot record() drops anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#99)

* refactor(edit): delete per-line hash model (PR3: whole-file-tag only)

The whole-file-tag edit model (edit/tag,parser,block,apply,recovery,
snapshots,mismatch), wired into the MCP in PR2, is now the only edit model.

Removed:
- format.rs: line_hash, hashlines, parse_anchor
- edit/mod.rs: Edit, FileEditTask, EditDiff, EditResult, apply_edits,
  is_seed_insert, seed_file, format_diffs, detect_duplicate_paths,
  apply_batch, BatchOutcome, apply_one, render_applied, per-line tests
- mcp/write.rs: whole file (overwrite/append/auto-fix helpers)
- mcp/tools/write.rs: HashOriginal, capture_hash_original,
  reapply_at_relocation, probe_one_auto_fix, append_per_file_auto_fix,
  parse_file_edit, parse_overwrite_flag, parse_edit_entry, find_git_root,
  cross_worktree_warning, resolved_display
- search/blast.rs + edit_parse_check.rs: orphaned per-line Edit consumers

Switched signature/stripped reads and search-expand edit-mode output off
per-line hashlines to the whole-file-tag N:content numbered format; updated
the corresponding mcp/mod.rs assertions and CLAUDE.md structure listing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): harden whole-file-tag-only surface (press pass)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: fix stale per-line-model references (cure pass 1)

Sweep the pre-PR2 per-line hash model out of the docs now that
whole-file-tag is the sole read/edit format:

- CLAUDE.md: correct the edit/block.rs description (anchor->span
  resolver, not the op model).
- README.md: rewrite the Edit mode section for the tilth_write
  op-grammar and [path#TAG] numbered-line reads.
- src/main.rs, src/mcp/mod.rs: hashline/tilth_edit -> whole-file-tag
  /tilth_write in help strings and rustdoc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(mcp): fix per-line-hash references orphaned by PR3 deletion (affinage cure)

tilth_read's tool description and SERVER_INSTRUCTIONS still described the
deleted <line>:<hash>|<content> output and start/end write anchors. Rewrite
both to the whole-file-tag model (N:content lines, [path#TAG] header),
regenerate AGENTS.md, update byte-lock literals, fix README intro line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(search): fall back to N: prefix parse when gutter parse fails (Copilot review)

In edit mode a content line containing a literal │ took the gutter branch
of filter_code_lines' line-number extraction and failed with no fallback,
so skip-listed lines weren't stripped. Chain the N: prefix parse as
fallback; regression test covers │-in-content. Also reword the
edit/mod.rs provenance comment to whole-file-tag wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…loration.py (#101)

compare_versions.py hardcodes Feb-session result paths in main() and ignores
CLI args, making it unusable as documented. analyze_exploration.py has no
importers and no __main__ entry point, so none of its functions are reachable.
Repoint the CLAUDE.md and benchmark/README.md doc lines at paired.py.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…r wiki page (#100)

* fix(bench): harden fixture recovery and install express dev deps

Two failure modes surfaced by an A/B/C benchmark session:

- reset.py: a no-git task rep can leave an impostor .git next to
  .git_hidden (observed twice: once a partial dir, once a fresh
  sha256-object-format repo), which the old "restore only if .git is
  absent" check never recovered from, crashing every later reset.
  .git_hidden is only ever created by hide_git, so treat it as
  authoritative: delete any co-existing .git and restore it.

- setup_repos.py: express task test_commands run `npx mocha`, which in
  a bare checkout hangs on an interactive install prompt until the rep
  times out (5/5 timeouts observed). Install dev deps after clone and on
  the already-pinned path so existing checkouts heal. A nonexistent
  SSL_CERT_FILE is dropped from the install env (it poisons node's CA
  store; same behavior as uv).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(wiki): add edit-anchor design page (per-line hash vs whole-file tag)

Captures the rationale for the whole-file-tag edit model swap
(PRs #96/#98/#99): the ~2.78 tok/line (+24.6%) per-line hashline read
tax, the FNV low-bit truncation bug, false-accept floors by hash width,
and the localized-staleness / snapshot-recovery trade-off. Cited by the
2026-07-03 A/B/C benchmark report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bench): address PR #100 review findings

- reset.py: extract shared restore_git(); handle impostor .git that is a
  file or symlink (unlink) vs directory (rmtree)
- base.py: use restore_git() so check_correctness gets the same
  authoritative .git_hidden restore as ensure_repo_clean
- setup_repos.py: print npm stderr on install failure; skip npm install
  when node_modules already exists
- wiki: reframe edit-anchor-design page as historical (whole-file-tag
  model has since shipped, PRs #96-#99); label source referents as
  pre-migration; update index entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…et alloc, Bash grammar, tilth_savings) (#102)

* feat(read): never-worse outline gate (#146)

(cherry picked from commit c4803af)

* feat(grok): auto-expand thin wrappers (#149)

(cherry picked from commit 3f40056)

* fix(grok): size-cap thin-wrapper delegate reads; harden the predicate

The cross-file delegate read in thin-wrapper auto-expansion called
fs::read_to_string with no size bound, so a wrapper resolving to a huge
or generated callee file would slurp the whole thing into memory. Route
the read through a new `read_delegate_content` helper that reuses the
already-loaded target content for same-file callees and caps cross-file
reads at the shared source-file bound (bloom_walk::MAX_FILE_SIZE). The
cap is a parameter, so it is unit-tested with a tiny fixture instead of
a 500KB one.

Also harden the predicate:
- Capture the sole internal callee pre-cap (`lone_internal_callee`) so
  expansion is correct regardless of `caps.max_callees`, replacing the
  post-cap `callees_internal.first()` and its misleading "safety" note.
- Add a `start_line > 0` guard so a degenerate/unresolved target cannot
  produce an empty delegate body, mirroring `slice_body`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9df5120)

* ci(release): dual-publish npm wrapper as @plotplot/tilth, best-effort (#144)

(cherry picked from commit e9155c1)

* feat(search): query-aware truncation (#147)

(cherry picked from commit 76abb65)

* feat(search): value-based budget allocation (rate-distortion) (#150)

(cherry picked from commit 66ae97e)

* fix(sync): dedup read_delegate_content after grok cherry-picks

The 9df5120 size-cap pick applied cleanly but produced a duplicate
definition of read_delegate_content alongside 3f40056's copy. Remove
the redundant second definition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(lang): add Bash tree-sitter support (#151)

(cherry picked from commit bc2d7e8)

* fix(sync): thread edit_mode into format_matches test call site

The value-based budget pick (66ae97e) added a segments-collecting test
that calls format_matches, but our fork's format_matches also takes an
edit_mode arg. Pass false at that call site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): runtime token-savings tool (tilth_savings) (#148)

(cherry picked from commit 63553eb)

* feat(mcp): add readOnlyHint annotations to tool definitions

Read-only MCP clients (e.g. Claude Code plan mode) gate any tool that does not declare readOnlyHint, prompting on every call. Mark the read tools read-only and tilth_write as not.

(cherry picked from commit 8196d27)

* fix(callers): 2nd-hop analysis + scaled budget + unified headers for multi-target

Addresses 3 unresolved PR review findings on the comma-separated
multi-target callers search:

- HIGH: the multi-target path omitted the single-target path's
  "Adaptive 2nd-hop impact analysis" entirely, so callers("a,b")
  returned strictly less than two separate callers("a") /
  callers("b") calls. Extracted the block into write_second_hop_impact
  (previously inline in search_callers_expanded) so both paths call
  the same code per target bucket.
- MED: BATCH_EARLY_QUIT (a walk-wide raw-match budget shared across
  every target in the batch walk) was not scaled by target count, so
  an earlier hit-rich target could exhaust the walk before a later,
  rarer target's files were visited. Added scaled_batch_quit, a pure
  function multiplying the single-target budget by target count
  (bounded — dispatch already caps multi-target queries at 5). The
  early-quit mechanism itself is a coarse heuristic and a candidate
  for future removal/replacement; this is a minimal parity fix, not
  an investment in it.
- MED: header format diverged between single (`# Callers of "foo" in
  <scope> — N call site(s)` / `## path:line`) and multi (`## callers
  of "foo"` / `### path:line`) purely on comma-presence. Extracted
  write_caller_bucket so both paths render the identical shape; a
  bucket inside a multi-target query is now byte-identical to what a
  lone single-target search of that symbol would produce.

Both extractions move existing logic verbatim rather than
reimplementing it — search_callers_expanded's behavior is unchanged
except for the mechanical format!->writeln! swap clippy requires when
appending to a shared &mut String instead of constructing the first
String.

Tests: scaled_batch_quit_multiplies_by_target_count,
scaled_batch_quit_treats_zero_targets_as_one (direct unit tests on
the pure scaling function — deterministic, unlike asserting on the
parallel walker's actual starvation behavior),
callers_multi_target_includes_second_hop_impact_per_bucket,
callers_multi_target_header_matches_single_target_shape,
callers_multi_target_later_target_not_starved_by_hit_rich_earlier_target
(scenario-level guard), plus two pre-existing tests updated for the
corrected header casing (Callers vs callers).

(cherry picked from commit 8e6c1bd)

* fix(benchmark): correct three ground-truth strings per grading audit

trustedCIDRs -> isTrustedProxy in gin_client_ip: the field is real but
sits one call-depth past ClientIP()'s own body; isTrustedProxy is the
method ClientIP() actually calls, same signal, zero-hop-deeper. Drop
view.js from express_render_chain and express_app_render: the file is
imported via require('./view') (Node's extensionless convention) and
never appears as literal text in source, so the string was unwinnable
regardless of answer quality.

Applied exactly as proposed in docs/research/grading-audit-2026-07.md
§6 — verified against the pinned commit via git grep, cross-checked by
an independent manual read, zero flips on the three flagship hard
tasks. rg_search_dispatch/glue.rs deliberately left untouched: same
bug class, but flagged as a maintainer decision, not auto-applied,
since its failures are genuine navigation misses, not phrasing gaps.

(cherry picked from commit f195bb9)

* fix(search): suppress outline preview line when a fence will reprint it

format_single_match always printed the raw "→ [line] text" preview
before checking whether expansion would run. expand_match's fence
range always contains m.line (def_range starts at m.line for
definitions; the ±10 fallback for def_range: None / usages trivially
contains it too), so whenever expansion actually fires the preview
line is a byte-identical duplicate of a line inside the fence below.

Repro: searching a <50-line file for a definition with no doc comment
(e.g. TilthError::exit_code) printed the matched line twice — once as
the "→ [31] pub fn exit_code(&self) -> i32 {" preview, once again as
line 31 inside the whole-file expansion fence.

Hoists the dedup/skip decision (previously computed after the
preview) into a `fence_will_follow` predicate checked before writing
either raw preview line (small-file branch and the no-outline
fallback). The structural outline_context branch is untouched — it
renders neighboring entries' signatures, not the matched line's own
source, so it doesn't duplicate the fence.

(cherry picked from commit d791449)

* fix(search): thread caller budget into fit_to_budget — value-based truncation now engages below 24k

Both fit_to_budget call sites (search/mod.rs format_search_result and
search_multi_symbol_expanded) hardcoded crate::budget::DEFAULT_BUDGET
(24,000) instead of the caller's real budget, so value-based match
selection only ever activated when the unconstrained render exceeded
24k tokens — every realistic caller budget silently fell through to
budget::apply's positional tail-cut instead (docs/research/tilth-audit-2026-07-logic.md §3).

Threads budget: Option<u64> through the four *_expanded search
functions and format_search_result, defaulting to DEFAULT_BUDGET via
unwrap_or when None so the no-budget path stays byte-identical. Wires
the already-extracted real budget at both call sites that have one:
MCP's tool_search (mcp/tools/search.rs) and the CLI's run_inner
(lib.rs), whose outer budget::apply pass is now a no-op once
fit_to_budget already selected within the same real budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 2f596b0)

* refactor(map): share the gitignore/hidden/skip-dirs walker policy with search

map.rs built its own inline WalkBuilder duplicating search::walker()'s
ignore/hidden/git_* flags and SKIP_DIRS filter_entry — but silently
missing same_file_system(true) (stop at NFS/external mount
boundaries), a guard every other walker in this codebase carries
(search::walker, search::find_basename_fallback).

Diffed the two policies: the ignore/hidden/gitignore flags were
already identical between map and search (no deliberate divergence to
document there). The only structural differences are max_depth
(map-specific, bounds the tree to the CLI depth arg) and sequential
vs parallel execution (map incrementally builds a BTreeMap per-entry;
search fans out across threads) — both stay as-is, since collapsing
map onto the parallel model would be a control-flow rewrite, not a
policy fix.

Extracted the shared flags into search::base_walk_builder(scope) ->
WalkBuilder. walker() adds .threads()+overrides+.build_parallel();
map::generate adds .max_depth()+.build(). map now inherits
same_file_system(true) for free.

(cherry picked from commit 21c4faf)

* test: add direct unit coverage for treesitter weight/name extraction and sibling resolution

lang/treesitter.rs::definition_weight and ::extract_definition_name
had zero direct tests — only exercised transitively through
search/symbol.rs's larger integration tests. Added:

- definition_weight_covers_every_tier: one assertion per weight tier
  (100/90/80/70/60/40/30/default-50), spot-checking a node kind from
  each source language shape the match arms group together.
- Five extract_definition_name cases against real parsed nodes (Rust
  function_item, Python class_definition, TS export_statement
  unwrapping, TS lexical_declaration's variable_declarator walk, and
  the None fallthrough for impl_item, which has no name/identifier/
  declarator field and isn't handled by any special-cased branch).

search/siblings.rs had no test module at all. Added 8 tests for the
pure resolution logic named in the brief (resolve_siblings,
find_parent_entry) — signature-copy, signature-fallback-to-name,
unmatched-name skip, function-before-field ordering, alphabetical
tiebreak, MAX_SIBLINGS truncation, and parent-lookup hit/miss. Scoped
to resolution only, not the tree-sitter query extraction
(extract_sibling_references) — that's a much larger, per-language
surface the brief didn't name.

Verified both suites have teeth: mutated definition_weight's
impl_item/object_declaration arm (90->91) and resolve_siblings'
sort comparator (b_is_fn.cmp(&a_is_fn) -> a_is_fn.cmp(&b_is_fn)),
confirmed each mutation failed its respective test, then reverted.

(cherry picked from commit be6195d)

* fix(sync): resolve clippy findings from upstream ports

Apply clippy's suggested rewrites for uninlined_format_args,
format_push_string (writeln! to avoid write_with_newline on the
trailing newline), format_collect (fold), and doc_markdown backticks.
Delete unused #[cfg(test)] Session::reads_count (leftover from the
tilth_savings port; not present or used upstream).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): derive read view-meta from actual output, not would_outline prediction

The never-worse outline gate (OGATE, from upstream c4803af) can return
full content for a file that would_outline predicts as outlined, which
left the meta header claiming view:"outline" over a [full] body and
prompted a pointless re-read. Key the meta off the emitted view marker.

Also rebuild the large-structured-read test fixture with values longer
than the outline's 40-char preview cap, so the keys outline actually
compresses and the gate stays off — same fixture adjustment upstream
made to its own alias test in c4803af.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: jahala <jahala@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Martin Wedvich <wedvich@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* chore(format): re-encode box-drawing/arrow chars to ASCII in search output

Re-derives upstream 401816e on the fork's diverged search code: --, |, ->,
> replace the box-drawing/arrow glyphs in caller/search output strings and
the comments that describe them. Also updates the fork-specific gutter tests
(search/mod.rs, mcp/mod.rs) and the edit-mode content-bar fallback test whose
premise the gutter change made stale.

Leaves diff/format.rs rename/move markers untouched (separate taxonomy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump pinned actions and cargo minor/patch group

Ports upstream Dependabot bumps:
- actions/cache 5.0.5 -> 6.1.0 (d322306)
- actions/checkout 6.0.2 -> 7.0.0 (088f58c)
- memmap2 0.9.11 (8fdd477); memchr 2.8.2, regex-syntax 0.8.11 (f868a52);
  ignore 0.4.26, libc 0.2.186, tree-sitter-swift 0.7.3 (bd7444b);
  dashmap 6.2.1, once_cell 1.21.4, serde_json 1.0.150, tree-sitter 0.26.9 (c769da5)

Cargo bumps applied via cargo update --precise (Cargo.lock only). tilth
version stays 0.8.4 (fork law: no version bump).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(format): finish ASCII re-encode of SECRET_REDACTION_NOTICE; fix cache ratchet comment

Taste-test corrective pass on PR #108:
- SECRET_REDACTION_NOTICE (src/search/mod.rs) still emitted a `->`-arrow in
  live search output (fork-only secrets denylist, not covered by upstream
  401816e). Re-encode to `->` to complete the port's ASCII-output goal.
- actions/cache ratchet comment in fuzz.yml was stale-by-major after the
  6.1.0 SHA bump; update `@v5` -> `@v6` so the ratchet comment matches the pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ci): align checkout ratchet hints with pinned v7.0.0 SHA

Copilot review: SHA 9c091bb is actions/checkout v7.0.0 but the ratchet
hints still said @v6; update all 9 hints so pin auditing stays consistent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…three parsers (#109)

* fix(benchmark): accumulate result_text across assistant turns in all parsers

The three transcript parsers overwrote result_text on each assistant turn,
so a short wrap-up turn silently discarded a substantive earlier answer.
Grading reads result_text, so this dropped real answers. Accumulate into a
list and join at RunResult construction, mirroring upstream 60c1cbd, in
parse_stream_json, parse_codex_json, and parse_opencode_json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(benchmark): pin grading semantics — substring over all assistant turns

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(benchmark): cover result_text accumulation and required_matches

Port upstream test_parse.py and extend to parse_codex_json and
parse_opencode_json — each accumulation case fails against the pre-fix
overwrite behavior. Add test_required_matches.py covering plain/alternation
hits and misses, whitespace-stripped alternates, and the empty-alternate
contract (leading/trailing/doubled pipe never an unconditional pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(benchmark): fix stale 'final message text' comment in codex parser

The codex parser now accumulates agent_message text across turns; the
comment still described the pre-fix last-write-wins behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… (upstream 891e617) (#110)

* test(benchmark): swap recycled Next mutation for novel loop-bound flip

Port upstream 891e617. The gin_edit_multi_context Next() mutation was the
recycled `c.index++` -> `c.index += 2` shape, identical to a single-bug edit
task, so the multi-site task could be solved by recalling a prior solution.
Flip the handler-loop bound (`c.index < safeInt8(len(c.handlers))` ->
`... > ...`) instead — a novel shape absent from every single-bug task — so
the Next/Copy pair both require fresh localization. Docstring updated to match.

d65a145 and 88d6dc6 were already present on origin/main (via PR #81, commit
150c07f) atop the PR #100 fixture hardening; only 891e617 remained to port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(bench): correct Next() loop-bound flip comment — no handlers run

With c.index starting at -1, Next() increments to 0 and the flipped
bound (0 > len) is immediately false, so zero handlers execute — the
old wording claimed the chain stops after the first handler. Verified
empirically: TestMiddlewareGeneralCase observes "" under the mutation.

Addresses Copilot review comment on PR #110.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…oved (#111)

* refactor(read): fuzzy path resolution is suggest-only — auto-open removed

Drop the auto-open half of fuzzy path resolution: a slightly-off path now
always errors with the closest real file surfaced as a ranked "did you mean"
suggestion instead of silently opening a file the agent didn't name. A
confidently-wrong substitution costs more than the round-trip a suggestion
adds, and a suggestion can't go un-noticed the way an unrelated file's content
can.

Removes FuzzyHit, log_auto_open, search_auto_open_body, apply_gate's
score-floor/margin gate, and the FuzzyResolution::Resolved branch;
resolve_fuzzy_path always returns the ranked top-K as Suggestions. GateProfile
is retained on the call sites (it no longer drives a decision) for a future
ranking distinction. is_path_like is kept only as the MCP search-miss
pre-check so a normal empty symbol search never walks the tree; the fork's
auto_open_search_miss becomes search_miss_suggestions, degrading the MCP
default-search miss into the same NotFound { suggestion } shape as the other
sites.

Mirrors upstream jahala 01405a5, re-derived on fork code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(read): correct fuzzy-walk docs and guard search_miss_suggestions

- Docs claimed the fuzzy candidate walk is gitignore-pruned, skips hidden
  files, and does not follow symlinks; the walker does the opposite
  (.tilthignore-pruned, includes hidden and gitignored files, follows
  symlinks). Reword to match reality — only path names surface, never
  file contents.
- Move read_file_resolving's doc block onto its function (was attached
  to scope_relative_query).
- Add an is_path_like early-return inside pub search_miss_suggestions so
  the API no longer relies on caller pre-checks; proving test added.

Addresses Copilot review on #111.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(mcp): make tilth_write take a JSON edits array of typed ops

Replace the `[path#TAG]` string op-grammar blob with a JSON-native `edits`
array of {path, tag?, ops} sections. A new wire-level serde enum (JsonOp,
tag-discriminated on `op`, snake_case verbs) in src/edit/json.rs lowers into
the existing grammar-independent Section/Op types, so apply/block/recovery/
snapshots stay untouched. Semantics are identical: 11 verbs, line/#symbol
anchors, tag verification, seen-lines gate, 3-way-merge recovery, duplicate-
path guard, 20-section cap, per-section `## <path>` reporting, tagless
new-file seed.

A string `edits` (legacy blob or double-encoded JSON) is rejected with a
teaching error showing the corrected JSON form; parse_sections is retained
internally, error-path-only, to render the legacy-blob translation.

Rewrites the tool schema (oneOf discriminated on `op`), tool description, and
prompts/mcp-edit.md; regenerates AGENTS.md and re-baselines the byte-lock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): harden tilth_write JSON deserialize boundary

Add three hardening tests through the real tool_write dispatch path:
- u32 out-of-range op field rejected before any file is touched
- unknown op verb rejected, echoing the offending verb
- deserialize failure in a later section leaves an earlier valid
  section's file untouched (all-or-nothing at the deserialize gate;
  best-effort reporting begins only at apply)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(edit): harden JSON ops — trailing-newline strip, schema bounds, deny_unknown_fields

Apply three age findings on the JSON-native tilth_write surface:
- split_content strips a single trailing "" so content ending in "\n"
  no longer splices an extra blank line (matches the old grammar's
  finalize_payload forgiveness).
- integer op schema fields (start/end/line and the integer arm of at)
  gain minimum:0 / maximum:u32::MAX so out-of-range line numbers are
  rejected client-side at the schema boundary.
- JsonOp gains deny_unknown_fields so a stray/typo op field surfaces as
  a named deserialize error instead of being silently dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): tighten tilth_write JSON op schema and cap ordering

Address PR review findings on the JSON-native tilth_write edits array:

- Enforce the 20-section batch cap up front in lower_edits, before any
  section is cloned/deserialized/allocated, and drop the now-redundant
  post-lowering check in write.rs.
- Raise op-schema integer minima from 0 to 1 on start/end/line/at so a
  1-based-invalid 0 is rejected at schema time, matching runtime
  check_bounds; add a boundary test.
- Add additionalProperties:false to every op oneOf branch so the schema
  matches the deserializer's deny_unknown_fields; add a stray-field test.
- Document that the leading # on a block `at` symbol is optional (bare
  names accepted); regenerate AGENTS.md and update byte-lock baselines.
- Bump crate recursion_limit to 256 (the additionalProperties keys push
  the giant json! schema literal past the default macro recursion limit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…shake (#113)

* feat(mcp): require cwd param, trust-absolute posture, drop roots handshake

Rename the optional `root` parameter to a required `cwd` on all seven
path-taking MCP tools (tilth_diff gains it). Relative paths anchor under
cwd with `..` refused; absolute paths are trusted as explicit intent.
A missing cwd refuses with a teaching error. Remove the post-initialize
roots/list one-shot handshake and extract_root_from_response. The cwd
schema description flips on TILTH_MCP_CWD_HOOK_INJECTED, which install
writes per host (1 for claude-code, 0 elsewhere). Prompts and AGENTS.md
regenerated; byte-lock tests updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugin): Claude Code cwd-injection hook

Ship plugin/claude/ — manifest, hooks.json, and inject-cwd.js. The
PreToolUse hook matches mcp__tilth__* and injects the live session cwd
as the cwd parameter when the model did not set one (model wins). Node
test exercises fake PreToolUse events: injects when absent, defers to a
model-set cwd, ignores non-tilth tools, silent with no session cwd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(claude): add fork-law section

Extend the version-bump rule into a Fork law section: version stays
0.8.4; keep-ours fork features (whole-file-tag edit model, cwd anchoring
and trust-absolute posture); never-merge upstream commits 399721c and
10bec56; sync via sync/upstream-<date> branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: reword stale confinement comments to anchoring after posture change

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): harden cwd-refusal coverage for grok, diff, and dispatch seam

Add missing-cwd and relative-cwd refusal tests for tilth_grok and
tilth_diff (the two path-taking tools cook left without a refusal test;
the diff pair also locks its validated-but-unused cwd seam), plus a
dispatch-level test asserting the real JSON-RPC routing refuses a missing
cwd for all 7 advertised tools. No production changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): clear branch clippy doc lints and warn on cwd hook injection

Add missing backticks around `tilth_diff` and `require_cwd` in test doc
comments flagged by `cargo clippy --all-targets`.

Emit a one-line stderr warning at MCP server startup when
`TILTH_MCP_CWD_HOOK_INJECTED=1` so an operator running without the
Claude Code cwd-injection hook installed has a grep-able line. Stdout
stays the clean JSON-RPC channel. Decision extracted into
`hook_injection_warning` with a unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(wiki): update mcp-cwd-root-binding to cwd-param-posture law

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): thread cwd through upstream savings/callers tests; exempt no-path tilth_savings

Upstream sync added tilth_savings (a no-path counter) and savings/callers
multi-target tests written for the old optional-root API. Under the cwd law
those handler calls now refuse without cwd. Pass an absolute cwd in each
incoming test, and extend the schema law test: all 7 path-taking tools
require cwd; the no-path tilth_savings is exempt; root absent on every tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): anchor diff file-path params under cwd; correct tilth_write quick-ref; drop orphaned dep

Apply PR #113 review findings:
- tilth_diff: relative patch/a/b file paths now anchor under the required
  cwd via resolve_anchored (.. refused, absolute unchanged); git-based
  sources still run in the server dir by design. Adds anchoring/refusal tests.
- prompts/mcp-base.md + AGENTS.md (regenerated): quick-reference no longer
  advertises the nonexistent tilth_write files array — it takes a single
  edits op-grammar string. Byte-lock baselines updated (+47 bytes).
- Cargo.toml: drop orphaned percent-encoding dep (consumer deleted with
  the roots handshake).
- install.rs: extract toml_server_section and test the TOML env emission
  of TILTH_MCP_CWD_HOOK_INJECTED parses and carries the flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: gate plugin hook test in CI; document tilth_diff git-source scope

Add a node job running plugin/claude/hooks/*.test.js so a hook
regression can't ship green, and note in the tilth_diff description
that git-based sources diff the server's project directory (only
patch/a/b anchor under cwd). Byte-lock baselines updated for the
prompt change; AGENTS.md regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(mcp): trim redundant per-tool cwd lines; fix stale MV op name

- mcp-base.md: shrink the 5 per-tool `cwd:` lines (search/read/list/deps/grok)
  to `cwd: required (see PATHS)` — they restated the global PATHS anchoring
  rule verbatim. Keep the tilth_diff cwd line (unique git-source semantics).
- mcp-edit.md: drop the trailing "server cannot see your shell cwd" (already in
  base PATHS); correct the stale `MV` op reference to `move_file` to match the
  JSON edits grammar adopted from #116.
- Regenerate AGENTS.md; re-baseline mcp byte-locks (SERVER_INSTRUCTIONS 5687,
  EDIT_MODE_EXTRA 2505, composed 8192).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The per-tool `cwd` schema description already flips on
TILTH_MCP_CWD_HOOK_INJECTED=1 ("do NOT set it"), but the MCP
`instructions` prompt was static and unconditionally told the model to
set `cwd` on every call — contradicting the schema on Claude Code when
the cwd-injection hook is installed.

build_instructions now takes a cwd_injected flag (read from the same env
var via cwd_hook_injected()) and swaps the two cwd-guidance spans — the
PATHS paragraph and the line-7 REQUIRES clause — for hook-aware variants
that tell the model the hook supplies `cwd`. Every per-tool
`cwd: required (see PATHS)` line defers to PATHS, so they inherit the new
meaning automatically.

Substitution happens at build time against a runtime copy; the
SERVER_INSTRUCTIONS const and prompts/mcp-base.md are untouched, so the
byte-lock and AGENTS.md-sync tests stay green. cwd_guidance_spans_present
guards both target spans so a future markdown edit can't silently no-op
the swap.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(install): auto-install cwd-injection hook for claude-code

`tilth install claude-code` now wires the cwd-injection PreToolUse hook
end-to-end, removing the manual "install from plugin/claude/" step:

- embeds plugin/claude/hooks/inject-cwd.js via include_str! and writes it
  to ~/.claude/tilth/inject-cwd.js (self-contained under npm and cargo)
- upserts a hooks.PreToolUse entry (matcher mcp__tilth__.*) into
  ~/.claude/settings.json, distinct from the MCP config in ~/.claude.json

The settings upsert is idempotent (replace-by-matcher, else append) and
preserves unrelated hooks, events, and top-level keys. Default-on for
claude-code; --no-hook opts out and prints the manual-install note. All
other hosts are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(install): reject non-UTF-8 hook script path

install_claude_code_hook used script_path.to_string_lossy(), which
silently substitutes bytes for a non-UTF-8 path and would write a
node command pointing at a nonexistent file. Guard with to_str() and
return an explicit error instead — fail fast rather than emit a hook
that fails silently at runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(install): backtick PreToolUse in test doc-comments

Satisfies clippy::doc_markdown (enabled via #![warn(clippy::pedantic)]
in lib.rs). These live in #[cfg(test)] modules, which CI's
`cargo clippy -- -D warnings` does not lint without --all-targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The tilth_savings tool reported per-session token savings vs a naive
cat/grep, but it was human-facing telemetry sitting in the agent-facing
tool list — the one tilth tool that isn't a grep/cat/find replacement,
and one the agent never needs mid-task. Every tool definition costs
tokens in the list sent to the model.

Remove the tool definition, dispatch arm, module, and file. Keep the
Session::record_savings recording plumbing and the savings() getter
(now #[allow(dead_code)]) so the counters still accumulate for a future
CLI/reporting decision, tracked in paulnsorensen#120.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…122)

* ci: add fork rolling-build channel publishing @paulnsorensen/tilth-nightly

Add a main-triggered workflow that cross-compiles the five release targets,
refreshes a rolling `nightly` GitHub prerelease, and publishes a fork-owned
npm wrapper so `npx @paulnsorensen/tilth-nightly` runs the latest main.

- .github/workflows/nightly.yml: push-to-main + workflow_dispatch triggers;
  prepare-release recreates the `nightly` prerelease, matrix build uploads
  assets via `gh release upload --clobber` (race-free), publish-npm stamps
  0.0.0-experimental.<run_number> at publish time.
- npm-nightly/: self-contained wrapper kept separate from the upstream-synced
  npm/ tree so it never conflicts on an upstream sync. install.js pulls from
  paulnsorensen/tilth's fixed `nightly` release.

Decoupled from Cargo.toml/npm versions (fork law: stays 0.8.4). Scoped +
name-signalled package avoids confusion with the official `tilth`.

Requires NPM_TOKEN repo secret before the first publish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: publish nightly via npm OIDC trusted publishing instead of NPM_TOKEN

Drop the long-lived NPM_TOKEN secret in favour of OIDC trusted publishing:
grant id-token: write on publish-npm, bump Node to 22 (OIDC needs >= 22.14.0)
and upgrade npm past the 11.5.1 floor, remove NODE_AUTH_TOKEN. Provenance is
attached automatically on publish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(nightly): address PR review — upload guard, https-only download, rerun-safe version

- nightly.yml: upload only the artifact that exists per runner (unix→tar.gz,
  windows→zip); listing both failed `gh release upload` on the missing file,
  breaking the workflow on every target.
- install.js: follow() is now https-only and depth-capped (5) so a redirect
  can't downgrade the executed binary download to plaintext or loop; dropped
  the orphaned http require.
- nightly.yml: include run_attempt in the npm version so a workflow re-run
  publishes a unique version instead of 403-ing on a duplicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#123)

The upload loop ran `[ -f "$f" ] && gh release upload …` per extension; on
unix the final iteration tests the absent `.zip`, so the step exited 1 and
skipped publish-npm even though the asset uploaded. Use `if [ -f ]` so the
loop ends 0. Also add `--tag latest` to the npm publish: the stamped version
is a prerelease and npm refuses to publish one without an explicit dist-tag.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- run_git_diff (GitRef) and diff_log reject any ref/range starting with
  '-' before it reaches Command::arg, closing an arg-injection path
  (e.g. source="--output=/path" overwriting an arbitrary file).
- parse_unified_diff now gates the "--- "/"+++ " path-header branches on
  current_hunk.is_none(), so a removed/added line whose own content
  starts with "-- "/"++ " (e.g. deleting a SQL/Lua comment) is kept as a
  hunk line instead of being mistaken for a file header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the hand-rolled TOML table writer (escaped only backslashes, not
quotes) and ~55-line section lexer with a toml_edit-based upsert: parse
the config, upsert [mcp_servers.tilth], serialize format-preserving.
Fixes latent quote-escaping corruption on paths/args containing a quote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ors, iso parsing

- tilth_grok now honors `budget`, applying crate::budget::apply to the
  formatted output instead of ignoring the arg.
- tilth_list surfaces the first invalid glob pattern as an error
  instead of silently dropping it from the matcher set.
- parse_iso_utc accepts fractional seconds (SS.sss) and numeric UTC
  offsets (+HH:MM, -HH:MM, +HHMM, -HHMM) alongside bare Z.
- Byte-lock tests and AGENTS.md regenerated to match the prompt edits
  from the prior pass (edits array wording, tilth_read-only TAG header).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-walk preamble

OutlineCache and BloomFilterCache were unbounded DashMaps that retained
every file touched for the server lifetime; wrap them in count-capped
clru caches (mirroring edit/snapshots.rs) and size bloom filters on
unique idents. Extract the shared file-walk entry filter into
content::accept_walk_entry, replacing the duplicated preamble (and two
hardcoded 500_000 literals) in symbol.rs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e snapshot keys

- apply.rs: Cursor::Tail resolves before the phantom empty split-row on
  newline-terminated files, so append no longer inserts a spurious blank
  line or drops the trailing newline (blocker).
- apply.rs: equal-index zero-width inserts apply in author order, not
  reversed.
- write.rs: move_file rejects an existing different destination instead
  of silently clobbering it.
- read.rs: fuzzy 'did you mean' resolution uses the per-call cwd, not the
  process cwd.
- snapshots.rs/session.rs: SnapshotStore keys normalize once internally
  (impl AsRef<Path>); callers drop redundant normalize_path_key calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…must-use)

Cargo.lock updated for the toml_edit dependency; silence the #[must_use]
lint on the bloom cache-bound test's contains() call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… guard

The fixture used a two-dash removed line (`-- a comment`) which never
matched `strip_prefix("--- ")`, so it passed with or without the
current_hunk gate. Use a genuine three-dash render so a revert of the
guard now fails the test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@paulnsorensen

Copy link
Copy Markdown
Collaborator Author

Opened against the wrong repo — this belongs on the fork. Recreated as paulnsorensen#126.

@paulnsorensen paulnsorensen deleted the fix/src-bug-hunt branch July 9, 2026 03:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants