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,524 @@
1
+ """``syncade --doctor`` run-plan + cost preview (PR-v2-12 check 7).
2
+
3
+ Split out of :mod:`syncade.doctor` so the readiness checks and this data-heavier preview
4
+ (which reaches into base-resolution, the diff snapshot, and the metrics corpus) each stay
5
+ under the file-length cap. Both build :class:`~syncade.doctor_types.DoctorCheck` rows; the
6
+ shared type lives in :mod:`syncade.doctor_types` to avoid an import cycle.
7
+
8
+ - **plan:** resolves the diff base the SAME way the CLI does (``--base`` / ``--scope``,
9
+ honoring ``--max-rounds``); an unresolvable scope or bad base is red, matching the real
10
+ run's exit-60 refusal. Reports diff size, the actor set, and the round budget.
11
+ - **cost:** a FORWARD estimate for the planned run — reviewers + judge cost per round (they
12
+ run every round) scaled by the round budget, from the local corpus. The producer is NOT
13
+ folded into the per-round figure (it runs only on NO-SHIP rounds); its extra cost is noted
14
+ separately. Runs whose reviewer/judge cost is priced from INCOMPLETE token data are
15
+ excluded, so the figure is not falsely precise. No history → a VERY ROUGH list-price
16
+ fallback. ``cost_usd`` is an API-equivalent VALUATION, not billed money.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ import sqlite3
23
+ from collections import Counter
24
+ from pathlib import Path
25
+
26
+ from syncade.base_resolution import BaseResolutionError, resolve_scope
27
+ from syncade.config import SyncadeConfig
28
+ from syncade.diff_filter import (
29
+ concealed_destinations,
30
+ elide_binary_hunks,
31
+ filter_diff_for_reviewer,
32
+ unidentifiable_sections,
33
+ )
34
+ from syncade.doctor_types import _OK, _RED, DoctorCheck
35
+ from syncade.findings import get_findings_schema_string
36
+ from syncade.metrics.aggregate import backfill
37
+ from syncade.metrics.schema import fetch_actor_stats, fetch_runs, open_db
38
+ from syncade.orchestrator.branch_guard import current_branch_name
39
+ from syncade.orchestrator.round_no_changes import _CODEX_CHAR_CEILING
40
+ from syncade.persistence import read_last_reviewed
41
+ from syncade.prompts import load_reviewer_template_for, render_reviewer_prompt
42
+ from syncade.snapshot import SnapshotError, take_snapshot
43
+
44
+ # Coarse fallback ONLY (no local history): price one round from list prices at a NOMINAL
45
+ # per-actor token budget. Calibrated to observed runs (~$1-3/round) so the figure is the
46
+ # right order of magnitude, but it is a guess — flagged VERY ROUGH — because real cost scales
47
+ # with the diff the reviewers actually read.
48
+ _NOMINAL_INPUT_TOK = 200_000
49
+ _NOMINAL_OUTPUT_TOK = 40_000
50
+
51
+ # The actor roles that spend on EVERY review round (unlike the producer, which runs only on
52
+ # NO-SHIP rounds). The cost estimate is built from these so producer spend is not folded into
53
+ # the per-round figure.
54
+ _PER_ROUND_ROLES = ("reviewer", "synthesizer")
55
+
56
+
57
+ def _diff_size(diff_text: str) -> tuple[int, int]:
58
+ """(files, changed lines) in a unified diff — ``diff --git`` headers and +/- body
59
+ lines (excluding the ``+++``/``---`` file headers)."""
60
+ files = changed = 0
61
+ for line in diff_text.splitlines():
62
+ if line.startswith("diff --git "):
63
+ files += 1
64
+ elif line.startswith(("+", "-")) and not line.startswith(("+++", "---")):
65
+ changed += 1
66
+ return files, changed
67
+
68
+
69
+ def reviewer_facing_bytes(diff_text: str, config: SyncadeConfig) -> int:
70
+ """UTF-8 size of what a reviewer would actually be handed.
71
+
72
+ The SAME two transforms the round applies, in the same order — repo-context stripping
73
+ then binary elision — so doctor's prediction and `[loop] max_diff_bytes`'s enforcement
74
+ cannot disagree about what "the diff" means. Doctor's row IS the prediction here: it has
75
+ no `run_review` downstream to be authoritative for it, so a false green sends the
76
+ operator on to spend the live auth and producer-commit legs.
77
+ """
78
+ stripped = filter_diff_for_reviewer(diff_text, config.review.strip_repo_context_files)
79
+ return len(elide_binary_hunks(stripped)[0].encode("utf-8"))
80
+
81
+
82
+ def based_diff_classify(
83
+ repo_root: Path,
84
+ config: SyncadeConfig,
85
+ *,
86
+ base_ref: str | None,
87
+ scope: str | None,
88
+ two_dot: bool,
89
+ ) -> str:
90
+ """Classify a based/scoped diff as ``'dispatch'``, ``'no_changes'``, or ``'malformed'``.
91
+
92
+ - ``'dispatch'``: reviewers will run; commit-safety guards apply.
93
+ - ``'no_changes'``: diff is known-empty; real run exits 0 before dispatch, no commit.
94
+ - ``'malformed'``: diff has unidentifiable headers; real run exits 60 (diff_malformed),
95
+ no commit — but this is a failure, not a benign no-op.
96
+ - ``'too_large'``: reviewer-facing diff exceeds ``[loop] max_diff_bytes``; real run
97
+ exits 60 (diff_too_large) before dispatch, no commit. Same shape as malformed.
98
+ - ``'prompt_too_large'``: assembled reviewer prompt exceeds the provider character
99
+ ceiling; real run exits 60 (prompt_too_large) before dispatch, no commit. Rendered
100
+ with a placeholder PR doc ref, so the size is a LOWER BOUND: this classification is
101
+ never wrong when it fires, and its absence promises nothing.
102
+
103
+ Returns ``'dispatch'`` (conservative: guard applies) on any resolution or snapshot error
104
+ — the plan check catches those failures with its own red; the branch check must not
105
+ double-fire."""
106
+ try:
107
+ if scope is not None:
108
+ branch = current_branch_name(repo_root)
109
+ last = read_last_reviewed(repo_root, branch) if branch else None
110
+ base = resolve_scope(repo_root, scope, last_reviewed_sha=last).base_sha
111
+ else:
112
+ base = base_ref
113
+ if base is None:
114
+ return "dispatch" # full-HEAD path (no diff base), always dispatches
115
+ snap = take_snapshot(repo_root, base_ref=base, three_dot=not two_dot)
116
+ except Exception:
117
+ return "dispatch" # cannot determine; be conservative
118
+ if config.review.strip_repo_context_files and unidentifiable_sections(snap.diff_text):
119
+ return "malformed"
120
+ _filtered = filter_diff_for_reviewer(snap.diff_text, config.review.strip_repo_context_files)
121
+ _filtered_elided, _ = elide_binary_hunks(_filtered)
122
+ if len(_filtered_elided.encode("utf-8")) > config.loop.max_diff_bytes:
123
+ return "too_large"
124
+ if (
125
+ snap.base_oid is not None
126
+ and not _filtered_elided
127
+ and not concealed_destinations(snap.diff_text, config.review.strip_repo_context_files)
128
+ ):
129
+ return "no_changes"
130
+ # Prompt-size check: render the full prompt for each reviewer and see if it exceeds
131
+ # the provider character ceiling. Uses the placeholder "<pr-doc>" (same as check_plan
132
+ # when pr_doc_path is unknown), so this can undercount if the template repeats
133
+ # {pr_doc_path} — but without it the branch check would fire for prompt_too_large runs.
134
+ _diff_text = _filtered_elided or (
135
+ "(diff not provided; review against the full repo state at HEAD)"
136
+ )
137
+ _json_schema = get_findings_schema_string()
138
+ for _reviewer in config.reviewers:
139
+ try:
140
+ _tmpl = load_reviewer_template_for(
141
+ repo_root, provider=_reviewer.provider, template=_reviewer.template
142
+ )
143
+ _rendered = render_reviewer_prompt(
144
+ _tmpl,
145
+ pr_doc_path="<pr-doc>",
146
+ diff=_diff_text,
147
+ master_plan_path=None,
148
+ json_schema=_json_schema,
149
+ adversarial_lens=_reviewer.adversarial_lens,
150
+ bug_class_sweep=_reviewer.bug_class_sweep,
151
+ )
152
+ except Exception: # noqa: BLE001 — fail open; check_plan catches template errors
153
+ continue
154
+ if len(_rendered) > _CODEX_CHAR_CEILING:
155
+ return "prompt_too_large"
156
+ return "dispatch"
157
+
158
+
159
+ def based_diff_will_dispatch(
160
+ repo_root: Path,
161
+ config: SyncadeConfig,
162
+ *,
163
+ base_ref: str | None,
164
+ scope: str | None,
165
+ two_dot: bool,
166
+ ) -> bool:
167
+ """Whether a based/scoped diff will dispatch reviewers.
168
+
169
+ Delegates to :func:`based_diff_classify`; ``'dispatch'`` → True, anything else → False."""
170
+ return (
171
+ based_diff_classify(repo_root, config, base_ref=base_ref, scope=scope, two_dot=two_dot)
172
+ == "dispatch"
173
+ )
174
+
175
+
176
+ def check_plan(
177
+ repo_root: Path,
178
+ config: SyncadeConfig,
179
+ *,
180
+ base_ref: str | None,
181
+ scope: str | None,
182
+ two_dot: bool = False,
183
+ max_rounds: int | None,
184
+ ) -> DoctorCheck:
185
+ """Preview the plan a real run would execute (C1): the resolved diff base + its size,
186
+ the exact actor set (producer runs ONLY on NO-SHIP), and the round budget. Resolves the
187
+ base the same way the CLI does — an unresolvable ``--scope`` or a bad ``--base`` is red,
188
+ matching the real run's exit-60 refusal. Read-only git, so inert.
189
+
190
+ **The prompt-size portion is a LOWER BOUND, and does not claim otherwise.** doctor
191
+ cannot know the PR doc: ``--doctor`` is a one-shot mode and the CLI rejects it beside a
192
+ PR_DOC positional, so the reference is rendered as a placeholder. A template that
193
+ repeats ``{pr_doc_path}`` therefore renders longer in the real run than here.
194
+
195
+ That asymmetry is why the check is still worth having: exceeding the ceiling on a lower
196
+ bound means the real prompt certainly exceeds it, so a RED is always a true positive.
197
+ The converse is NOT claimed — a green plan row does not promise the prompt will fit, and
198
+ the real run refuses cheaply before provisioning if it does not. Four dogfood rounds
199
+ tried to make this exact (estimate -> render -> real PR-doc path -> unreachable from the
200
+ CLI); saying what it is beats a fourth attempt at what it cannot be."""
201
+ if base_ref == "":
202
+ return DoctorCheck(
203
+ "plan",
204
+ _RED,
205
+ "--base was provided as an empty string — pass a valid commit ref",
206
+ fix="pass a non-empty --base <ref>, or omit --base for a full-HEAD review",
207
+ )
208
+ effective = max_rounds if max_rounds is not None else config.loop.max_rounds
209
+ will_commit = effective > 1
210
+ try:
211
+ if scope is not None:
212
+ branch = current_branch_name(repo_root)
213
+ last = read_last_reviewed(repo_root, branch) if branch else None
214
+ base = resolve_scope(repo_root, scope, last_reviewed_sha=last).base_sha
215
+ else:
216
+ base = base_ref # may be None: full-HEAD review with no diff base
217
+ except BaseResolutionError as exc:
218
+ return DoctorCheck(
219
+ "plan",
220
+ _RED,
221
+ f"--scope {scope!r} cannot resolve: {exc}",
222
+ fix="pass an explicit --base <ref>",
223
+ )
224
+ try:
225
+ if base is not None:
226
+ snap = take_snapshot(repo_root, base_ref=base, three_dot=not two_dot)
227
+ # A malformed diff exits 60 before dispatch in the real run — surface it as RED
228
+ # so the cheap-red gate skips live spend (auth/producer-commit). Only applies
229
+ # when strip targets are configured, matching the _diff_will_dispatch condition.
230
+ if config.review.strip_repo_context_files and unidentifiable_sections(snap.diff_text):
231
+ return DoctorCheck(
232
+ "plan",
233
+ _RED,
234
+ f"diff against {base!r} has an ambiguous path header"
235
+ " — real run exits 60 (diff_malformed)",
236
+ fix=(
237
+ "use --two-dot if paths contain ' b/';"
238
+ " check strip_repo_context_files config"
239
+ ),
240
+ )
241
+ # The cap refuses before dispatch in the real run — RED, same as malformed,
242
+ # so the cheap-red gate skips the live auth/producer-commit spend.
243
+ _reviewed = reviewer_facing_bytes(snap.diff_text, config)
244
+ if _reviewed > config.loop.max_diff_bytes:
245
+ return DoctorCheck(
246
+ "plan",
247
+ _RED,
248
+ f"reviewer-facing diff is {_reviewed:,} bytes, over "
249
+ f"[loop] max_diff_bytes ({config.loop.max_diff_bytes:,})"
250
+ " — real run exits 60 (diff_too_large)",
251
+ fix=(
252
+ "narrow --base to a smaller range, split the PR, or raise "
253
+ "[loop] max_diff_bytes"
254
+ ),
255
+ )
256
+ # Exact assembled-prompt check: render the full round-0 prompt and measure it.
257
+ # The former template+diff estimate omitted the JSON schema (~517 chars) and the
258
+ # adversarial-lens block (~3,316 chars) — a lower bound that let doctor report OK
259
+ # for a run that exits 60 (prompt_too_large). Fails open on template errors.
260
+ _filtered_text = filter_diff_for_reviewer(
261
+ snap.diff_text, config.review.strip_repo_context_files
262
+ )
263
+ _filtered_text, _ = elide_binary_hunks(_filtered_text)
264
+ _diff_for_render = (
265
+ _filtered_text
266
+ if _filtered_text
267
+ else "(diff not provided; review against the full repo state at HEAD)"
268
+ )
269
+ # A PLACEHOLDER ref, because doctor cannot know the real one: `--doctor` is a
270
+ # one-shot mode and the CLI rejects it alongside a PR_DOC positional. That makes
271
+ # the rendered size a LOWER BOUND — a template repeating `{pr_doc_path}` renders
272
+ # longer in the real run than here.
273
+ #
274
+ # A lower bound is still worth checking, because the error is one-directional:
275
+ # if even the lower bound exceeds the ceiling, the real prompt certainly does, so
276
+ # a RED here is always a true positive. The converse does not hold and is not
277
+ # claimed — passing this check does not promise the run will fit. Same idiom the
278
+ # budget surfaces use ("a strict lower bound"), and the honest alternative to
279
+ # predicting exactly, which four dogfood rounds showed doctor cannot do.
280
+ _pr_doc_ref = "<pr-doc>"
281
+ _json_schema = get_findings_schema_string()
282
+ for _reviewer in config.reviewers:
283
+ try:
284
+ _tmpl = load_reviewer_template_for(
285
+ repo_root, provider=_reviewer.provider, template=_reviewer.template
286
+ )
287
+ _rendered = render_reviewer_prompt(
288
+ _tmpl,
289
+ pr_doc_path=_pr_doc_ref,
290
+ diff=_diff_for_render,
291
+ master_plan_path=None,
292
+ json_schema=_json_schema,
293
+ adversarial_lens=_reviewer.adversarial_lens,
294
+ bug_class_sweep=_reviewer.bug_class_sweep,
295
+ )
296
+ except Exception: # noqa: BLE001 — template errors are checked live
297
+ continue
298
+ _prompt_chars = len(_rendered)
299
+ if _prompt_chars > _CODEX_CHAR_CEILING:
300
+ return DoctorCheck(
301
+ "plan",
302
+ _RED,
303
+ f"assembled prompt for reviewer {_reviewer.name!r} is "
304
+ f"{_prompt_chars:,} chars, over the provider "
305
+ f"ceiling of {_CODEX_CHAR_CEILING:,}"
306
+ " — real run exits 60 (prompt_too_large)",
307
+ fix=(
308
+ "trim the reviewer template in .syncade/templates/, narrow --base, "
309
+ "or lower [loop] max_diff_bytes"
310
+ ),
311
+ )
312
+ files, changed = _diff_size(snap.diff_text)
313
+ # Name the OID the diff was actually taken against, not the ref the
314
+ # operator typed. Under three-dot they differ whenever the branch is
315
+ # behind its base — exactly the phantom-deletion case PR-h-02
316
+ # increment B fixes — so `diff vs main` read as the advanced tip and
317
+ # understated the preview in the one scenario it most matters.
318
+ origin = base if two_dot else f"branch point of {base}"
319
+ actual = snap.base_oid[:7] if snap.base_oid else base
320
+ diffdesc = (
321
+ f"diff from {actual} ({origin}): {files} file(s), {changed} changed line(s), "
322
+ f"{_reviewed:,}B reviewed of {config.loop.max_diff_bytes:,}B allowed"
323
+ )
324
+ else:
325
+ # No-diff-base path still probes snapshot to catch a corrupt git index that
326
+ # would fail the real run's take_snapshot before dispatch.
327
+ take_snapshot(repo_root)
328
+ diffdesc = "no diff base (reviewers see full HEAD)"
329
+ except SnapshotError as exc:
330
+ return DoctorCheck(
331
+ "plan",
332
+ _RED,
333
+ f"cannot diff against base {base!r}: {exc}",
334
+ fix="pass a valid --base <ref>",
335
+ )
336
+ actors = f"{len(config.reviewers)} reviewer(s) + judge"
337
+ if will_commit:
338
+ actors += ", producer on non-final NO-SHIP"
339
+ if config.loop.test_command:
340
+ actors += ", test-leg"
341
+ if config.checks:
342
+ actors += f", {len(config.checks)} check(s)"
343
+ return DoctorCheck("plan", _OK, f"{diffdesc}; {actors}; up to {effective} round(s)")
344
+
345
+
346
+ def _review_actors(config: SyncadeConfig) -> list:
347
+ """The every-round actors whose cost the estimate is built from: each reviewer + the
348
+ judge. The producer is excluded (it runs only on NO-SHIP rounds and is noted separately);
349
+ the drafter/auditor belong to other modes and never run here."""
350
+ return [*config.reviewers, config.synthesizer]
351
+
352
+
353
+ def _unpriced_models(config: SyncadeConfig) -> list[str]:
354
+ """Every-round models with no ``[pricing]`` entry, first-seen order. Their cost reads as
355
+ *unknown*, never $0 — the PR-v2-24 valuation-honesty ethic applied forward."""
356
+ unpriced: list[str] = []
357
+ for actor in _review_actors(config):
358
+ if actor.model not in unpriced and config.pricing.price_for(actor.model) is None:
359
+ unpriced.append(actor.model)
360
+ return unpriced
361
+
362
+
363
+ def _coarse_round_estimate(config: SyncadeConfig) -> float | None:
364
+ """List-price cost of ONE reviewers + judge round at nominal per-actor token volumes, or
365
+ ``None`` if any every-round actor is unpriced (then no honest figure exists)."""
366
+ total = 0.0
367
+ for actor in _review_actors(config):
368
+ price = config.pricing.price_for(actor.model)
369
+ if price is None:
370
+ return None
371
+ total += (
372
+ _NOMINAL_INPUT_TOK / 1e6 * price.input_per_mtok
373
+ + _NOMINAL_OUTPUT_TOK / 1e6 * price.output_per_mtok
374
+ )
375
+ return total
376
+
377
+
378
+ def _planned_roster(config: SyncadeConfig) -> Counter:
379
+ """The planned every-round roster as a multiset of ``(role, model)`` — each reviewer's
380
+ model plus the judge's. A historical run's cost is comparable only if its own per-round
381
+ roster matches this exactly."""
382
+ roster: Counter = Counter()
383
+ for reviewer in config.reviewers:
384
+ roster[("reviewer", reviewer.model)] += 1
385
+ roster[("synthesizer", config.synthesizer.model)] += 1
386
+ return roster
387
+
388
+
389
+ def _reviewer_judge_per_round(
390
+ runs, actor_stats, *, expected_roster: Counter
391
+ ) -> tuple[float | None, int]:
392
+ """(reviewers + judge cost per round, n matching runs) from history, or ``(None, 0)``.
393
+
394
+ Sums only the :data:`_PER_ROUND_ROLES` actors (they run every round; the producer is
395
+ excluded). A run is counted only when its per-round ``(role, model)`` multiset EXACTLY
396
+ matches ``expected_roster`` (the planned reviewers + judge). Matching on identity — not a
397
+ bare actor COUNT — is what excludes:
398
+
399
+ - unrelated old-model history (a run whose roster changed models — e.g. a prior gpt-5.6
400
+ panel counted toward a gpt-5.5 estimate),
401
+ - partial coverage (a run missing a planned reviewer),
402
+ - a duplicate reviewer row standing in for a missing different-model one, and
403
+ - a reviewer-only run with no judge (its multiset lacks the ``synthesizer`` entry).
404
+
405
+ Runs with any unknown-cost (``cost_usd is None``) or incomplete-token
406
+ (``cost_incomplete_tokens > 0``) per-round actor are also excluded, so the figure is never
407
+ a falsely-precise number built on missing data.
408
+ """
409
+ rounds = {r.run_id: r.rounds_executed for r in runs}
410
+ per_run: dict[str, list] = {} # run_id -> [cost_sum, has_incomplete, roster_counter]
411
+ for actor in actor_stats:
412
+ if actor.role not in _PER_ROUND_ROLES:
413
+ continue
414
+ entry = per_run.setdefault(actor.run_id, [0.0, False, Counter()])
415
+ entry[2][(actor.role, actor.model)] += 1
416
+ if actor.cost_usd is None:
417
+ entry[1] = True # unknown cost is incomplete, not $0
418
+ else:
419
+ entry[0] += actor.cost_usd
420
+ if actor.cost_incomplete_tokens > 0:
421
+ entry[1] = True
422
+ # If this actor has per-round usage tracking and appeared in fewer rounds than the
423
+ # run executed, the run's cost average would be understated (the missing rounds are
424
+ # treated as $0 contribution). Exclude such runs. Legacy rows (rounds_with_usage=0)
425
+ # bypass this check so pre-schema-12 history is not silently dropped.
426
+ expected = rounds.get(actor.run_id, 0)
427
+ if actor.rounds_with_usage > 0 and actor.rounds_with_usage != expected:
428
+ entry[1] = True
429
+ total_cost = 0.0
430
+ total_rounds = 0
431
+ n = 0
432
+ for run_id, (cost, incomplete, roster) in per_run.items():
433
+ executed = rounds.get(run_id, 0)
434
+ if incomplete or executed <= 0 or roster != expected_roster:
435
+ continue
436
+ total_cost += cost
437
+ total_rounds += executed
438
+ n += 1
439
+ if not n:
440
+ return None, 0
441
+ return total_cost / total_rounds, n
442
+
443
+
444
+ def check_cost(config: SyncadeConfig, repo_root: Path, *, max_rounds: int | None) -> DoctorCheck:
445
+ """Forward cost estimate for the PLANNED run (C4), scaled by the round budget. Built from
446
+ the reviewers + judge cost-per-round in the local corpus (they run every round); the
447
+ producer is NOT folded in (it runs only on NO-SHIP rounds) — its extra cost is noted for a
448
+ committing run. With no clean history, falls back to a VERY ROUGH list-price estimate.
449
+ Always green — a preview must not gate a run — and reads an IN-MEMORY DB so the on-disk
450
+ ``metrics.db`` is never written (inert). Any every-round model absent from ``[pricing]``
451
+ is named, never silently $0. Costs are an API-equivalent VALUATION, not billed money."""
452
+ effective = max_rounds if max_rounds is not None else config.loop.max_rounds
453
+ producer_note = "; the producer adds more on non-final NO-SHIP rounds" if effective > 1 else ""
454
+ unpriced = _unpriced_models(config)
455
+ if effective > 1:
456
+ # Producer can run in loop mode — name it if its model is also unpriced.
457
+ pmodel = config.producer.model
458
+ if pmodel not in unpriced and config.pricing.price_for(pmodel) is None:
459
+ unpriced.append(pmodel)
460
+ note = (
461
+ f"; unpriced model(s): {', '.join(unpriced)} (cost would read unknown)" if unpriced else ""
462
+ )
463
+ try:
464
+ conn = open_db(":memory:") # ephemeral: reads the corpus, writes no file
465
+ try:
466
+ backfill(conn, repo_root / ".syncade" / "runs")
467
+ runs = fetch_runs(conn)
468
+ actor_stats = fetch_actor_stats(conn)
469
+ finally:
470
+ conn.close()
471
+ except (sqlite3.Error, OSError) as exc:
472
+ return DoctorCheck("cost", _OK, f"metrics unavailable ({exc}); no forward estimate{note}")
473
+ per_round, n = _reviewer_judge_per_round(
474
+ runs, actor_stats, expected_roster=_planned_roster(config)
475
+ )
476
+ if per_round is not None:
477
+ return DoctorCheck(
478
+ "cost",
479
+ _OK,
480
+ f"~${per_round * effective:.2f} for up to {effective} round(s) — reviewers + judge "
481
+ f"(est. ${per_round:.2f}/round over {n} prior run(s), API-equivalent; ~$0 marginal "
482
+ f"on a subscription{producer_note}){note}",
483
+ )
484
+ coarse = _coarse_round_estimate(config)
485
+ if coarse is None:
486
+ return DoctorCheck(
487
+ "cost",
488
+ _OK,
489
+ f"no clean run history and an unpriced model — cannot estimate{note}",
490
+ )
491
+ nominal = coarse * effective
492
+ # Coarse range: floor(0.5×) to ceil(3×), minimum $1 for the low bound.
493
+ # A single two-decimal amount falsely implies precision we don't have without history.
494
+ low = max(1, math.floor(nominal * 0.5))
495
+ high = max(low + 1, math.ceil(nominal * 3.0))
496
+ return DoctorCheck(
497
+ "cost",
498
+ _OK,
499
+ f"~${low}–${high} for up to {effective} round(s) — reviewers + judge (VERY "
500
+ f"ROUGH: no local history, coarse range at list price ~{_NOMINAL_INPUT_TOK // 1000}K in/"
501
+ f"{_NOMINAL_OUTPUT_TOK // 1000}K out per actor{producer_note}){note}",
502
+ )
503
+
504
+
505
+ def check_budget(config: SyncadeConfig) -> DoctorCheck:
506
+ """Active token/dollar ceiling preview (PR-h-field-06 item 1).
507
+
508
+ Named in ``--doctor`` output so a first-run operator knows the default stop condition is
509
+ active and how to opt out. Always green — a ceiling is not an error. Reports both
510
+ ceiling types when both are configured."""
511
+ parts = []
512
+ if config.loop.budget_tokens:
513
+ parts.append(
514
+ f"token ceiling: {config.loop.budget_tokens:,} "
515
+ "(set `[loop] budget_tokens = 0` to disable)"
516
+ )
517
+ if config.loop.budget_usd:
518
+ parts.append(
519
+ f"cost ceiling: ${config.loop.budget_usd:.2f} "
520
+ "(API-equivalent; not billed money on a subscription)"
521
+ )
522
+ if parts:
523
+ return DoctorCheck("budget", _OK, "; ".join(parts))
524
+ return DoctorCheck("budget", _OK, "no token or cost ceiling configured")
@@ -0,0 +1,28 @@
1
+ """Shared types for ``syncade --doctor`` (PR-v2-12).
2
+
3
+ A LEAF module so the check *engine* (:mod:`syncade.doctor`) and the run-plan + cost *preview*
4
+ (:mod:`syncade.doctor_preview`) can both build :class:`DoctorCheck` rows without an import
5
+ cycle (doctor imports doctor_preview for the preview checks; both import this).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ _OK = "ok"
13
+ _RED = "red"
14
+ _SKIP = "skip"
15
+
16
+ _STATUS_GLYPH = {_OK: "✓", _RED: "✗", _SKIP: "–"} # ✓ ✗ –
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class DoctorCheck:
21
+ """One preflight check's outcome. ``status`` is one of ``ok`` / ``red`` / ``skip``;
22
+ only ``red`` fails doctor's exit code (a ``skip`` means doctor could not run the check,
23
+ not that it passed). ``fix`` is an operator-facing remediation shown under a red row."""
24
+
25
+ name: str
26
+ status: str
27
+ detail: str
28
+ fix: str | None = None
syncade/exit_codes.py ADDED
@@ -0,0 +1,82 @@
1
+ """Exit codes for the ``syncade`` CLI.
2
+
3
+ These integer values are the CLI contract:
4
+
5
+ SUCCESS = 0
6
+ Clean run. With ``[loop] test_command`` unset, the synthesizer found no
7
+ active blockers. With ``test_command`` set, the clean worktree test command
8
+ also exited 0.
9
+
10
+ CLI_USAGE_ERROR = 2
11
+ Invalid command shape or argument syntax, matching argparse's usage-error
12
+ convention.
13
+
14
+ CLARIFICATION_NEEDED = 10
15
+ Clarification needed. Three producers, all of which write an operator-facing
16
+ ``decision-needed.md`` (except the first, which is its own report):
17
+
18
+ - ``--spec-audit`` found blocking ambiguity in the brief;
19
+ - a producer escalation covering every active blocker;
20
+ - **two or more distinct reviewers each raised a blocker and the
21
+ synthesizer deactivated every one of them** (PR-h-01 increment D). Not a
22
+ defect claim — independent corroboration is the strongest signal syncade
23
+ produces, and discarding all of it may be right but is not a call a
24
+ machine should make silently. The document quotes each reviewer verbatim
25
+ next to what the synthesizer did with it.
26
+
27
+ MAX_ROUNDS_REACHED = 20
28
+ Max rounds reached before convergence. Latest findings are written for the
29
+ user to inspect.
30
+
31
+ BUDGET_EXCEEDED = 25
32
+ The per-run token/dollar ceiling (``--budget-tokens`` / ``--budget-usd`` or
33
+ the ``[loop]`` twins) was crossed at a dispatch boundary, so the loop aborted
34
+ before spending more. Distinct from 20 so a caller can tell "ran out of
35
+ rounds" (raise ``--max-rounds``) from "hit the cost ceiling" (raise the
36
+ budget). Whatever findings the completed rounds produced are preserved. The
37
+ budget gates FURTHER dispatch only: a terminal round keeps its own verdict —
38
+ a converged SHIP stays 0, a max-rounds/single-pass NO-SHIP stays 20/30 — and
39
+ is never relabeled 25, so this code means "stopped early to save spend," not
40
+ "a finished run happened to cost a lot."
41
+
42
+ The code is SHARED with a second cause the operator did not configure: a
43
+ provider refusing to serve because the account's usage window is exhausted
44
+ (``termination_reason="provider_usage_limit"``). Both stop cleanly at a phase
45
+ boundary and both resume, which is why they share a code — but they are told
46
+ apart by ``termination_reason``, and they want opposite responses: raise the
47
+ ceiling versus wait for the window to reset. Anything reporting this code to a
48
+ person should read the reason rather than assume the budget.
49
+
50
+ FINDINGS_PRESENT = 30
51
+ Findings present. A synthesizer blocker remains, the configured test command
52
+ failed, or producer/branch promotion could not complete safely.
53
+
54
+ REVIEWER_FAILURE = 40
55
+ Reviewer OR synthesizer subprocess failure. This also covers producer and
56
+ test-leg subprocess failures such as missing binaries, timeouts, process
57
+ crashes, provider/network errors, and model refusals.
58
+
59
+ CONFIG_ERROR = 50
60
+ Configuration error. ``.syncade/config.toml`` failed to parse or failed
61
+ schema validation.
62
+
63
+ WORKTREE_ERROR = 60
64
+ Worktree provisioning error. ``git worktree add`` or equivalent setup failed
65
+ before the requested phase could run.
66
+
67
+ REVIEWER_OUTPUT_UNPARSEABLE = 70
68
+ Reviewer OR synthesizer output unparseable. The raw response is preserved in
69
+ the matching ``.stdout`` artifact and the parse exception is preserved in the
70
+ matching ``.error.txt`` artifact.
71
+ """
72
+
73
+ SUCCESS: int = 0
74
+ CLI_USAGE_ERROR: int = 2
75
+ CLARIFICATION_NEEDED: int = 10
76
+ MAX_ROUNDS_REACHED: int = 20
77
+ BUDGET_EXCEEDED: int = 25
78
+ FINDINGS_PRESENT: int = 30
79
+ REVIEWER_FAILURE: int = 40
80
+ CONFIG_ERROR: int = 50
81
+ WORKTREE_ERROR: int = 60
82
+ REVIEWER_OUTPUT_UNPARSEABLE: int = 70