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