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,336 @@
1
+ """Prior-round context extraction helpers.
2
+
3
+ The reviewer and producer prompts support ``{prior_round_output}`` (and,
4
+ for producer, ``{prior_round_commits}``) placeholders. This module is the
5
+ data-loading bridge: read the prior round's persisted
6
+ ``.stdout`` artifacts off disk, extract the response text via the
7
+ matching adapter's helper, and return the text (or a documented
8
+ framing fallback when the prior artifact is missing / unparseable).
9
+
10
+ Cross-PR isolation is structural: every ``syncade <pr-doc>`` invocation
11
+ runs under a fresh ``<run-id>``, so this module only ever sees stdouts
12
+ from prior rounds of the CURRENT run. No file path constructed here can
13
+ escape ``<run_dir>``.
14
+
15
+ Per-reviewer isolation is also structural: each reviewer's stdout lives
16
+ at ``<round-dir>/<reviewer_name>.stdout``, so a round-N claude reviewer
17
+ loading ``<round-(N-1)>/<claude_name>.stdout`` cannot see the codex
18
+ reviewer's output. The orchestrator (``round.py``) drives one extraction
19
+ per reviewer by their own name.
20
+
21
+ T4 formalized the per-adapter ``extract_response_text``
22
+ interface; this module dispatches by provider name to the matching
23
+ adapter's method. Both ``AnthropicAdapter`` and ``CodexAdapter`` now
24
+ expose a symmetric ``extract_response_text(self, raw_stdout: str) -> str``
25
+ method.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from pathlib import Path
32
+
33
+ from syncade.adapters.anthropic import AnthropicAdapter
34
+ from syncade.adapters.openai import CodexAdapter
35
+
36
+ # Sentinels rendered when the prior-round artifact is missing or
37
+ # unrecoverable. The reviewer / producer prompt prose tells the model
38
+ # how to interpret these strings, so the operator gets a coherent
39
+ # prompt instead of an empty placeholder.
40
+ _MISSING_ARTIFACT_FRAMING = (
41
+ "(prior round artifact not found on disk; treat this round as a fresh attempt)"
42
+ )
43
+ """Returned when ``<round-(N-1)>/<basename>.stdout`` doesn't exist or
44
+ can't be read. The framing tells the reviewer / producer that no prior
45
+ context is available and they should proceed as if starting fresh."""
46
+
47
+
48
+ def _extract_response_text_from_stdout(provider: str, raw_stdout: str) -> str:
49
+ """Extract the assistant's response text from a reviewer / producer
50
+ subprocess's raw stdout.
51
+
52
+ Dispatches by provider name to the matching adapter's symmetric
53
+ :meth:`extract_response_text` helper:
54
+
55
+ - ``anthropic`` →
56
+ :meth:`syncade.adapters.anthropic.AnthropicAdapter.extract_response_text`
57
+ - ``openai`` →
58
+ :meth:`syncade.adapters.openai.CodexAdapter.extract_response_text`
59
+
60
+ Empty stdout short-circuits to returning ``""`` — calling the
61
+ adapter's extractor on empty input would raise (codex would say
62
+ "no agent_message event") which the broad except below would
63
+ catch and fall back to raw_stdout anyway. The short-circuit makes
64
+ the empty-stdout path explicit.
65
+
66
+ Args:
67
+ provider: The reviewer / producer config's ``provider`` string.
68
+ Must be ``"anthropic"`` or ``"openai"`` to extract; any
69
+ other value falls through to returning the raw stdout
70
+ unchanged (the unknown-provider safe default — letting the
71
+ model see something is better than letting it see nothing).
72
+ raw_stdout: The contents of the prior round's
73
+ ``<reviewer_name>.stdout`` or ``producer.stdout`` artifact.
74
+
75
+ Returns:
76
+ The extracted response text. Falls back to ``raw_stdout``
77
+ verbatim on any per-adapter extraction failure (malformed
78
+ envelope, missing ``result`` field, codex extraction raised).
79
+ The fallback is deliberate — for cross-round context, having
80
+ the model see the raw envelope is better than rendering an
81
+ empty string.
82
+ """
83
+ # Empty stdout: pass through. The adapter helpers raise on empty
84
+ # input; this short-circuit is cheaper than try/except.
85
+ if not raw_stdout:
86
+ return raw_stdout
87
+ try:
88
+ if provider == "anthropic":
89
+ return AnthropicAdapter().extract_response_text(raw_stdout)
90
+ if provider == "openai":
91
+ return CodexAdapter().extract_response_text(raw_stdout)
92
+ except Exception: # noqa: BLE001 — best-effort: see docstring
93
+ return raw_stdout
94
+ # Unknown provider — pass the raw stdout through. The reviewer /
95
+ # producer prompt will show it verbatim. Better than an empty
96
+ # placeholder that loses cross-round signal entirely.
97
+ return raw_stdout
98
+
99
+
100
+ def load_prior_reviewer_response_text(
101
+ *,
102
+ prior_round_dir: Path,
103
+ reviewer_name: str,
104
+ reviewer_provider: str,
105
+ ) -> str:
106
+ """Load and extract the prior round's response text for ONE reviewer.
107
+
108
+ Reads ``<prior_round_dir>/<reviewer_name>.stdout`` and extracts the
109
+ assistant's response text via the matching adapter's helper.
110
+ Per-reviewer isolation: this function is called per reviewer name,
111
+ so round-N claude-reviewer's load reads claude's prior stdout, NOT
112
+ codex's. The orchestrator (round.py) drives the loop.
113
+
114
+ Args:
115
+ prior_round_dir: Path to ``<run_dir>/round-(N-1)``. Caller
116
+ (round.py) constructs this from ``run_dir / f"round-{round_idx - 1}"``.
117
+ reviewer_name: The :class:`~syncade.config.ReviewerConfig.name`.
118
+ Becomes the basename of the on-disk stdout file.
119
+ reviewer_provider: The :class:`~syncade.config.ReviewerConfig.provider`
120
+ (``"anthropic"`` or ``"openai"``). Selects the extraction
121
+ helper.
122
+
123
+ Returns:
124
+ The extracted prior-round response text, or
125
+ :data:`_MISSING_ARTIFACT_FRAMING` when the stdout artifact is
126
+ missing on disk. Defensive: a happy-path multi-round loop will
127
+ always have the prior stdout present (a failed reviewer would
128
+ have terminated the loop before reaching the next round), but
129
+ the missing-artifact path keeps the orchestrator robust
130
+ against on-disk corruption / manual artifact cleanup.
131
+ """
132
+ stdout_path = prior_round_dir / f"{reviewer_name}.stdout"
133
+ if not stdout_path.is_file():
134
+ return _MISSING_ARTIFACT_FRAMING
135
+ try:
136
+ raw_stdout = stdout_path.read_text(encoding="utf-8")
137
+ except OSError:
138
+ return _MISSING_ARTIFACT_FRAMING
139
+ return _extract_response_text_from_stdout(reviewer_provider, raw_stdout)
140
+
141
+
142
+ def _read_prior_producer_outcome(prior_round_dir: Path) -> str | None:
143
+ """Read the prior round's producer outcome from manifest.json.
144
+
145
+ Returns one of ``"committed"`` / ``"stalled"`` /
146
+ ``"subprocess_error"`` when the manifest has a producer block, or
147
+ ``None`` when the manifest is missing/unreadable, the producer
148
+ block isn't present, or the outcome field isn't a recognized
149
+ string. The caller treats ``None`` as "outcome unknown — don't
150
+ prefix" so a missing-manifest scenario doesn't pollute the
151
+ rendered prompt with a misleading framing message.
152
+ """
153
+ manifest_path = prior_round_dir / "manifest.json"
154
+ if not manifest_path.is_file():
155
+ return None
156
+ try:
157
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
158
+ except (OSError, json.JSONDecodeError):
159
+ return None
160
+ producer = manifest.get("producer")
161
+ if not isinstance(producer, dict):
162
+ return None
163
+ outcome = producer.get("outcome")
164
+ if outcome in ("committed", "stalled", "subprocess_error"):
165
+ return outcome
166
+ return None
167
+
168
+
169
+ def load_prior_producer_response_text(
170
+ *,
171
+ prior_round_dir: Path,
172
+ ) -> str:
173
+ """Load the prior round's producer response text.
174
+
175
+ Reads ``<prior_round_dir>/producer.stdout`` and returns its content.
176
+ UNLIKE :func:`load_prior_reviewer_response_text`, no per-adapter
177
+ envelope-strip is needed — the producer persistence layer
178
+ (:func:`syncade.persistence.persist_producer_result`) writes
179
+ ``producer_result.output.narrative_text`` to ``producer.stdout``
180
+ on the ``committed`` / ``stalled`` paths (where the producer
181
+ adapter's :meth:`parse_output` already extracted the response text
182
+ from the raw envelope) and writes the raw subprocess stdout on the
183
+ ``subprocess_error`` path (the adapter never produced a
184
+ :class:`ProducerOutput`).
185
+
186
+ Asymmetry vs. reviewer artifacts (load-bearing):
187
+
188
+ - **Reviewer** ``.stdout`` files hold the **raw subprocess stdout**
189
+ (anthropic JSON envelope / codex JSONL stream). The orchestrator
190
+ runs the per-adapter ``extract_response_text`` to get the
191
+ assistant's text.
192
+ - **Producer** ``.stdout`` files hold the **already-extracted
193
+ narrative text** (on committed/stalled) or raw partial stdout
194
+ (on subprocess_error). Re-extracting the narrative path would
195
+ either (a) be a no-op because the narrative isn't envelope-
196
+ shaped, or (b) silently truncate the narrative if it happens to
197
+ look like a valid envelope (e.g. a producer narrating with a
198
+ JSON dict at the top level). Skipping extraction is correct and
199
+ defensive.
200
+
201
+ **the design framing for non-committed prior outcomes:** when the
202
+ prior round's manifest records ``producer.outcome`` of ``stalled``
203
+ or ``subprocess_error``, the helper prefixes the returned text with
204
+ a one-line framing message
205
+ (``"(prior round producer ended with outcome=<X>; partial output
206
+ below)\\n\\n"``) so the round-N producer prose ("if your prior
207
+ attempt errored, treat as a fresh round") has a concrete signal to
208
+ interpret. The framing applies whether the partial output is empty
209
+ or has content. In the production loop the orchestrator terminates
210
+ on ``stalled`` / ``subprocess_error`` so round-N producer wouldn't
211
+ normally see these, but a future loop update that retries past a
212
+ stall would surface them; the framing keeps the prompt coherent
213
+ either way.
214
+
215
+ Edge cases mirror the reviewer loader's:
216
+
217
+ - producer.stdout missing → :data:`_MISSING_ARTIFACT_FRAMING`.
218
+ - producer.stdout empty + committed/unknown outcome → empty string.
219
+ - producer.stdout empty + stalled/subprocess_error → framing
220
+ message alone (the framing is itself the signal).
221
+
222
+ Args:
223
+ prior_round_dir: Path to ``<run_dir>/round-(N-1)``.
224
+
225
+ Returns:
226
+ The prior round's producer narrative text (with framing
227
+ prefix on non-committed outcomes), or
228
+ :data:`_MISSING_ARTIFACT_FRAMING` when producer.stdout
229
+ doesn't exist on disk.
230
+ """
231
+ stdout_path = prior_round_dir / "producer.stdout"
232
+ if not stdout_path.is_file():
233
+ return _MISSING_ARTIFACT_FRAMING
234
+ try:
235
+ raw = stdout_path.read_text(encoding="utf-8")
236
+ except OSError:
237
+ return _MISSING_ARTIFACT_FRAMING
238
+ outcome = _read_prior_producer_outcome(prior_round_dir)
239
+ if outcome in ("stalled", "subprocess_error"):
240
+ return f"(prior round producer ended with outcome={outcome}; partial output below)\n\n{raw}"
241
+ return raw
242
+
243
+
244
+ def load_prior_producer_commit_subjects(
245
+ *,
246
+ prior_round_dir: Path,
247
+ repo_root: Path,
248
+ ) -> str:
249
+ """Load the commit subjects of the prior round's producer commits.
250
+
251
+ Reads two artifacts the orchestrator persists for the prior round:
252
+
253
+ - ``<prior_round_dir>/manifest.json``'s ``snapshot.commit_sha`` —
254
+ the SHA the prior round's producer worktree was provisioned at
255
+ (the producer's STARTING sha).
256
+ - ``<prior_round_dir>/producer.commit.txt`` — the SHA the prior
257
+ producer left HEAD at (the producer's ENDING sha; written by
258
+ :func:`syncade.persistence.persist_producer_result`).
259
+
260
+ With both SHAs in hand, runs ``git log --pretty=format:%s
261
+ <starting>..<ending>`` in the operator's repo to get every commit
262
+ subject the producer landed last round.
263
+
264
+ The lookup runs in the OPERATOR'S REPO, not the producer worktree.
265
+ By the time round-N producer runs, branch-advance has promoted the
266
+ prior commits onto the operator's branch, so ``git log`` against
267
+ ``repo_root`` sees them. The producer worktree is fresh per round
268
+ and wouldn't have history before its own provisioning SHA.
269
+
270
+ Reading the starting SHA from the prior round's manifest (rather
271
+ than threading it through ``_run_producer_phase`` from the loop
272
+ driver) keeps the producer-phase signature unchanged at the cost
273
+ of one small JSON read per round > 0.
274
+
275
+ Args:
276
+ prior_round_dir: Path to ``<run_dir>/round-(N-1)``.
277
+ repo_root: The operator's repo root — where ``git log`` runs.
278
+
279
+ Returns:
280
+ Newline-separated commit subjects, oldest first reading
281
+ top-down (``git log`` is newest-first by default; the helper
282
+ reverses to chronological order for legibility). When any
283
+ artifact is missing or git returns nothing useful, returns a
284
+ sentinel string framed as the absence of commits.
285
+ """
286
+ commit_txt_path = prior_round_dir / "producer.commit.txt"
287
+ if not commit_txt_path.is_file():
288
+ return "(prior round producer.commit.txt not found)"
289
+ try:
290
+ ending_sha = commit_txt_path.read_text(encoding="utf-8").strip()
291
+ except OSError:
292
+ return "(prior round producer.commit.txt unreadable)"
293
+ if not ending_sha:
294
+ return "(prior round producer made no commits)"
295
+
296
+ # Read the prior round's manifest to get the starting SHA. The
297
+ # manifest is always present alongside producer.commit.txt on the
298
+ # happy path (persist_round_manifest fires before _run_producer_phase
299
+ # can attach a producer.commit.txt).
300
+ manifest_path = prior_round_dir / "manifest.json"
301
+ if not manifest_path.is_file():
302
+ return "(prior round manifest.json not found)"
303
+ try:
304
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
305
+ except (OSError, json.JSONDecodeError):
306
+ return "(prior round manifest.json unreadable)"
307
+ snapshot_dict = manifest.get("snapshot")
308
+ if not isinstance(snapshot_dict, dict):
309
+ return "(prior round manifest.json missing snapshot block)"
310
+ starting_sha = snapshot_dict.get("commit_sha")
311
+ if not isinstance(starting_sha, str) or not starting_sha:
312
+ return "(prior round manifest.json missing snapshot.commit_sha)"
313
+ if ending_sha == starting_sha:
314
+ return "(prior round producer made no commits)"
315
+
316
+ # Use run_subprocess for consistency with the rest of syncade —
317
+ # same timeout / process-group cleanup discipline.
318
+ from syncade.process import run_subprocess
319
+
320
+ try:
321
+ result = run_subprocess(
322
+ ["git", "log", "--pretty=format:%s", f"{starting_sha}..{ending_sha}"],
323
+ cwd=repo_root,
324
+ timeout=5.0,
325
+ )
326
+ except Exception as exc: # noqa: BLE001 — best-effort lookup
327
+ return f"(git log lookup failed: {type(exc).__name__})"
328
+ if result.returncode != 0:
329
+ return f"(git log returned rc={result.returncode})"
330
+ subjects = result.stdout.strip()
331
+ if not subjects:
332
+ return "(prior round producer commits: git log returned no subjects)"
333
+ # git log is newest-first; reverse to chronological so the
334
+ # producer reads commits in the order they were applied.
335
+ lines = subjects.splitlines()
336
+ return "\n".join(reversed(lines))
@@ -0,0 +1,169 @@
1
+ """Per-round producer provisioning and dispatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from syncade.adapters.producer import ProducerAdapter
8
+ from syncade.config import SyncadeConfig
9
+ from syncade.logging import Logger
10
+ from syncade.producer import ProducerResult, run_producer
11
+ from syncade.prompts import (
12
+ _NO_OPERATOR_DECISION_SENTINEL,
13
+ _NO_PRIOR_COMMITS_SENTINEL,
14
+ _NO_PRIOR_ROUND_SENTINEL,
15
+ )
16
+ from syncade.snapshot import Snapshot
17
+ from syncade.worktree import WorktreeManager
18
+
19
+ from .prior_round import (
20
+ load_prior_producer_commit_subjects,
21
+ load_prior_producer_response_text,
22
+ )
23
+ from .results import RoundResult
24
+
25
+ # Producer-worktree basename. Reserved because a reviewer named ``"producer"``
26
+ # would collide with the producer worktree path when the loop provisions one.
27
+ PRODUCER_WORKTREE_NAME = "producer"
28
+
29
+
30
+ def _run_producer_phase(
31
+ *,
32
+ round_idx: int,
33
+ round_result: RoundResult,
34
+ run_id: str,
35
+ run_dir: Path,
36
+ round_dir: Path,
37
+ snapshot: Snapshot,
38
+ repo_root: Path,
39
+ pr_doc_path: Path,
40
+ config: SyncadeConfig,
41
+ resolved_producer_timeout: float,
42
+ producer_adapter: ProducerAdapter | None,
43
+ worktree_base: Path,
44
+ logger: Logger,
45
+ managers_to_cleanup: list[WorktreeManager],
46
+ operator_decision: str | None = None,
47
+ ) -> ProducerResult:
48
+ """Provision a producer worktree at the round's snapshot SHA and
49
+ dispatch one producer subprocess.
50
+
51
+ ``operator_decision`` is the recorded decision when this round resumes
52
+ after a producer escalation. The default sentinel applies on every
53
+ non-resumed round and is threaded straight through to
54
+ :func:`syncade.producer.run_producer`.
55
+
56
+ The producer worktree is the asymmetric one: it's provisioned
57
+ with ``strip_files=()`` so ``CLAUDE.md`` AND ``AGENTS.md`` ARE
58
+ preserved because the producer writes code in the operator's repo and
59
+ needs the same project context the operator has.
60
+
61
+ The producer worktree lives at
62
+ ``<worktree_base>/<run-id>/round-N/producer/`` — a separate
63
+ :class:`WorktreeManager` (with a longer ``run_id`` containing
64
+ the round subscript) keeps the producer's worktree alongside
65
+ the reviewer + test worktrees for THIS round, not pooled at
66
+ the run level.
67
+
68
+ Returns the :class:`ProducerResult` from
69
+ :func:`syncade.producer.run_producer`. The orchestrator
70
+ persists it and folds the outcome into the loop-continuation
71
+ decision.
72
+ """
73
+ producer_worktree_id = f"{run_id}/round-{round_idx}/producer-worktree"
74
+ # Defer cleanup so stalled or decision-needed producer worktrees stay on
75
+ # disk for inspection. Final cleanup is decided at loop end.
76
+ producer_manager = WorktreeManager(
77
+ repo_root,
78
+ producer_worktree_id,
79
+ base_dir=worktree_base,
80
+ defer_cleanup=True,
81
+ )
82
+ managers_to_cleanup.append(producer_manager)
83
+ with producer_manager as manager:
84
+ # strip_files=[] — preserve CLAUDE.md / AGENTS.md so the
85
+ # producer sees the same project context the operator has.
86
+ # (Cast: WorktreeManager.create's signature is
87
+ # ``strip_files: list[str] | None``; pass [] explicitly to
88
+ # opt into "no stripping" rather than relying on the
89
+ # implicit-None fallback being equivalent.)
90
+ producer_worktree = manager.create(
91
+ reviewer_name=PRODUCER_WORKTREE_NAME,
92
+ commit_sha=snapshot.commit_sha,
93
+ strip_files=[],
94
+ )
95
+ logger.event(f" worktree ready: {PRODUCER_WORKTREE_NAME} -> {producer_worktree.path}")
96
+
97
+ # The findings.md path the round-N persistence wrote. Must
98
+ # exist by here — the producer only runs on NO-SHIP rounds,
99
+ # and findings.md is written on every synth-success path.
100
+ findings_md = round_result.artifacts.findings_md_path
101
+ if findings_md is None:
102
+ # Defensive: NO-SHIP rounds without findings.md
103
+ # shouldn't reach the producer (the only NO-SHIP
104
+ # paths are synth-blocker or test-failed; synth-blocker
105
+ # writes findings.md, test-failed writes findings.md
106
+ # because synth was clean).
107
+ raise RuntimeError(
108
+ f"producer phase reached for round {round_idx} with no "
109
+ f"findings.md — orchestrator contract violated"
110
+ )
111
+ test_stdout = (
112
+ round_result.artifacts.test_run_paths.stdout
113
+ if (
114
+ round_result.artifacts.test_run_paths is not None
115
+ and round_result.test_result is not None
116
+ and round_result.test_result.outcome == "failed"
117
+ )
118
+ else None
119
+ )
120
+
121
+ # Round 0 producer sees default prior-round/commit sentinels. Round N>0
122
+ # producer reads its own prior response plus landed commit subjects from
123
+ # the operator repo; each producer worktree itself is fresh per round.
124
+ #
125
+ # The prior round's starting SHA (needed for the git log
126
+ # range) is read from the prior round's manifest.json
127
+ # inside ``load_prior_producer_commit_subjects`` — keeps
128
+ # the producer-phase signature stable.
129
+ if round_idx == 0:
130
+ prior_output: str = _NO_PRIOR_ROUND_SENTINEL
131
+ prior_commits: str = _NO_PRIOR_COMMITS_SENTINEL
132
+ else:
133
+ prior_round_dir = run_dir / f"round-{round_idx - 1}"
134
+ # Producer.stdout already contains extracted narrative
135
+ # text (persistence writes output.narrative_text, not the
136
+ # raw envelope) — no per-adapter envelope-strip needed.
137
+ # See load_prior_producer_response_text's docstring for
138
+ # the reviewer-vs-producer asymmetry.
139
+ prior_output = load_prior_producer_response_text(
140
+ prior_round_dir=prior_round_dir,
141
+ )
142
+ prior_commits = load_prior_producer_commit_subjects(
143
+ prior_round_dir=prior_round_dir,
144
+ repo_root=repo_root,
145
+ )
146
+
147
+ return run_producer(
148
+ worktree_path=producer_worktree.path,
149
+ starting_sha=snapshot.commit_sha,
150
+ pr_doc_path=pr_doc_path,
151
+ findings_md_path=findings_md,
152
+ test_run_stdout_path=test_stdout,
153
+ producer_config=config.producer,
154
+ pricing=config.pricing,
155
+ timeout_seconds=resolved_producer_timeout,
156
+ round_number=round_idx,
157
+ max_rounds=config.loop.max_rounds,
158
+ repo_root=repo_root,
159
+ adapter=producer_adapter,
160
+ prior_round_output=prior_output,
161
+ prior_round_commits=prior_commits,
162
+ operator_decision=(
163
+ operator_decision
164
+ if operator_decision is not None
165
+ else _NO_OPERATOR_DECISION_SENTINEL
166
+ ),
167
+ max_retries=config.retry.max_retries,
168
+ capture_dir=round_dir,
169
+ )