diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa09a7..0c95333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased -- Fix: the Claude Code / Codebuddy `PreToolUse` graph-nudge hooks now work on Windows (#522). The hooks were inline POSIX bash (`case/esac`, `[ -f ]`, single-quoted `echo`), which Windows cmd.exe/PowerShell cannot parse — so on Windows the hook failed silently, no "run `graphify query` before grepping/reading raw files" context was injected, and users had to invoke `/graphify` by hand. The detection logic (grep-command match, source-file extension match, skip-if-under-output-dir, graph-exists check) moved into a shell-agnostic `graphify hook-guard ` subcommand invoked via the absolute exe path (the same pattern the codex hook already uses), so the hook parses and runs identically on Windows, macOS, and Linux. Behavior on macOS/Linux is unchanged (byte-identical nudge payload); the graph path now also honors `GRAPHIFY_OUT`. Codex stays a no-op there because Codex Desktop rejects `additionalContext`. +- Fix: the Claude Code / Codebuddy `PreToolUse` and Gemini CLI `BeforeTool` graph-nudge hooks now work on Windows (#522). The hooks were inline POSIX bash (`case/esac`, `[ -f ]`, single-quoted `echo`), which Windows cmd.exe/PowerShell cannot parse — so on Windows the hook failed silently, no "run `graphify query` before grepping/reading raw files" context was injected, and users had to invoke `/graphify` by hand. The detection logic (grep-command match, source-file extension match, skip-if-under-output-dir, graph-exists check) moved into a shell-agnostic `graphify hook-guard ` subcommand invoked via the absolute exe path (the same pattern the codex hook already uses), so the hook parses and runs identically on Windows, macOS, and Linux. Behavior on macOS/Linux is unchanged (byte-identical nudge payload); the graph path now also honors `GRAPHIFY_OUT`. The Gemini `BeforeTool` hook got the same treatment (`graphify hook-guard gemini`), which also removes its dependency on a bare `python` being on PATH. Codex stays a no-op there because Codex Desktop rejects `additionalContext`. - Fix: `--update`-style section writes to `CLAUDE.md`/`AGENTS.md` no longer corrupt or drop content (#1688, thanks @bdfinst). `_replace_or_append_section` located its managed block by substring (`marker in content`) and `next(... if marker in line)`, so a heading that appeared as a substring of another line (or duplicate headings) matched the wrong offset and the rewrite could truncate the file. It now matches the section heading exactly (`line.strip() == marker`), appends when absent, and prefers the last exact match when several exist, so unrelated content is preserved. - Fix: token estimation no longer crashes on files containing tiktoken special-token text like `<|endoftext|>` (#1685, thanks @Kyzcreig). `_TOKENIZER.encode(content)` raises `ValueError` by default when the text contains a special token, which aborted packing on docs/corpora that merely mention these strings. Both `encode` sites now pass `disallowed_special=()` so such text is tokenized as ordinary bytes. - Fix: the Ollama backend no longer multiplies a hang by the retry count (#1686, thanks @Kyzcreig). A stalled local model would wedge for `timeout * (max_retries + 1)`, which with the default 6 retries turned one long stall into a very long one. Ollama now defaults to zero client-side retries (a local model that stalls will not un-stall on retry); set `GRAPHIFY_MAX_RETRIES` to opt back in. Other backends are unchanged. Note: the underlying stall is non-deterministic and driven by the model server, so this bounds the wait rather than eliminating the hang. diff --git a/graphify/__main__.py b/graphify/__main__.py index 4b50234..8d37c91 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -852,23 +852,30 @@ _AGENTS_MD_MARKER = "## graphify" _GEMINI_MD_MARKER = "## graphify" -_GEMINI_HOOK = { - "matcher": "read_file|list_directory", - "hooks": [ - { - "type": "command", - "command": ( - 'python -c "' - "import sys,pathlib,json;" - "e=pathlib.Path('graphify-out/graph.json').exists();" - "d={'decision':'allow'};" - "e and d.update({'additionalContext':'graphify: knowledge graph at graphify-out/. For focused questions, run `graphify query \"\"` (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context.'});" - "sys.stdout.write(json.dumps(d))" - '"' - ), - } - ], -} +# Gemini CLI BeforeTool hook nudge text. The hook always returns +# {"decision":"allow"} (never blocks a tool) and appends this as additionalContext +# when a graph exists. Emitted by `graphify hook-guard gemini`. The old hook was a +# `python -c "..."` one-liner that depended on a bare `python` on PATH (often +# `python`/`py` or absent on Windows) and embedded backticks + escaped quotes that +# Windows PowerShell mangles (#522 follow-up); the subcommand form has no such +# dependency and parses under every shell. +_GEMINI_NUDGE_TEXT = ( + 'graphify: knowledge graph at graphify-out/. For focused questions, run ' + '`graphify query ""` (scoped subgraph, usually much smaller than ' + 'GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only ' + 'for broad architecture context.' +) + + +def _gemini_hook() -> dict: + """Gemini CLI BeforeTool hook, resolved to a shell-agnostic `graphify` call.""" + exe = _resolve_graphify_exe() + if " " in exe and not exe.startswith('"'): + exe = f'"{exe}"' + return { + "matcher": "read_file|list_directory", + "hooks": [{"type": "command", "command": f"{exe} hook-guard gemini"}], + } def gemini_install(project_dir: Path | None = None, *, project: bool = False) -> None: @@ -917,7 +924,7 @@ def _install_gemini_hook(project_dir: Path) -> None: settings["hooks"]["BeforeTool"] = [ h for h in before_tool if "graphify" not in str(h) ] - settings["hooks"]["BeforeTool"].append(_GEMINI_HOOK) + settings["hooks"]["BeforeTool"].append(_gemini_hook()) settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") print(" .gemini/settings.json -> BeforeTool hook registered") @@ -1637,6 +1644,18 @@ def _run_hook_guard(kind: str) -> None: tool call is never blocked. Detection mirrors the previous hooks exactly. """ from graphify.paths import out_path, GRAPHIFY_OUT_NAME + # Gemini's BeforeTool hook takes no stdin and must ALWAYS return a decision so + # the tool is never blocked; the graph nudge is appended only when a graph + # exists. Handled before the stdin read below (which the search/read guards need). + if kind == "gemini": + payload = {"decision": "allow"} + try: + if out_path("graph.json").is_file(): + payload["additionalContext"] = _GEMINI_NUDGE_TEXT + except Exception: + pass + sys.stdout.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))) + return try: d = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace")) except Exception: diff --git a/tests/test_gemini_hook.py b/tests/test_gemini_hook.py new file mode 100644 index 0000000..aa84117 --- /dev/null +++ b/tests/test_gemini_hook.py @@ -0,0 +1,71 @@ +"""The Gemini CLI BeforeTool guard nudges toward the graph, shell-agnostically. + +Since #522 it runs as `graphify hook-guard gemini` (not a `python -c` one-liner +that depended on a bare `python` on PATH and embedded PowerShell-hostile +backticks). It always returns {"decision":"allow"} so a tool is never blocked, +and appends additionalContext only when a graph exists. +""" +import json +import os +import subprocess +import sys + +from graphify.__main__ import _gemini_hook + + +def _env(): + e = dict(os.environ) + e.pop("GRAPHIFY_OUT", None) + return e + + +def _run(cwd, *, graph: bool): + if graph: + (cwd / "graphify-out").mkdir(parents=True, exist_ok=True) + (cwd / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8") + return subprocess.run( + [sys.executable, "-m", "graphify", "hook-guard", "gemini"], + input="", capture_output=True, text=True, cwd=cwd, env=_env(), + ) + + +def test_matcher_and_command_shape(): + h = _gemini_hook() + assert h["matcher"] == "read_file|list_directory" + cmd = h["hooks"][0]["command"] + # #522: no bare `python` dependency, no embedded quote/backtick soup. + assert "python -c" not in cmd + assert "graphify" in cmd and "hook-guard gemini" in cmd + + +def test_allows_and_nudges_with_graph(tmp_path): + out = _run(tmp_path, graph=True).stdout + payload = json.loads(out) + assert payload["decision"] == "allow" + assert "graphify query" in payload["additionalContext"] + + +def test_allows_without_nudge_when_no_graph(tmp_path): + out = _run(tmp_path, graph=False).stdout + payload = json.loads(out) + assert payload["decision"] == "allow" + assert "additionalContext" not in payload + + +def test_never_blocks(tmp_path): + r = _run(tmp_path, graph=True) + assert r.returncode == 0 + payload = json.loads(r.stdout) + assert payload["decision"] == "allow" + + +def test_honors_graphify_out_override(tmp_path): + custom = tmp_path / "custom-out" + custom.mkdir() + (custom / "graph.json").write_text("{}", encoding="utf-8") + env = dict(os.environ, GRAPHIFY_OUT=str(custom)) + r = subprocess.run( + [sys.executable, "-m", "graphify", "hook-guard", "gemini"], + input="", capture_output=True, text=True, cwd=tmp_path, env=env, + ) + assert "graphify query" in json.loads(r.stdout).get("additionalContext", "") diff --git a/tests/test_install_strings.py b/tests/test_install_strings.py index 36c5f56..7e65b76 100644 --- a/tests/test_install_strings.py +++ b/tests/test_install_strings.py @@ -18,7 +18,7 @@ from graphify.__main__ import ( _CLAUDE_MD_SECTION, _AGENTS_MD_SECTION, _GEMINI_MD_SECTION, - _GEMINI_HOOK, + _GEMINI_NUDGE_TEXT, _VSCODE_INSTRUCTIONS_SECTION, _ANTIGRAVITY_RULES, _KIRO_STEERING, @@ -37,7 +37,7 @@ _INSTALL_TEXTS: dict[str, str] = { "_CLAUDE_MD_SECTION": _CLAUDE_MD_SECTION, "_AGENTS_MD_SECTION": _AGENTS_MD_SECTION, "_GEMINI_MD_SECTION": _GEMINI_MD_SECTION, - "_GEMINI_HOOK": json.dumps(_GEMINI_HOOK), + "_GEMINI_NUDGE_TEXT": _GEMINI_NUDGE_TEXT, "_VSCODE_INSTRUCTIONS_SECTION": _VSCODE_INSTRUCTIONS_SECTION, "_ANTIGRAVITY_RULES": _ANTIGRAVITY_RULES, "_KIRO_STEERING": _KIRO_STEERING,