syncade 0.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- syncade-0.6.2.dist-info/top_level.txt +1 -0
syncade/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
|