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,122 @@
1
+ """Apply per-invocation CLI overrides to a loaded :class:`SyncadeConfig` (PR-v2-9).
2
+
3
+ The CLI can override config for a single run: loop-level (``--max-rounds`` / ``--budget-tokens`` /
4
+ ``--budget-usd``), per-reviewer (``--reviewer-model`` / ``--reviewer-thinking`` /
5
+ ``--reviewer-timeout`` ``NAME=VALUE``, name-qualified + repeatable), and the worktree base
6
+ (``--worktree-base``). This module is the ONE place that owns that precedence — a CLI override beats
7
+ the config-file value — extracted from ``cli/__init__`` so the merge is unit-testable in isolation
8
+ rather than only end-to-end.
9
+
10
+ An override that cannot be applied — a NAME that is not a configured reviewer, or a VALUE the
11
+ reviewer schema rejects — raises :class:`OverrideError`, which the caller maps to exit 50 so a typo
12
+ in a flag reads the same as a typo in the file. (``--timeout`` is deliberately NOT here: it is
13
+ threaded to ``run_review`` as a separate arg, not patched into the config object — tests pin that
14
+ "the CLI doesn't pre-resolve". One consequence, by design: the ``run-init.json`` config snapshot
15
+ records the config + the loop overrides folded in HERE, but NOT ``--timeout``; read the CLI
16
+ invocation for the effective per-reviewer timeout on a ``--timeout`` run.)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ from pydantic import ValidationError
24
+
25
+ from syncade.config import ReviewerConfig, SyncadeConfig
26
+
27
+
28
+ class OverrideError(Exception):
29
+ """A CLI override that can't be applied (unknown reviewer name / schema-rejected value)."""
30
+
31
+
32
+ def apply_cli_overrides(config: SyncadeConfig, args) -> SyncadeConfig:
33
+ """Return ``config`` with every per-invocation CLI override applied (CLI beats config).
34
+
35
+ Raises :class:`OverrideError` on an unknown reviewer name or a bad per-reviewer value.
36
+ """
37
+ config = _apply_loop_overrides(config, args)
38
+ config = _apply_reviewer_overrides(config, args)
39
+ config = apply_worktree_base_override(config, args)
40
+ return config
41
+
42
+
43
+ def apply_worktree_base_override(config: SyncadeConfig, args) -> SyncadeConfig:
44
+ """Return ``config`` with ``--worktree-base`` applied (CLI beats config); a no-op when unset.
45
+
46
+ Standalone rather than folded into the reviewer/loop merge because BOTH a review run AND
47
+ ``--doctor`` honor it, and ``--doctor`` does not route through :func:`apply_cli_overrides` —
48
+ ``doctor_mode`` calls this directly so the writability preview matches the run it previews."""
49
+ raw = getattr(args, "worktree_base", None)
50
+ if raw is None:
51
+ return config
52
+ return config.model_copy(update={"worktree_base": Path(raw).expanduser()})
53
+
54
+
55
+ def _apply_loop_overrides(config: SyncadeConfig, args) -> SyncadeConfig:
56
+ # --max-rounds / --budget-* patch [loop], only-set-if-given so an unset flag leaves the config
57
+ # value intact (including the 50M token default). One resolved value reaches the orchestrator.
58
+ updates = {
59
+ field: value
60
+ for field, value in (
61
+ ("max_rounds", args.max_rounds),
62
+ ("budget_tokens", args.budget_tokens),
63
+ ("budget_usd", args.budget_usd),
64
+ )
65
+ if value is not None
66
+ }
67
+ if not updates:
68
+ return config
69
+ return config.model_copy(update={"loop": config.loop.model_copy(update=updates)})
70
+
71
+
72
+ def _apply_reviewer_overrides(config: SyncadeConfig, args) -> SyncadeConfig:
73
+ # Collect NAME -> {field: value} from the three repeatable name-qualified flags. A later flag
74
+ # for the same NAME+field wins (argparse append order), which is the only sane last-wins rule.
75
+ overrides: dict[str, dict[str, object]] = {}
76
+ for attr, field in (
77
+ ("reviewer_model", "model"),
78
+ ("reviewer_thinking", "thinking"),
79
+ ("reviewer_timeout", "timeout_seconds"),
80
+ ):
81
+ for name, value in getattr(args, attr, None) or []:
82
+ if field == "timeout_seconds":
83
+ # The schema is strict (rejects a TOML string), so parse the CLI string to a float
84
+ # HERE — a non-numeric value is an OverrideError (exit 50), like a bad TOML value.
85
+ try:
86
+ value = float(value)
87
+ except (TypeError, ValueError) as exc:
88
+ raise OverrideError(
89
+ f"--reviewer-timeout {name}={value!r}: not a number"
90
+ ) from exc
91
+ overrides.setdefault(name, {})[field] = value
92
+ if not overrides:
93
+ return config
94
+
95
+ configured = {reviewer.name for reviewer in config.reviewers}
96
+ unknown = sorted(name for name in overrides if name not in configured)
97
+ if unknown:
98
+ raise OverrideError(
99
+ f"--reviewer-* override names unknown reviewer(s) {unknown}; "
100
+ f"configured reviewers: {sorted(configured)}"
101
+ )
102
+
103
+ new_reviewers = []
104
+ for reviewer in config.reviewers:
105
+ update = overrides.get(reviewer.name)
106
+ if update:
107
+ # Re-VALIDATE through the schema (a bare model_copy skips validators) so a bad tier or a
108
+ # non-numeric / <=0 / non-finite timeout is rejected here, by the SAME rules as TOML.
109
+ try:
110
+ reviewer = ReviewerConfig.model_validate({**reviewer.model_dump(), **update})
111
+ except ValidationError as exc:
112
+ raise OverrideError(
113
+ f"invalid --reviewer-* override for {reviewer.name!r}: {_first_error(exc)}"
114
+ ) from exc
115
+ new_reviewers.append(reviewer)
116
+ return config.model_copy(update={"reviewers": new_reviewers})
117
+
118
+
119
+ def _first_error(exc: ValidationError) -> str:
120
+ err = exc.errors()[0]
121
+ loc = ".".join(str(part) for part in err["loc"]) or "<value>"
122
+ return f"{loc}: {err['msg']}"
@@ -0,0 +1,476 @@
1
+ """Interactive ``syncade --config`` arrow-menu (pr-v2-30 Issue 3).
2
+
3
+ Arrow keys move between settings, Enter edits the highlighted one, ``t`` toggles the edit target
4
+ (global⇄repo), ``s`` saves the active target, ``q`` quits (prompting on unsaved edits in either
5
+ target). stdlib ``curses`` — no dependency.
6
+
7
+ The state machine :class:`ConfigMenu` is pure Python (unit-tested directly); ``run`` is the thin
8
+ curses layer (draw + key input), exercised end-to-end by a pty test. Non-TTY invocation degrades to
9
+ a message + exit 60 rather than a curses crash.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import copy
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from pydantic import ValidationError
19
+
20
+ from syncade import config_loader
21
+ from syncade.cli import config_keys, config_menu_rows
22
+ from syncade.cli.config_mode import (
23
+ _LIST_SECTIONS,
24
+ _PROVIDER_DEFAULT_MODELS,
25
+ _apply,
26
+ _atomic_write,
27
+ _cross_provider_error,
28
+ _existing_text,
29
+ _layer_of,
30
+ _shown,
31
+ _unknown_reviewer_provider_error,
32
+ )
33
+ from syncade.cli.toml_writer import render
34
+ from syncade.config import SyncadeConfig
35
+ from syncade.config_loader import CONFIG_RELATIVE_PATH, _read_toml
36
+
37
+ # Providers shown in the picker, in display order.
38
+ _PICK_PROVIDERS = list(_PROVIDER_DEFAULT_MODELS) + ["custom..."]
39
+ # Curated model lists per provider (shown first; "custom..." always last).
40
+ _PICK_MODELS: dict[str, list[str]] = {
41
+ "anthropic": ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5", "custom..."],
42
+ "openai": ["gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "o3", "custom..."],
43
+ }
44
+
45
+ # Precedence rank of the editable layers (see config_loader). A row is "shadowed" when its effective
46
+ # value comes from a layer strictly ABOVE the current edit target — an edit there won't take effect.
47
+ _LAYER_RANK = {"default": 0, "global": 1, "repo": 2}
48
+
49
+
50
+ class ConfigMenu:
51
+ """The editable state for the menu. Edits the ACTIVE TARGET layer — global (default) or, when
52
+ toggled inside a git repo, the repo's ``.syncade/config.toml``. The displayed value is the value
53
+ the ACTIVE TARGET produces (defaults+global at target=global; the full effective config at
54
+ target=repo), so an edit is visible immediately even when a higher layer shadows it; a row is
55
+ flagged ``shadowed by <layer>`` when an edit at the current target would be masked by a higher
56
+ layer at runtime (PR-v2-31 inc 3; the target-perspective display closes a dogfood UX bug where a
57
+ global edit of a repo-shadowed field appeared to do nothing)."""
58
+
59
+ def __init__(self, global_raw: dict, repo_raw: dict, *, in_git: bool = True) -> None:
60
+ self.global_raw = global_raw
61
+ self.repo_raw = repo_raw
62
+ self.in_git = in_git
63
+ self.target = "global" # edit target; `t` toggles global<->repo (repo needs a git repo)
64
+ self.cursor = 0
65
+ self.dirty_global = False # per-target: saving one target must not mask the other's edits
66
+ self.dirty_repo = False
67
+ self.message = ""
68
+ self.nav = [""] # screen stack (drill-in, inc 4); nav[-1] is the current screen, "" = top
69
+ self._recompute()
70
+
71
+ @property
72
+ def screen(self) -> str:
73
+ return self.nav[-1]
74
+
75
+ def current_row(self) -> config_menu_rows.Row:
76
+ return self.rows[self.cursor]
77
+
78
+ def drill(self) -> None:
79
+ """Enter the highlighted row's child screen (only for a ``drill`` row)."""
80
+ row = self.current_row()
81
+ if row.kind == "drill":
82
+ self.nav.append(row.key)
83
+ self.cursor = 0
84
+ self.message = ""
85
+ self._recompute()
86
+
87
+ def back(self) -> bool:
88
+ """Pop to the parent screen; returns True if it navigated (False at the top)."""
89
+ if len(self.nav) > 1:
90
+ self.nav.pop()
91
+ self.cursor = 0
92
+ self.message = ""
93
+ self._recompute()
94
+ return True
95
+ return False
96
+
97
+ @property
98
+ def dirty(self) -> bool:
99
+ """True if EITHER target has unsaved edits — the quit guard prompts on either."""
100
+ return self.dirty_global or self.dirty_repo
101
+
102
+ @property
103
+ def _target_dirty(self) -> bool:
104
+ return self.dirty_repo if self.target == "repo" else self.dirty_global
105
+
106
+ @_target_dirty.setter
107
+ def _target_dirty(self, value: bool) -> None:
108
+ if self.target == "repo":
109
+ self.dirty_repo = value
110
+ else:
111
+ self.dirty_global = value
112
+
113
+ def _recompute(self) -> None:
114
+ merged = config_loader._deep_merge(copy.deepcopy(self.global_raw), self.repo_raw)
115
+ self.config = SyncadeConfig.model_validate(merged)
116
+ self.rows = config_menu_rows.screen_rows(self.config, self.screen)
117
+ self.cursor = max(0, min(self.cursor, len(self.rows) - 1))
118
+
119
+ @property
120
+ def target_raw(self) -> dict:
121
+ """Raw dict of the active edit target — repo when toggled in a git repo, else global."""
122
+ return self.repo_raw if self.target == "repo" else self.global_raw
123
+
124
+ @target_raw.setter
125
+ def target_raw(self, value: dict) -> None:
126
+ if self.target == "repo":
127
+ self.repo_raw = value
128
+ else:
129
+ self.global_raw = value
130
+
131
+ def toggle_target(self) -> None:
132
+ """Switch the edit target global<->repo. A no-op outside a git repo (no repo layer)."""
133
+ if self.in_git:
134
+ self.target = "repo" if self.target == "global" else "global"
135
+
136
+ def _mat_config(self):
137
+ """Config to materialize an absent section from — the layer the TARGET inherits. A repo edit
138
+ inherits defaults+global (== the effective config); a global edit inherits defaults+global
139
+ only, so rebuild global-only lest the current repo's choices bake into ``~/.syncade``."""
140
+ if self.target == "repo":
141
+ return self.config
142
+ try:
143
+ return SyncadeConfig.model_validate(self.global_raw)
144
+ except ValidationError:
145
+ return self.config
146
+
147
+ def display_rows(self) -> list[tuple[str, str, str]]:
148
+ """(label, value, layer) per row. ``value`` is the row's value FROM THE CURRENT TARGET'S
149
+ perspective (:meth:`_mat_config` — defaults+global at target=global, full effective at
150
+ target=repo), so an edit at the active target is visible at once even when a higher layer
151
+ shadows it; ``layer`` is the effective source, ``shadowed by <layer>`` when an edit at the
152
+ target would be masked by a higher, ``→`` for a sectionless drill, or blank."""
153
+ mat = self._mat_config()
154
+ out = []
155
+ for r in self.rows:
156
+ value = _shown(mat, r.value_key) if r.value_key else ""
157
+ if r.kind == "info":
158
+ layer = ""
159
+ elif r.section is not None:
160
+ layer = self._layer_display(r.section, r.subkey)
161
+ else:
162
+ layer = "→"
163
+ out.append((r.label, value, layer))
164
+ return out
165
+
166
+ def _layer_display(self, section: str, subkey: str | None) -> str:
167
+ source = _layer_of(self.global_raw, self.repo_raw, section, subkey)
168
+ if _LAYER_RANK[source] > _LAYER_RANK[self.target]:
169
+ return f"shadowed by {source}" # an edit at the current target won't change this value
170
+ return source
171
+
172
+ def move(self, delta: int) -> None:
173
+ self.cursor = max(0, min(len(self.rows) - 1, self.cursor + delta))
174
+
175
+ def apply_edit(self, text: str) -> str | None:
176
+ """Apply the typed value to the highlighted row. A model row accepts ``model`` (keep the
177
+ provider) or ``provider/model`` (switch both). Validates the result in isolation; on any
178
+ error returns a message and changes nothing.
179
+
180
+ Returns ``None`` on a state change (caller should show "updated"), ``""`` on a no-op
181
+ (e.g. empty input on a non-clearable row), or a non-empty error string on failure."""
182
+ text = text.strip()
183
+ row = self.current_row()
184
+ if row.kind != "edit": # Enter on a drill/info row is navigation, handled by the caller
185
+ return ""
186
+ key = row.key
187
+ # Empty input: optional and list fields clear via the normal coerce/apply path; budget
188
+ # keys require explicit 0 (clearing would omit the TOML key, reactivating the default);
189
+ # all other rows are a no-op.
190
+ if not text:
191
+ if key in config_keys.BUDGET_KEYS:
192
+ return f"invalid: set {key!r} to 0 to disable the ceiling; empty is not accepted"
193
+ # Optional and list fields: empty input clears (parity with --config set).
194
+ try:
195
+ ann = config_keys.resolve_annotation(key)
196
+ except config_keys.UnknownKey:
197
+ return ""
198
+ if not config_keys._empty_clears(ann):
199
+ return ""
200
+ # fall through with text="" — coerce handles Optional→None and list→[]
201
+ # Build (dotted-key, raw) ops. A model row given "provider/model" splits into a provider
202
+ # edit (which re-derives the model) followed by the explicit model edit.
203
+ if key.endswith(".model") and "/" in text:
204
+ provider, model = (p.strip() for p in text.split("/", 1))
205
+ ops = [(key[: -len(".model")] + ".provider", provider), (key, model)]
206
+ else:
207
+ ops = [(key, text)]
208
+ candidate = copy.deepcopy(self.target_raw)
209
+ # Materialize an absent section from the layer the TARGET inherits (see _mat_config): a
210
+ # global edit uses global-only so the repo's choices don't bake into ~/.syncade; a repo edit
211
+ # uses the effective config (== defaults+global when the section is absent from repo).
212
+ mat_config = self._mat_config()
213
+ # A list-section element (reviewers/checks) may exist only in a layer ABOVE the target (e.g.
214
+ # a repo-only check edited at target=global). Range-check against the TARGET's roster so the
215
+ # menu emits the CLI's clean "N out of range" message, not an IndexError leak from _apply.
216
+ parts = key.split(".")
217
+ if parts[0] in _LIST_SECTIONS and len(parts) >= 2 and parts[1].isdigit():
218
+ index = int(parts[1])
219
+ existing = candidate.get(parts[0])
220
+ roster = len(existing) if existing is not None else len(getattr(mat_config, parts[0]))
221
+ if index >= roster:
222
+ return f"invalid: {parts[0]} {index} out of range (has {roster})"
223
+ try:
224
+ for op_key, raw in ops:
225
+ if op_key.endswith(".provider") and op_key.startswith("reviewers."):
226
+ err = _unknown_reviewer_provider_error(raw)
227
+ if err:
228
+ return f"invalid: {err}"
229
+ value = config_keys.coerce(config_keys.resolve_annotation(op_key), raw)
230
+ _apply(candidate, op_key, value, mat_config)
231
+ # Check for an obvious cross-provider model mismatch before pydantic validation.
232
+ if key.endswith(".model"):
233
+ parts = key.split(".")
234
+ actor_raw = (
235
+ candidate["reviewers"][int(parts[1])]
236
+ if parts[0] == "reviewers"
237
+ else candidate.get(parts[0], {})
238
+ )
239
+ pair_err = _cross_provider_error(actor_raw.get("provider", ""), ops[-1][1])
240
+ if pair_err:
241
+ return f"invalid: {pair_err}"
242
+ SyncadeConfig.model_validate(candidate)
243
+ # Also validate the merged effective config to catch cross-layer conflicts
244
+ # (e.g. duplicate reviewer/check names split across layers).
245
+ if self.target == "repo":
246
+ merged_cand = config_loader._deep_merge(copy.deepcopy(self.global_raw), candidate)
247
+ else:
248
+ merged_cand = config_loader._deep_merge(copy.deepcopy(candidate), self.repo_raw)
249
+ SyncadeConfig.model_validate(merged_cand)
250
+ except (
251
+ config_keys.InvalidValue,
252
+ config_keys.UnknownKey,
253
+ ValueError,
254
+ ValidationError,
255
+ KeyError,
256
+ TypeError,
257
+ IndexError,
258
+ ) as exc:
259
+ return _error(exc)
260
+ self.target_raw = candidate
261
+ self._target_dirty = True
262
+ self._recompute()
263
+ return None
264
+
265
+ def save(self, path: Path) -> str | None:
266
+ if not self._target_dirty: # don't write (or create) a file for a target with no edits
267
+ return f"no unsaved edits for the {self.target} config"
268
+ try:
269
+ _atomic_write(path, render(self.target_raw, _existing_text(path)))
270
+ except OSError as exc:
271
+ return f"save failed: {exc}"
272
+ self._target_dirty = False
273
+ return None
274
+
275
+
276
+ def _error(exc: Exception) -> str:
277
+ if isinstance(exc, ValidationError):
278
+ err = exc.errors()[0]
279
+ loc = ".".join(str(p) for p in err["loc"])
280
+ return f"invalid: {loc}: {err['msg']}"
281
+ return f"invalid: {exc}"
282
+
283
+
284
+ def run(*, global_path: Path, repo_root: Path, in_git: bool = True) -> int:
285
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
286
+ print(
287
+ "[syncade] --config: an interactive terminal is required for the menu. Use "
288
+ "`--config list` / `--config set <key> <value>` instead.",
289
+ file=sys.stderr,
290
+ )
291
+ return 60
292
+ import curses
293
+ import locale
294
+
295
+ locale.setlocale(locale.LC_ALL, "")
296
+ # Outside a git repo there is no repo layer (a stray cwd/.syncade/config.toml no run would read
297
+ # must not masquerade as one), matching --config list/get/set.
298
+ repo_path = repo_root / CONFIG_RELATIVE_PATH
299
+ repo_raw = _read_toml(repo_path) if in_git else {}
300
+ menu = ConfigMenu(_read_toml(global_path), repo_raw, in_git=in_git)
301
+ return curses.wrapper(_loop, menu, global_path, repo_path)
302
+
303
+
304
+ def _target_path(menu: ConfigMenu, global_path: Path, repo_path: Path) -> Path:
305
+ return repo_path if menu.target == "repo" else global_path
306
+
307
+
308
+ def _loop(stdscr, menu: ConfigMenu, global_path: Path, repo_path: Path) -> int:
309
+ import curses
310
+
311
+ curses.curs_set(0)
312
+ stdscr.keypad(True)
313
+ while True:
314
+ _draw(stdscr, menu, _target_path(menu, global_path, repo_path))
315
+ ch = stdscr.getch()
316
+ if ch in (curses.KEY_UP, ord("k")):
317
+ menu.move(-1)
318
+ elif ch in (curses.KEY_DOWN, ord("j")):
319
+ menu.move(1)
320
+ elif ch == ord("t"):
321
+ menu.toggle_target()
322
+ menu.message = (
323
+ f"editing target: {menu.target}"
324
+ if menu.in_git
325
+ else "not a git repo — target stays global"
326
+ )
327
+ elif ch == 27: # Esc -> back up one screen; at the top, same quit path as q
328
+ if not menu.back():
329
+ n = len(menu.rows)
330
+ if not menu.dirty or _confirm(stdscr, "Unsaved edits — quit anyway? (y/N) ", n):
331
+ return 0
332
+ else:
333
+ menu.message = ""
334
+ elif ch in (curses.KEY_ENTER, 10, 13):
335
+ row = menu.current_row()
336
+ if row.kind == "drill":
337
+ menu.drill()
338
+ elif row.kind == "edit":
339
+ if row.key.endswith(".model"):
340
+ value = _pick_model(stdscr, menu)
341
+ if value is None:
342
+ menu.message = ""
343
+ continue
344
+ else:
345
+ value = _prompt(stdscr, f"New {row.label}: ", len(menu.rows))
346
+ result = menu.apply_edit(value)
347
+ # None → state changed; "" → no-op (clear message); non-empty → error
348
+ menu.message = "updated (unsaved — press s)" if result is None else result
349
+ # kind == "info": inert
350
+ elif ch == ord("s"):
351
+ path = _target_path(menu, global_path, repo_path)
352
+ menu.message = menu.save(path) or f"saved to {path}"
353
+ elif ch == ord("q"):
354
+ n = len(menu.rows)
355
+ if not menu.dirty or _confirm(stdscr, "Unsaved edits — quit anyway? (y/N) ", n):
356
+ return 0
357
+
358
+
359
+ def _safe_addstr(win, y: int, x: int, text: str) -> None:
360
+ """``addstr`` that never raises on an off-screen or overflowing write (tiny/narrow terminals):
361
+ skips fully off-screen rows/cols, clips text to the line, and swallows the bottom-right-corner
362
+ ``curses.error``. Rendering degrades instead of crashing out of ``curses.wrapper``."""
363
+ import curses
364
+
365
+ rows, cols = win.getmaxyx()
366
+ if y < 0 or y >= rows or x < 0 or x >= cols:
367
+ return
368
+ try:
369
+ win.addstr(y, x, text[: max(0, cols - x - 1)])
370
+ except curses.error:
371
+ pass
372
+
373
+
374
+ def _pick_from_list(stdscr, title: str, choices: list[str]) -> str | None:
375
+ """Pick from a list with arrows. Returns the selected item, or None on Esc. Writes are bounded
376
+ (:func:`_safe_addstr`) so a too-small terminal degrades to a truncated list, not a traceback."""
377
+ import curses
378
+
379
+ cursor = 0
380
+ while True:
381
+ stdscr.erase()
382
+ _safe_addstr(stdscr, 0, 0, title)
383
+ for i, choice in enumerate(choices):
384
+ _safe_addstr(stdscr, 2 + i, 0, (">" if i == cursor else " ") + " " + choice)
385
+ _safe_addstr(stdscr, 3 + len(choices), 0, "up/down move Enter select Esc cancel")
386
+ stdscr.refresh()
387
+ ch = stdscr.getch()
388
+ if ch in (curses.KEY_UP, ord("k")):
389
+ cursor = max(0, cursor - 1)
390
+ elif ch in (curses.KEY_DOWN, ord("j")):
391
+ cursor = min(len(choices) - 1, cursor + 1)
392
+ elif ch in (curses.KEY_ENTER, 10, 13):
393
+ return choices[cursor]
394
+ elif ch == 27: # Esc
395
+ return None
396
+
397
+
398
+ def _pick_model(stdscr, menu: ConfigMenu) -> str | None:
399
+ """Two-step provider → model picker. Returns ``'provider/model'`` or None on cancel."""
400
+ provider = _pick_from_list(stdscr, "Select provider (Esc to cancel):", _PICK_PROVIDERS)
401
+ if provider is None:
402
+ return None
403
+ if provider == "custom...":
404
+ raw = _prompt(stdscr, "Custom (e.g. anthropic/claude-opus-4-8): ", len(menu.rows))
405
+ return raw.strip() or None
406
+ model = _pick_from_list(
407
+ stdscr,
408
+ f"Select {provider} model (Esc to cancel):",
409
+ _PICK_MODELS.get(provider, ["custom..."]),
410
+ )
411
+ if model is None:
412
+ return None
413
+ if model == "custom...":
414
+ raw = _prompt(stdscr, f"Custom model for {provider}: ", len(menu.rows))
415
+ return f"{provider}/{raw.strip()}" if raw.strip() else None
416
+ return f"{provider}/{model}"
417
+
418
+
419
+ def _too_small(stdscr) -> None:
420
+ """Render the shared resize message (bounded) and refresh. Used when a screen can't fit."""
421
+ stdscr.erase()
422
+ _safe_addstr(stdscr, 0, 0, "Terminal too small — resize window")
423
+ stdscr.refresh()
424
+
425
+
426
+ def _draw(stdscr, menu: ConfigMenu, target_path: Path) -> None:
427
+ rows, _cols = stdscr.getmaxyx()
428
+ min_rows = 3 + len(menu.rows) + 1 # header gap + rows + footer line
429
+ stdscr.erase()
430
+ if rows < min_rows:
431
+ _safe_addstr(stdscr, 0, 0, "Terminal too small — resize window")
432
+ stdscr.refresh()
433
+ return
434
+ crumb = "" if menu.screen == "" else " › " + " › ".join(menu.nav[1:])
435
+ _safe_addstr(stdscr, 0, 0, f"Configure syncade{crumb} [target: {menu.target} · t toggles]")
436
+ _safe_addstr(stdscr, 1, 0, f"(writing {target_path})")
437
+ for i, (label, value, layer) in enumerate(menu.display_rows()):
438
+ marker = ">" if i == menu.cursor else " "
439
+ _safe_addstr(stdscr, 2 + i, 0, f"{marker} {label:<20} {value:<28} {layer}")
440
+ foot = 3 + len(menu.rows)
441
+ _safe_addstr(stdscr, foot, 0, "up/down move Enter select Esc back t target s save q quit")
442
+ if menu.message and rows > foot + 1:
443
+ _safe_addstr(stdscr, foot + 1, 0, menu.message)
444
+ stdscr.refresh()
445
+
446
+
447
+ def _prompt(stdscr, label: str, n_rows: int) -> str:
448
+ import curses
449
+
450
+ row = 5 + n_rows
451
+ rows, cols = stdscr.getmaxyx()
452
+ if row >= rows: # too short to place the input line — degrade to a no-op edit, never crash
453
+ _too_small(stdscr)
454
+ return ""
455
+ _safe_addstr(stdscr, row, 0, label)
456
+ stdscr.clrtoeol()
457
+ curses.echo()
458
+ curses.curs_set(1)
459
+ try:
460
+ raw = stdscr.getstr(row, min(len(label), cols - 1)).decode("utf-8", "replace")
461
+ finally:
462
+ curses.noecho()
463
+ curses.curs_set(0)
464
+ return raw
465
+
466
+
467
+ def _confirm(stdscr, label: str, n_rows: int) -> bool:
468
+ rows, _cols = stdscr.getmaxyx()
469
+ row = 5 + n_rows
470
+ if row >= rows: # can't show the prompt — do NOT quit (preserve unsaved edits), never crash
471
+ _too_small(stdscr)
472
+ return False
473
+ _safe_addstr(stdscr, row, 0, label)
474
+ stdscr.clrtoeol()
475
+ stdscr.refresh()
476
+ return stdscr.getch() in (ord("y"), ord("Y"))
@@ -0,0 +1,72 @@
1
+ """``syncade --doctor`` dispatch — read-only run preflight (PR-v2-12).
2
+
3
+ Resolves the git repo root + loads ``.syncade/config.toml`` like every other one-shot
4
+ mode, then hands off to :func:`syncade.doctor.run_doctor`. Error mapping mirrors the
5
+ siblings in :mod:`syncade.cli.modes`: repo-discovery failures → exit 60, config errors →
6
+ exit 50; doctor's own exit codes (0 / 60) pass through verbatim.
7
+
8
+ Unlike ``--auth-check`` / ``--gc`` / ``--metrics``, doctor does **not** reject
9
+ ``--base`` / ``--scope`` — it is the one preflight that previews the diff those flags
10
+ select (the run-plan preview resolves that base and reports its diff size). So there is no
11
+ ``_reject_diff_base_flags`` guard here, deliberately; ``--base`` / ``--scope`` (and the
12
+ other run-shaping flags) are threaded into ``run_doctor`` below.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from syncade.config_loader import ConfigError, load_config
21
+ from syncade.exit_codes import CONFIG_ERROR, WORKTREE_ERROR
22
+ from syncade.snapshot import SnapshotError, discover_repo_root
23
+
24
+ from .config_overrides import apply_worktree_base_override
25
+
26
+
27
+ def _run_doctor(args) -> int:
28
+ """Dispatch ``syncade --doctor``."""
29
+ # Local import keeps the doctor engine out of the default-import cost.
30
+ from syncade.doctor import run_doctor
31
+
32
+ repo_root_hint = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
33
+ try:
34
+ repo_root = discover_repo_root(repo_root_hint)
35
+ except SnapshotError as exc:
36
+ print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
37
+ return WORKTREE_ERROR
38
+
39
+ def _emit_deprecation(message: str) -> None:
40
+ print(f"[syncade] {message}", file=sys.stderr)
41
+
42
+ try:
43
+ config = load_config(repo_root, preset=args.preset, deprecation_callback=_emit_deprecation)
44
+ except ConfigError as exc:
45
+ print(f"[syncade] config error: {exc}", file=sys.stderr)
46
+ return CONFIG_ERROR
47
+
48
+ # --worktree-base overrides config.worktree_base so the writability preview probes the base the
49
+ # real run would use (doctor does not route through apply_cli_overrides — see config_overrides).
50
+ config = apply_worktree_base_override(config, args)
51
+
52
+ # Mirror the CLI's own run resolution so the branch preview matches what a real
53
+ # `syncade <pr-doc>` would do for the same flags (C1). repo always pre-exists here
54
+ # (discover_repo_root succeeded), so the auto-init default-branch exemption never applies.
55
+ # NOTE: there is deliberately no PR-doc here. `--doctor` is a one-shot mode and the CLI
56
+ # rejects `--doctor` with a PR_DOC positional (cli/validate.py), so any `args.pr_doc`
57
+ # plumbing would be unreachable — a dogfood round shipped exactly that and a blind panel
58
+ # caught it. The prompt-size preview renders with a placeholder ref instead, which makes
59
+ # its number a LOWER BOUND; see doctor_preview.check_plan for why that is still useful.
60
+ return run_doctor(
61
+ config,
62
+ repo_root,
63
+ quick=args.quick,
64
+ max_rounds=args.max_rounds,
65
+ allow_default_branch=args.allow_default_branch,
66
+ force_dirty=args.force_dirty,
67
+ base_ref=args.base,
68
+ scope=args.scope,
69
+ two_dot=getattr(args, "two_dot", False),
70
+ quiet=args.quiet,
71
+ timeout_seconds=args.timeout,
72
+ )