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,421 @@
1
+ """Command-shape validation for the ``syncade`` CLI (split out of ``cli/__init__`` for the LOC cap).
2
+
3
+ The mutual-exclusion and context-only-knob checks that must run BEFORE any filesystem work, so a bad
4
+ invocation fails for $0 — never a config/snapshot/auth cost from a stale ``.syncade/`` or non-git
5
+ cwd.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from syncade.exit_codes import CLI_USAGE_ERROR
13
+
14
+ from .modes import _reject_config_mode_conflicts, _reject_diff_base_flags
15
+
16
+
17
+ def validate_command_shape(args, parser) -> int | None:
18
+ """Return an exit code if ``args`` is an invalid command shape (mutually exclusive flags, a
19
+ context-only knob used out of context, or no command at all), else ``None`` to proceed to
20
+ dispatch. Pure argument inspection — NO filesystem work."""
21
+ # --- command-shape validation (no filesystem work yet) --------------
22
+ if args.resume and args.pr_doc:
23
+ print(
24
+ "[syncade] error: --resume cannot be combined with a PR_DOC "
25
+ "positional argument; pass one or the other",
26
+ file=sys.stderr,
27
+ )
28
+ return 2
29
+ # --selfcheck is mutually exclusive with PR_DOC and other review modes
30
+ # because it is a one-shot producer smoke.
31
+ # Argparse-style mutual exclusion via post-parse check so each flag
32
+ # keeps its standalone help text.
33
+ if args.selfcheck and args.pr_doc:
34
+ print(
35
+ "[syncade] error: --selfcheck cannot be combined with a PR_DOC "
36
+ "positional argument; pass one or the other",
37
+ file=sys.stderr,
38
+ )
39
+ return 2
40
+ if args.selfcheck and args.resume:
41
+ print(
42
+ "[syncade] error: --selfcheck cannot be combined with --resume",
43
+ file=sys.stderr,
44
+ )
45
+ return 2
46
+ if args.selfcheck and args.spec_audit:
47
+ print(
48
+ "[syncade] error: --selfcheck cannot be combined with --spec-audit",
49
+ file=sys.stderr,
50
+ )
51
+ return 2
52
+ # --auth-check is mutually exclusive with PR_DOC and every other one-shot
53
+ # mode. Keep the post-parse checks so each flag has standalone help text.
54
+ if args.auth_check and args.pr_doc:
55
+ print(
56
+ "[syncade] error: --auth-check cannot be combined with a PR_DOC "
57
+ "positional argument; pass one or the other",
58
+ file=sys.stderr,
59
+ )
60
+ return 2
61
+ if args.auth_check and args.resume:
62
+ print(
63
+ "[syncade] error: --auth-check cannot be combined with --resume",
64
+ file=sys.stderr,
65
+ )
66
+ return 2
67
+ if args.auth_check and args.selfcheck:
68
+ print(
69
+ "[syncade] error: --auth-check cannot be combined with --selfcheck",
70
+ file=sys.stderr,
71
+ )
72
+ return 2
73
+ if args.auth_check and args.spec_audit:
74
+ print(
75
+ "[syncade] error: --auth-check cannot be combined with --spec-audit",
76
+ file=sys.stderr,
77
+ )
78
+ return 2
79
+ # --spec-audit takes its own PR_DOC path and is mutually exclusive with
80
+ # the positional PR_DOC and the other one-shot modes.
81
+ if args.spec_audit and args.pr_doc:
82
+ print(
83
+ "[syncade] error: --spec-audit cannot be combined with a PR_DOC "
84
+ "positional argument; pass one or the other",
85
+ file=sys.stderr,
86
+ )
87
+ return 2
88
+ # --base "" is always invalid: take_snapshot treats an empty string as absent
89
+ # (``if base_ref:`` guard), silently falling back to the no-diff full-HEAD
90
+ # path. Catch it before any filesystem work so the operator gets a clear
91
+ # usage error rather than a silent wrong diff.
92
+ if args.base is not None and not args.base:
93
+ print(
94
+ "[syncade] error: --base requires a non-empty ref; got an empty string",
95
+ file=sys.stderr,
96
+ )
97
+ return CLI_USAGE_ERROR
98
+ # --resume "" is always invalid: nargs="?" means bare --resume gives "latest";
99
+ # an explicit empty string is neither a run-id nor a valid alias.
100
+ if args.resume is not None and not args.resume:
101
+ print(
102
+ "[syncade] error: --resume requires a non-empty run-id; "
103
+ "pass --resume alone to resume the latest eligible run",
104
+ file=sys.stderr,
105
+ )
106
+ return CLI_USAGE_ERROR
107
+ # --spec-audit "" is always invalid: the path argument cannot be empty.
108
+ if args.spec_audit is not None and not args.spec_audit:
109
+ print(
110
+ "[syncade] error: --spec-audit requires a non-empty path",
111
+ file=sys.stderr,
112
+ )
113
+ return CLI_USAGE_ERROR
114
+ # --resume reuses the original run's base_ref from run-init.json.
115
+ if args.resume and args.base is not None:
116
+ print(
117
+ "[syncade] error: --resume cannot be combined with --base; "
118
+ "the original run's base_ref is read from run-init.json",
119
+ file=sys.stderr,
120
+ )
121
+ return 2
122
+ # --scope derives the base and cannot combine with an explicit --base or
123
+ # with resume, whose base is read from run-init.json.
124
+ if args.scope is not None and args.base is not None:
125
+ print(
126
+ "[syncade] error: --scope cannot be combined with --base; pass "
127
+ "one or the other (a scope derives the base for you)",
128
+ file=sys.stderr,
129
+ )
130
+ return 2
131
+ if args.scope is not None and args.resume:
132
+ print(
133
+ "[syncade] error: --scope cannot be combined with --resume; the "
134
+ "resumed run's base is read from run-init.json",
135
+ file=sys.stderr,
136
+ )
137
+ return 2
138
+ # --two-dot selects a diff RANGE, and a resumed run has no range left to
139
+ # select: run-init.json records the base OID the original run already
140
+ # resolved, so the resumed diff is taken against that pinned commit under
141
+ # either mode. Accepting the flag here would silently do nothing.
142
+ if args.two_dot and args.resume:
143
+ print(
144
+ "[syncade] error: --two-dot cannot be combined with --resume; the "
145
+ "resumed run diffs against the base OID recorded in run-init.json, "
146
+ "which the original run already resolved",
147
+ file=sys.stderr,
148
+ )
149
+ return 2
150
+ # --two-dot switches the diff mode from three-dot (branch-point) to literal
151
+ # two-dot (base..HEAD), so it is only meaningful when a base is also supplied.
152
+ # Without --base/--scope there is no range to switch, and the flag would be
153
+ # silently accepted but do nothing — reject it up front.
154
+ if args.two_dot and args.base is None and args.scope is None:
155
+ print(
156
+ "[syncade] error: --two-dot requires --base or --scope; it selects the "
157
+ "literal base..HEAD range instead of the default branch-point diff, "
158
+ "which only has meaning when a base is also provided",
159
+ file=sys.stderr,
160
+ )
161
+ return CLI_USAGE_ERROR
162
+ # --force-drift only has meaning during resume.
163
+ if args.force_install and args.install_skill is None:
164
+ print(
165
+ "[syncade] error: --force-install requires --install-skill; it overrides the "
166
+ "installer's refusal to destroy files it did not write, and has no meaning "
167
+ "outside that context",
168
+ file=sys.stderr,
169
+ )
170
+ return CLI_USAGE_ERROR
171
+ if args.force_drift and not args.resume:
172
+ print(
173
+ "[syncade] error: --force-drift requires --resume; it "
174
+ "controls tree-drift behavior during a resumed run and "
175
+ "has no meaning outside that context",
176
+ file=sys.stderr,
177
+ )
178
+ return CLI_USAGE_ERROR
179
+ if args.spec_audit and args.resume:
180
+ print(
181
+ "[syncade] error: --spec-audit cannot be combined with --resume",
182
+ file=sys.stderr,
183
+ )
184
+ return 2
185
+ # --openspec supplies the spec from an OpenSpec proposal folder, so it
186
+ # replaces the positional PR_DOC and cannot combine with diagnostic modes.
187
+ # It remains compatible with --base/--scope because those set the diff base.
188
+ if args.openspec is not None and args.pr_doc:
189
+ print(
190
+ "[syncade] error: --openspec cannot be combined with a PR_DOC "
191
+ "positional argument; pass one or the other (both supply the spec)",
192
+ file=sys.stderr,
193
+ )
194
+ return 2
195
+ for _flag, _used in (
196
+ ("--resume", bool(args.resume)),
197
+ ("--selfcheck", args.selfcheck),
198
+ ("--auth-check", args.auth_check),
199
+ ("--spec-audit", bool(args.spec_audit)),
200
+ ):
201
+ if args.openspec is not None and _used:
202
+ print(
203
+ f"[syncade] error: --openspec cannot be combined with {_flag}",
204
+ file=sys.stderr,
205
+ )
206
+ return 2
207
+ # --draft-spec is its own one-shot mode. It manufactures a spec from a
208
+ # transcript and is mutually exclusive with PR_DOC and every other mode.
209
+ if args.draft_spec:
210
+ for _flag, _used in (
211
+ ("a PR_DOC positional argument", bool(args.pr_doc)),
212
+ ("--resume", bool(args.resume)),
213
+ ("--selfcheck", args.selfcheck),
214
+ ("--auth-check", args.auth_check),
215
+ ("--spec-audit", bool(args.spec_audit)),
216
+ ("--openspec", args.openspec is not None),
217
+ ):
218
+ if _used:
219
+ print(
220
+ f"[syncade] error: --draft-spec cannot be combined with {_flag}",
221
+ file=sys.stderr,
222
+ )
223
+ return 2
224
+ # --transcript only has meaning with --draft-spec.
225
+ if args.transcript is not None and not args.draft_spec:
226
+ print(
227
+ "[syncade] error: --transcript requires --draft-spec; it names the "
228
+ "session transcript the cold drafter reads",
229
+ file=sys.stderr,
230
+ )
231
+ return 2
232
+ # --gc is its own one-shot maintenance mode and is mutually exclusive with
233
+ # PR_DOC and every other mode.
234
+ if args.gc:
235
+ for _flag, _used in (
236
+ ("a PR_DOC positional argument", bool(args.pr_doc)),
237
+ ("--resume", bool(args.resume)),
238
+ ("--selfcheck", args.selfcheck),
239
+ ("--auth-check", args.auth_check),
240
+ ("--spec-audit", bool(args.spec_audit)),
241
+ ("--draft-spec", args.draft_spec),
242
+ ("--openspec", args.openspec is not None),
243
+ ):
244
+ if _used:
245
+ print(
246
+ f"[syncade] error: --gc cannot be combined with {_flag}",
247
+ file=sys.stderr,
248
+ )
249
+ return 2
250
+ else:
251
+ # --gc-keep/--gc-max-age-days/--gc-dry-run are meaningful ONLY with --gc.
252
+ # They are NOT harmless without it: argparse still parses them, and the
253
+ # invocation would otherwise fall through to the normal review path
254
+ # (silently ignoring the knob — and, with a PR_DOC, even provisioning
255
+ # reviewers). Reject them up front.
256
+ for _flag, _used in (
257
+ ("--gc-keep", args.gc_keep is not None),
258
+ ("--gc-max-age-days", args.gc_max_age_days is not None),
259
+ ("--gc-dry-run", args.gc_dry_run),
260
+ ):
261
+ if _used:
262
+ print(
263
+ f"[syncade] error: {_flag} is meaningful only with --gc",
264
+ file=sys.stderr,
265
+ )
266
+ return 2
267
+ # --budget-tokens/--budget-usd are loop-only knobs; reject them for every one-shot mode
268
+ # that does not run the review loop (metrics, doctor, gc, install-skill, auth-check,
269
+ # selfcheck, spec-audit, draft-spec). Silently ignoring them would mislead the operator
270
+ # into thinking a budget was enforced when it was not.
271
+ _is_loop_mode = bool(args.pr_doc) or (args.openspec is not None) or bool(args.resume)
272
+ _budget_used = args.budget_tokens is not None or args.budget_usd is not None
273
+ if _budget_used and not _is_loop_mode:
274
+ _which = "--budget-tokens" if args.budget_tokens is not None else "--budget-usd"
275
+ print(
276
+ f"[syncade] error: {_which} is meaningful only with a review loop "
277
+ f"(a PR_DOC, --openspec, or --resume). Omit it for one-shot modes "
278
+ f"(--metrics, --doctor, --gc, --auth-check, --selfcheck, --spec-audit, "
279
+ f"--draft-spec, --install-skill).",
280
+ file=sys.stderr,
281
+ )
282
+ return 2
283
+
284
+ # --reviewer-model/thinking/timeout override the FRESH review loop's roster; --resume reuses the
285
+ # roster from the current .syncade/config.toml (per-invocation CLI overrides are not rehydrated
286
+ # from the run snapshot — see run_init), and the one-shot modes run no reviewers. Reject rather
287
+ # than silently ignore — the same "meaningful only in context" discipline as --budget-* above.
288
+ _reviewer_used = bool(args.reviewer_model or args.reviewer_thinking or args.reviewer_timeout)
289
+ _reviewer_applies = bool(args.pr_doc) or (args.openspec is not None)
290
+ if _reviewer_used and not _reviewer_applies:
291
+ print(
292
+ "[syncade] error: --reviewer-model / --reviewer-thinking / --reviewer-timeout are "
293
+ "meaningful only for a fresh review loop (a PR_DOC or --openspec). Omit them for "
294
+ "--resume (it reuses the roster from the current .syncade/config.toml; put a panel "
295
+ "override there to keep it across a resume) and the one-shot modes.",
296
+ file=sys.stderr,
297
+ )
298
+ return 2
299
+ # --metrics is a read-only maintenance/report mode; mutually exclusive with
300
+ # PR_DOC and every other one-shot mode.
301
+ if args.metrics:
302
+ for _flag, _used in (
303
+ ("a PR_DOC positional argument", bool(args.pr_doc)),
304
+ ("--resume", bool(args.resume)),
305
+ ("--selfcheck", args.selfcheck),
306
+ ("--auth-check", args.auth_check),
307
+ ("--spec-audit", bool(args.spec_audit)),
308
+ ("--draft-spec", args.draft_spec),
309
+ ("--openspec", args.openspec is not None),
310
+ ("--gc", args.gc),
311
+ ):
312
+ if _used:
313
+ print(
314
+ f"[syncade] error: --metrics cannot be combined with {_flag}",
315
+ file=sys.stderr,
316
+ )
317
+ return 2
318
+ elif args.metrics_last is not None:
319
+ print(
320
+ "[syncade] error: --metrics-last is meaningful only with --metrics",
321
+ file=sys.stderr,
322
+ )
323
+ return 2
324
+ # --doctor is a read-only one-shot preflight; mutually exclusive with PR_DOC and every
325
+ # other mode. NOT with --base/--scope: doctor previews the diff those select.
326
+ if args.doctor:
327
+ for _flag, _used in (
328
+ ("a PR_DOC positional argument", bool(args.pr_doc)),
329
+ ("--resume", bool(args.resume)),
330
+ ("--selfcheck", args.selfcheck),
331
+ ("--auth-check", args.auth_check),
332
+ ("--spec-audit", bool(args.spec_audit)),
333
+ ("--draft-spec", args.draft_spec),
334
+ ("--openspec", args.openspec is not None),
335
+ ("--gc", args.gc),
336
+ ("--metrics", args.metrics),
337
+ ("--install-skill", args.install_skill is not None),
338
+ ("--config", args.config is not None),
339
+ ):
340
+ if _used:
341
+ print(
342
+ f"[syncade] error: --doctor cannot be combined with {_flag}",
343
+ file=sys.stderr,
344
+ )
345
+ return 2
346
+ elif args.quick:
347
+ print(
348
+ "[syncade] error: --quick is meaningful only with --doctor",
349
+ file=sys.stderr,
350
+ )
351
+ return 2
352
+ # --install-skill is a file-copy one-shot mode; it renders no reviewer diff,
353
+ # so --base / --scope / --two-dot are meaningless and would be silently ignored.
354
+ if args.install_skill is not None:
355
+ rejection = _reject_diff_base_flags(args, "--install-skill")
356
+ if rejection is not None:
357
+ return rejection
358
+ # ...and it is mutually exclusive with every OTHER mode, which it was not.
359
+ # Measured: all 9 pairings — including `syncade <brief> --install-skill claude` —
360
+ # were ACCEPTED, exit 0, with the other intent silently dropped. An operator asking
361
+ # for a review got a skill install and a success code. Same shape as --doctor's list
362
+ # above; `test_mode_pairs_are_all_rejected` derives the matrix from the parser, so a
363
+ # mode added later is covered without editing either list.
364
+ for _flag, _used in (
365
+ ("a PR_DOC positional argument", bool(args.pr_doc)),
366
+ ("--resume", bool(args.resume)),
367
+ ("--selfcheck", args.selfcheck),
368
+ ("--auth-check", args.auth_check),
369
+ ("--spec-audit", bool(args.spec_audit)),
370
+ ("--draft-spec", args.draft_spec),
371
+ ("--openspec", args.openspec is not None),
372
+ ("--gc", args.gc),
373
+ ("--metrics", args.metrics),
374
+ # --config is deliberately ABSENT: _reject_config_mode_conflicts below already
375
+ # covers that pair and words it from --config's side. Listing it here would be
376
+ # duplicate coverage that only changes which flag the message names first.
377
+ # Review-loop-only flags: accepted by the parser but meaningless in installer mode.
378
+ # Silently ignoring them lets an operator supply real review intent that is lost.
379
+ ("--force-dirty", getattr(args, "force_dirty", False)),
380
+ ("--allow-default-branch", getattr(args, "allow_default_branch", False)),
381
+ ("--timeout", args.timeout is not None),
382
+ ("--preset", args.preset is not None),
383
+ ("--max-rounds", args.max_rounds is not None),
384
+ ("--worktree-base", args.worktree_base is not None),
385
+ ):
386
+ if _used:
387
+ print(
388
+ f"[syncade] error: --install-skill cannot be combined with {_flag}",
389
+ file=sys.stderr,
390
+ )
391
+ return 2
392
+ # --allow-auto-init controls whether the review entry path may git-init a populated
393
+ # directory; it is meaningless (and silently ignored) in every other mode.
394
+ _allow_auto_init_applies = bool(args.pr_doc) or (args.openspec is not None)
395
+ if args.allow_auto_init and not _allow_auto_init_applies:
396
+ print(
397
+ "[syncade] error: --allow-auto-init is meaningful only with a review invocation "
398
+ "(a PR_DOC or --openspec); it has no effect in one-shot modes",
399
+ file=sys.stderr,
400
+ )
401
+ return CLI_USAGE_ERROR
402
+ _config_conflict = _reject_config_mode_conflicts(args)
403
+ if _config_conflict is not None:
404
+ return _config_conflict
405
+ if (
406
+ not args.pr_doc
407
+ and args.openspec is None
408
+ and not args.draft_spec
409
+ and not args.resume
410
+ and not args.selfcheck
411
+ and not args.auth_check
412
+ and not args.spec_audit
413
+ and not args.gc
414
+ and not args.metrics
415
+ and not args.doctor
416
+ and args.install_skill is None
417
+ and args.config is None
418
+ ):
419
+ parser.print_help(sys.stderr)
420
+ return 2
421
+ return None