syncade 0.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- syncade-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Resolve what auth EACH actor will really use, and refuse when reality contradicts it.
|
|
2
|
+
|
|
3
|
+
Declaring a mode is worthless if syncade cannot tell whether it took effect. The two
|
|
4
|
+
CLIs are enforceable to completely different degrees, and the difference is not a
|
|
5
|
+
detail — it is the whole design:
|
|
6
|
+
|
|
7
|
+
**anthropic / ``claude`` — the env IS the lever.** Verified live (2.1.208): an exported
|
|
8
|
+
``ANTHROPIC_API_KEY`` beats the claude.ai login (a bogus one 401s rather than falling
|
|
9
|
+
back), and stripping it drops claude onto OAuth. So
|
|
10
|
+
:func:`~syncade.config_auth.apply_auth_to_env` *enforces* the declaration outright, and
|
|
11
|
+
the resolved mode is a pure function of the env we hand over. No probe needed.
|
|
12
|
+
|
|
13
|
+
**openai / ``codex`` — the env is IGNORED.** Verified live (codex-cli 0.144.1): with no
|
|
14
|
+
stored login, ``codex exec`` fails ``401 Missing bearer`` whether ``OPENAI_API_KEY`` is
|
|
15
|
+
set or not — *identically*. It never reads the var. Not "the stored login wins": the env
|
|
16
|
+
key is not consulted at all, even with ``-c preferred_auth_method="apikey"``. codex's own
|
|
17
|
+
help confirms it — ``--skip-config`` documents that "auth still uses ``CODEX_HOME``".
|
|
18
|
+
|
|
19
|
+
So for codex, auth is machine-global state (``codex login``), and syncade's only lever
|
|
20
|
+
does nothing. Enforcement is impossible; the honest move is to PROBE and REFUSE rather
|
|
21
|
+
than silently run in the mode the user did not ask for.
|
|
22
|
+
|
|
23
|
+
(A per-run ``CODEX_HOME`` *could* force API mode — ``codex login --with-api-key`` into a
|
|
24
|
+
temp dir. Rejected: it writes the user's API key to a temp ``auth.json`` on disk. Buying
|
|
25
|
+
enforcement with a secret spill is a bad trade when a one-line refusal, and a one-time
|
|
26
|
+
``codex login --with-api-key`` the user runs themselves, gets the same guarantee.)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import Literal
|
|
32
|
+
|
|
33
|
+
from syncade.config import SyncadeConfig
|
|
34
|
+
from syncade.config_auth import (
|
|
35
|
+
PROVIDER_KEY_VARS,
|
|
36
|
+
AuthedActor,
|
|
37
|
+
authed_actors,
|
|
38
|
+
credential_fingerprint,
|
|
39
|
+
)
|
|
40
|
+
from syncade.process import SubprocessError, run_subprocess
|
|
41
|
+
|
|
42
|
+
CodexState = Literal["subscription", "api", "none", "unknown"]
|
|
43
|
+
"""What ``codex login status`` reports. Observed non-destructively via CODEX_HOME:
|
|
44
|
+
|
|
45
|
+
"Logged in using ChatGPT" -> subscription
|
|
46
|
+
"Logged in using an API key - sk-xxx***yyy" -> api
|
|
47
|
+
"Not logged in" -> none
|
|
48
|
+
|
|
49
|
+
``unknown`` is anything else — a new CLI version rewording its output. It is NOT treated
|
|
50
|
+
as "probably fine": an unrecognised state with a non-auto declaration is refused, because
|
|
51
|
+
proceeding would mean guessing about the user's money.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def probe_codex_state(timeout: float = 15.0) -> tuple[CodexState, str]:
|
|
56
|
+
"""Ask ``codex login status`` what credential it would actually use.
|
|
57
|
+
|
|
58
|
+
Returns ``(state, raw_output)``. The raw output is carried so a refusal can SHOW the
|
|
59
|
+
user what syncade saw, instead of asserting an opaque verdict about their machine.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
result = run_subprocess(
|
|
63
|
+
["codex", "login", "status"], cwd=None, env=None, timeout=timeout, input_text=None
|
|
64
|
+
)
|
|
65
|
+
except (SubprocessError, OSError) as exc:
|
|
66
|
+
return "unknown", f"codex login status failed: {exc}"
|
|
67
|
+
|
|
68
|
+
raw = (result.stdout + result.stderr).strip()
|
|
69
|
+
lowered = raw.lower()
|
|
70
|
+
|
|
71
|
+
# codex's documented logged-out state is rc=1 with stdout "Not logged in" (verified;
|
|
72
|
+
# adapters/openai.py:60) — a real ANSWER, not a probe failure. Recognise it regardless
|
|
73
|
+
# of exit code, so a logged-out user gets the actionable "NOT LOGGED IN — run codex
|
|
74
|
+
# login" path instead of the opaque `unknown` diagnostic.
|
|
75
|
+
if "not logged in" in lowered:
|
|
76
|
+
return "none", raw
|
|
77
|
+
|
|
78
|
+
# Any OTHER non-zero exit means the probe FAILED — its text is an error message, not a
|
|
79
|
+
# status. Classifying it by substring is how a failed probe whose stderr happens to
|
|
80
|
+
# contain "api key" ("could not read API key file: permission denied") got read as a
|
|
81
|
+
# confident `api`, so the gate ANNOUNCED API billing on a state it never verified. The
|
|
82
|
+
# guardrail is that an unverified state is `unknown` (and `unknown` + a non-auto
|
|
83
|
+
# declaration is refused). Only a clean exit resolves the remaining real states.
|
|
84
|
+
if result.returncode != 0:
|
|
85
|
+
return "unknown", raw or f"codex login status exited {result.returncode}"
|
|
86
|
+
|
|
87
|
+
if "api key" in lowered:
|
|
88
|
+
return "api", raw
|
|
89
|
+
if "chatgpt" in lowered:
|
|
90
|
+
return "subscription", raw
|
|
91
|
+
return "unknown", raw
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_auth_mode(
|
|
95
|
+
actor: AuthedActor, env: dict[str, str], codex_state: CodexState | None = None
|
|
96
|
+
) -> str:
|
|
97
|
+
"""The mode this actor will ACTUALLY run in — not the one it declared.
|
|
98
|
+
|
|
99
|
+
``auto`` exists so syncade changes nobody's behaviour by default; this is what makes
|
|
100
|
+
it safe, because the preflight can then state the resolved truth out loud. Silence
|
|
101
|
+
is the bug, not the default.
|
|
102
|
+
"""
|
|
103
|
+
provider = getattr(actor, "provider", "")
|
|
104
|
+
if provider == "openai":
|
|
105
|
+
# The env cannot influence codex. Reality is whatever it is logged in as -- so
|
|
106
|
+
# the declaration is irrelevant here and the probed state IS the answer.
|
|
107
|
+
return codex_state or get_codex_state()
|
|
108
|
+
if provider == "anthropic":
|
|
109
|
+
if actor.auth != "auto":
|
|
110
|
+
return actor.auth # apply_auth_to_env enforces this outright
|
|
111
|
+
# auto: claude uses a key if one is present, else its OAuth login.
|
|
112
|
+
return "api" if any(env.get(v) for v in PROVIDER_KEY_VARS["anthropic"]) else "subscription"
|
|
113
|
+
return "unknown"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_CODEX_STATE: CodexState = "unknown"
|
|
117
|
+
"""Resolved once per process by :func:`preflight`, then read by every cost record.
|
|
118
|
+
|
|
119
|
+
A module global rather than a value threaded through the orchestrator, for the same
|
|
120
|
+
reason ``run_status`` is one: there is exactly one review per process, and threading a
|
|
121
|
+
constant through six call sites buys nothing. It NEVER probes lazily — an unset value
|
|
122
|
+
stays ``"unknown"`` — so unit tests are hermetic and no test can accidentally spawn
|
|
123
|
+
``codex``. Honest ignorance beats a hidden subprocess.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def set_codex_state(state: CodexState) -> None:
|
|
128
|
+
"""Record the probed state (or inject one in tests)."""
|
|
129
|
+
global _CODEX_STATE
|
|
130
|
+
_CODEX_STATE = state
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_codex_state() -> CodexState:
|
|
134
|
+
return _CODEX_STATE
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def assert_codex_reality_honours_declaration(actor: AuthedActor) -> None:
|
|
138
|
+
"""Structural spend-safety backstop, called at the codex spawn site.
|
|
139
|
+
|
|
140
|
+
:func:`~syncade.config_auth.apply_auth_to_env` makes an anthropic declaration real in
|
|
141
|
+
the adapter env, but codex reads NO env var, so an openai declaration is only ever made
|
|
142
|
+
real by :func:`preflight`'s probe-and-refuse. The CLI runs that gate; a DIRECT library
|
|
143
|
+
call (``run_review`` / ``run_producer`` / the cold drivers) can skip it — which is the
|
|
144
|
+
bypass the panel flagged. So every openai adapter calls this before it builds a codex
|
|
145
|
+
invocation:
|
|
146
|
+
|
|
147
|
+
- On the CLI path preflight already probed and, for a NON-auto declaration, refused
|
|
148
|
+
unless reality matched — so ``_CODEX_STATE`` equals ``actor.auth`` and this passes
|
|
149
|
+
silently.
|
|
150
|
+
- On a gate-skipping path ``_CODEX_STATE`` is still its ``"unknown"`` default, so a
|
|
151
|
+
non-auto declaration is refused HERE rather than silently billing the wrong codex
|
|
152
|
+
account. ``auto`` never refuses (it accepts whatever codex is), matching preflight.
|
|
153
|
+
|
|
154
|
+
Enforcement thus lives at the spawn site, not only in the CLI wrapper — the same
|
|
155
|
+
read-not-remember lesson as the six-entry-point gate, pushed one layer down to where a
|
|
156
|
+
subprocess is actually about to cost money.
|
|
157
|
+
"""
|
|
158
|
+
if getattr(actor, "provider", "") != "openai" or actor.auth == "auto":
|
|
159
|
+
return
|
|
160
|
+
state = get_codex_state()
|
|
161
|
+
if state != actor.auth:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f'openai auth = "{actor.auth}" cannot be honoured: codex login reality is '
|
|
164
|
+
f"{state!r} and no auth gate ran before this spawn. Run through the syncade "
|
|
165
|
+
f"CLI, which probes `codex login status` and refuses before any spend."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def preflight(
|
|
170
|
+
config: SyncadeConfig, env: dict[str, str], blocks: frozenset[str] | None = None
|
|
171
|
+
) -> list[str]:
|
|
172
|
+
"""Probe reality, remember it, and return every declaration we cannot honour.
|
|
173
|
+
|
|
174
|
+
Non-empty ⇒ the caller must REFUSE TO RUN, before a single subprocess bills.
|
|
175
|
+
|
|
176
|
+
The probe runs whenever an openai actor EXISTS, not merely when one declares a mode:
|
|
177
|
+
the resolved mode is needed for honest cost reporting either way (issue 4), and
|
|
178
|
+
codex's reality is the only way to know whether a dollar figure is money or fiction.
|
|
179
|
+
One ~100ms local subprocess per run. A config with no openai actor pays nothing.
|
|
180
|
+
"""
|
|
181
|
+
actors = authed_actors(config, blocks)
|
|
182
|
+
has_openai = any(getattr(a, "provider", "") == "openai" for _, a in actors)
|
|
183
|
+
if not has_openai:
|
|
184
|
+
set_codex_state("unknown")
|
|
185
|
+
return []
|
|
186
|
+
state, _raw = probe_codex_state()
|
|
187
|
+
set_codex_state(state)
|
|
188
|
+
return reality_problems(config, env, state, blocks)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def preflight_problems(config: SyncadeConfig, env: dict[str, str]) -> list[str]:
|
|
192
|
+
"""Backwards-compatible alias of :func:`preflight`."""
|
|
193
|
+
return preflight(config, env)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _why(provider: str, declared: str, mode: str, env: dict[str, str], key_var: str = "") -> str:
|
|
197
|
+
"""The reason this provider resolved the way it did, in the user's terms.
|
|
198
|
+
|
|
199
|
+
A mode with no reason is just as opaque as no mode at all — the user cannot act on
|
|
200
|
+
"anthropic → api" unless they know it is THEIR exported key doing it.
|
|
201
|
+
"""
|
|
202
|
+
if provider == "anthropic":
|
|
203
|
+
if declared == "subscription":
|
|
204
|
+
return 'auth = "subscription": API keys stripped from the child env'
|
|
205
|
+
if declared == "api":
|
|
206
|
+
named = f" from {key_var}" if key_var else ""
|
|
207
|
+
return f'auth = "api": your key{named} is passed; the claude.ai login is not used'
|
|
208
|
+
if mode == "api":
|
|
209
|
+
# Name the var that ACTUALLY resolved it. auto resolves to `api` if EITHER
|
|
210
|
+
# ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN is set, and naming the wrong one
|
|
211
|
+
# sends the user to unset a var they never set.
|
|
212
|
+
present = [v for v in PROVIDER_KEY_VARS["anthropic"] if env.get(v)]
|
|
213
|
+
named = " and ".join(present) if present else "an API credential"
|
|
214
|
+
return f"{named} is set and OVERRIDES your claude.ai login"
|
|
215
|
+
return "no API key in the environment; using your claude.ai login"
|
|
216
|
+
if provider == "openai":
|
|
217
|
+
if mode == "subscription":
|
|
218
|
+
return "codex is logged in with ChatGPT; OPENAI_API_KEY is ignored"
|
|
219
|
+
if mode == "api":
|
|
220
|
+
return "codex is logged in with an API key"
|
|
221
|
+
if mode == "none":
|
|
222
|
+
return "codex is NOT logged in — run `codex login`"
|
|
223
|
+
return "could not read `codex login status`"
|
|
224
|
+
return "unknown provider"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def report_lines(
|
|
228
|
+
config: SyncadeConfig, env: dict[str, str], blocks: frozenset[str] | None = None
|
|
229
|
+
) -> list[str]:
|
|
230
|
+
"""What each provider will actually bill, stated out loud, every run.
|
|
231
|
+
|
|
232
|
+
**This is why ``auto`` is allowed to be the default.** Stripping keys by default would
|
|
233
|
+
break users who deliberately run on API keys with no subscription; so ``auto`` leaves
|
|
234
|
+
the CLIs alone — and that is only safe because the resolved truth is ANNOUNCED. The
|
|
235
|
+
failure this PR exists to delete is not "the wrong mode", it is "the wrong mode,
|
|
236
|
+
silently". A developer with ANTHROPIC_API_KEY exported believes they are on their Max
|
|
237
|
+
plan while every reviewer, producer and judge subprocess bills the API.
|
|
238
|
+
|
|
239
|
+
Printed even under ``--quiet``, for the same reason deprecation warnings are: how your
|
|
240
|
+
money is being spent is actionable regardless of a verbosity preference.
|
|
241
|
+
|
|
242
|
+
Grouped by (provider, RESOLVED MODE) — never by provider alone. Auth is configured and
|
|
243
|
+
enforced PER ACTOR, so collapsing on provider would keep whichever actor came first and
|
|
244
|
+
hide the others: a config with one anthropic actor on ``subscription`` and another on
|
|
245
|
+
``api`` would print "anthropic → subscription" while an actor quietly billed the API.
|
|
246
|
+
A false reassurance is worse than no line at all, and this is the one function whose
|
|
247
|
+
entire job is to not do that. (Caught by syncade's own panel, unanimously.)
|
|
248
|
+
"""
|
|
249
|
+
# Keyed on the CREDENTIAL -- (provider, resolved mode, key var) -- not merely
|
|
250
|
+
# (provider, mode). Two anthropic actors can both resolve to `api` while presenting
|
|
251
|
+
# DIFFERENT keys (one via an exported ANTHROPIC_API_KEY, one via api_key_env =
|
|
252
|
+
# "WORK_KEY"). Sharing one group meant sharing one REASON, so the second actor was
|
|
253
|
+
# explained by the first actor's story -- the report said "ANTHROPIC_API_KEY overrides
|
|
254
|
+
# your login" about an actor that is actually paying with WORK_KEY. A wrong reason is
|
|
255
|
+
# a wrong report.
|
|
256
|
+
groups: dict[tuple[str, str, tuple[str, ...]], list[str]] = {}
|
|
257
|
+
declared: dict[tuple[str, str, tuple[str, ...]], str] = {}
|
|
258
|
+
key_vars: dict[tuple[str, str, tuple[str, ...]], list[str]] = {}
|
|
259
|
+
for label, actor in authed_actors(config, blocks):
|
|
260
|
+
provider = getattr(actor, "provider", "")
|
|
261
|
+
mode = resolve_auth_mode(actor, env)
|
|
262
|
+
key_var = (actor.key_var() or "") if mode == "api" else ""
|
|
263
|
+
# Group by the credential the CLI will actually get, READ from the enforced env --
|
|
264
|
+
# not derived from (provider, mode, key_var). Two anthropic `api` actors can share a
|
|
265
|
+
# key_var yet present different credentials: an `auto` actor keeps ANTHROPIC_AUTH_TOKEN
|
|
266
|
+
# while an explicit `api` actor strips it. Deriving grouped them together under one
|
|
267
|
+
# reason. Same read-not-derive lesson as _credential_key (round 6).
|
|
268
|
+
cred = credential_fingerprint(actor, env)
|
|
269
|
+
key = (provider, mode, cred)
|
|
270
|
+
groups.setdefault(key, []).append(label)
|
|
271
|
+
declared.setdefault(key, actor.auth)
|
|
272
|
+
# One credential (one account) can be reached through several source vars -- WORK1
|
|
273
|
+
# and WORK2 both holding the same key fingerprint identically, so they share a group.
|
|
274
|
+
# Naming only the first left a WORK2 actor explained by the WORK1 reason. Collect
|
|
275
|
+
# EVERY distinct source var so no actor is named by another's.
|
|
276
|
+
if key_var and key_var not in key_vars.setdefault(key, []):
|
|
277
|
+
key_vars[key].append(key_var)
|
|
278
|
+
|
|
279
|
+
lines = []
|
|
280
|
+
for key in sorted(groups):
|
|
281
|
+
provider, mode, _cred = key
|
|
282
|
+
reason = _why(provider, declared[key], mode, env, ", ".join(key_vars.get(key, [])))
|
|
283
|
+
billing = {
|
|
284
|
+
"api": "billed to your API account",
|
|
285
|
+
"subscription": "billed to your subscription — $0 marginal",
|
|
286
|
+
}.get(mode, "billing unknown")
|
|
287
|
+
lines.append(f" {provider:<10} → {mode:<12} {billing}")
|
|
288
|
+
lines.append(f" {'':<10} ({reason})")
|
|
289
|
+
lines.append(f" {'':<10} actors: {', '.join(groups[key])}")
|
|
290
|
+
return lines
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def reality_problems(
|
|
294
|
+
config: SyncadeConfig,
|
|
295
|
+
env: dict[str, str],
|
|
296
|
+
codex_state: CodexState,
|
|
297
|
+
blocks: frozenset[str] | None = None,
|
|
298
|
+
) -> list[str]:
|
|
299
|
+
"""Declarations syncade cannot honour. Non-empty ⇒ REFUSE TO RUN.
|
|
300
|
+
|
|
301
|
+
Only openai actors can land here: for anthropic the declaration IS enforced, so a
|
|
302
|
+
contradiction is impossible by construction.
|
|
303
|
+
|
|
304
|
+
Both directions are refused, deliberately. Running an ``api`` declaration on a
|
|
305
|
+
ChatGPT login silently bills a subscription the user meant to spare (and burns a
|
|
306
|
+
quota they were escaping); running a ``subscription`` declaration on a stored API key
|
|
307
|
+
silently bills money they never agreed to spend. Neither is "close enough".
|
|
308
|
+
"""
|
|
309
|
+
problems: list[str] = []
|
|
310
|
+
for label, actor in authed_actors(config, blocks):
|
|
311
|
+
if getattr(actor, "provider", "") != "openai" or actor.auth == "auto":
|
|
312
|
+
continue
|
|
313
|
+
if codex_state == actor.auth:
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
if codex_state == "none":
|
|
317
|
+
problems.append(
|
|
318
|
+
f'{label}: declares auth = "{actor.auth}" but codex is NOT LOGGED IN. '
|
|
319
|
+
f"Run `codex login` (subscription) or "
|
|
320
|
+
f"`printenv OPENAI_API_KEY | codex login --with-api-key` (api)."
|
|
321
|
+
)
|
|
322
|
+
elif actor.auth == "api" and codex_state == "subscription":
|
|
323
|
+
problems.append(
|
|
324
|
+
f'{label}: declares auth = "api", but codex is logged in with ChatGPT and '
|
|
325
|
+
f"IGNORES OPENAI_API_KEY entirely — this run would bill your ChatGPT "
|
|
326
|
+
f"subscription, not your API account. Run "
|
|
327
|
+
f"`printenv OPENAI_API_KEY | codex login --with-api-key`, or declare "
|
|
328
|
+
f'auth = "subscription".'
|
|
329
|
+
)
|
|
330
|
+
elif actor.auth == "subscription" and codex_state == "api":
|
|
331
|
+
problems.append(
|
|
332
|
+
f'{label}: declares auth = "subscription", but codex is logged in with an '
|
|
333
|
+
f"API KEY — this run would bill your API account real money. Run "
|
|
334
|
+
f'`codex login` to use your ChatGPT plan, or declare auth = "api".'
|
|
335
|
+
)
|
|
336
|
+
else: # unknown
|
|
337
|
+
problems.append(
|
|
338
|
+
f'{label}: declares auth = "{actor.auth}", but syncade could not tell what '
|
|
339
|
+
f"credential codex would use. It said: {codex_state!r}. Refusing rather "
|
|
340
|
+
f"than guessing about your money."
|
|
341
|
+
)
|
|
342
|
+
return problems
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Base / scope resolution.
|
|
2
|
+
|
|
3
|
+
Turns a *scope token* into a concrete base commit SHA, which the caller feeds
|
|
4
|
+
into the existing ``snapshot.take_snapshot(base_ref=...)`` → ``git diff
|
|
5
|
+
<base>..HEAD`` path. The diff machinery is unchanged — base resolution only chooses the
|
|
6
|
+
base. This is the Python half of "review *what*?" when the operator doesn't name
|
|
7
|
+
an explicit ``--base``; the scope-aware NL skill maps phrases onto these tokens.
|
|
8
|
+
|
|
9
|
+
The three scope anchors (operator-locked decisions):
|
|
10
|
+
|
|
11
|
+
- ``everything`` → the branch point off the default branch
|
|
12
|
+
(``git merge-base HEAD <default>``).
|
|
13
|
+
- ``local`` ("what I just did") → the local-ahead commits, i.e. the merge-base
|
|
14
|
+
with the branch's upstream (``@{upstream}``). No upstream → fall back to the
|
|
15
|
+
branch point (= ``everything``) with a note (a fresh local branch's "what I
|
|
16
|
+
did" is its work since it diverged).
|
|
17
|
+
- ``since-last-review`` → the recorded per-branch last-reviewed SHA (passed in by
|
|
18
|
+
the caller, read from ``.syncade/last-reviewed.json``). No record → fall back
|
|
19
|
+
to the branch point with a note ("everything since last review" with no prior
|
|
20
|
+
review unambiguously means everything).
|
|
21
|
+
|
|
22
|
+
A scope that cannot resolve a base (no default branch found for a fallback path)
|
|
23
|
+
raises :class:`BaseResolutionError`; the CLI maps that to a stop-before-the-loop
|
|
24
|
+
per CLAUDE.md's "Exit-code convention for CLI mode handlers", and the skill asks
|
|
25
|
+
for an explicit base. All git runs through
|
|
26
|
+
:func:`syncade.process.run_subprocess` (snapshot-module discipline), never
|
|
27
|
+
``subprocess.run``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from syncade.process import SubprocessNotFoundError, run_subprocess
|
|
36
|
+
|
|
37
|
+
_GIT_TIMEOUT_SECONDS = 30.0
|
|
38
|
+
|
|
39
|
+
VALID_SCOPES = ("everything", "local", "since-last-review")
|
|
40
|
+
"""The scope tokens the CLI ``--scope`` flag accepts and this module resolves."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BaseResolutionError(Exception):
|
|
44
|
+
"""A scope could not be resolved to a concrete base (e.g. no default branch
|
|
45
|
+
for a branch-point fallback). The CLI surfaces it as a stop-before-loop and
|
|
46
|
+
the skill asks the operator for an explicit ``--base``."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class ResolvedBase:
|
|
51
|
+
"""The resolved base ref + an optional operator-facing note explaining any
|
|
52
|
+
fallback (e.g. "no upstream; reviewing since the branch point")."""
|
|
53
|
+
|
|
54
|
+
base_sha: str
|
|
55
|
+
note: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _git(repo_root: Path, *args: str) -> tuple[int, str, str]:
|
|
59
|
+
"""Run ``git <args>`` in ``repo_root`` → (rc, stdout, stderr). Mirrors
|
|
60
|
+
:func:`syncade.snapshot._git` (own copy to keep this a standalone module).
|
|
61
|
+
|
|
62
|
+
``--no-replace-objects`` is prepended unconditionally: merge-base and
|
|
63
|
+
is-ancestor traversals are graph-sensitive operations, and a
|
|
64
|
+
producer-writable ``refs/replace/*`` ref can substitute a different
|
|
65
|
+
ancestor chain, poisoning scope resolution and ancestry checks.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
result = run_subprocess(
|
|
69
|
+
["git", "--no-replace-objects", *args], cwd=repo_root, timeout=_GIT_TIMEOUT_SECONDS
|
|
70
|
+
)
|
|
71
|
+
except SubprocessNotFoundError as exc:
|
|
72
|
+
raise BaseResolutionError(
|
|
73
|
+
"git binary not found on PATH — install git to use syncade"
|
|
74
|
+
) from exc
|
|
75
|
+
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _rev_parse_commit(repo_root: Path, ref: str) -> str | None:
|
|
79
|
+
"""``git rev-parse <ref>^{commit}`` → commit object ID, or ``None`` if absent.
|
|
80
|
+
|
|
81
|
+
Git repositories may use SHA-1 (40 hex chars) or SHA-256 (64 hex chars).
|
|
82
|
+
Trust git's successful parse instead of hard-coding an object-id length.
|
|
83
|
+
"""
|
|
84
|
+
rc, out, _ = _git(repo_root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
|
|
85
|
+
return out if rc == 0 and out else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_ancestor(repo_root: Path, possible_ancestor: str) -> bool:
|
|
89
|
+
rc, _, _ = _git(repo_root, "merge-base", "--is-ancestor", possible_ancestor, "HEAD")
|
|
90
|
+
return rc == 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def detect_default_branch(repo_root: Path) -> str | None:
|
|
94
|
+
"""The repo's default branch ref, or ``None`` if none can be determined.
|
|
95
|
+
|
|
96
|
+
Resolution chain: ``refs/remotes/origin/HEAD`` (e.g. ``origin/main``) → a
|
|
97
|
+
local ``main`` → a local ``master`` → ``None`` (caller asks).
|
|
98
|
+
"""
|
|
99
|
+
rc, out, _ = _git(repo_root, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
|
|
100
|
+
if rc == 0 and out.startswith("refs/remotes/"):
|
|
101
|
+
return out[len("refs/remotes/") :] # e.g. "origin/main"
|
|
102
|
+
for candidate in ("main", "master"):
|
|
103
|
+
if _rev_parse_commit(repo_root, candidate) is not None:
|
|
104
|
+
return candidate
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def local_default_branch(repo_root: Path) -> str | None:
|
|
109
|
+
"""A best-effort LOCAL default branch — ``main`` or ``master`` if either exists — else
|
|
110
|
+
``None``. Used by the default-branch guard only when there is no authoritative
|
|
111
|
+
``origin/HEAD``: a local ``main``/``master`` is a reasonable (not proven) default, so a
|
|
112
|
+
feature branch alongside it can proceed while HEAD *on* it is refused."""
|
|
113
|
+
for candidate in ("main", "master"):
|
|
114
|
+
if _rev_parse_commit(repo_root, candidate) is not None:
|
|
115
|
+
return candidate
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def remote_default_branch(repo_root: Path) -> str | None:
|
|
120
|
+
"""The default branch's BARE name from ``refs/remotes/origin/HEAD`` (AUTHORITATIVE),
|
|
121
|
+
or ``None`` when there is no remote default.
|
|
122
|
+
|
|
123
|
+
Unlike :func:`detect_default_branch`, this does NOT fall back to a local ``main`` /
|
|
124
|
+
``master`` guess: the default-branch commit guard needs to know whether the default is
|
|
125
|
+
PROVEN by the remote, versus merely guessed. A local-only repo therefore returns
|
|
126
|
+
``None`` here (the guard then uses a common-name heuristic), so a repo checked out on
|
|
127
|
+
``trunk`` with a vestigial local ``main`` is not wrongly cleared.
|
|
128
|
+
"""
|
|
129
|
+
rc, out, _ = _git(repo_root, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
|
|
130
|
+
remote_prefix = "refs/remotes/origin/"
|
|
131
|
+
if rc == 0 and out.startswith(remote_prefix):
|
|
132
|
+
return out[len(remote_prefix) :] # e.g. "main"
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _branch_point(repo_root: Path) -> str:
|
|
137
|
+
"""``git merge-base HEAD <default-branch>`` — the point HEAD diverged from the
|
|
138
|
+
default branch. Raises :class:`BaseResolutionError` if there's no default
|
|
139
|
+
branch or no merge-base (unrelated histories)."""
|
|
140
|
+
default = detect_default_branch(repo_root)
|
|
141
|
+
if default is None:
|
|
142
|
+
raise BaseResolutionError(
|
|
143
|
+
"could not determine a default branch (no origin/HEAD, main, or "
|
|
144
|
+
"master) to compute the branch point from — pass an explicit base "
|
|
145
|
+
"(e.g. --base <ref>)"
|
|
146
|
+
)
|
|
147
|
+
rc, out, _ = _git(repo_root, "merge-base", "HEAD", default)
|
|
148
|
+
if rc != 0 or not out:
|
|
149
|
+
raise BaseResolutionError(
|
|
150
|
+
f"no merge-base between HEAD and the default branch {default!r} "
|
|
151
|
+
"(unrelated histories?) — pass an explicit base"
|
|
152
|
+
)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def resolve_scope(
|
|
157
|
+
repo_root: Path,
|
|
158
|
+
scope: str,
|
|
159
|
+
*,
|
|
160
|
+
last_reviewed_sha: str | None = None,
|
|
161
|
+
) -> ResolvedBase:
|
|
162
|
+
"""Resolve a scope token into a :class:`ResolvedBase`. See the module
|
|
163
|
+
docstring for the per-scope semantics and the fallback notes."""
|
|
164
|
+
if scope not in VALID_SCOPES:
|
|
165
|
+
raise ValueError(f"unknown scope {scope!r}; expected one of {VALID_SCOPES}")
|
|
166
|
+
|
|
167
|
+
if scope == "everything":
|
|
168
|
+
return ResolvedBase(base_sha=_branch_point(repo_root))
|
|
169
|
+
|
|
170
|
+
if scope == "local":
|
|
171
|
+
upstream = _rev_parse_commit(repo_root, "@{upstream}")
|
|
172
|
+
if upstream is not None:
|
|
173
|
+
rc, out, _ = _git(repo_root, "merge-base", "HEAD", "@{upstream}")
|
|
174
|
+
if rc == 0 and out:
|
|
175
|
+
return ResolvedBase(base_sha=out)
|
|
176
|
+
# Upstream exists but shares no merge-base with HEAD (unrelated
|
|
177
|
+
# histories). Silently widening to the branch point would review
|
|
178
|
+
# far more than the operator intended; stop and ask for an explicit base.
|
|
179
|
+
raise BaseResolutionError(
|
|
180
|
+
"upstream branch exists but shares no merge-base with HEAD "
|
|
181
|
+
"(unrelated histories?) — pass an explicit base (e.g. --base <ref>)"
|
|
182
|
+
)
|
|
183
|
+
# No upstream: a fresh local branch's "what I did" is its work since it
|
|
184
|
+
# diverged from the default branch.
|
|
185
|
+
return ResolvedBase(
|
|
186
|
+
base_sha=_branch_point(repo_root),
|
|
187
|
+
note="no upstream tracking branch; reviewing since the branch point",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# since-last-review
|
|
191
|
+
if last_reviewed_sha is not None:
|
|
192
|
+
resolved = _rev_parse_commit(repo_root, last_reviewed_sha)
|
|
193
|
+
if resolved is not None:
|
|
194
|
+
if _is_ancestor(repo_root, resolved):
|
|
195
|
+
return ResolvedBase(base_sha=resolved)
|
|
196
|
+
return ResolvedBase(
|
|
197
|
+
base_sha=_branch_point(repo_root),
|
|
198
|
+
note=(
|
|
199
|
+
f"recorded last-reviewed SHA {resolved[:12]} is not an "
|
|
200
|
+
"ancestor of HEAD; reviewing since the branch point"
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
# Recorded SHA no longer resolves (history rewritten / gone) → fall back.
|
|
204
|
+
return ResolvedBase(
|
|
205
|
+
base_sha=_branch_point(repo_root),
|
|
206
|
+
note=(
|
|
207
|
+
f"recorded last-reviewed SHA {last_reviewed_sha[:12]} no longer "
|
|
208
|
+
"resolves; reviewing since the branch point"
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
return ResolvedBase(
|
|
212
|
+
base_sha=_branch_point(repo_root),
|
|
213
|
+
note="no prior review recorded for this branch; reviewing since the branch point",
|
|
214
|
+
)
|