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