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,249 @@
1
+ """Mechanical-check leg persistence.
2
+
3
+ Writes ``<round_dir>/<check_name>.check.{stdout,stderr,exit-code.txt}`` for each
4
+ configured check that ran, mirroring :func:`persist_test_run_result`'s
5
+ file-layout convention. The orchestrator calls this once per check on rounds
6
+ where checks ran (reviewers succeeded AND the synth produced output). The
7
+ manifest / findings surfacing of these results lives in the round-manifest +
8
+ findings_md + run_summary writers.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from syncade.synthesis import has_active_blocker
18
+ from syncade.test_runner import TestRunResult
19
+
20
+ from ._atomic import atomic_write_text
21
+ from ._validation import _validate_reviewer_filename_basename
22
+
23
+ if TYPE_CHECKING:
24
+ from syncade.synthesizer import SynthesizerResult
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CheckArtifactPaths:
29
+ """Where one mechanical check's artifacts land on disk.
30
+
31
+ All paths are absolute and rooted at ``<round_dir>``. ``name`` is the
32
+ check's name (a validated plain basename) so a consumer can map the files
33
+ back to the configured check without re-deriving the layout convention.
34
+ """
35
+
36
+ name: str
37
+ stdout: Path
38
+ stderr: Path
39
+ exit_code: Path
40
+
41
+
42
+ def persist_check_result(
43
+ round_dir: Path,
44
+ check_result: TestRunResult,
45
+ ) -> CheckArtifactPaths:
46
+ """Write one check's outputs to
47
+ ``<round_dir>/<name>.check.{stdout,stderr,exit-code.txt}``.
48
+
49
+ Mirrors :func:`persist_test_run_result`; the ``.check.`` infix groups the
50
+ files and keeps them distinct from the per-reviewer and test-run artifacts.
51
+ The check name is a validated plain basename (rejected at config-load
52
+ otherwise); this re-validates defensively before using it as a filename so
53
+ a check name can never escape ``round_dir``.
54
+
55
+ Args:
56
+ round_dir: The round directory to write into (must already exist).
57
+ check_result: One :class:`~syncade.test_runner.TestRunResult`
58
+ populated with mechanical-check ``name`` and ``severity``.
59
+
60
+ Returns:
61
+ :class:`CheckArtifactPaths` naming all three written files.
62
+
63
+ Raises:
64
+ FileNotFoundError: If ``round_dir`` does not exist.
65
+ """
66
+ _require_check_metadata(check_result)
67
+ _validate_reviewer_filename_basename(check_result.name)
68
+ if not round_dir.is_dir():
69
+ raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
70
+
71
+ base = check_result.name
72
+ stdout_path = round_dir / f"{base}.check.stdout"
73
+ stderr_path = round_dir / f"{base}.check.stderr"
74
+ exit_code_path = round_dir / f"{base}.check.exit-code.txt"
75
+
76
+ atomic_write_text(stdout_path, check_result.stdout)
77
+ atomic_write_text(stderr_path, check_result.stderr)
78
+ atomic_write_text(exit_code_path, f"{check_result.exit_code}\n")
79
+
80
+ return CheckArtifactPaths(
81
+ name=base,
82
+ stdout=stdout_path,
83
+ stderr=stderr_path,
84
+ exit_code=exit_code_path,
85
+ )
86
+
87
+
88
+ _STATUS_LABEL = {"passed": "PASS", "failed": "FAIL", "subprocess_error": "ERROR"}
89
+
90
+
91
+ def render_checks_section(check_results: list[TestRunResult]) -> list[str]:
92
+ """Render the ``## Mechanical checks`` section (findings.md / summary.md) as
93
+ a list of lines, or ``[]`` when there are no checks.
94
+
95
+ Advisory failures are tagged ``(advisory — non-blocking)`` and counted in a
96
+ trailing note; blocking failures read plainly (they drove the verdict). A
97
+ failing/erroring check links its captured output files.
98
+ """
99
+ if not check_results:
100
+ return []
101
+ lines = ["## Mechanical checks", ""]
102
+ advisory_failures = 0
103
+ for c in check_results:
104
+ status = _STATUS_LABEL.get(c.outcome, c.outcome.upper())
105
+ failed = c.outcome != "passed"
106
+ if failed and c.severity == "advisory":
107
+ tag = " (advisory — non-blocking)"
108
+ advisory_failures += 1
109
+ elif failed:
110
+ tag = " (blocking)"
111
+ else:
112
+ tag = ""
113
+ lines.append(f"- **{c.name}** ({c.severity}): {status} — exit {c.exit_code}{tag} ")
114
+ if failed:
115
+ lines.append(
116
+ f" Output: [{c.name}.check.stdout]({c.name}.check.stdout) | "
117
+ f"[{c.name}.check.stderr]({c.name}.check.stderr)"
118
+ )
119
+ if advisory_failures:
120
+ noun = "check" if advisory_failures == 1 else "checks"
121
+ lines.append("")
122
+ lines.append(f"Advisory: {advisory_failures} mechanical {noun} failed (non-blocking).")
123
+ lines.append("")
124
+ return lines
125
+
126
+
127
+ def _require_check_metadata(check_result: TestRunResult) -> None:
128
+ if check_result.name is None or check_result.severity is None:
129
+ raise ValueError("check result requires name and severity")
130
+
131
+
132
+ def _check_manifest_entry(check_result: TestRunResult) -> dict[str, object]:
133
+ """Build one ``checks[]`` entry for the round manifest. ``blocking`` is
134
+ surfaced as an explicit boolean so a consumer can filter gating vs advisory
135
+ checks without re-deriving it from ``severity``."""
136
+ _require_check_metadata(check_result)
137
+ return {
138
+ "name": check_result.name,
139
+ "severity": check_result.severity,
140
+ "blocking": check_result.severity == "blocking",
141
+ "outcome": check_result.outcome,
142
+ "exit_code": check_result.exit_code,
143
+ "command": check_result.command,
144
+ "duration_seconds": check_result.duration_seconds,
145
+ "stdout_path": f"{check_result.name}.check.stdout",
146
+ "stderr_path": f"{check_result.name}.check.stderr",
147
+ "exit_code_path": f"{check_result.name}.check.exit-code.txt",
148
+ "error_type": (
149
+ type(check_result.error).__name__ if check_result.error is not None else None
150
+ ),
151
+ }
152
+
153
+
154
+ # check-aware Next-steps for the per-round summary.md.
155
+ # When a BLOCKING mechanical check (not a reviewer / synth / test phase)
156
+ # drove the round's exit code, the generic per-exit-code guidance points
157
+ # the operator at the wrong artifact ("read the active synth blockers" /
158
+ # "a reviewer subprocess failed"). These variants point at the check.
159
+ _NEXT_STEPS_30_CHECK_FAILED = (
160
+ "- A blocking mechanical check FAILED (the cold synthesizer was\n"
161
+ " clean — no consolidated blockers). The `## Mechanical checks`\n"
162
+ " section of `findings.md` names which check failed; open that\n"
163
+ " check's `<name>.check.stdout` / `<name>.check.stderr` for its\n"
164
+ " output. This is a NO-SHIP from the mechanical lane, not a\n"
165
+ " reviewer / synth finding — `findings.md`'s consolidated review\n"
166
+ " has zero active blockers."
167
+ )
168
+ _NEXT_STEPS_40_CHECK_SUBPROCESS = (
169
+ "- A blocking mechanical check's subprocess could not run to\n"
170
+ " completion (every reviewer succeeded; the synthesizer\n"
171
+ " succeeded). `findings.md` renders Verdict: ABORT — the check\n"
172
+ " signal is indeterminate until the environment problem is\n"
173
+ " fixed. Read the failing check's `<name>.check.stderr` and the\n"
174
+ " manifest's `checks[].error_type`; common shapes are a missing\n"
175
+ " binary (the check `command` references a tool not on PATH in\n"
176
+ " the check worktree) or a timeout."
177
+ )
178
+
179
+
180
+ def check_aware_next_steps(
181
+ exit_code: int,
182
+ synth_result: SynthesizerResult | None,
183
+ test_result: TestRunResult | None,
184
+ check_results: list[TestRunResult] | None,
185
+ ) -> str | None:
186
+ """Return a check-driven Next-steps override for the per-round
187
+ summary.md, or ``None`` when the round's failure was NOT driven by a
188
+ blocking mechanical check (the caller keeps its normal routing).
189
+
190
+ Fires only for the two check-driven exits — a failing blocking check
191
+ on a synth-clean round (exit 30) and a blocking-check subprocess
192
+ error (exit 40) — and only when no reviewer / synth / test phase is
193
+ the real cause. Every zero-check round passes ``check_results``
194
+ empty and gets ``None`` here, so the generic guidance stays
195
+ byte-identical everywhere else.
196
+ """
197
+ checks = check_results or []
198
+ blocking_failed = any(c.severity == "blocking" and c.outcome == "failed" for c in checks)
199
+ blocking_errored = any(
200
+ c.severity == "blocking" and c.outcome == "subprocess_error" for c in checks
201
+ )
202
+ if exit_code == 40 and blocking_errored:
203
+ synth_failed = synth_result is not None and synth_result.error is not None
204
+ test_errored = test_result is not None and test_result.outcome == "subprocess_error"
205
+ if not synth_failed and not test_errored:
206
+ return _NEXT_STEPS_40_CHECK_SUBPROCESS
207
+ if exit_code == 30 and blocking_failed:
208
+ test_failed = test_result is not None and test_result.outcome == "failed"
209
+ synth_clean = (
210
+ synth_result is not None
211
+ and synth_result.output is not None
212
+ and not has_active_blocker(synth_result.output)
213
+ )
214
+ if synth_clean and not test_failed:
215
+ return _NEXT_STEPS_30_CHECK_FAILED
216
+ return None
217
+
218
+
219
+ # validation fix (codex blocker): the LOOP-summary equivalent of the
220
+ # per-round check-aware next-steps. When the FINAL round's NO-SHIP was a
221
+ # synth-clean blocking-check failure, loop-summary.md points at the check
222
+ # instead of the termination_reason-keyed text (e.g. max_rounds_reached's
223
+ # "read the remaining active blockers"), which misleads — there are none.
224
+ _LOOP_NEXT_STEPS_CHECK_FAILURE = (
225
+ "- The final round was a NO-SHIP driven by a failing BLOCKING mechanical "
226
+ "check, not by the synthesizer's consolidated findings (which were clean). "
227
+ "Read the `## Mechanical checks` section of that round's `findings.md` to "
228
+ "see which check failed, then open its `<name>.check.stdout` / "
229
+ "`<name>.check.stderr` in the round directory. There are NO active synth "
230
+ "blockers here — the mechanical lane is the NO-SHIP signal."
231
+ )
232
+
233
+
234
+ def _final_round_blocking_check_failure(rounds: list) -> bool:
235
+ """True when the LAST round's NO-SHIP was a synth-clean BLOCKING
236
+ mechanical-check failure (duck-typed over ``RoundResult``). Drives the
237
+ loop-summary next-steps override above."""
238
+ if not rounds:
239
+ return False
240
+ r = rounds[-1]
241
+ synth = r.synth_result
242
+ synth_clean = (
243
+ synth is not None and synth.output is not None and not has_active_blocker(synth.output)
244
+ )
245
+ if not synth_clean:
246
+ return False
247
+ if r.test_result is not None and r.test_result.outcome == "failed":
248
+ return False
249
+ return any(c.severity == "blocking" and c.outcome == "failed" for c in r.check_results)
@@ -0,0 +1,289 @@
1
+ """Decision-needed checkpoint persistence.
2
+
3
+ When a NO-SHIP round's producer escalates a finding as an operator
4
+ decision (see :mod:`syncade.producer_escalation`) AND the loop honors
5
+ that escalation, the loop terminator writes
6
+ ``<run_dir>/decision-needed.md`` — the operator-facing checkpoint
7
+ carrying the producer's case (the finding, the decision to make, the
8
+ concrete options, the reproduction-backed rationale) and the
9
+ record-a-decision-then-resume instructions. Since the escalation
10
+ is honored (and this file written) ONLY when its ``finding_indices``
11
+ cover every active blocker in the round; an escalation that leaves a
12
+ blocker uncovered is treated as a producer stall (exit 30) and this
13
+ file is not written.
14
+
15
+ The companion is ``decision.txt``: the operator writes their decision
16
+ into ``<run_dir>/decision.txt`` and runs ``syncade --resume <run-id>``;
17
+ the resumed round's producer receives that text. The filename constant
18
+ and the reader live here so the writer (which tells the operator where to
19
+ write) and the reader (resume) share one source of truth.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING
26
+
27
+ from syncade.producer_escalation import ProducerEscalation
28
+
29
+ from ._atomic import atomic_write_text
30
+
31
+ if TYPE_CHECKING:
32
+ from syncade.test_runner import TestRunResult
33
+
34
+ DECISION_NEEDED_FILENAME = "decision-needed.md"
35
+ """The operator-facing escalation checkpoint, at the run root."""
36
+
37
+ OPERATOR_DECISION_FILENAME = "decision.txt"
38
+ """Where the operator records the decision the resume reads back. Plain
39
+ text (the producer receives it verbatim). Single source of truth shared
40
+ by :func:`persist_decision_needed` (which instructs the operator) and
41
+ :func:`read_operator_decision` (which the resume path calls)."""
42
+
43
+
44
+ def persist_decision_needed(
45
+ run_dir: Path,
46
+ *,
47
+ round_idx: int,
48
+ escalation: ProducerEscalation,
49
+ run_id: str,
50
+ branch_advanced: bool = False,
51
+ check_results: list[TestRunResult] | None = None,
52
+ ) -> Path:
53
+ """Write ``<run_dir>/decision-needed.md`` for an honored escalation.
54
+
55
+ The loop terminator calls this only when the round's producer
56
+ escalated AND the escalation's ``finding_indices`` covered every
57
+ active blocker; a non-honored escalation maps to a producer
58
+ stall (exit 30) and never reaches this writer.
59
+
60
+ Args:
61
+ run_dir: Top-level run directory (``<repo>/.syncade/runs/<id>/``).
62
+ round_idx: The 0-indexed round whose producer escalated.
63
+ escalation: The structured :class:`ProducerEscalation` the
64
+ producer emitted.
65
+ run_id: The run-id, for the heading + the resume command.
66
+ branch_advanced: Whether an EARLIER round in this run already
67
+ fast-forwarded the branch. Passed in from the loop's cumulative
68
+ state rather than assumed: a multi-round run can reach this
69
+ state after a prior producer round landed commits.
70
+ check_results: The mechanical-check results from this round, if any.
71
+ Failing blocking checks are listed in the document so the operator
72
+ knows that resuming addresses the producer question but the checks
73
+ will still rerun and must also pass.
74
+
75
+ Returns:
76
+ Path of the written ``decision-needed.md``.
77
+
78
+ Raises:
79
+ FileNotFoundError: If ``run_dir`` does not exist.
80
+ """
81
+ if not run_dir.is_dir():
82
+ raise FileNotFoundError(f"run_dir does not exist: {run_dir}")
83
+
84
+ _branch_note = (
85
+ "**Your branch was already advanced** by an earlier round in this "
86
+ "run — producer commits from that round are on it. Nothing was "
87
+ "advanced for THIS round."
88
+ if branch_advanced
89
+ else "No branch was advanced."
90
+ )
91
+ lines: list[str] = [
92
+ f"# Decision needed — Syncade run {run_id}",
93
+ "",
94
+ f"The producer escalated a finding in round {round_idx} that it "
95
+ "determined is an **operator decision** — a spec/design conflict it "
96
+ "cannot resolve in code without a human ruling, not a defect it can "
97
+ f"fix. The loop checkpointed and terminated (exit 10); {_branch_note} "
98
+ "The mechanical verdict is unchanged (still NO-SHIP).",
99
+ "",
100
+ "## The finding",
101
+ "",
102
+ *_md_text_block_lines(escalation.finding),
103
+ "",
104
+ "## The decision you must make",
105
+ "",
106
+ *_md_text_block_lines(escalation.decision),
107
+ "",
108
+ "## Options",
109
+ "",
110
+ ]
111
+ for idx, option in enumerate(escalation.options, start=1):
112
+ lines.extend([f"### Option {idx}", "", *_md_text_block_lines(option), ""])
113
+ lines += [
114
+ "## The producer's rationale (reproduction-backed)",
115
+ "",
116
+ *_md_text_block_lines(escalation.rationale),
117
+ "",
118
+ ]
119
+
120
+ failing_blocking_checks = [
121
+ c for c in (check_results or []) if c.severity == "blocking" and c.outcome != "passed"
122
+ ]
123
+ if failing_blocking_checks:
124
+ lines += [
125
+ "## Co-failing blocking checks",
126
+ "",
127
+ "These blocking mechanical checks also failed this round. Resuming "
128
+ "addresses the producer decision above, but the checks rerun on "
129
+ "resume and must also pass before the round can SHIP:",
130
+ "",
131
+ ]
132
+ for c in failing_blocking_checks:
133
+ status = "FAIL" if c.outcome == "failed" else "ERROR"
134
+ lines.append(f"- **{c.name}**: {status} (exit {c.exit_code})")
135
+ if c.name:
136
+ lines.append(
137
+ f" Output: `{c.name}.check.stdout` / `{c.name}.check.stderr` "
138
+ f"in `round-{round_idx}/`"
139
+ )
140
+ lines.append("")
141
+
142
+ lines += [
143
+ "## How to continue",
144
+ "",
145
+ f"1. Decide, then write your decision (the option you chose plus any "
146
+ f"specifics the producer needs) into `{OPERATOR_DECISION_FILENAME}` in "
147
+ "this run directory.",
148
+ f"2. Resume: `syncade --resume {run_id}`. The escalated round re-runs "
149
+ "with your decision fed to the producer. (Resume refuses if "
150
+ f"`{OPERATOR_DECISION_FILENAME}` is missing or empty.)",
151
+ "",
152
+ ]
153
+
154
+ path = run_dir / DECISION_NEEDED_FILENAME
155
+ atomic_write_text(path, "\n".join(lines))
156
+ return path
157
+
158
+
159
+ def _md_text_block_lines(text: str) -> list[str]:
160
+ max_run = 0
161
+ current = 0
162
+ for ch in text:
163
+ if ch == "`":
164
+ current += 1
165
+ if current > max_run:
166
+ max_run = current
167
+ else:
168
+ current = 0
169
+ fence = "`" * max(4, max_run + 1)
170
+ return [f"{fence}text", text.rstrip("\n"), fence]
171
+
172
+
173
+ def read_operator_decision(run_dir: Path) -> str | None:
174
+ """Return the operator's recorded decision from
175
+ ``<run_dir>/decision.txt``, or ``None`` when the file is absent or
176
+ blank (whitespace-only counts as absent — an empty file is not a
177
+ decision). Stripped of surrounding whitespace.
178
+ """
179
+ decision_path = run_dir / OPERATOR_DECISION_FILENAME
180
+ if not decision_path.is_file():
181
+ return None
182
+ text = decision_path.read_text(encoding="utf-8").strip()
183
+ return text or None
184
+
185
+
186
+ def persist_deactivated_blockers_decision_needed(
187
+ run_dir: Path,
188
+ *,
189
+ round_idx: int,
190
+ run_id: str,
191
+ deactivated: list[tuple[str, str, str]],
192
+ branch_advanced: bool = False,
193
+ ) -> Path:
194
+ """Write ``decision-needed.md`` for a PR-h-01 increment-D escalation.
195
+
196
+ The other escalation in this module is the producer's: it argues a case and
197
+ asks the operator to choose. This one has no advocate. Two or more distinct
198
+ reviewers independently raised blockers, the synthesizer ruled every one of
199
+ them out, and nothing checked whether those rulings were right — so the
200
+ round is neither a defensible SHIP nor a mechanical NO-SHIP.
201
+
202
+ The document's whole job is to put the reviewers' own words in front of the
203
+ operator next to what the synthesizer did with them, because that
204
+ comparison is the decision. Quotes are verbatim: provenance text is
205
+ cross-checked against the source reviewer's finding
206
+ (``syncade.synthesizer.validation``), so what is printed here is what the
207
+ reviewer actually wrote.
208
+
209
+ Args:
210
+ run_dir: Top-level run directory (``<repo>/.syncade/runs/<id>/``).
211
+ round_idx: The 0-indexed round that escalated.
212
+ run_id: The run-id, for the heading.
213
+ deactivated: ``(reviewer_name, verbatim_finding_text, disposition)``
214
+ per deactivated source blocker, where ``disposition`` describes how
215
+ the synthesizer removed it (e.g. ``dismissed: <rationale>``).
216
+ branch_advanced: Whether an EARLIER round in this run already
217
+ fast-forwarded the branch. Passed in rather than assumed: a
218
+ multi-round run can reach this state after a producer round
219
+ landed commits, and telling an operator their branch is untouched
220
+ when it is not is exactly the kind of wrong that gets acted on.
221
+
222
+ Returns:
223
+ Path of the written ``decision-needed.md``.
224
+
225
+ Raises:
226
+ FileNotFoundError: If ``run_dir`` does not exist.
227
+ """
228
+ if not run_dir.is_dir():
229
+ raise FileNotFoundError(f"run_dir does not exist: {run_dir}")
230
+
231
+ reviewers = sorted({name for name, _, _ in deactivated})
232
+ lines: list[str] = [
233
+ f"# Decision needed — Syncade run {run_id}",
234
+ "",
235
+ f"In round {round_idx}, **{len(reviewers)} independent reviewers "
236
+ f"({', '.join(reviewers)}) each raised at least one blocker, and the "
237
+ "synthesizer deactivated every one of them** — by dismissal, by "
238
+ "downgrade, or by splitting one concern into separate single-reviewer "
239
+ "findings it could then rule out individually.",
240
+ "",
241
+ (
242
+ "That may be entirely correct. But independent corroboration is the "
243
+ "strongest signal this tool produces, and a machine should not "
244
+ "discard all of it silently. The loop terminated at exit 10 rather "
245
+ "than reporting a SHIP it cannot justify."
246
+ ),
247
+ "",
248
+ (
249
+ "**Your branch was already advanced** by an earlier round in this "
250
+ "run — producer commits from that round are on it. Nothing was "
251
+ "advanced for THIS round."
252
+ if branch_advanced
253
+ else "No branch was advanced."
254
+ ),
255
+ "",
256
+ "## What each reviewer actually said",
257
+ "",
258
+ "Quoted verbatim from the reviewers' own output — not the synthesizer's restatement of it.",
259
+ "",
260
+ ]
261
+ for idx, (reviewer, text, disposition) in enumerate(deactivated, start=1):
262
+ lines.extend(
263
+ [
264
+ f"### {idx}. {reviewer} — blocker",
265
+ "",
266
+ *_md_text_block_lines(text),
267
+ "",
268
+ f"**Synthesizer's disposition:** {disposition}",
269
+ "",
270
+ ]
271
+ )
272
+ lines += [
273
+ "## How to continue",
274
+ "",
275
+ "Read the quotes above and decide whether the synthesizer was right.",
276
+ "",
277
+ "- **It was right** — the concerns really are false positives. This "
278
+ "round is a SHIP; nothing is blocking you.",
279
+ "- **It was wrong about any of them** — that concern is real and "
280
+ "unfixed. Address it and re-run.",
281
+ "",
282
+ "The full consolidated view, including each dismissal rationale in "
283
+ f"context, is in `round-{round_idx}/findings.md`.",
284
+ "",
285
+ ]
286
+
287
+ path = run_dir / DECISION_NEEDED_FILENAME
288
+ atomic_write_text(path, "\n".join(lines))
289
+ return path