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,585 @@
1
+ """Per-round summary.md persistence.
2
+
3
+ Writes ``<round_dir>/summary.md`` — the human-readable per-round
4
+ dashboard. The manifest (``manifest.json``) is for tooling; this file
5
+ is for the user. Every round produces one, regardless of exit code.
6
+
7
+ the per-exit-code "Next steps" content blocks + the two resolvers
8
+ live in :mod:`.run_summary_next_steps`; this module keeps the summary renderer.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+ from syncade import billing
17
+ from syncade.dispatcher import DispatchResult
18
+ from syncade.producer import ProducerResult
19
+ from syncade.snapshot import Snapshot
20
+ from syncade.synthesis import has_active_blocker
21
+ from syncade.synthesizer import SYNTHESIZER_NAME, SynthesizerResult
22
+ from syncade.test_runner import TestRunResult
23
+
24
+ from ._atomic import atomic_write_text
25
+ from ._markdown import (
26
+ _format_string_list_block,
27
+ _format_summary_block,
28
+ _md_command_lines,
29
+ _reviewer_file_links,
30
+ )
31
+ from .checks import check_aware_next_steps as check_aware_next_steps
32
+ from .checks import render_checks_section
33
+ from .producer import PRODUCER_NAME
34
+ from .run_summary_next_steps import _resolve_next_steps, _resolve_next_steps_with_producer
35
+ from .test_run import TEST_RUN_NAME
36
+
37
+ # Exit-code labels for the human-readable run summary. Mirrors the
38
+ # constant names in :mod:`syncade.exit_codes`; kept local so persistence
39
+ # doesn't reach into that module's internals just for a display string.
40
+ _EXIT_CODE_LABELS: dict[int, str] = {
41
+ 0: "SUCCESS",
42
+ 10: "CLARIFICATION_NEEDED",
43
+ 20: "MAX_ROUNDS_REACHED",
44
+ 30: "FINDINGS_PRESENT",
45
+ 40: "REVIEWER_FAILURE",
46
+ 50: "CONFIG_ERROR",
47
+ 60: "WORKTREE_ERROR",
48
+ 70: "REVIEWER_OUTPUT_UNPARSEABLE",
49
+ }
50
+
51
+
52
+ _SKIP_REASON_MESSAGES: dict[str, str] = {
53
+ "test_command_unset": (
54
+ "skipped (`[loop] test_command` is not configured in "
55
+ "`.syncade/config.toml`; the test re-run leg is opt-in)"
56
+ ),
57
+ "reviewer_failed": (
58
+ "skipped (a reviewer failed; the test re-run leg runs only "
59
+ "when every prior phase succeeded)"
60
+ ),
61
+ "synth_failed": (
62
+ "skipped (the synthesizer failed; the test re-run leg runs "
63
+ "only when every prior phase succeeded)"
64
+ ),
65
+ "synth_blocker": (
66
+ "skipped (the synthesizer surfaced an active blocker; the "
67
+ "test re-run leg is skipped on synth-blocker paths to avoid "
68
+ "wasted compute when the verdict is already NO-SHIP)"
69
+ ),
70
+ "test_worktree_error": (
71
+ "skipped (the test re-run leg's worktree could not be "
72
+ "provisioned; reviewer + synthesizer artifacts above are "
73
+ "still valid — the failure happened AFTER they completed)"
74
+ ),
75
+ }
76
+ """Human-readable messages for each test-skip reason.
77
+
78
+ Used by :func:`persist_run_summary`. Logger uses its own short strings; both
79
+ surfaces are driven by the same enum so they agree about which reason fired.
80
+ """
81
+
82
+
83
+ def persist_run_summary(
84
+ round_dir: Path,
85
+ snapshot: Snapshot,
86
+ dispatch_result: DispatchResult,
87
+ exit_code: int,
88
+ started_at: datetime,
89
+ synth_result: SynthesizerResult | None = None,
90
+ test_result: TestRunResult | None = None,
91
+ test_skip_reason: str | None = None,
92
+ *,
93
+ producer_result: ProducerResult | None = None,
94
+ producer_provider: str | None = None,
95
+ producer_model: str | None = None,
96
+ resumed_under_drift: bool = False,
97
+ check_results: list[TestRunResult] | None = None,
98
+ escalation_honored: bool = False,
99
+ branch_already_advanced: bool = False,
100
+ no_changes_to_review: bool = False,
101
+ fail_closed_headers: list[str] | None = None,
102
+ oversize_diff_bytes: int | None = None,
103
+ oversize_prompt_chars: int | None = None,
104
+ ) -> Path:
105
+ """Write ``<round_dir>/summary.md`` — a human-readable run summary.
106
+
107
+ The manifest (``manifest.json``) is for tooling; this file is for
108
+ the user. Every run produces one, regardless of exit code.
109
+
110
+ Layout::
111
+
112
+ # Syncade run <run-id>
113
+
114
+ **Started:** 2026-05-13 11:46:55 UTC
115
+ **Exit code:** 0 (SUCCESS)
116
+ **Repo:** <commit-sha> on <branch>
117
+
118
+ ## Reviewers
119
+
120
+ ### claude-reviewer (anthropic)
121
+ - **Outcome:** success
122
+ - **Duration:** 588.6s
123
+ - **Verdict:** SHIP
124
+ - **Findings:** 0
125
+ - **Output:** [.parsed.json](claude-reviewer.parsed.json) | ...
126
+
127
+ **Summary:** I verified the new MoneyMovement widget against
128
+ mockup-v2 line-by-line, ran the full frontend test suite
129
+ (279 tests pass), confirmed the SectorRotation deletion is
130
+ complete...
131
+
132
+ **Coverage gaps:** None.
133
+
134
+ **Dismissed concerns:**
135
+
136
+ - Considered: the SectorRotationData interface still in
137
+ types/index.ts. The spec exempts types files explicitly.
138
+
139
+ ### codex-reviewer (openai)
140
+ - **Outcome:** failure
141
+ - **Duration:** 600.1s
142
+ - **Error:** SubprocessTimeoutError
143
+ - **Output:** [.stdout](codex-reviewer.stdout) | ...
144
+
145
+ ## Next steps
146
+
147
+ - <exit-code-specific guidance>
148
+
149
+ Per-reviewer ``Output`` links point only at files that exist for
150
+ that reviewer's outcome (success → ``.parsed.json`` / ``.stdout`` /
151
+ ``.stderr``; failure → ``.stdout`` / ``.stderr`` / ``.error.txt``),
152
+ so the rendered markdown never carries a dangling link.
153
+
154
+ success entries also render the structured-narrative blocks
155
+ the reviewer populated on :class:`ReviewerOutput`: ``Summary``,
156
+ ``Coverage gaps`` (rendered as ``None.`` when empty), and
157
+ ``Dismissed concerns`` (same empty treatment). Failure entries omit these
158
+ blocks because the reviewer never produced a structured output to render.
159
+
160
+ Args:
161
+ round_dir: The round directory to write into. Must already
162
+ exist. The run-id in the heading is derived from
163
+ ``round_dir.parent.name`` — the same convention :func:`persist_round_manifest` uses.
164
+ snapshot: The run's repo snapshot — supplies the commit SHA and
165
+ branch for the **Repo** line.
166
+ dispatch_result: The dispatch result whose per-reviewer entries
167
+ drive the **Reviewers** section.
168
+ exit_code: The run's exit code — drives the **Exit code** line
169
+ and the **Next steps** guidance.
170
+ started_at: The run-start instant, captured once by the
171
+ orchestrator and shared with :func:`persist_round_manifest`
172
+ so both files agree on when the run began — the
173
+ **Started:** line shows this, not the file's write time.
174
+
175
+ Returns:
176
+ The path of the written ``summary.md``.
177
+
178
+ Raises:
179
+ FileNotFoundError: If ``round_dir`` does not exist (caller bug —
180
+ the orchestrator is responsible for creating it).
181
+ """
182
+ if not round_dir.is_dir():
183
+ raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
184
+
185
+ run_id = round_dir.parent.name
186
+ started = started_at.strftime("%Y-%m-%d %H:%M:%S UTC")
187
+ if no_changes_to_review:
188
+ exit_label = "NO_CHANGES_TO_REVIEW"
189
+ elif fail_closed_headers is not None:
190
+ exit_label = "DIFF_MALFORMED"
191
+ elif oversize_diff_bytes is not None:
192
+ exit_label = "DIFF_TOO_LARGE"
193
+ elif oversize_prompt_chars is not None:
194
+ exit_label = "PROMPT_TOO_LARGE"
195
+ else:
196
+ exit_label = _EXIT_CODE_LABELS.get(exit_code, "UNKNOWN")
197
+ branch = snapshot.branch or "(detached HEAD)"
198
+
199
+ lines = [
200
+ f"# Syncade run {run_id}",
201
+ "",
202
+ f"**Started:** {started} ",
203
+ f"**Exit code:** {exit_code} ({exit_label}) ",
204
+ f"**Repo:** {snapshot.commit_sha} on {branch}",
205
+ "",
206
+ ]
207
+
208
+ if resumed_under_drift:
209
+ lines += [
210
+ "**Resumed under tree drift:** `--force-drift` was passed; "
211
+ "this round snapshotted from current HEAD, not from the "
212
+ "run's original expected SHA. Cross-round context from "
213
+ "prior rounds references findings against the original tree state.",
214
+ "",
215
+ ]
216
+
217
+ lines += [
218
+ "## Reviewers",
219
+ "",
220
+ ]
221
+
222
+ for r in dispatch_result.results:
223
+ lines.append(f"### {r.reviewer_name} ({r.provider})")
224
+ if r.output is not None:
225
+ lines.append("- **Outcome:** success")
226
+ lines.append(f"- **Duration:** {r.duration_seconds:.1f}s")
227
+ lines.append(f"- **Verdict:** {r.output.verdict}")
228
+ lines.append(f"- **Findings:** {len(r.output.findings)}")
229
+ lines.append(f"- **Output:** {_reviewer_file_links(r)}")
230
+ # structured-narrative blocks. Always rendered for
231
+ # success entries — every successful reviewer is required
232
+ # by the schema to populate them, so the summary mirrors
233
+ # what's in `.parsed.json` in human-readable form.
234
+ lines.append("")
235
+ lines.extend(_format_summary_block(r.output.summary))
236
+ lines.append("")
237
+ lines.extend(_format_string_list_block("Coverage gaps", r.output.coverage_gaps))
238
+ lines.append("")
239
+ lines.extend(
240
+ _format_string_list_block("Dismissed concerns", r.output.dismissed_concerns)
241
+ )
242
+ else:
243
+ err_cls = type(r.error).__name__ if r.error is not None else "Unknown"
244
+ lines.append("- **Outcome:** failure")
245
+ lines.append(f"- **Duration:** {r.duration_seconds:.1f}s")
246
+ lines.append(f"- **Error:** {err_cls}")
247
+ lines.append(f"- **Output:** {_reviewer_file_links(r)}")
248
+ # No Summary / Coverage gaps / Dismissed concerns blocks
249
+ # for failure entries — the reviewer never produced a
250
+ # structured ReviewerOutput for us to render. The error
251
+ # class + .error.txt + .stdout files (linked above) carry
252
+ # the actionable detail.
253
+ lines.append("")
254
+
255
+ # --- Synthesizer subsection -----------------------------
256
+ lines.append("## Synthesizer")
257
+ lines.append("")
258
+ if synth_result is None:
259
+ if no_changes_to_review:
260
+ lines.append(
261
+ "- **Outcome:** not applicable (no reviewers were dispatched — nothing to review)"
262
+ )
263
+ elif fail_closed_headers is not None:
264
+ lines.append(
265
+ "- **Outcome:** not applicable (no reviewers were dispatched — diff refused)"
266
+ )
267
+ elif oversize_diff_bytes is not None:
268
+ lines.append(
269
+ "- **Outcome:** not applicable (no reviewers were dispatched — diff too large)"
270
+ )
271
+ elif oversize_prompt_chars is not None:
272
+ lines.append(
273
+ "- **Outcome:** not applicable "
274
+ "(no reviewers were dispatched — assembled prompt too large)"
275
+ )
276
+ else:
277
+ lines.append(
278
+ "- **Outcome:** skipped (a reviewer failed; cold "
279
+ "synthesis runs only when every reviewer succeeded)"
280
+ )
281
+ elif synth_result.output is not None:
282
+ output = synth_result.output
283
+ consolidated = output.consolidated_findings
284
+ dismissed = sum(1 for f in consolidated if f.dismissed)
285
+ active_by_sev = {"blocker": 0, "minor": 0, "nit": 0}
286
+ for f in consolidated:
287
+ if not f.dismissed:
288
+ active_by_sev[f.severity] += 1
289
+ lines.append("- **Outcome:** success")
290
+ lines.append(f"- **Duration:** {synth_result.duration_seconds:.1f}s")
291
+ lines.append(
292
+ f"- **Consolidated findings:** {len(consolidated)} "
293
+ f"({dismissed} dismissed, {active_by_sev['blocker']} active "
294
+ f"blocker(s), {active_by_sev['minor']} active minor, "
295
+ f"{active_by_sev['nit']} active nit)"
296
+ )
297
+ lines.append(
298
+ f"- **Output:** [findings.md](findings.md) | "
299
+ f"[.parsed.json]({SYNTHESIZER_NAME}.parsed.json) | "
300
+ f"[.stdout]({SYNTHESIZER_NAME}.stdout) | "
301
+ f"[.stderr]({SYNTHESIZER_NAME}.stderr)"
302
+ )
303
+ lines.append("")
304
+ lines.extend(_format_summary_block(output.synthesis_summary))
305
+ else:
306
+ err_cls = type(synth_result.error).__name__ if synth_result.error is not None else "Unknown"
307
+ lines.append("- **Outcome:** failure")
308
+ lines.append(f"- **Duration:** {synth_result.duration_seconds:.1f}s")
309
+ lines.append(f"- **Error:** {err_cls}")
310
+ # only link `.error.txt` when persistence will
311
+ # actually write it — i.e. when ``synth_result.error is not
312
+ # None``. ``SynthesizerResult`` now enforces exactly one of
313
+ # output/error at construction; this conditional stays as
314
+ # defense in depth and mirrors the manifest side.
315
+ output_links = [
316
+ f"[.stdout]({SYNTHESIZER_NAME}.stdout)",
317
+ f"[.stderr]({SYNTHESIZER_NAME}.stderr)",
318
+ ]
319
+ if synth_result.error is not None:
320
+ output_links.append(f"[.error.txt]({SYNTHESIZER_NAME}.error.txt)")
321
+ lines.append("- **Output:** " + " | ".join(output_links))
322
+ lines.append("")
323
+
324
+ # --- Test Suite subsection ---------------------------
325
+ # Between the Synthesizer subsection and Next steps. Always
326
+ # rendered — even when the leg was skipped, the operator wants
327
+ # to see WHY it was skipped (config opt-out vs. prior-phase
328
+ # failure).
329
+ lines.append("## Test Suite")
330
+ lines.append("")
331
+ if test_result is None:
332
+ # Render the explicit skip reason passed by ``run_review``. This always
333
+ # agrees with the Logger's live-log skip message because both are driven
334
+ # by the same TestSkipReason value. Falls back to inference for callers
335
+ # that don't pass the argument.
336
+ if no_changes_to_review:
337
+ lines.append(
338
+ "- **Outcome:** not applicable (no reviewers were dispatched — nothing to review)"
339
+ )
340
+ elif fail_closed_headers is not None:
341
+ lines.append(
342
+ "- **Outcome:** not applicable (no reviewers were dispatched — diff refused)"
343
+ )
344
+ elif oversize_diff_bytes is not None:
345
+ lines.append(
346
+ "- **Outcome:** not applicable (no reviewers were dispatched — diff too large)"
347
+ )
348
+ elif oversize_prompt_chars is not None:
349
+ lines.append(
350
+ "- **Outcome:** not applicable "
351
+ "(no reviewers were dispatched — assembled prompt too large)"
352
+ )
353
+ elif test_skip_reason is not None and test_skip_reason in _SKIP_REASON_MESSAGES:
354
+ lines.append(f"- **Outcome:** {_SKIP_REASON_MESSAGES[test_skip_reason]}")
355
+ else:
356
+ # Fallback inference path for callers that don't pass test_skip_reason.
357
+ if not dispatch_result.all_succeeded:
358
+ lines.append(
359
+ "- **Outcome:** skipped (a reviewer failed; the test "
360
+ "re-run leg runs only when every prior phase succeeded)"
361
+ )
362
+ elif synth_result is not None and synth_result.error is not None:
363
+ lines.append(
364
+ "- **Outcome:** skipped (the synthesizer failed; "
365
+ "the test re-run leg runs only when every prior "
366
+ "phase succeeded)"
367
+ )
368
+ elif (
369
+ synth_result is not None
370
+ and synth_result.output is not None
371
+ and has_active_blocker(synth_result.output)
372
+ ):
373
+ lines.append(
374
+ "- **Outcome:** skipped (the synthesizer surfaced an "
375
+ "active blocker; the test re-run leg is skipped on "
376
+ "synth-blocker paths to avoid wasted compute when "
377
+ "the verdict is already NO-SHIP)"
378
+ )
379
+ else:
380
+ lines.append(
381
+ "- **Outcome:** skipped (`[loop] test_command` is not "
382
+ "configured in `.syncade/config.toml`; the test "
383
+ "re-run leg is opt-in)"
384
+ )
385
+ elif test_result.outcome == "subprocess_error":
386
+ err_cls = type(test_result.error).__name__ if test_result.error is not None else "Unknown"
387
+ lines.append("- **Outcome:** subprocess_error")
388
+ lines.append(f"- **Duration:** {test_result.duration_seconds:.1f}s")
389
+ lines.extend(_md_command_lines(test_result.command, prefix="- "))
390
+ lines.append(f"- **Error:** {err_cls}")
391
+ lines.append(
392
+ f"- **Output:** [.stdout]({TEST_RUN_NAME}.stdout) | "
393
+ f"[.stderr]({TEST_RUN_NAME}.stderr) | "
394
+ f"[exit-code.txt]({TEST_RUN_NAME}.exit-code.txt)"
395
+ )
396
+ else:
397
+ # outcome == "passed" or "failed". Both render the same
398
+ # block with outcome + exit_code + command + duration +
399
+ # artifact links.
400
+ lines.append(f"- **Outcome:** {test_result.outcome}")
401
+ lines.append(f"- **Exit code:** {test_result.exit_code}")
402
+ lines.extend(_md_command_lines(test_result.command, prefix="- "))
403
+ lines.append(f"- **Duration:** {test_result.duration_seconds:.1f}s")
404
+ lines.append(
405
+ f"- **Output:** [.stdout]({TEST_RUN_NAME}.stdout) | "
406
+ f"[.stderr]({TEST_RUN_NAME}.stderr) | "
407
+ f"[exit-code.txt]({TEST_RUN_NAME}.exit-code.txt)"
408
+ )
409
+ lines.append("")
410
+
411
+ # mechanical-checks subsection ([] for no checks → byte-identical).
412
+ lines.extend(render_checks_section(check_results or []))
413
+
414
+ # --- Producer subsection ----------------
415
+ # Rendered only when a producer ran on this round (NO-SHIP
416
+ # round in a multi-round loop). On the SHIP round + on
417
+ # single-pass (max_rounds=1) runs, the producer never runs
418
+ # and this section is omitted.
419
+ if producer_result is not None:
420
+ lines.append("## Producer")
421
+ lines.append("")
422
+ if producer_result.outcome == "committed":
423
+ ending = producer_result.ending_sha[:12]
424
+ lines.append("- **Outcome:** committed")
425
+ lines.append(f"- **Provider / model:** {producer_provider} / {producer_model}")
426
+ lines.append(f"- **Duration:** {producer_result.duration_seconds:.1f}s")
427
+ lines.append(f"- **Commit SHA:** `{producer_result.ending_sha}` (short: `{ending}`)")
428
+ lines.append(
429
+ f"- **Output:** [{PRODUCER_NAME}.stdout]({PRODUCER_NAME}.stdout) | "
430
+ f"[{PRODUCER_NAME}.stderr]({PRODUCER_NAME}.stderr) | "
431
+ f"[{PRODUCER_NAME}.commit.txt]({PRODUCER_NAME}.commit.txt)"
432
+ )
433
+ elif producer_result.outcome == "stalled":
434
+ lines.append("- **Outcome:** stalled (no commit)")
435
+ lines.append(f"- **Provider / model:** {producer_provider} / {producer_model}")
436
+ lines.append(f"- **Duration:** {producer_result.duration_seconds:.1f}s")
437
+ lines.append(
438
+ f"- **Output:** [{PRODUCER_NAME}.stdout]({PRODUCER_NAME}.stdout) | "
439
+ f"[{PRODUCER_NAME}.stderr]({PRODUCER_NAME}.stderr) | "
440
+ f"[{PRODUCER_NAME}.commit.txt]({PRODUCER_NAME}.commit.txt)"
441
+ )
442
+ elif producer_result.outcome == "escalated" and escalation_honored:
443
+ # the producer escalated a finding as an operator decision
444
+ # (no commit) AND the coverage guard HONORED it — its
445
+ # finding_indices cover every active blocker, so the loop
446
+ # checkpointed (exit 10) and wrote decision-needed.md at the run
447
+ # root. The per-round summary names the decision and links it.
448
+ lines.append("- **Outcome:** escalated (operator decision needed)")
449
+ lines.append(f"- **Provider / model:** {producer_provider} / {producer_model}")
450
+ lines.append(f"- **Duration:** {producer_result.duration_seconds:.1f}s")
451
+ if producer_result.escalation is not None:
452
+ lines.append(f"- **Decision needed:** {producer_result.escalation.decision}")
453
+ lines.append(
454
+ f"- **Output:** [{PRODUCER_NAME}.stdout]({PRODUCER_NAME}.stdout) | "
455
+ f"[{PRODUCER_NAME}.stderr]({PRODUCER_NAME}.stderr) | "
456
+ "[decision-needed.md](../decision-needed.md)"
457
+ )
458
+ elif producer_result.outcome == "escalated":
459
+ # the producer escalated, but the coverage guard REJECTED it
460
+ # — the escalation left at least one active blocker uncovered (or
461
+ # referenced a non-blocker / out-of-range index), so the loop treated
462
+ # the round as a stall (exit 30, NO branch advance, NO
463
+ # decision-needed.md). Render the TRUE disposition: do NOT link a
464
+ # decision-needed.md that was never written, and do NOT tell the
465
+ # operator a decision checkpoint is pending.
466
+ lines.append(
467
+ "- **Outcome:** escalated but not honored (left active "
468
+ "blocker(s) uncovered — treated as a stall)"
469
+ )
470
+ lines.append(f"- **Provider / model:** {producer_provider} / {producer_model}")
471
+ lines.append(f"- **Duration:** {producer_result.duration_seconds:.1f}s")
472
+ if producer_result.escalation is not None:
473
+ lines.append(f"- **Attempted escalation:** {producer_result.escalation.decision}")
474
+ lines.append(
475
+ f"- **Output:** [{PRODUCER_NAME}.stdout]({PRODUCER_NAME}.stdout) | "
476
+ f"[{PRODUCER_NAME}.stderr]({PRODUCER_NAME}.stderr)"
477
+ )
478
+ else: # subprocess_error
479
+ err_cls = type(producer_result.error).__name__ if producer_result.error else "Unknown"
480
+ lines.append("- **Outcome:** subprocess_error")
481
+ lines.append(f"- **Provider / model:** {producer_provider} / {producer_model}")
482
+ lines.append(f"- **Duration:** {producer_result.duration_seconds:.1f}s")
483
+ lines.append(f"- **Error:** {err_cls}")
484
+ if producer_result.ending_sha != producer_result.starting_sha:
485
+ lines.append(
486
+ "- **Indeterminate producer commit:** HEAD moved from "
487
+ f"{producer_result.starting_sha[:12]} to "
488
+ f"{producer_result.ending_sha[:12]} before the subprocess failed; "
489
+ "the branch was not advanced."
490
+ )
491
+ lines.append(
492
+ f"- **Output:** [{PRODUCER_NAME}.stdout]({PRODUCER_NAME}.stdout) | "
493
+ f"[{PRODUCER_NAME}.stderr]({PRODUCER_NAME}.stderr) | "
494
+ f"[{PRODUCER_NAME}.error.txt]({PRODUCER_NAME}.error.txt)"
495
+ )
496
+ lines.append("")
497
+
498
+ lines.extend(_cost_section(dispatch_result, synth_result, producer_result))
499
+
500
+ lines.append("## Next steps")
501
+ lines.append("")
502
+ # Exits 30, 40, and 70 split by phase; _resolve_next_steps routes to the
503
+ # right variant. When a producer ran on this round, next-step guidance
504
+ # should acknowledge that producer attempt.
505
+ if producer_result is not None:
506
+ lines.append(
507
+ _resolve_next_steps_with_producer(
508
+ exit_code, producer_result, escalation_honored, branch_already_advanced
509
+ )
510
+ )
511
+ else:
512
+ lines.append(
513
+ _resolve_next_steps(
514
+ exit_code,
515
+ synth_result,
516
+ test_result,
517
+ test_skip_reason,
518
+ check_results,
519
+ no_changes_to_review=no_changes_to_review,
520
+ fail_closed_headers=fail_closed_headers,
521
+ oversize_diff_bytes=oversize_diff_bytes,
522
+ oversize_prompt_chars=oversize_prompt_chars,
523
+ )
524
+ )
525
+ lines.append("")
526
+
527
+ summary_path = round_dir / "summary.md"
528
+ atomic_write_text(summary_path, "\n".join(lines))
529
+ return summary_path
530
+
531
+
532
+ def _cost_section(dispatch_result, synth_result, producer_result) -> list[str]:
533
+ """A single per-run '## Token usage & cost' section (PR-v2-04): per-actor tokens
534
+ + cost with a run total, in one place rather than scattered per-actor lines. Rendered
535
+ only when at least one actor reported usage.
536
+
537
+ **The dollars here are an API-EQUIVALENT VALUATION, not spend** (PR-v2-24). Fixing
538
+ ``--metrics`` and leaving this artifact printing "Total: $0.1426" for a run that cost
539
+ the user nothing would have re-introduced the exact bug one surface over -- which is
540
+ what the panel caught. Both surfaces now read the same ``auth_mode``.
541
+ """
542
+ rows: list[tuple[str, object]] = []
543
+ for r in dispatch_result.results:
544
+ if r.usage is not None:
545
+ rows.append((f"{r.reviewer_name} ({r.provider})", r.usage))
546
+ if synth_result is not None and synth_result.usage is not None:
547
+ rows.append((SYNTHESIZER_NAME, synth_result.usage))
548
+ if producer_result is not None and producer_result.usage is not None:
549
+ rows.append(("producer", producer_result.usage))
550
+ if not rows:
551
+ return []
552
+ out = ["## Token usage & cost", ""]
553
+ total_tok = 0
554
+ any_estimated = False
555
+ unpriced = 0
556
+ for label, u in rows:
557
+ cost = f"${u.cost_usd:.4f}" if u.cost_usd is not None else "unknown"
558
+ reasoning = f", {u.reasoning_output_tokens} reasoning" if u.reasoning_output_tokens else ""
559
+ out.append(
560
+ f"- **{label}:** {u.total_tokens} tok{reasoning} · {cost} "
561
+ f"({u.cost_source}, auth={u.auth_mode})"
562
+ )
563
+ total_tok += u.total_tokens
564
+ if u.cost_usd is None:
565
+ unpriced += 1
566
+ any_estimated = any_estimated or u.cost_source == "estimated"
567
+
568
+ notes = []
569
+ if any_estimated:
570
+ notes.append("includes estimates")
571
+ if unpriced:
572
+ # Unpriced usage is NOT free — it has tokens but no price (dogfood #4).
573
+ notes.append(f"{unpriced} unpriced, not free")
574
+ suffix = f" ({'; '.join(notes)})" if notes else ""
575
+ out.append(f"- **tokens:** {total_tok}{suffix}")
576
+
577
+ # THE SAME classifier and THE SAME words as `--metrics`. This file and metrics_mode
578
+ # kept diverging -- three separate rounds of the panel caught summary.md still telling
579
+ # the lie --metrics had just stopped telling -- because each computed billing itself.
580
+ # Now neither does.
581
+ out.extend(
582
+ billing.render(billing.from_usages([u for _label, u in rows]), indent="", bullet=True)
583
+ )
584
+ out.append("")
585
+ return out