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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -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