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_loader.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Load and validate ``.syncade/config.toml``.
|
|
2
|
+
|
|
3
|
+
The loader is intentionally tolerant of a missing config file (returns PRD
|
|
4
|
+
defaults silently) but strict about invalid content: TOML parse errors and
|
|
5
|
+
pydantic validation failures are both wrapped in :class:`ConfigError` with
|
|
6
|
+
a message that names the offending field(s) when possible.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import tomllib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from syncade.config import SyncadeConfig
|
|
17
|
+
from syncade.config_auth import api_key_problems
|
|
18
|
+
from syncade.presets import load_preset
|
|
19
|
+
|
|
20
|
+
CONFIG_RELATIVE_PATH: Path = Path(".syncade") / "config.toml"
|
|
21
|
+
"""Path of the config file relative to a repo root."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigError(Exception):
|
|
25
|
+
"""Raised when ``.syncade/config.toml`` cannot be parsed or fails schema
|
|
26
|
+
validation.
|
|
27
|
+
|
|
28
|
+
The exception message names the offending field(s) when possible so the
|
|
29
|
+
CLI can surface a self-explanatory error to the user without further
|
|
30
|
+
introspection of the underlying cause.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Top-level tables whose provider/model form a PAIR (config.py re-derives model from provider). A
|
|
35
|
+
# higher layer that names one of these replaces it WHOLESALE, so the pair can never split across
|
|
36
|
+
# layers (global sets anthropic/claude, repo overrides provider=openai -> a consistent openai pair,
|
|
37
|
+
# never openai + a claude model). Everything else merges key-by-key; lists/scalars replace.
|
|
38
|
+
_PAIRED_SECTIONS = frozenset({"producer", "synthesizer", "drafter", "auditor"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _deep_merge(base: dict, override: dict, *, _top_level: bool = True) -> dict:
|
|
42
|
+
"""Merge ``override`` onto ``base`` (override wins). Tables merge key-by-key EXCEPT the
|
|
43
|
+
TOP-LEVEL paired sections (:data:`_PAIRED_SECTIONS`), which replace wholesale; lists and scalars
|
|
44
|
+
replace. Layers the chain: defaults < ``--preset`` < global (``~/.syncade``) < repo
|
|
45
|
+
(``.syncade``). The paired-section rule is TOP-LEVEL ONLY (D2): ``_top_level=False`` on recurse
|
|
46
|
+
so a nested table that merely SHARES a paired name (e.g. a ``[pricing.models.producer]`` for a
|
|
47
|
+
model named ``producer``) still key-merges instead of being wholesale-replaced."""
|
|
48
|
+
out = dict(base)
|
|
49
|
+
for key, value in override.items():
|
|
50
|
+
if _top_level and key in _PAIRED_SECTIONS:
|
|
51
|
+
out[key] = value
|
|
52
|
+
elif isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
53
|
+
out[key] = _deep_merge(out[key], value, _top_level=False)
|
|
54
|
+
else:
|
|
55
|
+
out[key] = value
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _default_global_config_path() -> Path:
|
|
60
|
+
"""``~/.syncade/config.toml`` — the global config layer. A function (not a constant) so tests
|
|
61
|
+
can monkeypatch it to an isolated path without touching ``$HOME``."""
|
|
62
|
+
return Path.home() / ".syncade" / "config.toml"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_toml(path: Path) -> dict:
|
|
66
|
+
"""Parse ``path`` to a dict; ``{}`` if it is absent. Read/parse failures raise
|
|
67
|
+
:class:`ConfigError` naming ``path``, so a bad global vs repo file is unambiguous."""
|
|
68
|
+
try:
|
|
69
|
+
if not path.is_file():
|
|
70
|
+
return {}
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
raise ConfigError(f"Failed to inspect {path}: {exc}") from exc
|
|
73
|
+
try:
|
|
74
|
+
with path.open("rb") as f:
|
|
75
|
+
return tomllib.load(f)
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
raise ConfigError(f"Failed to read {path}: {exc}") from exc
|
|
78
|
+
except tomllib.TOMLDecodeError as exc:
|
|
79
|
+
raise ConfigError(f"Failed to parse {path}: {exc}") from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_config(
|
|
83
|
+
repo_root: Path,
|
|
84
|
+
*,
|
|
85
|
+
preset: str | None = None,
|
|
86
|
+
check_api_keys: bool = True,
|
|
87
|
+
deprecation_callback=None,
|
|
88
|
+
env: dict[str, str] | None = None,
|
|
89
|
+
global_config_path: Path | None = None,
|
|
90
|
+
include_repo: bool = True,
|
|
91
|
+
) -> SyncadeConfig:
|
|
92
|
+
"""Load and validate ``<repo_root>/.syncade/config.toml``.
|
|
93
|
+
|
|
94
|
+
Behaviour:
|
|
95
|
+
|
|
96
|
+
- If the file is missing, returns :class:`SyncadeConfig` with all PRD
|
|
97
|
+
defaults applied (or the ``preset`` base, if given). No warning is
|
|
98
|
+
emitted — zero-config is a supported and expected mode.
|
|
99
|
+
- Precedence is defaults < ``preset`` < ``~/.syncade/config.toml`` (global) <
|
|
100
|
+
the repo's ``.syncade/config.toml`` < CLI flags. Each layer deep-merges onto
|
|
101
|
+
the one below (higher wins), except the paired sections replace wholesale
|
|
102
|
+
(see :func:`_deep_merge`). A missing layer is skipped; with no global and no
|
|
103
|
+
repo file, the result is byte-identical to zero-config today.
|
|
104
|
+
- If the file exists but is syntactically invalid TOML, raises
|
|
105
|
+
:class:`ConfigError` referencing the parse error.
|
|
106
|
+
- If the file parses but fails schema validation (unknown field,
|
|
107
|
+
invalid enum value, wrong type, etc.), raises :class:`ConfigError`
|
|
108
|
+
whose message names the offending dotted field path(s).
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
repo_root: The git repo root to load config from.
|
|
112
|
+
preset: Optional bundled preset name; its TOML is the base config the
|
|
113
|
+
user file deep-merges onto (see :func:`syncade.presets.load_preset`).
|
|
114
|
+
check_api_keys: When ``True`` (default), an ``auth = "api"`` actor with no
|
|
115
|
+
key in the env fails as a config error. Set ``False`` for actor-less
|
|
116
|
+
maintenance modes (``--gc``) that must not require credentials for
|
|
117
|
+
actors they never spawn — malformed TOML / bad schema still fail.
|
|
118
|
+
deprecation_callback: Retained for the CLI call shape. There are no
|
|
119
|
+
current load-time deprecations; stale fields now fail validation.
|
|
120
|
+
env: Environment consulted for the ANTHROPIC ``auth = "api"`` key check (openai
|
|
121
|
+
is exempt -- codex reads no env key). Defaults to ``os.environ``. An explicit
|
|
122
|
+
parameter rather than a global read, so tests can exercise the check without
|
|
123
|
+
mutating process state — and so the env-dependence of config loading is
|
|
124
|
+
visible in the signature.
|
|
125
|
+
global_config_path: The global layer (defaults to ``~/.syncade/config.toml``). Tests pass
|
|
126
|
+
an explicit path to exercise or isolate it; production uses the default.
|
|
127
|
+
include_repo: When ``True`` (default), the repo layer (``<repo_root>/.syncade/config.toml``)
|
|
128
|
+
participates. ``False`` drops it — used by ``--config`` when the cwd is NOT inside a git
|
|
129
|
+
repo. ``--config`` inspects the CURRENT state (no repo → no repo layer), so a stray
|
|
130
|
+
``cwd/.syncade/config.toml`` is not read as the repo layer. (A *review* run with
|
|
131
|
+
``--allow-auto-init`` would ``git init`` the dir first and THEN read that file; without
|
|
132
|
+
it, a non-empty dir is refused. ``--config`` surfaces the divergence as a note rather
|
|
133
|
+
than silently adopting or ignoring the file.)
|
|
134
|
+
"""
|
|
135
|
+
global_path = (
|
|
136
|
+
global_config_path if global_config_path is not None else _default_global_config_path()
|
|
137
|
+
)
|
|
138
|
+
repo_path = repo_root / CONFIG_RELATIVE_PATH
|
|
139
|
+
|
|
140
|
+
# defaults(< preset) < global < repo. A missing layer contributes {} and is not blamed on error.
|
|
141
|
+
raw = load_preset(preset) if preset else {}
|
|
142
|
+
sources: list[Path] = []
|
|
143
|
+
layer_paths = (global_path, repo_path) if include_repo else (global_path,)
|
|
144
|
+
for path in layer_paths:
|
|
145
|
+
layer = _read_toml(path)
|
|
146
|
+
if layer:
|
|
147
|
+
raw = _deep_merge(raw, layer)
|
|
148
|
+
sources.append(path)
|
|
149
|
+
where = " + ".join(str(p) for p in sources) or "configuration"
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
config = SyncadeConfig.model_validate(raw)
|
|
153
|
+
except ValidationError as exc:
|
|
154
|
+
raise ConfigError(
|
|
155
|
+
f"Invalid configuration in {where}:\n{_format_validation_errors(exc)}"
|
|
156
|
+
) from exc
|
|
157
|
+
|
|
158
|
+
# auth = "api" with no key in the env is a CONFIG error, not a runtime one. Left to
|
|
159
|
+
# runtime it surfaces as a provider 401 — after every reviewer has already run and
|
|
160
|
+
# billed. Every offending actor is reported at once so one re-run fixes them all.
|
|
161
|
+
# ``check_api_keys=False`` for actor-less maintenance modes (e.g. ``--gc``): they still
|
|
162
|
+
# reject malformed TOML / bad schema above, but must not require credentials for actors
|
|
163
|
+
# they will never spawn (dogfood R2-B2).
|
|
164
|
+
if check_api_keys:
|
|
165
|
+
problems = api_key_problems(config, env=env)
|
|
166
|
+
if problems:
|
|
167
|
+
raise ConfigError(
|
|
168
|
+
"Invalid configuration in {}:\n{}".format(
|
|
169
|
+
where, "\n".join(f" - {p}" for p in problems)
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return config
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _format_validation_errors(exc: ValidationError) -> str:
|
|
176
|
+
"""Render a :class:`pydantic.ValidationError` as a human-readable
|
|
177
|
+
bulleted list of ``<dotted.field>: <message>`` lines."""
|
|
178
|
+
lines: list[str] = []
|
|
179
|
+
for err in exc.errors():
|
|
180
|
+
loc = ".".join(str(part) for part in err["loc"]) or "<root>"
|
|
181
|
+
lines.append(f" - {loc}: {err['msg']}")
|
|
182
|
+
return "\n".join(lines)
|
syncade/config_loop.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""[loop] convergence-loop config.
|
|
2
|
+
|
|
3
|
+
:class:`LoopConfig` for the ``[loop]`` block. Imports only stdlib + pydantic
|
|
4
|
+
(no dependency on ``config`` itself), so it extracts without a circular import.
|
|
5
|
+
Re-exported from ``config`` so the ``syncade.config.LoopConfig`` import path is
|
|
6
|
+
unchanged.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LoopConfig(BaseModel):
|
|
17
|
+
"""Convergence-loop tuning."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
max_rounds: int = Field(
|
|
22
|
+
default=5,
|
|
23
|
+
ge=1,
|
|
24
|
+
le=10,
|
|
25
|
+
description=(
|
|
26
|
+
"Per-run maximum rounds of (reviewers → synthesizer → "
|
|
27
|
+
"optional test → producer-if-NO-SHIP). The loop terminates "
|
|
28
|
+
"as soon as SHIP fires (exit 0, termination_reason='ship') "
|
|
29
|
+
"at any round, or the diff is known-empty before dispatch "
|
|
30
|
+
"(exit 0, termination_reason='no_changes_to_review') — "
|
|
31
|
+
"``max_rounds`` is the ceiling, not a target. Bounded to "
|
|
32
|
+
"[1, 10] (PR-v2-31, raised from 3); values outside that range "
|
|
33
|
+
"are rejected at config load. 10 is a typo-ceiling, not the "
|
|
34
|
+
"safety mechanism — budget_tokens/budget_usd (PR-v2-11) and the "
|
|
35
|
+
"per-subprocess timeout are the real runaway guards. Set to 1 for "
|
|
36
|
+
"single-pass operation with no producer subprocess."
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
timeout_seconds: float = Field(
|
|
40
|
+
# 1800.0, not 1800: the annotation says float, and an int default makes pydantic 2.0
|
|
41
|
+
# emit a serializer warning on every `config.model_dump()` ("Expected `float` but got
|
|
42
|
+
# `int`"). Harmless in isolation, fatal in context — the loop's own test leg runs
|
|
43
|
+
# `pytest -W error`, so at the declared floor that one int turned 279 tests red while
|
|
44
|
+
# the dev machine (pydantic 2.13) saw nothing (PR-h-10 item 4).
|
|
45
|
+
default=1800.0,
|
|
46
|
+
gt=0,
|
|
47
|
+
description="Per-subprocess wall-clock timeout in seconds. The fallback "
|
|
48
|
+
"wall-clock cap for every leg: each reviewer, the judge, the test run, "
|
|
49
|
+
"each mechanical check, and the producer. Default 1800 (30 minutes) — "
|
|
50
|
+
"sized for a thorough real review. Must be > 0 and finite (NaN and "
|
|
51
|
+
"infinity rejected via a field_validator; pydantic's gt=0 admits both "
|
|
52
|
+
"unaided). The CLI's --timeout flag overrides this per-invocation.",
|
|
53
|
+
)
|
|
54
|
+
max_diff_bytes: int = Field(
|
|
55
|
+
default=1_000_000,
|
|
56
|
+
gt=0,
|
|
57
|
+
description=(
|
|
58
|
+
"Ceiling on the REVIEWER-FACING diff, in UTF-8 bytes — measured after "
|
|
59
|
+
"repo-context stripping and binary elision, i.e. exactly what reaches the "
|
|
60
|
+
"model's context, not what the repo produced. A run whose diff exceeds it is "
|
|
61
|
+
"refused before any subprocess is dispatched (exit 60, "
|
|
62
|
+
"termination_reason='diff_too_large'), consistent with diff_malformed: if we "
|
|
63
|
+
"cannot show reviewers the change, we decline to render a verdict on it. "
|
|
64
|
+
"Narrow --base or split the PR.\n\n"
|
|
65
|
+
"The default is calibrated, not guessed, in two directions. Upper bound: "
|
|
66
|
+
"`codex exec` hard-refuses input over 1,048,576 CHARACTERS "
|
|
67
|
+
"(input_error_code=input_too_large) — a UTF-8 byte cap of 1,000,000 keeps the "
|
|
68
|
+
"diff under that in every encoding, since bytes >= characters, so syncade's own "
|
|
69
|
+
"actionable message always fires before the provider's opaque one. Lower bound: "
|
|
70
|
+
"across the 30 measured rounds in this repo's run corpus the largest "
|
|
71
|
+
"reviewer-facing diff was 147,171 B (median 77,026), so the default has ~7x "
|
|
72
|
+
"headroom over observed reality and refuses nothing that works today. Lower it "
|
|
73
|
+
"deliberately if you want a tighter review surface — this wave's evidence is "
|
|
74
|
+
"that large diffs converge poorly (a 25-commit diff gave 4/4/4/3/5 findings and "
|
|
75
|
+
"never converged; a 3-commit one gave 1/1/0 to SHIP on the same panel).\n\n"
|
|
76
|
+
"NOT a token cap: `claude -p` limits by TOKENS (1,000,000), which bytes cannot "
|
|
77
|
+
"predict, so a token-limit refusal is still possible on that provider."
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
budget_tokens: int | None = Field(
|
|
81
|
+
default=50_000_000,
|
|
82
|
+
ge=0,
|
|
83
|
+
description=(
|
|
84
|
+
"Optional per-RUN total-token ceiling (PR-v2-11). When the running tally of every "
|
|
85
|
+
"actor's usage crosses it at a phase boundary, the loop aborts gracefully with "
|
|
86
|
+
"termination_reason='budget_exceeded'. total_tokens is recorded for every actor "
|
|
87
|
+
"whose provider returned usage (the norm — captured even when the reviewer's OUTPUT "
|
|
88
|
+
"fails to parse), so this is the TIGHTEST cap available: exact when all actors "
|
|
89
|
+
"report usage, and a lower bound only if an actor reports none (a provider envelope "
|
|
90
|
+
"with no usage block). Always at least as tight as budget_usd, which ALSO drops "
|
|
91
|
+
"actors whose COST could not be priced. 0 = no token ceiling (opt-out sentinel; "
|
|
92
|
+
"TOML has no null, so 0 is the only way to express 'unlimited' once a default "
|
|
93
|
+
"exists). The CLI's --budget-tokens flag overrides this per-invocation. An int, "
|
|
94
|
+
"so NaN/inf cannot arise and ge=0 already rejects negatives.\n\n"
|
|
95
|
+
"DEFAULT 50,000,000 (PR-h-field-06). Previously unset, which left a first run "
|
|
96
|
+
"bounded only by max_rounds x the per-subprocess timeout — and the round default "
|
|
97
|
+
"moved 3 -> 5 in v0.6.1, widening that ~66%. Chosen against this repo's corpus "
|
|
98
|
+
"rather than picked round: across 102 priced runs the median run spent 11.0M "
|
|
99
|
+
"tokens and p90 spent 38.8M, so 50M would have stopped 4 of 102 (4%). Tokens "
|
|
100
|
+
"rather than dollars because cost_usd is an API-EQUIVALENT VALUATION and most "
|
|
101
|
+
"traffic is subscription, where a dollar ceiling would interrupt a run over money "
|
|
102
|
+
"nobody spent.\n\n"
|
|
103
|
+
"SET TO 0 FOR NO CEILING. TOML has no null, so once a default exists an omitted "
|
|
104
|
+
"key can no longer mean 'unlimited' — 0 is the opt-out. It reads as a ceiling of "
|
|
105
|
+
"zero only if you ignore that a zero ceiling would stop every run before it "
|
|
106
|
+
"started, which is why it is free to mean the opposite."
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
budget_usd: float | None = Field(
|
|
110
|
+
default=None,
|
|
111
|
+
ge=0,
|
|
112
|
+
description=(
|
|
113
|
+
"Optional per-RUN cost ceiling (PR-v2-11) on the API-EQUIVALENT VALUATION "
|
|
114
|
+
"(PR-v2-24), NOT billed money — on a subscription the marginal dollar is $0, so "
|
|
115
|
+
"this bounds the WORK, matching what `syncade --doctor` previews and `--metrics` "
|
|
116
|
+
"reports. A LOWER-BOUND tally: actors with incomplete cost contribute uncounted, "
|
|
117
|
+
"so a dollar-budgeted run can overshoot silently (use budget_tokens for a hard "
|
|
118
|
+
"cap). None (default) = no cost ceiling. Must be >= 0 and finite (NaN/inf rejected "
|
|
119
|
+
"via a field_validator; pydantic's gt=0 admits both). The CLI's --budget-usd flag "
|
|
120
|
+
"overrides this per-invocation.\n\n"
|
|
121
|
+
"SET TO 0 FOR NO CEILING, symmetric with budget_tokens. Added in PR-h-field-06 "
|
|
122
|
+
"after a blind reviewer caught the asymmetry: the stop message offered a dollar "
|
|
123
|
+
"opt-out that did not exist, and 'remove the key' does not work because --resume "
|
|
124
|
+
"RE-INHERITS a ceiling the current config omits. An explicit 0 is in "
|
|
125
|
+
"model_fields_set, so resume treats it as a decision rather than an absence."
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
test_command: str | None = Field(
|
|
129
|
+
default=None,
|
|
130
|
+
min_length=1,
|
|
131
|
+
description=(
|
|
132
|
+
"Shell command to run as the third convergence leg, "
|
|
133
|
+
"after all reviewers succeed and the synthesizer produces a "
|
|
134
|
+
"clean consolidated finding set. Runs in a fresh worktree "
|
|
135
|
+
"(same blindness mechanism as reviewers: WorktreeManager + "
|
|
136
|
+
"CLAUDE.md/AGENTS.md stripped). Non-zero exit → exit 30 "
|
|
137
|
+
"(treated as a blocker); subprocess failure (binary missing, "
|
|
138
|
+
"timeout) → exit 40. Unset (default) skips the test leg "
|
|
139
|
+
"entirely — exit 0 reflects synth-clean "
|
|
140
|
+
"(termination_reason='ship') only; a no_changes_to_review "
|
|
141
|
+
"early exit takes exit 0 regardless of this setting. The string "
|
|
142
|
+
"is passed verbatim to ``sh -c`` so operators can use pipes, "
|
|
143
|
+
"env exports, and multi-command sequences "
|
|
144
|
+
'(``"npm test && playwright test"``). shell=True is '
|
|
145
|
+
"intentional: the command comes from the operator's own "
|
|
146
|
+
"config file, not from untrusted input — same threat model "
|
|
147
|
+
'as a Makefile or package.json "scripts" entry. '
|
|
148
|
+
"Whitespace-only strings are rejected at config-load via "
|
|
149
|
+
"``Field(min_length=1)`` plus a ``field_validator`` so a "
|
|
150
|
+
'misconfigured ``test_command = " "`` surfaces as a '
|
|
151
|
+
"ValidationError rather than silently SIGKILLing every run."
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
test_timeout_seconds: float | None = Field(
|
|
155
|
+
default=None,
|
|
156
|
+
gt=0,
|
|
157
|
+
description=(
|
|
158
|
+
"Per-test-run wall-clock timeout in seconds. "
|
|
159
|
+
"``None`` (default) reuses :attr:`timeout_seconds`, which is "
|
|
160
|
+
"also the reviewer timeout. Set explicitly when the test "
|
|
161
|
+
"suite has a different expected runtime profile than the "
|
|
162
|
+
"reviewers (e.g. fast unit suite at 300s vs reviewers at "
|
|
163
|
+
"1800s). Must be > 0 when set; NaN and infinity are "
|
|
164
|
+
"rejected via a ``field_validator`` so the same "
|
|
165
|
+
"``math.isfinite`` guard pattern from the reviewer "
|
|
166
|
+
"``timeout_seconds`` applies here. The "
|
|
167
|
+
"``test_timeout_seconds = None`` → reuse-``timeout_seconds`` "
|
|
168
|
+
"resolution happens in the orchestrator, not in pydantic — "
|
|
169
|
+
"keeping the field nullable means 'use the default' is "
|
|
170
|
+
"distinguishable from 'explicitly set to the same value as "
|
|
171
|
+
"timeout_seconds.'"
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@field_validator("budget_tokens", mode="before")
|
|
176
|
+
@classmethod
|
|
177
|
+
def _strict_budget_tokens(cls, value: object) -> object:
|
|
178
|
+
# Same rule as max_diff_bytes: pydantic's lax mode silently coerces false->0 (the
|
|
179
|
+
# opt-out sentinel), true->1, 0.0->0, and "0"->0. A TOML typo of
|
|
180
|
+
# `budget_tokens = false` would silently disable the default safety ceiling rather
|
|
181
|
+
# than producing a config error — the opposite of a safe failure. Reject all
|
|
182
|
+
# wrong-type forms (exit 50). None is allowed (the no-default case).
|
|
183
|
+
if value is not None and (isinstance(value, bool) or not isinstance(value, int)):
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"budget_tokens must be a plain integer or omitted (got {value!r}); "
|
|
186
|
+
"quoted numbers, floats, and booleans are rejected"
|
|
187
|
+
)
|
|
188
|
+
return value
|
|
189
|
+
|
|
190
|
+
@field_validator("max_diff_bytes", mode="before")
|
|
191
|
+
@classmethod
|
|
192
|
+
def _strict_max_diff_bytes(cls, value: object) -> object:
|
|
193
|
+
# Same rule as [gc]'s integers (PR-v2-9): pydantic's lax mode silently coerces a
|
|
194
|
+
# quoted number ("0"->0), an exact float (1.0->1), and a boolean (bool subclasses
|
|
195
|
+
# int, so true->1). Any of those here is a typo with a severe outcome — true->1
|
|
196
|
+
# would refuse EVERY run for a one-byte ceiling. Reject all three (exit 50).
|
|
197
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"max_diff_bytes must be a plain integer (got {value!r}); quoted numbers, "
|
|
200
|
+
"floats, and booleans are rejected"
|
|
201
|
+
)
|
|
202
|
+
return value
|
|
203
|
+
|
|
204
|
+
@field_validator("test_command")
|
|
205
|
+
@classmethod
|
|
206
|
+
def _test_command_not_whitespace(cls, value: str | None) -> str | None:
|
|
207
|
+
"""Reject ``" "`` / ``"\\n\\t"`` etc. — schema-only ``min_length``
|
|
208
|
+
passes them through (length is non-zero) but they'd silently
|
|
209
|
+
SIGKILL every run when passed to ``sh -c``.
|
|
210
|
+
|
|
211
|
+
Mirrors the required-string validation pattern used elsewhere. Keeping
|
|
212
|
+
``None`` as the disabled sentinel — only NON-None values are checked for
|
|
213
|
+
whitespace
|
|
214
|
+
emptiness.
|
|
215
|
+
"""
|
|
216
|
+
if value is not None and not value.strip():
|
|
217
|
+
raise ValueError(
|
|
218
|
+
"test_command must not be empty or whitespace-only "
|
|
219
|
+
"(use ``test_command = `` <unset> to disable the test "
|
|
220
|
+
"leg; the empty / whitespace string is rejected so a "
|
|
221
|
+
"config typo can't silently disable the leg)"
|
|
222
|
+
)
|
|
223
|
+
return value
|
|
224
|
+
|
|
225
|
+
@field_validator("budget_usd", mode="before")
|
|
226
|
+
@classmethod
|
|
227
|
+
def _strict_budget_usd(cls, value: object) -> object:
|
|
228
|
+
# Same rule as budget_tokens: pydantic's lax mode coerces false->0.0 (the opt-out
|
|
229
|
+
# sentinel), true->1.0, and "1.5"->1.5. Because explicit 0 is now the no-ceiling
|
|
230
|
+
# sentinel and lands in model_fields_set, `budget_usd = false` in a hand-edited repo
|
|
231
|
+
# config can silently disable an inherited dollar ceiling rather than surfacing a config
|
|
232
|
+
# error. Reject booleans and non-numeric strings; allow plain int/float and None.
|
|
233
|
+
if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float))):
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"budget_usd must be a plain numeric value >= 0 or omitted (got {value!r}); "
|
|
236
|
+
"quoted numbers and booleans are rejected"
|
|
237
|
+
)
|
|
238
|
+
return value
|
|
239
|
+
|
|
240
|
+
@field_validator("budget_usd")
|
|
241
|
+
@classmethod
|
|
242
|
+
def _budget_usd_isfinite(cls, value: float | None) -> float | None:
|
|
243
|
+
"""Same ``math.isfinite`` guard as the timeouts: pydantic's ``gt=0`` admits NaN/inf,
|
|
244
|
+
and an infinite budget would silently mean 'no ceiling' (it never trips) rather than
|
|
245
|
+
erroring — hiding a config typo. ``budget_tokens`` needs no twin: it is an int."""
|
|
246
|
+
if value is not None and not math.isfinite(value):
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"budget_usd must be a finite non-negative number (0 = no ceiling); got {value!r}"
|
|
249
|
+
)
|
|
250
|
+
return value
|
|
251
|
+
|
|
252
|
+
@field_validator("test_timeout_seconds")
|
|
253
|
+
@classmethod
|
|
254
|
+
def _test_timeout_seconds_isfinite(cls, value: float | None) -> float | None:
|
|
255
|
+
"""Reject ``float('nan')`` and ``float('inf')`` — pydantic's
|
|
256
|
+
``gt=0`` admits both (inf > 0 is True; nan comparisons are
|
|
257
|
+
defined to return False so the gt check actually passes
|
|
258
|
+
with a warning shape in some pydantic versions). The same
|
|
259
|
+
``math.isfinite`` guard also applies to the reviewer
|
|
260
|
+
timeout applies here so a runaway operator config can't
|
|
261
|
+
produce an unkillable test-run subprocess."""
|
|
262
|
+
if value is not None and not math.isfinite(value):
|
|
263
|
+
raise ValueError(
|
|
264
|
+
f"test_timeout_seconds must be a finite positive number; got {value!r}"
|
|
265
|
+
)
|
|
266
|
+
return value
|
|
267
|
+
|
|
268
|
+
@field_validator("timeout_seconds")
|
|
269
|
+
@classmethod
|
|
270
|
+
def _timeout_seconds_isfinite(cls, value: float) -> float:
|
|
271
|
+
"""Apply the same ``math.isfinite`` guard for reviewer timeouts.
|
|
272
|
+
|
|
273
|
+
pydantic's ``gt=0`` admits NaN/inf unaided, which would produce an
|
|
274
|
+
unkillable reviewer subprocess: the reviewer phase would block forever
|
|
275
|
+
waiting on the dispatcher's ``communicate(timeout=...)`` since
|
|
276
|
+
``timeout=inf`` reads as "wait forever" and ``timeout=nan`` produces
|
|
277
|
+
undefined comparator behavior. Same field-validator pattern as
|
|
278
|
+
:meth:`_test_timeout_seconds_isfinite`.
|
|
279
|
+
"""
|
|
280
|
+
if not math.isfinite(value):
|
|
281
|
+
raise ValueError(f"timeout_seconds must be a finite positive number; got {value!r}")
|
|
282
|
+
return value
|