Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "codebase-index",
"displayName": "Codebase Index",
"description": "Give Claude a precise local map of your codebase: find implementations, trace behavior, and predict change impact with file-line evidence.",
"version": "2.1.1",
"version": "2.1.2",
"author": {
"name": "codebase-index contributors"
},
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/codebase-index/.skill_version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.1.1
2.1.2
2 changes: 1 addition & 1 deletion .codex/skills/codebase-index/.skill_version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.1.1
2.1.2
2 changes: 1 addition & 1 deletion .opencode/skills/codebase-index/.skill_version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.1.1
2.1.2
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ All notable changes to this project are documented here. The format is based on

## [Unreleased]

## [2.1.2] - 2026-09-25

### Fixed

- The `PreToolUse` guard no longer blocks work it should let through:
- the first `codebase-index` command of a session turns the guard off even when it
is the command that builds the index; before, a repository without an index kept
the guard on for the whole session. Session state now lives in the system temp
directory (`CBX_HOOK_STATE` overrides it) instead of inside the index;
- heredoc bodies (`cat > script.ps1 <<'EOF'`, `python - <<EOF`) are file content, not
commands, and are no longer scanned for `grep`/`Select-String`;
- a retry is matched by its search term, not by the whole tool call, so a rewritten
command or a new description for the same search goes through;
- a quoted alternation such as `grep "a\|b"` is read whole, and a search on its own
line of a multi-line command is recognised.

## [2.1.1] - 2026-09-25

### Added
Expand Down Expand Up @@ -886,7 +902,8 @@ Pooled over 305 queries (Python, Java, TypeScript), v1.8.0 → 1.9.0:
- Hooks example + `watch` mode for keeping the index fresh without blocking the edit loop (M8).
- `doctor`, `stats`, `clean` diagnostics/maintenance commands.

[Unreleased]: https://github.com/denfry/codebase-index/compare/v2.1.1...HEAD
[Unreleased]: https://github.com/denfry/codebase-index/compare/v2.1.2...HEAD
[2.1.2]: https://github.com/denfry/codebase-index/compare/v2.1.1...v2.1.2
[2.1.1]: https://github.com/denfry/codebase-index/compare/v2.1.0...v2.1.1
[2.1.0]: https://github.com/denfry/codebase-index/compare/v2.0.0...v2.1.0
[2.0.0]: https://github.com/denfry/codebase-index/compare/v1.9.0...v2.0.0
Expand Down
2 changes: 1 addition & 1 deletion docs/installer.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ pwsh ./install.ps1 -Target claude -InstallDir "D:\skills\codebase-index"
**Pin to a branch or tag** (reproducibility and safety):

```sh
sh install.sh --branch v2.1.1
sh install.sh --branch v2.1.2
```

---
Expand Down
2 changes: 1 addition & 1 deletion requirements.lock
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
codebase-index @ https://github.com/denfry/codebase-index/archive/refs/tags/v2.1.1.tar.gz
codebase-index @ https://github.com/denfry/codebase-index/archive/refs/tags/v2.1.2.tar.gz
tree-sitter==0.25.2
tree-sitter-language-pack==1.8.1
2 changes: 1 addition & 1 deletion src/codebase_index/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
See docs/ARCHITECTURE.md for the module map.
"""

__version__ = "2.1.1"
__version__ = "2.1.2"
62 changes: 39 additions & 23 deletions src/codebase_index/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
is indexed, and which commands answer code questions.
- ``guard`` (PreToolUse on Grep and Bash) intercepts a code search the first time
in a session, before any ``codebase-index`` command has run, and names the
index command to use instead. It is a nudge, not a wall: repeating the same
call lets it through, the first ``codebase-index`` command in the session turns
the guard off, and searches over docs, logs or config are never intercepted.
index command to use instead. It is a nudge, not a wall: searching for the
same term again lets it through, the first ``codebase-index`` command in the
session turns the guard off (even one that has yet to build the index), and
searches over docs, logs or config, or inside heredoc bodies, are never
intercepted.

Measured on a 5.9k-file monorepo, an agent spent 19% fewer tokens with the index
than with grep (tests/eval/results/2026-09-24-agent-pilot.md), but agents reach
Expand All @@ -17,6 +19,8 @@

Entry point: ``codebase-index-hook <event>`` reads the hook JSON on stdin and
prints the hook JSON reply (or nothing). ``CBX_GUARD=0`` disables the guard.
Per-session state lives in the system temp directory (``CBX_HOOK_STATE``
overrides it), so it exists before the repository has an index.
"""

from __future__ import annotations
Expand All @@ -27,21 +31,31 @@
import re
import sqlite3
import sys
import tempfile
import time
from pathlib import Path
from typing import Any, Optional

INDEX_REL = Path(".claude") / "cache" / "codebase-index" / "index.sqlite"
_STATE_DIR = "hook-sessions"
_STATE_DIR = "codebase-index-hooks"
_STATE_TTL_S = 2 * 24 * 3600

# A shell segment that searches file contents: the first command of the segment
# (after `&&`, `||`, `;` or the start), so `ps aux | grep x` is not a repo search.
# (after `&&`, `||`, `;`, a newline or the start), so `ps aux | grep x` is not a
# repo search. Quoted arguments may hold `|`, as in grep "a\|b".
_SEARCH_CMD = re.compile(
r"(?:^|&&|\|\||;)\s*(?:cd\s+\S+\s*&&\s*)?"
r"(?P<cmd>git\s+grep|grep|egrep|fgrep|rg|ag|ack|findstr|Select-String)\b(?P<args>[^|;&]*)",
r"(?:^|&&|\|\||;|\n)[ \t]*(?:cd\s+\S+\s*&&\s*)?"
r"(?P<cmd>git\s+grep|grep|egrep|fgrep|rg|ag|ack|findstr|Select-String)\b"
r"""(?P<args>(?:"[^"\n]*"|'[^'\n]*'|[^|;&\n"'])*)""",
re.IGNORECASE,
)
# A heredoc body is file content (a script being written, stdin for python), not
# commands; an unterminated one runs to the end of the command.
_HEREDOC = re.compile(
r"""(?<!<)<<-?(?!<)[ \t]*(['"]?)([A-Za-z_][\w-]*)\1(?P<rest>[^\n]*)\n"""
r".*?(?:^[ \t]*\2[ \t]*$|\Z)",
re.DOTALL | re.MULTILINE,
)
_INDEX_CMD = re.compile(r"(?:^|[\s;&|/\\\"'])(?:codebase-index|cbx)(?:\.exe|\.ps1)?\s+\w")
# Searches over prose, logs and config are what grep is for; leave them alone.
_NON_CODE = re.compile(
Expand Down Expand Up @@ -132,15 +146,12 @@ def guard(payload: dict) -> Optional[dict]:
tool_input = payload.get("tool_input") or {}
if tool not in ("Grep", "Bash"):
return None
root = find_index_root(_cwd(payload))
if root is None:
return None
session = str(payload.get("session_id") or "default")
state = _State(root, session)
state = _State(str(payload.get("session_id") or "default"))

if tool == "Bash":
command = str(tool_input.get("command") or "")
command = _HEREDOC.sub(lambda m: "<<" + m.group("rest"), str(tool_input.get("command") or ""))
if _INDEX_CMD.search(command):
# Checked before the index exists: the first search is what builds it.
state.mark_used()
return None
pattern = _shell_search_pattern(command)
Expand All @@ -151,11 +162,11 @@ def guard(payload: dict) -> Optional[dict]:
return None
pattern = str(tool_input.get("pattern") or "")

if state.used:
if state.used or find_index_root(_cwd(payload)) is None:
return None
fingerprint = hashlib.sha1(
(tool + json.dumps(tool_input, sort_keys=True, default=str)).encode("utf-8")
).hexdigest()
# Keyed by the search term, not the whole call: a retry rarely repeats the
# command byte for byte (a new description, another pipe, a regenerated script).
fingerprint = hashlib.sha1(_term(pattern).lower().encode("utf-8")).hexdigest()
if state.was_denied(fingerprint):
return None # the retry after a nudge: the agent decided grep is right
state.deny(fingerprint)
Expand Down Expand Up @@ -190,26 +201,31 @@ def _grep_targets_non_code(tool_input: dict) -> bool:
return bool(_NON_CODE.search(target))


def _term(pattern: str) -> str:
"""A regex search pattern as plain query words."""
return " ".join(re.sub(r"""[\\^$()\[\]{}|*+?"']""", " ", pattern).split())[:80]


def _reason(pattern: str) -> str:
term = re.sub(r"[\\^$()\[\]{}|*+?]", " ", pattern).strip() or "<what you are looking for>"
term = " ".join(term.split())[:80]
term = _term(pattern) or "<what you are looking for>"
return (
"codebase-index: this repository is indexed, and the index answers code "
"searches with ranked, numbered lines in fewer tokens than grep. Run it first:\n"
f' codebase-index search "{term}" --compact # where / how\n'
' codebase-index refs "Owner.member" --compact # every call site, with callers\n'
' codebase-index symbol "Name" # a definition\n'
"If the index does not answer, repeat this exact call and it will run. Once any "
"codebase-index command has run in this session, searches are not intercepted."
"If the index does not answer, search for the same term again and it will run. "
"Once any codebase-index command has run in this session, searches are not "
"intercepted."
)


class _State:
"""Per-session guard state: whether the index was used, which calls were nudged."""

def __init__(self, root: Path, session: str) -> None:
def __init__(self, session: str) -> None:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", session)[:80] or "default"
self.dir = root / INDEX_REL.parent / _STATE_DIR
self.dir = Path(os.environ.get("CBX_HOOK_STATE") or Path(tempfile.gettempdir()) / _STATE_DIR)
self.path = self.dir / f"{safe}.json"
self.data: dict[str, Any] = {"used": False, "denied": []}
try:
Expand Down
37 changes: 37 additions & 0 deletions tests/test_hooks_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
from codebase_index import hooks, scaffold


@pytest.fixture(autouse=True)
def _state_dir(tmp_path: Path, monkeypatch):
monkeypatch.setenv("CBX_HOOK_STATE", str(tmp_path / "hook-state"))


@pytest.fixture
def repo(tmp_path: Path) -> Path:
db = tmp_path / "repo" / hooks.INDEX_REL
Expand Down Expand Up @@ -68,6 +73,38 @@ def test_non_code_and_non_search_shell_commands_pass(repo: Path, command: str):
assert _call(repo, "Bash", command=command) is None


def test_the_retry_is_matched_by_search_term_not_by_the_whole_call(repo: Path):
assert _denied(_call(repo, "Bash", command="grep -rn TownService src",
description="Find the service"))
assert _call(repo, "Bash", command="cd src && grep -rn TownService . | head",
description="Find it again") is None


def test_an_index_command_that_builds_the_index_turns_the_guard_off(tmp_path: Path, repo: Path):
# The first `codebase-index search` in a fresh repository builds the index, so the
# guard sees that call before any index exists.
fresh = tmp_path / "fresh"
(fresh / "src").mkdir(parents=True)
assert _call(fresh, "Bash", command='codebase-index search "x" --compact') is None
assert _call(repo, "Grep", pattern="anything") is None


@pytest.mark.parametrize("command", [
# A heredoc body is file content, not commands.
"cat > /tmp/close.ps1 <<'EOF'\n$log = Get-Content x; Select-String \"BUILD FAILED\" $log\nEOF\n"
"echo done",
"python - <<EOF\nimport re; grep = 1\nEOF",
])
def test_heredoc_bodies_are_not_searched(repo: Path, command: str):
assert _call(repo, "Bash", command=command) is None


def test_a_search_on_its_own_line_is_guarded_and_quoted_alternation_kept(repo: Path):
reply = _call(repo, "Bash", command='ls src\ngrep -rn "Lifecycle\\|onInit" src --include=*.java')
assert _denied(reply)
assert 'search "Lifecycle onInit"' in reply["hookSpecificOutput"]["permissionDecisionReason"]


@pytest.mark.parametrize("command", [
"grep -rn treasurerDeparted --include=*.java .",
"cd /c/Projects/x && rg -n 'TownService.refresh' realism-polity",
Expand Down
Loading