syncade 0.6.2__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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- syncade-0.6.2.dist-info/top_level.txt +1 -0
syncade/doctor.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""``syncade --doctor`` — read-only run preflight (PR-v2-12).
|
|
2
|
+
|
|
3
|
+
Composes the checks a first run needs into one green/red report so a failure is
|
|
4
|
+
self-serviceable instead of a GitHub issue. **Advisory (Invariant I4): doctor mutates
|
|
5
|
+
nothing** — no commit, no ref move, no artifact under ``.syncade/runs/``, no ``/tmp``
|
|
6
|
+
worktree — and it never changes a review's verdict. Its own exit code is a scriptable
|
|
7
|
+
green/red: :data:`~syncade.exit_codes.SUCCESS` (0) iff every non-skipped check is green,
|
|
8
|
+
else :data:`~syncade.exit_codes.WORKTREE_ERROR` (60) — the "environment isn't ready"
|
|
9
|
+
family ``--auth-check`` / ``--selfcheck`` already use. A config that will not load never
|
|
10
|
+
reaches here: the CLI handler maps that to ``CONFIG_ERROR`` (50) upstream, exactly like
|
|
11
|
+
every other one-shot mode.
|
|
12
|
+
|
|
13
|
+
This module is the check *engine*; the CLI dispatch (repo/config resolution) lives in
|
|
14
|
+
:mod:`syncade.cli.doctor_mode`. :func:`collect_checks` is pure (no I/O beyond the
|
|
15
|
+
read-only probes each check owns) so it is asserted directly, without scraping stdout.
|
|
16
|
+
|
|
17
|
+
The checks: resolved-config summary; each provider's CLI on PATH; worktree root + disk;
|
|
18
|
+
the branch preview (the exact default-branch / dirty-tree / detached-HEAD refusal a real
|
|
19
|
+
run would hit, for $0); the run-plan preview (resolved base + diff size, actor set, round
|
|
20
|
+
budget); the cost preview (API-equivalent $ range from the local corpus). The auth probe
|
|
21
|
+
and the producer headless-commit smoke are the two LIVE legs — they spawn a provider CLI
|
|
22
|
+
(~30s) and are what ``--quick`` skips.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import io
|
|
28
|
+
import os
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from syncade.auth_check import probe_credentials
|
|
35
|
+
from syncade.auth_preflight import preflight, report_lines
|
|
36
|
+
from syncade.config import SyncadeConfig
|
|
37
|
+
from syncade.config_auth import ALL_BLOCKS
|
|
38
|
+
|
|
39
|
+
# Re-exported for import stability after the PR-h-field-01 split (Decomposition Rule): callers
|
|
40
|
+
# keep importing these from `syncade.doctor`. MONKEYPATCH TARGETS ARE `doctor_env`, NOT
|
|
41
|
+
# here — these names are bindings, and rebinding one does not change what the function
|
|
42
|
+
# bodies in doctor_env read.
|
|
43
|
+
from syncade.doctor_env import ( # noqa: F401
|
|
44
|
+
_CLI_LAUNCH_TIMEOUT,
|
|
45
|
+
_MIN_FREE_DISK_BYTES,
|
|
46
|
+
_PROVIDER_CLI,
|
|
47
|
+
_check_config,
|
|
48
|
+
_check_provider_clis,
|
|
49
|
+
_check_worktree_root,
|
|
50
|
+
_configured_providers,
|
|
51
|
+
_probe_cli_launch,
|
|
52
|
+
)
|
|
53
|
+
from syncade.doctor_preview import based_diff_classify, check_budget, check_cost, check_plan
|
|
54
|
+
from syncade.doctor_types import _OK, _RED, _SKIP, _STATUS_GLYPH, DoctorCheck
|
|
55
|
+
from syncade.exit_codes import SUCCESS, WORKTREE_ERROR
|
|
56
|
+
from syncade.orchestrator.branch_guard import current_branch_name, guard_default_branch
|
|
57
|
+
from syncade.selfcheck import run_selfcheck
|
|
58
|
+
from syncade.snapshot import SnapshotError, take_snapshot
|
|
59
|
+
from syncade.worktree import WorktreeError
|
|
60
|
+
|
|
61
|
+
# Danger floor for free disk on the worktree filesystem. Conservative on purpose: every
|
|
62
|
+
# reviewer/producer/test leg checks out a full worktree under DEFAULT_WORKTREE_BASE, and a
|
|
63
|
+
# machine under ~1 GiB free is at real risk of a mid-run `git worktree add` failure. Small
|
|
64
|
+
# enough that any healthy dev box clears it, so a red here means genuinely low, not tight.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _check_auth(
|
|
68
|
+
config: SyncadeConfig, *, timeout_seconds: float | None = None
|
|
69
|
+
) -> list[DoctorCheck]:
|
|
70
|
+
"""LIVE leg. Mirrors ``--auth-check``: first the declaration-honesty preflight (the
|
|
71
|
+
codex ``auth = "api"``-on-a-subscription footgun), rendered as red rows instead of a
|
|
72
|
+
refusal; if that is clean, one probe row per distinct credential (does it actually
|
|
73
|
+
authenticate), followed by a billing-mode disclosure row matching what ``auth_gate``
|
|
74
|
+
prints before every real run. Preflight and the probe both spawn a provider CLI, so
|
|
75
|
+
this whole leg is what ``--quick`` skips."""
|
|
76
|
+
env = dict(os.environ)
|
|
77
|
+
problems = preflight(config, env, ALL_BLOCKS)
|
|
78
|
+
if problems:
|
|
79
|
+
# A contradicted declaration is the blocker; do not probe under a lie (the probe
|
|
80
|
+
# can green a mis-declared codex, which is the exact footgun preflight catches).
|
|
81
|
+
return [
|
|
82
|
+
DoctorCheck(
|
|
83
|
+
"auth",
|
|
84
|
+
_RED,
|
|
85
|
+
problem,
|
|
86
|
+
fix="reconcile the actor's `auth =` with this machine's login",
|
|
87
|
+
)
|
|
88
|
+
for problem in problems
|
|
89
|
+
]
|
|
90
|
+
rows: list[DoctorCheck] = []
|
|
91
|
+
for result in probe_credentials(config, timeout_seconds=timeout_seconds):
|
|
92
|
+
rows.append(
|
|
93
|
+
DoctorCheck(
|
|
94
|
+
f"auth:{result.provider}",
|
|
95
|
+
_OK if result.ok else _RED,
|
|
96
|
+
result.detail,
|
|
97
|
+
fix=None if result.ok else "re-authenticate; `syncade --auth-check` shows detail",
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
# Disclose resolved billing mode — the auth_gate analog the PR-v2-24 transparency
|
|
101
|
+
# requirement adds to every real run. Only emit when auth is healthy (no point
|
|
102
|
+
# disclosing billing mode for a credential the probe just rejected).
|
|
103
|
+
if all(r.status == _OK for r in rows):
|
|
104
|
+
billing = report_lines(config, env, ALL_BLOCKS)
|
|
105
|
+
if billing:
|
|
106
|
+
rows.append(
|
|
107
|
+
DoctorCheck(
|
|
108
|
+
"auth:billing",
|
|
109
|
+
_OK,
|
|
110
|
+
"; ".join(ln.strip() for ln in billing if ln.strip()),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return rows
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_producer_commit(
|
|
117
|
+
config: SyncadeConfig, *, timeout_seconds: float | None = None
|
|
118
|
+
) -> DoctorCheck:
|
|
119
|
+
"""LIVE leg. Reuses ``--selfcheck`` wholesale: the producer must headless-commit in a
|
|
120
|
+
throwaway workspace (that smoke never touches the operator's repo). Its verbose output
|
|
121
|
+
is captured and dropped so doctor renders one clean row; the fix points at
|
|
122
|
+
``--selfcheck`` for the full transcript. ``--quick`` skips it (~30s + a real call).
|
|
123
|
+
|
|
124
|
+
``always_cleanup=True`` keeps doctor inert: the selfcheck preserves its workspace on
|
|
125
|
+
failure by default (for debugging), but doctor drops that output, so a preserved workspace
|
|
126
|
+
would be an invisible leftover. Doctor removes it and points at ``--selfcheck`` (which
|
|
127
|
+
still preserves) for the raw output."""
|
|
128
|
+
sink = io.StringIO()
|
|
129
|
+
who = f"{config.producer.provider}/{config.producer.model}"
|
|
130
|
+
try:
|
|
131
|
+
with redirect_stdout(sink), redirect_stderr(sink):
|
|
132
|
+
code = run_selfcheck(
|
|
133
|
+
config, quiet=True, always_cleanup=True, timeout_seconds=timeout_seconds
|
|
134
|
+
)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
return DoctorCheck(
|
|
137
|
+
"producer-commit",
|
|
138
|
+
_RED,
|
|
139
|
+
f"{who} selfcheck raised an unexpected error: {exc}",
|
|
140
|
+
fix="run `syncade --selfcheck` to see the full producer output",
|
|
141
|
+
)
|
|
142
|
+
if code == SUCCESS:
|
|
143
|
+
return DoctorCheck("producer-commit", _OK, f"{who} committed headlessly")
|
|
144
|
+
return DoctorCheck(
|
|
145
|
+
"producer-commit",
|
|
146
|
+
_RED,
|
|
147
|
+
f"{who} could not headless-commit (selfcheck exit {code})",
|
|
148
|
+
fix="run `syncade --selfcheck` to see the full producer output",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _head_has_commit(repo_root: Path) -> bool:
|
|
153
|
+
"""True iff HEAD resolves to a commit. False for an unborn HEAD (``git init`` with no
|
|
154
|
+
commits): there, the real run's ``take_snapshot`` fails (``could not resolve HEAD`` ->
|
|
155
|
+
exit 60), so doctor must red it rather than false-green single-pass or let the exception
|
|
156
|
+
escape. Read-only."""
|
|
157
|
+
result = subprocess.run(
|
|
158
|
+
["git", "-C", str(repo_root), "rev-parse", "--verify", "--quiet", "HEAD"],
|
|
159
|
+
capture_output=True,
|
|
160
|
+
text=True,
|
|
161
|
+
)
|
|
162
|
+
return result.returncode == 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _check_branch(
|
|
166
|
+
repo_root: Path,
|
|
167
|
+
config: SyncadeConfig,
|
|
168
|
+
*,
|
|
169
|
+
max_rounds: int | None,
|
|
170
|
+
allow_default_branch: bool,
|
|
171
|
+
force_dirty: bool,
|
|
172
|
+
base_ref: str | None = None,
|
|
173
|
+
scope: str | None = None,
|
|
174
|
+
two_dot: bool = False,
|
|
175
|
+
) -> DoctorCheck:
|
|
176
|
+
"""Branch preview (C5, C1): RED if malformed diff; ``will_commit`` iff dispatch. Inert."""
|
|
177
|
+
effective = max_rounds if max_rounds is not None else config.loop.max_rounds
|
|
178
|
+
# based_diff_classify returns "dispatch" immediately for baseless runs (base_ref=None,
|
|
179
|
+
# scope=None) so the baseless case needs no special branch — behaviourally identical.
|
|
180
|
+
_diff_class = based_diff_classify(
|
|
181
|
+
repo_root, config, base_ref=base_ref, scope=scope, two_dot=two_dot
|
|
182
|
+
)
|
|
183
|
+
will_commit = effective > 1 and _diff_class == "dispatch"
|
|
184
|
+
branch = current_branch_name(repo_root)
|
|
185
|
+
|
|
186
|
+
# Unborn HEAD (git init, no commits): a real run snapshots HEAD and fails (exit 60), in
|
|
187
|
+
# EVERY mode. Red it up front — before the single-pass early-return could false-green it,
|
|
188
|
+
# and before take_snapshot below could raise an uncaught SnapshotError.
|
|
189
|
+
if not _head_has_commit(repo_root):
|
|
190
|
+
return DoctorCheck(
|
|
191
|
+
"branch",
|
|
192
|
+
_RED,
|
|
193
|
+
"HEAD has no commits yet (unborn) — a review needs a committed HEAD to snapshot",
|
|
194
|
+
fix="make at least one commit before running syncade",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Default-branch guard — the exact call the CLI makes before dispatch.
|
|
198
|
+
try:
|
|
199
|
+
guard_default_branch(repo_root, branch, allow=allow_default_branch, will_commit=will_commit)
|
|
200
|
+
except WorktreeError as exc:
|
|
201
|
+
return DoctorCheck(
|
|
202
|
+
"branch",
|
|
203
|
+
_RED,
|
|
204
|
+
str(exc),
|
|
205
|
+
fix="re-run on a feature branch, or pass --allow-default-branch to commit here",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if not will_commit:
|
|
209
|
+
if effective <= 1:
|
|
210
|
+
return DoctorCheck(
|
|
211
|
+
"branch", _OK, f"single-pass (max_rounds={effective}) commits nothing; guard N/A"
|
|
212
|
+
)
|
|
213
|
+
if _diff_class == "too_large":
|
|
214
|
+
# Refused before dispatch, so no commit — the branch guard must not double-fire
|
|
215
|
+
# (check_plan already reds this). Same reasoning as `no_changes`/`malformed`.
|
|
216
|
+
return DoctorCheck(
|
|
217
|
+
"branch",
|
|
218
|
+
_OK,
|
|
219
|
+
"no commits — the diff exceeds [loop] max_diff_bytes (see plan)",
|
|
220
|
+
)
|
|
221
|
+
if _diff_class == "prompt_too_large":
|
|
222
|
+
# Refused before dispatch (prompt_too_large exits 60), so no commit.
|
|
223
|
+
# check_plan already reds this; the branch guard must not double-fire.
|
|
224
|
+
return DoctorCheck(
|
|
225
|
+
"branch",
|
|
226
|
+
_OK,
|
|
227
|
+
"no commits — assembled reviewer prompt exceeds provider ceiling (see plan)",
|
|
228
|
+
)
|
|
229
|
+
if _diff_class == "malformed":
|
|
230
|
+
# Malformed diff: real run exits 60 (diff_malformed) before dispatch — RED so the
|
|
231
|
+
# cheap-red gate fires and live spend (auth/producer-commit) is skipped.
|
|
232
|
+
return DoctorCheck(
|
|
233
|
+
"branch",
|
|
234
|
+
_RED,
|
|
235
|
+
"based/scoped diff is malformed — real run exits 60 (diff_malformed)",
|
|
236
|
+
fix="use --two-dot if paths contain ' b/'; check strip_repo_context_files",
|
|
237
|
+
)
|
|
238
|
+
return DoctorCheck(
|
|
239
|
+
"branch", _OK, "based/scoped diff is known-empty; no producers fire — guard N/A"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Loop mode on detached HEAD: the guard exempts it, but the producer's commits would be
|
|
243
|
+
# unreachable and dropped (branch_advance -> skipped_detached_head). A doomed run, so red.
|
|
244
|
+
if branch is None:
|
|
245
|
+
return DoctorCheck(
|
|
246
|
+
"branch",
|
|
247
|
+
_RED,
|
|
248
|
+
"HEAD is detached; a loop run would drop the producer's commits (no branch to advance)",
|
|
249
|
+
fix="check out a branch before running a committing loop",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Dirty-tree refusal — same condition loop.py enforces. Defensive SnapshotError -> red: a
|
|
253
|
+
# diagnostic must never traceback (HEAD is committed here, so this is a belt-and-suspenders
|
|
254
|
+
# guard against any other git failure).
|
|
255
|
+
try:
|
|
256
|
+
state = take_snapshot(repo_root).dirty_state
|
|
257
|
+
except SnapshotError as exc:
|
|
258
|
+
return DoctorCheck(
|
|
259
|
+
"branch",
|
|
260
|
+
_RED,
|
|
261
|
+
f"cannot snapshot the working tree ({exc})",
|
|
262
|
+
fix="ensure the repo has a resolvable HEAD and a clean git state",
|
|
263
|
+
)
|
|
264
|
+
if state in ("tracked", "both") and not force_dirty:
|
|
265
|
+
return DoctorCheck(
|
|
266
|
+
"branch",
|
|
267
|
+
_RED,
|
|
268
|
+
f"loop mode refuses a tracked-dirty tree (dirty_state={state!r})",
|
|
269
|
+
fix="commit or stash your changes, or pass --force-dirty to run over your WIP",
|
|
270
|
+
)
|
|
271
|
+
return DoctorCheck(
|
|
272
|
+
"branch", _OK, f"commits would fast-forward {branch!r}; tree dirty_state={state!r}"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_LIVE_ANNOUNCE = (
|
|
277
|
+
"[syncade] doctor: running live checks — auth probe + producer headless-commit "
|
|
278
|
+
"(real provider calls, ~30s; pass --quick to skip)..."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def collect_checks(
|
|
283
|
+
config: SyncadeConfig,
|
|
284
|
+
repo_root: Path,
|
|
285
|
+
*,
|
|
286
|
+
quick: bool = False,
|
|
287
|
+
max_rounds: int | None = None,
|
|
288
|
+
allow_default_branch: bool = False,
|
|
289
|
+
force_dirty: bool = False,
|
|
290
|
+
base_ref: str | None = None,
|
|
291
|
+
scope: str | None = None,
|
|
292
|
+
two_dot: bool = False,
|
|
293
|
+
timeout_seconds: float | None = None,
|
|
294
|
+
) -> list[DoctorCheck]:
|
|
295
|
+
"""Run every doctor check and return the results. The cheap checks (config, provider CLIs
|
|
296
|
+
on PATH, worktree/disk, branch preview, run-plan + cost preview) are inert and always run.
|
|
297
|
+
|
|
298
|
+
The two LIVE legs (auth probe + producer headless-commit) spawn provider CLIs and cost
|
|
299
|
+
~30s, so they are gated to preserve the ``$0`` doomed-run guarantee: they are reported as
|
|
300
|
+
``skip`` (skipped, NOT passed) when ``quick`` is set OR when any cheap check is already
|
|
301
|
+
red — a red cheap check means a real run would be refused, so doctor must not spend on
|
|
302
|
+
provider calls first. Within the live section the producer commit smoke runs only if the
|
|
303
|
+
auth rows are all green (never spend on a commit smoke under a credential that failed).
|
|
304
|
+
When the live legs will actually run, the ~30s warning is emitted here (right before the
|
|
305
|
+
spend), not in the caller, so it fires exactly when — and only when — money is at stake.
|
|
306
|
+
The warning bypasses ``--quiet`` deliberately (like the PR-v2-24 auth line): quiet may
|
|
307
|
+
silence the report, but never a disclosure printed right before real spend."""
|
|
308
|
+
cheap = [
|
|
309
|
+
_check_config(config),
|
|
310
|
+
*_check_provider_clis(config),
|
|
311
|
+
_check_worktree_root(config.worktree_base),
|
|
312
|
+
_check_branch(
|
|
313
|
+
repo_root,
|
|
314
|
+
config,
|
|
315
|
+
max_rounds=max_rounds,
|
|
316
|
+
allow_default_branch=allow_default_branch,
|
|
317
|
+
force_dirty=force_dirty,
|
|
318
|
+
base_ref=base_ref,
|
|
319
|
+
scope=scope,
|
|
320
|
+
two_dot=two_dot,
|
|
321
|
+
),
|
|
322
|
+
check_plan(
|
|
323
|
+
repo_root,
|
|
324
|
+
config,
|
|
325
|
+
base_ref=base_ref,
|
|
326
|
+
scope=scope,
|
|
327
|
+
two_dot=two_dot,
|
|
328
|
+
max_rounds=max_rounds,
|
|
329
|
+
),
|
|
330
|
+
check_cost(config, repo_root, max_rounds=max_rounds),
|
|
331
|
+
check_budget(config),
|
|
332
|
+
]
|
|
333
|
+
checks = list(cheap)
|
|
334
|
+
if quick:
|
|
335
|
+
checks.append(DoctorCheck("auth", _SKIP, "skipped (--quick): credential probe not run"))
|
|
336
|
+
checks.append(
|
|
337
|
+
DoctorCheck("producer-commit", _SKIP, "skipped (--quick): commit smoke not run")
|
|
338
|
+
)
|
|
339
|
+
elif any(c.status == _RED for c in cheap):
|
|
340
|
+
reason = "skipped: a red check above would refuse this run before any spend"
|
|
341
|
+
checks.append(DoctorCheck("auth", _SKIP, reason))
|
|
342
|
+
checks.append(DoctorCheck("producer-commit", _SKIP, reason))
|
|
343
|
+
else:
|
|
344
|
+
# Spend disclosure: printed even under --quiet — real provider calls are imminent.
|
|
345
|
+
print(_LIVE_ANNOUNCE, file=sys.stderr)
|
|
346
|
+
auth_rows = _check_auth(config, timeout_seconds=timeout_seconds)
|
|
347
|
+
checks.extend(auth_rows)
|
|
348
|
+
if any(r.status == _RED for r in auth_rows):
|
|
349
|
+
checks.append(
|
|
350
|
+
DoctorCheck(
|
|
351
|
+
"producer-commit",
|
|
352
|
+
_SKIP,
|
|
353
|
+
"skipped: auth failed above — not spending on the producer commit smoke",
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
else:
|
|
357
|
+
checks.append(_check_producer_commit(config, timeout_seconds=timeout_seconds))
|
|
358
|
+
return checks
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _render(checks: list[DoctorCheck], *, quiet: bool) -> None:
|
|
362
|
+
"""Print the green/red table (normal) or just the reds (quiet). stdout for the report,
|
|
363
|
+
stderr for anything a red-detecting script should see."""
|
|
364
|
+
reds = [c for c in checks if c.status == _RED]
|
|
365
|
+
if not quiet:
|
|
366
|
+
print(f"[syncade] doctor: {len(checks)} check(s)")
|
|
367
|
+
width = max((len(c.name) for c in checks), default=0)
|
|
368
|
+
for check in checks:
|
|
369
|
+
print(f" {_STATUS_GLYPH[check.status]} {check.name.ljust(width)} {check.detail}")
|
|
370
|
+
if check.fix:
|
|
371
|
+
print(f" → {check.fix}")
|
|
372
|
+
else:
|
|
373
|
+
for check in reds:
|
|
374
|
+
print(f"[doctor] {check.name}: {check.detail}", file=sys.stderr)
|
|
375
|
+
if check.fix:
|
|
376
|
+
print(f" → {check.fix}", file=sys.stderr)
|
|
377
|
+
# Flush the stdout table before the stderr summary so a combined TTY reads top-to-bottom
|
|
378
|
+
# (stderr is unbuffered and would otherwise race ahead of the buffered table).
|
|
379
|
+
sys.stdout.flush()
|
|
380
|
+
if reds:
|
|
381
|
+
suffix = "" if quiet else " (see the → lines above)"
|
|
382
|
+
print(f"[syncade] doctor: {len(reds)} check(s) need attention{suffix}", file=sys.stderr)
|
|
383
|
+
else:
|
|
384
|
+
# Always print the OK summary — even under --quiet — so skipped live legs are
|
|
385
|
+
# visible. C3/C6: a skipped check must not read as silently passed. The full
|
|
386
|
+
# per-row table is still suppressed in quiet mode; only this one-liner appears.
|
|
387
|
+
n_passed = sum(1 for c in checks if c.status == _OK)
|
|
388
|
+
n_skipped = sum(1 for c in checks if c.status == _SKIP)
|
|
389
|
+
skip_note = f", {n_skipped} skipped" if n_skipped else ""
|
|
390
|
+
print(f"[syncade] doctor OK: {n_passed} check(s) passed{skip_note}")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def run_doctor(
|
|
394
|
+
config: SyncadeConfig,
|
|
395
|
+
repo_root: Path,
|
|
396
|
+
*,
|
|
397
|
+
quick: bool = False,
|
|
398
|
+
max_rounds: int | None = None,
|
|
399
|
+
allow_default_branch: bool = False,
|
|
400
|
+
force_dirty: bool = False,
|
|
401
|
+
base_ref: str | None = None,
|
|
402
|
+
scope: str | None = None,
|
|
403
|
+
two_dot: bool = False,
|
|
404
|
+
quiet: bool = False,
|
|
405
|
+
timeout_seconds: float | None = None,
|
|
406
|
+
) -> int:
|
|
407
|
+
"""Collect the checks, render the report, and return the exit code: ``0`` when no check
|
|
408
|
+
is red, else ``60``. This is doctor's whole contract — advisory to a review, scriptable
|
|
409
|
+
on its own. The live-legs ~30s warning is emitted inside :func:`collect_checks`, which is
|
|
410
|
+
the only place that knows whether the live legs will actually run (they are skipped when a
|
|
411
|
+
cheap check is already red), so it fires exactly when a provider call is imminent."""
|
|
412
|
+
checks = collect_checks(
|
|
413
|
+
config,
|
|
414
|
+
repo_root,
|
|
415
|
+
quick=quick,
|
|
416
|
+
max_rounds=max_rounds,
|
|
417
|
+
allow_default_branch=allow_default_branch,
|
|
418
|
+
force_dirty=force_dirty,
|
|
419
|
+
base_ref=base_ref,
|
|
420
|
+
scope=scope,
|
|
421
|
+
two_dot=two_dot,
|
|
422
|
+
timeout_seconds=timeout_seconds,
|
|
423
|
+
)
|
|
424
|
+
_render(checks, quiet=quiet)
|
|
425
|
+
return SUCCESS if not any(c.status == _RED for c in checks) else WORKTREE_ERROR
|
syncade/doctor_env.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Environment readiness for ``syncade --doctor`` — "is this machine set up?".
|
|
2
|
+
|
|
3
|
+
Split out of ``doctor.py`` when the diff-size prediction (PR-h-field-01 item 4) pushed that file
|
|
4
|
+
over the 500-LOC cap. The seam is the one ``CLAUDE.md`` already describes: these checks ask
|
|
5
|
+
whether the machine can run syncade AT ALL — config resolves, each configured provider's CLI
|
|
6
|
+
is on PATH and launchable, the worktree root is usable — and none of them look at the repo,
|
|
7
|
+
the diff, or the run plan. ``doctor.py`` keeps everything that is about THIS run: the branch
|
|
8
|
+
preview, the two live legs, and the collect/render/exit orchestration.
|
|
9
|
+
|
|
10
|
+
Strictly inert, like every doctor check (Invariant I4): no commit, no ref move, no artifact,
|
|
11
|
+
no ``/tmp`` worktree, not even a directory-mtime bump — writability is an ``os.access`` read,
|
|
12
|
+
never a tempfile.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from shutil import disk_usage, which
|
|
21
|
+
|
|
22
|
+
from syncade.adapters.registry import known_providers
|
|
23
|
+
from syncade.config import SyncadeConfig
|
|
24
|
+
from syncade.doctor_types import _OK, _RED, _SKIP, DoctorCheck
|
|
25
|
+
|
|
26
|
+
_MIN_FREE_DISK_BYTES: int = 1024**3 # 1 GiB
|
|
27
|
+
|
|
28
|
+
# provider -> the CLI binary a run of that provider shells out to. The BEHAVIOURAL source
|
|
29
|
+
# of truth is the adapters (adapters/anthropic.py runs ``claude``; adapters/openai.py runs
|
|
30
|
+
# ``codex``); this mirrors them for a synchronous PATH pre-check that costs nothing and runs
|
|
31
|
+
# even when the live probes are skipped. It covers ``known_providers()`` by construction —
|
|
32
|
+
# config validation rejects any other provider before doctor runs — and
|
|
33
|
+
# ``tests/doctor/test_doctor.py`` fails if the registry grows a provider absent here.
|
|
34
|
+
_PROVIDER_CLI: dict[str, str] = {"anthropic": "claude", "openai": "codex"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_CLI_LAUNCH_TIMEOUT: float = 5.0 # seconds for --version probe; fail fast, no network needed
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _configured_providers(config: SyncadeConfig) -> list[str]:
|
|
41
|
+
"""Unique provider names across every actor (reviewers, producer, and the three cold
|
|
42
|
+
actors), in first-seen order."""
|
|
43
|
+
seen: list[str] = []
|
|
44
|
+
for actor in (
|
|
45
|
+
*config.reviewers,
|
|
46
|
+
config.producer,
|
|
47
|
+
config.synthesizer,
|
|
48
|
+
config.drafter,
|
|
49
|
+
config.auditor,
|
|
50
|
+
):
|
|
51
|
+
if actor.provider not in seen:
|
|
52
|
+
seen.append(actor.provider)
|
|
53
|
+
return seen
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _check_config(config: SyncadeConfig) -> DoctorCheck:
|
|
57
|
+
"""Summarise the resolved config doctor is operating on. Always green when reached (a
|
|
58
|
+
broken config exits 50 upstream); surfaced so the operator SEES which actors a run will
|
|
59
|
+
dispatch before anything spends."""
|
|
60
|
+
detail = (
|
|
61
|
+
f"{len(config.reviewers)} reviewer(s): "
|
|
62
|
+
+ ", ".join(f"{r.provider}/{r.model}" for r in config.reviewers)
|
|
63
|
+
+ f"; producer {config.producer.provider}/{config.producer.model}"
|
|
64
|
+
+ f"; judge {config.synthesizer.provider}/{config.synthesizer.model}"
|
|
65
|
+
)
|
|
66
|
+
return DoctorCheck("config", _OK, detail)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _probe_cli_launch(binary: str) -> None:
|
|
70
|
+
"""Run ``binary --version`` to verify the binary is actually executable and exits cleanly.
|
|
71
|
+
|
|
72
|
+
Raises ``OSError`` if the interpreter cannot be found (e.g. broken shebang),
|
|
73
|
+
``subprocess.TimeoutExpired`` if the binary hangs on startup, or
|
|
74
|
+
``subprocess.CalledProcessError`` if the binary exits non-zero — a non-zero exit means
|
|
75
|
+
the binary is not usable, and a real review dispatching it would fail.
|
|
76
|
+
|
|
77
|
+
Extracted as a module-level function so tests can patch ``doctor._probe_cli_launch``
|
|
78
|
+
without affecting unrelated subprocess calls (e.g. git in ``_head_has_commit``)."""
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
[binary, "--version"],
|
|
81
|
+
capture_output=True,
|
|
82
|
+
timeout=_CLI_LAUNCH_TIMEOUT,
|
|
83
|
+
)
|
|
84
|
+
if result.returncode != 0:
|
|
85
|
+
raise subprocess.CalledProcessError(result.returncode, binary)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_provider_clis(config: SyncadeConfig) -> list[DoctorCheck]:
|
|
89
|
+
"""One check per configured provider: its CLI binary is on PATH AND can be launched.
|
|
90
|
+
PATH discovery via ``shutil.which``; executability via a ``--version`` probe with a short
|
|
91
|
+
timeout. A script with a broken shebang interpreter is on PATH but raises OSError on
|
|
92
|
+
exec; this catches it before a real run fails with SubprocessNotFoundError."""
|
|
93
|
+
checks: list[DoctorCheck] = []
|
|
94
|
+
for provider in _configured_providers(config):
|
|
95
|
+
binary = _PROVIDER_CLI.get(provider)
|
|
96
|
+
if binary is None:
|
|
97
|
+
# Reviewer providers are NOT validated at config load (unlike the cold actors), so
|
|
98
|
+
# a config can carry a provider with no registered adapter — the real run then
|
|
99
|
+
# raises UnknownProviderError at dispatch. Red that (a real run fails). A provider
|
|
100
|
+
# that IS in the registry but lacks a _PROVIDER_CLI binary mapping is doctor's own
|
|
101
|
+
# gap (the drift test guards it) -> skip, not red.
|
|
102
|
+
if provider in known_providers():
|
|
103
|
+
checks.append(
|
|
104
|
+
DoctorCheck(
|
|
105
|
+
f"cli:{provider}",
|
|
106
|
+
_SKIP,
|
|
107
|
+
f"no PATH mapping for provider {provider!r} — doctor needs updating",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
checks.append(
|
|
112
|
+
DoctorCheck(
|
|
113
|
+
f"cli:{provider}",
|
|
114
|
+
_RED,
|
|
115
|
+
f"unknown provider {provider!r} — no adapter is registered, so a real "
|
|
116
|
+
f"run cannot dispatch it",
|
|
117
|
+
fix=f"fix the provider name in .syncade/config.toml (known: "
|
|
118
|
+
f"{', '.join(known_providers())})",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
continue
|
|
122
|
+
found = which(binary)
|
|
123
|
+
if not found:
|
|
124
|
+
checks.append(
|
|
125
|
+
DoctorCheck(
|
|
126
|
+
f"cli:{provider}",
|
|
127
|
+
_RED,
|
|
128
|
+
f"{binary} not found on PATH",
|
|
129
|
+
fix=f"install the {provider} CLI ({binary}) and put it on your PATH",
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
continue
|
|
133
|
+
# Verify the resolved binary is actually launchable — shutil.which only checks
|
|
134
|
+
# PATH/permission bits, not whether the interpreter (e.g. a broken shebang) exists.
|
|
135
|
+
try:
|
|
136
|
+
_probe_cli_launch(binary)
|
|
137
|
+
checks.append(DoctorCheck(f"cli:{provider}", _OK, f"{binary} on PATH ({found})"))
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
checks.append(
|
|
140
|
+
DoctorCheck(
|
|
141
|
+
f"cli:{provider}",
|
|
142
|
+
_RED,
|
|
143
|
+
f"{binary} found at {found} but cannot be launched: {exc}",
|
|
144
|
+
fix=f"reinstall the {provider} CLI ({binary}); found but not executable",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
except subprocess.CalledProcessError as exc:
|
|
148
|
+
checks.append(
|
|
149
|
+
DoctorCheck(
|
|
150
|
+
f"cli:{provider}",
|
|
151
|
+
_RED,
|
|
152
|
+
f"{binary} at {found} exited {exc.returncode} on --version — not runnable",
|
|
153
|
+
fix=f"reinstall or reconfigure the {provider} CLI ({binary}); "
|
|
154
|
+
f"it exits {exc.returncode} on --version",
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
except subprocess.TimeoutExpired:
|
|
158
|
+
timeout = int(_CLI_LAUNCH_TIMEOUT)
|
|
159
|
+
checks.append(
|
|
160
|
+
DoctorCheck(
|
|
161
|
+
f"cli:{provider}",
|
|
162
|
+
_RED,
|
|
163
|
+
f"{binary} found at {found} but did not respond to --version within {timeout}s",
|
|
164
|
+
fix=f"check the {provider} CLI ({binary}) — may be hanging on startup",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return checks
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _check_worktree_root(worktree_base: Path) -> DoctorCheck:
|
|
171
|
+
"""The worktree base (``worktree_base`` — ``config.worktree_base`` or ``--worktree-base``,
|
|
172
|
+
default :data:`DEFAULT_WORKTREE_BASE` = ``/tmp/syncade``) must be writable and its filesystem
|
|
173
|
+
must have headroom — every reviewer/producer/test leg checks out a worktree there. Reading the
|
|
174
|
+
configured/overridden base (not the hardcoded default) keeps the preview honest for a run that
|
|
175
|
+
relocated it. **Strictly inert (F4'):** probes the nearest EXISTING ancestor (the base if
|
|
176
|
+
present, else its first existing parent) with ``os.access`` — a pure permission read that writes
|
|
177
|
+
NOTHING, not even the directory-mtime bump a create-then-delete tempfile would cause.
|
|
178
|
+
``os.access`` can theoretically false-green under exotic ACLs / root-on-read-only, but the real
|
|
179
|
+
run's worktree provisioning is the final arbiter (a clean exit-60 if it is ever wrong) — worth
|
|
180
|
+
it to keep ``--quick`` truly side-effect-free."""
|
|
181
|
+
probe_dir = worktree_base
|
|
182
|
+
# lexists (not exists): a BROKEN symlink is "present" and must stop the walk-up — exists()
|
|
183
|
+
# follows the link, returns False for a broken target, and would skip PAST it to the parent
|
|
184
|
+
# and false-green, while the real run's mkdir(parents=True) fails on that same symlink.
|
|
185
|
+
while not os.path.lexists(probe_dir):
|
|
186
|
+
probe_dir = probe_dir.parent # terminates at "/", which always exists
|
|
187
|
+
if probe_dir.is_symlink() and not probe_dir.exists():
|
|
188
|
+
return DoctorCheck(
|
|
189
|
+
"worktree",
|
|
190
|
+
_RED,
|
|
191
|
+
f"{probe_dir} is a broken symlink — worktrees cannot be created under it",
|
|
192
|
+
fix=f"remove or repoint {probe_dir}; its target must exist as a directory",
|
|
193
|
+
)
|
|
194
|
+
if not probe_dir.is_dir():
|
|
195
|
+
return DoctorCheck(
|
|
196
|
+
"worktree",
|
|
197
|
+
_RED,
|
|
198
|
+
f"{probe_dir} exists but is not a directory — worktrees cannot be created under it",
|
|
199
|
+
fix=f"remove or rename {probe_dir}; it must be a directory for run worktrees",
|
|
200
|
+
)
|
|
201
|
+
# W_OK to create worktree entries, X_OK to traverse into the base.
|
|
202
|
+
if not os.access(probe_dir, os.W_OK | os.X_OK):
|
|
203
|
+
return DoctorCheck(
|
|
204
|
+
"worktree",
|
|
205
|
+
_RED,
|
|
206
|
+
f"{probe_dir} is not writable",
|
|
207
|
+
fix=f"make {worktree_base} (or {probe_dir}) writable for run worktrees",
|
|
208
|
+
)
|
|
209
|
+
free = disk_usage(probe_dir).free
|
|
210
|
+
free_gib = free / 1024**3
|
|
211
|
+
if free < _MIN_FREE_DISK_BYTES:
|
|
212
|
+
return DoctorCheck(
|
|
213
|
+
"worktree",
|
|
214
|
+
_RED,
|
|
215
|
+
f"only {free_gib:.1f} GiB free on {probe_dir}'s filesystem — worktrees may fail",
|
|
216
|
+
fix="free up disk space; each reviewer/producer/test leg checks out a worktree",
|
|
217
|
+
)
|
|
218
|
+
return DoctorCheck("worktree", _OK, f"{probe_dir} writable, {free_gib:.1f} GiB free")
|