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,369 @@
1
+ """Pre-dispatch early-exit results for the known-empty and fail-closed diff cases.
2
+
3
+ Split from :mod:`.round` to keep that module under the LOC cap.
4
+
5
+ D1/D3 (PR-h-02d): when ``snapshot.base_oid`` is set but the reviewer-facing diff
6
+ is empty (nothing changed, or all changed files were legitimate repo-context),
7
+ terminate before any subprocess is dispatched.
8
+
9
+ D2 (PR-h-02d): when ``filter_diff_for_reviewer`` dropped sections fail-closed
10
+ (headers that cannot be parsed or decoded), refuse the run (exit 60) rather than
11
+ treating "unreadable change" as "no change".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+
19
+ from syncade.config import SyncadeConfig
20
+ from syncade.dispatcher import DispatchResult
21
+ from syncade.exit_codes import SUCCESS, WORKTREE_ERROR
22
+ from syncade.logging import Logger
23
+ from syncade.persistence import persist_round_manifest, persist_run_summary
24
+ from syncade.snapshot import Snapshot
25
+
26
+ from .results import RoundArtifacts, RoundResult
27
+
28
+
29
+ def _no_changes_to_review_result(
30
+ *,
31
+ round_idx: int,
32
+ snapshot: Snapshot,
33
+ round_dir: Path,
34
+ config: SyncadeConfig,
35
+ started_at: datetime,
36
+ resumed_under_drift: bool,
37
+ ) -> RoundResult:
38
+ """Terminal result for a run whose reviewer-facing diff is empty (D1/D3, PR-h-02d).
39
+
40
+ ``snapshot.base_oid`` is set (a real base resolved) but the diff filtered to
41
+ empty — either no files changed, or every section was legitimate repo-context
42
+ stripped by the filter. Zero subprocesses are dispatched.
43
+
44
+ The round exit code is ``SUCCESS``, NOT a private sentinel. An earlier cut used
45
+ a sentinel ``1`` so the loop could distinguish this from a real SHIP, but ``1``
46
+ is not in ``_EXIT_CODE_LABELS`` — so the durable artifacts rendered the round as
47
+ ``Exit code: 1 (UNKNOWN)`` and ``ERROR (exit 1)`` while the top-level API
48
+ correctly reported exit 0. The distinction is carried by
49
+ ``termination_reason="no_changes_to_review"``, which is what the renderers key
50
+ on; the exit code does not need to encode it twice.
51
+ """
52
+ raw_diff_bytes = len((snapshot.diff_text or "").encode("utf-8"))
53
+ empty_dispatch = DispatchResult(results=[], total_duration_seconds=0.0)
54
+ persist_round_manifest(
55
+ round_dir,
56
+ snapshot,
57
+ empty_dispatch,
58
+ SUCCESS,
59
+ started_at,
60
+ None,
61
+ None,
62
+ None,
63
+ round_idx=round_idx,
64
+ producer_result=None,
65
+ producer_provider=config.producer.provider,
66
+ producer_model=config.producer.model,
67
+ check_results=[],
68
+ filtered_diff_bytes=0,
69
+ raw_diff_bytes=raw_diff_bytes,
70
+ )
71
+ persist_run_summary(
72
+ round_dir,
73
+ snapshot,
74
+ empty_dispatch,
75
+ SUCCESS,
76
+ started_at,
77
+ None,
78
+ None,
79
+ None,
80
+ resumed_under_drift=resumed_under_drift,
81
+ check_results=[],
82
+ no_changes_to_review=True,
83
+ )
84
+ artifacts = RoundArtifacts(
85
+ round_idx=round_idx,
86
+ round_dir=round_dir,
87
+ manifest_path=round_dir / "manifest.json",
88
+ summary_path=round_dir / "summary.md",
89
+ )
90
+ return RoundResult(
91
+ round_idx=round_idx,
92
+ snapshot=snapshot,
93
+ dispatch_result=empty_dispatch,
94
+ synth_result=None,
95
+ test_result=None,
96
+ test_skip_reason=None,
97
+ test_worktree_error=None,
98
+ producer_result=None,
99
+ round_exit_code=SUCCESS,
100
+ artifacts=artifacts,
101
+ check_results=[],
102
+ no_changes_to_review=True,
103
+ filtered_diff_bytes=0,
104
+ raw_diff_bytes=raw_diff_bytes,
105
+ )
106
+
107
+
108
+ def _fail_closed_refusal_result(
109
+ *,
110
+ round_idx: int,
111
+ snapshot: Snapshot,
112
+ round_dir: Path,
113
+ config: SyncadeConfig,
114
+ started_at: datetime,
115
+ resumed_under_drift: bool,
116
+ unidentifiable: list[str],
117
+ logger: Logger,
118
+ ) -> RoundResult:
119
+ """Refusal result for a diff with sections that cannot be identified (D2, PR-h-02d).
120
+
121
+ ``filter_diff_for_reviewer`` dropped one or more sections fail-closed because their
122
+ headers could not be parsed or decoded. Treating such a diff as "nothing to review"
123
+ would ship a change BECAUSE we could not read it — the exact false-SHIP path
124
+ PR-h-01 closed, reintroduced through a different door. Exit 60 (WORKTREE_ERROR)
125
+ names the dropped headers and refuses before any subprocess is dispatched.
126
+ """
127
+ headers = "\n ".join(unidentifiable)
128
+ logger.event(
129
+ f"refusing run: {len(unidentifiable)} diff section(s) had unidentifiable headers "
130
+ f"(fail-closed per D2 — cannot determine whether they are repo-context or real change); "
131
+ f"unidentifiable headers:\n {headers}",
132
+ error=True,
133
+ )
134
+ # Write a durable named artifact so the refusal cause is preserved after the
135
+ # run completes. manifest.json also carries diff_filter_refusal_headers for
136
+ # machine-readable access.
137
+ refused_path = round_dir / "diff-refused.txt"
138
+ refused_path.write_text(
139
+ f"Syncade refused this run (exit 60, diff_malformed).\n\n"
140
+ f"{len(unidentifiable)} diff section(s) could not be identified "
141
+ f"(D2, PR-h-02d):\n\n"
142
+ + "\n".join(f" {h}" for h in unidentifiable)
143
+ + "\n\nSee summary.md for next steps.\n",
144
+ encoding="utf-8",
145
+ )
146
+ return _persist_refusal(
147
+ round_idx=round_idx,
148
+ snapshot=snapshot,
149
+ round_dir=round_dir,
150
+ config=config,
151
+ started_at=started_at,
152
+ resumed_under_drift=resumed_under_drift,
153
+ fail_closed_headers=unidentifiable,
154
+ oversize_diff_bytes=None,
155
+ )
156
+
157
+
158
+ def _persist_refusal(
159
+ *,
160
+ round_idx: int,
161
+ snapshot: Snapshot,
162
+ round_dir: Path,
163
+ config: SyncadeConfig,
164
+ started_at: datetime,
165
+ resumed_under_drift: bool,
166
+ fail_closed_headers: list[str] | None,
167
+ oversize_diff_bytes: int | None,
168
+ oversize_prompt_chars: int | None = None,
169
+ ) -> RoundResult:
170
+ """Shared tail for every before-dispatch refusal: exit 60, zero reviewers, artifacts.
171
+
172
+ The three refusals differ only in WHY (which discriminator is set) — everything after
173
+ that is identical, and duplicating it is how the three would drift apart.
174
+ """
175
+ if fail_closed_headers is not None:
176
+ _refusal_reason = "diff_malformed"
177
+ elif oversize_diff_bytes is not None:
178
+ _refusal_reason = "diff_too_large"
179
+ elif oversize_prompt_chars is not None:
180
+ _refusal_reason = "prompt_too_large"
181
+ else:
182
+ _refusal_reason = None
183
+ empty_dispatch = DispatchResult(results=[], total_duration_seconds=0.0)
184
+ persist_round_manifest(
185
+ round_dir,
186
+ snapshot,
187
+ empty_dispatch,
188
+ WORKTREE_ERROR,
189
+ started_at,
190
+ None,
191
+ None,
192
+ None,
193
+ round_idx=round_idx,
194
+ producer_result=None,
195
+ producer_provider=config.producer.provider,
196
+ producer_model=config.producer.model,
197
+ check_results=[],
198
+ diff_filter_refusal_headers=fail_closed_headers,
199
+ # Pass measured sizes when they are known (diff_too_large path).
200
+ # The "only the path that measured it writes it" rule from PR-h-03.5 applies:
201
+ # fail_closed_headers path has no measured bytes (we refuse before measuring),
202
+ # so filtered_diff_bytes stays None there.
203
+ filtered_diff_bytes=oversize_diff_bytes,
204
+ raw_diff_bytes=(
205
+ len((snapshot.diff_text or "").encode("utf-8"))
206
+ if oversize_diff_bytes is not None
207
+ else None
208
+ ),
209
+ oversize_prompt_chars=oversize_prompt_chars,
210
+ refusal_reason=_refusal_reason,
211
+ )
212
+ persist_run_summary(
213
+ round_dir,
214
+ snapshot,
215
+ empty_dispatch,
216
+ WORKTREE_ERROR,
217
+ started_at,
218
+ None,
219
+ None,
220
+ None,
221
+ resumed_under_drift=resumed_under_drift,
222
+ check_results=[],
223
+ fail_closed_headers=fail_closed_headers,
224
+ oversize_diff_bytes=oversize_diff_bytes,
225
+ oversize_prompt_chars=oversize_prompt_chars,
226
+ )
227
+ artifacts = RoundArtifacts(
228
+ round_idx=round_idx,
229
+ round_dir=round_dir,
230
+ manifest_path=round_dir / "manifest.json",
231
+ summary_path=round_dir / "summary.md",
232
+ )
233
+ return RoundResult(
234
+ round_idx=round_idx,
235
+ snapshot=snapshot,
236
+ dispatch_result=empty_dispatch,
237
+ synth_result=None,
238
+ test_result=None,
239
+ test_skip_reason=None,
240
+ test_worktree_error=None,
241
+ producer_result=None,
242
+ round_exit_code=WORKTREE_ERROR,
243
+ artifacts=artifacts,
244
+ check_results=[],
245
+ fail_closed_headers=fail_closed_headers,
246
+ oversize_diff_bytes=oversize_diff_bytes,
247
+ oversize_prompt_chars=oversize_prompt_chars,
248
+ # Mirror the sizes passed to persist_round_manifest so persist_loop_manifest
249
+ # (which reads from RoundResult, not from the on-disk manifest) also carries
250
+ # them. Only populated on the diff_too_large path where they were measured.
251
+ filtered_diff_bytes=oversize_diff_bytes,
252
+ raw_diff_bytes=(
253
+ len((snapshot.diff_text or "").encode("utf-8"))
254
+ if oversize_diff_bytes is not None
255
+ else None
256
+ ),
257
+ )
258
+
259
+
260
+ def _diff_too_large_result(
261
+ *,
262
+ round_idx: int,
263
+ snapshot: Snapshot,
264
+ round_dir: Path,
265
+ config: SyncadeConfig,
266
+ started_at: datetime,
267
+ resumed_under_drift: bool,
268
+ diff_bytes: int,
269
+ logger: Logger,
270
+ ) -> RoundResult:
271
+ """Refusal for a reviewer-facing diff over ``[loop] max_diff_bytes`` (PR-h-02e D2).
272
+
273
+ Refuses rather than truncating: a verdict on a deliberately partial diff is a verdict
274
+ on the wrong code, which is the failure PR-h-02 exists to prevent. Refuses rather than
275
+ warning-and-proceeding: that is today's behaviour with extra text.
276
+
277
+ The point of refusing HERE is that the alternative is not "no cap" but an opaque
278
+ provider error after the operator has already paid for the run — measured, `codex exec`
279
+ returns `input_too_large` naming only a character count, and before PR-h-field-01 item 1 the
280
+ same input produced a bare `[Errno 7] Argument list too long` with no cause at all.
281
+ """
282
+ cap = config.loop.max_diff_bytes
283
+ over = diff_bytes - cap
284
+ logger.event(
285
+ f"refusing run: the reviewer-facing diff is {diff_bytes:,} bytes, over the "
286
+ f"[loop] max_diff_bytes ceiling of {cap:,} by {over:,}. Narrow --base, split the "
287
+ f"PR, or raise the ceiling. Refused before dispatching any reviewer, so nothing "
288
+ f"was spent.",
289
+ error=True,
290
+ )
291
+ (round_dir / "diff-refused.txt").write_text(
292
+ f"Syncade refused this run (exit 60, diff_too_large).\n\n"
293
+ f"Reviewer-facing diff: {diff_bytes:,} bytes\n"
294
+ f"[loop] max_diff_bytes: {cap:,} bytes\n"
295
+ f"Over by: {over:,} bytes\n\n"
296
+ f"This is measured AFTER repo-context stripping and binary elision — it is what a "
297
+ f"reviewer would actually be handed.\n\n"
298
+ f"Next steps: narrow --base to a smaller range, split the PR, or raise "
299
+ f"[loop] max_diff_bytes in .syncade/config.toml.\n",
300
+ encoding="utf-8",
301
+ )
302
+ return _persist_refusal(
303
+ round_idx=round_idx,
304
+ snapshot=snapshot,
305
+ round_dir=round_dir,
306
+ config=config,
307
+ started_at=started_at,
308
+ resumed_under_drift=resumed_under_drift,
309
+ fail_closed_headers=None,
310
+ oversize_diff_bytes=diff_bytes,
311
+ )
312
+
313
+
314
+ # Provider character ceilings, measured live (PR-h-field-01 item 1). The codex ceiling
315
+ # (1,048,576 chars) is the tighter of the two known limits, so it governs cross-provider
316
+ # checks. A prompt below this is safe for both providers currently in use.
317
+ _CODEX_CHAR_CEILING = 1_048_576
318
+
319
+
320
+ def _assembled_prompt_too_large_result(
321
+ *,
322
+ round_idx: int,
323
+ snapshot: Snapshot,
324
+ round_dir: Path,
325
+ config: SyncadeConfig,
326
+ started_at: datetime,
327
+ resumed_under_drift: bool,
328
+ reviewer_name: str,
329
+ prompt_chars: int,
330
+ logger: Logger,
331
+ ) -> RoundResult:
332
+ """Refusal when an assembled reviewer prompt exceeds the provider's character ceiling.
333
+
334
+ The diff-size cap ([loop] max_diff_bytes) gates on the reviewer-facing diff, but a
335
+ large reviewer template or prior-round context can push the full assembled prompt over
336
+ the provider limit while the diff alone stays under the cap. This refuses before
337
+ dispatch so the operator gets syncade's actionable message rather than the provider's
338
+ opaque `input_too_large` error after paying for the round.
339
+ """
340
+ over = prompt_chars - _CODEX_CHAR_CEILING
341
+ logger.event(
342
+ f"refusing run: the assembled prompt for reviewer {reviewer_name!r} is "
343
+ f"{prompt_chars:,} chars, over the provider ceiling of {_CODEX_CHAR_CEILING:,} "
344
+ f"by {over:,}. Trim the reviewer template or reduce prior-round context. "
345
+ f"Refused before dispatching any reviewer, so nothing was spent.",
346
+ error=True,
347
+ )
348
+ (round_dir / "diff-refused.txt").write_text(
349
+ f"Syncade refused this run (exit 60, prompt_too_large).\n\n"
350
+ f"Assembled prompt for {reviewer_name!r}: {prompt_chars:,} chars\n"
351
+ f"Provider ceiling (codex): {_CODEX_CHAR_CEILING:,} chars\n"
352
+ f"Over by: {over:,} chars\n\n"
353
+ f"The assembled prompt includes the diff, the reviewer template, and any "
354
+ f"prior-round context. Reduce prompt size by narrowing --base, trimming the "
355
+ f"reviewer template, or lowering [loop] max_diff_bytes so the diff contributes "
356
+ f"fewer characters.\n",
357
+ encoding="utf-8",
358
+ )
359
+ return _persist_refusal(
360
+ round_idx=round_idx,
361
+ snapshot=snapshot,
362
+ round_dir=round_dir,
363
+ config=config,
364
+ started_at=started_at,
365
+ resumed_under_drift=resumed_under_drift,
366
+ fail_closed_headers=None,
367
+ oversize_diff_bytes=None,
368
+ oversize_prompt_chars=prompt_chars,
369
+ )
@@ -0,0 +1,212 @@
1
+ """Every gate a round passes BEFORE it is allowed to spend anything.
2
+
3
+ Split out of ``round.py`` (PR-h-field-01): `_run_one_round` had grown to 388 code LOC and a
4
+ reviewer flagged it in all five dogfood rounds, tying it to "coordination failures around
5
+ prompt rendering, guard ordering, and persistence". That diagnosis was right, and the
6
+ coupling is the point of this module — **every way a round can refuse before dispatching now
7
+ lives in one place, in priority order**, next to the result constructors in
8
+ ``round_no_changes``.
9
+
10
+ The order is load-bearing and is the reason these belong together:
11
+
12
+ 1. **unidentifiable headers** (D2) before **known-empty** (D1/D3) — a diff with both is a
13
+ refusal, not a no-op: we could not read it, so we cannot ship it.
14
+ 2. **known-empty** before the size gates — nothing to review beats too much to review.
15
+ 3. **diff size** before **prompt render** — no point rendering a prompt for a diff already
16
+ over the cap.
17
+ 4. **prompt size** last, because it is the only gate that needs the rendered prompt.
18
+
19
+ Everything here runs before any worktree is provisioned, so a refusal costs nothing. That
20
+ is the whole claim: five dogfood rounds' worth of blockers were about a refusal reaching
21
+ this point correctly and being reported honestly.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+
30
+ from syncade.config import SyncadeConfig
31
+ from syncade.diff_filter import (
32
+ concealed_destinations,
33
+ elide_binary_hunks,
34
+ filter_diff_for_reviewer,
35
+ unidentifiable_sections,
36
+ )
37
+ from syncade.logging import Logger
38
+ from syncade.snapshot import Snapshot
39
+
40
+ from .results import RoundResult
41
+ from .reviewer_template_failure import _reviewer_template_failure_result
42
+ from .round_no_changes import (
43
+ _CODEX_CHAR_CEILING,
44
+ _assembled_prompt_too_large_result,
45
+ _diff_too_large_result,
46
+ _fail_closed_refusal_result,
47
+ _no_changes_to_review_result,
48
+ )
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ReadyToDispatch:
53
+ """The pre-dispatch gates passed; here is what dispatch needs."""
54
+
55
+ prompt_arg: str | dict[str, str]
56
+ pr_doc_ref: str
57
+ pr_doc_is_out_of_repo: bool
58
+ filtered_for_check: str
59
+
60
+
61
+ def run_predispatch_gates(
62
+ *,
63
+ repo_root: Path,
64
+ snapshot: Snapshot,
65
+ config: SyncadeConfig,
66
+ round_idx: int,
67
+ run_dir: Path,
68
+ round_dir: Path,
69
+ pr_doc_path: Path,
70
+ started_at: datetime,
71
+ resumed_under_drift: bool,
72
+ logger: Logger,
73
+ build_prompt,
74
+ ) -> RoundResult | ReadyToDispatch:
75
+ """Run every pre-dispatch gate. A :class:`RoundResult` means REFUSED; otherwise proceed.
76
+
77
+ ``build_prompt`` is injected rather than imported to keep this module free of a cycle
78
+ back into ``round``; it is ``round._build_reviewer_prompt``.
79
+ """
80
+ # --- Pre-dispatch diff checks (PR-h-02d) ------------------------
81
+ # Both checks run before any worktree is provisioned — zero subprocesses
82
+ # is the whole point (acceptance claim 1). Priority: D2 (unidentifiable)
83
+ # before D1/D3 (known-empty), because a diff with BOTH conditions is a
84
+ # refusal, not a no-changes exit (we could not read it → cannot ship it).
85
+ # Unidentifiable headers are only a problem when strip_repo_context_files is
86
+ # non-empty: with an empty strip list every section is kept byte-for-byte, so
87
+ # an undecodable header cannot hide any change from the reviewer.
88
+ _unidentifiable = (
89
+ unidentifiable_sections(snapshot.diff_text)
90
+ if config.review.strip_repo_context_files
91
+ else []
92
+ )
93
+ if _unidentifiable:
94
+ return _fail_closed_refusal_result(
95
+ round_idx=round_idx,
96
+ snapshot=snapshot,
97
+ round_dir=round_dir,
98
+ config=config,
99
+ started_at=started_at,
100
+ resumed_under_drift=resumed_under_drift,
101
+ unidentifiable=_unidentifiable,
102
+ logger=logger,
103
+ )
104
+ _filtered_for_check, _elided_binaries = elide_binary_hunks(
105
+ filter_diff_for_reviewer(snapshot.diff_text, config.review.strip_repo_context_files)
106
+ )
107
+ if _elided_binaries:
108
+ _shown = ", ".join(_elided_binaries[:5])
109
+ _more = f", … ({len(_elided_binaries) - 5} more)" if len(_elided_binaries) > 5 else ""
110
+ logger.event(
111
+ f"omitted binary file content from the reviewer diff: "
112
+ f"{len(_elided_binaries)} file(s) — {_shown}{_more}. "
113
+ f"Paths and withheld byte counts are disclosed in the diff itself."
114
+ )
115
+ # A drop is only legitimate when the DESTINATION is a strip target. A boundary
116
+ # rename (`git mv CLAUDE.md app.py`) empties the filtered diff while concealing a
117
+ # path a reviewer must see — concluding "nothing to review" there is a false SHIP
118
+ # over a real change (PR-h-02c.5).
119
+ _concealed = concealed_destinations(snapshot.diff_text, config.review.strip_repo_context_files)
120
+ if snapshot.base_oid is not None and not _filtered_for_check and not _concealed:
121
+ # D1: base resolved + empty diff (no real changes).
122
+ # D3: all sections were legitimate repo-context — same outcome.
123
+ logger.event(
124
+ "diff is empty after filtering — base resolved but no reviewable changes; "
125
+ "terminating without dispatching reviewers (no_changes_to_review)"
126
+ )
127
+ return _no_changes_to_review_result(
128
+ round_idx=round_idx,
129
+ snapshot=snapshot,
130
+ round_dir=round_dir,
131
+ config=config,
132
+ started_at=started_at,
133
+ resumed_under_drift=resumed_under_drift,
134
+ )
135
+
136
+ # Diff-size ceiling (PR-h-02e D2, landed in PR-h-field-01). Measured on the REVIEWER-FACING
137
+ # text — after stripping and binary elision — because that is what reaches the model's
138
+ # context; capping the raw diff would threshold bytes nobody sends. Checked here, after
139
+ # the known-empty classification and before any worktree or subprocess, so a refusal
140
+ # costs nothing. Without it the ceiling is enforced by the provider instead: `codex exec`
141
+ # returns `input_too_large` naming only a character count, after the operator has
142
+ # already confirmed the run.
143
+ _reviewed_bytes = len(_filtered_for_check.encode("utf-8"))
144
+ if _reviewed_bytes > config.loop.max_diff_bytes:
145
+ return _diff_too_large_result(
146
+ round_idx=round_idx,
147
+ snapshot=snapshot,
148
+ round_dir=round_dir,
149
+ config=config,
150
+ started_at=started_at,
151
+ resumed_under_drift=resumed_under_drift,
152
+ diff_bytes=_reviewed_bytes,
153
+ logger=logger,
154
+ )
155
+
156
+ # --- Render reviewer prompt(s) ----------------------------------
157
+ # Template load + render runs BEFORE any worktree is provisioned. A
158
+ # malformed operator override (`.syncade/templates/reviewer.md` with an
159
+ # unknown placeholder) raises out of the render. Mirror the producer/synth
160
+ # template-render contract: catch it, record the phase failure, persist the
161
+ # round artifacts, and return a phase-failure RoundResult — rather than
162
+ # letting the exception escape run_review with no manifest/summary written.
163
+ try:
164
+ prompt_arg, pr_doc_ref, pr_doc_is_out_of_repo = build_prompt(
165
+ repo_root=repo_root,
166
+ snapshot=snapshot,
167
+ config=config,
168
+ round_idx=round_idx,
169
+ run_dir=run_dir,
170
+ pr_doc_path=pr_doc_path,
171
+ )
172
+ except (KeyError, ValueError, FileNotFoundError) as exc:
173
+ logger.event(f"reviewer template render failed: {exc}", error=True)
174
+ return _reviewer_template_failure_result(
175
+ round_idx=round_idx,
176
+ snapshot=snapshot,
177
+ round_dir=round_dir,
178
+ config=config,
179
+ started_at=started_at,
180
+ resumed_under_drift=resumed_under_drift,
181
+ error=exc,
182
+ filtered_diff_bytes=len(_filtered_for_check.encode("utf-8")),
183
+ raw_diff_bytes=len((snapshot.diff_text or "").encode("utf-8")),
184
+ )
185
+
186
+ # --- Assembled-prompt size guard --------------------------------
187
+ # The diff-size cap ([loop] max_diff_bytes) gates on the reviewer-facing diff, but
188
+ # the assembled prompt also includes the template and prior-round context. A verbose
189
+ # round-0 reviewer or a large custom template can push the full prompt over the codex
190
+ # ceiling (1,048,576 chars) while the diff alone stays under the cap. Refuse here,
191
+ # before any worktree is provisioned, so the operator gets a useful message rather
192
+ # than an opaque provider-side `input_too_large` error after paying for the round.
193
+ for _reviewer_name, _prompt_text in prompt_arg.items():
194
+ if len(_prompt_text) > _CODEX_CHAR_CEILING:
195
+ return _assembled_prompt_too_large_result(
196
+ round_idx=round_idx,
197
+ snapshot=snapshot,
198
+ round_dir=round_dir,
199
+ config=config,
200
+ started_at=started_at,
201
+ resumed_under_drift=resumed_under_drift,
202
+ reviewer_name=_reviewer_name,
203
+ prompt_chars=len(_prompt_text),
204
+ logger=logger,
205
+ )
206
+
207
+ return ReadyToDispatch(
208
+ prompt_arg=prompt_arg,
209
+ pr_doc_ref=pr_doc_ref,
210
+ pr_doc_is_out_of_repo=pr_doc_is_out_of_repo,
211
+ filtered_for_check=_filtered_for_check,
212
+ )