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,484 @@
1
+ # SIZE_OK: 400 pure LOC; codex invocation and JSONL errors share one CLI contract.
2
+ # Retained to avoid changing the observed CodexAdapter behavior surface.
3
+ # Future split: extract stream/auth parsing behind the same adapter API.
4
+ """Adapter for the OpenAI ``codex`` CLI.
5
+
6
+ Built against the codex CLI's observed JSONL output — the
7
+ actual observed behavior of ``codex-cli 0.130.0``, not the PRD's
8
+ example invocation. If you're changing flag strings or the
9
+ JSONL-parsing path here, re-read the discovery doc first.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from syncade.adapters.base import (
17
+ Invocation,
18
+ ReviewerInvocationError,
19
+ )
20
+ from syncade.auth_preflight import assert_codex_reality_honours_declaration
21
+ from syncade.config import ReviewerConfig
22
+ from syncade.config_auth import apply_auth_to_env
23
+ from syncade.findings import (
24
+ ReviewerOutput,
25
+ ReviewerOutputError,
26
+ parse_reviewer_output,
27
+ )
28
+ from syncade.process import (
29
+ SubprocessNotFoundError,
30
+ SubprocessResult,
31
+ SubprocessTimeoutError,
32
+ run_subprocess,
33
+ )
34
+ from syncade.worktree_env import worktree_scoped_env
35
+
36
+ from .openai_parsing import (
37
+ _extract_failure_message,
38
+ _looks_like_auth_failure,
39
+ _parse_jsonl_events,
40
+ )
41
+
42
+ # Map ``ReviewerConfig.permissions`` (yolo | trusted-execute)
43
+ # to the corresponding flag set for ``codex exec``. ``safe`` is
44
+ # deliberately NOT mapped — see ``_validate_permissions`` for why.
45
+ #
46
+ # ``yolo`` uses the combined shorthand
47
+ # ``--dangerously-bypass-approvals-and-sandbox`` (sandbox bypass +
48
+ # never-prompt in one flag).
49
+ #
50
+ # ``trusted-execute`` uses ``-s workspace-write`` (writes inside the worktree
51
+ # are auto-approved) paired with ``-c approval_policy=never`` (the
52
+ # generic config override, since ``codex exec`` has no ``-a`` flag. The
53
+ # ``-a/--ask-for-approval`` flag exists on the top-level ``codex`` command but
54
+ # not on the ``exec`` subcommand.
55
+ _YOLO_FLAG = "--dangerously-bypass-approvals-and-sandbox"
56
+ _TRUSTED_SANDBOX = "workspace-write"
57
+ _TRUSTED_APPROVAL_CONFIG = "approval_policy=never"
58
+
59
+ # `codex login status` exit codes per the discovery doc:
60
+ # 0 — Logged in (stdout "Logged in using ChatGPT" or similar)
61
+ # 1 — Not logged in (stdout "Not logged in")
62
+ _CODEX_AUTH_CHECK_ARGV: list[str] = ["codex", "login", "status"]
63
+ _CODEX_AUTH_CHECK_TIMEOUT_SECONDS: float = 10.0
64
+
65
+
66
+ def _check_codex_auth(binary_missing_message: str) -> None:
67
+ try:
68
+ result = run_subprocess(
69
+ _CODEX_AUTH_CHECK_ARGV,
70
+ timeout=_CODEX_AUTH_CHECK_TIMEOUT_SECONDS,
71
+ )
72
+ except SubprocessNotFoundError as exc:
73
+ raise ReviewerInvocationError(
74
+ binary_missing_message,
75
+ returncode=-1,
76
+ stdout="",
77
+ stderr="",
78
+ api_error_status=None,
79
+ ) from exc
80
+ except SubprocessTimeoutError as exc:
81
+ raise ReviewerInvocationError(
82
+ f"codex login status timed out after {exc.timeout}s — "
83
+ f"codex may be hung or the auth file may be corrupt. "
84
+ f"Try `codex login` to re-authenticate.",
85
+ returncode=-1,
86
+ stdout=exc.stdout,
87
+ stderr=exc.stderr,
88
+ api_error_status=None,
89
+ ) from exc
90
+
91
+ if result.returncode != 0:
92
+ detail = result.stdout.strip() or result.stderr.strip() or "auth check failed"
93
+ raise ReviewerInvocationError(
94
+ f"codex auth check failed: {detail[:200]} — run `codex login` to authenticate",
95
+ returncode=result.returncode,
96
+ stdout=result.stdout,
97
+ stderr=result.stderr,
98
+ api_error_status=None,
99
+ )
100
+
101
+
102
+ class CodexAdapter:
103
+ """ReviewerAdapter for the OpenAI ``codex`` CLI.
104
+
105
+ ``build_invocation`` produces a ``codex exec`` argv that pins the
106
+ model via ``--model``, sets reasoning effort via the
107
+ ``-c model_reasoning_effort=<level>`` config override (codex has
108
+ no dedicated ``--effort`` flag — see the discovery doc), maps
109
+ permissions to the combined
110
+ ``--dangerously-bypass-approvals-and-sandbox`` flag for ``yolo``
111
+ or ``-s workspace-write -c approval_policy=never`` for
112
+ ``trusted-execute``, scopes file access to the worktree via ``-C`` and
113
+ ``--add-dir``, and requests JSONL output via ``--json``.
114
+
115
+ ``parse_output`` validates that the subprocess succeeded, parses
116
+ the JSONL event stream, treats terminal failures (``turn.failed``,
117
+ non-zero rc, or a bare ``error`` stream with no recovered
118
+ ``agent_message``) as invocation errors, and on success extracts the LAST
119
+ ``agent_message`` event's ``.item.text`` to hand to
120
+ :func:`~syncade.findings.parse_reviewer_output`. The parser is
121
+ already robust to markdown-fenced JSON, which the model may
122
+ emit regardless of prompt instructions.
123
+
124
+ ``check_auth`` runs ``codex login status`` — a fast filesystem
125
+ check (no network) — and raises if auth is missing, so the
126
+ dispatcher's pre-flight phase short-circuits the whole batch
127
+ instead of letting codex burn 10+ seconds of 401 retries.
128
+
129
+ The adapter never shells out itself in ``build_invocation`` or
130
+ ``parse_output`` — :class:`Invocation` is data for the dispatcher
131
+ to execute. ``check_auth`` is the one exception; it MUST shell
132
+ out because it's checking subprocess-visible state.
133
+ """
134
+
135
+ name = "openai"
136
+
137
+ def check_auth(self) -> None:
138
+ """Verify ``codex login status`` for reviewer subprocesses."""
139
+ _check_codex_auth(
140
+ binary_missing_message=(
141
+ "codex binary not found on PATH — install codex-cli to "
142
+ "run reviewer subprocesses (the OpenAI Codex CLI, not a "
143
+ "third-party fork)"
144
+ )
145
+ )
146
+
147
+ def build_invocation(
148
+ self,
149
+ reviewer_config: ReviewerConfig,
150
+ worktree_path: Path,
151
+ prompt: str,
152
+ ) -> Invocation:
153
+ """Construct the ``codex exec`` invocation for a single reviewer run.
154
+
155
+ The argv matches the form documented in
156
+ the CLI output format:
157
+
158
+ - ``codex exec``
159
+ - ``--json``: emit JSONL events on stdout (cleanest parsing target)
160
+ - ``--model <id>``: model from the reviewer config (full name
161
+ like ``gpt-5.5``)
162
+ - ``-c model_reasoning_effort=<level>``: maps from ``thinking``
163
+ (see :attr:`syncade.config.ReviewerConfig.thinking` for the
164
+ canonical list of accepted values). Codex has no dedicated
165
+ ``--effort`` flag; the generic ``-c`` config override is
166
+ the documented path.
167
+ - ``--dangerously-bypass-approvals-and-sandbox`` for ``yolo``,
168
+ or ``-s workspace-write -c approval_policy=never`` for
169
+ ``trusted-execute`` (``codex exec`` has no
170
+ ``-a/--ask-for-approval`` flag — that flag exists on the
171
+ top-level ``codex`` command only — so the ``approval_policy``
172
+ config key is the documented exec-subcommand path).
173
+ ``permissions="safe"`` is **rejected** —
174
+ see :meth:`_validate_permissions`.
175
+ - ``-C <worktree>``: codex's working-root flag (analogous to
176
+ claude's ``cwd`` behavior)
177
+ - ``--add-dir <worktree>``: grants the reviewer tool-access
178
+ to its worktree (explicit is safer than implicit, same as
179
+ AnthropicAdapter)
180
+ - prompt on STDIN (PR-h-field-01 item 1): ``codex exec`` reads the prompt
181
+ from stdin when no positional PROMPT is given; argv is flag-only
182
+
183
+ The subprocess runs with ``cwd = worktree_path`` and inherits
184
+ the caller's environment so existing ``codex`` auth (in
185
+ ``~/.codex/auth.json``) flows through.
186
+
187
+ Raises:
188
+ ValueError: If ``reviewer_config.provider`` is not
189
+ ``"openai"`` — guards against the dispatcher
190
+ misrouting a non-Codex config to this adapter.
191
+ ValueError: If ``reviewer_config.permissions`` is
192
+ ``"safe"`` — the corresponding
193
+ ``approval_policy=untrusted`` mode prompts for tool use
194
+ and ``codex exec`` is
195
+ non-interactive. Surface loudly rather than hang.
196
+ """
197
+ self._validate_provider(reviewer_config.provider)
198
+ self._validate_permissions(reviewer_config.permissions)
199
+ # Structural backstop: refuse to spawn codex for a non-auto declaration the probed
200
+ # login does not honour, on any path that skipped the CLI auth gate. No-op on the
201
+ # gated CLI path. See syncade.auth_preflight.
202
+ assert_codex_reality_honours_declaration(reviewer_config)
203
+
204
+ argv: list[str] = [
205
+ "codex",
206
+ "exec",
207
+ "--json",
208
+ "--model",
209
+ reviewer_config.model,
210
+ "-c",
211
+ f"model_reasoning_effort={reviewer_config.thinking}",
212
+ ]
213
+ # Permission flags — yolo uses the combined shorthand;
214
+ # trusted-execute uses `-s workspace-write` paired with
215
+ # `-c approval_policy=never` (the generic config-override
216
+ # mechanism, since `codex exec` has no `-a/--ask-for-approval`
217
+ # flag). Safe and stale/unknown values are refused above.
218
+ if reviewer_config.permissions == "yolo":
219
+ argv.append(_YOLO_FLAG)
220
+ else: # trusted-execute
221
+ argv.extend(["-s", _TRUSTED_SANDBOX, "-c", _TRUSTED_APPROVAL_CONFIG])
222
+ # The prompt goes on STDIN, never argv — see AnthropicAdapter.build_invocation.
223
+ # `codex exec` reads instructions from stdin when no positional PROMPT is given.
224
+ argv.extend(
225
+ [
226
+ "-C",
227
+ str(worktree_path),
228
+ "--add-dir",
229
+ str(worktree_path),
230
+ ]
231
+ )
232
+ return Invocation(
233
+ argv=argv,
234
+ cwd=worktree_path,
235
+ env=apply_auth_to_env(worktree_scoped_env(worktree_path), reviewer_config),
236
+ stdin_text=prompt,
237
+ timeout_seconds=None,
238
+ )
239
+
240
+ @staticmethod
241
+ def _validate_provider(provider: str) -> None:
242
+ """Refuse a config whose ``provider`` is not ``"openai"``.
243
+
244
+ Same defensive guard as
245
+ :meth:`syncade.adapters.anthropic.AnthropicAdapter._validate_provider` —
246
+ fail loudly at build time if the dispatcher misroutes a config,
247
+ rather than letting the subprocess fail with a confusing
248
+ ``codex`` CLI error after the model/effort/permission flags
249
+ from the wrong provider get spliced into argv.
250
+ """
251
+ if provider != "openai":
252
+ raise ValueError( # GENERIC_ERR_OK: config guard preserves existing ValueError API.
253
+ f"CodexAdapter received a ReviewerConfig with "
254
+ f"provider={provider!r}; expected 'openai'. The "
255
+ f"dispatcher should route configs to the adapter whose "
256
+ f"name matches the config's provider field."
257
+ )
258
+
259
+ @staticmethod
260
+ def _validate_permissions(permissions: str) -> None:
261
+ """Refuse ``permissions='safe'`` for the Codex adapter.
262
+
263
+ The natural ``approval_policy=untrusted`` mapping prompts for
264
+ any tool use outside a small auto-trusted set, and ``codex
265
+ exec`` is non-interactive so prompts cannot be answered. The
266
+ reviewer subprocess would hang on the first non-trusted command
267
+ until the dispatcher's timeout fires. Raise here so the
268
+ misconfiguration is a fast, legible error rather than a
269
+ 20-minute wait.
270
+ """
271
+ if permissions == "safe":
272
+ raise ValueError( # GENERIC_ERR_OK: config guard preserves existing ValueError API.
273
+ "CodexAdapter cannot run a reviewer with permissions='safe' "
274
+ "headlessly: the corresponding `approval_policy=untrusted` "
275
+ "mode prompts for tool use and `codex exec` cannot answer "
276
+ "prompts. Use 'trusted-execute' or 'yolo'."
277
+ )
278
+ if permissions not in {"trusted-execute", "yolo"}:
279
+ raise ValueError( # GENERIC_ERR_OK: config guard preserves existing ValueError API.
280
+ "CodexAdapter received unsupported reviewer "
281
+ f"permissions={permissions!r}; expected one of "
282
+ "'trusted-execute', 'yolo'."
283
+ )
284
+
285
+ def parse_output(self, result: SubprocessResult) -> ReviewerOutput:
286
+ """Parse a finished ``codex exec --json`` subprocess result.
287
+
288
+ Decision tree for codex's JSONL stream:
289
+
290
+ 1. Extract the final ``agent_message`` text via
291
+ :meth:`extract_final_text` — which itself
292
+ parses the JSONL events, detects subprocess failures
293
+ (``turn.failed`` events, non-zero ``returncode``, unrecovered
294
+ bare ``error`` streams, auth signatures) and raises
295
+ :class:`ReviewerInvocationError` for them. On
296
+ no-``agent_message`` (codex succeeded but emitted nothing
297
+ useful) the helper raises whatever
298
+ ``empty_output_exception_class`` was passed — for
299
+ reviewer dispatch that's :class:`ReviewerOutputError`
300
+ (exit-70 territory).
301
+ 2. Hand the extracted text to
302
+ :func:`~syncade.findings.parse_reviewer_output` — which
303
+ handles markdown-fenced JSON, JSON-in-prose, and the
304
+ JSX-shaped prose snippets.
305
+
306
+ The reusable JSONL → text extraction lets :mod:`syncade.synthesizer`
307
+ drive the same codex pipeline and parse the result as a
308
+ :class:`~syncade.synthesis.SynthesizerOutput`.
309
+ """
310
+ final_text = self.extract_final_text(
311
+ result,
312
+ empty_output_exception_class=ReviewerOutputError,
313
+ )
314
+ return parse_reviewer_output(final_text)
315
+
316
+ def extract_final_text(
317
+ self,
318
+ result: SubprocessResult,
319
+ *,
320
+ empty_output_exception_class: type[Exception],
321
+ ) -> str:
322
+ """Extract the final ``agent_message`` text from a
323
+ ``codex exec --json`` subprocess result.
324
+
325
+ Reusable across callers that need codex's output but parse it
326
+ into different typed shapes. The synthesizer uses this to
327
+ feed the text to :func:`syncade.synthesis.parse_synthesizer_output`
328
+ while preserving the existing reviewer-dispatch behavior of
329
+ :meth:`parse_output` (which uses
330
+ :func:`syncade.findings.parse_reviewer_output`).
331
+
332
+ Steps:
333
+
334
+ 1. Parse ``stdout`` as JSONL — one event per line. Skip blank
335
+ lines and non-JSON garbage silently (defensive).
336
+ 2. Collect agent messages and terminal failure signals:
337
+
338
+ - Any event of type ``"turn.failed"``.
339
+ - A non-zero ``returncode``.
340
+ - A bare ``"error"`` event only when no agent message was
341
+ produced. Codex may emit transient reconnect ``error`` events and
342
+ then recover with a completed turn; those are not fatal.
343
+ 3. If a terminal failure signal is present, raise
344
+ :class:`ReviewerInvocationError` — the failure shape is
345
+ codex-side (auth / network / API / process), not phase-
346
+ specific, so the reviewer-invocation exception applies to
347
+ both reviewer and synthesizer dispatch.
348
+ 4. Otherwise, return the LAST ``item.completed`` event whose
349
+ ``item.type`` is ``agent_message`` and return its
350
+ ``item.text``.
351
+ 5. If no ``agent_message`` event is present (codex succeeded
352
+ but emitted nothing useful), raise an instance of
353
+ ``empty_output_exception_class`` — the caller passes
354
+ the phase-appropriate class so the user's exit-70 diagnostic
355
+ tells them which ``.stdout`` to open.
356
+
357
+ Args:
358
+ result: The :class:`SubprocessResult` from running the
359
+ codex subprocess.
360
+ empty_output_exception_class: Exception class to
361
+ instantiate when no agent_message is found. Reviewer
362
+ dispatch passes :class:`ReviewerOutputError`;
363
+ synthesizer dispatch passes
364
+ :class:`syncade.synthesis.SynthesizerOutputError`.
365
+ Both map to exit 70 but the message names the phase.
366
+
367
+ Returns:
368
+ The final ``agent_message`` text on success.
369
+
370
+ Raises:
371
+ ReviewerInvocationError: On any subprocess-side failure
372
+ (failure events, non-zero rc, auth signature).
373
+ ``empty_output_exception_class``: When no
374
+ ``agent_message`` is present in the JSONL stream.
375
+ """
376
+ events = _parse_jsonl_events(result.stdout)
377
+
378
+ turn_failed_events = [e for e in events if e.get("type") == "turn.failed"]
379
+ bare_error_events = [e for e in events if e.get("type") == "error"]
380
+ agent_messages = [
381
+ e
382
+ for e in events
383
+ if e.get("type") == "item.completed"
384
+ and isinstance(e.get("item"), dict)
385
+ and e["item"].get("type") == "agent_message"
386
+ and isinstance(e["item"].get("text"), str)
387
+ ]
388
+
389
+ terminal_failure = (
390
+ bool(turn_failed_events)
391
+ or result.returncode != 0
392
+ or (bool(bare_error_events) and not agent_messages)
393
+ )
394
+ if turn_failed_events or result.returncode != 0:
395
+ failure_events = [*bare_error_events, *turn_failed_events]
396
+ elif bare_error_events and not agent_messages:
397
+ failure_events = bare_error_events
398
+ else:
399
+ failure_events = []
400
+
401
+ if terminal_failure:
402
+ message = _extract_failure_message(failure_events, result)
403
+ is_auth = _looks_like_auth_failure(message, failure_events)
404
+ api_error_status = 401 if is_auth else None
405
+ if is_auth:
406
+ raised_message = (
407
+ f"codex auth failed (rc={result.returncode}): "
408
+ f"{message[:200]} — run `codex login` to reauthenticate"
409
+ )
410
+ else:
411
+ raised_message = f"codex failed (rc={result.returncode}): {message[:300]}"
412
+ raise ReviewerInvocationError(
413
+ raised_message,
414
+ returncode=result.returncode,
415
+ stdout=result.stdout,
416
+ stderr=result.stderr,
417
+ api_error_status=api_error_status,
418
+ )
419
+
420
+ if not agent_messages:
421
+ raise empty_output_exception_class(
422
+ f"codex completed but emitted no agent_message event; "
423
+ f"stdout: {result.stdout[:200]!r}"
424
+ )
425
+
426
+ # Multiple agent_messages are possible in multi-turn flows; the
427
+ # reviewer's verdict is always the final one.
428
+ return agent_messages[-1]["item"]["text"]
429
+
430
+ def extract_response_text(self, raw_stdout: str) -> str:
431
+ """Extract the assistant's response text from a ``codex exec
432
+ --json`` JSONL stdout.
433
+
434
+ Thin wrapper around
435
+ :meth:`extract_final_text` that:
436
+
437
+ - Synthesizes a :class:`SubprocessResult` with
438
+ ``returncode=0`` (the on-disk artifact doesn't preserve the
439
+ original returncode; we assume success because a failed
440
+ codex round would have terminated the loop before the
441
+ stdout was archived for cross-round replay).
442
+ - Defaults the ``empty_output_exception_class`` argument
443
+ to :class:`~syncade.findings.ReviewerOutputError` — the
444
+ general-purpose exit-70 exception, mirroring what
445
+ :meth:`parse_output` passes when running the reviewer
446
+ pipeline.
447
+
448
+ Symmetric with
449
+ :meth:`syncade.adapters.anthropic.AnthropicAdapter.extract_response_text`
450
+ — both adapters expose the same single-method interface for
451
+ the orchestrator's prior-round-context plumbing
452
+ (:mod:`syncade.orchestrator.prior_round`) to dispatch into.
453
+
454
+ Args:
455
+ raw_stdout: The raw stdout of a finished ``codex exec
456
+ --json`` invocation. Expected shape: one JSON event
457
+ per line; the final ``item.completed`` event with
458
+ ``item.type == "agent_message"`` carries the response
459
+ text in ``item.text``.
460
+
461
+ Returns:
462
+ The assistant's final agent_message text.
463
+
464
+ Raises:
465
+ ReviewerInvocationError: If the JSONL stream contains
466
+ explicit terminal failure events (``turn.failed`` or an
467
+ unrecovered bare ``error`` stream).
468
+ In practice this shouldn't happen for cross-round
469
+ replay (the loop terminates on codex subprocess
470
+ failures) but the helper is robust.
471
+ ReviewerOutputError: If no ``agent_message`` event is
472
+ present in the JSONL stream — the default exception
473
+ class.
474
+ """
475
+ synthesized = SubprocessResult(
476
+ returncode=0,
477
+ stdout=raw_stdout,
478
+ stderr="",
479
+ duration_seconds=0.0,
480
+ )
481
+ return self.extract_final_text(
482
+ synthesized,
483
+ empty_output_exception_class=ReviewerOutputError,
484
+ )
@@ -0,0 +1,119 @@
1
+ """JSONL-event parsing + failure classification for the codex adapter.
2
+
3
+ ``openai.py``'s ``extract_final_text`` calls these as module
4
+ functions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Final
11
+
12
+ from syncade.process import SubprocessResult
13
+
14
+ _AUTH_FAILURE_MARKERS: Final[tuple[str, ...]] = (
15
+ "401 unauthorized",
16
+ "unauthorized",
17
+ "missing bearer",
18
+ "basic authentication",
19
+ )
20
+
21
+
22
+ def _parse_jsonl_events(stdout: str) -> list[dict]:
23
+ """Parse JSONL events from codex's stdout.
24
+
25
+ Each non-blank line is parsed independently; lines that fail
26
+ to parse as JSON, or parse to something that isn't a dict, are
27
+ silently skipped. This is defensive — real
28
+ ``codex exec --json`` emits well-formed JSONL only, but a
29
+ future CLI update that leaks a non-JSON line shouldn't break
30
+ the whole parse.
31
+ """
32
+ events: list[dict] = []
33
+ for line in stdout.splitlines():
34
+ line = line.strip()
35
+ if not line:
36
+ continue
37
+ try:
38
+ parsed = json.loads(line)
39
+ except json.JSONDecodeError:
40
+ continue
41
+ if isinstance(parsed, dict):
42
+ events.append(parsed)
43
+ return events
44
+
45
+
46
+ def _extract_failure_message(
47
+ failure_events: list[dict],
48
+ result: SubprocessResult,
49
+ ) -> str:
50
+ """Pull the most informative message out of codex's failure
51
+ events.
52
+
53
+ Order of preference:
54
+ 1. The LAST ``turn.failed`` event's ``error.message`` (this
55
+ is the terminal failure message).
56
+ 2. The LAST ``error`` event's ``message``.
57
+ 3. ``stderr`` content (CLI-level failures like unknown flag).
58
+ 4. ``stdout`` snippet as a fallback.
59
+ 5. A generic "exited with code N" if nothing useful is
60
+ available.
61
+ """
62
+ # Try turn.failed.error.message first, PREFIXED with error.type when present.
63
+ #
64
+ # codex carries its failure kind as a TYPED variant (`UsageLimitReached`, `QuotaExceeded`,
65
+ # …) beside a message that may be generic. Reducing the event to its message alone discards
66
+ # the only unambiguous signal in it, and downstream classification then has to guess from
67
+ # prose. Keeping the type in the text is the smallest change that preserves it: the message
68
+ # stays human-readable and `retry.is_usage_limit_error` gets an exact term to match instead
69
+ # of a substring of English (PR-h-field-02 dogfood, blocker 3).
70
+ for event in reversed(failure_events):
71
+ if event.get("type") == "turn.failed":
72
+ error = event.get("error")
73
+ if isinstance(error, dict):
74
+ msg = error.get("message")
75
+ if isinstance(msg, str) and msg:
76
+ kind = error.get("type")
77
+ if isinstance(kind, str) and kind and kind not in msg:
78
+ return f"{kind}: {msg}"
79
+ return msg
80
+ # Then bare error event messages
81
+ for event in reversed(failure_events):
82
+ if event.get("type") == "error":
83
+ msg = event.get("message")
84
+ if isinstance(msg, str) and msg:
85
+ return msg
86
+ # Fall back to stderr (CLI-level failures land here)
87
+ if result.stderr.strip():
88
+ return result.stderr.strip()
89
+ if result.stdout.strip():
90
+ return result.stdout.strip()
91
+ return f"codex exited with code {result.returncode}"
92
+
93
+
94
+ def _looks_like_auth_failure(message: str, events: list[dict]) -> bool:
95
+ """Detect codex's auth-failure pattern.
96
+
97
+ Codex's missing-auth shape emits multiple error events whose
98
+ messages contain "401 Unauthorized" and/or missing bearer/basic
99
+ authentication text. Classify as auth failure only when one of
100
+ those auth-specific markers is in the consolidated message or any
101
+ event's text.
102
+ """
103
+ if _contains_auth_failure_marker(message):
104
+ return True
105
+ for event in events:
106
+ for value in (
107
+ event.get("message"),
108
+ (event.get("error") or {}).get("message")
109
+ if isinstance(event.get("error"), dict)
110
+ else None,
111
+ ):
112
+ if isinstance(value, str) and _contains_auth_failure_marker(value):
113
+ return True
114
+ return False
115
+
116
+
117
+ def _contains_auth_failure_marker(text: str) -> bool:
118
+ normalized = text.lower()
119
+ return any(marker in normalized for marker in _AUTH_FAILURE_MARKERS)