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
syncade/cli/parser.py ADDED
@@ -0,0 +1,450 @@
1
+ """Argparse parser construction + CLI-boundary ``type`` validators.
2
+
3
+ ``build_parser`` is the single source of the CLI's argument shape. The two
4
+ ``type=`` validators (`_positive_float`, `_max_rounds`) mirror the corresponding
5
+ config-schema bounds at the CLI boundary.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+
12
+ from syncade import __version__
13
+ from syncade.base_resolution import VALID_SCOPES
14
+ from syncade.presets import PRESET_NAMES
15
+
16
+ from .parser_types import ( # noqa: E402
17
+ _max_rounds,
18
+ _non_negative_int,
19
+ _positive_float,
20
+ _positive_int,
21
+ _positive_usd,
22
+ _reviewer_override,
23
+ )
24
+
25
+
26
+ def build_parser() -> argparse.ArgumentParser:
27
+ """Construct the argparse parser. Factored out for testability."""
28
+ parser = argparse.ArgumentParser(
29
+ prog="syncade",
30
+ description=(
31
+ "External blind multi-judge review orchestrator "
32
+ "for AI-assisted coding. Invoked from inside Claude Code via "
33
+ "the syncade skill."
34
+ ),
35
+ )
36
+ parser.add_argument(
37
+ "pr_doc",
38
+ nargs="?",
39
+ metavar="PR_DOC",
40
+ help="Path to the PR doc that describes the work to review.",
41
+ )
42
+ parser.add_argument(
43
+ "--resume",
44
+ nargs="?",
45
+ const="latest",
46
+ default=None,
47
+ metavar="RUN_ID",
48
+ help="Resume an aborted, interrupted, or decision-needed run. Pass a "
49
+ "specific run-id (the timestamped directory name under "
50
+ ".syncade/runs/), the literal string 'latest', or pass "
51
+ "--resume alone (equivalent to --resume latest). Eligibility: "
52
+ "the original run was aborted by an environment failure "
53
+ "(exit 40/60/70), stopped at a budget ceiling or a provider usage "
54
+ "limit (exit 25), needs an "
55
+ "operator decision after a producer escalation (exit 10), OR was "
56
+ "interrupted before the loop terminator wrote loop-manifest.json. "
57
+ "Mutually exclusive with PR_DOC, "
58
+ "--selfcheck, --auth-check, --spec-audit, --draft-spec, --base.",
59
+ )
60
+ parser.add_argument(
61
+ "--force-drift",
62
+ action="store_true",
63
+ help="With --resume only: accept tree drift between the "
64
+ "original run's expected snapshot SHA and the operator's "
65
+ "current HEAD. The resumed round will snapshot from current "
66
+ "HEAD; cross-round context from prior rounds may reference "
67
+ "findings against a different SHA than the new tree. "
68
+ "Without --resume, passing --force-drift is a CLI error "
69
+ "(exit 2).",
70
+ )
71
+ parser.add_argument(
72
+ "--spec-audit",
73
+ metavar="PR_DOC",
74
+ default=None,
75
+ help="Audit the PR brief at PR_DOC for spec-level issues (unverified "
76
+ "claims, internal contradictions, ambiguous acceptance criteria, "
77
+ "missing references, scope drift, missing structural sections). "
78
+ "Advisory-only for v1 — exits 0 on a clean brief, 10 when blocker-"
79
+ "severity findings are present, 40 on subprocess error, 50 on config "
80
+ "load error, 60 on path/worktree error, 70 on parse failure. "
81
+ "Mutually exclusive with PR_DOC positional, --selfcheck, "
82
+ "--auth-check, --draft-spec, --resume.",
83
+ )
84
+ parser.add_argument(
85
+ "--repo-root",
86
+ metavar="PATH",
87
+ default=None,
88
+ help="Directory to run from (default: current working directory). "
89
+ "Used to locate .syncade/config.toml and as the starting hint for "
90
+ "git repo-root discovery — syncade writes run artifacts to the "
91
+ "actual repo root (git rev-parse --show-toplevel) regardless of "
92
+ "which subdirectory this points at. Relative PR_DOC, --spec-audit, "
93
+ "and --transcript paths resolve against the discovered repo root first, "
94
+ "then cwd as a compatibility fallback.",
95
+ )
96
+ parser.add_argument(
97
+ "--preset",
98
+ choices=list(PRESET_NAMES),
99
+ default=None,
100
+ help="Start from a bundled config preset: `cheap` (single pass, no "
101
+ "producer loop), `balanced` (the shipped defaults), or `thorough` "
102
+ "(full rounds + double the per-subprocess timeout). Your "
103
+ ".syncade/config.toml still layers on top (user file wins). Presets "
104
+ "vary only rounds/timeout — never the reviewer model or effort tier.",
105
+ )
106
+ parser.add_argument(
107
+ "--worktree-base",
108
+ metavar="PATH",
109
+ default=None,
110
+ help="Base directory under which per-run git worktrees are created "
111
+ "(overrides [worktree_base] in config; default /tmp/syncade). Use a "
112
+ "fast local disk when /tmp is small or slow. Applies to review runs, "
113
+ "--gc, --resume, and --doctor's writability preview.",
114
+ )
115
+ parser.add_argument(
116
+ "--base",
117
+ metavar="REF",
118
+ default=None,
119
+ help="Git ref to render the reviewer's diff against (e.g. "
120
+ "`main`, `HEAD~3`, a tag or commit SHA). When omitted, "
121
+ "reviewers run against the full HEAD state with no diff "
122
+ "included in the prompt — the model decides what to review "
123
+ "from the repo contents alone.",
124
+ )
125
+ parser.add_argument(
126
+ "--scope",
127
+ metavar="SCOPE",
128
+ choices=list(VALID_SCOPES),
129
+ default=None,
130
+ help="Derive the diff base from scope instead of an explicit --base: "
131
+ "`everything` (branch point off the default branch), `local` (your "
132
+ "local-ahead commits vs the branch's upstream), `since-last-review` "
133
+ "(the recorded last-reviewed SHA for this branch). Mutually exclusive "
134
+ "with --base and --resume.",
135
+ )
136
+ parser.add_argument(
137
+ "--openspec",
138
+ nargs="?",
139
+ const="",
140
+ default=None,
141
+ metavar="CHANGE_ID",
142
+ help="Review an existing OpenSpec proposal folder instead of a PR_DOC: "
143
+ "assemble openspec/changes/<CHANGE_ID>/ (proposal + spec deltas) into "
144
+ "the spec the loop reviews against. Pass a proposal-id, or pass --openspec "
145
+ "alone to auto-resolve when exactly one active proposal exists (else it "
146
+ "lists them and asks). Reads the markdown directly — no openspec binary "
147
+ "required. Mutually exclusive with PR_DOC, --selfcheck, --auth-check, "
148
+ "--spec-audit, --draft-spec, --resume. --base/--scope still set the diff base.",
149
+ )
150
+ parser.add_argument(
151
+ "--timeout",
152
+ metavar="SECONDS",
153
+ type=_positive_float,
154
+ default=None,
155
+ help="Per-subprocess timeout in seconds (must be > 0) — the fallback "
156
+ "wall-clock cap for every leg (reviewers, judge, test, checks, producer). "
157
+ "Overrides `[loop] timeout_seconds` in .syncade/config.toml (default 1800, "
158
+ "i.e. 30 minutes).",
159
+ )
160
+ parser.add_argument(
161
+ "--max-rounds",
162
+ metavar="INT",
163
+ type=_max_rounds,
164
+ default=None,
165
+ help="Per-run maximum rounds of (reviewers → synthesizer → "
166
+ "optional test → producer-if-NO-SHIP). Must be in [1, 10]. "
167
+ "Overrides `[loop] max_rounds` in .syncade/config.toml. "
168
+ "Default 5. Set to 1 for single-pass review without producer "
169
+ "code changes.",
170
+ )
171
+ parser.add_argument(
172
+ "--budget-tokens",
173
+ metavar="N",
174
+ type=_positive_int,
175
+ default=None,
176
+ help="Per-run total-token ceiling. When the running tally of every actor's usage "
177
+ "crosses it at a phase boundary, the loop aborts gracefully (budget_exceeded). This "
178
+ "is the TIGHTEST bound — exact when all actors report usage, a lower bound only if an "
179
+ "actor reports none. Overrides `[loop] budget_tokens`. "
180
+ "Default: inherits `[loop] budget_tokens` (50,000,000 by default; pass 0 to disable).",
181
+ )
182
+ parser.add_argument(
183
+ "--budget-usd",
184
+ metavar="USD",
185
+ type=_positive_usd,
186
+ default=None,
187
+ help="Per-run cost ceiling on the API-EQUIVALENT valuation (NOT billed money — the "
188
+ "marginal dollar is $0 on a subscription), matching what `syncade --doctor` previews. "
189
+ "A LOWER-BOUND tally: actors with incomplete cost are uncounted, so a dollar-budgeted "
190
+ "run can overshoot — use --budget-tokens for the tighter cap. Overrides `[loop] "
191
+ "budget_usd`. Default: no cost ceiling (pass 0 to disable an inherited ceiling).",
192
+ )
193
+ parser.add_argument(
194
+ "--reviewer-model",
195
+ action="append",
196
+ metavar="NAME=MODEL",
197
+ type=_reviewer_override,
198
+ default=None,
199
+ help="Override ONE reviewer's model for this run: NAME is a reviewer's `name` in "
200
+ ".syncade/config.toml. Repeatable (once per reviewer). An unknown NAME fails exit 50 "
201
+ "naming the configured reviewers. E.g. --reviewer-model codex-reviewer-adv=gpt-5.6-sol.",
202
+ )
203
+ parser.add_argument(
204
+ "--reviewer-thinking",
205
+ action="append",
206
+ metavar="NAME=TIER",
207
+ type=_reviewer_override,
208
+ default=None,
209
+ help="Override ONE reviewer's thinking/effort tier for this run (the tiers accepted by "
210
+ "`[reviewers] thinking`). NAME is a reviewer's `name`; repeatable. Bad TIER or unknown "
211
+ "NAME fails exit 50.",
212
+ )
213
+ parser.add_argument(
214
+ "--reviewer-timeout",
215
+ action="append",
216
+ metavar="NAME=SECONDS",
217
+ type=_reviewer_override,
218
+ default=None,
219
+ help="Override ONE reviewer's wall-clock timeout for this run (seconds, > 0). NAME is a "
220
+ "reviewer's `name`; repeatable. Overrides that reviewer's `timeout_seconds` (else the "
221
+ "loop timeout). Bad value or unknown NAME fails exit 50.",
222
+ )
223
+ parser.add_argument(
224
+ "--two-dot",
225
+ action="store_true",
226
+ help="Diff the literal <base>..HEAD range instead of from the branch "
227
+ "point (the default, equivalent to git's <base>...HEAD). Use when you "
228
+ "want everything between the two commits. WARNING: if your branch is "
229
+ "behind its base, commits that landed on the base but not on your "
230
+ "branch appear as DELETIONS in the reviewed diff, and the producer is "
231
+ "handed those phantom deletions as work.",
232
+ )
233
+ parser.add_argument(
234
+ "--force-dirty",
235
+ action="store_true",
236
+ help="Allow loop mode (max_rounds > 1) to start even when "
237
+ "the working tree has tracked-modified files. WARNING: the "
238
+ "producer will commit on top of the operator's WIP, which "
239
+ "may interleave with their uncommitted edits in confusing "
240
+ "ways. Use only when you understand the consequences. The "
241
+ "same refusal applies to a resumed loop-mode run (--resume), "
242
+ "where --force-dirty is likewise the only escape. "
243
+ "max_rounds=1 (single-pass) bypasses this guard entirely.",
244
+ )
245
+ parser.add_argument(
246
+ "--allow-default-branch",
247
+ action="store_true",
248
+ help="Allow loop mode (max_rounds > 1) to run while HEAD is the repo's "
249
+ "default branch. By default syncade REFUSES this, because the producer "
250
+ "fast-forwards the current branch and would land commits directly on "
251
+ "your default branch. Pass this to commit there deliberately. "
252
+ "Single-pass (max_rounds=1) commits nothing and bypasses the guard.",
253
+ )
254
+ parser.add_argument(
255
+ "--allow-auto-init",
256
+ action="store_true",
257
+ help="Let syncade `git init` + baseline-commit a directory that ALREADY HAS "
258
+ "FILES. Refused by default: that commit captures whatever it finds, and the "
259
+ "exclusions are defeatable (a key under an unknown name; your own `!` negation). "
260
+ "ALSO: if the run is then refused, the repository is LEFT BEHIND — syncade cannot "
261
+ "tell which files are its own in a directory that was not empty, so it deletes "
262
+ "nothing. Remove it by hand if you do not want it. An empty directory needs no "
263
+ "flag: a refused run there removes the repository it created. It leaves the starter "
264
+ "`.gitignore` (syncade never deletes bytes you could have edited) and any "
265
+ "`.syncade/` run record (run history is never deleted).",
266
+ )
267
+ parser.add_argument(
268
+ "--selfcheck",
269
+ action="store_true",
270
+ help="Verify the configured producer can headless-commit. Provisions "
271
+ "a tmp_path git repo + stub findings.md, runs the producer once, "
272
+ "asserts HEAD moved + the requested edit is present. Useful after a "
273
+ "claude/codex CLI update to detect sandbox-semantics drift before a "
274
+ "real loop hits it. Mutually exclusive with PR_DOC, --auth-check, "
275
+ "--spec-audit, --draft-spec, --resume, --openspec, --gc.",
276
+ )
277
+ parser.add_argument(
278
+ "--auth-check",
279
+ action="store_true",
280
+ help="Verify every configured credential can authenticate. Faster "
281
+ "than --selfcheck (~5-10s total vs ~30s/producer); covers the "
282
+ "common 'did my OAuth token rotate?' diagnostic. Mutually "
283
+ "exclusive with PR_DOC, --selfcheck, --resume, --spec-audit, --draft-spec.",
284
+ )
285
+ parser.add_argument(
286
+ "--doctor",
287
+ action="store_true",
288
+ help="Preflight a run without dispatching one. Read-only: prints a green/red "
289
+ "table of readiness checks (resolved config; each configured provider's CLI on "
290
+ "PATH; worktree root + disk; branch/dirty-tree refusal preview; run-plan + cost "
291
+ "preview; credential auth; producer headless-commit) and exits 0 iff every check is "
292
+ "green, else 60. Mutates nothing. The auth + producer-commit legs make real provider "
293
+ "calls (~30s) and are skipped when a cheap check already reds — pass --quick to skip "
294
+ "them outright. Unlike the other one-shot modes it ACCEPTS --base/--scope (it previews "
295
+ "that diff). Mutually exclusive with PR_DOC, --selfcheck, --auth-check, --spec-audit, "
296
+ "--draft-spec, --resume, --openspec, --gc, --metrics.",
297
+ )
298
+ # --quick / --quiet share the prefix `--qui`, so that bare abbreviation is ambiguous
299
+ # (argparse errors). Both FULL spellings parse fine and that is what everything uses; the
300
+ # tiny abbreviation collision is the accepted cost of matching the brief's `--quick`.
301
+ parser.add_argument(
302
+ "--quick",
303
+ action="store_true",
304
+ help="With --doctor only: skip the two LIVE legs (the auth credential probe and the "
305
+ "producer headless-commit smoke), which make real provider calls and take ~30s. "
306
+ "Leaves the instant config / PATH / worktree / branch / plan / cost checks. The "
307
+ "skipped legs are reported as 'skipped', never as passed.",
308
+ )
309
+ parser.add_argument(
310
+ "--install-skill",
311
+ nargs="?",
312
+ const="all",
313
+ choices=["all", "claude", "codex"],
314
+ default=None,
315
+ help="Install the bundled /syncade Agent Skill into your harness skill "
316
+ "directories (~/.claude/skills and $CODEX_HOME/skills), then exit. Works from a "
317
+ "pip install (no checkout needed). Default target 'all'; pass 'claude' or 'codex' "
318
+ "to install just one. REFUSES (exit 60) if the destination holds files syncade did "
319
+ "not write — an edited SKILL.md, or anything else you put there — naming each one "
320
+ "instead of destroying it.",
321
+ )
322
+ parser.add_argument(
323
+ "--force-install",
324
+ action="store_true",
325
+ help="With --install-skill only: overwrite a destination that holds files syncade "
326
+ "did not write. The casualties are still listed; this is informed consent, not a "
327
+ "safe mode. Without --install-skill, passing it is a CLI error (exit 2).",
328
+ )
329
+ parser.add_argument(
330
+ "--config",
331
+ nargs="*",
332
+ default=None,
333
+ metavar="ARG",
334
+ help="Inspect or edit configuration, then exit. `--config` (no verb) opens an interactive "
335
+ "arrow-key menu. `--config list` shows the effective settings and which layer "
336
+ "(default/global/repo) set each; `--config get <key>` prints one value. "
337
+ "`--config set <key> <value>` writes the global ~/.syncade/config.toml (or the "
338
+ "repo's with --repo).",
339
+ )
340
+ parser.add_argument(
341
+ "--repo",
342
+ action="store_true",
343
+ help="With `--config set`, target the current repo's .syncade/config.toml instead of the "
344
+ "global ~/.syncade/config.toml.",
345
+ )
346
+ parser.add_argument(
347
+ "--all",
348
+ action="store_true",
349
+ help="With `--config list`, show the FULL settable surface — every actor/section field, "
350
+ "including advanced retry/gc/checks and CLI-only knobs — not just the curated common set.",
351
+ )
352
+ parser.add_argument(
353
+ "--draft-spec",
354
+ action="store_true",
355
+ help="Manufacture a ratifiable spec from a session transcript. Requires "
356
+ "--transcript. Reads the transcript for INTENT + the diff "
357
+ "(via --base/--scope, optional), runs a cold drafter, and writes an "
358
+ "OpenSpec-shaped .syncade/draft-spec-<session>[-N].md with an "
359
+ "'Assumptions to confirm' section. Advisory — review/edit it, then run "
360
+ "`syncade <file>`. "
361
+ "Mutually exclusive with PR_DOC, --selfcheck, --auth-check, --spec-audit, "
362
+ "--resume, --openspec.",
363
+ )
364
+ parser.add_argument(
365
+ "--transcript",
366
+ metavar="PATH",
367
+ default=None,
368
+ help="Path to a Claude Code session JSONL transcript. Required with "
369
+ "--draft-spec; the cold drafter reads it for intent.",
370
+ )
371
+ parser.add_argument(
372
+ "--gc",
373
+ action="store_true",
374
+ help="Run a one-shot maintenance pass. Retention is TWO-TIER: run "
375
+ "history is kept FOREVER (loop-manifest.json, findings.md, run-init.json, "
376
+ "round manifests, summaries) and only the bulky subprocess transcripts "
377
+ "(round-*/*.stdout, *.stderr - 90%% of the corpus) are pruned, for runs "
378
+ "beyond --gc-keep and any --gc-max-age-days floor. NO run directory is "
379
+ "ever deleted: .syncade/metrics.db is a derived view over .syncade/runs/, "
380
+ "so deleting a run would destroy its history the next time that view "
381
+ "rebuilds. Also removes matching <worktree_base>/<run-id>/ worktree leftovers "
382
+ "whose directory identity still matches the GC plan, and safely reaps "
383
+ "orphaned reviewer/producer subprocesses whose working dir is INSIDE a "
384
+ "worktree being removed. Runs also auto-prune their transcripts at the "
385
+ "start of every fresh loop, so --gc is for worktree/process cleanup and "
386
+ "one-off maintenance rather than routine disk hygiene. Resume-eligible "
387
+ "runs are ALWAYS protected (they keep even their transcripts); non-run "
388
+ "state (config.toml, last-reviewed.json, draft-spec-*.md) is never "
389
+ "touched. "
390
+ "Mutually exclusive with PR_DOC, --selfcheck, --auth-check, "
391
+ "--spec-audit, --draft-spec, --resume, --openspec.",
392
+ )
393
+ parser.add_argument(
394
+ "--metrics",
395
+ action="store_true",
396
+ help="Aggregate the .syncade/runs/ artifact corpus into "
397
+ ".syncade/metrics.db (read-only over the artifacts) and print a "
398
+ "cumulative report: run count, ship-rate, blockers/minors/nits, rounds, "
399
+ "handoffs, and per-model reviewer stats. Rebuildable + idempotent. "
400
+ "Mutually exclusive with PR_DOC, --selfcheck, --auth-check, --spec-audit, "
401
+ "--draft-spec, --resume, --openspec, --gc.",
402
+ )
403
+ parser.add_argument(
404
+ "--metrics-last",
405
+ metavar="N",
406
+ type=_non_negative_int,
407
+ default=None,
408
+ help="With --metrics only: also print a billed/API-equivalent breakdown scoped to the N "
409
+ "most recent runs (e.g. --metrics-last 20).",
410
+ )
411
+ parser.add_argument(
412
+ "--gc-keep",
413
+ metavar="N",
414
+ type=_non_negative_int,
415
+ default=None,
416
+ help="With --gc only: number of most-recent (non-protected) runs whose "
417
+ "TRANSCRIPTS are kept; older runs have their transcripts pruned (their "
418
+ "history is kept either way). Default 20. (Passing this WITHOUT --gc is "
419
+ "an error — it is meaningful only with --gc.)",
420
+ )
421
+ parser.add_argument(
422
+ "--gc-max-age-days",
423
+ metavar="D",
424
+ type=_non_negative_int,
425
+ default=None,
426
+ help="With --gc only: optional age floor in days. 0 (default) "
427
+ "disables the age gate (prune transcripts for every candidate beyond "
428
+ "--gc-keep); D>0 only prunes a beyond-keep candidate that is ALSO older "
429
+ "than D days. (Passing this WITHOUT --gc is an error.)",
430
+ )
431
+ parser.add_argument(
432
+ "--gc-dry-run",
433
+ action="store_true",
434
+ help="With --gc only: report exactly what would be pruned/removed/reaped "
435
+ "(including the bytes that would be freed) and modify NOTHING on disk "
436
+ "(prune nothing, delete nothing, kill nothing).",
437
+ )
438
+ parser.add_argument(
439
+ "--quiet",
440
+ action="store_true",
441
+ help="Suppress phase-level progress output; print only the "
442
+ "final summary line, plus exit-70 artifact pointers and any "
443
+ "error messages.",
444
+ )
445
+ parser.add_argument(
446
+ "--version",
447
+ action="version",
448
+ version=f"syncade {__version__}",
449
+ )
450
+ return parser
@@ -0,0 +1,137 @@
1
+ """argparse ``type=`` coercers for syncade's numeric and structured CLI values.
2
+
3
+ Split out of ``parser.py`` (PR-h-04 item B), which sat exactly AT the 500-LOC cap — so
4
+ adding any flag broke the gate, and trimming help text to fit would have been squeezing
5
+ rather than engineering. The seam is real: these answer *how do I coerce and validate one
6
+ CLI scalar*, while ``parser.py`` answers *what flags exist*. They share no state and only
7
+ these raise ``ArgumentTypeError``.
8
+
9
+ Strictness is deliberate and load-bearing (PR-v2-9): a quoted number, a float where an int
10
+ is required, or a boolean must FAIL rather than silently coerce, because a config value
11
+ that quietly becomes something else is how a run ends up with settings nobody chose.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import math
18
+
19
+
20
+ def _positive_finite_float(noun: str, value: str, *, allow_zero: bool = False) -> float:
21
+ """Shared body for the finite float ``type`` validators. ``float()`` parses ``nan`` /
22
+ ``inf``, neither of which is a usable bound, so non-finite values are rejected too;
23
+ ``noun`` makes the message fit the flag (seconds vs dollars). ``allow_zero`` admits 0 for
24
+ the budget flags, where it is the no-ceiling opt-out rather than an absurd bound."""
25
+ try:
26
+ parsed = float(value)
27
+ except ValueError:
28
+ raise argparse.ArgumentTypeError(f"invalid float value: {value!r}") from None
29
+ floor_ok = parsed >= 0 if allow_zero else parsed > 0
30
+ if not math.isfinite(parsed) or not floor_ok:
31
+ bound = "non-negative" if allow_zero else "positive"
32
+ raise argparse.ArgumentTypeError(f"must be a {bound}, finite {noun} (got {value!r})")
33
+ return parsed
34
+
35
+
36
+ def _positive_float(value: str) -> float:
37
+ """argparse ``type`` for ``--timeout``: a strictly-positive, finite number of seconds.
38
+
39
+ Mirrors :class:`~syncade.config.LoopConfig`'s ``gt=0`` validation at the CLI boundary, so
40
+ ``--timeout 0`` / ``--timeout -1`` are rejected up front (exit 2) rather than reaching the
41
+ orchestrator and getting every reviewer SIGKILL'd instantly with a nonsensical timeout."""
42
+ return _positive_finite_float("number of seconds", value)
43
+
44
+
45
+ def _positive_usd(value: str) -> float:
46
+ """argparse ``type`` for ``--budget-usd``: a non-negative, finite dollar amount, 0 = OFF.
47
+
48
+ Mirrors ``budget_usd``'s ``ge=0`` + isfinite bound. 0 is the no-ceiling opt-out, symmetric
49
+ with ``--budget-tokens 0`` (PR-h-field-06) — and necessary rather than tidy: omitting the
50
+ key does NOT remove a ceiling, because --resume re-inherits one the current config leaves
51
+ unset. Only an explicit value says "I decided this".
52
+ """
53
+ parsed = _positive_finite_float("dollar amount", value, allow_zero=True)
54
+ return parsed
55
+
56
+
57
+ def _non_negative_int(value: str) -> int:
58
+ """argparse ``type`` for ``--gc-keep`` / ``--gc-max-age-days``: a
59
+ non-negative integer.
60
+
61
+ GC is a destructive maintenance command, so a negative count/age is a
62
+ nonsensical, dangerous input (a negative ``--gc-keep`` would slice from the
63
+ end and prune the transcripts of the NEWEST runs). Reject it up front
64
+ (exit 2) rather than letting Python slicing semantics quietly do the wrong
65
+ thing.
66
+ """
67
+ try:
68
+ n = int(value)
69
+ except ValueError:
70
+ raise argparse.ArgumentTypeError(f"must be an integer (got {value!r})") from None
71
+ if n < 0:
72
+ raise argparse.ArgumentTypeError(f"must be >= 0 (got {value!r})")
73
+ return n
74
+
75
+
76
+ def _positive_int(value: str) -> int:
77
+ """argparse ``type`` for ``--budget-tokens``: a non-negative integer, where 0 means OFF.
78
+
79
+ Mirrors :class:`~syncade.config.LoopConfig`'s ``ge=0`` bound at the CLI boundary.
80
+ ``budget_tokens`` has a DEFAULT ceiling since PR-h-field-06, so ``0`` had to stop meaning
81
+ "invalid" and start meaning "no ceiling" — it is the only way left to express unlimited
82
+ once an omitted value means the default. A zero ceiling would otherwise abort before the
83
+ first phase dispatched, which is nonsensical, so the value is free to carry the opposite
84
+ sense. Negatives are still rejected up front (exit 2) with argparse's legible message
85
+ rather than the config's exit 50.
86
+ """
87
+ try:
88
+ n = int(value)
89
+ except ValueError:
90
+ raise argparse.ArgumentTypeError(f"must be an integer (got {value!r})") from None
91
+ if n < 0:
92
+ raise argparse.ArgumentTypeError(
93
+ f"--budget-tokens must be >= 0, 0 = no ceiling (got {value!r})"
94
+ )
95
+ return n
96
+
97
+
98
+ def _max_rounds(value: str) -> int:
99
+ """argparse ``type`` for ``--max-rounds``: an integer in
100
+ ``[1, 10]``.
101
+
102
+ Mirrors :class:`~syncade.config.LoopConfig`'s ``ge=1, le=10``
103
+ bounds at the CLI boundary so ``--max-rounds 0`` /
104
+ ``--max-rounds 11`` are rejected up front (exit 2) rather than
105
+ surfacing the same error as a config-loaded value (exit 50). The
106
+ distinction matters because the CLI flag is the immediate cause
107
+ of the rejection — argparse's exit-2 path with the type name
108
+ in the error message is more legible than the schema's
109
+ ``ValidationError`` rendered through ``ConfigError``.
110
+
111
+ The ceiling was raised from 3 to 10 (PR-v2-31); it is a
112
+ typo-guard, not the runaway-protection mechanism —
113
+ budget_tokens/budget_usd and the per-subprocess timeout are.
114
+ """
115
+ try:
116
+ rounds = int(value)
117
+ except ValueError:
118
+ raise argparse.ArgumentTypeError(
119
+ f"--max-rounds must be an integer (got {value!r})"
120
+ ) from None
121
+ if rounds < 1 or rounds > 10:
122
+ raise argparse.ArgumentTypeError(f"--max-rounds must be in [1, 10] (got {value!r})")
123
+ return rounds
124
+
125
+
126
+ def _reviewer_override(value: str) -> tuple[str, str]:
127
+ """argparse ``type`` for the name-qualified ``--reviewer-*`` flags: parse ``NAME=VALUE`` into
128
+ ``(name, value)``, splitting on the FIRST ``=`` so a value may itself contain ``=``. A missing
129
+ ``=`` or empty name is a malformed FLAG → exit 2 here; whether the NAME exists and the VALUE is
130
+ valid for the knob is a config-level question resolved later against the loaded reviewers
131
+ (unknown name / bad value → exit 50), so this stays deliberately minimal (PR-v2-9)."""
132
+ name, sep, val = value.partition("=")
133
+ if not sep or not name or not val:
134
+ raise argparse.ArgumentTypeError(
135
+ f"expected NAME=VALUE (e.g. codex-reviewer=gpt-5.5), got {value!r}"
136
+ )
137
+ return name, val
syncade/cli/paths.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ def resolve_repo_relative_input_path(raw_path: str, *, repo_root: Path, label: str) -> Path:
8
+ path = Path(raw_path).expanduser()
9
+ if path.is_absolute():
10
+ return path
11
+
12
+ repo_path = repo_root / path
13
+ if repo_path.exists():
14
+ return repo_path
15
+
16
+ cwd_path = Path.cwd() / path
17
+ if cwd_path.exists():
18
+ print(
19
+ f"[syncade] {label}: {path} was not found under repo root; "
20
+ f"using cwd-relative path {cwd_path}",
21
+ file=sys.stderr,
22
+ )
23
+ return cwd_path
24
+
25
+ return repo_path
26
+
27
+
28
+ def unique_markdown_path(directory: Path, stem: str) -> Path:
29
+ path = directory / f"{stem}.md"
30
+ if not path.exists():
31
+ return path
32
+
33
+ suffix = 2
34
+ while True:
35
+ candidate = directory / f"{stem}-{suffix}.md"
36
+ if not candidate.exists():
37
+ return candidate
38
+ suffix += 1