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
@@ -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