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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- syncade-0.6.2.dist-info/top_level.txt +1 -0
syncade/__init__.py
ADDED
syncade/__main__.py
ADDED
|
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
|