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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- 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
|