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
syncade/synthesis.py ADDED
@@ -0,0 +1,428 @@
1
+ """Pydantic v2 models for the cold synthesizer's output.
2
+
3
+ The synthesizer consolidates reviewer outputs into one
4
+ :class:`SynthesizerOutput`. It does not see the diff or producer narrative; its
5
+ inputs are structured reviewer outputs only. It cannot invent findings; every
6
+ :class:`ConsolidatedFinding` must trace to at least one original reviewer
7
+ finding via :class:`FindingProvenance`. The final ship/no-ship verdict is
8
+ computed mechanically from this output, not by an LLM judgment field.
9
+
10
+ Key schema-level invariants:
11
+
12
+ - **Cannot invent findings.** ``ConsolidatedFinding.provenance`` is
13
+ required and ``min_length=1`` — a finding with no provenance is an
14
+ invented finding, schema-rejected.
15
+ - **Cannot deactivate unanimous blockers.** If two or more distinct
16
+ reviewers flagged the same consolidated concern AT
17
+ ``severity="blocker"``, the synthesizer cannot dismiss it OR downgrade it
18
+ off ``severity="blocker"``. Coverage is counted over the blocker-severity
19
+ provenance entries only, so merging in a third, lower-severity entry
20
+ cannot disarm the guard. This is the hard guardrail from the design
21
+ discussion: two independent blind reviewers reaching blocker on the same
22
+ concern is the strongest signal we get; overriding it would cost more than
23
+ trusting it. Enforced in the schema
24
+ (``_validate_unanimous_blocker_not_deactivated``) rather than only in the
25
+ prompt, so a conforming :class:`SynthesizerOutput` cannot violate it.
26
+ - **Dismissal rationale required when dismissed.** Forces the
27
+ synthesizer to write down WHY it ruled a finding out, instead of
28
+ silently dropping it.
29
+ - **Severity-update rationale required when the synthesizer overrode
30
+ every reviewer.** Moving off one reviewer's call when another agrees
31
+ is judgment within reviewer signal; moving off all reviewers' calls
32
+ is independent judgment and needs explicit reason.
33
+ - **Whitespace-only string rejection** on every always-required string
34
+ field. ``Field(min_length=1)`` alone is insufficient: pure-whitespace
35
+ values pass length but defeat the field's purpose.
36
+
37
+ :func:`parse_synthesizer_output` turns raw stdout into a typed
38
+ :class:`SynthesizerOutput`. It reuses :mod:`syncade.findings_json` so
39
+ verdict-block selection — fence authority, code-sample masking, unmatched-brace
40
+ tolerance, CRLF handling, duplicate-key rejection — is identical to reviewer
41
+ parsing and cannot drift from it.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
47
+
48
+ from syncade.findings import Severity
49
+ from syncade.findings_json import decode_and_validate
50
+ from syncade.synthesis_clusters import (
51
+ RootCauseCluster,
52
+ validate_clusters_against_findings,
53
+ )
54
+
55
+ # get_synthesizer_schema_string moved to synthesis_schema; redundant-alias
56
+ # re-export (X as X) marks it intentional so it survives ruff's F401 autofix —
57
+ # syncade.synthesis.get_synthesizer_schema_string stays importable.
58
+ from syncade.synthesis_repair import _repair_known_model_deviations
59
+ from syncade.synthesis_schema import get_synthesizer_schema_string as get_synthesizer_schema_string
60
+
61
+
62
+ class SynthesizerOutputError(Exception):
63
+ """Raised when synthesizer stdout can't be parsed as a :class:`SynthesizerOutput`.
64
+
65
+ Distinct from :class:`syncade.findings.ReviewerOutputError` so the
66
+ orchestrator's exit-code logic can distinguish "reviewer parse
67
+ failed" from "synthesizer parse failed" in error messaging. Both
68
+ still map to exit 70 (``REVIEWER_OUTPUT_UNPARSEABLE``) — same
69
+ operational fix path (inspect the raw .stdout, tighten the prompt
70
+ if needed) — but the message names which phase so the user knows
71
+ which file to open.
72
+ """
73
+
74
+
75
+ class FindingProvenance(BaseModel):
76
+ """Per-reviewer attribution for a :class:`ConsolidatedFinding`.
77
+
78
+ Records which reviewer flagged a given concern, with the index
79
+ into that reviewer's original ``findings`` list, the severity the
80
+ reviewer assigned, and the reviewer's verbatim one-line
81
+ description of the finding. Preserving the original description
82
+ lets the operator see how each reviewer framed the same concern —
83
+ useful when the two reviewers diverge on what they think the
84
+ problem actually is.
85
+
86
+ ``original_index`` is informational provenance — it lets the
87
+ operator (or a future tool) jump from a consolidated finding back
88
+ to the source reviewer's parsed.json. It is NOT validated against
89
+ the actual size of the source reviewer's findings list, which
90
+ would require cross-input plumbing this module deliberately
91
+ avoids; that check lives in the orchestrator if it lives anywhere.
92
+ """
93
+
94
+ model_config = ConfigDict(extra="forbid")
95
+
96
+ reviewer_name: str = Field(..., min_length=1)
97
+ original_severity: Severity
98
+ original_index: int = Field(..., ge=0)
99
+ # NO length or non-blank constraint, deliberately (PR-h-field-01, dogfood round 4).
100
+ # Every provenance entry passes through
101
+ # `synthesizer.validation._validate_provenance_against_reviewers`, which OVERWRITES this
102
+ # field with the source reviewer's own text. Whatever the model wrote here is discarded,
103
+ # so validating it rejects runs over a value that was never going to be used: a blank or
104
+ # whitespace-only quote aborted the whole run at exit 70 — the exact failure class the
105
+ # repair exists to eliminate, surviving through the schema instead of the validator.
106
+ # The guarantee did not weaken; it moved from "the model must write it correctly" to
107
+ # "syncade copies it from the source", which is strictly stronger.
108
+ original_description: str
109
+
110
+ @field_validator("reviewer_name")
111
+ @classmethod
112
+ def _validate_reviewer_name_nonblank(cls, v: str) -> str:
113
+ """``reviewer_name`` must contain at least one non-whitespace
114
+ character.
115
+
116
+ Whitespace-only validation: ``min_length=1`` accepts
117
+ whitespace-only values like ``" "`` which would pass the
118
+ length check but carry no provenance information.
119
+ """
120
+ if not v.strip():
121
+ raise ValueError(
122
+ "reviewer_name must contain non-whitespace content; got "
123
+ "an all-whitespace value which provides no provenance "
124
+ "attribution"
125
+ )
126
+ return v
127
+
128
+
129
+ def distinct_reviewer_count(provenance: list[FindingProvenance]) -> int:
130
+ """Number of DISTINCT ``reviewer_name``s in a ``provenance`` list.
131
+
132
+ Single source of truth for "how many reviewers actually raised this
133
+ finding". Used by BOTH the unanimous-blocker deactivation guard
134
+ (:meth:`ConsolidatedFinding._validate_unanimous_blocker_not_deactivated`)
135
+ and the findings.md consensus renderer
136
+ (:func:`syncade.persistence._markdown._consensus_lines`), so the two
137
+ views of consensus cannot drift: a single reviewer listed twice is
138
+ ONE reviewer, never a unanimous blocker.
139
+ """
140
+ return len({p.reviewer_name for p in provenance})
141
+
142
+
143
+ class ConsolidatedFinding(BaseModel):
144
+ """One concern in the synthesizer's consolidated finding set.
145
+
146
+ Built by merging one or more reviewers' original findings into a single
147
+ entry keyed on the underlying concern (different reviewers may word the same
148
+ issue differently). ``provenance`` carries the per-reviewer attribution;
149
+ ``severity`` is the synthesizer's final call,
150
+ possibly differing from one or more reviewers'
151
+ ``original_severity``.
152
+
153
+ Validators (all ``@model_validator(mode="after")``):
154
+
155
+ - :meth:`_validate_dismissal_rationale_present_when_dismissed` —
156
+ if ``dismissed=True``, ``dismissal_rationale`` must be a
157
+ non-whitespace string. Forces the synthesizer to record WHY a
158
+ finding was ruled out.
159
+ - :meth:`_validate_unanimous_blocker_not_deactivated` — if two or more
160
+ DISTINCT reviewers flagged the concern at
161
+ ``original_severity="blocker"`` (counted over the blocker-severity
162
+ entries, so an extra lower-severity entry cannot disarm the
163
+ guard), the finding cannot be deactivated — neither dismissed
164
+ nor downgraded off ``"blocker"`` (finding A2). This is the hard
165
+ schema-level guardrail: two blind reviewers reaching blocker on
166
+ the same concern is the strongest signal we get; the
167
+ synthesizer cannot silently override that.
168
+ - :meth:`_validate_severity_change_rationale` — if ``severity``
169
+ differs from every ``original_severity`` in ``provenance`` (the
170
+ synthesizer moved off ALL reviewers' calls),
171
+ ``severity_change_rationale`` is required.
172
+ """
173
+
174
+ model_config = ConfigDict(extra="forbid")
175
+
176
+ description: str = Field(..., min_length=1)
177
+ file: str | None = None
178
+ severity: Severity
179
+ provenance: list[FindingProvenance] = Field(..., min_length=1)
180
+ dismissed: bool = False
181
+ dismissal_rationale: str | None = None
182
+ severity_change_rationale: str | None = None
183
+
184
+ @field_validator("description")
185
+ @classmethod
186
+ def _validate_description_nonblank(cls, v: str) -> str:
187
+ """``description`` must contain at least one non-whitespace
188
+ character. Whitespace-only validation — a pure-whitespace
189
+ description renders as an empty bullet in ``findings.md``
190
+ and carries no information.
191
+ """
192
+ if not v.strip():
193
+ raise ValueError(
194
+ "description must contain non-whitespace content; got "
195
+ "an all-whitespace value which carries no description "
196
+ "of the consolidated finding"
197
+ )
198
+ return v
199
+
200
+ # Validator order matters for the operator's error-message experience. The
201
+ # structural-impossibility check for unanimous-blocker deactivation fires
202
+ # first; if dismissal is structurally allowed, we then check rationale.
203
+
204
+ @model_validator(mode="after")
205
+ def _validate_unanimous_blocker_not_deactivated(self) -> ConsolidatedFinding:
206
+ """A unanimous reviewer blocker cannot be DEACTIVATED — neither
207
+ dismissed NOR downgraded off ``severity="blocker"`` (finding A2).
208
+
209
+ Two independent blind reviewers reaching 'blocker' on the same concern is
210
+ the strongest signal we get; the synthesizer's consolidation pass cannot
211
+ override it. Both deactivation paths slip it past the mechanical verdict
212
+ (``has_active_blocker`` counts only non-dismissed 'blocker' findings) — a
213
+ false SHIP — so both are schema-rejected.
214
+
215
+ Keys on DISTINCT reviewers among the BLOCKER-severity provenance
216
+ entries — not on every entry being a blocker. That distinction is
217
+ load-bearing (2026-07-27 audit rank 1 / A C-01 / B C1): the guard
218
+ used to require ``all(p.original_severity == "blocker")`` over raw
219
+ provenance, so appending one non-blocker entry from an already-
220
+ counted reviewer — ``[r1:blocker, r2:blocker, r1:minor]`` — silently
221
+ disabled it and let a two-reviewer blocker be dismissed into a
222
+ false SHIP. Merging a reviewer's lower-severity note about the same
223
+ concern is normal, model-reachable consolidation behavior, so the
224
+ predicate must be monotone: extra provenance can only ever ADD
225
+ coverage, never remove it.
226
+
227
+ Distinctness still comes from the shared ``distinct_reviewer_count``
228
+ helper (one reviewer listed twice isn't unanimity); the findings.md
229
+ consensus renderer calls the same helper over UNFILTERED provenance
230
+ because it answers a different question — who *raised* the finding,
231
+ at any severity. Provenance ``original_severity`` is cross-checked
232
+ truthful in ``synthesizer/validation.py``, so the schema can trust
233
+ it here. Runs first among the model validators so the
234
+ structural-impossibility error fires before the rationale checks.
235
+ """
236
+ blocker_provenance = [p for p in self.provenance if p.original_severity == "blocker"]
237
+ distinct_reviewers = distinct_reviewer_count(blocker_provenance)
238
+ if distinct_reviewers < 2:
239
+ return self
240
+ if self.dismissed or self.severity != "blocker":
241
+ how = "dismiss" if self.dismissed else f"downgrade to severity={self.severity!r}"
242
+ raise ValueError(
243
+ f"cannot {how} a finding flagged at severity='blocker' by "
244
+ f"{distinct_reviewers} reviewers (unanimous-blocker rule): two or "
245
+ "more independent blind reviewers reaching 'blocker' is the "
246
+ "strongest available signal; the synthesizer cannot override it "
247
+ "(neither dismissal nor downgrade off 'blocker'). Provenance: "
248
+ + ", ".join(f"{p.reviewer_name}@blocker" for p in blocker_provenance)
249
+ )
250
+ return self
251
+
252
+ @model_validator(mode="after")
253
+ def _validate_dismissal_rationale_present_when_dismissed(self) -> ConsolidatedFinding:
254
+ """When ``dismissed=True``, ``dismissal_rationale`` must contain
255
+ non-whitespace content.
256
+
257
+ The synthesizer's prompt instructs it to provide rationale, but
258
+ a model that silently drops the rationale would otherwise pass
259
+ validation. Whitespace-only is also rejected so the field
260
+ carries actual narrative the operator can audit.
261
+
262
+ Runs after the unanimous-blocker check so the
263
+ operator sees the structural-impossibility error first when
264
+ both rules apply.
265
+ """
266
+ if self.dismissed:
267
+ if self.dismissal_rationale is None or not self.dismissal_rationale.strip():
268
+ raise ValueError(
269
+ "dismissal_rationale is required (and must contain "
270
+ "non-whitespace content) when dismissed=True; got "
271
+ f"{self.dismissal_rationale!r}. Dismissing a finding "
272
+ "without rationale leaves the operator no way to "
273
+ "audit the synthesizer's decision."
274
+ )
275
+ return self
276
+
277
+ @model_validator(mode="after")
278
+ def _validate_severity_change_rationale(self) -> ConsolidatedFinding:
279
+ """When ``severity`` differs from EVERY
280
+ ``original_severity`` in ``provenance``,
281
+ ``severity_change_rationale`` is required (and must be
282
+ non-whitespace).
283
+
284
+ The synthesizer is allowed to override individual reviewer
285
+ severity calls without justification when at least one
286
+ reviewer agrees with the final severity — that's normal
287
+ disagreement-arbitration. Moving off all reviewers' calls is
288
+ independent judgment and needs explicit rationale so the
289
+ operator can audit it.
290
+ """
291
+ original_severities = {p.original_severity for p in self.provenance}
292
+ if self.severity in original_severities:
293
+ return self
294
+ if self.severity_change_rationale is None or not self.severity_change_rationale.strip():
295
+ raise ValueError(
296
+ f"severity_change_rationale is required when severity "
297
+ f"({self.severity!r}) differs from every reviewer's "
298
+ f"original_severity ({sorted(original_severities)!r}); the "
299
+ "synthesizer moved off ALL reviewers' calls and needs to "
300
+ "record why"
301
+ )
302
+ return self
303
+
304
+
305
+ class SynthesizerOutput(BaseModel):
306
+ """Top-level output of one synthesizer subprocess run.
307
+
308
+ Holds the consolidated finding set and a headline
309
+ :attr:`synthesis_summary` narrating how the consolidation went: how many
310
+ findings merged, how many were dismissed, and where reviewers disagreed.
311
+
312
+ There is intentionally no ``verdict`` field. The verdict is a
313
+ mechanical function of ``consolidated_findings``: any non-dismissed blocker
314
+ means NO-SHIP (exit 30); otherwise SHIP (exit 0). Adding a verdict
315
+ field here would re-introduce LLM judgment at the verdict step,
316
+ which is exactly what this design moves away from.
317
+
318
+ Empty ``consolidated_findings`` is valid: it means both reviewers
319
+ surfaced nothing and the synthesizer has nothing to consolidate.
320
+ :attr:`synthesis_summary` is still required so even the empty case carries a
321
+ narrative. The :attr:`root_cause_clusters` field is an optional descriptive-only
322
+ grouping of consolidated findings that share a file, each grounded by a
323
+ verbatim quote from a reviewer's original text. It is **advisory**
324
+ — clusters never reach the mechanical verdict (``_compute_exit_code``
325
+ reads :func:`has_active_blocker` only) — and authors no cause or fix.
326
+ Defaults to ``[]``; the schema and cluster models live in
327
+ :mod:`syncade.synthesis_clusters`. Cross-checks that need
328
+ ``consolidated_findings`` run in :meth:`_validate_root_cause_clusters`.
329
+ """
330
+
331
+ model_config = ConfigDict(extra="forbid")
332
+
333
+ consolidated_findings: list[ConsolidatedFinding] = Field(default_factory=list)
334
+ synthesis_summary: str = Field(..., min_length=1)
335
+ root_cause_clusters: list[RootCauseCluster] = Field(default_factory=list)
336
+
337
+ @field_validator("synthesis_summary")
338
+ @classmethod
339
+ def _validate_synthesis_summary_nonblank(cls, v: str) -> str:
340
+ """``synthesis_summary`` must contain at least one
341
+ non-whitespace character. Whitespace-only validation.
342
+ """
343
+ if not v.strip():
344
+ raise ValueError(
345
+ "synthesis_summary must contain non-whitespace content; "
346
+ "got an all-whitespace value which provides no narrative "
347
+ "about how the consolidation went"
348
+ )
349
+ return v
350
+
351
+ @model_validator(mode="after")
352
+ def _validate_root_cause_clusters(self) -> SynthesizerOutput:
353
+ """Run the cluster cross-checks that need the consolidated-finding
354
+ list (in-range member indices, disjoint clusters, ``anchor_file`` ==
355
+ each member's ``.file``).
356
+
357
+ Delegated to :func:`syncade.synthesis_clusters.validate_clusters_against_findings`
358
+ so the bulk of the cluster logic stays in its companion module; a
359
+ violation raises ``ValueError`` → ``ValidationError`` → exit 70, the
360
+ same path as every other synth-output schema failure. The empty default
361
+ (``[]``) is a no-op.
362
+ """
363
+ validate_clusters_against_findings(self.root_cause_clusters, self.consolidated_findings)
364
+ return self
365
+
366
+
367
+ def has_active_blocker(output: SynthesizerOutput) -> bool:
368
+ """Return True iff ``output.consolidated_findings`` contains any
369
+ non-dismissed finding with ``severity == "blocker"``.
370
+
371
+ This is the mechanical verdict's substrate: any non-dismissed blocker means
372
+ NO-SHIP (30), else SHIP (0), and ``persist_findings_md`` renders the
373
+ operator-facing Verdict line from the same condition. Centralized
374
+ here so the two callers can't drift — a future update to "what
375
+ counts as an active blocker" (e.g. an additional "active" flag
376
+ on ConsolidatedFinding) lands in one place.
377
+
378
+ Dismissed blockers do not count, by design: dismissal-with-
379
+ rationale is the synthesizer's bounded-judgment surface for
380
+ ruling out false positives. The schema's unanimous-blocker rule
381
+ ensures a dismissed blocker is always single-reviewer.
382
+ """
383
+ return any(f.severity == "blocker" and not f.dismissed for f in output.consolidated_findings)
384
+
385
+
386
+ def _validate_synthesizer_object(parsed: object) -> SynthesizerOutput:
387
+ """Validate an already-decoded verdict object, strictly first and then
388
+ through the known-deviation repair list.
389
+
390
+ Raises :class:`pydantic.ValidationError` when both passes fail, so
391
+ :func:`~syncade.findings_json.decode_and_validate` reports it exactly as it
392
+ reports every other actor's schema rejection. The repair pass is a fixed
393
+ LIST of information-free provider deviations
394
+ (:func:`_repair_known_model_deviations`), not a tolerance — any other
395
+ deviation still fails.
396
+ """
397
+ try:
398
+ return SynthesizerOutput.model_validate(parsed)
399
+ except ValidationError:
400
+ repaired = _repair_known_model_deviations(parsed)
401
+ if repaired is parsed:
402
+ raise
403
+ return SynthesizerOutput.model_validate(repaired)
404
+
405
+
406
+ def parse_synthesizer_output(raw: str) -> SynthesizerOutput:
407
+ """Parse a synthesizer's raw stdout text into a :class:`SynthesizerOutput`.
408
+
409
+ Selects exactly ONE verdict block via
410
+ :func:`syncade.findings_json._decode_verdict_object` — the last
411
+ ``json``/unlabeled fence, or the whole response when no fence is present —
412
+ and validates it, strictly first and then through the known-deviation
413
+ repair list. If that block does not decode or does not validate, this
414
+ raises; it never falls back to an earlier block that happens to validate
415
+ (see :mod:`syncade.findings_json`).
416
+
417
+ Raises :class:`SynthesizerOutputError` rather than
418
+ :class:`ReviewerOutputError` so the orchestrator's error messaging can name
419
+ the phase the operator needs to debug, even though both map to exit 70.
420
+ """
421
+ return decode_and_validate(
422
+ raw,
423
+ validate=_validate_synthesizer_object,
424
+ error=SynthesizerOutputError,
425
+ label="synthesizer",
426
+ model_name="SynthesizerOutput",
427
+ artifact="synthesizer.stdout in the round directory",
428
+ )
@@ -0,0 +1,203 @@
1
+ """Root-cause cluster schema for the cold synthesizer.
2
+
3
+ Descriptive-only, zero-invention grouping: the synth groups ≥2 consolidated
4
+ findings that share a file and cites a **verbatim** quote from each member's
5
+ reviewer-original text. It authors **no cause and no fix** — it groups and
6
+ quotes; the producer infers the cause from the reviewers' own words. Clusters
7
+ are advisory: they never reach the mechanical verdict
8
+ (:func:`syncade.orchestrator.verdict._compute_exit_code` reads
9
+ :func:`syncade.synthesis.has_active_blocker` only).
10
+
11
+ This lives in its own module rather than ``synthesis.py`` (already at the
12
+ ~500-LOC cap — see the design / the LOC discipline). ``synthesis.py`` imports
13
+ :class:`RootCauseCluster` for the ``SynthesizerOutput.root_cause_clusters``
14
+ field and calls :func:`validate_clusters_against_findings` from a delegating
15
+ ``model_validator`` (the cross-checks need the ``consolidated_findings`` list,
16
+ which lives on ``SynthesizerOutput``). The verbatim-quote-against-reviewer-source
17
+ check lives in :mod:`syncade.synthesizer.validation` (it needs the input
18
+ ``ReviewerOutput`` set).
19
+
20
+ **Cannot-invent, strengthened.** A cluster's only content is a grouping
21
+ (checkable: shared ``anchor_file``) + verbatim quotes (checkable: real
22
+ substrings of the reviewers' original findings) + an optional ``label`` that
23
+ must itself be a verbatim substring of one of the quotes. No field carries
24
+ authored causal prose.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import TYPE_CHECKING
30
+
31
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
32
+
33
+ if TYPE_CHECKING:
34
+ from syncade.synthesis import ConsolidatedFinding
35
+
36
+
37
+ class ClusterMemberEvidence(BaseModel):
38
+ """One member finding's verbatim grounding in a root-cause cluster.
39
+
40
+ ``finding_index`` indexes into ``SynthesizerOutput.consolidated_findings``;
41
+ ``quote`` is a VERBATIM substring of that member's reviewer-original finding
42
+ text (validated against the input ``ReviewerOutput``s in
43
+ :mod:`syncade.synthesizer.validation` — NOT the synth's consolidated
44
+ rephrasing).
45
+ """
46
+
47
+ model_config = ConfigDict(extra="forbid")
48
+
49
+ finding_index: int = Field(..., ge=0)
50
+ quote: str = Field(..., min_length=1)
51
+
52
+ @field_validator("quote")
53
+ @classmethod
54
+ def _validate_quote_nonblank(cls, v: str) -> str:
55
+ """A whitespace-only quote is a substring of almost anything and
56
+ grounds nothing — reject it."""
57
+ if not v.strip():
58
+ raise ValueError(
59
+ "quote must contain non-whitespace content; an all-whitespace quote grounds nothing"
60
+ )
61
+ return v
62
+
63
+
64
+ class RootCauseCluster(BaseModel):
65
+ """A descriptive-only grouping of ≥2 consolidated findings sharing a file.
66
+
67
+ Authors no cause and no fix. ``label``, if present, must be a verbatim
68
+ substring of one of the evidence quotes (never authored prose).
69
+
70
+ Self-contained validators run here; the cross-checks that need the
71
+ ``consolidated_findings`` list (member indices in range, disjoint across
72
+ clusters, ``anchor_file`` == each member's ``.file``) run in
73
+ :func:`validate_clusters_against_findings`, invoked from a
74
+ ``SynthesizerOutput`` ``model_validator``.
75
+ """
76
+
77
+ model_config = ConfigDict(extra="forbid")
78
+
79
+ member_finding_indices: list[int] = Field(..., min_length=2)
80
+ anchor_file: str = Field(..., min_length=1)
81
+ evidence: list[ClusterMemberEvidence] = Field(..., min_length=2)
82
+ label: str | None = None
83
+
84
+ @field_validator("anchor_file")
85
+ @classmethod
86
+ def _validate_anchor_file_nonblank(cls, v: str) -> str:
87
+ if not v.strip():
88
+ raise ValueError("anchor_file must contain non-whitespace content")
89
+ return v
90
+
91
+ @model_validator(mode="after")
92
+ def _validate_self_consistency(self) -> RootCauseCluster:
93
+ """Self-contained cluster invariants (no consolidated_findings needed):
94
+
95
+ - member indices unique within the cluster;
96
+ - exactly one ``evidence`` per member (evidence ``finding_index`` set
97
+ equals the member-index set);
98
+ - ``label``, if present, is non-blank AND a verbatim substring of one
99
+ of the evidence quotes (a quote excerpt, not authored prose).
100
+ """
101
+ members = self.member_finding_indices
102
+ if len(set(members)) != len(members):
103
+ raise ValueError(
104
+ f"member_finding_indices must be unique within a cluster; got {members}"
105
+ )
106
+ ev_indices = [e.finding_index for e in self.evidence]
107
+ if len(set(ev_indices)) != len(ev_indices):
108
+ raise ValueError(
109
+ f"evidence finding_index values must be unique within a cluster; got {ev_indices}"
110
+ )
111
+ if set(ev_indices) != set(members):
112
+ raise ValueError(
113
+ "evidence must have exactly one entry per member finding: the "
114
+ f"evidence finding_index set {sorted(set(ev_indices))} must equal the "
115
+ f"member_finding_indices set {sorted(set(members))}"
116
+ )
117
+ if self.label is not None:
118
+ if not self.label.strip():
119
+ raise ValueError("label, when present, must contain non-whitespace content")
120
+ if not any(self.label in e.quote for e in self.evidence):
121
+ raise ValueError(
122
+ f"label {self.label!r}, when present, must be a verbatim substring "
123
+ "of one of the cluster's evidence quotes — it is a quote excerpt, "
124
+ "not authored prose"
125
+ )
126
+ return self
127
+
128
+
129
+ def validate_clusters_against_findings(
130
+ clusters: list[RootCauseCluster],
131
+ consolidated_findings: list[ConsolidatedFinding],
132
+ ) -> None:
133
+ """Cross-checks that need the ``consolidated_findings`` list.
134
+
135
+ Invoked from a ``SynthesizerOutput`` ``@model_validator(mode="after")`` so a
136
+ violation raises ``ValueError`` → ``ValidationError`` → exit 70 (same path as
137
+ the other synth-output schema failures). Invariants:
138
+
139
+ 1. every ``member_finding_indices`` value is in range ``[0, len(findings))``;
140
+ 2. findings are disjoint across clusters (each finding in ≤1 cluster);
141
+ 3. ``anchor_file`` equals the ``.file`` of every member finding.
142
+
143
+ (≥2 members + one-evidence-per-member + label-is-a-quote are self-contained
144
+ on :class:`RootCauseCluster`; verbatim-quote-vs-reviewer-source is in
145
+ :mod:`syncade.synthesizer.validation`.)
146
+
147
+ A finding with ``file=None`` cannot be clustered: ``anchor_file`` is a
148
+ required non-blank string, so the check #3 equality fails — the intended
149
+ precision floor (a cluster requires a shared file).
150
+ """
151
+ n = len(consolidated_findings)
152
+ seen: set[int] = set()
153
+ for ci, cluster in enumerate(clusters):
154
+ for idx in cluster.member_finding_indices:
155
+ if not (0 <= idx < n):
156
+ raise ValueError(
157
+ f"root_cause_clusters[{ci}] member_finding_indices contains {idx}, "
158
+ f"out of range for {n} consolidated finding(s)"
159
+ )
160
+ if idx in seen:
161
+ raise ValueError(
162
+ f"root_cause_clusters[{ci}] member finding {idx} already belongs to "
163
+ "another cluster; clusters must be disjoint (each finding in at most "
164
+ "one cluster)"
165
+ )
166
+ seen.add(idx)
167
+ for idx in cluster.member_finding_indices:
168
+ member_file = consolidated_findings[idx].file
169
+ if member_file != cluster.anchor_file:
170
+ raise ValueError(
171
+ f"root_cause_clusters[{ci}] anchor_file {cluster.anchor_file!r} does "
172
+ f"not match the file of member finding {idx} ({member_file!r}); a "
173
+ "cluster requires a shared file (every member must be in anchor_file)"
174
+ )
175
+
176
+
177
+ def cluster_schema_fragment() -> str:
178
+ """The ``root_cause_clusters`` block for the synthesizer prompt's JSON-schema
179
+ skeleton (concatenated into
180
+ :func:`syncade.synthesis.get_synthesizer_schema_string`).
181
+
182
+ Descriptive-only: the inline comments stress group-and-quote, no authored
183
+ cause/fix, and that clusters never affect the verdict.
184
+ """
185
+ return (
186
+ ' "root_cause_clusters": [ // OPTIONAL, default []; ADVISORY only — '
187
+ "never affects the verdict\n"
188
+ " {\n"
189
+ ' "member_finding_indices": [int, int], // >=2 indices into '
190
+ "consolidated_findings; same file; disjoint across clusters\n"
191
+ ' "anchor_file": "path", // MUST equal the .file of every member finding\n'
192
+ ' "evidence": [ // exactly one per member\n'
193
+ " {\n"
194
+ ' "finding_index": int, // a member index\n'
195
+ ' "quote": "string" // VERBATIM substring of that member\'s '
196
+ "reviewer-original finding text — do NOT paraphrase\n"
197
+ " }\n"
198
+ " ],\n"
199
+ ' "label": "string"|null // OPTIONAL; if present, a verbatim substring '
200
+ "of one quote — NOT an authored cause or fix\n"
201
+ " }\n"
202
+ " ]"
203
+ )