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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- 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
|
+
)
|