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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Producer subprocess adapter Protocol — symmetric to
|
|
2
|
+
:class:`~syncade.adapters.base.ReviewerAdapter` but for the fix-it
|
|
3
|
+
subprocess that runs after a NO-SHIP round.
|
|
4
|
+
|
|
5
|
+
The producer adapter builds an :class:`~syncade.adapters.base.Invocation`
|
|
6
|
+
that runs a fresh ``claude -p`` (or ``codex exec``) inside the producer
|
|
7
|
+
worktree. The producer is expected to make file edits AND commit them.
|
|
8
|
+
The orchestrator checks the worktree's HEAD after the subprocess
|
|
9
|
+
returns to distinguish "committed" from "stalled"; the adapter is NOT
|
|
10
|
+
responsible for that check — it only owns argv construction and
|
|
11
|
+
output text extraction.
|
|
12
|
+
|
|
13
|
+
The producer is asymmetric to the reviewers in two ways that ARE the
|
|
14
|
+
producer's responsibility surface:
|
|
15
|
+
|
|
16
|
+
1. **Different default disposition.** The producer is writing code
|
|
17
|
+
and must create git commits from a headless subprocess. Its default
|
|
18
|
+
permission mode is ``yolo`` because sandboxed modes do not let both
|
|
19
|
+
real producer CLIs complete the commit step unattended.
|
|
20
|
+
2. **Different output shape.** Producers don't emit structured JSON
|
|
21
|
+
like reviewers do. They emit free-form narrative + make file
|
|
22
|
+
edits + commit. The orchestrator's real source of truth for "did
|
|
23
|
+
the producer do anything" is the worktree's git HEAD after the
|
|
24
|
+
subprocess returns, NOT the parsed narrative. The narrative is
|
|
25
|
+
preserved for operator inspection (especially on stall or
|
|
26
|
+
next-round-regression paths) but it's not part of the verdict.
|
|
27
|
+
|
|
28
|
+
This module lands the Protocol + :class:`ProducerOutput` dataclass +
|
|
29
|
+
the registry lookup. The two concrete adapters live in sibling
|
|
30
|
+
modules :mod:`syncade.adapters.producer_anthropic` and
|
|
31
|
+
:mod:`syncade.adapters.producer_openai`; the
|
|
32
|
+
:class:`~syncade.adapters.fake.FakeProducerAdapter` test double lives
|
|
33
|
+
alongside the reviewer fakes in :mod:`syncade.adapters.fake`.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Protocol, runtime_checkable
|
|
41
|
+
|
|
42
|
+
from syncade.adapters.base import Invocation
|
|
43
|
+
from syncade.adapters.registry import UnknownProviderError
|
|
44
|
+
from syncade.config import ProducerConfig
|
|
45
|
+
from syncade.process import SubprocessResult
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ProducerOutput:
|
|
50
|
+
"""The producer's parsed output.
|
|
51
|
+
|
|
52
|
+
Producers don't emit structured JSON like reviewers do — they
|
|
53
|
+
emit free-form narrative + make file edits + commit. The
|
|
54
|
+
orchestrator's real source of truth for "did the producer do
|
|
55
|
+
anything" is the worktree's git HEAD after the subprocess
|
|
56
|
+
returns, not this dataclass. The narrative is preserved here
|
|
57
|
+
for persistence into ``producer.stdout`` — operators read it to
|
|
58
|
+
understand what the producer was trying to do, especially when
|
|
59
|
+
the producer stalled (no commit) or when next-round reviewers
|
|
60
|
+
flag NEW issues against the producer's diff.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
narrative_text: Free-form text the producer emitted. For
|
|
64
|
+
claude this is the ``.result`` field of the JSON
|
|
65
|
+
envelope; for codex this is the extracted final
|
|
66
|
+
``agent_message`` text. Always a string; empty string
|
|
67
|
+
allowed (a producer that committed without narrating
|
|
68
|
+
doesn't violate any invariant — the orchestrator's
|
|
69
|
+
stall detection is SHA-based, not narrative-based).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
narrative_text: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@runtime_checkable
|
|
76
|
+
class ProducerAdapter(Protocol):
|
|
77
|
+
"""Per-provider adapter protocol for the producer subprocess.
|
|
78
|
+
|
|
79
|
+
Implementations:
|
|
80
|
+
|
|
81
|
+
- :class:`syncade.adapters.producer_anthropic.AnthropicProducerAdapter`
|
|
82
|
+
— production adapter for ``claude -p``.
|
|
83
|
+
- :class:`syncade.adapters.producer_openai.OpenAIProducerAdapter`
|
|
84
|
+
— production adapter for ``codex exec``.
|
|
85
|
+
- :class:`syncade.adapters.fake.FakeProducerAdapter` — test
|
|
86
|
+
double; never shells out, optionally writes a fixture
|
|
87
|
+
commit to simulate a real producer.
|
|
88
|
+
|
|
89
|
+
:attr:`name` is the stable provider identifier (matches
|
|
90
|
+
:data:`syncade.config.ProducerProvider`); the producer registry
|
|
91
|
+
in :func:`get_producer_adapter` uses it to map a
|
|
92
|
+
:class:`~syncade.config.ProducerConfig` to its adapter.
|
|
93
|
+
|
|
94
|
+
Symmetric to :class:`~syncade.adapters.base.ReviewerAdapter`'s
|
|
95
|
+
surface — ``build_invocation`` + ``parse_output`` + optional
|
|
96
|
+
``check_auth`` — but with the producer-specific config and
|
|
97
|
+
output types in the signatures. The two Protocols are
|
|
98
|
+
separate (not a shared base) because the structural-typing
|
|
99
|
+
distinction prevents a careless caller from passing a
|
|
100
|
+
:class:`ProducerConfig` to a reviewer adapter or vice versa.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
name: str
|
|
104
|
+
|
|
105
|
+
def build_invocation(
|
|
106
|
+
self,
|
|
107
|
+
producer_config: ProducerConfig,
|
|
108
|
+
worktree_path: Path,
|
|
109
|
+
prompt: str,
|
|
110
|
+
) -> Invocation:
|
|
111
|
+
"""Build a subprocess invocation from a producer's config +
|
|
112
|
+
worktree + already-rendered prompt. No side effects."""
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
def parse_output(self, result: SubprocessResult) -> ProducerOutput:
|
|
116
|
+
"""Parse a finished subprocess result into a
|
|
117
|
+
:class:`ProducerOutput`. Raises
|
|
118
|
+
:class:`~syncade.adapters.base.ReviewerInvocationError` on
|
|
119
|
+
any subprocess-side failure (non-zero rc, ``is_error: true``
|
|
120
|
+
envelope, codex ``turn.failed`` event, auth signature).
|
|
121
|
+
|
|
122
|
+
Distinct from
|
|
123
|
+
:meth:`syncade.adapters.base.ReviewerAdapter.parse_output`:
|
|
124
|
+
the producer doesn't have a structured-output schema, so
|
|
125
|
+
there's no ``ReviewerOutputError`` shape — every failure is
|
|
126
|
+
either subprocess-side (raised as ``ReviewerInvocationError``)
|
|
127
|
+
or the subprocess succeeded and the narrative text is
|
|
128
|
+
whatever the model emitted. The orchestrator's stall
|
|
129
|
+
detection compares git SHAs, not narrative content.
|
|
130
|
+
"""
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
def check_auth(self) -> None:
|
|
134
|
+
"""Optional pre-flight: verify the producer's CLI is logged in.
|
|
135
|
+
|
|
136
|
+
Mirrors :meth:`ReviewerAdapter.check_auth`. Most production
|
|
137
|
+
deployments share auth between reviewer and producer for the
|
|
138
|
+
same provider — a claude reviewer + claude producer share
|
|
139
|
+
the same keychain/OAuth state — so the orchestrator can call
|
|
140
|
+
this once per provider per run without duplicating the
|
|
141
|
+
``codex login status`` round-trip.
|
|
142
|
+
|
|
143
|
+
``@runtime_checkable`` requires this attribute on every
|
|
144
|
+
instance even when the body is a no-op; concrete adapters
|
|
145
|
+
define an explicit ``return None`` when the provider has no
|
|
146
|
+
cheap pre-flight (same convention as
|
|
147
|
+
:class:`AnthropicAdapter.check_auth`).
|
|
148
|
+
"""
|
|
149
|
+
...
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Registry — separate from the reviewer registry so a single provider name
|
|
154
|
+
# (e.g. ``"anthropic"``) can route to BOTH a reviewer adapter and a producer
|
|
155
|
+
# adapter depending on call context. The two registries share vocabulary
|
|
156
|
+
# but not implementations.
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_KNOWN_PRODUCER_PROVIDERS: tuple[str, ...] = ("anthropic", "openai")
|
|
161
|
+
"""Tuple of known producer provider names.
|
|
162
|
+
|
|
163
|
+
Kept as a module-level constant rather than a dict-of-factories
|
|
164
|
+
so :func:`get_producer_adapter`'s lookup can use lazy imports
|
|
165
|
+
inside the function body — sidestepping the circular-import
|
|
166
|
+
problem the original dict-population design ran into when an
|
|
167
|
+
adapter module tried to import from this module while this module
|
|
168
|
+
was still finishing its load.
|
|
169
|
+
|
|
170
|
+
The lazy-import path also lets a future ``[producer]`` provider
|
|
171
|
+
land by adding one line to this tuple + one branch in
|
|
172
|
+
:func:`get_producer_adapter`, without restructuring the
|
|
173
|
+
registration machinery."""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_producer_adapter(provider: str) -> ProducerAdapter:
|
|
177
|
+
"""Return a fresh :class:`ProducerAdapter` for the given provider.
|
|
178
|
+
|
|
179
|
+
Symmetric to :func:`syncade.adapters.registry.get_adapter` but
|
|
180
|
+
for the producer's distinct adapter set. The provider string
|
|
181
|
+
must be one of the keys in :data:`_KNOWN_PRODUCER_PROVIDERS`
|
|
182
|
+
(the same vocabulary as
|
|
183
|
+
:data:`syncade.config.ProducerProvider`).
|
|
184
|
+
|
|
185
|
+
Uses lazy imports inside the function body so the producer
|
|
186
|
+
adapter modules (which themselves import from this module to
|
|
187
|
+
get :class:`ProducerOutput` + :class:`ProducerAdapter`) don't
|
|
188
|
+
create a circular-import problem when test code imports them
|
|
189
|
+
directly.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ValueError: When ``provider`` is not a known key. The
|
|
193
|
+
error message names the bad input AND lists the known
|
|
194
|
+
providers, so a typo in ``.syncade/config.toml`` (e.g.
|
|
195
|
+
``[producer] provider = "anthrpic"``) surfaces with
|
|
196
|
+
both the mistake and the fix visible at once.
|
|
197
|
+
"""
|
|
198
|
+
if provider == "anthropic":
|
|
199
|
+
from syncade.adapters.producer_anthropic import AnthropicProducerAdapter
|
|
200
|
+
|
|
201
|
+
return AnthropicProducerAdapter()
|
|
202
|
+
if provider == "openai":
|
|
203
|
+
from syncade.adapters.producer_openai import OpenAIProducerAdapter
|
|
204
|
+
|
|
205
|
+
return OpenAIProducerAdapter()
|
|
206
|
+
|
|
207
|
+
raise UnknownProviderError(
|
|
208
|
+
role="producer",
|
|
209
|
+
requested=provider,
|
|
210
|
+
known=_KNOWN_PRODUCER_PROVIDERS,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def known_producer_providers() -> list[str]:
|
|
215
|
+
"""Return the sorted list of registered producer providers.
|
|
216
|
+
|
|
217
|
+
Parallel to :func:`syncade.adapters.registry.known_providers` —
|
|
218
|
+
useful for surface-area assertions in tests and any future
|
|
219
|
+
``syncade providers --producer`` CLI command.
|
|
220
|
+
"""
|
|
221
|
+
return sorted(_KNOWN_PRODUCER_PROVIDERS)
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Producer adapter for the Anthropic ``claude`` CLI.
|
|
2
|
+
|
|
3
|
+
Symmetric to :class:`syncade.adapters.anthropic.AnthropicAdapter` but
|
|
4
|
+
for the fix-it subprocess that runs after a NO-SHIP round. The
|
|
5
|
+
producer needs to make file edits AND commit them from a headless
|
|
6
|
+
subprocess. Real ``claude -p`` needs ``bypassPermissions`` for that
|
|
7
|
+
commit step because ``acceptEdits`` auto-approves file edits but not
|
|
8
|
+
bash commands such as ``git commit``.
|
|
9
|
+
|
|
10
|
+
Built against the claude CLI's observed JSON output —
|
|
11
|
+
the actual observed behavior of ``claude 2.1.137 (Claude Code)``. If
|
|
12
|
+
you're changing flag strings or the output-parsing path here,
|
|
13
|
+
re-read that doc first. The envelope-parsing logic is the SAME as
|
|
14
|
+
the reviewer adapter's (``is_error`` / ``result`` / ``api_error_status``);
|
|
15
|
+
only the permission-mode mapping and the output type differ.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from syncade.adapters.base import (
|
|
24
|
+
Invocation,
|
|
25
|
+
ReviewerInvocationError,
|
|
26
|
+
)
|
|
27
|
+
from syncade.adapters.producer import ProducerOutput
|
|
28
|
+
from syncade.config import ProducerConfig
|
|
29
|
+
from syncade.config_auth import apply_auth_to_env
|
|
30
|
+
from syncade.findings import ReviewerOutputError
|
|
31
|
+
from syncade.process import SubprocessResult
|
|
32
|
+
from syncade.worktree_env import worktree_scoped_env
|
|
33
|
+
|
|
34
|
+
# Map :data:`syncade.config.ProducerPermissions` to the corresponding
|
|
35
|
+
# claude ``--permission-mode`` value.
|
|
36
|
+
#
|
|
37
|
+
# ``yolo`` → ``bypassPermissions``: full bypass. This is the default
|
|
38
|
+
# because live producer smokes need it to commit from headless mode.
|
|
39
|
+
#
|
|
40
|
+
# Sandboxed/stale values are intentionally NOT in this mapping. The schema
|
|
41
|
+
# rejects them at config-load; the validator below rejects them again for
|
|
42
|
+
# callers that bypass Pydantic.
|
|
43
|
+
_PRODUCER_PERMISSION_MAPPING: dict[str, str] = {
|
|
44
|
+
"yolo": "bypassPermissions",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AnthropicProducerAdapter:
|
|
49
|
+
"""ProducerAdapter for the Anthropic ``claude`` CLI.
|
|
50
|
+
|
|
51
|
+
``build_invocation`` produces a ``claude -p`` argv that:
|
|
52
|
+
|
|
53
|
+
- pins the model from :attr:`ProducerConfig.model`
|
|
54
|
+
- sets effort from :attr:`ProducerConfig.thinking`
|
|
55
|
+
- sets ``--permission-mode`` from :attr:`ProducerConfig.permissions`
|
|
56
|
+
(``yolo`` → ``bypassPermissions``; stale/sandboxed values rejected)
|
|
57
|
+
- scopes file access to the producer worktree via ``--add-dir``
|
|
58
|
+
- requests JSON output via ``--output-format json``
|
|
59
|
+
|
|
60
|
+
``parse_output`` re-uses the reviewer adapter's envelope-parsing
|
|
61
|
+
decision tree (``is_error`` / non-zero rc → invocation error;
|
|
62
|
+
success → extract ``.result`` text) but returns a
|
|
63
|
+
:class:`ProducerOutput` rather than a :class:`ReviewerOutput`. The
|
|
64
|
+
text is preserved verbatim — there is no structured-JSON parse on
|
|
65
|
+
top, because the producer doesn't emit one.
|
|
66
|
+
|
|
67
|
+
``check_auth`` is an explicit no-op for the same reason
|
|
68
|
+
:class:`AnthropicAdapter.check_auth` is: Anthropic doesn't expose
|
|
69
|
+
a fast standalone login-status check, and the cost of catching
|
|
70
|
+
an auth failure during the actual run is one wasted subprocess.
|
|
71
|
+
|
|
72
|
+
The adapter never shells out itself — :class:`Invocation` is
|
|
73
|
+
data for the orchestrator's
|
|
74
|
+
:func:`syncade.process.run_subprocess` to execute.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
name = "anthropic"
|
|
78
|
+
|
|
79
|
+
def check_auth(self) -> None:
|
|
80
|
+
"""No pre-flight check — Anthropic auth failures surface via
|
|
81
|
+
the JSON envelope's ``is_error: true`` field in
|
|
82
|
+
:meth:`parse_output`.
|
|
83
|
+
|
|
84
|
+
Mirrors :meth:`AnthropicAdapter.check_auth`'s no-op design.
|
|
85
|
+
Defining the method explicitly (not relying on the Protocol's
|
|
86
|
+
default ellipsis) is required because
|
|
87
|
+
:class:`syncade.adapters.producer.ProducerAdapter` is
|
|
88
|
+
``@runtime_checkable`` — see the reviewer adapter's docstring
|
|
89
|
+
for the full Protocol-conformance rationale.
|
|
90
|
+
"""
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def build_invocation(
|
|
94
|
+
self,
|
|
95
|
+
producer_config: ProducerConfig,
|
|
96
|
+
worktree_path: Path,
|
|
97
|
+
prompt: str,
|
|
98
|
+
) -> Invocation:
|
|
99
|
+
"""Construct the ``claude -p`` invocation for one producer round.
|
|
100
|
+
|
|
101
|
+
Argv shape matches the reviewer adapter's surface (same
|
|
102
|
+
discovery doc, same flag set) with three differences:
|
|
103
|
+
|
|
104
|
+
1. ``--permission-mode`` maps from the producer-specific
|
|
105
|
+
:data:`syncade.config.ProducerPermissions`.
|
|
106
|
+
2. The producer-permission value is ``yolo`` because
|
|
107
|
+
real headless producers must run ``git commit`` unattended.
|
|
108
|
+
3. There is no separate ``-c approval_policy=...`` flag —
|
|
109
|
+
claude's ``--permission-mode`` is the complete control
|
|
110
|
+
surface (compared with codex which needs both ``-s`` and
|
|
111
|
+
the ``-c`` config override).
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ValueError: If ``producer_config.provider`` is not
|
|
115
|
+
``"anthropic"`` — defensive guard against the
|
|
116
|
+
producer registry misrouting a config to this
|
|
117
|
+
adapter.
|
|
118
|
+
ValueError: If ``producer_config.permissions`` is
|
|
119
|
+
``"safe"``. The schema already rejects this at
|
|
120
|
+
config-load, but the validator here protects callers
|
|
121
|
+
that construct a :class:`ProducerConfig` directly
|
|
122
|
+
(e.g. test code passing a dict with the field
|
|
123
|
+
bypassed).
|
|
124
|
+
"""
|
|
125
|
+
self._validate_provider(producer_config.provider)
|
|
126
|
+
self._validate_permissions(producer_config.permissions)
|
|
127
|
+
# The prompt goes on STDIN, never argv: a reviewer diff can exceed the
|
|
128
|
+
# execve argument ceiling (measured 1,044,422 B on macOS 15/arm64) and the
|
|
129
|
+
# child then never exists — `[Errno 7] Argument list too long`, 0.0s, exit 40,
|
|
130
|
+
# before any review happens. Pass on ONE channel only; both CLIs append a
|
|
131
|
+
# piped stdin as an extra block when a positional prompt is also given.
|
|
132
|
+
argv: list[str] = [
|
|
133
|
+
"claude",
|
|
134
|
+
"-p",
|
|
135
|
+
"--output-format",
|
|
136
|
+
"json",
|
|
137
|
+
"--model",
|
|
138
|
+
producer_config.model,
|
|
139
|
+
"--effort",
|
|
140
|
+
producer_config.thinking,
|
|
141
|
+
"--permission-mode",
|
|
142
|
+
_PRODUCER_PERMISSION_MAPPING[producer_config.permissions],
|
|
143
|
+
"--add-dir",
|
|
144
|
+
str(worktree_path),
|
|
145
|
+
]
|
|
146
|
+
return Invocation(
|
|
147
|
+
argv=argv,
|
|
148
|
+
cwd=worktree_path,
|
|
149
|
+
env=apply_auth_to_env(worktree_scoped_env(worktree_path), producer_config),
|
|
150
|
+
stdin_text=prompt,
|
|
151
|
+
timeout_seconds=None,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _validate_provider(provider: str) -> None:
|
|
156
|
+
"""Refuse a config whose ``provider`` is not ``"anthropic"``.
|
|
157
|
+
|
|
158
|
+
Defensive guard mirroring
|
|
159
|
+
:meth:`AnthropicAdapter._validate_provider`. The producer
|
|
160
|
+
registry routes configs by provider name; if a future
|
|
161
|
+
registry update misroutes a codex config to this adapter,
|
|
162
|
+
the build_invocation argv would still LOOK valid (model and
|
|
163
|
+
effort flags pass through) but the spawned subprocess would
|
|
164
|
+
be ``claude`` running with a codex-shaped model string.
|
|
165
|
+
Raising here turns a confusing CLI-side rejection into a
|
|
166
|
+
legible config-side rejection.
|
|
167
|
+
"""
|
|
168
|
+
if provider != "anthropic":
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"AnthropicProducerAdapter received a ProducerConfig "
|
|
171
|
+
f"with provider={provider!r}; expected 'anthropic'. The "
|
|
172
|
+
f"producer registry should route configs to the adapter "
|
|
173
|
+
f"whose name matches the config's provider field."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _validate_permissions(permissions: str) -> None:
|
|
178
|
+
"""Refuse unsupported permissions at build time.
|
|
179
|
+
|
|
180
|
+
The :data:`ProducerPermissions` schema-level rejection handles this for configs loaded via
|
|
181
|
+
:mod:`syncade.config_loader`. This validator is the
|
|
182
|
+
belt-and-braces guard for callers that bypass the schema —
|
|
183
|
+
e.g. unit tests that construct a :class:`ProducerConfig`
|
|
184
|
+
from a kwargs dict, or a future plugin that builds configs
|
|
185
|
+
programmatically.
|
|
186
|
+
|
|
187
|
+
Same reasoning as the reviewer adapter's analogous guard:
|
|
188
|
+
``--permission-mode default`` would prompt for every tool
|
|
189
|
+
use, and ``claude -p`` can't answer prompts headlessly. The
|
|
190
|
+
subprocess hangs on the first tool call until the
|
|
191
|
+
orchestrator's timeout fires.
|
|
192
|
+
"""
|
|
193
|
+
if permissions == "safe":
|
|
194
|
+
raise ValueError(
|
|
195
|
+
"AnthropicProducerAdapter cannot run a producer with "
|
|
196
|
+
"permissions='safe' headlessly: --permission-mode=default "
|
|
197
|
+
"prompts for every tool use and `claude -p` cannot answer "
|
|
198
|
+
"prompts. Use 'yolo' for headless producer commits."
|
|
199
|
+
)
|
|
200
|
+
if permissions not in _PRODUCER_PERMISSION_MAPPING:
|
|
201
|
+
valid = "', '".join(_PRODUCER_PERMISSION_MAPPING)
|
|
202
|
+
raise ValueError(
|
|
203
|
+
"AnthropicProducerAdapter received unsupported producer "
|
|
204
|
+
f"permissions={permissions!r}; expected one of '{valid}'."
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def parse_output(self, result: SubprocessResult) -> ProducerOutput:
|
|
208
|
+
"""Parse a finished ``claude -p`` subprocess result.
|
|
209
|
+
|
|
210
|
+
Decision tree (the
|
|
211
|
+
underlying behavior table — same envelope shape as the
|
|
212
|
+
reviewer adapter):
|
|
213
|
+
|
|
214
|
+
1. Try to parse ``stdout`` as the JSON envelope ``claude
|
|
215
|
+
-p --output-format json`` emits.
|
|
216
|
+
2. If the envelope parsed AND (``is_error`` truthy OR
|
|
217
|
+
``returncode != 0``) → raise
|
|
218
|
+
:class:`ReviewerInvocationError` carrying the envelope's
|
|
219
|
+
``.result`` text as the human-readable message and the
|
|
220
|
+
``.api_error_status`` for distinguishing transient (5xx,
|
|
221
|
+
429) vs terminal (4xx) provider failures.
|
|
222
|
+
3. If the envelope parsed AND the call succeeded → return
|
|
223
|
+
:class:`ProducerOutput(narrative_text=envelope.result)`.
|
|
224
|
+
No further structured-JSON parsing on top; the producer
|
|
225
|
+
doesn't emit a schema-bound payload.
|
|
226
|
+
4. If the envelope did NOT parse AND ``rc != 0`` → raise
|
|
227
|
+
:class:`ReviewerInvocationError` with the stdout/stderr
|
|
228
|
+
tail as the message (CLI-level failure like unknown
|
|
229
|
+
flag).
|
|
230
|
+
5. If the envelope did NOT parse AND ``rc == 0`` → raise
|
|
231
|
+
:class:`ReviewerOutputError`. Unusual for the producer
|
|
232
|
+
but possible if claude's output format ever changes; the
|
|
233
|
+
orchestrator treats this as a producer-side failure for
|
|
234
|
+
verdict purposes (exit 40), same bucket as a reviewer
|
|
235
|
+
parse failure since the producer can't be re-asked to
|
|
236
|
+
produce a different output format mid-run.
|
|
237
|
+
"""
|
|
238
|
+
envelope: dict | None = None
|
|
239
|
+
try:
|
|
240
|
+
parsed = json.loads(result.stdout)
|
|
241
|
+
if isinstance(parsed, dict):
|
|
242
|
+
envelope = parsed
|
|
243
|
+
except json.JSONDecodeError:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
if envelope is not None:
|
|
247
|
+
is_error = bool(envelope.get("is_error", False))
|
|
248
|
+
api_error_status = envelope.get("api_error_status")
|
|
249
|
+
if not isinstance(api_error_status, int):
|
|
250
|
+
api_error_status = None
|
|
251
|
+
|
|
252
|
+
envelope_result = envelope.get("result")
|
|
253
|
+
|
|
254
|
+
if is_error or result.returncode != 0:
|
|
255
|
+
if isinstance(envelope_result, str) and envelope_result:
|
|
256
|
+
msg = envelope_result
|
|
257
|
+
else:
|
|
258
|
+
msg = (
|
|
259
|
+
f"claude (producer) failed with no result text "
|
|
260
|
+
f"(is_error={is_error}, "
|
|
261
|
+
f"api_error_status={api_error_status})"
|
|
262
|
+
)
|
|
263
|
+
raise ReviewerInvocationError(
|
|
264
|
+
f"claude (producer) failed (rc={result.returncode}, "
|
|
265
|
+
f"api_error_status={api_error_status}): {msg[:300]}",
|
|
266
|
+
returncode=result.returncode,
|
|
267
|
+
stdout=result.stdout,
|
|
268
|
+
stderr=result.stderr,
|
|
269
|
+
api_error_status=api_error_status,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Success path — narrative_text is the envelope's result
|
|
273
|
+
# field. Allow empty strings (a producer that committed
|
|
274
|
+
# without narrating is valid).
|
|
275
|
+
if not isinstance(envelope_result, str):
|
|
276
|
+
raise ReviewerOutputError(
|
|
277
|
+
f"claude (producer) envelope missing 'result' "
|
|
278
|
+
f"string field (got {type(envelope_result).__name__}); "
|
|
279
|
+
f"stdout: {result.stdout[:200]!r}"
|
|
280
|
+
)
|
|
281
|
+
return ProducerOutput(narrative_text=envelope_result)
|
|
282
|
+
|
|
283
|
+
# Envelope did NOT parse. rc!=0 → CLI-level invocation
|
|
284
|
+
# failure; rc==0 → output format unexpected.
|
|
285
|
+
if result.returncode != 0:
|
|
286
|
+
stderr_snippet = result.stderr.strip()[:200]
|
|
287
|
+
stdout_snippet = result.stdout.strip()[:200]
|
|
288
|
+
tail = stderr_snippet or stdout_snippet or "(no output)"
|
|
289
|
+
raise ReviewerInvocationError(
|
|
290
|
+
f"claude (producer) exited with code {result.returncode} "
|
|
291
|
+
f"and emitted no parseable envelope: {tail}",
|
|
292
|
+
returncode=result.returncode,
|
|
293
|
+
stdout=result.stdout,
|
|
294
|
+
stderr=result.stderr,
|
|
295
|
+
api_error_status=None,
|
|
296
|
+
)
|
|
297
|
+
raise ReviewerOutputError(
|
|
298
|
+
f"claude (producer) returned rc=0 but stdout is not a JSON "
|
|
299
|
+
f"envelope; stdout: {result.stdout[:200]!r}"
|
|
300
|
+
)
|