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,226 @@
1
+ """Producer adapter for the OpenAI ``codex`` CLI.
2
+
3
+ Symmetric to :class:`syncade.adapters.openai.CodexAdapter` but for
4
+ the fix-it subprocess. Real headless producer runs use ``yolo`` to
5
+ create git commits because the ``workspace-write`` sandbox blocks
6
+ ``.git/index.lock`` writes. The output type is
7
+ :class:`syncade.adapters.producer.ProducerOutput` (just the narrative text),
8
+ not a structured
9
+ :class:`syncade.findings.ReviewerOutput`.
10
+
11
+ Built against the codex CLI's observed JSONL output.
12
+ ``codex exec``'s JSONL extraction reuses
13
+ :meth:`CodexAdapter.extract_final_text` so the
14
+ shared envelope-parsing path stays one source of truth (this is
15
+ the same pattern used when the synthesizer reuses the
16
+ extractor with a different no-agent-message exception class).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ from syncade.adapters.base import Invocation
24
+ from syncade.adapters.openai import (
25
+ _YOLO_FLAG,
26
+ CodexAdapter,
27
+ _check_codex_auth,
28
+ )
29
+ from syncade.adapters.producer import ProducerOutput
30
+ from syncade.auth_preflight import assert_codex_reality_honours_declaration
31
+ from syncade.config import ProducerConfig
32
+ from syncade.config_auth import apply_auth_to_env
33
+ from syncade.findings import ReviewerOutputError
34
+ from syncade.process import SubprocessResult
35
+ from syncade.worktree_env import worktree_scoped_env
36
+
37
+ _PRODUCER_PERMISSION_VALUES: tuple[str, ...] = ("yolo",)
38
+
39
+
40
+ class OpenAIProducerAdapter:
41
+ """ProducerAdapter for the OpenAI ``codex`` CLI.
42
+
43
+ ``build_invocation`` produces a ``codex exec --json`` argv that:
44
+
45
+ - pins the model from :attr:`ProducerConfig.model`
46
+ - sets reasoning effort via ``-c model_reasoning_effort=<level>``
47
+ (codex has no dedicated ``--effort`` flag — same discovery-doc
48
+ finding the reviewer adapter relies on)
49
+ - maps permissions: ``yolo`` →
50
+ ``--dangerously-bypass-approvals-and-sandbox``; stale/sandboxed values
51
+ rejected at build time
52
+ - scopes file access to the producer worktree via ``-C`` and
53
+ ``--add-dir``
54
+
55
+ ``parse_output`` extracts the final ``agent_message`` text via
56
+ :meth:`CodexAdapter.extract_final_text`, passing
57
+ :class:`ReviewerOutputError` as the no-agent-message exception
58
+ class (the producer doesn't have a phase-specific exception
59
+ type, and exit 40 is the right bucket either way — a producer
60
+ that completed cleanly but emitted no agent message is
61
+ operationally similar to a reviewer that did the same).
62
+
63
+ ``check_auth`` runs ``codex login status``. The producer and
64
+ reviewer adapters share auth state (same ``~/.codex/auth.json``), but the
65
+ method remains available for callers that drive the producer phase in
66
+ isolation.
67
+
68
+ The adapter never shells out itself in ``build_invocation`` or
69
+ ``parse_output``. ``check_auth`` is the one exception (same
70
+ pattern as :class:`CodexAdapter.check_auth`).
71
+ """
72
+
73
+ name = "openai"
74
+
75
+ def check_auth(self) -> None:
76
+ _check_codex_auth(
77
+ binary_missing_message=(
78
+ "codex binary not found on PATH — install codex-cli to "
79
+ "run the producer subprocess (the OpenAI Codex CLI, not "
80
+ "a third-party fork)"
81
+ )
82
+ )
83
+
84
+ def build_invocation(
85
+ self,
86
+ producer_config: ProducerConfig,
87
+ worktree_path: Path,
88
+ prompt: str,
89
+ ) -> Invocation:
90
+ """Construct the ``codex exec --json`` invocation for one
91
+ producer round.
92
+
93
+ Same argv shape as :meth:`CodexAdapter.build_invocation` (per
94
+ the codex CLI output notes) with the
95
+ :data:`syncade.config.ProducerPermissions` value-set (``yolo``).
96
+
97
+ Raises:
98
+ ValueError: If ``producer_config.provider`` is not
99
+ ``"openai"`` — defensive guard against misroute.
100
+ ValueError: If ``producer_config.permissions`` is
101
+ ``"safe"`` — belt-and-braces guard for callers that
102
+ bypass the schema's :data:`ProducerPermissions`
103
+ Literal.
104
+ """
105
+ self._validate_provider(producer_config.provider)
106
+ self._validate_permissions(producer_config.permissions)
107
+ # Structural backstop: refuse to spawn codex for a non-auto declaration the probed
108
+ # login does not honour, on any path that skipped the CLI auth gate. No-op on the
109
+ # gated CLI path. See syncade.auth_preflight.
110
+ assert_codex_reality_honours_declaration(producer_config)
111
+
112
+ argv: list[str] = [
113
+ "codex",
114
+ "exec",
115
+ "--json",
116
+ "--model",
117
+ producer_config.model,
118
+ "-c",
119
+ f"model_reasoning_effort={producer_config.thinking}",
120
+ ]
121
+ argv.append(_YOLO_FLAG)
122
+ # The prompt goes on STDIN, never argv — see AnthropicAdapter.build_invocation.
123
+ # `codex exec` reads instructions from stdin when no positional PROMPT is given.
124
+ argv.extend(
125
+ [
126
+ "-C",
127
+ str(worktree_path),
128
+ "--add-dir",
129
+ str(worktree_path),
130
+ ]
131
+ )
132
+ return Invocation(
133
+ argv=argv,
134
+ cwd=worktree_path,
135
+ env=apply_auth_to_env(worktree_scoped_env(worktree_path), producer_config),
136
+ stdin_text=prompt,
137
+ timeout_seconds=None,
138
+ )
139
+
140
+ @staticmethod
141
+ def _validate_provider(provider: str) -> None:
142
+ """Refuse a config whose ``provider`` is not ``"openai"``.
143
+
144
+ Mirrors :meth:`CodexAdapter._validate_provider`. The
145
+ producer registry routes by provider name; this guard
146
+ catches a misroute at build time instead of letting the
147
+ subprocess fail with a confusing codex-side error after
148
+ non-codex flags get spliced in.
149
+ """
150
+ if provider != "openai":
151
+ raise ValueError(
152
+ f"OpenAIProducerAdapter received a ProducerConfig with "
153
+ f"provider={provider!r}; expected 'openai'. The "
154
+ f"producer registry should route configs to the adapter "
155
+ f"whose name matches the config's provider field."
156
+ )
157
+
158
+ @staticmethod
159
+ def _validate_permissions(permissions: str) -> None:
160
+ """Refuse unsupported permissions at build time.
161
+
162
+ Same logic as the reviewer adapter's analogous guard plus
163
+ :meth:`AnthropicProducerAdapter._validate_permissions`. The
164
+ :data:`ProducerPermissions` Literal already rejects ``safe``
165
+ at config-load; this guard catches the case where a caller
166
+ constructs a :class:`ProducerConfig` programmatically and
167
+ bypasses the schema (typically in unit tests).
168
+ """
169
+ if permissions == "safe":
170
+ raise ValueError(
171
+ "OpenAIProducerAdapter cannot run a producer with "
172
+ "permissions='safe' headlessly: the corresponding "
173
+ "`approval_policy=untrusted` mode prompts for tool use "
174
+ "and `codex exec` cannot answer prompts. Use 'yolo' for "
175
+ "headless producer commits."
176
+ )
177
+ if permissions not in _PRODUCER_PERMISSION_VALUES:
178
+ valid = "', '".join(_PRODUCER_PERMISSION_VALUES)
179
+ raise ValueError(
180
+ "OpenAIProducerAdapter received unsupported producer "
181
+ f"permissions={permissions!r}; expected one of '{valid}'."
182
+ )
183
+
184
+ def parse_output(self, result: SubprocessResult) -> ProducerOutput:
185
+ """Parse a finished ``codex exec --json`` subprocess result.
186
+
187
+ Decision tree:
188
+
189
+ 1. Extract the final ``agent_message`` text via
190
+ :meth:`CodexAdapter.extract_final_text`,
191
+ passing :class:`ReviewerOutputError` as the
192
+ no-agent-message exception class.
193
+ 2. The extractor itself handles subprocess-side failures
194
+ (``turn.failed`` events, non-zero rc, unrecovered bare ``error``
195
+ streams, auth signatures) — it raises
196
+ :class:`ReviewerInvocationError` for those, which the
197
+ orchestrator's producer-phase exception handler maps to
198
+ exit 40 (subprocess failure).
199
+ 3. On success, wrap the text in
200
+ :class:`ProducerOutput(narrative_text=...)`. No further
201
+ structured parsing — the producer doesn't emit a
202
+ schema-bound payload (unlike the reviewer adapter where
203
+ the next step is ``parse_reviewer_output`` against the
204
+ extracted text).
205
+
206
+ :meth:`CodexAdapter.extract_final_text` is
207
+ reused (rather than re-implemented in this module) for the
208
+ same reason the synthesizer uses it: codex's
209
+ JSONL stream-extraction logic is non-trivial, and a divergent
210
+ implementation in the producer adapter would have its own
211
+ edge cases to discover (turn.failed-vs-error event
212
+ precedence, auth-signature detection, empty-agent-message
213
+ handling).
214
+ """
215
+ # Reuse the codex reviewer adapter's extraction helper via
216
+ # an internal CodexAdapter instance. The instance is
217
+ # stateless across calls, so allocating fresh per parse_output
218
+ # is cheap and avoids stuffing the helper into a static
219
+ # @classmethod (which would need to update CodexAdapter's
220
+ # surface unnecessarily).
221
+ codex_helper = CodexAdapter()
222
+ final_text = codex_helper.extract_final_text(
223
+ result,
224
+ empty_output_exception_class=ReviewerOutputError,
225
+ )
226
+ return ProducerOutput(narrative_text=final_text)
@@ -0,0 +1,81 @@
1
+ """Provider-string-to-adapter lookup for the dispatcher.
2
+
3
+ The dispatcher takes a list of :class:`ReviewerConfig`s and
4
+ routes each to the adapter whose :attr:`ReviewerAdapter.name` matches
5
+ the config's ``provider`` field. This module is that routing table.
6
+
7
+ Intentionally a tiny dict and not a plugin system. Adding a new
8
+ provider lands by appending one line here; nothing dynamic until we
9
+ have evidence we need it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from typing import Literal
17
+
18
+ from syncade.adapters.anthropic import AnthropicAdapter
19
+ from syncade.adapters.base import ReviewerAdapter
20
+ from syncade.adapters.openai import CodexAdapter
21
+
22
+ ProviderRole = Literal["reviewer", "producer"]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class UnknownProviderError(ValueError):
27
+ role: ProviderRole
28
+ requested: str
29
+ known: tuple[str, ...]
30
+
31
+ def __str__(self) -> str:
32
+ known = ", ".join(sorted(self.known))
33
+ return (
34
+ f"unknown {self.role} adapter {self.requested!r} in "
35
+ f".syncade/config.toml. Known adapters: {known}"
36
+ )
37
+
38
+
39
+ # Mapping: provider string → zero-arg factory that constructs the
40
+ # adapter. Using factories (not pre-built instances) so each call to
41
+ # ``get_adapter`` returns a fresh adapter — keeps tests that mutate
42
+ # adapter state from leaking across calls.
43
+ _ADAPTER_FACTORIES: dict[str, Callable[[], ReviewerAdapter]] = {
44
+ "anthropic": AnthropicAdapter,
45
+ "openai": CodexAdapter,
46
+ }
47
+
48
+
49
+ def get_adapter(provider: str) -> ReviewerAdapter:
50
+ """Return a fresh adapter instance for the given provider string.
51
+
52
+ The provider name must match a key in the registry. The known
53
+ providers are tied to ``ReviewerAdapter.name`` for each shipped
54
+ adapter; the dispatcher uses this lookup to route each
55
+ :class:`~syncade.config.ReviewerConfig` to its matching adapter.
56
+
57
+ Raises:
58
+ ValueError: When ``provider`` is not a known key. The error
59
+ message names the bad input AND lists the known
60
+ providers, so a user with a typo in
61
+ ``.syncade/config.toml`` (``provider = "anthrpic"``) sees
62
+ both their mistake and the fix at once.
63
+ """
64
+ try:
65
+ factory = _ADAPTER_FACTORIES[provider]
66
+ except KeyError:
67
+ raise UnknownProviderError(
68
+ role="reviewer",
69
+ requested=provider,
70
+ known=tuple(_ADAPTER_FACTORIES),
71
+ ) from None
72
+ return factory()
73
+
74
+
75
+ def known_providers() -> list[str]:
76
+ """Return the sorted list of registered provider names.
77
+
78
+ Useful for surface-area assertions in tests and for any future
79
+ `syncade providers` CLI command that lists what's available.
80
+ """
81
+ return sorted(_ADAPTER_FACTORIES)