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,279 @@
1
+ """Mechanical per-round verdict and phase-failure classification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from syncade.adapters.base import ReviewerInvocationError
6
+ from syncade.adapters.registry import UnknownProviderError
7
+ from syncade.dispatcher import DispatchResult
8
+ from syncade.exit_codes import (
9
+ CLARIFICATION_NEEDED,
10
+ CONFIG_ERROR,
11
+ FINDINGS_PRESENT,
12
+ REVIEWER_FAILURE,
13
+ REVIEWER_OUTPUT_UNPARSEABLE,
14
+ SUCCESS,
15
+ WORKTREE_ERROR,
16
+ )
17
+ from syncade.findings import ReviewerOutputError
18
+ from syncade.process import (
19
+ SubprocessError,
20
+ SubprocessNotFoundError,
21
+ SubprocessTimeoutError,
22
+ )
23
+ from syncade.retry import is_usage_limit_error
24
+ from syncade.synthesis import SynthesizerOutput, SynthesizerOutputError, has_active_blocker
25
+ from syncade.synthesizer import SynthesizerResult
26
+ from syncade.test_runner import TestRunResult, is_blocking_check_subprocess_error
27
+
28
+ from .results import RoundResult, TerminationReason
29
+
30
+
31
+ def error_is_provider_usage_limit(error: BaseException | None) -> bool:
32
+ """One actor's error, one question — so every actor asks it the same way."""
33
+ return error is not None and is_usage_limit_error(error)
34
+
35
+
36
+ def round_hit_provider_usage_limit(round_result: RoundResult) -> bool:
37
+ """Did this round fail because the PROVIDER refused on exhausted quota? (PR-h-field-02)
38
+
39
+ Distinct from ``budget_exceeded``, which is the operator's own configured ceiling. Both
40
+ exit 25 — stop cleanly, resume later — but conflating them would tell someone to raise a
41
+ budget they never hit.
42
+
43
+ Not retried and not permanent: the classifier keeps quota out of ``_TRANSIENT_MARKERS`` on
44
+ purpose, so no attempts are burned against a window that has not moved, and the run stays
45
+ resumable instead of dying at exit 40.
46
+
47
+ Covers the REVIEWERS and the JUDGE. The first version checked only reviewers and the blind
48
+ panel caught it unanimously — the same refusal reaching the judge still died at exit 40. The
49
+ PRODUCER is checked at its own branch in the loop, because it is decided after this one, and
50
+ it is the likeliest of the three to exhaust a limit: 6.6M tokens in a single field round.
51
+ """
52
+ if any(error_is_provider_usage_limit(r.error) for r in round_result.dispatch_result.results):
53
+ return True
54
+ synth = round_result.synth_result
55
+ return synth is not None and error_is_provider_usage_limit(synth.error)
56
+
57
+
58
+ def _classify_phase_failure(round_result: RoundResult) -> TerminationReason:
59
+ """Map a phase-failure round_exit_code to a termination_reason.
60
+
61
+ Called from the loop terminator when ``round_exit_code`` is
62
+ one of {40, 50, 60, 70}. Inspects the round's per-phase
63
+ outcomes to distinguish which phase actually failed (e.g.
64
+ exit 40 could be reviewer / synth / test-subprocess; exit 70
65
+ could be reviewer-parse / synth-parse).
66
+ """
67
+ exit_code = round_result.round_exit_code
68
+ if exit_code == CONFIG_ERROR:
69
+ return "config_error"
70
+ if exit_code == WORKTREE_ERROR:
71
+ if round_result.fail_closed_headers is not None:
72
+ return "diff_malformed"
73
+ if round_result.oversize_diff_bytes is not None:
74
+ return "diff_too_large"
75
+ if round_result.oversize_prompt_chars is not None:
76
+ return "prompt_too_large"
77
+ return "worktree_error"
78
+ if exit_code == REVIEWER_OUTPUT_UNPARSEABLE:
79
+ return "parse_failure"
80
+ if exit_code == REVIEWER_FAILURE:
81
+ # Three flavors of 40: reviewer subprocess, synth subprocess,
82
+ # test subprocess. Distinguish by which phase has an error.
83
+ if not round_result.dispatch_result.all_succeeded:
84
+ return "reviewer_failure"
85
+ if round_result.synth_result is not None and round_result.synth_result.error is not None:
86
+ return "synth_failure"
87
+ if (
88
+ round_result.test_result is not None
89
+ and round_result.test_result.outcome == "subprocess_error"
90
+ ):
91
+ return "test_subprocess_error"
92
+ # A blocking mechanical check whose subprocess could not run is its
93
+ # own phase, distinct from a reviewer subprocess failure.
94
+ if any(
95
+ is_blocking_check_subprocess_error(c.severity, c.outcome)
96
+ for c in round_result.check_results
97
+ ):
98
+ return "check_subprocess_error"
99
+ # Fall-through default: treat as reviewer failure.
100
+ return "reviewer_failure"
101
+ # Shouldn't happen — caller filters to {40,50,60,70} before
102
+ # calling. Defensive fallback.
103
+ return "reviewer_failure"
104
+
105
+
106
+ def _compute_exit_code(
107
+ dispatch_result: DispatchResult,
108
+ synth_result: SynthesizerResult | None,
109
+ test_result: TestRunResult | None = None,
110
+ blocking_check_results: list[TestRunResult] | None = None,
111
+ ) -> int:
112
+ """Compute the per-round mechanical exit code."""
113
+ failures = dispatch_result.failures
114
+
115
+ for r in failures:
116
+ if isinstance(r.error, UnknownProviderError):
117
+ return CONFIG_ERROR
118
+
119
+ for r in failures:
120
+ if isinstance(r.error, ReviewerOutputError):
121
+ return REVIEWER_OUTPUT_UNPARSEABLE
122
+
123
+ for r in failures:
124
+ if isinstance(
125
+ r.error,
126
+ (
127
+ ReviewerInvocationError,
128
+ SubprocessNotFoundError,
129
+ SubprocessTimeoutError,
130
+ SubprocessError,
131
+ ),
132
+ ):
133
+ return REVIEWER_FAILURE
134
+
135
+ if failures:
136
+ return REVIEWER_FAILURE
137
+
138
+ successes = dispatch_result.successes
139
+ if not successes:
140
+ return CONFIG_ERROR
141
+
142
+ if synth_result is None:
143
+ raise RuntimeError(
144
+ "_compute_exit_code: synth_result is None but every reviewer "
145
+ "succeeded. The orchestrator runs the synthesizer phase "
146
+ "whenever dispatch_result.all_succeeded is True; receiving "
147
+ "synth_result=None on this path means a caller bypassed "
148
+ "run_review's lifecycle."
149
+ )
150
+
151
+ if isinstance(synth_result.error, SynthesizerOutputError):
152
+ return REVIEWER_OUTPUT_UNPARSEABLE
153
+ if isinstance(
154
+ synth_result.error,
155
+ (
156
+ ReviewerInvocationError,
157
+ SubprocessNotFoundError,
158
+ SubprocessTimeoutError,
159
+ SubprocessError,
160
+ ),
161
+ ):
162
+ return REVIEWER_FAILURE
163
+ if synth_result.error is not None:
164
+ return REVIEWER_FAILURE
165
+
166
+ if synth_result.output is None:
167
+ raise RuntimeError(
168
+ "_compute_exit_code: synth_result has neither output nor "
169
+ "error — SynthesizerResult contract violated"
170
+ )
171
+ # Fold blocking mechanical-check results in alongside the test leg. Advisory
172
+ # results are never passed in by the caller and cannot gate the verdict.
173
+ mechanical = ([test_result] if test_result is not None else []) + list(
174
+ blocking_check_results or []
175
+ )
176
+ # A subprocess_error means the harness could not run, so it outranks every
177
+ # determinate verdict, including an active synth blocker.
178
+ if any(m.outcome == "subprocess_error" for m in mechanical):
179
+ return REVIEWER_FAILURE
180
+
181
+ if has_active_blocker(synth_result.output):
182
+ return FINDINGS_PRESENT
183
+
184
+ # A gate that ran and FAILED (a real defect → exit 30) is the same
185
+ # determinate NO-SHIP signal as a synth blocker; checked after it.
186
+ if any(m.outcome == "failed" for m in mechanical):
187
+ return FINDINGS_PRESENT
188
+
189
+ if _multi_reviewer_blockers_all_deactivated(dispatch_result):
190
+ return CLARIFICATION_NEEDED
191
+
192
+ return SUCCESS
193
+
194
+
195
+ def _multi_reviewer_blockers_all_deactivated(dispatch_result: DispatchResult) -> bool:
196
+ """True when two or more DISTINCT reviewers each raised at least one
197
+ blocker-severity finding and none survived as an active blocker.
198
+
199
+ PR-h-01 increment D — the paraphrase guard, and the reason it is shaped
200
+ this way. The unanimous-blocker rule and the exact-duplicate split guard
201
+ both need to know that two reviewers named the SAME concern. Two reviewers
202
+ describing one bug in different words is the normal case — consolidating
203
+ that is the synthesizer's whole job — so the synthesizer can emit them as
204
+ two single-reviewer findings, dismiss each on its own merits, and neither
205
+ guard fires. ``has_active_blocker`` then sees nothing and the round is a
206
+ clean SHIP.
207
+
208
+ Semantic identity has no cheap correct implementation, and a similarity
209
+ heuristic manufactures FALSE locks — the worst possible failure for a tool
210
+ whose value is that its refusals mean something. So this does not attempt
211
+ to decide whether two findings are the same concern. It asks a question
212
+ with a mechanical answer: did multiple independent reviewers each say
213
+ "blocker", and did every one of those get deactivated?
214
+
215
+ That shape is not necessarily wrong — but it is not a verdict a machine
216
+ should render silently, so it escalates to exit 10 (decision needed) rather
217
+ than either shipping or blocking. It cannot false-lock: the worst case is a
218
+ human being asked to look. Callers reach this only when
219
+ ``has_active_blocker`` is already False, so "none survived" needs no
220
+ separate check.
221
+
222
+ Deliberately silent when: only one reviewer raised a blocker (the
223
+ synthesizer's dismissal authority over a single-source blocker is
224
+ intentional), no reviewer raised one, or any blocker is still active (that
225
+ round is already exit 30).
226
+ """
227
+ reviewers_with_blockers = {
228
+ r.reviewer_name
229
+ for r in dispatch_result.successes
230
+ if r.output is not None and any(f.severity == "blocker" for f in r.output.findings)
231
+ }
232
+ return len(reviewers_with_blockers) >= 2
233
+
234
+
235
+ def deactivated_blocker_details(
236
+ dispatch_result: DispatchResult,
237
+ synth_output: SynthesizerOutput,
238
+ ) -> list[tuple[str, str, str]]:
239
+ """``(reviewer_name, verbatim_finding_text, disposition)`` for every source
240
+ blocker, for the exit-10 operator document.
241
+
242
+ The text is the REVIEWER's own, read from the parsed reviewer output rather
243
+ than from synthesizer-supplied provenance — the operator is being asked to
244
+ judge the synthesizer, so quoting the synthesizer back at them would beg the
245
+ question. (Increment C makes the two provably equal anyway; reading the
246
+ source directly means this stays true if that check is ever relaxed.)
247
+
248
+ ``disposition`` reports what the synthesizer did with it, resolved by
249
+ matching provenance on ``(reviewer_name, original_index)``. A source blocker
250
+ with no consolidated finding at all is reported as dropped, which is its own
251
+ kind of answer.
252
+ """
253
+ details: list[tuple[str, str, str]] = []
254
+ for r in dispatch_result.successes:
255
+ if r.output is None:
256
+ continue
257
+ for idx, source in enumerate(r.output.findings):
258
+ if source.severity != "blocker":
259
+ continue
260
+ details.append((r.reviewer_name, source.finding, _disposition(synth_output, r, idx)))
261
+ return details
262
+
263
+
264
+ def _disposition(synth_output: SynthesizerOutput, reviewer, idx: int) -> str:
265
+ for consolidated in synth_output.consolidated_findings:
266
+ if not any(
267
+ p.reviewer_name == reviewer.reviewer_name and p.original_index == idx
268
+ for p in consolidated.provenance
269
+ ):
270
+ continue
271
+ if consolidated.dismissed:
272
+ rationale = (consolidated.dismissal_rationale or "").strip()
273
+ return f"dismissed — {rationale}" if rationale else "dismissed"
274
+ if consolidated.severity != "blocker":
275
+ rationale = (consolidated.severity_change_rationale or "").strip()
276
+ downgrade = f"downgraded to {consolidated.severity}"
277
+ return f"{downgrade} — {rationale}" if rationale else downgrade
278
+ return f"kept at {consolidated.severity}"
279
+ return "dropped — no consolidated finding carries provenance for it"
@@ -0,0 +1,189 @@
1
+ """On-disk persistence of dispatch + synthesizer results.
2
+
3
+ Materializes a :class:`~syncade.dispatcher.DispatchResult` and
4
+ :class:`~syncade.synthesizer.SynthesizerResult` into the
5
+ ``.syncade/runs/<run-id>/round-N/`` layout the PRD specifies. The
6
+ orchestrator is the only production caller; tests construct
7
+ synthesized inputs directly so they can exercise persistence without
8
+ spawning real subprocesses.
9
+
10
+ File layout per reviewer (one set per ``ReviewerRunResult``):
11
+
12
+ - ``<round_dir>/<name>.stdout`` — raw subprocess stdout (always
13
+ written; empty when no
14
+ subprocess ran)
15
+ - ``<round_dir>/<name>.stderr`` — raw subprocess stderr (same)
16
+ - ``<round_dir>/<name>.parsed.json`` — :class:`~syncade.findings.ReviewerOutput` as JSON
17
+ (success path only)
18
+ - ``<round_dir>/<name>.error.txt`` — exception class + message
19
+ + traceback (failure path
20
+ only)
21
+
22
+ This adds synthesizer artifacts:
23
+
24
+ - ``<round_dir>/synthesizer.stdout`` — raw codex stdout
25
+ - ``<round_dir>/synthesizer.stderr`` — raw codex stderr
26
+ - ``<round_dir>/synthesizer.parsed.json`` — :class:`~syncade.synthesis.SynthesizerOutput`
27
+ as JSON (success path)
28
+ - ``<round_dir>/synthesizer.error.txt`` — exception + traceback
29
+ (failure path)
30
+ - ``<round_dir>/findings.md`` — operator-facing
31
+ consolidated review
32
+ report (only when synth
33
+ succeeded)
34
+
35
+ This adds test re-run artifacts (opt-in; written only when the
36
+ operator configured ``[loop] test_command`` AND every prior phase
37
+ succeeded AND the synthesizer was clean):
38
+
39
+ - ``<round_dir>/test-run.stdout`` — captured test command
40
+ stdout
41
+ - ``<round_dir>/test-run.stderr`` — captured test command
42
+ stderr
43
+ - ``<round_dir>/test-run.exit-code.txt`` — one-line integer (or
44
+ ``-1\\n`` for the
45
+ subprocess-error path)
46
+ so manifest-readers
47
+ can grab it without
48
+ parsing JSON
49
+
50
+ Plus one file per round:
51
+
52
+ - ``<round_dir>/manifest.json`` — round-level summary written
53
+ by :func:`persist_round_manifest`
54
+ - ``<round_dir>/summary.md`` — human-readable dashboard
55
+ written by
56
+ :func:`persist_run_summary`
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ from ._markdown import (
62
+ _md_command_lines as _md_command_lines,
63
+ )
64
+ from ._markdown import (
65
+ _md_inline_code as _md_inline_code,
66
+ )
67
+ from .checks import (
68
+ CheckArtifactPaths as CheckArtifactPaths,
69
+ )
70
+ from .checks import (
71
+ persist_check_result as persist_check_result,
72
+ )
73
+ from .decision_needed import (
74
+ DECISION_NEEDED_FILENAME as DECISION_NEEDED_FILENAME,
75
+ )
76
+ from .decision_needed import (
77
+ OPERATOR_DECISION_FILENAME as OPERATOR_DECISION_FILENAME,
78
+ )
79
+ from .decision_needed import (
80
+ persist_deactivated_blockers_decision_needed as persist_deactivated_blockers_decision_needed,
81
+ )
82
+ from .decision_needed import (
83
+ persist_decision_needed as persist_decision_needed,
84
+ )
85
+ from .decision_needed import (
86
+ read_operator_decision as read_operator_decision,
87
+ )
88
+ from .findings_md import (
89
+ persist_current_findings_md as persist_current_findings_md,
90
+ )
91
+ from .findings_md import (
92
+ persist_findings_md as persist_findings_md,
93
+ )
94
+ from .handoff import (
95
+ persist_handoff as persist_handoff,
96
+ )
97
+ from .last_reviewed import (
98
+ LAST_REVIEWED_FILENAME as LAST_REVIEWED_FILENAME,
99
+ )
100
+ from .last_reviewed import (
101
+ persist_last_reviewed as persist_last_reviewed,
102
+ )
103
+ from .last_reviewed import (
104
+ read_last_reviewed as read_last_reviewed,
105
+ )
106
+ from .loop_manifest import (
107
+ persist_loop_manifest as persist_loop_manifest,
108
+ )
109
+ from .loop_summary import (
110
+ persist_loop_summary as persist_loop_summary,
111
+ )
112
+ from .producer import (
113
+ ProducerArtifactPaths as ProducerArtifactPaths,
114
+ )
115
+ from .producer import (
116
+ persist_producer_result as persist_producer_result,
117
+ )
118
+ from .reviewer import (
119
+ persist_dispatch_record as persist_dispatch_record,
120
+ )
121
+ from .reviewer import (
122
+ persist_reviewer_result as persist_reviewer_result,
123
+ )
124
+ from .reviewer import (
125
+ record_child_pid as record_child_pid,
126
+ )
127
+ from .round_manifest import (
128
+ persist_round_manifest as persist_round_manifest,
129
+ )
130
+ from .run_init import (
131
+ RUN_INIT_FILENAME as RUN_INIT_FILENAME,
132
+ )
133
+ from .run_init import (
134
+ persist_run_init as persist_run_init,
135
+ )
136
+ from .run_summary import (
137
+ persist_run_summary as persist_run_summary,
138
+ )
139
+ from .synth import (
140
+ SynthesizerArtifactPaths as SynthesizerArtifactPaths,
141
+ )
142
+ from .synth import (
143
+ persist_synthesizer_result as persist_synthesizer_result,
144
+ )
145
+ from .test_run import (
146
+ TEST_RUN_NAME as TEST_RUN_NAME,
147
+ )
148
+ from .test_run import (
149
+ TestRunArtifactPaths as TestRunArtifactPaths,
150
+ )
151
+ from .test_run import (
152
+ persist_test_run_result as persist_test_run_result,
153
+ )
154
+
155
+ # Public re-export surface. The 2 underscore-prefixed helpers
156
+ # (_md_inline_code, _md_command_lines) are intentionally NOT in
157
+ # __all__ per Python convention, but they remain importable from
158
+ # syncade.persistence for tests that reach into them directly.
159
+ __all__ = [
160
+ "CheckArtifactPaths",
161
+ "DECISION_NEEDED_FILENAME",
162
+ "LAST_REVIEWED_FILENAME",
163
+ "OPERATOR_DECISION_FILENAME",
164
+ "persist_last_reviewed",
165
+ "read_last_reviewed",
166
+ "ProducerArtifactPaths",
167
+ "RUN_INIT_FILENAME",
168
+ "SynthesizerArtifactPaths",
169
+ "TEST_RUN_NAME",
170
+ "TestRunArtifactPaths",
171
+ "persist_check_result",
172
+ "persist_current_findings_md",
173
+ "persist_deactivated_blockers_decision_needed",
174
+ "persist_decision_needed",
175
+ "persist_findings_md",
176
+ "read_operator_decision",
177
+ "persist_handoff",
178
+ "persist_loop_manifest",
179
+ "persist_loop_summary",
180
+ "persist_producer_result",
181
+ "persist_dispatch_record",
182
+ "record_child_pid",
183
+ "persist_reviewer_result",
184
+ "persist_round_manifest",
185
+ "persist_run_init",
186
+ "persist_run_summary",
187
+ "persist_synthesizer_result",
188
+ "persist_test_run_result",
189
+ ]
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+
9
+ def atomic_write_text(path: Path, text: str) -> None:
10
+ path.parent.mkdir(parents=True, exist_ok=True)
11
+ fd, tmp_name = tempfile.mkstemp(
12
+ prefix=f".{path.name}.",
13
+ suffix=".tmp",
14
+ dir=path.parent,
15
+ text=True,
16
+ )
17
+ tmp_path = Path(tmp_name)
18
+ try:
19
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
20
+ handle.write(text)
21
+ handle.flush()
22
+ os.fsync(handle.fileno())
23
+ os.replace(tmp_path, path)
24
+ except BaseException:
25
+ try:
26
+ tmp_path.unlink()
27
+ except FileNotFoundError:
28
+ pass
29
+ raise
30
+
31
+
32
+ def atomic_write_json(path: Path, data: object, *, sort_keys: bool = False) -> None:
33
+ atomic_write_text(path, json.dumps(data, indent=2, sort_keys=sort_keys) + "\n")
@@ -0,0 +1,70 @@
1
+ """Root-cause cluster rendering for findings.md.
2
+
3
+ Renders the descriptive-only ``## Root-cause clusters`` section that
4
+ ``persist_findings_md`` places ABOVE the individual findings, so the producer
5
+ reads "these N findings are one root cause — here is what each reviewer said
6
+ about it" before reading them individually.
7
+
8
+ Lives in its own module (called by ``findings_md.py``) per the LOC discipline —
9
+ ``findings_md.py`` was already at the ~500-LOC cap. The renderer is purely
10
+ descriptive: it surfaces the synth's grouping + the verbatim reviewer quotes and
11
+ authors nothing (the synth emits no cause or fix, and neither does this).
12
+
13
+ Returns ``[]`` when there are no clusters.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from syncade.synthesis import SynthesizerOutput
19
+
20
+
21
+ def render_cluster_section(output: SynthesizerOutput) -> list[str]:
22
+ """Return the ``## Root-cause clusters`` section as a list of lines, or
23
+ ``[]`` when ``output.root_cause_clusters`` is empty.
24
+
25
+ For each cluster: the ``anchor_file`` (+ the optional ``label``, itself a
26
+ verbatim quote excerpt), the member finding indices, and one bullet per
27
+ member with the finding's first description line and that member's verbatim
28
+ reviewer quote. Descriptive-only — no authored cause or fix.
29
+
30
+ Member indices are schema-validated in range by the time a
31
+ :class:`SynthesizerOutput` exists, but the render guards defensively so a
32
+ stray index can never raise mid-write.
33
+ """
34
+ clusters = output.root_cause_clusters
35
+ if not clusters:
36
+ return []
37
+
38
+ findings = output.consolidated_findings
39
+ lines: list[str] = [
40
+ "## Root-cause clusters",
41
+ "",
42
+ "_Advisory groupings of related findings (descriptive-only — they do "
43
+ "not change the verdict). Each member is quoted verbatim from a "
44
+ "reviewer's original finding; the cause is the reviewers' words, not "
45
+ "the synthesizer's._",
46
+ "",
47
+ ]
48
+ for n, cluster in enumerate(clusters, start=1):
49
+ heading = f"### Cluster {n}: `{cluster.anchor_file}`"
50
+ if cluster.label:
51
+ heading += f" — {cluster.label}"
52
+ lines.append(heading)
53
+ lines.append("")
54
+ member_list = ", ".join(f"#{idx}" for idx in cluster.member_finding_indices)
55
+ lines.append(f"**Member findings:** {member_list} ")
56
+ lines.append("")
57
+ quote_by_index = {e.finding_index: e.quote for e in cluster.evidence}
58
+ for idx in cluster.member_finding_indices:
59
+ if 0 <= idx < len(findings):
60
+ member = findings[idx]
61
+ desc_first = member.description.strip().splitlines()[0]
62
+ severity = member.severity
63
+ else: # pragma: no cover - defensive; schema validates in range
64
+ desc_first = "(member index out of range)"
65
+ severity = "?"
66
+ lines.append(f"- **#{idx}** [{severity}] {desc_first}")
67
+ quote = quote_by_index.get(idx, "").strip().replace("\n", " ")
68
+ lines.append(f" - reviewer quote: {quote!r}")
69
+ lines.append("")
70
+ return lines