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
syncade/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Syncade — external blind multi-judge review orchestrator."""
2
+
3
+ __version__ = "0.6.2"
syncade/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable `python -m syncade` invocation."""
2
+
3
+ from syncade.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,457 @@
1
+ """Adapter for the Anthropic ``claude`` CLI.
2
+
3
+ Built against the claude CLI's observed JSON output —
4
+ the actual observed behavior of ``claude 2.1.137 (Claude Code)``, not
5
+ the PRD's example invocation. If you're changing flag strings or the
6
+ output-parsing path here, re-read the discovery doc first.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from syncade.adapters.base import (
15
+ Invocation,
16
+ ReviewerInvocationError,
17
+ )
18
+ from syncade.config import ReviewerConfig
19
+ from syncade.config_auth import apply_auth_to_env
20
+ from syncade.findings import (
21
+ ReviewerOutput,
22
+ ReviewerOutputError,
23
+ parse_reviewer_output,
24
+ )
25
+ from syncade.process import SubprocessResult
26
+ from syncade.worktree_env import worktree_scoped_env
27
+
28
+ # Map ``ReviewerConfig.permissions`` to the corresponding
29
+ # ``--permission-mode`` value for ``claude``. ``safe`` is deliberately
30
+ # NOT mapped — see ``_validate_permissions`` for why.
31
+ #
32
+ # ``trusted-execute`` maps to ``bypassPermissions`` (no prompts,
33
+ # full worktree access) — the provider-symmetric mode.
34
+ _PERMISSION_MAPPING: dict[str, str] = {
35
+ "yolo": "bypassPermissions",
36
+ "trusted-execute": "bypassPermissions",
37
+ }
38
+
39
+
40
+ def _extract_claude_results(raw_stdout: str) -> tuple[dict | None, str | None]:
41
+ """Return ``(terminal_envelope, response_text)`` from a reviewer's stdout,
42
+ handling BOTH the single-object ``json`` format and the ``stream-json``
43
+ JSONL transcript the reviewer adapter now requests.
44
+
45
+ The reviewer adapter requests ``--output-format stream-json --verbose`` so
46
+ the persisted ``.stdout`` is the full tool-call transcript (every
47
+ ``system`` / ``assistant`` / ``user`` / ``tool_use`` / ``tool_result``
48
+ event), terminated by ``{"type":"result",...}`` line(s) carrying the SAME
49
+ keys (``result``, ``is_error``, ``api_error_status``) used by single-object
50
+ ``json`` envelopes. Preserving the stream is what lets an
51
+ operator audit whether a reviewer actually ran verification vs.
52
+ read-and-reasoned.
53
+
54
+ - ``terminal_envelope`` — the LAST ``{"type":"result"}`` object (or the
55
+ single object). Used for is_error / returncode / error-message checks:
56
+ it is the session's terminal status.
57
+ - ``response_text`` — the ``.result`` text of EVERY result event, joined
58
+ in stream order. claude occasionally emits a SPURIOUS extra result turn
59
+ AFTER delivering its verdict (observed live: a late async tool result
60
+ nudged a "my review is already complete" epilogue with no JSON). Taking
61
+ only the terminal turn lost that verdict and aborted the run at exit 70;
62
+ joining all turns and letting :func:`~syncade.findings.parse_reviewer_output`
63
+ pick the FINAL valid ``ReviewerOutput`` block recovers a verdict buried
64
+ before a chatty epilogue. ``None`` when no result event carries a string
65
+ ``result``.
66
+
67
+ A single-object stdout yields ``(obj, obj["result"])`` for non-streaming
68
+ JSON envelopes, the producer adapter, and fixtures. Returns ``(None,
69
+ None)`` when no envelope is present at all (CLI-level failure / garbage);
70
+ the caller maps that to ``ReviewerInvocationError`` (rc!=0) or
71
+ ``ReviewerOutputError`` (rc==0).
72
+ """
73
+ stripped = raw_stdout.strip()
74
+ if not stripped:
75
+ return None, None
76
+ try:
77
+ parsed = json.loads(stripped)
78
+ if isinstance(parsed, dict):
79
+ res = parsed.get("result")
80
+ return parsed, (res if isinstance(res, str) else None)
81
+ except json.JSONDecodeError:
82
+ pass
83
+ terminal: dict | None = None
84
+ texts: list[str] = []
85
+ for line in stripped.splitlines():
86
+ line = line.strip()
87
+ if not line:
88
+ continue
89
+ try:
90
+ obj = json.loads(line)
91
+ except json.JSONDecodeError:
92
+ continue
93
+ if isinstance(obj, dict) and obj.get("type") == "result":
94
+ terminal = obj
95
+ res = obj.get("result")
96
+ if isinstance(res, str):
97
+ texts.append(res)
98
+ return terminal, ("\n".join(texts) if texts else None)
99
+
100
+
101
+ class AnthropicAdapter:
102
+ """ReviewerAdapter for the Anthropic ``claude`` CLI.
103
+
104
+ ``build_invocation`` produces a ``claude -p`` argv that pins the
105
+ model, sets effort/permissions per the ReviewerConfig, scopes file
106
+ access to the worktree via ``--add-dir``, and requests the
107
+ ``stream-json`` transcript (``--output-format stream-json --verbose``)
108
+ so the reviewer's full tool-call stream is captured in the persisted
109
+ ``.stdout`` (operator auditability — did the reviewer actually run
110
+ verification?).
111
+
112
+ ``parse_output`` validates that the subprocess succeeded, extracts the
113
+ terminal ``{"type":"result"}`` envelope from the JSONL stream (via
114
+ :func:`_extract_claude_results`, which also accepts single-object
115
+ ``json`` stdout), pulls the ``.result`` field (the
116
+ assistant's final text), and hands it to
117
+ :func:`~syncade.findings.parse_reviewer_output`. The parser is robust
118
+ to JSON-in-markdown-fences, which the CLI emits routinely (see the
119
+ discovery doc).
120
+
121
+ The adapter never shells out itself — :class:`Invocation` is data
122
+ for the dispatcher to execute.
123
+ """
124
+
125
+ name = "anthropic"
126
+
127
+ def check_auth(self) -> None:
128
+ """No pre-flight check.
129
+
130
+ Anthropic auth failures surface via the JSON envelope's
131
+ ``is_error: true`` field in :meth:`parse_output` (see
132
+ the claude CLI output — both bad-model and
133
+ missing-auth shapes set ``is_error: true`` with the actionable
134
+ message in ``.result``). The cost of catching an auth failure
135
+ during the actual run is one wasted subprocess invocation,
136
+ which is acceptable because Anthropic's ``claude login status``
137
+ analog doesn't exist as a fast/standalone command — running
138
+ the real reviewer is the cheapest auth probe available.
139
+
140
+ See :meth:`ReviewerAdapter.check_auth` for the protocol
141
+ contract.
142
+ """
143
+ return None
144
+
145
+ def build_invocation(
146
+ self,
147
+ reviewer_config: ReviewerConfig,
148
+ worktree_path: Path,
149
+ prompt: str,
150
+ ) -> Invocation:
151
+ """Construct the ``claude -p`` invocation for a single reviewer run.
152
+
153
+ The argv matches the form documented in
154
+ the CLI output format:
155
+
156
+ - ``claude -p`` with prompt on STDIN (PR-h-field-01 item 1): avoids the execve
157
+ argument-list limit; stdin is read when no positional prompt is given
158
+ - ``--output-format stream-json --verbose``: JSONL transcript on
159
+ stdout (every tool_use/tool_result event), terminated by the
160
+ ``{"type":"result"}`` envelope. ``--verbose`` is required by
161
+ ``claude`` when ``stream-json`` is combined with ``-p``.
162
+ - ``--model <id>``: model from the reviewer config (full name
163
+ like ``claude-opus-4-6`` or alias like ``haiku``)
164
+ - ``--effort <level>``: maps from ``thinking`` (see
165
+ :attr:`syncade.config.ReviewerConfig.thinking` for the
166
+ canonical list of accepted values)
167
+ - ``--permission-mode <mode>``: maps from ``permissions``
168
+ (``yolo`` / ``trusted-execute`` → ``bypassPermissions``).
169
+ ``permissions="safe"`` is **rejected** —
170
+ see :meth:`_validate_permissions`.
171
+ - ``--add-dir <worktree>``: grants the reviewer tool-access to
172
+ its worktree
173
+
174
+ The subprocess runs with ``cwd = worktree_path`` and inherits
175
+ the caller's environment so existing ``claude`` auth (keychain,
176
+ OAuth, ``ANTHROPIC_API_KEY``) flows through.
177
+
178
+ Raises:
179
+ ValueError: If ``reviewer_config.provider`` is not
180
+ ``"anthropic"`` — guards against the dispatcher
181
+ accidentally sending a Codex or other-provider config
182
+ to this adapter.
183
+ ValueError: If ``reviewer_config.permissions`` is ``"safe"``
184
+ — the corresponding ``--permission-mode default`` mode
185
+ prompts for every tool use, and ``-p`` (non-interactive)
186
+ can't answer prompts. The combination is operationally
187
+ broken; surface it loudly at build time rather than
188
+ letting the dispatcher hang waiting on a never-answered
189
+ permission prompt.
190
+ """
191
+ self._validate_provider(reviewer_config.provider)
192
+ self._validate_permissions(reviewer_config.permissions)
193
+ # The prompt goes on STDIN, never argv: a reviewer diff can exceed the
194
+ # execve argument ceiling (measured 1,044,422 B on macOS 15/arm64) and the
195
+ # child then never exists — `[Errno 7] Argument list too long`, 0.0s, exit 40,
196
+ # before any review happens. Pass on ONE channel only; both CLIs append a
197
+ # piped stdin as an extra block when a positional prompt is also given.
198
+ argv: list[str] = [
199
+ "claude",
200
+ "-p",
201
+ # stream-json (+ required --verbose under -p) so the reviewer's
202
+ # full tool-call transcript lands in the persisted .stdout for
203
+ # auditability; the terminal {"type":"result"} line carries the
204
+ # same envelope shape as single-object JSON output. See
205
+ # see _extract_claude_results.
206
+ "--output-format",
207
+ "stream-json",
208
+ "--verbose",
209
+ "--model",
210
+ reviewer_config.model,
211
+ "--effort",
212
+ reviewer_config.thinking,
213
+ "--permission-mode",
214
+ _PERMISSION_MAPPING[reviewer_config.permissions],
215
+ "--add-dir",
216
+ str(worktree_path),
217
+ ]
218
+ return Invocation(
219
+ argv=argv,
220
+ cwd=worktree_path,
221
+ env=apply_auth_to_env(worktree_scoped_env(worktree_path), reviewer_config),
222
+ stdin_text=prompt,
223
+ timeout_seconds=None,
224
+ )
225
+
226
+ @staticmethod
227
+ def _validate_provider(provider: str) -> None:
228
+ """Refuse a config whose ``provider`` is not ``"anthropic"``.
229
+
230
+ The dispatcher routes each ReviewerConfig to the
231
+ adapter whose :attr:`name` matches the config's ``provider``
232
+ field. Defensive guard: if the dispatcher misroutes a config —
233
+ say, a Codex reviewer to this adapter — fail loudly at
234
+ build_invocation time rather than building a malformed
235
+ ``claude`` argv and letting the subprocess fail with a
236
+ confusing CLI error.
237
+ """
238
+ if provider != "anthropic":
239
+ raise ValueError(
240
+ f"AnthropicAdapter received a ReviewerConfig with "
241
+ f"provider={provider!r}; expected 'anthropic'. The "
242
+ f"dispatcher should route configs to the adapter whose "
243
+ f"name matches the config's provider field."
244
+ )
245
+
246
+ @staticmethod
247
+ def _validate_permissions(permissions: str) -> None:
248
+ """Refuse unsupported reviewer permissions for the Anthropic adapter.
249
+
250
+ ``--permission-mode default`` (the only sane CLI mapping for
251
+ "safe") asks for confirmation on every tool use. In ``-p``
252
+ mode there's no human to confirm, so the subprocess hangs on
253
+ the first tool call until syncade's own timeout fires. Raising
254
+ here turns a 2-minute hang into a 2-line stack trace.
255
+ """
256
+ if permissions == "safe":
257
+ raise ValueError(
258
+ "AnthropicAdapter cannot run a reviewer with "
259
+ "permissions='safe' headlessly: --permission-mode=default "
260
+ "prompts for every tool use and `claude -p` cannot answer "
261
+ "prompts. Use 'trusted-execute' or 'yolo'."
262
+ )
263
+ if permissions not in _PERMISSION_MAPPING:
264
+ valid = "', '".join(_PERMISSION_MAPPING)
265
+ raise ValueError(
266
+ "AnthropicAdapter received unsupported reviewer "
267
+ f"permissions={permissions!r}; expected one of '{valid}'."
268
+ )
269
+
270
+ def parse_output(self, result: SubprocessResult) -> ReviewerOutput:
271
+ """Parse a finished ``claude -p`` subprocess result into a reviewer
272
+ verdict.
273
+
274
+ Thin wrapper. :meth:`extract_final_text` does the envelope-strip and
275
+ every failure check (that method's docstring carries the decision
276
+ tree); :func:`~syncade.findings.parse_reviewer_output` then reads a
277
+ verdict out of the text — it is robust to markdown-fenced JSON and
278
+ picks the FINAL valid block, recovering a verdict even when claude
279
+ appends a chatty no-JSON epilogue turn after it.
280
+
281
+ Symmetric with
282
+ :meth:`syncade.adapters.openai.CodexAdapter.parse_output`: both
283
+ adapters are now ``extract_final_text`` → parse.
284
+ """
285
+ final_text = self.extract_final_text(
286
+ result,
287
+ empty_output_exception_class=ReviewerOutputError,
288
+ )
289
+ return parse_reviewer_output(final_text)
290
+
291
+ def extract_final_text(
292
+ self,
293
+ result: SubprocessResult,
294
+ *,
295
+ empty_output_exception_class: type[Exception],
296
+ ) -> str:
297
+ """Extract claude's final response text from a finished ``claude -p``
298
+ subprocess result.
299
+
300
+ Implements
301
+ :meth:`syncade.adapters.base.ReviewerAdapter.extract_final_text`; see
302
+ that method for the contract. Distinct from
303
+ :meth:`extract_response_text`, which does ONLY the envelope-strip on a
304
+ raw stdout string with no failure checks at all.
305
+
306
+ Decision tree (see the CLI-format notes for the underlying
307
+ behavior table):
308
+
309
+ 1. Try to parse ``stdout`` as a JSON envelope. ``claude -p`` emits one
310
+ even on the failures we care about. On ``claude 2.1.137`` both
311
+ observed failure shapes (bad model, ``--bare`` missing auth) set
312
+ ``is_error: true`` AND ``rc=1`` — the envelope ``.result`` field is
313
+ where the human-readable error message lives, NOT stderr. The
314
+ adapter ALSO handles ``rc=0`` with ``is_error: true`` as a
315
+ defensive hedge against future CLI / auth-helper variants.
316
+ 2. If the envelope parsed:
317
+
318
+ - ``envelope.is_error`` truthy OR ``result.returncode`` non-zero →
319
+ :class:`ReviewerInvocationError`. The ``envelope.result`` text
320
+ becomes the exception message; ``envelope.api_error_status`` (if
321
+ present) is exposed on the exception's ``.api_error_status``
322
+ attribute so the caller can distinguish transient (5xx, 429) from
323
+ terminal (4xx) provider errors.
324
+ - Otherwise (success path): return the joined text of every result
325
+ turn. No text → ``empty_output_exception_class``.
326
+ 3. If the envelope did NOT parse:
327
+
328
+ - ``rc`` non-zero → :class:`ReviewerInvocationError` with stdout /
329
+ stderr in the message. CLI-level failures (unknown flag, missing
330
+ prompt) land here — they don't produce a JSON envelope.
331
+ - ``rc`` zero → ``empty_output_exception_class``. The subprocess
332
+ ostensibly succeeded but didn't produce a parseable envelope —
333
+ exit 70 territory.
334
+ """
335
+ # Extract the terminal result envelope (for is_error / returncode)
336
+ # AND the joined text of every result turn (for the verdict parse)
337
+ # from either single-object JSON stdout or the stream-json JSONL
338
+ # transcript.
339
+ envelope, response_text = _extract_claude_results(result.stdout)
340
+
341
+ if envelope is not None:
342
+ is_error = bool(envelope.get("is_error", False))
343
+ api_error_status = envelope.get("api_error_status")
344
+ # The CLI uses int or None; coerce anything else to None so
345
+ # the documented attribute contract holds.
346
+ if not isinstance(api_error_status, int):
347
+ api_error_status = None
348
+
349
+ envelope_result = envelope.get("result")
350
+
351
+ if is_error or result.returncode != 0:
352
+ # Provider-level failure. Surface the envelope's
353
+ # message field (the human-readable error) so the user
354
+ # sees "Not logged in" or "model not available" rather
355
+ # than "claude exited with code 1:" with nothing after.
356
+ if isinstance(envelope_result, str) and envelope_result:
357
+ msg = envelope_result
358
+ else:
359
+ # Neutral message — this branch fires both when
360
+ # is_error=true with no result text AND when rc!=0
361
+ # with is_error=false (a defensive path that
362
+ # shouldn't happen in practice but is covered for
363
+ # future CLI-shape variability). Don't claim
364
+ # is_error=true unconditionally.
365
+ msg = (
366
+ f"claude failed with no result text "
367
+ f"(is_error={is_error}, "
368
+ f"api_error_status={api_error_status})"
369
+ )
370
+ raise ReviewerInvocationError(
371
+ f"claude failed (rc={result.returncode}, "
372
+ f"api_error_status={api_error_status}): {msg[:300]}",
373
+ returncode=result.returncode,
374
+ stdout=result.stdout,
375
+ stderr=result.stderr,
376
+ api_error_status=api_error_status,
377
+ )
378
+
379
+ # Success path. ``response_text`` is the joined text of every
380
+ # result turn.
381
+ if response_text is None:
382
+ raise empty_output_exception_class(
383
+ f"claude result envelope carried no string 'result' text; "
384
+ f"stdout: {result.stdout[:200]!r}"
385
+ )
386
+ return response_text
387
+
388
+ # Envelope did NOT parse. CLI-level failure if rc!=0 (unknown
389
+ # flag, missing prompt — message is on stderr). rc=0 with no
390
+ # envelope is "rc looked fine but output is garbage" → output
391
+ # parse error.
392
+ if result.returncode != 0:
393
+ stderr_snippet = result.stderr.strip()[:200]
394
+ stdout_snippet = result.stdout.strip()[:200]
395
+ tail = stderr_snippet or stdout_snippet or "(no output)"
396
+ raise ReviewerInvocationError(
397
+ f"claude exited with code {result.returncode} and emitted "
398
+ f"no parseable envelope: {tail}",
399
+ returncode=result.returncode,
400
+ stdout=result.stdout,
401
+ stderr=result.stderr,
402
+ api_error_status=None,
403
+ )
404
+ raise empty_output_exception_class(
405
+ f"claude returned rc=0 but stdout is not a JSON envelope; "
406
+ f"stdout: {result.stdout[:200]!r}"
407
+ )
408
+
409
+ def extract_response_text(self, raw_stdout: str) -> str:
410
+ """Extract the assistant's response text from a ``claude -p``
411
+ envelope stdout.
412
+
413
+ Reusable per-adapter helper that does ONLY the envelope-strip,
414
+ without the surrounding ReviewerOutput parse or the
415
+ is_error / returncode checks :meth:`parse_output` does on top.
416
+ The orchestrator's prior-round-context plumbing
417
+ (:mod:`syncade.orchestrator.prior_round`) calls this method
418
+ with the raw stdout read off disk from
419
+ ``<run-id>/round-(N-1)/<reviewer_name>.stdout`` — the round-N
420
+ reviewer's ``{prior_round_output}`` placeholder gets the
421
+ extracted text.
422
+
423
+ Symmetric with
424
+ :meth:`syncade.adapters.openai.CodexAdapter.extract_response_text`
425
+ — both adapters expose the same single-method interface for
426
+ the orchestrator to dispatch into.
427
+
428
+ Args:
429
+ raw_stdout: The raw stdout of a finished ``claude -p
430
+ --output-format stream-json --verbose`` invocation (a JSONL
431
+ transcript terminated by a ``{"type":"result"}`` line); a
432
+ single-object ``json`` stdout is also accepted. The
433
+ terminal result envelope's ``"result"`` field is returned.
434
+
435
+ Returns:
436
+ The assistant's final response text (the envelope's
437
+ ``.result`` field).
438
+
439
+ Raises:
440
+ ReviewerOutputError: If ``raw_stdout`` isn't a parseable
441
+ JSON dict, OR the dict's ``.result`` field is missing
442
+ / not a string. Per the
443
+ :class:`~syncade.findings.ReviewerOutputError` contract,
444
+ this maps to exit 70 if it bubbles up through
445
+ ``parse_output``; the orchestrator's prior-round
446
+ loader catches it and falls back to passing the raw
447
+ stdout through to the prompt (since cross-round
448
+ context is best-effort).
449
+ """
450
+ _, response_text = _extract_claude_results(raw_stdout)
451
+ if response_text is None:
452
+ raise ReviewerOutputError(
453
+ f"claude stdout has no parseable result text (neither a "
454
+ f"single JSON object with a string 'result' nor a stream-json "
455
+ f"result line): {raw_stdout[:200]!r}"
456
+ )
457
+ return response_text