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,226 @@
1
+ """Idempotent Claude Code settings wiring for graphite agent hooks.
2
+
3
+ Graphite owns exactly the hook commands whose command string starts with
4
+ ``HOOK_COMMAND_PREFIX``; those are stripped and re-written on every install.
5
+ Everything else in ``.claude/settings.json`` is preserved untouched, and a file
6
+ that fails to parse is never modified.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import shlex
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .io import atomic_write_text
17
+
18
+ # `-P` omits the working directory from `sys.path` (3.11+). Without it,
19
+ # `python -m graphite` puts the CWD at `sys.path[0]`, so a `graphite.py` -- or a
20
+ # `graphite/` directory -- in a managed repo's root wins over the installed
21
+ # package and the hook executes it. The pre-tool-use hook fires on EVERY tool
22
+ # use, so such a file never has to be invoked by anyone: it runs on the next
23
+ # agent action after it lands. `.claude/settings.json` is tracked in consumer
24
+ # repos, so it would arrive through an ordinary reviewed commit, and `init`
25
+ # ships this identical command line to every managed repo.
26
+ #
27
+ # Reported by aramid-agent (channel round 49) and reproduced here: with a
28
+ # `graphite.py` in the CWD, bare `python -m graphite --version` ran the shadow
29
+ # file; `python -P -m graphite --version` ran graphite.
30
+ #
31
+ # `-P` over PYTHONSAFEPATH=1 because it is visible in the command string itself,
32
+ # which matters for a generated file an operator audits by reading.
33
+ HOOK_COMMAND_PREFIX = "python -P -m graphite agent-hook"
34
+ # Every prefix graphite has ever written. Load-bearing, not history: this string
35
+ # is both what we WRITE and what we MATCH to find our own hooks. A matcher that
36
+ # knew only the current spelling would fail to strip the legacy hook from an
37
+ # already-onboarded repo (leaving two, the vulnerable one still firing) and
38
+ # would make `existing_mode` return None, silently downgrading every strict repo
39
+ # to DEFAULT_MODE on its next `init`. Never drop an entry; only ever append.
40
+ _OWNED_COMMAND_PREFIXES = (HOOK_COMMAND_PREFIX, "python -m graphite agent-hook")
41
+ DEFAULT_MODE = "remind"
42
+ _MODES = ("remind", "strict")
43
+ _SESSION_START_COMMAND = f"{HOOK_COMMAND_PREFIX} session-start"
44
+ _STOP_COMMAND = f"{HOOK_COMMAND_PREFIX} stop"
45
+
46
+
47
+ def _pre_tool_use_command(mode: str) -> str:
48
+ return f"{HOOK_COMMAND_PREFIX} pre-tool-use --mode {mode}"
49
+
50
+
51
+ def _is_graphite_command(hook: Any) -> bool:
52
+ return isinstance(hook, dict) and str(hook.get("command", "")).startswith(_OWNED_COMMAND_PREFIXES)
53
+
54
+
55
+ def _strip_graphite(groups: Any) -> list[Any]:
56
+ """Drop graphite-owned commands; keep foreign groups byte-identical."""
57
+ if not isinstance(groups, list):
58
+ return []
59
+ kept: list[Any] = []
60
+ for entry in groups:
61
+ if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list):
62
+ kept.append(entry)
63
+ continue
64
+ remaining = [hook for hook in entry["hooks"] if not _is_graphite_command(hook)]
65
+ if remaining == entry["hooks"]:
66
+ kept.append(entry)
67
+ elif remaining:
68
+ kept.append({**entry, "hooks": remaining})
69
+ return kept
70
+
71
+
72
+ def _load_settings(path: Path) -> dict[str, Any] | None:
73
+ """Parsed settings dict, {} when absent, None when malformed."""
74
+ if not path.exists():
75
+ return {}
76
+ try:
77
+ loaded = json.loads(path.read_text(encoding="utf-8"))
78
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
79
+ return None
80
+ return loaded if isinstance(loaded, dict) else None
81
+
82
+
83
+ def read_settings(root: Path) -> dict[str, Any] | None:
84
+ """A repo's parsed settings: `{}` when absent, `None` when malformed.
85
+
86
+ The tri-state matters to callers that report rather than write: "absent"
87
+ and "unreadable" are different answers, and collapsing them lets a corrupt
88
+ file read as a clean one.
89
+ """
90
+ return _load_settings(root / ".claude" / "settings.json")
91
+
92
+
93
+ #: Head tokens that mean the command runs graphite's own console-script entry
94
+ #: point. Measured 2026-08-03: this form is shadowed exactly like `python -m`
95
+ #: -- an installed console script does NOT put its own directory on
96
+ #: `sys.path[0]`, contrary to the obvious assumption -- and it cannot express
97
+ #: the fix, because there is no `-P` to add. It has to change form.
98
+ _CONSOLE_SCRIPT_STEMS = frozenset({"graphite"})
99
+ _INTERPRETER_STEMS = frozenset({"python", "python3", "py", "pythonw"})
100
+
101
+ #: Split on BOTH separators rather than using `PurePath`, whose `.name` follows
102
+ #: the HOST's flavour: on POSIX, `PurePath(r"C:\Python314\python.exe").name` is
103
+ #: the whole string, so a Windows-spelled hook command went unclassified on any
104
+ #: non-Windows host. A hook command is data read out of a file, not a path on
105
+ #: the machine reading it, so host semantics are the wrong tool entirely.
106
+ #: Caught by the portability matrix on macOS; never observable on Windows.
107
+ _PATH_SEPARATORS = re.compile(r"[\\/]")
108
+
109
+
110
+ def _executable_stem(token: str) -> str:
111
+ """`C:\\Python314\\python.exe` and `/usr/bin/python3` -> `python`, `python3`.
112
+
113
+ Identical on every host by construction.
114
+ """
115
+ return _PATH_SEPARATORS.split(token.strip('"'))[-1].lower().removesuffix(".exe")
116
+
117
+
118
+ def classify_hook_command(command: Any) -> str | None:
119
+ """Which shadowable invocation form this hook uses, else `None`.
120
+
121
+ `None` covers three different "not a finding" cases on purpose: the command
122
+ is one of ours (`graphite init` maintains those), it does not resolve to the
123
+ graphite package at all, or it cannot be parsed. Only the invocation HEAD is
124
+ examined -- a command that merely mentions graphite in an argument, like a
125
+ path or an `echo`, is not our business and must not be reported, because a
126
+ check that cries wolf is one nobody reads.
127
+
128
+ Never returns the command text. Callers report the form; these commands are
129
+ mostly absolute path.
130
+ """
131
+ if not isinstance(command, str) or not command.strip():
132
+ return None
133
+ if command.startswith(_OWNED_COMMAND_PREFIXES):
134
+ return None
135
+ try:
136
+ # posix=False keeps Windows backslashes intact; the POSIX lexer eats
137
+ # them as escapes and would mangle every real consumer command.
138
+ tokens = shlex.split(command, posix=False)
139
+ except ValueError:
140
+ return None # unbalanced quotes: decline to guess
141
+ if not tokens:
142
+ return None
143
+ stem = _executable_stem(tokens[0])
144
+ if stem in _CONSOLE_SCRIPT_STEMS:
145
+ return "console_script"
146
+ if stem not in _INTERPRETER_STEMS:
147
+ return None
148
+ flags = tokens[1:]
149
+ if "-m" not in flags:
150
+ return None
151
+ marker = flags.index("-m")
152
+ module = flags[marker + 1].strip('"') if marker + 1 < len(flags) else ""
153
+ if module.split(".")[0] != "graphite":
154
+ return None
155
+ return None if "-P" in flags[:marker] else "python_m_without_P"
156
+
157
+
158
+ def existing_mode(root: Path) -> str | None:
159
+ settings = _load_settings(root / ".claude" / "settings.json")
160
+ if not settings:
161
+ return None
162
+ hooks = settings.get("hooks")
163
+ groups = hooks.get("PreToolUse") if isinstance(hooks, dict) else None
164
+ for entry in groups if isinstance(groups, list) else []:
165
+ if not isinstance(entry, dict):
166
+ continue
167
+ for hook in entry.get("hooks", []) if isinstance(entry.get("hooks"), list) else []:
168
+ command = str(hook.get("command", "")) if isinstance(hook, dict) else ""
169
+ if command.startswith(_OWNED_COMMAND_PREFIXES) and "pre-tool-use" in command:
170
+ return "strict" if "--mode strict" in command else "remind"
171
+ return None
172
+
173
+
174
+ def ensure_claude_settings(root: Path, *, mode: str | None = None) -> dict[str, Any]:
175
+ path = root / ".claude" / "settings.json"
176
+ resolved = mode or existing_mode(root) or DEFAULT_MODE
177
+ if resolved not in _MODES:
178
+ raise ValueError(f"unknown agent-hook mode: {resolved}")
179
+ existed = path.exists()
180
+ original = _load_settings(path)
181
+ if original is None:
182
+ return {"path": str(path), "changed": False, "action": "malformed settings", "mode": resolved}
183
+
184
+ hooks_value = original.get("hooks")
185
+ if "hooks" in original and not isinstance(hooks_value, dict):
186
+ # A non-dict "hooks" value is foreign malformed content we must not
187
+ # silently overwrite with {} — treat it like unparseable JSON: no-op.
188
+ return {"path": str(path), "changed": False, "action": "malformed settings", "mode": resolved}
189
+ hooks = dict(hooks_value) if isinstance(hooks_value, dict) else {}
190
+ desired = {
191
+ "PreToolUse": {
192
+ # The shells are matched because the previous matcher, "Grep|Glob",
193
+ # named TOOLS rather than behaviour: `grep -rn ...` through the Bash
194
+ # tool and `Select-String` through the PowerShell tool never reached
195
+ # the hook at all, and consumer agents reported taking exactly those
196
+ # routes for cross-file work. Enforcing on the Grep tool alone
197
+ # enforces a naming convention, not a rule.
198
+ #
199
+ # `agent_hooks.handle_pre_tool_use` returns None for every shell
200
+ # command that is not a repo-wide search, so matching the shells
201
+ # here costs one fail-open hook invocation per command and never
202
+ # interferes with ordinary work.
203
+ "matcher": "Grep|Glob|Bash|PowerShell",
204
+ "hooks": [{"type": "command", "command": _pre_tool_use_command(resolved)}],
205
+ },
206
+ "SessionStart": {
207
+ "hooks": [{"type": "command", "command": _SESSION_START_COMMAND}],
208
+ },
209
+ "Stop": {
210
+ "hooks": [{"type": "command", "command": _STOP_COMMAND}],
211
+ },
212
+ }
213
+ changed = False
214
+ for event, entry in desired.items():
215
+ groups = _strip_graphite(hooks.get(event))
216
+ groups.append(entry)
217
+ if hooks.get(event) != groups:
218
+ changed = True
219
+ hooks[event] = groups
220
+
221
+ if not changed:
222
+ return {"path": str(path), "changed": False, "action": "already current", "mode": resolved}
223
+ updated = {**original, "hooks": hooks}
224
+ atomic_write_text(path, json.dumps(updated, ensure_ascii=False, indent=2) + "\n")
225
+ action = "updated" if existed else "created"
226
+ return {"path": str(path), "changed": True, "action": action, "mode": resolved}
graphite/analyze.py ADDED
@@ -0,0 +1,146 @@
1
+ """Graph analysis: god nodes, orphans, entry points, cycles."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ import networkx as nx
7
+
8
+ from .health import resolution_health
9
+
10
+ _PROJECT_NODE_KINDS = frozenset({"file", "function", "class"})
11
+
12
+
13
+ def _is_project_node(g: nx.DiGraph, node: str) -> bool:
14
+ """Return true for nodes extracted from repository-owned source files."""
15
+ return g.nodes[node].get("kind") in _PROJECT_NODE_KINDS
16
+
17
+
18
+ def _project_nodes(g: nx.DiGraph) -> list[str]:
19
+ return [n for n in g.nodes() if _is_project_node(g, n)]
20
+
21
+
22
+ def _stable_cycles(g: nx.DiGraph, top_n: int) -> list[list[str]]:
23
+ """Cycles in a fixed order, each rotated to start at its smallest node.
24
+
25
+ `nx.simple_cycles` yields in an order that follows set iteration, which
26
+ `PYTHONHASHSEED` randomizes per process. That is not a cosmetic difference
27
+ once `[:top_n]` truncates the result: two builds of one commit reported a
28
+ *different set* of cycles and so disagreed about which cycles the repository
29
+ contains. Measured on this repository -- `nodes`, `edges` and `metadata`
30
+ were byte-identical between builds while `analysis.cycles` was not.
31
+
32
+ A cycle is also the same cycle under rotation, so one spelling has to be
33
+ chosen or `[a, b, c]` and `[b, c, a]` both appear depending on where the
34
+ traversal entered. Nodes within a simple cycle are distinct, so the minimum
35
+ is unique and rotating to it is canonical. Shortest first, then
36
+ lexicographic, so the truncation keeps the tightest cycles.
37
+ """
38
+ canonical: list[list[str]] = []
39
+ for cycle in nx.simple_cycles(g):
40
+ pivot = cycle.index(min(cycle))
41
+ canonical.append(cycle[pivot:] + cycle[:pivot])
42
+ canonical.sort(key=lambda cycle: (len(cycle), cycle))
43
+ return canonical[:top_n]
44
+
45
+
46
+ def analyze(g: nx.DiGraph, top_n: int = 20) -> dict[str, Any]:
47
+ """Run the full analysis suite."""
48
+ project_subgraph = g.subgraph(_project_nodes(g)).copy()
49
+ return {
50
+ "god_nodes": god_nodes(g, top_n),
51
+ "orphans": orphan_nodes(g, top_n),
52
+ "entry_points": entry_points(g, top_n),
53
+ "surprising_connections": surprising_connections(g, top_n),
54
+ "cycles": _stable_cycles(project_subgraph, top_n),
55
+ "top_files_by_links": top_files_by_links(g, top_n),
56
+ "resolution_health": resolution_health(g),
57
+ }
58
+
59
+
60
+ def god_nodes(g: nx.DiGraph, top_n: int = 20) -> list[dict[str, Any]]:
61
+ """Project-owned nodes with the highest total degree (in + out)."""
62
+ degrees = [(n, g.degree(n)) for n in _project_nodes(g)]
63
+ degrees.sort(key=lambda x: x[1], reverse=True)
64
+ return [
65
+ {
66
+ "id": n,
67
+ "name": g.nodes[n].get("name", n),
68
+ "kind": g.nodes[n].get("kind", "unknown"),
69
+ "degree": deg,
70
+ "in_degree": g.in_degree(n),
71
+ "out_degree": g.out_degree(n),
72
+ }
73
+ for n, deg in degrees[:top_n]
74
+ ]
75
+
76
+
77
+ def orphan_nodes(g: nx.DiGraph, top_n: int = 20) -> list[dict[str, Any]]:
78
+ """Project-owned nodes with zero connections."""
79
+ orphans = [n for n in _project_nodes(g) if g.degree(n) == 0]
80
+ return [
81
+ {
82
+ "id": n,
83
+ "name": g.nodes[n].get("name", n),
84
+ "kind": g.nodes[n].get("kind", "unknown"),
85
+ }
86
+ for n in orphans[:top_n]
87
+ ]
88
+
89
+
90
+ def entry_points(g: nx.DiGraph, top_n: int = 20) -> list[dict[str, Any]]:
91
+ """File nodes with many outgoing edges and few incoming edges."""
92
+ file_nodes = [n for n in g.nodes() if g.nodes[n].get("kind") == "file"]
93
+ scored = []
94
+ for n in file_nodes:
95
+ out_deg = g.out_degree(n)
96
+ in_deg = g.in_degree(n)
97
+ if out_deg > 0:
98
+ scored.append((n, out_deg, in_deg, out_deg / max(in_deg, 1)))
99
+ scored.sort(key=lambda x: x[3], reverse=True)
100
+ return [
101
+ {
102
+ "id": n,
103
+ "name": g.nodes[n].get("name", n),
104
+ "out_degree": out_deg,
105
+ "in_degree": in_deg,
106
+ "ratio": ratio,
107
+ }
108
+ for n, out_deg, in_deg, ratio in scored[:top_n]
109
+ ]
110
+
111
+
112
+ def surprising_connections(g: nx.DiGraph, top_n: int = 20) -> list[dict[str, Any]]:
113
+ """Project-owned edges between nodes that share no common neighbours."""
114
+ scored = []
115
+ for u, v in g.edges():
116
+ if not (_is_project_node(g, u) and _is_project_node(g, v)):
117
+ continue
118
+ common = len(set(g.predecessors(u)).intersection(g.predecessors(v))) + len(
119
+ set(g.successors(u)).intersection(g.successors(v))
120
+ )
121
+ if common == 0:
122
+ scored.append(
123
+ {
124
+ "source": u,
125
+ "target": v,
126
+ "source_kind": g.nodes[u].get("kind", "unknown"),
127
+ "target_kind": g.nodes[v].get("kind", "unknown"),
128
+ "relation": g[u][v].get("relation", "unknown"),
129
+ }
130
+ )
131
+ return scored[:top_n]
132
+
133
+
134
+ def top_files_by_links(g: nx.DiGraph, top_n: int = 20) -> list[dict[str, Any]]:
135
+ """File nodes ranked by total degree."""
136
+ file_nodes = [n for n in g.nodes() if g.nodes[n].get("kind") == "file"]
137
+ ranked = sorted(file_nodes, key=lambda n: g.degree(n), reverse=True)
138
+ return [
139
+ {
140
+ "id": n,
141
+ "name": g.nodes[n].get("name", n),
142
+ "degree": g.degree(n),
143
+ "source_file": g.nodes[n].get("source_file"),
144
+ }
145
+ for n in ranked[:top_n]
146
+ ]