Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Watcher-triggered reindexes were invisible in the serve TUI, and branch switches never rebuilt symbols.** Three related gaps in the `codesearch serve` file watcher: (1) the ordinary text-batch reindex (the most common watcher activity) never signalled the TUI, so editing a file showed nothing in the status column even though the index updated — despite the callback's own doc claiming it fired on "batch flushes"; (2) a C# symbol rebuild toggled only the general repo-state label, never the C#-specific indicator, so that column never showed "Indexing" during the (30–90s) rebuild; (3) a git **branch switch** refreshed only the text index and discarded the buffered `.cs`/`.ts` events without rebuilding symbols, leaving `find_impact` serving references from the previous branch until the next incidental `.cs` edit or a serve restart. Now: the text-batch flush toggles the TUI "Indexing" label; the C# notifier is a 3-state signal (`Started`/`Succeeded`/`Failed`) so the C# indicator shows "Indexing" for the rebuild duration; and a branch switch triggers a full C#/TypeScript symbol rebuild. Watcher symbol-rebuild log lines now carry the repo label for multi-repo attribution.
- **`model: unknown` on indexes created via the serve / git-hook path (git worktrees especially).** When a repo was registered through `POST /repos` (the git-hook flow), the vector store was opened first and `ensure_schema_version` pre-created a `metadata.json` containing only `schema_version` — no model fields. The force-reindex path then saw the file already existed and skipped stamping the default model, so the index was left with no `model_short_name`. Every reader reported `model: unknown`, and that sentinel disabled the empty-index live-chunk-count self-heal, making a perfectly good worktree index look empty so agents fell back to grep. The serve/git-hook and incremental-refresh paths now always stamp the resolved model. As part of the fix, the model→metadata stamp (`model_short_name`/`model_name`/`dimensions`) is consolidated into a single `ModelType::write_metadata_fields` source of truth across all five index-creation sites — which also corrects a pre-existing drift where the auto-create-DB path wrote the Debug variant name (e.g. `AllMiniLML6V2Q`) as `model_name` instead of the real model name. Existing worktree indexes need one reindex to pick up the stamped model.
- **claude-code grep-guard hook leaked `grep` on every low-confidence codesearch result.** The hook blocked the first `Grep` on an indexed repo path but auto-unblocked the *same* query when retried within 5 minutes — intended as the "codesearch found nothing, fall back to grep" path. But a low-confidence or empty codesearch result is a *successful* call meaning "reformulate the query", not a dead server, so the retry-cache let `grep` through whenever a query merely scored below the relevance floor (e.g. punctuation-heavy or alternation patterns). Replaced the retry-cache with an active liveness probe: the hook now GETs the serve hub's unauthenticated `/healthz` endpoint (base URL from `CODESEARCH_SERVER`, else `127.0.0.1:$CODESEARCH_SERVE_PORT`, else the compiled default `:39725`) and keeps `grep` blocked whenever the server answers, allowing it only when the probe fails — i.e. codesearch is genuinely down. Both the PowerShell and bash hooks are updated (the bash hook now also requires `curl`), and the deny message steers to `find`/`explore`/single-clean-term reformulation instead of promising an auto-unblock.

## [1.1.31] - 2026-07-23

Expand Down
29 changes: 18 additions & 11 deletions integrations/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@ parent's `AGENTS.md` or the MCP `initialize` instructions at all.
Two [Claude Code hooks](https://docs.claude.com/en/docs/claude-code/hooks)
that make the preference *structural* instead of advisory:

- **`grep-guard`** — a `PreToolUse` hook on `Grep`. Blocks the first `Grep`
call against an internal repo path when codesearch looks available, with a
message telling the model exactly how to load and call codesearch instead.
If the *same* query is retried within 5 minutes, it's let through
unblocked — that's the legitimate "codesearch found nothing, falling back"
path. Grep against paths outside the current repo is never blocked;
codesearch doesn't cover arbitrary external paths well, grep is right there.
- **`grep-guard`** — a `PreToolUse` hook on `Grep`. Blocks every `Grep`
call against an internal repo path *for as long as the codesearch serve hub
is reachable*, with a message telling the model exactly how to load and call
codesearch instead. Grep is auto-allowed **only** when codesearch is
genuinely down: the hook probes the unauthenticated `/healthz` liveness
endpoint and lets Grep through only when that probe fails. A low-confidence
or empty codesearch *result* is a successful call ("reformulate"), not a dead
server, so it does **not** unblock Grep. Grep against paths outside the
current repo is never blocked; codesearch doesn't cover arbitrary external
paths well, grep is right there.

- **`subagent-preamble`** — a `PreToolUse` hook on `Agent` (the subagent-spawn
tool). Prepends a short preamble to every subagent prompt explaining that
Expand Down Expand Up @@ -127,7 +130,11 @@ points at `hooks/codesearch/`) from `settings.json`, and delete
- Both hooks are per-machine, not per-repo: install once at user scope and
every project benefits, including ones without a local `.codesearch.db`
(the guard simply won't block Grep there, since step 2 fails open).
- The 5-minute retry-unblock window is a heuristic, not a guarantee the model
actually called codesearch in between. It's deliberately permissive —
the goal is nudging the *first* attempt, not adversarially trapping the
model into an unusable state.
- `grep-guard` decides "is codesearch down?" by probing the serve hub's
unauthenticated `/healthz` endpoint (base URL from `CODESEARCH_SERVER`, else
`http://127.0.0.1:$CODESEARCH_SERVE_PORT`, else the compiled default
`http://127.0.0.1:39725`). Any HTTP response counts as up and keeps Grep
blocked; only a connection-level failure (refused / timeout) counts as down
and lets Grep through. The probe has a 2-second timeout, so a wedged server
eventually fails open rather than stalling every Grep. The PowerShell hook
needs no extra tools; the bash hook additionally requires `curl`.
143 changes: 86 additions & 57 deletions integrations/claude-code/hooks/grep-guard.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,31 @@
# `initialize` instructions (see docs) are advisory only — nothing stops the
# model from reaching for the always-on Grep/Glob tools instead, especially
# under time pressure. This hook makes the preference structural instead of
# advisory: the FIRST Grep call against an internal path is blocked with
# actionable guidance; if the same query is retried within 5 minutes (i.e.
# codesearch was tried and came up empty), it is let through.
# advisory: a Grep call against an indexed internal path is blocked with
# actionable guidance for as long as the codesearch serve hub is reachable.
#
# Blocks the first Grep call for a given (pattern, path) pair when:
# - codesearch appears to be active (running process, CODESEARCH_SERVER env,
# or an indexed .codesearch.db at the git root), AND
# Grep is auto-allowed ONLY when codesearch is genuinely unreachable ("plat").
# Crucially, a low-confidence / empty codesearch *result* is a SUCCESSFUL call
# ("reformulate your query"), NOT "codesearch is down" — so it must never open
# the grep escape hatch. The previous version used a blind "same query retried
# within 5 min" proxy that could not tell those two apart and leaked grep on
# every low-confidence result. We now probe the unauthenticated /healthz
# liveness endpoint directly, which is the only signal that actually means
# "codesearch is down".
#
# Blocks the Grep call when ALL of:
# - the search path is internal (empty/relative, or absolute-but-inside the
# current git repo)
# current git repo), AND
# - codesearch covers THIS repo (indexed .codesearch.db at git root, or the
# CODESEARCH_SERVER opt-in for remote/hub-only setups), AND
# - the codesearch serve hub answers its /healthz liveness probe (it's UP)
#
# Passes through (exit 0, no block) when:
# - codesearch is not running and no local index is found — grep is all
# you have, so don't get in the way
# - the path is outside the current git repo (codesearch doesn't cover
# arbitrary external paths well; grep is the right tool there)
# - the same (pattern, path) pair was already blocked in the last 5 minutes
# (covers the legitimate "codesearch found nothing, now try grep" case)
# - codesearch does not cover this repo (no local index, no CODESEARCH_SERVER)
# - the codesearch serve hub does not answer /healthz — it's down, so grep
# is genuinely all you have
#
# Install: see ../README.md (or run ../install.ps1 to wire this up automatically).

Expand All @@ -40,9 +48,8 @@ $inp = $data.tool_input
if ($tool -ne 'Grep') { exit 0 }
if ($null -eq $inp) { exit 0 }

$names = @($inp.PSObject.Properties.Name)
$path = if ($names -contains 'path') { [string]$inp.path } else { '' }
$pattern = if ($names -contains 'pattern') { [string]$inp.pattern } else { '' }
$names = @($inp.PSObject.Properties.Name)
$path = if ($names -contains 'path') { [string]$inp.path } else { '' }

# ------------------------------------------------------------------
# 1. Is the path internal to the current repo?
Expand Down Expand Up @@ -71,7 +78,7 @@ if ($path -and $path -ne '.' -and $path -ne './') {
if (-not $isInternal) { exit 0 }

# ------------------------------------------------------------------
# 2. Is codesearch actually available FOR THIS REPO? Don't block if it isn't.
# 2. Does codesearch COVER this repo? Don't block if it doesn't.
#
# NOTE: we deliberately do NOT treat "a codesearch process is running" as
# sufficient. codesearch commonly runs as a persistent background `serve`
Expand All @@ -82,7 +89,7 @@ if (-not $isInternal) { exit 0 }
# the machine, including ones with no index at all. A local `.codesearch.db`
# at the git root is the precise, fast signal that THIS repo is indexed.
# ------------------------------------------------------------------
function Test-CodesearchAvailable {
function Test-CodesearchCoversRepo {
try {
$gr = (& git rev-parse --show-toplevel 2>$null)
if ($LASTEXITCODE -eq 0 -and $gr) {
Expand All @@ -100,64 +107,86 @@ function Test-CodesearchAvailable {
return $false
}

if (-not (Test-CodesearchAvailable)) { exit 0 }
if (-not (Test-CodesearchCoversRepo)) { exit 0 }

# ------------------------------------------------------------------
# 3. Retry cache: same (pattern, path) blocked recently -> let it through.
# Covers "tried codesearch, it returned nothing, falling back to grep".
# 3. Is the codesearch serve hub actually UP right now? (Liveness probe.)
#
# This is the ONLY condition under which grep is auto-allowed: codesearch is
# genuinely unreachable ("plat"). We probe the unauthenticated /healthz
# liveness endpoint (fixed {"status":"ok"} body, no API key required). A
# reachable server -> DENY grep and force a codesearch reformulation, even
# when a previous codesearch call returned a low-confidence / empty result —
# an empty *result* is a SUCCESSFUL call, not a dead server, so it must NOT
# open the escape hatch. Only a connection-level failure (refused / DNS /
# timeout) means the server is down -> ALLOW grep.
#
# Base URL resolution (mirrors codesearch src/constants.rs):
# CODESEARCH_SERVER (full base URL, e.g. http://host:port)
# > http://127.0.0.1:$CODESEARCH_SERVE_PORT
# > http://127.0.0.1:39725 (DEFAULT_SERVE_URL / DEFAULT_SERVE_PORT)
# ------------------------------------------------------------------
$cacheFile = Join-Path $env:TEMP '.codesearch-grep-guard.json'
$cacheTTL = 300 # seconds

$cache = @{}
if (Test-Path $cacheFile) {
try {
$stored = Get-Content $cacheFile -Raw | ConvertFrom-Json
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
foreach ($prop in $stored.PSObject.Properties) {
if (($now - [long]$prop.Value) -lt $cacheTTL) {
$cache[$prop.Name] = [long]$prop.Value
}
}
} catch {}
function Get-CodesearchBaseUrl {
if ($env:CODESEARCH_SERVER) { return ($env:CODESEARCH_SERVER.TrimEnd('/')) }
if ($env:CODESEARCH_SERVE_PORT) { return "http://127.0.0.1:$($env:CODESEARCH_SERVE_PORT)" }
return 'http://127.0.0.1:39725'
}

$cacheKey = "$pattern|$path"
if ($cache.ContainsKey($cacheKey)) {
exit 0 # already blocked once this window -> allow the retry
function Test-CodesearchLive {
$base = Get-CodesearchBaseUrl
try {
# Short timeout keeps grep latency low; /healthz answers instantly.
$null = Invoke-WebRequest -Uri "$base/healthz" -TimeoutSec 2 -UseBasicParsing
return $true
} catch {
# An HTTP error RESPONSE (4xx/5xx) still proves the server is reachable
# and up; only a connection-level failure means it's genuinely down.
try {
if ($null -ne $_.Exception -and $null -ne $_.Exception.Response) { return $true }
} catch {}
return $false
}
}

$cache[$cacheKey] = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
try {
$cache | ConvertTo-Json -Compress | Set-Content $cacheFile -NoNewline
} catch {}
# codesearch is DOWN -> grep is genuinely all you have, let it through.
if (-not (Test-CodesearchLive)) { exit 0 }

# ------------------------------------------------------------------
# 4. Block with actionable guidance
# ------------------------------------------------------------------
$msg = @"
codesearch is active for this repo — try it before Grep for code discovery.
codesearch is LIVE for this repo (its /healthz probe just answered) — use it,
do NOT fall back to Grep. Grep on an indexed internal path is only auto-allowed
when the codesearch serve hub is actually DOWN, which it is not right now.

IMPORTANT: a low-confidence or EMPTY codesearch result is a SUCCESSFUL call that
means "reformulate your query" — it does NOT mean codesearch is down and it will
NOT unblock Grep. Reformulate instead of grepping.

Step 1 — load the deferred MCP tool schemas (Claude Code defers all MCP tools;
this is a one-time step per conversation):
ToolSearch("select:mcp__codesearch__search,mcp__codesearch__find,mcp__codesearch__explore,mcp__codesearch__get_chunk")

Step 2 — search:
mcp__codesearch__search(query="$pattern", mode="semantic") -- concepts, identifiers, cross-file
mcp__codesearch__search(query="$pattern", mode="literal", regex=true) -- exact pattern / regex
mcp__codesearch__find(symbol="...", kind="definition") -- symbol definition
mcp__codesearch__find(symbol="...", kind="usages") -- all call sites

Multi-repo serve mode: if the search returns a "scope_required" or
"Unknown alias" error, you MUST pass project="<repo-alias>" (single repo) or
group="<group>" (cross-repo). The error response LISTS the valid
available_projects / available_groups — pick from that list (the alias may
differ from the folder name). Example:
mcp__codesearch__search(query="$pattern", mode="semantic", project="<alias-from-error>")

This exact Grep call is auto-unblocked if you retry it within 5 minutes
(i.e. codesearch returned nothing useful — go ahead and grep).
Grep is always allowed for paths outside the current repo.
Step 2 — pick the RIGHT tool (this is usually why a query came back empty):
find(symbol="Name", kind="definition") -- known symbol / type / function definition
find(symbol="Name", kind="usages") -- all call sites of a known symbol
explore(kind="outline", target="path") -- every symbol in one file
search(query="concept", mode="semantic") -- concepts / cross-file, the DEFAULT
search(query="exact", mode="literal", regex=true) -- exact syntax / pattern

Query hygiene (this is what produces "low_confidence: []"):
* Do NOT paste grep-style multi-term alternations ("a|b|c", "::", "fn foo(")
into search — BM25 tokenises on punctuation and the match scores below the
relevance floor, so you get an empty result even though the string exists.
* Use ONE clean term, or switch to find()/explore() for exact symbols.

Multi-repo serve mode: if the call returns a "scope_required" or "Unknown alias"
error, you MUST pass project="<repo-alias>" (single repo) or group="<group>"
(cross-repo). The error response LISTS the valid available_projects /
available_groups — pick from that list (the alias may differ from the folder
name).

Grep is always allowed for paths OUTSIDE the current repo.
"@

$out = @{
Expand Down
Loading
Loading