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,165 @@
1
+ """Loop-level loop-manifest.json persistence.
2
+
3
+ Writes ``<run_dir>/loop-manifest.json`` — the top-level
4
+ machine-readable manifest with the multi-round rounds[] array. Per-
5
+ round manifests still live at ``round-N/manifest.json``; this top-
6
+ level loop-manifest aggregates the per-round entries and adds the
7
+ loop-level fields (``final_exit_code``, ``final_round``,
8
+ ``termination_reason``, ``max_rounds``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+ from syncade import __version__
17
+
18
+ from ._atomic import atomic_write_json
19
+ from .checks import _check_manifest_entry
20
+ from .producer import _producer_manifest_entry
21
+ from .reviewer import _reviewer_manifest_entry
22
+ from .synth import _synthesizer_manifest_entry
23
+ from .test_run import _test_run_manifest_entry
24
+
25
+
26
+ def persist_loop_manifest(
27
+ run_dir: Path,
28
+ *,
29
+ final_exit_code: int,
30
+ final_round: int,
31
+ termination_reason: str,
32
+ rounds: list, # list[RoundResult]
33
+ max_rounds: int,
34
+ started_at: datetime,
35
+ producer_provider: str | None,
36
+ producer_model: str | None,
37
+ ) -> Path:
38
+ """Write ``<run_dir>/loop-manifest.json`` — the top-level
39
+ machine-readable manifest with the multi-round rounds[] array.
40
+
41
+ Per-round manifests still live at ``round-N/manifest.json``; the top-level
42
+ loop-manifest aggregates the per-round entries and adds the
43
+ loop-level fields (``final_exit_code``, ``final_round``,
44
+ ``termination_reason``, ``max_rounds``).
45
+
46
+ Schema:
47
+
48
+ .. code-block:: json
49
+
50
+ {
51
+ "syncade_version": "0.x.y",
52
+ "run_id": "...",
53
+ "started_at_utc": "...",
54
+ "max_rounds": 3,
55
+ "final_exit_code": 0,
56
+ "final_round": 1,
57
+ "termination_reason": "ship",
58
+ "rounds": [
59
+ {
60
+ "round": 0,
61
+ "snapshot": {...},
62
+ "reviewers": [...],
63
+ "synthesizer": {...},
64
+ "test_run": null | {...},
65
+ "test_skip_reason": null | "...",
66
+ "producer": null | {...},
67
+ "round_exit_code": 30
68
+ },
69
+ {... round 1 ...}
70
+ ]
71
+ }
72
+
73
+ Args:
74
+ run_dir: Top-level run directory.
75
+ final_exit_code: The loop's final exit code.
76
+ final_round: 0-indexed round the loop terminated at.
77
+ termination_reason: Categorical termination label.
78
+ rounds: List of RoundResult objects from the orchestrator.
79
+ max_rounds: Configured ``[loop] max_rounds`` ceiling.
80
+ started_at: Run start instant.
81
+ producer_provider: ``config.producer.provider``, echoed
82
+ into each round's producer section.
83
+ producer_model: ``config.producer.model``.
84
+
85
+ Returns:
86
+ Path of the written ``loop-manifest.json``.
87
+ """
88
+ if not run_dir.is_dir():
89
+ raise FileNotFoundError(f"run_dir does not exist: {run_dir}")
90
+
91
+ run_id = run_dir.name
92
+ round_entries: list[dict[str, object]] = []
93
+ for r in rounds:
94
+ entry: dict[str, object] = {
95
+ "round": r.round_idx,
96
+ "snapshot": {
97
+ "commit_sha": r.snapshot.commit_sha,
98
+ "branch": r.snapshot.branch,
99
+ "base_ref": r.snapshot.base_ref,
100
+ "base_oid": r.snapshot.base_oid,
101
+ "diff_present": bool(r.snapshot.diff_text),
102
+ # Mirror the per-round manifest's size history fields.
103
+ # Both are None when not measured (diff_malformed, resume); see
104
+ # round_manifest.py for the full rationale.
105
+ "diff_bytes": r.raw_diff_bytes,
106
+ "diff_bytes_reviewed": r.filtered_diff_bytes,
107
+ },
108
+ "reviewers": (
109
+ [_reviewer_manifest_entry(rr) for rr in r.dispatch_result.results]
110
+ if r.dispatch_result is not None
111
+ else []
112
+ ),
113
+ "synthesizer": _synthesizer_manifest_entry(r.synth_result),
114
+ "test_run": _test_run_manifest_entry(r.test_result),
115
+ "test_skip_reason": r.test_skip_reason if r.test_result is None else None,
116
+ "producer": _producer_manifest_entry(
117
+ r.producer_result,
118
+ producer_config_provider=producer_provider,
119
+ producer_config_model=producer_model,
120
+ ),
121
+ "round_exit_code": r.round_exit_code,
122
+ }
123
+ # Mirror the per-round manifest (round_manifest.py): append the
124
+ # checks array ONLY when checks ran, so a zero-check round's
125
+ # loop entry stays byte-identical and the two surfaces agree on
126
+ # checks[] presence/shape.
127
+ if r.check_results:
128
+ entry["checks"] = [_check_manifest_entry(c) for c in r.check_results]
129
+ # Mirror the per-round manifest's refusal discriminator fields so the loop
130
+ # manifest is self-sufficient for tooling that wants to classify per-round
131
+ # refusal causes without reading individual round-N/manifest.json files.
132
+ if r.fail_closed_headers is not None:
133
+ entry["diff_filter_refusal_headers"] = r.fail_closed_headers
134
+ # Derive refusal_reason the same way round_no_changes.py does: fail_closed_headers
135
+ # → diff_malformed; oversize_diff_bytes → diff_too_large; oversize_prompt_chars →
136
+ # prompt_too_large. Only one is set per run; written only when non-None.
137
+ _refusal_reason: str | None
138
+ if r.fail_closed_headers is not None:
139
+ _refusal_reason = "diff_malformed"
140
+ elif r.oversize_diff_bytes is not None:
141
+ _refusal_reason = "diff_too_large"
142
+ elif r.oversize_prompt_chars is not None:
143
+ _refusal_reason = "prompt_too_large"
144
+ else:
145
+ _refusal_reason = None
146
+ if _refusal_reason is not None:
147
+ entry["refusal_reason"] = _refusal_reason
148
+ if r.oversize_prompt_chars is not None:
149
+ entry["oversize_prompt_chars"] = r.oversize_prompt_chars
150
+ round_entries.append(entry)
151
+
152
+ manifest = {
153
+ "syncade_version": __version__,
154
+ "run_id": run_id,
155
+ "started_at_utc": started_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
156
+ "max_rounds": max_rounds,
157
+ "final_exit_code": final_exit_code,
158
+ "final_round": final_round,
159
+ "termination_reason": termination_reason,
160
+ "rounds": round_entries,
161
+ }
162
+
163
+ path = run_dir / "loop-manifest.json"
164
+ atomic_write_json(path, manifest, sort_keys=False)
165
+ return path
@@ -0,0 +1,352 @@
1
+ """Loop-level loop-summary.md persistence.
2
+
3
+ Writes ``<run_dir>/loop-summary.md`` — the multi-round summary
4
+ artifact. Top-level alongside the per-round directories. Rolls up
5
+ every round + final verdict + the SHA series the loop's producer
6
+ commits produced.
7
+
8
+ the per-termination-reason next-steps + empty-series text tables and
9
+ the four small render helpers live in :mod:`.loop_summary_text`; this module
10
+ keeps the headline labels + ``persist_loop_summary``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ from ._atomic import atomic_write_text
19
+ from .checks import _LOOP_NEXT_STEPS_CHECK_FAILURE, _final_round_blocking_check_failure
20
+ from .loop_summary_text import (
21
+ _LOOP_NEXT_STEPS,
22
+ _budget_section,
23
+ _empty_commit_series_note,
24
+ _producer_commit_subject,
25
+ _round_duration_seconds,
26
+ _round_verdict_label,
27
+ _run_usages,
28
+ )
29
+
30
+ _TERMINATION_REASON_LABELS: dict[str, str] = {
31
+ "ship": "SHIP",
32
+ "no_changes_to_review": "nothing to review",
33
+ "producer_emptied_diff": "nothing to review (producer emptied all changes)",
34
+ "findings_present": "findings present",
35
+ "max_rounds_reached": "max rounds reached",
36
+ "budget_exceeded": "budget exceeded",
37
+ "provider_usage_limit": "provider usage limit reached",
38
+ "producer_stalled": "producer stalled",
39
+ "producer_subprocess_error": "producer subprocess error",
40
+ "reviewer_failure": "reviewer failure",
41
+ "synth_failure": "synthesizer failure",
42
+ "test_subprocess_error": "test subprocess error",
43
+ "check_subprocess_error": "blocking-check subprocess error",
44
+ "decision_needed": "decision needed (producer escalation)",
45
+ "blockers_all_deactivated": "decision needed (reviewers' blockers all deactivated)",
46
+ "worktree_error": "worktree provisioning error",
47
+ "diff_malformed": "diff filter refusal (unidentifiable headers)",
48
+ "diff_too_large": "diff exceeds [loop] max_diff_bytes",
49
+ "prompt_too_large": "assembled reviewer prompt exceeds provider ceiling",
50
+ "parse_failure": "output parse failure",
51
+ "config_error": "config error",
52
+ }
53
+ """Human-readable label for each :data:`syncade.orchestrator.TerminationReason`.
54
+ Used in loop-summary.md's headline so the operator sees a plain-
55
+ English description rather than the categorical machine-readable
56
+ slug."""
57
+
58
+
59
+ def persist_loop_summary(
60
+ run_dir: Path,
61
+ *,
62
+ final_exit_code: int,
63
+ final_round: int,
64
+ termination_reason: str,
65
+ rounds: list, # list[RoundResult]; typed as list to avoid circular import
66
+ max_rounds: int,
67
+ repo_root: Path | None = None,
68
+ started_at: datetime,
69
+ completed_at: datetime,
70
+ budget_tokens: int | None = None,
71
+ budget_usd: float | None = None,
72
+ budget_usages: list | None = None,
73
+ budget_ceiling: str | None = None,
74
+ ) -> Path:
75
+ """Write ``<run_dir>/loop-summary.md`` — the multi-round
76
+ summary.
77
+
78
+ Top-level artifact alongside the per-round directories. Rolls
79
+ up every round + final verdict + the SHA series the loop's
80
+ producer commits produced. The operator reading
81
+ ``loop-summary.md`` sees the whole loop in one document
82
+ without traversing N per-round `summary.md` files.
83
+
84
+ For ``max_rounds=1`` runs (single-pass back-compat), the
85
+ summary still fires but renders a single round section + the
86
+ trivial commit series ("no commits — round 0 shipped" or
87
+ similar).
88
+
89
+ Args:
90
+ run_dir: Top-level run directory (``<repo>/.syncade/runs/<id>/``).
91
+ final_exit_code: The loop's final exit code (see
92
+ :mod:`syncade.exit_codes`).
93
+ final_round: 0-indexed round that terminated the loop.
94
+ termination_reason: Categorical label (see
95
+ :data:`syncade.orchestrator.TerminationReason`).
96
+ rounds: List of :class:`syncade.orchestrator.RoundResult`,
97
+ one per round executed. ``len(rounds) == final_round + 1``.
98
+ max_rounds: Configured ``[loop] max_rounds`` ceiling.
99
+ started_at: Run start instant (captured once at the top of
100
+ ``run_review``).
101
+ completed_at: Run-end instant. The wall-clock duration is
102
+ ``completed_at - started_at``.
103
+
104
+ Returns:
105
+ Path of the written ``loop-summary.md``.
106
+ """
107
+ if not run_dir.is_dir():
108
+ raise FileNotFoundError(f"run_dir does not exist: {run_dir}")
109
+
110
+ run_id = run_dir.name
111
+ started = started_at.strftime("%Y-%m-%d %H:%M:%S UTC")
112
+ duration = completed_at - started_at
113
+ total_seconds = int(duration.total_seconds())
114
+ hh, rem = divmod(total_seconds, 3600)
115
+ mm, ss = divmod(rem, 60)
116
+ duration_str = f"{hh:02d}:{mm:02d}:{ss:02d}"
117
+
118
+ # Final verdict label — mirrors the findings.md verdict
119
+ # convention: SHIP for exit 0, NO-SHIP for exit 30 (real
120
+ # findings), ABORT for environmental failures (40 / 60).
121
+ # no_changes_to_review / producer_emptied_diff exit 0 but are NOT SHIPs — the
122
+ # final round dispatched no reviewers (no approval was rendered this round).
123
+ if final_exit_code == 0 and termination_reason in (
124
+ "no_changes_to_review",
125
+ "producer_emptied_diff",
126
+ ):
127
+ verdict_label = "NOTHING TO REVIEW"
128
+ elif final_exit_code == 0:
129
+ verdict_label = "SHIP"
130
+ elif final_exit_code == 30:
131
+ verdict_label = "NO-SHIP"
132
+ elif final_exit_code == 20:
133
+ verdict_label = "NO-SHIP"
134
+ elif final_exit_code == 25:
135
+ # Budget abort. Still NO-SHIP: the run did not converge, it was stopped
136
+ # at a token/dollar budget ceiling with findings potentially remaining.
137
+ verdict_label = "NO-SHIP"
138
+ elif final_exit_code == 10:
139
+ # producer escalation. Still NO-SHIP (escalation does not
140
+ # override the mechanical verdict); the termination reason carries
141
+ # the "decision needed" signal.
142
+ verdict_label = "NO-SHIP"
143
+ elif final_exit_code in (40, 60, 70):
144
+ verdict_label = "ABORT"
145
+ elif final_exit_code == 50:
146
+ verdict_label = "ABORT"
147
+ else:
148
+ verdict_label = "UNKNOWN"
149
+
150
+ reason_label = _TERMINATION_REASON_LABELS.get(termination_reason, termination_reason)
151
+ if termination_reason == "ship":
152
+ reason_label = f"ship (round {final_round})"
153
+ elif termination_reason == "budget_exceeded" and budget_ceiling is not None:
154
+ # Name WHICH ceiling in the headline too — a token-only abort must not read as a cost
155
+ # stop (Finding: the generic "cost ceiling hit" contradicted the Budget section).
156
+ which = "token" if budget_ceiling == "budget_tokens" else "cost"
157
+ reason_label = f"budget exceeded ({which} ceiling)"
158
+
159
+ lines: list[str] = [
160
+ f"# Syncade run {run_id} — loop summary",
161
+ "",
162
+ f"**Final verdict:** {verdict_label} ",
163
+ f"**Termination reason:** {reason_label} ",
164
+ f"**Final exit code:** {final_exit_code} ",
165
+ ]
166
+ # SHA annotation. The loop summary covers multiple rounds
167
+ # so a single "Generated against SHA" line would be ambiguous —
168
+ # the commit series below already lists every per-round snapshot.
169
+ # The headline gets the round-0 starting SHA (the operator's pre-
170
+ # loop tree state), which is the question a re-reader is most
171
+ # likely to have. The empty-``rounds`` branch is defensive; in
172
+ # practice the orchestrator only writes loop-summary.md after at
173
+ # least one round ran.
174
+ if rounds:
175
+ first_round_sha = rounds[0].snapshot.commit_sha
176
+ if first_round_sha:
177
+ lines.append(
178
+ f"**Round 0 starting SHA:** `{first_round_sha[:12]}` (full: `{first_round_sha}`) "
179
+ )
180
+ lines.extend(
181
+ [
182
+ f"**Rounds executed:** {len(rounds)} of {max_rounds} ",
183
+ f"**Started:** {started} ",
184
+ f"**Total wall-clock:** {duration_str}",
185
+ "",
186
+ ]
187
+ )
188
+
189
+ # --- Per-round sections ---------------------------------------
190
+ for r in rounds:
191
+ round_verdict = _round_verdict_label(r)
192
+ round_duration_s = _round_duration_seconds(r)
193
+ lines.append(f"## Round {r.round_idx} — {round_verdict} ({round_duration_s:.1f}s)")
194
+ lines.append("")
195
+ # Reviewers — count consolidated findings or report failure
196
+ if getattr(r, "no_changes_to_review", False):
197
+ lines.append("- Reviewers: not dispatched (diff was empty before review)")
198
+ elif r.fail_closed_headers:
199
+ lines.append("- Reviewers: not dispatched (diff refused — unidentifiable headers)")
200
+ elif r.oversize_diff_bytes is not None:
201
+ lines.append(
202
+ f"- Reviewers: not dispatched (diff too large — {r.oversize_diff_bytes:,} bytes)"
203
+ )
204
+ elif r.oversize_prompt_chars is not None:
205
+ lines.append(
206
+ f"- Reviewers: not dispatched (assembled prompt too large — "
207
+ f"{r.oversize_prompt_chars:,} chars)"
208
+ )
209
+ elif r.dispatch_result is not None and r.dispatch_result.all_succeeded:
210
+ n_reviewers = len(r.dispatch_result.successes)
211
+ lines.append(f"- Reviewers: {n_reviewers} succeeded")
212
+ elif r.dispatch_result is not None:
213
+ n_failed = len(r.dispatch_result.failures)
214
+ lines.append(f"- Reviewers: {n_failed} failed")
215
+ else:
216
+ lines.append("- Reviewers: did not run")
217
+ # Synth
218
+ _is_refusal = (
219
+ r.fail_closed_headers is not None
220
+ or r.oversize_diff_bytes is not None
221
+ or r.oversize_prompt_chars is not None
222
+ )
223
+ if r.synth_result is None and _is_refusal:
224
+ lines.append("- Synthesizer: not applicable (run refused before dispatch)")
225
+ elif r.synth_result is None:
226
+ lines.append("- Synthesizer: skipped")
227
+ elif r.synth_result.output is not None:
228
+ active = sum(1 for f in r.synth_result.output.consolidated_findings if not f.dismissed)
229
+ blockers = sum(
230
+ 1
231
+ for f in r.synth_result.output.consolidated_findings
232
+ if not f.dismissed and f.severity == "blocker"
233
+ )
234
+ lines.append(f"- Synthesizer: {active} active finding(s), {blockers} blocker(s)")
235
+ else:
236
+ err = type(r.synth_result.error).__name__
237
+ lines.append(f"- Synthesizer: failed ({err})")
238
+ # Test re-run
239
+ if r.test_result is None and _is_refusal:
240
+ lines.append("- Test re-run: not applicable (run refused before dispatch)")
241
+ elif r.test_result is None:
242
+ lines.append(f"- Test re-run: skipped ({r.test_skip_reason or 'unknown'})")
243
+ elif r.test_result.outcome == "subprocess_error":
244
+ err = type(r.test_result.error).__name__ if r.test_result.error else "Unknown"
245
+ lines.append(f"- Test re-run: subprocess_error ({err})")
246
+ else:
247
+ lines.append(f"- Test re-run: {r.test_result.outcome} (exit {r.test_result.exit_code})")
248
+ # Producer
249
+ if r.producer_result is None:
250
+ lines.append("- Producer: did not run (loop terminated)")
251
+ elif r.producer_result.outcome == "committed":
252
+ short_sha = r.producer_result.ending_sha[:12]
253
+ lines.append(f"- Producer: committed `{short_sha}`")
254
+ elif r.producer_result.outcome == "stalled":
255
+ lines.append("- Producer: stalled (no commit)")
256
+ elif r.producer_result.outcome == "escalated":
257
+ # an escalated producer always terminates the loop, so this
258
+ # round is the terminating round and ``termination_reason`` carries
259
+ # the coverage guard's disposition. "decision_needed" → the
260
+ # escalation covered every active blocker and was honored (exit 10);
261
+ # any other reason → it left a blocker uncovered and was rejected,
262
+ # so the loop treated the round as a stall (exit 30). Render the
263
+ # rejected case as such rather than as an honored decision checkpoint.
264
+ if termination_reason == "decision_needed":
265
+ lines.append("- Producer: escalated (operator decision needed)")
266
+ else:
267
+ lines.append(
268
+ "- Producer: escalated but not honored — left active "
269
+ "blocker(s) uncovered (treated as stall)"
270
+ )
271
+ else:
272
+ err = type(r.producer_result.error).__name__ if r.producer_result.error else "Unknown"
273
+ lines.append(f"- Producer: subprocess_error ({err})")
274
+ # Per-round artifacts
275
+ lines.append(f"- Per-round artifacts: [round-{r.round_idx}/](round-{r.round_idx}/)")
276
+ lines.append("")
277
+
278
+ # --- Commit series produced by this loop ----------------------
279
+ lines.append("## Commit series produced by this loop")
280
+ lines.append("")
281
+ # one-line lead-in. The headline Round 0 SHA invites the
282
+ # operator to look here for the rest of the SHAs; the lead-in
283
+ # makes that link explicit instead of dumping the operator into a
284
+ # bullet list with no framing.
285
+ lines.append(
286
+ "Each entry is one git commit on the operator's branch, in "
287
+ "chronological order. The first entry is the pre-loop tree "
288
+ "state; subsequent entries are producer commits."
289
+ )
290
+ lines.append("")
291
+ if not rounds:
292
+ lines.append("- (no rounds executed)")
293
+ else:
294
+ # First round's starting SHA = operator's pre-loop SHA
295
+ first_round = rounds[0]
296
+ starting_sha = first_round.snapshot.commit_sha
297
+ lines.append(f"- `{starting_sha[:12]}` — round 0 starting SHA (operator's commit)")
298
+ any_commits = False
299
+ for r in rounds:
300
+ if r.producer_result is None or r.producer_result.outcome != "committed":
301
+ continue
302
+ any_commits = True
303
+ ending = r.producer_result.ending_sha[:12]
304
+ # include the producer commit's
305
+ # subject line so the operator can scan the series
306
+ # without dropping to ``git log``. Looked up via
307
+ # ``git log -1 --pretty=format:'%s' <ending_sha>`` —
308
+ # wrapped in try/except so any git failure (missing
309
+ # repo_root, weird ref state) degrades to the
310
+ # subject-less form rather than crashing the summary
311
+ # write.
312
+ subject = _producer_commit_subject(repo_root, r.producer_result.ending_sha)
313
+ if subject:
314
+ lines.append(f'- `{ending}` — round {r.round_idx} producer ("{subject}")')
315
+ else:
316
+ lines.append(f"- `{ending}` — round {r.round_idx} producer")
317
+ if not any_commits:
318
+ # phrasing branches on termination
319
+ # reason so the empty-series wording matches what
320
+ # actually happened.
321
+ lines.append(_empty_commit_series_note(termination_reason))
322
+ lines.append("")
323
+
324
+ # --- Budget (only on a budget abort) --------------------------
325
+ if termination_reason == "budget_exceeded":
326
+ # Prefer the loop's ENFORCEMENT tally (fresh on resume — excludes rehydrated original
327
+ # rounds, so the reported number is the one that tripped). Fall back to summing all
328
+ # rounds only when a caller (a direct-construction test) didn't thread it — equivalent
329
+ # for a non-resumed run, where every round IS this-process spend.
330
+ usages = budget_usages if budget_usages is not None else _run_usages(rounds)
331
+ lines.extend(_budget_section(usages, budget_tokens, budget_usd, budget_ceiling))
332
+
333
+ # --- Next steps -----------------------------------------------
334
+ lines.append("## Next steps")
335
+ lines.append("")
336
+ # validation fix: a synth-clean BLOCKING-check NO-SHIP on the final round
337
+ # gets check-pointing guidance — the termination_reason-keyed text (e.g.
338
+ # max_rounds_reached's "read active blockers") is misleading when the
339
+ # NO-SHIP came from the mechanical lane, not the synthesizer.
340
+ if _final_round_blocking_check_failure(rounds):
341
+ next_steps = _LOOP_NEXT_STEPS_CHECK_FAILURE
342
+ else:
343
+ next_steps = _LOOP_NEXT_STEPS.get(
344
+ termination_reason,
345
+ "- See `manifest.json` and the per-round directories for details.",
346
+ )
347
+ lines.append(next_steps)
348
+ lines.append("")
349
+
350
+ summary_path = run_dir / "loop-summary.md"
351
+ atomic_write_text(summary_path, "\n".join(lines))
352
+ return summary_path