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,389 @@
|
|
|
1
|
+
"""findings.md persistence.
|
|
2
|
+
|
|
3
|
+
Renders the operator-facing consolidated review report. Two entry points:
|
|
4
|
+
|
|
5
|
+
- :func:`persist_findings_md` writes ``<round_dir>/findings.md`` once
|
|
6
|
+
per round, only when the synthesizer succeeded.
|
|
7
|
+
- :func:`persist_current_findings_md` copies the latest round's
|
|
8
|
+
``findings.md`` to ``<run_dir>/findings.md`` so the skill / future
|
|
9
|
+
tooling can address the active report without knowing the round
|
|
10
|
+
number.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shutil
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from syncade.dispatcher import DispatchResult
|
|
20
|
+
from syncade.synthesizer import SynthesizerResult
|
|
21
|
+
from syncade.test_runner import TestRunResult
|
|
22
|
+
|
|
23
|
+
from ._atomic import atomic_write_text
|
|
24
|
+
from ._clusters import render_cluster_section
|
|
25
|
+
from ._findings_verdict import _compute_findings_md_verdict
|
|
26
|
+
from ._markdown import (
|
|
27
|
+
_consensus_lines,
|
|
28
|
+
_file_or_repo_wide,
|
|
29
|
+
_format_summary_block,
|
|
30
|
+
_md_command_lines,
|
|
31
|
+
)
|
|
32
|
+
from .checks import render_checks_section
|
|
33
|
+
from .test_run import TEST_RUN_NAME
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def persist_findings_md(
|
|
37
|
+
round_dir: Path,
|
|
38
|
+
synth_result: SynthesizerResult,
|
|
39
|
+
started_at: datetime,
|
|
40
|
+
test_result: TestRunResult | None = None,
|
|
41
|
+
dispatch_result: DispatchResult | None = None,
|
|
42
|
+
test_skip_reason: str | None = None,
|
|
43
|
+
snapshot_sha: str | None = None,
|
|
44
|
+
check_results: list[TestRunResult] | None = None,
|
|
45
|
+
) -> Path:
|
|
46
|
+
"""Write ``<round_dir>/findings.md`` — the operator-facing
|
|
47
|
+
consolidated review report.
|
|
48
|
+
|
|
49
|
+
Called only when the synthesizer succeeded
|
|
50
|
+
(``synth_result.output is not None``). When the synthesizer
|
|
51
|
+
failed, there are no consolidated findings to render and the
|
|
52
|
+
operator's path forward is to inspect ``synthesizer.stdout`` /
|
|
53
|
+
``synthesizer.error.txt`` — both linked from ``summary.md``'s
|
|
54
|
+
Next-steps block.
|
|
55
|
+
|
|
56
|
+
Layout::
|
|
57
|
+
|
|
58
|
+
# Findings — Syncade run <run-id>
|
|
59
|
+
|
|
60
|
+
**Verdict:** SHIP|NO-SHIP (mechanical, from
|
|
61
|
+
consolidated_findings)
|
|
62
|
+
**Started:** YYYY-MM-DD HH:MM:SS UTC
|
|
63
|
+
|
|
64
|
+
## Test Suite (only when the test leg ran)
|
|
65
|
+
|
|
66
|
+
outcome / exit_code / command / duration + pointer to
|
|
67
|
+
test-run.stdout
|
|
68
|
+
|
|
69
|
+
## Synthesis summary
|
|
70
|
+
|
|
71
|
+
<synth_result.output.synthesis_summary>
|
|
72
|
+
|
|
73
|
+
## Findings
|
|
74
|
+
|
|
75
|
+
### [<severity>] <description first line>
|
|
76
|
+
|
|
77
|
+
**File:** `path` (or "repo-wide")
|
|
78
|
+
**Status:** Active (or "Dismissed by synthesizer")
|
|
79
|
+
**Flagged by:** <name1> (<sev1>), <name2> (<sev2>)
|
|
80
|
+
**Synthesizer severity:** <severity>
|
|
81
|
+
**Severity change rationale:** ... (if present)
|
|
82
|
+
|
|
83
|
+
<description body, if multi-line>
|
|
84
|
+
|
|
85
|
+
**Original per-reviewer descriptions:**
|
|
86
|
+
- claude-reviewer: "..."
|
|
87
|
+
- codex-reviewer: "..."
|
|
88
|
+
|
|
89
|
+
**Dismissal rationale:** ... (if dismissed)
|
|
90
|
+
|
|
91
|
+
## Per-reviewer summaries
|
|
92
|
+
|
|
93
|
+
### <reviewer name> (<provider>)
|
|
94
|
+
|
|
95
|
+
<ReviewerOutput.summary, rendered with _format_summary_block>
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
round_dir: The round directory to write into. Must already
|
|
99
|
+
exist.
|
|
100
|
+
synth_result: The :class:`SynthesizerResult`. Must have
|
|
101
|
+
``output is not None``; otherwise this function refuses
|
|
102
|
+
with ``ValueError`` (defensive — the orchestrator
|
|
103
|
+
shouldn't call us on the failure path).
|
|
104
|
+
The Verdict label is derived from
|
|
105
|
+
:func:`syncade.synthesis.has_active_blocker` against the synth's
|
|
106
|
+
consolidated findings. The mechanical exit code is persisted in
|
|
107
|
+
``manifest.json`` and ``summary.md``; findings.md only needs its
|
|
108
|
+
own local verdict label.
|
|
109
|
+
started_at: The run-start instant captured by the
|
|
110
|
+
orchestrator. Same value the manifest and summary use.
|
|
111
|
+
test_result: The :class:`TestRunResult` from the opt-in
|
|
112
|
+
test re-run leg, or ``None`` when the leg was
|
|
113
|
+
skipped. When present, a ``## Test Suite`` section is
|
|
114
|
+
prepended after the header so it's the first content
|
|
115
|
+
the operator sees — test failures are typically more
|
|
116
|
+
actionable than synth findings on a clean-synth run.
|
|
117
|
+
dispatch_result: The :class:`DispatchResult` from the
|
|
118
|
+
reviewer dispatch. When supplied, a
|
|
119
|
+
``## Per-reviewer summaries`` section is appended at
|
|
120
|
+
the END of the document, rendering each successful
|
|
121
|
+
reviewer's ``ReviewerOutput.summary`` field. This makes
|
|
122
|
+
findings.md self-sufficient in both ship-clean and findings-present
|
|
123
|
+
cases. ``None`` (the default) keeps the summaries section absent.
|
|
124
|
+
snapshot_sha: The snapshot SHA of THIS round (what
|
|
125
|
+
the reviewers had as HEAD when they produced the
|
|
126
|
+
findings rendered below). When supplied, a
|
|
127
|
+
``**Generated against SHA:**`` header line is written
|
|
128
|
+
immediately after the verdict line. ``None`` (the
|
|
129
|
+
default) omits the line.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The path of the written ``findings.md``.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: If ``synth_result.output is None`` — defensive
|
|
136
|
+
guard against being called on a failure path.
|
|
137
|
+
FileNotFoundError: If ``round_dir`` does not exist.
|
|
138
|
+
"""
|
|
139
|
+
if synth_result.output is None:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
"persist_findings_md called with synth_result.output=None — "
|
|
142
|
+
"there are no consolidated findings to render. The "
|
|
143
|
+
"orchestrator must only call this on the synth-success path."
|
|
144
|
+
)
|
|
145
|
+
if not round_dir.is_dir():
|
|
146
|
+
raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
|
|
147
|
+
|
|
148
|
+
run_id = round_dir.parent.name
|
|
149
|
+
started = started_at.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
150
|
+
|
|
151
|
+
output = synth_result.output
|
|
152
|
+
# The verdict line must reflect the overall mechanical result, not just
|
|
153
|
+
# the synth's view of consolidated_findings. A clean synth with failed
|
|
154
|
+
# tests still renders NO-SHIP, matching the orchestrator's exit code.
|
|
155
|
+
#
|
|
156
|
+
# The matrix below mirrors _compute_exit_code's test-leg AND
|
|
157
|
+
# blocking-check branches exactly so a future refinement to "what
|
|
158
|
+
# counts as blocking" lands in one place by changing both:
|
|
159
|
+
# - a mechanical gate (test leg OR a blocking check) subprocess_error
|
|
160
|
+
# → "ABORT" (the harness couldn't run — exit 40); outranks even a
|
|
161
|
+
# synth blocker, since checks run on synth-blocker rounds too
|
|
162
|
+
# - synth blocker (no gate errored) → NO-SHIP (synth said no)
|
|
163
|
+
# - synth clean + a mechanical gate failed → NO-SHIP (the gate said no)
|
|
164
|
+
# - synth clean + all gates passed → SHIP
|
|
165
|
+
# - synth clean + test skipped (test_command unset, etc.) →
|
|
166
|
+
# SHIP
|
|
167
|
+
# only BLOCKING checks reach the verdict; advisory results
|
|
168
|
+
# are filtered out here so they are structurally unable to gate the
|
|
169
|
+
# headline, exactly as they cannot reach _compute_exit_code.
|
|
170
|
+
blocking_check_results = [c for c in (check_results or []) if c.severity == "blocking"]
|
|
171
|
+
verdict_label, verdict_qualifier = _compute_findings_md_verdict(
|
|
172
|
+
output, test_result, test_skip_reason, blocking_check_results, dispatch_result
|
|
173
|
+
)
|
|
174
|
+
lines: list[str] = [
|
|
175
|
+
f"# Findings — Syncade run {run_id}",
|
|
176
|
+
"",
|
|
177
|
+
f"**Verdict:** {verdict_label} ({verdict_qualifier}) ",
|
|
178
|
+
]
|
|
179
|
+
# SHA annotation. The orchestrator passes the snapshot SHA of this round;
|
|
180
|
+
# callers that leave ``snapshot_sha`` as ``None`` omit the header line.
|
|
181
|
+
if snapshot_sha:
|
|
182
|
+
lines.append(f"**Generated against SHA:** `{snapshot_sha[:12]}` (full: `{snapshot_sha}`) ")
|
|
183
|
+
lines.extend(
|
|
184
|
+
[
|
|
185
|
+
f"**Started:** {started}",
|
|
186
|
+
"",
|
|
187
|
+
]
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# --- Test Suite section -----------------------------
|
|
191
|
+
# Rendered when the test leg ran, BEFORE the synthesis summary
|
|
192
|
+
# so it's the first thing the operator sees. Test failures on
|
|
193
|
+
# a clean-synth run are typically the most actionable signal
|
|
194
|
+
# (the synth said nothing; the tests said something).
|
|
195
|
+
if test_result is not None:
|
|
196
|
+
lines.extend(_format_findings_test_suite_block(test_result))
|
|
197
|
+
|
|
198
|
+
# Mechanical-checks section (advisory failures tagged non-blocking).
|
|
199
|
+
# render_checks_section returns [] for an empty/None list.
|
|
200
|
+
lines.extend(render_checks_section(check_results or []))
|
|
201
|
+
|
|
202
|
+
lines.extend(
|
|
203
|
+
[
|
|
204
|
+
"## Synthesis summary",
|
|
205
|
+
"",
|
|
206
|
+
output.synthesis_summary,
|
|
207
|
+
"",
|
|
208
|
+
]
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Root-cause clusters, rendered above the individual findings so the
|
|
212
|
+
# producer sees "these N are one issue" before reading them individually.
|
|
213
|
+
lines.extend(render_cluster_section(output))
|
|
214
|
+
|
|
215
|
+
lines.extend(
|
|
216
|
+
[
|
|
217
|
+
"## Findings",
|
|
218
|
+
"",
|
|
219
|
+
]
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if not output.consolidated_findings:
|
|
223
|
+
lines.append("No consolidated findings — both reviewers verified the spec cleanly.")
|
|
224
|
+
lines.append("")
|
|
225
|
+
else:
|
|
226
|
+
for finding in output.consolidated_findings:
|
|
227
|
+
# Section header: severity tag + description first line, so
|
|
228
|
+
# the operator scanning the document sees severity before
|
|
229
|
+
# narrative.
|
|
230
|
+
description_first_line = finding.description.strip().splitlines()[0]
|
|
231
|
+
lines.append(f"### [{finding.severity}] {description_first_line}")
|
|
232
|
+
lines.append("")
|
|
233
|
+
lines.append(f"**File:** {_file_or_repo_wide(finding.file)} ")
|
|
234
|
+
status = "Dismissed by synthesizer" if finding.dismissed else "Active"
|
|
235
|
+
lines.append(f"**Status:** {status} ")
|
|
236
|
+
flagged_by = ", ".join(
|
|
237
|
+
f"{p.reviewer_name} ({p.original_severity})" for p in finding.provenance
|
|
238
|
+
)
|
|
239
|
+
lines.append(f"**Flagged by:** {flagged_by} ")
|
|
240
|
+
# advisory per-finding reviewer consensus — render-derived,
|
|
241
|
+
# never reaches _compute_exit_code; omitted when dispatch_result is None.
|
|
242
|
+
lines.extend(_consensus_lines(finding, dispatch_result))
|
|
243
|
+
lines.append(f"**Synthesizer severity:** {finding.severity}")
|
|
244
|
+
if finding.severity_change_rationale:
|
|
245
|
+
lines.append("")
|
|
246
|
+
lines.append(f"**Severity change rationale:** {finding.severity_change_rationale}")
|
|
247
|
+
|
|
248
|
+
# If the description spans multiple lines, render the
|
|
249
|
+
# remainder as a body block.
|
|
250
|
+
description_rest = "\n".join(finding.description.strip().splitlines()[1:]).strip()
|
|
251
|
+
if description_rest:
|
|
252
|
+
lines.append("")
|
|
253
|
+
lines.append(description_rest)
|
|
254
|
+
|
|
255
|
+
# Original per-reviewer descriptions — always rendered even
|
|
256
|
+
# when single-reviewer, so the operator sees the verbatim
|
|
257
|
+
# source.
|
|
258
|
+
lines.append("")
|
|
259
|
+
lines.append("**Original per-reviewer descriptions:**")
|
|
260
|
+
lines.append("")
|
|
261
|
+
for p in finding.provenance:
|
|
262
|
+
# Quote the description so newlines inside don't break
|
|
263
|
+
# the bullet structure visually.
|
|
264
|
+
quoted = p.original_description.strip().replace("\n", " ")
|
|
265
|
+
lines.append(f"- {p.reviewer_name}: {quoted!r}")
|
|
266
|
+
|
|
267
|
+
if finding.dismissed and finding.dismissal_rationale:
|
|
268
|
+
lines.append("")
|
|
269
|
+
lines.append(f"**Dismissal rationale:** {finding.dismissal_rationale}")
|
|
270
|
+
lines.append("")
|
|
271
|
+
|
|
272
|
+
# --- Per-reviewer summaries section -----------------
|
|
273
|
+
# Appended AFTER the Findings section so the action items
|
|
274
|
+
# (consolidated findings) stay above the fold. The per-
|
|
275
|
+
# reviewer summaries are context — what each reviewer saw in
|
|
276
|
+
# prose — useful for understanding the synthesizer's
|
|
277
|
+
# consolidation choices but not the operator's first action
|
|
278
|
+
# target.
|
|
279
|
+
#
|
|
280
|
+
# Per-reviewer summaries make findings.md self-sufficient even when there
|
|
281
|
+
# are no consolidated findings.
|
|
282
|
+
#
|
|
283
|
+
# Only successful reviewers contribute (a failed reviewer
|
|
284
|
+
# never produced a structured ReviewerOutput.summary to
|
|
285
|
+
# render). If no reviewer succeeded, the section is omitted —
|
|
286
|
+
# but in that case persist_findings_md wouldn't have been
|
|
287
|
+
# called at all (the synthesizer is skipped on any reviewer
|
|
288
|
+
# failure → no synth output → no findings.md).
|
|
289
|
+
if dispatch_result is not None:
|
|
290
|
+
successful = [r for r in dispatch_result.results if r.output is not None]
|
|
291
|
+
if successful:
|
|
292
|
+
lines.append("## Per-reviewer summaries")
|
|
293
|
+
lines.append("")
|
|
294
|
+
for r in successful:
|
|
295
|
+
lines.append(f"### {r.reviewer_name} ({r.provider})")
|
|
296
|
+
lines.append("")
|
|
297
|
+
# Reuse the same _format_summary_block helper
|
|
298
|
+
# summary.md uses. One source of truth
|
|
299
|
+
# for the rendering rule.
|
|
300
|
+
lines.extend(_format_summary_block(r.output.summary))
|
|
301
|
+
lines.append("")
|
|
302
|
+
|
|
303
|
+
findings_path = round_dir / "findings.md"
|
|
304
|
+
atomic_write_text(findings_path, "\n".join(lines))
|
|
305
|
+
return findings_path
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _format_findings_test_suite_block(test_result: TestRunResult) -> list[str]:
|
|
309
|
+
"""Render the ``## Test Suite`` section for findings.md as a
|
|
310
|
+
list of lines.
|
|
311
|
+
|
|
312
|
+
rendered at the TOP of findings.md (between the header
|
|
313
|
+
and the Synthesis summary) so it's the first thing the
|
|
314
|
+
operator sees when the test leg fired. Detailed test output
|
|
315
|
+
stays in ``test-run.stdout``; findings.md just summarizes the
|
|
316
|
+
outcome and points at the artifact.
|
|
317
|
+
|
|
318
|
+
Three states: passed / failed / subprocess_error. Skipped
|
|
319
|
+
legs do NOT call this — findings.md only fires on the synth-
|
|
320
|
+
success path, and on that path the test leg either ran or
|
|
321
|
+
was skipped via config opt-out (which findings.md doesn't
|
|
322
|
+
mention; the operator already knows their config).
|
|
323
|
+
"""
|
|
324
|
+
block: list[str] = ["## Test Suite", ""]
|
|
325
|
+
# Link all three test-run artifacts on every outcome (passed / failed /
|
|
326
|
+
# subprocess_error), matching what summary.md does. Consistent linking
|
|
327
|
+
# keeps findings.md self-sufficient for any outcome.
|
|
328
|
+
output_links = (
|
|
329
|
+
f"**Output:** [test-run.stdout]({TEST_RUN_NAME}.stdout) | "
|
|
330
|
+
f"[test-run.stderr]({TEST_RUN_NAME}.stderr) | "
|
|
331
|
+
f"[test-run.exit-code.txt]({TEST_RUN_NAME}.exit-code.txt)"
|
|
332
|
+
)
|
|
333
|
+
if test_result.outcome == "subprocess_error":
|
|
334
|
+
err_cls = type(test_result.error).__name__ if test_result.error is not None else "Unknown"
|
|
335
|
+
block.append(f"**Outcome:** subprocess_error ({err_cls}) ")
|
|
336
|
+
block.extend(_md_command_lines(test_result.command, inline_suffix=" "))
|
|
337
|
+
block.append(f"**Duration:** {test_result.duration_seconds:.1f}s ")
|
|
338
|
+
block.append(output_links)
|
|
339
|
+
else:
|
|
340
|
+
# passed or failed — both render the same block shape.
|
|
341
|
+
block.append(f"**Outcome:** {test_result.outcome} (exit {test_result.exit_code}) ")
|
|
342
|
+
block.extend(_md_command_lines(test_result.command, inline_suffix=" "))
|
|
343
|
+
block.append(f"**Duration:** {test_result.duration_seconds:.1f}s ")
|
|
344
|
+
block.append(output_links)
|
|
345
|
+
block.append("")
|
|
346
|
+
return block
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def persist_current_findings_md(
|
|
350
|
+
run_dir: Path,
|
|
351
|
+
latest_round_findings_md: Path | None,
|
|
352
|
+
) -> Path | None:
|
|
353
|
+
"""Write/refresh ``<run_dir>/findings.md`` as a
|
|
354
|
+
copy of the latest round's per-round ``findings.md``.
|
|
355
|
+
|
|
356
|
+
The PRD calls out this artifact explicitly under "Run-dir
|
|
357
|
+
layout":
|
|
358
|
+
|
|
359
|
+
<run-id>/findings.md (run-root "current findings"
|
|
360
|
+
symlink-or-copy that always points at the latest round's
|
|
361
|
+
findings.md, so the skill / future tooling can address
|
|
362
|
+
the active report without knowing the round number)
|
|
363
|
+
|
|
364
|
+
Implemented as a file copy (not a symlink) for two reasons:
|
|
365
|
+
|
|
366
|
+
1. Cross-platform: Windows symlinks require elevated
|
|
367
|
+
privileges and behave differently than POSIX. A copy works
|
|
368
|
+
everywhere.
|
|
369
|
+
2. Operator inspecting ``<run_dir>/findings.md`` mid-loop
|
|
370
|
+
sees the latest round's content directly — no
|
|
371
|
+
broken-symlink moment if a round in progress hasn't
|
|
372
|
+
written its findings.md yet.
|
|
373
|
+
|
|
374
|
+
Returns the written path on success; ``None`` if the latest
|
|
375
|
+
round had no findings.md (synth failed or was skipped). Best-
|
|
376
|
+
effort: any I/O failure surfaces as None and a swallowed
|
|
377
|
+
error — the per-round artifacts are already on disk; the
|
|
378
|
+
convenience copy is non-essential.
|
|
379
|
+
"""
|
|
380
|
+
if latest_round_findings_md is None or not latest_round_findings_md.is_file():
|
|
381
|
+
return None
|
|
382
|
+
if not run_dir.is_dir():
|
|
383
|
+
raise FileNotFoundError(f"run_dir does not exist: {run_dir}")
|
|
384
|
+
target = run_dir / "findings.md"
|
|
385
|
+
try:
|
|
386
|
+
shutil.copy2(latest_round_findings_md, target)
|
|
387
|
+
except OSError:
|
|
388
|
+
return None
|
|
389
|
+
return target
|