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
graphite/doctor.py ADDED
@@ -0,0 +1,678 @@
1
+ """Typed, bounded system-readiness diagnostics."""
2
+ from __future__ import annotations
3
+
4
+ import importlib.util
5
+ import json
6
+ import math
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ from collections.abc import Mapping as MappingABC
13
+ from dataclasses import dataclass, field, replace
14
+ from pathlib import Path, PurePosixPath, PureWindowsPath
15
+ from types import MappingProxyType
16
+ from typing import Any, Callable, Iterable, Literal, Mapping
17
+
18
+ from .agent_settings import classify_hook_command, read_settings
19
+ from .config import Config, default_projects_root
20
+ from .daemon import read_daemon_status
21
+ from .daemon_health import HealthOptions, evaluate_daemon_health
22
+ from .freshness import FreshnessLimitError, check_graph_freshness
23
+ from .git import GitError, GitRunner
24
+ from .hookinstall import DEFAULT_HOOKS_DIRNAME, hook_shim_present, hooks_dir
25
+ from .init import gitignored_managed_paths, managed_doc_paths
26
+ from .hookshim import TRIGGERS
27
+ from .llm import canonical_provider_name
28
+ from .validation import validate_graph_bundle
29
+
30
+ DoctorStatus = Literal["ready", "optional", "degraded", "blocked"]
31
+ STATUSES: tuple[DoctorStatus, ...] = ("ready", "optional", "degraded", "blocked")
32
+ _RANK = {name: rank for rank, name in enumerate(STATUSES)}
33
+ _ARTIFACT_LIMIT = 128 * 1024 * 1024
34
+ _TEXT_LIMIT = 500
35
+ _MANIFEST_LIMIT = 16 * 1024 * 1024
36
+ _FILE_LIMIT = 10_000
37
+ _PROCESS_CLEANUP_SECONDS = 0.2
38
+ _DAEMON_ISSUES_EXCLUDED_FROM_GLOBAL = {
39
+ "project_failing",
40
+ "project_pending_initial_build",
41
+ "project_not_built_recently",
42
+ "project_nested_repo_unsupervised",
43
+ "daemon_process_check_unavailable",
44
+ }
45
+
46
+
47
+ def _freeze_json(value: Any) -> Any:
48
+ if value is None or isinstance(value, (bool, int, str)):
49
+ return value
50
+ if isinstance(value, float):
51
+ if math.isfinite(value):
52
+ return value
53
+ raise ValueError("invalid doctor check details")
54
+ if isinstance(value, MappingABC):
55
+ frozen: dict[str, Any] = {}
56
+ for key, item in value.items():
57
+ if not isinstance(key, str):
58
+ raise ValueError("invalid doctor check details")
59
+ frozen[key] = _freeze_json(item)
60
+ return MappingProxyType(frozen)
61
+ if isinstance(value, (list, tuple)):
62
+ return tuple(_freeze_json(item) for item in value)
63
+ raise ValueError("invalid doctor check details")
64
+
65
+
66
+ def _thaw_json(value: Any) -> Any:
67
+ if isinstance(value, MappingABC):
68
+ return {key: _thaw_json(item) for key, item in value.items()}
69
+ if isinstance(value, tuple):
70
+ return [_thaw_json(item) for item in value]
71
+ return value
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class DoctorCheck:
76
+ code: str
77
+ label: str
78
+ status: DoctorStatus
79
+ summary: str
80
+ details: Mapping[str, Any] = field(default_factory=dict)
81
+ remediation: tuple[str, ...] = ()
82
+
83
+ def __post_init__(self) -> None:
84
+ if self.status not in _RANK:
85
+ raise ValueError("invalid doctor check status")
86
+ if not isinstance(self.remediation, tuple) or not all(isinstance(item, str) for item in self.remediation):
87
+ raise ValueError("invalid doctor remediation")
88
+ frozen = _freeze_json(self.details)
89
+ if not isinstance(frozen, MappingABC):
90
+ raise ValueError("invalid doctor check details")
91
+ object.__setattr__(self, "details", frozen)
92
+
93
+ def to_dict(self) -> dict[str, Any]:
94
+ return {"code": self.code, "label": self.label, "status": self.status, "summary": self.summary, "details": _thaw_json(self.details), "remediation": list(self.remediation)}
95
+
96
+
97
+ def build_report(root: Path, checks: Iterable[DoctorCheck], deep: bool, llm_included: bool) -> dict[str, Any]:
98
+ ordered = sorted(checks, key=lambda check: check.code)
99
+ if any(check.status not in _RANK for check in ordered):
100
+ raise ValueError("invalid doctor check status")
101
+ status = max((check.status for check in ordered), key=_RANK.get, default="ready")
102
+ return {"schema_version": 1, "root": root.resolve().name, "deep": bool(deep), "llm_included": bool(llm_included), "status": status, "exit_code": 1 if status == "blocked" else 0, "checks": [check.to_dict() for check in ordered]}
103
+
104
+
105
+ def format_doctor_text(report: Mapping[str, Any]) -> str:
106
+ lines = [f"[graphite] doctor: {report.get('status', 'ready')}"]
107
+ for item in report.get("checks", []):
108
+ lines.append(f" [{item['status']}] {item['label']}: {item['summary']}")
109
+ for remediation in item.get("remediation", []):
110
+ lines.append(f" - {remediation}")
111
+ return "\n".join(lines) + "\n"
112
+
113
+
114
+ def check_python() -> DoctorCheck:
115
+ version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
116
+ ready = sys.version_info >= (3, 11)
117
+ return DoctorCheck("python", "Python", "ready" if ready else "blocked", f"Python {version} is {'supported' if ready else 'unsupported'}.", {"version": version}, (() if ready else ("Use Python 3.11 or newer.",)))
118
+
119
+
120
+ def check_git(root: Path) -> DoctorCheck:
121
+ try:
122
+ result = GitRunner(root).run(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], timeout_seconds=10.0, max_stdout_bytes=16 * 1024 * 1024)
123
+ if result.returncode != 0:
124
+ raise GitError("Git command failed")
125
+ records = _validated_git_records(root.resolve(), result.stdout)
126
+ count = len(records)
127
+ return DoctorCheck("git", "Git", "ready", "Git repository inventory is readable.", {"record_count": count})
128
+ except (GitError, OSError):
129
+ return DoctorCheck("git", "Git", "blocked", "Git repository inventory is unavailable.", remediation=("Verify Git 2.38+ and repository access.",))
130
+
131
+
132
+ def _validated_git_records(root: Path, output: bytes) -> list[str]:
133
+ try:
134
+ decoded = output.decode("utf-8")
135
+ except UnicodeDecodeError as exc:
136
+ raise GitError("Git returned malformed output") from exc
137
+ if not decoded:
138
+ return []
139
+ if not decoded.endswith("\0"):
140
+ raise GitError("Git returned malformed output")
141
+ records = decoded[:-1].split("\0")
142
+ if any(not record for record in records):
143
+ raise GitError("Git returned malformed output")
144
+ for record in records:
145
+ posix = PurePosixPath(record)
146
+ if record == "." or posix.as_posix() != record or posix.is_absolute() or ".." in posix.parts or PureWindowsPath(record).is_absolute():
147
+ raise GitError("Git returned malformed output")
148
+ try:
149
+ (root / Path(record)).resolve().relative_to(root)
150
+ except (OSError, ValueError) as exc:
151
+ raise GitError("Git returned malformed output") from exc
152
+ return records
153
+
154
+
155
+ def _hooks_path_configured(root: Path) -> bool:
156
+ try:
157
+ result = GitRunner(root).run(["config", "--get", "core.hooksPath"], timeout_seconds=5.0, max_stdout_bytes=4096)
158
+ except (GitError, OSError):
159
+ return False
160
+ if result.returncode != 0:
161
+ return False
162
+ try:
163
+ return bool(result.stdout.decode("utf-8").strip())
164
+ except UnicodeDecodeError:
165
+ return False
166
+
167
+
168
+ def check_hooks(root: Path) -> DoctorCheck:
169
+ """Are graphite's git hooks not just installed, but actually enforced.
170
+
171
+ Precedent from aramid's `probe_enforcement`: a repo with no `GRAPHITE.md`
172
+ is deliberately not onboarded, so it is reported `optional` -- not a
173
+ finding -- rather than nagging every repo that never opted in.
174
+ """
175
+ if not (root / "GRAPHITE.md").is_file():
176
+ return DoctorCheck("hooks", "Hooks", "optional", "Repository is not onboarded onto graphite; hook enforcement does not apply.", {"onboarded": False})
177
+
178
+ hdir = hooks_dir(root)
179
+ installed = all(hook_shim_present(hdir / hook) for hook in TRIGGERS)
180
+ configured = _hooks_path_configured(root)
181
+ custom_hooks_dir = hdir != root / DEFAULT_HOOKS_DIRNAME
182
+ details = {"onboarded": True, "custom_hooks_dir": custom_hooks_dir, "hookspath_configured": configured, "trampolines_installed": installed}
183
+
184
+ if configured and installed:
185
+ return DoctorCheck("hooks", "Hooks", "ready", "Git hooks are installed and enforced.", details)
186
+ if configured and not installed:
187
+ return DoctorCheck("hooks", "Hooks", "degraded", "core.hooksPath is configured but graphite's hook trampolines are missing.", details, ("Run `graphite init` to (re)install the hook trampolines.",))
188
+ if installed and not configured:
189
+ return DoctorCheck("hooks", "Hooks", "degraded", "Graphite's hook trampolines are installed but core.hooksPath is not set, so Git will not run them.", details, ("Run `git config core.hooksPath` to point at the installed hooks directory, or reinstall.",))
190
+ return DoctorCheck("hooks", "Hooks", "degraded", "Git hooks are not installed for this onboarded repository.", details, ("Run `graphite init` to install git hooks.",))
191
+
192
+
193
+ #: Claude Code's own hook events. An event name is a KEY read out of the
194
+ #: consumer's settings file, so it is attacker-controllable; anything unknown is
195
+ #: bucketed rather than echoed. That is what makes "details carry no absolute
196
+ #: path" a property of this code instead of a property of well-behaved input.
197
+ _AGENT_HOOK_EVENTS = frozenset({
198
+ "PreToolUse", "PostToolUse", "UserPromptSubmit", "Notification",
199
+ "SessionStart", "SessionEnd", "Stop", "SubagentStop", "PreCompact",
200
+ })
201
+
202
+
203
+ def check_agent_hooks(root: Path) -> DoctorCheck:
204
+ """Foreign `.claude/settings.json` hooks that invoke graphite shadowably.
205
+
206
+ graphite owns only the `agent-hook` commands it writes and leaves every
207
+ other hook byte-identical -- silently rewriting someone else's hook would be
208
+ worse than the bug. So a consumer's own hook keeps the exact defect
209
+ `67aafb2` and #43 removed from graphite's own. This reports them and never
210
+ repairs them (graphite#42).
211
+
212
+ Two rules hold the details together:
213
+
214
+ * **No command text, ever.** These commands are mostly absolute path, and
215
+ `check_hooks` already established that details carry none. The
216
+ classification is reported instead, which is inherently path-free.
217
+ * **Unreadable is not clean.** A malformed settings file is `degraded`, so
218
+ an absent finding can never be mistaken for a verified-safe one.
219
+ """
220
+ if not (root / "GRAPHITE.md").is_file():
221
+ return DoctorCheck("agent-hooks", "Agent hooks", "optional", "Repository is not onboarded onto graphite; agent hook wiring does not apply.", {"onboarded": False})
222
+
223
+ settings = read_settings(root)
224
+ if settings is None:
225
+ return DoctorCheck(
226
+ "agent-hooks", "Agent hooks", "degraded",
227
+ "`.claude/settings.json` could not be parsed, so its hooks were not inspected.",
228
+ {"onboarded": True, "settings_readable": False, "foreign_graphite_hooks": 0, "findings": ()},
229
+ ("Repair the JSON in `.claude/settings.json`, then re-run `graphite doctor`.",),
230
+ )
231
+
232
+ hooks = settings.get("hooks")
233
+ findings: list[dict[str, str]] = []
234
+ for event, groups in (hooks.items() if isinstance(hooks, dict) else ()):
235
+ label = event if isinstance(event, str) and event in _AGENT_HOOK_EVENTS else "other"
236
+ for entry in groups if isinstance(groups, list) else []:
237
+ nested = entry.get("hooks") if isinstance(entry, dict) else None
238
+ for hook in nested if isinstance(nested, list) else []:
239
+ form = classify_hook_command(hook.get("command")) if isinstance(hook, dict) else None
240
+ if form is not None:
241
+ findings.append({"event": label, "form": form})
242
+
243
+ details = {"onboarded": True, "settings_readable": True, "foreign_graphite_hooks": len(findings), "findings": tuple(findings)}
244
+ if not findings:
245
+ return DoctorCheck("agent-hooks", "Agent hooks", "ready", "No hook outside graphite's own invokes it in a shadowable form.", details)
246
+ return DoctorCheck(
247
+ "agent-hooks", "Agent hooks", "degraded",
248
+ f"{len(findings)} hook(s) graphite does not own invoke it in a form a repo-local `graphite.py` can hijack, exiting 0 while doing nothing of graphite's.",
249
+ details,
250
+ ("Rewrite each as `python -P -m graphite ...`. A bare `graphite ...` console script cannot express the fix -- it has to change form.",),
251
+ )
252
+
253
+
254
+ def _scoped_output(root: Path, cfg: Config) -> Path:
255
+ return cfg.output_dir if cfg.output_dir.is_absolute() else root / cfg.output_dir
256
+
257
+
258
+ def _artifact_size(path: Path) -> int:
259
+ return path.stat().st_size
260
+
261
+
262
+ def check_managed_docs(root: Path) -> DoctorCheck:
263
+ """Graphite-managed files that exist on disk but differ from what Git has.
264
+
265
+ Why this is a probe and not a comment (aramid interop round 24,
266
+ 2026-07-31): *a generated file left uncommitted is worse than a stale one,
267
+ because the staleness is invisible from the machine that generated it.*
268
+ `init` writes these files into the working tree; if nobody commits them the
269
+ author sees a correct repo while every clone and every CI checkout gets the
270
+ previous version. Measured live -- six graphite-managed files sat
271
+ uncommitted in aramid's repo for a day, and graphite had no way to say so.
272
+
273
+ Reported `degraded`, never `blocked`: the repo still works, and doctor
274
+ inventing a hard failure that no gate would produce is its own kind of lie
275
+ (aramid's `probe_deps` reasoning). A repo with no `GRAPHITE.md` is
276
+ deliberately not onboarded and is not a finding, matching `check_hooks`.
277
+ """
278
+ code, label = "managed-docs", "Managed docs"
279
+ if not (root / "GRAPHITE.md").is_file():
280
+ return DoctorCheck(code, label, "optional", "Repository is not onboarded onto graphite; no managed files to track.", {"onboarded": False, "uncommitted": []})
281
+
282
+ # Hook trampolines are deliberately absent: they are machine-local (an
283
+ # embedded absolute interpreter path), distributed by git template rather
284
+ # than by the repo, and gitignored. Reporting them here on 2026-07-31 was
285
+ # advice that would have committed another machine's Python location.
286
+ # `check_hooks` covers whether they are installed and enforced.
287
+ watched = {path.as_posix() for path in managed_doc_paths() if (root / path).is_file()}
288
+ if not watched:
289
+ return DoctorCheck(code, label, "ready", "No graphite-managed files are present.", {"onboarded": True, "watched": [], "uncommitted": []})
290
+
291
+ try:
292
+ result = GitRunner(root).run(["status", "--porcelain", "-z", "--", *sorted(watched)], timeout_seconds=10.0, max_stdout_bytes=1024 * 1024)
293
+ if result.returncode != 0:
294
+ raise GitError("Git command failed")
295
+ decoded = result.stdout.decode("utf-8")
296
+ except (GitError, OSError, UnicodeDecodeError):
297
+ return DoctorCheck(code, label, "optional", "Managed-file commit state could not be read from Git.", {"onboarded": True, "watched": sorted(watched), "uncommitted": []}, ("Verify Git 2.38+ and repository access.",))
298
+
299
+ # `-z` records are `XY <path>`, NUL-terminated and never quoted -- which is
300
+ # exactly why `-z` is used rather than parsing the quoted default format. A
301
+ # rename adds a second, bare-path field; intersecting against `watched` is
302
+ # what makes that harmless, since a field that is not a managed path simply
303
+ # never matches.
304
+ dirty = sorted({record[3:] for record in decoded.split("\0") if len(record) > 3} & watched)
305
+
306
+ # `git status` omits ignored files ENTIRELY, so everything above is blind to
307
+ # the worse failure: `init` writing a managed file into a path `.gitignore`
308
+ # already denies. Measured live in `BytesAI Learning` (2026-07-31), whose
309
+ # `.gitignore` denies `.claude/` -- the file carrying the graph-first hook
310
+ # was reported fine while its five neighbours were listed as uncommitted.
311
+ #
312
+ # `--ignored=matching` on status does NOT fix this: it still collapses the
313
+ # report to the ignored DIRECTORY (`.claude/`), which never matches the
314
+ # watched path. And `check-ignore` alone is wrong in the other direction --
315
+ # it answers "does a pattern match?", which is true but inert for an
316
+ # already-tracked file (`demo-store2`'s `CLAUDE.md` is both).
317
+ #
318
+ # `ls-files --others --ignored` is the conjunction itself: `--others`
319
+ # restricts to untracked, `--ignored` to ignored. Tracked-and-matched drops
320
+ # out by git's own semantics rather than by a rule reimplemented here.
321
+ # Same helper `init` repairs with, so the detector and the fix cannot drift
322
+ # into disagreeing about what "unreachable" means.
323
+ unreachable = list(gitignored_managed_paths(root, sorted(watched)))
324
+
325
+ # Disjoint by construction -- an ignored file never appears in `status`.
326
+ dirty = [path for path in dirty if path not in set(unreachable)]
327
+ details = {"onboarded": True, "watched": sorted(watched), "uncommitted": dirty, "unreachable": unreachable}
328
+ if not dirty and not unreachable:
329
+ return DoctorCheck(code, label, "ready", "Every graphite-managed file matches the committed version.", details)
330
+
331
+ # Reported apart from `uncommitted` because the remediation differs and the
332
+ # committing one is actively wrong here: `git add` on an ignored path is a
333
+ # no-op, so an operator who follows it sees nothing change and concludes
334
+ # the probe is broken.
335
+ summaries, steps = [], []
336
+ if unreachable:
337
+ summaries.append(f"{len(unreachable)} graphite-managed file(s) sit in gitignored paths and can never reach a clone")
338
+ steps.append("For details.unreachable, allow the path in .gitignore (e.g. `!/.claude/settings.json`) and then commit it -- `git add` alone is a no-op while the rule stands.")
339
+ if dirty:
340
+ summaries.append(f"{len(dirty)} differ from the committed version; clones and CI get the committed one")
341
+ steps.append("Commit the files listed in details.uncommitted -- a working tree that looks correct does not make them correct for anyone else.")
342
+ return DoctorCheck(code, label, "degraded", "; ".join(summaries) + ".", details, tuple(steps))
343
+
344
+
345
+ def _read_json_bounded(path: Path, limit: int) -> Any:
346
+ with path.open("rb") as handle:
347
+ data = handle.read(limit + 1)
348
+ if len(data) > limit:
349
+ raise ValueError("artifact too large")
350
+ return json.loads(data.decode("utf-8"))
351
+
352
+
353
+ def check_graph(root: Path, cfg: Config) -> DoctorCheck:
354
+ graph_path = _scoped_output(root, cfg) / "graph.json"
355
+ if not graph_path.exists():
356
+ return DoctorCheck("graph", "Graph", "degraded", "Graph artifact is missing.", remediation=("Run `graphite build .`.",))
357
+ try:
358
+ if _artifact_size(graph_path) > _ARTIFACT_LIMIT:
359
+ return DoctorCheck("graph", "Graph", "blocked", "Graph artifact exceeds the 128 MiB diagnostic limit.", remediation=("Rebuild or reduce the graph artifact.",))
360
+ bundle = _read_json_bounded(graph_path, _ARTIFACT_LIMIT)
361
+ if not isinstance(bundle, dict):
362
+ raise ValueError("invalid graph")
363
+ validation = validate_graph_bundle(bundle)
364
+ if not validation.get("ok"):
365
+ return DoctorCheck("graph", "Graph", "blocked", "Graph artifact validation failed.", {"node_count": validation.get("node_count", 0), "edge_count": validation.get("edge_count", 0), "warning_count": validation.get("warning_count", 0), "stale": False}, ("Rebuild the graph artifact.",))
366
+ configured_max = cfg.max_files
367
+ max_files = _FILE_LIMIT + 1 if configured_max is None else min(configured_max, _FILE_LIMIT + 1)
368
+ scoped = replace(
369
+ cfg,
370
+ output_dir=_scoped_output(root, cfg),
371
+ max_files=max_files,
372
+ max_file_size=min(cfg.max_file_size, Config().max_file_size),
373
+ )
374
+ freshness = check_graph_freshness(root, scoped, max_manifest_bytes=_MANIFEST_LIMIT)
375
+ stale = bool(freshness.get("stale"))
376
+ details = {"node_count": validation.get("node_count", 0), "edge_count": validation.get("edge_count", 0), "warning_count": validation.get("warning_count", 0), "stale": stale}
377
+ return DoctorCheck("graph", "Graph", "degraded" if stale else "ready", "Graph artifact is stale." if stale else "Graph artifact is valid and fresh.", details, (("Run `graphite build .`.",) if stale else ()))
378
+ except FreshnessLimitError:
379
+ return DoctorCheck("graph", "Graph", "blocked", "Graph freshness limit exceeded.", remediation=("Reduce the project or rebuild with bounded inputs.",))
380
+ except (OSError, UnicodeError, json.JSONDecodeError, ValueError, TypeError, AttributeError, KeyError):
381
+ return DoctorCheck("graph", "Graph", "blocked", "Graph artifact is unreadable or malformed.", remediation=("Rebuild the graph artifact.",))
382
+
383
+
384
+ def _normalized_root(path: object) -> str | None:
385
+ if not isinstance(path, str) or not path:
386
+ return None
387
+ try:
388
+ return os.path.normcase(str(Path(path).resolve())).casefold()
389
+ except OSError:
390
+ return None
391
+
392
+
393
+ def _category_contains(report: Mapping[str, Any], category: str, selected: str) -> bool:
394
+ projects = report.get("projects", {})
395
+ if not isinstance(projects, MappingABC):
396
+ return False
397
+ items = projects.get(category, [])
398
+ return isinstance(items, list) and any(
399
+ isinstance(item, MappingABC) and _normalized_root(item.get("root")) == selected
400
+ for item in items
401
+ )
402
+
403
+
404
+ def _global_issue_count(report: Mapping[str, Any], key: str) -> int:
405
+ issues = report.get(key, [])
406
+ if not isinstance(issues, list):
407
+ return 1
408
+ return sum(
409
+ 1
410
+ for issue in issues
411
+ if not isinstance(issue, MappingABC)
412
+ or issue.get("code") not in _DAEMON_ISSUES_EXCLUDED_FROM_GLOBAL
413
+ )
414
+
415
+
416
+ def check_daemon(root: Path, daemon_base: Path) -> DoctorCheck:
417
+ try:
418
+ report = evaluate_daemon_health(daemon_base, options=HealthOptions())
419
+ status_found = report.get("daemon_status") is not None
420
+ process = report.get("process", {})
421
+ process_checked = isinstance(process, MappingABC) and bool(process.get("checked"))
422
+ process_running = not process_checked or bool(process.get("running"))
423
+ process_observation_available = not (
424
+ process_checked and isinstance(process, MappingABC) and bool(process.get("error"))
425
+ )
426
+ selected = _normalized_root(str(root.resolve()))
427
+ try:
428
+ raw_status = read_daemon_status(daemon_base)
429
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
430
+ raw_status = None
431
+ raw_projects = raw_status.get("projects", []) if isinstance(raw_status, MappingABC) else []
432
+ registered = any(
433
+ isinstance(item, MappingABC) and _normalized_root(item.get("root")) == selected
434
+ for item in raw_projects
435
+ )
436
+ failing = bool(selected and _category_contains(report, "failing", selected))
437
+ pending = bool(selected and _category_contains(report, "pending", selected))
438
+ stale = bool(selected and _category_contains(report, "not_built_recently", selected))
439
+ global_error_count = _global_issue_count(report, "errors")
440
+ global_warning_count = _global_issue_count(report, "warnings")
441
+ details = {
442
+ "status_found": status_found,
443
+ "registered": registered,
444
+ "failing": failing,
445
+ "pending": pending,
446
+ "stale": stale,
447
+ "process_checked": process_checked,
448
+ "process_running": process_running,
449
+ "process_observation_available": process_observation_available,
450
+ "startup_checked": bool(report.get("startup", {}).get("checked")),
451
+ "global_error_count": global_error_count,
452
+ "global_warning_count": global_warning_count,
453
+ }
454
+ if not status_found and any(item.get("code") == "status_missing" for item in report.get("errors", [])):
455
+ return DoctorCheck("daemon", "Daemon", "optional", "Daemon status is not present; core operation is unaffected.", details)
456
+ if global_error_count or global_warning_count:
457
+ return DoctorCheck("daemon", "Daemon", "degraded", "Daemon runtime health needs attention.", details)
458
+ if process_observation_available and not process_running:
459
+ return DoctorCheck("daemon", "Daemon", "degraded", "Daemon process is not running.", details)
460
+ if not registered:
461
+ return DoctorCheck("daemon", "Daemon", "optional", "Selected project is not registered with the daemon.", details)
462
+ degraded = failing or pending or stale
463
+ return DoctorCheck("daemon", "Daemon", "degraded" if degraded else "ready", "Selected project daemon state needs attention." if degraded else "Selected project daemon state is healthy.", details)
464
+ except Exception:
465
+ return DoctorCheck("daemon", "Daemon", "degraded", "Daemon health could not be evaluated.")
466
+
467
+
468
+ def check_mcp() -> DoctorCheck:
469
+ package = importlib.util.find_spec("mcp") is not None
470
+ command = shutil.which("graphite-mcp") is not None
471
+ details = {"python_package": package, "command": command}
472
+ if package and command:
473
+ return DoctorCheck("mcp", "MCP", "ready", "MCP integration is available.", details)
474
+ return DoctorCheck("mcp", "MCP", "optional", "MCP integration is not fully activated.", details, ("Install the Graphite MCP extra and ensure `graphite-mcp` is on PATH if MCP is needed.",))
475
+
476
+
477
+ def check_typescript(root: Path, *, timeout_seconds: float = 5.0) -> DoctorCheck:
478
+ executable = _resolve_node_executable(root)
479
+ if executable is None:
480
+ return DoctorCheck("typescript", "TypeScript", "optional", "Node.js is unavailable; TypeScript compiler resolution is optional.")
481
+ script = "try{const p=require('typescript/package.json');process.stdout.write(JSON.stringify({ok:true,version:p.version}))}catch(e){process.stdout.write(JSON.stringify({ok:false,reason:'missing'}))}"
482
+ try:
483
+ returncode, stdout, timed_out = _run_node_probe(executable, root, script, timeout_seconds=timeout_seconds)
484
+ if timed_out:
485
+ return DoctorCheck("typescript", "TypeScript", "degraded", "TypeScript probe timed out.")
486
+ if returncode != 0:
487
+ return DoctorCheck("typescript", "TypeScript", "degraded", "TypeScript probe failed unexpectedly.")
488
+ data = json.loads(stdout.decode("utf-8"))
489
+ if data.get("ok") is True and isinstance(data.get("version"), str):
490
+ return DoctorCheck("typescript", "TypeScript", "ready", "TypeScript compiler is available.", {"version": data["version"][:64]})
491
+ if data.get("reason") == "missing":
492
+ return DoctorCheck("typescript", "TypeScript", "optional", "TypeScript compiler module is unavailable.", remediation=("Add TypeScript to the selected repository if compiler resolution is needed.",))
493
+ return DoctorCheck("typescript", "TypeScript", "degraded", "TypeScript probe returned an invalid response.")
494
+ except (OSError, UnicodeError, json.JSONDecodeError, TypeError, AttributeError):
495
+ return DoctorCheck("typescript", "TypeScript", "degraded", "TypeScript probe returned an invalid response.")
496
+
497
+
498
+ def _resolve_node_executable(root: Path, *, platform_name: str | None = None) -> Path | None:
499
+ selected_platform = os.name if platform_name is None else platform_name
500
+ name = "node.exe" if selected_platform == "nt" else "node"
501
+ resolved_root = root.resolve()
502
+ for raw_directory in os.environ.get("PATH", "").split(os.pathsep):
503
+ directory = Path(raw_directory)
504
+ if not raw_directory or not directory.is_absolute():
505
+ continue
506
+ try:
507
+ candidate = (directory / name).resolve(strict=True)
508
+ if not candidate.is_file():
509
+ continue
510
+ if selected_platform != "nt" and not os.access(candidate, os.X_OK):
511
+ continue
512
+ try:
513
+ candidate.relative_to(resolved_root)
514
+ except ValueError:
515
+ return candidate
516
+ except OSError:
517
+ continue
518
+ return None
519
+
520
+
521
+ def _node_environment() -> dict[str, str]:
522
+ allowed = {"SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP"} if os.name == "nt" else {"HOME", "TMPDIR", "LANG"}
523
+ return {key: value for key, value in os.environ.items() if key.upper() in allowed}
524
+
525
+
526
+ def _run_node_probe(executable: Path, root: Path, script: str, *, timeout_seconds: float) -> tuple[int, bytes, bool]:
527
+ return _run_bounded_process(
528
+ [str(executable), "-e", script],
529
+ cwd=root,
530
+ env=_node_environment(),
531
+ timeout_seconds=timeout_seconds,
532
+ max_stdout_bytes=_TEXT_LIMIT,
533
+ )
534
+
535
+
536
+ def _close_pipe(process: subprocess.Popen[bytes]) -> bool:
537
+ if process.stdout is None:
538
+ return True
539
+ try:
540
+ process.stdout.close()
541
+ except Exception:
542
+ return False
543
+ return True
544
+
545
+
546
+ def _cleanup_process(process: subprocess.Popen[bytes], reader: threading.Thread) -> bool:
547
+ try:
548
+ process.kill()
549
+ except Exception:
550
+ pass
551
+ closed = _close_pipe(process)
552
+ try:
553
+ process.wait(timeout=_PROCESS_CLEANUP_SECONDS)
554
+ except Exception:
555
+ pass
556
+ reader.join(timeout=_PROCESS_CLEANUP_SECONDS)
557
+ return closed and not reader.is_alive()
558
+
559
+
560
+ def _run_bounded_process(command: list[str], *, cwd: Path, env: Mapping[str, str], timeout_seconds: float, max_stdout_bytes: int) -> tuple[int, bytes, bool]:
561
+ process = subprocess.Popen(command, cwd=cwd, shell=False, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=dict(env))
562
+ if process.stdout is None:
563
+ raise OSError("bounded process stdout unavailable")
564
+ output: list[bytes] = []
565
+ read_failed = threading.Event()
566
+
567
+ def read_bounded() -> None:
568
+ try:
569
+ output.append(process.stdout.read(max_stdout_bytes + 1))
570
+ except Exception:
571
+ read_failed.set()
572
+
573
+ reader = threading.Thread(target=read_bounded, daemon=True)
574
+ reader.start()
575
+ try:
576
+ returncode = process.wait(timeout=timeout_seconds)
577
+ except Exception:
578
+ _cleanup_process(process, reader)
579
+ return process.returncode or -1, b"", True
580
+ reader.join(timeout=_PROCESS_CLEANUP_SECONDS)
581
+ if reader.is_alive():
582
+ _cleanup_process(process, reader)
583
+ return returncode, b"", False
584
+ data = output[0] if output else b""
585
+ if read_failed.is_set() or len(data) > max_stdout_bytes or not _close_pipe(process):
586
+ _cleanup_process(process, reader)
587
+ return returncode, b"", False
588
+ return returncode, data, False
589
+
590
+
591
+ def check_llm_config(cfg: Config) -> DoctorCheck:
592
+ mode = cfg.llm_mode.strip().lower()
593
+ normalized_provider = cfg.llm_provider.strip().lower().replace("_", "-")
594
+ provider = canonical_provider_name(cfg.llm_provider)
595
+ credential = bool(cfg.llm_api_key)
596
+ details = {"mode": mode, "provider": provider, "credential_present": credential}
597
+ if mode == "none":
598
+ summary = "LLM enrichment is disabled. An ambient credential is unused; rotate or remove it." if credential else "LLM enrichment is disabled."
599
+ return DoctorCheck("llm", "LLM", "optional", summary, details)
600
+ invalid_mode = mode not in {"auto", "local", "cloud"}
601
+ unsupported_provider = provider == "custom/unknown"
602
+ missing_base_url = normalized_provider in {"openai-compatible", "compatible"} and not cfg.llm_base_url
603
+ missing_cloud_credential = (
604
+ mode == "cloud"
605
+ and normalized_provider in {"openai", "openrouter", "groq"}
606
+ and not credential
607
+ )
608
+ if invalid_mode or unsupported_provider or missing_base_url or missing_cloud_credential:
609
+ return DoctorCheck(
610
+ "llm",
611
+ "LLM",
612
+ "degraded",
613
+ "LLM required configuration is incomplete or invalid.",
614
+ details,
615
+ ("Verify the provider, endpoint, model, and session credential configuration.",),
616
+ )
617
+ return DoctorCheck(
618
+ "llm",
619
+ "LLM",
620
+ "optional",
621
+ "LLM is configured but synthetic connectivity was not requested.",
622
+ details,
623
+ ("Run doctor with --deep --include-llm when network access is approved.",),
624
+ )
625
+
626
+
627
+ def _incidents_check(root: Path) -> DoctorCheck:
628
+ from .incident_ledger import fold_incidents, read_incident_entries, repo_ledger_dir
629
+
630
+ entries, skipped = read_incident_entries(repo_ledger_dir(root))
631
+ views = [v for v in fold_incidents(entries) if v.state != "resolved"]
632
+ open_views = [v for v in views if v.state == "open"]
633
+ top = [f"{v.fingerprint} {v.code} {v.subject} x{v.count}" for v in open_views[:10]]
634
+ summary = f"{len(open_views)} open / {len(views) - len(open_views)} acked"
635
+ if skipped:
636
+ summary += f", {skipped} corrupt line(s)"
637
+ status = "degraded" if open_views else "ready"
638
+ return DoctorCheck(
639
+ code="incidents",
640
+ label="Incident ledger",
641
+ status=status,
642
+ summary=summary,
643
+ details={"top_open": top},
644
+ remediation=("graphite incidents list", "graphite incidents ack <fingerprint> -m NOTE")
645
+ if open_views
646
+ else (),
647
+ )
648
+
649
+
650
+ def _fast_checks(root: Path, cfg: Config, daemon_base: Path | None) -> list[DoctorCheck]:
651
+ checks: list[tuple[str, str, Callable[[], DoctorCheck]]] = [("python", "Python", check_python), ("git", "Git", lambda: check_git(root)), ("hooks", "Hooks", lambda: check_hooks(root)), ("agent-hooks", "Agent hooks", lambda: check_agent_hooks(root)), ("managed-docs", "Managed docs", lambda: check_managed_docs(root)), ("graph", "Graph", lambda: check_graph(root, cfg)), ("daemon", "Daemon", lambda: check_daemon(root, daemon_base or root)), ("mcp", "MCP", check_mcp), ("typescript", "TypeScript", lambda: check_typescript(root)), ("llm", "LLM", lambda: check_llm_config(cfg)), ("incidents", "Incident ledger", lambda: _incidents_check(root))]
652
+ results = []
653
+ for code, label, check in checks:
654
+ try:
655
+ results.append(check())
656
+ except Exception:
657
+ results.append(DoctorCheck(code, label, "degraded", "The readiness check failed safely."))
658
+ return results
659
+
660
+
661
+ def run_doctor(root: Path, cfg: Config, daemon_base: Path | None = None, deep: bool = False, include_llm: bool = False, deep_runner: Callable[[Path, Config, bool], Iterable[DoctorCheck]] | None = None) -> dict[str, Any]:
662
+ selected = root.resolve()
663
+ selected_daemon_base = (daemon_base or default_projects_root()).resolve()
664
+ checks = _fast_checks(selected, cfg, selected_daemon_base)
665
+ if deep:
666
+ if deep_runner is None:
667
+ from .doctor_probes import run_deep_probes
668
+ deep_runner = run_deep_probes
669
+ deep_checks = list(deep_runner(selected, cfg=cfg, include_llm=include_llm))
670
+ disabled_llm = isinstance(cfg.llm_mode, str) and cfg.llm_mode.strip().lower() == "none"
671
+ if (
672
+ include_llm
673
+ and not disabled_llm
674
+ and any(check.code == "deep_llm" for check in deep_checks)
675
+ ):
676
+ checks = [check for check in checks if check.code != "llm"]
677
+ checks.extend(deep_checks)
678
+ return build_report(selected, checks, deep, bool(deep and include_llm))