syncade 0.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- syncade-0.6.2.dist-info/top_level.txt +1 -0
syncade/config_auth.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Auth-mode declaration for every actor: subscription vs API key.
|
|
2
|
+
|
|
3
|
+
**The bug this exists to close.** Syncade copies ``dict(os.environ)`` into every
|
|
4
|
+
subprocess, so the auth *mode* is whatever each CLI decides — and the two CLIs decide
|
|
5
|
+
OPPOSITELY. Verified live (claude 2.1.208 / codex-cli 0.144.1) by injecting a bogus
|
|
6
|
+
key into each and seeing who lost:
|
|
7
|
+
|
|
8
|
+
claude + bogus ANTHROPIC_API_KEY -> 401. The ENV KEY beat the claude.ai login.
|
|
9
|
+
The CLI even says so: "ANTHROPIC_API_KEY ...
|
|
10
|
+
takes precedence over your claude.ai login".
|
|
11
|
+
codex + bogus OPENAI_API_KEY -> replied fine. The STORED CHATGPT LOGIN beat
|
|
12
|
+
the env key; the run billed the subscription.
|
|
13
|
+
|
|
14
|
+
So on ONE machine, in ONE run, with ONE environment, one provider can bill your
|
|
15
|
+
subscription while the other silently bills your API account — fanned out N reviewers
|
|
16
|
+
x M rounds. A developer with ``ANTHROPIC_API_KEY`` exported (extremely common) believes
|
|
17
|
+
they are on their Max plan while every subprocess charges the API.
|
|
18
|
+
|
|
19
|
+
**Declared mode is enforced mode, in BOTH directions.** This is about quota as much as
|
|
20
|
+
money: subscriptions have usage caps and syncade is *designed* to fan out, so
|
|
21
|
+
``auth = "api"`` is a real escape hatch for heavy users — and their traffic must not
|
|
22
|
+
silently fall back to a throttled subscription either.
|
|
23
|
+
|
|
24
|
+
``auto`` is the default and resolves to whatever the CLI would actually do. It is
|
|
25
|
+
deliberately NOT "strip everything by default": that would break users who intentionally
|
|
26
|
+
run on API keys with no subscription at all. Auto is safe only because the preflight
|
|
27
|
+
ALWAYS prints the resolved mode. Silence is the bug.
|
|
28
|
+
|
|
29
|
+
Leaf module (imports nothing from syncade but :mod:`syncade.config_types`) because every
|
|
30
|
+
actor config imports it and ``config`` cannot depend on the actors — same constraint that
|
|
31
|
+
put the cold-actor defaults in :mod:`syncade.config_cold`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
from typing import TYPE_CHECKING, Literal
|
|
38
|
+
|
|
39
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING: # runtime import would cycle: config imports THIS module
|
|
42
|
+
from syncade.config import SyncadeConfig
|
|
43
|
+
|
|
44
|
+
AuthMode = Literal["auto", "subscription", "api"]
|
|
45
|
+
|
|
46
|
+
PROVIDER_KEY_VARS: dict[str, tuple[str, ...]] = {
|
|
47
|
+
"anthropic": ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
|
|
48
|
+
"openai": ("OPENAI_API_KEY",),
|
|
49
|
+
}
|
|
50
|
+
"""Env vars that route a provider's CLI to the API instead of its stored login.
|
|
51
|
+
|
|
52
|
+
Stripping these is what makes ``auth = "subscription"`` an ENFORCED guarantee rather
|
|
53
|
+
than a request: the CLI physically cannot reach the API without them.
|
|
54
|
+
|
|
55
|
+
Deliberately NOT included: ``*_BASE_URL``. Those point at proxies/gateways rather than
|
|
56
|
+
carrying credentials, and stripping them would break 3P-provider users for no security
|
|
57
|
+
gain. Only credential-bearing vars belong here.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def default_api_key_env(provider: str) -> str | None:
|
|
62
|
+
"""The conventional key var for ``provider``, or ``None`` if unknown."""
|
|
63
|
+
keys = PROVIDER_KEY_VARS.get(provider)
|
|
64
|
+
return keys[0] if keys else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AuthedActor(BaseModel):
|
|
68
|
+
"""Mixin: every actor that spawns a provider CLI declares its auth mode.
|
|
69
|
+
|
|
70
|
+
Inherited by ``ReviewerConfig``, ``ProducerConfig`` and ``_ColdActorConfig``, so the
|
|
71
|
+
declaration exists on all five actor types (reviewers, producer, judge, drafter,
|
|
72
|
+
auditor) rather than only where someone remembered to add it.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
model_config = ConfigDict(extra="forbid")
|
|
76
|
+
|
|
77
|
+
auth: AuthMode = Field(
|
|
78
|
+
default="auto",
|
|
79
|
+
description=(
|
|
80
|
+
"Which credential this actor's CLI must use. ``subscription`` STRIPS the "
|
|
81
|
+
"provider's API-key vars from the child env, so the CLI physically cannot "
|
|
82
|
+
"reach the API. ``api`` on ANTHROPIC requires the key in the env (exit 50 at "
|
|
83
|
+
"config load if missing); on OPENAI it means `codex login --with-api-key` and "
|
|
84
|
+
"reads no env var -- verified by the codex probe, not at load. "
|
|
85
|
+
"``auto`` (default) resolves to whatever the CLI would do on its own — and "
|
|
86
|
+
"the preflight always prints which that is, because the failure mode here "
|
|
87
|
+
"is silence, not the wrong choice."
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
api_key_env: str | None = Field(
|
|
91
|
+
default=None,
|
|
92
|
+
description=(
|
|
93
|
+
'Env var holding the API key, when ``auth = "api"``. Defaults to the '
|
|
94
|
+
"provider's conventional var (ANTHROPIC_API_KEY / OPENAI_API_KEY). Set it "
|
|
95
|
+
"to use a differently-named var — e.g. a work key separate from a personal "
|
|
96
|
+
"one."
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@model_validator(mode="after")
|
|
101
|
+
def _api_key_env_must_be_honourable(self) -> AuthedActor:
|
|
102
|
+
"""``api_key_env`` is refused whenever syncade could not actually honour it.
|
|
103
|
+
|
|
104
|
+
Two ways it becomes a silent no-op, and a user who names a key var plainly intends
|
|
105
|
+
it to be used, so both fail loudly at config load:
|
|
106
|
+
|
|
107
|
+
1. **Without ``auth = "api"``** the key would simply be ignored.
|
|
108
|
+
2. **On an openai actor, at all.** ``codex`` does not read the environment — its
|
|
109
|
+
key comes from ``CODEX_HOME`` (``codex login --with-api-key``). So
|
|
110
|
+
``api_key_env = "WORK_KEY"`` on an openai actor was ACCEPTED while codex quietly
|
|
111
|
+
used whatever key was in its stored login: the user believes their WORK_KEY is
|
|
112
|
+
paying, and a different key actually is. That is the exact deceit this module
|
|
113
|
+
exists to delete, so it is refused rather than accepted-and-ignored.
|
|
114
|
+
"""
|
|
115
|
+
if self.api_key_env is None:
|
|
116
|
+
return self
|
|
117
|
+
if self.auth != "api":
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f'api_key_env={self.api_key_env!r} requires auth = "api" '
|
|
120
|
+
f'(got auth = "{self.auth}"), otherwise the key would be ignored'
|
|
121
|
+
)
|
|
122
|
+
if getattr(self, "provider", "") == "openai":
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"api_key_env={self.api_key_env!r} cannot be honoured for provider "
|
|
125
|
+
f'"openai": codex ignores the environment and reads its key from its '
|
|
126
|
+
f"stored login. Run `printenv {self.api_key_env} | codex login "
|
|
127
|
+
f"--with-api-key` instead — syncade will verify it with `codex login "
|
|
128
|
+
f"status`."
|
|
129
|
+
)
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
def key_var(self) -> str | None:
|
|
133
|
+
"""The env var this actor reads its API key from, when in ``api`` mode."""
|
|
134
|
+
return self.api_key_env or default_api_key_env(getattr(self, "provider", ""))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def apply_auth_to_env(env: dict[str, str], actor: AuthedActor) -> dict[str, str]:
|
|
138
|
+
"""Enforce ``actor.auth`` on the environment its CLI subprocess will inherit.
|
|
139
|
+
|
|
140
|
+
This is where a declaration becomes a guarantee. The env is the ONLY lever syncade
|
|
141
|
+
has — it cannot reach inside a CLI's credential resolution — so:
|
|
142
|
+
|
|
143
|
+
- ``subscription`` → **strip** the provider's key vars. The CLI then physically
|
|
144
|
+
cannot reach the API; it has no key. This closes the ``claude`` footgun outright
|
|
145
|
+
(an exported ``ANTHROPIC_API_KEY`` otherwise beats the claude.ai login, verified
|
|
146
|
+
live). Not a request to the CLI — a removal of the capability.
|
|
147
|
+
- ``api`` → strip the provider's key vars, then set the CLI's CANONICAL var from
|
|
148
|
+
wherever the user actually keeps the key. That indirection is the point of
|
|
149
|
+
``api_key_env``: ``claude`` reads ``ANTHROPIC_API_KEY`` and nothing else, so a
|
|
150
|
+
user with the key in ``WORK_KEY`` needs it mapped across or the setting would be
|
|
151
|
+
a silent no-op. Stripping first also means a stale ``ANTHROPIC_AUTH_TOKEN`` can't
|
|
152
|
+
outrank the key the user just declared.
|
|
153
|
+
- ``auto`` → untouched. Whatever the CLI would have done, it still does — and the
|
|
154
|
+
preflight prints which that is. Auto is only safe BECAUSE it is announced.
|
|
155
|
+
|
|
156
|
+
Note this deliberately does NOT run for the test/check legs, which share
|
|
157
|
+
:func:`~syncade.worktree_env.worktree_scoped_env`: those run the operator's own test
|
|
158
|
+
command, which may legitimately need API keys. Enforcement belongs where a PROVIDER
|
|
159
|
+
CLI is spawned, which is why it lives in the adapters and not in the env builder.
|
|
160
|
+
|
|
161
|
+
Returns a new dict; ``env`` is never mutated.
|
|
162
|
+
"""
|
|
163
|
+
provider = getattr(actor, "provider", "")
|
|
164
|
+
key_vars = PROVIDER_KEY_VARS.get(provider, ())
|
|
165
|
+
if actor.auth == "auto" or not key_vars:
|
|
166
|
+
return env
|
|
167
|
+
|
|
168
|
+
stripped = {k: v for k, v in env.items() if k not in key_vars}
|
|
169
|
+
if actor.auth == "subscription":
|
|
170
|
+
return stripped
|
|
171
|
+
|
|
172
|
+
if provider == "openai":
|
|
173
|
+
# codex NEVER reads the environment -- its key lives in CODEX_HOME. There is no key
|
|
174
|
+
# to route and nothing to require: `auth = "api"` here is satisfied by
|
|
175
|
+
# `codex login --with-api-key`, which auth_preflight verifies via `codex login
|
|
176
|
+
# status` before anything spends.
|
|
177
|
+
#
|
|
178
|
+
# Config load already exempts openai from the key requirement. Raising HERE anyway
|
|
179
|
+
# meant the config loaded and then the reviewer/judge DIED at subprocess-build
|
|
180
|
+
# time -- the same bug one layer down, which is the seventh time in this PR I fixed
|
|
181
|
+
# one call site and left its twin. Strip the (ignored) var and get out of the way.
|
|
182
|
+
return stripped
|
|
183
|
+
|
|
184
|
+
var = actor.key_var()
|
|
185
|
+
key = env.get(var or "")
|
|
186
|
+
if not key:
|
|
187
|
+
# config load already guaranteed this; reaching here means the env changed
|
|
188
|
+
# underneath us, and shipping the request without a key would silently fall
|
|
189
|
+
# back to the subscription -- the exact failure this PR deletes.
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f'auth = "api" for provider "{provider}" requires {var} to be set in the '
|
|
192
|
+
f"environment, but it is empty or missing"
|
|
193
|
+
)
|
|
194
|
+
return {**stripped, key_vars[0]: key}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
#: The actors each CLI mode can ACTUALLY spawn. The gate must refuse (and report) on
|
|
198
|
+
#: these and no others: `syncade --spec-audit` spawns only the auditor, so refusing it
|
|
199
|
+
#: because a REVIEWER's declaration contradicts reality blocks a command that would never
|
|
200
|
+
#: have run that reviewer -- and the auth report would announce billing for actors that
|
|
201
|
+
#: are not going to bill anything.
|
|
202
|
+
REVIEW_BLOCKS = frozenset({"reviewers", "producer", "synthesizer"})
|
|
203
|
+
#: `--selfcheck` is a PRODUCER-ONLY commit smoke (run_selfcheck calls run_producer once
|
|
204
|
+
#: and spawns no reviewer). It listed `reviewers` in error — a reviewer's auth declaration
|
|
205
|
+
#: then blocked a command that never runs a reviewer. Verified against selfcheck.py.
|
|
206
|
+
SELFCHECK_BLOCKS = frozenset({"producer"})
|
|
207
|
+
AUDIT_BLOCKS = frozenset({"auditor"})
|
|
208
|
+
DRAFT_BLOCKS = frozenset({"drafter"})
|
|
209
|
+
ALL_BLOCKS = frozenset({"reviewers", "producer", "synthesizer", "drafter", "auditor"})
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def credential_fingerprint(actor: AuthedActor, env: dict[str, str]) -> tuple[tuple[str, str], ...]:
|
|
213
|
+
"""A stable identity for the credential this actor will ACTUALLY present, READ from the
|
|
214
|
+
enforced env rather than derived from its declaration.
|
|
215
|
+
|
|
216
|
+
ONE definition, used by both surfaces that need it — ``--auth-check``'s probe-dedup
|
|
217
|
+
(:func:`syncade.auth_check._credential_key`) and the auth report's grouping
|
|
218
|
+
(:func:`syncade.auth_preflight.report_lines`). Two copies of "which credential is this"
|
|
219
|
+
diverged twice: round 6 (auto vs explicit api, custom key var) and round 11 (stale
|
|
220
|
+
AUTH_TOKEN). Reading VALUES, not var names, settles both — and keeping it in one place
|
|
221
|
+
is the only thing that stops the third.
|
|
222
|
+
|
|
223
|
+
Values are hashed so a secret never lands in a dict key or a log. Missing-key ``api``
|
|
224
|
+
(``apply_auth_to_env`` raises) fingerprints as ``()`` — no credential, its own group.
|
|
225
|
+
"""
|
|
226
|
+
import hashlib
|
|
227
|
+
|
|
228
|
+
provider = getattr(actor, "provider", "")
|
|
229
|
+
if provider == "openai":
|
|
230
|
+
# codex reads NO env var (its login lives in CODEX_HOME), so the env does not
|
|
231
|
+
# distinguish an openai actor's credential at all: `auto`, `subscription`, and `api`
|
|
232
|
+
# openai actors on one machine are the SAME stored codex login. Fingerprinting by
|
|
233
|
+
# OPENAI_API_KEY (which `auto` leaves in the env) split them into phantom "different
|
|
234
|
+
# credentials", duplicating probes and report lines for one real credential.
|
|
235
|
+
return ()
|
|
236
|
+
try:
|
|
237
|
+
applied = apply_auth_to_env(dict(env), actor)
|
|
238
|
+
except ValueError:
|
|
239
|
+
return ()
|
|
240
|
+
return tuple(
|
|
241
|
+
sorted(
|
|
242
|
+
(var, hashlib.sha256(applied[var].encode()).hexdigest()[:12])
|
|
243
|
+
for var in PROVIDER_KEY_VARS.get(provider, ())
|
|
244
|
+
if applied.get(var)
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def authed_actors(
|
|
250
|
+
config: SyncadeConfig, blocks: frozenset[str] | None = None
|
|
251
|
+
) -> list[tuple[str, AuthedActor]]:
|
|
252
|
+
"""Every actor that spawns a provider CLI, labelled by its config block.
|
|
253
|
+
|
|
254
|
+
One list, so nothing gets enforced for four actors and forgotten for the fifth.
|
|
255
|
+
Reused by the load-time key check, the env enforcement, the codex reality probe,
|
|
256
|
+
and the preflight print.
|
|
257
|
+
|
|
258
|
+
``blocks`` narrows it to the actors a given mode can actually spawn (see
|
|
259
|
+
``*_BLOCKS`` above). ``None`` means all of them — correct for the load-time key check,
|
|
260
|
+
which validates the whole config regardless of which command is running.
|
|
261
|
+
"""
|
|
262
|
+
tagged: list[tuple[str, str, AuthedActor]] = [
|
|
263
|
+
("reviewers", f'[[reviewers]] "{r.name}"', r) for r in config.reviewers
|
|
264
|
+
]
|
|
265
|
+
tagged.append(("producer", "[producer]", config.producer))
|
|
266
|
+
tagged.append(("synthesizer", "[synthesizer]", config.synthesizer))
|
|
267
|
+
tagged.append(("drafter", "[drafter]", config.drafter))
|
|
268
|
+
tagged.append(("auditor", "[auditor]", config.auditor))
|
|
269
|
+
return [(label, a) for tag, label, a in tagged if blocks is None or tag in blocks]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def api_key_problems(config: SyncadeConfig, env: dict[str, str] | None = None) -> list[str]:
|
|
273
|
+
"""Actors that declare ``auth = "api"`` but have no key to use.
|
|
274
|
+
|
|
275
|
+
Returned rather than raised so :mod:`syncade.config_loader` owns the error type
|
|
276
|
+
(importing it here would cycle). The caller turns a non-empty list into a
|
|
277
|
+
``ConfigError`` — i.e. **exit 50, at config load**.
|
|
278
|
+
|
|
279
|
+
Failing here is the entire point: the alternative is discovering it mid-run, after
|
|
280
|
+
both reviewers have already run and billed. Every problem is reported at once, not
|
|
281
|
+
one per re-run.
|
|
282
|
+
"""
|
|
283
|
+
env = os.environ if env is None else env # type: ignore[assignment]
|
|
284
|
+
problems: list[str] = []
|
|
285
|
+
for label, actor in authed_actors(config):
|
|
286
|
+
if actor.auth != "api":
|
|
287
|
+
continue
|
|
288
|
+
var = actor.key_var()
|
|
289
|
+
provider = getattr(actor, "provider", "?")
|
|
290
|
+
if provider == "openai":
|
|
291
|
+
# codex NEVER reads the environment — its key lives in CODEX_HOME. Demanding
|
|
292
|
+
# OPENAI_API_KEY here was a false requirement, and a DEAD END: we tell the user
|
|
293
|
+
# to run `codex login --with-api-key` (the only thing that works), and then
|
|
294
|
+
# refused to load anyway unless they also exported a var codex ignores.
|
|
295
|
+
#
|
|
296
|
+
# The openai `api` declaration is verified where it actually lives — the
|
|
297
|
+
# `codex login status` probe in auth_preflight, which refuses if codex is not
|
|
298
|
+
# logged in with an API key. That is the real check; this one was theatre.
|
|
299
|
+
continue
|
|
300
|
+
if var is None:
|
|
301
|
+
problems.append(
|
|
302
|
+
f'{label}: auth = "api" but provider "{provider}" has no conventional '
|
|
303
|
+
f"key var — set api_key_env explicitly"
|
|
304
|
+
)
|
|
305
|
+
elif not env.get(var):
|
|
306
|
+
problems.append(
|
|
307
|
+
f'{label}: auth = "api" requires {var} to be set, but it is not '
|
|
308
|
+
f'(export it, or use auth = "subscription")'
|
|
309
|
+
)
|
|
310
|
+
return problems
|
syncade/config_cold.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Config surfaces + defaults for syncade's three COLD actors.
|
|
2
|
+
|
|
3
|
+
Cold = runs in an isolated git-init'd tempdir, never a repo worktree; sandboxed at
|
|
4
|
+
``trusted-execute``; sees only what it is handed, and returns a strict schema:
|
|
5
|
+
|
|
6
|
+
- **synthesizer** (the judge) — consolidates reviewer findings. Sees structured
|
|
7
|
+
reviewer outputs, never the diff.
|
|
8
|
+
- **drafter** (``--draft-spec``) — turns a session transcript into a spec.
|
|
9
|
+
- **auditor** (``--spec-audit``) — checks a spec for ambiguity before a review runs.
|
|
10
|
+
|
|
11
|
+
All three were hardcoded to ``CodexAdapter`` with module-constant models, so
|
|
12
|
+
``codex`` was a hard requirement for every one of them (PR-v2-23). They are now
|
|
13
|
+
registry-resolved, and each gets a config block. Same shape, so they live together.
|
|
14
|
+
|
|
15
|
+
This is a LEAF module (it imports only :mod:`syncade.config_types`) and it has to
|
|
16
|
+
be: both ``config.py`` (which mounts the models) and the actor modules (which
|
|
17
|
+
re-export the defaults) need these values, and ``config.py`` cannot import the
|
|
18
|
+
actors — ``syncade.synthesizer``'s ``__init__`` imports ``driver``, which imports
|
|
19
|
+
``config``. The defaults sit at the bottom of the graph; everyone imports down.
|
|
20
|
+
|
|
21
|
+
The defaults are ALSO the historical values, which is why ``persistence`` and
|
|
22
|
+
``metrics`` still read the ``SYNTHESIZER_*`` constants directly: for a legacy run
|
|
23
|
+
whose artifacts never recorded a model, the honest fallback is "the default as it
|
|
24
|
+
was", NOT whatever the operator has configured today.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Literal
|
|
30
|
+
|
|
31
|
+
from pydantic import ConfigDict, Field, field_validator, model_validator
|
|
32
|
+
|
|
33
|
+
from syncade.config_auth import AuthedActor
|
|
34
|
+
from syncade.config_types import Thinking
|
|
35
|
+
|
|
36
|
+
_COLD_MODELS: dict[str, str] = {
|
|
37
|
+
"openai": "gpt-5.5",
|
|
38
|
+
"anthropic": "claude-sonnet-4-6",
|
|
39
|
+
}
|
|
40
|
+
"""Default model per provider for a cold actor. See :class:`_ColdActorConfig`."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _ColdActorConfig(AuthedActor):
|
|
44
|
+
"""Shared base for the three cold actors: ``provider`` and ``model`` move as a
|
|
45
|
+
PAIR.
|
|
46
|
+
|
|
47
|
+
Setting ``provider`` alone re-derives ``model``. Without this,
|
|
48
|
+
``[auditor]\\nprovider = "anthropic"`` would keep the ``gpt-5.5`` default and
|
|
49
|
+
hand a codex model to ``claude`` — a 404 at dispatch, and a baffling one, since
|
|
50
|
+
the user never typed "gpt-5.5" anywhere.
|
|
51
|
+
|
|
52
|
+
This mirrors :meth:`syncade.config_producer.ProducerConfig._keep_provider_and_model_paired`
|
|
53
|
+
exactly; the producer has had the same footgun guarded since it became
|
|
54
|
+
harness-aware. It only became POSSIBLE for the cold actors in PR-v2-23, which
|
|
55
|
+
is what made their provider configurable in the first place.
|
|
56
|
+
|
|
57
|
+
An explicit ``model`` always wins — this only fills in a model the user did not
|
|
58
|
+
give, so pinning an off-map model (a new release, a fine-tune) still works.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(extra="forbid")
|
|
62
|
+
|
|
63
|
+
@model_validator(mode="before")
|
|
64
|
+
@classmethod
|
|
65
|
+
def _keep_provider_and_model_paired(cls, data: object) -> object:
|
|
66
|
+
if not isinstance(data, dict):
|
|
67
|
+
return data
|
|
68
|
+
provider = data.get("provider")
|
|
69
|
+
if provider and "model" not in data and provider in _COLD_MODELS:
|
|
70
|
+
data = {**data, "model": _COLD_MODELS[provider]}
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
@field_validator("provider", check_fields=False)
|
|
74
|
+
@classmethod
|
|
75
|
+
def _validate_provider(cls, v: str) -> str:
|
|
76
|
+
# Function-local import: config_cold is a leaf that must not import
|
|
77
|
+
# adapters at module level (import cycle via config → synthesizer → driver).
|
|
78
|
+
from syncade.adapters.registry import known_providers # noqa: PLC0415
|
|
79
|
+
|
|
80
|
+
providers = known_providers()
|
|
81
|
+
if v not in providers:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"unknown cold-actor provider {v!r}; known providers: {', '.join(providers)}"
|
|
84
|
+
)
|
|
85
|
+
return v
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
SYNTHESIZER_PROVIDER = "openai"
|
|
89
|
+
SYNTHESIZER_MODEL = "gpt-5.5"
|
|
90
|
+
SYNTHESIZER_THINKING: Thinking = "high"
|
|
91
|
+
SYNTHESIZER_PERMISSIONS: Literal["trusted-execute"] = "trusted-execute"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SynthesizerConfig(_ColdActorConfig):
|
|
95
|
+
"""``[synthesizer]`` — the cold judge that consolidates reviewer outputs.
|
|
96
|
+
|
|
97
|
+
Until PR-v2-23 these were module constants and the driver imported
|
|
98
|
+
:class:`~syncade.adapters.openai.CodexAdapter` by name, which made ``codex`` a
|
|
99
|
+
hard requirement for EVERY run — even an all-Anthropic one, where the user paid
|
|
100
|
+
for both reviewers and then lost the round to a CLI they never configured.
|
|
101
|
+
|
|
102
|
+
The judge is deliberately NOT harness-aware (unlike the producer): a verdict has
|
|
103
|
+
to stay comparable across runs regardless of which harness the operator happened
|
|
104
|
+
to be coding in.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
provider: str = Field(
|
|
108
|
+
default=SYNTHESIZER_PROVIDER,
|
|
109
|
+
description="Model provider for the judge. Resolved against the adapter "
|
|
110
|
+
"registry, exactly like a reviewer's.",
|
|
111
|
+
)
|
|
112
|
+
model: str = Field(
|
|
113
|
+
default=SYNTHESIZER_MODEL,
|
|
114
|
+
description=(
|
|
115
|
+
"Model identifier for the judge. Defaults to the same model as the "
|
|
116
|
+
"shipped reviewers, and they should be changed together: PR-28 moved "
|
|
117
|
+
"both to gpt-5.6-sol and PR-29 moved both back (the reviewers audited "
|
|
118
|
+
"too leniently on it —. Leaving the two pins "
|
|
119
|
+
"disagreeing sets a trap, because the edit that 'reconciles' them in "
|
|
120
|
+
"the wrong direction is the one that drags the reviewers onto the "
|
|
121
|
+
"lenient model."
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
thinking: Thinking = Field(
|
|
125
|
+
default=SYNTHESIZER_THINKING,
|
|
126
|
+
description="Reasoning-effort budget. The judge reasons across every "
|
|
127
|
+
"reviewer's findings at once, so it gets the full budget by default.",
|
|
128
|
+
)
|
|
129
|
+
permissions: Literal["trusted-execute"] = Field(
|
|
130
|
+
default=SYNTHESIZER_PERMISSIONS,
|
|
131
|
+
description=(
|
|
132
|
+
"Tool-permission tier. Cold actors are locked to ``trusted-execute``: "
|
|
133
|
+
"the OS sandbox must stay ACTIVE and scoped to the synth's temp "
|
|
134
|
+
"workspace so isolation is structural, not prompt-dependent. ``yolo`` "
|
|
135
|
+
"maps to ``--dangerously-bypass-approvals-and-sandbox`` and is "
|
|
136
|
+
"rejected at config load."
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
DRAFTER_PROVIDER = "openai"
|
|
142
|
+
DRAFTER_MODEL = "gpt-5.5"
|
|
143
|
+
DRAFTER_THINKING: Thinking = "xhigh"
|
|
144
|
+
DRAFTER_PERMISSIONS: Literal["trusted-execute"] = "trusted-execute"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class DrafterConfig(_ColdActorConfig):
|
|
148
|
+
"""``[drafter]`` — the cold spec drafter behind ``syncade --draft-spec``.
|
|
149
|
+
|
|
150
|
+
Turns a session transcript into a PR spec. Same codex-hardcoding bug as the
|
|
151
|
+
judge (PR-v2-23): a Claude-Code-only user could not draft a spec at all.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
provider: str = Field(
|
|
155
|
+
default=DRAFTER_PROVIDER,
|
|
156
|
+
description="Model provider for the drafter. Resolved against the adapter "
|
|
157
|
+
"registry, exactly like a reviewer's.",
|
|
158
|
+
)
|
|
159
|
+
model: str = Field(
|
|
160
|
+
default=DRAFTER_MODEL,
|
|
161
|
+
description="Model identifier for the drafter.",
|
|
162
|
+
)
|
|
163
|
+
thinking: Thinking = Field(
|
|
164
|
+
default=DRAFTER_THINKING,
|
|
165
|
+
description=(
|
|
166
|
+
"Reasoning-effort budget. Higher than the judge's by default: drafting a "
|
|
167
|
+
"spec from a rambling session transcript is the hardest inference syncade "
|
|
168
|
+
"asks for, and a vague spec silently degrades every downstream review."
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
permissions: Literal["trusted-execute"] = Field(
|
|
172
|
+
default=DRAFTER_PERMISSIONS,
|
|
173
|
+
description="Tool-permission tier. Locked to ``trusted-execute`` — cold "
|
|
174
|
+
"actors may not disable the OS sandbox. ``yolo`` is rejected at config load.",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
AUDITOR_PROVIDER = "openai"
|
|
179
|
+
AUDITOR_MODEL = "gpt-5.5"
|
|
180
|
+
AUDITOR_THINKING: Thinking = "xhigh"
|
|
181
|
+
AUDITOR_PERMISSIONS: Literal["trusted-execute"] = "trusted-execute"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class AuditorConfig(_ColdActorConfig):
|
|
185
|
+
"""``[auditor]`` — the cold spec auditor behind ``syncade --spec-audit``.
|
|
186
|
+
|
|
187
|
+
Flags an ambiguous spec BEFORE a review burns reviewer spend against it. Same
|
|
188
|
+
codex-hardcoding bug as the judge and drafter (PR-v2-23).
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
provider: str = Field(
|
|
192
|
+
default=AUDITOR_PROVIDER,
|
|
193
|
+
description="Model provider for the auditor. Resolved against the adapter "
|
|
194
|
+
"registry, exactly like a reviewer's.",
|
|
195
|
+
)
|
|
196
|
+
model: str = Field(
|
|
197
|
+
default=AUDITOR_MODEL,
|
|
198
|
+
description="Model identifier for the auditor.",
|
|
199
|
+
)
|
|
200
|
+
thinking: Thinking = Field(
|
|
201
|
+
default=AUDITOR_THINKING,
|
|
202
|
+
description="Reasoning-effort budget. High by default: a missed ambiguity "
|
|
203
|
+
"here is paid for by every reviewer in every round downstream.",
|
|
204
|
+
)
|
|
205
|
+
permissions: Literal["trusted-execute"] = Field(
|
|
206
|
+
default=AUDITOR_PERMISSIONS,
|
|
207
|
+
description="Tool-permission tier. Locked to ``trusted-execute`` — cold "
|
|
208
|
+
"actors may not disable the OS sandbox. ``yolo`` is rejected at config load.",
|
|
209
|
+
)
|
syncade/config_gc.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``[gc]`` config block (PR-v2-9): run-artifact retention shared by ``--gc`` AND auto-prune.
|
|
2
|
+
|
|
3
|
+
A LEAF module — pydantic only. Like :mod:`syncade.config_retry`, it deliberately does NOT import
|
|
4
|
+
:mod:`syncade.gc` (heavy: gc → gc_protection → orchestrator → config → back here), so the defaults
|
|
5
|
+
are LITERAL mirrors of ``gc.DEFAULT_KEEP`` / ``gc.DEFAULT_MAX_AGE_DAYS``, drift-guarded by a test
|
|
6
|
+
(``tests/config/test_gc_config.py::test_defaults_reproduce_the_runtime_defaults``).
|
|
7
|
+
|
|
8
|
+
These govern transcript pruning: the newest ``keep`` runs are always retained, and ``max_age_days``
|
|
9
|
+
(0 = disabled) is an ADDITIONAL floor — a beyond-keep run is pruned only if it is ALSO older. The
|
|
10
|
+
same policy feeds the auto-prune at each fresh loop AND the explicit ``syncade --gc`` pass, so the
|
|
11
|
+
two can never diverge. Run directories are NEVER deleted; only bulky subprocess transcripts are.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
17
|
+
|
|
18
|
+
# Drift-guarded literal mirrors of syncade.gc.DEFAULT_KEEP / DEFAULT_MAX_AGE_DAYS (see docstring).
|
|
19
|
+
_DEFAULT_KEEP = 20
|
|
20
|
+
_DEFAULT_MAX_AGE_DAYS = 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GcConfig(BaseModel):
|
|
24
|
+
model_config = ConfigDict(extra="forbid")
|
|
25
|
+
|
|
26
|
+
keep: int = Field(
|
|
27
|
+
default=_DEFAULT_KEEP,
|
|
28
|
+
ge=0,
|
|
29
|
+
description=(
|
|
30
|
+
"Newest N runs whose transcripts are ALWAYS kept when pruning (default "
|
|
31
|
+
f"{_DEFAULT_KEEP}). Run directories are never deleted — only bulky subprocess "
|
|
32
|
+
"transcripts. Governs BOTH the per-loop auto-prune and an explicit ``syncade --gc``."
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
max_age_days: int = Field(
|
|
36
|
+
default=_DEFAULT_MAX_AGE_DAYS,
|
|
37
|
+
ge=0,
|
|
38
|
+
description=(
|
|
39
|
+
"Additional age floor: a beyond-``keep`` run is pruned only if ALSO older than this "
|
|
40
|
+
f"many days. {_DEFAULT_MAX_AGE_DAYS} (default) disables the age floor."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@field_validator("keep", "max_age_days", mode="before")
|
|
45
|
+
@classmethod
|
|
46
|
+
def _strict_int(cls, value: object) -> object:
|
|
47
|
+
# Pydantic's lax mode coerces a quoted number ("0"→0), an exact float (1.0→1), and a boolean
|
|
48
|
+
# (bool subclasses int: false→0) silently. gc.keep=false/"0" → keep=0 would prune EVERY
|
|
49
|
+
# transcript; a float/string is a typo. Reject all three (exit 50), accept only a plain int.
|
|
50
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"gc integer fields must be plain integers (got {value!r}); quoted numbers, "
|
|
53
|
+
"floats, and booleans are rejected"
|
|
54
|
+
)
|
|
55
|
+
return value
|