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/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))
|