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,609 @@
|
|
|
1
|
+
"""``syncade --config`` — inspect and edit the config layers (pr-v2-30)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import get_args, get_origin
|
|
9
|
+
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from syncade import config_loader
|
|
13
|
+
from syncade.cli import config_keys
|
|
14
|
+
from syncade.cli.toml_writer import render
|
|
15
|
+
from syncade.config import SyncadeConfig
|
|
16
|
+
from syncade.config_loader import (
|
|
17
|
+
_PAIRED_SECTIONS,
|
|
18
|
+
CONFIG_RELATIVE_PATH,
|
|
19
|
+
ConfigError,
|
|
20
|
+
_deep_merge,
|
|
21
|
+
_read_toml,
|
|
22
|
+
load_config,
|
|
23
|
+
)
|
|
24
|
+
from syncade.snapshot import SnapshotError, discover_repo_root
|
|
25
|
+
|
|
26
|
+
_VERBS = ("list", "get", "set")
|
|
27
|
+
|
|
28
|
+
# Default model per provider, mirroring config_cold._COLD_MODELS. Used when a provider
|
|
29
|
+
# edit re-derives the paired model for a reviewer (which has no schema-level default).
|
|
30
|
+
_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
|
31
|
+
"anthropic": "claude-sonnet-4-6",
|
|
32
|
+
"openai": "gpt-5.5",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_repo_root(hint: str | None) -> tuple[Path, bool]:
|
|
37
|
+
"""The repo whose ``.syncade/config.toml`` is the repo layer, and whether we are actually inside
|
|
38
|
+
a git repo. ``--config`` is mostly about the global file, so being outside a repo is fine — and
|
|
39
|
+
``--config`` inspects the CURRENT state, so with no git repo there is no repo layer *yet* and
|
|
40
|
+
callers pass ``include_repo=False`` (nothing is labelled repo). NB a *review* run would ``git
|
|
41
|
+
init`` here and THEN read a ``cwd/.syncade/config.toml`` — that divergence is surfaced by
|
|
42
|
+
:func:`_ignored_repo_config_note`, not by pretending the file cannot exist."""
|
|
43
|
+
base = Path(hint) if hint else Path.cwd()
|
|
44
|
+
try:
|
|
45
|
+
return discover_repo_root(base), True
|
|
46
|
+
except SnapshotError:
|
|
47
|
+
return base.resolve(), False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get(config, key: str):
|
|
51
|
+
"""Resolve a dotted ``key`` against the effective config, restricted to pydantic model fields.
|
|
52
|
+
Rejects non-schema attributes (``model_dump``, ``__dict__``) and negative list indices."""
|
|
53
|
+
obj = config
|
|
54
|
+
for part in key.split("."):
|
|
55
|
+
if isinstance(obj, list):
|
|
56
|
+
idx = int(part) # ValueError → caught by _cmd_get
|
|
57
|
+
if idx < 0:
|
|
58
|
+
raise IndexError("negative list index not allowed")
|
|
59
|
+
obj = obj[idx]
|
|
60
|
+
else:
|
|
61
|
+
fields = getattr(type(obj), "model_fields", None)
|
|
62
|
+
if fields is None or part not in fields:
|
|
63
|
+
raise AttributeError(f"unknown field: {part!r}")
|
|
64
|
+
obj = getattr(obj, part)
|
|
65
|
+
return obj
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _layer_of(global_raw: dict, repo_raw: dict, section: str, subkey: str | None) -> str:
|
|
69
|
+
"""Which layer set this value: highest layer that defines the section (or, for ``[loop]`` scalar
|
|
70
|
+
knobs, the specific subkey); else ``default``. Mirrors the loader's merge rules."""
|
|
71
|
+
for label, raw in (("repo", repo_raw), ("global", global_raw)):
|
|
72
|
+
sec = raw.get(section)
|
|
73
|
+
if sec is None:
|
|
74
|
+
continue
|
|
75
|
+
if subkey is None or (isinstance(sec, dict) and subkey in sec):
|
|
76
|
+
return label
|
|
77
|
+
return "default"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _settings(config):
|
|
81
|
+
"""The surfaced settings (pr-v2-30 D6) as ``(key, label, section, subkey)``; reviewer rows
|
|
82
|
+
expand to the resolved roster so N reviewers each get a row."""
|
|
83
|
+
rows = [("producer.model", "Producer model", "producer", None)]
|
|
84
|
+
for i in range(len(config.reviewers)):
|
|
85
|
+
rows.append((f"reviewers.{i}.model", f"Reviewer {i + 1} model", "reviewers", None))
|
|
86
|
+
rows += [
|
|
87
|
+
("synthesizer.model", "Judge model", "synthesizer", None),
|
|
88
|
+
("loop.max_rounds", "Rounds (max)", "loop", "max_rounds"),
|
|
89
|
+
("loop.timeout_seconds", "Time per subprocess (s)", "loop", "timeout_seconds"),
|
|
90
|
+
("loop.budget_usd", "Cost cap (USD)", "loop", "budget_usd"),
|
|
91
|
+
]
|
|
92
|
+
return rows
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _shown(config, key: str) -> str:
|
|
96
|
+
"""A model row shows ``provider / model`` for context; a scalar shows its value or ``none``.
|
|
97
|
+
Returns the RAW value (control chars intact) so the curated ``--config list`` / ``get`` stay
|
|
98
|
+
byte-compatible; the ``--all`` renderer single-lines it via :func:`_single_line`."""
|
|
99
|
+
if key.endswith(".model"):
|
|
100
|
+
provider = _get(config, key.rsplit(".", 1)[0] + ".provider")
|
|
101
|
+
return f"{provider} / {_get(config, key)}"
|
|
102
|
+
value = _get(config, key)
|
|
103
|
+
return "— none —" if value is None else str(value)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _single_line(text: str) -> str:
|
|
107
|
+
"""Escape control characters so a value can never split or overwrite its physical row in the
|
|
108
|
+
``--all`` dump (a machine-readable-enough one-row-per-key surface). ``\\n``/``\\r``/``\\t`` get
|
|
109
|
+
readable escapes; any other C0 control char becomes ``\\xNN``."""
|
|
110
|
+
named = {"\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
111
|
+
return "".join(named.get(c) or (f"\\x{ord(c):02x}" if ord(c) < 0x20 else c) for c in text)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_config(config_args: list[str], *, args) -> int:
|
|
115
|
+
verb = config_args[0] if config_args else None
|
|
116
|
+
if verb is not None and verb not in _VERBS:
|
|
117
|
+
print(
|
|
118
|
+
f"[syncade] --config: unknown verb {verb!r} (use: list | get <key> | set <key> <val>)",
|
|
119
|
+
file=sys.stderr,
|
|
120
|
+
)
|
|
121
|
+
return 2
|
|
122
|
+
|
|
123
|
+
repo_root, in_git = _resolve_repo_root(getattr(args, "repo_root", None))
|
|
124
|
+
# Resolve the global path ONCE (module-qualified so tests can isolate it), and thread the SAME
|
|
125
|
+
# path into load_config so the effective config and the provenance read agree on the layer.
|
|
126
|
+
# Outside a git repo, drop the repo layer entirely (include_repo=in_git): --config reflects the
|
|
127
|
+
# current state (no repo → no repo layer). A present non-git config is noted, not read.
|
|
128
|
+
global_path = config_loader._default_global_config_path()
|
|
129
|
+
try:
|
|
130
|
+
config = load_config(
|
|
131
|
+
repo_root,
|
|
132
|
+
check_api_keys=False,
|
|
133
|
+
global_config_path=global_path,
|
|
134
|
+
include_repo=in_git,
|
|
135
|
+
)
|
|
136
|
+
except ConfigError as exc:
|
|
137
|
+
print(f"[syncade] config error:\n{exc}", file=sys.stderr)
|
|
138
|
+
return 50
|
|
139
|
+
|
|
140
|
+
if verb == "list":
|
|
141
|
+
if len(config_args) > 1:
|
|
142
|
+
print(
|
|
143
|
+
f"[syncade] --config list: takes no arguments (got: {config_args[1:]!r}); "
|
|
144
|
+
"did you mean `syncade <PR_DOC>` for a review run?",
|
|
145
|
+
file=sys.stderr,
|
|
146
|
+
)
|
|
147
|
+
return 2
|
|
148
|
+
return _cmd_list(
|
|
149
|
+
config, global_path, repo_root, in_git, show_all=bool(getattr(args, "all", False))
|
|
150
|
+
)
|
|
151
|
+
if verb == "get":
|
|
152
|
+
return _cmd_get(config, config_args[1:], repo_root=repo_root, in_git=in_git)
|
|
153
|
+
if verb == "set":
|
|
154
|
+
return _cmd_set(
|
|
155
|
+
config,
|
|
156
|
+
config_args[1:],
|
|
157
|
+
repo_root=repo_root,
|
|
158
|
+
global_path=global_path,
|
|
159
|
+
target_repo=bool(getattr(args, "repo", False)),
|
|
160
|
+
)
|
|
161
|
+
# no verb -> the interactive arrow-menu (Issue 3)
|
|
162
|
+
from syncade.cli.config_tui import run as run_tui
|
|
163
|
+
|
|
164
|
+
return run_tui(global_path=global_path, repo_root=repo_root, in_git=in_git)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _ignored_repo_config_note(repo_root: Path, in_git: bool) -> str | None:
|
|
168
|
+
"""`--config` inspects the CURRENT state, so outside a git repo it does not read
|
|
169
|
+
``cwd/.syncade/config.toml`` (no repo → no repo layer). A REVIEW run with
|
|
170
|
+
``--allow-auto-init`` would ``git init`` the dir and THEN consume that file; without
|
|
171
|
+
``--allow-auto-init``, a non-empty dir is refused. Either way there is a divergence between
|
|
172
|
+
``--config`` inspection and a run — surfaced as a note (on stderr, so ``get`` stdout stays
|
|
173
|
+
clean)."""
|
|
174
|
+
if in_git:
|
|
175
|
+
return None
|
|
176
|
+
repo_cfg = repo_root / CONFIG_RELATIVE_PATH
|
|
177
|
+
if not repo_cfg.is_file():
|
|
178
|
+
return None
|
|
179
|
+
return (
|
|
180
|
+
f"[syncade] note: {repo_cfg} exists but this directory is not a git repo, so --config does "
|
|
181
|
+
"not read it. Run `git init` to manage it as the repo layer."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _explicitly_set(raw: dict, key: str) -> bool:
|
|
186
|
+
"""Does the raw layer dict explicitly set this dotted ``key`` (vs inheriting a default)?"""
|
|
187
|
+
node = raw
|
|
188
|
+
for part in key.split("."):
|
|
189
|
+
if isinstance(node, list):
|
|
190
|
+
idx = int(part)
|
|
191
|
+
if idx >= len(node):
|
|
192
|
+
return False
|
|
193
|
+
node = node[idx]
|
|
194
|
+
elif isinstance(node, dict):
|
|
195
|
+
if part not in node:
|
|
196
|
+
return False
|
|
197
|
+
node = node[part]
|
|
198
|
+
else:
|
|
199
|
+
return False
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _raw_get(raw: dict, key: str):
|
|
204
|
+
"""Navigate a raw nested dict/list by dotted key (no pydantic field validation)."""
|
|
205
|
+
node = raw
|
|
206
|
+
for part in key.split("."):
|
|
207
|
+
if isinstance(node, list):
|
|
208
|
+
node = node[int(part)]
|
|
209
|
+
else:
|
|
210
|
+
node = node[part]
|
|
211
|
+
return node
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _default_provider_for(prefix: str) -> str | None:
|
|
215
|
+
"""Schema-default provider for an actor key prefix (e.g. ``'producer'``, ``'synthesizer'``),
|
|
216
|
+
used when the global raw layer sets ``.model`` but omits ``.provider``. Returns ``None`` when
|
|
217
|
+
the actor's ``provider`` is REQUIRED (no schema default — a ``[[reviewers]]`` entry), so the
|
|
218
|
+
masked-value display never FABRICATES a provider the global layer did not set."""
|
|
219
|
+
cls = SyncadeConfig
|
|
220
|
+
for part in prefix.split("."):
|
|
221
|
+
if get_origin(cls) is list:
|
|
222
|
+
cls = get_args(cls)[0] # element type; `part` is the index
|
|
223
|
+
continue
|
|
224
|
+
cls = cls.model_fields[part].annotation
|
|
225
|
+
field = getattr(cls, "model_fields", {}).get("provider")
|
|
226
|
+
if field is not None and field.is_required():
|
|
227
|
+
return None # reviewers: provider is required, there is no default to honestly show
|
|
228
|
+
try:
|
|
229
|
+
return _get(SyncadeConfig.model_validate({}), prefix + ".provider")
|
|
230
|
+
except (AttributeError, IndexError, ValueError, KeyError, TypeError):
|
|
231
|
+
return "openai" # safe fallback for a defaulted-provider actor
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _raw_shown(global_raw: dict, key: str) -> str | None:
|
|
235
|
+
"""Display string for ``key`` read directly from the raw global layer dict.
|
|
236
|
+
|
|
237
|
+
Applies per-field coercion so numeric types (int→float) display the same way ``_shown``
|
|
238
|
+
formats the pydantic-coerced effective value — enabling an accurate ``==`` comparison.
|
|
239
|
+
Falls back to the raw string when the key is invalid or off-schema. Returns ``None`` when
|
|
240
|
+
the key path is absent or broken (caller skips the note).
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
if key.endswith(".model"):
|
|
244
|
+
prefix = key.rsplit(".", 1)[0]
|
|
245
|
+
model = _raw_get(global_raw, key)
|
|
246
|
+
try:
|
|
247
|
+
provider = _raw_get(global_raw, prefix + ".provider")
|
|
248
|
+
except (KeyError, IndexError, TypeError):
|
|
249
|
+
# Global sets .model but omits .provider. For a defaulted-provider actor, show the
|
|
250
|
+
# schema default (accurate). For reviewers (provider REQUIRED, no default) show the
|
|
251
|
+
# bare model — never fabricate a provider the global layer did not set.
|
|
252
|
+
provider = _default_provider_for(prefix)
|
|
253
|
+
if provider is None:
|
|
254
|
+
return str(model)
|
|
255
|
+
return f"{provider} / {model}"
|
|
256
|
+
value = _raw_get(global_raw, key)
|
|
257
|
+
if value is None:
|
|
258
|
+
return "— none —"
|
|
259
|
+
# Coerce through the schema type so numeric formatting matches _shown() (e.g. int 2400 →
|
|
260
|
+
# float 2400.0). Falls back to the raw value when the field is invalid or off-schema.
|
|
261
|
+
# Skip re-coercion for already-decoded list values: str(list) followed by CSV parsing
|
|
262
|
+
# mangles the value (e.g. list[str] becomes ["['a'", " 'b']"]).
|
|
263
|
+
try:
|
|
264
|
+
annotation = config_keys.resolve_annotation(key)
|
|
265
|
+
if not (get_origin(annotation) is list and isinstance(value, list)):
|
|
266
|
+
value = config_keys.coerce(annotation, str(value))
|
|
267
|
+
except (config_keys.UnknownKey, config_keys.InvalidValue):
|
|
268
|
+
pass
|
|
269
|
+
return str(value)
|
|
270
|
+
except (KeyError, IndexError, TypeError, ValueError):
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _overrides_note(config, global_raw: dict, key: str, layer: str) -> str:
|
|
275
|
+
"""The ` — overrides global <value>` shadow note: fires only when ``repo`` wins AND ``global``
|
|
276
|
+
explicitly sets a DIFFERENT value for this key. Reads from the raw global dict directly so
|
|
277
|
+
masked invalid sections report their actual values, not schema defaults."""
|
|
278
|
+
if layer != "repo" or not _explicitly_set(global_raw, key):
|
|
279
|
+
return ""
|
|
280
|
+
masked = _raw_shown(global_raw, key)
|
|
281
|
+
if masked is None or masked == _shown(config, key):
|
|
282
|
+
return ""
|
|
283
|
+
return f" — overrides global {_single_line(masked)}"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _print_all(config, global_raw: dict, repo_raw: dict) -> None:
|
|
287
|
+
from syncade.cli import config_list
|
|
288
|
+
|
|
289
|
+
section = None
|
|
290
|
+
for key, label, sec, subkey in config_list.all_rows(config):
|
|
291
|
+
if sec != section:
|
|
292
|
+
section = sec
|
|
293
|
+
print(config_list.header_for(sec))
|
|
294
|
+
layer = _layer_of(global_raw, repo_raw, sec, subkey)
|
|
295
|
+
note = _overrides_note(config, global_raw, key, layer)
|
|
296
|
+
print(f" {label:<26} {_single_line(_shown(config, key)):<30} ({layer}{note}) [{key}]")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _cmd_list(
|
|
300
|
+
config, global_path: Path, repo_root: Path, in_git: bool, *, show_all: bool = False
|
|
301
|
+
) -> int:
|
|
302
|
+
global_raw = _read_toml(global_path)
|
|
303
|
+
# Outside a git repo there is no repo layer (see _resolve_repo_root); {} so nothing is
|
|
304
|
+
# mis-attributed to `repo`. A present-but-ignored non-git config is surfaced as a note below.
|
|
305
|
+
repo_raw = _read_toml(repo_root / CONFIG_RELATIVE_PATH) if in_git else {}
|
|
306
|
+
print(f"[syncade] config (effective; global: {global_path})")
|
|
307
|
+
if show_all:
|
|
308
|
+
_print_all(config, global_raw, repo_raw)
|
|
309
|
+
note = _ignored_repo_config_note(repo_root, in_git)
|
|
310
|
+
if note:
|
|
311
|
+
print(note, file=sys.stderr)
|
|
312
|
+
return 0
|
|
313
|
+
for key, label, section, subkey in _settings(config):
|
|
314
|
+
layer = _layer_of(global_raw, repo_raw, section, subkey)
|
|
315
|
+
print(f" {label:<23} {_shown(config, key):<28} ({layer}) [{key}]")
|
|
316
|
+
note = _ignored_repo_config_note(repo_root, in_git)
|
|
317
|
+
if note:
|
|
318
|
+
print(note, file=sys.stderr)
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _cmd_get(config, rest: list[str], *, repo_root: Path, in_git: bool) -> int:
|
|
323
|
+
if len(rest) != 1:
|
|
324
|
+
print("[syncade] --config get: expects exactly one <key>", file=sys.stderr)
|
|
325
|
+
return 2
|
|
326
|
+
key = rest[0]
|
|
327
|
+
try:
|
|
328
|
+
value = _get(config, key)
|
|
329
|
+
except (AttributeError, IndexError, ValueError, KeyError, TypeError):
|
|
330
|
+
print(f"[syncade] --config get: unknown key {key!r}", file=sys.stderr)
|
|
331
|
+
return 2
|
|
332
|
+
note = _ignored_repo_config_note(repo_root, in_git)
|
|
333
|
+
if note:
|
|
334
|
+
print(note, file=sys.stderr)
|
|
335
|
+
print("— none —" if value is None else value)
|
|
336
|
+
return 0
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# --- set (2b, generalized to the whole schema in pr-v2-31 Increment 2) ---------------------------
|
|
340
|
+
# Top-level LIST sections (reviewers, checks) whose TOML *list* replaces wholesale on override — so
|
|
341
|
+
# editing one element materializes the whole roster first (like the paired _PAIRED_SECTIONS models).
|
|
342
|
+
# Everything else (loop, review, retry, gc, pricing) merges key-by-key. Derived from the schema so a
|
|
343
|
+
# new list-of-models section is handled automatically. Key RESOLUTION + type COERCION live in
|
|
344
|
+
# ``config_keys`` (shared with the menu); this module owns materialization + the pairing guards.
|
|
345
|
+
_LIST_SECTIONS = frozenset(
|
|
346
|
+
name for name, f in SyncadeConfig.model_fields.items() if get_origin(f.annotation) is list
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# Models each provider is recognizably the owner of (the curated picker lists + defaults + common
|
|
351
|
+
# aliases). A model owned by the OTHER provider is a mismatch; a genuinely off-map custom string is
|
|
352
|
+
# allowed (the adapter validates it at dispatch). Prefix rules catch off-list variants (o3-mini, …).
|
|
353
|
+
_OPENAI_MODELS = {"gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "o3", "o4-mini"}
|
|
354
|
+
_ANTHROPIC_MODELS = {
|
|
355
|
+
"claude-sonnet-4-6",
|
|
356
|
+
"claude-opus-4-8",
|
|
357
|
+
"claude-haiku-4-5",
|
|
358
|
+
"opus",
|
|
359
|
+
"haiku",
|
|
360
|
+
"sonnet",
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _model_provider_hint(model: str) -> str | None:
|
|
365
|
+
"""Best-effort: which provider a model name obviously belongs to (known names + prefixes), or
|
|
366
|
+
None for a genuinely off-map custom string."""
|
|
367
|
+
if model in _OPENAI_MODELS or model.startswith(("gpt-", "o3", "o4")):
|
|
368
|
+
return "openai"
|
|
369
|
+
if model in _ANTHROPIC_MODELS or model.startswith("claude-"):
|
|
370
|
+
return "anthropic"
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _cross_provider_error(provider: str, model: str) -> str | None:
|
|
375
|
+
"""A fix-it message when the model is recognizably the OTHER known provider's; else None.
|
|
376
|
+
Catches off-prefix models (``o3``, ``o4-mini``, ``opus``), not just ``gpt-*``/``claude-*``."""
|
|
377
|
+
hint = _model_provider_hint(model)
|
|
378
|
+
if hint is not None and provider in ("anthropic", "openai") and hint != provider:
|
|
379
|
+
appropriate = "claude-*" if provider == "anthropic" else "gpt-*"
|
|
380
|
+
return (
|
|
381
|
+
f"model {model!r} looks like {hint}'s but provider is {provider!r}; "
|
|
382
|
+
f"set provider={hint!r} first, or pick a {appropriate} model"
|
|
383
|
+
)
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _unknown_reviewer_provider_error(provider: str) -> str | None:
|
|
388
|
+
"""Return an error message if ``provider`` is not a registered reviewer adapter, else None."""
|
|
389
|
+
from syncade.adapters.registry import known_providers
|
|
390
|
+
|
|
391
|
+
known = known_providers()
|
|
392
|
+
if provider not in known:
|
|
393
|
+
return f"unknown reviewer provider {provider!r}; known: {', '.join(known)}"
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _rederive_model(section: str, provider: str) -> str:
|
|
398
|
+
"""The default model for ``(section, provider)`` so a provider edit keeps the pair valid in the
|
|
399
|
+
SAME write. For paired actors (producer/synthesizer/drafter/auditor) pydantic auto-fills the
|
|
400
|
+
model; for reviewers (no schema default) we fall back to ``_PROVIDER_DEFAULT_MODELS``."""
|
|
401
|
+
if section == "reviewers":
|
|
402
|
+
return _PROVIDER_DEFAULT_MODELS.get(provider, "gpt-5.5")
|
|
403
|
+
resolved = SyncadeConfig.model_validate({section: {"provider": provider}})
|
|
404
|
+
return getattr(resolved, section).model
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _keep_int_if_equal(existing, value):
|
|
408
|
+
"""When coercing a float from CLI/menu, preserve an existing int token that is semantically
|
|
409
|
+
equal so that ``timeout_seconds = 1800`` is not rewritten as ``1800.0`` on a no-op edit.
|
|
410
|
+
Guards booleans, which satisfy ``int(True) == 1`` but must never silently substitute."""
|
|
411
|
+
if (
|
|
412
|
+
isinstance(value, float)
|
|
413
|
+
and isinstance(existing, int)
|
|
414
|
+
and not isinstance(existing, bool)
|
|
415
|
+
and float(existing) == value
|
|
416
|
+
):
|
|
417
|
+
return existing
|
|
418
|
+
return value
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _apply(target_raw: dict, key: str, value, config) -> None:
|
|
422
|
+
"""Set the dotted ``key`` to ``value`` in ``target_raw`` (the target layer's raw dict).
|
|
423
|
+
|
|
424
|
+
A wholesale-replace section absent from the target is MATERIALIZED from ``config`` first, so
|
|
425
|
+
editing one field can't drop the pair-consistent rest: the paired actors (``_PAIRED_SECTIONS``)
|
|
426
|
+
and the list rosters (``_LIST_SECTIONS``) dump their whole section/list; a key-by-key section
|
|
427
|
+
(loop, review, retry, gc, pricing) sets just the (possibly nested) key. Setting an actor
|
|
428
|
+
``.provider`` re-derives its paired ``model`` in the same write."""
|
|
429
|
+
parts = key.split(".")
|
|
430
|
+
if len(parts) == 1: # top-level scalar (e.g. worktree_base)
|
|
431
|
+
target_raw[parts[0]] = value
|
|
432
|
+
return
|
|
433
|
+
section = parts[0]
|
|
434
|
+
if section in _LIST_SECTIONS: # reviewers/checks: [section, index, field] — materialize roster
|
|
435
|
+
index, field = int(parts[1]), parts[2]
|
|
436
|
+
if section not in target_raw:
|
|
437
|
+
target_raw[section] = [m.model_dump() for m in getattr(config, section)]
|
|
438
|
+
existing = target_raw[section][index].get(field)
|
|
439
|
+
target_raw[section][index][field] = _keep_int_if_equal(existing, value)
|
|
440
|
+
if field == "provider":
|
|
441
|
+
target_raw[section][index]["model"] = _rederive_model(section, value)
|
|
442
|
+
return
|
|
443
|
+
if section in _PAIRED_SECTIONS: # producer/synthesizer/drafter/auditor — materialize section
|
|
444
|
+
field = parts[1]
|
|
445
|
+
if section not in target_raw:
|
|
446
|
+
target_raw[section] = getattr(config, section).model_dump()
|
|
447
|
+
existing = target_raw[section].get(field)
|
|
448
|
+
target_raw[section][field] = _keep_int_if_equal(existing, value)
|
|
449
|
+
if field == "provider":
|
|
450
|
+
target_raw[section]["model"] = _rederive_model(section, value)
|
|
451
|
+
return
|
|
452
|
+
# key-by-key merge section: set just the (possibly nested) key, creating sub-tables as needed.
|
|
453
|
+
node = target_raw.setdefault(section, {})
|
|
454
|
+
for part in parts[1:-1]:
|
|
455
|
+
node = node.setdefault(part, {})
|
|
456
|
+
# Guard: a malformed node (non-dict) surfaces its existing TypeError on the assignment below,
|
|
457
|
+
# not here — so apply_edit's exception handler can catch it.
|
|
458
|
+
existing = node.get(parts[-1]) if isinstance(node, dict) else None
|
|
459
|
+
node[parts[-1]] = _keep_int_if_equal(existing, value)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _existing_text(path: Path) -> str:
|
|
463
|
+
"""The file's current text, or "" when absent/unreadable — what :func:`render` preserves
|
|
464
|
+
comments from. A read problem must degrade to a full rewrite, never block the write.
|
|
465
|
+
|
|
466
|
+
``newline=""`` disables Python's universal-newline translation so CRLF files reach
|
|
467
|
+
:func:`render` with their ``\\r\\n`` intact; :func:`render` detects CRLF and normalises
|
|
468
|
+
any inserted lines to match."""
|
|
469
|
+
try:
|
|
470
|
+
with open(path, encoding="utf-8", newline="") as f:
|
|
471
|
+
return f.read()
|
|
472
|
+
except (OSError, UnicodeDecodeError):
|
|
473
|
+
return ""
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
477
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
478
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
479
|
+
# newline="" writes the string verbatim — CRLF produced by render() reaches the file as CRLF
|
|
480
|
+
# rather than being re-translated on Windows (and is a no-op on Unix).
|
|
481
|
+
with open(tmp, "w", encoding="utf-8", newline="") as f:
|
|
482
|
+
f.write(text)
|
|
483
|
+
tmp.replace(path)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _cmd_set(
|
|
487
|
+
config, rest: list[str], *, repo_root: Path, global_path: Path, target_repo: bool
|
|
488
|
+
) -> int:
|
|
489
|
+
if len(rest) != 2:
|
|
490
|
+
print("[syncade] --config set: expects <key> <value>", file=sys.stderr)
|
|
491
|
+
return 2
|
|
492
|
+
key, raw_value = rest
|
|
493
|
+
try:
|
|
494
|
+
annotation = config_keys.resolve_annotation(key)
|
|
495
|
+
except config_keys.UnknownKey as exc:
|
|
496
|
+
print(f"[syncade] --config set: {exc}", file=sys.stderr)
|
|
497
|
+
return 2 # a bad KEY is a usage error (exit 2); a bad VALUE is a config error (exit 50)
|
|
498
|
+
parts = key.split(".")
|
|
499
|
+
section, field = parts[0], parts[-1]
|
|
500
|
+
|
|
501
|
+
if target_repo:
|
|
502
|
+
# A per-repo config only matters inside a git repo — a real run resolves the repo via a HARD
|
|
503
|
+
# discover, so writing one in a non-git dir fabricates a file no run will ever read.
|
|
504
|
+
try:
|
|
505
|
+
discover_repo_root(repo_root)
|
|
506
|
+
except SnapshotError:
|
|
507
|
+
print(
|
|
508
|
+
f"[syncade] --config set --repo: {repo_root} is not inside a git repository; "
|
|
509
|
+
"a per-repo config there would never be read. Use the global config (omit --repo).",
|
|
510
|
+
file=sys.stderr,
|
|
511
|
+
)
|
|
512
|
+
return 60
|
|
513
|
+
|
|
514
|
+
repo_path = repo_root / CONFIG_RELATIVE_PATH
|
|
515
|
+
global_raw, repo_raw = _read_toml(global_path), _read_toml(repo_path)
|
|
516
|
+
target_raw = repo_raw if target_repo else global_raw
|
|
517
|
+
target_path = repo_path if target_repo else global_path
|
|
518
|
+
|
|
519
|
+
# Materializing an absent section (so one field-edit can't reset the pair-consistent whole) must
|
|
520
|
+
# pull from the layer the TARGET inherits, not the full effective config. A repo edit inherits
|
|
521
|
+
# defaults+global (== effective when the section is absent from repo), so `config` is right. A
|
|
522
|
+
# GLOBAL edit inherits defaults+global only — using `config` would bake the current repo's
|
|
523
|
+
# producer/reviewer choices into ~/.syncade. Rebuild global-only for that case.
|
|
524
|
+
if target_repo:
|
|
525
|
+
mat_config = config
|
|
526
|
+
else:
|
|
527
|
+
try:
|
|
528
|
+
mat_config = SyncadeConfig.model_validate(global_raw)
|
|
529
|
+
except ValidationError:
|
|
530
|
+
mat_config = config # a global file invalid on its own; effective is the safe fallback
|
|
531
|
+
|
|
532
|
+
if section in _LIST_SECTIONS and len(parts) >= 2 and parts[1].isdigit():
|
|
533
|
+
index = int(parts[1])
|
|
534
|
+
existing = target_raw.get(section)
|
|
535
|
+
roster = len(existing) if existing is not None else len(getattr(mat_config, section))
|
|
536
|
+
if index >= roster:
|
|
537
|
+
print(
|
|
538
|
+
f"[syncade] --config set: {section} {index} out of range (has {roster})",
|
|
539
|
+
file=sys.stderr,
|
|
540
|
+
)
|
|
541
|
+
return 2
|
|
542
|
+
if raw_value == "" and key in config_keys.BUDGET_KEYS:
|
|
543
|
+
print(
|
|
544
|
+
f"[syncade] --config set: set {key!r} to 0 to disable the ceiling;"
|
|
545
|
+
" clearing to empty is not supported (empty would omit the key,"
|
|
546
|
+
" reactivating the default); file unchanged",
|
|
547
|
+
file=sys.stderr,
|
|
548
|
+
)
|
|
549
|
+
return 50
|
|
550
|
+
try:
|
|
551
|
+
value = config_keys.coerce(annotation, raw_value)
|
|
552
|
+
except config_keys.InvalidValue:
|
|
553
|
+
# A bad VALUE is a config error (exit 50), matching the schema-invalid path below and the
|
|
554
|
+
# exit-50 contract in docs/skills — distinct from a bad KEY (exit 2, a usage error).
|
|
555
|
+
print(
|
|
556
|
+
f"[syncade] --config set: {raw_value!r} is not a valid value for {key}; file unchanged",
|
|
557
|
+
file=sys.stderr,
|
|
558
|
+
)
|
|
559
|
+
return 50
|
|
560
|
+
|
|
561
|
+
if field == "provider" and section == "reviewers":
|
|
562
|
+
err = _unknown_reviewer_provider_error(str(value))
|
|
563
|
+
if err:
|
|
564
|
+
print(f"[syncade] --config set: {err}; file unchanged", file=sys.stderr)
|
|
565
|
+
return 50
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
_apply(target_raw, key, value, mat_config)
|
|
569
|
+
# After materializing the section, check for an obvious cross-provider model mismatch
|
|
570
|
+
# before pydantic validation (the schema accepts arbitrary model strings).
|
|
571
|
+
if field == "model":
|
|
572
|
+
actor_raw = (
|
|
573
|
+
target_raw[section][int(parts[1])]
|
|
574
|
+
if section in _LIST_SECTIONS
|
|
575
|
+
else target_raw.get(section, {})
|
|
576
|
+
)
|
|
577
|
+
pair_err = _cross_provider_error(actor_raw.get("provider", ""), str(value))
|
|
578
|
+
if pair_err:
|
|
579
|
+
print(f"[syncade] --config set: {pair_err}", file=sys.stderr)
|
|
580
|
+
return 50
|
|
581
|
+
# Validate the FILE BEING WRITTEN on its own (schema only), before touching disk. In
|
|
582
|
+
# isolation, not merged: a bad global value could be masked by a repo section-replace, but
|
|
583
|
+
# the file must stand on its own — else it breaks the moment it's used without that repo.
|
|
584
|
+
SyncadeConfig.model_validate(target_raw)
|
|
585
|
+
# Also validate the merged effective config (global + repo) to catch cross-layer conflicts
|
|
586
|
+
# such as duplicate reviewer/check names split across layers. global_raw and repo_raw each
|
|
587
|
+
# point to the post-_apply dict for their respective target, so the merge is always current.
|
|
588
|
+
SyncadeConfig.model_validate(_deep_merge(copy.deepcopy(global_raw), repo_raw))
|
|
589
|
+
except (ValidationError, KeyError, TypeError, IndexError, AttributeError) as exc:
|
|
590
|
+
if isinstance(exc, ValidationError):
|
|
591
|
+
print(
|
|
592
|
+
"[syncade] --config set: rejected — the result would be invalid, file unchanged:\n"
|
|
593
|
+
f"{config_loader._format_validation_errors(exc)}",
|
|
594
|
+
file=sys.stderr,
|
|
595
|
+
)
|
|
596
|
+
else:
|
|
597
|
+
print(
|
|
598
|
+
f"[syncade] --config set: malformed config section, file unchanged: {exc}",
|
|
599
|
+
file=sys.stderr,
|
|
600
|
+
)
|
|
601
|
+
return 50
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
_atomic_write(target_path, render(target_raw, _existing_text(target_path)))
|
|
605
|
+
except OSError as exc:
|
|
606
|
+
print(f"[syncade] --config set: cannot write {target_path}: {exc}", file=sys.stderr)
|
|
607
|
+
return 60
|
|
608
|
+
print(f"[syncade] set {key} = {value} ({'repo' if target_repo else 'global'}: {target_path})")
|
|
609
|
+
return 0
|