|
4be3595…
|
lmata
|
1 |
#!/usr/bin/env python3 |
|
4be3595…
|
lmata
|
2 |
""" |
|
4be3595…
|
lmata
|
3 |
Navegador hook for Claude Code. |
|
4be3595…
|
lmata
|
4 |
|
|
4be3595…
|
lmata
|
5 |
Fires on PostToolUse to keep the knowledge graph in sync as Claude works: |
|
4be3595…
|
lmata
|
6 |
- Tracks files Claude edits or creates |
|
4be3595…
|
lmata
|
7 |
- Re-ingests changed files into the graph |
|
4be3595…
|
lmata
|
8 |
- Logs decisions and notes Claude produces (when it writes to DECISIONS.md) |
|
4be3595…
|
lmata
|
9 |
|
|
4be3595…
|
lmata
|
10 |
Install: |
|
4be3595…
|
lmata
|
11 |
Copy to your project root as .claude/hooks/navegador.py |
|
4be3595…
|
lmata
|
12 |
Add to .claude/settings.json: |
|
4be3595…
|
lmata
|
13 |
|
|
4be3595…
|
lmata
|
14 |
{ |
|
4be3595…
|
lmata
|
15 |
"hooks": { |
|
4be3595…
|
lmata
|
16 |
"PostToolUse": [ |
|
4be3595…
|
lmata
|
17 |
{ |
|
4be3595…
|
lmata
|
18 |
"matcher": "Edit|Write|Bash", |
|
4be3595…
|
lmata
|
19 |
"hooks": [{ "type": "command", "command": "python3 .claude/hooks/navegador.py" }] |
|
4be3595…
|
lmata
|
20 |
} |
|
4be3595…
|
lmata
|
21 |
] |
|
4be3595…
|
lmata
|
22 |
} |
|
4be3595…
|
lmata
|
23 |
} |
|
4be3595…
|
lmata
|
24 |
""" |
|
4be3595…
|
lmata
|
25 |
|
|
4be3595…
|
lmata
|
26 |
import json |
|
4be3595…
|
lmata
|
27 |
import os |
|
4be3595…
|
lmata
|
28 |
import subprocess |
|
4be3595…
|
lmata
|
29 |
import sys |
|
4be3595…
|
lmata
|
30 |
|
|
4be3595…
|
lmata
|
31 |
NAV_DB = os.environ.get("NAVEGADOR_DB", ".navegador/graph.db") |
|
4be3595…
|
lmata
|
32 |
NAV_CMD = os.environ.get("NAVEGADOR_CMD", "navegador") |
|
4be3595…
|
lmata
|
33 |
|
|
4be3595…
|
lmata
|
34 |
# File extensions that navegador can ingest |
|
4be3595…
|
lmata
|
35 |
INGESTABLE = {".py", ".ts", ".tsx", ".js", ".jsx"} |
|
4be3595…
|
lmata
|
36 |
|
|
4be3595…
|
lmata
|
37 |
|
|
4be3595…
|
lmata
|
38 |
def run_nav(*args) -> str: |
|
4be3595…
|
lmata
|
39 |
result = subprocess.run( |
|
4be3595…
|
lmata
|
40 |
[NAV_CMD, "--db", NAV_DB, *args], |
|
4be3595…
|
lmata
|
41 |
capture_output=True, text=True, |
|
4be3595…
|
lmata
|
42 |
) |
|
4be3595…
|
lmata
|
43 |
return result.stdout.strip() |
|
4be3595…
|
lmata
|
44 |
|
|
4be3595…
|
lmata
|
45 |
|
|
4be3595…
|
lmata
|
46 |
def main(): |
|
4be3595…
|
lmata
|
47 |
try: |
|
4be3595…
|
lmata
|
48 |
payload = json.loads(sys.stdin.read()) |
|
4be3595…
|
lmata
|
49 |
except Exception: |
|
4be3595…
|
lmata
|
50 |
sys.exit(0) |
|
4be3595…
|
lmata
|
51 |
|
|
4be3595…
|
lmata
|
52 |
tool = payload.get("tool_name", "") |
|
4be3595…
|
lmata
|
53 |
inp = payload.get("tool_input", {}) |
|
4be3595…
|
lmata
|
54 |
|
|
4be3595…
|
lmata
|
55 |
# Re-ingest any source file Claude edited |
|
4be3595…
|
lmata
|
56 |
if tool in ("Edit", "Write"): |
|
4be3595…
|
lmata
|
57 |
file_path = inp.get("file_path", "") |
|
4be3595…
|
lmata
|
58 |
ext = os.path.splitext(file_path)[1] |
|
4be3595…
|
lmata
|
59 |
if ext in INGESTABLE and os.path.exists(file_path): |
|
4be3595…
|
lmata
|
60 |
# Ingest just the repo containing this file (fast on small repos) |
|
4be3595…
|
lmata
|
61 |
repo_root = _find_repo_root(file_path) |
|
4be3595…
|
lmata
|
62 |
if repo_root: |
|
4be3595…
|
lmata
|
63 |
run_nav("ingest", repo_root) |
|
4be3595…
|
lmata
|
64 |
|
|
4be3595…
|
lmata
|
65 |
# Watch for DECISIONS.md updates — extract new entries into the graph |
|
4be3595…
|
lmata
|
66 |
if tool in ("Edit", "Write"): |
|
4be3595…
|
lmata
|
67 |
if inp.get("file_path", "").endswith("DECISIONS.md"): |
|
4be3595…
|
lmata
|
68 |
_sync_decisions() |
|
4be3595…
|
lmata
|
69 |
|
|
4be3595…
|
lmata
|
70 |
|
|
4be3595…
|
lmata
|
71 |
def _find_repo_root(path: str) -> str | None: |
|
4be3595…
|
lmata
|
72 |
"""Walk up to find the git root.""" |
|
4be3595…
|
lmata
|
73 |
d = os.path.dirname(os.path.abspath(path)) |
|
4be3595…
|
lmata
|
74 |
while d != os.path.dirname(d): |
|
4be3595…
|
lmata
|
75 |
if os.path.exists(os.path.join(d, ".git")): |
|
4be3595…
|
lmata
|
76 |
return d |
|
4be3595…
|
lmata
|
77 |
d = os.path.dirname(d) |
|
4be3595…
|
lmata
|
78 |
return None |
|
4be3595…
|
lmata
|
79 |
|
|
4be3595…
|
lmata
|
80 |
|
|
4be3595…
|
lmata
|
81 |
def _sync_decisions(): |
|
4be3595…
|
lmata
|
82 |
"""Parse DECISIONS.md and upsert Decision nodes.""" |
|
4be3595…
|
lmata
|
83 |
if not os.path.exists("DECISIONS.md"): |
|
4be3595…
|
lmata
|
84 |
return |
|
4be3595…
|
lmata
|
85 |
content = open("DECISIONS.md").read() |
|
4be3595…
|
lmata
|
86 |
# Simple heuristic: ## headings are decision names |
|
4be3595…
|
lmata
|
87 |
import re |
|
4be3595…
|
lmata
|
88 |
for match in re.finditer(r"^##\s+(.+)", content, re.MULTILINE): |
|
4be3595…
|
lmata
|
89 |
name = match.group(1).strip() |
|
4be3595…
|
lmata
|
90 |
# Find the body until the next heading |
|
4be3595…
|
lmata
|
91 |
start = match.end() |
|
4be3595…
|
lmata
|
92 |
next_h = re.search(r"^##", content[start:], re.MULTILINE) |
|
4be3595…
|
lmata
|
93 |
body = content[start: start + next_h.start() if next_h else len(content)].strip() |
|
4be3595…
|
lmata
|
94 |
run_nav("add", "decision", name, "--desc", body[:500]) |
|
4be3595…
|
lmata
|
95 |
|
|
4be3595…
|
lmata
|
96 |
|
|
4be3595…
|
lmata
|
97 |
if __name__ == "__main__": |
|
4be3595…
|
lmata
|
98 |
main() |