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,421 @@
1
+ """Cross-input provenance validation for synthesizer output.
2
+
3
+ The synthesizer's prompt can fabricate ``provenance`` entries that
4
+ point at a non-existent ``reviewer_name`` or an out-of-range
5
+ ``original_index``. The pydantic schema can't catch this — it has no
6
+ view of the input reviewer set — so this module owns the post-parse
7
+ sanity check.
8
+
9
+ Failures map to :class:`~syncade.synthesis.SynthesizerOutputError`,
10
+ which the driver re-raises so the orchestrator's exit-code table
11
+ turns it into exit 70 (parse failure). The error message identifies
12
+ the specific consolidated finding + provenance entry so the persisted
13
+ ``synthesizer.error.txt`` tells the operator exactly which finding
14
+ misattributed its source.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+
21
+ from syncade.dispatcher import ReviewerRunResult
22
+ from syncade.findings import Severity
23
+ from syncade.synthesis import SynthesizerOutput, SynthesizerOutputError
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ProvenanceRepair:
28
+ """One provenance quotation corrected from the reviewer's own text.
29
+
30
+ Recorded rather than silent: the operator must be able to see that a synthesizer
31
+ miscopied a source, both because it is a signal about that model's fidelity and
32
+ because "we quietly rewrote the model's output" is not something to hide.
33
+ """
34
+
35
+ consolidated_index: int
36
+ provenance_index: int
37
+ reviewer_name: str
38
+ original_index: int
39
+ synthesizer_text: str
40
+ reviewer_text: str
41
+
42
+
43
+ def _validate_provenance_against_reviewers(
44
+ output: SynthesizerOutput,
45
+ reviewer_results: list[ReviewerRunResult],
46
+ ) -> list[ProvenanceRepair]:
47
+ """Cross-input validation: every ``provenance`` entry must point
48
+ at a real reviewer + a real finding within that reviewer's
49
+ output.
50
+
51
+ Schema-level checks (``min_length=1`` on ``provenance``) prevent
52
+ zero-provenance findings, but the schema can't see the input
53
+ reviewer set, so it can't catch the model fabricating a
54
+ ``reviewer_name`` that doesn't correspond to a real reviewer, an
55
+ ``original_index`` outside the source reviewer's findings list, or
56
+ an ``original_severity`` that misreports what the source reviewer
57
+ actually assigned. All three shapes would render into
58
+ ``findings.md`` as if they had real attribution — exactly the
59
+ "cannot invent findings" invariant design set out to enforce. The
60
+ severity cross-check additionally closes the unanimous-blocker
61
+ bypass: a synth that records a genuinely-unanimous blocker as
62
+ ``"minor"`` for one reviewer would defeat the schema's all-blocker
63
+ dismissal guard (which keys on ``original_severity``); verifying
64
+ each entry's severity against its source reviewer makes that
65
+ impossible.
66
+
67
+ Args:
68
+ output: The parsed :class:`SynthesizerOutput`.
69
+ reviewer_results: The dispatcher's per-reviewer results.
70
+ Only successful reviewers contribute to the reviewer-name
71
+ and findings-length lookup tables.
72
+
73
+ Raises:
74
+ SynthesizerOutputError: When any provenance entry references
75
+ an unknown reviewer name, an out-of-range
76
+ ``original_index``, OR an ``original_severity`` that does
77
+ not match the source reviewer's actual finding severity.
78
+ The message names the specific violation so the persisted
79
+ ``synthesizer.error.txt`` tells the operator exactly which
80
+ finding misattributed its source. The error maps to exit
81
+ 70 via the existing ``_compute_exit_code`` decision table.
82
+
83
+ ``original_description`` is also cross-checked: a synthesizer that may
84
+ restate the source can narrow a blocker into something it can then honestly
85
+ dismiss, and the operator reading ``findings.md`` would see the fabrication
86
+ quoted as the reviewer's verbatim framing. Provenance whose text the
87
+ synthesizer may author is not provenance.
88
+
89
+ The comparison normalizes runs of whitespace (so reflow and line-wrapping
90
+ survive) but preserves case and every character otherwise — a summarized,
91
+ truncated, or reworded quote is a hard exit 70.
92
+ """
93
+ # Build lookup: reviewer_name → number of original findings.
94
+ # Only successful reviewers contribute — failed reviewers have
95
+ # no findings list to reference, and the orchestrator's
96
+ # synth-skipped-on-any-reviewer-failure rule means we shouldn't
97
+ # see provenance pointing at a failed reviewer here anyway.
98
+ findings_count_by_name: dict[str, int] = {}
99
+ severities_by_name: dict[str, list[Severity]] = {}
100
+ texts_by_name: dict[str, list[str]] = {}
101
+ for r in reviewer_results:
102
+ if r.output is None:
103
+ continue
104
+ findings_count_by_name[r.reviewer_name] = len(r.output.findings)
105
+ severities_by_name[r.reviewer_name] = [f.severity for f in r.output.findings]
106
+ texts_by_name[r.reviewer_name] = [f.finding for f in r.output.findings]
107
+
108
+ repairs: list[ProvenanceRepair] = []
109
+ for consolidated_idx, finding in enumerate(output.consolidated_findings):
110
+ for prov_idx, prov in enumerate(finding.provenance):
111
+ # Unknown reviewer name → ghost provenance.
112
+ if prov.reviewer_name not in findings_count_by_name:
113
+ known = sorted(findings_count_by_name.keys())
114
+ raise SynthesizerOutputError(
115
+ f"synthesizer provenance references unknown reviewer "
116
+ f"{prov.reviewer_name!r} (consolidated_findings[{consolidated_idx}]"
117
+ f".provenance[{prov_idx}]); known reviewer names from this "
118
+ f"run: {known}. The synthesizer may have fabricated a "
119
+ f"provenance entry; check synthesizer.stdout for the raw "
120
+ f"output."
121
+ )
122
+ # Out-of-range original_index → ghost source finding. The
123
+ # zero-findings case needs a distinct message.
124
+ max_index = findings_count_by_name[prov.reviewer_name]
125
+ if max_index == 0:
126
+ raise SynthesizerOutputError(
127
+ f"synthesizer provenance original_index "
128
+ f"{prov.original_index} is invalid for reviewer "
129
+ f"{prov.reviewer_name!r}: that reviewer produced 0 "
130
+ f"findings, so no original_index value is valid. "
131
+ f"consolidated_findings[{consolidated_idx}]"
132
+ f".provenance[{prov_idx}]. The synthesizer may have "
133
+ f"fabricated a provenance entry; check "
134
+ f"synthesizer.stdout for the raw output."
135
+ )
136
+ if prov.original_index >= max_index:
137
+ raise SynthesizerOutputError(
138
+ f"synthesizer provenance original_index "
139
+ f"{prov.original_index} is out of range for reviewer "
140
+ f"{prov.reviewer_name!r} (which produced {max_index} "
141
+ f"finding(s); valid indices are 0..{max_index - 1}). "
142
+ f"consolidated_findings[{consolidated_idx}]"
143
+ f".provenance[{prov_idx}]. The synthesizer may have "
144
+ f"fabricated a provenance entry; check "
145
+ f"synthesizer.stdout for the raw output."
146
+ )
147
+ # original_severity must match the SOURCE reviewer's actual
148
+ # finding severity. The schema accepts any valid Severity
149
+ # enum value, so a synth could record a TRUTHFUL reviewer +
150
+ # in-range index but LIE about the severity (claim a
151
+ # genuinely-blocker finding was "minor") to slip a unanimous
152
+ # blocker past the schema's all-blocker dismissal guard.
153
+ # Cross-check it here so that bypass cannot pass.
154
+ actual_severity = severities_by_name[prov.reviewer_name][prov.original_index]
155
+ if prov.original_severity != actual_severity:
156
+ raise SynthesizerOutputError(
157
+ f"synthesizer provenance original_severity "
158
+ f"{prov.original_severity!r} does not match reviewer "
159
+ f"{prov.reviewer_name!r}'s actual severity "
160
+ f"{actual_severity!r} for finding {prov.original_index} "
161
+ f"(consolidated_findings[{consolidated_idx}]"
162
+ f".provenance[{prov_idx}]). The synthesizer may have "
163
+ f"misreported a reviewer's severity to evade the "
164
+ f"unanimous-blocker rule; check synthesizer.stdout for "
165
+ f"the raw output."
166
+ )
167
+ # original_description must be the reviewer's OWN words. A
168
+ # synthesizer that may restate the source can narrow a blocker into
169
+ # something it can then honestly dismiss, and the operator sees the
170
+ # fabrication quoted as the reviewer's verbatim framing in
171
+ # findings.md. Whitespace runs are normalized so reflow survives;
172
+ # nothing else is.
173
+ actual_text = texts_by_name[prov.reviewer_name][prov.original_index]
174
+ if _normalize_whitespace(prov.original_description) != _normalize_whitespace(
175
+ actual_text
176
+ ):
177
+ # REPAIR, not abort (PR-h-field-01 item 5). Everything that could be an
178
+ # ATTRIBUTION attack has already been verified above: the reviewer exists,
179
+ # the index is in range, and the severity matches. What is left is a
180
+ # transcription error in a QUOTATION of a finding — and the ground truth is
181
+ # right here in `reviewer_results`, which is how we know it is wrong.
182
+ #
183
+ # This is strictly STRONGER than the abort it replaces. Before, the rendered
184
+ # text was verbatim only because the check happened to pass — we trusted the
185
+ # synthesizer's copy. Now it is verbatim BY CONSTRUCTION, copied from the
186
+ # source. Nothing a synthesizer writes in this field can reach findings.md.
187
+ #
188
+ # It cost a real run to learn: one dropped backtick in a finding about
189
+ # `style={{ backdropFilter: ... }}` aborted a run at exit 70 after 713
190
+ # seconds of reviewer wall-clock, discarding six valid findings and three
191
+ # unrun rounds. The exposure is proportional to reviewer verbosity, and
192
+ # findings that quote code are exactly the ones full of backticks and braces.
193
+ repairs.append(
194
+ ProvenanceRepair(
195
+ consolidated_index=consolidated_idx,
196
+ provenance_index=prov_idx,
197
+ reviewer_name=prov.reviewer_name,
198
+ original_index=prov.original_index,
199
+ synthesizer_text=prov.original_description,
200
+ reviewer_text=actual_text,
201
+ )
202
+ )
203
+ prov.original_description = actual_text
204
+
205
+ return repairs
206
+
207
+
208
+ def _validate_reviewer_blockers_passed_through(
209
+ output: SynthesizerOutput,
210
+ reviewer_results: list[ReviewerRunResult],
211
+ ) -> None:
212
+ """Cross-input validation (finding R): every reviewer-surfaced BLOCKER
213
+ finding must appear in ``consolidated_findings`` — referenced by at least
214
+ one provenance entry, whether the synth kept it active OR dismissed it with
215
+ rationale.
216
+
217
+ This is the symmetric partner of
218
+ :func:`_validate_provenance_against_reviewers`. That function enforces
219
+ *cannot-invent* (every emitted provenance entry traces to a real reviewer
220
+ finding). This one enforces *cannot-omit* for blockers: anything substantive
221
+ that ships was either caught by no reviewer or dismissed with rationale. A
222
+ reviewer blocker must not silently vanish.
223
+
224
+ The pydantic schema deliberately allows ``consolidated_findings == []`` (it
225
+ means "the reviewers found nothing"), but the schema has no view of the input
226
+ reviewer set, so it cannot tell a legitimate empty set from a synth that
227
+ DROPPED two reviewers' blockers. With the blockers omitted,
228
+ ``has_active_blocker`` is False and the mechanical verdict returns exit 0
229
+ SHIP on a tree two reviewers blocked — a false ship (reproduced). This check
230
+ closes that hole.
231
+
232
+ Scope: BLOCKER findings only. An omitted advisory (minor/nit) finding is an
233
+ auditability loss but cannot cause a false ship — the mechanical verdict
234
+ reads only blockers — so enforcing pass-through for blockers is the
235
+ correctness-critical, false-positive-free subset of pass-through rule. Runs
236
+ after :func:`_validate_provenance_against_reviewers`, so every referenced
237
+ ``(reviewer_name, original_index)`` pair is already known-valid.
238
+
239
+ Args:
240
+ output: The parsed :class:`SynthesizerOutput`.
241
+ reviewer_results: The dispatcher's per-reviewer results. Only successful
242
+ reviewers contribute (a failed reviewer has no findings, and the
243
+ orchestrator skips synthesis on any reviewer failure).
244
+
245
+ Raises:
246
+ SynthesizerOutputError: when a reviewer blocker is referenced by no
247
+ consolidated finding's provenance. The message names the orphaned
248
+ reviewer finding(s) so the persisted ``synthesizer.error.txt`` points
249
+ the operator at the dropped blocker. Maps to exit 70.
250
+ """
251
+ # Required: every (reviewer_name, index) the reviewers themselves flagged at
252
+ # blocker — sourced from the reviewers' actual findings, not from what the
253
+ # synth claimed (the severity cross-check already proved any claim truthful).
254
+ required_blockers: set[tuple[str, int]] = set()
255
+ for r in reviewer_results:
256
+ if r.output is None:
257
+ continue
258
+ for i, finding in enumerate(r.output.findings):
259
+ if finding.severity == "blocker":
260
+ required_blockers.add((r.reviewer_name, i))
261
+
262
+ # Covered: every reviewer finding referenced by SOME consolidated finding
263
+ # (active or dismissed — a dismissed-with-rationale blocker is still a
264
+ # legitimate pass-through; the unanimous-blocker guard handles the rest).
265
+ covered: set[tuple[str, int]] = set()
266
+ for finding in output.consolidated_findings:
267
+ for prov in finding.provenance:
268
+ covered.add((prov.reviewer_name, prov.original_index))
269
+
270
+ missing = sorted(required_blockers - covered)
271
+ if missing:
272
+ detail = ", ".join(f"{name}#{idx}" for name, idx in missing)
273
+ raise SynthesizerOutputError(
274
+ f"synthesizer omitted reviewer-surfaced blocker finding(s) "
275
+ f"[{detail}] from consolidated_findings. Every reviewer blocker must "
276
+ f"be passed through (kept active OR dismissed with rationale), never "
277
+ f"dropped. The mechanical verdict reads only "
278
+ f"consolidated_findings, so a dropped blocker would falsely SHIP; "
279
+ f"check synthesizer.stdout for the raw output."
280
+ )
281
+
282
+
283
+ def _normalize_whitespace(text: str) -> str:
284
+ """Collapse whitespace runs for provenance-quote comparison.
285
+
286
+ Deliberately case- and content-PRESERVING, unlike
287
+ :func:`_normalize_finding_text`: this one asks "did the synthesizer quote
288
+ the reviewer verbatim", where a case change is a rewrite. Only reflow and
289
+ line-wrapping are forgiven.
290
+ """
291
+ return " ".join(text.split())
292
+
293
+
294
+ def _normalize_finding_text(text: str) -> str:
295
+ """Normalize reviewer finding text for exact duplicate split detection."""
296
+ return " ".join(text.casefold().split())
297
+
298
+
299
+ def _validate_duplicate_blockers_not_split_deactivated(
300
+ output: SynthesizerOutput,
301
+ reviewer_results: list[ReviewerRunResult],
302
+ ) -> None:
303
+ """Defense-in-depth split guard for exact duplicate reviewer blockers.
304
+
305
+ The schema-level unanimous-blocker rule fires only when the synth merges two
306
+ reviewers' blocker findings into one consolidated finding. A malicious or
307
+ confused synth can otherwise split the same concern into separate
308
+ single-reviewer findings and downgrade/dismiss each one; pass-through is
309
+ satisfied, but the mechanical verdict sees no active blocker.
310
+
311
+ Full semantic concern matching is intentionally out of scope here. This guard
312
+ takes the narrow mechanically defensible case: two or more DISTINCT reviewers
313
+ emitted blocker findings with the same source ``file`` and normalized
314
+ ``finding`` text. That exact duplicate must be represented by at least one
315
+ ACTIVE consolidated blocker that carries provenance for two or more of those
316
+ source findings. Anything else is a split/deactivate shape and maps to exit
317
+ 70.
318
+ """
319
+ duplicate_blockers: dict[tuple[str | None, str], set[tuple[str, int]]] = {}
320
+ for r in reviewer_results:
321
+ if r.output is None:
322
+ continue
323
+ for i, finding in enumerate(r.output.findings):
324
+ if finding.severity != "blocker":
325
+ continue
326
+ key = (finding.file, _normalize_finding_text(finding.finding))
327
+ duplicate_blockers.setdefault(key, set()).add((r.reviewer_name, i))
328
+
329
+ for (file, text), required_sources in duplicate_blockers.items():
330
+ distinct_reviewers = {name for name, _ in required_sources}
331
+ if len(distinct_reviewers) < 2:
332
+ continue
333
+
334
+ has_merged_active_blocker = False
335
+ for finding in output.consolidated_findings:
336
+ covered_here = {
337
+ (prov.reviewer_name, prov.original_index)
338
+ for prov in finding.provenance
339
+ if (prov.reviewer_name, prov.original_index) in required_sources
340
+ }
341
+ if (
342
+ len({name for name, _ in covered_here}) >= 2
343
+ and finding.severity == "blocker"
344
+ and not finding.dismissed
345
+ ):
346
+ has_merged_active_blocker = True
347
+ break
348
+
349
+ if not has_merged_active_blocker:
350
+ detail = ", ".join(f"{name}#{idx}" for name, idx in sorted(required_sources))
351
+ location = f"file={file!r}, " if file is not None else ""
352
+ raise SynthesizerOutputError(
353
+ f"synthesizer split/deactivated duplicate reviewer blocker "
354
+ f"({location}finding={text!r}) across [{detail}]. Exact duplicate "
355
+ f"blocker findings from two or more distinct reviewers must be "
356
+ f"merged into an active blocker, not split into single-reviewer "
357
+ f"downgrades/dismissals; check synthesizer.stdout for the raw output."
358
+ )
359
+
360
+
361
+ def _validate_cluster_quotes_against_reviewers(
362
+ output: SynthesizerOutput,
363
+ reviewer_results: list[ReviewerRunResult],
364
+ ) -> None:
365
+ """Cross-input validation: every root-cause cluster's evidence
366
+ ``quote`` must be a VERBATIM substring of its member finding's
367
+ reviewer-original text.
368
+
369
+ Mirrors :func:`_validate_provenance_against_reviewers`' ``reviewer_results``
370
+ plumbing but deliberately sets a HIGHER bar. That function intentionally
371
+ does NOT substring-validate ``original_description`` — paraphrase is
372
+ legitimate there ("how each reviewer framed the same concern"). Cluster
373
+ quotes go the other way ON PURPOSE: verbatim grounding is exactly what makes
374
+ a cluster zero-invention (the synth groups + quotes; it authors no cause or
375
+ fix). Do not loosen this to match provenance's looser check.
376
+
377
+ For each cluster member, the quote must be a verbatim substring of at least
378
+ one of that member's source reviewer findings' ``finding`` text (located via
379
+ the member's ``provenance``). Runs after
380
+ :func:`_validate_provenance_against_reviewers` (so each provenance entry is
381
+ known-valid) and after
382
+ schema validation (so the cluster's member indices are in range and match
383
+ its evidence set).
384
+
385
+ Args:
386
+ output: The parsed :class:`SynthesizerOutput`.
387
+ reviewer_results: The dispatcher's per-reviewer results (same plumbing
388
+ as the provenance check). Only successful reviewers contribute.
389
+
390
+ Raises:
391
+ SynthesizerOutputError: when a quote is not a verbatim substring of any
392
+ of its member's reviewer-original finding texts. The message names
393
+ the cluster + finding so the persisted ``synthesizer.error.txt``
394
+ points the operator at the offending quote. Maps to exit 70.
395
+ """
396
+ # reviewer_name → list of original finding texts (the verbatim source).
397
+ findings_text_by_name: dict[str, list[str]] = {}
398
+ for r in reviewer_results:
399
+ if r.output is None:
400
+ continue
401
+ findings_text_by_name[r.reviewer_name] = [f.finding for f in r.output.findings]
402
+
403
+ for cluster_idx, cluster in enumerate(output.root_cause_clusters):
404
+ for ev in cluster.evidence:
405
+ member = output.consolidated_findings[ev.finding_index]
406
+ # Gather the reviewer-original texts this member traces to.
407
+ sources: list[str] = []
408
+ for prov in member.provenance:
409
+ texts = findings_text_by_name.get(prov.reviewer_name, [])
410
+ if 0 <= prov.original_index < len(texts):
411
+ sources.append(texts[prov.original_index])
412
+ if not any(ev.quote in src for src in sources):
413
+ raise SynthesizerOutputError(
414
+ f"root_cause_clusters[{cluster_idx}] evidence quote for "
415
+ f"consolidated finding {ev.finding_index} is not a verbatim "
416
+ f"substring of any of that finding's reviewer-original texts "
417
+ f"(quote={ev.quote!r}). A cluster quote must be grounded in "
418
+ f"the reviewers' own words (zero-invention); the synthesizer "
419
+ f"may have paraphrased or fabricated it. Check "
420
+ f"synthesizer.stdout for the raw output."
421
+ )
@@ -0,0 +1,208 @@
1
+ """Cold-synth workspace setup helpers.
2
+
3
+ The load-bearing cold-synth invariant is that the subprocess cannot *read* the
4
+ repo root: the workspace lives in a fresh tempdir, cwd/-C/--add-dir are scoped to
5
+ it, and ``permissions=trusted-execute`` keeps the codex sandbox scoped there too
6
+ (see :mod:`syncade.synthesizer.driver`). That holds independently of the env.
7
+
8
+ The env scrub is defense-in-depth on top: it removes repo-root path references so
9
+ the subprocess cannot even *discover* where the repo lives — with ONE deliberate
10
+ exception, :data:`_AUTH_LOCATOR_KEYS` (``CODEX_HOME``). A credential-locator var
11
+ must survive the scrub or the login the auth gate probed is not the login that
12
+ runs (verified != runtime; see :mod:`syncade.auth_preflight`). So a *repo-local*
13
+ ``CODEX_HOME`` — an unusual config where the user parked codex's login dir inside
14
+ the repo under review — does pass its path (which references repo_root) through to
15
+ the cold subprocess. That weakens *discovery* only: the sandbox above still
16
+ prevents the synth from reading anything under it, so blindness is intact. The
17
+ alternative — stripping it — silently swaps the user's codex credential for
18
+ whatever ``~/.codex`` holds, which is the worse failure.
19
+
20
+ - :data:`_PATH_LIST_ENV_KEYS` — env keys whose values are path lists. Filtered
21
+ per segment so absolute repo-local entries get removed without dropping
22
+ unrelated sibling paths.
23
+ - :func:`_path_is_relative_to` / :func:`_value_references_repo_path`
24
+ — boundary-aware path-match helpers used by :func:`_scrub_env_for_cold_synth`.
25
+ Both RESOLVE the candidate path before testing containment, so a symlink alias
26
+ of the repo root (macOS ``/tmp`` -> ``/private/tmp``) cannot slip through.
27
+ - :func:`_scrub_env_for_cold_synth` — drops ``PWD`` / ``OLDPWD``,
28
+ filters path-list vars, and drops scalar vars whose value references
29
+ the repo root, EXCEPT the :data:`_AUTH_LOCATOR_KEYS` credential locators
30
+ (see the module note above on why they survive).
31
+ - :func:`_init_workspace_git` — runs ``git init -q`` in the workspace
32
+ so codex's ``permissions=trusted-execute`` mode launches.
33
+
34
+ The two underscore-prefixed names (``_scrub_env_for_cold_synth``,
35
+ ``_init_workspace_git``) are imported by tests and by :mod:`syncade.spec_audit`.
36
+ The package ``__init__.py`` re-exports them so existing imports keep working.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import os
42
+ import re
43
+ from pathlib import Path
44
+
45
+ from syncade.process import SubprocessError, run_subprocess
46
+
47
+ _AUTH_LOCATOR_KEYS = frozenset({"CODEX_HOME"})
48
+ """Vars that tell a provider CLI WHERE its stored credential lives. Kept through the cold
49
+ scrub even when repo-local: stripping them re-auths the subprocess, so the mode the auth
50
+ gate probed (parent env) would differ from the mode that runs (scrubbed env). CODEX_HOME is
51
+ the codex login dir. Anthropic auth is a key VALUE (ANTHROPIC_API_KEY) or the OS keychain,
52
+ not an env-var path, so it needs no entry here."""
53
+
54
+ _PATH_LIST_ENV_KEYS = frozenset(
55
+ {
56
+ "PATH",
57
+ "PYTHONPATH",
58
+ "NODE_PATH",
59
+ "CDPATH",
60
+ "MANPATH",
61
+ "INFOPATH",
62
+ "LD_LIBRARY_PATH",
63
+ "DYLD_LIBRARY_PATH",
64
+ "PKG_CONFIG_PATH",
65
+ }
66
+ )
67
+
68
+
69
+ def _path_is_relative_to(path: Path, parent: Path) -> bool:
70
+ """Return whether ``path`` is within ``parent`` after resolution."""
71
+ try:
72
+ path.resolve(strict=False).relative_to(parent.resolve(strict=False))
73
+ except (OSError, RuntimeError, ValueError):
74
+ return False
75
+ return True
76
+
77
+
78
+ _VALUE_TOKEN_SPLIT = re.compile(r"[\s'\"=" + re.escape(os.pathsep) + r"]+")
79
+
80
+
81
+ def _value_references_repo_path(value: str, resolved_repo_root: Path) -> bool:
82
+ """Detect repo-root path references without sibling-prefix false positives.
83
+
84
+ Splits ``value`` into path-like tokens and RESOLVES each one before testing
85
+ containment, rather than string-matching spellings of the repo root.
86
+
87
+ Resolving the value, not the root, is the only way to catch symlink aliases:
88
+ on macOS ``/tmp`` is a symlink to ``/private/tmp``, so a parent process's
89
+ ``VIRTUAL_ENV=/tmp/<repo>/.venv`` refers to a repo root the orchestrator
90
+ knows as ``/private/tmp/<repo>``. Matching root spellings cannot see that —
91
+ you would have to enumerate every aliased spelling of the root, which is
92
+ unbounded. Resolving the token collapses both spellings to the same real
93
+ path and the containment test just works, in either direction.
94
+
95
+ This is also how the path-list branch of :func:`_scrub_env_for_cold_synth`
96
+ has always worked (via :func:`_path_is_relative_to`); the scalar branch was
97
+ the one doing string comparison, and it was the one that leaked.
98
+
99
+ Boundary correctness comes free: ``relative_to`` on resolved paths cannot
100
+ produce a sibling-prefix false positive the way a substring scan can
101
+ (``/private/tmp/repo-sibling`` is not within ``/private/tmp/repo``).
102
+ """
103
+ for token in _VALUE_TOKEN_SPLIT.split(value):
104
+ if not token:
105
+ continue
106
+ candidate = Path(token).expanduser()
107
+ if candidate.is_absolute() and _path_is_relative_to(candidate, resolved_repo_root):
108
+ return True
109
+ return False
110
+
111
+
112
+ def _scrub_env_for_cold_synth(env: dict[str, str], repo_root: Path) -> dict[str, str]:
113
+ """Strip environment variables that would leak ``repo_root`` to
114
+ the synth subprocess.
115
+
116
+ Workspace-scoped cwd/``-C``/``--add-dir`` are one half of that; this is the
117
+ other. The parent process's env typically carries:
118
+
119
+ - ``PWD`` and ``OLDPWD`` from the shell — both contain the cwd
120
+ at invocation time, which is normally inside the repo
121
+ (``syncade`` is invoked from the repo or one of its
122
+ subdirs). Dropped unconditionally.
123
+ - Path-list vars like ``PATH`` and ``PYTHONPATH`` — split by
124
+ :data:`os.pathsep`; only absolute segments contained by ``repo_root`` are
125
+ removed. Sibling paths such as
126
+ ``/tmp/syncade-dev/bin`` and relative child-workspace segments
127
+ are preserved.
128
+ - Other vars like ``VIRTUAL_ENV`` or tool cache settings that may
129
+ reference a repo-local path. Dropped when their value contains
130
+ ``repo_root`` as a path-boundary-delimited reference, not as an
131
+ arbitrary substring.
132
+
133
+ What stays: non-repo ``PATH`` segments (codex needs them to find
134
+ its own binaries and any tools it shells out to), ``HOME`` (codex
135
+ auth state lives under ``~/.codex/``), and everything else that
136
+ doesn't reference the repo.
137
+
138
+ Args:
139
+ env: The parent invocation env.
140
+ repo_root: The git repo root whose path string should not
141
+ leak into the synth subprocess's environment.
142
+
143
+ Returns:
144
+ A new dict whose values do not expose paths inside
145
+ ``repo_root``.
146
+ """
147
+ resolved_repo_root = repo_root.resolve(strict=False)
148
+ scrubbed: dict[str, str] = {}
149
+ for key, value in env.items():
150
+ if key in {"PWD", "OLDPWD"}:
151
+ continue
152
+ if key in _AUTH_LOCATOR_KEYS:
153
+ # An auth-credential LOCATOR, kept even when it points inside the repo.
154
+ # Stripping CODEX_HOME because a user set it to a repo-local path silently
155
+ # changes which credential codex uses for the cold actor — so the mode the
156
+ # auth gate PROBED (with the parent env) is no longer the mode that RUNS. The
157
+ # guardrail's whole promise is "verified == runtime"; a scrub that re-auths the
158
+ # subprocess breaks it. CODEX_HOME is codex's own config dir, not the source
159
+ # under review, so preserving it does not weaken cold isolation (which is
160
+ # enforced by cwd / -C / --add-dir scope, not by hiding this path).
161
+ scrubbed[key] = value
162
+ continue
163
+ if key in _PATH_LIST_ENV_KEYS:
164
+ kept_segments = [
165
+ segment
166
+ for segment in value.split(os.pathsep)
167
+ if not segment
168
+ or not Path(segment).expanduser().is_absolute()
169
+ or not _path_is_relative_to(Path(segment).expanduser(), resolved_repo_root)
170
+ ]
171
+ if kept_segments:
172
+ scrubbed[key] = os.pathsep.join(kept_segments)
173
+ continue
174
+ if _value_references_repo_path(value, resolved_repo_root):
175
+ continue
176
+ scrubbed[key] = value
177
+ return scrubbed
178
+
179
+
180
+ def _init_workspace_git(workspace: Path) -> None:
181
+ """Initialize ``workspace`` as an empty git working tree.
182
+
183
+ Codex with ``permissions=trusted-execute`` refuses to launch unless cwd is a
184
+ git working tree, failing with ``Not inside a trusted directory and
185
+ --skip-git-repo-check was not specified``.
186
+ The synth workspace is a fresh tempdir; this helper runs
187
+ ``git init -q`` in it before launching codex.
188
+
189
+ Failures surface as :class:`~syncade.process.SubprocessError`-bucket
190
+ exceptions that the caller maps to a SynthesizerResult failure.
191
+
192
+ No commits are made — the workspace stays as a fresh repo
193
+ with one untracked file (the copied PR doc). Codex's trusted-
194
+ mode check is satisfied by the ``.git/`` directory's presence.
195
+ """
196
+ git_init = run_subprocess(
197
+ ["git", "init", "-q"],
198
+ cwd=workspace,
199
+ env=None,
200
+ timeout=10.0,
201
+ input_text=None,
202
+ )
203
+ if git_init.returncode != 0:
204
+ raise SubprocessError(
205
+ f"synthesizer: failed to git-init the cold workspace at "
206
+ f"{workspace}: git init exited with code "
207
+ f"{git_init.returncode}; stderr: {git_init.stderr[:200]!r}"
208
+ )
@@ -0,0 +1,13 @@
1
+ # Preset: balanced (PR-v2-9) — the proven, shipped defaults.
2
+ #
3
+ # Behaviourally identical to a zero-config run: `--preset balanced` changes nothing on its own.
4
+ # It exists as the honest starting point — copy it, then deviate ONE knob at a time. The values
5
+ # below are pinned to the shipped defaults by a drift test (test_presets.py), so this file can
6
+ # never quietly diverge from what a plain `syncade <pr-doc>` does.
7
+ #
8
+ # Presets vary ONLY loop dimensions (rounds / timeout). They NEVER touch the reviewer model or
9
+ # effort tier — a cheaper panel that audits leniently is the pr-29 hazard, and a preset must not
10
+ # reintroduce it. Set `[[reviewers]]` yourself in .syncade/config.toml if you truly want that.
11
+ [loop]
12
+ max_rounds = 5 # full loop: reviewers -> synthesizer -> optional test -> producer-if-NO-SHIP
13
+ timeout_seconds = 1800 # 30 min per subprocess (the default)
@@ -0,0 +1,12 @@
1
+ # Preset: cheap (PR-v2-9) — a fast, low-cost single-pass check.
2
+ #
3
+ # Trades ROUNDS for cost: one round of reviewers -> synthesizer, with NO producer loop. That is the
4
+ # structurally cheapest honest config — a single review pass at the SAME proven reviewer model and
5
+ # effort tier. It deliberately does NOT lower the effort tier or swap in a cheaper model (the pr-29
6
+ # leniency hazard), and it sets no ADDITIONAL token/dollar budget beyond the default ceiling
7
+ # (budget_tokens = 50,000,000 from the global default; set budget_tokens = 0 in your
8
+ # .syncade/config.toml to disable it).
9
+ #
10
+ # Layer your own knobs on top in .syncade/config.toml — the user file always wins over the preset.
11
+ [loop]
12
+ max_rounds = 1 # single pass: no producer subprocess, no second round
@@ -0,0 +1,9 @@
1
+ # Preset: thorough (PR-v2-9) — full rounds plus extra per-subprocess wall-clock for large diffs.
2
+ #
3
+ # Trades TIME for depth: the full producer loop (max_rounds = 5, the default) and double
4
+ # the per-subprocess timeout, so a heavy reviewer working a large diff is not SIGKILLed mid-review. It
5
+ # holds the reviewer model + effort tier at the SAME proven level as balanced — "thorough" buys more
6
+ # rounds and more time, never a different (or more expensive) panel.
7
+ [loop]
8
+ max_rounds = 5 # the full producer loop (5 is the default; ceiling is 10)
9
+ timeout_seconds = 3600 # 60 min per subprocess — double the default, for big diffs