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,443 @@
1
+ """Per-exit-code "Next steps" guidance for summary.md.
2
+
3
+ Holds the static next-steps content blocks and the two resolvers
4
+ (``_resolve_next_steps`` for non-producer rounds, ``_resolve_next_steps_with_producer``
5
+ for rounds where a producer ran).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from syncade.producer import ProducerResult
11
+ from syncade.synthesizer import SynthesizerResult
12
+ from syncade.test_runner import TestRunResult
13
+
14
+ from .checks import check_aware_next_steps
15
+ from .producer import PRODUCER_NAME
16
+
17
+ # Per-exit-code "Next steps" guidance for summary.md. This rewired
18
+ # the SHIP/NO-SHIP guidance around the new findings.md artifact and
19
+ # added synth-specific pointers for the synthesizer-failure exit
20
+ # codes. Keyed on the exit codes run_review can actually emit
21
+ # (0/30/40/50/60/70); 10/20 aren't reachable from a single pass
22
+ # but get the generic fallback.
23
+ _NEXT_STEPS: dict[int, str] = {
24
+ 0: (
25
+ "- Read `findings.md` (the consolidated review across both\n"
26
+ " reviewers, written by the synthesizer) for the headline\n"
27
+ " narrative. `summary.md` is the run-level dashboard. Ship it."
28
+ ),
29
+ 30: (
30
+ "- Read `findings.md` FIRST — it lists the active blockers\n"
31
+ " (synthesizer's consolidated view, with per-reviewer\n"
32
+ " provenance). Each finding names the original reviewer(s)\n"
33
+ " and their severity calls. Then `summary.md` for run-level\n"
34
+ " context. The per-reviewer `.parsed.json` files have the raw\n"
35
+ " structured outputs for deeper inspection."
36
+ ),
37
+ # Exit 40 has TWO meaningful subcases:
38
+ # - reviewer-subprocess-failed → look at the per-reviewer files first
39
+ # - all-reviewers-succeeded + synth-subprocess-failed → look at
40
+ # synthesizer.* files first
41
+ # The static lookup below is the reviewer-failure variant; the
42
+ # synth-failure variant is _NEXT_STEPS_40_SYNTH, picked between in
43
+ # `_resolve_next_steps`.
44
+ 40: (
45
+ "- A reviewer subprocess failed. Check the `.error.txt` and\n"
46
+ " `.stderr` files for each failed reviewer; the persisted\n"
47
+ " `.error.txt` carries the exception class + message +\n"
48
+ " traceback so you can route by failure shape (auth, network,\n"
49
+ " timeout, binary-not-found). If it's a timeout, re-run with\n"
50
+ " `--timeout <seconds>` or set `[loop] timeout_seconds` in\n"
51
+ " `.syncade/config.toml`. The synthesizer phase was skipped\n"
52
+ " on this path (no silent N-1 degradation), so no\n"
53
+ " `synthesizer.*` artifacts exist for this run."
54
+ ),
55
+ 50: (
56
+ "- A reviewer's `provider` isn't a known adapter. Fix the\n"
57
+ " `provider` field in `.syncade/config.toml` — the `.error.txt`\n"
58
+ " files name the offending value."
59
+ ),
60
+ 60: (
61
+ "- Worktree provisioning failed for a reviewer worktree (the\n"
62
+ " pre-dispatch path). Check the `.error.txt` files; a stale\n"
63
+ " `<worktree_base>/<run-id>/` directory from a prior failed run\n"
64
+ " is the usual cause. If the test re-run leg's worktree was\n"
65
+ " the failing one, see the test_worktree_error variant\n"
66
+ " rendered when ``test_skip_reason == 'test_worktree_error'``."
67
+ ),
68
+ # Exit 70 has TWO meaningful subcases :
69
+ # - reviewer parse failure → look at per-reviewer .stdout / .error.txt
70
+ # - synth parse failure → look at synthesizer.{stdout,error.txt}
71
+ # The static lookup below is the reviewer-failure variant; the
72
+ # synth-failure variant is _NEXT_STEPS_70_SYNTH, picked between in
73
+ # `_resolve_next_steps` based on whether synth_result is the
74
+ # failing phase. This mirrors the exit-40 split from the fix
75
+ # #10 .
76
+ 70: (
77
+ "- A reviewer's output didn't parse as a `ReviewerOutput`.\n"
78
+ " `<reviewer-name>.stdout` has the raw response; look for a\n"
79
+ " `result` field in the JSON envelope or inline JSON in the\n"
80
+ " narrative. The parse exception is in\n"
81
+ " `<reviewer-name>.error.txt`. The synthesizer phase was\n"
82
+ " skipped on this path (no silent N-1 degradation), so no\n"
83
+ " `synthesizer.*` artifacts exist for this run."
84
+ ),
85
+ }
86
+ _NEXT_STEPS_FALLBACK = (
87
+ "- See `manifest.json` and the per-reviewer files in this directory\n for details."
88
+ )
89
+
90
+ _NEXT_STEPS_60_DIFF_MALFORMED = (
91
+ "- The diff contained section(s) whose headers could not be identified\n"
92
+ " (unparseable header, malformed C-quoted escape, or invalid UTF-8 in a\n"
93
+ " path). Syncade refused to continue because it could not determine whether\n"
94
+ " these sections are real changes or repo-context files to strip — treating\n"
95
+ " an unreadable change as 'nothing to review' would be a false SHIP.\n"
96
+ " The dropped headers are listed in `diff-refused.txt` in this round's\n"
97
+ " directory and in `manifest.json` under `diff_filter_refusal_headers`.\n"
98
+ " Common causes: binary paths with non-UTF-8 bytes; git C-quoting of\n"
99
+ " unusual characters. If the unreadable sections are repo-context files,\n"
100
+ " add their basenames to `strip_repo_context_files` in\n"
101
+ " `.syncade/config.toml`; if they are real changes you need reviewed,\n"
102
+ " the diff encoding itself may need investigation (e.g. a misconfigured\n"
103
+ " `core.quotepath` or a non-UTF-8 filename)."
104
+ )
105
+
106
+ _NEXT_STEPS_60_DIFF_TOO_LARGE = (
107
+ "- The reviewer-facing diff exceeded `[loop] max_diff_bytes` before any reviewer "
108
+ "was dispatched. The measured size and the ceiling are in `diff-refused.txt`. "
109
+ "Syncade refuses rather than truncating — a verdict on a partial diff is a verdict "
110
+ "on the wrong code. Narrow `--base` to a smaller range, split the PR, or raise "
111
+ "`[loop] max_diff_bytes` in `.syncade/config.toml`."
112
+ )
113
+
114
+ _NEXT_STEPS_60_PROMPT_TOO_LARGE = (
115
+ "- The assembled reviewer prompt exceeded the provider character ceiling before any "
116
+ "reviewer was dispatched. The oversized reviewer and char count are in `diff-refused.txt`. "
117
+ "The assembled prompt includes the diff, the reviewer template, and any prior-round "
118
+ "context. To reduce it: narrow `--base` to a smaller diff range, trim the reviewer "
119
+ "template in `.syncade/templates/`, or lower `[loop] max_diff_bytes` so the diff "
120
+ "contributes fewer characters."
121
+ )
122
+
123
+ _NEXT_STEPS_NO_CHANGES = (
124
+ "- The diff resolved to empty before any reviewer was dispatched: "
125
+ "the base ref resolved but no reviewable changes were found "
126
+ "(either no files changed, or every changed file was a repo-context "
127
+ "file stripped from the reviewer diff). No model work was spent. "
128
+ "If you expected changes to be reviewed, check that the correct "
129
+ "`--base` / `--scope` is specified and that the changed files are "
130
+ "not all listed in `strip_repo_context_files`."
131
+ )
132
+
133
+ # Variant for exit 40 when the failing subprocess was the synthesizer
134
+ # (every reviewer succeeded; the synth phase ran and failed). Point the operator
135
+ # at synthesizer.error.txt / .stderr first, not at reviewer files which are clean
136
+ # in this case.
137
+ _NEXT_STEPS_40_SYNTH = (
138
+ "- The synthesizer subprocess failed (every reviewer succeeded;\n"
139
+ " the consolidation pass crashed). Open `synthesizer.error.txt`\n"
140
+ " for the exception class + message + traceback, and\n"
141
+ " `synthesizer.stderr` for any captured codex stderr. Common\n"
142
+ " shapes: auth (codex token expired / wrong account; run\n"
143
+ " `codex login`), network (transient — retry), timeout (re-run\n"
144
+ " with `--timeout <seconds>` or set `[loop] timeout_seconds`\n"
145
+ " in `.syncade/config.toml`; the same timeout applies to the\n"
146
+ " synthesizer subprocess in v1). The per-reviewer `.error.txt`\n"
147
+ " files do not exist for this exit code — every reviewer\n"
148
+ " succeeded; their `.parsed.json` files have the structured\n"
149
+ " outputs the synth was asked to consolidate, in case the\n"
150
+ " synth failure is content-driven (very long combined\n"
151
+ " reviewer-output blob, etc.)."
152
+ )
153
+
154
+ # variant for exit 30 when the failing leg was the test
155
+ # re-run (not the synthesizer's consolidated_findings). The
156
+ # synthesizer was clean; the operator's tests reported failures.
157
+ # Different fix path: read test-run.stdout for the test runner's
158
+ # own output, not findings.md (which renders the synth's clean
159
+ # verdict with zero blockers).
160
+ _NEXT_STEPS_30_TEST_FAILED = (
161
+ "- The independent test re-run leg reported failures (the\n"
162
+ " cold synthesizer was clean — no consolidated\n"
163
+ " blockers). Open `test-run.stdout` FIRST for the operator-\n"
164
+ " configured test command's output; `test-run.stderr` for\n"
165
+ " any captured stderr; `test-run.exit-code.txt` for the\n"
166
+ " one-line exit code. `findings.md` still renders the\n"
167
+ " synthesizer's consolidated review (zero active blockers)\n"
168
+ " plus a Test Suite section pointing back at this artifact.\n"
169
+ " `summary.md` is the run-level dashboard."
170
+ )
171
+
172
+ # variant for exit 40 when the failing subprocess was the
173
+ # test-leg (not a reviewer or the synthesizer). The reviewer and
174
+ # synthesizer subprocesses succeeded; only the test command's
175
+ # subprocess failed (binary missing, timeout, OS launch error).
176
+ # findings.md renders Verdict: ABORT on this path (not SHIP / NO-SHIP).
177
+ _NEXT_STEPS_40_TEST_SUBPROCESS = (
178
+ "- The test re-run leg's subprocess failed (every reviewer\n"
179
+ " succeeded; the synthesizer succeeded; the operator-\n"
180
+ " configured test command couldn't run to completion). Open\n"
181
+ " `test-run.stderr` for any captured stderr from before the\n"
182
+ " kill; `manifest.json`'s `test_run.error_type` names the\n"
183
+ " failure shape (`SubprocessTimeoutError`,\n"
184
+ " `SubprocessNotFoundError`, etc.). Common shapes:\n"
185
+ " - timeout: re-run with `--timeout <seconds>` or set\n"
186
+ " `[loop] test_timeout_seconds` in `.syncade/config.toml`.\n"
187
+ " - binary not found: the operator's `test_command` references\n"
188
+ " a tool that isn't on PATH in the test worktree. Install\n"
189
+ " the tool, or adjust `test_command` to use the installed\n"
190
+ " one.\n"
191
+ " The synthesizer artifacts are present and valid for this\n"
192
+ " exit code; `findings.md` renders Verdict: ABORT (the test\n"
193
+ " signal is indeterminate until the environment problem is\n"
194
+ " fixed)."
195
+ )
196
+
197
+
198
+ # exit-60 variant when the failing worktree provisioning
199
+ # was the test leg's (not a reviewer's). Reviewer + synth phases
200
+ # already produced valid artifacts; the only thing missing is the
201
+ # test re-run output. Different fix path than the reviewer-
202
+ # worktree-error case.
203
+ _NEXT_STEPS_60_TEST_WORKTREE = (
204
+ "- The test re-run leg's worktree could not be provisioned\n"
205
+ " (every reviewer succeeded; the synthesizer succeeded; only\n"
206
+ " the test-leg worktree-add failed). Reviewer + synthesizer\n"
207
+ " artifacts are valid and on disk — read them as usual:\n"
208
+ " `findings.md` (Verdict: ABORT, indeterminate until the\n"
209
+ " provisioning failure is fixed), the per-reviewer\n"
210
+ " `.parsed.json` files for the structured outputs, and\n"
211
+ " `manifest.json` for the run-level structured view\n"
212
+ " (`test_skip_reason: test_worktree_error`).\n"
213
+ "\n"
214
+ " No `test-run.*` files were written (the leg never ran).\n"
215
+ " The provisioning diagnostic was emitted to the CLI's\n"
216
+ " stderr at the moment of failure (the `[syncade]` worktree-\n"
217
+ " error line) and via the `test re-run skipped (test\n"
218
+ " worktree provisioning failed: ...)` log line in this run's\n"
219
+ " stdout. Re-running after addressing the worktree failure\n"
220
+ " (typical causes: stale `<worktree_base>/<run-id>/` from a\n"
221
+ " prior interrupted run; `.git/worktrees/tests/` metadata\n"
222
+ " drift) will exercise the test leg fresh."
223
+ )
224
+
225
+
226
+ # Variant for exit 70 when the failing parser was the synthesizer.
227
+ # Reviewer files are clean on this path; don't send the operator
228
+ # there. The common-shapes list names ghost reviewer provenance and
229
+ # out-of-range original indices because those are cross-input
230
+ # provenance-validation failures.
231
+ _NEXT_STEPS_70_SYNTH = (
232
+ "- The synthesizer's output didn't parse as a `SynthesizerOutput`\n"
233
+ " (every reviewer succeeded; only the synth phase failed at\n"
234
+ " parse). `synthesizer.stdout` has the codex JSONL stream; the\n"
235
+ " final `agent_message` event's text is what the parser tried\n"
236
+ " to validate. The parse exception is in `synthesizer.error.txt`.\n"
237
+ " Common shapes: invented findings (empty `provenance`),\n"
238
+ " attempted dismissal of a unanimous blocker (schema-rejected),\n"
239
+ " missing required fields, ghost reviewer name (provenance\n"
240
+ " references a reviewer that doesn't exist in this run), and\n"
241
+ " out-of-range `original_index` (provenance points past the\n"
242
+ " source reviewer's findings list). The per-reviewer `.error.txt`\n"
243
+ " files do not exist for this exit code; their `.parsed.json`\n"
244
+ " files have the structured outputs the synth was asked to\n"
245
+ " consolidate."
246
+ )
247
+
248
+
249
+ def _resolve_next_steps(
250
+ exit_code: int,
251
+ synth_result: SynthesizerResult | None,
252
+ test_result: TestRunResult | None = None,
253
+ test_skip_reason: str | None = None,
254
+ check_results: list[TestRunResult] | None = None,
255
+ *,
256
+ no_changes_to_review: bool = False,
257
+ fail_closed_headers: list[str] | None = None,
258
+ oversize_diff_bytes: int | None = None,
259
+ oversize_prompt_chars: int | None = None,
260
+ ) -> str:
261
+ """Pick the right Next-steps content for the given exit code +
262
+ synthesizer outcome + test outcome + test-skip reason.
263
+
264
+ Exits 30, 40, 60, and 70 each have multiple meaningful
265
+ flavors that demand distinct first instructions. The split
266
+ rules:
267
+
268
+ - Exit 30: ``test_failed`` (clean synth, test reported
269
+ failures) → ``_NEXT_STEPS_30_TEST_FAILED`` (read
270
+ test-run.stdout); else (synth had blocker — the original
271
+ recorded reason) keep the synth-blocker variant from
272
+ ``_NEXT_STEPS``.
273
+ - Exit 40: ``test_subprocess_errored`` → test-subprocess
274
+ variant; else ``synth_failed`` → synth variant; else
275
+ reviewer-failed variant from ``_NEXT_STEPS``.
276
+ - Exit 60 : ``test_skip_reason == "test_worktree_error"``
277
+ → test-leg worktree variant naming the preserved
278
+ reviewer + synth artifacts; else the generic worktree-
279
+ provisioning variant for reviewer-side failures.
280
+ - Exit 70: unchanged — the test leg has no parse path, so
281
+ this code only ever points at reviewer or synth output.
282
+
283
+ Args:
284
+ exit_code: The run's exit code.
285
+ synth_result: The :class:`SynthesizerResult`, or ``None``
286
+ if the synth phase was skipped.
287
+ test_result: The :class:`TestRunResult`, or ``None`` if
288
+ the test phase was skipped.
289
+ test_skip_reason: Why the test leg was skipped. Used to
290
+ distinguish the test-worktree-error variant of
291
+ exit 60 from the reviewer-worktree variant.
292
+
293
+ Returns:
294
+ The Next-steps content string for this exit-code / phase
295
+ combination, or :data:`_NEXT_STEPS_FALLBACK` for any code
296
+ not covered above (10/20 today).
297
+ """
298
+ # a blocking mechanical check that drove the round's
299
+ # exit code (failed → 30 on a synth-clean round; subprocess_error → 40)
300
+ # takes precedence over the generic per-exit-code guidance, which would
301
+ # otherwise point at the wrong artifact. Returns None for every
302
+ # non-check-driven round (including all zero-check rounds), keeping the
303
+ # rest of this routing byte-identical.
304
+ if fail_closed_headers is not None:
305
+ return _NEXT_STEPS_60_DIFF_MALFORMED
306
+ if oversize_diff_bytes is not None:
307
+ return _NEXT_STEPS_60_DIFF_TOO_LARGE
308
+ if oversize_prompt_chars is not None:
309
+ return _NEXT_STEPS_60_PROMPT_TOO_LARGE
310
+ if no_changes_to_review:
311
+ return _NEXT_STEPS_NO_CHANGES
312
+
313
+ check_override = check_aware_next_steps(exit_code, synth_result, test_result, check_results)
314
+ if check_override is not None:
315
+ return check_override
316
+
317
+ synth_failed = synth_result is not None and synth_result.error is not None
318
+ test_failed = test_result is not None and test_result.outcome == "failed"
319
+ test_subprocess_errored = test_result is not None and test_result.outcome == "subprocess_error"
320
+
321
+ if exit_code == 30 and test_failed:
322
+ return _NEXT_STEPS_30_TEST_FAILED
323
+ if exit_code == 40 and test_subprocess_errored:
324
+ return _NEXT_STEPS_40_TEST_SUBPROCESS
325
+ if exit_code == 40 and synth_failed:
326
+ return _NEXT_STEPS_40_SYNTH
327
+ # exit 60 splits between reviewer-worktree-error
328
+ # (generic provisioning variant) and test-leg-worktree-error
329
+ # (preserved reviewer + synth artifacts variant).
330
+ if exit_code == 60 and test_skip_reason == "test_worktree_error":
331
+ return _NEXT_STEPS_60_TEST_WORKTREE
332
+ if exit_code == 70 and synth_failed:
333
+ return _NEXT_STEPS_70_SYNTH
334
+ return _NEXT_STEPS.get(exit_code, _NEXT_STEPS_FALLBACK)
335
+
336
+
337
+ def _resolve_next_steps_with_producer(
338
+ exit_code: int,
339
+ producer_result: ProducerResult,
340
+ escalation_honored: bool = False,
341
+ branch_already_advanced: bool = False,
342
+ ) -> str:
343
+ """Next-steps guidance for a per-round
344
+ summary.md AFTER a producer ran on this round.
345
+
346
+ On a round where the producer already attempted to fix the findings, the
347
+ producer's outcome is the actionable signal for this round.
348
+
349
+ Branches by producer outcome:
350
+
351
+ - ``committed`` — the producer made a commit; the next round
352
+ will dispatch reviewers against the new diff. Operator
353
+ reads ``producer.stdout`` to see what the producer
354
+ attempted.
355
+ - ``stalled`` — the producer subprocess completed cleanly
356
+ but didn't commit. The loop terminated with
357
+ ``producer_stalled`` (exit 30 + that termination reason);
358
+ operator should clarify the spec or fix manually.
359
+ - ``subprocess_error`` — the producer subprocess failed.
360
+ Loop terminated with exit 40. Operator reads
361
+ ``producer.error.txt`` for the exception trace.
362
+ - ``escalated`` — splits on ``escalation_honored``: when
363
+ True (the escalation covered every active blocker), the loop
364
+ checkpointed at exit 10 and the operator records a decision and
365
+ resumes; when False (the coverage guard rejected it for leaving
366
+ a blocker uncovered), the round is treated as a stall (exit 30,
367
+ no decision-needed.md) and the operator is pointed at the open
368
+ findings, not a checkpoint that doesn't exist.
369
+ """
370
+ if producer_result.outcome == "committed":
371
+ return (
372
+ f"- The producer subprocess committed `{producer_result.ending_sha[:12]}` "
373
+ f"to attempt a fix for this round's findings. Read "
374
+ f"`{PRODUCER_NAME}.stdout` for the producer's narrative; the "
375
+ f"commit subject + body are in git history at the new SHA. The "
376
+ f"next round of reviewers will see the post-producer diff and "
377
+ f"decide SHIP or NO-SHIP from there. To inspect the consolidated "
378
+ f"review the producer was given, open `findings.md`."
379
+ )
380
+ if producer_result.outcome == "stalled":
381
+ return (
382
+ f"- The producer subprocess completed but did NOT commit "
383
+ f"(HEAD stayed at `{producer_result.starting_sha[:12]}`). The "
384
+ f"loop terminated with `producer_stalled`. Read "
385
+ f"`{PRODUCER_NAME}.stdout` for the producer's narrative; "
386
+ f"common causes are under-specified findings (the spec doesn't "
387
+ f"say what 'right' looks like) or the producer concluding the "
388
+ f"existing code is already correct. The operator's next step "
389
+ f"is to clarify the spec / address the finding manually and "
390
+ f"re-run."
391
+ )
392
+ if producer_result.outcome == "escalated" and escalation_honored:
393
+ _branch_note = (
394
+ "An earlier round already advanced your branch — producer "
395
+ "commits from that round are on it. This round's producer "
396
+ "did not commit."
397
+ if branch_already_advanced
398
+ else "No branch was advanced by this round."
399
+ )
400
+ return (
401
+ "- The producer ESCALATED a finding it determined is an operator "
402
+ "decision (a spec/design conflict), not a code defect. The loop "
403
+ f"checkpointed and terminated (exit 10). {_branch_note} "
404
+ "Read `decision-needed.md` at the run root for the "
405
+ "producer's case + concrete options, record your decision in "
406
+ "`decision.txt`, and run `syncade --resume <run-id>` — the "
407
+ "escalated round re-runs with your decision fed to the producer."
408
+ )
409
+ if producer_result.outcome == "escalated":
410
+ # the escalation did NOT cover every active blocker, so the
411
+ # coverage guard rejected it and the round is treated as a stall
412
+ # (NO-SHIP, exit 30; no decision checkpoint was created). Point the
413
+ # operator at the open findings, not at a `decision-needed.md` that
414
+ # was never written.
415
+ return (
416
+ f"- The producer attempted to ESCALATE a finding as an operator "
417
+ f"decision, but its escalation did not cover every active blocker "
418
+ f"this round, so it was NOT honored — the round is treated as a "
419
+ f"stall (NO-SHIP, exit 30; no branch advanced and no decision "
420
+ f"checkpoint created). Read `findings.md` for the active blockers "
421
+ f"and `{PRODUCER_NAME}.stdout` for the producer's narrative. The "
422
+ f"uncovered blocker(s) carry forward; re-run after the producer can "
423
+ f"fix or fully cover them, or address the finding manually."
424
+ )
425
+ if producer_result.ending_sha != producer_result.starting_sha:
426
+ return (
427
+ f"- The producer subprocess failed after moving HEAD to "
428
+ f"`{producer_result.ending_sha[:12]}`. This is an indeterminate "
429
+ f"producer commit, not a successful committed round: the operator's "
430
+ f"branch was NOT advanced. Read `{PRODUCER_NAME}.stdout`, "
431
+ f"`{PRODUCER_NAME}.stderr`, and `{PRODUCER_NAME}.error.txt`; use "
432
+ f"`git show {producer_result.ending_sha}` to inspect the recorded "
433
+ f"commit before deciding whether to keep or manually replay it."
434
+ )
435
+ return (
436
+ f"- The producer subprocess failed before it could attempt a fix. "
437
+ f"Read `{PRODUCER_NAME}.stderr` and `{PRODUCER_NAME}.error.txt` for "
438
+ f"the failure shape. Common causes are auth (run `claude login` / "
439
+ f"`codex login`), network errors (retry), or a missing CLI binary. "
440
+ f"The operator's branch was NOT advanced for this round; "
441
+ f"`findings.md` still reflects this round's NO-SHIP signal and is "
442
+ f"the operator's manual-fix target."
443
+ )