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/config.py ADDED
@@ -0,0 +1,478 @@
1
+ # config.py is the public Pydantic config import surface: it owns the reviewer/review/
2
+ # top-level models and RE-EXPORTS the rest, so `from syncade.config import X` keeps
3
+ # working for every X it ever exposed. Producer + harness detection live in
4
+ # ``config_producer``; the shared Literals in ``config_types`` (PR-v2-23 — this module
5
+ # was 462 LOC against a blocking 500 cap and had no room for the judge/drafter blocks).
6
+ """Pydantic v2 models for ``.syncade/config.toml``.
7
+
8
+ Schema mirrors the PRD's "Configuration spec" section exactly. All defaults
9
+ match the PRD; any TOML field not listed here is rejected (``extra="forbid"``)
10
+ so typos in user config files surface as validation errors rather than being
11
+ silently ignored.
12
+ """
13
+
14
+ import math
15
+ from pathlib import Path
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
18
+
19
+ from syncade.checks_config import CheckConfig, validate_check_names
20
+ from syncade.config_auth import AuthedActor
21
+ from syncade.config_cold import AuditorConfig, DrafterConfig, SynthesizerConfig
22
+ from syncade.config_gc import GcConfig
23
+ from syncade.config_loop import LoopConfig
24
+ from syncade.config_producer import (
25
+ _PRODUCER_MODELS,
26
+ Harness,
27
+ ProducerConfig,
28
+ ProducerPermissions,
29
+ ProducerProvider,
30
+ _default_producer_model,
31
+ _default_producer_provider,
32
+ _invoking_harness,
33
+ )
34
+ from syncade.config_retry import RetryConfig
35
+ from syncade.config_types import Permissions, ReviewerPermissions, Thinking
36
+ from syncade.diff_filter import REVIEWER_STRIP_FILES
37
+ from syncade.pricing_config import PricingConfig
38
+ from syncade.worktree import DEFAULT_WORKTREE_BASE, TEST_WORKTREE_NAME
39
+
40
+ __all__ = [
41
+ # re-exported so every existing `from syncade.config import X` keeps working
42
+ # after the PR-v2-23 split (config_producer / config_cold / config_types).
43
+ "AuditorConfig",
44
+ "DrafterConfig",
45
+ "Harness",
46
+ "Permissions",
47
+ "ProducerConfig",
48
+ "ProducerPermissions",
49
+ "ProducerProvider",
50
+ "ReviewConfig",
51
+ "ReviewerConfig",
52
+ "ReviewerPermissions",
53
+ "SyncadeConfig",
54
+ "SynthesizerConfig",
55
+ "Thinking",
56
+ # private, but imported by tests and by config's own defaults
57
+ "_PRODUCER_MODELS",
58
+ "_default_producer_model",
59
+ "_default_producer_provider",
60
+ "_invoking_harness",
61
+ ]
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Literal type aliases for enum-like fields. Kept narrow on purpose: any value
65
+ # not listed here will fail validation, which is the point.
66
+ # ---------------------------------------------------------------------------
67
+
68
+ # Thinking / Permissions moved to ``config_types`` so config.py and
69
+ # config_producer.py can share them without an import cycle. Re-exported below.
70
+
71
+ # Producer config + harness detection moved to ``config_producer`` (PR-v2-23) to
72
+ # keep this module under the blocking 500-LOC cap. Re-exported below so every
73
+ # existing ``from syncade.config import ProducerConfig`` keeps working.
74
+
75
+
76
+ class ReviewerConfig(AuthedActor):
77
+ """A single reviewer entry. Multiple reviewers are configured via
78
+ repeated ``[[reviewers]]`` TOML blocks. Adding another reviewer that
79
+ uses an already-registered provider is configuration-only; adding a
80
+ new provider requires an adapter and registry entry."""
81
+
82
+ model_config = ConfigDict(extra="forbid")
83
+
84
+ name: str = Field(
85
+ description=(
86
+ "Stable identifier used in run artifacts and findings. "
87
+ "Must not contain ``=`` — that character is the delimiter in the "
88
+ "``NAME=VALUE`` per-reviewer CLI override grammar "
89
+ "(``--reviewer-model``, ``--reviewer-thinking``, ``--reviewer-timeout``), "
90
+ "so a name containing it cannot be targeted."
91
+ ),
92
+ )
93
+
94
+ @field_validator("name")
95
+ @classmethod
96
+ def _name_no_equals(cls, value: str) -> str:
97
+ if "=" in value:
98
+ raise ValueError( # GENERIC_ERR_OK: Pydantic validator expects ValueError.
99
+ f"reviewer name {value!r} must not contain '=' — that character is the "
100
+ "NAME=VALUE delimiter for --reviewer-model / --reviewer-thinking / "
101
+ "--reviewer-timeout overrides; a name containing it cannot be targeted"
102
+ )
103
+ return value
104
+
105
+ provider: str = Field(
106
+ description="Model provider (e.g. ``anthropic``, ``openai``). "
107
+ "Resolved against the adapter registry at dispatch time; new provider "
108
+ "names require code support before they can be used in config.",
109
+ )
110
+ model: str = Field(
111
+ description="Model identifier within the provider.",
112
+ )
113
+ thinking: Thinking = Field(
114
+ default="high",
115
+ description=(
116
+ "Reasoning effort budget for this reviewer. "
117
+ "CANONICAL SOURCE: this field's type annotation "
118
+ "(:data:`syncade.config.Thinking`) is the single "
119
+ "authoritative enumeration of accepted thinking values "
120
+ "across syncade. All docs that need to list these "
121
+ "values reference this field rather than duplicating "
122
+ "the list. Future enum extensions update only the "
123
+ "Literal; doc references stay current automatically. "
124
+ "If you find yourself listing thinking values in a doc, "
125
+ "link to :data:`syncade.config.Thinking` instead. The "
126
+ "drift-defense regression test is "
127
+ "``tests/config/test_config_schema.py::"
128
+ "test_thinking_canonical_source_no_duplicate_enumerations``."
129
+ ),
130
+ )
131
+ permissions: ReviewerPermissions = Field(
132
+ default="trusted-execute",
133
+ description=(
134
+ "Tool-permission tier for this reviewer. ``trusted-execute`` is the "
135
+ "default: it still runs fully unattended (Codex "
136
+ "``-s workspace-write -c approval_policy=never``, Anthropic "
137
+ "``bypassPermissions`` — neither ever prompts) but keeps the OS "
138
+ "sandbox ACTIVE and scoped to the reviewer's worktree, so worktree "
139
+ "confinement is enforced structurally instead of resting on the "
140
+ "prompt asking the model to stay put. ``yolo`` maps to Codex's "
141
+ "``--dangerously-bypass-approvals-and-sandbox`` and turns that "
142
+ "sandbox off; it buys a reviewer nothing, since reviewers only read "
143
+ "the repo and run its test/lint commands inside the worktree. "
144
+ "(``yolo`` and ``trusted-execute`` are identical on Anthropic — both "
145
+ "are ``bypassPermissions``; the distinction only bites on Codex, "
146
+ "which is the default reviewer provider.) ``safe`` is not offered: "
147
+ "it prompts, so it would hang a headless subprocess — rejected at "
148
+ "config-load (and again by the adapters as belt-and-braces)."
149
+ ),
150
+ )
151
+ adversarial_lens: bool = Field(
152
+ default=False,
153
+ description=(
154
+ "When true, this reviewer's prompt carries the adversarial "
155
+ "edge-enumeration block (enumerate-then-attack the flag/input/state "
156
+ "combinations the spec does NOT cover, before any SHIP). Opt-in and "
157
+ "default-off so only explicitly configured reviewers receive the "
158
+ "adversarial lens."
159
+ ),
160
+ )
161
+ bug_class_sweep: bool = Field(
162
+ default=False,
163
+ description=(
164
+ "When true, this reviewer's prompt carries the directed bug-class "
165
+ "sweep (removed-behavior audit, caller/callee trace, language "
166
+ "pitfall, wrapper/proxy) that raises recall on specific defect "
167
+ "classes. OPT-IN for now, like the adversarial lens. The "
168
+ "contributed design defaulted it ON, and that argument may well "
169
+ "win — it helps the plain standard reviewer most. It is held off "
170
+ "until an ablation measures it, because this repo has reverted a "
171
+ "panel change before (PR-28 -> PR-29) after a cheaper reasoning "
172
+ "tier audited far too leniently, and changing every reviewer's "
173
+ "prompt by default is the same category of change. Per-reviewer, "
174
+ "so turning it on for one reviewer and off for another IS the "
175
+ "ablation."
176
+ ),
177
+ )
178
+ template: str | None = Field(
179
+ default=None,
180
+ description=(
181
+ "Optional reviewer prompt template basename that overrides "
182
+ "provider-based selection (e.g. ``reviewer_adversarial.md``). When "
183
+ "unset, the reviewer uses its provider's default template. Must be a "
184
+ "plain basename — path separators, absolute paths, and a bare "
185
+ "`.`/`..` are rejected at config load (mirrors load_template; a "
186
+ "filename that merely contains `..`, e.g. `foo..md`, is accepted)."
187
+ ),
188
+ )
189
+ timeout_seconds: float | None = Field(
190
+ default=None,
191
+ gt=0,
192
+ description=(
193
+ "Per-reviewer wall-clock timeout in seconds. ``None`` (default) reuses the loop "
194
+ "timeout (:attr:`LoopConfig.timeout_seconds`, itself overridable by the global "
195
+ "``--timeout``). Set it to give one reviewer a longer or shorter budget than the "
196
+ "others — e.g. a heavier model more time. Must be > 0 and finite when set; NaN and "
197
+ "infinity are rejected (same ``math.isfinite`` guard as the producer / loop timeouts)."
198
+ ),
199
+ )
200
+
201
+ @field_validator("timeout_seconds", mode="before")
202
+ @classmethod
203
+ def _timeout_seconds_strict_number(cls, value: object) -> object:
204
+ # None (unset) reuses the loop global. bool subclasses float (True→1.0) and a quoted number
205
+ # ("1"→1.0) coerces silently — both are config mistakes in a TOML float knob, so reject them
206
+ # (exit 50). A plain int is accepted and widened to float. (The CLI parses the
207
+ # --reviewer-timeout value to float BEFORE validation, so this doesn't break the flag path.)
208
+ if value is None or (isinstance(value, (int, float)) and not isinstance(value, bool)):
209
+ return value
210
+ raise ValueError( # GENERIC_ERR_OK: Pydantic validator expects ValueError.
211
+ f"reviewer timeout_seconds must be a number (got {value!r}); quoted numbers and "
212
+ "booleans are rejected"
213
+ )
214
+
215
+ @field_validator("timeout_seconds")
216
+ @classmethod
217
+ def _timeout_seconds_isfinite(cls, value: float | None) -> float | None:
218
+ # Reject NaN / inf — pydantic's gt=0 admits both. Mirrors ProducerConfig / LoopConfig: an
219
+ # unkillable reviewer subprocess would hang the whole parallel dispatch.
220
+ if value is not None and not math.isfinite(value):
221
+ raise ValueError( # GENERIC_ERR_OK: Pydantic validator expects ValueError.
222
+ f"reviewer timeout_seconds must be a finite positive number; got {value!r}"
223
+ )
224
+ return value
225
+
226
+ @field_validator("template")
227
+ @classmethod
228
+ def _validate_template_basename(cls, value: str | None) -> str | None:
229
+ # Exact-match `.`/`..` (not substring) deliberately mirrors
230
+ # load_template's basename guard: only `.` or `..` as the WHOLE value
231
+ # traverses out of the templates dir. A filename that merely contains
232
+ # `..` (e.g. `foo..md`) is a safe basename and is accepted — rejecting
233
+ # substring `..` would over-reject valid names and drift from
234
+ # load_template.
235
+ if value is None:
236
+ return value
237
+ if (
238
+ not value
239
+ or "/" in value
240
+ or "\\" in value
241
+ or value in (".", "..")
242
+ or Path(value).is_absolute()
243
+ ):
244
+ raise ValueError(
245
+ f"reviewer template {value!r} must be a plain basename "
246
+ "(no separators, parent refs, or absolute paths)"
247
+ )
248
+ return value
249
+
250
+
251
+ class ReviewConfig(BaseModel):
252
+ """What reviewers are allowed to see and how their worktrees are
253
+ prepared."""
254
+
255
+ model_config = ConfigDict(extra="forbid")
256
+
257
+ strip_repo_context_files: list[str] = Field(
258
+ default_factory=lambda: list(REVIEWER_STRIP_FILES),
259
+ description="Bare FILENAMES (not globs, not paths) to remove from each "
260
+ "reviewer worktree before dispatch and to strip from the reviewer-facing "
261
+ "diff, so the producer's setup cannot leak. Matching is by basename "
262
+ "equality: '*.md' matches nothing, and an entry containing '/' is "
263
+ "REFUSED by the worktree strip while the diff filter still matches its "
264
+ "basename -- so 'docs/CLAUDE.md' strips the diff hunk but leaves the "
265
+ "file readable in the worktree. Use bare basenames. The default is "
266
+ "sourced from REVIEWER_STRIP_FILES (single source of truth).",
267
+ )
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # Top-level config
272
+ # ---------------------------------------------------------------------------
273
+
274
+
275
+ def _default_reviewers() -> list[ReviewerConfig]:
276
+ """The two default reviewers used when no ``[[reviewers]]`` blocks
277
+ are present in the user's config: ``codex-reviewer`` on the standard
278
+ prompt and ``codex-reviewer-adv`` on ``reviewer_adversarial.md`` with
279
+ the adversarial lens. Both use OpenAI / ``gpt-5.5``; the panel is
280
+ same-model / cross-prompt while the Anthropic reviewer is offlined.
281
+ The Anthropic reviewer is offlined but revivable — see the commented
282
+ revival example below.
283
+
284
+ Deliberately NOT harness-aware, unlike the producer. The reviewers and the
285
+ judge are the blind panel; pinning them to one cold tier is what keeps a
286
+ verdict comparable across runs and keeps the panel a different model from
287
+ the Anthropic producer under Claude Code. Only the producer follows the
288
+ harness the operator is coding in (see :func:`_invoking_harness`).
289
+
290
+ **Why ``gpt-5.5``** (PR-29): PR-28 moved the panel to ``gpt-5.6-sol`` @
291
+ ``medium``; two dogfood rounds later measurement showed it
292
+ making ~18 tool calls/round against ``gpt-5.5`` @ ``xhigh``'s 90–101, with the
293
+ plain reviewer shipping 2/2 rounds at ZERO findings while only the adversarial
294
+ one caught anything. That is the same leniency shape that got the Anthropic
295
+ reviewer offlined on 2026-06-30, so the panel went back to the configuration
296
+ with 94 rounds behind it (29%/26% ship-rate, 81/77 findings) rather than being
297
+ kept on a 2-round sample. The judge (:data:`SYNTHESIZER_MODEL`) went back too
298
+ and still matches this pin. See the dogfood history (2026-07-11 → 07-12).
299
+
300
+ ``gpt-5.5`` rather than ``gpt-5-codex``: real
301
+ ``codex exec --model gpt-5-codex`` returns
302
+ ``"The 'gpt-5-codex' model is not supported when using Codex with a
303
+ ChatGPT account"`` for the auth mode this orchestrator targets in
304
+ v1 (ChatGPT-account-backed ``codex login``). Users who pay for API-key
305
+ auth and want ``gpt-5-codex`` can override per-reviewer in their
306
+ ``.syncade/config.toml``.
307
+ """
308
+ return [
309
+ ReviewerConfig(
310
+ name="codex-reviewer",
311
+ provider="openai",
312
+ model="gpt-5.5",
313
+ ),
314
+ ReviewerConfig(
315
+ name="codex-reviewer-adv",
316
+ provider="openai",
317
+ model="gpt-5.5",
318
+ template="reviewer_adversarial.md",
319
+ adversarial_lens=True,
320
+ ),
321
+ ]
322
+ # Claude reviewer offlined 2026-06-30 (0 unique synth-upheld blocking
323
+ # verdicts across many dogfood datapoints;. Revive
324
+ # by adding, e.g.:
325
+ # ReviewerConfig(name="claude-reviewer", provider="anthropic",
326
+ # model="claude-opus-4-6", adversarial_lens=True)
327
+
328
+
329
+ class SyncadeConfig(BaseModel):
330
+ """Top-level ``.syncade/config.toml`` schema.
331
+
332
+ Loading is handled by :mod:`syncade.config_loader`. Constructing this
333
+ class with no arguments yields the PRD's documented zero-config defaults.
334
+ """
335
+
336
+ model_config = ConfigDict(extra="forbid")
337
+
338
+ producer: ProducerConfig = Field(default_factory=ProducerConfig)
339
+ reviewers: list[ReviewerConfig] = Field(
340
+ default_factory=_default_reviewers,
341
+ min_length=1,
342
+ description="At least one reviewer is required. Zero-config defaults "
343
+ "are two OpenAI reviewers (codex-reviewer + codex-reviewer-adv) — "
344
+ "same-model, cross-prompt while the Anthropic reviewer is offlined. "
345
+ "Custom configs are validated against the reviewer adapter registry, "
346
+ "but provider diversity is advisory rather than schema- or "
347
+ "dispatch-enforced today.",
348
+ )
349
+ loop: LoopConfig = Field(default_factory=LoopConfig)
350
+ review: ReviewConfig = Field(default_factory=ReviewConfig)
351
+ # The shared transient-retry bound (PR-v2-9). Default reproduces retry.MAX_RETRIES exactly, so a
352
+ # zero-config run is byte-identical; threaded to the reviewer / synth / producer legs.
353
+ retry: RetryConfig = Field(default_factory=RetryConfig)
354
+ # Run-artifact retention (PR-v2-9). Defaults reproduce gc.DEFAULT_KEEP / DEFAULT_MAX_AGE_DAYS;
355
+ # governs BOTH the per-loop auto-prune and an explicit ``syncade --gc``.
356
+ gc: GcConfig = Field(default_factory=GcConfig)
357
+ # Where per-run git worktrees are provisioned (PR-v2-9). A single top-level value (not a
358
+ # ``[worktree]`` block — Q5); default reproduces worktree.DEFAULT_WORKTREE_BASE.
359
+ # ``--worktree-base`` overrides per-invocation; threaded into the review run and doctor preview.
360
+ worktree_base: Path = Field(
361
+ default=DEFAULT_WORKTREE_BASE,
362
+ description=(
363
+ "Base directory under which each run's per-reviewer/producer/test git worktrees are "
364
+ f"created (default ``{DEFAULT_WORKTREE_BASE}``). Overridable per-run with "
365
+ "``--worktree-base``. Point it at a fast local disk if ``/tmp`` is small or slow."
366
+ ),
367
+ )
368
+ # User-defined mechanical checks. Empty (default) = today's loop,
369
+ # byte-identical. Model + collision logic live in `syncade.checks_config`.
370
+ checks: list[CheckConfig] = Field(default_factory=list)
371
+ # Per-model token pricing for cost estimation (PR-v2-04). The model lives in
372
+ # `syncade.pricing_config` (config.py is at its LOC cap); a default table ships
373
+ # so zero-config runs still estimate cost. MANDATORY field — extra="forbid"
374
+ # would otherwise reject a user's [pricing] table.
375
+ pricing: PricingConfig = Field(default_factory=PricingConfig)
376
+ # The three COLD actors (PR-v2-23) — all were hardcoded to CodexAdapter, so
377
+ # `codex` was mandatory even for an all-Anthropic user. Models live in
378
+ # `syncade.config_cold`, which is a leaf: the actor modules re-export its
379
+ # defaults and import `config`, so the values cannot live here. Every default
380
+ # reproduces the previous hardcoded constants exactly — a zero-config run is
381
+ # byte-identical.
382
+ synthesizer: SynthesizerConfig = Field(default_factory=SynthesizerConfig)
383
+ drafter: DrafterConfig = Field(default_factory=DrafterConfig)
384
+ auditor: AuditorConfig = Field(default_factory=AuditorConfig)
385
+
386
+ @field_validator("worktree_base", mode="before")
387
+ @classmethod
388
+ def _reject_nul_in_worktree_base(cls, value: object) -> object:
389
+ # Path() accepts embedded NUL bytes but the OS rejects them at the first
390
+ # syscall, producing an uncaught ValueError instead of a config error.
391
+ # Catch it here so load_config raises ConfigError (exit 50) consistently.
392
+ if isinstance(value, str) and "\x00" in value:
393
+ raise ValueError( # GENERIC_ERR_OK: Pydantic validator expects ValueError.
394
+ "worktree_base must not contain an embedded NUL byte"
395
+ )
396
+ return value
397
+
398
+ @model_validator(mode="after")
399
+ def _reject_duplicate_reviewer_names(self) -> "SyncadeConfig":
400
+ """Reviewer ``name`` is the dedup key in synthesis cross-input
401
+ validation (:mod:`syncade.synthesizer.validation` indexes finding
402
+ counts and unanimous-blocker provenance by ``reviewer_name``) and
403
+ the per-reviewer prompt/worktree key in dispatch. Two reviewers
404
+ sharing a name would collapse to one key and silently undercount a
405
+ unanimous blocker, so reject duplicates at config-load with a clear
406
+ error naming the offender(s) rather than letting the ambiguity reach
407
+ synthesis. Exact-string match mirrors the dedup key, which is an
408
+ exact dict/tuple key (not casefolded)."""
409
+ seen: set[str] = set()
410
+ duplicates: list[str] = []
411
+ for reviewer in self.reviewers:
412
+ if reviewer.name in seen and reviewer.name not in duplicates:
413
+ duplicates.append(reviewer.name)
414
+ seen.add(reviewer.name)
415
+ if duplicates:
416
+ raise ValueError( # GENERIC_ERR_OK: Pydantic model validator expects ValueError.
417
+ f"duplicate reviewer name(s) {duplicates!r}; each "
418
+ f"[[reviewers]] name must be unique (the name is the dedup "
419
+ f"key in synthesis and the per-reviewer worktree/prompt key "
420
+ f"in dispatch)."
421
+ )
422
+ return self
423
+
424
+ @model_validator(mode="after")
425
+ def _reject_reviewer_named_tests_when_test_command_set(self) -> "SyncadeConfig":
426
+ """``TEST_WORKTREE_NAME`` is the
427
+ reserved worktree-basename the test re-run leg uses
428
+ (single source of truth in :mod:`syncade.worktree`). When the
429
+ operator configures ``[loop] test_command``, both the test
430
+ leg AND a reviewer with a colliding name would try to
431
+ create a worktree at the same path and the second one
432
+ would fail with a generic ``WorktreeError``.
433
+
434
+ Case-insensitive comparison is required: exact-match checks miss
435
+ ``"Tests"`` / ``"TESTS"`` / ``"TeStS"`` etc. On
436
+ case-insensitive filesystems (macOS default HFS+/APFS,
437
+ Windows) those all resolve to the SAME on-disk path and
438
+ produce the same collision. ``casefold()`` is the right
439
+ comparator for case-insensitive equality (covers
440
+ full-Unicode case folding like German ß → "ss").
441
+
442
+ Catching this at config-load gives the operator a clear
443
+ error message naming the conflict, rather than paying the
444
+ reviewer+synth cost first only to fail on worktree
445
+ collision in the test phase.
446
+
447
+ The check is conditional on ``test_command`` being set — a
448
+ reviewer named ``"tests"`` is harmless when the test leg
449
+ is disabled (the only worktree at that path would be the
450
+ reviewer's own).
451
+ """
452
+ if self.loop.test_command is None:
453
+ return self
454
+ reserved = TEST_WORKTREE_NAME.casefold()
455
+ offenders = [r.name for r in self.reviewers if r.name.casefold() == reserved]
456
+ if offenders:
457
+ raise ValueError( # GENERIC_ERR_OK: Pydantic model validator expects ValueError.
458
+ f"reviewer name(s) {offenders!r} collide with the "
459
+ f"reserved test-re-run worktree basename "
460
+ f"{TEST_WORKTREE_NAME!r} (case-insensitive) used when "
461
+ f"`[loop] test_command` is configured. Rename the "
462
+ f"reviewer(s) or unset `test_command`. (Both target "
463
+ f"{self.worktree_base}/<run-id>/{TEST_WORKTREE_NAME}/ as their "
464
+ f"worktree directory on case-insensitive filesystems; "
465
+ f"the second one to provision would fail with a "
466
+ f"generic WorktreeError after reviewer+synth cost "
467
+ f"was already paid.)"
468
+ )
469
+ return self
470
+
471
+ @model_validator(mode="after")
472
+ def _validate_check_name_collisions(self) -> "SyncadeConfig":
473
+ """configured checks provision per-round worktrees, so check
474
+ names must be unique and must not collide with reviewer names or the
475
+ reserved `tests` basename. Logic lives in `syncade.checks_config` to
476
+ keep this over-cap module from growing."""
477
+ validate_check_names([r.name for r in self.reviewers], [c.name for c in self.checks])
478
+ return self