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
+ """Per-provider adapter contract for reviewer dispatch.
2
+
3
+ An adapter translates a :class:`~syncade.config.ReviewerConfig`, a
4
+ worktree path, and a rendered prompt into a concrete
5
+ :class:`Invocation` that :func:`syncade.process.run_subprocess` can
6
+ execute. It then translates the resulting :class:`SubprocessResult`
7
+ back into a typed :class:`~syncade.findings.ReviewerOutput`.
8
+
9
+ Adapters never touch the network or filesystem themselves — they only build and
10
+ parse. The dispatcher actually runs subprocesses. This split lets adapters be
11
+ pure-ish and unit-testable without spawning reviewer CLIs.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Protocol, runtime_checkable
19
+
20
+ from syncade.config import ReviewerConfig
21
+ from syncade.findings import ReviewerOutput
22
+ from syncade.process import SubprocessResult
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Invocation:
27
+ """Resolved subprocess invocation for a single reviewer run.
28
+
29
+ Attributes:
30
+ argv: The argument vector to pass to
31
+ :func:`syncade.process.run_subprocess`. ``argv[0]`` is the
32
+ binary to execute.
33
+ cwd: Working directory the subprocess runs from. Typically the
34
+ reviewer's worktree path.
35
+ env: Full environment for the subprocess. Adapters usually
36
+ inherit the parent's env so the user's existing auth
37
+ (keychain, OAuth, ``ANTHROPIC_API_KEY``) flows through.
38
+ stdin_text: If not ``None``, written to the subprocess's stdin
39
+ and stdin is then closed.
40
+ timeout_seconds: Maximum wall-clock seconds the dispatcher
41
+ should wait. ``None`` means no timeout — the dispatcher
42
+ may apply its own ceiling regardless.
43
+ """
44
+
45
+ argv: list[str]
46
+ cwd: Path
47
+ env: dict[str, str]
48
+ stdin_text: str | None = None
49
+ timeout_seconds: float | None = None
50
+
51
+
52
+ class ReviewerInvocationError(Exception):
53
+ """Raised by :meth:`ReviewerAdapter.parse_output` when a reviewer
54
+ subprocess failed.
55
+
56
+ This is the "subprocess itself blew up" path — auth failure, model
57
+ unavailable, network error, CLI usage bug. Distinct from
58
+ :class:`~syncade.findings.ReviewerOutputError` (the subprocess ran
59
+ fine but its output didn't parse). The orchestrator maps this to
60
+ syncade exit code 40 (``REVIEWER_FAILURE``); parse failures map to
61
+ exit code 70 (``REVIEWER_OUTPUT_UNPARSEABLE``).
62
+
63
+ "Failed" here means either a non-zero ``returncode`` OR a zero
64
+ return code with the provider's structured-failure signal set
65
+ (``claude``'s ``is_error: true`` envelope field, for example).
66
+ Adapters that surface a structured failure should populate
67
+ :attr:`api_error_status` so the dispatcher can distinguish
68
+ transient (5xx, 429) from terminal (4xx) provider errors.
69
+
70
+ Attributes:
71
+ returncode: The subprocess's exit code.
72
+ stdout: Captured stdout (may include partial reviewer output
73
+ or, for Anthropic, the failure envelope).
74
+ stderr: Captured stderr.
75
+ api_error_status: Provider HTTP-style status code if available
76
+ (e.g. 404 for a missing model). ``None`` when not
77
+ applicable, when the failure happened before reaching the
78
+ provider, or when the adapter doesn't surface this.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ message: str,
84
+ *,
85
+ returncode: int,
86
+ stdout: str,
87
+ stderr: str,
88
+ api_error_status: int | None = None,
89
+ ) -> None:
90
+ super().__init__(message)
91
+ self.returncode = returncode
92
+ self.stdout = stdout
93
+ self.stderr = stderr
94
+ self.api_error_status = api_error_status
95
+
96
+
97
+ @runtime_checkable
98
+ class ReviewerAdapter(Protocol):
99
+ """Per-provider adapter protocol.
100
+
101
+ Implementations:
102
+
103
+ - :class:`syncade.adapters.anthropic.AnthropicAdapter` — production
104
+ adapter for the ``claude`` CLI.
105
+ - :class:`syncade.adapters.openai.CodexAdapter` — production
106
+ adapter for the OpenAI ``codex`` CLI.
107
+ - :class:`syncade.adapters.fake.FakeAdapter` — test double; never
108
+ shells out.
109
+
110
+ :attr:`name` is the stable identifier that must match
111
+ :attr:`syncade.config.ReviewerConfig.provider` so the dispatcher
112
+ can pick the right adapter for each configured reviewer.
113
+ """
114
+
115
+ name: str
116
+
117
+ def build_invocation(
118
+ self,
119
+ reviewer_config: ReviewerConfig,
120
+ worktree_path: Path,
121
+ prompt: str,
122
+ ) -> Invocation:
123
+ """Build a subprocess invocation from a reviewer's config + worktree
124
+ + already-rendered prompt. No side effects."""
125
+ ...
126
+
127
+ def parse_output(self, result: SubprocessResult) -> ReviewerOutput:
128
+ """Parse a finished subprocess result into a typed reviewer
129
+ output. Raises :class:`ReviewerInvocationError` on non-zero
130
+ returncode; :class:`~syncade.findings.ReviewerOutputError` on
131
+ unparseable output."""
132
+ ...
133
+
134
+ def extract_final_text(
135
+ self,
136
+ result: SubprocessResult,
137
+ *,
138
+ empty_output_exception_class: type[Exception],
139
+ ) -> str:
140
+ """Return the model's final response text, stripped of whatever
141
+ envelope the provider's CLI wraps it in.
142
+
143
+ This is the provider-agnostic seam. :meth:`parse_output` is only
144
+ one thing you can do with a finished subprocess: parse the text as
145
+ a :class:`~syncade.findings.ReviewerOutput`. The cold synthesizer
146
+ parses the SAME text as a
147
+ :class:`~syncade.synthesis.SynthesizerOutput`, the spec drafter as
148
+ a ``SpecDraftOutput``, the producer as free narrative. Each needs
149
+ the text without the schema, so the text extraction lives here and
150
+ ``parse_output`` is a thin wrapper over it.
151
+
152
+ Without this on the Protocol, any caller that wants raw text has to
153
+ name a concrete adapter class — which is exactly how the judge ended
154
+ up hardwired to codex.
155
+
156
+ Args:
157
+ result: The finished subprocess.
158
+ empty_output_exception_class: Raised when the subprocess
159
+ SUCCEEDED but produced no usable text. Callers pass the
160
+ exception type matching the shape they were going to parse
161
+ into (``ReviewerOutputError``, ``SynthesizerOutputError``,
162
+ …) so a content failure lands in that caller's error
163
+ taxonomy rather than the adapter's. All map to exit 70.
164
+
165
+ Returns:
166
+ The model's final response text. Never empty.
167
+
168
+ Raises:
169
+ ReviewerInvocationError: The subprocess itself failed — non-zero
170
+ returncode, or a zero returncode carrying the provider's
171
+ structured-failure signal (claude's ``is_error: true``
172
+ envelope, codex's ``turn.failed`` event). Exit 40, and the
173
+ dispatcher/synthesizer consult
174
+ :mod:`syncade.retry` on it to decide whether to retry.
175
+ empty_output_exception_class: Ran fine, said nothing usable.
176
+ """
177
+ ...
178
+
179
+ def check_auth(self) -> None:
180
+ """Optional pre-flight: verify the provider's CLI is logged in
181
+ and ready for headless invocation.
182
+
183
+ Adapters with a cheap, deterministic auth-check command (e.g.
184
+ ``codex login status``) override this to short-circuit the
185
+ whole dispatch batch when one reviewer's auth is broken — the
186
+ dispatcher then fails fast rather than letting the broken
187
+ reviewer chew through tokens or retry loops before its sibling
188
+ reviewers finish.
189
+
190
+ Implementations should:
191
+
192
+ - Be fast (no network call if possible).
193
+ - Raise :class:`ReviewerInvocationError` with a message that
194
+ tells the user EXACTLY what to fix (e.g. "run ``codex login``
195
+ to authenticate").
196
+ - When the provider has no cheap pre-flight, define an
197
+ explicit no-op (``def check_auth(self) -> None: return None``)
198
+ so the dispatcher's auth phase skips this adapter cleanly
199
+ and :func:`isinstance(adapter, ReviewerAdapter)` keeps
200
+ working. See the note below on Protocol conformance.
201
+
202
+ **No-op via inheritance is NOT enough.** This is a
203
+ ``@runtime_checkable`` Protocol, so
204
+ ``isinstance(adapter, ReviewerAdapter)`` checks that the
205
+ attribute exists on the *instance*. A class that omits
206
+ ``check_auth`` entirely does NOT pick up the ellipsis body
207
+ below as a real implementation — the isinstance check fails
208
+ outright. Every concrete adapter must define ``check_auth``,
209
+ even if it's only ``return None``.
210
+ :class:`syncade.adapters.anthropic.AnthropicAdapter` defines
211
+ this explicit no-op; its auth failures surface via the
212
+ ``is_error: true`` envelope in :meth:`parse_output` during
213
+ the actual run.
214
+ :class:`syncade.adapters.openai.CodexAdapter` overrides with
215
+ a real ``codex login status`` check (see
216
+ the codex CLI output notes).
217
+
218
+ The dispatcher always calls ``check_auth`` before starting parallel
219
+ runs.
220
+ """
221
+ ...
@@ -0,0 +1,73 @@
1
+ """Test-double adapters for :class:`~syncade.adapters.base.ReviewerAdapter`,
2
+ the synthesizer's :class:`~syncade.adapters.openai.CodexAdapter`
3
+ surface, and the producer's
4
+ :class:`~syncade.adapters.producer.ProducerAdapter` surface.
5
+
6
+ The fakes never shell out: ``build_invocation`` returns an
7
+ :class:`~syncade.adapters.base.Invocation` pointing at a no-op shell
8
+ command (so the dispatcher / synthesizer can still flow through
9
+ ``process.run_subprocess`` without errors during integration tests),
10
+ and the parse / extract methods ignore the
11
+ :class:`~syncade.process.SubprocessResult` entirely and return
12
+ whatever was configured at construction time.
13
+
14
+ These aren't features — they're primitives for testing other code.
15
+ The dispatcher tests compose :class:`FakeAdapter` instances to
16
+ exercise N-reviewer scheduling, partial-failure handling, etc. The
17
+ orchestrator tests compose :class:`FakeSynthesizerAdapter`
18
+ instances to exercise the synthesizer phase without spawning a real
19
+ codex CLI.
20
+
21
+ Threading: the dispatcher's pre-flight phase calls ``check_auth`` from a
22
+ ThreadPoolExecutor and the dispatch phase calls
23
+ ``build_invocation``/``parse_output`` from one too. ``check_auth_calls``,
24
+ ``parse_output_calls``, and the ``invocations`` log all use a
25
+ :class:`threading.Lock` so concurrent updates from multiple worker
26
+ threads don't lose increments or interleave list appends. Tests in
27
+ practice use one adapter instance per reviewer (matching production —
28
+ each reviewer gets a fresh adapter from the registry), but the lock
29
+ makes the test double safe under any thread layout.
30
+
31
+ The synthesizer fake doesn't take that locking step because the
32
+ synthesizer phase is single-threaded (one codex subprocess per round,
33
+ not N parallel). If a future PR introduces parallel synthesizer
34
+ dispatch, revisit this.
35
+
36
+ this module was decomposed into ``fake_common`` (the shared
37
+ ``_noop_argv`` helper), ``fake_reviewer_synth`` (the reviewer + synthesizer
38
+ doubles), and ``fake_producer_audit_draft`` (the producer / auditor / drafter
39
+ doubles). This module re-exports all of them so the
40
+ ``syncade.adapters.fake.<name>`` import paths are unchanged.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from .fake_common import _noop_argv
46
+ from .fake_producer_audit_draft import (
47
+ FakeAuditorAdapter,
48
+ FakeDrafterAdapter,
49
+ FakeProducerAdapter,
50
+ _default_canned_audit_output,
51
+ _default_canned_draft_output,
52
+ _default_canned_producer_output,
53
+ )
54
+ from .fake_reviewer_synth import (
55
+ FakeAdapter,
56
+ FakeSynthesizerAdapter,
57
+ _default_canned_output,
58
+ _default_canned_synth_output,
59
+ )
60
+
61
+ __all__ = [
62
+ "_noop_argv",
63
+ "FakeAdapter",
64
+ "_default_canned_output",
65
+ "FakeSynthesizerAdapter",
66
+ "_default_canned_synth_output",
67
+ "FakeProducerAdapter",
68
+ "_default_canned_producer_output",
69
+ "FakeAuditorAdapter",
70
+ "_default_canned_audit_output",
71
+ "FakeDrafterAdapter",
72
+ "_default_canned_draft_output",
73
+ ]
@@ -0,0 +1,29 @@
1
+ """Shared helper for the fake adapters.
2
+
3
+ ``_noop_argv`` is the do-nothing-exit-0 argv every fake's ``build_invocation``
4
+ points at, so the dispatcher / synthesizer / producer can still flow through
5
+ ``process.run_subprocess`` without errors during integration tests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import sys
12
+
13
+
14
+ def _noop_argv() -> list[str]:
15
+ """The argv for a "do nothing, exit 0" subprocess.
16
+
17
+ POSIX gets a path discovered via ``shutil.which("true")``; Windows
18
+ gets ``cmd /c exit 0``. Using ``shutil.which`` rather than a
19
+ hard-coded path covers the GNU coreutils install (``/usr/bin/true``)
20
+ AND the BSD/macOS layout (``/usr/bin/true``, with ``/bin/true``
21
+ absent on recent macOS). Falls back to ``["true"]`` if not on PATH —
22
+ that path is virtually never exercised because every POSIX system
23
+ has ``true`` somewhere, but it keeps imports safe even in stripped
24
+ environments.
25
+ """
26
+ if sys.platform == "win32": # pragma: no cover - macOS/Linux dev path
27
+ return ["cmd", "/c", "exit", "0"]
28
+ resolved = shutil.which("true")
29
+ return [resolved or "true"]