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/auth_check.py ADDED
@@ -0,0 +1,554 @@
1
+ """``syncade --auth-check`` — verify configured CREDENTIAL authentication.
2
+
3
+ Probes one representative per DISTINCT CREDENTIAL (provider + resolved mode + key), not
4
+ merely per provider (PR-v2-24): two actors on the same provider but different credentials
5
+ are probed separately, so the check cannot pass on auth it never exercised.
6
+
7
+ Fast pre-flight diagnostic for "did my OAuth token rotate?" /
8
+ "is my provider auth healthy?" — the common operator
9
+ question that :func:`syncade.selfcheck.run_selfcheck` answers via a
10
+ ~30s producer-commit smoke. The auth-check is the cheaper sibling:
11
+ ~5-10 seconds end-to-end, probes the auth path only (no producer
12
+ round, no worktree, no findings.md).
13
+
14
+ For each unique credential in the config (``[[reviewers]]`` blocks
15
+ plus ``[producer]``, deduped by the credential each actor presents),
16
+ the auth-check runs the provider's documented cheap auth probe:
17
+
18
+ - **anthropic:** ``claude -p "respond with exactly: AUTH OK"
19
+ --output-format json --model <model>``. Success iff
20
+ ``is_error == false`` in the JSON envelope AND ``"AUTH OK"`` is
21
+ ``in`` the ``.result`` text. The 30-second timeout is generous;
22
+ healthy auth responds in well under 5s.
23
+ - **openai:** ``codex login status`` via the existing
24
+ :meth:`syncade.adapters.openai.CodexAdapter.check_auth` — a
25
+ filesystem check (no network call) the adapter already supports
26
+ for the dispatcher's pre-flight phase.
27
+
28
+ The probe message ``"AUTH OK"`` is matched with ``in`` (not ``==``)
29
+ so a model that wraps the sentinel in additional text — common when
30
+ the model is overly chatty — still passes.
31
+
32
+ This module is NOT part of the review loop. ``syncade <pr-doc>``
33
+ does not call it; selfcheck does not call it; it's a standalone
34
+ operator diagnostic surfaced via ``--auth-check`` and intended to
35
+ be cheap enough to run as a habit before invoking syncade.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import os
42
+ import sys
43
+ import time
44
+ from collections.abc import Callable
45
+ from dataclasses import dataclass
46
+
47
+ from syncade.adapters.base import ReviewerInvocationError
48
+ from syncade.config import SyncadeConfig
49
+ from syncade.config_auth import apply_auth_to_env, credential_fingerprint
50
+ from syncade.exit_codes import SUCCESS, WORKTREE_ERROR
51
+ from syncade.process import (
52
+ SubprocessError,
53
+ SubprocessNotFoundError,
54
+ SubprocessTimeoutError,
55
+ run_subprocess,
56
+ )
57
+
58
+ DEFAULT_AUTH_CHECK_TIMEOUT_SECONDS: float = 30.0
59
+ """Per-provider probe timeout. Generous — healthy auth typically
60
+ responds in well under 5s; the 30s ceiling tolerates a slow network
61
+ or a sluggish provider without false-negative failures."""
62
+
63
+ AUTH_CHECK_PROBE_PROMPT: str = "respond with exactly: AUTH OK"
64
+ """The probe message sent to the anthropic adapter's ``claude -p``
65
+ call. The model is expected to echo ``AUTH OK`` somewhere in its
66
+ response — the ``in`` check (not ``==``) tolerates provider-side
67
+ wrapping (e.g. an over-eager model that prefixes "Sure: AUTH OK")."""
68
+
69
+ AUTH_CHECK_SENTINEL: str = "AUTH OK"
70
+ """The sentinel string the probe expects in the model's response.
71
+ Module constant so the probe instruction text and the verifier stay
72
+ in lockstep — a refactor that changes the prompt's sentinel but not
73
+ the verifier (or vice versa) would silently break auth-check."""
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class AuthCheckResult:
78
+ """Per-provider auth-check outcome.
79
+
80
+ Attributes:
81
+ provider: The provider name (``"anthropic"`` / ``"openai"``).
82
+ model: The representative model used for the probe — ``None``
83
+ for providers whose probe doesn't pin a model (codex's
84
+ ``codex login status`` is filesystem-level and doesn't
85
+ need one).
86
+ ok: ``True`` iff the probe succeeded and the sentinel was
87
+ present.
88
+ duration_seconds: Wall-clock probe duration.
89
+ detail: Operator-facing summary — either the ``"OK (4.2s)"``
90
+ success form or a remediation-bearing failure message
91
+ (e.g. "401 — token expired. Run 'claude' interactively
92
+ to re-authenticate."). Surfaced verbatim in stderr on
93
+ failure paths.
94
+ """
95
+
96
+ provider: str
97
+ model: str | None
98
+ ok: bool
99
+ duration_seconds: float
100
+ detail: str
101
+
102
+
103
+ # Type alias for a provider-probe callable. Tests inject fakes by
104
+ # passing a dict of these to :func:`run_auth_check`'s ``probes``
105
+ # kwarg. Production routing goes through :data:`_DEFAULT_PROBES`.
106
+ ProviderProbe = Callable[..., AuthCheckResult]
107
+ """``(model, timeout_seconds, env=...) -> AuthCheckResult``.
108
+
109
+ ``env`` is the AUTH-APPLIED environment, not the parent's. Without it the probe
110
+ inherited ``ANTHROPIC_API_KEY`` and hit the API even when every anthropic actor declared
111
+ ``auth = "subscription"`` -- so the check could BILL the API account it was supposed to
112
+ be keeping the user away from, and green-light a credential the actual run would never
113
+ use. Caught by syncade's own panel."""
114
+
115
+
116
+ def _probe_anthropic(
117
+ model: str, timeout_seconds: float, env: dict[str, str] | None = None
118
+ ) -> AuthCheckResult:
119
+ """Probe anthropic auth via ``claude -p`` with the sentinel prompt.
120
+
121
+ Build the argv directly rather than going through
122
+ :class:`~syncade.adapters.anthropic.AnthropicAdapter` because the
123
+ auth-check probe doesn't need the adapter's full reviewer-
124
+ invocation argv (``--effort``, ``--permission-mode``,
125
+ ``--add-dir``). Keep it minimal: ``claude -p <prompt>
126
+ --output-format json --model <model>``. Same JSON envelope shape
127
+ as the adapter, so the parse logic reuses the same
128
+ ``is_error`` / ``.result`` fields.
129
+
130
+ Failure modes:
131
+
132
+ - ``SubprocessNotFoundError`` → claude CLI missing.
133
+ - ``SubprocessTimeoutError`` → probe didn't return in time.
134
+ Typically a hung auth helper or a wedged network.
135
+ - Non-JSON stdout → CLI emitted something other than the
136
+ documented envelope. May indicate a CLI version mismatch.
137
+ - ``is_error: true`` envelope → token issue (401, model
138
+ unavailable, etc.). The envelope's ``.result`` carries the
139
+ provider-readable error; surface it verbatim.
140
+ - Envelope OK but sentinel absent → the model responded but
141
+ didn't follow the instruction. Unlikely auth issue; surfaced
142
+ as a separate failure so the operator can rerun.
143
+ """
144
+ start = time.monotonic()
145
+ argv = [
146
+ "claude",
147
+ "-p",
148
+ AUTH_CHECK_PROBE_PROMPT,
149
+ "--output-format",
150
+ "json",
151
+ "--model",
152
+ model,
153
+ ]
154
+ try:
155
+ result = run_subprocess(argv, timeout=timeout_seconds, env=env)
156
+ except SubprocessNotFoundError:
157
+ return AuthCheckResult(
158
+ provider="anthropic",
159
+ model=model,
160
+ ok=False,
161
+ duration_seconds=time.monotonic() - start,
162
+ detail=(
163
+ "claude binary not found on PATH — install the Anthropic "
164
+ "claude CLI to run auth-check."
165
+ ),
166
+ )
167
+ except SubprocessTimeoutError as exc:
168
+ return AuthCheckResult(
169
+ provider="anthropic",
170
+ model=model,
171
+ ok=False,
172
+ duration_seconds=time.monotonic() - start,
173
+ detail=(
174
+ f"claude auth probe timed out after {exc.timeout:g}s — auth "
175
+ "helper or network may be hung. Run 'claude' interactively "
176
+ "to re-authenticate."
177
+ ),
178
+ )
179
+ except SubprocessError as exc:
180
+ return AuthCheckResult(
181
+ provider="anthropic",
182
+ model=model,
183
+ ok=False,
184
+ duration_seconds=time.monotonic() - start,
185
+ detail=(
186
+ f"claude subprocess launch failed: {exc}. Check that the "
187
+ "claude binary is executable and accessible on PATH."
188
+ ),
189
+ )
190
+
191
+ duration = time.monotonic() - start
192
+
193
+ envelope: dict | None = None
194
+ try:
195
+ parsed = json.loads(result.stdout)
196
+ if isinstance(parsed, dict):
197
+ envelope = parsed
198
+ except json.JSONDecodeError:
199
+ envelope = None
200
+
201
+ if envelope is None:
202
+ stderr_snippet = result.stderr.strip()[:200]
203
+ stdout_snippet = result.stdout.strip()[:200]
204
+ tail = stderr_snippet or stdout_snippet or "(no output)"
205
+ return AuthCheckResult(
206
+ provider="anthropic",
207
+ model=model,
208
+ ok=False,
209
+ duration_seconds=duration,
210
+ detail=(
211
+ f"claude returned non-JSON stdout "
212
+ f"(rc={result.returncode}); output tail: {tail!r}. "
213
+ "May indicate a claude CLI version mismatch."
214
+ ),
215
+ )
216
+
217
+ is_error = bool(envelope.get("is_error", False))
218
+ api_error_status = envelope.get("api_error_status")
219
+ envelope_result = envelope.get("result")
220
+
221
+ if is_error or result.returncode != 0:
222
+ msg = (
223
+ envelope_result
224
+ if isinstance(envelope_result, str) and envelope_result
225
+ else f"is_error={is_error}, api_error_status={api_error_status}"
226
+ )
227
+ return AuthCheckResult(
228
+ provider="anthropic",
229
+ model=model,
230
+ ok=False,
231
+ duration_seconds=duration,
232
+ detail=(
233
+ f"FAILED (rc={result.returncode}, "
234
+ f"api_error_status={api_error_status}): {str(msg)[:200]}. "
235
+ "Run 'claude' interactively to re-authenticate."
236
+ ),
237
+ )
238
+
239
+ if not isinstance(envelope_result, str) or AUTH_CHECK_SENTINEL not in envelope_result:
240
+ excerpt = (
241
+ envelope_result[:120]
242
+ if isinstance(envelope_result, str)
243
+ else str(envelope_result)[:120]
244
+ )
245
+ return AuthCheckResult(
246
+ provider="anthropic",
247
+ model=model,
248
+ ok=False,
249
+ duration_seconds=duration,
250
+ detail=(
251
+ f"claude responded without {AUTH_CHECK_SENTINEL!r} sentinel "
252
+ f"({excerpt!r}). Auth appears OK but the model didn't follow "
253
+ "the instruction; retry once before treating this as a real "
254
+ "failure."
255
+ ),
256
+ )
257
+
258
+ return AuthCheckResult(
259
+ provider="anthropic",
260
+ model=model,
261
+ ok=True,
262
+ duration_seconds=duration,
263
+ detail=f"OK ({duration:.1f}s)",
264
+ )
265
+
266
+
267
+ def _probe_openai(
268
+ model: str, timeout_seconds: float, env: dict[str, str] | None = None
269
+ ) -> AuthCheckResult: # noqa: ARG001
270
+ """Probe codex auth via ``codex login status``.
271
+
272
+ Delegates to
273
+ :meth:`syncade.adapters.openai.CodexAdapter.check_auth`, which
274
+ already implements the documented `codex login status` probe
275
+ with the right error mapping. The model is recorded for
276
+ operator-facing reporting but not used by the probe — codex
277
+ login state is per-token, not per-model.
278
+
279
+ ``timeout_seconds`` is part of the :data:`ProviderProbe` protocol
280
+ signature but is intentionally unused: ``CodexAdapter.check_auth``
281
+ has its own internal timeout via ``_CODEX_AUTH_CHECK_TIMEOUT_SECONDS``
282
+ (10s by default). The parameter is kept on the public surface to
283
+ conform to the probe-protocol shape so callers using
284
+ ``timeout_seconds=`` as a keyword (including tests) keep working.
285
+ The ``# noqa: ARG001`` annotation marks the intentional non-use for ruff's
286
+ unused-arg linter while keeping the public keyword name stable.
287
+
288
+ The local import sidesteps a circular-import concern (the adapters
289
+ module's tests import from auth_check; importing CodexAdapter at
290
+ module-load would tie this module to the adapters layer).
291
+ """
292
+ from syncade.adapters.openai import CodexAdapter
293
+
294
+ start = time.monotonic()
295
+ adapter = CodexAdapter()
296
+ try:
297
+ # CodexAdapter.check_auth already raises ReviewerInvocationError
298
+ # on every failure path (binary missing, timeout, non-zero rc
299
+ # from `codex login status`) with operator-facing messages.
300
+ # Take its message verbatim — the codex CLI's own output is
301
+ # exactly what the user needs to see.
302
+ adapter.check_auth()
303
+ except ReviewerInvocationError as exc:
304
+ return AuthCheckResult(
305
+ provider="openai",
306
+ model=model,
307
+ ok=False,
308
+ duration_seconds=time.monotonic() - start,
309
+ detail=str(exc)[:300],
310
+ )
311
+ duration = time.monotonic() - start
312
+ return AuthCheckResult(
313
+ provider="openai",
314
+ model=model,
315
+ ok=True,
316
+ duration_seconds=duration,
317
+ detail=f"OK ({duration:.1f}s)",
318
+ )
319
+
320
+
321
+ _DEFAULT_PROBES: dict[str, ProviderProbe] = {
322
+ "anthropic": _probe_anthropic,
323
+ "openai": _probe_openai,
324
+ }
325
+ """Mapping from provider name to its real auth probe. Tests override
326
+ by passing a different dict to :func:`run_auth_check`'s ``probes``
327
+ kwarg; production routes through this default."""
328
+
329
+
330
+ def _collect_unique_providers(config: SyncadeConfig) -> list[tuple[str, str]]:
331
+ """Return ``[(provider, representative_model), ...]`` in config
332
+ order with duplicates removed (first occurrence wins).
333
+
334
+ Iterates reviewers in config order, then the producer, then the three COLD
335
+ actors (judge / drafter / auditor).
336
+
337
+ **The cold actors are why this list exists at all now.** Before PR-v2-23 they
338
+ were hardwired to codex and invisible to config, so this probed reviewers +
339
+ producer and returned exit 0 on a machine that *could not finish a run* — a
340
+ false green, and the most expensive kind, because the user only discovers it
341
+ after both reviewers have run and billed. Now that the judge's provider is
342
+ configurable, an `[synthesizer] provider = "anthropic"` with OpenAI reviewers
343
+ is a perfectly reasonable config that this check MUST cover.
344
+
345
+ Cost is nothing in practice: dedup is by CREDENTIAL (see :func:`_by_credential`),
346
+ so an actor presenting a credential already covered adds no probe. The default
347
+ roster is single-credential, so the cold actors add ZERO probes there. They add
348
+ one only when the operator actually configured a different credential — precisely
349
+ when you want to know it works. (Same provider, DIFFERENT credential still probes:
350
+ that is the point — a second credential is a second thing that can fail to auth.)
351
+
352
+ A "representative" model is sufficient because auth is
353
+ per-token, not per-model; ``claude -p --model claude-opus-4-6``
354
+ and ``claude -p --model claude-sonnet-4-6`` succeed or fail on
355
+ the same OAuth token. Probing one model per credential keeps
356
+ the auth-check fast (the brief's ~5-10s target) without losing
357
+ the diagnostic value.
358
+ """
359
+ return [(provider, model) for (provider, _cred), (model, _actor) in _by_credential(config)]
360
+
361
+
362
+ def _credential_key(actor: object) -> tuple[str, str, tuple[tuple[str, str], ...]]:
363
+ """(provider, resolved mode, credential fingerprint). The fingerprint READS the actual
364
+ credential from the enforced env -- see credential_fingerprint. Two credentials that
365
+ resolve to the same mode but present different keys are distinct probes."""
366
+ from syncade.auth_preflight import resolve_auth_mode
367
+
368
+ provider = str(getattr(actor, "provider", ""))
369
+ mode = resolve_auth_mode(actor, dict(os.environ)) # type: ignore[arg-type]
370
+ return (provider, mode, credential_fingerprint(actor, dict(os.environ))) # type: ignore[arg-type]
371
+
372
+
373
+ def _by_credential(config: SyncadeConfig) -> list[tuple[tuple[str, str], tuple[str, object]]]:
374
+ """One representative actor per DISTINCT CREDENTIAL, in config order."""
375
+ seen: dict[tuple[str, str, str], tuple[str, object]] = {}
376
+ actors: list[object] = [
377
+ *config.reviewers,
378
+ config.producer,
379
+ config.synthesizer,
380
+ config.drafter,
381
+ config.auditor,
382
+ ]
383
+ for actor in actors:
384
+ key = _credential_key(actor)
385
+ if key not in seen:
386
+ seen[key] = (str(getattr(actor, "model", "")), actor)
387
+ return [((k[0], f"{k[1]}:{[v for v, _h in k[2]]}"), v) for k, v in seen.items()]
388
+
389
+
390
+ def _provider_actors(config: SyncadeConfig) -> dict[str, object]:
391
+ """One representative actor per provider (first wins). Kept for the probe-env lookup;
392
+ the PROBE LIST itself is keyed by credential — see :func:`_by_credential`."""
393
+ seen: dict[str, object] = {}
394
+ for reviewer in config.reviewers:
395
+ seen.setdefault(reviewer.provider, reviewer)
396
+ for actor in (config.producer, config.synthesizer, config.drafter, config.auditor):
397
+ seen.setdefault(actor.provider, actor)
398
+ return seen
399
+
400
+
401
+ def probe_credentials(
402
+ config: SyncadeConfig,
403
+ *,
404
+ timeout_seconds: float | None = None,
405
+ probes: dict[str, ProviderProbe] | None = None,
406
+ ):
407
+ """Yield one :class:`AuthCheckResult` per DISTINCT CREDENTIAL, in config order.
408
+
409
+ The pure probe core shared by :func:`run_auth_check` (which prints each result and maps
410
+ them to an exit code) and ``syncade --doctor`` (which renders them as table rows). No
411
+ printing, no exit code — just results. A generator so a caller can stream them as each
412
+ sequential probe lands. Never raises: an unknown provider, an ``api`` declaration with
413
+ no key, and a probe that itself throws all become a failed result, because a diagnostic
414
+ must report, not traceback.
415
+
416
+ Probing carries each credential's OWN actor through to the env (via
417
+ :func:`apply_auth_to_env`), not the provider's first actor — the wiring bug the panel
418
+ caught: a second ``api`` credential on a provider was probed under the first actor's env,
419
+ verifying a credential the real run would never use.
420
+ """
421
+ if probes is None:
422
+ probes = _DEFAULT_PROBES
423
+ if timeout_seconds is None:
424
+ timeout_seconds = DEFAULT_AUTH_CHECK_TIMEOUT_SECONDS
425
+
426
+ for (provider, _cred), (model, actor) in _by_credential(config):
427
+ probe = probes.get(provider)
428
+ if probe is None:
429
+ yield AuthCheckResult(
430
+ provider=provider,
431
+ model=model,
432
+ ok=False,
433
+ duration_seconds=0.0,
434
+ detail=f"no probe registered for provider {provider!r}. Known: {sorted(probes)!r}.",
435
+ )
436
+ continue
437
+ try:
438
+ probe_env = apply_auth_to_env(dict(os.environ), actor) # type: ignore[arg-type]
439
+ except ValueError as exc:
440
+ # `api` declared with no key: degrade gracefully, never traceback.
441
+ yield AuthCheckResult(
442
+ provider=provider, model=model, ok=False, duration_seconds=0.0, detail=str(exc)
443
+ )
444
+ continue
445
+ try:
446
+ yield probe(model, timeout_seconds, env=probe_env)
447
+ except Exception as exc:
448
+ yield AuthCheckResult(
449
+ provider=provider,
450
+ model=model,
451
+ ok=False,
452
+ duration_seconds=0.0,
453
+ detail=f"probe raised an unexpected exception: {exc!r}",
454
+ )
455
+
456
+
457
+ def run_auth_check(
458
+ config: SyncadeConfig,
459
+ *,
460
+ timeout_seconds: float | None = None,
461
+ probes: dict[str, ProviderProbe] | None = None,
462
+ quiet: bool = False,
463
+ ) -> int:
464
+ """Run the auth-check across all configured credentials and return
465
+ a CLI exit code.
466
+
467
+ Steps:
468
+
469
+ 1. Collect the unique credentials from ``[[reviewers]]`` +
470
+ ``[producer]`` via :func:`_collect_unique_providers`.
471
+ 2. Run each credential's probe (real probe from
472
+ :data:`_DEFAULT_PROBES` unless ``probes`` overrides). Probes
473
+ run sequentially — total wall-clock is the sum of per-probe
474
+ durations. With two providers (anthropic + openai) and
475
+ healthy auth, the brief targets ~5-10s.
476
+ 3. Report each result as it lands (so a slow probe doesn't
477
+ silence early results).
478
+ 4. Return :data:`syncade.exit_codes.SUCCESS` (0) if every probe
479
+ succeeded; :data:`~syncade.exit_codes.WORKTREE_ERROR` (60) if
480
+ any failed. Same exit-60 semantics as ``--selfcheck`` — "your
481
+ environment isn't ready for a real run".
482
+
483
+ Args:
484
+ config: The operator's loaded :class:`SyncadeConfig`.
485
+ timeout_seconds: Optional per-probe timeout override
486
+ (typically from ``--timeout``). ``None`` (default) uses
487
+ :data:`DEFAULT_AUTH_CHECK_TIMEOUT_SECONDS` (30s).
488
+ probes: Optional override of the probe registry, used by
489
+ tests to inject fakes. Keys are provider names matching
490
+ ``ReviewerConfig.provider`` / ``ProducerConfig.provider``;
491
+ values are :data:`ProviderProbe` callables. ``None``
492
+ (default) routes through :data:`_DEFAULT_PROBES`.
493
+ quiet: Suppress informational stdout. Stderr (failure paths)
494
+ unaffected. Wired from ``--quiet``.
495
+
496
+ Returns:
497
+ ``SUCCESS`` (0) when every probe succeeded.
498
+
499
+ ``WORKTREE_ERROR`` (60) when any probe failed, when an
500
+ unknown provider is configured (no probe registered), or
501
+ when the config has zero providers (degenerate case).
502
+
503
+ Raises:
504
+ Never. All failure modes map to a ``WORKTREE_ERROR`` return.
505
+ """
506
+ if probes is None:
507
+ probes = _DEFAULT_PROBES
508
+ if timeout_seconds is None:
509
+ timeout_seconds = DEFAULT_AUTH_CHECK_TIMEOUT_SECONDS
510
+
511
+ def _info(msg: str) -> None:
512
+ if not quiet:
513
+ print(msg)
514
+
515
+ def _err(msg: str) -> None:
516
+ print(msg, file=sys.stderr)
517
+
518
+ providers = _collect_unique_providers(config)
519
+ if not providers:
520
+ _err(
521
+ "[syncade] auth-check FAILED: config has zero providers — "
522
+ "ensure at least one [[reviewers]] block or [producer] is configured."
523
+ )
524
+ return WORKTREE_ERROR
525
+
526
+ _info(f"[syncade] auth-check: probing {len(providers)} credential(s)")
527
+
528
+ results: list[AuthCheckResult] = []
529
+ any_failed = False
530
+ # The per-credential probe core lives in probe_credentials() so syncade --doctor can
531
+ # reuse the exact same wiring (carrying each credential's own actor through to the env)
532
+ # rather than reimplementing it and re-introducing the "wrong actor's env" bug. This
533
+ # loop just prints each result as it lands and tracks failure for the exit code.
534
+ for result in probe_credentials(config, timeout_seconds=timeout_seconds, probes=probes):
535
+ results.append(result)
536
+ if result.ok:
537
+ _info(f"[auth-check] {result.provider}: {result.detail}")
538
+ else:
539
+ any_failed = True
540
+ _err(f"[auth-check] {result.provider}: {result.detail}")
541
+
542
+ if any_failed:
543
+ _err(
544
+ "[syncade] auth-check FAILED: one or more credentials could not "
545
+ "authenticate. See the per-credential detail above; re-authenticate "
546
+ "before invoking syncade for a real review."
547
+ )
548
+ return WORKTREE_ERROR
549
+
550
+ total_duration = sum(r.duration_seconds for r in results)
551
+ _info(
552
+ f"[syncade] auth-check OK: {len(results)} credential(s) verified in {total_duration:.1f}s"
553
+ )
554
+ return SUCCESS