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,546 @@
1
+ """Argparse-based CLI entry point for the syncade orchestrator.
2
+
3
+ The parser, one-shot mode handlers, and diff-base/spec-source resolvers live in
4
+ the package siblings. The main review path (``_run``) and ``main`` stay here so
5
+ the ``run_review`` import lives in the same namespace ``_run`` reads it from —
6
+ that keeps the ``syncade.cli.run_review`` monkeypatch working without any
7
+ rebind. Public CLI helpers are re-exported here so supported ``syncade.cli.X``
8
+ import paths remain stable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from syncade import run_status
17
+ from syncade.cli.auth_gate import auth_gate
18
+ from syncade.config_auth import REVIEW_BLOCKS
19
+ from syncade.config_loader import ConfigError, load_config
20
+ from syncade.exit_codes import (
21
+ CLI_USAGE_ERROR,
22
+ CONFIG_ERROR,
23
+ WORKTREE_ERROR,
24
+ )
25
+ from syncade.git_preconditions import (
26
+ AutoInitRefusedError,
27
+ GitUnavailableError,
28
+ ensure_repo_initialized,
29
+ undo_auto_init,
30
+ )
31
+ from syncade.logging import Logger
32
+ from syncade.orchestrator import run_review
33
+ from syncade.orchestrator.branch_guard import current_branch_name, guard_default_branch
34
+ from syncade.process import SubprocessError
35
+ from syncade.run_inputs import validate_run_inputs
36
+ from syncade.snapshot import SnapshotError, discover_repo_root
37
+ from syncade.worktree import WorktreeError
38
+
39
+ from .config_overrides import OverrideError, apply_cli_overrides
40
+ from .modes import (
41
+ _DRAFT_DIALOGUE_SOFT_CAP,
42
+ _extract_config_operands,
43
+ _run_auth_check,
44
+ _run_doctor,
45
+ _run_draft_spec,
46
+ _run_gc,
47
+ _run_metrics,
48
+ _run_resume,
49
+ _run_selfcheck,
50
+ _run_spec_audit,
51
+ )
52
+ from .parser import _max_rounds, _positive_float, build_parser
53
+ from .paths import resolve_repo_relative_input_path
54
+ from .preflight_paths import check_write_targets
55
+ from .resolve import (
56
+ _cli_proves_commit,
57
+ _current_branch,
58
+ _resolve_openspec_pr_doc,
59
+ _resolve_scope_base,
60
+ )
61
+ from .validate import validate_command_shape
62
+
63
+ __all__ = [
64
+ "main",
65
+ "build_parser",
66
+ "run_review",
67
+ "_run",
68
+ "_positive_float",
69
+ "_max_rounds",
70
+ "_current_branch",
71
+ "_resolve_scope_base",
72
+ "_resolve_openspec_pr_doc",
73
+ "_run_selfcheck",
74
+ "_run_auth_check",
75
+ "_run_doctor",
76
+ "_run_spec_audit",
77
+ "_run_draft_spec",
78
+ "_run_resume",
79
+ "_run_gc",
80
+ "_run_metrics",
81
+ "_DRAFT_DIALOGUE_SOFT_CAP",
82
+ "resolve_repo_relative_input_path",
83
+ ]
84
+
85
+
86
+ def _run(args, repo_root: Path) -> int:
87
+ """Execute a ``syncade <PR_DOC>`` invocation.
88
+
89
+ Wraps :func:`syncade.orchestrator.run_review` with CLI-friendly error
90
+ handling; expected exceptions map to exit codes from
91
+ :mod:`syncade.exit_codes` with a single-line stderr message.
92
+
93
+ ``repo_root`` arrives as the user-supplied *hint* (cwd or
94
+ ``--repo-root``). ``_run`` resolves it to the actual git repo root
95
+ via :func:`~syncade.snapshot.discover_repo_root`. Config is loaded
96
+ from the discovered root before any mutation so that a bad config
97
+ or bad CLI override is caught before ``ensure_repo_initialized``
98
+ creates ``.git`` and a baseline commit. A hint that isn't inside a
99
+ git repo is initialized with a baseline commit and the run proceeds.
100
+ Only a missing ``git`` *binary* fails here with exit 60.
101
+
102
+ The end-of-run summary is printed by ``run_review`` itself via the
103
+ :class:`~syncade.logging.Logger` constructed here from ``--quiet``; the CLI
104
+ no longer formats its own summary line.
105
+ """
106
+ # Reset the process-global run-state before anything reads it. One review per process is
107
+ # the normal case, but tests (and any library caller) invoke main() repeatedly, and
108
+ # `began()` below is deliberately sticky — without this, a previous invocation's run makes
109
+ # this one look like it took ownership.
110
+ run_status.clear_active()
111
+
112
+ # A repo syncade itself auto-initializes (a fresh non-git dir) is EXEMPT from the
113
+ # default-branch guard below: there is no pre-existing integration branch to protect and
114
+ # the operator explicitly ran syncade here. Detect it BEFORE ensure_repo_initialized
115
+ # creates the repo.
116
+ #
117
+ # Capture the discovered root here: the pre-flight below needs it for correct path
118
+ # resolution when --repo-root is a subdirectory (PR-h-04 finding).
119
+ try:
120
+ _discovered_root = discover_repo_root(repo_root)
121
+ repo_preexisted = True
122
+ except SnapshotError:
123
+ _discovered_root = None
124
+ repo_preexisted = False
125
+
126
+ # VALIDATE BEFORE MUTATE (PR-h-04 item A). Everything below this point can change the
127
+ # operator's directory — `ensure_repo_initialized` creates a repo and a baseline commit,
128
+ # and `guard_default_branch` further down refuses with a message about BRANCHES. A
129
+ # mistyped brief path therefore either left a repo behind (fresh dir) or was reported as
130
+ # a default-branch problem (existing repo), when the real mistake was a filename.
131
+ #
132
+ # `validate_run_inputs` is the SAME function `run_review` calls, so this pre-flight
133
+ # cannot refuse something the library would accept. `--openspec` is validated HERE too:
134
+ # resolving the proposal folder before mutation ensures a bad change-id is caught before
135
+ # auto-init creates .git and a baseline commit.
136
+ #
137
+ # Use the discovered root for path resolution when the repo already exists: a
138
+ # --repo-root hint that is a subdirectory of the real root must not reject PR docs
139
+ # that exist at the repo root. Fall back to the hint for the auto-init case (no root yet).
140
+
141
+ # NOTE: an unresolvable `--base` needs no pre-flight here. It used to: a ~45-line
142
+ # allowlist of the refs auto-init creates, plus a loop normalizing `@{0}`, `^{commit}`,
143
+ # `^{}`, `^0` and `~0` suffixes. That allowlist was revised FIVE times across two dogfoods
144
+ # and was still rejecting spellings git would resolve, because enumerating valid revision
145
+ # syntax does not terminate. `take_snapshot` already resolves the ref authoritatively, and
146
+ # undo (see the finally below) makes reaching it harmless — so the enumeration is deleted
147
+ # rather than extended, and git remains the only authority on what a ref means.
148
+
149
+ # Logger only depends on --quiet; create it here so the OpenSpec preflight can use it,
150
+ # and so it is available before config is loaded.
151
+ logger = Logger("quiet" if args.quiet else "normal")
152
+
153
+ # Initialized before mutation so the outer try-finally can clean up on every exit path.
154
+ openspec_tmp_path: Path | None = None
155
+ pr_doc_artifact_name: str | None = None
156
+ # PR-h-04.6: was this directory EMPTY before syncade touched it? Captured before the
157
+ # first write, so a partial init cannot lose it and no filesystem race can invalidate it.
158
+ # If it was empty, everything present afterwards is syncade's and undo needs no ownership
159
+ # proof; if it was not, syncade does not undo (see --allow-auto-init's help).
160
+ _started_empty = False
161
+ # Undo is decided in ONE place — the `finally` at the bottom — from these two positional
162
+ # facts. Neither classifies anything, which is the point: naming which exceptions or exit
163
+ # codes mean "the run began" is the enumeration this PR exists to delete, and three
164
+ # successive guards each looked correct and each was wrong. `sys.exc_info()` read empty
165
+ # after a caught signal; `run_status.active()` reads None once `run_review` finalizes, so
166
+ # a real mid-run failure looked like a clean refusal; `exit_code != CONFIG_ERROR` called
167
+ # every refusal exit code review work.
168
+ _result = None # set IFF run_review returned — it is then the authority on what it did
169
+ _abnormal = False # set IFF a signal or an unexplained exception ended the run (L4, L6)
170
+
171
+ _preflight_root = _discovered_root if _discovered_root is not None else repo_root
172
+
173
+ # VALIDATE BEFORE MUTATE — config and CLI overrides (PR-h-04 item A, extended).
174
+ # load_config and apply_cli_overrides are PURE (no filesystem mutations); running them
175
+ # BEFORE the OpenSpec tempfile is created ensures a bad .syncade/config.toml or an
176
+ # unknown --reviewer-* name is caught before any tempfile or .git exists. An early
177
+ # config failure would otherwise return CONFIG_ERROR after the OpenSpec tempfile was
178
+ # written but before the outer try-finally's cleanup started, leaking private content
179
+ # in the system temp directory. Uses _preflight_root (discovered root when the repo
180
+ # already exists, raw hint otherwise) — the same anchor used for PR_DOC validation.
181
+ #
182
+ # deprecation warnings are emitted directly to stderr (not through `logger.warning`
183
+ # which is suppressed in quiet mode). Operators using --quiet still see the warning
184
+ # because deprecated config is actionable regardless of verbosity preference.
185
+ def _emit_deprecation(message: str) -> None:
186
+ print(f"[syncade] {message}", file=sys.stderr)
187
+
188
+ try:
189
+ config = load_config(
190
+ _preflight_root, preset=args.preset, deprecation_callback=_emit_deprecation
191
+ )
192
+ except ConfigError as exc:
193
+ print(f"[syncade] config error: {exc}", file=sys.stderr)
194
+ return CONFIG_ERROR
195
+
196
+ # Apply CLI overrides before mutation — same reasoning: a bad --reviewer-model name
197
+ # or an invalid timeout must refuse before .git is created. apply_cli_overrides is
198
+ # pure (no I/O); CLI beats config.
199
+ try:
200
+ config = apply_cli_overrides(config, args)
201
+ except OverrideError as exc:
202
+ print(f"[syncade] config error: {exc}", file=sys.stderr)
203
+ return CONFIG_ERROR
204
+
205
+ if args.openspec is not None:
206
+ # Resolve/validate the OpenSpec proposal BEFORE any mutation so that a bad
207
+ # change-id is caught here, not after ensure_repo_initialized runs.
208
+ # Config is loaded first so a bad config fails before the tempfile is created.
209
+ _early_path = _resolve_openspec_pr_doc(_preflight_root, args.openspec or None, logger)
210
+ if _early_path is None:
211
+ return WORKTREE_ERROR
212
+ openspec_tmp_path = _early_path
213
+ pr_doc_artifact_name = _early_path.name
214
+ else:
215
+ try:
216
+ _pr_doc_path = resolve_repo_relative_input_path(
217
+ args.pr_doc, repo_root=_preflight_root, label="PR_DOC"
218
+ )
219
+ validate_run_inputs(_preflight_root, _pr_doc_path)
220
+ except (FileNotFoundError, NotADirectoryError) as exc:
221
+ print(f"[syncade] error: {exc}", file=sys.stderr)
222
+ return 2
223
+
224
+ # Outer try-finally ensures openspec_tmp_path is cleaned up on every exit path —
225
+ # including error returns from ensure_repo_initialized and post-mutation setup that
226
+ # would otherwise leak the staged tempfile.
227
+ try:
228
+ # Auth reality check (PR-v2-24). Moved BEFORE ensure_repo_initialized so that an
229
+ # auth mismatch refuses without creating .git in a fresh directory. `codex` IGNORES
230
+ # OPENAI_API_KEY entirely — auth comes only from its stored login — so an
231
+ # `auth = "api"` declaration on a ChatGPT login cannot be enforced by anything
232
+ # syncade controls. Refusing here, before any mutation, keeps the fresh directory
233
+ # byte-inert on auth failure.
234
+ # The SAME gate every other entry point uses -- one function, not a policy each mode
235
+ # is trusted to remember. See cli/auth_gate.py for why that distinction matters.
236
+ gate = auth_gate(config, REVIEW_BLOCKS)
237
+ if gate is not None:
238
+ return gate
239
+
240
+ # Refuse unusable write targets before auto-init mutates anything. Both dirs are
241
+ # created later (worktree_base by provisioning, .syncade/runs by the run-dir mkdir),
242
+ # so without this a refused run would create a repository on its way to failing.
243
+ _bad_target = check_write_targets(config.worktree_base, _preflight_root)
244
+ if _bad_target is not None:
245
+ return _bad_target
246
+
247
+ # In a non-repo directory, initialize a conservative baseline repo so the
248
+ # snapshot below has a tree to work with. Diagnostic modes still use hard
249
+ # repo discovery and never mutate the caller's directory.
250
+ # Capture emptiness BEFORE the call: if `git init` fails halfway, the bit is already
251
+ # recorded and undo still removes the repository. A receipt written by the callee
252
+ # cannot do that — round 0 of the dogfood proved exactly this.
253
+ if not repo_preexisted:
254
+ try:
255
+ _started_empty = not any(repo_root.iterdir())
256
+ except OSError:
257
+ _started_empty = False
258
+ try:
259
+ ensure_repo_initialized(repo_root, allow_populated=args.allow_auto_init)
260
+ except AutoInitRefusedError as exc:
261
+ print(f"[syncade] {exc}", file=sys.stderr)
262
+ return WORKTREE_ERROR
263
+ except GitUnavailableError as exc:
264
+ print(f"[syncade] {exc}", file=sys.stderr)
265
+ return WORKTREE_ERROR
266
+ except SubprocessError as exc:
267
+ # A malformed repo_root hint (a path that does not exist or is a
268
+ # file — run_subprocess pre-validates cwd) or a genuine init /
269
+ # baseline-commit failure (read-only target dir, unresolvable git
270
+ # identity, ...) surfaces out of the precondition as a
271
+ # SubprocessError. These are environment/precondition failures →
272
+ # exit 60, mirroring the discover_repo_root/SnapshotError mapping
273
+ # just below.
274
+ print(f"[syncade] git precondition error: {exc}", file=sys.stderr)
275
+ return WORKTREE_ERROR
276
+ except OSError as exc:
277
+ # Filesystem-level precondition failures that are neither a missing
278
+ # git binary nor a git subprocess error: an over-long --repo-root
279
+ # component (OSError(ENAMETOOLONG) from the path stat inside
280
+ # discover_repo_root), or a write that cannot complete (e.g. a
281
+ # read-only target dir when writing .git/info/exclude or the starter
282
+ # The .gitignore write can escape the precondition as a bare OSError; map
283
+ # them to the same environment/precondition exit 60 as above rather
284
+ # than letting them surface as an uncaught traceback (exit 1).
285
+ print(f"[syncade] git precondition error: {exc}", file=sys.stderr)
286
+ return WORKTREE_ERROR
287
+
288
+ # Resolve the hint to the real git repo root — the whole run must be
289
+ # anchored there, not under whatever subdirectory the user invoked from.
290
+ try:
291
+ repo_root = discover_repo_root(repo_root)
292
+ except SnapshotError as exc:
293
+ print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
294
+ return WORKTREE_ERROR
295
+
296
+ # Default-branch guard (PR-v2-26). auth_gate already ran above; this guard runs after
297
+ # repo discovery so it can read the current branch name. Based/scoped runs defer
298
+ # (D1(c), PR-h-02d.5): run_review enforces the guard, so both paths refuse at exit 60.
299
+ # --max-rounds is already folded into config.loop.max_rounds by apply_cli_overrides above.
300
+ effective_rounds = config.loop.max_rounds
301
+ # A syncade-auto-created repo (fresh dir) is exempt — see repo_preexisted above.
302
+ allow_default = args.allow_default_branch or not repo_preexisted
303
+
304
+ # Resolve --scope BEFORE the guard: a scope that resolves to HEAD is a known no-change run
305
+ # (no producer fires), so the guard must not refuse it. --base and --scope are mutually
306
+ # exclusive; only one path runs.
307
+ base_ref = args.base
308
+ if args.scope is not None:
309
+ base_ref = _resolve_scope_base(repo_root, args.scope, logger)
310
+ if base_ref is None:
311
+ return WORKTREE_ERROR
312
+
313
+ # Refuse ONLY when the CLI can PROVE the run commits (D1(c), PR-h-02d.5).
314
+ #
315
+ # Whether a run commits depends on the FILTERED diff, which is not knowable here:
316
+ # the CLI has not snapshotted or resolved scope. (Config IS loaded, so
317
+ # `strip_repo_context_files` is available — but the filtered diff isn't.) Four
318
+ # earlier attempts substituted a cheaper predicate — base == HEAD, then merge-base,
319
+ # then merge-base plus `--two-dot` — and each was wrong for a different input, because
320
+ # the question simply is not expressible from what the CLI has.
321
+ #
322
+ # So the direction is inverted. With NO diff-shaping flag the reviewer diff is full
323
+ # HEAD, which is non-empty in any repo that has a commit, so a multi-round run will
324
+ # produce a producer commit — provable, and the common case this pre-auth guard exists
325
+ # for. With a base or a scope, defer: `run_review` classifies authoritatively at the
326
+ # run-entry choke, still BEFORE any reviewer/producer subprocess. The cost of deferring
327
+ # is one auth probe; the cost of guessing wrong was refusing valid no-change runs.
328
+ #
329
+ # `--two-dot` needs `--base`/`--scope` (enforced in validate), so it is covered.
330
+ _cli_will_commit = effective_rounds > 1 and _cli_proves_commit(
331
+ repo_root, base_ref, config.review.strip_repo_context_files, two_dot=args.two_dot
332
+ )
333
+ try:
334
+ guard_default_branch(
335
+ repo_root,
336
+ current_branch_name(repo_root),
337
+ allow=allow_default,
338
+ will_commit=_cli_will_commit,
339
+ )
340
+ except WorktreeError as exc:
341
+ print(f"[syncade] worktree error: {exc}", file=sys.stderr)
342
+ return WORKTREE_ERROR
343
+
344
+ # ``--openspec`` spec was already resolved in the preflight above; use the staged
345
+ # tempfile directly. For the normal path, resolve the PR_DOC relative to the
346
+ # (now-confirmed) repo root.
347
+ if args.openspec is not None:
348
+ pr_doc_path = openspec_tmp_path # type: ignore[assignment]
349
+ else:
350
+ pr_doc_path = resolve_repo_relative_input_path(
351
+ args.pr_doc, repo_root=repo_root, label="PR_DOC"
352
+ )
353
+
354
+ with run_status.install_signal_handlers():
355
+ try:
356
+ result = run_review(
357
+ repo_root=repo_root,
358
+ pr_doc_path=pr_doc_path,
359
+ config=config,
360
+ base_ref=base_ref,
361
+ timeout_seconds=args.timeout,
362
+ logger=logger,
363
+ force_dirty=args.force_dirty,
364
+ two_dot=args.two_dot,
365
+ allow_default_branch=allow_default,
366
+ pr_doc_artifact_name=pr_doc_artifact_name,
367
+ worktree_base=config.worktree_base,
368
+ )
369
+ except FileNotFoundError as exc:
370
+ print(f"[syncade] error: {exc}", file=sys.stderr)
371
+ # PR_DOC is a CLI-input issue; exit 2 matches argparse's
372
+ # convention for "user-supplied argument problem".
373
+ return 2
374
+ except NotADirectoryError as exc:
375
+ print(f"[syncade] error: {exc}", file=sys.stderr)
376
+ return 2
377
+ except SnapshotError as exc:
378
+ print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
379
+ # A mid-loop SnapshotError is caught HERE, not by the catch-all below,
380
+ # so it must finalize the breadcrumb itself — else status.json stays
381
+ # `running` and a clean exit-60 falsely reads as a hard kill.
382
+ run_status.finalize_active(f"exception:{type(exc).__name__}", WORKTREE_ERROR)
383
+ return WORKTREE_ERROR
384
+ except WorktreeError as exc:
385
+ print(f"[syncade] worktree error: {exc}", file=sys.stderr)
386
+ run_status.finalize_active(f"exception:{type(exc).__name__}", WORKTREE_ERROR)
387
+ return WORKTREE_ERROR
388
+ except KeyboardInterrupt:
389
+ # L4: an interrupted run is never a clean refusal. This handler RETURNS, so
390
+ # the outer catch-all below never sees it and it must record that itself.
391
+ _abnormal = True
392
+ if run_status.received_signal():
393
+ # Signal-induced KI: finalize with signal:<NAME> + 128+signum.
394
+ return run_status.finalize_signal()
395
+ # Non-signal KI: the orchestrator guard already finalized status.json
396
+ # as exception:KeyboardInterrupt. Finalize here only if still active
397
+ # (e.g. raised before begin()), then return the conventional 130.
398
+ run_status.finalize_active("exception:KeyboardInterrupt", None)
399
+ return 130
400
+ except BaseException as exc:
401
+ # Unexpected mid-run failure: record it before it propagates so the
402
+ # breadcrumb never lies about why the run ended. It re-raises, so the outer
403
+ # catch-all marks it abnormal — an unexplained failure is not a refusal (L6).
404
+ if isinstance(exc, Exception):
405
+ run_status.finalize_active(f"exception:{type(exc).__name__}", None)
406
+ raise
407
+
408
+ # run_review already printed the summary via Logger.summary.
409
+ _result = result
410
+ return result.exit_code
411
+ except KeyboardInterrupt:
412
+ # Signal arriving in the narrow window BEFORE run_status.install_signal_handlers()
413
+ # is entered (between ensure_repo_initialized success and the with-block). The
414
+ # inner handler covers signals during run_review; this catches the gap. L4: not a
415
+ # clean refusal — signals must not trigger undo.
416
+ _abnormal = True
417
+ if run_status.received_signal():
418
+ return run_status.finalize_signal()
419
+ run_status.finalize_active("exception:KeyboardInterrupt", None)
420
+ return 130
421
+ except BaseException:
422
+ # Unexpected exception, here or re-raised from the run itself. Not a refusal (L6).
423
+ _abnormal = True
424
+ raise
425
+ finally:
426
+ # UNDO (PR-h-04.6), decided HERE and nowhere else. Any return between auto-init and
427
+ # the review — present or added later — lands in this `finally`, so no list of refusal
428
+ # sites exists to fall out of date. That list is what two dogfoods could not complete.
429
+ #
430
+ # `_started_empty` is what makes deletion safe at all: the directory was empty before
431
+ # the first write, so everything in it now is syncade's (item 1). The rest decides
432
+ # whether this invocation is a refusal, from facts rather than judgements:
433
+ #
434
+ # run_review RETURNED → it is the authority on what it did. A run is a refusal
435
+ # when no reviewer subprocess was started across any round. That covers
436
+ # `no_changes_to_review` (exit 0), `diff_malformed` (exit 60), and adapter-
437
+ # lookup / auth-preflight failures — all of which have no dispatcher Phase 3.
438
+ # `DispatchResult.reviewer_subprocess_started` is the explicit fact;
439
+ # `dispatch_result.results` is NOT (adapter lookup failures return non-empty
440
+ # results before any subprocess runs, and `producer_emptied_diff` has an empty
441
+ # final result after real reviewers ran in prior rounds).
442
+ # run_review RAISED → `began()` says whether it had taken ownership. Unlike
443
+ # `active()` it survives the finalization `run_review` performs before
444
+ # re-raising, which is exactly what the previous guard got wrong.
445
+ # ABNORMAL → a signal or an unexplained exception is never a refusal.
446
+ _refused = not _abnormal and (
447
+ not any(r.dispatch_result.reviewer_subprocess_started for r in _result.rounds)
448
+ if _result is not None
449
+ else not run_status.began()
450
+ )
451
+ if _started_empty and _refused:
452
+ _failed = undo_auto_init(repo_root)
453
+ if _failed:
454
+ # Never abort on a failed cleanup: the run is already refusing, and a leftover
455
+ # repo is the OLD behaviour rather than a new hazard. Printed straight to
456
+ # stderr so it survives --quiet, like the auth block.
457
+ print(
458
+ "[syncade] warning: could not remove what auto-init created: "
459
+ + ", ".join(_failed),
460
+ file=sys.stderr,
461
+ )
462
+ if openspec_tmp_path is not None:
463
+ try:
464
+ openspec_tmp_path.unlink(missing_ok=True)
465
+ except OSError as exc:
466
+ print(
467
+ f"[syncade] warning: could not remove OpenSpec tempfile: {exc}", file=sys.stderr
468
+ )
469
+
470
+
471
+ def main(argv: list[str] | None = None) -> int:
472
+ """Entry point used by both ``python -m syncade`` and the installed
473
+ ``syncade`` console script.
474
+
475
+ Returns the process exit code; callers (e.g. ``__main__.py``) are
476
+ responsible for passing the return value to :func:`sys.exit`.
477
+
478
+ Validation order:
479
+
480
+ 1. Command-shape checks (mutex, required pairs, no-command) — these
481
+ happen *before* any filesystem work, so a user running
482
+ ``syncade`` with no command sees help, never a config or
483
+ snapshot error from a stale ``.syncade/`` or a non-git cwd.
484
+ 2. Dispatch to the right command handler. For a real review, :func:`_run`
485
+ resolves the git repo root and loads ``.syncade/config.toml`` *from that root* — not from the
486
+ user-supplied ``--repo-root``/cwd hint.
487
+ """
488
+ _argv = list(sys.argv[1:]) if argv is None else list(argv)
489
+ # Extract --config operands from raw argv before argparse sees them: argparse's nargs="*"
490
+ # stops at dash-prefixed tokens (treating them as unknown options), so a model string like
491
+ # "-custom-model" would be rejected. We pull the operands manually, strip them from argv, and
492
+ # re-inject after parsing. Also detect the forbidden prefix form (--repo before --config).
493
+ _config_operands, _argv_parsed, _repo_prefix = _extract_config_operands(_argv)
494
+ parser = build_parser()
495
+ args = parser.parse_args(_argv_parsed)
496
+ if _config_operands is not None:
497
+ # Merge any trailing non-flag operands that argparse still captured (e.g. `--config list
498
+ # extra`) with the dash-prefixed ones we extracted before parsing.
499
+ args.config = _config_operands + (args.config or [])
500
+ # Reject the prefix form: --repo before --config set violates the suffix-only contract (D1).
501
+ # Only applies to "set" — for other verbs (list/get) or no verb, _reject_config_mode_conflicts
502
+ # will catch the --repo misuse with the appropriate "meaningful only with --config set" message.
503
+ if _repo_prefix and args.config and args.config[0] == "set":
504
+ print(
505
+ "[syncade] error: --repo must trail the key/value: `--config set <key> <value> --repo`",
506
+ file=sys.stderr,
507
+ )
508
+ return CLI_USAGE_ERROR
509
+
510
+ rc = validate_command_shape(args, parser)
511
+ if rc is not None:
512
+ return rc
513
+
514
+ # --- dispatch -------------------------------------------------------
515
+ # --install-skill is a standalone local operation — copies bundled skill files into the
516
+ # harness dirs. Dispatched after command-shape validation so --doctor/--quick combinations
517
+ # are rejected before the filesystem mutation.
518
+ if args.install_skill is not None:
519
+ from syncade.cli.install_skill import install_skill
520
+
521
+ return install_skill(args.install_skill, force=args.force_install)
522
+ if args.config is not None:
523
+ from syncade.cli.config_mode import run_config
524
+
525
+ return run_config(args.config, args=args)
526
+ if args.gc:
527
+ return _run_gc(args)
528
+ if args.metrics:
529
+ return _run_metrics(args)
530
+ if args.resume is not None:
531
+ return _run_resume(args)
532
+ if args.selfcheck:
533
+ return _run_selfcheck(args)
534
+ if args.auth_check:
535
+ return _run_auth_check(args)
536
+ if args.doctor:
537
+ return _run_doctor(args)
538
+ if args.spec_audit is not None:
539
+ return _run_spec_audit(args)
540
+ if args.draft_spec:
541
+ return _run_draft_spec(args)
542
+
543
+ # repo_root here is a starting *hint* (cwd or --repo-root); _run
544
+ # resolves it to the actual git repo root before doing anything else.
545
+ repo_root = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
546
+ return _run(args, repo_root)
@@ -0,0 +1,59 @@
1
+ """The one auth gate every entry point passes through.
2
+
3
+ Wiring the preflight into the review path only was not enough, and syncade's own panel
4
+ caught it unanimously: FIVE other modes load config and spawn provider subprocesses, and
5
+ none of them checked anything.
6
+
7
+ --resume resumes a FULL review loop -- reviewers, judge, producer
8
+ --selfcheck spawns real provider subprocesses
9
+ --spec-audit spawns the cold auditor
10
+ --draft-spec spawns the cold drafter
11
+ --auth-check probes each configured provider
12
+
13
+ ``--resume`` is the one that stings: a user could be refused on a fresh run and then
14
+ simply resume past the refusal, billing the account they were protected from thirty
15
+ seconds earlier.
16
+
17
+ The mistake is worth naming, because it is the same one twice. In issue 2 I checked that
18
+ enforcement covered all five ACTOR TYPES and was pleased with myself. It never occurred to
19
+ me to check that it covered all six ENTRY POINTS. Right instinct, wrong axis. So the gate
20
+ now lives in ONE function that every mode calls, rather than in a policy each mode is
21
+ trusted to remember.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import sys
28
+
29
+ from syncade import auth_preflight
30
+ from syncade.config import SyncadeConfig
31
+ from syncade.exit_codes import CONFIG_ERROR
32
+
33
+
34
+ def auth_gate(config: SyncadeConfig, blocks: frozenset[str] | None = None) -> int | None:
35
+ """Refuse impossible declarations; announce who is about to be billed.
36
+
37
+ Returns ``CONFIG_ERROR`` when the run must not start, else ``None``.
38
+
39
+ There is no ``announce=False``. It existed for ``--auth-check`` on the theory that a
40
+ second auth block would be "noise" — and that was exactly the mutation I had warned
41
+ about in issue 5: a quiet ``--auth-check`` could then spawn a probe under ``auto``,
42
+ hit the API because ANTHROPIC_API_KEY was set, and never say so. The command whose
43
+ entire job is auth is the LAST place to suppress the auth line.
44
+ """
45
+ env = dict(os.environ)
46
+ # Scoped to the actors THIS command can spawn. `--spec-audit` runs only the auditor, so
47
+ # refusing it over a REVIEWER's declaration blocks a command that would never have run
48
+ # that reviewer -- and the report would announce billing for actors that will not bill.
49
+ problems = auth_preflight.preflight(config, env, blocks)
50
+ if problems:
51
+ print("[syncade] auth error: declared mode contradicts this machine", file=sys.stderr)
52
+ for problem in problems:
53
+ print(f" - {problem}", file=sys.stderr)
54
+ return CONFIG_ERROR
55
+
56
+ print("[syncade] auth:", file=sys.stderr)
57
+ for line in auth_preflight.report_lines(config, env, blocks):
58
+ print(line, file=sys.stderr)
59
+ return None