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,135 @@
1
+ """Schema-driven dotted-key resolution + coercion for ``--config set`` (pr-v2-31 Increment 2).
2
+
3
+ Shared by ``cli.config_mode`` (the ``--config set`` verb) and ``cli.config_tui`` (the menu), so
4
+ both surfaces resolve and coerce identically. Resolves a dotted key against ``SyncadeConfig``'s
5
+ pydantic schema to the leaf field's declared type, and coerces a raw CLI string to that type.
6
+
7
+ A path that does not resolve to a settable SCALAR leaf (an unknown field, or a whole
8
+ section/roster) is an :class:`UnknownKey`; a value that does not fit the leaf's type is an
9
+ :class:`InvalidValue`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from types import UnionType
15
+ from typing import Literal, Union, get_args, get_origin
16
+
17
+ from pydantic import BaseModel
18
+
19
+ from syncade.config import SyncadeConfig
20
+
21
+ _TRUE = {"true", "1", "yes", "on"}
22
+ _FALSE = {"false", "0", "no", "off"}
23
+
24
+
25
+ class UnknownKey(Exception):
26
+ """A dotted key that does not resolve to a settable scalar field."""
27
+
28
+
29
+ class InvalidValue(Exception):
30
+ """A raw string that cannot be coerced to the field's declared type."""
31
+
32
+
33
+ def coerce(annotation, raw: str):
34
+ """Coerce ``raw`` (a CLI string) to ``annotation``'s Python type.
35
+
36
+ Raises :class:`InvalidValue` when the string does not fit. An optional field (``X | None``)
37
+ clears to ``None`` on empty input; ``str``/``Path`` and other scalars keep the raw string
38
+ (pydantic coerces on load, and TOML has no ``Path`` type).
39
+ """
40
+ origin = get_origin(annotation)
41
+ args = get_args(annotation)
42
+
43
+ # Optional (X | None): empty input clears; otherwise coerce the non-None arm.
44
+ if origin in (Union, UnionType) and type(None) in args:
45
+ if raw == "":
46
+ return None
47
+ (inner,) = [a for a in args if a is not type(None)]
48
+ return coerce(inner, raw)
49
+
50
+ if origin is Literal:
51
+ if raw not in args:
52
+ raise InvalidValue(f"{raw!r} is not one of {list(args)}")
53
+ return raw
54
+
55
+ if origin is list:
56
+ return [s.strip() for s in raw.split(",") if s.strip()]
57
+
58
+ if annotation is bool:
59
+ low = raw.strip().lower()
60
+ if low in _TRUE:
61
+ return True
62
+ if low in _FALSE:
63
+ return False
64
+ raise InvalidValue(f"{raw!r} is not a boolean (use true/false)")
65
+
66
+ if annotation is int:
67
+ try:
68
+ return int(raw)
69
+ except ValueError:
70
+ raise InvalidValue(f"{raw!r} is not an integer") from None
71
+
72
+ if annotation is float:
73
+ try:
74
+ return float(raw)
75
+ except ValueError:
76
+ raise InvalidValue(f"{raw!r} is not a number") from None
77
+
78
+ return raw
79
+
80
+
81
+ def resolve_annotation(key: str):
82
+ """Walk ``SyncadeConfig``'s schema along the dotted ``key`` to the leaf field's declared
83
+ annotation.
84
+
85
+ Raises :class:`UnknownKey` for an unknown field, a non-index where a list index is expected,
86
+ or a leaf that is a whole section/roster (a pydantic model, or a list of them) rather than a
87
+ settable scalar.
88
+ """
89
+ current = SyncadeConfig
90
+ for part in key.split("."):
91
+ if get_origin(current) is list: # walking into a list element
92
+ elem = (get_args(current) or (str,))[0]
93
+ if not _is_model(elem): # a list-of-values (list[str]) is set WHOLE (csv), not indexed
94
+ raise UnknownKey(f"{key!r}: {part!r} indexes a list set as a whole, not by element")
95
+ if not part.isdigit():
96
+ raise UnknownKey(f"{key!r}: {part!r} is not a list index")
97
+ current = elem
98
+ continue
99
+ fields = getattr(current, "model_fields", None)
100
+ if fields is None or part not in fields:
101
+ raise UnknownKey(f"{key!r}: unknown field {part!r}")
102
+ current = fields[part].annotation
103
+ if not _settable_scalar(current):
104
+ raise UnknownKey(f"{key!r} is a section or roster, not a settable field")
105
+ return current
106
+
107
+
108
+ def _settable_scalar(annotation) -> bool:
109
+ """A leaf is settable iff it is not a pydantic model and not a list of models (``list[str]``
110
+ is fine; ``list[ReviewerConfig]`` is not — you set elements by index, not the whole roster)."""
111
+ origin = get_origin(annotation)
112
+ if origin is dict: # a dict-shaped roster (e.g. pricing.models) is not a settable scalar
113
+ return False
114
+ if origin is list:
115
+ elem = (get_args(annotation) or (str,))[0]
116
+ return not _is_model(elem)
117
+ if origin in (Union, UnionType):
118
+ return all(_settable_scalar(a) for a in get_args(annotation) if a is not type(None))
119
+ return not _is_model(annotation)
120
+
121
+
122
+ def _is_model(annotation) -> bool:
123
+ return isinstance(annotation, type) and issubclass(annotation, BaseModel)
124
+
125
+
126
+ def _empty_clears(annotation) -> bool:
127
+ """True for Optional (X | None) and list fields — empty input clears instead of no-op."""
128
+ origin = get_origin(annotation)
129
+ return origin is list or (origin in (Union, UnionType) and type(None) in get_args(annotation))
130
+
131
+
132
+ # Keys whose opt-out sentinel is explicit 0, not omission. Clearing these to None would strip the
133
+ # TOML key, causing the default (budget_tokens=50M) or resume inheritance (budget_usd) to
134
+ # silently re-activate. Empty input must be rejected with a "set 0 to disable" message.
135
+ BUDGET_KEYS: frozenset[str] = frozenset({"loop.budget_tokens", "loop.budget_usd"})
@@ -0,0 +1,82 @@
1
+ """Enumerate the FULL settable config surface for ``syncade --config list --all`` (pr-v2-32).
2
+
3
+ A leaf module. It walks the ``SyncadeConfig`` schema with the SAME settability rule as
4
+ ``--config set`` (``config_keys._settable_scalar``), so the ``--all`` surface can never drift from
5
+ what ``set`` accepts. It decides only WHAT fields exist and how they group; ``config_mode`` renders
6
+ each row's value, layer, and ``overrides`` note.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import get_args, get_origin
12
+
13
+ from syncade.cli import config_keys
14
+ from syncade.config import SyncadeConfig
15
+ from syncade.config_loader import _PAIRED_SECTIONS
16
+
17
+ # Sections whose TOML table replaces WHOLESALE (layer is section-granular → subkey=None); others
18
+ # merge key-by-key (layer is per-field). Mirrors config_loader / config_menu_rows.
19
+ _WHOLESALE = _PAIRED_SECTIONS | {"reviewers", "checks"}
20
+
21
+ # Display order + section headers. Sections not listed here are appended after, under a `[<name>]`
22
+ # fallback header — a completeness test asserts every settable field is emitted, so none vanish.
23
+ _HEADERS = {
24
+ "producer": "[producer]",
25
+ "reviewers": "[[reviewers]]",
26
+ "synthesizer": "[synthesizer] (judge)",
27
+ "loop": "[loop]",
28
+ "review": "[review]",
29
+ "retry": "[advanced.retry]",
30
+ "gc": "[advanced.gc]",
31
+ "checks": "[advanced.checks]",
32
+ "drafter": "[advanced.drafter]",
33
+ "auditor": "[advanced.auditor]",
34
+ "worktree_base": "[advanced]",
35
+ }
36
+
37
+
38
+ def header_for(section: str) -> str:
39
+ return _HEADERS.get(section, f"[{section}]")
40
+
41
+
42
+ def _subkey(section: str, parts: list[str]) -> str | None:
43
+ """Layer granularity for ``_layer_of``: per-field for a key-merge section (a model section not
44
+ replaced wholesale, e.g. loop/review/retry/gc); section-granular (None) for wholesale sections
45
+ and top-level scalars."""
46
+ if len(parts) == 1 or section in _WHOLESALE:
47
+ return None
48
+ return parts[-1]
49
+
50
+
51
+ def _walk(value, annotation, prefix: str):
52
+ """Yield the dotted key of every settable scalar under ``value`` (a config instance/section)."""
53
+ if config_keys._settable_scalar(annotation):
54
+ yield prefix
55
+ return
56
+ if get_origin(annotation) is list:
57
+ elems = [a for a in get_args(annotation) if a is not type(None)]
58
+ if elems and config_keys._is_model(elems[0]):
59
+ for i, item in enumerate(value):
60
+ yield from _walk(item, elems[0], f"{prefix}.{i}")
61
+ return
62
+ if config_keys._is_model(annotation):
63
+ for name, field in annotation.model_fields.items():
64
+ yield from _walk(getattr(value, name), field.annotation, f"{prefix}.{name}")
65
+
66
+
67
+ def all_rows(config) -> list[tuple[str, str, str, str | None]]:
68
+ """``(key, label, section, subkey)`` for every settable scalar field, in display order.
69
+
70
+ ``label`` is the key minus its section prefix (unambiguous per section); ``config`` is the
71
+ effective config, whose roster sizes drive the reviewer/check indices."""
72
+ fields = SyncadeConfig.model_fields
73
+ sections = list(_HEADERS) + [s for s in fields if s not in _HEADERS]
74
+ rows = []
75
+ for section in sections:
76
+ if section not in fields:
77
+ continue
78
+ for key in _walk(getattr(config, section), fields[section].annotation, section):
79
+ parts = key.split(".")
80
+ label = key[len(section) + 1 :] if "." in key else key
81
+ rows.append((key, label, section, _subkey(section, parts)))
82
+ return rows
@@ -0,0 +1,166 @@
1
+ """Screen/row model for the drill-in config menu (pr-v2-31 inc 4).
2
+
3
+ The menu is a tree of screens: the top screen, one per actor (``producer`` / ``reviewers.N`` /
4
+ ``synthesizer``), an ``advanced`` screen, and one per advanced section (``retry`` / ``gc`` /
5
+ ``review`` / ``checks`` and each ``checks.N``). Each screen is a list of :class:`Row`; a row is an
6
+ ``edit`` (a settable scalar field), a ``drill`` (navigate to a child screen), or an ``info`` (an
7
+ inert placeholder, e.g. an empty ``checks`` screen). Pure — no curses; ``ConfigMenu`` drives it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import get_args, get_origin
14
+
15
+ from syncade.cli import config_keys
16
+ from syncade.config import SyncadeConfig
17
+ from syncade.config_loader import _PAIRED_SECTIONS
18
+
19
+ # Sections whose TOML table replaces WHOLESALE (shadow is section-granular → subkey=None); every
20
+ # other section merges key-by-key (shadow is per-key → subkey=field). Mirrors the loader's rule.
21
+ _WHOLESALE = _PAIRED_SECTIONS | {"reviewers", "checks"}
22
+ _ACTOR_SECTIONS = _PAIRED_SECTIONS | {"reviewers"}
23
+
24
+ # Actor fields shown in a drill screen, in display order — filtered to the fields the actor actually
25
+ # has. ``provider`` is folded into the Model row (edited as ``provider/model``); ``api_key_env`` is
26
+ # CLI-only, kept out of the menu to stay scannable.
27
+ _ACTOR_FIELD_ORDER = [
28
+ "model",
29
+ "thinking",
30
+ "permissions",
31
+ "auth",
32
+ "timeout_seconds",
33
+ "name",
34
+ "adversarial_lens",
35
+ "bug_class_sweep",
36
+ "template",
37
+ ]
38
+ _SKIP_FIELDS = {"provider", "api_key_env"}
39
+
40
+ _LABELS = {
41
+ "model": "Model",
42
+ "thinking": "Thinking",
43
+ "permissions": "Permissions",
44
+ "auth": "Auth",
45
+ "timeout_seconds": "Timeout (s)",
46
+ "name": "Name",
47
+ "adversarial_lens": "Adversarial lens",
48
+ "bug_class_sweep": "Bug-class sweep",
49
+ "template": "Template",
50
+ "max_retries": "Max retries",
51
+ "keep": "Keep (runs)",
52
+ "max_age_days": "Max age (days)",
53
+ "strip_repo_context_files": "Strip repo-context files",
54
+ "command": "Command",
55
+ "severity": "Severity",
56
+ "test_command": "Test command",
57
+ "test_timeout_seconds": "Test timeout (s)",
58
+ "budget_tokens": "Budget (tokens)",
59
+ }
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Row:
64
+ """One menu row. ``kind``: ``edit`` (``key`` is a dotted field path), ``drill`` (``key`` is the
65
+ child screen id), or ``info`` (inert). ``value_key`` is the dotted key whose value to display
66
+ (``None`` → no value column); ``section``/``subkey`` drive the shadow flag for edit rows."""
67
+
68
+ label: str
69
+ kind: str
70
+ key: str
71
+ section: str | None = None
72
+ subkey: str | None = None
73
+ value_key: str | None = None
74
+
75
+
76
+ def _label(field: str) -> str:
77
+ return _LABELS.get(field, field.replace("_", " ").capitalize())
78
+
79
+
80
+ def _model_at(screen: str):
81
+ """The pydantic model class shown by ``screen`` (walks list indices to the element type)."""
82
+ current = SyncadeConfig
83
+ for part in screen.split("."):
84
+ if get_origin(current) is list:
85
+ current = get_args(current)[0] # element type; `part` is the index
86
+ continue
87
+ current = current.model_fields[part].annotation
88
+ return current
89
+
90
+
91
+ def _settable(model) -> list[str]:
92
+ return [n for n, f in model.model_fields.items() if config_keys._settable_scalar(f.annotation)]
93
+
94
+
95
+ def _field_rows(config, screen: str) -> list[Row]:
96
+ """Edit rows for every settable scalar field of the model at ``screen`` (actor or section)."""
97
+ section = screen.split(".")[0]
98
+ fields = [f for f in _settable(_model_at(screen)) if f not in _SKIP_FIELDS]
99
+ if section in _ACTOR_SECTIONS:
100
+ fields = [f for f in _ACTOR_FIELD_ORDER if f in fields]
101
+ subkey_none = section in _WHOLESALE
102
+ return [
103
+ Row(
104
+ _label(f), "edit", f"{screen}.{f}", section, None if subkey_none else f, f"{screen}.{f}"
105
+ )
106
+ for f in fields
107
+ ]
108
+
109
+
110
+ def _top_rows(config) -> list[Row]:
111
+ rows = [Row("Producer", "drill", "producer", "producer", None, "producer.model")]
112
+ for i in range(len(config.reviewers)):
113
+ rows.append(
114
+ Row(
115
+ f"Reviewer {i + 1}",
116
+ "drill",
117
+ f"reviewers.{i}",
118
+ "reviewers",
119
+ None,
120
+ f"reviewers.{i}.model",
121
+ )
122
+ )
123
+ rows += [
124
+ Row("Judge", "drill", "synthesizer", "synthesizer", None, "synthesizer.model"),
125
+ Row("Rounds (max)", "edit", "loop.max_rounds", "loop", "max_rounds", "loop.max_rounds"),
126
+ Row(
127
+ "Time per subprocess (s)",
128
+ "edit",
129
+ "loop.timeout_seconds",
130
+ "loop",
131
+ "timeout_seconds",
132
+ "loop.timeout_seconds",
133
+ ),
134
+ Row("Cost cap (USD)", "edit", "loop.budget_usd", "loop", "budget_usd", "loop.budget_usd"),
135
+ Row("Advanced…", "drill", "advanced"),
136
+ ]
137
+ return rows
138
+
139
+
140
+ def _advanced_rows(config) -> list[Row]:
141
+ return [
142
+ Row("Retry", "drill", "retry"),
143
+ Row("GC", "drill", "gc"),
144
+ Row("Review", "drill", "review"),
145
+ Row("Checks", "drill", "checks"),
146
+ ]
147
+
148
+
149
+ def _checks_rows(config) -> list[Row]:
150
+ if not config.checks:
151
+ return [Row("(no checks configured — add via config.toml)", "info", "")]
152
+ return [
153
+ Row(f"Check {i + 1}: {c.name}", "drill", f"checks.{i}", "checks", None, f"checks.{i}.name")
154
+ for i, c in enumerate(config.checks)
155
+ ]
156
+
157
+
158
+ def screen_rows(config, screen: str) -> list[Row]:
159
+ """The rows for ``screen`` (``""`` = top). ``config`` is the effective (merged) config."""
160
+ if screen == "":
161
+ return _top_rows(config)
162
+ if screen == "advanced":
163
+ return _advanced_rows(config)
164
+ if screen == "checks":
165
+ return _checks_rows(config)
166
+ return _field_rows(config, screen)