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,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
|