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,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
+ )