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,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