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.
Files changed (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. 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")