# Changelog

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.1](https://github.com/drip-cli/drip/compare/v0.1.0...v0.1.1) (2026-05-13)


### Bug Fixes

* ship cross-platform tests + update/help error handling to main ([#12](https://github.com/drip-cli/drip/issues/12)) ([f0ce966](https://github.com/drip-cli/drip/commit/f0ce96621d403161c780591ccef5691ba111e49a))

## [Unreleased]

### Removed

- **Bash interception dropped.** `drip hook claude-bash`, the `PreToolUse:Bash` hook, the pipeline / bare-cmd safe-transform whitelist, the `[DRIP: pipeline unchanged]` sentinel cache, and the bash savings lane in `drip meter` are all gone. In real agent traffic bash usage was overwhelmingly one-shot exploration (a single `head` / `grep` on a file, then never the exact same pipeline again) — the sentinel cache almost never hit, leaving the hook contributing ~0.5 % of total savings against ~3 500 lines of code, a strict whitelist that surprised users when it didn't trigger, and a `DRIP_PIPELINE_*` env-var surface to maintain. `drip init` now drops the legacy `PreToolUse:Bash` registration on upgrade so old settings.json files clean up automatically. The RTK coexistence shim (`drip doctor`'s "DRIP fires first" check, the init-time hook-order rewrite) is also gone — without a Bash hook there's nothing to order ahead of RTK. Pipeline-related tables (`pipeline_results`, `lifetime_pipeline_stats`, `lifetime_pipeline_daily`, `session_pipeline_stats`) are dropped via the schema migration; lifetime totals that previously included bash savings stay where they were (we don't rewrite history), but new traffic flows through the file-read lane only.
- **Cursor support dropped.** `drip init --agent cursor` and the matching uninstall path are gone. Cursor's agent has a built-in `read_file` tool that bypasses MCP — even with our server registered, the agent reaches for its native read by default and DRIP saw only a fraction of the calls. We'd rather support no IDE than advertise a half-working one. The MCP server itself (`drip mcp`) and the wiring for Codex CLI / Gemini CLI are unaffected. Existing `agent='cursor'` rows in `sessions` are preserved (no migration); `drip sessions` now displays them as the raw tag rather than the friendly "Cursor" label.

### Security

- **Post-edit hook honours `.dripignore` before persisting file content.** An Edit / Write / MultiEdit on a `.env` file (or any file matching `.dripignore`'s default secret list — `*.pem`, `*.key`, `id_rsa`, `**/.aws/credentials`, …) used to land the file's content in both the per-session `reads` table and the cross-session `file_registry`, where backup tools could carry it off-host. The Read path already substituted a placeholder for ignored files; the post-edit path now applies the same `Matcher::is_ignored` gate before calling `set_baseline`. Existing users who edited a `.env` (or similar) before this fix should run `drip reset --all` once to wipe any persisted secret content.

### Changed

- **`drip meter` swaps "Since install" → "Since reset" after a `drip reset --all` or `drip reset --stats`.** Pre-fix, the lifetime headline always advertised "Since install: 15h53m" even when the user had just zeroed every counter five minutes earlier — the elapsed duration no longer matched the numbers below it. Both reset paths now stamp `meta('last_reset_at')` (the `meta` table is intentionally preserved by `reset --all`, so the marker survives a full wipe); `drip meter` reads that marker, anchors `elapsed_secs` to it, swaps the "Since install:" row label and the banner title. JSON consumers get a new optional `last_reset_at` field (absent on fresh installs).

### Fixed

- **Re-reads of >100 KB files no longer regress to native after the first compressed delivery.** `process_read_inner`'s `TooLarge` arm unconditionally returned `FullFallback{ LargeFile }`, ignoring prev. The Claude hook then routed that to `allow → native`, and on files past Claude's ~25 k-token limit the harness rejected the read — so a 130 KB Python module DRIP had just substituted as a compressed view became unreadable on the very next read. The arm now honours prev first: a matching hash collapses to the Unchanged sentinel (zero-byte body, full savings credit), same contract as the regular `Text` path. Live numbers: 26 233 tokens saved on a re-read that previously errored. New integration test pins the regression.
- **Deleted files under symlinked parent directories (macOS `/tmp → /private/tmp`, `tempdir()` under `/var/folders`) now emit the `[DRIP: file deleted]` sentinel instead of bailing with "file not found".** Baselines are stored under the canonicalised path (`/private/tmp/x`), but once the file is gone `Path::canonicalize` returns `Err`, and `canonical_key` fell back to the raw `to_string_lossy()` form (`/tmp/x`). The Deleted-detection branch then queried `reads` with the unstripped path, missed the row, and bailed via `anyhow::bail!`. `canonical_key` now canonicalises the parent directory and re-appends the file name when the path itself can't be resolved — deleted lookups land on the same row the baseline write produced.

### Added

- **Files past Claude's `Read` token limit now get a compressed substitute instead of an error.** Claude's `Read` tool refuses files whose tokenized size exceeds ~25 000 tokens with `File content (X tokens) exceeds maximum allowed tokens (25000). Use offset and limit parameters…` — until now DRIP's Claude hook returned `allow` on first reads regardless (so Claude's read-tracker could populate from native), and the agent was stranded with only the error to go on. The hook now peeks at file size first: when the on-disk byte count would put the file past `DRIP_CLAUDE_READ_TOKEN_BUDGET` (default 10 000 DRIP tokens ≈ 24-26 k Claude tokens, since Claude's tokenizer runs ~2.5× tighter than `bytes/4`), it routes through the `DripRendered` entry point so semantic compression runs even on the `NativePassthrough` path. If the compressed view is workable (signatures + `[DRIP-elided]` stubs) the hook substitutes it via `deny` and the agent gets the file's structure to navigate; partial `Read(offset, limit)` reads then populate the harness's edit-tracker for the regions the agent wants to modify. If compression isn't available (non-code file, raw data, `DRIP_NO_COMPRESS=1`) we fall back to `allow` so Claude's native error tells the agent to use `offset`/`limit` directly. Override the budget via `DRIP_CLAUDE_READ_TOKEN_BUDGET=<int>` if Anthropic moves the limit. Live numbers from a 130 KB / 1 980-line Python module: native Read used to error out; DRIP now returns 1 781 tokens of structured signatures + stubs (95 % reduction).
- **`process_read_inner` attempts compression in the `TooLarge` branch on first DripRendered reads.** Previously any file past `LARGE_FILE_BYTES` (100 KB) collapsed to `FullFallback{ LargeFile }` and the hook routed it to native — which on files past Claude's 25 k-token limit just surfaces the harness error. Large code files now try semantic compression; when the elided view fits under the same 100 KB cap (so future diffs stay bounded) we deliver it as `FullFirst` with the compressed body and write the sparse `seen_ranges` matching the non-elided source map. Compression-ineligible content (no language detected, no elidable bodies, output didn't shrink) keeps the original `LargeFile` fallback so the diff path stays performant on later reads.

### Fixed

- **Edit certificates now pinpoint the actually modified lines, not the hunk window.** A one-line edit used to surface as `Changed: (L1-L6)` because `parse_diff_hunks` parsed only the `@@ -a,b +c,d @@` header and `d` includes the ±3 lines of unified-diff context around each change — readers parsed that as "six lines moved" when only one did. The parser now walks the hunk body and records the new-file line ranges that contain `+` lines (coalescing contiguous runs), so the cert reads `Changed: (L3-L3)` for the same edit. Pure-deletion hunks (no `+` lines) emit a zero-width marker at the hunk anchor so the agent still sees where the edit landed without claiming new content. Six new unit tests cover one-line, two-line contiguous, pure-insertion, pure-deletion, two-distant-hunks, and replace-block cases.

- **Header-overhead accounting now bumps per-file and per-session counters, not just the install-wide headline.** Found during a live-agent walkthrough: on macOS `/tmp/x` resolves to `/private/tmp/x`, but `render_and_record` was passing the caller's raw path to `bump_lifetime_overhead`. The `lifetime_stats` UPDATE has no path filter so it bumped fine, but the `lifetime_per_file` and `reads` UPDATEs matched zero rows — `drip meter --session` and the per-file `top` list silently under-reported the header cost while the headline number was correct. `render_and_record` now canonicalizes via the freshly-public `tracker::canonical_key` (same helper `process_read` uses for DB writes) before both the overhead bump and the replay-log `record_event` call, so all three accounting surfaces agree.

- **Meter no longer clamps net-negative savings to zero.** With honest header-overhead counting, DRIP's wire traffic can exceed the body bytes it saved on small files (header ≈ 80 B, content ≈ 60 B re-read once → net loss). Previously `meter.rs:260` and `:441` ran `(tokens_full - tokens_sent).max(0)`, the breakdown gate at `:716` was `r.tokens_saved > 0`, and `(saved - bash_saved).max(0)` masked the file-reads share — so a user re-reading a tiny config file would see `Tokens saved: 0` with no breakdown, hiding the loss. The data layer now keeps the signed delta (file-reads can dip negative, bash savings stay ≥ 0 by construction, invariant `file + bash == headline` holds); the human display shows `Tokens saved: -59  (loss)` and always renders the breakdown when at least one read has happened; the JSON shape is unchanged but values can now be negative — consumers should expect signed `tokens_saved`.

- **Partial reads on elided regions of a semantic-compressed baseline now pass through to native instead of claiming "unchanged".** A cross-tool pattern (Bash `cat foo.py` → DRIP returns compressed view with `# [DRIP-elided …]` stubs for long bodies, then Claude `Read(foo.py, offset=N, limit=M)` lands inside one of those stubs) used to falsely return `[DRIP: unchanged (lines N-N+M)]` because `seen_ranges` was written as `[(1, total_lines)]` on the first delivery — which over-claimed coverage for content the agent only saw as a stub. `upsert_read_with_compression` now derives `seen_ranges` from the source map: only original-line ranges of non-elided entries are stored, so partial reads on elided bodies miss the coverage check and correctly fall through. Full re-reads stay on the Delta path: `process_read_inner` and `try_precomputed` bypass the coverage gate when `prev.was_semantic_compressed` is set, since a unified diff against the compressed baseline is still well-formed (any body that changed surfaces as a stub-vs-body diff). Two new regression tests pin both branches.
- **Delta on external file change no longer breaks Claude Code's read-tracker.** The user-facing symptom was an `Error editing file` loop after running `cargo fmt`, `git pull`, or any other Bash command that rewrites a file the agent has previously Read: DRIP would intercept the next Read with a Delta via `permissionDecision: deny`, but the harness keys its "must Read first" check on the file's `content_hash` and a deny-substituted Read does not refresh that tracker. The next Edit then failed with `file modified since read`, and the recovery loop (auto-re-Read → Edit) hit the same trap. The fix adds a `FallbackReason::ExternalChange` outcome: when `process_read_inner` is in `NativePassthrough` mode (Claude Code Read hook) and `prev.content_hash != disk_hash`, return `FullFallback{ ExternalChange }` instead of `Delta`. The Claude hook routes that variant to `allow`, native Read fires, the harness records the fresh hash, and the next Edit succeeds. Symmetric handling in `process_partial_read`: detect the whole-file hash drift, refresh the baseline, reset `seen_ranges`, and pass through. CLI / MCP / Bash callers (`FirstReadDelivery::DripRendered`) keep the Delta optimisation since they don't share Claude Code's tracker.
- **`DRIP_DISABLE=1` now actually disables the Gemini hook and the MCP server.** The documented "bypasses interception entirely without uninstalling the hooks" kill switch was wired into every Claude hook (Read, Bash, Glob, Grep, pre-edit, post-edit, SessionStart) and the Gemini compress hook, but `src/hooks/gemini.rs` and `src/mcp.rs` ignored it — a user running `DRIP_DISABLE=1 gemini …` or pointing an MCP client at a `DRIP_DISABLE=1`-launched `drip mcp` would still get DRIP intercepts. Both paths now check the env var and return raw file content (Gemini) or a delta-free `read_file` tool result (MCP) when set. Workspace boundary checks for MCP still apply.
- **Grep hook no longer charges header overhead on genuinely-empty searches.** Previously `Grep` always denied the native call and substituted `[DRIP: grep filtered via .dripignore | …]\n(no matches)\n` — ~80 bytes — even when the search would have returned nothing natively and DRIP's matcher hadn't dropped a single hit. The hook now early-outs to `allow` when the filter result is empty AND the post-rg matcher dropped zero lines (i.e., DRIP can't claim the filter added value). Override with `DRIP_GREP_ALWAYS_FILTER=1` for the previous "always substitute" behaviour. Cases where the filter actually drops files (the win case for noisy `node_modules` hits) are unchanged — the substitution still fires whenever DRIP saved the agent from at least one line.
- **`try_precomputed` honours the silent-baseline guard.** The `drip watch` precompute path runs before the inline `seen_ranges` check, so a cached Unchanged or Delta against a partially-seen baseline could leak past the new guard and serve the agent the same dishonest sentinel the inline path was fixed to refuse. Mirrored the same coverage check in `try_precomputed`: cache hits on rows where `seen_ranges` does not cover the full baseline now return `None`, letting the inline FullFirst path fire and write a complete `seen_ranges` instead.
- **Partial-read silent baselines no longer claim "unchanged" for content the agent never saw.** A `Read(file, offset=N, limit=M)` on an unseen file installed a *full-file* baseline silently to enable future intercepts. The bug: a subsequent partial read on a *different* window then collapsed to `[DRIP: unchanged (lines X-Y)]`, even though the agent had only ever received window 1 from native Claude Code. Same trap for the next full read after a partial: matching content hash → unchanged sentinel for a file the agent had only sampled. The fix tracks per-window coverage in a new `seen_ranges` JSON column (schema v10, additive `ALTER TABLE ADD COLUMN`): a sorted, merged list of 1-indexed inclusive line ranges the agent has actually been delivered. Full reads write `[[1, total_lines]]`; partial passthroughs append the window they delivered; post-edit Passthroughs reset to full coverage once Claude Code's native Read has run. Window* / Unchanged / Delta intercepts only fire when `seen_ranges` covers the requested range — silent baselines and uncovered partials pass through cleanly. The `try_precomputed` path for `drip watch` honours the same gate so a cached Unchanged from the watcher can't bypass the check. Preserves the "two partial reads on the same window collapse" optimisation: append-on-passthrough means the agent's second read of the same lines hits a sentinel honestly. Two existing tests were updated, two new regression tests added (different-window partial after silent install must pass through; same-window partial repeats can collapse; window straddling a gap in seen_ranges passes through).
- **Partial-read savings now show in `drip meter --session`.** First cut of the partial-read intercept bumped only the install-wide `lifetime_*` counters (visible in plain `drip meter`) but left the per-row `reads.tokens_full` / `tokens_sent` columns alone — so users querying the session-scoped view saw nothing. New `Session::record_partial_read` mirrors the `record_unchanged` shape: UPDATEs the row's three accounting columns in place + bumps the install-wide tally. Crucially still does NOT touch `content_hash` / `content` / `content_storage`, so the baseline-immutability invariant for partial reads holds.
- **Cache-blob orphan leaks plugged across four write paths.** `purge_stale_sessions` already cleaned blobs after the DELETE, but every other write site that overwrote or deleted a `reads` row left the previous blob behind — the dominant source of the "75 orphan blob(s)" warning in `drip doctor` for users on heavy-edit workflows. Two new private helpers (`Session::capture_blob_hash` + `Session::maybe_drop_blobs`) wired into `Session::reset()`, `set_baseline()`, `upsert_read_with_compression()`, and `delete_read()`. Each site captures the previous hash before the mutation, then GCs the freshly-orphaned blob via the existing dedup-aware `cache::delete_blobs_if_unreferenced`. Existing users with already-orphaned blobs from before this fix should run `drip cache gc` once.
- **`drip init --agent codex` refreshes stale config blocks on re-init.** Pre-v0.2 versions wrote `args = ["mcp"]` (no agent tag) to `~/.codex/config.toml`; the idempotency check then skipped re-writes silently, leaving Codex spawning DRIP without `--agent codex` and tagging session rows as `agent=NULL` (rendered as "shell" instead of "Codex"). `ensure_codex_mcp` now compares the existing block against what it would write today and rewrites on divergence — user content elsewhere in `config.toml` survives untouched.

### Added

- **Group 11 (`stat` / `ls -l` / `file` / `du`) deliberately NOT implemented.** Closes the bash-hook expansion arc at 12 of the 13 originally-planned groups. The remaining commands all read filesystem METADATA (mtime, ctime, atime, perms, owner, inode, allocated blocks) rather than file content. That metadata changes independently of content — `touch file` bumps mtime without touching bytes, package managers update ctime on every reinstall, ACLs change perms — so the same-pipeline sentinel (which keys on the content hash) would happily emit a stale `pipeline unchanged` after a metadata-only change, lying about what `stat` would actually show. The narrow content-only subset that IS safe (`stat -c '%s'` for size, `stat -f '%z'` on BSD) emits outputs short enough (~10 B per call) that the tiny-output bypass at 200 B fires before the intercept can possibly help — net byte savings would be 0 % vs. native, with a small latency win on cache hits but no token win. `file file.py` (magic-byte detection) would require either a new dep (`infer`) or hand-rolled magic logic for what's already a niche agent pattern. The honest call is to passthrough every group-11 shape — strict-safety: `passthrough is strictly preferred over a wrong substitution` from the implementation plan applies cleanly.

- **Bash hook intercepts gzip-decompressing reads (`zcat file.gz`, `gunzip -c file.gz`, `gzip -dc file.gz`) — group 8.** Adds the `flate2` dep (rust_backend, default-features off — no zlib FFI). New `SourceStage::DecompressGzip { path }` plus a sibling `tracker::read_for_pipeline_gzip` that mirrors the regular pipeline read gauntlet (dripignore — see below — symlink reject, hard size cap, UTF-8 validation) but inserts a streaming `flate2::read::GzDecoder` between `std::fs::read` and the UTF-8 check. The decompressed-byte budget enforces the same `HARD_SIZE_CAP_BYTES` ceiling so a gzip bomb can't OOM the hook. The dripignore matcher is deliberately bypassed for this read path: the default ignore set includes `*.gz` (so plain `cat foo.gz` doesn't try to dedup the binary archive), but when the user explicitly calls `zcat` they're asking for the *decompressed text content* — honouring the global filter would silently bail on every `.gz` file and the feature would be dead on arrival. Mutating shapes (`gunzip file.gz` without `-c`, `gzip file` without `-d`) all bail to native; corrupted gzip streams bail too (flate2 errors → handle_pipeline returns allow). Bench: `bare_gunzip_c` and `bare_gzip_dc` both clock **79 %** byte reduction across 5 calls on a compressed multi-line text fixture, matching the band of every other content-derived transform. 9 new tests including a fixture builder using `flate2::write::GzEncoder` directly to produce known-good .gz bytes.

- **Bash hook intercepts a `jq` subset (group 7).** Three exact filter shapes: `.` (identity), `.key` (top-level lookup), `.key.sub.subsub` (dot-separated identifier paths). The `-r` / `--raw-output` flag is honoured: when the resolved value is a JSON string, the outer quotes are stripped (`drip\n` rather than `"drip"\n`); for non-string values it's a no-op per jq's documented semantics. Implementation reuses the existing `serde_json` dep — now with the `preserve_order` feature enabled so `jq '.'` round-trips the file's key order rather than sorting alphabetically (matches jq byte-for-byte). Verified empirically: enabling `preserve_order` doesn't break any existing test (the previously-implicit "sorted keys" behaviour wasn't load-bearing anywhere). Out of scope: array indexing (`.[0]`), array iteration (`.[]`), pipe filters (`. | keys`), built-ins (`keys`, `length`, `type`), and the compact form (`-c`) — every one of those bails to native cleanly. Invalid JSON also passes through to native (the apply step Errs and the bash hook catches it).

- **Bash hook intercepts whitelisted Python and Ruby file-read one-liners (group 13).** Six exact-shape patterns: `python -c "print(open('FILE').read())"` (and the `, end=''` variant), `python -c "print(len(open('FILE').readlines()))"`, `python -c "print(sum(1 for _ in open('FILE')))"`, `ruby -e "puts File.read('FILE')"`, `ruby -e "puts File.readlines('FILE').length"`. Both single-quoted and double-quoted inner-filename forms are accepted; the matcher follows bash's quoting rule (outer `'…'` forces inner `"…"` and vice versa, so we only see the two homogeneous combos in the wild). The trailing-newline semantics are reproduced faithfully — `print()` always appends `\n` (so a file ending in `\n` produces a double newline), `puts` appends `\n` only when missing — via two `TransformStage::AppendNewline { force }` variants. Anything mentioning `exec` / `subprocess` / `os.system` / `import os` / `eval` / `__import__` is hard-rejected as a defence-in-depth net even when the surrounding shape would have matched. Bench: `py_read` and `ruby_read` both clock **79 %** byte reduction on a 1.5 KB fixture across 5 calls. 8 positive tests + 7 safety pins (subprocess/eval/exec/os.system/unknown-pattern/unknown-flag combinations all fall through cleanly).

- **Stabilisation: `cat -A/-T/-E/-v/-s/-b file` no longer silently lies about the file content.** Earlier builds let any flag-bearing `cat`-family invocation fall through to the simple-read path, which dropped the flag and returned the raw file bytes — so `cat -A file` (which should display non-printables as `^I`/`$`) returned the unmodified content, and `cat -n file` returned the un-numbered content. The pipeline path now serves `cat -n`; all other cat flags bail to native (the simple-read parser explicitly refuses any pre-`--` flag on cat / less / more / bat / view). The `--` separator stays accepted (`cat -- -weird-name.txt` still resolves the path correctly).

- **Stabilisation: 14 cross-cutting edge-case tests added.** Empty files, files without a trailing newline (cat -n preserves the partial line, diff emits the canonical `\ No newline at end of file` footer), CRLF inputs, Unicode multibyte payloads, single-byte files, unbalanced-quote rejection, path-traversal handling, and — crucially — the cross-form sentinel-cache pin proving that `grep PAT file`, `cat file | grep PAT`, `< file grep PAT`, and `grep PAT < file` all key the same cache row, so the agent gets the sentinel benefit regardless of how it spelled the command. Pipeline composition like `grep PAT file | head -N` is currently a passthrough (the multi-segment parser doesn't lift bare-cmd into source position) — pinned as the current contract; a follow-up can elevate it.

- **Bash hook intercepts `cat -n file`, `diff [-u] /dev/null file`, and the trailing-stdin-redirect form `CMD < file`.** Three bare-form additions plus the symmetric of the leading-redirect normaliser shipped previously. `cat -n` mirrors GNU/coreutils byte-for-byte (6-char right-aligned 1-indexed line numbers, TAB-separated). `diff /dev/null file` emits the ed-style change script (`0a1,N\n> line\n`); `diff -u /dev/null file` emits unified diff (`--- /dev/null\n+++ path\n@@ -0,0 +1,N @@\n+line\n`) — both intentionally OMIT the timestamps real diff(1) writes (reproducing them would require a stat() that taints the otherwise-pure transform; agents read the body, not the headers). Missing trailing newlines surface the canonical `\ No newline at end of file` footer in both forms. `CMD < file` is rewritten to `cat file | CMD` at parse entry, sharing the leading-redirect's quote-aware path extraction. Bench: `bare_cat_n` and `bare_diff_u` each clock **79 %** byte reduction across 5 calls; `trail_head50` matches the leading-redirect equivalent at **70 %**. 13 new tests pin coreutils-byte-exact assertions plus the safety pins (`diff` between two real files, multi-redirect, trailing redirect with stderr redirect).

- **Bash hook intercepts `sha256sum / sha512sum file`, `xxd [-p] file`, `strings [-n N] file` (groups 9 + 10).** Three pure-Rust transforms — no new dependencies. Checksums use the existing `sha2` crate (which already ships both Sha256 and Sha512), so md5sum / sha1sum still pass through to native rather than pull in a hash crate for each algorithm. `xxd` mirrors the canonical `xxd(1)` output byte-for-byte (8 hex pairs per line, ASCII column padded so it always lines up at the same column). `xxd -p` and the `strings -n N` (spaced + glued) flag forms are recognised; everything else (`xxd -l/-s/-c/-r`, `strings -t/-e`) bails to native. Pinned with byte-exact assertions in tests so output drift surfaces immediately.

- **Tiny-output bypass threshold raised from 128 B to 200 B.** Discovered while benchmarking `sha256sum`: its raw output (`<64-char hex>  <path>\n`, ~140 B) sits in the awkward gap where the DRIP intercept header (~140 B by itself) and the same-pipeline sentinel (~120 B) are *each* comparable in size to the raw line, so interception strictly hurt — bench showed -31 % bytes vs. native. 200 B comfortably covers single-line outputs (`wc -l`, `grep -c`, `tail -1`, all checksums) without touching the patterns where DRIP wins big (`head -50`, `xxd`, `sort`, `strings` all produce kilobyte-scale outputs). Override stays `DRIP_PIPELINE_BYPASS_BYTES`; tests still set it to 0 to assert transformation correctness on small fixtures.

- **Bash hook intercepts bare `wc -l/-w/-c file`, `sort [-r/-n/-k N] file`, `cut -d X -f N[,M] file`, `uniq [-c/-d/-u] file`.** Same dispatcher used for groups 2+3 in the previous entry, extended via a new `extract_trailing_file` helper that knows which flags consume a separate value argument (`-k` for sort; `-d` and `-f` for cut). Bare `wc` with no flag still passes through (its 4-column "L W C path" output is implementation-defined whitespace); `cut` without an explicit `-d` also passes through. Bench: `bare_sort` shows **79 %** byte reduction across 5 consecutive calls on a 24 KB fixture; `bare_wc_l` falls under the existing tiny-output bypass and contributes 0 % (already optimal). 14 new tests, including safety pins for glob / multi-file / `-k` value-flag handling / spaced vs. glued `cut -d` forms.

- **Bash hook intercepts the leading-stdin-redirect form (`< file CMD ARGS`).** Agents write `< foo.py head -n 50` as a substitute for `cat foo.py | head -n 50`; we now normalise the redirect form into the equivalent pipeline at parse entry, so the rest of the parser stays redirect-blind. Quote-aware extraction handles real-world paths with spaces (`< "with space.txt" head -n 5`) by stripping the surrounding `'…'` or `"…"` and round-tripping the filename through a synthesised `cat 'path' | CMD` string. Refused: `<<` here-docs, `<<<` here-strings, glob paths, `< -` (stdin marker), `< file` with no command, trailing-redirect `CMD < file` (a follow-up can add it). Bench: `stdin_head50` matches `head50` at **70 %** byte reduction once the quote-aware fix landed (an early build silently bailed on every quoted path and contributed 0 %). 9 new tests pin the supported shapes plus the safety rejections.

- **Bash hook intercepts bare-cmd-on-file forms (`grep PAT file`, `sed -n 'A,Bp' file`, `awk 'NR==N' file`).** Until now the Bash hook only matched piped reads (`cat file | grep PAT`); standalone invocations slipped through to native. Agents commonly issue the bare form, so the second and third re-runs of an unchanged file paid full output cost every time. New `parse_bare_cmd_on_file` recognises a tight whitelist — `grep / egrep / fgrep [-i / -n / -v / -c / -E / -F] PAT file`, `sed -n '<range>p' file` (`A,Bp` and `Np`), `awk 'NR==N' file` and `awk 'NR>=A && NR<=B' file` (terms order-flipped accepted) — and lifts each into a synthetic `cat file | <stage>` `Pipeline`, so the existing pipeline_results sentinel and apply path handle the rest unchanged. New `TransformStage::LineRange { start, end: Option<usize> }` plus `line_range_op` drive the sed/awk range extraction. Pre-parse safety guards in `parse()` are now quote-aware (`has_unquoted_meta`) so legitimate `awk 'NR>=A && NR<=B'` programs aren't rejected for the `&` and `>` they contain inside their quoted body. Filename arguments containing `*` / `?` / `[` after tokenization (i.e. unquoted globs the agent meant for shell expansion) are refused. Bench: 67-71 % byte reduction across 5 consecutive calls on the new patterns, matching what the piped equivalents already get; 13 positive + 12 negative tests added (multifile / glob / redirect / command-substitution / sequence / `eval` / `sudo` / `sed -i` / open-ended `$p` / awk free-form action blocks all stay on the native path). Out of scope here: perl one-liners, `rg` / `ag` single-file forms, and the rest of the implementation plan's groups 4-13.

- **Partial-read interception (`Read(file, offset=N, limit=M)`).** Before, partial reads always passed through to native — DRIP couldn't honestly diff a slice against a full-file baseline, so it bailed. The pessimistic floor: window-scrolling on large files saw zero DRIP savings. Now, when DRIP already has a full-file baseline, partial reads get a window-scoped intercept: byte-identical window → `[DRIP: unchanged (lines X-Y) | 0 tokens sent (NN saved)]`; drifted window → `[DRIP: delta only (lines X-Y) | NN% reduction (...)]`. Falls back to native when no baseline exists (DRIP has nothing honest to compare to). The baseline is **never mutated** by partial reads — the agent saw a slice, not the file, so the next genuine full read still serves the whole content. Token tracking uses the window size as `tokens_full` and the substituted-payload size as `tokens_sent`, so the % saved figures stay honest. New `ReadOutcome::WindowUnchanged` / `WindowDelta` variants, `tracker::process_partial_read` entry, `Session::record_partial_read` for the per-row counters bump. 5 + 1 integration tests cover unchanged window, delta in range, unchanged on out-of-range edit (correctness pivot), no-baseline passthrough, no-baseline-mutation invariant, and `--session` meter visibility regression.

- **`drip update` — self-upgrade.** New `drip update` command detects how DRIP was installed by inspecting `current_exe()` (`/opt/homebrew/`, `/usr/local/Cellar/`, `/home/linuxbrew/.linuxbrew/`, `~/.cargo/bin/`, `~/.local/bin/`) and runs the matching upgrade command (`brew upgrade drip-cli/drip/drip`, `cargo install drip-cli --force`, or the install-script curl pipe). No new HTTP dependency — shells out to `curl` (with `wget` fallback) for the GitHub Releases API call, keeping the binary lean. `--dry-run` shows what would happen without executing. `DRIP_UPDATE_FAKE_LATEST=X.Y.Z` short-circuits the network for tests and offline use. `DRIP_CHECK_UPDATES=1` opts `drip doctor` into a one-shot update check that adds a "⚠ Update available: X.Y.Z — run `drip update`" warning on the Binary section. Already-up-to-date is a clean no-op with exit 0; unknown install methods produce a clear error listing the three manual-upgrade commands.
- **Homebrew tap.** New `drip-cli/homebrew-drip` repo holds a `Formula/drip.rb` that's auto-bumped on every release. `release.yml` gains an `update-homebrew` job that runs after `publish`: SHA256s the freshly-uploaded `darwin` (x86_64+arm64) and `linux-musl` (x86_64+arm64) archives, regenerates the formula with the new version + hashes, and pushes via a fine-grained PAT (secret: `HOMEBREW_TAP_TOKEN`). `brew tap drip-cli/drip && brew install drip` is the new recommended install path. The Linux release matrix also switched from `-gnu` to `-musl` so the brew Linux formula gets a static binary that works on every distro.

- **Agentic-experience: three frictions eliminated.** The compress / diff / session pipeline now pushes back when the *agent's* reasoning would be poorly served by a literal interpretation of "send less."
  - **Friction 1 — edit on an elided function body.** When semantic compression hides a function body and the agent later edits inside that body, the `claude-post-edit` hook now emits a `hookSpecificOutput.additionalContext` warning naming the function (`⚠ edited elided function(s): NAME`) and pointing at `drip refresh`. New schema_version 6 columns `reads.was_semantic_compressed` and `reads.elided_functions` (JSON-encoded list) carry the state across the read → edit boundary. Detection runs three heuristics in order: (a) Edit/MultiEdit `old_string`/`new_string` text mentioning the function name or a callsite, (b) Write-tool diff fallback against the prior content, (c) edit-position-inside-function-span, which catches body-only edits whose text gives no name hint. Default `DRIP_COMPRESS_MIN_BODY` raised from `4` to `8` (floor `4`) so the elision threshold matches "actually substantial body" rather than tiny helpers.
  - **Friction 2 — diff-too-complex fallback.** A unified diff scattered across many functions costs more tokens than the file itself *and* is harder for the agent to reason about than a clean re-read. New `differ::analyze_complexity` measures hunk count, changed-line ratio, and max hunk distance; when any threshold trips (`DRIP_MAX_HUNKS=6`, `DRIP_MAX_CHANGED_PCT=0.40`, or `>3 hunks` with `>200`-line span) DRIP ships a `[DRIP: diff complexity: N hunks, X% changed]` full re-read. Multi-hunk diffs that *don't* trip the gate gain a hunk-summary segment in the header (`| 3 hunks: calculate_subtotal (ln 42), main (ln 156)`, language-aware nearest-enclosing-function lookup, capped at 6 names with `+N more`).
  - **Friction 3 — session lifetime is heartbeat-driven.** `DRIP_SESSION_TTL_SECS` (default `7200`, floor `1800`) replaces the hard-coded 2 h purge. Every read/edit `touch()`es the session, so an active conversation never expires; only quiet sessions get GC'd. Purged sessions land in a new `expired_sessions` tombstone table (24 h retention); when the agent reopens the same `DRIP_SESSION_ID` after a tombstone, the next first-read carries a one-shot `ℹ session expired — fresh baseline started` notice so the agent knows its prior context is no longer authoritative. When < 10 % of TTL remains, header gains `⏱ session expires in N min — run \`drip reset\` to start fresh`.
  - Integration coverage: `warns_when_edit_targets_an_elided_function_body`, `no_warning_when_edited_function_body_was_visible`, `complex_diff_falls_back_to_full_read`, `expired_session_announces_fresh_baseline_on_first_read`. Compression unit tests gain a `Mutex<()>` to serialise env-var pokes (cargo's parallel runner was racing `set_var`/`remove_var` and producing intermittent failures).

### Security

- **Post-edit size cap.** `claude-post-edit` now mirrors the tracker's `HARD_SIZE_CAP_BYTES` (50 MB) check and `metadata().len()`-rejects oversized files *before* `fs::read`. Without this, an Edit on a multi-GB file would OOM the hook subprocess and break the agent's edit chain.
- **MCP workspace-root fail-closed.** `DRIP_WORKSPACE_ROOT` enforcement now refuses any path whose `canonicalize()` fails, instead of falling back to a textual `starts_with` against the un-resolved path. The previous behavior could be fooled by `../../etc/passwd`-style traversal on missing files (not exploitable in practice — `process_read` errored on the non-existent target — but the security control should be robust regardless).
- **Grep hook stdout cap.** The Grep hook now streams `rg`'s stdout with a 4 MiB ceiling instead of buffering the entire output via `cmd.output()`. A pathological pattern against a huge tree can no longer pull arbitrary memory into the hook process.
- **Session purge cleans `passthrough_pending`.** Stale passthrough markers belonging to expired sessions (or to sessions that no longer exist at all) are now deleted alongside `reads` rows during the 2 h-TTL purge, so abandoned `(session, file)` entries don't accumulate forever.
- **`drip init` preserves existing file mode.** `atomic_write` now copies the target's Unix mode bits onto the tmp file before `rename(2)`, so a `0600` Codex `config.toml` (which may contain provider tokens) isn't silently widened to umask-default `0644` on next init.

### Fixed

- **Diff-bigger-than-file fallback.** On tiny files (`package.json`, `.env`, single-line text files) the unified-diff envelope (`---` / `+++` / `@@` headers + context lines) frequently costs more tokens than just resending the file. DRIP now detects that case (`delta_tokens >= new_tokens`) and falls back to a full read instead, so the tool never sends a *bigger* payload than the original.
- **Atomic writes for `drip init`.** The Claude Code `settings.json`, Codex `config.toml`, and Codex `AGENTS.md` writes now go through a tmp-file + `rename(2)` pattern, so a Ctrl-C or power loss in the middle of `drip init` can no longer leave a half-written settings file that breaks the agent on next launch.
- **Hook commands now use absolute paths.** `drip init -g` previously wrote `"command": "drip hook claude"`; subprocesses spawned by Claude Code don't inherit `~/.cargo/bin` in `PATH`, so the hook silently failed to find `drip` and Claude Code fell back to native `Read`. Init now writes the canonical absolute path of the running `drip` binary, mirroring what we already did for Codex.
- **`Session::open_with_id` no longer creates a ghost row.** Previously it called `open()` (which inserted a row keyed by the cwd-derived id), then overwrote `s.id`, leaving the real session without a `started_at` row — so `drip sessions` only listed ghosts and `drip meter` always reported `elapsed_secs = 0`. Refactored `open()` into a single `open_inner(Option<id>)` so the explicit id is the one that gets written.

### Added

- **Hybrid content storage: large blobs hoisted to a hash-addressed file cache.** SQLite stored every `reads.content` payload inline regardless of size — fine for typical source files, but on monorepos with lots of 50–100 KB files re-read dozens of times the DB ballooned and every transaction dragged all that text through WAL. New schema_version 2 adds a `reads.content_storage` column (`inline` | `file`); files at or below `DRIP_INLINE_MAX_BYTES` (default 32 KB) keep the v1 inline path, larger ones get written to `<DRIP_DATA_DIR>/cache/<sha256>.bin` (atomic tmp + rename, 0700 dir, 0600 file) and `reads.content` is set to `''`. Hash-addressed naming gives **automatic deduplication** — two sessions reading the same vendored library or generated file share one blob. `Session::get_read` transparently fetches from the cache when needed; a missing blob (manual `rm`, partial restore) is treated as a stale baseline and degrades to "first read" rather than crashing the agent. Tunable via `DRIP_INLINE_MAX_BYTES` (`0` = always cache, `-1` = always inline, pinning v1 behaviour). Migration is additive and tolerant — existing v1 DBs gain the column with default `'inline'` so every existing row keeps working unchanged. New `drip cache gc` reclaims orphan blobs (cache files no `reads` row references) and `drip cache stats` reports inline rows, cached files, dedup hits, orphans, and DB size. `drip meter --json` exposes a `storage` block with the same numbers for scripting. 4 unit tests cover the cache primitive (roundtrip, idempotent overwrite, missing-blob = `Ok(None)`, threshold boundary); 13 integration tests cover the routing (small inline / large cached), env override, dedup, missing-cache fallback, diff/unchanged parity with cache-backed baselines, GC orphan removal vs active-blob preservation, stats output, and v1→v2 migration of a hand-seeded legacy DB.
- **Crash-resilient session keying with git-aware strategy.** The session id used to derive from `cwd + parent_pid + parent_start_time` only — useful for "one Claude Code = one session" but two pathological edges: (a) Claude Code crashes and gets relaunched in the same dir, the new ppid produces a brand-new session id, and every previously-tracked file resets to FullFirst even though the agent already saw them; (b) the same dir on two git branches (or two `git worktree`s) collapses into a single session, so a delta computed against branch A's content can leak into branch B. New `core::git` module reads `.git/HEAD` (handles both real `.git` directories and worktree gitlink files, walking up from cwd) in pure file IO — no subprocess, no libgit2 — and `derive_session()` resolves a 4-strategy ladder: `Env` (`DRIP_SESSION_ID` set, verbatim) → `Git` (sha256 of `cwd:branch:worktree_id`) → `Pid` (legacy fallback) → `Cwd` (opt-in via `DRIP_SESSION_STRATEGY=cwd`, permanent per directory). Force a strategy with `DRIP_SESSION_STRATEGY=git|pid|cwd`. Detached HEAD uses the first 8 chars of the commit sha as the context label. Malformed `.git`, missing HEAD, gitlink-to-nowhere, garbage in HEAD — all bail silently to `Pid`. The `sessions` table gains nullable `strategy` + `context` columns (additive migration via tolerant `ALTER TABLE … ADD COLUMN`, no schema-version bump needed); `drip sessions` shows them, and `drip meter --session --json` exposes `session_strategy` + `session_context` for scripting. Concrete win: relaunch the agent on the same branch → same session id → previously-tracked reads return `unchanged`, not `full read`. 10 unit tests cover the git-context parser (branch, slashed branch, detached, missing HEAD, garbage HEAD, worktree gitlink, broken gitlink); 14 integration tests cover env-priority, strategy resolution, branch isolation, worktree isolation, ppid stability under git keying, malformed-git fallback, and end-to-end crash-recovery (read 3 files at ppid=1000, re-read at ppid=9999, all return Unchanged).
- **Bash hook intercepts pipelines.** The `PreToolUse:Bash` hook used to bail at the first `|` and let the agent's shell run pipelines natively, so reads that the model dressed up as `cat src/foo.py | head -50` or `cat foo | grep -n 'def '` got no DRIP filtering. New `hooks::bash_pipeline` module recognises a *single-source, side-effect-free* pipeline built on top of `cat` / `head` / `tail`, applies the transform chain in pure Rust (`head`, `tail`, `grep` with `-i`/`-n`/`-v`/`-c`/`-E`/`-F`, `wc`, `cut`, `sort`, `uniq`, basic literal `sed s///`), and returns the filtered bytes via `permissionDecision: deny`. Default-mode `grep` with regex metachars, `tail -f`, `sed -i`, redirects, command substitution, glob / multi-file sources, `;`/`&&`/`||` chains all stay on the native path — false positives would silently change shell semantics, which is unacceptable. Pipeline reads bump the lifetime savings counters but never establish a per-file baseline (a pipeline shows a *partial view*; a subsequent `cat <path>` must still receive the full content). 38 unit tests cover parsing + transform application; 24 integration tests cover end-to-end interception, byte-identical output, and the safety rejections.
- **Ghost-file pollution hint in `drip meter`.** Lifetime mode now stat()s every `lifetime_per_file` row and, when ≥50% of `tokens_full` comes from paths no longer on disk, prepends a yellow `⚠ N ghost file(s) inflate lifetime stats (X% of tokens) — run drip meter --prune to clean` line above the report. Nothing is deleted implicitly — purely a UX nudge so the user understands a misleading 0% headline before reaching for `--prune`. JSON output gains an optional `ghost_pollution` field (`ghost_files`, `ghost_tokens_full`, `total_tokens_full`, `ghost_pct`) when triggered. Threshold tunable via `DRIP_GHOST_HINT_THRESHOLD` (0–100; 101 disables). Suppressed when `--prune` is run in the same invocation, on `--session` reports, and when no ghosts exist. Two regression tests cover the trigger / clean-after-prune / no-false-positive paths.
- **`drip meter --prune` cleans artifact rows from lifetime stats.** Any path in `lifetime_per_file` that no longer exists on disk is dropped; install-wide totals (`total_reads`, `tokens_full`, `tokens_sent`) are recomputed from the survivors so the headline reduction reflects real source files, not stale `/tmp` benches. Reports the count, the first 5 paths reclaimed, and the tokens / reads recovered before printing the standard meter output. JSON output gets a `{"prune": {...}, "report": {...}}` envelope. Motivated by an in-the-wild case where two `/tmp/drip-watch-bench/*.txt` rows from prior benchmarking were responsible for 92.6 M of 93.2 M lifetime tokens, dragging the headline efficiency to 0% despite real files showing 67%+ reduction.
- **`drip meter` shows estimated $ saved and CO₂e avoided.** New `dollars_saved` and `co2_g_saved` fields (with their per-Mtok / per-Ktok rates surfaced alongside) appear in both human and JSON output. Defaults assume Claude Sonnet 4.6 input pricing (\$3 / Mtok) and ~0.4 g CO₂e / Ktok of cloud-GPU inference; override with `DRIP_PRICE_PER_MTOK` and `DRIP_CO2_G_PER_KTOK`. Bad env values (negative, NaN, non-numeric) silently fall back to defaults — verified by regression test.
- **Banner re-positioned in `drip meter`.** The `┌─ DRIP (since install) ─┐` headline now sits as a section divider just above "Top savings" instead of at the very top of the output, so the eye reads counts → costs → headline → per-file detail in a more natural top-to-bottom flow.
- **`drip watch` security hardening.** The watcher now refuses to recompute paths that fall outside the canonical watch root (defends against symlink-delivered FS events on macOS FSEvents) and loads `.dripignore` from the watch root rather than the daemon's cwd, so a project-local ignore file is honored even when `drip watch` is invoked from elsewhere. Two new integration tests cover both cases.
- **Semantic compression on first reads.** New `core::compress` module elides long function/method bodies on the agent-facing payload while keeping the full original as the SQLite baseline (so subsequent diffs are correct). 13 languages supported: Python (`.py`, indentation-based), Rust (`.rs`), JavaScript (`.js`/`.mjs`/`.cjs`/`.jsx`), TypeScript (`.ts`/`.tsx`), Go (`.go`), Java (`.java`), C (`.c`/`.h`), C++ (`.cpp`/`.cc`/`.cxx`/`.hpp`/`.hh`/`.hxx`), C# (`.cs`), Kotlin (`.kt`/`.kts`), Swift (`.swift`), Scala (`.scala`/`.sc`), PHP (`.php`/`.phtml`). All C-family flavors share one brace-balancer that tracks string/comment state (literal `}` in a string can't truncate the body), excludes control-flow keywords (`if`/`else`/`while`/`for`/`switch`/…) and structural keywords (`class`/`struct`/`interface`/`namespace`/…) so method bodies elide while their signatures and the surrounding class stay visible. Bodies shorter than 4 lines stay inline (otherwise the elision marker costs more than the body). Real-world demo: a 53-line Python pricing module renders as 15 lines (71% reduction, 4 functions elided). New `[DRIP: full read (semantic-compressed) | …]` header announces the savings and points the agent at `drip refresh` if it actually wants a body. Opt-out via `DRIP_NO_COMPRESS=1`; threshold tuning via `DRIP_COMPRESS_MIN_BYTES=N` (default 1024). 14 unit tests + 5 integration tests cover Python / Rust / JS arrows / Java methods (with class signatures preserved) / control-flow-not-elided / C++ namespace-qualified names / Kotlin `fun` / Swift `func` / PHP / C# methods / brace-in-string defense / disable-via-env / small-file skip / baseline-stays-uncompressed-so-diffs-still-work.

- **Reddit-ready benchmark script.** `scripts/bench_reddit.sh` builds a synthetic 4-language project (Python / Rust / Java / TypeScript), simulates a typical 4-read-per-file agent loop, and emits a markdown table ready to paste. Reproducible — anyone can run it and verify the numbers.
- **`drip replay` — chronological read-log replay.** Every intercepted read now writes one row to a new `read_events` table (kind, fallback reason, tokens full/sent, rendered bytes). `drip replay` walks that log so you can see exactly what the agent received, in order. Flags: `--session <id>`, `--since 5m|1h|2d`, `--file <substr>`, `--limit N`, `--full` (expands each row with the rendered output), `--json` (machine-readable). Per-session cap defaults to 500 events (`DRIP_REPLAY_KEEP=N` to tune); `DRIP_REPLAY_LOG=0` opts out of recording entirely. Each event truncates rendered output at 32 KB on a UTF-8 boundary so big-file deltas don't bloat the DB. Five integration tests cover record / `--full` / `--file` filter / `LOG=0` opt-out / `KEEP=N` rolling cap.
- **`drip watch [path]` — background pre-computation of diffs.** A long-lived watcher subscribes to FS events via [`notify`](https://docs.rs/notify) (FSEvents/inotify/ReadDirectoryChangesW), re-runs the diff on every change to any file with a baseline, and stashes the result in a new `precomputed_reads` table keyed by `(session, file, mtime, size)`. The Read hook hits this cache first: matching `(mtime, size)` AND the cached `baseline_hash` matching the current `reads.content_hash` → instant cache hit (skips fs::read + sha256 + similar's diff). Stale cache rows are auto-invalidated on baseline change (`set_baseline`, `delete_read`) and on session purge. Most useful on truly big files (5–30 MB+) where the inline diff path costs 50–500 ms; for typical < 1 MB code files the inline hook is already in the noise. Two new integration tests cover the precompute + invalidation flow.

- **DB hardening.** `sessions.db` is now `chmod 0600` on creation (parent dir `0700`) on Unix, so other users on a shared host can't read cached file contents.
- **Pre-flight size cap.** Files larger than 50 MB are detected via `metadata().len()` *before* `fs::read`, eliminating the OOM hazard when an agent points DRIP at `/dev/zero` or a huge log.
- **`DRIP_REJECT_SYMLINKS` env var.** When set, DRIP refuses to follow symlinks and returns a `<symlink, not followed>` fallback so the agent's native read runs untouched.
- **`DRIP_WORKSPACE_ROOT` env var.** When set, the MCP `read_file` tool refuses any path that doesn't canonicalize under that root.
- **Schema versioning.** A `meta(schema_version)` row, currently `1`, guards against running an older `drip` build against a DB written by a future version.
- **Hook stderr diagnostics.** `claude`, `claude-bash`, and `claude-post-edit` hooks now `eprintln!` errors before falling back to `allow`, so misconfigurations show up in Claude Code's debug logs instead of failing silently.
- **Session id includes parent process start time** (macOS `proc_pidinfo`, Linux `/proc/<pid>/stat`) so PID reuse can't make two distinct agent sessions hash to the same DRIP id.
- **`drip meter` UX fallback.** When `DRIP_SESSION_ID` is unset and the cwd-derived session has no reads, `drip meter` falls back to the most recently active session in the DB so a casual `drip meter` from a terminal shows the agent's actual savings.
- **JSON-RPC compliance.** MCP server now treats explicit `"id": null` as a notification (no response), matching the spec.
- **`DRIP_DISABLE=1` escape hatch.** Setting this env var makes every Claude Code hook (`Read`, `Bash`, `PostToolUse`) return immediately without touching the DB or the agent's tool result. Lets users bypass DRIP for one session without uninstalling hooks if it ever misbehaves mid-task.
- **`drip refresh <file>`.** Drops the cached baseline for a single file so the next read returns the full content. Useful after an out-of-band change (manual edit in another editor, `git pull`, …) when DRIP would otherwise hand the agent a delta against a stale snapshot.
- **`drip sessions` shows tokens-saved per session.** Now displays `SAVED` (comma-grouped) alongside files-tracked and age, so it's obvious from a glance which session is doing real work.
- **Post-edit passthrough.** When the agent edits a file, the very next Read of that file passes through native instead of returning `[DRIP: unchanged]`. Fixes the Claude-Code "File has not been read yet" check that doesn't trust DRIP's deny-as-substitute Reads as proof-of-read. One-shot per edit — subsequent reads resume normal interception. New `passthrough_pending` table holds the marker; `Session::mark_passthrough` / `take_passthrough` manage the lifecycle.
- **Edit visibility in `drip meter`.** New `lifetime_edited_files` table counts every PostToolUse fire (Edit/Write/MultiEdit/NotebookEdit). `drip meter` now shows `files edited` and `total edits` next to `files tracked` / `total reads`, so it's clear which files you've worked on even when the model didn't re-read them enough to produce token savings.

- **`.dripignore` support.** Gitignore-flavored pattern file (cwd, then `~/.dripignore`, plus `$DRIP_IGNORE_FILE` for explicit overrides) that DRIP applies to file reads — matched paths return a `<ignored by .dripignore>` placeholder instead of the actual content, so lock files, `node_modules`, build artifacts, and binary assets never reach the agent. Sensible defaults baked in (`node_modules/**`, `.git/**`, `*.lock`, common image/font/archive extensions, `target/`, `dist/`, `.next/`, etc.) — extend or override with negations (`!Cargo.lock`).
- **Glob hook (`PreToolUse:Glob`).** `drip init` now installs a hook that re-runs the agent's glob, applies `.dripignore`, and substitutes the filtered list. Prunes at the directory level so DRIP doesn't even descend into `node_modules/`. Capped at 1,000 results, sorted newest-first to mirror Claude's native behavior.
- **Grep hook (`PreToolUse:Grep`).** When `rg` is on `PATH`, DRIP re-issues the search with `--glob '!…'` exclude rules built from `.dripignore`, then strips any post-hoc lines whose path still slipped through. Fully supports `output_mode` (files_with_matches / content / count), `-i`, `-n`, multiline, `-A` / `-B` / `-C`, `head_limit`, `glob`, `type`. Falls back to native if `rg` is missing.

### Changed

- **`drip meter` is now cumulative-since-install by default.** Previously it scoped to the current session — fragile when run from an external terminal (would land on a random recently-touched session) and reset every 2h with the session purge. Three new SQLite tables (`lifetime_stats`, `lifetime_per_file`, `lifetime_daily`) accumulate counters forever; the per-session `reads` table is still purged to keep the DB small. Pass `--session` to get the legacy single-session view. Existing reads from before the upgrade are not backfilled — totals start accumulating from the first read after install.

## [0.1.0] — 2026-05-05

Initial public release.

### Added

#### Core

- `drip read <file>` — full content on first read of a `(session, path)` pair, unified diff on every subsequent read against the previously-served version.
- `drip read --dry-run` — compute the outcome without persisting to the SQLite ledger (debugging).
- Per-session SQLite store at `~/.local/share/drip/sessions.db` with WAL mode and a 500 ms busy timeout. Sessions auto-expire after 2 h of inactivity.
- Session id derivation: `DRIP_SESSION_ID` env override, otherwise SHA-256 of `cwd + parent_pid` truncated to 16 hex chars (stable across hook invocations of the same agent process).
- Edge-case handling: binary files, files > 100 KB, > 50 % truncation, non-UTF-8, deleted files, concurrent access.

#### Agent integrations

- **Claude Code** — `drip init [-g]` installs three hooks in one shot:
  - `PreToolUse(Read)` — intercepts the native Read tool and substitutes a delta on re-reads.
  - `PreToolUse(Bash)` — recognises `cat`, `head`, `tail`, `less`, `more`, `bat` against a single file (with quote-aware tokenization and `--` end-of-options) and reroutes through DRIP.
  - `PostToolUse(Edit|Write|MultiEdit|NotebookEdit)` — refreshes the baseline after the model edits a file so the immediately-following read returns `[DRIP: unchanged]` instead of replaying the model's own edits.
  - Read tool calls with `offset` / `limit` pass through untouched (no baseline pollution).
- **Codex CLI** — `drip mcp` runs a minimal MCP stdio server exposing a `read_file` tool. `drip init --agent codex` writes the `[mcp_servers.drip]` block to `~/.codex/config.toml` and appends a usage instruction to `~/.codex/AGENTS.md`. Both writes are idempotent and preserve existing user config.
- **Cursor** / **Gemini CLI** — generic stdin/stdout proxy via `drip hook cursor` / `drip hook gemini` (manual wiring; auto-install pending stable hook specs).

#### Stats and observability

- `drip meter` — token-savings stats for the current session: total reads, full vs sent tokens, reduction percentage, top 10 files by tokens saved.
- `drip meter --history` — per-day breakdown for the last 30 days.
- `drip meter --graph` — ASCII bar of sent vs full tokens.
- `drip meter --json` — machine-readable JSON (stable schema: `session_id`, `tokens_full`, `tokens_sent`, `tokens_saved`, `reduction_pct`, `top[]`, `history?`).
- Color thresholds in human output: green ≥ 70 %, yellow 30 – 69 %, red < 30 %. Honors `NO_COLOR` and TTY detection; disables automatically when piped.
- `drip sessions` — list all sessions in the local store.
- `drip reset` — wipe the current session.

#### Distribution

- Pre-built binaries for 5 targets via GitHub Actions on tag push:
  - `x86_64-unknown-linux-gnu`
  - `aarch64-unknown-linux-gnu` (cross-compiled via `cross`)
  - `x86_64-apple-darwin`
  - `aarch64-apple-darwin`
  - `x86_64-pc-windows-msvc`
- `install.sh` one-liner with OS/arch detection and SHA-256 checksums (`SHA256SUMS` published alongside binaries).
- `scripts/test-install.sh` — 9 detection scenarios (Linux x86_64/aarch64/amd64/arm64, macOS Intel/ARM, Windows MINGW/MSYS/Cygwin).
- `scripts/bench.sh` — realistic 50-read / 10-file workload benchmark with token + latency reporting.

### Performance

Measured on the release binary, reproducible via `bash scripts/bench.sh --release`:

| Path                          |   p50    |   p99    |
|-------------------------------|---------:|---------:|
| `drip --version` cold start   | 1.69 ms  | 4.14 ms  |
| `drip read` unchanged         | 2.39 ms  | 2.88 ms  |
| `drip read` 50 KB delta       | 2.65 ms  | 3.27 ms  |
| `drip read` 99 KB delta       | 2.67 ms  | 2.97 ms  |

Realistic workload: 50 reads across 10 files, 4 mutate-and-reread cycles → **77.1 % token reduction** (133,262 → 30,531 tokens).

### Tested

- 60 / 60 tests passing (12 unit + 48 integration).
- Concurrency: 15 simultaneous SQLite-backed processes, zero deadlocks under WAL + 500 ms busy timeout.

[Unreleased]: https://github.com/drip-cli/drip/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/drip-cli/drip/releases/tag/v0.1.0
