graphite-code 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/agent_hooks.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""Claude Code agent-hook handlers enforcing graphite-first on onboarded repos.
|
|
2
|
+
|
|
3
|
+
Every handler is fail-open by contract: any parse, IO, graph, or timing problem
|
|
4
|
+
yields ``None`` (no hook output) rather than an exception, so a hook bug can
|
|
5
|
+
never break a tool call or a session. The CLI wrapper adds a second catch-all.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import savings as savings_model
|
|
17
|
+
from . import usage_ledger
|
|
18
|
+
from .activation import mark_active
|
|
19
|
+
from .config import Config
|
|
20
|
+
from .freshness import check_graph_freshness
|
|
21
|
+
from .graph_io import load_validated_graph_bundle
|
|
22
|
+
from .health import persisted_resolution
|
|
23
|
+
from .incident_ledger import record_incident, repo_ledger_dir
|
|
24
|
+
|
|
25
|
+
SESSION_CONTRACT = (
|
|
26
|
+
"graphite-first: this repo uses a shared code graph. For cross-file questions "
|
|
27
|
+
"(who-calls / who-reads / where-defined / data-flow / blast-radius / structure), run "
|
|
28
|
+
'python -m graphite context <file> | impact <file> | query "..." | search "..." BEFORE '
|
|
29
|
+
"grep/glob exploration; manual search is for literal text and filename lookups. Fall "
|
|
30
|
+
"back only when a graph answer proved insufficient, and say so. See GRAPHITE.md."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_FRESHNESS_BUDGET_SECONDS = 2.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _payload_root(payload: dict[str, Any]) -> Path:
|
|
37
|
+
cwd = payload.get("cwd")
|
|
38
|
+
if isinstance(cwd, str) and cwd:
|
|
39
|
+
return Path(cwd)
|
|
40
|
+
return Path.cwd()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _activate(root: Path) -> None:
|
|
44
|
+
"""Register this repo as open, so the daemon supervises it and leaves the
|
|
45
|
+
rest of the machine alone.
|
|
46
|
+
|
|
47
|
+
Guarded separately from the caller's own try/except: activation is
|
|
48
|
+
bookkeeping, and this module is fail-open by contract -- a broken registry
|
|
49
|
+
must never cost the agent its session context or its graph-first reminder.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
mark_active(root, "claude")
|
|
53
|
+
except Exception:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _freshness_within_budget(root: Path) -> tuple[str, str | None]:
|
|
58
|
+
"""Return ``(state, reason)`` within budget, never raising.
|
|
59
|
+
|
|
60
|
+
``state`` is 'fresh' | 'stale' | 'timeout' | 'unknown'.
|
|
61
|
+
|
|
62
|
+
'timeout' and 'unknown' are kept apart deliberately (#24). Both used to
|
|
63
|
+
collapse into a single "unknown" message, but they mean different things
|
|
64
|
+
and the timeout case is the one most likely to be hiding a real STALE --
|
|
65
|
+
a check that runs long suggests a large, cold, or unbuilt repo, which is
|
|
66
|
+
exactly the population that tends to be stale. Reporting them identically
|
|
67
|
+
made the reassuring reading the default in the least reassuring case.
|
|
68
|
+
|
|
69
|
+
``reason`` carries ``check_graph_freshness``'s own explanation
|
|
70
|
+
(``engine_changed``, ``missing manifest``, ...) so a STALE message can say
|
|
71
|
+
why without the agent running ``graphite check .`` a second time to find
|
|
72
|
+
out. It is advisory: absent or malformed, the state still stands.
|
|
73
|
+
"""
|
|
74
|
+
outcome: list[tuple[str, str | None]] = [("unknown", None)]
|
|
75
|
+
|
|
76
|
+
def _worker() -> None:
|
|
77
|
+
try:
|
|
78
|
+
cfg = Config(output_dir=root / "graph-out", cache_dir=root / ".cache" / "graphite")
|
|
79
|
+
status = check_graph_freshness(root, cfg)
|
|
80
|
+
raw = status.get("reason")
|
|
81
|
+
reason = raw if isinstance(raw, str) and raw else None
|
|
82
|
+
outcome[0] = ("stale", reason) if status.get("stale") else ("fresh", reason)
|
|
83
|
+
except Exception:
|
|
84
|
+
outcome[0] = ("unknown", None)
|
|
85
|
+
|
|
86
|
+
worker = threading.Thread(target=_worker, daemon=True)
|
|
87
|
+
worker.start()
|
|
88
|
+
worker.join(_FRESHNESS_BUDGET_SECONDS)
|
|
89
|
+
# The thread is a daemon and is never joined again, so a slow check is
|
|
90
|
+
# abandoned rather than waited on. is_alive() is what separates "still
|
|
91
|
+
# running" from "ran and gave up" -- outcome[0] alone cannot.
|
|
92
|
+
if worker.is_alive():
|
|
93
|
+
return ("timeout", None)
|
|
94
|
+
return outcome[0]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def handle_session_start(payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
98
|
+
try:
|
|
99
|
+
root = _payload_root(payload)
|
|
100
|
+
_activate(root)
|
|
101
|
+
if (root / "graph-out" / "graph.json").is_file():
|
|
102
|
+
state, reason = _freshness_within_budget(root)
|
|
103
|
+
if state == "stale":
|
|
104
|
+
cause = f" ({reason})" if reason else ""
|
|
105
|
+
status = f"Graph status: STALE{cause} - run `python -m graphite build .` before relying on graph answers."
|
|
106
|
+
elif state == "fresh":
|
|
107
|
+
status = "Graph status: fresh."
|
|
108
|
+
elif state == "timeout":
|
|
109
|
+
status = (
|
|
110
|
+
f"Graph status: unknown (freshness check did not finish in {_FRESHNESS_BUDGET_SECONDS}s) "
|
|
111
|
+
"- verify with `python -m graphite check .`."
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
status = (
|
|
115
|
+
"Graph status: unknown (check ran, could not determine) "
|
|
116
|
+
"- verify with `python -m graphite check .`."
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
status = "Graph status: missing - run `python -m graphite build .` to create it."
|
|
120
|
+
return {
|
|
121
|
+
"hookSpecificOutput": {
|
|
122
|
+
"hookEventName": "SessionStart",
|
|
123
|
+
"additionalContext": f"{SESSION_CONTRACT}\n{status}",
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
except Exception:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
PRE_TOOL_REMINDER = (
|
|
131
|
+
"graph-first: this repo's graphite graph answers relationship questions. For who-calls / "
|
|
132
|
+
"who-reads / data-flow / blast-radius / where-defined questions, use python -m graphite "
|
|
133
|
+
'context <file> | impact <file> | query "..." instead of grepping or globbing across '
|
|
134
|
+
"files. Literal text and filename searches are fine. Falling back after an insufficient "
|
|
135
|
+
"graph answer is allowed - say so when you do."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
STRICT_SUSPENSION_NOTE = (
|
|
139
|
+
" (strict denial suspended: graph resolution health is low or unknown — "
|
|
140
|
+
"grep fallback allowed.)"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
MAX_HOOK_GRAPH_BYTES = 32 * 1024 * 1024
|
|
144
|
+
_MAX_TOKENS_CHECKED = 5
|
|
145
|
+
_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{2,}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _pattern_tokens(pattern: str) -> list[str]:
|
|
149
|
+
"""Identifier-like tokens from a grep pattern, deduplicated, capped."""
|
|
150
|
+
seen: set[str] = set()
|
|
151
|
+
tokens: list[str] = []
|
|
152
|
+
for token in _IDENTIFIER_RE.findall(pattern):
|
|
153
|
+
low = token.lower()
|
|
154
|
+
if low in seen:
|
|
155
|
+
continue
|
|
156
|
+
seen.add(low)
|
|
157
|
+
tokens.append(token)
|
|
158
|
+
if len(tokens) >= _MAX_TOKENS_CHECKED:
|
|
159
|
+
break
|
|
160
|
+
return tokens
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _graph_symbol(root: Path, tokens: list[str]) -> tuple[str, str] | None:
|
|
164
|
+
"""First token that IS a graph node (exact id or name match), else None."""
|
|
165
|
+
graph_path = root / "graph-out" / "graph.json"
|
|
166
|
+
if not graph_path.is_file() or graph_path.stat().st_size > MAX_HOOK_GRAPH_BYTES:
|
|
167
|
+
return None
|
|
168
|
+
_, graph = load_validated_graph_bundle(graph_path, root=root, max_bytes=MAX_HOOK_GRAPH_BYTES)
|
|
169
|
+
lowered = {token.lower(): token for token in tokens}
|
|
170
|
+
for node_id, data in graph.nodes(data=True):
|
|
171
|
+
node_lower = node_id.lower()
|
|
172
|
+
name = str(data.get("name", "")).lower()
|
|
173
|
+
if node_lower in lowered:
|
|
174
|
+
return node_id, lowered[node_lower]
|
|
175
|
+
if name and name in lowered:
|
|
176
|
+
return node_id, lowered[name]
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Search binaries reachable from the Bash tool. The PreToolUse matcher was
|
|
181
|
+
# "Grep|Glob" -- tool NAMES -- so `grep` run through Bash bypassed this hook
|
|
182
|
+
# entirely, and consumer agents reported using exactly that route for
|
|
183
|
+
# cross-file work. Enforcing on the Grep tool alone enforces a naming
|
|
184
|
+
# convention, not a rule.
|
|
185
|
+
_SEARCH_COMMANDS = frozenset({
|
|
186
|
+
"grep", "egrep", "fgrep", "rg", "ag", "ack", "findstr",
|
|
187
|
+
# PowerShell's grep, plus its alias. The PowerShell tool is a separate
|
|
188
|
+
# tool name from Bash, so it needs its own matcher entry as well.
|
|
189
|
+
"select-string", "sls",
|
|
190
|
+
})
|
|
191
|
+
# `find -name` is deliberately absent: a filename lookup is not a cross-file
|
|
192
|
+
# content search, and the graph-first contract explicitly permits it.
|
|
193
|
+
_SHELL_OPERATORS = frozenset({"|", "||", "&&", "&", ";", "|&"})
|
|
194
|
+
_ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
195
|
+
# Flags whose VALUE is the pattern. Without these, `grep -e PAT path` and
|
|
196
|
+
# PowerShell's `-Path . -Pattern PAT` put a non-pattern token first and the
|
|
197
|
+
# positional guess silently reads the wrong one -- which fails open, i.e. the
|
|
198
|
+
# search is allowed through. Honouring them explicitly is what closes that.
|
|
199
|
+
_PATTERN_FLAGS = frozenset({"-e", "--regexp", "-pattern", "--pattern"})
|
|
200
|
+
# Matched case-SENSITIVELY for the POSIX short forms. The old lookup lowercased
|
|
201
|
+
# every token, so `-E` (extended-regex mode, takes no value) collided with `-e`
|
|
202
|
+
# (whose value IS the pattern) and `grep -E -i PAT file` read `-i` as the
|
|
203
|
+
# pattern. That direction fails open, so it denied nothing -- but it also
|
|
204
|
+
# matched nothing it should have.
|
|
205
|
+
_PATTERN_FLAGS_EXACT = frozenset({"-e", "--regexp"})
|
|
206
|
+
_PATTERN_FLAGS_FOLDED = frozenset({"-pattern", "--pattern"})
|
|
207
|
+
|
|
208
|
+
# Redirection operators as `shlex(punctuation_chars=True)` emits them. It splits
|
|
209
|
+
# `2>/dev/null` into `2`, `>` and `/dev/null`, all three of which then looked
|
|
210
|
+
# like path ARGUMENTS to the search.
|
|
211
|
+
_REDIRECTION_TOKENS = frozenset({">", ">>", "<", "<<", "<<<", ">&", "<&", ">|", "&>", "&>>"})
|
|
212
|
+
|
|
213
|
+
# Flags whose value is a NUMBER, not a path. `grep -A 5 file` left `5` in the
|
|
214
|
+
# path list. Case-sensitive on purpose: lowercasing `-A` yields `-a`, which is a
|
|
215
|
+
# real grep flag that takes no value at all.
|
|
216
|
+
_VALUE_FLAGS = frozenset({
|
|
217
|
+
"-A", "-B", "-C", "-m", "-d", "-j",
|
|
218
|
+
"--after-context", "--before-context", "--context",
|
|
219
|
+
"--max-count", "--max-depth", "--threads",
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _strip_redirections(argv: list[str]) -> list[str]:
|
|
224
|
+
"""Drop redirection operators, their targets, and any file-descriptor digit.
|
|
225
|
+
|
|
226
|
+
Without this a single `2>/dev/null` contributed `2`, `>` and `/dev/null` to
|
|
227
|
+
the path list, and `_is_single_file_scope` requires EVERY named path to be an
|
|
228
|
+
existing file -- so one redirection turned a legitimate single-file search
|
|
229
|
+
into a denial whose own message promised that such searches are allowed.
|
|
230
|
+
"""
|
|
231
|
+
kept: list[str] = []
|
|
232
|
+
skip_target = False
|
|
233
|
+
for token in argv:
|
|
234
|
+
if skip_target:
|
|
235
|
+
skip_target = False
|
|
236
|
+
continue
|
|
237
|
+
if token in _REDIRECTION_TOKENS:
|
|
238
|
+
# `2>file`: the descriptor was lexed as its own token just before.
|
|
239
|
+
if kept and kept[-1].isdigit():
|
|
240
|
+
kept.pop()
|
|
241
|
+
skip_target = True
|
|
242
|
+
continue
|
|
243
|
+
kept.append(token)
|
|
244
|
+
return kept
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _strip_flag_values(rest: list[str]) -> list[str]:
|
|
248
|
+
"""Drop the VALUE that follows a numeric-valued flag, so it is not a path."""
|
|
249
|
+
kept: list[str] = []
|
|
250
|
+
skip_value = False
|
|
251
|
+
for token in rest:
|
|
252
|
+
if skip_value:
|
|
253
|
+
skip_value = False
|
|
254
|
+
continue
|
|
255
|
+
if token in _VALUE_FLAGS:
|
|
256
|
+
skip_value = True
|
|
257
|
+
kept.append(token)
|
|
258
|
+
continue
|
|
259
|
+
kept.append(token)
|
|
260
|
+
return kept
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _is_outside_repository(root: Path, paths: list[str]) -> bool:
|
|
264
|
+
"""Every named path lies outside this repository.
|
|
265
|
+
|
|
266
|
+
The graph describes THIS repo. A search rooted anywhere else is not a
|
|
267
|
+
question it can answer, so denying it points the reader at a tool with
|
|
268
|
+
nothing to say -- and names symbols from a repository the search never
|
|
269
|
+
touched. Observed exactly that way: a scratchpad file was refused because
|
|
270
|
+
the PATTERN happened to contain `START`, matching `daemon_start` here.
|
|
271
|
+
"""
|
|
272
|
+
if not paths:
|
|
273
|
+
return False
|
|
274
|
+
try:
|
|
275
|
+
root_resolved = root.resolve()
|
|
276
|
+
except OSError: # pragma: no cover - a repo root that cannot resolve
|
|
277
|
+
return False
|
|
278
|
+
for target in paths:
|
|
279
|
+
candidate = Path(target)
|
|
280
|
+
if not candidate.is_absolute():
|
|
281
|
+
candidate = root / candidate
|
|
282
|
+
try:
|
|
283
|
+
candidate.resolve().relative_to(root_resolved)
|
|
284
|
+
except (ValueError, OSError):
|
|
285
|
+
continue # outside, keep looking
|
|
286
|
+
return False # at least one path is inside the repo
|
|
287
|
+
return True
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _bash_command_heads(command: str) -> list[list[str]]:
|
|
291
|
+
"""argv of each command in `command` that is NOT downstream of a pipe.
|
|
292
|
+
|
|
293
|
+
A search downstream of a pipe filters another command's output rather than
|
|
294
|
+
searching the repository -- `graphite query ... | grep name` is the obvious
|
|
295
|
+
case, and denying it would break the very commands the denial message
|
|
296
|
+
recommends. Everything after a `|` is therefore dropped, while `&&`, `||`,
|
|
297
|
+
`;` and `&` start a genuinely new command that is checked on its own.
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
301
|
+
lexer.whitespace_split = True
|
|
302
|
+
tokens = list(lexer)
|
|
303
|
+
except ValueError:
|
|
304
|
+
return [] # unbalanced quotes: fail open, never break the user's shell
|
|
305
|
+
heads: list[list[str]] = []
|
|
306
|
+
current: list[str] = []
|
|
307
|
+
piped = False
|
|
308
|
+
for token in tokens:
|
|
309
|
+
if token in _SHELL_OPERATORS:
|
|
310
|
+
if current and not piped:
|
|
311
|
+
heads.append(current)
|
|
312
|
+
# `||` is or-else, not a pipe -- only `|` and `|&` feed stdout on.
|
|
313
|
+
piped = token in ("|", "|&")
|
|
314
|
+
current = []
|
|
315
|
+
continue
|
|
316
|
+
current.append(token)
|
|
317
|
+
if current and not piped:
|
|
318
|
+
heads.append(current)
|
|
319
|
+
return heads
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _bash_search_pattern(command: str) -> tuple[str, list[str]] | None:
|
|
323
|
+
"""`(pattern, path arguments)` when `command` searches the repo, else None.
|
|
324
|
+
|
|
325
|
+
Deliberately conservative: the first non-flag argument is taken as the
|
|
326
|
+
pattern, which is the positional order of every tool in
|
|
327
|
+
`_BASH_SEARCH_TOOLS`. A flag that takes a separate value (`grep -e X`,
|
|
328
|
+
`rg --glob X`) can mis-identify the pattern, and that is the safe
|
|
329
|
+
direction -- a mis-read pattern simply fails to match a graph symbol and
|
|
330
|
+
nothing is denied.
|
|
331
|
+
"""
|
|
332
|
+
for argv in _bash_command_heads(command):
|
|
333
|
+
while argv and _ENV_ASSIGNMENT_RE.match(argv[0]):
|
|
334
|
+
argv = argv[1:]
|
|
335
|
+
argv = _strip_redirections(argv)
|
|
336
|
+
if not argv:
|
|
337
|
+
continue
|
|
338
|
+
name = PurePosixPath(argv[0].replace("\\", "/")).name.lower()
|
|
339
|
+
if name.endswith(".exe"):
|
|
340
|
+
name = name[:-4]
|
|
341
|
+
rest = argv[1:]
|
|
342
|
+
if name == "git" and rest and rest[0] == "grep":
|
|
343
|
+
name, rest = "grep", rest[1:]
|
|
344
|
+
if name not in _SEARCH_COMMANDS:
|
|
345
|
+
continue
|
|
346
|
+
rest = _strip_flag_values(rest)
|
|
347
|
+
for index, token in enumerate(rest):
|
|
348
|
+
if (
|
|
349
|
+
token in _PATTERN_FLAGS_EXACT or token.lower() in _PATTERN_FLAGS_FOLDED
|
|
350
|
+
) and index + 1 < len(rest):
|
|
351
|
+
paths = [
|
|
352
|
+
value
|
|
353
|
+
for position, value in enumerate(rest)
|
|
354
|
+
if position not in (index, index + 1) and not value.startswith("-")
|
|
355
|
+
]
|
|
356
|
+
return rest[index + 1], paths
|
|
357
|
+
positional = [token for token in rest if not token.startswith("-")]
|
|
358
|
+
if not positional:
|
|
359
|
+
continue
|
|
360
|
+
return positional[0], positional[1:]
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _is_single_file_scope(root: Path, paths: list[str]) -> bool:
|
|
365
|
+
"""Every named path exists and is a file. Literal-text searches scoped to
|
|
366
|
+
one file are always allowed -- the graph answers relationships, not what a
|
|
367
|
+
specific file literally contains."""
|
|
368
|
+
if not paths:
|
|
369
|
+
return False
|
|
370
|
+
for target in paths:
|
|
371
|
+
candidate = Path(target)
|
|
372
|
+
if not candidate.is_absolute():
|
|
373
|
+
candidate = root / candidate
|
|
374
|
+
if not candidate.is_file():
|
|
375
|
+
return False
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _denial_for(root: Path, pattern: str, paths: list[str]) -> str | None:
|
|
380
|
+
"""Shared by the Grep and Bash routes so both enforce the same rule."""
|
|
381
|
+
if not pattern:
|
|
382
|
+
return None
|
|
383
|
+
if _is_single_file_scope(root, paths):
|
|
384
|
+
return None
|
|
385
|
+
if _is_outside_repository(root, paths):
|
|
386
|
+
return None
|
|
387
|
+
tokens = _pattern_tokens(pattern)
|
|
388
|
+
if not tokens:
|
|
389
|
+
return None
|
|
390
|
+
match = _graph_symbol(root, tokens)
|
|
391
|
+
if match is None:
|
|
392
|
+
return None
|
|
393
|
+
node_id, token = match
|
|
394
|
+
return (
|
|
395
|
+
f"graphite-first (strict): '{token}' is a symbol in this repo's code graph "
|
|
396
|
+
f"({node_id}). Use the graph instead of cross-file grep: "
|
|
397
|
+
f'python -m graphite query "callers {token}" | python -m graphite query "calls {token}" | '
|
|
398
|
+
f'python -m graphite search "{token}" | python -m graphite context <file>. '
|
|
399
|
+
"Literal-text searches scoped to a single file path are always allowed."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _strict_denial(payload: dict[str, Any], root: Path) -> str | None:
|
|
404
|
+
try:
|
|
405
|
+
tool_input = payload.get("tool_input")
|
|
406
|
+
if not isinstance(tool_input, dict):
|
|
407
|
+
return None
|
|
408
|
+
pattern = tool_input.get("pattern")
|
|
409
|
+
if not isinstance(pattern, str) or not pattern:
|
|
410
|
+
return None
|
|
411
|
+
target = tool_input.get("path")
|
|
412
|
+
paths = [target] if isinstance(target, str) and target else []
|
|
413
|
+
return _denial_for(root, pattern, paths)
|
|
414
|
+
except Exception:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
MAX_CURSOR_SESSIONS = 20
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _entries_between(path: Path, start: int, end: int) -> list[dict[str, Any]]:
|
|
422
|
+
entries: list[dict[str, Any]] = []
|
|
423
|
+
with open(path, "rb") as handle:
|
|
424
|
+
handle.seek(start)
|
|
425
|
+
blob = handle.read(max(0, end - start))
|
|
426
|
+
for line in blob.decode("utf-8", errors="replace").splitlines():
|
|
427
|
+
try:
|
|
428
|
+
entry = json.loads(line)
|
|
429
|
+
except ValueError:
|
|
430
|
+
continue
|
|
431
|
+
if isinstance(entry, dict):
|
|
432
|
+
entries.append(entry)
|
|
433
|
+
return entries
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _cursor_number(value: Any, cast: type) -> Any:
|
|
437
|
+
"""Coerce a possibly-corrupted cursor field; non-numeric (or bool) -> 0.
|
|
438
|
+
|
|
439
|
+
A raw ``int(...)``/``float(...)`` on a corrupted (e.g. hand-edited or
|
|
440
|
+
concurrently-truncated) cursor field would raise before ``write_cursor``
|
|
441
|
+
runs, permanently stalling the session (offset never advances, same
|
|
442
|
+
failure repeats every turn). Guarding here lets the cursor self-heal.
|
|
443
|
+
"""
|
|
444
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
445
|
+
return cast(0)
|
|
446
|
+
return cast(value)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def handle_stop(payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
450
|
+
"""Turn-end savings summary; silent unless graphite was used this turn."""
|
|
451
|
+
try:
|
|
452
|
+
root = _payload_root(payload)
|
|
453
|
+
# Stop fires once per assistant turn, which makes it the heartbeat that
|
|
454
|
+
# keeps a working session inside the activation TTL. It must run before
|
|
455
|
+
# the session_id guard below, which returns early on most turns.
|
|
456
|
+
_activate(root)
|
|
457
|
+
session_id = str(payload.get("session_id") or "")
|
|
458
|
+
if not session_id:
|
|
459
|
+
return None
|
|
460
|
+
ledger = usage_ledger.ledger_path(root)
|
|
461
|
+
ledger_stat = ledger.stat() if ledger.is_file() else None
|
|
462
|
+
size = ledger_stat.st_size if ledger_stat is not None else 0
|
|
463
|
+
# Rotation (usage_ledger.record_usage) replaces the file at the same path with a
|
|
464
|
+
# brand-new one; the new generation can coincidentally match the old byte offset
|
|
465
|
+
# (e.g. identical repeated entries), so size alone can't detect it. st_ino changes
|
|
466
|
+
# across the replace even when size doesn't, so it's used as the rotation signal.
|
|
467
|
+
current_ino = ledger_stat.st_ino if ledger_stat is not None else None
|
|
468
|
+
cursor = usage_ledger.read_cursor(root)
|
|
469
|
+
sessions = cursor.get("sessions")
|
|
470
|
+
if not isinstance(sessions, dict):
|
|
471
|
+
sessions = {}
|
|
472
|
+
state = sessions.pop(session_id, None)
|
|
473
|
+
if not isinstance(state, dict):
|
|
474
|
+
state = {"offset": 0, "tokens": 0, "seconds": 0.0, "ino": current_ino}
|
|
475
|
+
offset = state.get("offset", 0)
|
|
476
|
+
stored_ino = state.get("ino")
|
|
477
|
+
# A stored `ino` that isn't an int (missing key on the older 3-key cursor
|
|
478
|
+
# schema, or a corrupted value) never counts as rotation, nor does a current
|
|
479
|
+
# ino of 0/None (filesystem doesn't report inodes) -- both fall back to the
|
|
480
|
+
# offset-only heuristic below. That's fail-safe: no false resync/overcount
|
|
481
|
+
# from an absent or unsupported inode, at the cost of not detecting a
|
|
482
|
+
# same-size rotation on such filesystems (same limitation the size-only
|
|
483
|
+
# check always had).
|
|
484
|
+
rotated = (
|
|
485
|
+
isinstance(stored_ino, int)
|
|
486
|
+
and not isinstance(stored_ino, bool)
|
|
487
|
+
and bool(current_ino)
|
|
488
|
+
and stored_ino != current_ino
|
|
489
|
+
)
|
|
490
|
+
if not isinstance(offset, int) or offset > size or rotated:
|
|
491
|
+
offset = 0 # ledger rotated or cursor damaged; resync
|
|
492
|
+
new_entries = _entries_between(ledger, offset, size) if size > offset else []
|
|
493
|
+
turn_tokens = 0
|
|
494
|
+
turn_seconds = 0.0
|
|
495
|
+
for entry in new_entries:
|
|
496
|
+
est = savings_model.estimate_entry(entry)
|
|
497
|
+
turn_tokens += est["tokens_saved"]
|
|
498
|
+
turn_seconds += est["seconds_saved"]
|
|
499
|
+
state["offset"] = size
|
|
500
|
+
state["ino"] = current_ino
|
|
501
|
+
state["tokens"] = _cursor_number(state.get("tokens", 0), int) + turn_tokens
|
|
502
|
+
state["seconds"] = _cursor_number(state.get("seconds", 0.0), float) + turn_seconds
|
|
503
|
+
sessions[session_id] = state # re-insert -> newest position
|
|
504
|
+
while len(sessions) > MAX_CURSOR_SESSIONS:
|
|
505
|
+
sessions.pop(next(iter(sessions)))
|
|
506
|
+
usage_ledger.write_cursor(root, {"sessions": sessions})
|
|
507
|
+
if not new_entries or not usage_ledger.savings_display_enabled(root):
|
|
508
|
+
return None
|
|
509
|
+
turn_text = savings_model.format_compact(turn_tokens, turn_seconds)
|
|
510
|
+
session_text = savings_model.format_compact(state["tokens"], state["seconds"])
|
|
511
|
+
return {
|
|
512
|
+
"systemMessage": (
|
|
513
|
+
f"graphite: est. {turn_text} saved this turn "
|
|
514
|
+
f"(session: {session_text}) [estimates]"
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
except Exception:
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def handle_pre_tool_use(payload: dict[str, Any], mode: str) -> dict[str, Any] | None:
|
|
522
|
+
try:
|
|
523
|
+
tool_name = payload.get("tool_name")
|
|
524
|
+
if tool_name not in ("Grep", "Glob", "Bash", "PowerShell"):
|
|
525
|
+
return None
|
|
526
|
+
root = _payload_root(payload)
|
|
527
|
+
if not (root / "graph-out" / "graph.json").is_file():
|
|
528
|
+
return None
|
|
529
|
+
denial: str | None = None
|
|
530
|
+
if tool_name in ("Bash", "PowerShell"):
|
|
531
|
+
# The matcher now routes EVERY Bash call here, so anything that is
|
|
532
|
+
# not a repo-wide search must leave silently -- not even a
|
|
533
|
+
# reminder. Nagging `git status` is how a real warning gets
|
|
534
|
+
# ignored, which is the same rule check_hooks applies to repos
|
|
535
|
+
# that never onboarded.
|
|
536
|
+
tool_input = payload.get("tool_input")
|
|
537
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
538
|
+
found = _bash_search_pattern(command) if isinstance(command, str) else None
|
|
539
|
+
if found is None:
|
|
540
|
+
return None
|
|
541
|
+
if mode == "strict":
|
|
542
|
+
denial = _denial_for(root, found[0], found[1])
|
|
543
|
+
elif mode == "strict" and tool_name == "Grep":
|
|
544
|
+
denial = _strict_denial(payload, root)
|
|
545
|
+
if denial is not None:
|
|
546
|
+
health = persisted_resolution(
|
|
547
|
+
root,
|
|
548
|
+
on_error=lambda exc: record_incident(
|
|
549
|
+
repo_ledger_dir(root),
|
|
550
|
+
klass="build",
|
|
551
|
+
code="artifact_malformed",
|
|
552
|
+
subject=".graphite_analysis.json",
|
|
553
|
+
detail=str(exc),
|
|
554
|
+
),
|
|
555
|
+
)
|
|
556
|
+
if isinstance(health, dict) and health.get("healthy") is True:
|
|
557
|
+
return {
|
|
558
|
+
"hookSpecificOutput": {
|
|
559
|
+
"hookEventName": "PreToolUse",
|
|
560
|
+
"permissionDecision": "deny",
|
|
561
|
+
"permissionDecisionReason": denial,
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
"hookSpecificOutput": {
|
|
566
|
+
"hookEventName": "PreToolUse",
|
|
567
|
+
"additionalContext": PRE_TOOL_REMINDER + STRICT_SUSPENSION_NOTE,
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
"hookSpecificOutput": {
|
|
572
|
+
"hookEventName": "PreToolUse",
|
|
573
|
+
"additionalContext": PRE_TOOL_REMINDER,
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
except Exception:
|
|
577
|
+
return None
|