autoforge-cli 0.1.0a0__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 (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/doctor.py ADDED
@@ -0,0 +1,1551 @@
1
+ """`autoforge doctor`: read-only environment checks.
2
+
3
+ Every check is non-destructive: version queries, `gh auth status`,
4
+ `git rev-parse`, `pi auth check --no-refresh`, reading the config, and
5
+ creating/removing one temp file in the state directory to prove it is
6
+ writable.
7
+
8
+ The agent CLIs checked are the ones the run can reach: each distinct
9
+ command named by a reachable profile, once, labelled with those profiles.
10
+ A Pi profile adds a version floor and a read-only credential probe, run
11
+ under the same environment allow-list as the agent launch (never the
12
+ operator's whole environment) and reported without Pi's raw output, and a
13
+ warning naming the instruction files Pi would load from outside the
14
+ checkout (docs/pi-policy.md §4-5); those are found by path, never read.
15
+ It also warns where the controller cannot contain Pi's detached tool
16
+ processes (it cannot make itself a child subreaper: not Linux, or a
17
+ security policy denies it), where unattended Pi use is not supported
18
+ (docs/pi.md, #135). A Claude Code command must list the flags every
19
+ launch keeps the operator's and the repository's integrations off with in
20
+ its ``--help`` (#165) and fails while its settings or global
21
+ configuration set a variable over a launch's isolation, and an OpenCode
22
+ profile fails while OpenCode would load an organization's configuration;
23
+ a launch refuses either (#165,
24
+ :meth:`autoforge.providers.ClaudeCodeProvider.integration_preflight`,
25
+ :meth:`autoforge.providers.OpenCodeProvider.integration_preflight`).
26
+
27
+ Both report the environment an agent and a repository-defined command are
28
+ launched with, by name only (never a value), and run the launch's own
29
+ credential preflight (#165, :func:`autoforge.child_env.preflight`): one
30
+ local ``git config`` read, ``~/.netrc`` and one login shell per shell a
31
+ launch may start; a credential helper in the repository's configuration, a
32
+ URL with an embedded credential, an HTTP header for git, a GitHub entry in
33
+ ``~/.netrc`` or a shell startup file that changes a launch's variables
34
+ fails ``doctor`` exactly as it would refuse the launch. ``~/.curlrc``,
35
+ wget's configuration and zsh's startup files need no check: a launch's
36
+ curl, wget and zsh read the controller's empty ones instead. The
37
+ ``origin`` URL is shown redacted.
38
+
39
+ A REMOTE run's doctor also makes the merge gate's checks read on one open
40
+ pull request whose head has check results (#291): GitHub refuses a
41
+ fine-grained personal access token the check runs of a private repository,
42
+ which no permission it can be given changes. PASS needs a check run read;
43
+ no such pull request is UNKNOWN, never a PASS.
44
+
45
+ Every run shows the parallel EPIC scheduler's settings (``scheduler.*``,
46
+ bounded by the configuration loader). ``doctor --epic <url>`` adds the
47
+ scheduler's own checks (ADR 0008, #183): the coordination root (the git
48
+ common dir) is on a filesystem whose ``flock`` is a local kernel lock
49
+ (:func:`autoforge.locking.check_local_lock_filesystem`); the scheduler root
50
+ is absent or a real directory owned by this user and not world-writable
51
+ (``lstat`` only); and ``gh`` can read the EPIC's sub-issues and
52
+ dependencies (#179's snapshot, its four reads). They create nothing.
53
+
54
+ ``autoforge local doctor`` runs the LOCAL subset: no `gh`, no `gh auth
55
+ status`, no `origin` remote. A machine with no GitHub CLI and no GitHub
56
+ credentials must pass it, so the checks it omits are omitted entirely rather
57
+ than reported as warnings.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import os
63
+ import re
64
+ import secrets
65
+ import stat
66
+ from collections.abc import Callable
67
+ from dataclasses import dataclass
68
+ from pathlib import Path
69
+
70
+ from .child_env import (
71
+ LaunchIdentity,
72
+ child_shell,
73
+ describe_overrides,
74
+ forbidden_env_entry,
75
+ isolated_overrides,
76
+ preflight,
77
+ )
78
+ from .config import (
79
+ MAX_PARALLEL_BOUNDS,
80
+ POLL_INTERVAL_BOUNDS,
81
+ STOP_GRACE_BOUNDS,
82
+ AutoForgeConfig,
83
+ ProfileConfig,
84
+ load_config_file,
85
+ validate_required_profiles,
86
+ )
87
+ from .config_fingerprint import fingerprint_routing
88
+ from .epic_graph import build_epic_snapshot, evaluate, ready_set
89
+ from .errors import (
90
+ ConfigurationError,
91
+ ExecutionError,
92
+ GitHubChecksAccessError,
93
+ GitHubError,
94
+ GitHubNotFoundError,
95
+ GitHubPlanLimitedError,
96
+ GitHubUnavailableError,
97
+ LockError,
98
+ SnapshotRefused,
99
+ StateError,
100
+ )
101
+ from .executor import (
102
+ ExecutionRequest,
103
+ ExecutionResult,
104
+ execute,
105
+ orphan_containment_available,
106
+ )
107
+ from .github import (
108
+ GH_MIN_VERSION,
109
+ GitHubClient,
110
+ checks_access_remedy,
111
+ gh_request,
112
+ gh_workdir,
113
+ is_access_denied_gh_failure,
114
+ parse_gh_version,
115
+ )
116
+ from .local_workspace import DEFAULT_MAX_BYTES, DEFAULT_MAX_ENTRIES
117
+ from .locking import check_local_lock_filesystem, repository_lock_path
118
+ from .profiles import REQUIRED_PROFILES, local_required_profiles
119
+ from .providers import (
120
+ CLAUDE_ISOLATION_FLAGS,
121
+ OPENCODE_MIN_VERSION,
122
+ PI_CONTEXT_FILES,
123
+ PI_DEFAULT_AGENT_DIR,
124
+ PI_MIN_VERSION,
125
+ PI_SYSTEM_PROMPT_FILES,
126
+ ClaudeCodeProvider,
127
+ OpenCodeProvider,
128
+ PiProvider,
129
+ parse_opencode_version,
130
+ parse_pi_version,
131
+ provider_for,
132
+ )
133
+ from .redaction import redact, redact_argv
134
+ from .run_namespace import AUTOFORGE_DIRNAME, SCHEDULER_DIRNAME
135
+ from .safefs import STATE_DIR_NEEDS_HARD_LINKS, HardLinksUnavailable, SafeRoot
136
+ from .sdk_policy import SDK_PROVIDERS
137
+ from .validation import parse_issue_url, parse_remote_repository
138
+
139
+ Runner = Callable[[ExecutionRequest], ExecutionResult]
140
+ # A fixed list of required profile names, or a function of the loaded config.
141
+ RequiredProfiles = list[str] | Callable[[AutoForgeConfig], list[str]]
142
+
143
+ # Fallback executable for a provider whose profile does not set `command`,
144
+ # matching the provider adapters' own defaults.
145
+ DEFAULT_AGENT_COMMANDS = {"claude": "claude", "opencode": "opencode", "pi": "pi"}
146
+
147
+ # The profile a REMOTE run reaches only after a merge, on top of
148
+ # `REQUIRED_PROFILES`; checked when it is configured.
149
+ REMOTE_POST_MERGE_PROFILES = ["update_epic"]
150
+
151
+ # Name of the `doctor` row that verifies the default branch's required checks.
152
+ REQUIRED_CHECKS_ROW = "default branch requires checks"
153
+ # Name of the `doctor` row that tries the merge gate's checks read (#291).
154
+ CHECKS_READ_ROW = "pull request check runs readable"
155
+ # How many candidate PRs that row reads before it gives up on finding a check
156
+ # run: each is one `gh pr view`, and a candidate whose results are commit
157
+ # statuses only proves nothing about check runs.
158
+ CHECKS_READ_TRIES = 3
159
+
160
+
161
+ @dataclass
162
+ class CheckResult:
163
+ name: str
164
+ ok: bool
165
+ detail: str = ""
166
+ required: bool = True
167
+ # The check could not be performed (a read this token is not allowed to
168
+ # make, a transient GitHub failure, an earlier check it depends on
169
+ # failed). Neither a pass nor a failure: it never fails `doctor`, and it
170
+ # never claims the property holds.
171
+ skipped: bool = False
172
+
173
+ @property
174
+ def label(self) -> str:
175
+ if self.skipped:
176
+ return "SKIP"
177
+ if self.ok:
178
+ return "OK "
179
+ return "FAIL" if self.required else "WARN"
180
+
181
+ @classmethod
182
+ def skip(cls, name: str, detail: str) -> CheckResult:
183
+ return cls(name, False, detail, required=False, skipped=True)
184
+
185
+
186
+ class Doctor:
187
+ def __init__(
188
+ self,
189
+ config_path: str | None = None,
190
+ state_dir: str | None = None,
191
+ cwd: str | None = None,
192
+ runner: Runner | None = None,
193
+ timeout_seconds: int = 30,
194
+ epic_url: str | None = None,
195
+ ) -> None:
196
+ self.config_path = config_path
197
+ # ``doctor --epic``: the EPIC whose graph the scheduler rows read.
198
+ self.epic_url = epic_url
199
+ self.state_dir = state_dir
200
+ self.cwd = cwd or os.getcwd()
201
+ self._runner: Runner = runner or execute
202
+ self.timeout = timeout_seconds
203
+ self.config: AutoForgeConfig | None = None
204
+ # 'owner/repo' of the `origin` remote, set exactly when `check_remote` passes.
205
+ self.repo: str | None = None
206
+ # Where the TCP listeners are read from (ADR 0005 D9.3); a test points
207
+ # it at its own files.
208
+ self.proc_net = "/proc/net"
209
+
210
+ @property
211
+ def runner(self) -> Runner:
212
+ """What every check's process runs through (a test's fake, or the executor)."""
213
+ return self._runner
214
+
215
+ # -- helpers -----------------------------------------------------------------
216
+ def _run(self, argv: list[str]) -> tuple[bool, str]:
217
+ return self._run_request(
218
+ ExecutionRequest(command=argv, cwd=self.cwd, timeout_seconds=self.timeout)
219
+ )
220
+
221
+ def _run_gh(self, argv: list[str]) -> tuple[bool, str]:
222
+ """:meth:`_run` for a `gh` argv: in an empty directory of its own, never the checkout.
223
+
224
+ The controller runs every `gh` that way (#283), and so does doctor.
225
+ """
226
+ try:
227
+ with gh_workdir() as workdir:
228
+ return self._run_request(gh_request(argv, workdir, timeout_seconds=self.timeout))
229
+ except GitHubError as exc: # no empty directory could be made
230
+ return False, str(exc)
231
+
232
+ def _run_request(self, request: ExecutionRequest) -> tuple[bool, str]:
233
+ try:
234
+ res = self._runner(request)
235
+ except Exception as exc: # spawn failure / missing binary
236
+ return False, f"{type(exc).__name__}: {exc}"
237
+ text = (res.stdout or "").strip() or (res.stderr or "").strip()
238
+ first = text.splitlines()[0] if text else ""
239
+ if res.timed_out:
240
+ return False, "timed out"
241
+ return res.exit_code == 0, first if res.exit_code == 0 else (text[-400:] or "non-zero exit")
242
+
243
+ def _version_check(self, name: str, argv: list[str]) -> CheckResult:
244
+ ok, detail = self._run(argv)
245
+ return CheckResult(name, ok, detail)
246
+
247
+ # -- checks --------------------------------------------------------------------
248
+ def check_config(self, required_profiles: RequiredProfiles | None = None) -> CheckResult:
249
+ """Load the config and require its profiles.
250
+
251
+ ``required_profiles`` may be a fixed list or a callable, because a
252
+ LOCAL run's required reviewer profiles are derived from the config that
253
+ is only loaded here (see :func:`autoforge.profiles.local_required_profiles`).
254
+
255
+ ``self.config`` is set exactly when this check passes, and to what
256
+ *this* attempt accepted. Every later check reads it, so it must not
257
+ describe a config a run would refuse: not a previous file (a `Doctor`
258
+ reused after its config turned invalid), and not one that parsed but
259
+ failed profile validation -- a run does not start on such a config
260
+ (`ControllerEngine.validate_config`), so the merge gate must not claim
261
+ what a run "WILL" do with it.
262
+ """
263
+ self.config = None
264
+ try:
265
+ cfg = load_config_file(self.config_path)
266
+ required = required_profiles or REQUIRED_PROFILES
267
+ names = required(cfg) if callable(required) else required
268
+ validate_required_profiles(cfg, names)
269
+ if required_profiles is None:
270
+ # A REMOTE run: its routing is recorded in its state file (#219).
271
+ fingerprint_routing(cfg)
272
+ except ConfigurationError as exc:
273
+ return CheckResult("config", False, str(exc))
274
+ # #280: a file an enforced launch could have rewritten is refused
275
+ # here as every command refuses it, and since ``self.config`` stays
276
+ # unset, no check runs a command it names (a profile's ``command``,
277
+ # ``github.command``).
278
+ from .engine import config_file_placement_problem
279
+
280
+ problem = config_file_placement_problem(cfg, self.config_path or "", self.cwd, self.runner)
281
+ if problem:
282
+ return CheckResult(
283
+ "config", False, f"{problem}; doctor runs no command this file names"
284
+ )
285
+ self.config = cfg
286
+ src = self.config_path or "(built-in defaults)"
287
+ return CheckResult("config", True, f"{src}; profiles: {', '.join(sorted(cfg.profiles))}")
288
+
289
+ def check_merge_gate(self) -> CheckResult:
290
+ """Report the effective merge gate and the file/key that set it (informational).
291
+
292
+ The gate has two halves -- `safety.allow_merge` in config AND
293
+ `--allow-merge` on the CLI -- and `doctor` cannot see the flag, so it
294
+ states what the flag *would* do with this config. The source is named
295
+ so an operator who believes the gate is closed can check the key that
296
+ decides it rather than the one they last edited.
297
+ """
298
+ name = "merge gate"
299
+ cfg = self.config
300
+ if cfg is None:
301
+ return CheckResult(name, False, "(config check failed)", required=False)
302
+ source = cfg.safety.allow_merge_source
303
+ if cfg.merge_allowed_by_config:
304
+ detail = (
305
+ f"config half OPEN: safety.allow_merge=true ({source}); "
306
+ "a run with --allow-merge WILL merge PRs itself"
307
+ )
308
+ else:
309
+ detail = (
310
+ f"CLOSED: safety.allow_merge=false ({source}); --allow-merge alone cannot merge"
311
+ )
312
+ return CheckResult(name, True, detail, required=False)
313
+
314
+ def check_premerge_verification(self) -> CheckResult:
315
+ """Report the controller's own pre-merge evidence (informational; runs nothing).
316
+
317
+ A green required check proves the PR's workflow ran, not that the
318
+ base branch's definition ran (``safety.verify_check_definition``)
319
+ and not what the tests assert (``merge.verification_commands``).
320
+ With the config half of the merge gate open and neither in place,
321
+ the hosted check alone decides what gets merged unattended, which
322
+ is worth stating next to the gate rather than discovering later.
323
+ """
324
+ name = "pre-merge verification"
325
+ cfg = self.config
326
+ if cfg is None:
327
+ return CheckResult(name, False, "(config check failed)", required=False)
328
+ definition = bool(cfg.safety.verify_check_definition and cfg.safety.required_checks)
329
+ commands = cfg.merge.verification_commands
330
+ parts = [
331
+ (
332
+ "check definition compared to the base branch's run "
333
+ f"({', '.join(cfg.safety.required_checks)})"
334
+ if definition
335
+ else "check definition NOT verified (safety.verify_check_definition)"
336
+ ),
337
+ (
338
+ "local commands: " + "; ".join(" ".join(argv) for argv in commands)
339
+ if commands
340
+ else "no merge.verification_commands: nothing runs locally before a merge"
341
+ ),
342
+ ]
343
+ detail = "; ".join(parts)
344
+ if cfg.merge_allowed_by_config and not commands:
345
+ detail += (
346
+ " -- with safety.allow_merge=true a green check is the only evidence about "
347
+ "what the PR's tests assert; consider merge.verification_commands"
348
+ )
349
+ return CheckResult(name, True, detail, required=False)
350
+
351
+ def check_candidate_validation(self) -> CheckResult:
352
+ """List ``validation.commands`` (informational; runs nothing, ADR 0006 D3.1).
353
+
354
+ The definitions REMOTE candidate validation runs in a candidate's
355
+ byte-exact export, redacted, and the mode their records are made
356
+ under; none is launched here.
357
+ """
358
+ name = "candidate validation (validation.commands)"
359
+ cfg = self.config
360
+ if cfg is None:
361
+ return CheckResult(name, False, "(config check failed)", required=False)
362
+ commands = cfg.validation.commands
363
+ mode = f"records made under sandbox.mode {cfg.sandbox.mode}"
364
+ if not cfg.sandbox.enabled:
365
+ mode += " (validators run unsandboxed as the operator's UID: unenforced evidence)"
366
+ if not commands:
367
+ return CheckResult(
368
+ name, True, f"(none configured: an empty set); {mode}", required=False
369
+ )
370
+ listed = "; ".join(" ".join(redact_argv(list(argv))) for argv in commands)
371
+ return CheckResult(
372
+ name, True, f"{len(commands)} definition(s): {listed}; {mode}", required=False
373
+ )
374
+
375
+ def check_state_dir(self) -> CheckResult:
376
+ d = self.state_dir or (self.config.state_dir if self.config else ".autoforge")
377
+ path = Path(self.cwd) / d if not Path(d).is_absolute() else Path(d)
378
+ problem = self._writable(lambda: SafeRoot.open(path, create=True))
379
+ if problem is not None:
380
+ return CheckResult("state dir writable", False, f"{path}: {problem}")
381
+ return CheckResult("state dir writable", True, str(path))
382
+
383
+ def check_git_repo(self) -> CheckResult:
384
+ ok, detail = self._run(["git", "rev-parse", "--show-toplevel"])
385
+ if not ok:
386
+ return CheckResult("cwd is a git repository", False, detail)
387
+ return CheckResult("cwd is a git repository", True, detail)
388
+
389
+ def check_remote(self) -> CheckResult:
390
+ self.repo = None
391
+ ok, detail = self._run(["git", "remote", "get-url", "origin"])
392
+ if not ok:
393
+ return CheckResult("GitHub remote", False, f"no 'origin' remote: {redact(detail)}")
394
+ # The URL is shown redacted: one carrying a token is refused by the
395
+ # credential row, and never printed.
396
+ try:
397
+ repo = parse_remote_repository(detail)
398
+ except Exception as exc:
399
+ return CheckResult("GitHub remote", False, redact(f"{detail!r}: {exc}"))
400
+ self.repo = repo
401
+ return CheckResult("GitHub remote", True, f"{repo} ({redact(detail)})")
402
+
403
+ def check_agent_environment(self) -> CheckResult:
404
+ """What an agent and a repository-defined command inherit, by name only.
405
+
406
+ Informational: the configured allow-list (each provider adds its own
407
+ names on top), the names the controller sets for every launch, and
408
+ the credential-route names this shell holds that no launch receives.
409
+ """
410
+ cfg = self.config if self.config is not None else AutoForgeConfig()
411
+ names = cfg.execution.environment_names()
412
+ withheld = sorted(name for name in os.environ if forbidden_env_entry(name))
413
+ detail = (
414
+ f"allow-listed: {', '.join(names)} (plus each provider's own names); "
415
+ f"set by the controller: {describe_overrides()}; "
416
+ f"withheld from this shell: {', '.join(withheld) or '(none present)'}"
417
+ )
418
+ return CheckResult("launch environment", True, detail, required=False)
419
+
420
+ def check_launch_credentials(self) -> CheckResult:
421
+ """The launch preflight, run here: no GitHub or git credential reaches a child."""
422
+ name = "no credential reaches a launch"
423
+ cfg = self.config if self.config is not None else AutoForgeConfig()
424
+ try:
425
+ identity = preflight(
426
+ self.cwd, self._runner, env_allowlist=cfg.execution.environment_names()
427
+ )
428
+ except ConfigurationError as exc:
429
+ return CheckResult(name, False, redact(str(exc)))
430
+ return CheckResult(
431
+ name,
432
+ True,
433
+ "no credential helper, credential-bearing URL or HTTP header in the repository's "
434
+ "git configuration, and no GitHub entry in ~/.netrc; a launch's curl and wget "
435
+ "read an empty configuration of the controller's, never ~/.curlrc or ~/.wgetrc; "
436
+ f"a launch's shell is {child_shell()} and no login shell's startup files change "
437
+ "its variables, zsh reading none of yours; "
438
+ f"author identity supplied: {identity.describe()}",
439
+ )
440
+
441
+ def check_required_checks(self, gh: str) -> CheckResult:
442
+ """Whether the default branch really *requires* `safety.required_checks`.
443
+
444
+ The merge gate verifies that every check on the PR succeeded, which
445
+ is a statement about check results, not about whether any check had
446
+ to exist: a branch that requires nothing makes a PR with no check
447
+ runs vacuously green. The requirement lives in repository settings,
448
+ outside version control, so this is the only place drift is caught.
449
+
450
+ Read-only. When the branch rules cannot be verified -- GitHub's
451
+ plan offers none on this repository (#289), the token may not read
452
+ them, GitHub reports the repository not found, or GitHub could not
453
+ be read at all (transient) -- nothing shows that the merge gate's
454
+ "every check succeeded" has a required check behind it. With the
455
+ configuration's half of the merge gate open (``safety.allow_merge:
456
+ true``) that is a FAIL; with it closed a SKIP that says why. A plan
457
+ limitation is named as one and never sent to the token's
458
+ permissions: no permission changes it. The same holds for a rule
459
+ whose bypass actors GitHub withholds (it returns them only with
460
+ write access to the ruleset) when it also does not say whether this
461
+ token may bypass it: whether the controller's own login can skip the
462
+ rule is then unverified. When GitHub does say that this token may
463
+ never bypass it, the hidden list is a SKIP either way: the required
464
+ checks were read and the controller cannot bypass them. A problem
465
+ that *is* visible (a missing context, a non-active ruleset, a bypass
466
+ actor) is a FAIL regardless of what stayed hidden. Never an
467
+ unverified OK.
468
+ """
469
+ name = REQUIRED_CHECKS_ROW
470
+ cfg = self.config
471
+ if cfg is None:
472
+ return CheckResult.skip(name, "(config check failed)")
473
+ if self.repo is None:
474
+ return CheckResult.skip(name, "(GitHub remote check failed)")
475
+ client = GitHubClient(gh_command=gh, timeout_seconds=self.timeout, runner=self._runner)
476
+ try:
477
+ return self._required_checks(client, self.repo, cfg.safety.required_checks)
478
+ except GitHubUnavailableError as exc:
479
+ return self._required_checks_unverifiable(
480
+ f"GitHub could not be read (transient; run doctor again): {exc}",
481
+ "run doctor again",
482
+ )
483
+ except GitHubPlanLimitedError as exc:
484
+ return self._required_checks_unverifiable(
485
+ _plan_limited_text(self.repo, exc), _PLAN_REMEDY
486
+ )
487
+ except GitHubError as exc:
488
+ if isinstance(exc, GitHubNotFoundError) or is_access_denied_gh_failure(str(exc)):
489
+ return self._required_checks_unverifiable(
490
+ f"this token cannot read the branch rules of {self.repo}: {exc}",
491
+ _READ_RULES_REMEDY,
492
+ )
493
+ return CheckResult(name, False, str(exc))
494
+
495
+ def _required_checks_unverifiable(self, why: str, remedy: str) -> CheckResult:
496
+ """No verified answer about the default branch's required checks (#289).
497
+
498
+ A FAIL with the gate open, a SKIP that says why with it closed. The
499
+ merge gate's own rollup check refuses a rollup in which nothing
500
+ succeeded, but only a branch rule makes a check *required*: with
501
+ ``safety.allow_merge: true`` and nothing doctor can verify requiring
502
+ one, the unattended merge rests on a property no one verified.
503
+ ``remedy`` is the way out for this cause, never a permission for a
504
+ plan limitation.
505
+ """
506
+ name = REQUIRED_CHECKS_ROW
507
+ if self.config is not None and self.config.merge_allowed_by_config:
508
+ return CheckResult(
509
+ name,
510
+ False,
511
+ redact(
512
+ f"{why}; with safety.allow_merge=true the merge gate would merge without "
513
+ f"a branch rule doctor could verify requires a check: {remedy}, or close "
514
+ "the gate (safety.allow_merge: false)"
515
+ ),
516
+ )
517
+ return CheckResult.skip(
518
+ name,
519
+ redact(f"{why} (the merge gate is closed, so no unattended merge relies on it)"),
520
+ )
521
+
522
+ def _required_checks(self, client: GitHubClient, repo: str, expected: list[str]) -> CheckResult:
523
+ name = REQUIRED_CHECKS_ROW
524
+ branch = client.get_repo(repo).default_branch
525
+ if not branch:
526
+ return CheckResult(name, False, f"{repo}: GitHub reports no default branch")
527
+ rules = client.get_required_status_check_rules(repo, branch)
528
+ if not rules:
529
+ return self._required_checks_without_ruleset(client, repo, branch, expected)
530
+ contexts = sorted({context for rule in rules for context in rule.contexts})
531
+ problems = _missing_contexts(contexts, expected)
532
+ # The effective-rules read lists active rulesets only, but the
533
+ # ruleset itself says who may bypass it, and that is read separately.
534
+ # GitHub withholds `bypass_actors` from a token without write access
535
+ # to the ruleset (the read still succeeds), so "no bypass actors" is
536
+ # established only by an explicitly empty list; `current_user_can_
537
+ # bypass` is returned to every caller and a token that may bypass the
538
+ # rule is a bypass actor whether or not the list is visible.
539
+ unseen: list[str] = [] # list hidden; GitHub says this token may never bypass
540
+ unverified: list[str] = [] # list hidden, and nothing said about this token
541
+ for ruleset_id in sorted({rule.ruleset_id for rule in rules}):
542
+ ruleset = client.get_ruleset(repo, ruleset_id)
543
+ label = f"ruleset '{ruleset.name}' (#{ruleset.id})"
544
+ can_bypass = ruleset.current_user_can_bypass
545
+ if not ruleset.is_active:
546
+ problems.append(f"{label} enforcement is '{ruleset.enforcement}', not 'active'")
547
+ if ruleset.bypass_actors:
548
+ problems.append(
549
+ f"{label} can be bypassed by: {', '.join(ruleset.bypass_actors)}"
550
+ + (f" (this token: {can_bypass})" if can_bypass else "")
551
+ )
552
+ elif can_bypass not in ("", "never"):
553
+ problems.append(f"{label} can be bypassed by this token ({can_bypass})")
554
+ elif ruleset.bypass_actors is None:
555
+ (unverified if can_bypass == "" else unseen).append(label)
556
+ sources = ", ".join(
557
+ f"ruleset #{rule.ruleset_id} ({rule.ruleset_source_type or '?'} {rule.ruleset_source})"
558
+ for rule in rules
559
+ )
560
+ summary = f"'{branch}' requires: {', '.join(contexts) or '(no context)'} via {sources}"
561
+ hidden = (
562
+ [
563
+ f"bypass actors of {', '.join(unseen)} are not visible to this token (GitHub "
564
+ "returns them only with write access to the ruleset), so 'no bypass actors' "
565
+ "is unverified"
566
+ ]
567
+ if unseen
568
+ else []
569
+ )
570
+ if unverified:
571
+ hidden.append(
572
+ f"bypass actors of {', '.join(unverified)} are not visible to this token, and "
573
+ "GitHub did not say whether this token may bypass the rule, so whether the "
574
+ "controller's own login can skip the required checks is unverified"
575
+ )
576
+ if problems:
577
+ # A visible problem is conclusive whatever else stayed hidden;
578
+ # the hidden part is still named so the remedy is known to be partial.
579
+ return CheckResult(name, False, "; ".join([summary, *problems, *hidden]))
580
+ if unverified:
581
+ return self._required_checks_unverifiable(
582
+ "; ".join([summary, *hidden]),
583
+ "run doctor with a login that has write access to the ruleset",
584
+ )
585
+ if hidden:
586
+ return CheckResult.skip(name, f"{summary}; {hidden[0]}")
587
+ return CheckResult(name, True, summary)
588
+
589
+ def _required_checks_without_ruleset(
590
+ self, client: GitHubClient, repo: str, branch: str, expected: list[str]
591
+ ) -> CheckResult:
592
+ """No active ruleset requires a check: classic branch protection is the last resort."""
593
+ name = REQUIRED_CHECKS_ROW
594
+ remedy = (
595
+ f"add a ruleset at https://github.com/{repo}/settings/rules targeting '{branch}' "
596
+ f"with a 'Require status checks to pass' rule naming "
597
+ f"{', '.join(expected) or 'the CI check'}, enforcement 'active' and no bypass actors"
598
+ )
599
+ try:
600
+ classic = client.get_branch_protection(repo, branch)
601
+ except GitHubUnavailableError:
602
+ raise
603
+ except GitHubPlanLimitedError as exc:
604
+ return self._required_checks_unverifiable(
605
+ f"no active ruleset requires a status check on '{branch}', and "
606
+ + _plan_limited_text(repo, exc),
607
+ _PLAN_REMEDY,
608
+ )
609
+ except GitHubError as exc:
610
+ if not is_access_denied_gh_failure(str(exc)):
611
+ raise
612
+ # Rulesets require nothing, and the only other mechanism cannot
613
+ # be read: no conclusion either way.
614
+ return self._required_checks_unverifiable(
615
+ f"no active ruleset requires a status check on '{branch}', and this token "
616
+ f"cannot read its classic branch protection (Administration: read): {exc}",
617
+ _READ_RULES_REMEDY,
618
+ )
619
+ if classic is not None and classic.required_status_checks:
620
+ contexts = sorted(classic.required_contexts)
621
+ problems = _missing_contexts(contexts, expected)
622
+ if not classic.enforce_admins:
623
+ problems.append("'Do not allow bypassing the above settings' is off for admins")
624
+ summary = (
625
+ f"'{branch}' requires: {', '.join(contexts) or '(no context)'} "
626
+ "via classic branch protection"
627
+ )
628
+ if problems:
629
+ return CheckResult(name, False, f"{summary}; {'; '.join(problems)}")
630
+ return CheckResult(name, True, summary)
631
+ # A disabled ruleset is invisible to the effective-rules read, so the
632
+ # listing is consulted to make the remedy concrete. It is a hint only:
633
+ # a listing this token may not read changes nothing about the answer.
634
+ try:
635
+ rulesets = client.list_rulesets(repo)
636
+ except GitHubUnavailableError:
637
+ raise
638
+ except GitHubError as exc:
639
+ if not (
640
+ isinstance(exc, GitHubPlanLimitedError) or is_access_denied_gh_failure(str(exc))
641
+ ):
642
+ raise
643
+ rulesets = []
644
+ hints = [
645
+ f"ruleset '{r.name}' (#{r.id}) exists but its enforcement is '{r.enforcement}'"
646
+ for r in rulesets
647
+ if r.target == "branch" and not r.is_active
648
+ ]
649
+ detail = f"no rule requires a status check on '{branch}'"
650
+ if classic is not None:
651
+ detail += " (classic branch protection exists but requires no status check)"
652
+ return CheckResult(name, False, "; ".join([detail, *hints, remedy]))
653
+
654
+ def check_checks_read(self, gh: str) -> CheckResult:
655
+ """Whether this `gh` login may read a pull request's check runs (#291).
656
+
657
+ The merge gate reads a PR's check results with
658
+ :meth:`GitHubClient.get_pr_with_checks`, and GitHub refuses a
659
+ fine-grained personal access token the check runs of a private
660
+ repository's PR: no permission it can be given grants them. This row
661
+ makes that same read, with the same `gh` command and login, on an
662
+ open PR whose head has at least one check result (selected by count,
663
+ :meth:`GitHubClient.list_checks_probe_candidates`), up to
664
+ :data:`CHECKS_READ_TRIES` of them. Read-only; it never reads, prints
665
+ or stores a token.
666
+
667
+ PASS only when the read returned at least one check *run*, the
668
+ result GitHub was seen to refuse (#257). Whether it refuses a PR
669
+ whose results are commit statuses only was not tried, so reading
670
+ one proves nothing about check runs: the next candidate is read,
671
+ and none with a check run is UNKNOWN. DENIED when GitHub refused the
672
+ read (whatever the PR's results), a FAIL while the
673
+ configuration's half of the merge gate is open (every merge would be
674
+ BLOCKED) and a WARN otherwise. UNKNOWN (a SKIP, never a PASS) when no
675
+ PR could be tried or the answer was a plan limitation, a not-found or
676
+ a transient failure: none of those says what this login may read.
677
+ """
678
+ name = CHECKS_READ_ROW
679
+ cfg = self.config
680
+ if cfg is None:
681
+ return CheckResult.skip(name, "(config check failed)")
682
+ if self.repo is None:
683
+ return CheckResult.skip(name, "(GitHub remote check failed)")
684
+ repo = self.repo
685
+ client = GitHubClient(gh_command=gh, timeout_seconds=self.timeout, runner=self._runner)
686
+ try:
687
+ candidates = client.list_checks_probe_candidates(repo)
688
+ except GitHubChecksAccessError as exc:
689
+ # Even the count was refused: that is the refusal itself.
690
+ return self._checks_read_denied(
691
+ f"GitHub refused this gh login the check results of {repo}'s open pull "
692
+ f"requests: {checks_access_remedy(exc.credential)}",
693
+ exc,
694
+ )
695
+ except GitHubError as exc:
696
+ return self._checks_read_unknown(
697
+ f"the open pull requests of {repo} could not be listed to pick one to read", exc
698
+ )
699
+ if not candidates:
700
+ return CheckResult.skip(
701
+ name,
702
+ f"UNKNOWN: no recently updated open pull request of {repo} has a check result "
703
+ "on its head, so the checks read the merge gate makes was not tried; run doctor "
704
+ "again while one does",
705
+ )
706
+ tried: list[str] = []
707
+ for url, _count in candidates[:CHECKS_READ_TRIES]:
708
+ try:
709
+ pr = client.get_pr_with_checks(url)
710
+ except GitHubChecksAccessError as exc:
711
+ return self._checks_read_denied(
712
+ f"GitHub refused this gh login the check results of {url}: "
713
+ f"{checks_access_remedy(exc.credential)}",
714
+ exc,
715
+ )
716
+ except GitHubError as exc:
717
+ if _pr_read_refused(exc):
718
+ return self._checks_read_denied(
719
+ f"GitHub refused this gh login the pull request {url} itself (see the "
720
+ "'gh authenticated' row)",
721
+ exc,
722
+ )
723
+ return self._checks_read_unknown(f"the checks read of {url} failed", exc)
724
+ runs = [check for check in pr.checks or [] if check.kind == "CheckRun"]
725
+ if runs:
726
+ return CheckResult(
727
+ name,
728
+ True,
729
+ f"PASS: this gh login read {len(pr.checks or [])} check result(s) of {url}, "
730
+ f"{len(runs)} of them check runs, with the merge gate's own read",
731
+ )
732
+ tried.append(url)
733
+ return CheckResult.skip(
734
+ name,
735
+ f"UNKNOWN: the check results of {', '.join(tried)} hold no check run (commit "
736
+ "statuses only), which proves nothing about the check runs GitHub may refuse",
737
+ )
738
+
739
+ def _checks_read_denied(self, what: str, exc: GitHubError) -> CheckResult:
740
+ gate_open = self.config is not None and self.config.merge_allowed_by_config
741
+ consequence = (
742
+ "with safety.allow_merge=true every merge would be BLOCKED at READY_FOR_MERGE"
743
+ if gate_open
744
+ else "a run with the merge gate open would be BLOCKED at READY_FOR_MERGE; "
745
+ "REVIEW and FIX proceed without the check results"
746
+ )
747
+ return CheckResult(
748
+ CHECKS_READ_ROW,
749
+ False,
750
+ redact(f"DENIED: {what}; {consequence}. GitHub's answer: {exc}"),
751
+ required=gate_open,
752
+ )
753
+
754
+ @staticmethod
755
+ def _checks_read_unknown(what: str, exc: GitHubError) -> CheckResult:
756
+ # The client's typed answers first; only a plain GitHubError is
757
+ # classified by its text.
758
+ if isinstance(exc, GitHubUnavailableError):
759
+ why = "a transient GitHub failure; run doctor again"
760
+ elif isinstance(exc, GitHubNotFoundError):
761
+ why = "the repository or pull request was not found by this login"
762
+ elif isinstance(exc, GitHubPlanLimitedError):
763
+ why = "a GitHub plan limitation, not this login's permissions"
764
+ elif type(exc) is GitHubError and is_access_denied_gh_failure(str(exc)):
765
+ why = "this login may not make that read (see the 'gh authenticated' row)"
766
+ else:
767
+ why = "an answer this check cannot classify"
768
+ return CheckResult.skip(CHECKS_READ_ROW, redact(f"UNKNOWN: {what} ({why}): {exc}"))
769
+
770
+ def check_gh_version(self, gh: str) -> CheckResult:
771
+ """`gh` runs and is at least :data:`GH_MIN_VERSION`.
772
+
773
+ The client's paginated reads use `gh api --paginate --slurp`, which an
774
+ older `gh` rejects. That would surface only as a conclusive read
775
+ failure at the merge gate (BLOCKED) or as a SKIP of the branch-rule
776
+ check, so the version is refused here, up front, with the minimum
777
+ named. A `--version` line the check cannot parse is a FAIL too: an
778
+ unknown version is not a known-good one.
779
+ """
780
+ name = "gh available"
781
+ ok, detail = self._run_gh([gh, "--version"])
782
+ if not ok:
783
+ return CheckResult(name, False, detail)
784
+ minimum = ".".join(str(part) for part in GH_MIN_VERSION)
785
+ found = parse_gh_version(detail)
786
+ if found is None:
787
+ return CheckResult(
788
+ name, False, f"cannot read the gh version from {detail!r} (need >= {minimum})"
789
+ )
790
+ if found < GH_MIN_VERSION:
791
+ return CheckResult(
792
+ name,
793
+ False,
794
+ f"{detail}: gh >= {minimum} is required "
795
+ "(`gh api --paginate --slurp`, used to list branch rules and a PR's changed files)",
796
+ )
797
+ return CheckResult(name, True, detail)
798
+
799
+ def check_gh_auth(self, gh: str) -> CheckResult:
800
+ ok, detail = self._run_gh([gh, "auth", "status"])
801
+ return CheckResult("gh authenticated", ok, detail)
802
+
803
+ def check_feature_spec(self, spec_path: str) -> CheckResult:
804
+ """Validate a feature specification path the way `local run` would."""
805
+ from .local_workspace import read_feature_spec
806
+
807
+ name = "feature specification"
808
+ ws = self._workspace()
809
+ try:
810
+ spec = read_feature_spec(ws, spec_path)
811
+ except Exception as exc:
812
+ return CheckResult(name, False, f"{spec_path}: {exc}")
813
+ return CheckResult(name, True, f"{spec.relative_path} (sha256 {spec.sha256[:16]}...)")
814
+
815
+ def check_validation_commands(self) -> CheckResult:
816
+ """Report the configured local validation commands (never runs them)."""
817
+ cfg = self.config
818
+ cmds = cfg.local.validation_commands if cfg else []
819
+ name = "local validation commands"
820
+ if not cmds:
821
+ return CheckResult(name, True, "(none configured)", required=False)
822
+ return CheckResult(name, True, "; ".join(" ".join(argv) for argv in cmds))
823
+
824
+ def run_local(self, feature_spec_path: str | None = None) -> list[CheckResult]:
825
+ """Checks a LOCAL run needs — and nothing that touches GitHub.
826
+
827
+ No `gh` binary, no `gh auth status`, no `origin` remote: a local run
828
+ makes zero GitHub calls, so requiring any of them here would be a
829
+ false failure on exactly the machine local mode exists for.
830
+ """
831
+ # Which reviewer profiles a local run needs depends on its configured
832
+ # review bound, so the requirement is derived from the loaded config
833
+ # rather than from a fixed list (`local_required_profiles`). `doctor`
834
+ # therefore fails on a missing `review_round_6_plus` exactly when a run
835
+ # with this `local.max_fix_rounds` could actually ask for it.
836
+ results = [self.check_config(local_required_profiles)]
837
+ cfg = self.config
838
+ results.append(self._version_check("git available", ["git", "--version"]))
839
+ results.append(self.check_git_repo())
840
+ results.append(self.check_agent_environment())
841
+ results.append(self.check_launch_credentials())
842
+ results.extend(self._local_agent_checks(cfg))
843
+ results.append(self.check_local_state_dir())
844
+ results.append(self.check_validation_commands())
845
+ if feature_spec_path:
846
+ results.append(self.check_feature_spec(feature_spec_path))
847
+ results.extend(self.sandbox_checks(local=True))
848
+ return results
849
+
850
+ def sandbox_checks(self, local: bool) -> list[CheckResult]:
851
+ """The outer sandbox's rows (ADR 0005 D12.4, D14.1; :mod:`autoforge.doctor_sandbox`)."""
852
+ from .doctor_sandbox import sandbox_checks
853
+
854
+ return sandbox_checks(self, local)
855
+
856
+ def _local_agent_checks(self, cfg: AutoForgeConfig | None) -> list[CheckResult]:
857
+ """The agent CLIs a LOCAL run can actually reach.
858
+
859
+ Derived from `local_required_profiles`, not from every configured
860
+ profile: a machine only needs the binaries the *reachable* local
861
+ profiles name. A local configuration that routes everything through
862
+ OpenCode must not fail because an unrelated remote profile mentions
863
+ `claude`, and one that uses only `scripted` profiles must not require
864
+ an external CLI at all.
865
+ """
866
+ if cfg is None:
867
+ return []
868
+ try:
869
+ reachable = local_required_profiles(cfg)
870
+ except ConfigurationError:
871
+ return []
872
+ return [
873
+ *self._agent_checks(cfg, reachable, "this local configuration"),
874
+ self.check_agent_limits(cfg, reachable),
875
+ self.check_loop_detection(cfg),
876
+ ]
877
+
878
+ def check_agent_limits(self, cfg: AutoForgeConfig, reachable: list[str]) -> CheckResult:
879
+ """Report the limits each reachable profile's agent runs under (#193).
880
+
881
+ Informational: an agent is killed after writing nothing for its idle
882
+ timeout, and once it has run for its max runtime when one is set.
883
+ Fails only for a profile a run would refuse to launch (a CLI that
884
+ reports no progress and has no wall-clock limit), which the config
885
+ check does not already cover for a profile reachable only after a
886
+ merge.
887
+ """
888
+ name = "profile limits"
889
+ shown: list[str] = []
890
+ for profile_name in reachable:
891
+ profile = cfg.profiles.get(profile_name)
892
+ if profile is None:
893
+ continue
894
+ try:
895
+ limits = cfg.agent_limits(profile)
896
+ except ConfigurationError as exc:
897
+ return CheckResult(name, False, str(exc))
898
+ shown.append(f"{profile_name}: {limits.describe()}")
899
+ return CheckResult(name, True, "; ".join(shown) or "(no profile)", required=False)
900
+
901
+ @staticmethod
902
+ def check_loop_detection(cfg: AutoForgeConfig) -> CheckResult:
903
+ """Report how every agent is watched for a loop (#194). Informational."""
904
+ return CheckResult(
905
+ "loop detection", True, cfg.execution.loop_detection.describe(), required=False
906
+ )
907
+
908
+ def _agent_checks(
909
+ self, cfg: AutoForgeConfig, reachable: list[str], scope: str
910
+ ) -> list[CheckResult]:
911
+ """One availability check per external CLI the ``reachable`` profiles name.
912
+
913
+ Each distinct command is checked once per provider that names it,
914
+ labelled with that provider's profiles: what a command must be
915
+ depends on the adapter that launches it, so a command that profiles of
916
+ two providers share (a wrapper) must pass both providers' checks.
917
+ `scripted` spawns the configured argv directly rather than an agent
918
+ CLI, so there is no version to query. A command a Pi profile names
919
+ gets Pi's version floor instead of a bare `--version`, and each Pi
920
+ profile a read-only credential probe; one an OpenCode profile names
921
+ gets OpenCode's floor.
922
+ """
923
+ groups: dict[tuple[str, str], list[ProfileConfig]] = {}
924
+ for name in reachable:
925
+ profile = cfg.profiles.get(name)
926
+ if profile is None or profile.provider == "scripted":
927
+ continue
928
+ command = profile.command or DEFAULT_AGENT_COMMANDS.get(profile.provider, "")
929
+ if not command:
930
+ continue
931
+ groups.setdefault((command, profile.provider), []).append(profile)
932
+ if not groups:
933
+ return [
934
+ CheckResult(
935
+ "agent CLI available",
936
+ True,
937
+ f"(no external agent CLI is reachable for {scope})",
938
+ required=False,
939
+ )
940
+ ]
941
+ results: list[CheckResult] = []
942
+ for (command, provider), profiles in groups.items():
943
+ label = f"agent '{command}' available ({', '.join(p.name for p in profiles)})"
944
+ if provider == "pi":
945
+ available = self.check_pi_version(label, profiles[0])
946
+ results.append(available)
947
+ results.extend(self.check_pi_auth(command, profiles, available.ok))
948
+ elif provider == "opencode":
949
+ results.append(self.check_opencode_version(label, command))
950
+ results.append(self.check_opencode_isolation(cfg, command, profiles))
951
+ elif provider in SDK_PROVIDERS:
952
+ results.append(self._version_check(label, [command, "--version"]))
953
+ results.append(self.check_sdk_launch(provider, profiles))
954
+ else:
955
+ results.append(self._version_check(label, [command, "--version"]))
956
+ if provider == "claude":
957
+ results.append(self.check_claude_isolation(command, profiles))
958
+ results.append(self.check_claude_configuration(cfg, command, profiles))
959
+ pi_profiles = [p for (_, provider), ps in groups.items() if provider == "pi" for p in ps]
960
+ if pi_profiles:
961
+ results.append(self.check_pi_outside_instructions(pi_profiles))
962
+ results.append(self.check_pi_orphan_containment(pi_profiles))
963
+ return results
964
+
965
+ def check_sdk_launch(self, provider: str, profiles: list[ProfileConfig]) -> CheckResult:
966
+ """FAIL for an SDK provider's profiles: their policy validates (#149),
967
+ but the helper that would launch them has not landed (#151, #152), so
968
+ every launch is refused before it starts."""
969
+ name = f"provider {provider} can launch ({', '.join(p.name for p in profiles)})"
970
+ try:
971
+ provider_for(profiles[0]).integration_preflight(None)
972
+ except ConfigurationError as exc:
973
+ return CheckResult(name, False, redact(str(exc)))
974
+ return CheckResult(name, True, "its helper is available")
975
+
976
+ def check_pi_outside_instructions(self, profiles: list[ProfileConfig]) -> CheckResult:
977
+ """WARN when Pi would load instruction files from outside the checkout.
978
+
979
+ Pi always appends its agent directory's ``SYSTEM.md`` /
980
+ ``APPEND_SYSTEM.md`` (no flag turns them off), and, unless every Pi
981
+ profile sets ``context_files: false``, the agent directory's context
982
+ file and one from each directory above the checkout. None of them is
983
+ project data under review, and none is visible in the PR. Only
984
+ whether each path exists is checked; no file is read.
985
+ """
986
+ names = ", ".join(p.name for p in profiles)
987
+ name = f"pi loads no instructions from outside the checkout ({names})"
988
+ env = os.environ.get("PI_CODING_AGENT_DIR", "") if self._pi_passes_agent_dir() else ""
989
+ agent_dir = Path(env or PI_DEFAULT_AGENT_DIR).expanduser()
990
+ found = [agent_dir / f for f in PI_SYSTEM_PROMPT_FILES if (agent_dir / f).is_file()]
991
+ if any(PiProvider.context_files(p) for p in profiles):
992
+ for directory in [agent_dir, *Path(self.cwd).resolve().parents]:
993
+ first = next((f for f in PI_CONTEXT_FILES if (directory / f).is_file()), None)
994
+ if first is not None:
995
+ found.append(directory / first)
996
+ if not found:
997
+ return CheckResult(name, True, "(none found)", required=False)
998
+ return CheckResult(
999
+ name,
1000
+ False,
1001
+ f"every Pi run will also follow {', '.join(str(p) for p in found)}; remove "
1002
+ "or move them, or set options.context_files: false for context files "
1003
+ "(docs/pi-policy.md)",
1004
+ required=False,
1005
+ )
1006
+
1007
+ def check_pi_orphan_containment(self, profiles: list[ProfileConfig]) -> CheckResult:
1008
+ """WARN where Pi's detached tool processes cannot be contained.
1009
+
1010
+ Pi's `bash` tool starts every command detached, so what a command
1011
+ backgrounds outlives Pi; only a child subreaper (Linux) lets the
1012
+ executor find and kill it (ADR 0002 §4b). The row passes only where
1013
+ the controller can make itself one, as each contained launch does,
1014
+ not merely read the setting. Without one, unattended Pi use is not
1015
+ supported (docs/pi.md#unattended-use, #135). Never a FAIL: an
1016
+ attended run that reads each result's leftovers sentence is still
1017
+ possible.
1018
+ """
1019
+ name = f"pi's detached tool processes are contained ({', '.join(p.name for p in profiles)})"
1020
+ if orphan_containment_available():
1021
+ return CheckResult(
1022
+ name,
1023
+ True,
1024
+ "a process a Pi tool command leaves running is killed when the launch ends "
1025
+ "(Linux child subreaper, ADR 0002 §4b)",
1026
+ required=False,
1027
+ )
1028
+ return CheckResult(
1029
+ name,
1030
+ False,
1031
+ "the controller cannot make itself a child subreaper here (not Linux, or a "
1032
+ "security policy denies it): a process a Pi tool command leaves "
1033
+ "running can outlive the launch, and each result says so (orphans_unchecked); "
1034
+ "do not run Pi unattended here (docs/pi.md#unattended-use)",
1035
+ required=False,
1036
+ )
1037
+
1038
+ def _pi_passes_agent_dir(self) -> bool:
1039
+ """Whether the launch allow-list hands ``PI_CODING_AGENT_DIR`` to Pi."""
1040
+ return "PI_CODING_AGENT_DIR" in self._pi_allowlist()
1041
+
1042
+ def _pi_allowlist(self) -> tuple[str, ...]:
1043
+ """The environment a Pi agent is launched with, so the probes see what it would."""
1044
+ cfg = self.config if self.config is not None else AutoForgeConfig()
1045
+ return PiProvider().launch_allowlist(cfg.execution.environment_names())
1046
+
1047
+ def _pi_probe(self, argv: list[str]) -> ExecutionResult | str:
1048
+ """Run one Pi probe as the launch would (its allow-list and the variables
1049
+ the controller sets, #165); the result, or why it did not run."""
1050
+ try:
1051
+ with isolated_overrides(LaunchIdentity()) as overrides:
1052
+ res = self._runner(
1053
+ ExecutionRequest(
1054
+ command=argv,
1055
+ cwd=self.cwd,
1056
+ timeout_seconds=self.timeout,
1057
+ env_allowlist=self._pi_allowlist(),
1058
+ env=overrides,
1059
+ )
1060
+ )
1061
+ except Exception as exc: # spawn failure / missing binary
1062
+ return f"{type(exc).__name__}: {exc}"
1063
+ return "timed out" if res.timed_out else res
1064
+
1065
+ def check_pi_version(self, name: str, profile: ProfileConfig) -> CheckResult:
1066
+ """`pi` runs and is at least :data:`PI_MIN_VERSION` (ADR 0003 §2.8).
1067
+
1068
+ A version line the check cannot parse is a FAIL, as for `gh`: an
1069
+ unknown version is not a known-good one. Pi's output is never quoted:
1070
+ a detail names only the parsed release number, so whatever a wrapper
1071
+ prints there (or appends as a pre-release suffix) stays out of it.
1072
+ """
1073
+ argv = PiProvider().version_command(profile)
1074
+ res = self._pi_probe(argv)
1075
+ if isinstance(res, str):
1076
+ return CheckResult(name, False, res)
1077
+ if res.exit_code != 0:
1078
+ return CheckResult(
1079
+ name,
1080
+ False,
1081
+ f"`{' '.join(argv)}` exited {res.exit_code} (output not shown; run it yourself)",
1082
+ )
1083
+ minimum = ".".join(str(part) for part in PI_MIN_VERSION)
1084
+ text = (res.stdout or "").strip()
1085
+ found = parse_pi_version(text)
1086
+ if found is None:
1087
+ return CheckResult(
1088
+ name,
1089
+ False,
1090
+ f"cannot read the pi version from `{' '.join(argv)}` (output not shown; "
1091
+ f"need >= {minimum})",
1092
+ )
1093
+ release, _, suffix = text.partition("-")
1094
+ shown = f"pi {release} (pre-release)" if suffix else f"pi {release}"
1095
+ if found < PI_MIN_VERSION:
1096
+ return CheckResult(
1097
+ name,
1098
+ False,
1099
+ f"{shown}: pi >= {minimum} is required (the RPC and auth contract "
1100
+ "AutoForge relies on, ADR 0003)",
1101
+ )
1102
+ return CheckResult(name, True, shown)
1103
+
1104
+ def check_claude_isolation(self, command: str, profiles: list[ProfileConfig]) -> CheckResult:
1105
+ """`claude --help` lists every flag a launch keeps integrations off with (#165).
1106
+
1107
+ Every Claude Code launch passes :data:`CLAUDE_ISOLATION_FLAGS` so that
1108
+ no hook, plugin or MCP server of the operator's or the repository's
1109
+ configuration runs and no project or local settings apply; a CLI
1110
+ without one would refuse every launch. The help text is never quoted.
1111
+ """
1112
+ name = f"agent '{command}' keeps integrations off ({', '.join(p.name for p in profiles)})"
1113
+ argv = [command, "--help"]
1114
+ try:
1115
+ res = self._runner(
1116
+ ExecutionRequest(command=argv, cwd=self.cwd, timeout_seconds=self.timeout)
1117
+ )
1118
+ except Exception as exc: # spawn failure / missing binary
1119
+ return CheckResult(name, False, f"{type(exc).__name__}: {exc}")
1120
+ if res.timed_out:
1121
+ return CheckResult(name, False, "timed out")
1122
+ if res.exit_code != 0:
1123
+ return CheckResult(
1124
+ name,
1125
+ False,
1126
+ f"`{' '.join(argv)}` exited {res.exit_code} (output not shown; run it yourself)",
1127
+ )
1128
+ listed = set(re.findall(r"(?<![\w-])--[a-z][a-z-]*", res.stdout or ""))
1129
+ # A flag is offered by its name; its value (``=user``) is the launch's.
1130
+ flags = [flag.partition("=")[0] for flag in CLAUDE_ISOLATION_FLAGS]
1131
+ missing = [flag for flag in flags if flag not in listed]
1132
+ if missing:
1133
+ return CheckResult(
1134
+ name,
1135
+ False,
1136
+ f"`{' '.join(argv)}` does not list {', '.join(missing)}, which every launch "
1137
+ "passes so that no hook, plugin or MCP server of the operator's or the "
1138
+ "repository's configuration runs and no setting of the repository's "
1139
+ "applies; update Claude Code",
1140
+ )
1141
+ return CheckResult(
1142
+ name,
1143
+ True,
1144
+ f"launched with {' '.join(CLAUDE_ISOLATION_FLAGS)}: no hook, plugin or MCP server "
1145
+ "of the operator's or the repository's configuration runs, and no setting of "
1146
+ "the repository's applies",
1147
+ )
1148
+
1149
+ def check_claude_configuration(
1150
+ self, cfg: AutoForgeConfig, command: str, profiles: list[ProfileConfig]
1151
+ ) -> CheckResult:
1152
+ """The launch's own refusal of Claude Code configuration that sets a
1153
+ variable over its isolation (#165).
1154
+
1155
+ Run under the agent launch's allow-list, so the configuration files
1156
+ looked at are the launch's; only paths and variable names are
1157
+ reported, never a value.
1158
+ """
1159
+ name = (
1160
+ f"agent '{command}' configuration keeps the launch environment "
1161
+ f"({', '.join(p.name for p in profiles)})"
1162
+ )
1163
+ try:
1164
+ ClaudeCodeProvider().integration_preflight(cfg.execution.environment_names())
1165
+ except (ConfigurationError, ExecutionError) as exc:
1166
+ return CheckResult(name, False, redact(str(exc)))
1167
+ return CheckResult(
1168
+ name,
1169
+ True,
1170
+ "no variable of Claude Code's settings or global configuration replaces one "
1171
+ "AutoForge sets; its Bash tool runs no shell startup file's function or alias",
1172
+ )
1173
+
1174
+ def check_opencode_isolation(
1175
+ self, cfg: AutoForgeConfig, command: str, profiles: list[ProfileConfig]
1176
+ ) -> CheckResult:
1177
+ """The launch's own refusal of an organization OpenCode would load (#165).
1178
+
1179
+ Run under the agent launch's allow-list, so the data directory and
1180
+ database looked at are the launch's; only paths and counts are
1181
+ reported, never an origin or a credential.
1182
+ """
1183
+ name = (
1184
+ f"agent '{command}' loads no organization configuration "
1185
+ f"({', '.join(p.name for p in profiles)})"
1186
+ )
1187
+ provider = OpenCodeProvider()
1188
+ allowlist = cfg.execution.environment_names()
1189
+ try:
1190
+ provider.integration_preflight(allowlist)
1191
+ data_dir = provider.data_dir(allowlist)
1192
+ except (ConfigurationError, ExecutionError) as exc:
1193
+ return CheckResult(name, False, redact(str(exc)))
1194
+ return CheckResult(
1195
+ name,
1196
+ True,
1197
+ f"no organization connected to OpenCode (data directory {data_dir}); a launch "
1198
+ "reads no configuration file of the operator's or the repository's",
1199
+ )
1200
+
1201
+ def check_opencode_version(self, name: str, command: str) -> CheckResult:
1202
+ """`opencode` runs and is at least :data:`OPENCODE_MIN_VERSION` (#186).
1203
+
1204
+ The adapter speaks v2's argv (``--standalone``, ``-m model#variant``)
1205
+ and hands the prompt over on stdin; 1.x rejects that argv. As for Pi,
1206
+ an unreadable version is a FAIL and the CLI's output is never quoted.
1207
+ """
1208
+ argv = [command, "--version"]
1209
+ try:
1210
+ res = self._runner(
1211
+ ExecutionRequest(command=argv, cwd=self.cwd, timeout_seconds=self.timeout)
1212
+ )
1213
+ except Exception as exc: # spawn failure / missing binary
1214
+ return CheckResult(name, False, f"{type(exc).__name__}: {exc}")
1215
+ if res.timed_out:
1216
+ return CheckResult(name, False, "timed out")
1217
+ if res.exit_code != 0:
1218
+ return CheckResult(
1219
+ name,
1220
+ False,
1221
+ f"`{' '.join(argv)}` exited {res.exit_code} (output not shown; run it yourself)",
1222
+ )
1223
+ minimum = ".".join(str(part) for part in OPENCODE_MIN_VERSION)
1224
+ text = (res.stdout or "").strip()
1225
+ found = parse_opencode_version(text)
1226
+ if found is None:
1227
+ return CheckResult(
1228
+ name,
1229
+ False,
1230
+ f"cannot read the opencode version from `{' '.join(argv)}` (output not "
1231
+ f"shown; need >= {minimum})",
1232
+ )
1233
+ # The text matched the version pattern whole, so its only `-` opens a
1234
+ # pre-release suffix.
1235
+ head, _, suffix = text.partition("-")
1236
+ release = head.removeprefix("opencode ").removeprefix("v")
1237
+ shown = f"opencode {release} (pre-release)" if suffix else f"opencode {release}"
1238
+ if found < OPENCODE_MIN_VERSION:
1239
+ return CheckResult(
1240
+ name,
1241
+ False,
1242
+ f"{shown}: opencode >= {minimum} is required (the argv and stdin "
1243
+ "delivery the adapter speaks, #186)",
1244
+ )
1245
+ return CheckResult(name, True, shown)
1246
+
1247
+ def check_pi_auth(
1248
+ self, command: str, profiles: list[ProfileConfig], available: bool
1249
+ ) -> list[CheckResult]:
1250
+ """``pi auth check --no-refresh`` for each distinct (model, OAuth policy) pair.
1251
+
1252
+ Run under the agent launch's allow-list, so a key only the
1253
+ operator's shell holds (an `OPENAI_API_KEY` the launch would not
1254
+ see) cannot make it report "ready". Pi's stdout and stderr are never
1255
+ quoted: the verdict names the four fields of its JSON result only.
1256
+ """
1257
+ provider = PiProvider()
1258
+ groups: dict[tuple[str, bool], list[ProfileConfig]] = {}
1259
+ for profile in profiles:
1260
+ groups.setdefault((profile.model, provider.require_oauth(profile)), []).append(profile)
1261
+ results: list[CheckResult] = []
1262
+ for members in groups.values():
1263
+ profile = members[0]
1264
+ name = f"agent '{command}' auth ready ({', '.join(p.name for p in members)})"
1265
+ if not available:
1266
+ results.append(CheckResult.skip(name, f"(agent '{command}' is not available)"))
1267
+ continue
1268
+ results.append(self._pi_auth(name, provider, profile))
1269
+ return results
1270
+
1271
+ def _pi_auth(self, name: str, provider: PiProvider, profile: ProfileConfig) -> CheckResult:
1272
+ refused = provider.api_key_problem(profile, self._pi_allowlist())
1273
+ if refused:
1274
+ return CheckResult(name, False, refused)
1275
+ res = self._pi_probe(provider.auth_check_command(profile))
1276
+ if isinstance(res, str):
1277
+ return CheckResult(name, False, res)
1278
+ ok, detail = provider.auth_verdict(profile, res.exit_code, res.stdout or "")
1279
+ return CheckResult(name, ok, f"{profile.model}: {detail}")
1280
+
1281
+ def _workspace(self):
1282
+ """A LocalWorkspace over the doctor's cwd, configured like a real run."""
1283
+ from .local_workspace import LocalWorkspace
1284
+
1285
+ cfg = self.config
1286
+ local = cfg.local if cfg else None
1287
+ return LocalWorkspace(
1288
+ workdir=self.cwd,
1289
+ runner=self._runner,
1290
+ exclude=local.exclude if local else (),
1291
+ max_entries=local.max_workspace_entries if local else DEFAULT_MAX_ENTRIES,
1292
+ max_bytes=local.max_workspace_bytes if local else DEFAULT_MAX_BYTES,
1293
+ )
1294
+
1295
+ def check_local_state_dir(self) -> CheckResult:
1296
+ """Where this run would keep its state — outside the reviewed tree, writable.
1297
+
1298
+ This replaces the generic `check_state_dir` for local mode rather than
1299
+ joining it: the generic check would create `.autoforge/` inside the
1300
+ working tree, and in LOCAL mode that directory is part of what gets
1301
+ fingerprinted. A read-only diagnostic must not change what a run would
1302
+ review.
1303
+ """
1304
+ from .engine import local_state_paths
1305
+
1306
+ cfg = self.config
1307
+ name = "state dir outside the reviewed working tree"
1308
+ ws = self._workspace()
1309
+ try:
1310
+ paths = local_state_paths(
1311
+ ws, explicit=self.state_dir, configured=cfg.state_dir if cfg else None
1312
+ )
1313
+ ws.check_state_dir_location(paths.state_dir)
1314
+ except Exception as exc:
1315
+ return CheckResult(name, False, str(exc))
1316
+ # The probe goes through the same capability a run would hold
1317
+ # (``StatePaths.open_root``: descriptor-relative from the git dir,
1318
+ # refusing a symbolic link at any component), so the doctor cannot be
1319
+ # made to write where the run would refuse to -- a symlinked
1320
+ # ``<git dir>/autoforge`` fails this check instead of being followed.
1321
+ writable = self._writable(lambda: paths.open_root(create=True))
1322
+ if writable is not None:
1323
+ return CheckResult(name, False, f"{paths.state_dir}: {writable}")
1324
+ return CheckResult(name, True, str(paths.state_dir))
1325
+
1326
+ @staticmethod
1327
+ def _writable(open_root: Callable[[], SafeRoot]) -> str | None:
1328
+ """None when a probe file can be created through ``open_root()``, else why not.
1329
+
1330
+ The probe is a controller write like any other: it is created and
1331
+ removed through the opened root, never by pathname, so the check
1332
+ exercises exactly the boundary a run's writes would go through. A
1333
+ filesystem without hard links fails it as it fails every run's
1334
+ run-log probe, and the reason names that requirement (#120).
1335
+ """
1336
+ probe = f".doctor-probe-{secrets.token_hex(8)}"
1337
+ try:
1338
+ with open_root() as root:
1339
+ root.create_exclusive(probe, b"")
1340
+ root.unlink(probe)
1341
+ except HardLinksUnavailable as exc:
1342
+ return f"{exc}. {STATE_DIR_NEEDS_HARD_LINKS}"
1343
+ except (OSError, StateError) as exc:
1344
+ return str(exc)
1345
+ return None
1346
+
1347
+ def _remote_agent_checks(self, cfg: AutoForgeConfig | None) -> list[CheckResult]:
1348
+ """The agent CLIs a REMOTE run can reach: `REQUIRED_PROFILES`, plus
1349
+ `update_epic` (reachable after a merge) when it is configured.
1350
+
1351
+ Nothing is checked when the config failed: that row already fails
1352
+ `doctor`, and a guess at the routing would check the wrong binaries.
1353
+ """
1354
+ if cfg is None:
1355
+ return []
1356
+ reachable = REQUIRED_PROFILES + [
1357
+ name for name in REMOTE_POST_MERGE_PROFILES if name in cfg.profiles
1358
+ ]
1359
+ return [
1360
+ *self._agent_checks(cfg, reachable, "this configuration"),
1361
+ self.check_agent_limits(cfg, reachable),
1362
+ self.check_loop_detection(cfg),
1363
+ ]
1364
+
1365
+ # -- the parallel EPIC scheduler (ADR 0008, #183) -------------------------------
1366
+ def check_scheduler_settings(self) -> CheckResult:
1367
+ """``scheduler.*`` as a scheduler would run with them; the loader enforces the bounds."""
1368
+ name = "scheduler settings"
1369
+ cfg = self.config
1370
+ if cfg is None:
1371
+ return CheckResult.skip(name, "(config check failed)")
1372
+ settings = cfg.scheduler
1373
+ values = (
1374
+ ("max_parallel", settings.max_parallel, MAX_PARALLEL_BOUNDS),
1375
+ ("poll_interval_seconds", settings.poll_interval_seconds, POLL_INTERVAL_BOUNDS),
1376
+ ("stop_grace_seconds", settings.stop_grace_seconds, STOP_GRACE_BOUNDS),
1377
+ )
1378
+ outside = [
1379
+ f"{key} {value}" for key, value, (low, high) in values if not low <= value <= high
1380
+ ]
1381
+ detail = ", ".join(f"{key} {value} ({low}-{high})" for key, value, (low, high) in values)
1382
+ if outside:
1383
+ return CheckResult(name, False, f"out of bounds: {', '.join(outside)}; {detail}")
1384
+ return CheckResult(name, True, detail)
1385
+
1386
+ def check_scheduler_root(self) -> CheckResult:
1387
+ """The coordination root's filesystem and the scheduler root's entry; read-only."""
1388
+ name = "scheduler coordination root"
1389
+ try:
1390
+ root = repository_lock_path(self.cwd, runner=self._runner).parent.parent
1391
+ check_local_lock_filesystem(root, what=f"the coordination root {root}")
1392
+ except LockError as exc:
1393
+ return CheckResult(name, False, str(exc))
1394
+ problems = []
1395
+ path = Path(root)
1396
+ for component in (AUTOFORGE_DIRNAME, SCHEDULER_DIRNAME):
1397
+ path = path / component
1398
+ try:
1399
+ st = os.lstat(path)
1400
+ except FileNotFoundError:
1401
+ break
1402
+ except OSError as exc:
1403
+ problems.append(f"{path} cannot be read ({exc})")
1404
+ break
1405
+ if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
1406
+ problems.append(f"{path} is not a directory (a link or another entry)")
1407
+ break
1408
+ if st.st_uid != os.getuid():
1409
+ problems.append(f"{path} is owned by uid {st.st_uid}, not this user")
1410
+ if st.st_mode & stat.S_IWOTH:
1411
+ problems.append(f"{path} is world-writable")
1412
+ if problems:
1413
+ return CheckResult(name, False, "; ".join(problems))
1414
+ scheduler = Path(root) / AUTOFORGE_DIRNAME / SCHEDULER_DIRNAME
1415
+ found = (
1416
+ "exists" if os.path.lexists(scheduler) else "is absent (the first 'epic run' makes it)"
1417
+ )
1418
+ return CheckResult(
1419
+ name, True, f"{root}: local flock; the scheduler root {scheduler} {found}"
1420
+ )
1421
+
1422
+ def check_epic_graph(self, gh: str) -> CheckResult:
1423
+ """Whether ``gh`` reads the EPIC's graph: #179's snapshot, read-only."""
1424
+ name = "EPIC sub-issues and dependencies readable"
1425
+ if self.epic_url is None:
1426
+ return CheckResult.skip(name, "(no --epic)")
1427
+ try:
1428
+ epic = parse_issue_url(self.epic_url)
1429
+ except ConfigurationError as exc:
1430
+ return CheckResult(name, False, str(exc))
1431
+ if self.repo is not None and self.repo.lower() != epic.repository.lower():
1432
+ return CheckResult(
1433
+ name,
1434
+ False,
1435
+ f"the EPIC is in {epic.repository}, and this checkout's origin is {self.repo}",
1436
+ )
1437
+ client = GitHubClient(gh_command=gh, timeout_seconds=self.timeout, runner=self._runner)
1438
+ try:
1439
+ repository_id = client.get_repository_id(epic.repository)
1440
+ snapshot = build_epic_snapshot(client, epic.canonical, repository_id=repository_id)
1441
+ except GitHubUnavailableError as exc:
1442
+ return CheckResult.skip(name, f"GitHub could not be read (transient): {exc}")
1443
+ except SnapshotRefused as exc:
1444
+ if exc.transient:
1445
+ return CheckResult.skip(name, f"GitHub could not be read (transient): {exc}")
1446
+ return CheckResult(name, False, str(exc))
1447
+ except GitHubError as exc:
1448
+ return CheckResult(name, False, str(exc))
1449
+ ready = ready_set(evaluate(snapshot))
1450
+ return CheckResult(
1451
+ name,
1452
+ True,
1453
+ f"EPIC #{epic.number}: {len(snapshot.children)} sub-issues and "
1454
+ f"{len(snapshot.edges)} blocked-by links read; {len(ready)} ready by the graph "
1455
+ f"alone (snapshot {snapshot.digest[:12]})",
1456
+ )
1457
+
1458
+ def scheduler_checks(self, gh: str) -> list[CheckResult]:
1459
+ """The scheduler rows: its settings always, the rest for ``doctor --epic``."""
1460
+ results = [self.check_scheduler_settings()]
1461
+ if self.epic_url is not None:
1462
+ results.append(self.check_scheduler_root())
1463
+ results.append(self.check_epic_graph(gh))
1464
+ return results
1465
+
1466
+ def run_all(self) -> list[CheckResult]:
1467
+ results = [
1468
+ self.check_config(),
1469
+ self.check_merge_gate(),
1470
+ self.check_premerge_verification(),
1471
+ self.check_candidate_validation(),
1472
+ ]
1473
+ cfg = self.config
1474
+ gh = cfg.github.command if cfg else "gh"
1475
+ results.append(self._version_check("git available", ["git", "--version"]))
1476
+ results.append(self.check_gh_version(gh))
1477
+ results.append(self.check_gh_auth(gh))
1478
+ results.extend(self._remote_agent_checks(cfg))
1479
+ results.append(self.check_git_repo())
1480
+ results.append(self.check_remote())
1481
+ results.append(self.check_agent_environment())
1482
+ results.append(self.check_launch_credentials())
1483
+ results.append(self.check_required_checks(gh))
1484
+ results.append(self.check_checks_read(gh))
1485
+ results.append(self.check_state_dir())
1486
+ results.extend(self.scheduler_checks(gh))
1487
+ results.extend(self.sandbox_checks(local=False))
1488
+ return results
1489
+
1490
+
1491
+ # The way out of an unverifiable required-check row, by cause (#289). A plan
1492
+ # limitation is never sent to the token's permissions.
1493
+ _PLAN_REMEDY = "make the repository public or move it to a plan that offers rules"
1494
+ _READ_RULES_REMEDY = "run doctor with a login that may read the branch rules"
1495
+
1496
+
1497
+ def _plan_limited_text(repo: str, exc: GitHubError) -> str:
1498
+ """What doctor says about GitHub's plan-entitlement 403 (#289): the plan, never the token."""
1499
+ return (
1500
+ f"GitHub's plan for {repo} does not offer rulesets or branch protection on this "
1501
+ "repository (GitHub Free offers neither on a private repository), so nothing can "
1502
+ "require a check on its default branch; this is the plan, not the token, and no "
1503
+ "token permission changes it: make the repository public or move it to a plan "
1504
+ f"that offers them (GitHub Pro, Team or Enterprise). GitHub's answer: {exc}"
1505
+ )
1506
+
1507
+
1508
+ def _pr_read_refused(exc: GitHubError) -> bool:
1509
+ """Whether GitHub refused this login the pull request itself (#291).
1510
+
1511
+ Classified by the client's types: only a plain conclusive
1512
+ :class:`GitHubError` can be such a refusal. Every typed answer says
1513
+ something else (transient, not found, a refused checks read, another
1514
+ PR, a plan limitation, which is about the plan, not the login).
1515
+ """
1516
+ return type(exc) is GitHubError and is_access_denied_gh_failure(str(exc))
1517
+
1518
+
1519
+ def _missing_contexts(contexts: list[str], expected: list[str]) -> list[str]:
1520
+ """The problems an expected-context list has with the required contexts (often none)."""
1521
+ if not contexts:
1522
+ return ["the rule names no status check context at all"]
1523
+ missing = [context for context in expected if context not in contexts]
1524
+ if missing:
1525
+ return [f"required contexts do not include {', '.join(repr(m) for m in missing)}"]
1526
+ return []
1527
+
1528
+
1529
+ def run_doctor(
1530
+ config_path: str | None = None,
1531
+ state_dir: str | None = None,
1532
+ cwd: str | None = None,
1533
+ runner: Runner | None = None,
1534
+ epic_url: str | None = None,
1535
+ ) -> list[CheckResult]:
1536
+ return Doctor(
1537
+ config_path=config_path, state_dir=state_dir, cwd=cwd, runner=runner, epic_url=epic_url
1538
+ ).run_all()
1539
+
1540
+
1541
+ def run_local_doctor(
1542
+ config_path: str | None = None,
1543
+ state_dir: str | None = None,
1544
+ cwd: str | None = None,
1545
+ runner: Runner | None = None,
1546
+ feature_spec_path: str | None = None,
1547
+ ) -> list[CheckResult]:
1548
+ """`autoforge local doctor`: the LOCAL checks only (never touches GitHub)."""
1549
+ return Doctor(config_path=config_path, state_dir=state_dir, cwd=cwd, runner=runner).run_local(
1550
+ feature_spec_path
1551
+ )